From 8dbaf3d97e4c81e59ee94e704af0f898639b88b0 Mon Sep 17 00:00:00 2001
From: "hamed@B"
Date: Sat, 31 Oct 2026 08:13:00 +0330
Subject: [PATCH] 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
---
README.md | 5 +-
index.html | 2 +-
src/config/assets.js | 71 +++++
src/config/company.js | 106 +++++++
src/constants/locations.js | 39 +--
src/constants/showroomCatalog.js | 50 +--
src/constants/vehicles.js | 14 +-
src/game/device/ar.js | 8 +-
src/game/device/realworld.js | 18 +-
src/game/economy/gameState.js | 21 +-
src/game/economy/scoring.js | 35 +-
src/game/models/carModels.js | 6 +-
src/game/models/factory.js | 143 ++++-----
src/game/models/staff.js | 8 +-
src/game/models/vending.js | 18 +-
src/game/runtime/mapInteraction.js | 5 +-
src/game/runtime/movement.js | 10 +-
src/game/runtime/navigationHud.js | 36 +--
src/game/runtime/purchaseFlow.js | 66 ++--
src/game/runtime/realMode.js | 25 +-
src/game/runtime/runtimeLifecycle.js | 25 +-
src/game/runtime/runtimeSetup.js | 73 ++---
src/game/runtime/worldEncounters.js | 39 +--
src/game/world/mapRender.js | 17 +-
src/i18n/fa.js | 457 +++++++++++++++++++++++++++
src/pages/game/BriefingPanel.jsx | 24 +-
src/pages/game/EndDialog.jsx | 7 +-
src/pages/game/GameHud.jsx | 9 +-
src/pages/game/GamePage.jsx | 5 +-
src/pages/game/GameSurfaces.jsx | 3 +-
src/pages/game/GameToolbar.jsx | 15 +-
src/pages/game/InstallDialog.jsx | 17 +-
src/pages/game/LocationStatus.jsx | 5 +-
src/pages/game/MachineDetails.jsx | 3 +-
src/pages/game/MapDialog.jsx | 13 +-
src/pages/game/MissionList.jsx | 3 +-
src/pages/game/PaymentPanel.jsx | 7 +-
src/pages/game/RankProgress.jsx | 5 +-
src/pages/game/RealIntroDialog.jsx | 23 +-
src/pages/game/ShopDialog.jsx | 7 +-
src/pages/game/ShowroomDialog.jsx | 27 +-
src/pages/game/ShowroomImage.jsx | 13 +-
src/pages/game/StatusBar.jsx | 15 +-
src/pages/game/TouchControls.jsx | 3 +-
src/pages/game/UpdateNotice.jsx | 5 +-
src/pages/game/ViewLists.jsx | 7 +-
src/routes/AppRoutes.jsx | 3 +-
src/services/apiClient.js | 3 +-
src/services/worldService.js | 7 +-
src/styles/gameTheme.js | 41 ++-
src/utils/geo.js | 5 +-
src/utils/showroomAnswers.js | 11 +-
tailwind.config.js | 13 +-
tests/content.test.js | 74 +++++
54 files changed, 1184 insertions(+), 486 deletions(-)
create mode 100644 src/config/assets.js
create mode 100644 src/config/company.js
create mode 100644 src/i18n/fa.js
create mode 100644 tests/content.test.js
diff --git a/README.md b/README.md
index dc86802..200195c 100644
--- a/README.md
+++ b/README.md
@@ -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`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، بههمراه دفترها، کامیون و ورود مدیر
diff --git a/index.html b/index.html
index 8ce6716..9bbd831 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
-
+
عملیات مشهد | روبات مارکت
[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;
diff --git a/src/config/company.js b/src/config/company.js
new file mode 100644
index 0000000..60a6174
--- /dev/null
+++ b/src/config/company.js
@@ -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'],
+ },
+ ],
+};
diff --git a/src/constants/locations.js b/src/constants/locations.js
index f34e8c4..759a516 100644
--- a/src/constants/locations.js
+++ b/src/constants/locations.js
@@ -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 = {
diff --git a/src/constants/showroomCatalog.js b/src/constants/showroomCatalog.js
index 3ba65a8..55d648a 100644
--- a/src/constants/showroomCatalog.js
+++ b/src/constants/showroomCatalog.js
@@ -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;
diff --git a/src/constants/vehicles.js b/src/constants/vehicles.js
index aec7d34..144c970 100644
--- a/src/constants/vehicles.js
+++ b/src/constants/vehicles.js
@@ -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'];
diff --git a/src/game/device/ar.js b/src/game/device/ar.js
index 0c0c362..f09bc1f 100644
--- a/src/game/device/ar.js
+++ b/src/game/device/ar.js
@@ -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;
diff --git a/src/game/device/realworld.js b/src/game/device/realworld.js
index 64570c3..3a6a140 100644
--- a/src/game/device/realworld.js
+++ b/src/game/device/realworld.js
@@ -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, () => {}, {
diff --git a/src/game/economy/gameState.js b/src/game/economy/gameState.js
index 687caf9..78b364b 100644
--- a/src/game/economy/gameState.js
+++ b/src/game/economy/gameState.js
@@ -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',
diff --git a/src/game/economy/scoring.js b/src/game/economy/scoring.js
index c4e72cc..4087e49 100644
--- a/src/game/economy/scoring.js
+++ b/src/game/economy/scoring.js
@@ -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,
diff --git a/src/game/models/carModels.js b/src/game/models/carModels.js
index de09c4d..a989957 100644
--- a/src/game/models/carModels.js
+++ b/src/game/models/carModels.js
@@ -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,
});
diff --git a/src/game/models/factory.js b/src/game/models/factory.js
index 2d90b26..bb6f2ff 100644
--- a/src/game/models/factory.js
+++ b/src/game/models/factory.js
@@ -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 ----------
diff --git a/src/game/models/staff.js b/src/game/models/staff.js
index 242243a..d694015 100644
--- a/src/game/models/staff.js
+++ b/src/game/models/staff.js
@@ -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);
diff --git a/src/game/models/vending.js b/src/game/models/vending.js
index 885c08f..b7bf42b 100644
--- a/src/game/models/vending.js
+++ b/src/game/models/vending.js
@@ -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,
diff --git a/src/game/runtime/mapInteraction.js b/src/game/runtime/mapInteraction.js
index 3ce0fa4..817cfdd 100644
--- a/src/game/runtime/mapInteraction.js
+++ b/src/game/runtime/mapInteraction.js
@@ -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();
diff --git a/src/game/runtime/movement.js b/src/game/runtime/movement.js
index 2243be3..eef4ba9 100644
--- a/src/game/runtime/movement.js
+++ b/src/game/runtime/movement.js
@@ -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) };
diff --git a/src/game/runtime/navigationHud.js b/src/game/runtime/navigationHud.js
index 50d0664..ebbaa7f 100644
--- a/src/game/runtime/navigationHud.js
+++ b/src/game/runtime/navigationHud.js
@@ -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();
diff --git a/src/game/runtime/purchaseFlow.js b/src/game/runtime/purchaseFlow.js
index d2ca9bc..e8c20e1 100644
--- a/src/game/runtime/purchaseFlow.js
+++ b/src/game/runtime/purchaseFlow.js
@@ -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', {
diff --git a/src/game/runtime/realMode.js b/src/game/runtime/realMode.js
index a93b2ec..06caefe 100644
--- a/src/game/runtime/realMode.js
+++ b/src/game/runtime/realMode.js
@@ -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,
});
diff --git a/src/game/runtime/runtimeLifecycle.js b/src/game/runtime/runtimeLifecycle.js
index 6d97ba2..d986d63 100644
--- a/src/game/runtime/runtimeLifecycle.js
+++ b/src/game/runtime/runtimeLifecycle.js
@@ -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?.(() => {});
}
diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js
index 319dd08..0d437e5 100644
--- a/src/game/runtime/runtimeSetup.js
+++ b/src/game/runtime/runtimeSetup.js
@@ -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,
});
});
diff --git a/src/game/runtime/worldEncounters.js b/src/game/runtime/worldEncounters.js
index 2b70349..fb4e034 100644
--- a/src/game/runtime/worldEncounters.js
+++ b/src/game/runtime/worldEncounters.js
@@ -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);
}
}
diff --git a/src/game/world/mapRender.js b/src/game/world/mapRender.js
index 773dc78..0aeb34b 100644
--- a/src/game/world/mapRender.js
+++ b/src/game/world/mapRender.js
@@ -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();
diff --git a/src/i18n/fa.js b/src/i18n/fa.js
new file mode 100644
index 0000000..7e21797
--- /dev/null
+++ b/src/i18n/fa.js
@@ -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++'],
+ },
+ },
+};
diff --git a/src/pages/game/BriefingPanel.jsx b/src/pages/game/BriefingPanel.jsx
index 7beaa58..d0560d2 100644
--- a/src/pages/game/BriefingPanel.jsx
+++ b/src/pages/game/BriefingPanel.jsx
@@ -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 }) {
-
{'عملیات ۰۱ · خراسان رضوی'}
-
{'عملیات مشهد'}
-
{'روبات مارکت · شکار دستگاههای وندینگ'}
+
{strings.briefing.tag}
+
{strings.briefing.title}
+
{strings.briefing.subtitle}
actions.emit('start-mission')}
>
- {'شروع عملیات'}
+ {strings.briefing.start}
actions.emit('real-mode')}
>
- {'📍 بازی در دنیای واقعی (GPS)'}
+ {strings.briefing.realWorld}
actions.emit('install')}
>
- {'⬇ نصب بازی روی گوشی'}
+ {strings.briefing.install}
-
- {
- 'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
- }
-
+
{strings.briefing.keys}
>
diff --git a/src/pages/game/EndDialog.jsx b/src/pages/game/EndDialog.jsx
index 4c0465a..99ffc80 100644
--- a/src/pages/game/EndDialog.jsx
+++ b/src/pages/game/EndDialog.jsx
@@ -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 }) {
<>
actions.close('end')}>
- {'پایان عملیات'}
+ {strings.end.title}
@@ -29,7 +30,7 @@ export function EndDialog({ nodes, actions }) {
model={nodes['again']}
onAction={() => actions.emit('again')}
>
- {'عملیات دوباره'}
+ {strings.end.again}
actions.emit('continue')}
>
- {'ادامهٔ گشت در شهر'}
+ {strings.end.keepExploring}
diff --git a/src/pages/game/GameHud.jsx b/src/pages/game/GameHud.jsx
index 75fb01a..a15a04f 100644
--- a/src/pages/game/GameHud.jsx
+++ b/src/pages/game/GameHud.jsx
@@ -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')}
>
- {'نقشه · M'}
+ {strings.hud.mapHint}
@@ -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')}
>
@@ -68,7 +69,7 @@ export function GameHud({ nodes, actions }) {
- {'نقشه: © مشارکتکنندگان OpenStreetMap'}
+ {strings.app.osmCredit}
>
diff --git a/src/pages/game/GamePage.jsx b/src/pages/game/GamePage.jsx
index 2f2cd0f..bb786ec 100644
--- a/src/pages/game/GamePage.jsx
+++ b/src/pages/game/GamePage.jsx
@@ -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"
>
- بازی اجرا نشد: {error}
+ {strings.app.failed(error)}
location.reload()}>
- تلاش دوباره
+ {strings.app.retry}
)}
diff --git a/src/pages/game/GameSurfaces.jsx b/src/pages/game/GameSurfaces.jsx
index 590c988..036dac6 100644
--- a/src/pages/game/GameSurfaces.jsx
+++ b/src/pages/game/GameSurfaces.jsx
@@ -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 }) {
>
- {'در حال ساختن شهر مشهد…'}
+ {strings.app.buildingCity}
>
);
diff --git a/src/pages/game/GameToolbar.jsx b/src/pages/game/GameToolbar.jsx
index beda6fe..ee36961 100644
--- a/src/pages/game/GameToolbar.jsx
+++ b/src/pages/game/GameToolbar.jsx
@@ -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}
actions.emit('gps-toggle')}
>
- {'📍 دنیای واقعی'}
+ {strings.toolbar.realWorld}
actions.emit('ar-toggle')}
>
- {'📷 دوربین'}
+ {strings.toolbar.camera}
actions.emit('stable-toggle')}
>
- {'🎯 پایدار: روشن'}
+ {strings.toolbar.stableOn}
actions.emit('sound')}
>
- {'صدا: خاموش'}
+ {strings.toolbar.soundOff}
actions.emit('quality')}
>
- {'گرافیک'}
+ {strings.toolbar.graphics}
actions.emit('restart')}
>
- {'شروع دوباره'}
+ {strings.toolbar.restart}
);
diff --git a/src/pages/game/InstallDialog.jsx b/src/pages/game/InstallDialog.jsx
index de17c37..8119c3e 100644
--- a/src/pages/game/InstallDialog.jsx
+++ b/src/pages/game/InstallDialog.jsx
@@ -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')}
>
- {'نصب روی آیفون'}
+ {strings.install.title}
- {'در Safari دکمهٔ '}
+ {strings.install.shareBefore}
{'Share'}
- {' (مربع با فلش رو به بالا) را بزنید.'}
+ {strings.install.shareAfter}
- {'گزینهٔ '}
+ {strings.install.addBefore}
{'Add to Home Screen'}
- {' را انتخاب کنید.'}
-
-
- {'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار میگیرد و آفلاین هم اجرا میشود.'}
+ {strings.install.addAfter}
+ {strings.install.note}
actions.emit('ios-close')}
>
- {'متوجه شدم'}
+ {strings.common.gotIt}
>
diff --git a/src/pages/game/LocationStatus.jsx b/src/pages/game/LocationStatus.jsx
index a21a1e4..87c5d89 100644
--- a/src/pages/game/LocationStatus.jsx
+++ b/src/pages/game/LocationStatus.jsx
@@ -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 (
- {'مشهد'}
+ {strings.common.mashhad}
- {'پیاده'}
+ {strings.common.walking}
diff --git a/src/pages/game/MachineDetails.jsx b/src/pages/game/MachineDetails.jsx
index e10975b..747ecf3 100644
--- a/src/pages/game/MachineDetails.jsx
+++ b/src/pages/game/MachineDetails.jsx
@@ -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}
);
diff --git a/src/pages/game/MapDialog.jsx b/src/pages/game/MapDialog.jsx
index f414e84..d83760b 100644
--- a/src/pages/game/MapDialog.jsx
+++ b/src/pages/game/MapDialog.jsx
@@ -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}
actions.emit('tab-iran')}
>
- {'ایران'}
+ {strings.common.iran}
actions.emit('map-close')}
>
@@ -58,12 +59,10 @@ export function MapDialog({ nodes, actions }) {
-
- {'کشیدن: جابهجایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب'}
-
+
{strings.map.help}
- {'دستگاههای روبات مارکت'}
+ {strings.map.machines}
diff --git a/src/pages/game/MissionList.jsx b/src/pages/game/MissionList.jsx
index 52a67fa..71ae44a 100644
--- a/src/pages/game/MissionList.jsx
+++ b/src/pages/game/MissionList.jsx
@@ -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 (
- {'مأموریتها'}
+ {strings.status.missions}
- {'کارتخوان روبات مارکت'}
+ {strings.payment.reader}
@@ -20,7 +21,7 @@ export function PaymentPanel({ nodes, actions }) {
model={nodes['swipe']}
onAction={() => actions.emit('swipe')}
>
- {'کشیدن کارت · C'}
+ {strings.payment.swipe}
actions.emit('cancel-payment')}
>
- {'انصراف'}
+ {strings.common.cancel}
diff --git a/src/pages/game/RankProgress.jsx b/src/pages/game/RankProgress.jsx
index a9782fe..52f501c 100644
--- a/src/pages/game/RankProgress.jsx
+++ b/src/pages/game/RankProgress.jsx
@@ -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 (
- {'تازهوارد'}
+ {strings.status.rankStart}
- {'۰ XP'}
+ {strings.status.xpStart}
diff --git a/src/pages/game/RealIntroDialog.jsx b/src/pages/game/RealIntroDialog.jsx
index 1391d14..bfb5f62 100644
--- a/src/pages/game/RealIntroDialog.jsx
+++ b/src/pages/game/RealIntroDialog.jsx
@@ -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')}
>
-
-
{'بازی در دنیای واقعی'}
-
- {
- 'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده میشود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، میتوانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
- }
-
+
+
{strings.realIntro.title}
+
{strings.realIntro.body}
- {'هنگام راه رفتن به خیابان و ماشینها نگاه کنید، نه به صفحه.'}
- {'بچهها فقط همراه یک بزرگتر بازی کنند.'}
- {'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال میشود.'}
- {'موقعیت شما فقط روی همین گوشی میماند و جایی ارسال نمیشود.'}
+ {strings.realIntro.safety.map((line) => (
+ {line}
+ ))}
actions.emit('real-go')}
>
- {'قبول دارم، شروع کن'}
+ {strings.realIntro.accept}
actions.emit('real-cancel')}
>
- {'انصراف'}
+ {strings.common.cancel}
diff --git a/src/pages/game/ShopDialog.jsx b/src/pages/game/ShopDialog.jsx
index 6a08680..d056996 100644
--- a/src/pages/game/ShopDialog.jsx
+++ b/src/pages/game/ShopDialog.jsx
@@ -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)}
/>
-
- {'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشیاند و پرداخت واقعی انجام نمیشود.'}
-
+
{strings.shop.note}
>
);
diff --git a/src/pages/game/ShowroomDialog.jsx b/src/pages/game/ShowroomDialog.jsx
index 665bcbe..862cbc6 100644
--- a/src/pages/game/ShowroomDialog.jsx
+++ b/src/pages/game/ShowroomDialog.jsx
@@ -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 }) {
>
- نمایشگاه کارخانه · {model.name}
+ {strings.showroom.heading(model.name)}
actions.close('showroom')}
>
✕
-
+
{SHOWROOM_CATALOG.map((item) => (
{model.name}
- قیمت خود دستگاه: {formatRial(model.priceNumeric)}
+ {strings.showroom.price(formatRial(model.priceNumeric))}
{model.description}
{model.highlights.map((item) => (
{item}
))}
-
- این بخش معرفی محصول است؛ خرید واقعی انجام نمیشود و بودجهٔ هتومان تغییر نمیکند.
-
+ {strings.showroom.displayOnly}
@@ -72,7 +72,7 @@ export function ShowroomDialog({ nodes, actions }) {
})
}
>
- روبات، قیمتش چنده؟
+ {strings.showroom.askPrice}
- روبات، امکاناتش چیه؟
+ {strings.showroom.askFeatures}
-
- {nodes['showroom-response']?.text ??
- 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
-
+
{nodes['showroom-response']?.text ?? strings.showroom.greeting}
);
diff --git a/src/pages/game/ShowroomImage.jsx b/src/pages/game/ShowroomImage.jsx
index c842f35..a1e494c 100644
--- a/src/pages/game/ShowroomImage.jsx
+++ b/src/pages/game/ShowroomImage.jsx
@@ -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 (
{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}
) : (
setFailed((previous) => ({
...previous,
@@ -34,11 +35,7 @@ export function ShowroomImage({ model }) {
/>
)}
- {[
- ['image', 'نمای اصلی'],
- ['imageAlt', 'رنگ سفید'],
- ['gif', 'نمای متحرک'],
- ].map(([key, label]) => (
+ {Object.entries(strings.showroom.views).map(([key, label]) => (
- {'بودجه'}
+ {strings.status.budget}
- {'۵۰۰'}
+ {strings.status.budgetStart}
- {'هتومان'}
+ {strings.units.hetomanName}
- {'ساعت'}
+ {strings.status.clock}
- {'۰۸:۳۰'}
+ {strings.status.clockStart}
- {'سرعت'}
+ {strings.status.speed}
- {'۰'}
+ {strings.status.speedStart}
{'km/h'}
diff --git a/src/pages/game/TouchControls.jsx b/src/pages/game/TouchControls.jsx
index 0d843d5..2e783d0 100644
--- a/src/pages/game/TouchControls.jsx
+++ b/src/pages/game/TouchControls.jsx
@@ -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}
diff --git a/src/pages/game/UpdateNotice.jsx b/src/pages/game/UpdateNotice.jsx
index 28c514d..29f33ec 100644
--- a/src/pages/game/UpdateNotice.jsx
+++ b/src/pages/game/UpdateNotice.jsx
@@ -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 (
<>
- {'نسخهٔ جدید بازی آماده است '}
+ {strings.app.updateReady}
actions.emit('update-reload')}
>
- {'بهروزرسانی'}
+ {strings.app.update}
>
diff --git a/src/pages/game/ViewLists.jsx b/src/pages/game/ViewLists.jsx
index cbc782f..a09023a 100644
--- a/src/pages/game/ViewLists.jsx
+++ b/src/pages/game/ViewLists.jsx
@@ -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 }) {
{formatHetoman(item.price)}
{item.price !== item.cost && {formatHetoman(item.cost)} }
- {item.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}
- {item.blocked === 'funds' ? ' · موجودی کافی نیست' : ''}
+ {item.type === 'drink' ? strings.lists.drink : strings.lists.snack}
+ {item.blocked === 'funds' ? strings.lists.noFunds : ''}
));
@@ -52,7 +53,7 @@ export function ViewLists({ id, model = {}, onAction }) {
{item.name}
{item.visited ? ' ✓' : ''}
- {item.locked ? item.note : `${item.category} · ${item.open ? 'باز' : 'بسته'}`}
+ {item.locked ? item.note : strings.lists.machineState(item.category, item.open)}
{item.distance}
diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx
index 44a59a1..c4737ad 100644
--- a/src/routes/AppRoutes.jsx
+++ b/src/routes/AppRoutes.jsx
@@ -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 (
- در حال آمادهسازی بازی…
}>
+ {strings.app.preparing}}>
);
diff --git a/src/services/apiClient.js b/src/services/apiClient.js
index 3bf7275..ac8f77f 100644
--- a/src/services/apiClient.js
+++ b/src/services/apiClient.js
@@ -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,
}),
diff --git a/src/services/worldService.js b/src/services/worldService.js
index ea355c9..1eea40d 100644
--- a/src/services/worldService.js
+++ b/src/services/worldService.js
@@ -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.
diff --git a/src/styles/gameTheme.js b/src/styles/gameTheme.js
index b71f00f..5521c91 100644
--- a/src/styles/gameTheme.js
+++ b/src/styles/gameTheme.js
@@ -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)',
diff --git a/src/utils/geo.js b/src/utils/geo.js
index 2de59b7..b1ff287 100644
--- a/src/utils/geo.js
+++ b/src/utils/geo.js
@@ -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'));
}
diff --git a/src/utils/showroomAnswers.js b/src/utils/showroomAnswers.js
index 1403dd3..e2b6a26 100644
--- a/src/utils/showroomAnswers.js
+++ b/src/utils/showroomAnswers.js
@@ -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;
}
diff --git a/tailwind.config.js b/tailwind.config.js
index 032b17a..3d506bb 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -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))],
diff --git a/tests/content.test.js b/tests/content.test.js
new file mode 100644
index 0000000..c424462
--- /dev/null
+++ b/tests/content.test.js
@@ -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);
+});