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)}

)} 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}

  1. - {'در Safari دکمهٔ '} + {strings.install.shareBefore} {'Share'} - {' (مربع با فلش رو به بالا) را بزنید.'} + {strings.install.shareAfter}
  2. - {'گزینهٔ '} + {strings.install.addBefore} {'Add to Home Screen'} - {' را انتخاب کنید.'} -
  3. -
  4. - {'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.'} + {strings.install.addAfter}
  5. +
  6. {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 }) {
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}

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)}

-
@@ -72,7 +72,7 @@ export function ShowroomDialog({ nodes, actions }) { }) } > - روبات، قیمتش چنده؟ + {strings.showroom.askPrice}
روبات راهنمای نمایشگاه -

- {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}
) : ( {`${model.name} 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]) => (
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 ( <> 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); +});