feat: add landing-style start screen with four start modes

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

- Replace the Iran-map briefing with a brand start screen over the
  orbiting 3D factory: logo, tagline and four ways in.
- Real world: safety notice, then GPS; if location is denied or
  missing, a friendly dialog offers offline play instead.
- Offline: start at Park Mellat beside the family car with the route
  to the factory, where the first purchase mission is.
- About Us: start in the factory yard. Products: start at the machine
  samples with the showroom open.
- Contact, address with map links, FAQ, careers, gallery and social
  links open in a sheet (bottom sheet on phones) from company config.
- Restart replays the chosen mode. Layout scrolls on short screens and
  respects safe areas.
- Add tests for the start screen, map links and theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 08:39:22 +03:30
co-authored by Claude Opus 5.5
parent 611960511e
commit d9ae8a45da
28 changed files with 838 additions and 261 deletions
+14 -3
View File
@@ -20,18 +20,29 @@ npm run lint
npm run format:check npm run format:check
``` ```
## صفحهٔ شروع
صفحهٔ شروع شبیه صفحهٔ اصلی سایت است: لوگو و شعار شرکت روی نمای سه‌بعدی کارخانه که آرام دور آن می‌چرخد. چهار راه ورود به بازی دارد:
- **بازی در دنیای واقعی:** بعد از تذکر ایمنی، موقعیت GPS گوشی بازیکن را جابه‌جا می‌کند. اگر اجازهٔ موقعیت داده نشود یا GPS در دسترس نباشد، پیامی ساده نشان داده می‌شود و پیشنهاد بازی آفلاین می‌آید. هدف اول: رفتن تا کارخانه و خرید.
- **بازی آفلاین:** شروع از بوستان ملت، با ماشین خانواده کنار بازیکن و مسیر طلایی تا کارخانه. هدف اول: رفتن تا کارخانه و خرید.
- **دربارهٔ ما:** شروع در حیاط کارخانه برای آشنایی با شرکت.
- **محصولات:** شروع کنار نمونه‌دستگاه‌های کارخانه با پنجرهٔ معرفی محصول باز.
زیر این دکمه‌ها، بخش‌های معمول سایت (معرفی شرکت، تماس، آدرس و نقشه، پرسش‌های متداول، فرصت‌های شغلی، گالری و شبکه‌های اجتماعی) در پنجره‌ای کوچک باز می‌شوند و محتوایشان از `src/config/company.js` می‌آید. «شروع دوباره» بازی را در همان حالت انتخاب‌شده از نو شروع می‌کند.
## داستان و هدف ## داستان و هدف
- **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه این بخش‌ها را دارد: - **کارخانه:** حیاط کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه این بخش‌ها را دارد:
- **سالن تولید** با پنج بخش خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی و مونتاژ، و ۴۰ کارگر مشغول کار؛ - **سالن تولید** با پنج بخش خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی و مونتاژ، و ۴۰ کارگر مشغول کار؛
- **کامیون ارسال**: دستگاه‌های بسته‌بندی‌شده از سکوی بارگیری روی کامیون می‌روند. کامیون پر که شد از دروازه بیرون می‌رود و بعد خالی برمی‌گردد؛ - **کامیون ارسال**: دستگاه‌های بسته‌بندی‌شده از سکوی بارگیری روی کامیون می‌روند. کامیون پر که شد از دروازه بیرون می‌رود و بعد خالی برمی‌گردد؛
- **دفتر فنی مهندسی** با پنج برنامه‌نویس که اسمشان بالای سرشان دیده می‌شود: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کت‌وشلوار) و محمد. دستگاه وندینگ اول بازی هم همین‌جاست؛ - **دفتر فنی مهندسی** با پنج برنامه‌نویس که اسمشان بالای سرشان دیده می‌شود: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کت‌وشلوار) و محمد. دستگاه وندینگ اول بازی هم همین‌جاست؛
- **واحد حسابداری** با چهار همکار خانم؛ - **واحد حسابداری** با چهار همکار خانم؛
- **اتاق مدیریت**: آقای محمدی، مدیر شرکت، اول بازی با وانت KMC T8 وارد حیاط می‌شود و به اتاقش می‌رود. - **اتاق مدیریت**: آقای محمدی، مدیر شرکت، اول بازی با بوگاتی مشکی وارد حیاط می‌شود و به اتاقش می‌رود.
با نزدیک شدن به هر کدام از این آدم‌ها، با خانواده حرف می‌زنند. ماشین خانواده، یک سدان اسپرت به سبک BMW با سرعت تا ۲۰۰ کیلومتر بر ساعت، درست کنار نقطهٔ شروع پارک است. با نزدیک شدن به هر کدام از این آدم‌ها، با خانواده حرف می‌زنند. ماشین خانواده، یک سدان اسپرت به سبک BMW با سرعت تا ۲۰۰ کیلومتر بر ساعت، درست کنار نقطهٔ شروع پارک است.
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد. - **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد.
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند. - **نقشهٔ ایران:** زبانهٔ ایران در نقشهٔ بازی استان‌ها را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند.
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap: - **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap:
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی - جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
- همهٔ خیابان‌ها و ساختمان‌ها در فاصلهٔ ۲۵۰ متری مسیر رانندگی - همهٔ خیابان‌ها و ساختمان‌ها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
+2
View File
@@ -49,6 +49,8 @@ export const ASSETS = {
gif: './gifs/machine-60.gif', gif: './gifs/machine-60.gif',
}, },
}, },
// Gallery photos for the start screen. TODO(asset): add { src, alt } entries, 1600 × 1000 WebP.
gallery: [],
// 3D models. Every entry is null today: the game builds low-poly stand-ins in code // 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. // (src/game/models). TODO(asset): set a .glb path to use a real model.
models: { models: {
+15
View File
@@ -0,0 +1,15 @@
// Ways into the game from the start screen.
// - real: GPS places the player at their real position (falls back to offline on failure).
// - offline: starts at Park Mellat with the family car; the first goal is the factory.
// - about: starts in the factory yard to get to know the company.
// - products: starts at the machine samples in the factory with the showroom open.
export const START_MODES = ['real', 'offline', 'about', 'products'];
export const DEFAULT_START_MODE = 'about';
// Where the player and the family car stand relative to the Park Mellat machine (local metres:
// x along the machine front, z away from it), so the park gate and the machine are in view.
export const PARK_PLAYER_OFFSET = { x: -2, z: 6 };
export const PARK_CAR_OFFSET = { x: 3.5, z: 8 };
// Index of the showroom sample the Products mode starts at (the middle one of three).
export const PRODUCTS_SAMPLE = 1;
+1 -1
View File
@@ -7,7 +7,7 @@ import { publishProgress } from '../store/slices/gameSlice.js';
*/ */
export function createInterfaceBridge(store) { export function createInterfaceBridge(store) {
let nodes = { let nodes = {
briefing: { landing: {
hidden: false, hidden: false,
}, },
hud: { hud: {
+13
View File
@@ -120,6 +120,19 @@ export function resetGarage(context) {
setActive(context, garage.vehicles[0]); setActive(context, garage.vehicles[0]);
} }
/**
* Moves the family car (the first fleet vehicle) to a new spot, for modes that start away from
* the factory. The next reset parks it back in the yard.
* @param {object} context - Game context.
* @param {{x: number, z: number, heading: number}} pose - Where to park it.
*/
export function parkFamilyCar(context, pose) {
const family = context.garage.vehicles[0];
family.state = createCar(pose.x, pose.z, pose.heading);
placeModel(family);
setActive(context, family);
}
/** /**
* Takes over the manager's car once he has parked it, so it can be driven like the others. * Takes over the manager's car once he has parked it, so it can be driven like the others.
* @param {object} context - Game context. * @param {object} context - Game context.
+9 -2
View File
@@ -26,6 +26,7 @@ export function setRealMode(context, on) {
error: null, error: null,
waiting: null, waiting: null,
stop: null, stop: null,
offeredOffline: false,
}); });
for (const s of context.stations) if (s.def.test) s.model.group.visible = on; for (const s of context.stations) if (s.def.test) s.model.group.visible = on;
context.ui.classState('gps-toggle', 'on', on); context.ui.classState('gps-toggle', 'on', on);
@@ -55,7 +56,11 @@ export function setRealMode(context, on) {
context.real.error = null; context.real.error = null;
}, },
onHeading: (heading) => (context.real.heading = heading), onHeading: (heading) => (context.real.heading = heading),
onError: (message) => (context.real.error = message), onError: (message) => {
context.real.error = message;
// Started from the start screen's real-world option: offer offline play instead.
if (context.startMode === 'real') context.offerOffline(message);
},
onStatus: (message) => (context.real.waiting = message), onStatus: (message) => (context.real.waiting = message),
stabilizer: (context.real.stabilizer = createStabilizer()), stabilizer: (context.real.stabilizer = createStabilizer()),
}); });
@@ -114,8 +119,10 @@ function askRealMode(context, thenStart) {
context.ui.on('real-go', async () => { context.ui.on('real-go', async () => {
context.ui.close('real-intro'); context.ui.close('real-intro');
await requestCompass(); await requestCompass();
// Record the mode first: without any GPS the error arrives during setRealMode itself.
if (thenStart) context.startMode = 'real';
context.setRealMode(true); context.setRealMode(true);
if (thenStart) context.startMission(); if (thenStart) context.startGame('real');
}); });
} }
/** /**
+2 -22
View File
@@ -1,7 +1,5 @@
import * as THREE from 'three'; import * as THREE from 'three';
import iran from '../../data/iran.json';
import { clearFraction } from '../world/collision.js'; import { clearFraction } from '../world/collision.js';
import { drawIran } from '../world/mapRender.js';
import { syncActiveVehicle, trafficObstacles, updateGarage } from './garage.js'; import { syncActiveVehicle, trafficObstacles, updateGarage } from './garage.js';
/** /**
* Runs the resize operation for this subsystem. * Runs the resize operation for this subsystem.
@@ -73,7 +71,7 @@ export function frame(context) {
const dt = THREE.MathUtils.clamp((now - context.last) / 1000, 0, context.MAX_FRAME); const dt = THREE.MathUtils.clamp((now - context.last) / 1000, 0, context.MAX_FRAME);
context.last = now; context.last = now;
context.elapsed += dt; context.elapsed += dt;
const playing = context.ui.read('briefing').hidden; const playing = context.ui.read('landing').hidden;
const paused = const paused =
!playing || !playing ||
context.ui.read('shop').open || context.ui.read('shop').open ||
@@ -83,25 +81,6 @@ export function frame(context) {
document.hidden; document.hidden;
const steps = Math.max(1, Math.ceil(dt / (1 / 30))), const steps = Math.max(1, Math.ceil(dt / (1 / 30))),
step = dt / steps; step = dt / steps;
if (!playing) {
// Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay.
const ic = context.$('#iran'),
dpr = context.sizeCanvas(ic),
c = ic.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawIran(c, iran, {
width: ic.width / dpr,
height: ic.height / dpr,
t: context.elapsed,
cities: context.CITIES,
});
context.briefChars = Math.min(context.BRIEF.length, context.briefChars + dt * 55);
context.ui.patch('brief-text', {
text:
context.BRIEF.slice(0, Math.floor(context.briefChars)) +
(context.briefChars < context.BRIEF.length ? '▌' : ''),
});
}
if (!paused) { if (!paused) {
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps; i++) {
context.state.minutes += (step * 10) / 60; context.state.minutes += (step * 10) / 60;
@@ -173,6 +152,7 @@ export function frame(context) {
} }
context.updateCamera(dt); context.updateCamera(dt);
} else { } else {
// Start screen: a slow orbit around the factory yard behind the menu.
const a = context.elapsed * 0.08; const a = context.elapsed * 0.08;
context.camera.position.set( context.camera.position.set(
context.player.x + Math.cos(a) * 30, context.player.x + Math.cos(a) * 30,
+6 -2
View File
@@ -1,4 +1,5 @@
import { placeStation, updateReachCircles, reset, startMission } from './runtimeLifecycle.js'; import { placeStation, updateReachCircles, reset } from './runtimeLifecycle.js';
import { startGame, restartGame, offerOffline, switchToOffline } from './startModes.js';
import { look, toggleCar, updateWalk, updateDrive } from './movement.js'; import { look, toggleCar, updateWalk, updateDrive } from './movement.js';
import { say, updateSubtitles, tone } from './audioAndDialogue.js'; import { say, updateSubtitles, tone } from './audioAndDialogue.js';
import { import {
@@ -105,7 +106,10 @@ const RUNTIME_METHODS = {
renderMachineList, renderMachineList,
selectMachine, selectMachine,
openMap, openMap,
startMission, startGame,
restartGame,
offerOffline,
switchToOffline,
setRealMode, setRealMode,
setArMode, setArMode,
askRealMode, askRealMode,
-24
View File
@@ -3,7 +3,6 @@ import { createState } from '../economy/gameState.js';
import { createPayment } from '../economy/payment.js'; import { createPayment } from '../economy/payment.js';
import { createVendingMachine } from '../models/vending.js'; import { createVendingMachine } from '../models/vending.js';
import { resetGarage } from './garage.js'; import { resetGarage } from './garage.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js'; import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings. // Brand colours for materials and canvas drawings.
@@ -121,26 +120,3 @@ export function reset(context) {
context.keys.clear(); context.keys.clear();
context.refreshHUD(); context.refreshHUD();
} }
// ---------- Input ----------
/**
* Runs the start mission operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function startMission(context) {
context.ui.patch('briefing', {
hidden: true,
});
context.ui.patch('hud', {
hidden: false,
});
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?.(() => {});
}
+6 -6
View File
@@ -436,7 +436,7 @@ export async function initializeSpawnCar(context) {
context.ui.read('map-dialog').open || context.ui.read('map-dialog').open ||
context.ui.read('end').open || context.ui.read('end').open ||
context.ui.read('showroom').open || context.ui.read('showroom').open ||
!context.ui.read('briefing').hidden; !context.ui.read('landing').hidden;
// Physical key codes, so the controls also work with a Persian keyboard layout. // Physical key codes, so the controls also work with a Persian keyboard layout.
context.keyName = (e) => context.keyName = (e) =>
e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase(); e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase();
@@ -450,7 +450,7 @@ export async function registerKeyboard(context) {
context.listen(window, 'keydown', (e) => { context.listen(window, 'keydown', (e) => {
if (e.target instanceof HTMLInputElement) return; if (e.target instanceof HTMLInputElement) return;
const k = context.keyName(e); const k = context.keyName(e);
if (k === 'm' && !e.repeat && context.ui.read('briefing').hidden) { if (k === 'm' && !e.repeat && context.ui.read('landing').hidden) {
e.preventDefault(); e.preventDefault();
context.ui.read('map-dialog').open ? context.ui.close('map-dialog') : context.openMap(); context.ui.read('map-dialog').open ? context.ui.close('map-dialog') : context.openMap();
return; return;
@@ -735,7 +735,7 @@ export async function registerPreferences(context) {
}); });
context.ui.on('again', context.reset); context.ui.on('again', context.reset);
context.ui.on('continue', () => context.ui.close('end')); context.ui.on('continue', () => context.ui.close('end'));
context.ui.on('restart', context.reset); context.ui.on('restart', context.restartGame);
// ---------- Objective, route and HUD ---------- // ---------- Objective, route and HUD ----------
context.objectiveStation = () => { context.objectiveStation = () => {
@@ -1050,9 +1050,9 @@ export async function initializeCities(context) {
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
export async function initializeBriefingAndTracking(context) { export async function initializeBriefingAndTracking(context) {
context.BRIEF = strings.briefing.lines.join('\n'); context.ui.on('start', (mode) => context.startGame(mode));
context.briefChars = 0; context.ui.on('gps-offline', context.switchToOffline);
context.ui.on('start-mission', context.startMission); context.ui.on('gps-fallback-close', () => context.ui.close('gps-fallback'));
// ---------- Real-world (GPS) mode ---------- // ---------- Real-world (GPS) mode ----------
// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where // The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where
+145
View File
@@ -0,0 +1,145 @@
import { CITY_START } from '../../constants/locations.js';
import {
DEFAULT_START_MODE,
PARK_CAR_OFFSET,
PARK_PLAYER_OFFSET,
PRODUCTS_SAMPLE,
START_MODES,
} from '../../constants/startModes.js';
import { strings } from '../../i18n/fa.js';
import { resolveCircle } from '../world/collision.js';
import { parkFamilyCar } from './garage.js';
// Starts the game in one of the start-screen modes (see constants/startModes.js). Every mode
// begins from a fresh reset, which puts the family in the factory yard; modes that start
// elsewhere move them afterwards.
/**
* Points the player (and the chase camera behind them) along a heading.
* @param {object} context - Game context.
* @param {{x: number, z: number}} at - Position to stand on.
* @param {number} heading - Heading in radians (Three rotation.y).
*/
function standAt(context, at, heading) {
const spot = resolveCircle(context.collision, at.x, at.z, 1);
Object.assign(context.player, { x: spot.x, z: spot.z, heading });
// The camera sits at player + (sin yaw, cos yaw), so this puts it behind the player.
context.cam.yaw = Math.atan2(-Math.cos(heading), Math.sin(heading));
}
/**
* Moves the family and their car to the Park Mellat gate, facing the machine, and routes to the
* factory for the first mission.
* @param {object} context - Game context.
*/
function placeAtPark(context) {
const machine = context.stationById(CITY_START);
const local = (offset) => context.rotateLocal(offset.x, offset.z, machine.rot);
const player = local(PARK_PLAYER_OFFSET),
car = local(PARK_CAR_OFFSET);
const towardsMachine = Math.atan2(player.z, -player.x);
standAt(context, { x: machine.x + player.x, z: machine.z + player.z }, towardsMachine);
const carSpot = resolveCircle(context.collision, machine.x + car.x, machine.z + car.z, 2);
parkFamilyCar(context, { x: carSpot.x, z: carSpot.z, heading: machine.rot + Math.PI / 2 });
context.waypoint = 'factory';
context.route = null;
}
/**
* Puts the player in front of the middle showroom sample.
* @param {object} context - Game context.
*/
function placeAtShowroom(context) {
const sample = context.showroom.samples[PRODUCTS_SAMPLE];
const heading = Math.atan2(
-(sample.position.z - sample.front.z),
sample.position.x - sample.front.x,
);
standAt(context, sample.front, heading);
}
/**
* Says the opening lines for a mode.
* @param {object} context - Game context.
* @param {string} mode - Start mode.
*/
function introduce(context, mode) {
const { dialogue } = strings;
if (mode === 'offline') {
context.say('father', dialogue.offlineStart);
context.say('child', dialogue.offlineFollow);
return;
}
if (mode === 'real') {
context.say('father', dialogue.realStart);
return;
}
if (mode === 'products') return;
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);
}
/**
* Places the player for a mode, without touching the interface.
* @param {object} context - Game context.
* @param {string} mode - Start mode.
*/
function placeForMode(context, mode) {
if (mode === 'offline') placeAtPark(context);
if (mode === 'products') placeAtShowroom(context);
}
/**
* Leaves the start screen and starts playing in a mode.
* @param {object} context - Game context.
* @param {string} mode - One of START_MODES; unknown values start the About Us tour.
*/
export function startGame(context, mode) {
context.startMode = START_MODES.includes(mode) ? mode : DEFAULT_START_MODE;
context.reset();
placeForMode(context, context.startMode);
context.ui.patch('landing', { hidden: true });
context.ui.patch('hud', { hidden: false });
introduce(context, context.startMode);
if (context.startMode === 'products') {
context.openShowroom();
return;
}
if (!context.isTouch && !context.real.on)
context.canvas.requestPointerLock?.()?.catch?.(() => {});
}
/**
* Restarts in the mode the player chose on the start screen.
* @param {object} context - Game context.
*/
export function restartGame(context) {
context.reset();
placeForMode(context, context.startMode ?? DEFAULT_START_MODE);
}
/**
* Explains that location is unavailable and offers to play offline instead. Shown once per
* real-world session, the first time tracking reports an error.
* @param {object} context - Game context.
* @param {string} message - Why location failed.
*/
export function offerOffline(context, message) {
if (context.real.offeredOffline) return;
context.real.offeredOffline = true;
context.ui.patch('gps-fallback-text', { text: message });
context.ui.open('gps-fallback');
}
/**
* Switches from a failed real-world session to offline play at Park Mellat.
* @param {object} context - Game context.
*/
export function switchToOffline(context) {
context.ui.close('gps-fallback');
context.setRealMode(false);
context.startGame('offline');
}
+40 -16
View File
@@ -45,23 +45,44 @@ export const strings = {
osmImageryCredit: (imagery) => osmImageryCredit: (imagery) =>
`نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${imagery}`, `نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${imagery}`,
}, },
briefing: { landing: {
robotAlt: `روبات راهنمای ${COMPANY.shortName}`, robotAlt: `روبات راهنمای ${COMPANY.shortName}`,
tag: 'عملیات ۰۱ · خراسان رضوی', logoAlt: `لوگوی ${COMPANY.shortName}`,
title: 'عملیات مشهد', heading: 'دنیای دستگاه‌های وندینگ هوشمند را بگردید',
subtitle: `${COMPANY.shortName} · شکار دستگاه‌های وندینگ`, intro: 'کارخانه، محصولات و دستگاه‌های نصب‌شده در شهر را در یک بازی سه‌بعدی ببینید.',
start: 'شروع عملیات', modesLabel: 'شروع بازی',
realWorld: '📍 بازی در دنیای واقعی (GPS)', modes: {
install: '⬇ نصب بازی روی گوشی', real: { label: 'بازی در دنیای واقعی', hint: 'با GPS گوشی' },
keys: 'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M', offline: { label: 'بازی آفلاین', hint: 'شروع از بوستان ملت' },
lines: [ about: { label: 'دربارهٔ ما', hint: 'گشت در کارخانه' },
`نقطهٔ شروع: ${COMPANY.name}، بزرگراه مشهد–چناران`, products: { label: 'محصولات', hint: 'دستگاه‌ها از نزدیک' },
'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست', },
`هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های ${COMPANY.shortName} را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.`, sectionsLabel: 'اطلاعات شرکت',
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.', sections: {
'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.', contact: 'تماس با ما',
'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.', address: 'آدرس و نقشه',
], faq: 'پرسش‌های متداول',
careers: 'فرصت‌های شغلی',
gallery: 'گالری',
social: 'شبکه‌های اجتماعی',
},
history: 'معرفی شرکت',
values: 'ارزش‌های ما',
phone: 'تلفن',
email: 'ایمیل',
website: 'وب‌سایت',
openMap: 'باز کردن در نقشه',
/** @type {(address: string, city: string) => string} */
fullAddress: (address, city) => `${address}، ${city}`,
galleryEmpty: 'تصاویر به‌زودی اضافه می‌شوند.',
close: 'بستن',
install: 'نصب روی گوشی',
keys: 'W A S D حرکت · ماوس دوربین · F ماشین · E خرید · M نقشه',
},
gpsFallback: {
title: 'موقعیت‌یابی در دسترس نیست',
offline: 'بازی آفلاین',
close: 'بستن',
}, },
toolbar: { toolbar: {
showroom: 'مدل‌های کارخانه · Q', showroom: 'مدل‌های کارخانه · Q',
@@ -264,6 +285,9 @@ export const strings = {
childAsksFactory: `بابا، اینجا همون کارخونه‌ایه که دستگاه‌های ${COMPANY.shortName} رو می‌سازه؟`, childAsksFactory: `بابا، اینجا همون کارخونه‌ایه که دستگاه‌های ${COMPANY.shortName} رو می‌سازه؟`,
fatherAnswersFactory: fatherAnswersFactory:
'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!', 'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
offlineStart: `اینجا بوستان ملته. ماشین کنارمونه؛ بریم کارخونهٔ ${COMPANY.shortName} و از دستگاهش خرید کنیم!`,
offlineFollow: 'مسیر طلایی روی زمین رو دنبال کنیم!',
realStart: `هدف اول: تا کارخونهٔ ${COMPANY.shortName} بریم و از دستگاهش خرید کنیم.`,
childAsksArrival: 'اون ماشین مشکیه کیه داره میاد تو؟', childAsksArrival: 'اون ماشین مشکیه کیه داره میاد تو؟',
fatherAnswersArrival: 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.', fatherAnswersArrival: 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.',
robotHello: `سلام! من روبات راهنمای ${COMPANY.shortName}م؛ تو دستگاه دفتر فنی زندگی می‌کنم!`, robotHello: `سلام! من روبات راهنمای ${COMPANY.shortName}م؛ تو دستگاه دفتر فنی زندگی می‌کنم!`,
-70
View File
@@ -1,70 +0,0 @@
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.
* @returns {import('react').ReactElement} Game panel.
*/
export function BriefingPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="briefing" model={nodes['briefing']}>
<div className="brief-map">
<GameNode as="canvas" id="iran" model={nodes['iran']} />
</div>
<div className="brief-panel">
<img
className="brief-robot"
src={robotFace('front')}
alt={strings.briefing.robotAlt}
width="136"
height="223"
/>
<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"
aria-live="polite"
model={nodes['brief-text']}
></GameNode>
<GameNode
as="button"
id="start-mission"
type="button"
model={nodes['start-mission']}
onAction={() => actions.emit('start-mission')}
>
{strings.briefing.start}
</GameNode>
<div className="brief-actions">
<GameNode
as="button"
id="real-mode"
type="button"
className="ghost"
model={nodes['real-mode']}
onAction={() => actions.emit('real-mode')}
>
{strings.briefing.realWorld}
</GameNode>
<GameNode
as="button"
id="install"
type="button"
className="ghost"
hidden
model={nodes['install']}
onAction={() => actions.emit('install')}
>
{strings.briefing.install}
</GameNode>
</div>
<p className="keys">{strings.briefing.keys}</p>
</div>
</GameNode>
</>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { RealIntroDialog } from './RealIntroDialog.jsx';
import { InstallDialog } from './InstallDialog.jsx'; import { InstallDialog } from './InstallDialog.jsx';
import { UpdateNotice } from './UpdateNotice.jsx'; import { UpdateNotice } from './UpdateNotice.jsx';
import { EndDialog } from './EndDialog.jsx'; import { EndDialog } from './EndDialog.jsx';
import { GpsFallbackDialog } from './GpsFallbackDialog.jsx';
/** Composes independently managed game dialogs. */ /** Composes independently managed game dialogs. */
export function GameDialogs(props) { export function GameDialogs(props) {
return ( return (
@@ -18,6 +19,7 @@ export function GameDialogs(props) {
<InstallDialog {...props} /> <InstallDialog {...props} />
<UpdateNotice {...props} /> <UpdateNotice {...props} />
<EndDialog {...props} /> <EndDialog {...props} />
<GpsFallbackDialog {...props} />
</> </>
); );
} }
+2 -2
View File
@@ -6,7 +6,7 @@ import { createInterfaceBridge } from '@/game/interfaceBridge.js';
import { useGameEngine } from '@/hooks/useGameEngine.js'; import { useGameEngine } from '@/hooks/useGameEngine.js';
import { setupPwa } from '@/game/device/pwa.js'; import { setupPwa } from '@/game/device/pwa.js';
import { GameSurfaces } from './GameSurfaces.jsx'; import { GameSurfaces } from './GameSurfaces.jsx';
import { BriefingPanel } from './BriefingPanel.jsx'; import { StartScreen } from './startScreen/StartScreen.jsx';
import { GameHud } from './GameHud.jsx'; import { GameHud } from './GameHud.jsx';
import { GameDialogs } from './GameDialogs.jsx'; import { GameDialogs } from './GameDialogs.jsx';
import { strings } from '@/i18n/fa.js'; import { strings } from '@/i18n/fa.js';
@@ -41,7 +41,7 @@ export function GamePage() {
return ( return (
<main ref={root}> <main ref={root}>
<GameSurfaces {...props} /> <GameSurfaces {...props} />
<BriefingPanel {...props} /> <StartScreen {...props} />
<GameHud {...props} /> <GameHud {...props} />
<GameDialogs {...props} /> <GameDialogs {...props} />
{error && ( {error && (
+44
View File
@@ -0,0 +1,44 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
import { robotFace } from '@/config/assets.js';
/** @typedef {{nodes: object, actions: object}} GpsFallbackDialogProps */
/**
* Explains why location failed and offers offline play from Park Mellat instead.
* @param {GpsFallbackDialogProps} props - Interface snapshots and actions.
* @returns {import('react').ReactElement} Dialog.
*/
export function GpsFallbackDialog({ nodes, actions }) {
return (
<GameNode
as="dialog"
id="gps-fallback"
aria-labelledby="gps-fallback-title"
className="info-sheet"
model={nodes['gps-fallback']}
onClose={() => actions.emit('gps-fallback-close')}
>
<img src={robotFace('serious')} alt="" width="72" height="68" className="float-end ms-3" />
<h2 id="gps-fallback-title" className="mb-2 text-lg font-bold">
{strings.gpsFallback.title}
</h2>
<GameNode as="p" id="gps-fallback-text" model={nodes['gps-fallback-text']} />
<div className="mt-5 flex flex-wrap gap-3">
<button
type="button"
className="start-option start-option-primary"
onClick={() => actions.emit('gps-offline')}
>
{strings.gpsFallback.offline}
</button>
<button
type="button"
className="start-chip"
onClick={() => actions.emit('gps-fallback-close')}
>
{strings.gpsFallback.close}
</button>
</div>
</GameNode>
);
}
@@ -0,0 +1,150 @@
import { COMPANY } from '@/config/company.js';
import { ASSETS } from '@/config/assets.js';
import { FACTORY } from '@/constants/locations.js';
import { strings } from '@/i18n/fa.js';
import { toLatLon } from '@/utils/geo.js';
import { mapLinks } from '@/utils/mapLinks.js';
const { contact, about } = COMPANY;
const text = strings.landing;
const links = mapLinks(toLatLon(FACTORY.center.x, FACTORY.center.z));
const LINK = 'text-primary underline underline-offset-4';
/**
* Company history and values.
* @returns {import('react').ReactElement} Section body.
*/
function AboutBody() {
return (
<>
<p>{about.history}</p>
<h3 className="mt-4 font-bold">{text.values}</h3>
<ul className="mt-2 list-inside list-disc">
{about.values.map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</>
);
}
/**
* Phone, email and website.
* @returns {import('react').ReactElement} Section body.
*/
function ContactBody() {
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
<dt className="opacity-70">{text.phone}</dt>
<dd dir="ltr" className="text-end">
<a className={LINK} href={`tel:${contact.phone}`}>
{contact.phone}
</a>
</dd>
<dt className="opacity-70">{text.email}</dt>
<dd dir="ltr" className="text-end">
<a className={LINK} href={`mailto:${contact.email}`}>
{contact.email}
</a>
</dd>
<dt className="opacity-70">{text.website}</dt>
<dd dir="ltr" className="text-end">
<a className={LINK} href={contact.website} target="_blank" rel="noreferrer">
{contact.website.replace(/^https?:\/\//, '')}
</a>
</dd>
</dl>
);
}
/**
* Postal address with links to open the factory on a map.
* @returns {import('react').ReactElement} Section body.
*/
function AddressBody() {
return (
<>
<p>{text.fullAddress(contact.address, contact.city)}</p>
<div className="mt-4 flex flex-wrap gap-3">
<a className="start-chip" href={links.osm} target="_blank" rel="noreferrer">
{`${text.openMap} · OpenStreetMap`}
</a>
<a className="start-chip" href={links.google} target="_blank" rel="noreferrer">
{`${text.openMap} · Google`}
</a>
</div>
</>
);
}
/**
* Frequently asked questions as expandable rows.
* @returns {import('react').ReactElement} Section body.
*/
function FaqBody() {
return about.faq.map(({ question, answer }) => (
<details key={question} className="border-b border-white/10 py-2">
<summary className="cursor-pointer font-bold">{question}</summary>
<p className="mt-2 opacity-80">{answer}</p>
</details>
));
}
/**
* Open positions.
* @returns {import('react').ReactElement} Section body.
*/
function CareersBody() {
return (
<ul className="grid gap-3">
{about.careers.map(({ title, note }) => (
<li key={title}>
<strong>{title}</strong>
<p className="opacity-80">{note}</p>
</li>
))}
</ul>
);
}
/**
* Photo grid, or a note while there are no photos yet.
* @returns {import('react').ReactElement} Section body.
*/
function GalleryBody() {
if (!ASSETS.gallery.length) return <p className="opacity-80">{text.galleryEmpty}</p>;
return (
<div className="grid grid-cols-2 gap-2">
{ASSETS.gallery.map(({ src, alt }) => (
<img key={src} src={src} alt={alt} loading="lazy" className="rounded" />
))}
</div>
);
}
/**
* Social network links.
* @returns {import('react').ReactElement} Section body.
*/
function SocialBody() {
return (
<div className="flex flex-wrap gap-3">
{contact.socials.map(({ id, label, url }) => (
<a key={id} className="start-chip" href={url} target="_blank" rel="noreferrer">
{label}
</a>
))}
</div>
);
}
// Body component for each section key used by InfoSheet.
export const SECTION_BODIES = {
about: AboutBody,
contact: ContactBody,
address: AddressBody,
faq: FaqBody,
careers: CareersBody,
gallery: GalleryBody,
social: SocialBody,
};
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react';
import { strings } from '@/i18n/fa.js';
import { StartIcon } from './StartIcon.jsx';
import { SECTION_BODIES } from './InfoSectionContent.jsx';
/** @typedef {{section: string | null, onClose: function}} InfoSheetProps */
/**
* Shows one company information section in a modal sheet (bottom sheet on phones).
* @param {InfoSheetProps} props - Open section key, or null when closed, and the close callback.
* @returns {import('react').ReactElement} Native dialog.
*/
export function InfoSheet({ section, onClose }) {
const dialog = useRef(null);
const Body = SECTION_BODIES[section];
const title = section === 'about' ? strings.landing.history : strings.landing.sections[section];
// The native dialog gives focus trapping and Escape handling; keep it in step with `section`.
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (section && !element.open) element.showModal();
if (!section && element.open) element.close();
}, [section]);
return (
<dialog
ref={dialog}
id="info-sheet"
aria-labelledby="info-sheet-title"
onClose={onClose}
className="info-sheet"
>
<header className="mb-4 flex items-center justify-between gap-4">
<h2 id="info-sheet-title" className="text-lg font-bold">
{title}
</h2>
<button
type="button"
className="start-icon-button"
aria-label={strings.landing.close}
onClick={onClose}
>
<StartIcon name="close" className="h-5 w-5" />
</button>
</header>
{Body && <Body />}
</dialog>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Simple line icons (24 × 24, stroke drawn in currentColor) for the start screen.
const PATHS = {
pin: 'M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21zm0-9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
play: 'M7 5l12 7-12 7z',
building: 'M4 21V8l8-4 8 4v13M9 21v-6h6v6M8 10h.01M12 10h.01M16 10h.01',
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
phone:
'M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z',
map: 'M9 4l6 2 5-2v16l-5 2-6-2-5 2V6zM9 4v16M15 6v16',
help: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14M12 17h.01',
briefcase: 'M4 8h16v11H4zM9 8V5h6v3M4 13h16',
image: 'M4 5h16v14H4zM4 16l5-5 4 4 3-3 4 4M15 9h.01',
share:
'M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4',
close: 'M6 6l12 12M18 6L6 18',
download: 'M12 4v11M7 10l5 5 5-5M5 20h14',
};
/** @typedef {{name: keyof typeof PATHS, className?: string}} StartIconProps */
/**
* Draws one start-screen icon; decorative, so hidden from screen readers.
* @param {StartIconProps} props - Icon name and classes.
* @returns {import('react').ReactElement} SVG icon.
*/
export function StartIcon({ name, className = 'h-6 w-6' }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={PATHS[name]} />
</svg>
);
}
@@ -0,0 +1,47 @@
import { strings } from '@/i18n/fa.js';
import { StartIcon } from './StartIcon.jsx';
// The four ways into the game, in display order, with their icons.
const OPTIONS = [
{ mode: 'real', icon: 'pin' },
{ mode: 'offline', icon: 'play' },
{ mode: 'about', icon: 'building' },
{ mode: 'products', icon: 'grid' },
];
/** @typedef {{actions: object}} StartOptionsProps */
/**
* Shows the main start buttons. Real-world play goes through the safety notice first.
* @param {StartOptionsProps} props - Interface actions.
* @returns {import('react').ReactElement} Button grid.
*/
export function StartOptions({ actions }) {
/**
* Starts the game in a mode.
* @param {string} mode - Start mode.
*/
const handleStart = (mode) =>
mode === 'real' ? actions.emit('real-mode') : actions.emit('start', mode);
return (
<nav aria-label={strings.landing.modesLabel} className="grid grid-cols-2 gap-3">
{OPTIONS.map(({ mode, icon }, index) => (
<button
key={mode}
id={`start-${mode}`}
type="button"
onClick={() => handleStart(mode)}
className={
index < 2 ? 'start-option start-option-primary' : 'start-option start-option-secondary'
}
>
<StartIcon name={icon} className="h-7 w-7 shrink-0" />
<span className="grid text-start">
<span className="font-bold">{strings.landing.modes[mode].label}</span>
<span className="text-xs opacity-80">{strings.landing.modes[mode].hint}</span>
</span>
</button>
))}
</nav>
);
}
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { COMPANY } from '@/config/company.js';
import { ASSETS } from '@/config/assets.js';
import { strings } from '@/i18n/fa.js';
import { StartIcon } from './StartIcon.jsx';
import { StartOptions } from './StartOptions.jsx';
import { InfoSheet } from './InfoSheet.jsx';
// Company information links under the start buttons, with their icons.
const SECTIONS = [
['about', 'building'],
['contact', 'phone'],
['address', 'map'],
['faq', 'help'],
['careers', 'briefcase'],
['gallery', 'image'],
['social', 'share'],
];
/** @typedef {{nodes: object, actions: object}} StartScreenProps */
/**
* Landing-page style start screen over the slowly orbiting 3D factory: brand, the four ways
* into the game, and the company information a website would normally show.
* @param {StartScreenProps} props - Interface snapshots and actions.
* @returns {import('react').ReactElement} Start screen.
*/
export function StartScreen({ nodes, actions }) {
const [section, setSection] = useState(null);
const text = strings.landing;
return (
<GameNode as="section" id="landing" aria-labelledby="landing-title" model={nodes.landing}>
<div className="landing-panel">
<header className="flex items-center gap-3">
<img src={ASSETS.brand.logo} alt={text.logoAlt} className="h-12 w-12 rounded-xl" />
<div>
<p className="text-lg font-bold">{COMPANY.shortName}</p>
<p className="text-sm text-primary">{COMPANY.tagline}</p>
</div>
</header>
<div className="my-6 md:my-10">
<h1 id="landing-title" className="text-2xl font-black leading-snug md:text-4xl">
{text.heading}
</h1>
<p className="mt-3 max-w-md opacity-80">{text.intro}</p>
</div>
<StartOptions actions={actions} />
<nav aria-label={text.sectionsLabel} className="mt-6 flex flex-wrap gap-2">
{SECTIONS.map(([key, icon]) => (
<button key={key} type="button" className="start-chip" onClick={() => setSection(key)}>
<StartIcon name={icon} className="h-4 w-4" />
{key === 'about' ? text.history : text.sections[key]}
</button>
))}
<GameNode
as="button"
id="install"
type="button"
className="start-chip"
hidden
model={nodes.install}
onAction={() => actions.emit('install')}
>
<StartIcon name="download" className="h-4 w-4" />
{text.install}
</GameNode>
</nav>
<p className="mt-6 hidden text-xs opacity-60 md:block">{text.keys}</p>
</div>
<InfoSheet section={section} onClose={() => setSection(null)} />
</GameNode>
);
}
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { strings } from '@/i18n/fa.js';
import { COMPANY } from '@/config/company.js';
import { StartScreen } from './StartScreen.jsx';
afterEach(cleanup);
// jsdom has no modal dialogs; open and close them by toggling the attribute.
beforeAll(() => {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close = function close() {
this.removeAttribute('open');
this.dispatchEvent(new Event('close'));
};
});
/**
* Renders the start screen with a spy for interface actions.
* @returns {{emit: import('vitest').Mock}} The actions spy.
*/
function renderStartScreen() {
const actions = { emit: vi.fn() };
render(<StartScreen nodes={{ landing: { hidden: false } }} actions={actions} />);
return actions;
}
describe('StartScreen', () => {
it('starts each mode, sending real-world play through its safety notice', () => {
const actions = renderStartScreen();
const { modes } = strings.landing;
fireEvent.click(screen.getByRole('button', { name: new RegExp(modes.offline.label) }));
expect(actions.emit).toHaveBeenLastCalledWith('start', 'offline');
fireEvent.click(screen.getByRole('button', { name: new RegExp(modes.products.label) }));
expect(actions.emit).toHaveBeenLastCalledWith('start', 'products');
fireEvent.click(screen.getByRole('button', { name: new RegExp(modes.real.label) }));
expect(actions.emit).toHaveBeenLastCalledWith('real-mode');
});
it('opens company information in a sheet and closes it again', () => {
renderStartScreen();
fireEvent.click(screen.getByRole('button', { name: strings.landing.sections.contact }));
const sheet = screen.getByRole('dialog', { name: strings.landing.sections.contact });
expect(sheet).toHaveAttribute('open');
expect(screen.getByRole('link', { name: COMPANY.contact.email })).toHaveAttribute(
'href',
`mailto:${COMPANY.contact.email}`,
);
fireEvent.click(screen.getByRole('button', { name: strings.landing.close }));
expect(sheet).not.toHaveAttribute('open');
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
const initialState = { const initialState = {
nodes: { nodes: {
briefing: { landing: {
hidden: false, hidden: false,
}, },
hud: { hud: {
+90 -109
View File
@@ -165,87 +165,97 @@ export const gameTheme = {
'font-size': 'var(--game-token-11)', 'font-size': 'var(--game-token-11)',
'letter-spacing': 'var(--game-token-12)', 'letter-spacing': 'var(--game-token-12)',
}, },
// The briefing can be taller than a phone screen (portrait text, or any landscape phone), so it // Start screen over the slowly orbiting 3D factory. The gradient keeps the menu readable while
// scrolls; otherwise the start and install buttons end up below the edge, out of reach. // the scene shows through on the far side (in RTL the menu sits on the right).
'#briefing': { '#landing': {
position: 'fixed', position: 'fixed',
inset: '0', inset: '0',
'z-index': '40', 'z-index': '40',
'overflow-y': 'auto', 'overflow-y': 'auto',
display: 'grid', display: 'flex',
'grid-template-columns': '1.3fr 1fr', padding:
'max(var(--game-token-42), env(safe-area-inset-top)) max(var(--game-token-42), env(safe-area-inset-right)) max(var(--game-token-42), env(safe-area-inset-bottom)) max(var(--game-token-42), env(safe-area-inset-left))',
background: background:
'radial-gradient(circle at 30% 40%, var(--game-token-13) 0%, var(--game-token-14) 75%)', 'linear-gradient(to left, var(--color-surface) 0%, var(--game-token-54) 45%, transparent 100%)',
color: 'var(--color-text)',
}, },
'#briefing[hidden]': { '#landing[hidden]': {
display: 'none', display: 'none',
}, },
'.brief-map': { // Auto margins centre the menu but, unlike align-items, still let a tall menu scroll to its top.
position: 'relative', '.landing-panel': {
'margin-block': 'auto',
width: 'min(100%, var(--game-token-70))',
'margin-inline-start': 'clamp(0px, 5vw, var(--game-token-77))',
}, },
'.brief-map::after': { '.start-option': {
content: "''", display: 'flex',
position: 'absolute', 'align-items': 'center',
inset: '0', gap: 'var(--game-token-30)',
'pointer-events': 'none', 'min-height': 'var(--game-token-77)',
background: padding: 'var(--game-token-30) var(--game-token-28)',
'repeating-linear-gradient(\n 0deg,\n var(--game-token-15) 0 var(--game-token-16),\n transparent var(--game-token-16) var(--game-token-17)\n )', 'border-radius': 'var(--game-token-28)',
}, border: 'var(--game-token-16) solid transparent',
'#iran': {
width: '100%',
height: '100%',
display: 'block',
},
'.brief-panel': {
'align-self': 'center',
margin: 'var(--game-token-18)',
padding: 'var(--game-token-19)',
'max-width': 'var(--game-token-20)',
background: 'var(--panel)',
border: 'var(--game-token-16) solid var(--line)',
'border-inline-start': 'var(--game-token-21) solid var(--gold)',
},
'.tag': {
color: 'var(--gold)',
'letter-spacing': 'var(--game-token-16)',
'font-weight': '600',
},
'.brief-panel h1': {
margin: 'var(--game-token-22) 0 0',
'font-size': 'clamp(var(--game-token-23), 5vw, var(--game-token-24))',
'font-weight': '800',
},
'.sub': {
margin: '0 0 var(--game-token-11)',
color: 'var(--teal)',
},
'#brief-text': {
'min-height': 'var(--game-token-25)',
margin: '0 0 var(--game-token-26)',
'white-space': 'pre-wrap',
'font-family': 'var(--font)', 'font-family': 'var(--font)',
'font-size': 'var(--game-token-27)', 'text-align': 'start',
'line-height': '1.9', cursor: 'pointer',
color: 'var(--hud)', transition: 'filter 0.15s',
}, },
'#start-mission': { '.start-option:hover,\n.start-chip:hover': {
width: '100%', filter: 'brightness(1.12)',
padding: 'var(--game-token-28)', },
'.start-option:focus-visible,\n.start-chip:focus-visible,\n.start-icon-button:focus-visible': {
outline: 'var(--game-token-21) solid var(--color-primary-light)',
'outline-offset': 'var(--game-token-35)',
},
'.start-option-primary': {
background: 'var(--color-primary)',
color: 'var(--color-on-primary)',
},
'.start-option-secondary': {
background: 'var(--game-token-65)',
color: 'var(--color-text)',
'border-color': 'var(--color-primary)',
},
'.start-chip': {
display: 'inline-flex',
'align-items': 'center',
gap: 'var(--game-token-22)',
'min-height': 'var(--game-token-74)',
padding: 'var(--game-token-22) var(--game-token-28)',
'border-radius': 'var(--game-token-88)',
border: 'var(--game-token-16) solid var(--game-token-58)',
background: 'var(--game-token-65)',
color: 'var(--color-text)',
'font-family': 'var(--font)',
'font-size': 'var(--game-token-28)',
cursor: 'pointer',
},
'.start-chip[hidden]': {
display: 'none',
},
'.start-icon-button': {
display: 'grid',
'place-items': 'center',
width: 'var(--game-token-74)',
height: 'var(--game-token-74)',
border: '0', border: '0',
background: 'var(--gold)', 'border-radius': '50%',
color: 'var(--game-token-29)', background: 'var(--game-token-65)',
'font-size': 'var(--game-token-11)', color: 'var(--color-text)',
'font-weight': '800', cursor: 'pointer',
'letter-spacing': 'var(--game-token-12)',
}, },
'#start-mission:hover': { '.info-sheet': {
filter: 'brightness(1.08)', width: 'min(var(--game-token-70), 92vw)',
}, 'max-height': '85dvh',
'.keys': { 'overflow-y': 'auto',
color: 'var(--dim)', padding: 'var(--game-token-26)',
'font-size': 'var(--game-token-30)', 'border-radius': 'var(--game-token-11)',
'line-height': '1.8', border: 'var(--game-token-16) solid var(--game-token-58)',
'margin-bottom': '0', 'border-top': 'var(--game-token-21) solid var(--color-primary)',
background: 'var(--color-surface-raised)',
color: 'var(--color-text)',
'line-height': '1.9',
}, },
'#hud[hidden]': { '#hud[hidden]': {
display: 'none', display: 'none',
@@ -912,18 +922,20 @@ export const gameTheme = {
color: 'var(--game-token-59)', color: 'var(--game-token-59)',
}, },
'@media (max-width: 760px)': { '@media (max-width: 760px)': {
'#briefing': { // Phones: the menu sits at the bottom over a stronger fade; info opens as a bottom sheet.
'grid-template-columns': '1fr', '#landing': {
'grid-template-rows': '38vh 1fr', background:
'linear-gradient(to top, var(--color-surface) 55%, var(--game-token-54) 75%, transparent 100%)',
}, },
'.brief-panel': { '.landing-panel': {
margin: '0 var(--game-token-42) var(--game-token-42)', 'margin-block': 'auto 0',
padding: 'var(--game-token-11)',
'align-self': 'start',
}, },
'#brief-text': { '.info-sheet': {
'min-height': 'var(--game-token-78)', margin: 'auto auto 0',
'font-size': 'var(--game-token-41)', width: '100vw',
'max-width': '100vw',
'border-radius': 'var(--game-token-11) var(--game-token-11) 0 0',
'padding-bottom': 'max(var(--game-token-26), env(safe-area-inset-bottom))',
}, },
'#minimap-button,\n #minimap': { '#minimap-button,\n #minimap': {
width: 'var(--game-token-72)', width: 'var(--game-token-72)',
@@ -995,9 +1007,6 @@ export const gameTheme = {
'border-inline-start': '0', 'border-inline-start': '0',
'border-top': 'var(--game-token-16) solid var(--line)', 'border-top': 'var(--game-token-16) solid var(--line)',
}, },
'.brief-robot': {
width: 'var(--game-token-81)',
},
'#rank .bar': { '#rank .bar': {
height: 'var(--game-token-21)', height: 'var(--game-token-21)',
'margin-top': 'var(--game-token-35)', 'margin-top': 'var(--game-token-35)',
@@ -1014,14 +1023,6 @@ export const gameTheme = {
// Only height is tested, so every phone held sideways matches whatever its width; narrow ones // Only height is tested, so every phone held sideways matches whatever its width; narrow ones
// also match the phone block above, and this block, coming later, wins. // also match the phone block above, and this block, coming later, wins.
'@media (max-height: 520px)': { '@media (max-height: 520px)': {
'.brief-panel': {
'align-self': 'start',
margin: 'var(--game-token-42)',
padding: 'var(--game-token-11)',
},
'#brief-text': {
'font-size': 'var(--game-token-41)',
},
'#minimap-button,\n #minimap': { '#minimap-button,\n #minimap': {
width: 'var(--game-token-72)', width: 'var(--game-token-72)',
height: 'var(--game-token-72)', height: 'var(--game-token-72)',
@@ -1078,26 +1079,6 @@ export const gameTheme = {
'margin-bottom': 'var(--game-token-69)', 'margin-bottom': 'var(--game-token-69)',
}, },
}, },
'.brief-robot': {
float: 'left',
width: 'var(--game-token-82)',
height: 'auto',
margin: 'calc(-1 * var(--game-token-45)) 0 0 var(--game-token-45)',
filter: 'drop-shadow(0 var(--game-token-45) var(--game-token-11) var(--game-token-83))',
},
'.brief-actions': {
display: 'flex',
gap: 'var(--game-token-45)',
'margin-top': 'var(--game-token-45)',
},
'.brief-actions button': {
flex: '1',
padding: 'var(--game-token-36)',
'font-size': 'var(--game-token-41)',
},
'.brief-actions button[hidden]': {
display: 'none',
},
'#ios-install': { '#ios-install': {
width: 'min(var(--game-token-84), 92vw)', width: 'min(var(--game-token-84), 92vw)',
padding: 'var(--game-token-55)', padding: 'var(--game-token-55)',
+7 -2
View File
@@ -28,7 +28,12 @@ describe('game theme', () => {
expect(gameTheme['@media (max-width: 760px)']['@media (max-height: 520px)']).toBeUndefined(); expect(gameTheme['@media (max-width: 760px)']['@media (max-height: 520px)']).toBeUndefined();
}); });
it('lets the briefing scroll so its buttons stay reachable on short screens', () => { it('lets the start screen scroll so its buttons stay reachable on short screens', () => {
expect(gameTheme['#briefing']['overflow-y']).toBe('auto'); expect(gameTheme['#landing']['overflow-y']).toBe('auto');
});
it('keeps start screen buttons large enough to tap', () => {
expect(gameTheme['.start-option']['min-height']).toBe('var(--game-token-77)');
expect(gameTheme['.start-chip']['min-height']).toBe('var(--game-token-74)');
}); });
}); });
+13
View File
@@ -0,0 +1,13 @@
/**
* Builds links that open a position in public map sites.
* @param {{lat: number, lon: number}} position - Latitude and longitude in degrees.
* @returns {{osm: string, google: string}} OpenStreetMap and Google Maps URLs.
*/
export function mapLinks({ lat, lon }) {
const latText = lat.toFixed(6),
lonText = lon.toFixed(6);
return {
osm: `https://www.openstreetmap.org/?mlat=${latText}&mlon=${lonText}#map=17/${latText}/${lonText}`,
google: `https://www.google.com/maps/search/?api=1&query=${latText},${lonText}`,
};
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { mapLinks } from './mapLinks.js';
describe('mapLinks', () => {
it('points both map sites at the same rounded position', () => {
const links = mapLinks({ lat: 36.4501234567, lon: 59.4234567 });
expect(links.osm).toBe(
'https://www.openstreetmap.org/?mlat=36.450123&mlon=59.423457#map=17/36.450123/59.423457',
);
expect(links.google).toContain('query=36.450123,59.423457');
});
});
+1 -1
View File
@@ -69,6 +69,6 @@ test('Brand palette keeps readable contrast', () => {
test('Strings that take values build complete sentences', () => { test('Strings that take values build complete sentences', () => {
assert.equal(strings.prompts.getIn('بی‌ام‌و'), 'F · سوار بی‌ام‌و شو'); assert.equal(strings.prompts.getIn('بی‌ام‌و'), 'F · سوار بی‌ام‌و شو');
assert.equal(strings.units.hetoman('۵۰۰'), '۵۰۰ هتومان'); assert.equal(strings.units.hetoman('۵۰۰'), '۵۰۰ هتومان');
assert.ok(strings.briefing.subtitle.includes(COMPANY.shortName)); assert.ok(strings.landing.logoAlt.includes(COMPANY.shortName));
assert.equal(strings.ranks.length, 6); assert.equal(strings.ranks.length, 6);
}); });