feat: embeddable CompanyGame with loader, poster, no-WebGL fallback and SEO
- CompanyGame bundles the store, a branded loader with start-up progress, an optional light poster (Enter the game) and a company-info fallback when WebGL is missing. - startMode prop and ?mode= start straight in a mode; ?poster shows the poster first. - Pixel ratio capped at 1.5 on touch devices and 1.75 on desktop (constants/graphics.js). - Build writes the title, description, Organization JSON-LD and a hidden Persian summary into index.html from the company config. - README explains iframe and component embedding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,32 @@ node scripts/fetch-imagery.mjs
|
|||||||
|
|
||||||
**مجوز:** استفادهٔ تجاری و میزبانی نسخهای از تصاویر Esri نیاز به مجوز مناسب از Esri دارد. شرایط را با Esri بررسی کنید. نام منبع در پایین صفحهٔ بازی نمایش داده میشود.
|
**مجوز:** استفادهٔ تجاری و میزبانی نسخهای از تصاویر Esri نیاز به مجوز مناسب از Esri دارد. شرایط را با Esri بررسی کنید. نام منبع در پایین صفحهٔ بازی نمایش داده میشود.
|
||||||
|
|
||||||
|
## جاسازی در سایت شرکت
|
||||||
|
|
||||||
|
بازی کل صفحه را میگیرد، پس بهترین راه جاسازی یک صفحهٔ جدا یا یک `iframe` است:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<iframe src="https://robotmarkets.ir/game/?poster&mode=about" title="بازی روبات مارکت"
|
||||||
|
style="width:100%;height:100dvh;border:0" allow="fullscreen; geolocation; camera"></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `?poster`: اول یک پوستر سبک با دکمهٔ «ورود به بازی» نشان داده میشود و بازی سهبعدی فقط با کلیک بارگذاری میشود. این برای صفحهٔ اصلی سایت مناسب است.
|
||||||
|
- `?mode=real|offline|about|products`: بازی بدون صفحهٔ شروع، مستقیم در همان حالت شروع میشود. حالت دنیای واقعی همچنان اول پیام ایمنی را نشان میدهد.
|
||||||
|
|
||||||
|
در یک پروژهٔ React همین کار با کامپوننت انجام میشود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
|
||||||
|
|
||||||
|
<CompanyGame startMode="products" showPoster />;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `CompanyGame` استور Redux، صفحهٔ بارگذاری با نوار پیشرفت، پوستر و حالت بدون WebGL را خودش دارد.
|
||||||
|
- اگر مرورگر WebGL نداشته باشد، بهجای بازی پیام کوتاه و اطلاعات شرکت (معرفی، تماس و آدرس) نشان داده میشود.
|
||||||
|
- رنگها و اطلاعات برند از `src/config/company.js` میآیند، نه از props، چون مدلهای سهبعدی هم از همان رنگها ساخته میشوند.
|
||||||
|
- عنوان صفحه، توضیح، دادهٔ ساختیافتهٔ Organization و یک خلاصهٔ فارسی پنهان (برای موتورهای جستوجو) هنگام ساخت از همان فایل در `index.html` نوشته میشوند (`src/utils/seoSummary.js`).
|
||||||
|
- برای کارایی: صفحهٔ بازی و Three.js یک بستهٔ جدا هستند، تراکم پیکسل روی گوشی ۱٫۵ و روی دسکتاپ ۱٫۷۵ محدود است، و هنگام بستن بازی همهٔ هندسهها، متریالها، بافتها، تایمرها و حسگرها آزاد میشوند.
|
||||||
|
|
||||||
## ساختار
|
## ساختار
|
||||||
|
|
||||||
- `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگهای برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکههای اجتماعی، دربارهما و فهرست محصولات. مقدارهای `TODO(company)` جایگزینشدنیاند.
|
- `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگهای برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکههای اجتماعی، دربارهما و فهرست محصولات. مقدارهای `TODO(company)` جایگزینشدنیاند.
|
||||||
|
|||||||
+3
-5
@@ -4,11 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0b1614" />
|
<meta name="theme-color" content="#0b1614" />
|
||||||
<title>عملیات مشهد | روبات مارکت</title>
|
<!-- Title, description and structured data come from the company config (vite.config.js). -->
|
||||||
<meta
|
<!--seo-head-->
|
||||||
name="description"
|
|
||||||
content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاههای وندینگ را در مشهد پیدا کن و امتیاز بگیر."
|
|
||||||
/>
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
@@ -18,6 +15,7 @@
|
|||||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!--seo-summary-->
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Device pixel ratio caps. Rendering at the full ratio of a 3× phone screen costs nine times the
|
||||||
|
// pixels for little visible gain, so it is capped; the low setting renders at 1× for weak devices.
|
||||||
|
export const PIXEL_RATIO_LOW = 1;
|
||||||
|
export const PIXEL_RATIO_MOBILE = 1.5;
|
||||||
|
export const PIXEL_RATIO_DESKTOP = 1.75;
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js';
|
import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js';
|
||||||
/** Starts isolated systems in dependency order and returns their shared lifecycle cleanup.
|
/** Starts isolated systems in dependency order and returns their shared lifecycle cleanup.
|
||||||
* @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount context.
|
* Reports start-up progress (0–1) on the `loading` interface node as each system is ready.
|
||||||
|
* @param {{root:HTMLElement,ui:object,signal:AbortSignal,startMode?:string}} options - React
|
||||||
|
* mount context, and a start mode that skips the start screen.
|
||||||
* @returns {Promise<function>} Disposes input, timers, sensors and graphics.
|
* @returns {Promise<function>} Disposes input, timers, sensors and graphics.
|
||||||
*/
|
*/
|
||||||
export async function createGame({ root, ui, signal }) {
|
export async function createGame({ root, ui, signal, startMode }) {
|
||||||
const context = {
|
const context = {
|
||||||
root,
|
root,
|
||||||
ui,
|
ui,
|
||||||
signal,
|
signal,
|
||||||
|
startMode: null,
|
||||||
|
requestedMode: startMode ?? null,
|
||||||
};
|
};
|
||||||
bindRuntime(context);
|
bindRuntime(context);
|
||||||
try {
|
try {
|
||||||
for (const initialize of RUNTIME_INITIALIZERS) {
|
for (const [index, initialize] of RUNTIME_INITIALIZERS.entries()) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
await initialize(context);
|
await initialize(context);
|
||||||
|
ui.patch('loading', { progress: (index + 1) / RUNTIME_INITIALIZERS.length });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
context.renderer?.dispose();
|
context.renderer?.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Whether this browser can draw the 3D world. Creates a throwaway canvas and releases its context
|
||||||
|
* straight away, so the check costs nothing once the answer is known.
|
||||||
|
* @returns {boolean} True when a WebGL context is available.
|
||||||
|
*/
|
||||||
|
export function supportsWebgl() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
|
||||||
|
gl?.getExtension('WEBGL_lose_context')?.loseContext();
|
||||||
|
return Boolean(gl);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ import { formatDistance, toLocal } from '../../utils/geo.js';
|
|||||||
import { strings } from '../../i18n/fa.js';
|
import { strings } from '../../i18n/fa.js';
|
||||||
import { COMPANY } from '../../config/company.js';
|
import { COMPANY } from '../../config/company.js';
|
||||||
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
||||||
|
import {
|
||||||
|
PIXEL_RATIO_DESKTOP,
|
||||||
|
PIXEL_RATIO_LOW,
|
||||||
|
PIXEL_RATIO_MOBILE,
|
||||||
|
} from '../../constants/graphics.js';
|
||||||
import { createObjectiveArrow } from '../models/objectiveArrow.js';
|
import { createObjectiveArrow } from '../models/objectiveArrow.js';
|
||||||
|
|
||||||
// Brand colours for materials and canvas drawings.
|
// Brand colours for materials and canvas drawings.
|
||||||
@@ -95,7 +100,10 @@ export async function initializeRenderer(context) {
|
|||||||
}
|
}
|
||||||
})() ??
|
})() ??
|
||||||
(matchMedia('(pointer: coarse)').matches ? 'low' : 'high');
|
(matchMedia('(pointer: coarse)').matches ? 'low' : 'high');
|
||||||
context.renderer.setPixelRatio(context.quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75));
|
const cap = matchMedia('(pointer: coarse)').matches ? PIXEL_RATIO_MOBILE : PIXEL_RATIO_DESKTOP;
|
||||||
|
context.renderer.setPixelRatio(
|
||||||
|
context.quality === 'low' ? PIXEL_RATIO_LOW : Math.min(devicePixelRatio, cap),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Initializes scene in dependency order.
|
* Initializes scene in dependency order.
|
||||||
@@ -1247,6 +1255,10 @@ export async function startGameLoop(context) {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
context.frameId = requestAnimationFrame(context.frame);
|
context.frameId = requestAnimationFrame(context.frame);
|
||||||
|
// An embedding page may ask for a mode directly (CompanyGame's startMode, or ?mode=). Real-world
|
||||||
|
// play still shows its safety notice first, and asks for location from the tap on it.
|
||||||
|
if (context.requestedMode === 'real') context.askRealMode(true);
|
||||||
|
else if (context.requestedMode) context.startGame(context.requestedMode);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Initializes dispose in dependency order.
|
* Initializes dispose in dependency order.
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
/** Mounts one game runtime and releases its GPU, input, sensor and timer resources.
|
/** Mounts one game runtime and releases its GPU, input, sensor and timer resources.
|
||||||
* @param {object} root - React ref containing all render surfaces.
|
* @param {object} root - React ref containing all render surfaces.
|
||||||
* @param {object} bridge - Interface publisher owned by this page.
|
* @param {object} bridge - Interface publisher owned by this page.
|
||||||
|
* @param {string} [startMode] - Mode to start in directly, skipping the start screen.
|
||||||
* @returns {{error:string|null}} Startup failure for the accessible loading surface.
|
* @returns {{error:string|null}} Startup failure for the accessible loading surface.
|
||||||
*/
|
*/
|
||||||
export function useGameEngine(root, bridge) {
|
export function useGameEngine(root, bridge, startMode) {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const disposeRef = useRef(null);
|
const disposeRef = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,6 +24,7 @@ export function useGameEngine(root, bridge) {
|
|||||||
root: root.current,
|
root: root.current,
|
||||||
ui: bridge,
|
ui: bridge,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
startMode,
|
||||||
});
|
});
|
||||||
if (controller.signal.aborted) dispose();
|
if (controller.signal.aborted) dispose();
|
||||||
else disposeRef.current = dispose;
|
else disposeRef.current = dispose;
|
||||||
@@ -40,7 +42,7 @@ export function useGameEngine(root, bridge) {
|
|||||||
disposeRef.current?.();
|
disposeRef.current?.();
|
||||||
disposeRef.current = null;
|
disposeRef.current = null;
|
||||||
};
|
};
|
||||||
}, [root, bridge]);
|
}, [root, bridge, startMode]);
|
||||||
return {
|
return {
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-1
@@ -31,9 +31,24 @@ export const strings = {
|
|||||||
hetomanName: 'هتومان',
|
hetomanName: 'هتومان',
|
||||||
speed: 'km/h',
|
speed: 'km/h',
|
||||||
},
|
},
|
||||||
|
// Search-engine text written into index.html at build time (utils/seoSummary.js).
|
||||||
|
seo: {
|
||||||
|
title: `${COMPANY.name} | بازی سهبعدی دستگاههای وندینگ`,
|
||||||
|
description: `${COMPANY.tagline}. کارخانه، محصولات و دستگاههای نصبشدهٔ ${COMPANY.shortName} را در یک بازی سهبعدی در مشهد ببینید.`,
|
||||||
|
summaryLabel: 'خلاصهٔ معرفی شرکت',
|
||||||
|
productsHeading: 'محصولات',
|
||||||
|
},
|
||||||
|
// Poster shown before the 3D game loads, and instead of it when the browser cannot draw 3D.
|
||||||
|
poster: {
|
||||||
|
enter: 'ورود به بازی',
|
||||||
|
noWebgl:
|
||||||
|
'مرورگر یا دستگاه شما بازی سهبعدی را اجرا نمیکند. اطلاعات شرکت را اینجا ببینید یا با مرورگر دیگری امتحان کنید.',
|
||||||
|
heroAlt: `نمای کارخانهٔ ${COMPANY.shortName}`,
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
preparing: 'در حال آمادهسازی بازی…',
|
|
||||||
buildingCity: 'در حال ساختن شهر مشهد…',
|
buildingCity: 'در حال ساختن شهر مشهد…',
|
||||||
|
/** @type {(percent: string) => string} */
|
||||||
|
loadingPercent: (percent) => `${percent}٪`,
|
||||||
/** @type {(error: string) => string} */
|
/** @type {(error: string) => string} */
|
||||||
failed: (error) => `بازی اجرا نشد: ${error}`,
|
failed: (error) => `بازی اجرا نشد: ${error}`,
|
||||||
retry: 'تلاش دوباره',
|
retry: 'تلاش دوباره',
|
||||||
|
|||||||
+2
-7
@@ -1,13 +1,8 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { AppRoutes } from '@/routes/AppRoutes.jsx';
|
import { AppRoutes } from '@/routes/AppRoutes.jsx';
|
||||||
import { store } from '@/store/index.js';
|
|
||||||
import '@fontsource/vazirmatn/400.css';
|
import '@fontsource/vazirmatn/400.css';
|
||||||
import '@fontsource/vazirmatn/600.css';
|
import '@fontsource/vazirmatn/600.css';
|
||||||
import '@fontsource/vazirmatn/800.css';
|
import '@fontsource/vazirmatn/800.css';
|
||||||
import '@/styles/game.css';
|
import '@/styles/game.css';
|
||||||
createRoot(document.getElementById('root')).render(
|
// The store is provided inside CompanyGame, so the game can also be mounted on its own.
|
||||||
<Provider store={store}>
|
createRoot(document.getElementById('root')).render(<AppRoutes />);
|
||||||
<AppRoutes />
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Suspense, lazy, useState } from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { store } from '@/store/index.js';
|
||||||
|
import { supportsWebgl } from '@/game/device/webgl.js';
|
||||||
|
import { GamePoster } from './GamePoster.jsx';
|
||||||
|
import { GameLoader } from './GameLoader.jsx';
|
||||||
|
|
||||||
|
// The game page (and Three.js with it) is a separate chunk, fetched only when the game starts.
|
||||||
|
const GamePage = lazy(() =>
|
||||||
|
import('./GamePage.jsx').then((module) => ({ default: module.GamePage })),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} CompanyGameProps
|
||||||
|
* @property {'real' | 'offline' | 'about' | 'products'} [startMode] - Start straight in this mode
|
||||||
|
* instead of showing the start screen.
|
||||||
|
* @property {boolean} [showPoster] - Show a light poster with an "Enter the game" button first,
|
||||||
|
* so the 3D game only loads when the visitor asks (good for landing pages). Default false.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The whole company game as one self-contained component: store, loader, poster, no-WebGL
|
||||||
|
* fallback and the game itself. Mount it on a full-page route (or in an iframe); the game uses
|
||||||
|
* the whole viewport.
|
||||||
|
* @param {CompanyGameProps} props - Embedding options.
|
||||||
|
* @returns {import('react').ReactElement} Game, poster or fallback.
|
||||||
|
*/
|
||||||
|
export function CompanyGame({ startMode, showPoster = false }) {
|
||||||
|
const [canRender] = useState(supportsWebgl);
|
||||||
|
const [entered, setEntered] = useState(!showPoster);
|
||||||
|
if (!canRender || !entered)
|
||||||
|
return <GamePoster canRender={canRender} onEnter={() => setEntered(true)} />;
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<Suspense fallback={<GameLoader nodes={{}} />}>
|
||||||
|
<GamePage startMode={startMode} />
|
||||||
|
</Suspense>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { COMPANY } from '@/config/company.js';
|
||||||
|
import { ASSETS } from '@/config/assets.js';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object}} GameLoaderProps */
|
||||||
|
/**
|
||||||
|
* Branded loading screen with a progress bar, shown while the 3D world is being built.
|
||||||
|
* @param {GameLoaderProps} props - Interface snapshots; `loading` carries progress (0–1).
|
||||||
|
* @returns {import('react').ReactElement} Loading screen.
|
||||||
|
*/
|
||||||
|
export function GameLoader({ nodes }) {
|
||||||
|
const loading = nodes.loading ?? {};
|
||||||
|
const percent = Math.round((loading.progress ?? 0) * 100);
|
||||||
|
return (
|
||||||
|
<section id="loading" hidden={loading.hidden} aria-busy={!loading.hidden}>
|
||||||
|
<div className="flex w-64 flex-col items-center gap-4 text-center">
|
||||||
|
<img
|
||||||
|
src={ASSETS.brand.logo}
|
||||||
|
alt={strings.landing.logoAlt}
|
||||||
|
className="h-16 w-16 rounded-2xl"
|
||||||
|
/>
|
||||||
|
<p className="text-lg font-bold text-hud">{COMPANY.shortName}</p>
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={strings.app.buildingCity}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={percent}
|
||||||
|
className="h-2 w-full overflow-hidden rounded-full bg-white/10"
|
||||||
|
>
|
||||||
|
{/* Width is the live progress, so it is the one inline style here. */}
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">
|
||||||
|
{loading.text ?? strings.app.buildingCity}{' '}
|
||||||
|
<span dir="ltr">{strings.app.loadingPercent(percent.toLocaleString('fa-IR'))}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,18 +6,23 @@ 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 { GameLoader } from './GameLoader.jsx';
|
||||||
import { StartScreen } from './startScreen/StartScreen.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';
|
||||||
|
|
||||||
/** Composes React views around the isolated Three.js runtime. */
|
/**
|
||||||
export function GamePage() {
|
* Composes React views around the isolated Three.js runtime.
|
||||||
|
* @param {{startMode?: string}} props - Mode to start in directly, skipping the start screen.
|
||||||
|
* @returns {import('react').ReactElement} Game page.
|
||||||
|
*/
|
||||||
|
export function GamePage({ startMode }) {
|
||||||
const root = useRef(null),
|
const root = useRef(null),
|
||||||
store = useStore();
|
store = useStore();
|
||||||
const bridge = useMemo(() => createInterfaceBridge(store), [store]);
|
const bridge = useMemo(() => createInterfaceBridge(store), [store]);
|
||||||
const nodes = useAppSelector(selectNodes);
|
const nodes = useAppSelector(selectNodes);
|
||||||
const { error } = useGameEngine(root, bridge);
|
const { error } = useGameEngine(root, bridge, startMode);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bridge.activate();
|
bridge.activate();
|
||||||
const dispose = setupPwa(bridge);
|
const dispose = setupPwa(bridge);
|
||||||
@@ -41,6 +46,7 @@ export function GamePage() {
|
|||||||
return (
|
return (
|
||||||
<main ref={root}>
|
<main ref={root}>
|
||||||
<GameSurfaces {...props} />
|
<GameSurfaces {...props} />
|
||||||
|
<GameLoader {...props} />
|
||||||
<StartScreen {...props} />
|
<StartScreen {...props} />
|
||||||
<GameHud {...props} />
|
<GameHud {...props} />
|
||||||
<GameDialogs {...props} />
|
<GameDialogs {...props} />
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { COMPANY } from '@/config/company.js';
|
||||||
|
import { ASSETS } from '@/config/assets.js';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { SECTION_BODIES } from './startScreen/InfoSectionContent.jsx';
|
||||||
|
|
||||||
|
// Company sections the poster shows when the 3D game cannot run.
|
||||||
|
const FALLBACK_SECTIONS = ['about', 'contact', 'address'];
|
||||||
|
|
||||||
|
/** @typedef {{canRender: boolean, onEnter: function}} GamePosterProps */
|
||||||
|
/**
|
||||||
|
* Light poster in place of the 3D game: a hero image and an "Enter the game" button, so an
|
||||||
|
* embedding page loads nothing heavy until the visitor asks. When the browser cannot draw 3D it
|
||||||
|
* explains that and shows the company information instead.
|
||||||
|
* @param {GamePosterProps} props - Whether WebGL works, and the callback that starts the game.
|
||||||
|
* @returns {import('react').ReactElement} Poster.
|
||||||
|
*/
|
||||||
|
export function GamePoster({ canRender, onEnter }) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-dvh bg-surface text-hud">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={ASSETS.brand.hero}
|
||||||
|
alt={strings.poster.heroAlt}
|
||||||
|
className="h-[45dvh] w-full object-cover opacity-60"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-surface to-transparent p-6 pt-20">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<p className="text-2xl font-black md:text-4xl">{COMPANY.name}</p>
|
||||||
|
<p className="mt-2 text-primary">{COMPANY.tagline}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<p className="opacity-80">{strings.landing.intro}</p>
|
||||||
|
{canRender ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="start-option start-option-primary mt-6"
|
||||||
|
onClick={onEnter}
|
||||||
|
>
|
||||||
|
{strings.poster.enter}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<FallbackInfo />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company information for browsers without 3D support.
|
||||||
|
* @returns {import('react').ReactElement} Notice and sections.
|
||||||
|
*/
|
||||||
|
function FallbackInfo() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p role="alert" className="mt-6 rounded-xl border border-primary/40 p-4">
|
||||||
|
{strings.poster.noWebgl}
|
||||||
|
</p>
|
||||||
|
{FALLBACK_SECTIONS.map((key) => {
|
||||||
|
const Body = SECTION_BODIES[key];
|
||||||
|
const title = key === 'about' ? strings.landing.history : strings.landing.sections[key];
|
||||||
|
return (
|
||||||
|
<section key={key} className="mt-8">
|
||||||
|
<h2 className="mb-3 text-lg font-bold">{title}</h2>
|
||||||
|
<Body />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { COMPANY } from '@/config/company.js';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { GamePoster } from './GamePoster.jsx';
|
||||||
|
import { GameLoader } from './GameLoader.jsx';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('GamePoster', () => {
|
||||||
|
it('starts the game from the enter button when 3D works', () => {
|
||||||
|
const onEnter = vi.fn();
|
||||||
|
render(<GamePoster canRender onEnter={onEnter} />);
|
||||||
|
expect(screen.getByText(COMPANY.name)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: strings.poster.enter }));
|
||||||
|
expect(onEnter).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains missing 3D support and shows the company information instead', () => {
|
||||||
|
render(<GamePoster canRender={false} onEnter={vi.fn()} />);
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(strings.poster.noWebgl);
|
||||||
|
expect(screen.queryByRole('button', { name: strings.poster.enter })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(COMPANY.about.history)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(COMPANY.contact.email)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GameLoader', () => {
|
||||||
|
it('shows start-up progress as a percentage', () => {
|
||||||
|
render(<GameLoader nodes={{ loading: { progress: 0.42 } }} />);
|
||||||
|
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { strings } from '@/i18n/fa.js';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
|
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
|
||||||
/** Presents the GameSurfaces view using plain models and semantic native elements.
|
/** Presents the GameSurfaces view using plain models and semantic native elements.
|
||||||
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
|
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
|
||||||
@@ -18,9 +17,6 @@ export function GameSurfaces({ nodes }) {
|
|||||||
model={nodes['camera']}
|
model={nodes['camera']}
|
||||||
></GameNode>
|
></GameNode>
|
||||||
<GameNode as="canvas" id="world" model={nodes['world']} />
|
<GameNode as="canvas" id="world" model={nodes['world']} />
|
||||||
<GameNode as="div" id="loading" model={nodes['loading']}>
|
|
||||||
{strings.app.buildingCity}
|
|
||||||
</GameNode>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -1,15 +1,21 @@
|
|||||||
import { Suspense, lazy } from 'react';
|
import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
|
||||||
import { strings } from '@/i18n/fa.js';
|
import { START_MODES } from '@/constants/startModes.js';
|
||||||
const GamePage = lazy(() =>
|
|
||||||
import('@/pages/game/GamePage.jsx').then((module) => ({
|
/**
|
||||||
default: module.GamePage,
|
* Reads the embedding options from the address: `?mode=about` starts in a mode, `?poster` shows
|
||||||
})),
|
* the light poster first. An iframe on the company site can set them in its src.
|
||||||
);
|
* @returns {{startMode?: string, showPoster: boolean}} CompanyGame props.
|
||||||
/** Loads the one game page on demand while keeping a visible startup status. */
|
*/
|
||||||
|
function optionsFromUrl() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const mode = params.get('mode');
|
||||||
|
return {
|
||||||
|
startMode: START_MODES.includes(mode) ? mode : undefined,
|
||||||
|
showPoster: params.has('poster'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app has one page: the company game, configured from the address. */
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return <CompanyGame {...optionsFromUrl()} />;
|
||||||
<Suspense fallback={<p role="status">{strings.app.preparing}</p>}>
|
|
||||||
<GamePage />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { COMPANY } from '../config/company.js';
|
||||||
|
import { strings } from '../i18n/fa.js';
|
||||||
|
|
||||||
|
// Builds the search-engine content written into index.html at build time (see vite.config.js):
|
||||||
|
// the page title and description, a visually hidden Persian summary of the company and its
|
||||||
|
// products, and Organization structured data. Crawlers read it without running the 3D game.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes text for use inside HTML.
|
||||||
|
* @param {string} text - Plain text.
|
||||||
|
* @returns {string} HTML-safe text.
|
||||||
|
*/
|
||||||
|
const escapeHtml = (text) =>
|
||||||
|
String(text).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title and description tags.
|
||||||
|
* @returns {string} HTML for the head.
|
||||||
|
*/
|
||||||
|
export function seoHead() {
|
||||||
|
return [
|
||||||
|
`<title>${escapeHtml(strings.seo.title)}</title>`,
|
||||||
|
`<meta name="description" content="${escapeHtml(strings.seo.description)}" />`,
|
||||||
|
].join('\n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visually hidden summary of the company, its products and contact details.
|
||||||
|
* @returns {string} HTML for the body.
|
||||||
|
*/
|
||||||
|
export function seoSummary() {
|
||||||
|
const { contact, about, products } = COMPANY;
|
||||||
|
const items = products
|
||||||
|
.map((p) => `<li><h3>${escapeHtml(p.name)}</h3><p>${escapeHtml(p.description)}</p></li>`)
|
||||||
|
.join('');
|
||||||
|
const address = strings.landing.fullAddress(contact.address, contact.city);
|
||||||
|
return `<section class="sr-only" aria-label="${escapeHtml(strings.seo.summaryLabel)}">
|
||||||
|
<h1>${escapeHtml(COMPANY.name)}</h1>
|
||||||
|
<p>${escapeHtml(COMPANY.tagline)}</p>
|
||||||
|
<p>${escapeHtml(about.history)}</p>
|
||||||
|
<h2>${escapeHtml(strings.seo.productsHeading)}</h2>
|
||||||
|
<ul>${items}</ul>
|
||||||
|
<h2>${escapeHtml(strings.landing.sections.contact)}</h2>
|
||||||
|
<p>${escapeHtml(address)}</p>
|
||||||
|
<p>${escapeHtml(`${strings.landing.phone}: ${contact.phone}`)}</p>
|
||||||
|
<p>${escapeHtml(`${strings.landing.email}: ${contact.email}`)}</p>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization structured data (schema.org JSON-LD).
|
||||||
|
* @returns {string} Script tag.
|
||||||
|
*/
|
||||||
|
export function seoStructuredData() {
|
||||||
|
const { contact } = COMPANY;
|
||||||
|
const data = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: COMPANY.name,
|
||||||
|
description: COMPANY.tagline,
|
||||||
|
url: contact.website,
|
||||||
|
email: contact.email,
|
||||||
|
telephone: contact.phone,
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
streetAddress: contact.address,
|
||||||
|
addressLocality: contact.city,
|
||||||
|
addressCountry: 'IR',
|
||||||
|
},
|
||||||
|
sameAs: contact.socials.map((social) => social.url),
|
||||||
|
};
|
||||||
|
// "<" is escaped so no value can close the script tag early.
|
||||||
|
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
||||||
|
return `<script type="application/ld+json">${json}</script>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { COMPANY } from '../src/config/company.js';
|
||||||
|
import { seoHead, seoStructuredData, seoSummary } from '../src/utils/seoSummary.js';
|
||||||
|
|
||||||
|
test('The page head carries the company title and description', () => {
|
||||||
|
const head = seoHead();
|
||||||
|
assert.ok(head.includes(`<title>${COMPANY.name}`));
|
||||||
|
assert.match(head, /<meta name="description" content="[^"]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('The hidden summary lists the company, every product and the contact details', () => {
|
||||||
|
const summary = seoSummary();
|
||||||
|
assert.ok(summary.startsWith('<section class="sr-only"'));
|
||||||
|
for (const product of COMPANY.products) assert.ok(summary.includes(product.name));
|
||||||
|
assert.ok(summary.includes(COMPANY.contact.email));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Structured data is valid JSON-LD for the organization', () => {
|
||||||
|
const json = seoStructuredData().replace(/^<script[^>]*>|<\/script>$/g, '');
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
assert.equal(data['@type'], 'Organization');
|
||||||
|
assert.equal(data.name, COMPANY.name);
|
||||||
|
assert.equal(data.address.addressLocality, COMPANY.contact.city);
|
||||||
|
});
|
||||||
+14
-1
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
|
|||||||
import { readdirSync, statSync } from 'node:fs';
|
import { readdirSync, statSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { seoHead, seoStructuredData, seoSummary } from './src/utils/seoSummary.js';
|
||||||
|
|
||||||
const listFiles = (dir) =>
|
const listFiles = (dir) =>
|
||||||
readdirSync(dir).flatMap((name) => {
|
readdirSync(dir).flatMap((name) => {
|
||||||
@@ -62,11 +63,23 @@ self.addEventListener('fetch', event => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes the company's title, description, structured data and a hidden Persian summary into
|
||||||
|
// index.html, so search engines and link previews see real content without running the game.
|
||||||
|
function companySeo() {
|
||||||
|
return {
|
||||||
|
name: 'company-seo',
|
||||||
|
transformIndexHtml: (html) =>
|
||||||
|
html
|
||||||
|
.replace('<!--seo-head-->', `${seoHead()}\n ${seoStructuredData()}`)
|
||||||
|
.replace('<!--seo-summary-->', seoSummary()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Relative assets also load under a GitHub Pages repository path.
|
// Relative assets also load under a GitHub Pages repository path.
|
||||||
base: './',
|
base: './',
|
||||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||||
plugins: [react(), offlineServiceWorker()],
|
plugins: [react(), companySeo(), offlineServiceWorker()],
|
||||||
// Top-level await loads the building and imagery files before the world is built.
|
// Top-level await loads the building and imagery files before the world is built.
|
||||||
build: { target: 'es2022' },
|
build: { target: 'es2022' },
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user