From bc6f472f083ae9adb083dcfdef9de78a4169e4cd Mon Sep 17 00:00:00 2001
From: "hamed@B"
Date: Sat, 31 Oct 2026 13:26:30 +0300
Subject: [PATCH] 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
---
README.md | 26 ++++++++++
index.html | 8 ++-
src/constants/graphics.js | 5 ++
src/game/createGame.js | 11 +++--
src/game/device/webgl.js | 15 ++++++
src/game/runtime/runtimeSetup.js | 14 +++++-
src/hooks/useGameEngine.js | 6 ++-
src/i18n/fa.js | 17 ++++++-
src/main.jsx | 9 +---
src/pages/game/CompanyGame.jsx | 39 +++++++++++++++
src/pages/game/GameLoader.jsx | 41 ++++++++++++++++
src/pages/game/GamePage.jsx | 12 +++--
src/pages/game/GamePoster.jsx | 73 ++++++++++++++++++++++++++++
src/pages/game/GamePoster.test.jsx | 33 +++++++++++++
src/pages/game/GameSurfaces.jsx | 4 --
src/routes/AppRoutes.jsx | 32 +++++++-----
src/utils/seoSummary.js | 78 ++++++++++++++++++++++++++++++
tests/seo.test.js | 25 ++++++++++
vite.config.js | 15 +++++-
19 files changed, 423 insertions(+), 40 deletions(-)
create mode 100644 src/constants/graphics.js
create mode 100644 src/game/device/webgl.js
create mode 100644 src/pages/game/CompanyGame.jsx
create mode 100644 src/pages/game/GameLoader.jsx
create mode 100644 src/pages/game/GamePoster.jsx
create mode 100644 src/pages/game/GamePoster.test.jsx
create mode 100644 src/utils/seoSummary.js
create mode 100644 tests/seo.test.js
diff --git a/README.md b/README.md
index 316236f..b073b51 100644
--- a/README.md
+++ b/README.md
@@ -237,6 +237,32 @@ node scripts/fetch-imagery.mjs
**مجوز:** استفادهٔ تجاری و میزبانی نسخهای از تصاویر Esri نیاز به مجوز مناسب از Esri دارد. شرایط را با Esri بررسی کنید. نام منبع در پایین صفحهٔ بازی نمایش داده میشود.
+## جاسازی در سایت شرکت
+
+بازی کل صفحه را میگیرد، پس بهترین راه جاسازی یک صفحهٔ جدا یا یک `iframe` است:
+
+```html
+
+```
+
+- `?poster`: اول یک پوستر سبک با دکمهٔ «ورود به بازی» نشان داده میشود و بازی سهبعدی فقط با کلیک بارگذاری میشود. این برای صفحهٔ اصلی سایت مناسب است.
+- `?mode=real|offline|about|products`: بازی بدون صفحهٔ شروع، مستقیم در همان حالت شروع میشود. حالت دنیای واقعی همچنان اول پیام ایمنی را نشان میدهد.
+
+در یک پروژهٔ React همین کار با کامپوننت انجام میشود:
+
+```jsx
+import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
+
+;
+```
+
+- `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)` جایگزینشدنیاند.
diff --git a/index.html b/index.html
index 9bbd831..215f6b5 100644
--- a/index.html
+++ b/index.html
@@ -4,11 +4,8 @@
- عملیات مشهد | روبات مارکت
-
+
+
@@ -18,6 +15,7 @@
+
diff --git a/src/constants/graphics.js b/src/constants/graphics.js
new file mode 100644
index 0000000..5055e4e
--- /dev/null
+++ b/src/constants/graphics.js
@@ -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;
diff --git a/src/game/createGame.js b/src/game/createGame.js
index 4cb3094..79cf43b 100644
--- a/src/game/createGame.js
+++ b/src/game/createGame.js
@@ -1,19 +1,24 @@
import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js';
/** 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} Disposes input, timers, sensors and graphics.
*/
-export async function createGame({ root, ui, signal }) {
+export async function createGame({ root, ui, signal, startMode }) {
const context = {
root,
ui,
signal,
+ startMode: null,
+ requestedMode: startMode ?? null,
};
bindRuntime(context);
try {
- for (const initialize of RUNTIME_INITIALIZERS) {
+ for (const [index, initialize] of RUNTIME_INITIALIZERS.entries()) {
if (signal.aborted) break;
await initialize(context);
+ ui.patch('loading', { progress: (index + 1) / RUNTIME_INITIALIZERS.length });
}
} catch (error) {
context.renderer?.dispose();
diff --git a/src/game/device/webgl.js b/src/game/device/webgl.js
new file mode 100644
index 0000000..b56c8c9
--- /dev/null
+++ b/src/game/device/webgl.js
@@ -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;
+ }
+}
diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js
index 8fc64ee..dd4054c 100644
--- a/src/game/runtime/runtimeSetup.js
+++ b/src/game/runtime/runtimeSetup.js
@@ -27,6 +27,11 @@ import { formatDistance, toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.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';
// Brand colours for materials and canvas drawings.
@@ -95,7 +100,10 @@ export async function initializeRenderer(context) {
}
})() ??
(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.
@@ -1247,6 +1255,10 @@ export async function startGameLoop(context) {
hidden: true,
});
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.
diff --git a/src/hooks/useGameEngine.js b/src/hooks/useGameEngine.js
index 0e439b6..f860ed5 100644
--- a/src/hooks/useGameEngine.js
+++ b/src/hooks/useGameEngine.js
@@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react';
/** Mounts one game runtime and releases its GPU, input, sensor and timer resources.
* @param {object} root - React ref containing all render surfaces.
* @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.
*/
-export function useGameEngine(root, bridge) {
+export function useGameEngine(root, bridge, startMode) {
const [error, setError] = useState(null);
const disposeRef = useRef(null);
useEffect(() => {
@@ -23,6 +24,7 @@ export function useGameEngine(root, bridge) {
root: root.current,
ui: bridge,
signal: controller.signal,
+ startMode,
});
if (controller.signal.aborted) dispose();
else disposeRef.current = dispose;
@@ -40,7 +42,7 @@ export function useGameEngine(root, bridge) {
disposeRef.current?.();
disposeRef.current = null;
};
- }, [root, bridge]);
+ }, [root, bridge, startMode]);
return {
error,
};
diff --git a/src/i18n/fa.js b/src/i18n/fa.js
index 7a9252e..a3e54c1 100644
--- a/src/i18n/fa.js
+++ b/src/i18n/fa.js
@@ -31,9 +31,24 @@ export const strings = {
hetomanName: 'هتومان',
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: {
- preparing: 'در حال آمادهسازی بازی…',
buildingCity: 'در حال ساختن شهر مشهد…',
+ /** @type {(percent: string) => string} */
+ loadingPercent: (percent) => `${percent}٪`,
/** @type {(error: string) => string} */
failed: (error) => `بازی اجرا نشد: ${error}`,
retry: 'تلاش دوباره',
diff --git a/src/main.jsx b/src/main.jsx
index ae84265..4d19b11 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,13 +1,8 @@
import { createRoot } from 'react-dom/client';
-import { Provider } from 'react-redux';
import { AppRoutes } from '@/routes/AppRoutes.jsx';
-import { store } from '@/store/index.js';
import '@fontsource/vazirmatn/400.css';
import '@fontsource/vazirmatn/600.css';
import '@fontsource/vazirmatn/800.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.
+createRoot(document.getElementById('root')).render();
diff --git a/src/pages/game/CompanyGame.jsx b/src/pages/game/CompanyGame.jsx
new file mode 100644
index 0000000..7e059f5
--- /dev/null
+++ b/src/pages/game/CompanyGame.jsx
@@ -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 setEntered(true)} />;
+ return (
+
+ }>
+
+
+
+ );
+}
diff --git a/src/pages/game/GameLoader.jsx b/src/pages/game/GameLoader.jsx
new file mode 100644
index 0000000..07bcfd4
--- /dev/null
+++ b/src/pages/game/GameLoader.jsx
@@ -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 (
+
+
+
+
{COMPANY.shortName}
+
+ {/* Width is the live progress, so it is the one inline style here. */}
+
+
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/pages/game/GamePoster.test.jsx b/src/pages/game/GamePoster.test.jsx
new file mode 100644
index 0000000..782069c
--- /dev/null
+++ b/src/pages/game/GamePoster.test.jsx
@@ -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();
+ 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();
+ 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();
+ expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
+ });
+});
diff --git a/src/pages/game/GameSurfaces.jsx b/src/pages/game/GameSurfaces.jsx
index 036dac6..d886026 100644
--- a/src/pages/game/GameSurfaces.jsx
+++ b/src/pages/game/GameSurfaces.jsx
@@ -1,5 +1,4 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
-import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
/** Presents the GameSurfaces view using plain models and semantic native elements.
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
@@ -18,9 +17,6 @@ export function GameSurfaces({ nodes }) {
model={nodes['camera']}
>
-
- {strings.app.buildingCity}
-
>
);
}
diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx
index c4737ad..1757834 100644
--- a/src/routes/AppRoutes.jsx
+++ b/src/routes/AppRoutes.jsx
@@ -1,15 +1,21 @@
-import { Suspense, lazy } from 'react';
-import { strings } from '@/i18n/fa.js';
-const GamePage = lazy(() =>
- import('@/pages/game/GamePage.jsx').then((module) => ({
- default: module.GamePage,
- })),
-);
-/** Loads the one game page on demand while keeping a visible startup status. */
+import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
+import { START_MODES } from '@/constants/startModes.js';
+
+/**
+ * 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.
+ */
+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() {
- return (
- {strings.app.preparing}
}>
-
-
- );
+ return ;
}
diff --git a/src/utils/seoSummary.js b/src/utils/seoSummary.js
new file mode 100644
index 0000000..5881480
--- /dev/null
+++ b/src/utils/seoSummary.js
@@ -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 [
+ `${escapeHtml(strings.seo.title)}`,
+ ``,
+ ].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) => `