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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 08:13:00 +03:30
co-authored by Claude Opus 5.5
parent e02347142b
commit 8dbaf3d97e
54 changed files with 1184 additions and 486 deletions
+4 -4
View File
@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { strings } from '../../i18n/fa.js';
// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
@@ -119,8 +120,7 @@ export function createArView(video) {
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
async start() {
if (stream) return null;
if (!navigator.mediaDevices?.getUserMedia)
return 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).';
if (!navigator.mediaDevices?.getUserMedia) return strings.real.cameraNeedsHttps;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
@@ -139,8 +139,8 @@ export function createArView(video) {
} catch (error) {
stream = null;
return error?.name === 'NotAllowedError'
? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟';
? strings.real.cameraDenied
: strings.real.cameraBusy;
}
video.srcObject = stream;
video.hidden = false;
+6 -12
View File
@@ -1,4 +1,5 @@
import { toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
@@ -124,11 +125,11 @@ export function startTracking({
stabilizer = createStabilizer(),
}) {
if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
onError(strings.real.noGps);
return () => {};
}
if (!window.isSecureContext) {
onError('موقعیت‌یابی فقط روی HTTPS کار می‌کند؛ بازی را از نشانی https:// باز کنید.');
onError(strings.real.needsHttps);
return () => {};
}
let fixes = 0;
@@ -160,17 +161,10 @@ export function startTracking({
* @returns {*} Operation result, when applicable.
*/
const fail = (error) => {
if (error.code === error.PERMISSION_DENIED)
onError(
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
);
else if (error.code === error.POSITION_UNAVAILABLE)
onError(
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیت‌یابی کار نمی‌کند. (کد ۲)',
);
if (error.code === error.PERMISSION_DENIED) onError(strings.real.denied);
else if (error.code === error.POSITION_UNAVAILABLE) onError(strings.real.unavailable);
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
else if (!fixes)
onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)');
else if (!fixes) onStatus(strings.real.timeout);
};
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
navigator.geolocation.getCurrentPosition(handle, () => {}, {
+11 -10
View File
@@ -1,3 +1,4 @@
import { strings } from '../../i18n/fa.js';
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30;
@@ -12,7 +13,7 @@ export const fa = (n) => n.toLocaleString('fa-IR');
* @param {*} value - value.
* @returns {*} Operation result, when applicable.
*/
export const formatHetoman = (value) => `${fa(value)} هتومان`;
export const formatHetoman = (value) => strings.units.hetoman(fa(value));
/**
* Runs the hetoman to rial operation for this subsystem.
* @param {*} value - value.
@@ -31,63 +32,63 @@ export const formatClock = (minutes) => {
export const catalog = {
juice: {
id: 'juice',
name: 'آب‌میوه',
name: strings.snacks.juice,
icon: '🧃',
cost: 35,
type: 'drink',
},
water: {
id: 'water',
name: 'آب معدنی',
name: strings.snacks.water,
icon: '💧',
cost: 15,
type: 'drink',
},
milk: {
id: 'milk',
name: 'شیر کاکائو',
name: strings.snacks.milk,
icon: '🥛',
cost: 40,
type: 'drink',
},
tea: {
id: 'tea',
name: 'چای سرد',
name: strings.snacks.tea,
icon: '🧋',
cost: 45,
type: 'drink',
},
lemonade: {
id: 'lemonade',
name: 'لیموناد',
name: strings.snacks.lemonade,
icon: '🍋',
cost: 40,
type: 'drink',
},
biscuit: {
id: 'biscuit',
name: 'بیسکویت',
name: strings.snacks.biscuit,
icon: '🍪',
cost: 25,
type: 'snack',
},
chocolate: {
id: 'chocolate',
name: 'شکلات',
name: strings.snacks.chocolate,
icon: '🍫',
cost: 30,
type: 'snack',
},
nuts: {
id: 'nuts',
name: 'آجیل',
name: strings.snacks.nuts,
icon: '🥜',
cost: 65,
type: 'snack',
},
cake: {
id: 'cake',
name: 'کیک',
name: strings.snacks.cake,
icon: '🧁',
cost: 35,
type: 'snack',
+18 -17
View File
@@ -1,4 +1,5 @@
import { CATEGORIES } from '../../constants/locations.js';
import { strings } from '../../i18n/fa.js';
// The bonus for visiting every kind of place counts city locations, not the factory.
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
@@ -10,27 +11,27 @@ export const POINTS = {
};
export const RANKS = [
{
name: 'تازه‌وارد',
name: strings.ranks[0],
xp: 0,
},
{
name: 'کاوشگر',
name: strings.ranks[1],
xp: 600,
},
{
name: 'راه‌بلد',
name: strings.ranks[2],
xp: 1400,
},
{
name: 'شکارچی دستگاه',
name: strings.ranks[3],
xp: 2600,
},
{
name: 'استاد نقشه',
name: strings.ranks[4],
xp: 4200,
},
{
name: 'افسانهٔ مشهد',
name: strings.ranks[5],
xp: 6500,
},
];
@@ -45,21 +46,21 @@ export const RANKS = [
export function awardsFor(previous, record) {
const awards = [
{
label: 'خرید موفق',
label: strings.bonuses.purchase,
points: POINTS.purchase,
},
];
const here = previous.filter((p) => p.machineId === record.machineId);
if (!here.length)
awards.push({
label: 'کشف مکان جدید',
label: strings.bonuses.newPlace,
points: POINTS.discovery,
});
// Paid once per machine: the first time it has sold both a drink and a snack.
const typesHere = new Set(here.map((p) => p.type));
if (typesHere.size === 1 && !typesHere.has(record.type))
awards.push({
label: 'ترکیب نوشیدنی و خوراکی',
label: strings.bonuses.combo,
points: POINTS.combo,
});
const categories = new Set(
@@ -71,7 +72,7 @@ export function awardsFor(previous, record) {
categories.size === CITY_CATEGORIES.length - 1
)
awards.push({
label: 'همهٔ انواع مکان',
label: strings.bonuses.allCategories,
points: POINTS.allCategories,
});
return awards;
@@ -102,7 +103,7 @@ const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).
export const MISSIONS = [
{
id: 'factory',
title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن',
title: strings.missions.factory,
target: 'factory',
xp: 100,
hetoman: 20,
@@ -110,7 +111,7 @@ export const MISSIONS = [
},
{
id: 'first',
title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
title: strings.missions.first,
target: 'mellat-park',
xp: 150,
hetoman: 30,
@@ -118,7 +119,7 @@ export const MISSIONS = [
},
{
id: 'school',
title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
title: strings.missions.school,
target: 'school-rahil',
xp: 200,
hetoman: 40,
@@ -126,7 +127,7 @@ export const MISSIONS = [
},
{
id: 'campus',
title: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
title: strings.missions.campus,
target: 'ferdowsi-university',
xp: 200,
hetoman: 40,
@@ -134,7 +135,7 @@ export const MISSIONS = [
},
{
id: 'metro',
title: 'از دستگاه ایستگاه مترو آزادی خرید کن',
title: strings.missions.metro,
target: 'azadi-metro',
xp: 150,
hetoman: 30,
@@ -142,7 +143,7 @@ export const MISSIONS = [
},
{
id: 'mall',
title: 'با ماشین تا مشهد مال برو و خرید کن',
title: strings.missions.mall,
target: 'mashhad-mall',
xp: 250,
hetoman: 50,
@@ -150,7 +151,7 @@ export const MISSIONS = [
},
{
id: 'explorer',
title: 'از ۵ مکان متفاوت شهر خرید کن',
title: strings.missions.explorer,
target: null,
xp: 400,
hetoman: 80,
+5 -1
View File
@@ -1,4 +1,8 @@
import * as THREE from 'three';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// Detailed vehicles for the family and the factory, plus the shared helpers luxuryCars.js builds
// on. All face local +X and sit on y = 0, like createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
@@ -337,7 +341,7 @@ export function createTruck(color = '#e9eef0') {
const black = mat('#1b1d1f', {
roughness: 0.7,
}),
teal = mat('#0b8f88', {
teal = mat(BRAND.primary, {
metalness: 0.3,
roughness: 0.4,
});
+65 -78
View File
@@ -6,6 +6,11 @@ import { MANAGER_CAR_BRAND, MOTORCYCLE_COUNT, VEHICLE_BLACK } from '../../consta
import { createPerson, createCrew, nameTag } from './staff.js';
import { FACTORY } from '../../constants/locations.js';
import { textTexture } from './textures.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
/**
* Runs the stripes operation for this subsystem.
* @param {*} c - c.
@@ -32,9 +37,9 @@ function stripes(c, w, h, base, line, every) {
*/
const sign = (text, sub = '') =>
textTexture(512, 160, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillStyle = BRAND.primaryDarker;
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(0, h - 12, w, 12);
c.textAlign = 'center';
c.direction = 'rtl';
@@ -42,7 +47,7 @@ const sign = (text, sub = '') =>
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(text, w / 2, sub ? 72 : 100);
if (sub) {
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '36px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, w / 2, 128);
}
@@ -113,25 +118,20 @@ const CODE = {
),
sheet: screen(
[
['فاکتور دستگاه مبلغ', '#1d3b2f'],
['۱۰۴۲ بوستان ملت ۴۸۰', '#333'],
['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'],
['۱۰۴۴ مترو آزادی ۲۹۰', '#333'],
['۱۰۴۵ مشهد مال ۶۱۰', '#333'],
['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'],
['جمع ۱۸۸۰', '#0b8f88'],
// Header, five invoice rows, then the total in the brand colour.
...strings.factory.decor.invoices.map((line, i, all) => [
line,
['#1d3b2f', ...Array(all.length - 2).fill('#333'), COMPANY.colors.primaryDark][i],
]),
],
'#f4f7f2',
),
dashboard: screen(
[
['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'],
['تولید هفته: ۴۶ دستگاه', '#f2c94c'],
['ارسال شده: ۳۸', '#eeffff'],
['', '#fff'],
['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'],
['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'],
['▇▇▇▇▇▇▇ رنگ', '#82aaff'],
...strings.factory.decor.dashboard.map((line, i) => [
line,
[COMPANY.colors.primary, '#f2c94c', '#eeffff', '#fff'][i] ?? '#82aaff',
]),
],
'#101820',
),
@@ -507,7 +507,7 @@ function factoryBuildFlat(build) {
build.steel = build.mat('#59646b', {
metalness: 0.5,
});
build.teal = build.mat('#0b8f88', {
build.teal = build.mat(BRAND.primary, {
metalness: 0.3,
roughness: 0.4,
});
@@ -563,16 +563,16 @@ function factoryBuildAnimated(build) {
*/
function factoryBuildNameSign(build) {
build.nameSign = textTexture(1024, 320, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillStyle = BRAND.primaryDarker;
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(0, h - 18, w, 18);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.name, w / 2, 120);
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '44px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.tagline, w / 2, 210);
c.font = 'bold 34px sans-serif';
@@ -716,12 +716,8 @@ function factoryBuildGeometry3(build) {
for (const du of [-2.2, 2.2])
build.box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, build.H, build.steel, false);
};
build.zone('خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه', -93);
build.zone('برش لیزر', 'برش ورق و قطعات', -71);
build.zone('رنگ‌کاری', 'کابین رنگ پودری', -52);
build.zone('یخچال‌سازی', 'کمپرسور و عایق‌کاری', -31);
build.zone('مونتاژ', 'خط مونتاژ نهایی', -13);
build.zone('بسته‌بندی', 'ارسال به شهر', -3);
// Work areas along the hall, from the press brakes to packing (u of each sign).
[-93, -71, -52, -31, -13, -3].forEach((u, i) => build.zone(...strings.factory.zones[i], u));
// Bending: two press brakes with a moving ram, sheet stacks behind.
}
@@ -942,7 +938,7 @@ function factoryBuildGeometry6(build) {
*/
function factoryBuildFrames(build) {
build.frames = [];
build.frameMaterial = build.mat('#0b8f88', {
build.frameMaterial = build.mat(BRAND.primary, {
wireframe: true,
});
for (let i = 0; i < 4; i++) {
@@ -990,7 +986,7 @@ function factoryBuildGeometry7(build) {
function factoryBuildGeometry8(build) {
for (let i = 0; i < 4; i++) {
const finished = createVendingMachine({
name: 'آمادهٔ نصب',
name: strings.factory.readyToInstall,
products: ['water', 'juice', 'biscuit', 'chocolate'],
});
finished.beacon.visible = finished.ring.visible = false;
@@ -1014,7 +1010,7 @@ function factoryBuildCarton(build) {
c.fillRect(0, 0, w, h);
c.fillStyle = 'rgba(255,255,255,.18)';
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
c.fillStyle = '#0b8f88';
c.fillStyle = BRAND.primary;
c.fillRect(0, h * 0.42, w, 34);
c.fillStyle = '#1d2a2a';
c.textAlign = 'center';
@@ -1294,9 +1290,9 @@ function factoryBuildGeometry9(build) {
])
build.box(u0, u1, -36, -34, build.OH - 0.08, build.OH - 0.02, build.lamp, false).castShadow =
false;
build.panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, build.F + 0.05);
build.panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, build.F + 0.05);
build.panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, build.F + 0.05);
build.panel(sign(...strings.factory.signs.engineering), 3.6, 1.1, 15.5, 3.25, build.F + 0.05);
build.panel(sign(...strings.factory.signs.accounting), 3.6, 1.1, 32.5, 3.25, build.F + 0.05);
build.panel(sign(...strings.factory.signs.management), 3.6, 1.1, 45, 3.25, build.F + 0.05);
build.wood = build.mat('#c8a477', {
roughness: 0.6,
});
@@ -1322,7 +1318,7 @@ function factoryBuildFabric(build) {
function factoryBuildDevelopers(build) {
build.developers = [
{
name: 'حامد',
name: strings.factory.staff.hamed.name,
role: 'React',
u: 11,
v: -31.6,
@@ -1333,13 +1329,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.react,
speaker: 'hamed',
lines: [
'سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاه‌ها رو با React می‌سازم.',
'هر چی روی صفحهٔ دستگاه می‌بینین، از همین مانیتور اومده!',
],
lines: strings.factory.staff.hamed.lines,
},
{
name: 'رایان',
name: strings.factory.staff.ryan.name,
role: 'Go',
u: 16,
v: -31.6,
@@ -1351,10 +1344,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.go,
speaker: 'ryan',
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاه‌ها رو با Go می‌نویسم.'],
lines: strings.factory.staff.ryan.lines,
},
{
name: 'علیرضا',
name: strings.factory.staff.alireza.name,
role: 'C++',
u: 21,
v: -31.6,
@@ -1366,12 +1359,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.cpp,
speaker: 'alireza',
lines: [
'سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم می‌کنم.',
],
lines: strings.factory.staff.alireza.lines,
},
{
name: 'رضا',
name: strings.factory.staff.reza.name,
role: 'C++',
u: 13.5,
v: -38.6,
@@ -1384,13 +1375,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.cpp,
speaker: 'reza',
lines: [
'سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.',
'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!',
],
lines: strings.factory.staff.reza.lines,
},
{
name: 'محمد',
name: strings.factory.staff.mohammad.name,
role: '',
u: 18.5,
v: -38.6,
@@ -1402,7 +1390,7 @@ function factoryBuildDevelopers(build) {
},
code: CODE.terminal,
speaker: 'mohammad',
lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!'],
lines: strings.factory.staff.mohammad.lines,
},
];
}
@@ -1444,12 +1432,10 @@ function factoryBuildBoard(build) {
c.direction = 'rtl';
c.fillStyle = '#1d4f91';
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
c.fillText(strings.factory.decor.sprintTitle, w / 2, 56);
c.font = '28px Vazirmatn, Tahoma, sans-serif';
c.fillStyle = '#333';
['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) =>
c.fillText(t, w / 2, 110 + i * 44),
);
strings.factory.decor.sprintItems.forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
});
build.panel(build.board, 4, 2, 16, 1.9, build.B + 0.05);
build.officeMachine = {
@@ -1538,10 +1524,10 @@ function factoryBuildGeometry11(build) {
);
build.talks.push({
speaker: 'accountant',
label: 'واحد حسابداری',
label: strings.factory.accounting.label,
radius: 4,
pos: () => build.toWorld(33, -29),
lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!'],
lines: strings.factory.accounting.lines,
});
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
@@ -1576,7 +1562,15 @@ function factoryBuildGeometry12(build) {
build.leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), build.mat('#3e7a3e'));
build.leaves.position.set(49.25, 1.0, -26.75);
build.group.add(build.leaves);
build.nameplate = build.panel(sign('محمدی', 'مدیر شرکت'), 0.6, 0.19, 45, 0.87, -39.2, 1);
build.nameplate = build.panel(
sign(...strings.factory.signs.nameplate),
0.6,
0.19,
45,
0.87,
-39.2,
1,
);
build.nameplate.rotation.x = -0.3;
// ---------- Staff car park ----------
@@ -1618,7 +1612,7 @@ function factoryBuildManager(build) {
glasses: true,
shoes: '#111',
});
build.managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
build.managerTag = nameTag(strings.factory.manager.name, strings.factory.manager.role);
build.managerTag.position.y = 2.15;
build.manager.root.add(build.managerTag);
build.group.add(build.manager.root);
@@ -1652,15 +1646,11 @@ function factoryBuildArrival(build) {
};
build.talks.push({
speaker: 'mohammadi',
label: 'آقای محمدی',
label: strings.factory.manager.name,
radius: 3.4,
pos: () => build.toWorld(build.manager.root.position.x, build.manager.root.position.z),
ready: () => build.arrival.stage === 'seated',
lines: [
'سلام، خوش اومدین! من محمدی‌ام، مدیر روبات مارکت.',
'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.',
'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!',
],
lines: strings.factory.managerLines,
});
// Position and direction a distance `s` along a polyline of [u, v] points.
@@ -1751,7 +1741,7 @@ function factoryBuildDelivery(build) {
radius: 9,
pos: () => build.toWorld(4, 31),
once: true,
lines: ['بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!'],
lines: [strings.factory.truck],
});
// The family comments on each work area the first time they walk into it.
}
@@ -1761,23 +1751,20 @@ function factoryBuildDelivery(build) {
* @returns {*} Operation result, when applicable.
*/
function factoryBuildGeometry14(build) {
for (const [u, text, speaker] of [
[-93, 'اینجا خم‌کاریه؛ ورق‌های فلزی بدنهٔ دستگاه رو با پرس خم می‌کنن.', 'father'],
[-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ می‌بُره.', 'child'],
[-52, 'این کابین رنگه؛ بدنه‌ها اینجا رنگ فیروزه‌ای روبات مارکت می‌گیرن.', 'father'],
[-31, 'یخچال‌سازی! برای همینه که نوشیدنی‌های دستگاه همیشه خنکن.', 'child'],
[
-13,
'خط مونتاژ: روبات‌ها و کارگرها قطعه‌ها رو کنار هم می‌ذارن و دستگاه کامل می‌شه.',
'father',
],
])
// u of each production area and who comments on it; the lines come from the strings file.
for (const [i, [u, speaker]] of [
[-93, 'father'],
[-71, 'child'],
[-52, 'father'],
[-31, 'child'],
[-13, 'father'],
].entries())
build.talks.push({
speaker,
radius: 8,
pos: () => build.toWorld(u, 25),
once: true,
lines: [text],
lines: [strings.factory.areaTalks[i]],
});
// ---------- Key positions ----------
+6 -2
View File
@@ -1,5 +1,9 @@
import * as THREE from 'three';
import { textTexture } from './textures.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
@@ -264,7 +268,7 @@ export function nameTag(name, role = '') {
c.beginPath();
c.roundRect(6, 6, w - 12, h - 12, 26);
c.fill();
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(6, h - 18, w - 12, 8);
c.textAlign = 'center';
c.direction = 'rtl';
@@ -272,7 +276,7 @@ export function nameTag(name, role = '') {
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(name, w / 2, role ? 72 : 100);
if (role) {
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '40px Vazirmatn, Tahoma, sans-serif';
c.direction = 'ltr';
c.fillText(role, w / 2, 126);
+11 -7
View File
@@ -1,6 +1,10 @@
import * as THREE from 'three';
import { catalog } from '../economy/gameState.js';
import { textTexture } from './textures.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
const PRODUCT_COLORS = {
juice: '#e5ae47',
water: '#9fd3e6',
@@ -24,14 +28,14 @@ const label = (text, sub) =>
textTexture(512, 160, (c, width, height) => {
c.fillStyle = '#0f2a2a';
c.fillRect(0, 0, width, height);
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(0, height - 10, width, 10);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#e9fff8';
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
c.fillText(text, width / 2, 72);
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '34px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, width / 2, 126);
});
@@ -75,10 +79,10 @@ export function createVendingMachine(def) {
parent.add(mesh);
return mesh;
};
const shell = mat('#0b8f88', {
const shell = mat(BRAND.primary, {
metalness: 0.45,
roughness: 0.3,
emissive: '#0b8f88',
emissive: BRAND.primary,
emissiveIntensity: 0.15,
}),
white = mat('#eef5f3', {
@@ -190,7 +194,7 @@ export function createVendingMachine(def) {
const sign = new THREE.Mesh(
new THREE.PlaneGeometry(1.86, 0.3),
new THREE.MeshBasicMaterial({
map: label('روبات مارکت', def.name),
map: label(COMPANY.shortName, def.name),
}),
);
sign.position.set(0, 2.62, 0.575);
@@ -213,7 +217,7 @@ export function createVendingMachine(def) {
// Objective beacon: a tall column of light visible across the city, like a mission marker.
const beaconMaterial = new THREE.MeshBasicMaterial({
color: '#3ee0c9',
color: BRAND.primary,
transparent: true,
opacity: 0.22,
depthWrite: false,
@@ -230,7 +234,7 @@ export function createVendingMachine(def) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(1.6, 1.9, 40),
new THREE.MeshBasicMaterial({
color: '#3ee0c9',
color: BRAND.primary,
transparent: true,
opacity: 0.6,
depthWrite: false,
+3 -2
View File
@@ -3,6 +3,7 @@ import { CATEGORIES, lockedPlaces } from '../../constants/locations.js';
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
import { drawFullMap, drawIran } from '../world/mapRender.js';
import { formatDistance } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the size canvas operation for this subsystem.
* @param {*} context - context.
@@ -104,7 +105,7 @@ export function selectMachine(context, id, center = false) {
text: `📍 ${def.address}`,
});
context.ui.patch('detail-meta', {
text: `🕒 ${context.hoursText(def)} · ${isOpen(def, context.state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`,
text: strings.map.details(context.hoursText(def), isOpen(def, context.state.minutes), def.note),
});
context.ui.patch('detail-products', {
text: def.products
@@ -112,7 +113,7 @@ export function selectMachine(context, id, center = false) {
.join(' · '),
});
context.ui.patch('route-button', {
text: context.waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا',
text: context.waypoint === id ? strings.map.routeActive : strings.map.route,
});
context.renderMachineList();
context.renderMap();
+4 -6
View File
@@ -4,6 +4,7 @@ import { resolveCircle } from '../world/collision.js';
import { stepCar } from '../systems/vehicle.js';
import { EXIT_MAX_SPEED } from '../../constants/vehicles.js';
import { collideDriving, nearestRide, takeRide, walkAroundVehicles } from './garage.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the look operation for this subsystem.
* @param {*} context - context.
@@ -39,7 +40,7 @@ export // ---------- Car ----------
function toggleCar(context) {
if (context.payment.phase !== 'idle' || context.state.busy) return;
if (context.real.on) {
context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.');
context.say('father', strings.real.noVirtualCar);
return;
}
if (context.mode === 'walk') getIn(context);
@@ -58,10 +59,7 @@ function getIn(context) {
context.mode = 'drive';
context.cam.distance = Math.max(context.cam.distance, kind === 'bike' ? 6 : 8);
context.tone(320, 0.2, 0.05, 'square');
const line =
kind === 'bike'
? 'کلاه ایمنی سرت باشه! با موتور می‌شه از لای ماشین‌ها رد شد.'
: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.';
const line = kind === 'bike' ? strings.dialogue.bikeOn : strings.dialogue.carOn;
context.say('father', line);
}
@@ -72,7 +70,7 @@ function getIn(context) {
function getOut(context) {
const { state, spec } = context.vehicle;
if (Math.abs(state.speed) > EXIT_MAX_SPEED) {
context.say('father', 'اول نگه دار، بعد پیاده می‌شویم.');
context.say('father', strings.dialogue.stopFirst);
return;
}
const left = { x: -Math.sin(state.heading), z: -Math.cos(state.heading) };
+18 -18
View File
@@ -8,6 +8,11 @@ import { kmh } from '../systems/vehicle.js';
import { nearestRide } from './garage.js';
import { drawMinimap } from '../world/mapRender.js';
import { bearing, formatDistance } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
/**
* Runs the rebuild route mesh operation for this subsystem.
* @param {*} context - context.
@@ -125,12 +130,7 @@ export function drawCompass(context, headingDeg, markers) {
context.compass.fillRect(0, 10, w, 24);
context.compass.textAlign = 'center';
context.compass.textBaseline = 'middle';
const labels = {
0: 'شمال',
90: 'شرق',
180: 'جنوب',
270: 'غرب',
};
const labels = strings.hud.compass;
for (let d = -100; d <= 100; d += 5) {
const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
@@ -204,9 +204,9 @@ export function refreshHUD(context) {
* @returns {string} Persian label.
*/
function modeLabel(context) {
if (context.mode !== 'drive') return 'پیاده';
if (context.vehicle.kind === 'bike') return 'روی موتور · F برای پیاده شدن';
return `در ${context.vehicle.label} · F برای پیاده شدن`;
if (context.mode !== 'drive') return strings.common.walking;
if (context.vehicle.kind === 'bike') return strings.travel.onBike;
return strings.travel.inVehicle(context.vehicle.label);
}
/**
* Updates prompt from the active simulation.
@@ -217,7 +217,7 @@ export function updatePrompt(context) {
const sample = context.nearShowroom();
if (sample && !context.state.busy && context.payment.phase === 'idle') {
context.ui.patch('prompt', {
text: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`,
text: strings.prompts.showroom(sample.model.name),
hidden: false,
});
return;
@@ -228,9 +228,9 @@ export function updatePrompt(context) {
if (!context.state.busy && context.payment.phase === 'idle') {
if (station)
text = isOpen(station.def, context.state.minutes)
? `E · خرید از روبات مارکت · ${station.def.name}`
: `دستگاه بسته است · ${context.hoursText(station.def)}`;
else if (ride) text = `F · سوار ${ride.label} شو`;
? strings.prompts.buy(station.def.name)
: strings.prompts.closed(context.hoursText(station.def));
else if (ride) text = strings.prompts.getIn(ride.label);
else if (
context.mode === 'drive' &&
Math.abs(context.car.speed) < 1 &&
@@ -238,7 +238,7 @@ export function updatePrompt(context) {
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
)
)
text = 'F · پیاده شو؛ دستگاه نزدیک است';
text = strings.prompts.getOutNearMachine;
}
context.ui.patch('prompt', {
text,
@@ -334,7 +334,7 @@ export function updateHUD(context, dt) {
});
for (const s of context.stations) {
const isObjective = s === objective;
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : BRAND.primary);
s.model.beacon.material.opacity =
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
}
@@ -346,10 +346,10 @@ export function updateHUD(context, dt) {
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
context.ui.patch('street', {
text: inFactory
? `کارخانهٔ ${FACTORY.name}`
? strings.hud.factory(FACTORY.name)
: street
? `مشهد · ${data.names[street.name]}`
: 'حومهٔ مشهد',
? strings.hud.street(data.names[street.name])
: strings.hud.outskirts,
});
}
context.updatePrompt();
+38 -28
View File
@@ -24,6 +24,7 @@ import {
activeMission,
MISSIONS,
} from '../economy/scoring.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the open showroom operation for this subsystem.
* @param {*} context - context.
@@ -40,7 +41,7 @@ export function openShowroom(context) {
modelId: sample.model.id,
});
context.ui.patch('showroom-response', {
text: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.',
text: strings.showroom.allModels,
});
context.keys.clear();
document.exitPointerLock?.();
@@ -55,13 +56,13 @@ export function interact(context) {
const station = context.nearStation();
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
context.say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.');
context.say('father', strings.dialogue.tooFastToBuy);
return;
}
if (!isOpen(station.def, context.state.minutes)) {
context.say(
context.guideOut() ? 'robot' : 'father',
`این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(station.def)}.`,
strings.dialogue.machineClosed(context.hoursText(station.def)),
'serious',
);
return;
@@ -81,16 +82,20 @@ export function interact(context) {
export function renderShop(context) {
const def = context.shopStation.def;
context.ui.patch('shop-category', {
text: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${context.hoursText(def)}`,
text: strings.shop.category(
CATEGORIES[def.category].icon,
CATEGORIES[def.category].label,
context.hoursText(def),
),
});
context.ui.patch('shop-name', {
text: `روبات مارکت · ${def.name}`,
text: strings.shop.title(def.name),
});
context.ui.patch('shop-address', {
text: def.address,
});
context.ui.patch('shop-note', {
text: `${def.note} · موجودی شما: ${formatHetoman(context.state.balance)}`,
text: strings.shop.balance(def.note, formatHetoman(context.state.balance)),
});
context.ui.patch('products', {
items: def.products.map((id) => ({
@@ -120,7 +125,7 @@ export function startPayment(context, productId) {
text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(context.shopStation.def, productId))}`,
});
context.ui.patch('payment-step', {
text: 'در حال نزدیک شدن به کارت‌خوان…',
text: strings.payment.approaching,
});
context.ui.patch('swipe', {
disabled: true,
@@ -128,7 +133,7 @@ export function startPayment(context, productId) {
context.ui.patch('cancel-payment', {
hidden: false,
});
context.say('child', 'بابا، کارت رو بده من بکشم!');
context.say('child', strings.dialogue.childSwipes);
}
/**
* Runs the swipe operation for this subsystem.
@@ -146,7 +151,7 @@ export function swipe(context) {
hidden: true,
});
context.ui.patch('payment-step', {
text: 'پرداخت موفق ✓ محصول در حال تحویل است',
text: strings.payment.success,
});
context.tone(1100, 0.3);
context.refreshHUD();
@@ -178,7 +183,7 @@ export function updatePayment(context, dt) {
disabled: false,
});
context.ui.patch('payment-step', {
text: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C',
text: strings.payment.swipeHint,
});
}
if (context.payment.phase === 'approved') {
@@ -243,7 +248,7 @@ export function reward(context, record, product) {
rankBefore = rankFor(context.state.xp).index;
if (context.real.on)
awards.push({
label: 'خرید در دنیای واقعی',
label: strings.payment.realPurchase,
points: 200,
});
const total = awards.reduce((s, a) => s + a.points, 0);
@@ -260,13 +265,13 @@ export function reward(context, record, product) {
}),
3200,
);
context.say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
context.say('child', strings.dialogue.childGotIt(product.icon, product.name));
const done = completeMissions(context.state);
for (const m of done) {
context.showBanner(
'مأموریت انجام شد',
strings.rewards.missionDone,
m.title,
`+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`,
strings.rewards.missionReward(fa(m.xp), formatHetoman(m.hetoman)),
);
context.tone(660, 0.5);
}
@@ -274,9 +279,9 @@ export function reward(context, record, product) {
context.setTimeout(
() =>
context.showBanner(
'ارتقای درجه',
strings.rewards.rankUp,
rankFor(context.state.xp).name,
'دستگاه‌های بیشتری پیدا کن',
strings.rewards.findMore,
),
done.length ? 3400 : 0,
);
@@ -286,7 +291,11 @@ export function reward(context, record, product) {
context.route = null;
context.setTimeout(
() =>
context.say(context.guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'),
context.say(
context.guideOut() ? 'robot' : 'father',
strings.dialogue.nextMission(next.title),
'wink',
),
1500,
);
}
@@ -328,25 +337,26 @@ export function checkEnd(context) {
context.ended = true;
document.exitPointerLock?.();
context.ui.patch('end-tag', {
text: allDone ? 'عملیات موفق' : 'پایان بودجه',
text: allDone ? strings.end.success : strings.end.outOfBudget,
});
context.ui.patch('end-title', {
text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد',
text: allDone ? strings.end.successHeading : strings.end.budgetHeading,
});
context.ui.patch('end-text', {
text: allDone
? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.'
: 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:',
text: allDone ? strings.end.successText : strings.end.budgetText,
});
const places = new Set(context.state.purchases.map((p) => p.machineId)).size;
context.ui.patch('end-stats', {
items: [
['امتیاز', `${fa(context.state.xp)} XP`],
['درجه', rankFor(context.state.xp).name],
['خریدها', fa(context.state.purchases.length)],
['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`],
['مأموریت‌ها', `${fa(context.state.missionsDone.length)} از ${fa(MISSIONS.length)}`],
['باقی‌مانده', formatHetoman(context.state.balance)],
[strings.end.score, `${fa(context.state.xp)} XP`],
[strings.end.rank, rankFor(context.state.xp).name],
[strings.end.purchases, fa(context.state.purchases.length)],
[strings.end.places, strings.end.outOf(fa(places), fa(machines.length))],
[
strings.end.missions,
strings.end.outOf(fa(context.state.missionsDone.length), fa(MISSIONS.length)),
],
[strings.end.remaining, formatHetoman(context.state.balance)],
],
});
context.ui.patch('continue', {
+11 -14
View File
@@ -9,6 +9,7 @@ import data from '../../data/mellat.json';
import { resolveCircle } from '../world/collision.js';
import { fa } from '../economy/gameState.js';
import { formatDistance } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the set real mode operation for this subsystem.
* @param {*} context - context.
@@ -59,10 +60,7 @@ export function setRealMode(context, on) {
stabilizer: (context.real.stabilizer = createStabilizer()),
});
context.real.stabilizer.stable = context.stableMode;
context.say(
'father',
'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.',
);
context.say('father', strings.real.on);
context.setArMode(true);
}
@@ -80,21 +78,17 @@ export async function setArMode(context, on) {
if (on && !context.arMode) {
const problem = await context.arView.start();
if (problem) {
context.say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`);
context.say('father', strings.real.cameraFailed(problem));
return;
}
if (!context.real.on) {
context.arView.stop();
return;
}
context.say(
'robot',
'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
'excited',
);
context.say('robot', strings.real.cameraOn, 'excited');
context.setTimeout(() => {
if (context.real.on && !context.arView.hasOrientation)
context.say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
context.say('father', strings.real.noOrientation);
}, 3000);
} else if (!on && context.arMode) context.arView.stop();
else return;
@@ -133,16 +127,19 @@ function askRealMode(context, thenStart) {
export function updateReal(context, dt) {
let uiStatus;
if (context.real.error) uiStatus = `⚠ ${context.real.error}`;
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? 'در حال یافتن موقعیت…'}`;
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? strings.real.locating}`;
else if (!insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
const nearest = Math.min(
...context
.activeStations()
.map((s) => Math.hypot(s.x - context.real.fix.x, s.z - context.real.fix.z)),
);
uiStatus = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`;
uiStatus = strings.real.outOfArea(formatDistance(nearest));
} else
uiStatus = `📍 GPS ±${fa(Math.round(context.real.fix.accuracy))} متر${context.real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
uiStatus = strings.real.accuracy(
fa(Math.round(context.real.fix.accuracy)),
context.real.fix.speed > MAX_WALKING_SPEED,
);
context.ui.patch('gps-status', {
text: uiStatus,
});
+12 -13
View File
@@ -3,6 +3,11 @@ import { createState } from '../economy/gameState.js';
import { createPayment } from '../economy/payment.js';
import { createVendingMachine } from '../models/vending.js';
import { resetGarage } from './garage.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
/**
* Runs the place station operation for this subsystem.
* @param {*} context - context.
@@ -53,7 +58,7 @@ export function updateReachCircles(context) {
s.reach.visible = context.real.on;
if (!context.real.on) continue;
s.reach.scale.setScalar(radius);
s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9');
s.reach.material.color.set(s === objective ? '#f2c94c' : BRAND.primary);
}
}
// GPS test points only take part while real-world mode is on.
@@ -130,18 +135,12 @@ export function startMission(context) {
context.ui.patch('hud', {
hidden: false,
});
context.say(
'robot',
'سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدل‌های کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.',
'wink',
);
context.say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟');
context.say(
'father',
'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
);
context.say('child', 'اون وانت سفیده کیه داره میاد تو؟');
context.say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
const { dialogue } = strings;
context.say('robot', dialogue.showroomIntro, 'wink');
context.say('child', dialogue.childAsksFactory);
context.say('father', dialogue.fatherAnswersFactory);
context.say('child', dialogue.childAsksArrival);
context.say('father', dialogue.fatherAnswersArrival);
if (!context.isTouch && !context.real.on)
context.canvas.requestPointerLock?.()?.catch?.(() => {});
}
+35 -38
View File
@@ -24,6 +24,11 @@ import { createTraffic } from '../systems/traffic.js';
import { createGarage } from './garage.js';
import { createMapAssets } from '../world/mapRender.js';
import { formatDistance, toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// City map zoom limits in metres per screen pixel (closest and furthest).
const MAP_ZOOM_MIN = 0.35;
@@ -73,7 +78,7 @@ export async function initializeRenderer(context) {
});
} catch {
context.ui.patch('loading', {
text: 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.',
text: strings.app.noWebgl,
});
throw new Error('WebGL unavailable');
}
@@ -281,7 +286,7 @@ export async function initializeStationById(context) {
s.reach = new THREE.Mesh(
new THREE.RingGeometry(0.96, 1, 96),
new THREE.MeshBasicMaterial({
color: '#3ee0c9',
color: BRAND.primary,
transparent: true,
opacity: 0.55,
depthWrite: false,
@@ -326,7 +331,7 @@ export async function initializeClosestStation(context) {
});
if (context.imagery.available)
context.ui.patch('attribution', {
text: `نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${context.imageryIndex.attribution}`,
text: strings.app.osmImageryCredit(context.imageryIndex.attribution),
});
context.traffic = createTraffic(context.scene, data, 28);
context.mapAssets = createMapAssets(data, [context.factory.label]);
@@ -357,7 +362,7 @@ export async function buildParkGate(context) {
c.fillStyle = '#f4efd9';
c.textAlign = 'center';
c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
c.fillText('بوستان ملت', 256, 86);
c.fillText(strings.places.parkSign, 256, 86);
const texture = new THREE.CanvasTexture(sign);
texture.colorSpace = THREE.SRGBColorSpace;
const gate = context.parkGate,
@@ -598,11 +603,7 @@ export async function initializeAxis(context) {
context.axis = (pos, neg) =>
Number(pos.some((k) => context.keys.has(k))) - Number(neg.some((k) => context.keys.has(k)));
// ---------- Subtitles ----------
context.SPEAKERS = {
robot: 'روبات',
father: 'پدر',
child: 'بچه',
};
context.SPEAKERS = { ...strings.speakers };
// Factory staff speak under their own names.
for (const t of context.factory.talks) if (t.label) context.SPEAKERS[t.speaker] = t.label;
context.lines = [];
@@ -637,7 +638,7 @@ export async function registerSoundControl(context) {
};
}
context.ui.patch('sound', {
text: context.soundOn ? 'صدا: روشن' : 'صدا: خاموش',
text: context.soundOn ? strings.toolbar.soundOn : strings.toolbar.soundOff,
});
context.ui.patch('sound', {
pressed: String(context.soundOn),
@@ -645,7 +646,7 @@ export async function registerSoundControl(context) {
context.tone(700);
} catch {
context.ui.patch('sound', {
text: 'صدا در دسترس نیست',
text: strings.toolbar.soundUnavailable,
});
}
});
@@ -668,8 +669,8 @@ export async function initializeReachRadius(context) {
: null;
context.hoursText = (m) =>
m.hours[1] - m.hours[0] >= 24 * 60
? 'شبانه‌روزی'
: `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
? strings.common.allDay
: strings.common.hours(formatClock(m.hours[0]), formatClock(m.hours[1]));
context.shopStation = null;
context.nearShowroom = () =>
!context.real.on && context.mode === 'walk' ? context.showroom.nearest(context.player) : null;
@@ -679,7 +680,7 @@ export async function initializeReachRadius(context) {
modelId: id,
});
context.ui.patch('showroom-response', {
text: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟',
text: strings.showroom.whatToKnow,
});
});
}
@@ -699,7 +700,7 @@ export async function registerPurchaseControls(context) {
const answer = showroomAnswer(question);
if (!answer) return;
context.ui.patch('showroom-response', {
text: `روبات: ${answer}`,
text: strings.showroom.robotSays(answer),
});
context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink');
});
@@ -717,7 +718,7 @@ export async function registerPurchaseControls(context) {
context.ui.on('cancel-payment', context.cancel);
context.ui.on('shop-close', () => context.ui.close('shop'));
context.ui.patch('quality', {
text: context.quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل',
text: context.quality === 'low' ? strings.toolbar.graphicsLow : strings.toolbar.graphicsHigh,
});
}
/**
@@ -825,7 +826,10 @@ export async function registerRouteSelection(context) {
const s = context.stationById(context.selected);
context.say(
'father',
`مسیر تا ${s.def.name} رو گذاشتم؛ ${context.route ? formatDistance(context.route.length) : ''}. دنبال خط طلایی بریم.`,
strings.dialogue.routeSet(
s.def.name,
context.route ? formatDistance(context.route.length) : '',
),
);
context.selectMachine(context.selected);
});
@@ -978,63 +982,63 @@ export async function registerMapSelectionAndZoom(context) {
export async function initializeCities(context) {
context.CITIES = [
{
name: 'مشهد',
name: strings.cities.mashhad,
lat: 36.3,
lon: 59.6,
active: true,
},
{
name: 'تهران',
name: strings.cities.tehran,
lat: 35.69,
lon: 51.39,
},
{
name: 'اصفهان',
name: strings.cities.isfahan,
lat: 32.65,
lon: 51.67,
},
{
name: 'شیراز',
name: strings.cities.shiraz,
lat: 29.59,
lon: 52.58,
},
{
name: 'تبریز',
name: strings.cities.tabriz,
lat: 38.08,
lon: 46.29,
},
{
name: 'اهواز',
name: strings.cities.ahvaz,
lat: 31.32,
lon: 48.67,
},
{
name: 'کرمان',
name: strings.cities.kerman,
lat: 30.28,
lon: 57.08,
},
{
name: 'رشت',
name: strings.cities.rasht,
lat: 37.28,
lon: 49.58,
},
{
name: 'یزد',
name: strings.cities.yazd,
lat: 31.9,
lon: 54.37,
},
{
name: 'زاهدان',
name: strings.cities.zahedan,
lat: 29.5,
lon: 60.86,
},
{
name: 'کرمانشاه',
name: strings.cities.kermanshah,
lat: 34.31,
lon: 47.07,
},
{
name: 'بندرعباس',
name: strings.cities.bandarAbbas,
lat: 27.18,
lon: 56.27,
},
@@ -1046,14 +1050,7 @@ export async function initializeCities(context) {
* @returns {*} Operation result, when applicable.
*/
export async function initializeBriefingAndTracking(context) {
context.BRIEF = [
`نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست',
'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.',
'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.',
].join('\n');
context.BRIEF = strings.briefing.lines.join('\n');
context.briefChars = 0;
context.ui.on('start-mission', context.startMission);
@@ -1099,7 +1096,7 @@ export async function initializeArMode(context) {
context.real.stabilizer.stable = context.stableMode;
context.ui.classState('stable-toggle', 'on', context.stableMode);
context.ui.patch('stable-toggle', {
text: context.stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش',
text: context.stableMode ? strings.toolbar.stableOn : strings.toolbar.stableOff,
});
});
+12 -27
View File
@@ -1,6 +1,7 @@
import * as THREE from 'three';
import { FACTORY } from '../../constants/locations.js';
import { isOpen } from '../economy/gameState.js';
import { strings } from '../../i18n/fa.js';
/**
* Updates talks from the active simulation.
* @param {*} context - context.
@@ -33,36 +34,20 @@ export function greet(context, station) {
first = !context.guideState.greeted.has(def.id);
context.guideState.greeted.add(def.id);
if (def.id === 'factory' && first) {
context.say(
'robot',
'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!',
'excited',
);
context.say(
'robot',
`به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`,
'happy',
);
context.say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
context.say(
'robot',
'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!',
'love',
);
context.say('robot', strings.dialogue.robotHello, 'excited');
context.say('robot', strings.dialogue.robotWelcome(FACTORY.name), 'happy');
context.say('robot', strings.dialogue.robotFirstBuy, 'wink');
context.say('robot', strings.dialogue.robotGoToPark, 'love');
return;
}
if (!isOpen(def, context.state.minutes)) {
context.say(
'robot',
`سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(def)}.`,
'serious',
);
context.say('robot', strings.dialogue.robotClosed(context.hoursText(def)), 'serious');
return;
}
if (first) {
context.say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
context.say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
} else context.say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
context.say('robot', strings.dialogue.robotWelcomeBack(def.name), 'excited');
context.say('robot', strings.dialogue.robotNote(def.note), 'happy');
} else context.say('robot', strings.dialogue.robotAgain, 'wink');
}
/**
* Updates guide from the active simulation.
@@ -95,7 +80,7 @@ export function updateGuide(context, dt) {
context.tone(990, 0.15);
context.tone(1320, 0.2);
}
if (wasOut && near !== s) context.say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
if (wasOut && near !== s) context.say('robot', strings.dialogue.robotBye, 'happy');
if (!context.guideState.t) {
context.guideState.station = null;
context.guide.group.visible = false;
@@ -149,7 +134,7 @@ export function updateHints(context) {
context.hints.closed = true;
context.say(
'robot',
`${target.def.name} الان بسته است (${context.hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`,
strings.dialogue.targetClosed(target.def.name, context.hoursText(target.def)),
);
}
if (
@@ -159,6 +144,6 @@ export function updateHints(context) {
!context.hints.car
) {
context.hints.car = true;
context.say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
context.say('father', strings.dialogue.longWay);
}
}
+11 -6
View File
@@ -1,3 +1,8 @@
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
const SCALE = 0.75; // tile pixels per metre
@@ -271,7 +276,7 @@ function machineIcon(c, x, y, { objective, visited, open }) {
c.fillRect(-7, -7, 14, 14);
c.strokeRect(-7, -7, 14, 14);
} else {
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
c.fillStyle = open ? (visited ? '#7fb8ad' : BRAND.primary) : '#7b817f';
c.strokeStyle = '#08201c';
c.lineWidth = 2;
c.beginPath();
@@ -367,7 +372,7 @@ export function drawMinimap(ctx, assets, view) {
ctx.font = 'bold 13px Vazirmatn, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.fillText(strings.hud.minimapNorth, (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.restore();
ctx.strokeStyle = 'rgba(230,240,235,.55)';
ctx.lineWidth = 2;
@@ -461,11 +466,11 @@ export function drawFullMap(ctx, assets, view, state) {
metro: '#ff8a80',
mall: '#f5b8d9',
industrial: '#a9b2ad',
factory: '#3ee0c9',
factory: BRAND.primary,
}[l.kind];
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
l.kind === 'metro' ? `Ⓜ ${l.name.replace(strings.hud.metroPrefix, '')}` : l.name,
sx,
sy + 14,
);
@@ -546,9 +551,9 @@ export function drawIran(
for (const province of iran.provinces) {
const active = province.id === highlight;
ctx.fillStyle = active
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
? `rgba(0,166,147,${0.35 + Math.sin(t * 3) * 0.12})`
: 'rgba(40,56,50,.85)';
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
ctx.strokeStyle = active ? BRAND.primary : 'rgba(150,190,175,.35)';
ctx.lineWidth = active ? 2 : 1;
for (const ring of province.rings) {
ctx.beginPath();