refactor: centralize brand, company info, assets and Persian strings
Phase 1 of turning the game into the company landing page. - Add src/config/company.js with name, tagline, contact, about and the product line, plus a brand palette built around #00a693 (contrast checked); placeholders are marked TODO(company). - Add src/config/assets.js listing every loaded file with its expected format; placeholders are marked TODO(asset). - Move all Persian interface text, dialogue, missions and scenery text into src/i18n/fa.js; components and game code read from it. - Expose the palette as CSS variables (--color-primary and shades) and Tailwind colours, and use it for 3D materials and map drawings. - Fix the arrival line that still called the manager's car a white pickup. - Add tests that block hard-coded Persian text and low-contrast colours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,12 +205,15 @@ node scripts/fetch-imagery.mjs
|
||||
|
||||
## ساختار
|
||||
|
||||
- `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگهای برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکههای اجتماعی، دربارهما و فهرست محصولات. مقدارهای `TODO(company)` جایگزینشدنیاند.
|
||||
- `src/config/assets.js`: مسیر همهٔ فایلها (لوگو، تصویر محصولات، چهرههای روبات، مدلهای سهبعدی و دادهٔ نقشه) با قالب و اندازهٔ مورد انتظار هر کدام. موارد `TODO(asset)` جای فایل واقعیاند.
|
||||
- `src/i18n/fa.js`: همهٔ متنهای فارسی رابط کاربری، گفتوگوها، مأموریتها و نوشتههای روی صحنه. تست `tests/content.test.js` جلوی متن فارسی مستقیم در کد را میگیرد.
|
||||
- `src/game/createGame.js و src/game/runtime/`: صحنه، دوربین سومشخص، کنترلها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
|
||||
- `src/game/world/city.js`: ساخت شهر سهبعدی از دادهها (خیابان، پیادهرو، ساختمان، پارک، درخت، ورودی مترو، کوهها) در تکههای ۲ کیلومتری که دورها پنهان میشوند
|
||||
- `src/game/world/mapRender.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشیهای کششده و نمای کلی) و نقشهٔ ایران
|
||||
- `src/game/world/routing.js`: گراف خیابانها، مسیریابی A* و پیدا کردن نام خیابان
|
||||
- `src/game/world/collision.js`: برخورد با ساختمانها برای شخصیتها، ماشین و دوربین
|
||||
- `src/constants/locations.js`: دستگاهها، آدرسها، ساعت کاری و محصولات
|
||||
- `src/constants/locations.js`: دستگاههای نصبشده در شهر، آدرسها، ساعت کاری و محصولات
|
||||
- `src/game/economy/gameState.js`، `src/game/economy/payment.js`، `src/game/economy/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریتها
|
||||
- `src/game/models/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاهها بیرون میآید
|
||||
- `src/game/models/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، بههمراه دفترها، کامیون و ورود مدیر
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d1412" />
|
||||
<meta name="theme-color" content="#0b1614" />
|
||||
<title>عملیات مشهد | روبات مارکت</title>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Every file the game loads, in one place. Most entries are placeholders until the company sends
|
||||
// real assets: swap the path (and drop the file into public/) without touching any game logic.
|
||||
// Paths are relative to the site root so the game also works from a sub-folder.
|
||||
//
|
||||
// Expected formats
|
||||
// - Images: WebP (JPG fallback), sRGB, under 300 KB each.
|
||||
// - Logo: SVG preferred, or a transparent PNG at least 512 px wide.
|
||||
// - 3D models: glTF binary (.glb), Draco or Meshopt compressed, real-world metres, +X forward,
|
||||
// under 2 MB each and around 20k triangles for a machine.
|
||||
|
||||
/** Robot guide portraits, one per expression. 256 × 240 WebP. */
|
||||
const ROBOT_FACES = [
|
||||
'front',
|
||||
'happy',
|
||||
'wink',
|
||||
'excited',
|
||||
'curious',
|
||||
'laugh',
|
||||
'love',
|
||||
'serious',
|
||||
'surprised',
|
||||
];
|
||||
|
||||
export const ASSETS = {
|
||||
brand: {
|
||||
// TODO(asset): replace with the real company logo (SVG).
|
||||
logo: './icons/icon-512.png',
|
||||
// TODO(asset): hero image for the no-WebGL fallback screen. 1600 × 900 WebP.
|
||||
hero: './icons/icon-512.png',
|
||||
favicon: './icons/favicon-64.png',
|
||||
},
|
||||
robot: Object.fromEntries(ROBOT_FACES.map((face) => [face, `./robot/${face}.webp`])),
|
||||
// Product photos for the showroom. TODO(asset): real photos, 1200 × 1600 WebP; `gif` is a short
|
||||
// looping turntable (GIF or WebP animation, under 1 MB). Missing files show a placeholder card.
|
||||
products: {
|
||||
'rm-35': {
|
||||
image: './images/machine-35-base.webp',
|
||||
imageAlt: './images/machine-35-white.webp',
|
||||
gif: './gifs/machine-35.gif',
|
||||
},
|
||||
'rm-48': {
|
||||
image: './images/machine-48-base.webp',
|
||||
imageAlt: './images/machine-48-white.webp',
|
||||
gif: './gifs/machine-48.gif',
|
||||
},
|
||||
'rm-60': {
|
||||
image: './images/machine-60-base.webp',
|
||||
imageAlt: './images/machine-60-white.webp',
|
||||
gif: './gifs/machine-60.gif',
|
||||
},
|
||||
},
|
||||
// 3D models. Every entry is null today: the game builds low-poly stand-ins in code
|
||||
// (src/game/models). TODO(asset): set a .glb path to use a real model.
|
||||
models: {
|
||||
vendingMachine: null,
|
||||
factory: null,
|
||||
familyCar: null,
|
||||
},
|
||||
// World data generated by the scripts in scripts/.
|
||||
world: {
|
||||
buildings: './data/buildings.bin',
|
||||
imagery: './imagery/index.json',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Path of a robot portrait.
|
||||
* @param {string} face - Expression name, such as 'happy'.
|
||||
* @returns {string} Image path; the front view when the expression is unknown.
|
||||
*/
|
||||
export const robotFace = (face) => ASSETS.robot[face] ?? ASSETS.robot.front;
|
||||
@@ -0,0 +1,106 @@
|
||||
// Everything about the company in one place: name, colours, contact details, history and the
|
||||
// product line. Replace the values marked `TODO(company)` with real content; nothing else in the
|
||||
// game needs to change. Colours are plain hex so both CSS (styles/gameTheme.js) and Three.js
|
||||
// materials can use them.
|
||||
import { ASSETS } from './assets.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} Product
|
||||
* @property {string} id - Stable id used by the showroom and missions.
|
||||
* @property {string} name - Full product name.
|
||||
* @property {string} shortLabel - Short label for buttons.
|
||||
* @property {number} channels - Number of product channels.
|
||||
* @property {string} priceLabel - Price as shown to people.
|
||||
* @property {number} priceNumeric - Price in rial.
|
||||
* @property {string} description - One or two sentences.
|
||||
* @property {string[]} highlights - Key specs, shown as a list.
|
||||
* @property {string} image - Main image path, from config/assets.js.
|
||||
* @property {string} imageAlt - Alternative colour image path.
|
||||
* @property {string} gif - Animated view path.
|
||||
*/
|
||||
|
||||
// Brand palette derived from the primary colour #00a693. On the dark game surfaces the primary is
|
||||
// used for accents and text (contrast 6.0:1 on `surface`); buttons filled with the primary use the
|
||||
// dark `onPrimary` text (contrast 5.5:1).
|
||||
const COLORS = {
|
||||
primary: '#00a693',
|
||||
primaryLight: '#33b8a9',
|
||||
primaryLighter: '#9fe0d6',
|
||||
primaryDark: '#007d6e',
|
||||
primaryDarker: '#00473f',
|
||||
onPrimary: '#03221e',
|
||||
surface: '#0b1614',
|
||||
surfaceRaised: '#13231f',
|
||||
surfaceDeep: '#060c0b',
|
||||
text: '#ecf2ee',
|
||||
textMuted: '#a9bdb6',
|
||||
accent: '#f2c94c',
|
||||
danger: '#ff6b5e',
|
||||
};
|
||||
|
||||
export const COMPANY = {
|
||||
name: 'شرکت روبات مارکت',
|
||||
shortName: 'روبات مارکت',
|
||||
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
||||
colors: COLORS,
|
||||
// TODO(company): real postal address, phone, email, website and social links.
|
||||
contact: {
|
||||
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
||||
city: 'مشهد',
|
||||
phone: '۰۵۱-۰۰۰۰۰۰۰۰',
|
||||
email: 'info@example.com',
|
||||
website: 'https://example.com',
|
||||
socials: [
|
||||
{ id: 'instagram', label: 'اینستاگرام', url: 'https://instagram.com/' },
|
||||
{ id: 'telegram', label: 'تلگرام', url: 'https://t.me/' },
|
||||
{ id: 'linkedin', label: 'لینکدین', url: 'https://linkedin.com/' },
|
||||
],
|
||||
},
|
||||
// TODO(company): real history, values, FAQ and open positions.
|
||||
about: {
|
||||
history: 'متن کوتاه تاریخچهٔ شرکت اینجا قرار میگیرد.',
|
||||
values: ['کیفیت ساخت', 'پشتیبانی شبانهروزی', 'فناوری بومی'],
|
||||
faq: [{ question: 'پرسش نمونه؟', answer: 'پاسخ نمونه.' }],
|
||||
careers: [{ title: 'عنوان شغل نمونه', note: 'توضیح کوتاه' }],
|
||||
},
|
||||
/** @type {Product[]} Display prices in rial; independent of the in-game snack economy. */
|
||||
products: [
|
||||
{
|
||||
id: 'rm-35',
|
||||
category: 'warm-vending',
|
||||
name: 'روبات مارکت ۳۵ کانال',
|
||||
shortLabel: '۳۵ کانال',
|
||||
channels: 35,
|
||||
priceLabel: '4,680,000,000 ریال',
|
||||
priceNumeric: 4680000000,
|
||||
description:
|
||||
'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنککننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
|
||||
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
|
||||
...ASSETS.products['rm-35'],
|
||||
},
|
||||
{
|
||||
id: 'rm-48',
|
||||
category: 'warm-vending',
|
||||
name: 'روبات مارکت ۴۸ کانال',
|
||||
shortLabel: '۴۸ کانال',
|
||||
channels: 48,
|
||||
priceLabel: '5,480,000,000 ریال',
|
||||
priceNumeric: 5480000000,
|
||||
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیتهای پیشرفته مدیریت آنلاین و تحویل کالا.',
|
||||
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
|
||||
...ASSETS.products['rm-48'],
|
||||
},
|
||||
{
|
||||
id: 'rm-60',
|
||||
category: 'warm-vending',
|
||||
name: 'روبات مارکت ۶۰ کانال',
|
||||
shortLabel: '۶۰ کانال',
|
||||
channels: 60,
|
||||
priceLabel: '5,980,000,000 ریال',
|
||||
priceNumeric: 5980000000,
|
||||
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسبوکارهای پرتردد.',
|
||||
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
|
||||
...ASSETS.products['rm-60'],
|
||||
},
|
||||
],
|
||||
};
|
||||
+21
-18
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, () => {}, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { strings } from '../../i18n/fa.js';
|
||||
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
||||
export const INITIAL_BALANCE = 500;
|
||||
export const START_MINUTES = 8 * 60 + 30;
|
||||
@@ -12,7 +13,7 @@ export const fa = (n) => n.toLocaleString('fa-IR');
|
||||
* @param {*} value - value.
|
||||
* @returns {*} Operation result, when applicable.
|
||||
*/
|
||||
export const formatHetoman = (value) => `${fa(value)} هتومان`;
|
||||
export const formatHetoman = (value) => strings.units.hetoman(fa(value));
|
||||
/**
|
||||
* Runs the hetoman to rial operation for this subsystem.
|
||||
* @param {*} value - value.
|
||||
@@ -31,63 +32,63 @@ export const formatClock = (minutes) => {
|
||||
export const catalog = {
|
||||
juice: {
|
||||
id: 'juice',
|
||||
name: 'آبمیوه',
|
||||
name: strings.snacks.juice,
|
||||
icon: '🧃',
|
||||
cost: 35,
|
||||
type: 'drink',
|
||||
},
|
||||
water: {
|
||||
id: 'water',
|
||||
name: 'آب معدنی',
|
||||
name: strings.snacks.water,
|
||||
icon: '💧',
|
||||
cost: 15,
|
||||
type: 'drink',
|
||||
},
|
||||
milk: {
|
||||
id: 'milk',
|
||||
name: 'شیر کاکائو',
|
||||
name: strings.snacks.milk,
|
||||
icon: '🥛',
|
||||
cost: 40,
|
||||
type: 'drink',
|
||||
},
|
||||
tea: {
|
||||
id: 'tea',
|
||||
name: 'چای سرد',
|
||||
name: strings.snacks.tea,
|
||||
icon: '🧋',
|
||||
cost: 45,
|
||||
type: 'drink',
|
||||
},
|
||||
lemonade: {
|
||||
id: 'lemonade',
|
||||
name: 'لیموناد',
|
||||
name: strings.snacks.lemonade,
|
||||
icon: '🍋',
|
||||
cost: 40,
|
||||
type: 'drink',
|
||||
},
|
||||
biscuit: {
|
||||
id: 'biscuit',
|
||||
name: 'بیسکویت',
|
||||
name: strings.snacks.biscuit,
|
||||
icon: '🍪',
|
||||
cost: 25,
|
||||
type: 'snack',
|
||||
},
|
||||
chocolate: {
|
||||
id: 'chocolate',
|
||||
name: 'شکلات',
|
||||
name: strings.snacks.chocolate,
|
||||
icon: '🍫',
|
||||
cost: 30,
|
||||
type: 'snack',
|
||||
},
|
||||
nuts: {
|
||||
id: 'nuts',
|
||||
name: 'آجیل',
|
||||
name: strings.snacks.nuts,
|
||||
icon: '🥜',
|
||||
cost: 65,
|
||||
type: 'snack',
|
||||
},
|
||||
cake: {
|
||||
id: 'cake',
|
||||
name: 'کیک',
|
||||
name: strings.snacks.cake,
|
||||
icon: '🧁',
|
||||
cost: 35,
|
||||
type: 'snack',
|
||||
|
||||
+18
-17
@@ -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,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import * as THREE from 'three';
|
||||
import { COMPANY } from '../../config/company.js';
|
||||
|
||||
// Brand colours for materials and canvas drawings.
|
||||
const BRAND = COMPANY.colors;
|
||||
|
||||
// Detailed vehicles for the family and the factory, plus the shared helpers luxuryCars.js builds
|
||||
// on. All face local +X and sit on y = 0, like createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
|
||||
@@ -337,7 +341,7 @@ export function createTruck(color = '#e9eef0') {
|
||||
const black = mat('#1b1d1f', {
|
||||
roughness: 0.7,
|
||||
}),
|
||||
teal = mat('#0b8f88', {
|
||||
teal = mat(BRAND.primary, {
|
||||
metalness: 0.3,
|
||||
roughness: 0.4,
|
||||
});
|
||||
|
||||
+65
-78
@@ -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 ----------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as THREE from 'three';
|
||||
import { catalog } from '../economy/gameState.js';
|
||||
import { textTexture } from './textures.js';
|
||||
import { COMPANY } from '../../config/company.js';
|
||||
|
||||
// Brand colours for materials and canvas drawings.
|
||||
const BRAND = COMPANY.colors;
|
||||
const PRODUCT_COLORS = {
|
||||
juice: '#e5ae47',
|
||||
water: '#9fd3e6',
|
||||
@@ -24,14 +28,14 @@ const label = (text, sub) =>
|
||||
textTexture(512, 160, (c, width, height) => {
|
||||
c.fillStyle = '#0f2a2a';
|
||||
c.fillRect(0, 0, width, height);
|
||||
c.fillStyle = '#3ee0c9';
|
||||
c.fillStyle = BRAND.primary;
|
||||
c.fillRect(0, height - 10, width, 10);
|
||||
c.textAlign = 'center';
|
||||
c.direction = 'rtl';
|
||||
c.fillStyle = '#e9fff8';
|
||||
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
|
||||
c.fillText(text, width / 2, 72);
|
||||
c.fillStyle = '#9fe9da';
|
||||
c.fillStyle = BRAND.primaryLighter;
|
||||
c.font = '34px Vazirmatn, Tahoma, sans-serif';
|
||||
c.fillText(sub, width / 2, 126);
|
||||
});
|
||||
@@ -75,10 +79,10 @@ export function createVendingMachine(def) {
|
||||
parent.add(mesh);
|
||||
return mesh;
|
||||
};
|
||||
const shell = mat('#0b8f88', {
|
||||
const shell = mat(BRAND.primary, {
|
||||
metalness: 0.45,
|
||||
roughness: 0.3,
|
||||
emissive: '#0b8f88',
|
||||
emissive: BRAND.primary,
|
||||
emissiveIntensity: 0.15,
|
||||
}),
|
||||
white = mat('#eef5f3', {
|
||||
@@ -190,7 +194,7 @@ export function createVendingMachine(def) {
|
||||
const sign = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1.86, 0.3),
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: label('روبات مارکت', def.name),
|
||||
map: label(COMPANY.shortName, def.name),
|
||||
}),
|
||||
);
|
||||
sign.position.set(0, 2.62, 0.575);
|
||||
@@ -213,7 +217,7 @@ export function createVendingMachine(def) {
|
||||
|
||||
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
||||
const beaconMaterial = new THREE.MeshBasicMaterial({
|
||||
color: '#3ee0c9',
|
||||
color: BRAND.primary,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
depthWrite: false,
|
||||
@@ -230,7 +234,7 @@ export function createVendingMachine(def) {
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(1.6, 1.9, 40),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: '#3ee0c9',
|
||||
color: BRAND.primary,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
depthWrite: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CATEGORIES, lockedPlaces } from '../../constants/locations.js';
|
||||
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
|
||||
import { drawFullMap, drawIran } from '../world/mapRender.js';
|
||||
import { formatDistance } from '../../utils/geo.js';
|
||||
import { strings } from '../../i18n/fa.js';
|
||||
/**
|
||||
* Runs the size canvas operation for this subsystem.
|
||||
* @param {*} context - context.
|
||||
@@ -104,7 +105,7 @@ export function selectMachine(context, id, center = false) {
|
||||
text: `📍 ${def.address}`,
|
||||
});
|
||||
context.ui.patch('detail-meta', {
|
||||
text: `🕒 ${context.hoursText(def)} · ${isOpen(def, context.state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`,
|
||||
text: strings.map.details(context.hoursText(def), isOpen(def, context.state.minutes), def.note),
|
||||
});
|
||||
context.ui.patch('detail-products', {
|
||||
text: def.products
|
||||
@@ -112,7 +113,7 @@ export function selectMachine(context, id, center = false) {
|
||||
.join(' · '),
|
||||
});
|
||||
context.ui.patch('route-button', {
|
||||
text: context.waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا',
|
||||
text: context.waypoint === id ? strings.map.routeActive : strings.map.route,
|
||||
});
|
||||
context.renderMachineList();
|
||||
context.renderMap();
|
||||
|
||||
@@ -4,6 +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) };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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?.(() => {});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { strings } from '../../i18n/fa.js';
|
||||
import { COMPANY } from '../../config/company.js';
|
||||
|
||||
// Brand colours for materials and canvas drawings.
|
||||
const BRAND = COMPANY.colors;
|
||||
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
|
||||
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
|
||||
const SCALE = 0.75; // tile pixels per metre
|
||||
@@ -271,7 +276,7 @@ function machineIcon(c, x, y, { objective, visited, open }) {
|
||||
c.fillRect(-7, -7, 14, 14);
|
||||
c.strokeRect(-7, -7, 14, 14);
|
||||
} else {
|
||||
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
|
||||
c.fillStyle = open ? (visited ? '#7fb8ad' : BRAND.primary) : '#7b817f';
|
||||
c.strokeStyle = '#08201c';
|
||||
c.lineWidth = 2;
|
||||
c.beginPath();
|
||||
@@ -367,7 +372,7 @@ export function drawMinimap(ctx, assets, view) {
|
||||
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
||||
ctx.fillText(strings.hud.minimapNorth, (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
||||
ctx.lineWidth = 2;
|
||||
@@ -461,11 +466,11 @@ export function drawFullMap(ctx, assets, view, state) {
|
||||
metro: '#ff8a80',
|
||||
mall: '#f5b8d9',
|
||||
industrial: '#a9b2ad',
|
||||
factory: '#3ee0c9',
|
||||
factory: BRAND.primary,
|
||||
}[l.kind];
|
||||
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
||||
ctx.fillText(
|
||||
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
|
||||
l.kind === 'metro' ? `Ⓜ ${l.name.replace(strings.hud.metroPrefix, '')}` : l.name,
|
||||
sx,
|
||||
sy + 14,
|
||||
);
|
||||
@@ -546,9 +551,9 @@ export function drawIran(
|
||||
for (const province of iran.provinces) {
|
||||
const active = province.id === highlight;
|
||||
ctx.fillStyle = active
|
||||
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
|
||||
? `rgba(0,166,147,${0.35 + Math.sin(t * 3) * 0.12})`
|
||||
: 'rgba(40,56,50,.85)';
|
||||
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
|
||||
ctx.strokeStyle = active ? BRAND.primary : 'rgba(150,190,175,.35)';
|
||||
ctx.lineWidth = active ? 2 : 1;
|
||||
for (const ring of province.rings) {
|
||||
ctx.beginPath();
|
||||
|
||||
+457
@@ -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++'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
import { robotFace } from '@/config/assets.js';
|
||||
/** @typedef {{nodes:object,actions:object}} BriefingPanelProps */
|
||||
/** Presents the BriefingPanel view using plain models and semantic native elements.
|
||||
* @param {BriefingPanelProps} props - Interface snapshots and callbacks.
|
||||
@@ -14,14 +16,14 @@ export function BriefingPanel({ nodes, actions }) {
|
||||
<div className="brief-panel">
|
||||
<img
|
||||
className="brief-robot"
|
||||
src="./robot/front.webp"
|
||||
alt="روبات راهنمای روبات مارکت"
|
||||
src={robotFace('front')}
|
||||
alt={strings.briefing.robotAlt}
|
||||
width="136"
|
||||
height="223"
|
||||
/>
|
||||
<small className="tag">{'عملیات ۰۱ · خراسان رضوی'}</small>
|
||||
<h1>{'عملیات مشهد'}</h1>
|
||||
<p className="sub">{'روبات مارکت · شکار دستگاههای وندینگ'}</p>
|
||||
<small className="tag">{strings.briefing.tag}</small>
|
||||
<h1>{strings.briefing.title}</h1>
|
||||
<p className="sub">{strings.briefing.subtitle}</p>
|
||||
<GameNode
|
||||
as="pre"
|
||||
id="brief-text"
|
||||
@@ -35,7 +37,7 @@ export function BriefingPanel({ nodes, actions }) {
|
||||
model={nodes['start-mission']}
|
||||
onAction={() => actions.emit('start-mission')}
|
||||
>
|
||||
{'شروع عملیات'}
|
||||
{strings.briefing.start}
|
||||
</GameNode>
|
||||
<div className="brief-actions">
|
||||
<GameNode
|
||||
@@ -46,7 +48,7 @@ export function BriefingPanel({ nodes, actions }) {
|
||||
model={nodes['real-mode']}
|
||||
onAction={() => actions.emit('real-mode')}
|
||||
>
|
||||
{'📍 بازی در دنیای واقعی (GPS)'}
|
||||
{strings.briefing.realWorld}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -57,14 +59,10 @@ export function BriefingPanel({ nodes, actions }) {
|
||||
model={nodes['install']}
|
||||
onAction={() => actions.emit('install')}
|
||||
>
|
||||
{'⬇ نصب بازی روی گوشی'}
|
||||
{strings.briefing.install}
|
||||
</GameNode>
|
||||
</div>
|
||||
<p className="keys">
|
||||
{
|
||||
'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
|
||||
}
|
||||
</p>
|
||||
<p className="keys">{strings.briefing.keys}</p>
|
||||
</div>
|
||||
</GameNode>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { ViewLists } from './ViewLists.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
|
||||
/** Presents the EndDialog view using plain models and semantic native elements.
|
||||
* @param {EndDialogProps} props - Interface snapshots and callbacks.
|
||||
@@ -10,7 +11,7 @@ export function EndDialog({ nodes, actions }) {
|
||||
<>
|
||||
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
|
||||
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
|
||||
{'پایان عملیات'}
|
||||
{strings.end.title}
|
||||
</GameNode>
|
||||
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
|
||||
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
|
||||
@@ -29,7 +30,7 @@ export function EndDialog({ nodes, actions }) {
|
||||
model={nodes['again']}
|
||||
onAction={() => actions.emit('again')}
|
||||
>
|
||||
{'عملیات دوباره'}
|
||||
{strings.end.again}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -39,7 +40,7 @@ export function EndDialog({ nodes, actions }) {
|
||||
model={nodes['continue']}
|
||||
onAction={() => actions.emit('continue')}
|
||||
>
|
||||
{'ادامهٔ گشت در شهر'}
|
||||
{strings.end.keepExploring}
|
||||
</GameNode>
|
||||
</div>
|
||||
</GameNode>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GameToolbar } from './GameToolbar.jsx';
|
||||
import { TouchControls } from './TouchControls.jsx';
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { ViewLists } from './ViewLists.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} GameHudProps */
|
||||
/** Presents the GameHud view using plain models and semantic native elements.
|
||||
* @param {GameHudProps} props - Interface snapshots and callbacks.
|
||||
@@ -20,12 +21,12 @@ export function GameHud({ nodes, actions }) {
|
||||
as="button"
|
||||
id="minimap-button"
|
||||
type="button"
|
||||
aria-label="باز کردن نقشهٔ شهر (M)"
|
||||
aria-label={strings.hud.openMap}
|
||||
model={nodes['minimap-button']}
|
||||
onAction={() => actions.emit('minimap-button')}
|
||||
>
|
||||
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes['minimap']} />
|
||||
<span className="map-hint">{'نقشه · M'}</span>
|
||||
<span className="map-hint">{strings.hud.mapHint}</span>
|
||||
</GameNode>
|
||||
<LocationStatus nodes={nodes} actions={actions} />
|
||||
<MissionList nodes={nodes} actions={actions} />
|
||||
@@ -39,7 +40,7 @@ export function GameHud({ nodes, actions }) {
|
||||
id="prompt"
|
||||
hidden
|
||||
type="button"
|
||||
aria-label="تعامل با بازی"
|
||||
aria-label={strings.hud.interact}
|
||||
model={nodes['prompt']}
|
||||
onAction={() => actions.emit('prompt')}
|
||||
></GameNode>
|
||||
@@ -68,7 +69,7 @@ export function GameHud({ nodes, actions }) {
|
||||
<TouchControls nodes={nodes} actions={actions} />
|
||||
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
|
||||
<GameNode as="small" id="attribution" model={nodes['attribution']}>
|
||||
{'نقشه: © مشارکتکنندگان OpenStreetMap'}
|
||||
{strings.app.osmCredit}
|
||||
</GameNode>
|
||||
</GameNode>
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GameSurfaces } from './GameSurfaces.jsx';
|
||||
import { BriefingPanel } from './BriefingPanel.jsx';
|
||||
import { GameHud } from './GameHud.jsx';
|
||||
import { GameDialogs } from './GameDialogs.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
|
||||
/** Composes React views around the isolated Three.js runtime. */
|
||||
export function GamePage() {
|
||||
@@ -49,9 +50,9 @@ export function GamePage() {
|
||||
role="alert"
|
||||
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
|
||||
>
|
||||
<p>بازی اجرا نشد: {error}</p>
|
||||
<p>{strings.app.failed(error)}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
تلاش دوباره
|
||||
{strings.app.retry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
|
||||
/** Presents the GameSurfaces view using plain models and semantic native elements.
|
||||
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
|
||||
@@ -18,7 +19,7 @@ export function GameSurfaces({ nodes }) {
|
||||
></GameNode>
|
||||
<GameNode as="canvas" id="world" model={nodes['world']} />
|
||||
<GameNode as="div" id="loading" model={nodes['loading']}>
|
||||
{'در حال ساختن شهر مشهد…'}
|
||||
{strings.app.buildingCity}
|
||||
</GameNode>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
|
||||
/** Presents the GameToolbar using the current interface snapshot. */
|
||||
export function GameToolbar({ nodes, actions }) {
|
||||
@@ -12,7 +13,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['showroom-open']}
|
||||
onAction={() => actions.emit('showroom-open')}
|
||||
>
|
||||
مدلهای کارخانه · Q
|
||||
{strings.toolbar.showroom}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -21,7 +22,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['gps-toggle']}
|
||||
onAction={() => actions.emit('gps-toggle')}
|
||||
>
|
||||
{'📍 دنیای واقعی'}
|
||||
{strings.toolbar.realWorld}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -31,7 +32,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['ar-toggle']}
|
||||
onAction={() => actions.emit('ar-toggle')}
|
||||
>
|
||||
{'📷 دوربین'}
|
||||
{strings.toolbar.camera}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -42,7 +43,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['stable-toggle']}
|
||||
onAction={() => actions.emit('stable-toggle')}
|
||||
>
|
||||
{'🎯 پایدار: روشن'}
|
||||
{strings.toolbar.stableOn}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -52,7 +53,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['sound']}
|
||||
onAction={() => actions.emit('sound')}
|
||||
>
|
||||
{'صدا: خاموش'}
|
||||
{strings.toolbar.soundOff}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -61,7 +62,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['quality']}
|
||||
onAction={() => actions.emit('quality')}
|
||||
>
|
||||
{'گرافیک'}
|
||||
{strings.toolbar.graphics}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -70,7 +71,7 @@ export function GameToolbar({ nodes, actions }) {
|
||||
model={nodes['restart']}
|
||||
onAction={() => actions.emit('restart')}
|
||||
>
|
||||
{'شروع دوباره'}
|
||||
{strings.toolbar.restart}
|
||||
</GameNode>
|
||||
</GameNode>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} InstallDialogProps */
|
||||
/** Presents the InstallDialog view using plain models and semantic native elements.
|
||||
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
|
||||
@@ -13,21 +14,19 @@ export function InstallDialog({ nodes, actions }) {
|
||||
model={nodes['ios-install']}
|
||||
onClose={() => actions.close('ios-install')}
|
||||
>
|
||||
<h2>{'نصب روی آیفون'}</h2>
|
||||
<h2>{strings.install.title}</h2>
|
||||
<ol>
|
||||
<li>
|
||||
{'در Safari دکمهٔ '}
|
||||
{strings.install.shareBefore}
|
||||
<b>{'Share'}</b>
|
||||
{' (مربع با فلش رو به بالا) را بزنید.'}
|
||||
{strings.install.shareAfter}
|
||||
</li>
|
||||
<li>
|
||||
{'گزینهٔ '}
|
||||
{strings.install.addBefore}
|
||||
<b>{'Add to Home Screen'}</b>
|
||||
{' را انتخاب کنید.'}
|
||||
</li>
|
||||
<li>
|
||||
{'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار میگیرد و آفلاین هم اجرا میشود.'}
|
||||
{strings.install.addAfter}
|
||||
</li>
|
||||
<li>{strings.install.note}</li>
|
||||
</ol>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -36,7 +35,7 @@ export function InstallDialog({ nodes, actions }) {
|
||||
model={nodes['ios-close']}
|
||||
onAction={() => actions.emit('ios-close')}
|
||||
>
|
||||
{'متوجه شدم'}
|
||||
{strings.common.gotIt}
|
||||
</GameNode>
|
||||
</GameNode>
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
|
||||
/** Presents the LocationStatus using the current interface snapshot. */
|
||||
export function LocationStatus({ nodes }) {
|
||||
return (
|
||||
<GameNode as="div" id="location" model={nodes['location']}>
|
||||
<GameNode as="span" id="street" model={nodes['street']}>
|
||||
{'مشهد'}
|
||||
{strings.common.mashhad}
|
||||
</GameNode>
|
||||
<GameNode as="small" id="mode-label" model={nodes['mode-label']}>
|
||||
{'پیاده'}
|
||||
{strings.common.walking}
|
||||
</GameNode>
|
||||
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']}></GameNode>
|
||||
</GameNode>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
|
||||
/** Presents the MachineDetails using the current interface snapshot. */
|
||||
export function MachineDetails({ nodes, actions }) {
|
||||
@@ -15,7 +16,7 @@ export function MachineDetails({ nodes, actions }) {
|
||||
model={nodes['route-button']}
|
||||
onAction={() => actions.emit('route-button')}
|
||||
>
|
||||
{'مسیریابی تا اینجا'}
|
||||
{strings.map.route}
|
||||
</GameNode>
|
||||
</GameNode>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MachineDetails } from './MachineDetails.jsx';
|
||||
import { MachineList } from './MachineList.jsx';
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
|
||||
/** Presents the MapDialog view using plain models and semantic native elements.
|
||||
* @param {MapDialogProps} props - Interface snapshots and callbacks.
|
||||
@@ -27,7 +28,7 @@ export function MapDialog({ nodes, actions }) {
|
||||
model={nodes['tab-city']}
|
||||
onAction={() => actions.emit('tab-city')}
|
||||
>
|
||||
{'مشهد'}
|
||||
{strings.common.mashhad}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -39,7 +40,7 @@ export function MapDialog({ nodes, actions }) {
|
||||
model={nodes['tab-iran']}
|
||||
onAction={() => actions.emit('tab-iran')}
|
||||
>
|
||||
{'ایران'}
|
||||
{strings.common.iran}
|
||||
</GameNode>
|
||||
</div>
|
||||
<GameNode
|
||||
@@ -47,7 +48,7 @@ export function MapDialog({ nodes, actions }) {
|
||||
type="button"
|
||||
className="close"
|
||||
id="map-close"
|
||||
aria-label="بستن"
|
||||
aria-label={strings.map.close}
|
||||
model={nodes['map-close']}
|
||||
onAction={() => actions.emit('map-close')}
|
||||
>
|
||||
@@ -58,12 +59,10 @@ export function MapDialog({ nodes, actions }) {
|
||||
<div className="map-view">
|
||||
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
|
||||
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
|
||||
<p className="map-help">
|
||||
{'کشیدن: جابهجایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب'}
|
||||
</p>
|
||||
<p className="map-help">{strings.map.help}</p>
|
||||
</div>
|
||||
<aside className="map-side">
|
||||
<h3>{'دستگاههای روبات مارکت'}</h3>
|
||||
<h3>{strings.map.machines}</h3>
|
||||
<MachineDetails nodes={nodes} actions={actions} />
|
||||
<MachineList nodes={nodes} actions={actions} />
|
||||
</aside>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { ViewLists } from './ViewLists.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} MissionListProps */
|
||||
/** Presents the MissionList using the current interface snapshot. */
|
||||
export function MissionList({ nodes, actions }) {
|
||||
return (
|
||||
<GameNode as="aside" id="objectives" model={nodes['objectives']}>
|
||||
<h2>{'مأموریتها'}</h2>
|
||||
<h2>{strings.status.missions}</h2>
|
||||
<GameNode as="ol" id="mission-list" model={nodes['mission-list']}>
|
||||
<ViewLists
|
||||
id="mission-list"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} PaymentPanelProps */
|
||||
/** Presents the PaymentPanel view using plain models and semantic native elements.
|
||||
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
|
||||
@@ -8,7 +9,7 @@ export function PaymentPanel({ nodes, actions }) {
|
||||
return (
|
||||
<>
|
||||
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
|
||||
<small>{'کارتخوان روبات مارکت'}</small>
|
||||
<small>{strings.payment.reader}</small>
|
||||
<GameNode as="h2" id="payment-title" model={nodes['payment-title']}></GameNode>
|
||||
<GameNode as="p" id="payment-step" model={nodes['payment-step']}></GameNode>
|
||||
<div className="actions">
|
||||
@@ -20,7 +21,7 @@ export function PaymentPanel({ nodes, actions }) {
|
||||
model={nodes['swipe']}
|
||||
onAction={() => actions.emit('swipe')}
|
||||
>
|
||||
{'کشیدن کارت · C'}
|
||||
{strings.payment.swipe}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -30,7 +31,7 @@ export function PaymentPanel({ nodes, actions }) {
|
||||
model={nodes['cancel-payment']}
|
||||
onAction={() => actions.emit('cancel-payment')}
|
||||
>
|
||||
{'انصراف'}
|
||||
{strings.common.cancel}
|
||||
</GameNode>
|
||||
</div>
|
||||
</GameNode>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
|
||||
/** Presents the RankProgress using the current interface snapshot. */
|
||||
export function RankProgress({ nodes }) {
|
||||
return (
|
||||
<GameNode as="div" id="rank" model={nodes['rank']}>
|
||||
<GameNode as="b" id="rank-name" model={nodes['rank-name']}>
|
||||
{'تازهوارد'}
|
||||
{strings.status.rankStart}
|
||||
</GameNode>
|
||||
<GameNode as="span" id="xp" model={nodes['xp']}>
|
||||
{'۰ XP'}
|
||||
{strings.status.xpStart}
|
||||
</GameNode>
|
||||
<div className="bar">
|
||||
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
import { robotFace } from '@/config/assets.js';
|
||||
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
|
||||
/** Presents the RealIntroDialog view using plain models and semantic native elements.
|
||||
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
|
||||
@@ -13,18 +15,13 @@ export function RealIntroDialog({ nodes, actions }) {
|
||||
model={nodes['real-intro']}
|
||||
onClose={() => actions.close('real-intro')}
|
||||
>
|
||||
<img src="./robot/serious.webp" alt="" width="72" height="68" />
|
||||
<h2>{'بازی در دنیای واقعی'}</h2>
|
||||
<p>
|
||||
{
|
||||
'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده میشود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، میتوانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
|
||||
}
|
||||
</p>
|
||||
<img src={robotFace('serious')} alt="" width="72" height="68" />
|
||||
<h2>{strings.realIntro.title}</h2>
|
||||
<p>{strings.realIntro.body}</p>
|
||||
<ul>
|
||||
<li>{'هنگام راه رفتن به خیابان و ماشینها نگاه کنید، نه به صفحه.'}</li>
|
||||
<li>{'بچهها فقط همراه یک بزرگتر بازی کنند.'}</li>
|
||||
<li>{'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال میشود.'}</li>
|
||||
<li>{'موقعیت شما فقط روی همین گوشی میماند و جایی ارسال نمیشود.'}</li>
|
||||
{strings.realIntro.safety.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="actions">
|
||||
<GameNode
|
||||
@@ -34,7 +31,7 @@ export function RealIntroDialog({ nodes, actions }) {
|
||||
model={nodes['real-go']}
|
||||
onAction={() => actions.emit('real-go')}
|
||||
>
|
||||
{'قبول دارم، شروع کن'}
|
||||
{strings.realIntro.accept}
|
||||
</GameNode>
|
||||
<GameNode
|
||||
as="button"
|
||||
@@ -44,7 +41,7 @@ export function RealIntroDialog({ nodes, actions }) {
|
||||
model={nodes['real-cancel']}
|
||||
onAction={() => actions.emit('real-cancel')}
|
||||
>
|
||||
{'انصراف'}
|
||||
{strings.common.cancel}
|
||||
</GameNode>
|
||||
</div>
|
||||
</GameNode>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { ViewLists } from './ViewLists.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
|
||||
/** Presents the ShopDialog view using plain models and semantic native elements.
|
||||
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
|
||||
@@ -20,7 +21,7 @@ export function ShopDialog({ nodes, actions }) {
|
||||
type="button"
|
||||
className="close"
|
||||
id="shop-close"
|
||||
aria-label="بستن"
|
||||
aria-label={strings.shop.close}
|
||||
model={nodes['shop-close']}
|
||||
onAction={() => actions.emit('shop-close')}
|
||||
>
|
||||
@@ -35,9 +36,7 @@ export function ShopDialog({ nodes, actions }) {
|
||||
onAction={(name, value) => actions.emit(name, value)}
|
||||
/>
|
||||
</GameNode>
|
||||
<p className="rate">
|
||||
{'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشیاند و پرداخت واقعی انجام نمیشود.'}
|
||||
</p>
|
||||
<p className="rate">{strings.shop.note}</p>
|
||||
</GameNode>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
|
||||
import { robotFace } from '@/config/assets.js';
|
||||
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
|
||||
import { ShowroomImage } from './ShowroomImage.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
|
||||
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
|
||||
/** Presents the factory sample catalog and questions answered by the robot host.
|
||||
@@ -22,17 +24,17 @@ export function ShowroomDialog({ nodes, actions }) {
|
||||
>
|
||||
<header className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 id="showroom-title" className="text-xl font-bold">
|
||||
نمایشگاه کارخانه · {model.name}
|
||||
{strings.showroom.heading(model.name)}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن معرفی دستگاه"
|
||||
aria-label={strings.showroom.close}
|
||||
onClick={() => actions.close('showroom')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
|
||||
<nav aria-label={strings.showroom.models} className="mb-5 flex flex-wrap gap-3">
|
||||
{SHOWROOM_CATALOG.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -49,16 +51,14 @@ export function ShowroomDialog({ nodes, actions }) {
|
||||
<ShowroomImage key={model.id} model={model} />
|
||||
<section>
|
||||
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
|
||||
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
|
||||
<p className="mb-3 text-gold">{strings.showroom.price(formatRial(model.priceNumeric))}</p>
|
||||
<p>{model.description}</p>
|
||||
<ul className="my-4 list-inside list-disc">
|
||||
{model.highlights.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm opacity-75">
|
||||
این بخش معرفی محصول است؛ خرید واقعی انجام نمیشود و بودجهٔ هتومان تغییر نمیکند.
|
||||
</p>
|
||||
<p className="text-sm opacity-75">{strings.showroom.displayOnly}</p>
|
||||
</section>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
@@ -72,7 +72,7 @@ export function ShowroomDialog({ nodes, actions }) {
|
||||
})
|
||||
}
|
||||
>
|
||||
روبات، قیمتش چنده؟
|
||||
{strings.showroom.askPrice}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-teal px-4 py-3"
|
||||
@@ -84,19 +84,16 @@ export function ShowroomDialog({ nodes, actions }) {
|
||||
})
|
||||
}
|
||||
>
|
||||
روبات، امکاناتش چیه؟
|
||||
{strings.showroom.askFeatures}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
|
||||
<img
|
||||
className="h-16 w-16 shrink-0 object-contain"
|
||||
src="./robot/happy.webp"
|
||||
alt="روبات راهنمای نمایشگاه"
|
||||
src={robotFace('happy')}
|
||||
alt={strings.showroom.robotAlt}
|
||||
/>
|
||||
<p role="status">
|
||||
{nodes['showroom-response']?.text ??
|
||||
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
|
||||
</p>
|
||||
<p role="status">{nodes['showroom-response']?.text ?? strings.showroom.greeting}</p>
|
||||
</div>
|
||||
</GameNode>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
|
||||
/** @typedef {{model:object}} ShowroomImageProps */
|
||||
/** Shows supplied catalog assets, with an explicit fallback while files are unavailable.
|
||||
@@ -10,7 +11,7 @@ export function ShowroomImage({ model }) {
|
||||
const [failed, setFailed] = useState({});
|
||||
const path = model[variant];
|
||||
const unavailable = failed[path];
|
||||
const src = new URL(`.${path}`, document.baseURI).href;
|
||||
const src = new URL(path, document.baseURI).href;
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{unavailable ? (
|
||||
@@ -18,13 +19,13 @@ export function ShowroomImage({ model }) {
|
||||
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
|
||||
role="status"
|
||||
>
|
||||
نمای سهبعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.
|
||||
{strings.showroom.noImage}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
className="mx-auto max-h-64 max-w-full object-contain"
|
||||
src={src}
|
||||
alt={`${model.name} · ${variant === 'imageAlt' ? 'رنگ سفید' : variant === 'gif' ? 'نمای متحرک' : 'نمای اصلی'}`}
|
||||
alt={`${model.name} · ${strings.showroom.views[variant]}`}
|
||||
onError={() =>
|
||||
setFailed((previous) => ({
|
||||
...previous,
|
||||
@@ -34,11 +35,7 @@ export function ShowroomImage({ model }) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
['image', 'نمای اصلی'],
|
||||
['imageAlt', 'رنگ سفید'],
|
||||
['gif', 'نمای متحرک'],
|
||||
].map(([key, label]) => (
|
||||
{Object.entries(strings.showroom.views).map(([key, label]) => (
|
||||
<button
|
||||
className="rounded border border-white/20 px-3 py-2"
|
||||
key={key}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
|
||||
/** Presents the StatusBar using the current interface snapshot. */
|
||||
export function StatusBar({ nodes }) {
|
||||
return (
|
||||
<GameNode as="div" id="status" model={nodes['status']}>
|
||||
<div className="stat">
|
||||
<small>{'بودجه'}</small>
|
||||
<small>{strings.status.budget}</small>
|
||||
<GameNode as="strong" id="balance" model={nodes['balance']}>
|
||||
{'۵۰۰'}
|
||||
{strings.status.budgetStart}
|
||||
</GameNode>
|
||||
<span>{'هتومان'}</span>
|
||||
<span>{strings.units.hetomanName}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<small>{'ساعت'}</small>
|
||||
<small>{strings.status.clock}</small>
|
||||
<GameNode as="strong" id="clock" model={nodes['clock']}>
|
||||
{'۰۸:۳۰'}
|
||||
{strings.status.clockStart}
|
||||
</GameNode>
|
||||
</div>
|
||||
<GameNode as="div" className="stat" id="speed-stat" hidden model={nodes['speed-stat']}>
|
||||
<small>{'سرعت'}</small>
|
||||
<small>{strings.status.speed}</small>
|
||||
<GameNode as="strong" id="speed" model={nodes['speed']}>
|
||||
{'۰'}
|
||||
{strings.status.speedStart}
|
||||
</GameNode>
|
||||
<span>{'km/h'}</span>
|
||||
</GameNode>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
|
||||
/** Presents the TouchControls using the current interface snapshot. */
|
||||
export function TouchControls({ nodes, actions }) {
|
||||
@@ -36,7 +37,7 @@ export function TouchControls({ nodes, actions }) {
|
||||
model={nodes['touch-shift']}
|
||||
onAction={() => actions.emit('touch-shift')}
|
||||
>
|
||||
{'دویدن'}
|
||||
{strings.status.run}
|
||||
</GameNode>
|
||||
</div>
|
||||
</GameNode>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} UpdateNoticeProps */
|
||||
/** Presents the UpdateNotice view using plain models and semantic native elements.
|
||||
* @param {UpdateNoticeProps} props - Interface snapshots and callbacks.
|
||||
@@ -8,7 +9,7 @@ export function UpdateNotice({ nodes, actions }) {
|
||||
return (
|
||||
<>
|
||||
<GameNode as="div" id="update-toast" hidden model={nodes['update-toast']}>
|
||||
{'نسخهٔ جدید بازی آماده است '}
|
||||
{strings.app.updateReady}
|
||||
<GameNode
|
||||
as="button"
|
||||
type="button"
|
||||
@@ -16,7 +17,7 @@ export function UpdateNotice({ nodes, actions }) {
|
||||
model={nodes['update-reload']}
|
||||
onAction={() => actions.emit('update-reload')}
|
||||
>
|
||||
{'بهروزرسانی'}
|
||||
{strings.app.update}
|
||||
</GameNode>
|
||||
</GameNode>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatHetoman } from '@/game/economy/gameState.js';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
|
||||
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
|
||||
/** Renders structured game lists as JSX; strings are never interpreted as HTML.
|
||||
@@ -20,8 +21,8 @@ export function ViewLists({ id, model = {}, onAction }) {
|
||||
<span className="price">{formatHetoman(item.price)}</span>
|
||||
{item.price !== item.cost && <s>{formatHetoman(item.cost)}</s>}
|
||||
<small>
|
||||
{item.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}
|
||||
{item.blocked === 'funds' ? ' · موجودی کافی نیست' : ''}
|
||||
{item.type === 'drink' ? strings.lists.drink : strings.lists.snack}
|
||||
{item.blocked === 'funds' ? strings.lists.noFunds : ''}
|
||||
</small>
|
||||
</button>
|
||||
));
|
||||
@@ -52,7 +53,7 @@ export function ViewLists({ id, model = {}, onAction }) {
|
||||
{item.name}
|
||||
{item.visited ? ' ✓' : ''}
|
||||
<span className="meta">
|
||||
{item.locked ? item.note : `${item.category} · ${item.open ? 'باز' : 'بسته'}`}
|
||||
{item.locked ? item.note : strings.lists.machineState(item.category, item.open)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="dist">{item.distance}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
const GamePage = lazy(() =>
|
||||
import('@/pages/game/GamePage.jsx').then((module) => ({
|
||||
default: module.GamePage,
|
||||
@@ -7,7 +8,7 @@ const GamePage = lazy(() =>
|
||||
/** Loads the one game page on demand while keeping a visible startup status. */
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Suspense fallback={<p role="status">در حال آمادهسازی بازی…</p>}>
|
||||
<Suspense fallback={<p role="status">{strings.app.preparing}</p>}>
|
||||
<GamePage />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { apiClient } from './apiClient.js';
|
||||
import { SATELLITE_GROUND } from '../constants/world.js';
|
||||
const WORLD_FILES = {
|
||||
buildings: './data/buildings.bin',
|
||||
imagery: './imagery/index.json',
|
||||
};
|
||||
import { ASSETS } from '../config/assets.js';
|
||||
|
||||
const WORLD_FILES = ASSETS.world;
|
||||
|
||||
/** Loads optional world assets without preventing the OSM fallback on network failure.
|
||||
* @param {AbortSignal} signal - Cancels startup when the game unmounts.
|
||||
|
||||
+34
-7
@@ -1,20 +1,47 @@
|
||||
import { COMPANY } from '../config/company.js';
|
||||
|
||||
// Tailwind base layer preserves the established game presentation during the React migration.
|
||||
const { colors } = COMPANY;
|
||||
|
||||
/**
|
||||
* Converts a #rrggbb colour to an rgba() string.
|
||||
* @param {string} hex - Colour such as '#00a693'.
|
||||
* @param {number} alpha - Opacity from 0 to 1.
|
||||
* @returns {string} CSS rgba() value.
|
||||
*/
|
||||
const withAlpha = (hex, alpha) => {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
export const gameTheme = {
|
||||
':root': {
|
||||
// Brand palette from config/company.js; every accent below refers to these.
|
||||
'--color-primary': colors.primary,
|
||||
'--color-primary-light': colors.primaryLight,
|
||||
'--color-primary-lighter': colors.primaryLighter,
|
||||
'--color-primary-dark': colors.primaryDark,
|
||||
'--color-primary-darker': colors.primaryDarker,
|
||||
'--color-on-primary': colors.onPrimary,
|
||||
'--color-surface': colors.surface,
|
||||
'--color-surface-raised': colors.surfaceRaised,
|
||||
'--color-surface-deep': colors.surfaceDeep,
|
||||
'--color-text': colors.text,
|
||||
'--color-text-muted': colors.textMuted,
|
||||
'--game-token-1': 'rgba(236, 242, 238, 0.92)',
|
||||
'--game-token-2': 'rgba(200, 214, 207, 0.65)',
|
||||
'--game-token-3': 'rgba(10, 16, 14, 0.62)',
|
||||
'--game-token-4': 'rgba(10, 16, 14, 0.9)',
|
||||
'--game-token-5': 'rgba(220, 235, 228, 0.2)',
|
||||
'--game-token-6': '#f2c94c',
|
||||
'--game-token-7': '#3ee0c9',
|
||||
'--game-token-7': 'var(--color-primary)',
|
||||
'--game-token-8': '#ff6b5e',
|
||||
'--game-token-9': '0px',
|
||||
'--game-token-10': '#0d1412',
|
||||
'--game-token-10': 'var(--color-surface)',
|
||||
'--game-token-11': '18px',
|
||||
'--game-token-12': '0.5px',
|
||||
'--game-token-13': '#16231f',
|
||||
'--game-token-14': '#070b0a',
|
||||
'--game-token-13': 'var(--color-surface-raised)',
|
||||
'--game-token-14': 'var(--color-surface-deep)',
|
||||
'--game-token-15': 'rgba(255, 255, 255, 0.025)',
|
||||
'--game-token-16': '1px',
|
||||
'--game-token-17': '4px',
|
||||
@@ -59,7 +86,7 @@ export const gameTheme = {
|
||||
'--game-token-56': '96px',
|
||||
'--game-token-57': '380px',
|
||||
'--game-token-58': 'rgba(255, 255, 255, 0.15)',
|
||||
'--game-token-59': '#04201b',
|
||||
'--game-token-59': 'var(--color-on-primary)',
|
||||
'--game-token-60': 'rgba(0, 0, 0, 0.45)',
|
||||
'--game-token-61': '1180px',
|
||||
'--game-token-62': '760px',
|
||||
@@ -68,7 +95,7 @@ export const gameTheme = {
|
||||
'--game-token-65': 'rgba(255, 255, 255, 0.05)',
|
||||
'--game-token-66': '620px',
|
||||
'--game-token-67': '140px',
|
||||
'--game-token-68': 'rgba(62, 224, 201, 0.1)',
|
||||
'--game-token-68': withAlpha(colors.primary, 0.1),
|
||||
'--game-token-69': '30px',
|
||||
'--game-token-70': '480px',
|
||||
'--game-token-71': '90px',
|
||||
@@ -83,7 +110,7 @@ export const gameTheme = {
|
||||
'--game-token-80': '198px',
|
||||
'--game-token-81': '70px',
|
||||
'--game-token-82': '110px',
|
||||
'--game-token-83': 'rgba(62, 224, 201, 0.25)',
|
||||
'--game-token-83': withAlpha(colors.primary, 0.25),
|
||||
'--game-token-84': '420px',
|
||||
'--game-token-85': '460px',
|
||||
'--game-token-86': 'rgba(0, 0, 0, 0.7)',
|
||||
|
||||
+3
-2
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SHOWROOM_CATALOG } from '../constants/showroomCatalog.js';
|
||||
import { strings } from '../i18n/fa.js';
|
||||
|
||||
/** Looks up a display model by its stable identifier.
|
||||
* @param {string} id - Model identifier.
|
||||
@@ -13,7 +14,7 @@ export function showroomModelById(id) {
|
||||
* @returns {string} Persian rial price.
|
||||
*/
|
||||
export function formatRial(price) {
|
||||
return `${price.toLocaleString('fa-IR')} ریال`;
|
||||
return strings.units.rial(price.toLocaleString('fa-IR'));
|
||||
}
|
||||
|
||||
/** Builds the robot's deterministic answer from the shared showroom catalog.
|
||||
@@ -24,8 +25,12 @@ export function showroomAnswer({ modelId, topic }) {
|
||||
const model = showroomModelById(modelId);
|
||||
if (!model) return null;
|
||||
if (topic === 'price')
|
||||
return `قیمت ${model.name}، ${formatRial(model.priceNumeric)} است. این قیمت خود دستگاه به ریال است و از بودجهٔ بازی کم نمیشود.`;
|
||||
return strings.showroom.priceAnswer(model.name, formatRial(model.priceNumeric));
|
||||
if (topic === 'features')
|
||||
return `${model.name}: ${model.description} امکانات: ${model.highlights.join('، ')}.`;
|
||||
return strings.showroom.featuresAnswer(
|
||||
model.name,
|
||||
model.description,
|
||||
model.highlights.join(strings.showroom.featureSeparator),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
+12
-1
@@ -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))],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user