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. */}
+
+