Compare commits
11
Commits
fbfa8d011d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6f472f08 | ||
|
|
53caa0a208 | ||
|
|
71ae4ffa9b | ||
|
|
bced4dc6f6 | ||
|
|
5a4dffc9a3 | ||
|
|
d9ae8a45da | ||
|
|
611960511e | ||
|
|
8dbaf3d97e | ||
|
|
e02347142b | ||
|
|
526796126d | ||
|
|
df930694f7 |
@@ -0,0 +1,387 @@
|
|||||||
|
# AGENTS.md — Frontend Coding Conventions (React & Next.js)
|
||||||
|
|
||||||
|
These rules apply to every task in this repository. Follow them exactly. If a rule conflicts with existing code, follow the rule for new code and do not refactor unrelated files. If a rule is unclear, ask before guessing.
|
||||||
|
|
||||||
|
## 0. How this file is organized
|
||||||
|
|
||||||
|
- **Sections 1–13 are general** and apply to every project (React or Next.js).
|
||||||
|
- **Section 14 is framework-specific.** Detect the project type first:
|
||||||
|
- `next` in `package.json` dependencies → **Next.js** project → also follow 14.1.
|
||||||
|
- Otherwise (Vite, CRA, etc.) → **React** project → also follow 14.2.
|
||||||
|
- Never apply the rules of the other framework.
|
||||||
|
|
||||||
|
## 1. Core principles
|
||||||
|
|
||||||
|
1. **Readability beats cleverness.** Write the simplest code that works. A teammate must understand it on the first read.
|
||||||
|
2. **Easy to extend.** Small, single-purpose functions and components; clear layers; no hidden coupling.
|
||||||
|
3. **Don't repeat yourself, but don't abstract early.** Duplicate once, tolerate it twice, extract on the **third** occurrence (Rule of Three).
|
||||||
|
4. **One source of truth.** All HTTP goes through one Axios client; all app-wide state lives in one Redux store; all shared UI lives in `components/shared`.
|
||||||
|
5. **Documented.** Every function has a doc comment; important places have inline comments.
|
||||||
|
6. **Formatted before it runs.** Prettier runs automatically before dev and build.
|
||||||
|
|
||||||
|
## 2. Ask before you start (mandatory)
|
||||||
|
|
||||||
|
At the **start of every task**, before writing any code, ask the user these questions and wait for the answers:
|
||||||
|
|
||||||
|
1. **Language:** "Should this be written in **TypeScript** or **JavaScript**?"
|
||||||
|
2. **Ant Design:** "Do you need **Ant Design** for this, or should I use **only Tailwind CSS**?"
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Never assume the answers, even if the repo already contains `tsconfig.json` or Ant Design. Always ask.
|
||||||
|
- Skip a question only if the user already answered it explicitly in the current task description.
|
||||||
|
- If Ant Design is not needed, use **Tailwind only** and do not import anything from `antd`.
|
||||||
|
- State the chosen answers in your final summary.
|
||||||
|
|
||||||
|
## 3. Stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Language | **Asked every task** (TypeScript or JavaScript). If TypeScript: `strict: true` |
|
||||||
|
| UI | React 18 (function components + hooks only) |
|
||||||
|
| Framework | Next.js App Router **or** React + Vite (detected, see section 0) |
|
||||||
|
| Global state | Redux Toolkit |
|
||||||
|
| HTTP | Axios (no RTK Query, no raw `fetch`) |
|
||||||
|
| Forms | React Hook Form |
|
||||||
|
| Styling | Tailwind CSS by default. Ant Design **only if the user confirms** |
|
||||||
|
| Formatting | Prettier |
|
||||||
|
| Testing | Vitest + React Testing Library (Cypress for E2E) |
|
||||||
|
|
||||||
|
Do not add a dependency without stating why and asking first.
|
||||||
|
|
||||||
|
## 4. Naming
|
||||||
|
|
||||||
|
### Files and folders
|
||||||
|
- **Component files:** `PascalCase.tsx` / `.jsx` → `UserCard.tsx`
|
||||||
|
- **Everything else:** `camelCase.ts` / `.js` → `useAuth.ts`, `userService.ts`, `authSlice.ts`, `formatDate.ts`
|
||||||
|
- **Folders:** `camelCase` (`userManagement/`). A folder holding one component plus companion files is `PascalCase` (`UserCard/`).
|
||||||
|
- **Tests:** next to the file → `UserCard.test.tsx`
|
||||||
|
- **Type files (TS):** `user.types.ts`
|
||||||
|
- **Exception:** Next.js reserved names (`page.tsx`, `layout.tsx`, `loading.tsx`, `route.ts`) and route folders follow Next.js rules.
|
||||||
|
|
||||||
|
### Code identifiers
|
||||||
|
| Kind | Style | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Variables, functions | camelCase | `totalPrice`, `calculateTotal()` |
|
||||||
|
| Components | PascalCase | `UserCard` |
|
||||||
|
| Hooks | `use` + PascalCase | `useDebounce` |
|
||||||
|
| Types, interfaces | PascalCase, **no `I` prefix** | `User`, `UserCardProps` |
|
||||||
|
| Props type | `<Component>Props` | `UserCardProps` |
|
||||||
|
| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
||||||
|
| Booleans | `is` / `has` / `can` / `should` | `isLoading`, `hasError` |
|
||||||
|
| Internal handlers | `handle` + Event | `handleSubmit` |
|
||||||
|
| Callback props | `on` + Event | `onSubmit` |
|
||||||
|
| Slice | `<feature>Slice` | `authSlice` |
|
||||||
|
| Thunks | verb + noun | `fetchUsers` |
|
||||||
|
| Selectors | `select` + Name | `selectCurrentUser` |
|
||||||
|
| Service functions | verb + noun | `getUsers`, `createOrder` |
|
||||||
|
|
||||||
|
- Prefer union types over `enum`: `type Status = 'idle' | 'loading' | 'failed'`.
|
||||||
|
- Names are descriptive. Abbreviate only `id`, `url`, `api`, `props`.
|
||||||
|
- Identifiers and comments are in **English**.
|
||||||
|
|
||||||
|
## 5. Project structure (layer-based)
|
||||||
|
|
||||||
|
The shared layers are **identical** in both project types and live in `src/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
shared/ # reusable components used by 2+ pages/features
|
||||||
|
layout/ # Header, Sidebar, PageLayout
|
||||||
|
hooks/ # shared custom hooks
|
||||||
|
services/ # Axios client + API functions (the ONLY place for HTTP)
|
||||||
|
apiClient.ts
|
||||||
|
userService.ts
|
||||||
|
store/
|
||||||
|
index.ts # configureStore + RootState/AppDispatch
|
||||||
|
hooks.ts # useAppDispatch, useAppSelector
|
||||||
|
slices/
|
||||||
|
types/
|
||||||
|
utils/ # pure helpers
|
||||||
|
constants/
|
||||||
|
styles/
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the routing layer differs (see section 14):
|
||||||
|
- **Next.js:** `src/app/` (routes only).
|
||||||
|
- **React (Vite):** `src/pages/`, `src/routes/`, `src/main.tsx`, `src/assets/`.
|
||||||
|
|
||||||
|
### Layer rules
|
||||||
|
- Route files (`app/` or `pages/`) compose components and wire data; no business logic beyond that.
|
||||||
|
- A component used by **one** page stays in that page's folder. On its **third** use (or when a second page needs it) move it to `components/shared`.
|
||||||
|
- `components/shared` must be **generic and props-driven**: no imports from pages/routes, `services`, or `store`.
|
||||||
|
- Components never call Axios. They call hooks, dispatch thunks, or use data passed as props.
|
||||||
|
- `utils` are pure functions (no React, no side effects).
|
||||||
|
- No circular imports. Use the `@/` alias for `src/`; avoid `../` chains deeper than two levels.
|
||||||
|
|
||||||
|
## 6. Writing simple, readable code
|
||||||
|
|
||||||
|
- **Max nesting: 2 levels** (an `if` inside a loop is the limit). Flatten with early returns, guard clauses, or by extracting a named function.
|
||||||
|
- **Max function length: ~30 lines.** If longer, split into well-named helpers.
|
||||||
|
- **One function, one job.** If you need "and" to describe it, split it.
|
||||||
|
- **Max 3 parameters.** Beyond that, pass a single object.
|
||||||
|
- **Rule of Three:** repeated logic/markup is extracted on the third occurrence into a util, hook, or `shared` component. Do not pre-abstract.
|
||||||
|
- No nested ternaries. Use `if`/early return or a lookup object.
|
||||||
|
- Name intermediate values instead of writing long chained expressions.
|
||||||
|
- No magic numbers or strings: move them to `constants/`.
|
||||||
|
- Prefer plain `map`/`filter`/`find` over clever `reduce` chains.
|
||||||
|
- Avoid premature optimization: add `useMemo`/`useCallback` only for a measured problem or a memoized child.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Bad: nested and hard to scan
|
||||||
|
function getLabel(user) {
|
||||||
|
if (user) {
|
||||||
|
if (user.isActive) {
|
||||||
|
return user.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: guard clause, flat
|
||||||
|
function getLabel(user: User | null): string {
|
||||||
|
if (!user?.isActive) return 'Unknown';
|
||||||
|
return user.name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Documentation and comments
|
||||||
|
|
||||||
|
### Every function gets a doc comment
|
||||||
|
Every function must have a short JSDoc block directly above it describing **what it does**: components, hooks, helpers, services, thunks, selectors, and handlers.
|
||||||
|
|
||||||
|
- First line: one clear sentence about the purpose.
|
||||||
|
- Add `@param` and `@returns` when the function has parameters or a return value.
|
||||||
|
- One line is enough for trivial functions.
|
||||||
|
- Not required for tiny inline callbacks (e.g. inside `.map()`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Returns the display name of a user, or "Unknown" when the user is inactive.
|
||||||
|
* @param user - The user to read the name from.
|
||||||
|
* @returns The name to show in the UI.
|
||||||
|
*/
|
||||||
|
function getLabel(user: User | null): string {
|
||||||
|
if (!user?.isActive) return 'Unknown';
|
||||||
|
return user.name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline comments for important places
|
||||||
|
Always comment:
|
||||||
|
- **Why** something is done in a non-obvious way (workarounds, browser/device quirks, offline handling).
|
||||||
|
- Business rules, calculations, and necessary magic values.
|
||||||
|
- Complex conditions, regex, and data transformations.
|
||||||
|
- Side effects and cleanup in `useEffect`, and why the dependencies are what they are.
|
||||||
|
- Anything that looks like a bug but is intentional.
|
||||||
|
- Open work as `// TODO: <what and why>`.
|
||||||
|
|
||||||
|
Do **not** write comments that only repeat the code (`// increment i`). No commented-out code. No leftover `console.log`.
|
||||||
|
|
||||||
|
## 8. Components
|
||||||
|
|
||||||
|
- Function declarations for components; arrow functions for inline callbacks.
|
||||||
|
- One component per file, under ~150 lines. Split when larger.
|
||||||
|
- Define a props type right above the component (TS) or a `@typedef` (JS). Do not use `React.FC`.
|
||||||
|
- **Named exports only.** Default export only where a framework requires it (Next.js special files, `React.lazy` pages).
|
||||||
|
- Order: hooks → derived values → handlers → effects → early returns → JSX.
|
||||||
|
- Always render **loading, error, and empty** states for async UI.
|
||||||
|
- Extract repeated logic into custom hooks and repeated UI into `components/shared` (Rule of Three).
|
||||||
|
- Semantic HTML and basic accessibility (labels, alt text, focus).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type UserCardProps = {
|
||||||
|
user: User;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a clickable card for one user and reports the selection.
|
||||||
|
*/
|
||||||
|
export function UserCard({ user, onSelect }: UserCardProps) {
|
||||||
|
const handleClick = () => onSelect(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handleClick}>
|
||||||
|
{user.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. TypeScript / JavaScript
|
||||||
|
|
||||||
|
Follow the answer to the question in section 2.
|
||||||
|
|
||||||
|
### If TypeScript
|
||||||
|
- No `any`. Use `unknown` and narrow, or define a type.
|
||||||
|
- Type all parameters and exported return values; let inference handle locals.
|
||||||
|
- Request/response shapes live in `types/` and are shared by services and slices.
|
||||||
|
- `type` for unions and props; `interface` only when extending.
|
||||||
|
- No non-null assertion (`!`) without a comment explaining why it is safe.
|
||||||
|
- Files use `.ts` / `.tsx`.
|
||||||
|
|
||||||
|
### If JavaScript
|
||||||
|
- Files use `.js` / `.jsx`. Same naming and structure rules apply.
|
||||||
|
- Describe data shapes with JSDoc (`@typedef`, `@param {Type}`, `@returns {Type}`), kept in `types/`.
|
||||||
|
- Document props with a `@typedef` above the component.
|
||||||
|
- `const`/`let` only (never `var`), strict equality (`===`), optional chaining where needed.
|
||||||
|
|
||||||
|
## 10. API layer (all requests via Axios)
|
||||||
|
|
||||||
|
- **Every HTTP request goes through Axios.** Do not use `fetch`, `XMLHttpRequest`, or other HTTP clients. (WebSocket is not HTTP; see 14.2.)
|
||||||
|
- There is **one Axios instance**: `services/apiClient.ts`. It configures `baseURL`, timeout, auth header, and interceptors for token refresh and error normalization.
|
||||||
|
- Requests are made **only inside `services/`**, one file per domain (`userService.ts`). Services are plain async functions that return data and know nothing about Redux or React.
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Fetches the list of all users from the API.
|
||||||
|
* @returns The users array.
|
||||||
|
*/
|
||||||
|
export async function getUsers(): Promise<User[]> {
|
||||||
|
const { data } = await apiClient.get<User[]>('/users');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Endpoint paths are constants (in the service file or `constants/endpoints.ts`), not scattered strings.
|
||||||
|
- Errors are normalized **once** in the interceptor into a single shape (`{ message, status }`). Callers show `error.message`; they do not parse Axios errors themselves.
|
||||||
|
- Cancel or ignore stale requests with `AbortController` when a component can unmount mid-request.
|
||||||
|
|
||||||
|
## 11. Global state (Redux Toolkit)
|
||||||
|
|
||||||
|
The store holds **only** these categories. Anything else stays local (`useState`, or a small hook calling a service).
|
||||||
|
|
||||||
|
| Category | Slice | Holds |
|
||||||
|
|---|---|---|
|
||||||
|
| Auth & current user | `authSlice` | token, profile, permissions |
|
||||||
|
| General UI settings | `uiSlice` | theme, language, sidebar state |
|
||||||
|
| Device connection | `deviceSlice` | online/offline status, connected device info (only if the project talks to devices) |
|
||||||
|
| Shared cross-page data | one slice per domain (e.g. `lookupsSlice`) | data needed by 2+ pages |
|
||||||
|
| Shopping cart | `cartSlice` | cart items and quantities (header badge, cart page, product pages) |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- One slice per feature in `store/slices/`. Slice shape for fetched data:
|
||||||
|
`{ items, status: 'idle' | 'loading' | 'succeeded' | 'failed', error: string | null }`.
|
||||||
|
- Async work uses `createAsyncThunk` that calls a **service**; handle `pending/fulfilled/rejected` in `extraReducers`.
|
||||||
|
- Use typed `useAppDispatch` / `useAppSelector` only (plain equivalents in JS).
|
||||||
|
- Write selectors beside the slice; components do not read deep state shapes directly.
|
||||||
|
- Page-local data (a table on one page, form state, open/closed flags) does **not** go in Redux.
|
||||||
|
- Keep the state shape documented: when adding a slice, add one line to the table above.
|
||||||
|
|
||||||
|
## 12. Formatting (Prettier is mandatory)
|
||||||
|
|
||||||
|
Code **must** be formatted with Prettier before the project runs in dev or build.
|
||||||
|
|
||||||
|
`package.json` scripts:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||||
|
"predev": "npm run format",
|
||||||
|
"prebuild": "npm run format",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint . --max-warnings 0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`predev` and `prebuild` run automatically before `npm run dev` / `npm run build`. `typecheck` is TypeScript only.)
|
||||||
|
|
||||||
|
`.prettierrc`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run `npm run format` after editing files and before reporting a task as done.
|
||||||
|
- Never hand-format against Prettier; never use `// prettier-ignore` without a reason in a comment.
|
||||||
|
- ESLint passes with zero warnings (`react-hooks`, `@typescript-eslint` if TS, import order).
|
||||||
|
- Import order: React → third-party → `@/` aliases → relative → styles. Remove unused imports.
|
||||||
|
|
||||||
|
## 13. Styling, testing, and git
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- **Default is Tailwind CSS only.** Use Ant Design **only** if the user said yes in section 2.
|
||||||
|
- Tailwind utilities for layout/spacing; `clsx` for conditional classes; extract a component when the same class list repeats 3+ times.
|
||||||
|
- When Ant Design is approved: customize via theme tokens, not CSS overrides, and do not mix it with custom components that do the same job.
|
||||||
|
- No inline `style` except for truly dynamic values. No hard-coded colors or sizes: use tokens.
|
||||||
|
- Mobile-first and responsive; for Persian UI use RTL-safe logical utilities (`ms-*`, `me-*`, `ps-*`, `pe-*`).
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Test behavior via React Testing Library; query by role/label/text.
|
||||||
|
- Mock the network at the **service** boundary; never call real APIs.
|
||||||
|
- New utils, slices, and non-trivial components ship with tests; bug fixes ship with a regression test.
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Branches: `feature/<name>`, `fix/<name>`. Commits: Conventional Commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`).
|
||||||
|
- Keep changes small and focused; do not mix refactors with features.
|
||||||
|
|
||||||
|
## 14. Framework-specific rules
|
||||||
|
|
||||||
|
### 14.1 Next.js (App Router)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/ # routes only: page.tsx, layout.tsx, loading.tsx, route.ts
|
||||||
|
components/ hooks/ services/ store/ types/ utils/ constants/ styles/
|
||||||
|
```
|
||||||
|
|
||||||
|
- Components are **Server Components by default**. Add `'use client'` to the smallest leaf that needs state, effects, or browser APIs.
|
||||||
|
- Public/SEO data is fetched on the server (the same Axios client may be used server-side), not through Redux.
|
||||||
|
- Redux and browser-only hooks live in client components, provided via a client `Providers` component. Create the store only if client state is actually needed.
|
||||||
|
- Every page defines metadata (`generateMetadata`) with title and description.
|
||||||
|
- Use `next/image` and `next/link`. Use route handlers (`route.ts`) only when an API layer is truly needed.
|
||||||
|
- Never touch `window`/`localStorage` during server rendering.
|
||||||
|
- Default exports are allowed for `page`, `layout`, `loading`, `error`, `not-found`.
|
||||||
|
|
||||||
|
### 14.2 React (Vite SPA / PWA / embedded UI)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
assets/ # images, fonts, icons
|
||||||
|
pages/ # one folder per route; page-specific components live here
|
||||||
|
routes/ # router config, route guards
|
||||||
|
main.tsx
|
||||||
|
components/ hooks/ services/ store/ types/ utils/ constants/ styles/ tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
- Lazy-load pages with `React.lazy` + `Suspense`.
|
||||||
|
- Assume **unreliable or no network** if the app targets devices (vending, PWA). Handle offline state explicitly; never block the UI on a failed request.
|
||||||
|
- Device communication over HTTP goes through Axios in `services/`. WebSocket/serial live in a dedicated `services/` module with typed messages and reconnect logic, updating `deviceSlice`.
|
||||||
|
- For ESP32 / Orange Pi targets keep bundles small: avoid heavy dependencies and unused polyfills.
|
||||||
|
- Document PWA caching/queue strategy in the PR description.
|
||||||
|
|
||||||
|
## 15. Refactoring an existing project
|
||||||
|
|
||||||
|
When asked to refactor a project to match this file:
|
||||||
|
|
||||||
|
1. **Detect the project type** (section 0) and ask the questions in section 2 before starting. Do not migrate between TypeScript and JavaScript unless explicitly asked.
|
||||||
|
2. **Audit first.** Scan the project and report what violates these rules (structure, naming, HTTP calls outside `services/`, missing doc comments, deep nesting, duplicated code, unformatted files). Do not change code yet; present a short plan and wait for approval.
|
||||||
|
3. **Preserve behavior.** A refactor must not change what the app does. No new features, no UI changes, no dependency upgrades.
|
||||||
|
4. **Work in small steps,** one concern at a time, in this order:
|
||||||
|
1. Add Prettier config and scripts, then format the project (formatting-only commit).
|
||||||
|
2. Centralize HTTP: create `apiClient` and move every request into `services/`.
|
||||||
|
3. Restructure folders and rename files per sections 4 and 5; update imports.
|
||||||
|
4. Organize global state per section 11; move page-local state out of Redux.
|
||||||
|
5. Extract duplicated code (Rule of Three) and move reusable components to `components/shared`.
|
||||||
|
6. Flatten nesting and split long functions (section 6).
|
||||||
|
7. Add doc comments and important inline comments (section 7).
|
||||||
|
5. **Verify after every step:** run `format`, `typecheck` (TS only), `lint`, and `test`, and make sure the project still builds. Fix breakage before moving on.
|
||||||
|
6. **One commit per step,** using Conventional Commits (`refactor: ...`).
|
||||||
|
7. **Do not touch** anything you are unsure about; list it in the final report instead.
|
||||||
|
|
||||||
|
## 16. Agent checklist (before finishing any task)
|
||||||
|
|
||||||
|
1. Did I detect the project type and ask the TypeScript/JavaScript and Ant Design questions (section 2)?
|
||||||
|
2. Did I read the surrounding code and match its patterns?
|
||||||
|
3. Is new code in the correct layer (section 5)? Any new HTTP call goes in `services/` via `apiClient`.
|
||||||
|
4. Nesting ≤ 2, functions ≤ ~30 lines, no third copy of repeated code?
|
||||||
|
5. Does every function have a JSDoc description, and do important places have inline comments?
|
||||||
|
6. Did I run `npm run format`, `typecheck` (TS only), `lint`, and `test`?
|
||||||
|
7. Did I summarize what changed, the chosen language/UI library, assumptions, and follow-ups?
|
||||||
|
8. Did I avoid touching unrelated files, configs, and dependencies?
|
||||||
@@ -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:
|
||||||
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
|
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
|
||||||
- همهٔ خیابانها و ساختمانها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
|
- همهٔ خیابانها و ساختمانها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
|
||||||
@@ -50,12 +61,18 @@ npm run format:check
|
|||||||
| خرید از دستگاه | E | دکمهٔ E |
|
| خرید از دستگاه | E | دکمهٔ E |
|
||||||
| کشیدن کارت | C | دکمهٔ E |
|
| کشیدن کارت | C | دکمهٔ E |
|
||||||
| ترمز دستی | Space | — |
|
| ترمز دستی | Space | — |
|
||||||
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
|
| نقشهٔ شهر | M یا «نقشه» در منو | «نقشه» در منو |
|
||||||
|
|
||||||
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین در بازی عادی سومشخص است و در حالت دنیای واقعی اولشخص.
|
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین در بازی عادی سومشخص است و در حالت دنیای واقعی اولشخص.
|
||||||
|
|
||||||
روی گوشی، اعلانها (گفتوگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده میشوند. خود دکمهٔ خرید هم قابل لمس است.
|
روی گوشی، اعلانها (گفتوگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده میشوند. خود دکمهٔ خرید هم قابل لمس است.
|
||||||
|
|
||||||
|
### رابط کاربری کممتن
|
||||||
|
|
||||||
|
روی صفحه فقط اینها همیشه دیده میشوند: دکمهٔ گرد منو (گوشهٔ بالا)، نشانگر هدف بعدی (بالای صفحه، با فلشی که به سمت هدف میچرخد و فاصله؛ لمسش فهرست مأموریتها را باز میکند) و کنترلهای لمسی. سرعت فقط هنگام رانندگی دیده میشود.
|
||||||
|
|
||||||
|
بقیه در منو هستند: نقشه، مأموریتها، وضعیت (بودجه، ساعت، درجه و امتیاز)، محصولات (فقط داخل کارخانه)، دنیای واقعی، دوربین و حالت پایدار (فقط در حالت دنیای واقعی)، صدا، گرافیک، نقشهٔ کوچک (بهطور پیشفرض خاموش)، تمامصفحه، راهنما، شروع دوباره و بازگشت به منوی اصلی. منو با لمس دوباره، لمس بیرون آن یا Escape بسته میشود.
|
||||||
|
|
||||||
## نقشه و مسیریابی
|
## نقشه و مسیریابی
|
||||||
|
|
||||||
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
||||||
@@ -70,7 +87,24 @@ npm run format:check
|
|||||||
| خرید از هر پنج نوع مکان شهری | ۵۰۰ |
|
| خرید از هر پنج نوع مکان شهری | ۵۰۰ |
|
||||||
| خرید در حالت دنیای واقعی | ۲۰۰ |
|
| خرید در حالت دنیای واقعی | ۲۰۰ |
|
||||||
|
|
||||||
هفت مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجهها از «تازهوارد» تا «افسانهٔ مشهد» بالا میروند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریتها انجام شود، عملیات تمام میشود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
|
مأموریتها امتیاز و پاداش نقدی (هتومان) دارند. درجهها از «تازهوارد» تا «افسانهٔ مشهد» بالا میروند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریتها انجام شود، عملیات تمام میشود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
|
||||||
|
|
||||||
|
## مأموریتها
|
||||||
|
|
||||||
|
هر مأموریت چند گام کوتاه دارد و نشانگر هدف بالای صفحه همیشه گام فعلی را نشان میدهد. یک فلش سهبعدی به رنگ برند هم بالای دستگاهِ هدف شناور است و مسیر طلایی تا آن کشیده میشود. بیشتر مأموریتها دربارهٔ دستگاههای نصبشده در بوستان ملتاند (ورودی جنوبی، کنار دریاچه و زمینهای ورزشی):
|
||||||
|
|
||||||
|
| مأموریت | گامها |
|
||||||
|
|---|---|
|
||||||
|
| اولین خرید در کارخانه | رفتن به دستگاه دفتر فنی ← خرید |
|
||||||
|
| دستگاه ورودی بوستان ملت | پیدا کردن روی نقشه ← رفتن ← خرید |
|
||||||
|
| بازدید از دستگاه کنار دریاچه | رفتن ← ایستادن کنار دستگاه (کارت مشخصات باز میشود) |
|
||||||
|
| سفارش دستگاه برای زمین ورزشی | رفتن ← ایستادن کنار دستگاه ← «ثبت درخواست سفارش» در کارت |
|
||||||
|
| گشت دستگاههای بوستان | خرید از دستگاه دریاچه ← خرید از دستگاه زمین ورزشی |
|
||||||
|
| کاشف شهر | خرید از پنج دستگاه مختلف شهر |
|
||||||
|
|
||||||
|
کارت دستگاه مدل نصبشده در آن محل (از فهرست محصولات `src/config/company.js`)، مشخصات و قیمتش، دکمهٔ سفارش و تماس با فروش را نشان میدهد.
|
||||||
|
|
||||||
|
برای تغییر مأموریتها، فهرست `src/game/missions/missions.js` را ویرایش کنید (نوع گامها: `goto`، `locate`، `inspect`، `order`، `buy`، `places`) و عنوانش را در `missions` و متن گامها را در `steps` فایل `src/i18n/fa.js` بنویسید. دستگاهها و مدل نصبشده در هر محل در `src/constants/locations.js` هستند.
|
||||||
|
|
||||||
## نصب روی گوشی (PWA)
|
## نصب روی گوشی (PWA)
|
||||||
|
|
||||||
@@ -190,7 +224,7 @@ node scripts/build-buildings.mjs <فایل quadkey ایران، مثلاً 12301
|
|||||||
|
|
||||||
### تصویر ماهوارهای (Esri World Imagery)
|
### تصویر ماهوارهای (Esri World Imagery)
|
||||||
|
|
||||||
زمین بازی تصویر ماهوارهای واقعی است و جادهها، لاینها، پارکها و حیاطها همانطور که در واقعیت هستند دیده میشوند. با وجود تصویر، جادهها و زمینهای رسمشده ساخته نمیشوند.
|
**فعلاً خاموش است.** زمین بازی از خیابانها، پارکها و کاربری زمین OpenStreetMap کشیده میشود. برای برگرداندن زمین ماهوارهای، `SATELLITE_GROUND` در `src/constants/world.js` را `true` کنید. در آن حالت جادهها، لاینها، پارکها و حیاطها از تصویر واقعی دیده میشوند و جادهها و زمینهای رسمشده ساخته نمیشوند.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node scripts/fetch-imagery.mjs
|
node scripts/fetch-imagery.mjs
|
||||||
@@ -203,14 +237,43 @@ 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/assets.js`: مسیر همهٔ فایلها (لوگو، تصویر محصولات، چهرههای روبات، مدلهای سهبعدی و دادهٔ نقشه) با قالب و اندازهٔ مورد انتظار هر کدام. موارد `TODO(asset)` جای فایل واقعیاند.
|
||||||
|
- `src/i18n/fa.js`: همهٔ متنهای فارسی رابط کاربری، گفتوگوها، مأموریتها و نوشتههای روی صحنه. تست `tests/content.test.js` جلوی متن فارسی مستقیم در کد را میگیرد.
|
||||||
- `src/game/createGame.js و src/game/runtime/`: صحنه، دوربین سومشخص، کنترلها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
|
- `src/game/createGame.js و src/game/runtime/`: صحنه، دوربین سومشخص، کنترلها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
|
||||||
- `src/game/world/city.js`: ساخت شهر سهبعدی از دادهها (خیابان، پیادهرو، ساختمان، پارک، درخت، ورودی مترو، کوهها) در تکههای ۲ کیلومتری که دورها پنهان میشوند
|
- `src/game/world/city.js`: ساخت شهر سهبعدی از دادهها (خیابان، پیادهرو، ساختمان، پارک، درخت، ورودی مترو، کوهها) در تکههای ۲ کیلومتری که دورها پنهان میشوند
|
||||||
- `src/game/world/mapRender.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشیهای کششده و نمای کلی) و نقشهٔ ایران
|
- `src/game/world/mapRender.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشیهای کششده و نمای کلی) و نقشهٔ ایران
|
||||||
- `src/game/world/routing.js`: گراف خیابانها، مسیریابی A* و پیدا کردن نام خیابان
|
- `src/game/world/routing.js`: گراف خیابانها، مسیریابی A* و پیدا کردن نام خیابان
|
||||||
- `src/game/world/collision.js`: برخورد با ساختمانها برای شخصیتها، ماشین و دوربین
|
- `src/game/world/collision.js`: برخورد با ساختمانها برای شخصیتها، ماشین و دوربین
|
||||||
- `src/constants/locations.js`: دستگاهها، آدرسها، ساعت کاری و محصولات
|
- `src/constants/locations.js`: دستگاههای نصبشده در شهر، آدرسها، ساعت کاری و محصولات
|
||||||
- `src/game/economy/gameState.js`، `src/game/economy/payment.js`، `src/game/economy/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریتها
|
- `src/game/economy/gameState.js`، `src/game/economy/payment.js`، `src/game/economy/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریتها
|
||||||
- `src/game/models/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاهها بیرون میآید
|
- `src/game/models/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاهها بیرون میآید
|
||||||
- `src/game/models/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، بههمراه دفترها، کامیون و ورود مدیر
|
- `src/game/models/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، بههمراه دفترها، کامیون و ورود مدیر
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
پنج بخش تولید: خمکاری، برش لیزر، رنگکاری، یخچالسازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاههای آماده، بستهبندی و بارگیری. کامیون هشت بسته را بار میزند، خارج میشود، مدتی غایب میماند، بازمیگردد و دندهعقب به سکو میرسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمیدهد.
|
پنج بخش تولید: خمکاری، برش لیزر، رنگکاری، یخچالسازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاههای آماده، بستهبندی و بارگیری. کامیون هشت بسته را بار میزند، خارج میشود، مدتی غایب میماند، بازمیگردد و دندهعقب به سکو میرسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمیدهد.
|
||||||
|
|
||||||
مدیر با وانت سفید به سبک KMC T8 وارد میشود، پارک میکند، تا دفتر راه میرود و مینشیند؛ گفتوگوی او بعد از نشستن فعال میشود. گفتوگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیکشدن فعال میشود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجعاند.
|
مدیر با بوگاتی مشکی وارد میشود، پارک میکند (از آن به بعد ماشینش سوارشدنی است)، تا دفتر راه میرود و مینشیند؛ گفتوگوی او بعد از نشستن فعال میشود. گفتوگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیکشدن فعال میشود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجعاند.
|
||||||
|
|
||||||
## خارج از دامنهٔ فعلی
|
## خارج از دامنهٔ فعلی
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
- موبایل joystick و دکمههای F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت میکشد.
|
- موبایل joystick و دکمههای F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت میکشد.
|
||||||
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرمسازی است. برخورد دایرهای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود میشود.
|
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرمسازی است. برخورد دایرهای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود میشود.
|
||||||
|
|
||||||
## ماشین
|
## ماشینها
|
||||||
|
|
||||||
|
همهٔ وسیلههای حیاط کارخانه مشکیاند و سوارشدنی: بیاموی خانواده کنار نقطهٔ شروع، لامبورگینی، مزدا و نیسان در پارکینگ کارکنان، و بوگاتی آقای محمدی پس از اینکه پارک کرد. هر ماشین ترافیک شهر هم با F گرفته میشود و تا reset همانجا که رها شده میماند. کامیون بارگیری تزئینی است و سوار نمیشود. همه در `runtime/garage.js` نگهداری میشوند.
|
||||||
|
|
||||||
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیادهشدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیادهشدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان میدهد. سمت خروج راننده بررسی برخورد میشود.
|
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیادهشدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیادهشدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان میدهد. سمت خروج راننده بررسی برخورد میشود.
|
||||||
|
|
||||||
ثابتهای `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر میشود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. تصادف ترافیک سرعت را کم میکند و NPC پس از تماس طولانی حذف/بازپدید میشود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
|
ثابتهای `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر میشود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. برخورد بین وسیلهها با جعبهٔ جهتدار به اندازهٔ واقعی مدل (SAT در `world/vehicleCollision.js`) است، پس فقط وقتی بدنهها واقعاً به هم برسند سرعت کم میشود و ماشین از هر فاصلهای که واقعاً جا شود رد میشود. کاهش سرعت به زاویهٔ برخورد بستگی دارد: شاخبهشاخ ۶۰٪، مالیدن کناری تقریباً هیچ. ترافیک فقط برای چیزی که واقعاً در عرض خطش باشد ترمز میکند و NPC پس از تماس طولانی حذف/بازپدید میشود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
|
||||||
|
|
||||||
## پول و محصول
|
## پول و محصول
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ JavaScript ES Modules، Three.js/WebGL، Vite، HTML و CSS فارسی RTL و Ca
|
|||||||
| characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن |
|
| characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن |
|
||||||
| factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون |
|
| factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون |
|
||||||
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
|
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
|
||||||
| car-models.js | سدان خانواده، pickup مدیر و کامیون |
|
| car-models.js | سدان خانواده، کامیون و helperهای مشترک بدنه |
|
||||||
| vehicle.js | state و فیزیک آرکید ماشین |
|
| luxuryCars.js | ماشینهای لوکس مشکی به سبک بوگاتی، لامبورگینی، مزدا و نیسان |
|
||||||
|
| vehicle.js | state و فیزیک آرکید ماشین (`CAR`) |
|
||||||
|
| vehicleCollision.js | برخورد جعبهٔ جهتدار وسیلهها و هلدادن پیاده از وسیلهٔ پارکشده |
|
||||||
|
| garage.js | فهرست وسیلههای سوارشدنی، سوار/پیاده، برخورد رانندگی و reset |
|
||||||
| traffic.js | ماشینهای NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
|
| traffic.js | ماشینهای NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
|
||||||
| city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility |
|
| city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility |
|
||||||
| collision.js | spatial grid چندضلعیها، resolveCircle و clearFraction دوربین |
|
| collision.js | spatial grid چندضلعیها، resolveCircle و clearFraction دوربین |
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ buildGraph گراف پیاده با همهٔ مسیرها، و سواره با r
|
|||||||
- city بخشهای ۲۰۰۰ متری و ساختمانها بلوکهای ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند.
|
- city بخشهای ۲۰۰۰ متری و ساختمانها بلوکهای ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند.
|
||||||
- `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery همزمان حداکثر ۶ درخواست میفرستد؛ tileهای دور geometry/material/texture آزاد میکنند.
|
- `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery همزمان حداکثر ۶ درخواست میفرستد؛ tileهای دور geometry/material/texture آزاد میکنند.
|
||||||
- زوم۱۷: شعاع ۴۵۰ متر در low و ۷۵۰ در high؛ زوم۱۵: ۱۸۰۰ و ۲۸۰۰. محدودهٔ حذف ۱٫۲ برابر range است.
|
- زوم۱۷: شعاع ۴۵۰ متر در low و ۷۵۰ در high؛ زوم۱۵: ۱۸۰۰ و ۲۸۰۰. محدودهٔ حذف ۱٫۲ برابر range است.
|
||||||
- با index ماهوارهای موجود، زمین/جادهٔ procedural شهر جایگزین میشود. نبود دانلود bin/index fallback دارد؛ index موجود با JPGهای غایب لزوماً به fallback بدون تصویر تبدیل نمیشود.
|
- زمین ماهوارهای با `SATELLITE_GROUND = false` در `src/constants/world.js` خاموش است. در این حالت index دانلود نمیشود و زمین و جادهها از OSM کشیده میشوند. اگر روشن شود و index موجود باشد، زمین/جادهٔ procedural شهر جایگزین میشود. نبود دانلود bin/index fallback دارد؛ index موجود با JPGهای غایب لزوماً به fallback بدون تصویر تبدیل نمیشود.
|
||||||
- portraitهای روبات: front، happy، wink، surprised، laugh، love، curious، serious، excited؛ نام فایل با face زیرنویس تطبیق دارد.
|
- portraitهای روبات: front، happy، wink، surprised، laugh، love، curious، serious، excited؛ نام فایل با face زیرنویس تطبیق دارد.
|
||||||
- آیکونهای PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند.
|
- آیکونهای PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند.
|
||||||
|
|
||||||
|
|||||||
+4
-6
@@ -3,12 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<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="#0d1412" />
|
<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,52 @@
|
|||||||
|
// Simple line icons (24 × 24, stroke drawn in currentColor) shared by the start screen and HUD.
|
||||||
|
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',
|
||||||
|
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||||
|
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||||
|
chart: 'M5 20V10M12 20V4M19 20v-7',
|
||||||
|
camera: 'M4 8h3l2-3h6l2 3h3v11H4zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||||
|
target: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 12h.01',
|
||||||
|
sound: 'M4 9h4l5-4v14l-5-4H4zM16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11',
|
||||||
|
sliders: 'M4 7h10M18 7h2M4 17h4M12 17h8M16 4v6M10 14v6',
|
||||||
|
compass: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM15.5 8.5l-2 5-5 2 2-5z',
|
||||||
|
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
|
||||||
|
refresh: 'M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6',
|
||||||
|
home: 'M4 11l8-7 8 7M6 9.5V20h12V9.5',
|
||||||
|
arrow: 'M12 4l6 8h-4v8h-4v-8H6z',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @typedef {{name: keyof typeof PATHS, className?: string}} IconProps */
|
||||||
|
/**
|
||||||
|
* Draws one icon; decorative, so hidden from screen readers.
|
||||||
|
* @param {IconProps} props - Icon name and classes.
|
||||||
|
* @returns {import('react').ReactElement} SVG icon.
|
||||||
|
*/
|
||||||
|
export function Icon({ 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 { useEffect, useRef } from 'react';
|
||||||
|
import { Icon } from './Icon.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{id: string, open: boolean, title: string, closeLabel: string, onClose: function,
|
||||||
|
* children?: import('react').ReactNode}} SheetProps */
|
||||||
|
/**
|
||||||
|
* Modal panel built on the native dialog (focus trap and Escape for free). Styled by the
|
||||||
|
* `.info-sheet` rules: a centred card on wide screens, a bottom sheet on phones.
|
||||||
|
* @param {SheetProps} props - Visibility, title, close label and callback, and content.
|
||||||
|
* @returns {import('react').ReactElement} Dialog element.
|
||||||
|
*/
|
||||||
|
export function Sheet({ id, open, title, closeLabel, onClose, children }) {
|
||||||
|
const dialog = useRef(null);
|
||||||
|
|
||||||
|
// Keep the native dialog in step with `open`; closing by Escape reports back through onClose.
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (open && !element.open) element.showModal();
|
||||||
|
if (!open && element.open) element.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
id={id}
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
onClose={onClose}
|
||||||
|
className="info-sheet"
|
||||||
|
>
|
||||||
|
<header className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<h2 id={`${id}-title`} className="text-lg font-bold">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="start-icon-button"
|
||||||
|
aria-label={closeLabel}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{open && children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Every file the game loads, in one place. Most entries are placeholders until the company sends
|
||||||
|
// real assets: swap the path (and drop the file into public/) without touching any game logic.
|
||||||
|
// Paths are relative to the site root so the game also works from a sub-folder.
|
||||||
|
//
|
||||||
|
// Expected formats
|
||||||
|
// - Images: WebP (JPG fallback), sRGB, under 300 KB each.
|
||||||
|
// - Logo: SVG preferred, or a transparent PNG at least 512 px wide.
|
||||||
|
// - 3D models: glTF binary (.glb), Draco or Meshopt compressed, real-world metres, +X forward,
|
||||||
|
// under 2 MB each and around 20k triangles for a machine.
|
||||||
|
|
||||||
|
/** Robot guide portraits, one per expression. 256 × 240 WebP. */
|
||||||
|
const ROBOT_FACES = [
|
||||||
|
'front',
|
||||||
|
'happy',
|
||||||
|
'wink',
|
||||||
|
'excited',
|
||||||
|
'curious',
|
||||||
|
'laugh',
|
||||||
|
'love',
|
||||||
|
'serious',
|
||||||
|
'surprised',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ASSETS = {
|
||||||
|
brand: {
|
||||||
|
// TODO(asset): replace with the real company logo (SVG).
|
||||||
|
logo: './icons/icon-512.png',
|
||||||
|
// TODO(asset): hero image for the no-WebGL fallback screen. 1600 × 900 WebP.
|
||||||
|
hero: './icons/icon-512.png',
|
||||||
|
favicon: './icons/favicon-64.png',
|
||||||
|
},
|
||||||
|
robot: Object.fromEntries(ROBOT_FACES.map((face) => [face, `./robot/${face}.webp`])),
|
||||||
|
// Product photos for the showroom. TODO(asset): real photos, 1200 × 1600 WebP; `gif` is a short
|
||||||
|
// looping turntable (GIF or WebP animation, under 1 MB). Missing files show a placeholder card.
|
||||||
|
products: {
|
||||||
|
'rm-35': {
|
||||||
|
image: './images/machine-35-base.webp',
|
||||||
|
imageAlt: './images/machine-35-white.webp',
|
||||||
|
gif: './gifs/machine-35.gif',
|
||||||
|
},
|
||||||
|
'rm-48': {
|
||||||
|
image: './images/machine-48-base.webp',
|
||||||
|
imageAlt: './images/machine-48-white.webp',
|
||||||
|
gif: './gifs/machine-48.gif',
|
||||||
|
},
|
||||||
|
'rm-60': {
|
||||||
|
image: './images/machine-60-base.webp',
|
||||||
|
imageAlt: './images/machine-60-white.webp',
|
||||||
|
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
|
||||||
|
// (src/game/models). TODO(asset): set a .glb path to use a real model.
|
||||||
|
models: {
|
||||||
|
vendingMachine: null,
|
||||||
|
factory: null,
|
||||||
|
familyCar: null,
|
||||||
|
},
|
||||||
|
// World data generated by the scripts in scripts/.
|
||||||
|
world: {
|
||||||
|
buildings: './data/buildings.bin',
|
||||||
|
imagery: './imagery/index.json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path of a robot portrait.
|
||||||
|
* @param {string} face - Expression name, such as 'happy'.
|
||||||
|
* @returns {string} Image path; the front view when the expression is unknown.
|
||||||
|
*/
|
||||||
|
export const robotFace = (face) => ASSETS.robot[face] ?? ASSETS.robot.front;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Everything about the company in one place: name, colours, contact details, history and the
|
||||||
|
// product line. Replace the values marked `TODO(company)` with real content; nothing else in the
|
||||||
|
// game needs to change. Colours are plain hex so both CSS (styles/gameTheme.js) and Three.js
|
||||||
|
// materials can use them.
|
||||||
|
import { ASSETS } from './assets.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Product
|
||||||
|
* @property {string} id - Stable id used by the showroom and missions.
|
||||||
|
* @property {string} name - Full product name.
|
||||||
|
* @property {string} shortLabel - Short label for buttons.
|
||||||
|
* @property {number} channels - Number of product channels.
|
||||||
|
* @property {string} priceLabel - Price as shown to people.
|
||||||
|
* @property {number} priceNumeric - Price in rial.
|
||||||
|
* @property {string} description - One or two sentences.
|
||||||
|
* @property {string[]} highlights - Key specs, shown as a list.
|
||||||
|
* @property {string} image - Main image path, from config/assets.js.
|
||||||
|
* @property {string} imageAlt - Alternative colour image path.
|
||||||
|
* @property {string} gif - Animated view path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Brand palette derived from the primary colour #00a693. On the dark game surfaces the primary is
|
||||||
|
// used for accents and text (contrast 6.0:1 on `surface`); buttons filled with the primary use the
|
||||||
|
// dark `onPrimary` text (contrast 5.5:1).
|
||||||
|
const COLORS = {
|
||||||
|
primary: '#00a693',
|
||||||
|
primaryLight: '#33b8a9',
|
||||||
|
primaryLighter: '#9fe0d6',
|
||||||
|
primaryDark: '#007d6e',
|
||||||
|
primaryDarker: '#00473f',
|
||||||
|
onPrimary: '#03221e',
|
||||||
|
surface: '#0b1614',
|
||||||
|
surfaceRaised: '#13231f',
|
||||||
|
surfaceDeep: '#060c0b',
|
||||||
|
text: '#ecf2ee',
|
||||||
|
textMuted: '#a9bdb6',
|
||||||
|
accent: '#f2c94c',
|
||||||
|
danger: '#ff6b5e',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COMPANY = {
|
||||||
|
name: 'شرکت روبات مارکت',
|
||||||
|
shortName: 'روبات مارکت',
|
||||||
|
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
||||||
|
colors: COLORS,
|
||||||
|
// TODO(company): real postal address, phone, email, website and social links.
|
||||||
|
contact: {
|
||||||
|
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
||||||
|
city: 'مشهد',
|
||||||
|
phone: '۰۵۱-۰۰۰۰۰۰۰۰',
|
||||||
|
email: 'info@example.com',
|
||||||
|
website: 'https://example.com',
|
||||||
|
socials: [
|
||||||
|
{ id: 'instagram', label: 'اینستاگرام', url: 'https://instagram.com/' },
|
||||||
|
{ id: 'telegram', label: 'تلگرام', url: 'https://t.me/' },
|
||||||
|
{ id: 'linkedin', label: 'لینکدین', url: 'https://linkedin.com/' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// TODO(company): real history, values, FAQ and open positions.
|
||||||
|
about: {
|
||||||
|
history: 'متن کوتاه تاریخچهٔ شرکت اینجا قرار میگیرد.',
|
||||||
|
values: ['کیفیت ساخت', 'پشتیبانی شبانهروزی', 'فناوری بومی'],
|
||||||
|
faq: [{ question: 'پرسش نمونه؟', answer: 'پاسخ نمونه.' }],
|
||||||
|
careers: [{ title: 'عنوان شغل نمونه', note: 'توضیح کوتاه' }],
|
||||||
|
},
|
||||||
|
/** @type {Product[]} Display prices in rial; independent of the in-game snack economy. */
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'rm-35',
|
||||||
|
category: 'warm-vending',
|
||||||
|
name: 'روبات مارکت ۳۵ کانال',
|
||||||
|
shortLabel: '۳۵ کانال',
|
||||||
|
channels: 35,
|
||||||
|
priceLabel: '4,680,000,000 ریال',
|
||||||
|
priceNumeric: 4680000000,
|
||||||
|
description:
|
||||||
|
'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنککننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
|
||||||
|
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
|
||||||
|
...ASSETS.products['rm-35'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rm-48',
|
||||||
|
category: 'warm-vending',
|
||||||
|
name: 'روبات مارکت ۴۸ کانال',
|
||||||
|
shortLabel: '۴۸ کانال',
|
||||||
|
channels: 48,
|
||||||
|
priceLabel: '5,480,000,000 ریال',
|
||||||
|
priceNumeric: 5480000000,
|
||||||
|
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیتهای پیشرفته مدیریت آنلاین و تحویل کالا.',
|
||||||
|
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
|
||||||
|
...ASSETS.products['rm-48'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rm-60',
|
||||||
|
category: 'warm-vending',
|
||||||
|
name: 'روبات مارکت ۶۰ کانال',
|
||||||
|
shortLabel: '۶۰ کانال',
|
||||||
|
channels: 60,
|
||||||
|
priceLabel: '5,980,000,000 ریال',
|
||||||
|
priceNumeric: 5980000000,
|
||||||
|
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسبوکارهای پرتردد.',
|
||||||
|
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
|
||||||
|
...ASSETS.products['rm-60'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Places in the factory that missions can point at besides vending machines: information boards
|
||||||
|
// about the company, the manager's desk and the products hall. Positions use the factory's local
|
||||||
|
// frame (u along the driveway towards the gate, v across it; see game/models/factory.js).
|
||||||
|
|
||||||
|
// Boards stand in a row in the yard, left of the product samples, facing the family's start spot.
|
||||||
|
// `topic` is the company information section the board shows (see startScreen/InfoSectionContent).
|
||||||
|
export const ABOUT_BOARDS = [
|
||||||
|
{ id: 'board-about', topic: 'about', u: -32, v: -15 },
|
||||||
|
{ id: 'board-contact', topic: 'contact', u: -27, v: -15 },
|
||||||
|
{ id: 'board-address', topic: 'address', u: -22, v: -15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mr Mohammadi's desk in the management room, where he sits once he has arrived.
|
||||||
|
export const MANAGER_POINT = { id: 'mohammadi', u: 45, v: -40.7 };
|
||||||
|
|
||||||
|
// The products hall: the row of machine samples next to the boards.
|
||||||
|
export const SHOWROOM_POINT_ID = 'showroom';
|
||||||
|
|
||||||
|
export const FACTORY_POINT_IDS = [
|
||||||
|
...ABOUT_BOARDS.map((board) => board.id),
|
||||||
|
MANAGER_POINT.id,
|
||||||
|
SHOWROOM_POINT_ID,
|
||||||
|
];
|
||||||
|
|
||||||
|
// How close (metres from the board front) the player must stand to read a board.
|
||||||
|
export const BOARD_REACH = 3;
|
||||||
|
// How close to the manager counts as meeting him; matches his talk radius in the factory.
|
||||||
|
export const MEET_RADIUS = 3.4;
|
||||||
@@ -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;
|
||||||
+61
-19
@@ -1,40 +1,43 @@
|
|||||||
|
import { COMPANY } from '../config/company.js';
|
||||||
|
import { strings } from '../i18n/fa.js';
|
||||||
|
|
||||||
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
|
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
|
||||||
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
||||||
export const CATEGORIES = {
|
export const CATEGORIES = {
|
||||||
factory: {
|
factory: {
|
||||||
label: 'کارخانه',
|
label: strings.categories.factory,
|
||||||
icon: '🏭',
|
icon: '🏭',
|
||||||
},
|
},
|
||||||
park: {
|
park: {
|
||||||
label: 'پارک',
|
label: strings.categories.park,
|
||||||
icon: '🌳',
|
icon: '🌳',
|
||||||
},
|
},
|
||||||
university: {
|
university: {
|
||||||
label: 'دانشگاه',
|
label: strings.categories.university,
|
||||||
icon: '🎓',
|
icon: '🎓',
|
||||||
},
|
},
|
||||||
school: {
|
school: {
|
||||||
label: 'مدرسه',
|
label: strings.categories.school,
|
||||||
icon: '🏫',
|
icon: '🏫',
|
||||||
},
|
},
|
||||||
metro: {
|
metro: {
|
||||||
label: 'ایستگاه مترو',
|
label: strings.categories.metro,
|
||||||
icon: 'Ⓜ',
|
icon: 'Ⓜ',
|
||||||
},
|
},
|
||||||
mall: {
|
mall: {
|
||||||
label: 'مرکز خرید',
|
label: strings.categories.mall,
|
||||||
icon: '🛍',
|
icon: '🛍',
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
label: 'نقطهٔ آزمایشی GPS',
|
label: strings.categories.test,
|
||||||
icon: '🧪',
|
icon: '🧪',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const ALL_DAY = [7 * 60, 23 * 60];
|
const ALL_DAY = [7 * 60, 23 * 60];
|
||||||
export const FACTORY = {
|
export const FACTORY = {
|
||||||
name: 'شرکت روبات مارکت',
|
name: COMPANY.name,
|
||||||
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
tagline: COMPANY.tagline,
|
||||||
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
address: COMPANY.contact.address,
|
||||||
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
||||||
center: {
|
center: {
|
||||||
x: -10308.4,
|
x: -10308.4,
|
||||||
@@ -48,18 +51,20 @@ export const FACTORY = {
|
|||||||
export const machines = [
|
export const machines = [
|
||||||
{
|
{
|
||||||
id: 'factory',
|
id: 'factory',
|
||||||
name: 'دستگاه دفتر فنی مهندسی',
|
model: 'rm-35',
|
||||||
|
name: strings.places.officeMachine,
|
||||||
category: 'factory',
|
category: 'factory',
|
||||||
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
|
address: strings.places.officeAddress(FACTORY.address),
|
||||||
anchor: null,
|
anchor: null,
|
||||||
hours: [0, 24 * 60],
|
hours: [0, 24 * 60],
|
||||||
priceFactor: 0.5,
|
priceFactor: 0.5,
|
||||||
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
|
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
|
||||||
note: 'دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه',
|
note: strings.places.officeNote,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mellat-park',
|
id: 'mellat-park',
|
||||||
name: 'بوستان ملت',
|
model: 'rm-48',
|
||||||
|
name: 'بوستان ملت · ورودی جنوبی',
|
||||||
category: 'park',
|
category: 'park',
|
||||||
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
||||||
anchor: {
|
anchor: {
|
||||||
@@ -71,8 +76,39 @@ export const machines = [
|
|||||||
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
||||||
note: 'نوشیدنیهای خنک برای پیادهروی در پارک',
|
note: 'نوشیدنیهای خنک برای پیادهروی در پارک',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'mellat-lake',
|
||||||
|
model: 'rm-35',
|
||||||
|
name: 'بوستان ملت · کنار دریاچه',
|
||||||
|
category: 'park',
|
||||||
|
address: 'بوستان ملت، مسیر پیادهروی کنار دریاچه',
|
||||||
|
anchor: {
|
||||||
|
x: -210.6,
|
||||||
|
z: -484.8,
|
||||||
|
},
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 1,
|
||||||
|
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'cake'],
|
||||||
|
note: 'دستگاه کنار دریاچه؛ مناسب پیکنیک خانوادگی',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mellat-sports',
|
||||||
|
model: 'rm-60',
|
||||||
|
name: 'بوستان ملت · زمینهای ورزشی',
|
||||||
|
category: 'park',
|
||||||
|
address: 'بوستان ملت، مسیر کنار زمینهای ورزشی',
|
||||||
|
anchor: {
|
||||||
|
x: -401.8,
|
||||||
|
z: -713,
|
||||||
|
},
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 1,
|
||||||
|
products: ['water', 'juice', 'lemonade', 'nuts', 'chocolate', 'biscuit'],
|
||||||
|
note: 'نوشیدنی خنک برای بعد از ورزش',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'school-rahil',
|
id: 'school-rahil',
|
||||||
|
model: 'rm-35',
|
||||||
name: 'مجموعه آموزشی راحیل',
|
name: 'مجموعه آموزشی راحیل',
|
||||||
category: 'school',
|
category: 'school',
|
||||||
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
||||||
@@ -87,6 +123,7 @@ export const machines = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ferdowsi-university',
|
id: 'ferdowsi-university',
|
||||||
|
model: 'rm-60',
|
||||||
name: 'دانشگاه فردوسی مشهد',
|
name: 'دانشگاه فردوسی مشهد',
|
||||||
category: 'university',
|
category: 'university',
|
||||||
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
||||||
@@ -101,6 +138,7 @@ export const machines = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'literature-faculty',
|
id: 'literature-faculty',
|
||||||
|
model: 'rm-48',
|
||||||
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
|
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
|
||||||
category: 'university',
|
category: 'university',
|
||||||
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
||||||
@@ -115,6 +153,7 @@ export const machines = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'medical-university',
|
id: 'medical-university',
|
||||||
|
model: 'rm-48',
|
||||||
name: 'دانشگاه علوم پزشکی مشهد',
|
name: 'دانشگاه علوم پزشکی مشهد',
|
||||||
category: 'university',
|
category: 'university',
|
||||||
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
||||||
@@ -129,6 +168,7 @@ export const machines = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'azadi-metro',
|
id: 'azadi-metro',
|
||||||
|
model: 'rm-60',
|
||||||
name: 'ایستگاه مترو آزادی',
|
name: 'ایستگاه مترو آزادی',
|
||||||
category: 'metro',
|
category: 'metro',
|
||||||
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
||||||
@@ -143,6 +183,7 @@ export const machines = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mashhad-mall',
|
id: 'mashhad-mall',
|
||||||
|
model: 'rm-60',
|
||||||
name: 'مشهد مال',
|
name: 'مشهد مال',
|
||||||
category: 'mall',
|
category: 'mall',
|
||||||
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
||||||
@@ -168,10 +209,11 @@ export const machines = [
|
|||||||
*/
|
*/
|
||||||
const testPoint = (n, lat, lon) => ({
|
const testPoint = (n, lat, lon) => ({
|
||||||
id: `test-${n}`,
|
id: `test-${n}`,
|
||||||
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
|
model: 'rm-35',
|
||||||
|
name: strings.places.testPoint(n.toLocaleString('fa-IR')),
|
||||||
category: 'test',
|
category: 'test',
|
||||||
test: true,
|
test: true,
|
||||||
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
address: strings.places.testAddress(`${lat.toFixed(6)}, ${lon.toFixed(6)}`),
|
||||||
anchor: null,
|
anchor: null,
|
||||||
spot: {
|
spot: {
|
||||||
lat,
|
lat,
|
||||||
@@ -180,7 +222,7 @@ const testPoint = (n, lat, lon) => ({
|
|||||||
hours: [0, 24 * 60],
|
hours: [0, 24 * 60],
|
||||||
priceFactor: 0.5,
|
priceFactor: 0.5,
|
||||||
products: ['water', 'biscuit'],
|
products: ['water', 'biscuit'],
|
||||||
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
|
note: strings.places.testNote,
|
||||||
});
|
});
|
||||||
machines.push(
|
machines.push(
|
||||||
testPoint(1, 36.450276, 59.423431),
|
testPoint(1, 36.450276, 59.423431),
|
||||||
@@ -192,11 +234,11 @@ machines.push(
|
|||||||
export const lockedPlaces = [
|
export const lockedPlaces = [
|
||||||
{
|
{
|
||||||
name: 'پارک کوهسنگی',
|
name: 'پارک کوهسنگی',
|
||||||
note: 'خارج از محدودهٔ فعلی · بهزودی',
|
note: strings.places.comingSoon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'حرم و مرکز شهر',
|
name: 'حرم و مرکز شهر',
|
||||||
note: 'خارج از محدودهٔ فعلی · بهزودی',
|
note: strings.places.comingSoon,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
export const START = {
|
export const START = {
|
||||||
|
|||||||
@@ -1,47 +1,5 @@
|
|||||||
/** Display-only machine prices in rial, independent of the consumable economy. */
|
import { COMPANY } from '../config/company.js';
|
||||||
export const SHOWROOM_CATALOG = [
|
|
||||||
{
|
/** Display-only machine models; the product line itself lives in config/company.js. */
|
||||||
id: 'rm-35',
|
export const SHOWROOM_CATALOG = COMPANY.products;
|
||||||
category: 'warm-vending',
|
|
||||||
name: 'روبات مارکت ۳۵ کانال',
|
|
||||||
shortLabel: '۳۵ کانال',
|
|
||||||
channels: 35,
|
|
||||||
priceLabel: '4,680,000,000 ریال',
|
|
||||||
priceNumeric: 4680000000,
|
|
||||||
description:
|
|
||||||
'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنککننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
|
|
||||||
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
|
|
||||||
image: '/images/machine-35-base.webp',
|
|
||||||
imageAlt: '/images/machine-35-white.webp',
|
|
||||||
gif: '/gifs/machine-35.gif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rm-48',
|
|
||||||
category: 'warm-vending',
|
|
||||||
name: 'روبات مارکت ۴۸ کانال',
|
|
||||||
shortLabel: '۴۸ کانال',
|
|
||||||
channels: 48,
|
|
||||||
priceLabel: '5,480,000,000 ریال',
|
|
||||||
priceNumeric: 5480000000,
|
|
||||||
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیتهای پیشرفته مدیریت آنلاین و تحویل کالا.',
|
|
||||||
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
|
|
||||||
image: '/images/machine-48-base.webp',
|
|
||||||
imageAlt: '/images/machine-48-white.webp',
|
|
||||||
gif: '/gifs/machine-48.gif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rm-60',
|
|
||||||
category: 'warm-vending',
|
|
||||||
name: 'روبات مارکت ۶۰ کانال',
|
|
||||||
shortLabel: '۶۰ کانال',
|
|
||||||
channels: 60,
|
|
||||||
priceLabel: '5,980,000,000 ریال',
|
|
||||||
priceNumeric: 5980000000,
|
|
||||||
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسبوکارهای پرتردد.',
|
|
||||||
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
|
|
||||||
image: '/images/machine-60-base.webp',
|
|
||||||
imageAlt: '/images/machine-60-white.webp',
|
|
||||||
gif: '/gifs/machine-60.gif',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
export const SHOWROOM_REACH = 4;
|
export const SHOWROOM_REACH = 4;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { strings } from '../i18n/fa.js';
|
||||||
|
|
||||||
|
// Every vehicle in the factory yard is painted this gloss black.
|
||||||
|
export const VEHICLE_BLACK = '#0c0d0f';
|
||||||
|
|
||||||
|
// How close (metres, centre to centre) the player must stand to get on or in a vehicle.
|
||||||
|
export const ENTER_DISTANCE = 4.5;
|
||||||
|
|
||||||
|
// Above this speed (m/s) the player has to stop before getting off.
|
||||||
|
export const EXIT_MAX_SPEED = 3;
|
||||||
|
|
||||||
|
// Names shown in prompts, from the strings file. `bmw` is the family car beside the start point.
|
||||||
|
export const VEHICLE_LABELS = strings.vehicles;
|
||||||
|
|
||||||
|
// Brands parked in the staff bays, in bay order. Mr Mohammadi arrives in the Bugatti.
|
||||||
|
export const STAFF_CAR_BRANDS = ['lamborghini', 'mazda', 'nissan'];
|
||||||
|
export const MANAGER_CAR_BRAND = 'bugatti';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Ground style of the 3D world. When false the ground is drawn from OpenStreetMap streets, parks
|
||||||
|
// and land use; when true the hosted Esri satellite tiles (public/imagery) cover it instead.
|
||||||
|
// The user asked for the drawn street map, so the satellite ground is off.
|
||||||
|
export const SATELLITE_GROUND = false;
|
||||||
+4100
-1
File diff suppressed because one or more lines are too long
+180561
-1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
|
||||||
// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
|
// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
|
||||||
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
|
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
|
||||||
@@ -119,8 +120,7 @@ export function createArView(video) {
|
|||||||
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
|
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
|
||||||
async start() {
|
async start() {
|
||||||
if (stream) return null;
|
if (stream) return null;
|
||||||
if (!navigator.mediaDevices?.getUserMedia)
|
if (!navigator.mediaDevices?.getUserMedia) return strings.real.cameraNeedsHttps;
|
||||||
return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
|
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
@@ -139,8 +139,8 @@ export function createArView(video) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
stream = null;
|
stream = null;
|
||||||
return error?.name === 'NotAllowedError'
|
return error?.name === 'NotAllowedError'
|
||||||
? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
|
? strings.real.cameraDenied
|
||||||
: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
|
: strings.real.cameraBusy;
|
||||||
}
|
}
|
||||||
video.srcObject = stream;
|
video.srcObject = stream;
|
||||||
video.hidden = false;
|
video.hidden = false;
|
||||||
|
|||||||
@@ -86,10 +86,13 @@ export function setupPwa(ui) {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
// On a first visit the new worker takes control without any update; reloading then would
|
||||||
|
// throw the visitor back to the poster or start screen, so only updates reload the page.
|
||||||
|
const hadController = Boolean(navigator.serviceWorker.controller);
|
||||||
navigator.serviceWorker.addEventListener(
|
navigator.serviceWorker.addEventListener(
|
||||||
'controllerchange',
|
'controllerchange',
|
||||||
() => {
|
() => {
|
||||||
if (reloading) return;
|
if (reloading || !hadController) return;
|
||||||
reloading = true;
|
reloading = true;
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { toLocal } from '../../utils/geo.js';
|
import { toLocal } from '../../utils/geo.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
|
||||||
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
|
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
|
||||||
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
|
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
|
||||||
@@ -124,11 +125,11 @@ export function startTracking({
|
|||||||
stabilizer = createStabilizer(),
|
stabilizer = createStabilizer(),
|
||||||
}) {
|
}) {
|
||||||
if (!('geolocation' in navigator)) {
|
if (!('geolocation' in navigator)) {
|
||||||
onError('این دستگاه موقعیتیابی (GPS) ندارد.');
|
onError(strings.real.noGps);
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
onError('موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.');
|
onError(strings.real.needsHttps);
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
let fixes = 0;
|
let fixes = 0;
|
||||||
@@ -160,17 +161,10 @@ export function startTracking({
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
const fail = (error) => {
|
const fail = (error) => {
|
||||||
if (error.code === error.PERMISSION_DENIED)
|
if (error.code === error.PERMISSION_DENIED) onError(strings.real.denied);
|
||||||
onError(
|
else if (error.code === error.POSITION_UNAVAILABLE) onError(strings.real.unavailable);
|
||||||
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
|
|
||||||
);
|
|
||||||
else if (error.code === error.POSITION_UNAVAILABLE)
|
|
||||||
onError(
|
|
||||||
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیتیابی کار نمیکند. (کد ۲)',
|
|
||||||
);
|
|
||||||
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
|
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
|
||||||
else if (!fixes)
|
else if (!fixes) onStatus(strings.real.timeout);
|
||||||
onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریعتر پیدا میشود. (کد ۳)');
|
|
||||||
};
|
};
|
||||||
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
|
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
|
||||||
navigator.geolocation.getCurrentPosition(handle, () => {}, {
|
navigator.geolocation.getCurrentPosition(handle, () => {}, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { missionOrder } from '../missions/missions.js';
|
||||||
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
||||||
export const INITIAL_BALANCE = 500;
|
export const INITIAL_BALANCE = 500;
|
||||||
export const START_MINUTES = 8 * 60 + 30;
|
export const START_MINUTES = 8 * 60 + 30;
|
||||||
@@ -12,7 +14,7 @@ export const fa = (n) => n.toLocaleString('fa-IR');
|
|||||||
* @param {*} value - value.
|
* @param {*} value - value.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
export const formatHetoman = (value) => `${fa(value)} هتومان`;
|
export const formatHetoman = (value) => strings.units.hetoman(fa(value));
|
||||||
/**
|
/**
|
||||||
* Runs the hetoman to rial operation for this subsystem.
|
* Runs the hetoman to rial operation for this subsystem.
|
||||||
* @param {*} value - value.
|
* @param {*} value - value.
|
||||||
@@ -31,63 +33,63 @@ export const formatClock = (minutes) => {
|
|||||||
export const catalog = {
|
export const catalog = {
|
||||||
juice: {
|
juice: {
|
||||||
id: 'juice',
|
id: 'juice',
|
||||||
name: 'آبمیوه',
|
name: strings.snacks.juice,
|
||||||
icon: '🧃',
|
icon: '🧃',
|
||||||
cost: 35,
|
cost: 35,
|
||||||
type: 'drink',
|
type: 'drink',
|
||||||
},
|
},
|
||||||
water: {
|
water: {
|
||||||
id: 'water',
|
id: 'water',
|
||||||
name: 'آب معدنی',
|
name: strings.snacks.water,
|
||||||
icon: '💧',
|
icon: '💧',
|
||||||
cost: 15,
|
cost: 15,
|
||||||
type: 'drink',
|
type: 'drink',
|
||||||
},
|
},
|
||||||
milk: {
|
milk: {
|
||||||
id: 'milk',
|
id: 'milk',
|
||||||
name: 'شیر کاکائو',
|
name: strings.snacks.milk,
|
||||||
icon: '🥛',
|
icon: '🥛',
|
||||||
cost: 40,
|
cost: 40,
|
||||||
type: 'drink',
|
type: 'drink',
|
||||||
},
|
},
|
||||||
tea: {
|
tea: {
|
||||||
id: 'tea',
|
id: 'tea',
|
||||||
name: 'چای سرد',
|
name: strings.snacks.tea,
|
||||||
icon: '🧋',
|
icon: '🧋',
|
||||||
cost: 45,
|
cost: 45,
|
||||||
type: 'drink',
|
type: 'drink',
|
||||||
},
|
},
|
||||||
lemonade: {
|
lemonade: {
|
||||||
id: 'lemonade',
|
id: 'lemonade',
|
||||||
name: 'لیموناد',
|
name: strings.snacks.lemonade,
|
||||||
icon: '🍋',
|
icon: '🍋',
|
||||||
cost: 40,
|
cost: 40,
|
||||||
type: 'drink',
|
type: 'drink',
|
||||||
},
|
},
|
||||||
biscuit: {
|
biscuit: {
|
||||||
id: 'biscuit',
|
id: 'biscuit',
|
||||||
name: 'بیسکویت',
|
name: strings.snacks.biscuit,
|
||||||
icon: '🍪',
|
icon: '🍪',
|
||||||
cost: 25,
|
cost: 25,
|
||||||
type: 'snack',
|
type: 'snack',
|
||||||
},
|
},
|
||||||
chocolate: {
|
chocolate: {
|
||||||
id: 'chocolate',
|
id: 'chocolate',
|
||||||
name: 'شکلات',
|
name: strings.snacks.chocolate,
|
||||||
icon: '🍫',
|
icon: '🍫',
|
||||||
cost: 30,
|
cost: 30,
|
||||||
type: 'snack',
|
type: 'snack',
|
||||||
},
|
},
|
||||||
nuts: {
|
nuts: {
|
||||||
id: 'nuts',
|
id: 'nuts',
|
||||||
name: 'آجیل',
|
name: strings.snacks.nuts,
|
||||||
icon: '🥜',
|
icon: '🥜',
|
||||||
cost: 65,
|
cost: 65,
|
||||||
type: 'snack',
|
type: 'snack',
|
||||||
},
|
},
|
||||||
cake: {
|
cake: {
|
||||||
id: 'cake',
|
id: 'cake',
|
||||||
name: 'کیک',
|
name: strings.snacks.cake,
|
||||||
icon: '🧁',
|
icon: '🧁',
|
||||||
cost: 35,
|
cost: 35,
|
||||||
type: 'snack',
|
type: 'snack',
|
||||||
@@ -122,6 +124,10 @@ export function createState() {
|
|||||||
purchases: [],
|
purchases: [],
|
||||||
xp: 0,
|
xp: 0,
|
||||||
missionsDone: [],
|
missionsDone: [],
|
||||||
|
// Mission order for the start mode, and the current mission and step (see game/missions).
|
||||||
|
missionOrder: missionOrder(),
|
||||||
|
missionIndex: 0,
|
||||||
|
missionStep: 0,
|
||||||
minutes: START_MINUTES,
|
minutes: START_MINUTES,
|
||||||
busy: false,
|
busy: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-99
@@ -1,4 +1,5 @@
|
|||||||
import { CATEGORIES } from '../../constants/locations.js';
|
import { CATEGORIES } from '../../constants/locations.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
|
||||||
// The bonus for visiting every kind of place counts city locations, not the factory.
|
// The bonus for visiting every kind of place counts city locations, not the factory.
|
||||||
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
||||||
@@ -10,27 +11,27 @@ export const POINTS = {
|
|||||||
};
|
};
|
||||||
export const RANKS = [
|
export const RANKS = [
|
||||||
{
|
{
|
||||||
name: 'تازهوارد',
|
name: strings.ranks[0],
|
||||||
xp: 0,
|
xp: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'کاوشگر',
|
name: strings.ranks[1],
|
||||||
xp: 600,
|
xp: 600,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'راهبلد',
|
name: strings.ranks[2],
|
||||||
xp: 1400,
|
xp: 1400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'شکارچی دستگاه',
|
name: strings.ranks[3],
|
||||||
xp: 2600,
|
xp: 2600,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'استاد نقشه',
|
name: strings.ranks[4],
|
||||||
xp: 4200,
|
xp: 4200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'افسانهٔ مشهد',
|
name: strings.ranks[5],
|
||||||
xp: 6500,
|
xp: 6500,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -45,21 +46,21 @@ export const RANKS = [
|
|||||||
export function awardsFor(previous, record) {
|
export function awardsFor(previous, record) {
|
||||||
const awards = [
|
const awards = [
|
||||||
{
|
{
|
||||||
label: 'خرید موفق',
|
label: strings.bonuses.purchase,
|
||||||
points: POINTS.purchase,
|
points: POINTS.purchase,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const here = previous.filter((p) => p.machineId === record.machineId);
|
const here = previous.filter((p) => p.machineId === record.machineId);
|
||||||
if (!here.length)
|
if (!here.length)
|
||||||
awards.push({
|
awards.push({
|
||||||
label: 'کشف مکان جدید',
|
label: strings.bonuses.newPlace,
|
||||||
points: POINTS.discovery,
|
points: POINTS.discovery,
|
||||||
});
|
});
|
||||||
// Paid once per machine: the first time it has sold both a drink and a snack.
|
// Paid once per machine: the first time it has sold both a drink and a snack.
|
||||||
const typesHere = new Set(here.map((p) => p.type));
|
const typesHere = new Set(here.map((p) => p.type));
|
||||||
if (typesHere.size === 1 && !typesHere.has(record.type))
|
if (typesHere.size === 1 && !typesHere.has(record.type))
|
||||||
awards.push({
|
awards.push({
|
||||||
label: 'ترکیب نوشیدنی و خوراکی',
|
label: strings.bonuses.combo,
|
||||||
points: POINTS.combo,
|
points: POINTS.combo,
|
||||||
});
|
});
|
||||||
const categories = new Set(
|
const categories = new Set(
|
||||||
@@ -71,7 +72,7 @@ export function awardsFor(previous, record) {
|
|||||||
categories.size === CITY_CATEGORIES.length - 1
|
categories.size === CITY_CATEGORIES.length - 1
|
||||||
)
|
)
|
||||||
awards.push({
|
awards.push({
|
||||||
label: 'همهٔ انواع مکان',
|
label: strings.bonuses.allCategories,
|
||||||
points: POINTS.allCategories,
|
points: POINTS.allCategories,
|
||||||
});
|
});
|
||||||
return awards;
|
return awards;
|
||||||
@@ -93,92 +94,3 @@ export function rankFor(xp) {
|
|||||||
progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1,
|
progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Runs the places visited operation for this subsystem.
|
|
||||||
* @param {*} purchases - purchases.
|
|
||||||
* @returns {*} Operation result, when applicable.
|
|
||||||
*/
|
|
||||||
const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).size;
|
|
||||||
export const MISSIONS = [
|
|
||||||
{
|
|
||||||
id: 'factory',
|
|
||||||
title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن',
|
|
||||||
target: 'factory',
|
|
||||||
xp: 100,
|
|
||||||
hetoman: 20,
|
|
||||||
done: (ps) => ps.some((p) => p.machineId === 'factory'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'first',
|
|
||||||
title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
|
|
||||||
target: 'mellat-park',
|
|
||||||
xp: 150,
|
|
||||||
hetoman: 30,
|
|
||||||
done: (ps) => ps.some((p) => p.machineId === 'mellat-park'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'school',
|
|
||||||
title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
|
|
||||||
target: 'school-rahil',
|
|
||||||
xp: 200,
|
|
||||||
hetoman: 40,
|
|
||||||
done: (ps) => ps.some((p) => p.machineId === 'school-rahil'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'campus',
|
|
||||||
title: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
|
|
||||||
target: 'ferdowsi-university',
|
|
||||||
xp: 200,
|
|
||||||
hetoman: 40,
|
|
||||||
done: (ps) => ps.some((p) => p.machineId === 'ferdowsi-university' && p.type === 'snack'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'metro',
|
|
||||||
title: 'از دستگاه ایستگاه مترو آزادی خرید کن',
|
|
||||||
target: 'azadi-metro',
|
|
||||||
xp: 150,
|
|
||||||
hetoman: 30,
|
|
||||||
done: (ps) => ps.some((p) => p.machineId === 'azadi-metro'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mall',
|
|
||||||
title: 'با ماشین تا مشهد مال برو و خرید کن',
|
|
||||||
target: 'mashhad-mall',
|
|
||||||
xp: 250,
|
|
||||||
hetoman: 50,
|
|
||||||
done: (ps) => ps.some((p) => p.machineId === 'mashhad-mall'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'explorer',
|
|
||||||
title: 'از ۵ مکان متفاوت شهر خرید کن',
|
|
||||||
target: null,
|
|
||||||
xp: 400,
|
|
||||||
hetoman: 80,
|
|
||||||
done: (ps) => placesVisited(ps.filter((p) => CITY_CATEGORIES.includes(p.category))) >= 5,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Marks and returns missions that the current purchases complete for the first time.
|
|
||||||
/**
|
|
||||||
* Runs the complete missions operation for this subsystem.
|
|
||||||
* @param {*} state - state.
|
|
||||||
* @returns {*} Operation result, when applicable.
|
|
||||||
*/
|
|
||||||
export function completeMissions(state) {
|
|
||||||
const fresh = MISSIONS.filter(
|
|
||||||
(m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
|
|
||||||
);
|
|
||||||
for (const m of fresh) {
|
|
||||||
state.missionsDone.push(m.id);
|
|
||||||
state.xp += m.xp;
|
|
||||||
state.balance += m.hetoman;
|
|
||||||
}
|
|
||||||
return fresh;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Runs the active mission operation for this subsystem.
|
|
||||||
* @param {*} state - state.
|
|
||||||
* @returns {*} Operation result, when applicable.
|
|
||||||
*/
|
|
||||||
export const activeMission = (state) =>
|
|
||||||
MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null;
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { missionById } from './missions.js';
|
||||||
|
|
||||||
|
// Pure mission bookkeeping on the game state: which step is current, and what an event (arriving,
|
||||||
|
// buying, inspecting…) completes. No rendering or timing here; the runtime reports events.
|
||||||
|
|
||||||
|
// The event that completes each step type; only `goto` is finished by a differently named one.
|
||||||
|
const EVENT_FOR_STEP = {
|
||||||
|
goto: 'arrive',
|
||||||
|
locate: 'locate',
|
||||||
|
inspect: 'inspect',
|
||||||
|
order: 'order',
|
||||||
|
buy: 'buy',
|
||||||
|
read: 'read',
|
||||||
|
meet: 'meet',
|
||||||
|
view: 'view',
|
||||||
|
ask: 'ask',
|
||||||
|
request: 'request',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mission the player is working on.
|
||||||
|
* @param {object} state - Game state.
|
||||||
|
* @returns {object | null} Mission, or null when all are done.
|
||||||
|
*/
|
||||||
|
export const currentMission = (state) => missionById(state.missionOrder[state.missionIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The step the player is working on.
|
||||||
|
* @param {object} state - Game state.
|
||||||
|
* @returns {object | null} Step, or null when all missions are done.
|
||||||
|
*/
|
||||||
|
export const currentStep = (state) => currentMission(state)?.steps[state.missionStep] ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine id the current step points at, if any.
|
||||||
|
* @param {object} state - Game state.
|
||||||
|
* @returns {string | null} Target machine id.
|
||||||
|
*/
|
||||||
|
export const stepTarget = (state) => currentStep(state)?.target ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of different city machines (outside the factory) the player has bought from.
|
||||||
|
* @param {object[]} purchases - Purchase records.
|
||||||
|
* @returns {number} Distinct machines.
|
||||||
|
*/
|
||||||
|
export const cityPlacesVisited = (purchases) =>
|
||||||
|
new Set(purchases.filter((p) => p.category !== 'factory').map((p) => p.machineId)).size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an event completes a step.
|
||||||
|
* @param {object} step - Mission step.
|
||||||
|
* @param {{type: string, machineId?: string}} event - What happened.
|
||||||
|
* @param {object} state - Game state (for purchase counts).
|
||||||
|
* @returns {boolean} True when the step is done.
|
||||||
|
*/
|
||||||
|
function completes(step, event, state) {
|
||||||
|
if (step.type === 'places')
|
||||||
|
return event.type === 'buy' && cityPlacesVisited(state.purchases) >= step.count;
|
||||||
|
return EVENT_FOR_STEP[step.type] === event.type && step.target === event.machineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a finished mission's reward and moves on to the next mission.
|
||||||
|
* @param {object} state - Game state, changed in place.
|
||||||
|
* @param {object} mission - The finished mission.
|
||||||
|
*/
|
||||||
|
function finishMission(state, mission) {
|
||||||
|
state.missionsDone.push(mission.id);
|
||||||
|
state.xp += mission.reward.xp;
|
||||||
|
state.balance += mission.reward.hetoman;
|
||||||
|
state.missionIndex += 1;
|
||||||
|
state.missionStep = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an event to the current step.
|
||||||
|
* @param {object} state - Game state, changed in place.
|
||||||
|
* @param {{type: string, machineId?: string}} event - What happened.
|
||||||
|
* @returns {{stepDone: boolean, mission: object | null}} Whether the step finished, and the
|
||||||
|
* mission it finished (null while the mission still has steps left).
|
||||||
|
*/
|
||||||
|
export function applyMissionEvent(state, event) {
|
||||||
|
const mission = currentMission(state),
|
||||||
|
step = currentStep(state);
|
||||||
|
if (!step || !completes(step, event, state)) return { stepDone: false, mission: null };
|
||||||
|
state.missionStep += 1;
|
||||||
|
if (state.missionStep < mission.steps.length) return { stepDone: true, mission: null };
|
||||||
|
finishMission(state, mission);
|
||||||
|
return { stepDone: true, mission };
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { machineById } from '../../constants/locations.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { fa } from '../economy/gameState.js';
|
||||||
|
import { missionById } from './missions.js';
|
||||||
|
import { cityPlacesVisited, currentStep } from './missionProgress.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short instruction for a step, such as "go to Park Mellat".
|
||||||
|
* @param {object} step - Mission step.
|
||||||
|
* @param {object} state - Game state (for the places count).
|
||||||
|
* @returns {string} One line of text.
|
||||||
|
*/
|
||||||
|
export function stepText(step, state) {
|
||||||
|
if (step.type === 'places')
|
||||||
|
return strings.steps.places(
|
||||||
|
fa(Math.min(step.count, cityPlacesVisited(state.purchases))),
|
||||||
|
fa(step.count),
|
||||||
|
);
|
||||||
|
return strings.steps[step.type](targetName(step.target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name of a step target: a machine site or a factory point.
|
||||||
|
* @param {string} [target] - Machine or factory point id.
|
||||||
|
* @returns {string} Name, or an empty string for steps without a target.
|
||||||
|
*/
|
||||||
|
export const targetName = (target) => machineById(target)?.name ?? strings.points[target] ?? '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current instruction, or a note that everything is done.
|
||||||
|
* @param {object} state - Game state.
|
||||||
|
* @returns {string} One line of text.
|
||||||
|
*/
|
||||||
|
export function currentStepText(state) {
|
||||||
|
const step = currentStep(state);
|
||||||
|
return step ? stepText(step, state) : strings.steps.allDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mission list for the missions sheet: every mission with its state, and the steps of the
|
||||||
|
* current one.
|
||||||
|
* @param {object} state - Game state.
|
||||||
|
* @returns {object[]} List items for ViewLists.
|
||||||
|
*/
|
||||||
|
export function missionListItems(state) {
|
||||||
|
return state.missionOrder.map(missionById).map((mission, index) => ({
|
||||||
|
id: mission.id,
|
||||||
|
title: strings.missions[mission.id],
|
||||||
|
done: state.missionsDone.includes(mission.id),
|
||||||
|
active: index === state.missionIndex,
|
||||||
|
steps:
|
||||||
|
index === state.missionIndex
|
||||||
|
? mission.steps.map((step, i) => ({
|
||||||
|
text: stepText(step, state),
|
||||||
|
done: i < state.missionStep,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// The mission list, as data. Each mission is a short chain of steps; the player always works on
|
||||||
|
// the first unfinished step of the first unfinished mission. Titles and step texts come from the
|
||||||
|
// strings file (missions.<id>, steps.<type>).
|
||||||
|
//
|
||||||
|
// Step types
|
||||||
|
// - goto: reach the machine at `target`.
|
||||||
|
// - locate: find `target` on the city map (select it there).
|
||||||
|
// - inspect: stand at `target` for a few seconds while the robot introduces the machine; its
|
||||||
|
// machine card then opens.
|
||||||
|
// - order: send an order request from the machine card of `target`.
|
||||||
|
// - buy: buy anything from `target`.
|
||||||
|
// - places: buy from `count` different machines in the city (not the factory).
|
||||||
|
// - read: read the information board `target` in the factory yard.
|
||||||
|
// - meet: meet Mr Mohammadi (`target`) at his desk; he tells the company story.
|
||||||
|
// - view: open the products hall (`target` is 'showroom').
|
||||||
|
// - ask: ask the robot in the products hall about a model's price or features.
|
||||||
|
// - request: send a purchase request for a model from the products hall.
|
||||||
|
//
|
||||||
|
// Targets are machine ids (constants/locations.js) or factory point ids
|
||||||
|
// (constants/factoryPoints.js).
|
||||||
|
|
||||||
|
export const MISSIONS = [
|
||||||
|
{
|
||||||
|
id: 'firstBuy',
|
||||||
|
steps: [
|
||||||
|
{ type: 'goto', target: 'factory' },
|
||||||
|
{ type: 'buy', target: 'factory' },
|
||||||
|
],
|
||||||
|
reward: { xp: 100, hetoman: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aboutTour',
|
||||||
|
steps: [
|
||||||
|
{ type: 'read', target: 'board-about' },
|
||||||
|
{ type: 'meet', target: 'mohammadi' },
|
||||||
|
{ type: 'read', target: 'board-contact' },
|
||||||
|
],
|
||||||
|
reward: { xp: 150, hetoman: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'productsTour',
|
||||||
|
steps: [
|
||||||
|
{ type: 'view', target: 'showroom' },
|
||||||
|
{ type: 'ask', target: 'showroom' },
|
||||||
|
{ type: 'request', target: 'showroom' },
|
||||||
|
],
|
||||||
|
reward: { xp: 150, hetoman: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parkGate',
|
||||||
|
steps: [
|
||||||
|
{ type: 'locate', target: 'mellat-park' },
|
||||||
|
{ type: 'goto', target: 'mellat-park' },
|
||||||
|
{ type: 'buy', target: 'mellat-park' },
|
||||||
|
],
|
||||||
|
reward: { xp: 150, hetoman: 30 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parkLake',
|
||||||
|
steps: [
|
||||||
|
{ type: 'goto', target: 'mellat-lake' },
|
||||||
|
{ type: 'inspect', target: 'mellat-lake' },
|
||||||
|
],
|
||||||
|
reward: { xp: 150, hetoman: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parkSports',
|
||||||
|
steps: [
|
||||||
|
{ type: 'goto', target: 'mellat-sports' },
|
||||||
|
{ type: 'inspect', target: 'mellat-sports' },
|
||||||
|
{ type: 'order', target: 'mellat-sports' },
|
||||||
|
],
|
||||||
|
reward: { xp: 200, hetoman: 30 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parkTour',
|
||||||
|
steps: [
|
||||||
|
{ type: 'buy', target: 'mellat-lake' },
|
||||||
|
{ type: 'buy', target: 'mellat-sports' },
|
||||||
|
],
|
||||||
|
reward: { xp: 250, hetoman: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'explorer',
|
||||||
|
steps: [{ type: 'places', count: 5 }],
|
||||||
|
reward: { xp: 400, hetoman: 80 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Distances (metres) for `goto` arrival and `inspect` range, and how long inspecting takes.
|
||||||
|
export const ARRIVE_RADIUS = 12;
|
||||||
|
export const INSPECT_RADIUS = 6;
|
||||||
|
export const INSPECT_SECONDS = 3;
|
||||||
|
|
||||||
|
// The mission each start mode leads with; the others follow in list order.
|
||||||
|
const LEAD_MISSION = { about: 'aboutTour', products: 'productsTour' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission ids in the order a start mode plays them: its lead mission first, then the rest.
|
||||||
|
* @param {string} [mode] - Start mode; unknown or missing modes use the list order.
|
||||||
|
* @returns {string[]} Mission ids.
|
||||||
|
*/
|
||||||
|
export function missionOrder(mode) {
|
||||||
|
const ids = MISSIONS.map((mission) => mission.id);
|
||||||
|
const lead = LEAD_MISSION[mode];
|
||||||
|
if (!lead) return ids;
|
||||||
|
return [lead, ...ids.filter((id) => id !== lead)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a mission by id.
|
||||||
|
* @param {string} id - Mission id.
|
||||||
|
* @returns {object | null} The mission, or null.
|
||||||
|
*/
|
||||||
|
export const missionById = (id) => MISSIONS.find((mission) => mission.id === id) ?? null;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
import { ABOUT_BOARDS, BOARD_REACH } from '../../constants/factoryPoints.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js';
|
||||||
|
import { textTexture } from './textures.js';
|
||||||
|
|
||||||
|
// Free-standing information boards in the factory yard: history and values, contact details and
|
||||||
|
// the address. The text is painted from the company config, so it updates with real content.
|
||||||
|
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
|
// Board face in metres, and the canvas painted on it (same aspect ratio).
|
||||||
|
const BOARD_WIDTH = 3;
|
||||||
|
const BOARD_HEIGHT = 1.9;
|
||||||
|
const CANVAS_WIDTH = 900;
|
||||||
|
const CANVAS_HEIGHT = 570;
|
||||||
|
// Height of the board's centre, so it reads at eye level from a few metres away.
|
||||||
|
const BOARD_CENTRE_Y = 1.75;
|
||||||
|
const PADDING = 40;
|
||||||
|
const TITLE_BAND = 110;
|
||||||
|
const BODY_LINE = 52;
|
||||||
|
const FONT = 'Vazirmatn, Tahoma, sans-serif';
|
||||||
|
// How far in front of the board the player stands to read it (local +v).
|
||||||
|
const FRONT_DISTANCE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text lines a board shows for its topic.
|
||||||
|
* @param {string} topic - Information section: 'about', 'contact' or 'address'.
|
||||||
|
* @returns {string[]} Paragraphs; long ones are wrapped when painted.
|
||||||
|
*/
|
||||||
|
export function boardLines(topic) {
|
||||||
|
const { contact, about } = COMPANY;
|
||||||
|
const labels = strings.landing;
|
||||||
|
if (topic === 'about')
|
||||||
|
return [about.history, ...about.values.map((value) => `${strings.boards.bullet} ${value}`)];
|
||||||
|
if (topic === 'contact')
|
||||||
|
return [
|
||||||
|
`${labels.phone}: ${contact.phone}`,
|
||||||
|
`${labels.email}: ${contact.email}`,
|
||||||
|
`${labels.website}: ${contact.website.replace(/^https?:\/\//, '')}`,
|
||||||
|
];
|
||||||
|
return [labels.fullAddress(contact.address, contact.city)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a paragraph into lines that fit a width on a canvas.
|
||||||
|
* @param {CanvasRenderingContext2D} c - Canvas context with the font already set.
|
||||||
|
* @param {string} text - Paragraph.
|
||||||
|
* @param {number} maxWidth - Line width in pixels.
|
||||||
|
* @returns {string[]} Lines.
|
||||||
|
*/
|
||||||
|
function wrapLines(c, text, maxWidth) {
|
||||||
|
const lines = [];
|
||||||
|
let line = '';
|
||||||
|
for (const word of text.split(' ')) {
|
||||||
|
const next = line ? `${line} ${word}` : word;
|
||||||
|
if (line && c.measureText(next).width > maxWidth) {
|
||||||
|
lines.push(line);
|
||||||
|
line = word;
|
||||||
|
} else line = next;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a board: a brand-coloured title band, the wrapped text and a hint at the bottom.
|
||||||
|
* @param {string} topic - Information section.
|
||||||
|
* @returns {THREE.CanvasTexture} Board texture.
|
||||||
|
*/
|
||||||
|
function boardTexture(topic) {
|
||||||
|
return textTexture(CANVAS_WIDTH, CANVAS_HEIGHT, (c, w, h) => {
|
||||||
|
c.fillStyle = BRAND.surfaceRaised;
|
||||||
|
c.fillRect(0, 0, w, h);
|
||||||
|
c.fillStyle = BRAND.primary;
|
||||||
|
c.fillRect(0, 0, w, TITLE_BAND);
|
||||||
|
c.direction = 'rtl';
|
||||||
|
c.textAlign = 'right';
|
||||||
|
c.fillStyle = BRAND.onPrimary;
|
||||||
|
c.font = `bold 64px ${FONT}`;
|
||||||
|
c.fillText(strings.boards.titles[topic], w - PADDING, 80);
|
||||||
|
c.fillStyle = BRAND.text;
|
||||||
|
c.font = `36px ${FONT}`;
|
||||||
|
const lines = boardLines(topic).flatMap((text) => wrapLines(c, text, w - PADDING * 2));
|
||||||
|
lines.forEach((line, i) => c.fillText(line, w - PADDING, TITLE_BAND + 70 + i * BODY_LINE));
|
||||||
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
|
c.font = `28px ${FONT}`;
|
||||||
|
c.fillText(strings.boards.readMore, w - PADDING, h - 30);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the board mesh: two posts and a framed face.
|
||||||
|
* @param {string} topic - Information section.
|
||||||
|
* @returns {THREE.Group} Board model, facing local +Z.
|
||||||
|
*/
|
||||||
|
function boardModel(topic) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const steel = new THREE.MeshStandardMaterial({ color: BRAND.primaryDarker, roughness: 0.5 });
|
||||||
|
const top = BOARD_CENTRE_Y + BOARD_HEIGHT / 2;
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const post = new THREE.Mesh(new THREE.BoxGeometry(0.12, top, 0.12), steel);
|
||||||
|
post.position.set((side * (BOARD_WIDTH + 0.1)) / 2, top / 2, 0);
|
||||||
|
group.add(post);
|
||||||
|
}
|
||||||
|
const frame = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(BOARD_WIDTH + 0.16, BOARD_HEIGHT + 0.16, 0.08),
|
||||||
|
steel,
|
||||||
|
);
|
||||||
|
frame.position.y = BOARD_CENTRE_Y;
|
||||||
|
const face = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(BOARD_WIDTH, BOARD_HEIGHT),
|
||||||
|
new THREE.MeshBasicMaterial({ map: boardTexture(topic) }),
|
||||||
|
);
|
||||||
|
face.position.set(0, BOARD_CENTRE_Y, 0.05);
|
||||||
|
group.add(frame, face);
|
||||||
|
group.traverse((mesh) => (mesh.castShadow = true));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places one board in the yard.
|
||||||
|
* @param {THREE.Scene} scene - Scene to add it to.
|
||||||
|
* @param {{id: string, topic: string, u: number, v: number}} board - Board definition.
|
||||||
|
* @returns {object} Board with world position, reading spot, collision outline and objective info.
|
||||||
|
*/
|
||||||
|
function placeBoard(scene, { id, topic, u, v }) {
|
||||||
|
const model = boardModel(topic);
|
||||||
|
const position = factoryToWorld(u, v);
|
||||||
|
model.position.set(position.x, 0, position.z);
|
||||||
|
model.rotation.y = FACTORY_ROTATION;
|
||||||
|
model.name = id;
|
||||||
|
scene.add(model);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
topic,
|
||||||
|
x: position.x,
|
||||||
|
z: position.z,
|
||||||
|
front: factoryToWorld(u, v + FRONT_DISTANCE),
|
||||||
|
footprint: factoryFootprint({ u, v, halfU: BOARD_WIDTH / 2 + 0.1, halfV: 0.15 }),
|
||||||
|
// Same shape as a machine station's `def`, so boards can be mission objectives.
|
||||||
|
def: { id, name: strings.points[id], poi: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the information boards in the factory yard.
|
||||||
|
* @param {THREE.Scene} scene - Scene to add them to.
|
||||||
|
* @returns {{boards: object[], nearest: function}} Boards and a lookup for the one in reach.
|
||||||
|
*/
|
||||||
|
export function createAboutBoards(scene) {
|
||||||
|
const boards = ABOUT_BOARDS.map((board) => placeBoard(scene, board));
|
||||||
|
return {
|
||||||
|
boards,
|
||||||
|
/**
|
||||||
|
* The board the player can read from where they stand.
|
||||||
|
* @param {{x: number, z: number}} player - Player position.
|
||||||
|
* @returns {object | null} Closest board within reach, or null.
|
||||||
|
*/
|
||||||
|
nearest(player) {
|
||||||
|
const distance = (board) => Math.hypot(board.front.x - player.x, board.front.z - player.z);
|
||||||
|
const inReach = boards.filter((board) => distance(board) < BOARD_REACH);
|
||||||
|
return inReach.sort((a, b) => distance(a) - distance(b))[0] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+20
-142
@@ -1,7 +1,11 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
|
// Brand colours for materials and canvas drawings.
|
||||||
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
|
const BRAND = COMPANY.colors;
|
||||||
|
|
||||||
|
// Detailed vehicles for the family and the factory, plus the shared helpers luxuryCars.js builds
|
||||||
|
// on. All face local +X and sit on y = 0, like createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the mat operation for this subsystem.
|
* Runs the mat operation for this subsystem.
|
||||||
@@ -9,7 +13,7 @@ import * as THREE from 'three';
|
|||||||
* @param {*} extra - extra.
|
* @param {*} extra - extra.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
const mat = (color, extra = {}) =>
|
export const mat = (color, extra = {}) =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color,
|
color,
|
||||||
roughness: 0.45,
|
roughness: 0.45,
|
||||||
@@ -25,7 +29,7 @@ const mat = (color, extra = {}) =>
|
|||||||
* @param {*} bevel - bevel.
|
* @param {*} bevel - bevel.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
function extrudeProfile(points, width, material, bevel = 0.1) {
|
export function extrudeProfile(points, width, material, bevel = 0.1) {
|
||||||
const shape = new THREE.Shape();
|
const shape = new THREE.Shape();
|
||||||
points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
|
points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
@@ -52,7 +56,7 @@ function extrudeProfile(points, width, material, bevel = 0.1) {
|
|||||||
* @param {*} top - top.
|
* @param {*} top - top.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
function bodyOutline(front, rear, arches, radius, top) {
|
export function bodyOutline(front, rear, arches, radius, top) {
|
||||||
const shape = new THREE.Shape();
|
const shape = new THREE.Shape();
|
||||||
shape.moveTo(rear[0][0], rear[0][1]);
|
shape.moveTo(rear[0][0], rear[0][1]);
|
||||||
const bottom = 0.3;
|
const bottom = 0.3;
|
||||||
@@ -66,6 +70,15 @@ function bodyOutline(front, rear, arches, radius, top) {
|
|||||||
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Measures a freshly built model (not yet placed or parented) on the ground plane.
|
||||||
|
* @param {THREE.Group} group - Model group facing local +X.
|
||||||
|
* @returns {{halfLength: number, halfWidth: number}} Half extents along and across the body.
|
||||||
|
*/
|
||||||
|
export function measureFootprint(group) {
|
||||||
|
const size = new THREE.Box3().setFromObject(group).getSize(new THREE.Vector3());
|
||||||
|
return { halfLength: size.x / 2, halfWidth: size.z / 2 };
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the add wheels operation for this subsystem.
|
* Runs the add wheels operation for this subsystem.
|
||||||
* @param {*} group - group.
|
* @param {*} group - group.
|
||||||
@@ -76,7 +89,7 @@ function bodyOutline(front, rear, arches, radius, top) {
|
|||||||
* @param {*} spokes - spokes.
|
* @param {*} spokes - spokes.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
|
export function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
|
||||||
const tyre = mat('#151719', {
|
const tyre = mat('#151719', {
|
||||||
roughness: 0.9,
|
roughness: 0.9,
|
||||||
}),
|
}),
|
||||||
@@ -311,141 +324,6 @@ export function createSportSedan(color = '#1d3f78') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Double-cab pickup in the style of the KMC T8: tall square bonnet, wide chrome grille, a cargo
|
|
||||||
* bed behind the cab. 5.3 m long.
|
|
||||||
*/
|
|
||||||
export function createPickup(color = '#e8e8e4') {
|
|
||||||
const group = new THREE.Group();
|
|
||||||
const paint = mat(color, {
|
|
||||||
metalness: 0.5,
|
|
||||||
roughness: 0.32,
|
|
||||||
}),
|
|
||||||
glass = mat('#13202a', {
|
|
||||||
metalness: 0.6,
|
|
||||||
roughness: 0.1,
|
|
||||||
});
|
|
||||||
const black = mat('#1a1c1e', {
|
|
||||||
roughness: 0.7,
|
|
||||||
}),
|
|
||||||
chrome = mat('#d0d4d7', {
|
|
||||||
metalness: 0.9,
|
|
||||||
roughness: 0.2,
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* Runs the box operation for this subsystem.
|
|
||||||
* @param {*} w - w.
|
|
||||||
* @param {*} h - h.
|
|
||||||
* @param {*} d - d.
|
|
||||||
* @param {*} m - m.
|
|
||||||
* @param {*} x - x.
|
|
||||||
* @param {*} y - y.
|
|
||||||
* @param {*} z - z.
|
|
||||||
* @returns {*} Operation result, when applicable.
|
|
||||||
*/
|
|
||||||
const box = (w, h, d, m, x, y, z) => {
|
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
|
||||||
mesh.position.set(x, y, z);
|
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
|
||||||
group.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
group.add(
|
|
||||||
extrudeProfile(
|
|
||||||
[
|
|
||||||
[2.62, 0.5],
|
|
||||||
[2.66, 0.95],
|
|
||||||
[2.5, 1.18],
|
|
||||||
[1.3, 1.24],
|
|
||||||
[1.3, 0.5],
|
|
||||||
],
|
|
||||||
1.9,
|
|
||||||
paint,
|
|
||||||
0.1,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
group.add(
|
|
||||||
extrudeProfile(
|
|
||||||
[
|
|
||||||
[1.3, 0.5],
|
|
||||||
[1.3, 1.24],
|
|
||||||
[0.7, 1.86],
|
|
||||||
[-0.95, 1.88],
|
|
||||||
[-1.0, 0.5],
|
|
||||||
],
|
|
||||||
1.88,
|
|
||||||
paint,
|
|
||||||
0.1,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
group.add(
|
|
||||||
extrudeProfile(
|
|
||||||
[
|
|
||||||
[1.24, 1.26],
|
|
||||||
[0.68, 1.82],
|
|
||||||
[-0.9, 1.84],
|
|
||||||
[-0.94, 1.26],
|
|
||||||
],
|
|
||||||
1.92,
|
|
||||||
glass,
|
|
||||||
0.02,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Cargo bed: floor, sides and tailgate.
|
|
||||||
box(1.62, 0.1, 1.8, black, -1.85, 0.72, 0);
|
|
||||||
for (const side of [-1, 1]) box(1.66, 0.5, 0.07, paint, -1.85, 0.95, side * 0.9);
|
|
||||||
box(0.07, 0.5, 1.86, paint, -2.66, 0.95, 0);
|
|
||||||
box(1.66, 0.46, 1.9, paint, -1.85, 0.5, 0);
|
|
||||||
box(0.3, 0.06, 1.86, black, -1.02, 1.26, 0);
|
|
||||||
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
|
|
||||||
box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0);
|
|
||||||
for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0);
|
|
||||||
const lamp = mat('#eaf6ff', {
|
|
||||||
emissive: '#cfe9ff',
|
|
||||||
emissiveIntensity: 1.2,
|
|
||||||
});
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
|
|
||||||
box(
|
|
||||||
0.06,
|
|
||||||
0.26,
|
|
||||||
0.1,
|
|
||||||
mat('#9e1410', {
|
|
||||||
emissive: '#c4241b',
|
|
||||||
emissiveIntensity: 0.7,
|
|
||||||
}),
|
|
||||||
-2.7,
|
|
||||||
1.0,
|
|
||||||
side * 0.86,
|
|
||||||
);
|
|
||||||
box(0.2, 0.14, 0.2, black, 1.2, 1.36, side * 1.02);
|
|
||||||
box(1.0, 0.05, 0.06, chrome, -0.1, 1.25, side * 0.97);
|
|
||||||
for (const x of [0.6, -0.4]) box(0.16, 0.04, 0.03, chrome, x, 1.12, side * 0.97);
|
|
||||||
}
|
|
||||||
box(0.16, 0.2, 2.0, black, 2.7, 0.55, 0);
|
|
||||||
box(0.16, 0.2, 2.0, black, -2.72, 0.55, 0);
|
|
||||||
box(0.04, 0.12, 0.5, mat('#f4f4f0'), 2.79, 0.6, 0);
|
|
||||||
const wheels = addWheels(
|
|
||||||
group,
|
|
||||||
[
|
|
||||||
[-1.7, -0.86],
|
|
||||||
[-1.7, 0.86],
|
|
||||||
[1.7, -0.86],
|
|
||||||
[1.7, 0.86],
|
|
||||||
],
|
|
||||||
0.4,
|
|
||||||
0.3,
|
|
||||||
'#a9adb1',
|
|
||||||
6,
|
|
||||||
);
|
|
||||||
for (const x of [-1.7, 1.7])
|
|
||||||
for (const side of [-1, 1]) box(1.05, 0.1, 0.2, black, x, 0.86, side * 0.96);
|
|
||||||
return {
|
|
||||||
group,
|
|
||||||
wheels,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatbed delivery lorry with side rails. `bed` is a group on the load floor (local +X towards the
|
* Flatbed delivery lorry with side rails. `bed` is a group on the load floor (local +X towards the
|
||||||
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
|
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
|
||||||
@@ -463,7 +341,7 @@ export function createTruck(color = '#e9eef0') {
|
|||||||
const black = mat('#1b1d1f', {
|
const black = mat('#1b1d1f', {
|
||||||
roughness: 0.7,
|
roughness: 0.7,
|
||||||
}),
|
}),
|
||||||
teal = mat('#0b8f88', {
|
teal = mat(BRAND.primary, {
|
||||||
metalness: 0.3,
|
metalness: 0.3,
|
||||||
roughness: 0.4,
|
roughness: 0.4,
|
||||||
});
|
});
|
||||||
|
|||||||
+93
-115
@@ -1,10 +1,17 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createVendingMachine } from './vending.js';
|
import { createVendingMachine } from './vending.js';
|
||||||
import { createCarModel } from '../systems/traffic.js';
|
import { createTruck } from './carModels.js';
|
||||||
import { createPickup, createTruck } from './carModels.js';
|
import { createLuxuryCar } from './luxuryCars.js';
|
||||||
|
import { MANAGER_CAR_BRAND, VEHICLE_BLACK } from '../../constants/vehicles.js';
|
||||||
import { createPerson, createCrew, nameTag } from './staff.js';
|
import { createPerson, createCrew, nameTag } from './staff.js';
|
||||||
import { FACTORY } from '../../constants/locations.js';
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
|
import { MANAGER_POINT } from '../../constants/factoryPoints.js';
|
||||||
import { textTexture } from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
/**
|
/**
|
||||||
* Runs the stripes operation for this subsystem.
|
* Runs the stripes operation for this subsystem.
|
||||||
* @param {*} c - c.
|
* @param {*} c - c.
|
||||||
@@ -31,9 +38,9 @@ function stripes(c, w, h, base, line, every) {
|
|||||||
*/
|
*/
|
||||||
const sign = (text, sub = '') =>
|
const sign = (text, sub = '') =>
|
||||||
textTexture(512, 160, (c, w, h) => {
|
textTexture(512, 160, (c, w, h) => {
|
||||||
c.fillStyle = '#0d2f2c';
|
c.fillStyle = BRAND.primaryDarker;
|
||||||
c.fillRect(0, 0, w, h);
|
c.fillRect(0, 0, w, h);
|
||||||
c.fillStyle = '#3ee0c9';
|
c.fillStyle = BRAND.primary;
|
||||||
c.fillRect(0, h - 12, w, 12);
|
c.fillRect(0, h - 12, w, 12);
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
c.direction = 'rtl';
|
c.direction = 'rtl';
|
||||||
@@ -41,7 +48,7 @@ const sign = (text, sub = '') =>
|
|||||||
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
||||||
c.fillText(text, w / 2, sub ? 72 : 100);
|
c.fillText(text, w / 2, sub ? 72 : 100);
|
||||||
if (sub) {
|
if (sub) {
|
||||||
c.fillStyle = '#9fe9da';
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
c.font = '36px Vazirmatn, Tahoma, sans-serif';
|
c.font = '36px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText(sub, w / 2, 128);
|
c.fillText(sub, w / 2, 128);
|
||||||
}
|
}
|
||||||
@@ -112,25 +119,20 @@ const CODE = {
|
|||||||
),
|
),
|
||||||
sheet: screen(
|
sheet: screen(
|
||||||
[
|
[
|
||||||
['فاکتور دستگاه مبلغ', '#1d3b2f'],
|
// Header, five invoice rows, then the total in the brand colour.
|
||||||
['۱۰۴۲ بوستان ملت ۴۸۰', '#333'],
|
...strings.factory.decor.invoices.map((line, i, all) => [
|
||||||
['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'],
|
line,
|
||||||
['۱۰۴۴ مترو آزادی ۲۹۰', '#333'],
|
['#1d3b2f', ...Array(all.length - 2).fill('#333'), COMPANY.colors.primaryDark][i],
|
||||||
['۱۰۴۵ مشهد مال ۶۱۰', '#333'],
|
]),
|
||||||
['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'],
|
|
||||||
['جمع ۱۸۸۰', '#0b8f88'],
|
|
||||||
],
|
],
|
||||||
'#f4f7f2',
|
'#f4f7f2',
|
||||||
),
|
),
|
||||||
dashboard: screen(
|
dashboard: screen(
|
||||||
[
|
[
|
||||||
['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'],
|
...strings.factory.decor.dashboard.map((line, i) => [
|
||||||
['تولید هفته: ۴۶ دستگاه', '#f2c94c'],
|
line,
|
||||||
['ارسال شده: ۳۸', '#eeffff'],
|
[COMPANY.colors.primary, '#f2c94c', '#eeffff', '#fff'][i] ?? '#82aaff',
|
||||||
['', '#fff'],
|
]),
|
||||||
['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'],
|
|
||||||
['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'],
|
|
||||||
['▇▇▇▇▇▇▇ رنگ', '#82aaff'],
|
|
||||||
],
|
],
|
||||||
'#101820',
|
'#101820',
|
||||||
),
|
),
|
||||||
@@ -139,7 +141,7 @@ const CODE = {
|
|||||||
/**
|
/**
|
||||||
* The Robot Market factory compound: production hall with five work areas and forty workers, an
|
* The Robot Market factory compound: production hall with five work areas and forty workers, an
|
||||||
* office building (engineering, accounting and the manager's room), a loading truck, the manager's
|
* office building (engineering, accounting and the manager's room), a loading truck, the manager's
|
||||||
* arrival in his pickup, fenced yard and gate. Layout uses a local frame: u runs along the driveway
|
* arrival in his car, fenced yard and gate. Layout uses a local frame: u runs along the driveway
|
||||||
* towards the gate, v across it. Returns world-space collision footprints, key positions and the
|
* towards the gate, v across it. Returns world-space collision footprints, key positions and the
|
||||||
* people the family can talk to.
|
* people the family can talk to.
|
||||||
*/
|
*/
|
||||||
@@ -270,8 +272,8 @@ function factoryUpdateArrival(build, dt, time) {
|
|||||||
if (build.arrival.stage === 'drive') {
|
if (build.arrival.stage === 'drive') {
|
||||||
build.arrival.s += dt * 7;
|
build.arrival.s += dt * 7;
|
||||||
const p = build.along(build.DRIVE_IN, build.arrival.s);
|
const p = build.along(build.DRIVE_IN, build.arrival.s);
|
||||||
build.placeVehicle(build.pickup, build.arrival, p, dt);
|
build.placeVehicle(build.managerCar, build.arrival, p, dt);
|
||||||
build.pickup.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4));
|
build.managerCar.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4));
|
||||||
build.manager.root.visible = false;
|
build.manager.root.visible = false;
|
||||||
if (p.done)
|
if (p.done)
|
||||||
Object.assign(build.arrival, {
|
Object.assign(build.arrival, {
|
||||||
@@ -304,7 +306,7 @@ function factoryResetArrival(build) {
|
|||||||
s: 0,
|
s: 0,
|
||||||
sat: false,
|
sat: false,
|
||||||
});
|
});
|
||||||
build.placeVehicle(build.pickup, build.arrival, build.along(build.DRIVE_IN, 0), 0);
|
build.placeVehicle(build.managerCar, build.arrival, build.along(build.DRIVE_IN, 0), 0);
|
||||||
build.manager.root.visible = false;
|
build.manager.root.visible = false;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -506,7 +508,7 @@ function factoryBuildFlat(build) {
|
|||||||
build.steel = build.mat('#59646b', {
|
build.steel = build.mat('#59646b', {
|
||||||
metalness: 0.5,
|
metalness: 0.5,
|
||||||
});
|
});
|
||||||
build.teal = build.mat('#0b8f88', {
|
build.teal = build.mat(BRAND.primary, {
|
||||||
metalness: 0.3,
|
metalness: 0.3,
|
||||||
roughness: 0.4,
|
roughness: 0.4,
|
||||||
});
|
});
|
||||||
@@ -562,16 +564,16 @@ function factoryBuildAnimated(build) {
|
|||||||
*/
|
*/
|
||||||
function factoryBuildNameSign(build) {
|
function factoryBuildNameSign(build) {
|
||||||
build.nameSign = textTexture(1024, 320, (c, w, h) => {
|
build.nameSign = textTexture(1024, 320, (c, w, h) => {
|
||||||
c.fillStyle = '#0d2f2c';
|
c.fillStyle = BRAND.primaryDarker;
|
||||||
c.fillRect(0, 0, w, h);
|
c.fillRect(0, 0, w, h);
|
||||||
c.fillStyle = '#3ee0c9';
|
c.fillStyle = BRAND.primary;
|
||||||
c.fillRect(0, h - 18, w, 18);
|
c.fillRect(0, h - 18, w, 18);
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
c.direction = 'rtl';
|
c.direction = 'rtl';
|
||||||
c.fillStyle = '#ffffff';
|
c.fillStyle = '#ffffff';
|
||||||
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
|
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText(FACTORY.name, w / 2, 120);
|
c.fillText(FACTORY.name, w / 2, 120);
|
||||||
c.fillStyle = '#9fe9da';
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
c.font = '44px Vazirmatn, Tahoma, sans-serif';
|
c.font = '44px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText(FACTORY.tagline, w / 2, 210);
|
c.fillText(FACTORY.tagline, w / 2, 210);
|
||||||
c.font = 'bold 34px sans-serif';
|
c.font = 'bold 34px sans-serif';
|
||||||
@@ -715,12 +717,8 @@ function factoryBuildGeometry3(build) {
|
|||||||
for (const du of [-2.2, 2.2])
|
for (const du of [-2.2, 2.2])
|
||||||
build.box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, build.H, build.steel, false);
|
build.box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, build.H, build.steel, false);
|
||||||
};
|
};
|
||||||
build.zone('خمکاری', 'ورقکاری بدنهٔ دستگاه', -93);
|
// Work areas along the hall, from the press brakes to packing (u of each sign).
|
||||||
build.zone('برش لیزر', 'برش ورق و قطعات', -71);
|
[-93, -71, -52, -31, -13, -3].forEach((u, i) => build.zone(...strings.factory.zones[i], u));
|
||||||
build.zone('رنگکاری', 'کابین رنگ پودری', -52);
|
|
||||||
build.zone('یخچالسازی', 'کمپرسور و عایقکاری', -31);
|
|
||||||
build.zone('مونتاژ', 'خط مونتاژ نهایی', -13);
|
|
||||||
build.zone('بستهبندی', 'ارسال به شهر', -3);
|
|
||||||
|
|
||||||
// Bending: two press brakes with a moving ram, sheet stacks behind.
|
// Bending: two press brakes with a moving ram, sheet stacks behind.
|
||||||
}
|
}
|
||||||
@@ -941,7 +939,7 @@ function factoryBuildGeometry6(build) {
|
|||||||
*/
|
*/
|
||||||
function factoryBuildFrames(build) {
|
function factoryBuildFrames(build) {
|
||||||
build.frames = [];
|
build.frames = [];
|
||||||
build.frameMaterial = build.mat('#0b8f88', {
|
build.frameMaterial = build.mat(BRAND.primary, {
|
||||||
wireframe: true,
|
wireframe: true,
|
||||||
});
|
});
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
@@ -989,7 +987,7 @@ function factoryBuildGeometry7(build) {
|
|||||||
function factoryBuildGeometry8(build) {
|
function factoryBuildGeometry8(build) {
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const finished = createVendingMachine({
|
const finished = createVendingMachine({
|
||||||
name: 'آمادهٔ نصب',
|
name: strings.factory.readyToInstall,
|
||||||
products: ['water', 'juice', 'biscuit', 'chocolate'],
|
products: ['water', 'juice', 'biscuit', 'chocolate'],
|
||||||
});
|
});
|
||||||
finished.beacon.visible = finished.ring.visible = false;
|
finished.beacon.visible = finished.ring.visible = false;
|
||||||
@@ -1013,7 +1011,7 @@ function factoryBuildCarton(build) {
|
|||||||
c.fillRect(0, 0, w, h);
|
c.fillRect(0, 0, w, h);
|
||||||
c.fillStyle = 'rgba(255,255,255,.18)';
|
c.fillStyle = 'rgba(255,255,255,.18)';
|
||||||
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
|
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
|
||||||
c.fillStyle = '#0b8f88';
|
c.fillStyle = BRAND.primary;
|
||||||
c.fillRect(0, h * 0.42, w, 34);
|
c.fillRect(0, h * 0.42, w, 34);
|
||||||
c.fillStyle = '#1d2a2a';
|
c.fillStyle = '#1d2a2a';
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
@@ -1293,9 +1291,9 @@ function factoryBuildGeometry9(build) {
|
|||||||
])
|
])
|
||||||
build.box(u0, u1, -36, -34, build.OH - 0.08, build.OH - 0.02, build.lamp, false).castShadow =
|
build.box(u0, u1, -36, -34, build.OH - 0.08, build.OH - 0.02, build.lamp, false).castShadow =
|
||||||
false;
|
false;
|
||||||
build.panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, build.F + 0.05);
|
build.panel(sign(...strings.factory.signs.engineering), 3.6, 1.1, 15.5, 3.25, build.F + 0.05);
|
||||||
build.panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, build.F + 0.05);
|
build.panel(sign(...strings.factory.signs.accounting), 3.6, 1.1, 32.5, 3.25, build.F + 0.05);
|
||||||
build.panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, build.F + 0.05);
|
build.panel(sign(...strings.factory.signs.management), 3.6, 1.1, 45, 3.25, build.F + 0.05);
|
||||||
build.wood = build.mat('#c8a477', {
|
build.wood = build.mat('#c8a477', {
|
||||||
roughness: 0.6,
|
roughness: 0.6,
|
||||||
});
|
});
|
||||||
@@ -1321,7 +1319,7 @@ function factoryBuildFabric(build) {
|
|||||||
function factoryBuildDevelopers(build) {
|
function factoryBuildDevelopers(build) {
|
||||||
build.developers = [
|
build.developers = [
|
||||||
{
|
{
|
||||||
name: 'حامد',
|
name: strings.factory.staff.hamed.name,
|
||||||
role: 'React',
|
role: 'React',
|
||||||
u: 11,
|
u: 11,
|
||||||
v: -31.6,
|
v: -31.6,
|
||||||
@@ -1332,13 +1330,10 @@ function factoryBuildDevelopers(build) {
|
|||||||
},
|
},
|
||||||
code: CODE.react,
|
code: CODE.react,
|
||||||
speaker: 'hamed',
|
speaker: 'hamed',
|
||||||
lines: [
|
lines: strings.factory.staff.hamed.lines,
|
||||||
'سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاهها رو با React میسازم.',
|
|
||||||
'هر چی روی صفحهٔ دستگاه میبینین، از همین مانیتور اومده!',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'رایان',
|
name: strings.factory.staff.ryan.name,
|
||||||
role: 'Go',
|
role: 'Go',
|
||||||
u: 16,
|
u: 16,
|
||||||
v: -31.6,
|
v: -31.6,
|
||||||
@@ -1350,10 +1345,10 @@ function factoryBuildDevelopers(build) {
|
|||||||
},
|
},
|
||||||
code: CODE.go,
|
code: CODE.go,
|
||||||
speaker: 'ryan',
|
speaker: 'ryan',
|
||||||
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاهها رو با Go مینویسم.'],
|
lines: strings.factory.staff.ryan.lines,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'علیرضا',
|
name: strings.factory.staff.alireza.name,
|
||||||
role: 'C++',
|
role: 'C++',
|
||||||
u: 21,
|
u: 21,
|
||||||
v: -31.6,
|
v: -31.6,
|
||||||
@@ -1365,12 +1360,10 @@ function factoryBuildDevelopers(build) {
|
|||||||
},
|
},
|
||||||
code: CODE.cpp,
|
code: CODE.cpp,
|
||||||
speaker: 'alireza',
|
speaker: 'alireza',
|
||||||
lines: [
|
lines: strings.factory.staff.alireza.lines,
|
||||||
'سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم میکنم.',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'رضا',
|
name: strings.factory.staff.reza.name,
|
||||||
role: 'C++',
|
role: 'C++',
|
||||||
u: 13.5,
|
u: 13.5,
|
||||||
v: -38.6,
|
v: -38.6,
|
||||||
@@ -1383,13 +1376,10 @@ function factoryBuildDevelopers(build) {
|
|||||||
},
|
},
|
||||||
code: CODE.cpp,
|
code: CODE.cpp,
|
||||||
speaker: 'reza',
|
speaker: 'reza',
|
||||||
lines: [
|
lines: strings.factory.staff.reza.lines,
|
||||||
'سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.',
|
|
||||||
'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'محمد',
|
name: strings.factory.staff.mohammad.name,
|
||||||
role: '',
|
role: '',
|
||||||
u: 18.5,
|
u: 18.5,
|
||||||
v: -38.6,
|
v: -38.6,
|
||||||
@@ -1401,7 +1391,7 @@ function factoryBuildDevelopers(build) {
|
|||||||
},
|
},
|
||||||
code: CODE.terminal,
|
code: CODE.terminal,
|
||||||
speaker: 'mohammad',
|
speaker: 'mohammad',
|
||||||
lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!'],
|
lines: strings.factory.staff.mohammad.lines,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1443,12 +1433,10 @@ function factoryBuildBoard(build) {
|
|||||||
c.direction = 'rtl';
|
c.direction = 'rtl';
|
||||||
c.fillStyle = '#1d4f91';
|
c.fillStyle = '#1d4f91';
|
||||||
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
|
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
|
c.fillText(strings.factory.decor.sprintTitle, w / 2, 56);
|
||||||
c.font = '28px Vazirmatn, Tahoma, sans-serif';
|
c.font = '28px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillStyle = '#333';
|
c.fillStyle = '#333';
|
||||||
['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) =>
|
strings.factory.decor.sprintItems.forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
|
||||||
c.fillText(t, w / 2, 110 + i * 44),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
build.panel(build.board, 4, 2, 16, 1.9, build.B + 0.05);
|
build.panel(build.board, 4, 2, 16, 1.9, build.B + 0.05);
|
||||||
build.officeMachine = {
|
build.officeMachine = {
|
||||||
@@ -1537,10 +1525,10 @@ function factoryBuildGeometry11(build) {
|
|||||||
);
|
);
|
||||||
build.talks.push({
|
build.talks.push({
|
||||||
speaker: 'accountant',
|
speaker: 'accountant',
|
||||||
label: 'واحد حسابداری',
|
label: strings.factory.accounting.label,
|
||||||
radius: 4,
|
radius: 4,
|
||||||
pos: () => build.toWorld(33, -29),
|
pos: () => build.toWorld(33, -29),
|
||||||
lines: ['سلام! اینجا فروش همهٔ دستگاهها ثبت میشه. هر خریدی بکنین، فاکتورش اینجا میاد!'],
|
lines: strings.factory.accounting.lines,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
|
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
|
||||||
@@ -1575,7 +1563,15 @@ function factoryBuildGeometry12(build) {
|
|||||||
build.leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), build.mat('#3e7a3e'));
|
build.leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), build.mat('#3e7a3e'));
|
||||||
build.leaves.position.set(49.25, 1.0, -26.75);
|
build.leaves.position.set(49.25, 1.0, -26.75);
|
||||||
build.group.add(build.leaves);
|
build.group.add(build.leaves);
|
||||||
build.nameplate = build.panel(sign('محمدی', 'مدیر شرکت'), 0.6, 0.19, 45, 0.87, -39.2, 1);
|
build.nameplate = build.panel(
|
||||||
|
sign(...strings.factory.signs.nameplate),
|
||||||
|
0.6,
|
||||||
|
0.19,
|
||||||
|
45,
|
||||||
|
0.87,
|
||||||
|
-39.2,
|
||||||
|
1,
|
||||||
|
);
|
||||||
build.nameplate.rotation.x = -0.3;
|
build.nameplate.rotation.x = -0.3;
|
||||||
|
|
||||||
// ---------- Staff car park ----------
|
// ---------- Staff car park ----------
|
||||||
@@ -1586,20 +1582,17 @@ function factoryBuildGeometry12(build) {
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
function factoryBuildGeometry13(build) {
|
function factoryBuildGeometry13(build) {
|
||||||
for (let i = 0; i < 4; i++) {
|
// Bay centres for the staff cars; the garage parks drivable cars there.
|
||||||
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
|
const heading = build.worldHeading(0, -1);
|
||||||
const u = 56 + i * 4;
|
build.parking = {
|
||||||
parked.group.position.set(u, 0, -19);
|
cars: [58, 62, 66].map((u) => ({ ...build.toWorld(u, -19), heading })),
|
||||||
parked.group.rotation.y = Math.PI / 2;
|
};
|
||||||
build.group.add(parked.group);
|
|
||||||
build.solid(u - 1.1, u + 1.1, -21.3, -16.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- The manager arrives in his pickup ----------
|
// ---------- The manager arrives in his Bugatti ----------
|
||||||
// Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
|
// Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
|
||||||
// his room and sits at his desk.
|
// his room and sits at his desk. Once parked the car can be taken like any other.
|
||||||
build.pickup = createPickup('#f1f1ee');
|
build.managerCar = createLuxuryCar(MANAGER_CAR_BRAND);
|
||||||
build.group.add(build.pickup.group);
|
build.group.add(build.managerCar.group);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the factory build manager operation for this subsystem.
|
* Runs the factory build manager operation for this subsystem.
|
||||||
@@ -1615,7 +1608,7 @@ function factoryBuildManager(build) {
|
|||||||
glasses: true,
|
glasses: true,
|
||||||
shoes: '#111',
|
shoes: '#111',
|
||||||
});
|
});
|
||||||
build.managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
|
build.managerTag = nameTag(strings.factory.manager.name, strings.factory.manager.role);
|
||||||
build.managerTag.position.y = 2.15;
|
build.managerTag.position.y = 2.15;
|
||||||
build.manager.root.add(build.managerTag);
|
build.manager.root.add(build.managerTag);
|
||||||
build.group.add(build.manager.root);
|
build.group.add(build.manager.root);
|
||||||
@@ -1634,24 +1627,6 @@ function factoryBuildManager(build) {
|
|||||||
[48.5, -40.7],
|
[48.5, -40.7],
|
||||||
[45, -40.7],
|
[45, -40.7],
|
||||||
];
|
];
|
||||||
{
|
|
||||||
// The parked pickup blocks its bay.
|
|
||||||
const [ax, az] = build.DRIVE_IN.at(-2),
|
|
||||||
[bx, bz] = build.DRIVE_IN.at(-1),
|
|
||||||
len = Math.hypot(bx - ax, bz - az);
|
|
||||||
const du = (bx - ax) / len,
|
|
||||||
dv = (bz - az) / len;
|
|
||||||
build.footprints.push(
|
|
||||||
[
|
|
||||||
[2.7, 1],
|
|
||||||
[2.7, -1],
|
|
||||||
[-2.7, -1],
|
|
||||||
[-2.7, 1],
|
|
||||||
].flatMap(([a, b]) =>
|
|
||||||
Object.values(build.toWorld(bx + du * a - dv * b, bz + dv * a + du * b)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the factory build arrival operation for this subsystem.
|
* Runs the factory build arrival operation for this subsystem.
|
||||||
@@ -1666,16 +1641,15 @@ function factoryBuildArrival(build) {
|
|||||||
sat: false,
|
sat: false,
|
||||||
};
|
};
|
||||||
build.talks.push({
|
build.talks.push({
|
||||||
|
// He welcomes the family on every visit; meeting him is an About Us mission step.
|
||||||
|
id: MANAGER_POINT.id,
|
||||||
|
repeat: true,
|
||||||
speaker: 'mohammadi',
|
speaker: 'mohammadi',
|
||||||
label: 'آقای محمدی',
|
label: strings.factory.manager.name,
|
||||||
radius: 3.4,
|
radius: 3.4,
|
||||||
pos: () => build.toWorld(build.manager.root.position.x, build.manager.root.position.z),
|
pos: () => build.toWorld(build.manager.root.position.x, build.manager.root.position.z),
|
||||||
ready: () => build.arrival.stage === 'seated',
|
ready: () => build.arrival.stage === 'seated',
|
||||||
lines: [
|
lines: strings.factory.managerLines,
|
||||||
'سلام، خوش اومدین! من محمدیام، مدیر روبات مارکت.',
|
|
||||||
'امروز چهل نفر توی سالن تولید کار میکنن: خمکاری، برش، رنگ، یخچالسازی و مونتاژ.',
|
|
||||||
'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاههای شهر!',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Position and direction a distance `s` along a polyline of [u, v] points.
|
// Position and direction a distance `s` along a polyline of [u, v] points.
|
||||||
@@ -1723,7 +1697,7 @@ function factoryBuildPlaceVehicle(build) {
|
|||||||
// ---------- Delivery truck ----------
|
// ---------- Delivery truck ----------
|
||||||
// Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out
|
// Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out
|
||||||
// of the gate to the city, and comes back empty a while later, reversing up to the dock.
|
// of the gate to the city, and comes back empty a while later, reversing up to the dock.
|
||||||
build.truck = createTruck();
|
build.truck = createTruck(VEHICLE_BLACK);
|
||||||
build.group.add(build.truck.group);
|
build.group.add(build.truck.group);
|
||||||
build.DOCK = [5.5, 31];
|
build.DOCK = [5.5, 31];
|
||||||
build.DEPART = [build.DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
|
build.DEPART = [build.DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
|
||||||
@@ -1766,7 +1740,7 @@ function factoryBuildDelivery(build) {
|
|||||||
radius: 9,
|
radius: 9,
|
||||||
pos: () => build.toWorld(4, 31),
|
pos: () => build.toWorld(4, 31),
|
||||||
once: true,
|
once: true,
|
||||||
lines: ['بابا ببین! کامیون داره دستگاههای بستهبندیشده رو بار میزنه که ببره شهر!'],
|
lines: [strings.factory.truck],
|
||||||
});
|
});
|
||||||
// The family comments on each work area the first time they walk into it.
|
// The family comments on each work area the first time they walk into it.
|
||||||
}
|
}
|
||||||
@@ -1776,23 +1750,20 @@ function factoryBuildDelivery(build) {
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
function factoryBuildGeometry14(build) {
|
function factoryBuildGeometry14(build) {
|
||||||
for (const [u, text, speaker] of [
|
// u of each production area and who comments on it; the lines come from the strings file.
|
||||||
[-93, 'اینجا خمکاریه؛ ورقهای فلزی بدنهٔ دستگاه رو با پرس خم میکنن.', 'father'],
|
for (const [i, [u, speaker]] of [
|
||||||
[-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ میبُره.', 'child'],
|
[-93, 'father'],
|
||||||
[-52, 'این کابین رنگه؛ بدنهها اینجا رنگ فیروزهای روبات مارکت میگیرن.', 'father'],
|
[-71, 'child'],
|
||||||
[-31, 'یخچالسازی! برای همینه که نوشیدنیهای دستگاه همیشه خنکن.', 'child'],
|
[-52, 'father'],
|
||||||
[
|
[-31, 'child'],
|
||||||
-13,
|
[-13, 'father'],
|
||||||
'خط مونتاژ: روباتها و کارگرها قطعهها رو کنار هم میذارن و دستگاه کامل میشه.',
|
].entries())
|
||||||
'father',
|
|
||||||
],
|
|
||||||
])
|
|
||||||
build.talks.push({
|
build.talks.push({
|
||||||
speaker,
|
speaker,
|
||||||
radius: 8,
|
radius: 8,
|
||||||
pos: () => build.toWorld(u, 25),
|
pos: () => build.toWorld(u, 25),
|
||||||
once: true,
|
once: true,
|
||||||
lines: [text],
|
lines: [strings.factory.areaTalks[i]],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Key positions ----------
|
// ---------- Key positions ----------
|
||||||
@@ -1836,6 +1807,13 @@ function factoryBuildResult(build) {
|
|||||||
...build.toWorld(-20, 0),
|
...build.toWorld(-20, 0),
|
||||||
},
|
},
|
||||||
talks: build.talks,
|
talks: build.talks,
|
||||||
|
parking: build.parking,
|
||||||
|
// The garage takes the manager's car over once it has parked, and hands it back on reset.
|
||||||
|
managerCar: {
|
||||||
|
model: build.managerCar,
|
||||||
|
home: build.group,
|
||||||
|
parked: () => build.arrival.stage !== 'drive',
|
||||||
|
},
|
||||||
reset: build.resetArrival,
|
reset: build.resetArrival,
|
||||||
// `dt` is 0 while the game is paused; distant viewers skip the people and machines.
|
// `dt` is 0 while the game is paused; distant viewers skip the people and machines.
|
||||||
update(time, dt, viewer) {
|
update(time, dt, viewer) {
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { VEHICLE_BLACK } from '../../constants/vehicles.js';
|
||||||
|
import { addWheels, bodyOutline, extrudeProfile, mat, measureFootprint } from './carModels.js';
|
||||||
|
|
||||||
|
// Low-poly luxury cars for the factory yard, each in the style of a well-known brand but without
|
||||||
|
// badges or logos. Like the other models they face local +X, sit on y = 0 and list their wheels
|
||||||
|
// rear pair first, then the two front (steering) ones.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the materials every luxury car shares.
|
||||||
|
* @param {string} color - Body paint colour.
|
||||||
|
* @returns {object} Paint, glass, trim and lamp materials.
|
||||||
|
*/
|
||||||
|
function createKit(color) {
|
||||||
|
return {
|
||||||
|
paint: mat(color, { metalness: 0.75, roughness: 0.22 }),
|
||||||
|
glass: mat('#0f1a22', { metalness: 0.7, roughness: 0.08 }),
|
||||||
|
black: mat('#050506', { roughness: 0.6 }),
|
||||||
|
chrome: mat('#d8dcdf', { metalness: 0.95, roughness: 0.15 }),
|
||||||
|
head: mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 }),
|
||||||
|
tail: mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a box part, optionally mirrored to both sides of the car.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {THREE.Material} material - Part material.
|
||||||
|
* @param {{size: number[], at: number[], mirror?: boolean}} placement - Box size and centre; with
|
||||||
|
* `mirror` a copy is placed at -z too.
|
||||||
|
* @returns {THREE.Mesh[]} The added meshes.
|
||||||
|
*/
|
||||||
|
function part(group, material, { size, at, mirror = false }) {
|
||||||
|
return (mirror ? [1, -1] : [1]).map((side) => {
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material);
|
||||||
|
mesh.position.set(at[0], at[1], at[2] * side);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
group.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a round lamp ring facing along the car, mirrored to both sides.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {THREE.Material} material - Lamp material.
|
||||||
|
* @param {{radius: number, at: number[]}} placement - Ring radius and centre.
|
||||||
|
*/
|
||||||
|
function lampRing(group, material, { radius, at }) {
|
||||||
|
for (const side of [1, -1]) {
|
||||||
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(radius, radius * 0.3, 8, 20), material);
|
||||||
|
ring.rotation.y = Math.PI / 2;
|
||||||
|
ring.position.set(at[0], at[1], at[2] * side);
|
||||||
|
group.add(ring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrudes the lower body outline across the car's width.
|
||||||
|
* @param {THREE.Shape} outline - Side outline from bodyOutline.
|
||||||
|
* @param {number} width - Overall body width.
|
||||||
|
* @param {THREE.Material} paint - Body paint.
|
||||||
|
* @returns {THREE.Mesh} The body mesh.
|
||||||
|
*/
|
||||||
|
function extrudeBody(outline, width, paint) {
|
||||||
|
const geometry = new THREE.ExtrudeGeometry(outline, {
|
||||||
|
depth: width - 0.2,
|
||||||
|
bevelEnabled: true,
|
||||||
|
bevelThickness: 0.1,
|
||||||
|
bevelSize: 0.08,
|
||||||
|
bevelSegments: 3,
|
||||||
|
curveSegments: 10,
|
||||||
|
});
|
||||||
|
geometry.translate(0, 0, -(width - 0.2) / 2);
|
||||||
|
const body = new THREE.Mesh(geometry, paint);
|
||||||
|
body.castShadow = body.receiveShadow = true;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the painted roof panel, mirrors and side skirts shared by every model.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {object} spec - Brand spec.
|
||||||
|
* @param {object} kit - Materials from createKit.
|
||||||
|
*/
|
||||||
|
function addCommonParts(group, spec, kit) {
|
||||||
|
const [, roofFront, roofRear] = spec.cabin;
|
||||||
|
const roofTop = Math.max(roofFront[1], roofRear[1]) + 0.02;
|
||||||
|
part(group, kit.paint, {
|
||||||
|
size: [roofFront[0] - roofRear[0], 0.05, spec.cabinWidth - 0.1],
|
||||||
|
at: [(roofFront[0] + roofRear[0]) / 2, roofTop, 0],
|
||||||
|
});
|
||||||
|
const [windscreenX, windscreenY] = spec.cabin[0];
|
||||||
|
part(group, kit.paint, {
|
||||||
|
size: [0.18, 0.1, 0.14],
|
||||||
|
at: [windscreenX - 0.12, windscreenY + 0.08, spec.width / 2 + 0.02],
|
||||||
|
mirror: true,
|
||||||
|
});
|
||||||
|
part(group, kit.black, {
|
||||||
|
size: [spec.arches[1] - spec.arches[0] - 0.9, 0.08, 0.04],
|
||||||
|
at: [(spec.arches[0] + spec.arches[1]) / 2, 0.34, spec.width / 2 + 0.01],
|
||||||
|
mirror: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wheel centres for a spec: rear pair first, then the front pair.
|
||||||
|
* @param {object} spec - Brand spec.
|
||||||
|
* @returns {number[][]} [x, z] pairs.
|
||||||
|
*/
|
||||||
|
function wheelPositions(spec) {
|
||||||
|
const track = spec.width / 2 - 0.14;
|
||||||
|
return spec.arches.flatMap((x) => [
|
||||||
|
[x, -track],
|
||||||
|
[x, track],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hypercar details: horseshoe grille, C-shaped side sweep, roof fin and a full-width tail bar.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {object} kit - Materials.
|
||||||
|
* @param {object} spec - Brand spec.
|
||||||
|
*/
|
||||||
|
function bugattiDetails(group, kit, spec) {
|
||||||
|
part(group, kit.black, { size: [0.05, 0.3, 0.3], at: [2.33, 0.46, 0] });
|
||||||
|
const horseshoe = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(0.16, 0.025, 8, 20, Math.PI),
|
||||||
|
kit.chrome,
|
||||||
|
);
|
||||||
|
// Euler XYZ applies z first: flip the arch into a U, then turn it to face forward.
|
||||||
|
horseshoe.rotation.set(0, Math.PI / 2, Math.PI);
|
||||||
|
horseshoe.position.set(2.35, 0.56, 0);
|
||||||
|
group.add(horseshoe);
|
||||||
|
for (const side of [1, -1]) {
|
||||||
|
const sweep = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.022, 6, 24, Math.PI), kit.chrome);
|
||||||
|
// The C bulges towards the nose and opens towards the rear.
|
||||||
|
sweep.rotation.z = -Math.PI / 2;
|
||||||
|
sweep.position.set(-0.1, 0.6, (side * spec.width) / 2);
|
||||||
|
group.add(sweep);
|
||||||
|
}
|
||||||
|
part(group, kit.paint, { size: [1.2, 0.07, 0.03], at: [-0.4, 1.27, 0] });
|
||||||
|
for (const y of [0.6, 0.53])
|
||||||
|
part(group, kit.head, { size: [0.05, 0.035, 0.26], at: [2.3, y, 0.62], mirror: true });
|
||||||
|
part(group, kit.tail, { size: [0.05, 0.06, 1.7], at: [-2.28, 0.8, 0] });
|
||||||
|
part(group, kit.chrome, { size: [0.06, 0.12, 0.5], at: [-2.3, 0.38, 0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wedge supercar details: Y-shaped lamps, side intakes, rear wing and a hexagonal exhaust.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {object} kit - Materials.
|
||||||
|
* @param {object} spec - Brand spec.
|
||||||
|
*/
|
||||||
|
function lamborghiniDetails(group, kit, spec) {
|
||||||
|
for (const side of [1, -1]) {
|
||||||
|
for (const tilt of [0.45, -0.45]) {
|
||||||
|
const lamp = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.3), kit.head);
|
||||||
|
lamp.position.set(2.3, 0.52, side * 0.64);
|
||||||
|
lamp.rotation.x = tilt * side;
|
||||||
|
group.add(lamp);
|
||||||
|
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.34), kit.tail);
|
||||||
|
tail.position.set(-2.42, 0.74, side * 0.66);
|
||||||
|
tail.rotation.x = tilt * side;
|
||||||
|
group.add(tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part(group, kit.black, {
|
||||||
|
size: [0.7, 0.25, 0.04],
|
||||||
|
at: [-0.75, 0.62, spec.width / 2 + 0.01],
|
||||||
|
mirror: true,
|
||||||
|
});
|
||||||
|
part(group, kit.black, { size: [0.05, 0.14, 1.5], at: [2.4, 0.36, 0] });
|
||||||
|
part(group, kit.paint, { size: [0.34, 0.04, 1.7], at: [-2.1, 1.1, 0] });
|
||||||
|
part(group, kit.black, { size: [0.06, 0.16, 0.05], at: [-2.1, 1.0, 0.6], mirror: true });
|
||||||
|
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.12, 6), kit.chrome);
|
||||||
|
exhaust.rotation.z = Math.PI / 2;
|
||||||
|
exhaust.position.set(-2.46, 0.42, 0);
|
||||||
|
group.add(exhaust);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grand-tourer details: large five-point grille with a chrome wing and twin round tail lamps.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {object} kit - Materials.
|
||||||
|
*/
|
||||||
|
function mazdaDetails(group, kit) {
|
||||||
|
part(group, kit.black, { size: [0.05, 0.28, 0.9], at: [2.3, 0.5, 0] });
|
||||||
|
part(group, kit.chrome, { size: [0.04, 0.03, 1.1], at: [2.31, 0.37, 0] });
|
||||||
|
part(group, kit.head, { size: [0.06, 0.05, 0.38], at: [2.24, 0.66, 0.6], mirror: true });
|
||||||
|
for (const z of [0.5, 0.72]) lampRing(group, kit.tail, { radius: 0.07, at: [-2.26, 0.76, z] });
|
||||||
|
part(group, kit.chrome, { size: [0.06, 0.1, 0.16], at: [-2.26, 0.38, 0.5], mirror: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grand-touring coupe details: four round tail lamps, a rear wing on struts and a V grille.
|
||||||
|
* @param {THREE.Group} group - Car group.
|
||||||
|
* @param {object} kit - Materials.
|
||||||
|
*/
|
||||||
|
function nissanDetails(group, kit) {
|
||||||
|
part(group, kit.black, { size: [0.05, 0.22, 0.8], at: [2.4, 0.52, 0] });
|
||||||
|
part(group, kit.chrome, { size: [0.04, 0.03, 0.82], at: [2.41, 0.64, 0] });
|
||||||
|
part(group, kit.head, { size: [0.06, 0.08, 0.42], at: [2.34, 0.72, 0.6], mirror: true });
|
||||||
|
for (const z of [0.45, 0.72]) lampRing(group, kit.tail, { radius: 0.08, at: [-2.4, 0.86, z] });
|
||||||
|
part(group, kit.paint, { size: [0.3, 0.04, 1.6], at: [-2.02, 1.24, 0] });
|
||||||
|
part(group, kit.black, { size: [0.06, 0.22, 0.05], at: [-2.02, 1.12, 0.55], mirror: true });
|
||||||
|
part(group, kit.chrome, { size: [0.06, 0.12, 0.12], at: [-2.4, 0.38, 0.55], mirror: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side profiles (x forward, y up). `body` feeds bodyOutline; `cabin` is the glasshouse: windscreen
|
||||||
|
// base, roof front, roof rear, rear-window base, closing point.
|
||||||
|
const BRANDS = {
|
||||||
|
bugatti: {
|
||||||
|
width: 2.0,
|
||||||
|
arches: [-1.4, 1.35],
|
||||||
|
archRadius: 0.43,
|
||||||
|
front: [
|
||||||
|
[2.12, 0.3],
|
||||||
|
[2.26, 0.4],
|
||||||
|
[2.28, 0.58],
|
||||||
|
[2.1, 0.72],
|
||||||
|
],
|
||||||
|
rear: [
|
||||||
|
[-2.2, 0.3],
|
||||||
|
[-2.25, 0.55],
|
||||||
|
[-2.2, 0.82],
|
||||||
|
[-2.05, 0.9],
|
||||||
|
],
|
||||||
|
top: [
|
||||||
|
[1.3, 0.82],
|
||||||
|
[0.6, 0.9],
|
||||||
|
[-1.0, 0.95],
|
||||||
|
[-1.9, 0.9],
|
||||||
|
],
|
||||||
|
cabin: [
|
||||||
|
[0.6, 0.88],
|
||||||
|
[-0.15, 1.2],
|
||||||
|
[-0.75, 1.21],
|
||||||
|
[-1.5, 0.94],
|
||||||
|
[-1.4, 0.9],
|
||||||
|
],
|
||||||
|
cabinWidth: 1.4,
|
||||||
|
rim: '#2a2d30',
|
||||||
|
spokes: 6,
|
||||||
|
details: bugattiDetails,
|
||||||
|
},
|
||||||
|
lamborghini: {
|
||||||
|
width: 2.03,
|
||||||
|
arches: [-1.45, 1.4],
|
||||||
|
archRadius: 0.43,
|
||||||
|
front: [
|
||||||
|
[2.3, 0.3],
|
||||||
|
[2.42, 0.38],
|
||||||
|
[2.35, 0.55],
|
||||||
|
],
|
||||||
|
rear: [
|
||||||
|
[-2.35, 0.3],
|
||||||
|
[-2.4, 0.6],
|
||||||
|
[-2.3, 0.92],
|
||||||
|
],
|
||||||
|
top: [
|
||||||
|
[1.6, 0.7],
|
||||||
|
[0.7, 0.85],
|
||||||
|
[-1.2, 0.95],
|
||||||
|
[-2.1, 0.92],
|
||||||
|
],
|
||||||
|
cabin: [
|
||||||
|
[0.75, 0.83],
|
||||||
|
[-0.1, 1.13],
|
||||||
|
[-0.7, 1.14],
|
||||||
|
[-1.6, 0.95],
|
||||||
|
[-1.5, 0.92],
|
||||||
|
],
|
||||||
|
cabinWidth: 1.3,
|
||||||
|
rim: '#16181a',
|
||||||
|
spokes: 5,
|
||||||
|
details: lamborghiniDetails,
|
||||||
|
},
|
||||||
|
mazda: {
|
||||||
|
width: 1.9,
|
||||||
|
arches: [-1.38, 1.4],
|
||||||
|
archRadius: 0.42,
|
||||||
|
front: [
|
||||||
|
[2.1, 0.3],
|
||||||
|
[2.25, 0.42],
|
||||||
|
[2.24, 0.62],
|
||||||
|
[2.05, 0.76],
|
||||||
|
],
|
||||||
|
rear: [
|
||||||
|
[-2.15, 0.3],
|
||||||
|
[-2.2, 0.6],
|
||||||
|
[-2.1, 0.85],
|
||||||
|
[-1.95, 0.9],
|
||||||
|
],
|
||||||
|
top: [
|
||||||
|
[1.5, 0.82],
|
||||||
|
[0.5, 0.9],
|
||||||
|
[-1.2, 0.92],
|
||||||
|
[-1.8, 0.88],
|
||||||
|
],
|
||||||
|
cabin: [
|
||||||
|
[0.4, 0.88],
|
||||||
|
[-0.35, 1.18],
|
||||||
|
[-0.95, 1.18],
|
||||||
|
[-1.75, 0.92],
|
||||||
|
[-1.6, 0.88],
|
||||||
|
],
|
||||||
|
cabinWidth: 1.35,
|
||||||
|
rim: '#9aa0a5',
|
||||||
|
spokes: 10,
|
||||||
|
details: mazdaDetails,
|
||||||
|
},
|
||||||
|
nissan: {
|
||||||
|
width: 1.9,
|
||||||
|
arches: [-1.39, 1.39],
|
||||||
|
archRadius: 0.42,
|
||||||
|
front: [
|
||||||
|
[2.2, 0.3],
|
||||||
|
[2.34, 0.44],
|
||||||
|
[2.34, 0.66],
|
||||||
|
[2.18, 0.8],
|
||||||
|
],
|
||||||
|
rear: [
|
||||||
|
[-2.3, 0.3],
|
||||||
|
[-2.35, 0.6],
|
||||||
|
[-2.3, 0.95],
|
||||||
|
[-2.15, 1.0],
|
||||||
|
],
|
||||||
|
top: [
|
||||||
|
[1.6, 0.86],
|
||||||
|
[0.7, 0.96],
|
||||||
|
[-1.5, 1.0],
|
||||||
|
[-2.0, 0.98],
|
||||||
|
],
|
||||||
|
cabin: [
|
||||||
|
[0.7, 0.95],
|
||||||
|
[-0.1, 1.36],
|
||||||
|
[-0.95, 1.37],
|
||||||
|
[-1.85, 1.0],
|
||||||
|
[-1.7, 0.97],
|
||||||
|
],
|
||||||
|
cabinWidth: 1.5,
|
||||||
|
rim: '#3a3d40',
|
||||||
|
spokes: 6,
|
||||||
|
details: nissanDetails,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a black luxury car in the style of one brand.
|
||||||
|
* @param {'bugatti' | 'lamborghini' | 'mazda' | 'nissan'} brand - Which style to build.
|
||||||
|
* @param {string} color - Body paint colour.
|
||||||
|
* @returns {{group: THREE.Group, wheels: THREE.Group[], size: object}} The car model and its
|
||||||
|
* footprint half extents.
|
||||||
|
*/
|
||||||
|
export function createLuxuryCar(brand, color = VEHICLE_BLACK) {
|
||||||
|
const spec = BRANDS[brand];
|
||||||
|
const kit = createKit(color);
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const outline = bodyOutline(spec.front, spec.rear, spec.arches, spec.archRadius, spec.top);
|
||||||
|
group.add(extrudeBody(outline, spec.width, kit.paint));
|
||||||
|
group.add(extrudeProfile(spec.cabin, spec.cabinWidth, kit.glass, 0.08));
|
||||||
|
addCommonParts(group, spec, kit);
|
||||||
|
spec.details(group, kit, spec);
|
||||||
|
const wheels = addWheels(group, wheelPositions(spec), 0.36, 0.28, spec.rim, spec.spokes);
|
||||||
|
return { group, wheels, size: measureFootprint(group) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Height above the ground the arrow floats at, and how far it bobs.
|
||||||
|
const HOVER_HEIGHT = 4.6;
|
||||||
|
const BOB = 0.35;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the brand-coloured arrow that hovers over the current objective, pointing down at it.
|
||||||
|
* It glows so it reads from far away and from any angle.
|
||||||
|
* @returns {{group: THREE.Group, update: function}} The arrow and its per-frame update.
|
||||||
|
*/
|
||||||
|
export function createObjectiveArrow() {
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: COMPANY.colors.primary,
|
||||||
|
emissive: COMPANY.colors.primary,
|
||||||
|
emissiveIntensity: 0.6,
|
||||||
|
});
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const head = new THREE.Mesh(new THREE.ConeGeometry(0.55, 0.9, 4), material);
|
||||||
|
head.rotation.x = Math.PI; // point down
|
||||||
|
const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.8, 0.3), material);
|
||||||
|
shaft.position.y = 0.8;
|
||||||
|
group.add(head, shaft);
|
||||||
|
group.visible = false;
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
/**
|
||||||
|
* Places the arrow over a target, or hides it.
|
||||||
|
* @param {{x: number, z: number} | null} target - Objective position, or null for none.
|
||||||
|
* @param {number} time - Elapsed seconds, for the bob and spin.
|
||||||
|
*/
|
||||||
|
update(target, time) {
|
||||||
|
group.visible = Boolean(target);
|
||||||
|
if (!target) return;
|
||||||
|
group.position.set(target.x, HOVER_HEIGHT + Math.sin(time * 2.4) * BOB, target.z);
|
||||||
|
group.rotation.y = time * 1.2;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,7 @@
|
|||||||
import { FACTORY } from '../../constants/locations.js';
|
|
||||||
import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js';
|
import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js';
|
||||||
import { createVendingMachine } from './vending.js';
|
import { createVendingMachine } from './vending.js';
|
||||||
import { createFamily } from './characters.js';
|
import { createFamily } from './characters.js';
|
||||||
|
import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js';
|
||||||
/** Projects factory-local coordinates into the shared world metre system.
|
|
||||||
* @param {number} u - Position along the factory axis.
|
|
||||||
* @param {number} v - Position normal to the factory axis.
|
|
||||||
* @returns {{x:number,z:number}} World position.
|
|
||||||
*/
|
|
||||||
function showroomPosition(u, v) {
|
|
||||||
return {
|
|
||||||
x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v,
|
|
||||||
z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates one display-only sample, with a footprint and interaction position.
|
/** Creates one display-only sample, with a footprint and interaction position.
|
||||||
* @param {{scene:object,model:object,index:number}} options - Sample placement context.
|
* @param {{scene:object,model:object,index:number}} options - Sample placement context.
|
||||||
@@ -27,19 +15,14 @@ function createSample({ scene, model, index }) {
|
|||||||
channels: model.channels,
|
channels: model.channels,
|
||||||
products: ['water', 'juice', 'biscuit', 'chocolate'],
|
products: ['water', 'juice', 'biscuit', 'chocolate'],
|
||||||
});
|
});
|
||||||
const position = showroomPosition(u, v),
|
const position = factoryToWorld(u, v),
|
||||||
front = showroomPosition(u, v + 2);
|
front = factoryToWorld(u, v + 2);
|
||||||
machine.group.name = `showroom-${model.id}`;
|
machine.group.name = `showroom-${model.id}`;
|
||||||
machine.group.position.set(position.x, 0, position.z);
|
machine.group.position.set(position.x, 0, position.z);
|
||||||
machine.group.rotation.y = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x);
|
machine.group.rotation.y = FACTORY_ROTATION;
|
||||||
machine.beacon.visible = machine.ring.visible = false;
|
machine.beacon.visible = machine.ring.visible = false;
|
||||||
scene.add(machine.group);
|
scene.add(machine.group);
|
||||||
const footprint = [
|
const footprint = factoryFootprint({ u, v, halfU: 1, halfV: 0.6 });
|
||||||
[-1, -0.6],
|
|
||||||
[1, -0.6],
|
|
||||||
[1, 0.6],
|
|
||||||
[-1, 0.6],
|
|
||||||
].flatMap(([x, z]) => Object.values(showroomPosition(u + x, v + z)));
|
|
||||||
return {
|
return {
|
||||||
model,
|
model,
|
||||||
machine,
|
machine,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { textTexture } from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
|
|
||||||
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
|
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
|
||||||
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
|
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
|
||||||
@@ -264,7 +268,7 @@ export function nameTag(name, role = '') {
|
|||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.roundRect(6, 6, w - 12, h - 12, 26);
|
c.roundRect(6, 6, w - 12, h - 12, 26);
|
||||||
c.fill();
|
c.fill();
|
||||||
c.fillStyle = '#3ee0c9';
|
c.fillStyle = BRAND.primary;
|
||||||
c.fillRect(6, h - 18, w - 12, 8);
|
c.fillRect(6, h - 18, w - 12, 8);
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
c.direction = 'rtl';
|
c.direction = 'rtl';
|
||||||
@@ -272,7 +276,7 @@ export function nameTag(name, role = '') {
|
|||||||
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
||||||
c.fillText(name, w / 2, role ? 72 : 100);
|
c.fillText(name, w / 2, role ? 72 : 100);
|
||||||
if (role) {
|
if (role) {
|
||||||
c.fillStyle = '#9fe9da';
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
c.font = '40px Vazirmatn, Tahoma, sans-serif';
|
c.font = '40px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.direction = 'ltr';
|
c.direction = 'ltr';
|
||||||
c.fillText(role, w / 2, 126);
|
c.fillText(role, w / 2, 126);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { catalog } from '../economy/gameState.js';
|
import { catalog } from '../economy/gameState.js';
|
||||||
import { textTexture } from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
const PRODUCT_COLORS = {
|
const PRODUCT_COLORS = {
|
||||||
juice: '#e5ae47',
|
juice: '#e5ae47',
|
||||||
water: '#9fd3e6',
|
water: '#9fd3e6',
|
||||||
@@ -24,14 +28,14 @@ const label = (text, sub) =>
|
|||||||
textTexture(512, 160, (c, width, height) => {
|
textTexture(512, 160, (c, width, height) => {
|
||||||
c.fillStyle = '#0f2a2a';
|
c.fillStyle = '#0f2a2a';
|
||||||
c.fillRect(0, 0, width, height);
|
c.fillRect(0, 0, width, height);
|
||||||
c.fillStyle = '#3ee0c9';
|
c.fillStyle = BRAND.primary;
|
||||||
c.fillRect(0, height - 10, width, 10);
|
c.fillRect(0, height - 10, width, 10);
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
c.direction = 'rtl';
|
c.direction = 'rtl';
|
||||||
c.fillStyle = '#e9fff8';
|
c.fillStyle = '#e9fff8';
|
||||||
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
|
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText(text, width / 2, 72);
|
c.fillText(text, width / 2, 72);
|
||||||
c.fillStyle = '#9fe9da';
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
c.font = '34px Vazirmatn, Tahoma, sans-serif';
|
c.font = '34px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText(sub, width / 2, 126);
|
c.fillText(sub, width / 2, 126);
|
||||||
});
|
});
|
||||||
@@ -75,10 +79,10 @@ export function createVendingMachine(def) {
|
|||||||
parent.add(mesh);
|
parent.add(mesh);
|
||||||
return mesh;
|
return mesh;
|
||||||
};
|
};
|
||||||
const shell = mat('#0b8f88', {
|
const shell = mat(BRAND.primary, {
|
||||||
metalness: 0.45,
|
metalness: 0.45,
|
||||||
roughness: 0.3,
|
roughness: 0.3,
|
||||||
emissive: '#0b8f88',
|
emissive: BRAND.primary,
|
||||||
emissiveIntensity: 0.15,
|
emissiveIntensity: 0.15,
|
||||||
}),
|
}),
|
||||||
white = mat('#eef5f3', {
|
white = mat('#eef5f3', {
|
||||||
@@ -190,7 +194,7 @@ export function createVendingMachine(def) {
|
|||||||
const sign = new THREE.Mesh(
|
const sign = new THREE.Mesh(
|
||||||
new THREE.PlaneGeometry(1.86, 0.3),
|
new THREE.PlaneGeometry(1.86, 0.3),
|
||||||
new THREE.MeshBasicMaterial({
|
new THREE.MeshBasicMaterial({
|
||||||
map: label('روبات مارکت', def.name),
|
map: label(COMPANY.shortName, def.name),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
sign.position.set(0, 2.62, 0.575);
|
sign.position.set(0, 2.62, 0.575);
|
||||||
@@ -213,7 +217,7 @@ export function createVendingMachine(def) {
|
|||||||
|
|
||||||
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
||||||
const beaconMaterial = new THREE.MeshBasicMaterial({
|
const beaconMaterial = new THREE.MeshBasicMaterial({
|
||||||
color: '#3ee0c9',
|
color: BRAND.primary,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.22,
|
opacity: 0.22,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
@@ -230,7 +234,7 @@ export function createVendingMachine(def) {
|
|||||||
const ring = new THREE.Mesh(
|
const ring = new THREE.Mesh(
|
||||||
new THREE.RingGeometry(1.6, 1.9, 40),
|
new THREE.RingGeometry(1.6, 1.9, 40),
|
||||||
new THREE.MeshBasicMaterial({
|
new THREE.MeshBasicMaterial({
|
||||||
color: '#3ee0c9',
|
color: BRAND.primary,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { MANAGER_POINT, SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
||||||
|
import { PRODUCTS_SAMPLE } from '../../constants/startModes.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { createAboutBoards } from '../models/aboutBoards.js';
|
||||||
|
import { addPolygon } from '../world/collision.js';
|
||||||
|
import { factoryToWorld } from '../world/factoryFrame.js';
|
||||||
|
|
||||||
|
// Factory places that missions can point at besides machines (boards, the manager's desk and the
|
||||||
|
// products hall), and reading the information boards. Every point has the same shape as a machine
|
||||||
|
// station ({x, z, front, def}) so the objective arrow, route and chip treat them alike.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a point that is not a board.
|
||||||
|
* @param {string} id - Point id.
|
||||||
|
* @param {{x: number, z: number}} at - Where the arrow hangs.
|
||||||
|
* @param {{x: number, z: number}} front - Where the route leads.
|
||||||
|
* @returns {object} Objective point.
|
||||||
|
*/
|
||||||
|
const point = (id, at, front) => ({
|
||||||
|
x: at.x,
|
||||||
|
z: at.z,
|
||||||
|
front,
|
||||||
|
def: { id, name: strings.points[id], poi: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the information boards and registers every factory point. Runs after the factory and
|
||||||
|
* showroom are built.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export async function initializeFactoryPoints(context) {
|
||||||
|
context.aboutBoards = createAboutBoards(context.scene);
|
||||||
|
for (const board of context.aboutBoards.boards) addPolygon(context.collision, board.footprint);
|
||||||
|
const desk = factoryToWorld(MANAGER_POINT.u, MANAGER_POINT.v);
|
||||||
|
const sample = context.showroom.samples[PRODUCTS_SAMPLE];
|
||||||
|
context.factoryPoints = new Map([
|
||||||
|
...context.aboutBoards.boards.map((board) => [board.id, board]),
|
||||||
|
[MANAGER_POINT.id, point(MANAGER_POINT.id, desk, desk)],
|
||||||
|
[SHOWROOM_POINT_ID, point(SHOWROOM_POINT_ID, sample.position, sample.front)],
|
||||||
|
]);
|
||||||
|
context.ui.on('about-board-close', () => context.ui.close('about-board'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a mission target: a machine station or a factory point.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {string} id - Machine or point id.
|
||||||
|
* @returns {object | null} Station or point, or null when unknown.
|
||||||
|
*/
|
||||||
|
export function pointById(context, id) {
|
||||||
|
return context.stationById(id) ?? context.factoryPoints.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The information board the walking player can read, if any.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @returns {object | null} Board in reach, or null.
|
||||||
|
*/
|
||||||
|
export function nearBoard(context) {
|
||||||
|
if (context.real.on || context.mode !== 'walk') return null;
|
||||||
|
return context.aboutBoards.nearest(context.player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the full text of a board in a sheet and counts it as read.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {object} board - Board from createAboutBoards.
|
||||||
|
*/
|
||||||
|
export function openBoard(context, board) {
|
||||||
|
context.ui.patch('about-board', { topic: board.topic });
|
||||||
|
context.keys.clear();
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.ui.open('about-board');
|
||||||
|
context.reportMissionEvent({ type: 'read', machineId: board.id });
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import {
|
||||||
|
ENTER_DISTANCE,
|
||||||
|
MANAGER_CAR_BRAND,
|
||||||
|
STAFF_CAR_BRANDS,
|
||||||
|
VEHICLE_BLACK,
|
||||||
|
VEHICLE_LABELS,
|
||||||
|
} from '../../constants/vehicles.js';
|
||||||
|
import { createSportSedan, measureFootprint } from '../models/carModels.js';
|
||||||
|
import { createLuxuryCar } from '../models/luxuryCars.js';
|
||||||
|
import { TRAFFIC_CAR } from '../systems/traffic.js';
|
||||||
|
import { CAR, createCar } from '../systems/vehicle.js';
|
||||||
|
import { boxOverlap, circleOutOfBox, vehicleBox } from '../world/vehicleCollision.js';
|
||||||
|
|
||||||
|
// Every vehicle the player can drive: the family BMW, the staff cars,
|
||||||
|
// Mr Mohammadi's Bugatti once he has parked it, and any traffic car the player has taken over.
|
||||||
|
// `context.vehicle` is the one in use (or last used); `context.car` is its moving state.
|
||||||
|
|
||||||
|
// Distance beyond which two vehicles can't touch, to skip the box test.
|
||||||
|
const CONTACT_RANGE = 8;
|
||||||
|
// Radius of the walking player against parked vehicles.
|
||||||
|
const WALKER_RADIUS = 0.45;
|
||||||
|
// Speed kept after a head-on hit; a glancing scrape keeps nearly all of it.
|
||||||
|
const HEAD_ON_SPEED_KEPT = 0.4;
|
||||||
|
// A traffic car the player keeps pushing against pulls away (respawns) after this many seconds.
|
||||||
|
const TRAFFIC_GIVE_WAY = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a vehicle to the garage and the scene.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {{brand: string, model: object, slot: object}} vehicle - Vehicle
|
||||||
|
* fields; `slot` is where it is parked on reset (null for vehicles that are not reset).
|
||||||
|
* @returns {object} The garage record.
|
||||||
|
*/
|
||||||
|
function addVehicle(context, { brand, model, slot, ...extra }) {
|
||||||
|
const record = {
|
||||||
|
brand,
|
||||||
|
model,
|
||||||
|
slot,
|
||||||
|
label: VEHICLE_LABELS[brand],
|
||||||
|
// Car handling, with the collision box measured from the model itself so contact is exact.
|
||||||
|
spec: { ...CAR, ...model.size },
|
||||||
|
state: createCar(slot.x, slot.z, slot.heading),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
context.garage.group.add(model.group);
|
||||||
|
context.garage.vehicles.push(record);
|
||||||
|
placeModel(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds footprint half extents to a freshly built model that doesn't report its own.
|
||||||
|
* @param {object} model - Model with a group.
|
||||||
|
* @returns {object} The same model with `size`.
|
||||||
|
*/
|
||||||
|
function withSize(model) {
|
||||||
|
model.size = measureFootprint(model.group);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a vehicle's model to its state's position and heading.
|
||||||
|
* @param {object} record - Garage record.
|
||||||
|
*/
|
||||||
|
function placeModel({ model, state }) {
|
||||||
|
model.group.position.set(state.x, 0, state.z);
|
||||||
|
model.group.rotation.set(0, state.heading, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a garage vehicle the one the player uses.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {object} record - Garage record.
|
||||||
|
*/
|
||||||
|
function setActive(context, record) {
|
||||||
|
context.vehicle = record;
|
||||||
|
context.car = record.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the garage with the factory fleet parked in the yard.
|
||||||
|
* @param {object} context - Game context with scene and factory.
|
||||||
|
*/
|
||||||
|
export function createGarage(context) {
|
||||||
|
context.garage = { group: new THREE.Group(), vehicles: [], managerCar: null };
|
||||||
|
context.scene.add(context.garage.group);
|
||||||
|
const { car: familySlot, parking } = context.factory;
|
||||||
|
addVehicle(context, {
|
||||||
|
brand: 'bmw',
|
||||||
|
model: withSize(createSportSedan(VEHICLE_BLACK)),
|
||||||
|
slot: familySlot,
|
||||||
|
});
|
||||||
|
STAFF_CAR_BRANDS.forEach((brand, i) =>
|
||||||
|
addVehicle(context, {
|
||||||
|
brand,
|
||||||
|
model: createLuxuryCar(brand),
|
||||||
|
slot: parking.cars[i],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setActive(context, context.garage.vehicles[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks the fleet back in its bays, drops taken traffic cars and returns the manager's car to the
|
||||||
|
* factory so his arrival can play again. Call before factory.reset().
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function resetGarage(context) {
|
||||||
|
const { garage } = context;
|
||||||
|
for (const record of garage.vehicles.filter((v) => !v.slot))
|
||||||
|
garage.group.remove(record.model.group);
|
||||||
|
garage.vehicles = garage.vehicles.filter((v) => v.slot);
|
||||||
|
if (garage.managerCar) context.factory.managerCar.home.add(garage.managerCar.model.group);
|
||||||
|
garage.managerCar = null;
|
||||||
|
for (const record of garage.vehicles) {
|
||||||
|
record.state = createCar(record.slot.x, record.slot.z, record.slot.heading);
|
||||||
|
placeModel(record);
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function updateGarage(context) {
|
||||||
|
const manager = context.factory.managerCar;
|
||||||
|
if (context.garage.managerCar || !manager.parked()) return;
|
||||||
|
const group = manager.model.group;
|
||||||
|
group.updateWorldMatrix(true, false);
|
||||||
|
const at = group.getWorldPosition(new THREE.Vector3());
|
||||||
|
const nose = group.localToWorld(new THREE.Vector3(1, 0, 0));
|
||||||
|
// The factory frame is rotated; recover the world heading from where the nose points.
|
||||||
|
const heading = Math.atan2(-(nose.z - at.z), nose.x - at.x);
|
||||||
|
const record = addVehicle(context, {
|
||||||
|
brand: MANAGER_CAR_BRAND,
|
||||||
|
model: manager.model,
|
||||||
|
slot: { x: at.x, z: at.z, heading },
|
||||||
|
});
|
||||||
|
// Not reset with the fleet: resetGarage hands it back to the factory instead.
|
||||||
|
record.slot = null;
|
||||||
|
context.garage.managerCar = record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the closest vehicle the walking player can get on or in.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @returns {{label: string, record?: object, npc?: object} | null} A garage vehicle or a traffic
|
||||||
|
* car within reach, or null.
|
||||||
|
*/
|
||||||
|
export function nearestRide(context) {
|
||||||
|
const { x, z } = context.player;
|
||||||
|
let best = null,
|
||||||
|
bestDistance = ENTER_DISTANCE;
|
||||||
|
for (const record of context.garage.vehicles) {
|
||||||
|
const distance = Math.hypot(record.state.x - x, record.state.z - z);
|
||||||
|
if (distance < bestDistance) [best, bestDistance] = [{ record, label: record.label }, distance];
|
||||||
|
}
|
||||||
|
for (const npc of context.traffic.cars) {
|
||||||
|
if (!npc.model.group.visible) continue;
|
||||||
|
const distance = Math.hypot(npc.x - x, npc.z - z);
|
||||||
|
if (distance < bestDistance)
|
||||||
|
[best, bestDistance] = [{ npc, label: VEHICLE_LABELS.traffic }, distance];
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player into a ride found by nearestRide; a traffic car becomes a garage vehicle.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {{record?: object, npc?: object}} ride - The ride to take.
|
||||||
|
* @returns {object} The garage record now in use.
|
||||||
|
*/
|
||||||
|
export function takeRide(context, { record, npc }) {
|
||||||
|
if (npc) {
|
||||||
|
const slot = { x: npc.x, z: npc.z, heading: npc.model.group.rotation.y };
|
||||||
|
const model = { ...context.traffic.adopt(npc), size: TRAFFIC_CAR };
|
||||||
|
record = addVehicle(context, { brand: 'traffic', model, slot });
|
||||||
|
// Taken traffic cars stay where they are left until the next reset, which removes them.
|
||||||
|
record.slot = null;
|
||||||
|
}
|
||||||
|
setActive(context, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the active vehicle's model: position, wheel spin and front-wheel steering.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {number} dt - Frame time in seconds.
|
||||||
|
*/
|
||||||
|
export function syncActiveVehicle(context, dt) {
|
||||||
|
const { model, state, spec } = context.vehicle;
|
||||||
|
placeModel(context.vehicle);
|
||||||
|
// Wheels list the rear pair first, then the two front (steering) wheels.
|
||||||
|
for (const [i, wheel] of model.wheels.entries()) {
|
||||||
|
wheel.rotation.z -= (state.speed * dt) / spec.wheelRadius;
|
||||||
|
if (i >= 2) wheel.rotation.y = -state.steer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footprint box of a garage vehicle.
|
||||||
|
* @param {object} record - Garage record.
|
||||||
|
* @returns {object} Box from vehicleBox.
|
||||||
|
*/
|
||||||
|
const recordBox = (record) => vehicleBox(record.state, record.spec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footprint box of a traffic car.
|
||||||
|
* @param {object} npc - Traffic car.
|
||||||
|
* @returns {object} Box from vehicleBox.
|
||||||
|
*/
|
||||||
|
const trafficBox = (npc) =>
|
||||||
|
vehicleBox({ x: npc.x, z: npc.z, heading: npc.model.group.rotation.y }, TRAFFIC_CAR);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the driven vehicle out of another body and slows it by how head-on the hit was.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {object} box - The other body's box.
|
||||||
|
* @returns {boolean} Whether the bodies touched.
|
||||||
|
*/
|
||||||
|
function bump(context, box) {
|
||||||
|
const { state, spec } = context.vehicle;
|
||||||
|
const push = boxOverlap(vehicleBox(state, spec), box);
|
||||||
|
if (!push) return false;
|
||||||
|
state.x += push.x;
|
||||||
|
state.z += push.z;
|
||||||
|
// 1 when the push points straight against the direction of travel, 0 for a side scrape.
|
||||||
|
const forwardX = Math.cos(state.heading) * Math.sign(state.speed),
|
||||||
|
forwardZ = -Math.sin(state.heading) * Math.sign(state.speed);
|
||||||
|
const headOn = Math.max(0, -(push.x * forwardX + push.z * forwardZ) / push.depth);
|
||||||
|
if (headOn > 0.5 && Math.abs(state.speed) > 4) context.tone(110, 0.25, 0.08, 'square');
|
||||||
|
state.speed *= 1 - (1 - HEAD_ON_SPEED_KEPT) * headOn;
|
||||||
|
context.debugHits.push({ kind: 'vehicle', x: Math.round(state.x), z: Math.round(state.z) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a point is close enough to the driven vehicle to bother with a box test.
|
||||||
|
* @param {object} state - Driven vehicle state.
|
||||||
|
* @param {{x: number, z: number}} other - Other body's centre.
|
||||||
|
* @returns {boolean} True when within CONTACT_RANGE.
|
||||||
|
*/
|
||||||
|
const inRange = (state, other) => Math.hypot(other.x - state.x, other.z - state.z) < CONTACT_RANGE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the driven vehicle against parked garage vehicles and traffic. Bodies only meet when
|
||||||
|
* their outlines overlap, so the player keeps full speed through any gap they fit.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {number} dt - Step length in seconds.
|
||||||
|
*/
|
||||||
|
export function collideDriving(context, dt) {
|
||||||
|
const active = context.vehicle;
|
||||||
|
for (const record of context.garage.vehicles) {
|
||||||
|
if (record !== active && inRange(active.state, record.state)) bump(context, recordBox(record));
|
||||||
|
}
|
||||||
|
for (const npc of context.traffic.cars) {
|
||||||
|
if (!npc.model.group.visible || !inRange(active.state, npc)) continue;
|
||||||
|
if (!bump(context, trafficBox(npc))) {
|
||||||
|
npc.contact = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Staying pressed against a traffic car makes it pull away, so the player never gets stuck.
|
||||||
|
npc.contact = (npc.contact ?? 0) + dt;
|
||||||
|
if (npc.contact > TRAFFIC_GIVE_WAY) context.traffic.remove(npc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the walking player out of parked vehicles.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {{x: number, z: number}} point - Proposed player position.
|
||||||
|
* @returns {{x: number, z: number}} The position outside every vehicle.
|
||||||
|
*/
|
||||||
|
export function walkAroundVehicles(context, point) {
|
||||||
|
let at = point;
|
||||||
|
for (const record of context.garage.vehicles) {
|
||||||
|
if (!inRange(at, record.state)) continue;
|
||||||
|
at = circleOutOfBox(recordBox(record), at, WALKER_RADIUS) ?? at;
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything traffic should not drive into: the player and every garage vehicle, with widths so
|
||||||
|
* traffic only stops for what really blocks its lane.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @returns {{x: number, z: number, halfWidth: number}[]} Obstacles.
|
||||||
|
*/
|
||||||
|
export function trafficObstacles(context) {
|
||||||
|
const walker = { x: context.player.x, z: context.player.z, halfWidth: WALKER_RADIUS };
|
||||||
|
return [
|
||||||
|
...(context.mode === 'walk' ? [walker] : []),
|
||||||
|
...context.garage.vehicles.map(({ state, spec }) => ({
|
||||||
|
x: state.x,
|
||||||
|
z: state.z,
|
||||||
|
halfWidth: spec.halfWidth,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { CATEGORIES, lockedPlaces } from '../../constants/locations.js';
|
|||||||
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
|
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
|
||||||
import { drawFullMap, drawIran } from '../world/mapRender.js';
|
import { drawFullMap, drawIran } from '../world/mapRender.js';
|
||||||
import { formatDistance } from '../../utils/geo.js';
|
import { formatDistance } from '../../utils/geo.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
/**
|
/**
|
||||||
* Runs the size canvas operation for this subsystem.
|
* Runs the size canvas operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -87,6 +88,8 @@ export function renderMachineList(context) {
|
|||||||
*/
|
*/
|
||||||
export function selectMachine(context, id, center = false) {
|
export function selectMachine(context, id, center = false) {
|
||||||
context.selected = id;
|
context.selected = id;
|
||||||
|
// Finding the machine on the map is a mission step ("locate").
|
||||||
|
context.reportMissionEvent({ type: 'locate', machineId: id });
|
||||||
const s = context.stationById(id),
|
const s = context.stationById(id),
|
||||||
def = s.def;
|
def = s.def;
|
||||||
if (center) {
|
if (center) {
|
||||||
@@ -104,7 +107,7 @@ export function selectMachine(context, id, center = false) {
|
|||||||
text: `📍 ${def.address}`,
|
text: `📍 ${def.address}`,
|
||||||
});
|
});
|
||||||
context.ui.patch('detail-meta', {
|
context.ui.patch('detail-meta', {
|
||||||
text: `🕒 ${context.hoursText(def)} · ${isOpen(def, context.state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`,
|
text: strings.map.details(context.hoursText(def), isOpen(def, context.state.minutes), def.note),
|
||||||
});
|
});
|
||||||
context.ui.patch('detail-products', {
|
context.ui.patch('detail-products', {
|
||||||
text: def.products
|
text: def.products
|
||||||
@@ -112,7 +115,7 @@ export function selectMachine(context, id, center = false) {
|
|||||||
.join(' · '),
|
.join(' · '),
|
||||||
});
|
});
|
||||||
context.ui.patch('route-button', {
|
context.ui.patch('route-button', {
|
||||||
text: context.waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا',
|
text: context.waypoint === id ? strings.map.routeActive : strings.map.route,
|
||||||
});
|
});
|
||||||
context.renderMachineList();
|
context.renderMachineList();
|
||||||
context.renderMap();
|
context.renderMap();
|
||||||
@@ -133,7 +136,9 @@ export function openMap(context) {
|
|||||||
mpp: 2.2,
|
mpp: 2.2,
|
||||||
});
|
});
|
||||||
context.ui.open('map-dialog');
|
context.ui.open('map-dialog');
|
||||||
context.selected ??= context.objectiveStation()?.def.id ?? null;
|
// Factory points (boards, the manager) are not on the machine map, so they are not selected.
|
||||||
|
const objective = context.objectiveStation();
|
||||||
|
context.selected ??= objective && !objective.def.poi ? objective.def.id : null;
|
||||||
if (context.selected) context.selectMachine(context.selected);
|
if (context.selected) context.selectMachine(context.selected);
|
||||||
context.renderMachineList();
|
context.renderMachineList();
|
||||||
context.renderMap();
|
context.renderMap();
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
||||||
|
import { machineById } from '../../constants/locations.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { showroomModelById } from '../../utils/showroomAnswers.js';
|
||||||
|
import { fa, formatHetoman } from '../economy/gameState.js';
|
||||||
|
import { applyMissionEvent, currentStep } from '../missions/missionProgress.js';
|
||||||
|
import { ARRIVE_RADIUS, INSPECT_RADIUS, INSPECT_SECONDS } from '../missions/missions.js';
|
||||||
|
import { currentStepText } from '../missions/missionText.js';
|
||||||
|
|
||||||
|
// Connects the mission steps to the running game: it reports what the player does (arrive,
|
||||||
|
// inspect, buy, locate, order), celebrates finished steps and missions, and opens machine cards.
|
||||||
|
|
||||||
|
// Delay before the next instruction is spoken, so it follows any reward banner.
|
||||||
|
const NEXT_LINE_DELAY = 1500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speaker for game guidance: the robot when it is out of a machine, otherwise the father.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @returns {string} Speaker id.
|
||||||
|
*/
|
||||||
|
const guide = (context) => (context.guideOut() ? 'robot' : 'father');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports something the player did; finishes the current step or mission when it matches.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {{type: string, machineId?: string}} event - What happened.
|
||||||
|
* @returns {{stepDone: boolean, mission: object | null}} The result of applying the event.
|
||||||
|
*/
|
||||||
|
export function reportMissionEvent(context, event) {
|
||||||
|
const result = applyMissionEvent(context.state, event);
|
||||||
|
if (!result.stepDone) return result;
|
||||||
|
context.inspectTime = 0;
|
||||||
|
// A new target: drop the old route so it is rebuilt to the next machine.
|
||||||
|
context.waypoint = null;
|
||||||
|
context.route = null;
|
||||||
|
if (result.mission) announceMission(context, result.mission);
|
||||||
|
else context.tone(760, 0.25);
|
||||||
|
context.setTimeout(
|
||||||
|
() => context.say(guide(context), strings.dialogue.nextStep(currentStepText(context.state))),
|
||||||
|
NEXT_LINE_DELAY,
|
||||||
|
);
|
||||||
|
context.refreshHUD();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the reward banner for a finished mission.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {object} mission - The finished mission.
|
||||||
|
*/
|
||||||
|
function announceMission(context, mission) {
|
||||||
|
context.showBanner(
|
||||||
|
strings.rewards.missionDone,
|
||||||
|
strings.missions[mission.id],
|
||||||
|
strings.rewards.missionReward(fa(mission.reward.xp), formatHetoman(mission.reward.hetoman)),
|
||||||
|
);
|
||||||
|
context.tone(660, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance from the player to the front of a machine.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {string} machineId - Machine id.
|
||||||
|
* @returns {number} Metres, or Infinity for an unknown machine.
|
||||||
|
*/
|
||||||
|
function distanceTo(context, machineId) {
|
||||||
|
const station = context.pointById(machineId);
|
||||||
|
if (!station) return Infinity;
|
||||||
|
return Math.hypot(station.front.x - context.player.x, station.front.z - context.player.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-frame mission checks: arriving at a `goto` target, and standing at an `inspect` target
|
||||||
|
* long enough to finish inspecting it.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {number} dt - Step length in seconds.
|
||||||
|
*/
|
||||||
|
export function updateMissions(context, dt) {
|
||||||
|
const step = currentStep(context.state);
|
||||||
|
if (!step?.target) return;
|
||||||
|
// In real-world mode "being there" uses the GPS reach radius, which allows for GPS error.
|
||||||
|
const near = (radius) =>
|
||||||
|
distanceTo(context, step.target) < (context.real.on ? context.reachRadius() : radius);
|
||||||
|
if (step.type === 'goto' && near(ARRIVE_RADIUS))
|
||||||
|
reportMissionEvent(context, { type: 'arrive', machineId: step.target });
|
||||||
|
if (step.type !== 'inspect') return;
|
||||||
|
context.inspectTime = near(INSPECT_RADIUS) ? (context.inspectTime ?? 0) + dt : 0;
|
||||||
|
if (context.inspectTime < INSPECT_SECONDS) return;
|
||||||
|
openMachineCard(context, step.target);
|
||||||
|
reportMissionEvent(context, { type: 'inspect', machineId: step.target });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the machine card: which model stands here, its specs and an order button.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {string} machineId - Machine id.
|
||||||
|
*/
|
||||||
|
export function openMachineCard(context, machineId) {
|
||||||
|
const site = machineById(machineId);
|
||||||
|
context.ui.patch('machine-card', {
|
||||||
|
machineId,
|
||||||
|
modelId: site.model,
|
||||||
|
site: site.name,
|
||||||
|
ordered: false,
|
||||||
|
});
|
||||||
|
context.keys.clear();
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.ui.open('machine-card');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the order button on a machine card.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function orderFromCard(context) {
|
||||||
|
const { machineId } = context.ui.read('machine-card');
|
||||||
|
context.ui.patch('machine-card', { ordered: true });
|
||||||
|
context.say('robot', strings.machineCard.orderThanks, 'happy');
|
||||||
|
reportMissionEvent(context, { type: 'order', machineId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the purchase request button in the products hall: thanks the player and records the
|
||||||
|
* model as requested.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function orderFromShowroom(context) {
|
||||||
|
const { open, modelId, ordered = [] } = context.ui.read('showroom');
|
||||||
|
const model = showroomModelById(modelId);
|
||||||
|
if (!open || !model) return;
|
||||||
|
const thanks = strings.showroom.orderThanks(model.name);
|
||||||
|
context.ui.patch('showroom', { ordered: [...new Set([...ordered, model.id])] });
|
||||||
|
context.ui.patch('showroom-response', { text: strings.showroom.robotSays(thanks) });
|
||||||
|
context.say('robot', thanks, 'happy');
|
||||||
|
reportMissionEvent(context, { type: 'request', machineId: SHOWROOM_POINT_ID });
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import * as THREE from 'three';
|
|||||||
import data from '../../data/mellat.json';
|
import data from '../../data/mellat.json';
|
||||||
import { resolveCircle } from '../world/collision.js';
|
import { resolveCircle } from '../world/collision.js';
|
||||||
import { stepCar } from '../systems/vehicle.js';
|
import { stepCar } from '../systems/vehicle.js';
|
||||||
|
import { EXIT_MAX_SPEED } from '../../constants/vehicles.js';
|
||||||
|
import { collideDriving, nearestRide, takeRide, walkAroundVehicles } from './garage.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
/**
|
/**
|
||||||
* Runs the look operation for this subsystem.
|
* Runs the look operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -30,51 +33,58 @@ export function look(context, dx, dy, k) {
|
|||||||
|
|
||||||
// Touch joystick: x steers / strafes, y walks / throttles.
|
// Touch joystick: x steers / strafes, y walks / throttles.
|
||||||
/**
|
/**
|
||||||
* Runs the toggle car operation for this subsystem.
|
* Gets the player into the nearest vehicle, or out of the current one.
|
||||||
* @param {*} context - context.
|
* @param {object} context - Game context.
|
||||||
* @returns {*} Operation result, when applicable.
|
|
||||||
*/
|
*/
|
||||||
export // ---------- Car ----------
|
export // ---------- Car ----------
|
||||||
function toggleCar(context) {
|
function toggleCar(context) {
|
||||||
if (context.payment.phase !== 'idle' || context.state.busy) return;
|
if (context.payment.phase !== 'idle' || context.state.busy) return;
|
||||||
if (context.real.on) {
|
if (context.real.on) {
|
||||||
context.say('father', 'توی حالت دنیای واقعی پیاده راه میریم؛ ماشین مجازی لازم نیست.');
|
context.say('father', strings.real.noVirtualCar);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (context.mode === 'walk') {
|
if (context.mode === 'walk') getIn(context);
|
||||||
if (Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) > 4.5)
|
else getOut(context);
|
||||||
return;
|
context.refreshHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets into the closest vehicle within reach, if there is one.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
function getIn(context) {
|
||||||
|
const ride = nearestRide(context);
|
||||||
|
if (!ride) return;
|
||||||
|
takeRide(context, ride);
|
||||||
context.mode = 'drive';
|
context.mode = 'drive';
|
||||||
context.cam.distance = Math.max(context.cam.distance, 8);
|
context.cam.distance = Math.max(context.cam.distance, 8);
|
||||||
context.tone(320, 0.2, 0.05, 'square');
|
context.tone(320, 0.2, 0.05, 'square');
|
||||||
context.say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.');
|
context.say('father', strings.dialogue.carOn);
|
||||||
} else {
|
}
|
||||||
if (Math.abs(context.car.speed) > 3) {
|
|
||||||
context.say('father', 'اول ماشین را نگه دار، بعد پیاده میشویم.');
|
/**
|
||||||
|
* Steps out of the current vehicle on its left side, or wherever is free.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
function getOut(context) {
|
||||||
|
const { state, spec } = context.vehicle;
|
||||||
|
if (Math.abs(state.speed) > EXIT_MAX_SPEED) {
|
||||||
|
context.say('father', strings.dialogue.stopFirst);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Step out on the driver's (left) side, or wherever is free.
|
const left = { x: -Math.sin(state.heading), z: -Math.cos(state.heading) };
|
||||||
const left = {
|
const step = spec.halfWidth + 1.8;
|
||||||
x: -Math.sin(context.car.heading),
|
|
||||||
z: -Math.cos(context.car.heading),
|
|
||||||
};
|
|
||||||
const spot = resolveCircle(
|
const spot = resolveCircle(
|
||||||
context.collision,
|
context.collision,
|
||||||
context.car.x + left.x * 2.8,
|
state.x + left.x * step,
|
||||||
context.car.z + left.z * 2.8,
|
state.z + left.z * step,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
Object.assign(context.player, {
|
Object.assign(context.player, { x: spot.x, z: spot.z, heading: state.heading });
|
||||||
x: spot.x,
|
state.speed = 0;
|
||||||
z: spot.z,
|
|
||||||
heading: context.car.heading,
|
|
||||||
});
|
|
||||||
context.car.speed = 0;
|
|
||||||
context.mode = 'walk';
|
context.mode = 'walk';
|
||||||
context.cam.distance = 5.5;
|
context.cam.distance = 5.5;
|
||||||
context.tone(260, 0.2, 0.05, 'square');
|
context.tone(260, 0.2, 0.05, 'square');
|
||||||
}
|
|
||||||
context.refreshHUD();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Machines, shop and payment ----------
|
// ---------- Machines, shop and payment ----------
|
||||||
@@ -121,13 +131,40 @@ export function updateWalk(context, dt) {
|
|||||||
}
|
}
|
||||||
const mx = Math.cos(context.player.heading) * context.player.speed * dt,
|
const mx = Math.cos(context.player.heading) * context.player.speed * dt,
|
||||||
mz = -Math.sin(context.player.heading) * context.player.speed * dt;
|
mz = -Math.sin(context.player.heading) * context.player.speed * dt;
|
||||||
const p = resolveCircle(context.collision, context.player.x + mx, context.player.z + mz, 1);
|
const p = walkAroundVehicles(
|
||||||
|
context,
|
||||||
|
resolveCircle(context.collision, context.player.x + mx, context.player.z + mz, 1),
|
||||||
|
);
|
||||||
const { minX, minZ, maxX, maxZ } = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
context.player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
|
context.player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
|
||||||
context.player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
|
context.player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
|
||||||
context.player.gait += dt * context.player.speed * 1.9;
|
context.player.gait += dt * context.player.speed * 1.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the driven vehicle out of buildings using the circles along its body.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
function keepOutOfBuildings(context) {
|
||||||
|
const car = context.car;
|
||||||
|
for (const { along, radius } of context.vehicle.spec.probes) {
|
||||||
|
const cx = car.x + Math.cos(car.heading) * along,
|
||||||
|
cz = car.z - Math.sin(car.heading) * along;
|
||||||
|
const p = resolveCircle(context.collision, cx, cz, radius);
|
||||||
|
if (!p.hit) continue;
|
||||||
|
context.debugHits.push({
|
||||||
|
kind: 'building',
|
||||||
|
x: Math.round(cx),
|
||||||
|
z: Math.round(cz),
|
||||||
|
v: +car.speed.toFixed(1),
|
||||||
|
});
|
||||||
|
car.x += p.x - cx;
|
||||||
|
car.z += p.z - cz;
|
||||||
|
if (Math.abs(car.speed) > 4) context.tone(90, 0.2, 0.08, 'square');
|
||||||
|
car.speed *= 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collision log for ?debug runs; trimmed so it never grows without bound.
|
// Collision log for ?debug runs; trimmed so it never grows without bound.
|
||||||
/**
|
/**
|
||||||
* Updates drive from the active simulation.
|
* Updates drive from the active simulation.
|
||||||
@@ -148,67 +185,17 @@ export function updateDrive(context, dt) {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
// At top speed the car covers almost 2 m per step, so move it in short slices that can't
|
// At top speed the car covers almost 2 m per step, so move it in short slices that can't
|
||||||
// tunnel through a thin wall or fence.
|
// tunnel through a thin wall, a fence or another vehicle.
|
||||||
const slices = Math.max(1, Math.ceil((Math.abs(context.car.speed) * dt) / 0.6));
|
const slices = Math.max(1, Math.ceil((Math.abs(context.car.speed) * dt) / 0.6));
|
||||||
for (let i = 0; i < slices; i++) {
|
for (let i = 0; i < slices; i++) {
|
||||||
stepCar(
|
stepCar(
|
||||||
context.car,
|
context.car,
|
||||||
{
|
{ throttle, steer, handbrake: context.keys.has(' ') },
|
||||||
throttle,
|
|
||||||
steer,
|
|
||||||
handbrake: context.keys.has(' '),
|
|
||||||
},
|
|
||||||
dt / slices,
|
dt / slices,
|
||||||
|
context.vehicle.spec,
|
||||||
);
|
);
|
||||||
// Two collision circles along the body keep the nose and tail out of buildings.
|
keepOutOfBuildings(context);
|
||||||
for (const along of [1.3, -1.3]) {
|
collideDriving(context, dt / slices);
|
||||||
const cx = context.car.x + Math.cos(context.car.heading) * along,
|
|
||||||
cz = context.car.z - Math.sin(context.car.heading) * along;
|
|
||||||
const p = resolveCircle(context.collision, cx, cz, 1.05);
|
|
||||||
if (p.hit) {
|
|
||||||
context.debugHits.push({
|
|
||||||
kind: 'building',
|
|
||||||
x: Math.round(cx),
|
|
||||||
z: Math.round(cz),
|
|
||||||
v: +context.car.speed.toFixed(1),
|
|
||||||
});
|
|
||||||
context.car.x += p.x - cx;
|
|
||||||
context.car.z += p.z - cz;
|
|
||||||
if (Math.abs(context.car.speed) > 4) context.tone(90, 0.2, 0.08, 'square');
|
|
||||||
context.car.speed *= 0.35;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
|
|
||||||
// against one, it pulls away (respawns out of sight) so you can never get stuck behind it.
|
|
||||||
for (const npc of context.traffic.cars) {
|
|
||||||
const dx = context.car.x - npc.x,
|
|
||||||
dz = context.car.z - npc.z,
|
|
||||||
d = Math.hypot(dx, dz);
|
|
||||||
if (d >= 3.4 || d === 0) {
|
|
||||||
if (npc.contact) npc.contact = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
context.debugHits.push({
|
|
||||||
kind: 'traffic',
|
|
||||||
x: Math.round(context.car.x),
|
|
||||||
z: Math.round(context.car.z),
|
|
||||||
v: +context.car.speed.toFixed(1),
|
|
||||||
npcDir: npc.dir,
|
|
||||||
});
|
|
||||||
context.car.x = npc.x + (dx / d) * 3.4;
|
|
||||||
context.car.z = npc.z + (dz / d) * 3.4;
|
|
||||||
const towards =
|
|
||||||
(Math.cos(context.car.heading) * -dx - Math.sin(context.car.heading) * -dz) *
|
|
||||||
Math.sign(context.car.speed) >
|
|
||||||
0;
|
|
||||||
if (towards && Math.abs(context.car.speed) > 4) context.tone(110, 0.25, 0.08, 'square');
|
|
||||||
if (towards) context.car.speed *= 0.4;
|
|
||||||
npc.contact = (npc.contact ?? 0) + dt;
|
|
||||||
if (npc.contact > 0.8) {
|
|
||||||
npc.contact = 0;
|
|
||||||
context.traffic.remove(npc);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const { minX, minZ, maxX, maxZ } = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15);
|
context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15);
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import data from '../../data/mellat.json';
|
import { routeBetween } from '../world/routing.js';
|
||||||
import { routeBetween, nearestSegment } from '../world/routing.js';
|
|
||||||
import { FACTORY } from '../../constants/locations.js';
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
import { isOpen, formatClock, fa } from '../economy/gameState.js';
|
import { isOpen, formatClock, fa } from '../economy/gameState.js';
|
||||||
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
|
import { rankFor } from '../economy/scoring.js';
|
||||||
|
import { stepTarget } from '../missions/missionProgress.js';
|
||||||
|
import { currentStepText, missionListItems } from '../missions/missionText.js';
|
||||||
import { kmh } from '../systems/vehicle.js';
|
import { kmh } from '../systems/vehicle.js';
|
||||||
|
import { nearestRide } from './garage.js';
|
||||||
import { drawMinimap } from '../world/mapRender.js';
|
import { drawMinimap } from '../world/mapRender.js';
|
||||||
import { bearing, formatDistance } from '../../utils/geo.js';
|
import { bearing, formatDistance } from '../../utils/geo.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
/**
|
/**
|
||||||
* Runs the rebuild route mesh operation for this subsystem.
|
* Runs the rebuild route mesh operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -110,54 +117,25 @@ export function updateRoute(context, dt) {
|
|||||||
context.rebuildRouteMesh();
|
context.rebuildRouteMesh();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the draw compass operation for this subsystem.
|
* Updates the objective chip: what to do next, how far it is, and an arrow that points at it
|
||||||
* @param {*} context - context.
|
* relative to where the camera looks (0° = straight ahead).
|
||||||
* @param {*} headingDeg - heading Deg.
|
* @param {object} context - Game context.
|
||||||
* @param {*} markers - markers.
|
* @param {{objective: object | null, pos: {x: number, z: number}, headingDeg: number}} view -
|
||||||
* @returns {*} Operation result, when applicable.
|
* Objective station, player position and camera bearing.
|
||||||
*/
|
*/
|
||||||
export function drawCompass(context, headingDeg, markers) {
|
function updateObjectiveChip(context, { objective, pos, headingDeg }) {
|
||||||
const w = 560,
|
// A route the player picked on the map shows the place; otherwise show the current step.
|
||||||
h = 44;
|
const ownRoute = context.waypoint && context.waypoint !== stepTarget(context.state);
|
||||||
context.compass.clearRect(0, 0, w, h);
|
const title = ownRoute ? objective?.def.name : currentStepText(context.state);
|
||||||
context.compass.fillStyle = 'rgba(10,16,14,.45)';
|
context.ui.patch('objective', { hidden: !title });
|
||||||
context.compass.fillRect(0, 10, w, 24);
|
if (!title) return;
|
||||||
context.compass.textAlign = 'center';
|
context.ui.patch('objective-title', { text: title });
|
||||||
context.compass.textBaseline = 'middle';
|
context.ui.patch('objective-distance', {
|
||||||
const labels = {
|
text: objective ? formatDistance(Math.hypot(objective.x - pos.x, objective.z - pos.z)) : '',
|
||||||
0: 'شمال',
|
});
|
||||||
90: 'شرق',
|
if (!objective) return;
|
||||||
180: 'جنوب',
|
const turn = bearing(objective.x - pos.x, objective.z - pos.z) - headingDeg;
|
||||||
270: 'غرب',
|
context.ui.style('objective-arrow', { transform: `rotate(${Math.round(turn)}deg)` });
|
||||||
};
|
|
||||||
for (let d = -100; d <= 100; d += 5) {
|
|
||||||
const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
|
|
||||||
x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
|
|
||||||
if (x < 10 || x > w - 10) continue;
|
|
||||||
context.compass.fillStyle = 'rgba(236,242,238,.8)';
|
|
||||||
if (labels[deg] !== undefined) {
|
|
||||||
context.compass.font = 'bold 13px Vazirmatn, sans-serif';
|
|
||||||
context.compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
|
|
||||||
context.compass.fillText(labels[deg], x, 22);
|
|
||||||
} else if (deg % 15 === 0) {
|
|
||||||
context.compass.font = '10px Vazirmatn, sans-serif';
|
|
||||||
context.compass.fillText(fa(deg), x, 22);
|
|
||||||
} else context.compass.fillRect(x - 0.5, 18, 1, 8);
|
|
||||||
}
|
|
||||||
for (const m of markers) {
|
|
||||||
const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6;
|
|
||||||
if (x < 10 || x > w - 10) continue;
|
|
||||||
context.compass.save();
|
|
||||||
context.compass.translate(x, 6);
|
|
||||||
context.compass.rotate(Math.PI / 4);
|
|
||||||
context.compass.fillStyle = m.color;
|
|
||||||
context.compass.fillRect(-4, -4, 8, 8);
|
|
||||||
context.compass.restore();
|
|
||||||
}
|
|
||||||
context.compass.fillStyle = '#ecf2ee';
|
|
||||||
context.compass.fillRect(w / 2 - 1, 34, 2, 8);
|
|
||||||
context.compass.font = 'bold 11px Vazirmatn, sans-serif';
|
|
||||||
context.compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the refresh hud operation for this subsystem.
|
* Runs the refresh hud operation for this subsystem.
|
||||||
@@ -180,19 +158,8 @@ export function refreshHUD(context) {
|
|||||||
context.ui.style('xp-bar', {
|
context.ui.style('xp-bar', {
|
||||||
width: `${Math.round(rank.progress * 100)}%`,
|
width: `${Math.round(rank.progress * 100)}%`,
|
||||||
});
|
});
|
||||||
const active = activeMission(context.state);
|
context.ui.patch('mission-list', { items: missionListItems(context.state) });
|
||||||
context.ui.patch('mission-list', {
|
|
||||||
items: MISSIONS.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
title: m.title,
|
|
||||||
done: context.state.missionsDone.includes(m.id),
|
|
||||||
active: m === active,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
context.ui.publishGame(context.state);
|
context.ui.publishGame(context.state);
|
||||||
context.ui.patch('mode-label', {
|
|
||||||
text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده',
|
|
||||||
});
|
|
||||||
context.ui.patch('speed-stat', {
|
context.ui.patch('speed-stat', {
|
||||||
hidden: context.mode !== 'drive',
|
hidden: context.mode !== 'drive',
|
||||||
});
|
});
|
||||||
@@ -206,23 +173,25 @@ export function updatePrompt(context) {
|
|||||||
const sample = context.nearShowroom();
|
const sample = context.nearShowroom();
|
||||||
if (sample && !context.state.busy && context.payment.phase === 'idle') {
|
if (sample && !context.state.busy && context.payment.phase === 'idle') {
|
||||||
context.ui.patch('prompt', {
|
context.ui.patch('prompt', {
|
||||||
text: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`,
|
text: strings.prompts.showroom(sample.model.name),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const board = context.nearBoard();
|
||||||
|
if (board && !context.state.busy && context.payment.phase === 'idle') {
|
||||||
|
context.ui.patch('prompt', { text: strings.prompts.board(board.def.name), hidden: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let text = '';
|
let text = '';
|
||||||
const station = context.nearStation();
|
const station = context.nearStation();
|
||||||
|
const ride = context.mode === 'walk' ? nearestRide(context) : null;
|
||||||
if (!context.state.busy && context.payment.phase === 'idle') {
|
if (!context.state.busy && context.payment.phase === 'idle') {
|
||||||
if (station)
|
if (station)
|
||||||
text = isOpen(station.def, context.state.minutes)
|
text = isOpen(station.def, context.state.minutes)
|
||||||
? `E · خرید از روبات مارکت · ${station.def.name}`
|
? strings.prompts.buy(station.def.name)
|
||||||
: `دستگاه بسته است · ${context.hoursText(station.def)}`;
|
: strings.prompts.closed(context.hoursText(station.def));
|
||||||
else if (
|
else if (ride) text = strings.prompts.getIn(ride.label);
|
||||||
context.mode === 'walk' &&
|
|
||||||
Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5
|
|
||||||
)
|
|
||||||
text = 'F · سوار ماشین شو';
|
|
||||||
else if (
|
else if (
|
||||||
context.mode === 'drive' &&
|
context.mode === 'drive' &&
|
||||||
Math.abs(context.car.speed) < 1 &&
|
Math.abs(context.car.speed) < 1 &&
|
||||||
@@ -230,7 +199,7 @@ export function updatePrompt(context) {
|
|||||||
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
|
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text = 'F · پیاده شو؛ دستگاه نزدیک است';
|
text = strings.prompts.getOutNearMachine;
|
||||||
}
|
}
|
||||||
context.ui.patch('prompt', {
|
context.ui.patch('prompt', {
|
||||||
text,
|
text,
|
||||||
@@ -242,10 +211,9 @@ export function updatePrompt(context) {
|
|||||||
/**
|
/**
|
||||||
* Updates hud from the active simulation.
|
* Updates hud from the active simulation.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
* @param {*} dt - dt.
|
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
export function updateHUD(context, dt) {
|
export function updateHUD(context) {
|
||||||
context.ui.patch('showroom-open', {
|
context.ui.patch('showroom-open', {
|
||||||
hidden:
|
hidden:
|
||||||
context.real.on ||
|
context.real.on ||
|
||||||
@@ -275,17 +243,10 @@ export function updateHUD(context, dt) {
|
|||||||
};
|
};
|
||||||
const headingDeg = bearing(forward.x, forward.z);
|
const headingDeg = bearing(forward.x, forward.z);
|
||||||
const objective = context.objectiveStation();
|
const objective = context.objectiveStation();
|
||||||
context.drawCompass(
|
context.objectiveArrow.update(objective, context.elapsed);
|
||||||
headingDeg,
|
updateObjectiveChip(context, { objective, pos, headingDeg });
|
||||||
objective
|
// The minimap is optional (floating menu); skip drawing it while it is hidden.
|
||||||
? [
|
if (context.showMinimap)
|
||||||
{
|
|
||||||
bearing: bearing(objective.x - pos.x, objective.z - pos.z),
|
|
||||||
color: '#f2c94c',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
drawMinimap(context.minimap, context.mapAssets, {
|
drawMinimap(context.minimap, context.mapAssets, {
|
||||||
size: 220,
|
size: 220,
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
@@ -326,23 +287,10 @@ export function updateHUD(context, dt) {
|
|||||||
});
|
});
|
||||||
for (const s of context.stations) {
|
for (const s of context.stations) {
|
||||||
const isObjective = s === objective;
|
const isObjective = s === objective;
|
||||||
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
|
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : BRAND.primary);
|
||||||
s.model.beacon.material.opacity =
|
s.model.beacon.material.opacity =
|
||||||
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
|
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.streetTimer -= dt;
|
|
||||||
if (context.streetTimer <= 0) {
|
|
||||||
context.streetTimer = 0.6;
|
|
||||||
const street = nearestSegment(context.segments, pos.x, pos.z, 35, (s) => s[5] >= 0);
|
|
||||||
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
|
|
||||||
context.ui.patch('street', {
|
|
||||||
text: inFactory
|
|
||||||
? `کارخانهٔ ${FACTORY.name}`
|
|
||||||
: street
|
|
||||||
? `مشهد · ${data.names[street.name]}`
|
|
||||||
: 'حومهٔ مشهد',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
context.updatePrompt();
|
context.updatePrompt();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ import {
|
|||||||
swipePayment,
|
swipePayment,
|
||||||
cancelPayment,
|
cancelPayment,
|
||||||
} from '../economy/payment.js';
|
} from '../economy/payment.js';
|
||||||
import {
|
import { awardsFor, rankFor } from '../economy/scoring.js';
|
||||||
awardsFor,
|
import { strings } from '../../i18n/fa.js';
|
||||||
rankFor,
|
import { MISSIONS } from '../missions/missions.js';
|
||||||
completeMissions,
|
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
||||||
activeMission,
|
|
||||||
MISSIONS,
|
|
||||||
} from '../economy/scoring.js';
|
|
||||||
/**
|
/**
|
||||||
* Runs the open showroom operation for this subsystem.
|
* Runs the open showroom operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -40,11 +37,12 @@ export function openShowroom(context) {
|
|||||||
modelId: sample.model.id,
|
modelId: sample.model.id,
|
||||||
});
|
});
|
||||||
context.ui.patch('showroom-response', {
|
context.ui.patch('showroom-response', {
|
||||||
text: 'روبات: سلام! قیمت و امکانات هر سه مدل را میتوانم توضیح بدهم.',
|
text: strings.showroom.allModels,
|
||||||
});
|
});
|
||||||
context.keys.clear();
|
context.keys.clear();
|
||||||
document.exitPointerLock?.();
|
document.exitPointerLock?.();
|
||||||
context.ui.open('showroom');
|
context.ui.open('showroom');
|
||||||
|
context.reportMissionEvent({ type: 'view', machineId: SHOWROOM_POINT_ID });
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the interact operation for this subsystem.
|
* Runs the interact operation for this subsystem.
|
||||||
@@ -52,16 +50,21 @@ export function openShowroom(context) {
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
export function interact(context) {
|
export function interact(context) {
|
||||||
|
const board = context.nearBoard();
|
||||||
|
if (board && !context.state.busy && context.payment.phase === 'idle') {
|
||||||
|
context.openBoard(board);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const station = context.nearStation();
|
const station = context.nearStation();
|
||||||
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
|
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
|
||||||
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
|
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
|
||||||
context.say('father', 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.');
|
context.say('father', strings.dialogue.tooFastToBuy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isOpen(station.def, context.state.minutes)) {
|
if (!isOpen(station.def, context.state.minutes)) {
|
||||||
context.say(
|
context.say(
|
||||||
context.guideOut() ? 'robot' : 'father',
|
context.guideOut() ? 'robot' : 'father',
|
||||||
`این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(station.def)}.`,
|
strings.dialogue.machineClosed(context.hoursText(station.def)),
|
||||||
'serious',
|
'serious',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -81,16 +84,20 @@ export function interact(context) {
|
|||||||
export function renderShop(context) {
|
export function renderShop(context) {
|
||||||
const def = context.shopStation.def;
|
const def = context.shopStation.def;
|
||||||
context.ui.patch('shop-category', {
|
context.ui.patch('shop-category', {
|
||||||
text: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${context.hoursText(def)}`,
|
text: strings.shop.category(
|
||||||
|
CATEGORIES[def.category].icon,
|
||||||
|
CATEGORIES[def.category].label,
|
||||||
|
context.hoursText(def),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
context.ui.patch('shop-name', {
|
context.ui.patch('shop-name', {
|
||||||
text: `روبات مارکت · ${def.name}`,
|
text: strings.shop.title(def.name),
|
||||||
});
|
});
|
||||||
context.ui.patch('shop-address', {
|
context.ui.patch('shop-address', {
|
||||||
text: def.address,
|
text: def.address,
|
||||||
});
|
});
|
||||||
context.ui.patch('shop-note', {
|
context.ui.patch('shop-note', {
|
||||||
text: `${def.note} · موجودی شما: ${formatHetoman(context.state.balance)}`,
|
text: strings.shop.balance(def.note, formatHetoman(context.state.balance)),
|
||||||
});
|
});
|
||||||
context.ui.patch('products', {
|
context.ui.patch('products', {
|
||||||
items: def.products.map((id) => ({
|
items: def.products.map((id) => ({
|
||||||
@@ -120,7 +127,7 @@ export function startPayment(context, productId) {
|
|||||||
text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(context.shopStation.def, productId))}`,
|
text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(context.shopStation.def, productId))}`,
|
||||||
});
|
});
|
||||||
context.ui.patch('payment-step', {
|
context.ui.patch('payment-step', {
|
||||||
text: 'در حال نزدیک شدن به کارتخوان…',
|
text: strings.payment.approaching,
|
||||||
});
|
});
|
||||||
context.ui.patch('swipe', {
|
context.ui.patch('swipe', {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -128,7 +135,7 @@ export function startPayment(context, productId) {
|
|||||||
context.ui.patch('cancel-payment', {
|
context.ui.patch('cancel-payment', {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
});
|
});
|
||||||
context.say('child', 'بابا، کارت رو بده من بکشم!');
|
context.say('child', strings.dialogue.childSwipes);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the swipe operation for this subsystem.
|
* Runs the swipe operation for this subsystem.
|
||||||
@@ -146,7 +153,7 @@ export function swipe(context) {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
context.ui.patch('payment-step', {
|
context.ui.patch('payment-step', {
|
||||||
text: 'پرداخت موفق ✓ محصول در حال تحویل است',
|
text: strings.payment.success,
|
||||||
});
|
});
|
||||||
context.tone(1100, 0.3);
|
context.tone(1100, 0.3);
|
||||||
context.refreshHUD();
|
context.refreshHUD();
|
||||||
@@ -178,7 +185,7 @@ export function updatePayment(context, dt) {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
context.ui.patch('payment-step', {
|
context.ui.patch('payment-step', {
|
||||||
text: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C',
|
text: strings.payment.swipeHint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (context.payment.phase === 'approved') {
|
if (context.payment.phase === 'approved') {
|
||||||
@@ -243,7 +250,7 @@ export function reward(context, record, product) {
|
|||||||
rankBefore = rankFor(context.state.xp).index;
|
rankBefore = rankFor(context.state.xp).index;
|
||||||
if (context.real.on)
|
if (context.real.on)
|
||||||
awards.push({
|
awards.push({
|
||||||
label: 'خرید در دنیای واقعی',
|
label: strings.payment.realPurchase,
|
||||||
points: 200,
|
points: 200,
|
||||||
});
|
});
|
||||||
const total = awards.reduce((s, a) => s + a.points, 0);
|
const total = awards.reduce((s, a) => s + a.points, 0);
|
||||||
@@ -260,36 +267,18 @@ export function reward(context, record, product) {
|
|||||||
}),
|
}),
|
||||||
3200,
|
3200,
|
||||||
);
|
);
|
||||||
context.say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
|
context.say('child', strings.dialogue.childGotIt(product.icon, product.name));
|
||||||
const done = completeMissions(context.state);
|
const { mission } = context.reportMissionEvent({ type: 'buy', machineId: record.machineId });
|
||||||
for (const m of done) {
|
|
||||||
context.showBanner(
|
|
||||||
'مأموریت انجام شد',
|
|
||||||
m.title,
|
|
||||||
`+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`,
|
|
||||||
);
|
|
||||||
context.tone(660, 0.5);
|
|
||||||
}
|
|
||||||
if (rankFor(context.state.xp).index > rankBefore)
|
if (rankFor(context.state.xp).index > rankBefore)
|
||||||
context.setTimeout(
|
context.setTimeout(
|
||||||
() =>
|
() =>
|
||||||
context.showBanner(
|
context.showBanner(
|
||||||
'ارتقای درجه',
|
strings.rewards.rankUp,
|
||||||
rankFor(context.state.xp).name,
|
rankFor(context.state.xp).name,
|
||||||
'دستگاههای بیشتری پیدا کن',
|
strings.rewards.findMore,
|
||||||
),
|
),
|
||||||
done.length ? 3400 : 0,
|
mission ? 3400 : 0,
|
||||||
);
|
);
|
||||||
const next = activeMission(context.state);
|
|
||||||
if (next) {
|
|
||||||
context.waypoint = null;
|
|
||||||
context.route = null;
|
|
||||||
context.setTimeout(
|
|
||||||
() =>
|
|
||||||
context.say(context.guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'),
|
|
||||||
1500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
context.refreshHUD();
|
context.refreshHUD();
|
||||||
context.checkEnd();
|
context.checkEnd();
|
||||||
}
|
}
|
||||||
@@ -328,25 +317,26 @@ export function checkEnd(context) {
|
|||||||
context.ended = true;
|
context.ended = true;
|
||||||
document.exitPointerLock?.();
|
document.exitPointerLock?.();
|
||||||
context.ui.patch('end-tag', {
|
context.ui.patch('end-tag', {
|
||||||
text: allDone ? 'عملیات موفق' : 'پایان بودجه',
|
text: allDone ? strings.end.success : strings.end.outOfBudget,
|
||||||
});
|
});
|
||||||
context.ui.patch('end-title', {
|
context.ui.patch('end-title', {
|
||||||
text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد',
|
text: allDone ? strings.end.successHeading : strings.end.budgetHeading,
|
||||||
});
|
});
|
||||||
context.ui.patch('end-text', {
|
context.ui.patch('end-text', {
|
||||||
text: allDone
|
text: allDone ? strings.end.successText : strings.end.budgetText,
|
||||||
? 'همهٔ مأموریتهای محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد بهزودی باز میشوند.'
|
|
||||||
: 'دیگر نمیتوانید از دستگاهها خرید کنید. امتیاز امروز:',
|
|
||||||
});
|
});
|
||||||
const places = new Set(context.state.purchases.map((p) => p.machineId)).size;
|
const places = new Set(context.state.purchases.map((p) => p.machineId)).size;
|
||||||
context.ui.patch('end-stats', {
|
context.ui.patch('end-stats', {
|
||||||
items: [
|
items: [
|
||||||
['امتیاز', `${fa(context.state.xp)} XP`],
|
[strings.end.score, `${fa(context.state.xp)} XP`],
|
||||||
['درجه', rankFor(context.state.xp).name],
|
[strings.end.rank, rankFor(context.state.xp).name],
|
||||||
['خریدها', fa(context.state.purchases.length)],
|
[strings.end.purchases, fa(context.state.purchases.length)],
|
||||||
['مکانهای کشفشده', `${fa(places)} از ${fa(machines.length)}`],
|
[strings.end.places, strings.end.outOf(fa(places), fa(machines.length))],
|
||||||
['مأموریتها', `${fa(context.state.missionsDone.length)} از ${fa(MISSIONS.length)}`],
|
[
|
||||||
['باقیمانده', formatHetoman(context.state.balance)],
|
strings.end.missions,
|
||||||
|
strings.end.outOf(fa(context.state.missionsDone.length), fa(MISSIONS.length)),
|
||||||
|
],
|
||||||
|
[strings.end.remaining, formatHetoman(context.state.balance)],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
context.ui.patch('continue', {
|
context.ui.patch('continue', {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import data from '../../data/mellat.json';
|
|||||||
import { resolveCircle } from '../world/collision.js';
|
import { resolveCircle } from '../world/collision.js';
|
||||||
import { fa } from '../economy/gameState.js';
|
import { fa } from '../economy/gameState.js';
|
||||||
import { formatDistance } from '../../utils/geo.js';
|
import { formatDistance } from '../../utils/geo.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
/**
|
/**
|
||||||
* Runs the set real mode operation for this subsystem.
|
* Runs the set real mode operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -25,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);
|
||||||
@@ -54,15 +56,16 @@ 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()),
|
||||||
});
|
});
|
||||||
context.real.stabilizer.stable = context.stableMode;
|
context.real.stabilizer.stable = context.stableMode;
|
||||||
context.say(
|
context.say('father', strings.real.on);
|
||||||
'father',
|
|
||||||
'حالت دنیای واقعی روشن شد؛ از چشم خودمون میبینیم و هر جا راه بریم، توی بازی هم همونجاییم.',
|
|
||||||
);
|
|
||||||
context.setArMode(true);
|
context.setArMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +83,17 @@ export async function setArMode(context, on) {
|
|||||||
if (on && !context.arMode) {
|
if (on && !context.arMode) {
|
||||||
const problem = await context.arView.start();
|
const problem = await context.arView.start();
|
||||||
if (problem) {
|
if (problem) {
|
||||||
context.say('father', `${problem} فعلاً شهر سهبعدی رو از چشم خودمون میبینیم.`);
|
context.say('father', strings.real.cameraFailed(problem));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!context.real.on) {
|
if (!context.real.on) {
|
||||||
context.arView.stop();
|
context.arView.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.say(
|
context.say('robot', strings.real.cameraOn, 'excited');
|
||||||
'robot',
|
|
||||||
'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
|
|
||||||
'excited',
|
|
||||||
);
|
|
||||||
context.setTimeout(() => {
|
context.setTimeout(() => {
|
||||||
if (context.real.on && !context.arView.hasOrientation)
|
if (context.real.on && !context.arView.hasOrientation)
|
||||||
context.say('father', 'حسگر جهت گوشی جواب نمیده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
|
context.say('father', strings.real.noOrientation);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else if (!on && context.arMode) context.arView.stop();
|
} else if (!on && context.arMode) context.arView.stop();
|
||||||
else return;
|
else return;
|
||||||
@@ -120,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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -133,16 +134,19 @@ function askRealMode(context, thenStart) {
|
|||||||
export function updateReal(context, dt) {
|
export function updateReal(context, dt) {
|
||||||
let uiStatus;
|
let uiStatus;
|
||||||
if (context.real.error) uiStatus = `⚠ ${context.real.error}`;
|
if (context.real.error) uiStatus = `⚠ ${context.real.error}`;
|
||||||
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? 'در حال یافتن موقعیت…'}`;
|
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? strings.real.locating}`;
|
||||||
else if (!insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
|
else if (!insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
|
||||||
const nearest = Math.min(
|
const nearest = Math.min(
|
||||||
...context
|
...context
|
||||||
.activeStations()
|
.activeStations()
|
||||||
.map((s) => Math.hypot(s.x - context.real.fix.x, s.z - context.real.fix.z)),
|
.map((s) => Math.hypot(s.x - context.real.fix.x, s.z - context.real.fix.z)),
|
||||||
);
|
);
|
||||||
uiStatus = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیکترین دستگاه`;
|
uiStatus = strings.real.outOfArea(formatDistance(nearest));
|
||||||
} else
|
} else
|
||||||
uiStatus = `📍 GPS ±${fa(Math.round(context.real.fix.accuracy))} متر${context.real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
|
uiStatus = strings.real.accuracy(
|
||||||
|
fa(Math.round(context.real.fix.accuracy)),
|
||||||
|
context.real.fix.speed > MAX_WALKING_SPEED,
|
||||||
|
);
|
||||||
context.ui.patch('gps-status', {
|
context.ui.patch('gps-status', {
|
||||||
text: uiStatus,
|
text: uiStatus,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
/**
|
/**
|
||||||
* Runs the resize operation for this subsystem.
|
* Runs the resize operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -72,35 +71,11 @@ 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 =
|
// Any open dialog (or the start screen) pauses the world.
|
||||||
!playing ||
|
const paused = context.anyDialogOpen() || document.hidden;
|
||||||
context.ui.read('shop').open ||
|
|
||||||
context.ui.read('map-dialog').open ||
|
|
||||||
context.ui.read('end').open ||
|
|
||||||
context.ui.read('showroom').open ||
|
|
||||||
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;
|
||||||
@@ -109,20 +84,8 @@ export function frame(context) {
|
|||||||
else context.updateWalk(step);
|
else context.updateWalk(step);
|
||||||
context.updatePayment(step);
|
context.updatePayment(step);
|
||||||
context.updateDelivery(step);
|
context.updateDelivery(step);
|
||||||
context.traffic.update(
|
context.updateMissions(step);
|
||||||
step,
|
context.traffic.update(step, trafficObstacles(context), context.player);
|
||||||
[
|
|
||||||
{
|
|
||||||
x: context.player.x,
|
|
||||||
z: context.player.z,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: context.car.x,
|
|
||||||
z: context.car.z,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
context.player,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
context.updateRoute(dt);
|
context.updateRoute(dt);
|
||||||
context.updateHints();
|
context.updateHints();
|
||||||
@@ -147,6 +110,7 @@ export function frame(context) {
|
|||||||
delivery: context.deliveryTimer > 0,
|
delivery: context.deliveryTimer > 0,
|
||||||
});
|
});
|
||||||
context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position);
|
context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position);
|
||||||
|
updateGarage(context);
|
||||||
context.showroom.update({
|
context.showroom.update({
|
||||||
player: context.player,
|
player: context.player,
|
||||||
time: context.elapsed,
|
time: context.elapsed,
|
||||||
@@ -154,12 +118,7 @@ export function frame(context) {
|
|||||||
enabled: context.mode === 'walk' && !context.real.on,
|
enabled: context.mode === 'walk' && !context.real.on,
|
||||||
talking: context.ui.read('showroom').open,
|
talking: context.ui.read('showroom').open,
|
||||||
});
|
});
|
||||||
context.carModel.group.position.set(context.car.x, 0, context.car.z);
|
syncActiveVehicle(context, dt);
|
||||||
context.carModel.group.rotation.y = context.car.heading;
|
|
||||||
for (const [i, wheel] of context.carModel.wheels.entries()) {
|
|
||||||
wheel.rotation.z -= (context.car.speed * dt) / 0.36;
|
|
||||||
if (i >= 2) wheel.rotation.y = -context.car.steer;
|
|
||||||
}
|
|
||||||
for (const s of context.stations)
|
for (const s of context.stations)
|
||||||
s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25;
|
s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25;
|
||||||
context.updateReachCircles();
|
context.updateReachCircles();
|
||||||
@@ -189,6 +148,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,
|
||||||
@@ -205,7 +165,7 @@ export function frame(context) {
|
|||||||
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
|
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
|
||||||
if (playing) {
|
if (playing) {
|
||||||
context.hudTimer -= dt;
|
context.hudTimer -= dt;
|
||||||
context.updateHUD(dt);
|
context.updateHUD();
|
||||||
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
|
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
|
||||||
context.hudTimer = 0.5;
|
context.hudTimer = 0.5;
|
||||||
context.renderMap();
|
context.renderMap();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { placeStation, updateReachCircles, reset, startMission } from './runtimeLifecycle.js';
|
import { placeStation, updateReachCircles, reset } from './runtimeLifecycle.js';
|
||||||
|
import { startGame, restartGame, backToMenu, 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 {
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
rebuildRouteMesh,
|
rebuildRouteMesh,
|
||||||
updateRoute,
|
updateRoute,
|
||||||
drawCompass,
|
|
||||||
refreshHUD,
|
refreshHUD,
|
||||||
updatePrompt,
|
updatePrompt,
|
||||||
updateHUD,
|
updateHUD,
|
||||||
@@ -30,8 +30,16 @@ import {
|
|||||||
openMap,
|
openMap,
|
||||||
} from './mapInteraction.js';
|
} from './mapInteraction.js';
|
||||||
import { setRealMode, setArMode, askRealMode, updateReal } from './realMode.js';
|
import { setRealMode, setArMode, askRealMode, updateReal } from './realMode.js';
|
||||||
|
import {
|
||||||
|
reportMissionEvent,
|
||||||
|
updateMissions,
|
||||||
|
openMachineCard,
|
||||||
|
orderFromCard,
|
||||||
|
orderFromShowroom,
|
||||||
|
} from './missionRuntime.js';
|
||||||
import { resize, updateCamera, frame } from './renderLoop.js';
|
import { resize, updateCamera, frame } from './renderLoop.js';
|
||||||
import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js';
|
import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js';
|
||||||
|
import { initializeFactoryPoints, pointById, nearBoard, openBoard } from './factoryPoints.js';
|
||||||
import {
|
import {
|
||||||
initializeTimers,
|
initializeTimers,
|
||||||
initializeRenderer,
|
initializeRenderer,
|
||||||
@@ -97,7 +105,6 @@ const RUNTIME_METHODS = {
|
|||||||
checkEnd,
|
checkEnd,
|
||||||
rebuildRouteMesh,
|
rebuildRouteMesh,
|
||||||
updateRoute,
|
updateRoute,
|
||||||
drawCompass,
|
|
||||||
refreshHUD,
|
refreshHUD,
|
||||||
updatePrompt,
|
updatePrompt,
|
||||||
sizeCanvas,
|
sizeCanvas,
|
||||||
@@ -105,7 +112,19 @@ const RUNTIME_METHODS = {
|
|||||||
renderMachineList,
|
renderMachineList,
|
||||||
selectMachine,
|
selectMachine,
|
||||||
openMap,
|
openMap,
|
||||||
startMission,
|
startGame,
|
||||||
|
reportMissionEvent,
|
||||||
|
updateMissions,
|
||||||
|
openMachineCard,
|
||||||
|
orderFromCard,
|
||||||
|
orderFromShowroom,
|
||||||
|
pointById,
|
||||||
|
nearBoard,
|
||||||
|
openBoard,
|
||||||
|
restartGame,
|
||||||
|
backToMenu,
|
||||||
|
offerOffline,
|
||||||
|
switchToOffline,
|
||||||
setRealMode,
|
setRealMode,
|
||||||
setArMode,
|
setArMode,
|
||||||
askRealMode,
|
askRealMode,
|
||||||
@@ -129,6 +148,7 @@ export const RUNTIME_INITIALIZERS = [
|
|||||||
initializeLightingAndWorldFiles,
|
initializeLightingAndWorldFiles,
|
||||||
initializeCollisionAndRoads,
|
initializeCollisionAndRoads,
|
||||||
initializeFactory,
|
initializeFactory,
|
||||||
|
initializeFactoryPoints,
|
||||||
initializeStations,
|
initializeStations,
|
||||||
initializeStationById,
|
initializeStationById,
|
||||||
initializeClosestStation,
|
initializeClosestStation,
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { addPolygon, resolveCircle } from '../world/collision.js';
|
|||||||
import { createState } from '../economy/gameState.js';
|
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 { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
/**
|
/**
|
||||||
* Runs the place station operation for this subsystem.
|
* Runs the place station operation for this subsystem.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -52,7 +57,7 @@ export function updateReachCircles(context) {
|
|||||||
s.reach.visible = context.real.on;
|
s.reach.visible = context.real.on;
|
||||||
if (!context.real.on) continue;
|
if (!context.real.on) continue;
|
||||||
s.reach.scale.setScalar(radius);
|
s.reach.scale.setScalar(radius);
|
||||||
s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9');
|
s.reach.material.color.set(s === objective ? '#f2c94c' : BRAND.primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// GPS test points only take part while real-world mode is on.
|
// GPS test points only take part while real-world mode is on.
|
||||||
@@ -78,9 +83,9 @@ export function reset(context) {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
gait: 0,
|
gait: 0,
|
||||||
};
|
};
|
||||||
|
resetGarage(context);
|
||||||
context.factory.reset();
|
context.factory.reset();
|
||||||
for (const t of context.factory.talks) t.done = false;
|
for (const t of context.factory.talks) t.done = false;
|
||||||
context.car = context.spawnCar();
|
|
||||||
context.mode = 'walk';
|
context.mode = 'walk';
|
||||||
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
|
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
|
||||||
context.cam = {
|
context.cam = {
|
||||||
@@ -111,36 +116,8 @@ export function reset(context) {
|
|||||||
s.model.delivery.visible = s.model.card.visible = false;
|
s.model.delivery.visible = s.model.card.visible = false;
|
||||||
s.model.flap.rotation.x = 0;
|
s.model.flap.rotation.x = 0;
|
||||||
}
|
}
|
||||||
for (const id of ['shop', 'map-dialog', 'end', 'showroom']) context.ui.close(id);
|
for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board'])
|
||||||
|
context.ui.close(id);
|
||||||
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,
|
|
||||||
});
|
|
||||||
context.say(
|
|
||||||
'robot',
|
|
||||||
'سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدلهای کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.',
|
|
||||||
'wink',
|
|
||||||
);
|
|
||||||
context.say('child', 'بابا، اینجا همون کارخونهایه که دستگاههای روبات مارکت رو میسازه؟');
|
|
||||||
context.say(
|
|
||||||
'father',
|
|
||||||
'آره! سالن تولید اونوره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
|
|
||||||
);
|
|
||||||
context.say('child', 'اون وانت سفیده کیه داره میاد تو؟');
|
|
||||||
context.say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
|
|
||||||
if (!context.isTouch && !context.real.on)
|
|
||||||
context.canvas.requestPointerLock?.()?.catch?.(() => {});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,14 +17,29 @@ import {
|
|||||||
} from '../world/routing.js';
|
} from '../world/routing.js';
|
||||||
import { machines, CITY_START, FACTORY } from '../../constants/locations.js';
|
import { machines, CITY_START, FACTORY } from '../../constants/locations.js';
|
||||||
import { isOpen, formatClock } from '../economy/gameState.js';
|
import { isOpen, formatClock } from '../economy/gameState.js';
|
||||||
import { activeMission } from '../economy/scoring.js';
|
import { stepTarget } from '../missions/missionProgress.js';
|
||||||
import { createFamily } from '../models/characters.js';
|
import { createFamily } from '../models/characters.js';
|
||||||
import { createFactory } from '../models/factory.js';
|
import { createFactory } from '../models/factory.js';
|
||||||
import { createCar } from '../systems/vehicle.js';
|
|
||||||
import { createTraffic } from '../systems/traffic.js';
|
import { createTraffic } from '../systems/traffic.js';
|
||||||
import { createSportSedan } from '../models/carModels.js';
|
import { createGarage } from './garage.js';
|
||||||
import { createMapAssets } from '../world/mapRender.js';
|
import { createMapAssets } from '../world/mapRender.js';
|
||||||
import { formatDistance, toLocal } from '../../utils/geo.js';
|
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.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
|
|
||||||
|
// City map zoom limits in metres per screen pixel (closest and furthest).
|
||||||
|
const MAP_ZOOM_MIN = 0.35;
|
||||||
|
const MAP_ZOOM_MAX = 30;
|
||||||
/**
|
/**
|
||||||
* Initializes timers in dependency order.
|
* Initializes timers in dependency order.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -70,7 +85,7 @@ export async function initializeRenderer(context) {
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
context.ui.patch('loading', {
|
context.ui.patch('loading', {
|
||||||
text: 'مرورگر شما گرافیک سهبعدی (WebGL) را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.',
|
text: strings.app.noWebgl,
|
||||||
});
|
});
|
||||||
throw new Error('WebGL unavailable');
|
throw new Error('WebGL unavailable');
|
||||||
}
|
}
|
||||||
@@ -85,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.
|
||||||
@@ -278,7 +296,7 @@ export async function initializeStationById(context) {
|
|||||||
s.reach = new THREE.Mesh(
|
s.reach = new THREE.Mesh(
|
||||||
new THREE.RingGeometry(0.96, 1, 96),
|
new THREE.RingGeometry(0.96, 1, 96),
|
||||||
new THREE.MeshBasicMaterial({
|
new THREE.MeshBasicMaterial({
|
||||||
color: '#3ee0c9',
|
color: BRAND.primary,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.55,
|
opacity: 0.55,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
@@ -323,7 +341,7 @@ export async function initializeClosestStation(context) {
|
|||||||
});
|
});
|
||||||
if (context.imagery.available)
|
if (context.imagery.available)
|
||||||
context.ui.patch('attribution', {
|
context.ui.patch('attribution', {
|
||||||
text: `نقشه: © مشارکتکنندگان OpenStreetMap · تصویر ماهوارهای: ${context.imageryIndex.attribution}`,
|
text: strings.app.osmImageryCredit(context.imageryIndex.attribution),
|
||||||
});
|
});
|
||||||
context.traffic = createTraffic(context.scene, data, 28);
|
context.traffic = createTraffic(context.scene, data, 28);
|
||||||
context.mapAssets = createMapAssets(data, [context.factory.label]);
|
context.mapAssets = createMapAssets(data, [context.factory.label]);
|
||||||
@@ -354,7 +372,7 @@ export async function buildParkGate(context) {
|
|||||||
c.fillStyle = '#f4efd9';
|
c.fillStyle = '#f4efd9';
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
|
c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText('بوستان ملت', 256, 86);
|
c.fillText(strings.places.parkSign, 256, 86);
|
||||||
const texture = new THREE.CanvasTexture(sign);
|
const texture = new THREE.CanvasTexture(sign);
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
const gate = context.parkGate,
|
const gate = context.parkGate,
|
||||||
@@ -398,12 +416,11 @@ export async function initializeFamily(context) {
|
|||||||
});
|
});
|
||||||
context.guide.group.visible = false;
|
context.guide.group.visible = false;
|
||||||
context.scene.add(context.guide.group);
|
context.scene.add(context.guide.group);
|
||||||
context.carModel = createSportSedan('#1d3f78');
|
|
||||||
context.scene.add(context.carModel.group);
|
|
||||||
context.state = undefined;
|
context.state = undefined;
|
||||||
context.payment = undefined;
|
context.payment = undefined;
|
||||||
context.player = undefined;
|
context.player = undefined;
|
||||||
context.car = undefined;
|
context.car = undefined;
|
||||||
|
context.vehicle = undefined;
|
||||||
context.mode = undefined;
|
context.mode = undefined;
|
||||||
context.cam = undefined;
|
context.cam = undefined;
|
||||||
context.waypoint = undefined;
|
context.waypoint = undefined;
|
||||||
@@ -419,9 +436,9 @@ export async function initializeFamily(context) {
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
export async function initializeSpawnCar(context) {
|
export async function initializeSpawnCar(context) {
|
||||||
// The family car waits in the factory yard right beside them, pointing at the gate.
|
// The family car waits in the factory yard right beside them, pointing at the gate; the staff
|
||||||
context.spawnCar = () =>
|
// cars are parked in front of the office.
|
||||||
createCar(context.factory.car.x, context.factory.car.z, context.factory.car.heading);
|
createGarage(context);
|
||||||
// ---------- Input ----------
|
// ---------- Input ----------
|
||||||
context.isTouch = matchMedia('(pointer: coarse)').matches;
|
context.isTouch = matchMedia('(pointer: coarse)').matches;
|
||||||
context.anyDialogOpen = () =>
|
context.anyDialogOpen = () =>
|
||||||
@@ -429,7 +446,9 @@ 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('machine-card').open ||
|
||||||
|
context.ui.read('about-board').open ||
|
||||||
|
!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();
|
||||||
@@ -443,7 +462,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;
|
||||||
@@ -596,11 +615,7 @@ export async function initializeAxis(context) {
|
|||||||
context.axis = (pos, neg) =>
|
context.axis = (pos, neg) =>
|
||||||
Number(pos.some((k) => context.keys.has(k))) - Number(neg.some((k) => context.keys.has(k)));
|
Number(pos.some((k) => context.keys.has(k))) - Number(neg.some((k) => context.keys.has(k)));
|
||||||
// ---------- Subtitles ----------
|
// ---------- Subtitles ----------
|
||||||
context.SPEAKERS = {
|
context.SPEAKERS = { ...strings.speakers };
|
||||||
robot: 'روبات',
|
|
||||||
father: 'پدر',
|
|
||||||
child: 'بچه',
|
|
||||||
};
|
|
||||||
// Factory staff speak under their own names.
|
// Factory staff speak under their own names.
|
||||||
for (const t of context.factory.talks) if (t.label) context.SPEAKERS[t.speaker] = t.label;
|
for (const t of context.factory.talks) if (t.label) context.SPEAKERS[t.speaker] = t.label;
|
||||||
context.lines = [];
|
context.lines = [];
|
||||||
@@ -635,7 +650,7 @@ export async function registerSoundControl(context) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
context.ui.patch('sound', {
|
context.ui.patch('sound', {
|
||||||
text: context.soundOn ? 'صدا: روشن' : 'صدا: خاموش',
|
text: context.soundOn ? strings.toolbar.soundOn : strings.toolbar.soundOff,
|
||||||
});
|
});
|
||||||
context.ui.patch('sound', {
|
context.ui.patch('sound', {
|
||||||
pressed: String(context.soundOn),
|
pressed: String(context.soundOn),
|
||||||
@@ -643,7 +658,7 @@ export async function registerSoundControl(context) {
|
|||||||
context.tone(700);
|
context.tone(700);
|
||||||
} catch {
|
} catch {
|
||||||
context.ui.patch('sound', {
|
context.ui.patch('sound', {
|
||||||
text: 'صدا در دسترس نیست',
|
text: strings.toolbar.soundUnavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -666,8 +681,8 @@ export async function initializeReachRadius(context) {
|
|||||||
: null;
|
: null;
|
||||||
context.hoursText = (m) =>
|
context.hoursText = (m) =>
|
||||||
m.hours[1] - m.hours[0] >= 24 * 60
|
m.hours[1] - m.hours[0] >= 24 * 60
|
||||||
? 'شبانهروزی'
|
? strings.common.allDay
|
||||||
: `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
|
: strings.common.hours(formatClock(m.hours[0]), formatClock(m.hours[1]));
|
||||||
context.shopStation = null;
|
context.shopStation = null;
|
||||||
context.nearShowroom = () =>
|
context.nearShowroom = () =>
|
||||||
!context.real.on && context.mode === 'walk' ? context.showroom.nearest(context.player) : null;
|
!context.real.on && context.mode === 'walk' ? context.showroom.nearest(context.player) : null;
|
||||||
@@ -677,7 +692,7 @@ export async function initializeReachRadius(context) {
|
|||||||
modelId: id,
|
modelId: id,
|
||||||
});
|
});
|
||||||
context.ui.patch('showroom-response', {
|
context.ui.patch('showroom-response', {
|
||||||
text: 'روبات: دربارهٔ این مدل چی میخواهی بدانی؟',
|
text: strings.showroom.whatToKnow,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -697,10 +712,12 @@ export async function registerPurchaseControls(context) {
|
|||||||
const answer = showroomAnswer(question);
|
const answer = showroomAnswer(question);
|
||||||
if (!answer) return;
|
if (!answer) return;
|
||||||
context.ui.patch('showroom-response', {
|
context.ui.patch('showroom-response', {
|
||||||
text: `روبات: ${answer}`,
|
text: strings.showroom.robotSays(answer),
|
||||||
});
|
});
|
||||||
context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink');
|
context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink');
|
||||||
|
context.reportMissionEvent({ type: 'ask', machineId: SHOWROOM_POINT_ID });
|
||||||
});
|
});
|
||||||
|
context.ui.on('showroom-order', context.orderFromShowroom);
|
||||||
context.ui.on('swipe', context.swipe);
|
context.ui.on('swipe', context.swipe);
|
||||||
// On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car.
|
// On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car.
|
||||||
context.ui.on('prompt', () => {
|
context.ui.on('prompt', () => {
|
||||||
@@ -708,6 +725,11 @@ export async function registerPurchaseControls(context) {
|
|||||||
context.openShowroom();
|
context.openShowroom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const board = context.nearBoard();
|
||||||
|
if (board) {
|
||||||
|
context.openBoard(board);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (context.payment.phase === 'ready') context.swipe();
|
if (context.payment.phase === 'ready') context.swipe();
|
||||||
else if (context.nearStation()) context.interact();
|
else if (context.nearStation()) context.interact();
|
||||||
else context.toggleCar();
|
else context.toggleCar();
|
||||||
@@ -715,7 +737,7 @@ export async function registerPurchaseControls(context) {
|
|||||||
context.ui.on('cancel-payment', context.cancel);
|
context.ui.on('cancel-payment', context.cancel);
|
||||||
context.ui.on('shop-close', () => context.ui.close('shop'));
|
context.ui.on('shop-close', () => context.ui.close('shop'));
|
||||||
context.ui.patch('quality', {
|
context.ui.patch('quality', {
|
||||||
text: context.quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل',
|
text: context.quality === 'low' ? strings.toolbar.graphicsLow : strings.toolbar.graphicsHigh,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -732,13 +754,34 @@ 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);
|
||||||
|
context.ui.on('back-to-menu', context.backToMenu);
|
||||||
|
context.ui.on('machine-order', context.orderFromCard);
|
||||||
|
context.ui.on('machine-card-close', () => context.ui.close('machine-card'));
|
||||||
|
// The minimap is off by default to keep the screen clear; the floating menu toggles it.
|
||||||
|
try {
|
||||||
|
context.showMinimap = localStorage.getItem('minimap') === 'on';
|
||||||
|
} catch {
|
||||||
|
context.showMinimap = false;
|
||||||
|
}
|
||||||
|
const applyMinimap = () => {
|
||||||
|
context.ui.patch('minimap-button', { hidden: !context.showMinimap });
|
||||||
|
context.ui.patch('minimap-toggle', { pressed: context.showMinimap });
|
||||||
|
};
|
||||||
|
applyMinimap();
|
||||||
|
context.ui.on('minimap-toggle', () => {
|
||||||
|
context.showMinimap = !context.showMinimap;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('minimap', context.showMinimap ? 'on' : 'off');
|
||||||
|
} catch {}
|
||||||
|
applyMinimap();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Objective, route and HUD ----------
|
// ---------- Objective, route and HUD ----------
|
||||||
context.objectiveStation = () => {
|
context.objectiveStation = () => {
|
||||||
if (context.waypoint) return context.stationById(context.waypoint);
|
if (context.waypoint) return context.pointById(context.waypoint);
|
||||||
const mission = activeMission(context.state);
|
const target = stepTarget(context.state);
|
||||||
if (mission?.target) return context.stationById(mission.target);
|
if (target) return context.pointById(target);
|
||||||
// Open-ended missions point to the nearest place not bought from yet.
|
// Open-ended missions point to the nearest place not bought from yet.
|
||||||
const visited = new Set(context.state.purchases.map((p) => p.machineId));
|
const visited = new Set(context.state.purchases.map((p) => p.machineId));
|
||||||
return (
|
return (
|
||||||
@@ -771,12 +814,14 @@ export async function initializeRouteMaterial(context) {
|
|||||||
});
|
});
|
||||||
context.routeMesh = null;
|
context.routeMesh = null;
|
||||||
context.routeTarget = null;
|
context.routeTarget = null;
|
||||||
|
// Arrow hovering over the current objective machine.
|
||||||
|
context.objectiveArrow = createObjectiveArrow();
|
||||||
|
context.scene.add(context.objectiveArrow.group);
|
||||||
context.routeFrom = {
|
context.routeFrom = {
|
||||||
x: 0,
|
x: 0,
|
||||||
z: 0,
|
z: 0,
|
||||||
};
|
};
|
||||||
context.routeMode = null;
|
context.routeMode = null;
|
||||||
context.compass = context.$('#compass').getContext('2d');
|
|
||||||
context.minimap = context.$('#minimap').getContext('2d');
|
context.minimap = context.$('#minimap').getContext('2d');
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -823,7 +868,10 @@ export async function registerRouteSelection(context) {
|
|||||||
const s = context.stationById(context.selected);
|
const s = context.stationById(context.selected);
|
||||||
context.say(
|
context.say(
|
||||||
'father',
|
'father',
|
||||||
`مسیر تا ${s.def.name} رو گذاشتم؛ ${context.route ? formatDistance(context.route.length) : ''}. دنبال خط طلایی بریم.`,
|
strings.dialogue.routeSet(
|
||||||
|
s.def.name,
|
||||||
|
context.route ? formatDistance(context.route.length) : '',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
context.selectMachine(context.selected);
|
context.selectMachine(context.selected);
|
||||||
});
|
});
|
||||||
@@ -863,16 +911,65 @@ export async function registerMapTabs(context) {
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
export async function registerMapDragging(context) {
|
export async function registerMapDragging(context) {
|
||||||
|
// Every finger (or the mouse) on the map, so two fingers can pinch to zoom on phones.
|
||||||
|
context.mapPointers = new Map();
|
||||||
|
context.mapPinch = null;
|
||||||
context.listen(context.mapCanvas, 'pointerdown', (e) => {
|
context.listen(context.mapCanvas, 'pointerdown', (e) => {
|
||||||
context.mapDrag = {
|
context.mapPointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
moved: 0,
|
|
||||||
};
|
|
||||||
context.mapCanvas.setPointerCapture(e.pointerId);
|
context.mapCanvas.setPointerCapture(e.pointerId);
|
||||||
|
// A second finger turns the gesture into a pinch; it never counts as a tap on a machine.
|
||||||
|
if (context.mapPointers.size > 1) {
|
||||||
|
context.mapDrag = null;
|
||||||
|
context.mapPinch = pointerSpread(context.mapPointers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.mapDrag = { x: e.clientX, y: e.clientY, moved: 0 };
|
||||||
});
|
});
|
||||||
context.listen(context.mapCanvas, 'pointermove', (e) => {
|
context.listen(context.mapCanvas, 'pointermove', (e) => {
|
||||||
if (!context.mapDrag) return;
|
if (!context.mapPointers.has(e.pointerId)) return;
|
||||||
|
context.mapPointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (context.mapPinch) pinchMap(context);
|
||||||
|
else if (context.mapDrag) dragMap(context, e);
|
||||||
|
});
|
||||||
|
const release = (e) => {
|
||||||
|
context.mapPointers.delete(e.pointerId);
|
||||||
|
if (context.mapPointers.size < 2) context.mapPinch = null;
|
||||||
|
};
|
||||||
|
context.listen(context.mapCanvas, 'pointercancel', (e) => {
|
||||||
|
release(e);
|
||||||
|
context.mapDrag = null;
|
||||||
|
});
|
||||||
|
context.mapRelease = release;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Measures the distance between the first two pointers on the map.
|
||||||
|
* @param {Map<number, {x: number, y: number}>} pointers - Active pointers by id.
|
||||||
|
* @returns {number} Distance in CSS pixels.
|
||||||
|
*/
|
||||||
|
function pointerSpread(pointers) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Zooms the city map by how much the two fingers spread or closed since the last move.
|
||||||
|
* @param {*} context - Game context with the map view and active pointers.
|
||||||
|
*/
|
||||||
|
function pinchMap(context) {
|
||||||
|
const spread = pointerSpread(context.mapPointers);
|
||||||
|
context.mapView.mpp = THREE.MathUtils.clamp(
|
||||||
|
(context.mapView.mpp * context.mapPinch) / spread,
|
||||||
|
MAP_ZOOM_MIN,
|
||||||
|
MAP_ZOOM_MAX,
|
||||||
|
);
|
||||||
|
context.mapPinch = spread;
|
||||||
|
context.renderMap();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Pans the city map with a one-finger or mouse drag.
|
||||||
|
* @param {*} context - Game context with the map view and drag state.
|
||||||
|
* @param {PointerEvent} e - The move event.
|
||||||
|
*/
|
||||||
|
function dragMap(context, e) {
|
||||||
context.mapView.cx -= (e.clientX - context.mapDrag.x) * context.mapView.mpp;
|
context.mapView.cx -= (e.clientX - context.mapDrag.x) * context.mapView.mpp;
|
||||||
context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp;
|
context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp;
|
||||||
context.mapDrag.moved +=
|
context.mapDrag.moved +=
|
||||||
@@ -880,7 +977,6 @@ export async function registerMapDragging(context) {
|
|||||||
context.mapDrag.x = e.clientX;
|
context.mapDrag.x = e.clientX;
|
||||||
context.mapDrag.y = e.clientY;
|
context.mapDrag.y = e.clientY;
|
||||||
context.renderMap();
|
context.renderMap();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the register map selection and zoom operation for this subsystem.
|
* Runs the register map selection and zoom operation for this subsystem.
|
||||||
@@ -899,6 +995,7 @@ export async function registerMapSelectionAndZoom(context) {
|
|||||||
if (hit) context.selectMachine(hit.def.id);
|
if (hit) context.selectMachine(hit.def.id);
|
||||||
}
|
}
|
||||||
context.mapDrag = null;
|
context.mapDrag = null;
|
||||||
|
context.mapRelease(e);
|
||||||
});
|
});
|
||||||
context.listen(
|
context.listen(
|
||||||
context.mapCanvas,
|
context.mapCanvas,
|
||||||
@@ -907,8 +1004,8 @@ export async function registerMapSelectionAndZoom(context) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
context.mapView.mpp = THREE.MathUtils.clamp(
|
context.mapView.mpp = THREE.MathUtils.clamp(
|
||||||
context.mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15),
|
context.mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15),
|
||||||
0.35,
|
MAP_ZOOM_MIN,
|
||||||
30,
|
MAP_ZOOM_MAX,
|
||||||
);
|
);
|
||||||
context.renderMap();
|
context.renderMap();
|
||||||
},
|
},
|
||||||
@@ -927,63 +1024,63 @@ export async function registerMapSelectionAndZoom(context) {
|
|||||||
export async function initializeCities(context) {
|
export async function initializeCities(context) {
|
||||||
context.CITIES = [
|
context.CITIES = [
|
||||||
{
|
{
|
||||||
name: 'مشهد',
|
name: strings.cities.mashhad,
|
||||||
lat: 36.3,
|
lat: 36.3,
|
||||||
lon: 59.6,
|
lon: 59.6,
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'تهران',
|
name: strings.cities.tehran,
|
||||||
lat: 35.69,
|
lat: 35.69,
|
||||||
lon: 51.39,
|
lon: 51.39,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'اصفهان',
|
name: strings.cities.isfahan,
|
||||||
lat: 32.65,
|
lat: 32.65,
|
||||||
lon: 51.67,
|
lon: 51.67,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'شیراز',
|
name: strings.cities.shiraz,
|
||||||
lat: 29.59,
|
lat: 29.59,
|
||||||
lon: 52.58,
|
lon: 52.58,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'تبریز',
|
name: strings.cities.tabriz,
|
||||||
lat: 38.08,
|
lat: 38.08,
|
||||||
lon: 46.29,
|
lon: 46.29,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'اهواز',
|
name: strings.cities.ahvaz,
|
||||||
lat: 31.32,
|
lat: 31.32,
|
||||||
lon: 48.67,
|
lon: 48.67,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'کرمان',
|
name: strings.cities.kerman,
|
||||||
lat: 30.28,
|
lat: 30.28,
|
||||||
lon: 57.08,
|
lon: 57.08,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'رشت',
|
name: strings.cities.rasht,
|
||||||
lat: 37.28,
|
lat: 37.28,
|
||||||
lon: 49.58,
|
lon: 49.58,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'یزد',
|
name: strings.cities.yazd,
|
||||||
lat: 31.9,
|
lat: 31.9,
|
||||||
lon: 54.37,
|
lon: 54.37,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'زاهدان',
|
name: strings.cities.zahedan,
|
||||||
lat: 29.5,
|
lat: 29.5,
|
||||||
lon: 60.86,
|
lon: 60.86,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'کرمانشاه',
|
name: strings.cities.kermanshah,
|
||||||
lat: 34.31,
|
lat: 34.31,
|
||||||
lon: 47.07,
|
lon: 47.07,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'بندرعباس',
|
name: strings.cities.bandarAbbas,
|
||||||
lat: 27.18,
|
lat: 27.18,
|
||||||
lon: 56.27,
|
lon: 56.27,
|
||||||
},
|
},
|
||||||
@@ -995,16 +1092,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 = [
|
context.ui.on('start', (mode) => context.startGame(mode));
|
||||||
`نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
|
context.ui.on('gps-offline', context.switchToOffline);
|
||||||
'تیم: پدر و فرزند · روبات راهنما در دستگاهها منتظر شماست',
|
context.ui.on('gps-fallback-close', () => context.ui.close('gps-fallback'));
|
||||||
'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاههای روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
|
|
||||||
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
|
|
||||||
'بودجه: ۵۰۰ هتومان · مأموریتها پاداش نقدی دارند.',
|
|
||||||
'رفتوآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاهها را ببینید.',
|
|
||||||
].join('\n');
|
|
||||||
context.briefChars = 0;
|
|
||||||
context.ui.on('start-mission', context.startMission);
|
|
||||||
|
|
||||||
// ---------- 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
|
||||||
@@ -1038,7 +1128,7 @@ export async function initializeArMode(context) {
|
|||||||
context.imagery.group,
|
context.imagery.group,
|
||||||
context.factory.group,
|
context.factory.group,
|
||||||
context.traffic.group,
|
context.traffic.group,
|
||||||
context.carModel.group,
|
context.garage.group,
|
||||||
context.parkGate,
|
context.parkGate,
|
||||||
...context.showroom.samples.map((sample) => sample.machine.group),
|
...context.showroom.samples.map((sample) => sample.machine.group),
|
||||||
];
|
];
|
||||||
@@ -1048,7 +1138,7 @@ export async function initializeArMode(context) {
|
|||||||
context.real.stabilizer.stable = context.stableMode;
|
context.real.stabilizer.stable = context.stableMode;
|
||||||
context.ui.classState('stable-toggle', 'on', context.stableMode);
|
context.ui.classState('stable-toggle', 'on', context.stableMode);
|
||||||
context.ui.patch('stable-toggle', {
|
context.ui.patch('stable-toggle', {
|
||||||
text: context.stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش',
|
text: context.stableMode ? strings.toolbar.stableOn : strings.toolbar.stableOff,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1112,6 +1202,9 @@ export async function exposeDebugTools(context) {
|
|||||||
return context.cam;
|
return context.cam;
|
||||||
},
|
},
|
||||||
factory: context.factory,
|
factory: context.factory,
|
||||||
|
get points() {
|
||||||
|
return context.factoryPoints;
|
||||||
|
},
|
||||||
get car() {
|
get car() {
|
||||||
return context.car;
|
return context.car;
|
||||||
},
|
},
|
||||||
@@ -1144,6 +1237,10 @@ export async function exposeDebugTools(context) {
|
|||||||
real: context.real,
|
real: context.real,
|
||||||
keys: context.keys,
|
keys: context.keys,
|
||||||
traffic: context.traffic,
|
traffic: context.traffic,
|
||||||
|
garage: context.garage,
|
||||||
|
get vehicle() {
|
||||||
|
return context.vehicle;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -1158,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.
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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 { missionOrder } from '../missions/missions.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a fresh game for a mode, without touching the interface: where the player stands and
|
||||||
|
* which mission comes first.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {string} mode - Start mode.
|
||||||
|
*/
|
||||||
|
function placeForMode(context, mode) {
|
||||||
|
context.state.missionOrder = missionOrder(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves play and shows the start screen again, with a fresh game behind it.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function backToMenu(context) {
|
||||||
|
context.setRealMode(false);
|
||||||
|
const dialogs = ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board'];
|
||||||
|
for (const id of [...dialogs, 'gps-fallback']) context.ui.close(id);
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.reset();
|
||||||
|
context.ui.patch('hud', { hidden: true });
|
||||||
|
context.ui.patch('landing', { hidden: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
@@ -1,24 +1,45 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { FACTORY } from '../../constants/locations.js';
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
import { isOpen } from '../economy/gameState.js';
|
import { isOpen } from '../economy/gameState.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
/**
|
/**
|
||||||
* Updates talks from the active simulation.
|
* Distance from the player to someone who talks.
|
||||||
* @param {*} context - context.
|
* @param {object} context - Game context.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @param {object} talk - Factory talk.
|
||||||
|
* @returns {number} Metres.
|
||||||
*/
|
*/
|
||||||
export // People in the factory say their lines once, when the family walks up to them.
|
function talkDistance(context, talk) {
|
||||||
function updateTalks(context) {
|
const p = talk.pos();
|
||||||
if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return;
|
return Math.hypot(p.x - context.player.x, p.z - context.player.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets people who repeat their lines (the manager) talk again once the family has walked away.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
function rearmTalks(context) {
|
||||||
for (const t of context.factory.talks) {
|
for (const t of context.factory.talks) {
|
||||||
if (t.done || (t.ready && !t.ready())) continue;
|
if (t.repeat && t.done && talkDistance(context, t) > t.radius * 2) t.done = false;
|
||||||
const p = t.pos();
|
|
||||||
if (Math.hypot(p.x - context.player.x, p.z - context.player.z) > t.radius) continue;
|
|
||||||
t.done = true;
|
|
||||||
for (const text of t.lines) context.say(t.speaker, text);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* People in the factory say their lines when the family walks up to them. A talk with an `id`
|
||||||
|
* (the manager) also counts as meeting that person for the missions.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function updateTalks(context) {
|
||||||
|
if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return;
|
||||||
|
rearmTalks(context);
|
||||||
|
const talk = context.factory.talks.find(
|
||||||
|
(t) => !t.done && (!t.ready || t.ready()) && talkDistance(context, t) <= t.radius,
|
||||||
|
);
|
||||||
|
if (!talk) return;
|
||||||
|
talk.done = true;
|
||||||
|
for (const text of talk.lines) context.say(talk.speaker, text);
|
||||||
|
if (talk.id) context.reportMissionEvent({ type: 'meet', machineId: talk.id });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Robot guide ----------
|
// ---------- Robot guide ----------
|
||||||
// When the family walks up to a machine the robot pops out of it, greets them and explains what to
|
// When the family walks up to a machine the robot pops out of it, greets them and explains what to
|
||||||
// do; when they leave it goes back inside. It is never a walking companion.
|
// do; when they leave it goes back inside. It is never a walking companion.
|
||||||
@@ -33,36 +54,20 @@ export function greet(context, station) {
|
|||||||
first = !context.guideState.greeted.has(def.id);
|
first = !context.guideState.greeted.has(def.id);
|
||||||
context.guideState.greeted.add(def.id);
|
context.guideState.greeted.add(def.id);
|
||||||
if (def.id === 'factory' && first) {
|
if (def.id === 'factory' && first) {
|
||||||
context.say(
|
context.say('robot', strings.dialogue.robotHello, 'excited');
|
||||||
'robot',
|
context.say('robot', strings.dialogue.robotWelcome(FACTORY.name), 'happy');
|
||||||
'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی میکنم!',
|
context.say('robot', strings.dialogue.robotFirstBuy, 'wink');
|
||||||
'excited',
|
context.say('robot', strings.dialogue.robotGoToPark, 'love');
|
||||||
);
|
|
||||||
context.say(
|
|
||||||
'robot',
|
|
||||||
`به ${FACTORY.name} خوش اومدین. بچههای همین دفتر نرمافزار دستگاهها رو مینویسن.`,
|
|
||||||
'happy',
|
|
||||||
);
|
|
||||||
context.say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
|
|
||||||
context.say(
|
|
||||||
'robot',
|
|
||||||
'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاهها منتظرتونم!',
|
|
||||||
'love',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isOpen(def, context.state.minutes)) {
|
if (!isOpen(def, context.state.minutes)) {
|
||||||
context.say(
|
context.say('robot', strings.dialogue.robotClosed(context.hoursText(def)), 'serious');
|
||||||
'robot',
|
|
||||||
`سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(def)}.`,
|
|
||||||
'serious',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (first) {
|
if (first) {
|
||||||
context.say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
|
context.say('robot', strings.dialogue.robotWelcomeBack(def.name), 'excited');
|
||||||
context.say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
|
context.say('robot', strings.dialogue.robotNote(def.note), 'happy');
|
||||||
} else context.say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
|
} else context.say('robot', strings.dialogue.robotAgain, 'wink');
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Updates guide from the active simulation.
|
* Updates guide from the active simulation.
|
||||||
@@ -95,7 +100,7 @@ export function updateGuide(context, dt) {
|
|||||||
context.tone(990, 0.15);
|
context.tone(990, 0.15);
|
||||||
context.tone(1320, 0.2);
|
context.tone(1320, 0.2);
|
||||||
}
|
}
|
||||||
if (wasOut && near !== s) context.say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
|
if (wasOut && near !== s) context.say('robot', strings.dialogue.robotBye, 'happy');
|
||||||
if (!context.guideState.t) {
|
if (!context.guideState.t) {
|
||||||
context.guideState.station = null;
|
context.guideState.station = null;
|
||||||
context.guide.group.visible = false;
|
context.guide.group.visible = false;
|
||||||
@@ -145,11 +150,13 @@ export function updateGuide(context, dt) {
|
|||||||
*/
|
*/
|
||||||
export function updateHints(context) {
|
export function updateHints(context) {
|
||||||
const target = context.objectiveStation();
|
const target = context.objectiveStation();
|
||||||
if (target && !isOpen(target.def, context.state.minutes) && !context.hints.closed) {
|
// Only machines have opening hours; factory points are always open.
|
||||||
|
const closed = target?.def.hours && !isOpen(target.def, context.state.minutes);
|
||||||
|
if (closed && !context.hints.closed) {
|
||||||
context.hints.closed = true;
|
context.hints.closed = true;
|
||||||
context.say(
|
context.say(
|
||||||
'robot',
|
'robot',
|
||||||
`${target.def.name} الان بسته است (${context.hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`,
|
strings.dialogue.targetClosed(target.def.name, context.hoursText(target.def)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -159,6 +166,6 @@ export function updateHints(context) {
|
|||||||
!context.hints.car
|
!context.hints.car
|
||||||
) {
|
) {
|
||||||
context.hints.car = true;
|
context.hints.car = true;
|
||||||
context.say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
|
context.say('father', strings.dialogue.longWay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ const COLORS = [
|
|||||||
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
|
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
|
||||||
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
|
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
|
||||||
|
|
||||||
|
// Body size of a traffic car (createCarModel), for collisions and lane checks.
|
||||||
|
export const TRAFFIC_CAR = { halfLength: 2.2, halfWidth: 0.88 };
|
||||||
|
// Extra side clearance a traffic car wants before it drives past something.
|
||||||
|
const PASSING_MARGIN = 0.25;
|
||||||
|
// Half width assumed for an obstacle that doesn't give one, such as a pedestrian.
|
||||||
|
const DEFAULT_OBSTACLE_HALF_WIDTH = 0.4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates traffic resources for the game.
|
* Creates traffic resources for the game.
|
||||||
* @param {*} scene - scene.
|
* @param {*} scene - scene.
|
||||||
@@ -238,11 +245,14 @@ export function createTraffic(scene, data, count = 28) {
|
|||||||
cars = [];
|
cars = [];
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const model = createCarModel(COLORS[i % COLORS.length]);
|
const color = COLORS[i % COLORS.length];
|
||||||
|
const model = createCarModel(color);
|
||||||
group.add(model.group);
|
group.add(model.group);
|
||||||
model.group.visible = false;
|
model.group.visible = false;
|
||||||
cars.push({
|
cars.push({
|
||||||
model,
|
model,
|
||||||
|
color,
|
||||||
|
halfWidth: TRAFFIC_CAR.halfWidth,
|
||||||
road: null,
|
road: null,
|
||||||
d: 0,
|
d: 0,
|
||||||
dir: 1,
|
dir: 1,
|
||||||
@@ -263,6 +273,20 @@ export function createTraffic(scene, data, count = 28) {
|
|||||||
car.x = car.z = 1e9;
|
car.x = car.z = 1e9;
|
||||||
car.model.group.visible = false;
|
car.model.group.visible = false;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Hands a traffic car's model over to the player, who takes it as their own vehicle. The traffic
|
||||||
|
* slot gets a fresh model of the same colour and respawns out of sight later.
|
||||||
|
* @param {object} car - The traffic car being taken.
|
||||||
|
* @returns {{group: THREE.Group, wheels: THREE.Group[]}} The model that now belongs to the player.
|
||||||
|
*/
|
||||||
|
function adopt(car) {
|
||||||
|
const { model } = car;
|
||||||
|
group.remove(model.group);
|
||||||
|
car.model = createCarModel(car.color);
|
||||||
|
group.add(car.model.group);
|
||||||
|
remove(car);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
/**
|
/**
|
||||||
* Updates update from the active simulation.
|
* Updates update from the active simulation.
|
||||||
@@ -290,7 +314,11 @@ export function createTraffic(scene, data, count = 28) {
|
|||||||
const ox = o.x - car.x,
|
const ox = o.x - car.x,
|
||||||
oz = o.z - car.z,
|
oz = o.z - car.z,
|
||||||
along = ox * fx + oz * fz;
|
along = ox * fx + oz * fz;
|
||||||
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
|
// Only something that really overlaps the lane stops the car, so a car or walker
|
||||||
|
// beside the lane doesn't bring traffic to a halt.
|
||||||
|
const reach =
|
||||||
|
TRAFFIC_CAR.halfWidth + (o.halfWidth ?? DEFAULT_OBSTACLE_HALF_WIDTH) + PASSING_MARGIN;
|
||||||
|
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < reach;
|
||||||
});
|
});
|
||||||
const speed = ahead ? 0 : car.speed;
|
const speed = ahead ? 0 : car.speed;
|
||||||
car.d += speed * car.dir * dt;
|
car.d += speed * car.dir * dt;
|
||||||
@@ -317,5 +345,6 @@ export function createTraffic(scene, data, count = 28) {
|
|||||||
cars,
|
cars,
|
||||||
update,
|
update,
|
||||||
remove,
|
remove,
|
||||||
|
adopt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-24
@@ -1,5 +1,9 @@
|
|||||||
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y.
|
// Arcade vehicle physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's
|
||||||
// maxSpeed 55.6 m/s is 200 km/h.
|
// rotation.y. A spec holds the handling and the body size used for collisions:
|
||||||
|
// halfLength/halfWidth is the footprint box, `probes` are circles along the body that keep it out
|
||||||
|
// of buildings, `wheelRadius` turns speed into wheel spin.
|
||||||
|
|
||||||
|
// Cars: maxSpeed 55.6 m/s is 200 km/h.
|
||||||
export const CAR = {
|
export const CAR = {
|
||||||
maxSpeed: 55.6,
|
maxSpeed: 55.6,
|
||||||
maxReverse: 6,
|
maxReverse: 6,
|
||||||
@@ -9,13 +13,21 @@ export const CAR = {
|
|||||||
maxSteer: 0.55,
|
maxSteer: 0.55,
|
||||||
wheelBase: 2.7,
|
wheelBase: 2.7,
|
||||||
radius: 1.6,
|
radius: 1.6,
|
||||||
|
halfLength: 2.35,
|
||||||
|
halfWidth: 0.98,
|
||||||
|
wheelRadius: 0.36,
|
||||||
|
probes: [
|
||||||
|
{ along: 1.3, radius: 1.05 },
|
||||||
|
{ along: -1.3, radius: 1.05 },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates car resources for the game.
|
* Creates the moving state of a vehicle.
|
||||||
* @param {*} x - x.
|
* @param {number} x - World x.
|
||||||
* @param {*} z - z.
|
* @param {number} z - World z.
|
||||||
* @param {*} heading - heading.
|
* @param {number} heading - Heading in radians.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {{x: number, z: number, heading: number, speed: number, steer: number}} The state.
|
||||||
*/
|
*/
|
||||||
export function createCar(x, z, heading = 0) {
|
export function createCar(x, z, heading = 0) {
|
||||||
return {
|
return {
|
||||||
@@ -26,32 +38,45 @@ export function createCar(x, z, heading = 0) {
|
|||||||
steer: 0,
|
steer: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the step car operation for this subsystem.
|
* Applies throttle and brakes to the speed for one step.
|
||||||
* @param {*} car - car.
|
* @param {object} car - Vehicle state, changed in place.
|
||||||
* @param {*} options1 - Operation options.
|
* @param {{throttle: number, handbrake: boolean}} input - Driver input.
|
||||||
* @param {*} dt - dt.
|
* @param {{dt: number, spec: object}} step - Time step and vehicle spec.
|
||||||
* @returns {*} Operation result, when applicable.
|
|
||||||
*/
|
*/
|
||||||
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) {
|
function applyThrottle(car, { throttle, handbrake }, { dt, spec }) {
|
||||||
car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6);
|
if (throttle > 0) car.speed += (car.speed < 0 ? spec.brake : spec.accel) * throttle * dt;
|
||||||
if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt;
|
|
||||||
else if (throttle < 0)
|
else if (throttle < 0)
|
||||||
car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * 0.6) * -throttle * dt;
|
car.speed += (car.speed > 0 ? -spec.brake : -spec.accel * 0.6) * -throttle * dt;
|
||||||
else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt);
|
else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), spec.drag * dt);
|
||||||
if (handbrake)
|
if (handbrake)
|
||||||
car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt);
|
car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), spec.brake * 1.4 * dt);
|
||||||
car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
|
car.speed = Math.max(-spec.maxReverse, Math.min(spec.maxSpeed, car.speed));
|
||||||
// High speed narrows the usable steering angle so the car stays controllable.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances a vehicle by one physics step.
|
||||||
|
* @param {object} car - Vehicle state, changed in place.
|
||||||
|
* @param {{throttle?: number, steer?: number, handbrake?: boolean}} input - Driver input.
|
||||||
|
* @param {number} dt - Step length in seconds.
|
||||||
|
* @param {object} spec - Handling spec, CAR by default.
|
||||||
|
* @returns {object} The same vehicle state.
|
||||||
|
*/
|
||||||
|
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt, spec = CAR) {
|
||||||
|
car.steer += (steer * spec.maxSteer - car.steer) * Math.min(1, dt * 6);
|
||||||
|
applyThrottle(car, { throttle, handbrake }, { dt, spec });
|
||||||
|
// High speed narrows the usable steering angle so the vehicle stays controllable.
|
||||||
const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
|
const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
|
||||||
car.heading -= ((car.speed * Math.tan(steerAngle)) / CAR.wheelBase) * dt;
|
car.heading -= ((car.speed * Math.tan(steerAngle)) / spec.wheelBase) * dt;
|
||||||
car.x += Math.cos(car.heading) * car.speed * dt;
|
car.x += Math.cos(car.heading) * car.speed * dt;
|
||||||
car.z -= Math.sin(car.heading) * car.speed * dt;
|
car.z -= Math.sin(car.heading) * car.speed * dt;
|
||||||
return car;
|
return car;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the kmh operation for this subsystem.
|
* Converts a vehicle's speed to whole km/h for the HUD.
|
||||||
* @param {*} car - car.
|
* @param {{speed: number}} car - Vehicle state.
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {number} Speed in km/h.
|
||||||
*/
|
*/
|
||||||
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
|
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
|
|
||||||
|
// The factory's local frame: u runs along the driveway towards the gate, v across it. Things placed
|
||||||
|
// in the yard outside the factory model (product samples, information boards) use it too.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects factory-local coordinates into world metres.
|
||||||
|
* @param {number} u - Position along the factory axis.
|
||||||
|
* @param {number} v - Position across the factory axis.
|
||||||
|
* @returns {{x: number, z: number}} World position.
|
||||||
|
*/
|
||||||
|
export function factoryToWorld(u, v) {
|
||||||
|
return {
|
||||||
|
x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v,
|
||||||
|
z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collision outline of a rectangle in the factory frame.
|
||||||
|
* @param {{u: number, v: number, halfU: number, halfV: number}} box - Centre and half sizes.
|
||||||
|
* @returns {number[]} Flat world ring [x0, z0, x1, z1, …] for the collision world.
|
||||||
|
*/
|
||||||
|
export function factoryFootprint({ u, v, halfU, halfV }) {
|
||||||
|
return [
|
||||||
|
[-halfU, -halfV],
|
||||||
|
[halfU, -halfV],
|
||||||
|
[halfU, halfV],
|
||||||
|
[-halfU, halfV],
|
||||||
|
].flatMap(([du, dv]) => Object.values(factoryToWorld(u + du, v + dv)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation (Three rotation.y) that turns a model's local +X along the factory axis, so its local
|
||||||
|
// +Z faces +v.
|
||||||
|
export const FACTORY_ROTATION = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x);
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
const BRAND = COMPANY.colors;
|
||||||
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
|
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
|
||||||
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
|
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
|
||||||
const SCALE = 0.75; // tile pixels per metre
|
const SCALE = 0.75; // tile pixels per metre
|
||||||
@@ -271,7 +276,7 @@ function machineIcon(c, x, y, { objective, visited, open }) {
|
|||||||
c.fillRect(-7, -7, 14, 14);
|
c.fillRect(-7, -7, 14, 14);
|
||||||
c.strokeRect(-7, -7, 14, 14);
|
c.strokeRect(-7, -7, 14, 14);
|
||||||
} else {
|
} else {
|
||||||
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
|
c.fillStyle = open ? (visited ? '#7fb8ad' : BRAND.primary) : '#7b817f';
|
||||||
c.strokeStyle = '#08201c';
|
c.strokeStyle = '#08201c';
|
||||||
c.lineWidth = 2;
|
c.lineWidth = 2;
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
@@ -367,7 +372,7 @@ export function drawMinimap(ctx, assets, view) {
|
|||||||
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
ctx.fillText(strings.hud.minimapNorth, (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
@@ -461,11 +466,11 @@ export function drawFullMap(ctx, assets, view, state) {
|
|||||||
metro: '#ff8a80',
|
metro: '#ff8a80',
|
||||||
mall: '#f5b8d9',
|
mall: '#f5b8d9',
|
||||||
industrial: '#a9b2ad',
|
industrial: '#a9b2ad',
|
||||||
factory: '#3ee0c9',
|
factory: BRAND.primary,
|
||||||
}[l.kind];
|
}[l.kind];
|
||||||
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
|
l.kind === 'metro' ? `Ⓜ ${l.name.replace(strings.hud.metroPrefix, '')}` : l.name,
|
||||||
sx,
|
sx,
|
||||||
sy + 14,
|
sy + 14,
|
||||||
);
|
);
|
||||||
@@ -546,9 +551,9 @@ export function drawIran(
|
|||||||
for (const province of iran.provinces) {
|
for (const province of iran.provinces) {
|
||||||
const active = province.id === highlight;
|
const active = province.id === highlight;
|
||||||
ctx.fillStyle = active
|
ctx.fillStyle = active
|
||||||
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
|
? `rgba(0,166,147,${0.35 + Math.sin(t * 3) * 0.12})`
|
||||||
: 'rgba(40,56,50,.85)';
|
: 'rgba(40,56,50,.85)';
|
||||||
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
|
ctx.strokeStyle = active ? BRAND.primary : 'rgba(150,190,175,.35)';
|
||||||
ctx.lineWidth = active ? 2 : 1;
|
ctx.lineWidth = active ? 2 : 1;
|
||||||
for (const ring of province.rings) {
|
for (const ring of province.rings) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Oriented-box collisions between vehicles, so two bodies only touch when their outlines really
|
||||||
|
// meet. A car can therefore pass through any gap it actually fits, without slowing down.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the footprint box of a vehicle on the ground plane.
|
||||||
|
* @param {{x: number, z: number, heading: number}} pose - Centre and heading (Three rotation.y).
|
||||||
|
* @param {{halfLength: number, halfWidth: number}} size - Half extents along and across the body.
|
||||||
|
* @returns {object} Box with centre, forward/side unit axes and half extents.
|
||||||
|
*/
|
||||||
|
export function vehicleBox({ x, z, heading }, { halfLength, halfWidth }) {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
// Heading h faces (cos h, -sin h); the side axis is that rotated by 90°.
|
||||||
|
forward: { x: Math.cos(heading), z: -Math.sin(heading) },
|
||||||
|
side: { x: Math.sin(heading), z: Math.cos(heading) },
|
||||||
|
halfLength,
|
||||||
|
halfWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Half the length of a box's shadow on an axis.
|
||||||
|
* @param {object} box - Box from vehicleBox.
|
||||||
|
* @param {{x: number, z: number}} axis - Unit axis.
|
||||||
|
* @returns {number} Projected radius.
|
||||||
|
*/
|
||||||
|
function projectedRadius(box, axis) {
|
||||||
|
const along = Math.abs(box.forward.x * axis.x + box.forward.z * axis.z),
|
||||||
|
across = Math.abs(box.side.x * axis.x + box.side.z * axis.z);
|
||||||
|
return box.halfLength * along + box.halfWidth * across;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests two boxes with the separating-axis theorem.
|
||||||
|
* @param {object} a - The box to move.
|
||||||
|
* @param {object} b - The box it hits.
|
||||||
|
* @returns {{x: number, z: number, depth: number} | null} The shortest push that moves `a` out of
|
||||||
|
* `b`, or null when they don't touch.
|
||||||
|
*/
|
||||||
|
export function boxOverlap(a, b) {
|
||||||
|
const dx = b.x - a.x,
|
||||||
|
dz = b.z - a.z;
|
||||||
|
let best = null;
|
||||||
|
for (const axis of [a.forward, a.side, b.forward, b.side]) {
|
||||||
|
const distance = dx * axis.x + dz * axis.z;
|
||||||
|
const depth = projectedRadius(a, axis) + projectedRadius(b, axis) - Math.abs(distance);
|
||||||
|
// One gap on any axis means the boxes are apart.
|
||||||
|
if (depth <= 0) return null;
|
||||||
|
if (best && depth >= best.depth) continue;
|
||||||
|
const away = distance > 0 ? -1 : 1;
|
||||||
|
best = { x: axis.x * away * depth, z: axis.z * away * depth, depth };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a walking circle (the player) out of a box.
|
||||||
|
* @param {object} box - Box from vehicleBox.
|
||||||
|
* @param {{x: number, z: number}} point - Circle centre.
|
||||||
|
* @param {number} radius - Circle radius.
|
||||||
|
* @returns {{x: number, z: number} | null} The corrected centre, or null when there is no contact.
|
||||||
|
*/
|
||||||
|
export function circleOutOfBox(box, point, radius) {
|
||||||
|
const dx = point.x - box.x,
|
||||||
|
dz = point.z - box.z;
|
||||||
|
const along = dx * box.forward.x + dz * box.forward.z,
|
||||||
|
across = dx * box.side.x + dz * box.side.z;
|
||||||
|
const nearAlong = Math.max(-box.halfLength, Math.min(box.halfLength, along)),
|
||||||
|
nearAcross = Math.max(-box.halfWidth, Math.min(box.halfWidth, across));
|
||||||
|
const gapAlong = along - nearAlong,
|
||||||
|
gapAcross = across - nearAcross,
|
||||||
|
gap = Math.hypot(gapAlong, gapAcross);
|
||||||
|
if (gap >= radius) return null;
|
||||||
|
if (gap > 0) {
|
||||||
|
const push = (radius - gap) / gap;
|
||||||
|
return toWorld(box, along + gapAlong * push, across + gapAcross * push);
|
||||||
|
}
|
||||||
|
// The centre is inside the box: leave through the nearest side.
|
||||||
|
const outAlong = box.halfLength + radius - Math.abs(along),
|
||||||
|
outAcross = box.halfWidth + radius - Math.abs(across);
|
||||||
|
if (outAlong < outAcross) return toWorld(box, along + Math.sign(along || 1) * outAlong, across);
|
||||||
|
return toWorld(box, along, across + Math.sign(across || 1) * outAcross);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts box-local coordinates back to world space.
|
||||||
|
* @param {object} box - Box from vehicleBox.
|
||||||
|
* @param {number} along - Distance along the forward axis.
|
||||||
|
* @param {number} across - Distance along the side axis.
|
||||||
|
* @returns {{x: number, z: number}} World position.
|
||||||
|
*/
|
||||||
|
function toWorld(box, along, across) {
|
||||||
|
return {
|
||||||
|
x: box.x + box.forward.x * along + box.side.x * across,
|
||||||
|
z: box.z + box.forward.z * along + box.side.z * across,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+592
@@ -0,0 +1,592 @@
|
|||||||
|
// Every piece of Persian text the game shows: menus, HUD, prompts, dialogue, missions and the
|
||||||
|
// scenery text painted onto 3D props. Company and product content lives in config/company.js,
|
||||||
|
// and place names come from the map data. To add a language, copy this file with the same keys.
|
||||||
|
// Entries that need values are functions; everything else is a plain string.
|
||||||
|
|
||||||
|
import { COMPANY } from '../config/company.js';
|
||||||
|
|
||||||
|
export const strings = {
|
||||||
|
common: {
|
||||||
|
close: 'بستن',
|
||||||
|
cancel: 'انصراف',
|
||||||
|
gotIt: 'متوجه شدم',
|
||||||
|
mashhad: 'مشهد',
|
||||||
|
iran: 'ایران',
|
||||||
|
walking: 'پیاده',
|
||||||
|
open: 'باز',
|
||||||
|
closed: 'بسته',
|
||||||
|
allDay: 'شبانهروزی',
|
||||||
|
/** @type {(from: string, to: string) => string} */
|
||||||
|
hours: (from, to) => `${from} تا ${to}`,
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
/** @type {(n: string) => string} */
|
||||||
|
metres: (n) => `${n} متر`,
|
||||||
|
/** @type {(n: string) => string} */
|
||||||
|
kilometres: (n) => `${n} کیلومتر`,
|
||||||
|
/** @type {(n: string) => string} */
|
||||||
|
hetoman: (n) => `${n} هتومان`,
|
||||||
|
/** @type {(n: string) => string} */
|
||||||
|
rial: (n) => `${n} ریال`,
|
||||||
|
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: {
|
||||||
|
buildingCity: 'در حال ساختن شهر مشهد…',
|
||||||
|
/** @type {(percent: string) => string} */
|
||||||
|
loadingPercent: (percent) => `${percent}٪`,
|
||||||
|
/** @type {(error: string) => string} */
|
||||||
|
failed: (error) => `بازی اجرا نشد: ${error}`,
|
||||||
|
retry: 'تلاش دوباره',
|
||||||
|
noWebgl: 'مرورگر شما گرافیک سهبعدی (WebGL) را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.',
|
||||||
|
dataFailed: 'بارگذاری داده ناموفق بود',
|
||||||
|
updateReady: 'نسخهٔ جدید بازی آماده است ',
|
||||||
|
update: 'بهروزرسانی',
|
||||||
|
osmCredit: 'نقشه: © مشارکتکنندگان OpenStreetMap',
|
||||||
|
/** @type {(imagery: string) => string} */
|
||||||
|
osmImageryCredit: (imagery) =>
|
||||||
|
`نقشه: © مشارکتکنندگان OpenStreetMap · تصویر ماهوارهای: ${imagery}`,
|
||||||
|
},
|
||||||
|
landing: {
|
||||||
|
robotAlt: `روبات راهنمای ${COMPANY.shortName}`,
|
||||||
|
logoAlt: `لوگوی ${COMPANY.shortName}`,
|
||||||
|
heading: 'دنیای دستگاههای وندینگ هوشمند را بگردید',
|
||||||
|
intro: 'کارخانه، محصولات و دستگاههای نصبشده در شهر را در یک بازی سهبعدی ببینید.',
|
||||||
|
modesLabel: 'شروع بازی',
|
||||||
|
modes: {
|
||||||
|
real: { label: 'بازی در دنیای واقعی', hint: 'با GPS گوشی' },
|
||||||
|
offline: { label: 'بازی آفلاین', hint: 'شروع از بوستان ملت' },
|
||||||
|
about: { label: 'دربارهٔ ما', hint: 'گشت در کارخانه' },
|
||||||
|
products: { label: 'محصولات', hint: 'دستگاهها از نزدیک' },
|
||||||
|
},
|
||||||
|
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: {
|
||||||
|
showroom: 'مدلهای کارخانه · Q',
|
||||||
|
realWorld: '📍 دنیای واقعی',
|
||||||
|
camera: '📷 دوربین',
|
||||||
|
stableOn: '🎯 پایدار: روشن',
|
||||||
|
stableOff: '🎯 پایدار: خاموش',
|
||||||
|
soundOn: 'صدا: روشن',
|
||||||
|
soundOff: 'صدا: خاموش',
|
||||||
|
soundUnavailable: 'صدا در دسترس نیست',
|
||||||
|
graphics: 'گرافیک',
|
||||||
|
graphicsLow: 'گرافیک: سبک',
|
||||||
|
graphicsHigh: 'گرافیک: کامل',
|
||||||
|
restart: 'شروع دوباره',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
budget: 'بودجه',
|
||||||
|
budgetStart: '۵۰۰',
|
||||||
|
clock: 'ساعت',
|
||||||
|
clockStart: '۰۸:۳۰',
|
||||||
|
speed: 'سرعت',
|
||||||
|
speedStart: '۰',
|
||||||
|
rank: 'درجه',
|
||||||
|
xp: 'امتیاز',
|
||||||
|
rankStart: 'تازهوارد',
|
||||||
|
xpStart: '۰ XP',
|
||||||
|
missions: 'مأموریتها',
|
||||||
|
run: 'دویدن',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
open: 'منو',
|
||||||
|
label: 'منوی بازی',
|
||||||
|
map: 'نقشه',
|
||||||
|
missions: 'مأموریتها',
|
||||||
|
status: 'وضعیت',
|
||||||
|
products: 'محصولات',
|
||||||
|
realWorld: 'دنیای واقعی',
|
||||||
|
camera: 'دوربین',
|
||||||
|
stable: 'پایدار',
|
||||||
|
sound: 'صدا',
|
||||||
|
graphics: 'گرافیک',
|
||||||
|
minimap: 'نقشهٔ کوچک',
|
||||||
|
fullscreen: 'تمامصفحه',
|
||||||
|
help: 'راهنما',
|
||||||
|
restart: 'شروع دوباره',
|
||||||
|
home: 'منوی اصلی',
|
||||||
|
close: 'بستن',
|
||||||
|
},
|
||||||
|
objective: {
|
||||||
|
label: 'هدف بعدی',
|
||||||
|
open: 'نمایش مأموریتها',
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
keyboardTitle: 'صفحهکلید',
|
||||||
|
keyboard: [
|
||||||
|
['W A S D', 'حرکت'],
|
||||||
|
['Shift', 'دویدن'],
|
||||||
|
['ماوس', 'چرخاندن دوربین'],
|
||||||
|
['F', 'سوار و پیاده شدن'],
|
||||||
|
['E', 'خرید'],
|
||||||
|
['Q', 'معرفی محصولات'],
|
||||||
|
['M', 'نقشه'],
|
||||||
|
['Space', 'ترمز دستی'],
|
||||||
|
],
|
||||||
|
touchTitle: 'لمسی',
|
||||||
|
touch: [
|
||||||
|
['دایرهٔ پایین', 'حرکت و رانندگی'],
|
||||||
|
['کشیدن صفحه', 'چرخاندن دوربین'],
|
||||||
|
['دکمهٔ وسط پایین', 'کار بعدی: خرید، سوار شدن، معرفی'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hud: {
|
||||||
|
openMap: 'باز کردن نقشهٔ شهر (M)',
|
||||||
|
interact: 'تعامل با بازی',
|
||||||
|
// Single-letter north marker on the minimap.
|
||||||
|
minimapNorth: 'ش',
|
||||||
|
metroPrefix: 'ایستگاه مترو ',
|
||||||
|
},
|
||||||
|
prompts: {
|
||||||
|
/** @type {(model: string) => string} */
|
||||||
|
showroom: (model) => `Q · معرفی ${model} و پرسیدن قیمت`,
|
||||||
|
/** @type {(machine: string) => string} */
|
||||||
|
buy: (machine) => `E · خرید از ${COMPANY.shortName} · ${machine}`,
|
||||||
|
/** @type {(hours: string) => string} */
|
||||||
|
closed: (hours) => `دستگاه بسته است · ${hours}`,
|
||||||
|
/** @type {(vehicle: string) => string} */
|
||||||
|
getIn: (vehicle) => `F · سوار ${vehicle} شو`,
|
||||||
|
getOutNearMachine: 'F · پیاده شو؛ دستگاه نزدیک است',
|
||||||
|
/** @type {(board: string) => string} */
|
||||||
|
board: (board) => `E · خواندن ${board}`,
|
||||||
|
},
|
||||||
|
vehicles: {
|
||||||
|
bmw: 'بیامو',
|
||||||
|
bugatti: 'بوگاتی',
|
||||||
|
lamborghini: 'لامبورگینی',
|
||||||
|
mazda: 'مزدا',
|
||||||
|
nissan: 'نیسان',
|
||||||
|
traffic: 'ماشین',
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
close: 'بستن',
|
||||||
|
help: 'کشیدن: جابهجایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب',
|
||||||
|
machines: `دستگاههای ${COMPANY.shortName}`,
|
||||||
|
route: 'مسیریابی تا اینجا',
|
||||||
|
routeActive: 'مسیر فعال است ✓',
|
||||||
|
openNow: 'الان باز است',
|
||||||
|
closedNow: 'الان بسته است',
|
||||||
|
/** @type {(hours: string, open: boolean, note: string) => string} */
|
||||||
|
details: (hours, open, note) =>
|
||||||
|
`🕒 ${hours} · ${open ? 'الان باز است' : 'الان بسته است'} · ${note}`,
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
drink: 'نوشیدنی',
|
||||||
|
snack: 'خوراکی',
|
||||||
|
noFunds: ' · موجودی کافی نیست',
|
||||||
|
/** @type {(category: string, open: boolean) => string} */
|
||||||
|
machineState: (category, open) => `${category} · ${open ? 'باز' : 'بسته'}`,
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
title: 'نصب روی آیفون',
|
||||||
|
shareBefore: 'در Safari دکمهٔ ',
|
||||||
|
shareAfter: ' (مربع با فلش رو به بالا) را بزنید.',
|
||||||
|
addBefore: 'گزینهٔ ',
|
||||||
|
addAfter: ' را انتخاب کنید.',
|
||||||
|
note: `${COMPANY.shortName} مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار میگیرد و آفلاین هم اجرا میشود.`,
|
||||||
|
},
|
||||||
|
realIntro: {
|
||||||
|
title: 'بازی در دنیای واقعی',
|
||||||
|
body: `با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده میشود. وقتی واقعاً به محل یک دستگاه ${COMPANY.shortName} برسید، میتوانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.`,
|
||||||
|
safety: [
|
||||||
|
'هنگام راه رفتن به خیابان و ماشینها نگاه کنید، نه به صفحه.',
|
||||||
|
'بچهها فقط همراه یک بزرگتر بازی کنند.',
|
||||||
|
'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال میشود.',
|
||||||
|
'موقعیت شما فقط روی همین گوشی میماند و جایی ارسال نمیشود.',
|
||||||
|
],
|
||||||
|
accept: 'قبول دارم، شروع کن',
|
||||||
|
},
|
||||||
|
real: {
|
||||||
|
on: 'حالت دنیای واقعی روشن شد؛ از چشم خودمون میبینیم و هر جا راه بریم، توی بازی هم همونجاییم.',
|
||||||
|
/** @type {(problem: string) => string} */
|
||||||
|
cameraFailed: (problem) => `${problem} فعلاً شهر سهبعدی رو از چشم خودمون میبینیم.`,
|
||||||
|
cameraOn: 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
|
||||||
|
noOrientation: 'حسگر جهت گوشی جواب نمیده؛ برای چرخیدن، صفحه رو با انگشت بکش.',
|
||||||
|
noVirtualCar: 'توی حالت دنیای واقعی پیاده راه میریم؛ ماشین مجازی لازم نیست.',
|
||||||
|
locating: 'در حال یافتن موقعیت…',
|
||||||
|
/** @type {(distance: string) => string} */
|
||||||
|
outOfArea: (distance) => `📍 خارج از محدودهٔ بازی · ${distance} تا نزدیکترین دستگاه`,
|
||||||
|
/** @type {(metres: string, fast: boolean) => string} */
|
||||||
|
accuracy: (metres, fast) => `📍 GPS ±${metres} متر${fast ? ' · حرکت سریع' : ''}`,
|
||||||
|
noGps: 'این دستگاه موقعیتیابی (GPS) ندارد.',
|
||||||
|
needsHttps: 'موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.',
|
||||||
|
denied:
|
||||||
|
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
|
||||||
|
unavailable:
|
||||||
|
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیتیابی کار نمیکند. (کد ۲)',
|
||||||
|
timeout: 'هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریعتر پیدا میشود. (کد ۳)',
|
||||||
|
cameraNeedsHttps: 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).',
|
||||||
|
cameraDenied: 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.',
|
||||||
|
cameraBusy: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟',
|
||||||
|
},
|
||||||
|
shop: {
|
||||||
|
close: 'بستن',
|
||||||
|
note: '۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشیاند و پرداخت واقعی انجام نمیشود.',
|
||||||
|
/** @type {(icon: string, category: string, hours: string) => string} */
|
||||||
|
category: (icon, category, hours) => `${icon} ${category} · ساعت کاری ${hours}`,
|
||||||
|
/** @type {(machine: string) => string} */
|
||||||
|
title: (machine) => `${COMPANY.shortName} · ${machine}`,
|
||||||
|
/** @type {(note: string, balance: string) => string} */
|
||||||
|
balance: (note, balance) => `${note} · موجودی شما: ${balance}`,
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
reader: `کارتخوان ${COMPANY.shortName}`,
|
||||||
|
swipe: 'کشیدن کارت · C',
|
||||||
|
approaching: 'در حال نزدیک شدن به کارتخوان…',
|
||||||
|
success: 'پرداخت موفق ✓ محصول در حال تحویل است',
|
||||||
|
swipeHint: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C',
|
||||||
|
realPurchase: 'خرید در دنیای واقعی',
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
missionDone: 'مأموریت انجام شد',
|
||||||
|
/** @type {(xp: string, cash: string) => string} */
|
||||||
|
missionReward: (xp, cash) => `+${xp} XP · +${cash} پاداش`,
|
||||||
|
rankUp: 'ارتقای درجه',
|
||||||
|
findMore: 'دستگاههای بیشتری پیدا کن',
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
title: 'پایان عملیات',
|
||||||
|
again: 'عملیات دوباره',
|
||||||
|
keepExploring: 'ادامهٔ گشت در شهر',
|
||||||
|
success: 'عملیات موفق',
|
||||||
|
outOfBudget: 'پایان بودجه',
|
||||||
|
successHeading: 'عملیات مشهد کامل شد!',
|
||||||
|
budgetHeading: 'بودجهٔ امروز تمام شد',
|
||||||
|
successText:
|
||||||
|
'همهٔ مأموریتهای محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد بهزودی باز میشوند.',
|
||||||
|
budgetText: 'دیگر نمیتوانید از دستگاهها خرید کنید. امتیاز امروز:',
|
||||||
|
score: 'امتیاز',
|
||||||
|
rank: 'درجه',
|
||||||
|
purchases: 'خریدها',
|
||||||
|
places: 'مکانهای کشفشده',
|
||||||
|
missions: 'مأموریتها',
|
||||||
|
remaining: 'باقیمانده',
|
||||||
|
/** @type {(done: string, total: string) => string} */
|
||||||
|
outOf: (done, total) => `${done} از ${total}`,
|
||||||
|
},
|
||||||
|
showroom: {
|
||||||
|
/** @type {(model: string) => string} */
|
||||||
|
heading: (model) => `نمایشگاه کارخانه · ${model}`,
|
||||||
|
close: 'بستن معرفی دستگاه',
|
||||||
|
models: 'مدل دستگاه',
|
||||||
|
/** @type {(price: string) => string} */
|
||||||
|
price: (price) => `قیمت خود دستگاه: ${price}`,
|
||||||
|
displayOnly: 'این بخش معرفی محصول است؛ خرید واقعی انجام نمیشود و بودجهٔ هتومان تغییر نمیکند.',
|
||||||
|
askPrice: 'روبات، قیمتش چنده؟',
|
||||||
|
askFeatures: 'روبات، امکاناتش چیه؟',
|
||||||
|
robotAlt: 'روبات راهنمای نمایشگاه',
|
||||||
|
greeting: 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.',
|
||||||
|
allModels: 'روبات: سلام! قیمت و امکانات هر سه مدل را میتوانم توضیح بدهم.',
|
||||||
|
whatToKnow: 'روبات: دربارهٔ این مدل چی میخواهی بدانی؟',
|
||||||
|
order: 'ثبت درخواست خرید',
|
||||||
|
ordered: 'درخواست ثبت شد ✓',
|
||||||
|
contact: 'تماس با فروش',
|
||||||
|
/** @type {(model: string) => string} */
|
||||||
|
orderThanks: (model) =>
|
||||||
|
`درخواست خرید ${model} ثبت شد؛ کارشناس فروش بهزودی با شما تماس میگیرد.`,
|
||||||
|
/** @type {(answer: string) => string} */
|
||||||
|
robotSays: (answer) => `روبات: ${answer}`,
|
||||||
|
noImage: 'نمای سهبعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.',
|
||||||
|
views: { image: 'نمای اصلی', imageAlt: 'رنگ سفید', gif: 'نمای متحرک' },
|
||||||
|
/** @type {(model: string, price: string) => string} */
|
||||||
|
priceAnswer: (model, price) =>
|
||||||
|
`قیمت ${model}، ${price} است. این قیمت خود دستگاه به ریال است و از بودجهٔ بازی کم نمیشود.`,
|
||||||
|
/** @type {(model: string, description: string, features: string) => string} */
|
||||||
|
featuresAnswer: (model, description, features) =>
|
||||||
|
`${model}: ${description} امکانات: ${features}.`,
|
||||||
|
featureSeparator: '، ',
|
||||||
|
},
|
||||||
|
speakers: { robot: 'روبات', father: 'پدر', child: 'بچه' },
|
||||||
|
dialogue: {
|
||||||
|
showroomIntro: `سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدلهای کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.`,
|
||||||
|
childAsksFactory: `بابا، اینجا همون کارخونهایه که دستگاههای ${COMPANY.shortName} رو میسازه؟`,
|
||||||
|
fatherAnswersFactory:
|
||||||
|
'آره! سالن تولید اونوره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
|
||||||
|
offlineStart: `اینجا بوستان ملته. ماشین کنارمونه؛ بریم کارخونهٔ ${COMPANY.shortName} و از دستگاهش خرید کنیم!`,
|
||||||
|
offlineFollow: 'مسیر طلایی روی زمین رو دنبال کنیم!',
|
||||||
|
realStart: `هدف اول: تا کارخونهٔ ${COMPANY.shortName} بریم و از دستگاهش خرید کنیم.`,
|
||||||
|
childAsksArrival: 'اون ماشین مشکیه کیه داره میاد تو؟',
|
||||||
|
fatherAnswersArrival: 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.',
|
||||||
|
robotHello: `سلام! من روبات راهنمای ${COMPANY.shortName}م؛ تو دستگاه دفتر فنی زندگی میکنم!`,
|
||||||
|
/** @type {(company: string) => string} */
|
||||||
|
robotWelcome: (company) =>
|
||||||
|
`به ${company} خوش اومدین. بچههای همین دفتر نرمافزار دستگاهها رو مینویسن.`,
|
||||||
|
robotFirstBuy: 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.',
|
||||||
|
robotGoToPark:
|
||||||
|
'بعدش تابلوهای حیاط رو بخونین و با آقای محمدی آشنا شین؛ بعد با ماشین برین بوستان ملت. من توی همهٔ دستگاهها منتظرتونم!',
|
||||||
|
/** @type {(hours: string) => string} */
|
||||||
|
robotClosed: (hours) => `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hours}.`,
|
||||||
|
/** @type {(machine: string) => string} */
|
||||||
|
robotWelcomeBack: (machine) => `سلام دوباره! به دستگاه ${machine} خوش اومدین.`,
|
||||||
|
/** @type {(note: string) => string} */
|
||||||
|
robotNote: (note) => `${note}. برای خرید E رو بزنین.`,
|
||||||
|
robotAgain: 'باز هم خوش اومدین! برای خرید E رو بزنین.',
|
||||||
|
robotBye: 'خداحافظ! مسیر طلایی رو دنبال کنین.',
|
||||||
|
/** @type {(machine: string, hours: string) => string} */
|
||||||
|
targetClosed: (machine, hours) =>
|
||||||
|
`${machine} الان بسته است (${hours}). از نقشه مقصد دیگری انتخاب کن.`,
|
||||||
|
longWay: 'راه طولانیه؛ بهتره با ماشین بریم.',
|
||||||
|
carOn: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.',
|
||||||
|
stopFirst: 'اول نگه دار، بعد پیاده میشویم.',
|
||||||
|
tooFastToBuy: 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.',
|
||||||
|
/** @type {(hours: string) => string} */
|
||||||
|
machineClosed: (hours) => `این دستگاه الان بسته است. ساعت کاری: ${hours}.`,
|
||||||
|
childSwipes: 'بابا، کارت رو بده من بکشم!',
|
||||||
|
/** @type {(icon: string, product: string) => string} */
|
||||||
|
childGotIt: (icon, product) => `${icon} ${product} گرفتم! مرسی بابا.`,
|
||||||
|
/** @type {(mission: string) => string} */
|
||||||
|
nextMission: (mission) => `مأموریت بعدی: ${mission}`,
|
||||||
|
/** @type {(step: string) => string} */
|
||||||
|
nextStep: (step) => `خب! حالا: ${step}`,
|
||||||
|
/** @type {(machine: string, distance: string) => string} */
|
||||||
|
routeSet: (machine, distance) =>
|
||||||
|
`مسیر تا ${machine} رو گذاشتم؛ ${distance}. دنبال خط طلایی بریم.`,
|
||||||
|
},
|
||||||
|
ranks: ['تازهوارد', 'کاوشگر', 'راهبلد', 'شکارچی دستگاه', 'استاد نقشه', 'افسانهٔ مشهد'],
|
||||||
|
bonuses: {
|
||||||
|
purchase: 'خرید موفق',
|
||||||
|
newPlace: 'کشف مکان جدید',
|
||||||
|
combo: 'ترکیب نوشیدنی و خوراکی',
|
||||||
|
allCategories: 'همهٔ انواع مکان',
|
||||||
|
},
|
||||||
|
// Mission titles, keyed by mission id (game/missions/missions.js).
|
||||||
|
missions: {
|
||||||
|
firstBuy: 'اولین خرید در کارخانه',
|
||||||
|
aboutTour: `آشنایی با ${COMPANY.shortName}`,
|
||||||
|
productsTour: 'نمایشگاه محصولات',
|
||||||
|
parkGate: 'دستگاه ورودی بوستان ملت',
|
||||||
|
parkLake: 'بازدید از دستگاه کنار دریاچه',
|
||||||
|
parkSports: 'سفارش دستگاه برای زمین ورزشی',
|
||||||
|
parkTour: 'گشت دستگاههای بوستان',
|
||||||
|
explorer: 'کاشف شهر',
|
||||||
|
},
|
||||||
|
// One short line per step type, shown on the objective chip.
|
||||||
|
steps: {
|
||||||
|
/** @type {(place: string) => string} */
|
||||||
|
goto: (place) => `برو به ${place}`,
|
||||||
|
/** @type {(place: string) => string} */
|
||||||
|
locate: (place) => `${place} را روی نقشه پیدا کن`,
|
||||||
|
/** @type {(place: string) => string} */
|
||||||
|
inspect: () => 'کنار دستگاه بایست تا معرفیاش کنم',
|
||||||
|
/** @type {(place: string) => string} */
|
||||||
|
order: () => 'از کارت دستگاه سفارش بده',
|
||||||
|
/** @type {(place: string) => string} */
|
||||||
|
buy: (place) => `از ${place} خرید کن`,
|
||||||
|
/** @type {(done: string, count: string) => string} */
|
||||||
|
places: (done, count) => `از ${count} مکان شهر خرید کن (${done}/${count})`,
|
||||||
|
/** @type {(board: string) => string} */
|
||||||
|
read: (board) => `${board} را بخوان`,
|
||||||
|
/** @type {(person: string) => string} */
|
||||||
|
meet: (person) => `در دفتر مدیریت با ${person} آشنا شو`,
|
||||||
|
view: () => 'به نمایشگاه دستگاهها برو و مدلها را ببین',
|
||||||
|
ask: () => 'قیمت یا امکانات یک مدل را از روبات بپرس',
|
||||||
|
request: () => 'درخواست خرید یک دستگاه را ثبت کن',
|
||||||
|
allDone: 'همهٔ مأموریتها انجام شد',
|
||||||
|
},
|
||||||
|
// Names of the factory places missions point at (constants/factoryPoints.js).
|
||||||
|
points: {
|
||||||
|
'board-about': 'تابلوی دربارهٔ ما',
|
||||||
|
'board-contact': 'تابلوی تماس با ما',
|
||||||
|
'board-address': 'تابلوی آدرس کارخانه',
|
||||||
|
mohammadi: 'آقای محمدی',
|
||||||
|
showroom: 'نمایشگاه دستگاهها',
|
||||||
|
},
|
||||||
|
// Information boards in the factory yard.
|
||||||
|
boards: {
|
||||||
|
titles: { about: 'دربارهٔ ما', contact: 'تماس با ما', address: 'آدرس کارخانه' },
|
||||||
|
readMore: 'برای خواندن کامل، نزدیک شو و E را بزن',
|
||||||
|
bullet: '•',
|
||||||
|
},
|
||||||
|
machineCard: {
|
||||||
|
title: 'مشخصات دستگاه',
|
||||||
|
installedAt: 'محل نصب',
|
||||||
|
model: 'مدل',
|
||||||
|
price: 'قیمت',
|
||||||
|
order: 'ثبت درخواست سفارش',
|
||||||
|
ordered: 'درخواست ثبت شد ✓',
|
||||||
|
contact: 'تماس با فروش',
|
||||||
|
close: 'بستن',
|
||||||
|
orderThanks: 'درخواستت ثبت شد؛ کارشناس فروش با تو تماس میگیرد.',
|
||||||
|
robotIntro: (model) => `این دستگاه ${model} است. مشخصاتش را روی صفحه ببین!`,
|
||||||
|
},
|
||||||
|
|
||||||
|
snacks: {
|
||||||
|
juice: 'آبمیوه',
|
||||||
|
water: 'آب معدنی',
|
||||||
|
milk: 'شیر کاکائو',
|
||||||
|
tea: 'چای سرد',
|
||||||
|
lemonade: 'لیموناد',
|
||||||
|
biscuit: 'بیسکویت',
|
||||||
|
chocolate: 'شکلات',
|
||||||
|
nuts: 'آجیل',
|
||||||
|
cake: 'کیک',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
factory: 'کارخانه',
|
||||||
|
park: 'پارک',
|
||||||
|
university: 'دانشگاه',
|
||||||
|
school: 'مدرسه',
|
||||||
|
metro: 'ایستگاه مترو',
|
||||||
|
mall: 'مرکز خرید',
|
||||||
|
test: 'نقطهٔ آزمایشی GPS',
|
||||||
|
},
|
||||||
|
places: {
|
||||||
|
officeMachine: 'دستگاه دفتر فنی مهندسی',
|
||||||
|
/** @type {(address: string) => string} */
|
||||||
|
officeAddress: (address) => `${address}، ساختمان اداری، دفتر فنی مهندسی`,
|
||||||
|
officeNote: 'دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه',
|
||||||
|
/** @type {(n: string) => string} */
|
||||||
|
testPoint: (n) => `نقطهٔ آزمایشی ${n}`,
|
||||||
|
/** @type {(coords: string) => string} */
|
||||||
|
testAddress: (coords) => `کنار کارخانهٔ ${COMPANY.shortName} · ${coords}`,
|
||||||
|
testNote: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
|
||||||
|
comingSoon: 'خارج از محدودهٔ فعلی · بهزودی',
|
||||||
|
parkSign: 'بوستان ملت',
|
||||||
|
},
|
||||||
|
cities: {
|
||||||
|
mashhad: 'مشهد',
|
||||||
|
tehran: 'تهران',
|
||||||
|
isfahan: 'اصفهان',
|
||||||
|
shiraz: 'شیراز',
|
||||||
|
tabriz: 'تبریز',
|
||||||
|
ahvaz: 'اهواز',
|
||||||
|
kerman: 'کرمان',
|
||||||
|
rasht: 'رشت',
|
||||||
|
yazd: 'یزد',
|
||||||
|
zahedan: 'زاهدان',
|
||||||
|
kermanshah: 'کرمانشاه',
|
||||||
|
bandarAbbas: 'بندرعباس',
|
||||||
|
},
|
||||||
|
factory: {
|
||||||
|
readyToInstall: 'آمادهٔ نصب',
|
||||||
|
zones: [
|
||||||
|
['خمکاری', 'ورقکاری بدنهٔ دستگاه'],
|
||||||
|
['برش لیزر', 'برش ورق و قطعات'],
|
||||||
|
['رنگکاری', 'کابین رنگ پودری'],
|
||||||
|
['یخچالسازی', 'کمپرسور و عایقکاری'],
|
||||||
|
['مونتاژ', 'خط مونتاژ نهایی'],
|
||||||
|
['بستهبندی', 'ارسال به شهر'],
|
||||||
|
],
|
||||||
|
signs: {
|
||||||
|
engineering: ['دفتر فنی مهندسی', 'Engineering'],
|
||||||
|
accounting: ['واحد حسابداری', 'Accounting'],
|
||||||
|
management: ['مدیریت', 'آقای محمدی'],
|
||||||
|
nameplate: ['محمدی', 'مدیر شرکت'],
|
||||||
|
},
|
||||||
|
manager: { name: 'آقای محمدی', role: 'مدیر شرکت' },
|
||||||
|
staff: {
|
||||||
|
hamed: {
|
||||||
|
name: 'حامد',
|
||||||
|
lines: [
|
||||||
|
'سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاهها رو با React میسازم.',
|
||||||
|
'هر چی روی صفحهٔ دستگاه میبینین، از همین مانیتور اومده!',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ryan: {
|
||||||
|
name: 'رایان',
|
||||||
|
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاهها رو با Go مینویسم.'],
|
||||||
|
},
|
||||||
|
alireza: {
|
||||||
|
name: 'علیرضا',
|
||||||
|
lines: [
|
||||||
|
'سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم میکنم.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reza: {
|
||||||
|
name: 'رضا',
|
||||||
|
lines: [
|
||||||
|
'سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.',
|
||||||
|
'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mohammad: {
|
||||||
|
name: 'محمد',
|
||||||
|
lines: [`سلام! من محمدم. به دفتر فنی مهندسی ${COMPANY.shortName} خوش اومدین!`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accounting: {
|
||||||
|
label: 'واحد حسابداری',
|
||||||
|
lines: ['سلام! اینجا فروش همهٔ دستگاهها ثبت میشه. هر خریدی بکنین، فاکتورش اینجا میاد!'],
|
||||||
|
},
|
||||||
|
// Mr Mohammadi's welcome; he says it each time the family comes to his desk.
|
||||||
|
managerLines: [
|
||||||
|
`سلام، خوش اومدین! من محمدیام، مدیر ${COMPANY.shortName}.`,
|
||||||
|
COMPANY.about.history,
|
||||||
|
`چیزهایی که برامون مهمه: ${COMPANY.about.values.join('، ')}.`,
|
||||||
|
'امروز چهل نفر توی سالن تولید کار میکنن: خمکاری، برش، رنگ، یخچالسازی و مونتاژ.',
|
||||||
|
'مدلهای دستگاه رو توی نمایشگاه حیاط ببینین؛ روبات اونجا قیمتها رو میگه.',
|
||||||
|
],
|
||||||
|
truck: 'بابا ببین! کامیون داره دستگاههای بستهبندیشده رو بار میزنه که ببره شهر!',
|
||||||
|
// One comment per production area, spoken the first time the family walks in.
|
||||||
|
areaTalks: [
|
||||||
|
'اینجا خمکاریه؛ ورقهای فلزی بدنهٔ دستگاه رو با پرس خم میکنن.',
|
||||||
|
'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ میبُره.',
|
||||||
|
`این کابین رنگه؛ بدنهها اینجا رنگ برند ${COMPANY.shortName} رو میگیرن.`,
|
||||||
|
'یخچالسازی! برای همینه که نوشیدنیهای دستگاه همیشه خنکن.',
|
||||||
|
'خط مونتاژ: روباتها و کارگرها قطعهها رو کنار هم میذارن و دستگاه کامل میشه.',
|
||||||
|
],
|
||||||
|
// Scenery text painted on office screens and boards.
|
||||||
|
decor: {
|
||||||
|
invoices: [
|
||||||
|
'فاکتور دستگاه مبلغ',
|
||||||
|
'۱۰۴۲ بوستان ملت ۴۸۰',
|
||||||
|
'۱۰۴۳ دانشگاه فردوسی ۳۲۰',
|
||||||
|
'۱۰۴۴ مترو آزادی ۲۹۰',
|
||||||
|
'۱۰۴۵ مشهد مال ۶۱۰',
|
||||||
|
'۱۰۴۶ مدرسه راحیل ۱۸۰',
|
||||||
|
'جمع ۱۸۸۰',
|
||||||
|
],
|
||||||
|
dashboard: [
|
||||||
|
'فروش امروز: ۱۲۸ دستگاه',
|
||||||
|
'تولید هفته: ۴۶ دستگاه',
|
||||||
|
'ارسال شده: ۳۸',
|
||||||
|
'',
|
||||||
|
'▇▇▇▇▇▇▇▇▇▇▇ خم',
|
||||||
|
'▇▇▇▇▇▇▇▇▇ برش',
|
||||||
|
'▇▇▇▇▇▇▇ رنگ',
|
||||||
|
],
|
||||||
|
sprintTitle: 'اسپرینت ۱۴: دستگاه نسل جدید',
|
||||||
|
sprintItems: ['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+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,19 @@
|
|||||||
|
import { InfoSheet } from './startScreen/InfoSheet.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, actions: object}} AboutBoardSheetProps */
|
||||||
|
/**
|
||||||
|
* Full text of the information board the player is reading in the factory yard. Reuses the start
|
||||||
|
* screen's company sections, so boards and the landing page always say the same thing.
|
||||||
|
* @param {AboutBoardSheetProps} props - Interface snapshots and actions.
|
||||||
|
* @returns {import('react').ReactElement} Sheet.
|
||||||
|
*/
|
||||||
|
export function AboutBoardSheet({ nodes, actions }) {
|
||||||
|
const board = nodes['about-board'] ?? {};
|
||||||
|
return (
|
||||||
|
<InfoSheet
|
||||||
|
id="about-board"
|
||||||
|
section={board.open ? board.topic : null}
|
||||||
|
onClose={() => actions.emit('about-board-close')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { COMPANY } from '@/config/company.js';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { AboutBoardSheet } from './AboutBoardSheet.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');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AboutBoardSheet', () => {
|
||||||
|
it('shows the board topic in full and reports closing', () => {
|
||||||
|
const actions = { emit: vi.fn() };
|
||||||
|
const nodes = { 'about-board': { open: true, topic: 'contact' } };
|
||||||
|
render(<AboutBoardSheet nodes={nodes} actions={actions} />);
|
||||||
|
expect(screen.getByRole('heading', { name: strings.landing.sections.contact })).toBeVisible();
|
||||||
|
expect(screen.getByText(COMPANY.contact.email)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: strings.landing.close }));
|
||||||
|
expect(actions.emit).toHaveBeenCalledWith('about-board-close');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing inside while closed', () => {
|
||||||
|
render(<AboutBoardSheet nodes={{}} actions={{ emit: vi.fn() }} />);
|
||||||
|
expect(screen.queryByText(COMPANY.contact.email)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
/** @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="./robot/front.webp"
|
|
||||||
alt="روبات راهنمای روبات مارکت"
|
|
||||||
width="136"
|
|
||||||
height="223"
|
|
||||||
/>
|
|
||||||
<small className="tag">{'عملیات ۰۱ · خراسان رضوی'}</small>
|
|
||||||
<h1>{'عملیات مشهد'}</h1>
|
|
||||||
<p className="sub">{'روبات مارکت · شکار دستگاههای وندینگ'}</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')}
|
|
||||||
>
|
|
||||||
{'شروع عملیات'}
|
|
||||||
</GameNode>
|
|
||||||
<div className="brief-actions">
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="real-mode"
|
|
||||||
type="button"
|
|
||||||
className="ghost"
|
|
||||||
model={nodes['real-mode']}
|
|
||||||
onAction={() => actions.emit('real-mode')}
|
|
||||||
>
|
|
||||||
{'📍 بازی در دنیای واقعی (GPS)'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="install"
|
|
||||||
type="button"
|
|
||||||
className="ghost"
|
|
||||||
hidden
|
|
||||||
model={nodes['install']}
|
|
||||||
onAction={() => actions.emit('install')}
|
|
||||||
>
|
|
||||||
{'⬇ نصب بازی روی گوشی'}
|
|
||||||
</GameNode>
|
|
||||||
</div>
|
|
||||||
<p className="keys">
|
|
||||||
{
|
|
||||||
'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</GameNode>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
import { ViewLists } from './ViewLists.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
|
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
|
||||||
/** Presents the EndDialog view using plain models and semantic native elements.
|
/** Presents the EndDialog view using plain models and semantic native elements.
|
||||||
* @param {EndDialogProps} props - Interface snapshots and callbacks.
|
* @param {EndDialogProps} props - Interface snapshots and callbacks.
|
||||||
@@ -10,7 +11,7 @@ export function EndDialog({ nodes, actions }) {
|
|||||||
<>
|
<>
|
||||||
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
|
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
|
||||||
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
|
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
|
||||||
{'پایان عملیات'}
|
{strings.end.title}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
|
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
|
||||||
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
|
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
|
||||||
@@ -29,7 +30,7 @@ export function EndDialog({ nodes, actions }) {
|
|||||||
model={nodes['again']}
|
model={nodes['again']}
|
||||||
onAction={() => actions.emit('again')}
|
onAction={() => actions.emit('again')}
|
||||||
>
|
>
|
||||||
{'عملیات دوباره'}
|
{strings.end.again}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
@@ -39,7 +40,7 @@ export function EndDialog({ nodes, actions }) {
|
|||||||
model={nodes['continue']}
|
model={nodes['continue']}
|
||||||
onAction={() => actions.emit('continue')}
|
onAction={() => actions.emit('continue')}
|
||||||
>
|
>
|
||||||
{'ادامهٔ گشت در شهر'}
|
{strings.end.keepExploring}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</div>
|
</div>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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';
|
||||||
|
import { MachineCard } from './MachineCard.jsx';
|
||||||
|
import { AboutBoardSheet } from './AboutBoardSheet.jsx';
|
||||||
/** Composes independently managed game dialogs. */
|
/** Composes independently managed game dialogs. */
|
||||||
export function GameDialogs(props) {
|
export function GameDialogs(props) {
|
||||||
return (
|
return (
|
||||||
@@ -18,6 +21,9 @@ export function GameDialogs(props) {
|
|||||||
<InstallDialog {...props} />
|
<InstallDialog {...props} />
|
||||||
<UpdateNotice {...props} />
|
<UpdateNotice {...props} />
|
||||||
<EndDialog {...props} />
|
<EndDialog {...props} />
|
||||||
|
<GpsFallbackDialog {...props} />
|
||||||
|
<MachineCard {...props} />
|
||||||
|
<AboutBoardSheet {...props} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-46
@@ -1,76 +1,73 @@
|
|||||||
import { LocationStatus } from './LocationStatus.jsx';
|
import { useState } from 'react';
|
||||||
import { MissionList } from './MissionList.jsx';
|
|
||||||
import { StatusBar } from './StatusBar.jsx';
|
|
||||||
import { RankProgress } from './RankProgress.jsx';
|
|
||||||
import { GameToolbar } from './GameToolbar.jsx';
|
|
||||||
import { TouchControls } from './TouchControls.jsx';
|
|
||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { TouchControls } from './TouchControls.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
import { ViewLists } from './ViewLists.jsx';
|
||||||
/** @typedef {{nodes:object,actions:object}} GameHudProps */
|
import { FloatingMenu } from './hud/FloatingMenu.jsx';
|
||||||
/** Presents the GameHud view using plain models and semantic native elements.
|
import { ObjectiveChip } from './hud/ObjectiveChip.jsx';
|
||||||
* @param {GameHudProps} props - Interface snapshots and callbacks.
|
import { SpeedChip } from './hud/SpeedChip.jsx';
|
||||||
* @returns {import('react').ReactElement} Game panel.
|
import { HudSheets } from './hud/HudSheets.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, actions: object}} GameHudProps */
|
||||||
|
/**
|
||||||
|
* Minimal in-game interface: the floating menu, the current objective and the controls are the
|
||||||
|
* only things always on screen; everything else is contextual (prompt, dialogue, awards, speed
|
||||||
|
* while driving) or lives in a sheet behind the menu.
|
||||||
|
* @param {GameHudProps} props - Interface snapshots and actions.
|
||||||
|
* @returns {import('react').ReactElement} HUD.
|
||||||
*/
|
*/
|
||||||
export function GameHud({ nodes, actions }) {
|
export function GameHud({ nodes, actions }) {
|
||||||
|
const [sheet, setSheet] = useState(null);
|
||||||
|
const emit = (name, value) => actions.emit(name, value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<GameNode as="div" id="hud" hidden model={nodes.hud}>
|
||||||
<GameNode as="div" id="hud" hidden model={nodes['hud']}>
|
<FloatingMenu nodes={nodes} actions={actions} onOpenSheet={setSheet} />
|
||||||
<GameNode as="canvas" id="compass" width="560" height="44" model={nodes['compass']} />
|
<ObjectiveChip nodes={nodes} onOpen={() => setSheet('missions')} />
|
||||||
|
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']} />
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
id="minimap-button"
|
id="minimap-button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="باز کردن نقشهٔ شهر (M)"
|
hidden
|
||||||
|
aria-label={strings.hud.openMap}
|
||||||
model={nodes['minimap-button']}
|
model={nodes['minimap-button']}
|
||||||
onAction={() => actions.emit('minimap-button')}
|
onAction={() => actions.emit('minimap-button')}
|
||||||
>
|
>
|
||||||
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes['minimap']} />
|
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes.minimap} />
|
||||||
<span className="map-hint">{'نقشه · M'}</span>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<LocationStatus nodes={nodes} actions={actions} />
|
<GameNode as="div" id="waypoint" hidden model={nodes.waypoint}>
|
||||||
<MissionList nodes={nodes} actions={actions} />
|
|
||||||
<GameNode as="div" id="waypoint" hidden model={nodes['waypoint']}>
|
|
||||||
<i></i>
|
<i></i>
|
||||||
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']}></GameNode>
|
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']} />
|
||||||
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']}></GameNode>
|
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']} />
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
id="prompt"
|
id="prompt"
|
||||||
hidden
|
hidden
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="تعامل با بازی"
|
aria-label={strings.hud.interact}
|
||||||
model={nodes['prompt']}
|
model={nodes.prompt}
|
||||||
onAction={() => actions.emit('prompt')}
|
onAction={() => actions.emit('prompt')}
|
||||||
></GameNode>
|
/>
|
||||||
<GameNode as="div" id="subtitle" hidden model={nodes['subtitle']}>
|
<GameNode as="div" id="subtitle" hidden model={nodes.subtitle}>
|
||||||
<GameNode as="img" id="subtitle-face" alt="" hidden model={nodes['subtitle-face']} />
|
<GameNode as="img" id="subtitle-face" alt="" hidden model={nodes['subtitle-face']} />
|
||||||
<GameNode as="b" id="subtitle-speaker" model={nodes['subtitle-speaker']}></GameNode>
|
<GameNode as="b" id="subtitle-speaker" model={nodes['subtitle-speaker']} />
|
||||||
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']}></GameNode>
|
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']} />
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode as="div" id="awards" aria-live="polite" model={nodes['awards']}>
|
<GameNode as="div" id="awards" aria-live="polite" model={nodes.awards}>
|
||||||
<ViewLists
|
<ViewLists id="awards" model={nodes.awards} onAction={emit} />
|
||||||
id="awards"
|
|
||||||
model={nodes['awards']}
|
|
||||||
onAction={(name, value) => actions.emit(name, value)}
|
|
||||||
/>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode as="div" id="banner" hidden model={nodes['banner']}>
|
<GameNode as="div" id="banner" hidden model={nodes.banner}>
|
||||||
<ViewLists
|
<ViewLists id="banner" model={nodes.banner} onAction={emit} />
|
||||||
id="banner"
|
|
||||||
model={nodes['banner']}
|
|
||||||
onAction={(name, value) => actions.emit(name, value)}
|
|
||||||
/>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<StatusBar nodes={nodes} actions={actions} />
|
<SpeedChip nodes={nodes} />
|
||||||
<RankProgress nodes={nodes} actions={actions} />
|
|
||||||
<GameToolbar nodes={nodes} actions={actions} />
|
|
||||||
<TouchControls nodes={nodes} actions={actions} />
|
<TouchControls nodes={nodes} actions={actions} />
|
||||||
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
|
<GameNode as="div" id="crosshair" model={nodes.crosshair} />
|
||||||
<GameNode as="small" id="attribution" model={nodes['attribution']}>
|
<GameNode as="small" id="attribution" model={nodes.attribution}>
|
||||||
{'نقشه: © مشارکتکنندگان OpenStreetMap'}
|
{strings.app.osmCredit}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
|
<HudSheets sheet={sheet} nodes={nodes} actions={actions} onClose={() => setSheet(null)} />
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 +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 { BriefingPanel } from './BriefingPanel.jsx';
|
import { GameLoader } from './GameLoader.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';
|
||||||
|
|
||||||
/** 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);
|
||||||
@@ -40,7 +46,8 @@ export function GamePage() {
|
|||||||
return (
|
return (
|
||||||
<main ref={root}>
|
<main ref={root}>
|
||||||
<GameSurfaces {...props} />
|
<GameSurfaces {...props} />
|
||||||
<BriefingPanel {...props} />
|
<GameLoader {...props} />
|
||||||
|
<StartScreen {...props} />
|
||||||
<GameHud {...props} />
|
<GameHud {...props} />
|
||||||
<GameDialogs {...props} />
|
<GameDialogs {...props} />
|
||||||
{error && (
|
{error && (
|
||||||
@@ -49,9 +56,9 @@ export function GamePage() {
|
|||||||
role="alert"
|
role="alert"
|
||||||
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
|
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
|
||||||
>
|
>
|
||||||
<p>بازی اجرا نشد: {error}</p>
|
<p>{strings.app.failed(error)}</p>
|
||||||
<button type="button" onClick={() => location.reload()}>
|
<button type="button" onClick={() => location.reload()}>
|
||||||
تلاش دوباره
|
{strings.app.retry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,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']}>
|
|
||||||
{'در حال ساختن شهر مشهد…'}
|
|
||||||
</GameNode>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
|
|
||||||
/** Presents the GameToolbar using the current interface snapshot. */
|
|
||||||
export function GameToolbar({ nodes, actions }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="toolbar" model={nodes['toolbar']}>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="showroom-open"
|
|
||||||
type="button"
|
|
||||||
hidden
|
|
||||||
model={nodes['showroom-open']}
|
|
||||||
onAction={() => actions.emit('showroom-open')}
|
|
||||||
>
|
|
||||||
مدلهای کارخانه · Q
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="gps-toggle"
|
|
||||||
type="button"
|
|
||||||
model={nodes['gps-toggle']}
|
|
||||||
onAction={() => actions.emit('gps-toggle')}
|
|
||||||
>
|
|
||||||
{'📍 دنیای واقعی'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="ar-toggle"
|
|
||||||
type="button"
|
|
||||||
hidden
|
|
||||||
model={nodes['ar-toggle']}
|
|
||||||
onAction={() => actions.emit('ar-toggle')}
|
|
||||||
>
|
|
||||||
{'📷 دوربین'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="stable-toggle"
|
|
||||||
type="button"
|
|
||||||
className="on"
|
|
||||||
hidden
|
|
||||||
model={nodes['stable-toggle']}
|
|
||||||
onAction={() => actions.emit('stable-toggle')}
|
|
||||||
>
|
|
||||||
{'🎯 پایدار: روشن'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="sound"
|
|
||||||
type="button"
|
|
||||||
aria-pressed="false"
|
|
||||||
model={nodes['sound']}
|
|
||||||
onAction={() => actions.emit('sound')}
|
|
||||||
>
|
|
||||||
{'صدا: خاموش'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="quality"
|
|
||||||
type="button"
|
|
||||||
model={nodes['quality']}
|
|
||||||
onAction={() => actions.emit('quality')}
|
|
||||||
>
|
|
||||||
{'گرافیک'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="restart"
|
|
||||||
type="button"
|
|
||||||
model={nodes['restart']}
|
|
||||||
onAction={() => actions.emit('restart')}
|
|
||||||
>
|
|
||||||
{'شروع دوباره'}
|
|
||||||
</GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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}} InstallDialogProps */
|
/** @typedef {{nodes:object,actions:object}} InstallDialogProps */
|
||||||
/** Presents the InstallDialog view using plain models and semantic native elements.
|
/** Presents the InstallDialog view using plain models and semantic native elements.
|
||||||
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
|
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
|
||||||
@@ -13,21 +14,19 @@ export function InstallDialog({ nodes, actions }) {
|
|||||||
model={nodes['ios-install']}
|
model={nodes['ios-install']}
|
||||||
onClose={() => actions.close('ios-install')}
|
onClose={() => actions.close('ios-install')}
|
||||||
>
|
>
|
||||||
<h2>{'نصب روی آیفون'}</h2>
|
<h2>{strings.install.title}</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
{'در Safari دکمهٔ '}
|
{strings.install.shareBefore}
|
||||||
<b>{'Share'}</b>
|
<b>{'Share'}</b>
|
||||||
{' (مربع با فلش رو به بالا) را بزنید.'}
|
{strings.install.shareAfter}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{'گزینهٔ '}
|
{strings.install.addBefore}
|
||||||
<b>{'Add to Home Screen'}</b>
|
<b>{'Add to Home Screen'}</b>
|
||||||
{' را انتخاب کنید.'}
|
{strings.install.addAfter}
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
{'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار میگیرد و آفلاین هم اجرا میشود.'}
|
|
||||||
</li>
|
</li>
|
||||||
|
<li>{strings.install.note}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
@@ -36,7 +35,7 @@ export function InstallDialog({ nodes, actions }) {
|
|||||||
model={nodes['ios-close']}
|
model={nodes['ios-close']}
|
||||||
onAction={() => actions.emit('ios-close')}
|
onAction={() => actions.emit('ios-close')}
|
||||||
>
|
>
|
||||||
{'متوجه شدم'}
|
{strings.common.gotIt}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
|
|
||||||
/** Presents the LocationStatus using the current interface snapshot. */
|
|
||||||
export function LocationStatus({ nodes }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="location" model={nodes['location']}>
|
|
||||||
<GameNode as="span" id="street" model={nodes['street']}>
|
|
||||||
{'مشهد'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode as="small" id="mode-label" model={nodes['mode-label']}>
|
|
||||||
{'پیاده'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']}></GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { COMPANY } from '@/config/company.js';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, actions: object}} MachineCardProps */
|
||||||
|
/**
|
||||||
|
* Card for an installed machine: where it stands, which model it is, key specs, and an order
|
||||||
|
* request button (a mission step) with the sales contact.
|
||||||
|
* @param {MachineCardProps} props - Interface snapshots and actions.
|
||||||
|
* @returns {import('react').ReactElement} Dialog.
|
||||||
|
*/
|
||||||
|
export function MachineCard({ nodes, actions }) {
|
||||||
|
const card = nodes['machine-card'] ?? {};
|
||||||
|
const model = showroomModelById(card.modelId);
|
||||||
|
const text = strings.machineCard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameNode
|
||||||
|
as="dialog"
|
||||||
|
id="machine-card"
|
||||||
|
aria-labelledby="machine-card-title"
|
||||||
|
className="info-sheet"
|
||||||
|
model={{ open: card.open }}
|
||||||
|
onClose={() => actions.emit('machine-card-close')}
|
||||||
|
>
|
||||||
|
<h2 id="machine-card-title" className="mb-3 text-lg font-bold">
|
||||||
|
{model?.name ?? text.title}
|
||||||
|
</h2>
|
||||||
|
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt className="opacity-70">{text.installedAt}</dt>
|
||||||
|
<dd>{card.site}</dd>
|
||||||
|
<dt className="opacity-70">{text.price}</dt>
|
||||||
|
<dd className="text-gold">{model && formatRial(model.priceNumeric)}</dd>
|
||||||
|
</dl>
|
||||||
|
<p className="opacity-90">{model?.description}</p>
|
||||||
|
<ul className="my-3 list-inside list-disc">
|
||||||
|
{model?.highlights.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="machine-order"
|
||||||
|
className="start-option start-option-primary"
|
||||||
|
disabled={card.ordered}
|
||||||
|
onClick={() => actions.emit('machine-order')}
|
||||||
|
>
|
||||||
|
{card.ordered ? text.ordered : text.order}
|
||||||
|
</button>
|
||||||
|
<a className="start-chip" href={`tel:${COMPANY.contact.phone}`}>
|
||||||
|
{text.contact}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="start-chip"
|
||||||
|
onClick={() => actions.emit('machine-card-close')}
|
||||||
|
>
|
||||||
|
{text.close}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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 { MachineCard } from './MachineCard.jsx';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
// jsdom has no modal dialogs; open them by setting the attribute.
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute('open', '');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the card for the 60-channel model with an actions spy.
|
||||||
|
* @param {object} card - Extra card fields.
|
||||||
|
* @returns {{emit: import('vitest').Mock}} Actions spy.
|
||||||
|
*/
|
||||||
|
function renderCard(card = {}) {
|
||||||
|
const actions = { emit: vi.fn() };
|
||||||
|
const nodes = { 'machine-card': { open: true, modelId: 'rm-60', site: 'زمین ورزشی', ...card } };
|
||||||
|
render(<MachineCard nodes={nodes} actions={actions} />);
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MachineCard', () => {
|
||||||
|
it('shows the installed model with its site and specs, and sends an order request', () => {
|
||||||
|
const actions = renderCard();
|
||||||
|
const product = COMPANY.products.find((p) => p.id === 'rm-60');
|
||||||
|
expect(screen.getByRole('heading', { name: product.name })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('زمین ورزشی')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(product.highlights[0])).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: strings.machineCard.order }));
|
||||||
|
expect(actions.emit).toHaveBeenCalledWith('machine-order');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms an order and does not let it be sent twice', () => {
|
||||||
|
renderCard({ ordered: true });
|
||||||
|
expect(screen.getByRole('button', { name: strings.machineCard.ordered })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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}} MachineDetailsProps */
|
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
|
||||||
/** Presents the MachineDetails using the current interface snapshot. */
|
/** Presents the MachineDetails using the current interface snapshot. */
|
||||||
export function MachineDetails({ nodes, actions }) {
|
export function MachineDetails({ nodes, actions }) {
|
||||||
@@ -15,7 +16,7 @@ export function MachineDetails({ nodes, actions }) {
|
|||||||
model={nodes['route-button']}
|
model={nodes['route-button']}
|
||||||
onAction={() => actions.emit('route-button')}
|
onAction={() => actions.emit('route-button')}
|
||||||
>
|
>
|
||||||
{'مسیریابی تا اینجا'}
|
{strings.map.route}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MachineDetails } from './MachineDetails.jsx';
|
import { MachineDetails } from './MachineDetails.jsx';
|
||||||
import { MachineList } from './MachineList.jsx';
|
import { MachineList } from './MachineList.jsx';
|
||||||
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}} MapDialogProps */
|
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
|
||||||
/** Presents the MapDialog view using plain models and semantic native elements.
|
/** Presents the MapDialog view using plain models and semantic native elements.
|
||||||
* @param {MapDialogProps} props - Interface snapshots and callbacks.
|
* @param {MapDialogProps} props - Interface snapshots and callbacks.
|
||||||
@@ -27,7 +28,7 @@ export function MapDialog({ nodes, actions }) {
|
|||||||
model={nodes['tab-city']}
|
model={nodes['tab-city']}
|
||||||
onAction={() => actions.emit('tab-city')}
|
onAction={() => actions.emit('tab-city')}
|
||||||
>
|
>
|
||||||
{'مشهد'}
|
{strings.common.mashhad}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
@@ -39,7 +40,7 @@ export function MapDialog({ nodes, actions }) {
|
|||||||
model={nodes['tab-iran']}
|
model={nodes['tab-iran']}
|
||||||
onAction={() => actions.emit('tab-iran')}
|
onAction={() => actions.emit('tab-iran')}
|
||||||
>
|
>
|
||||||
{'ایران'}
|
{strings.common.iran}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</div>
|
</div>
|
||||||
<GameNode
|
<GameNode
|
||||||
@@ -47,7 +48,7 @@ export function MapDialog({ nodes, actions }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="close"
|
className="close"
|
||||||
id="map-close"
|
id="map-close"
|
||||||
aria-label="بستن"
|
aria-label={strings.map.close}
|
||||||
model={nodes['map-close']}
|
model={nodes['map-close']}
|
||||||
onAction={() => actions.emit('map-close')}
|
onAction={() => actions.emit('map-close')}
|
||||||
>
|
>
|
||||||
@@ -58,12 +59,10 @@ export function MapDialog({ nodes, actions }) {
|
|||||||
<div className="map-view">
|
<div className="map-view">
|
||||||
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
|
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
|
||||||
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
|
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
|
||||||
<p className="map-help">
|
<p className="map-help">{strings.map.help}</p>
|
||||||
{'کشیدن: جابهجایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<aside className="map-side">
|
<aside className="map-side">
|
||||||
<h3>{'دستگاههای روبات مارکت'}</h3>
|
<h3>{strings.map.machines}</h3>
|
||||||
<MachineDetails nodes={nodes} actions={actions} />
|
<MachineDetails nodes={nodes} actions={actions} />
|
||||||
<MachineList nodes={nodes} actions={actions} />
|
<MachineList nodes={nodes} actions={actions} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} MissionListProps */
|
|
||||||
/** Presents the MissionList using the current interface snapshot. */
|
|
||||||
export function MissionList({ nodes, actions }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="aside" id="objectives" model={nodes['objectives']}>
|
|
||||||
<h2>{'مأموریتها'}</h2>
|
|
||||||
<GameNode as="ol" id="mission-list" model={nodes['mission-list']}>
|
|
||||||
<ViewLists
|
|
||||||
id="mission-list"
|
|
||||||
model={nodes['mission-list']}
|
|
||||||
onAction={(name, value) => actions.emit(name, value)}
|
|
||||||
/>
|
|
||||||
</GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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}} PaymentPanelProps */
|
/** @typedef {{nodes:object,actions:object}} PaymentPanelProps */
|
||||||
/** Presents the PaymentPanel view using plain models and semantic native elements.
|
/** Presents the PaymentPanel view using plain models and semantic native elements.
|
||||||
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
|
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
|
||||||
@@ -8,7 +9,7 @@ export function PaymentPanel({ nodes, actions }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
|
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
|
||||||
<small>{'کارتخوان روبات مارکت'}</small>
|
<small>{strings.payment.reader}</small>
|
||||||
<GameNode as="h2" id="payment-title" model={nodes['payment-title']}></GameNode>
|
<GameNode as="h2" id="payment-title" model={nodes['payment-title']}></GameNode>
|
||||||
<GameNode as="p" id="payment-step" model={nodes['payment-step']}></GameNode>
|
<GameNode as="p" id="payment-step" model={nodes['payment-step']}></GameNode>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
@@ -20,7 +21,7 @@ export function PaymentPanel({ nodes, actions }) {
|
|||||||
model={nodes['swipe']}
|
model={nodes['swipe']}
|
||||||
onAction={() => actions.emit('swipe')}
|
onAction={() => actions.emit('swipe')}
|
||||||
>
|
>
|
||||||
{'کشیدن کارت · C'}
|
{strings.payment.swipe}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
@@ -30,7 +31,7 @@ export function PaymentPanel({ nodes, actions }) {
|
|||||||
model={nodes['cancel-payment']}
|
model={nodes['cancel-payment']}
|
||||||
onAction={() => actions.emit('cancel-payment')}
|
onAction={() => actions.emit('cancel-payment')}
|
||||||
>
|
>
|
||||||
{'انصراف'}
|
{strings.common.cancel}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</div>
|
</div>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
|
|
||||||
/** Presents the RankProgress using the current interface snapshot. */
|
|
||||||
export function RankProgress({ nodes }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="rank" model={nodes['rank']}>
|
|
||||||
<GameNode as="b" id="rank-name" model={nodes['rank-name']}>
|
|
||||||
{'تازهوارد'}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode as="span" id="xp" model={nodes['xp']}>
|
|
||||||
{'۰ XP'}
|
|
||||||
</GameNode>
|
|
||||||
<div className="bar">
|
|
||||||
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
|
|
||||||
</div>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { robotFace } from '@/config/assets.js';
|
||||||
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
|
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
|
||||||
/** Presents the RealIntroDialog view using plain models and semantic native elements.
|
/** Presents the RealIntroDialog view using plain models and semantic native elements.
|
||||||
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
|
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
|
||||||
@@ -13,18 +15,13 @@ export function RealIntroDialog({ nodes, actions }) {
|
|||||||
model={nodes['real-intro']}
|
model={nodes['real-intro']}
|
||||||
onClose={() => actions.close('real-intro')}
|
onClose={() => actions.close('real-intro')}
|
||||||
>
|
>
|
||||||
<img src="./robot/serious.webp" alt="" width="72" height="68" />
|
<img src={robotFace('serious')} alt="" width="72" height="68" />
|
||||||
<h2>{'بازی در دنیای واقعی'}</h2>
|
<h2>{strings.realIntro.title}</h2>
|
||||||
<p>
|
<p>{strings.realIntro.body}</p>
|
||||||
{
|
|
||||||
'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده میشود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، میتوانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>{'هنگام راه رفتن به خیابان و ماشینها نگاه کنید، نه به صفحه.'}</li>
|
{strings.realIntro.safety.map((line) => (
|
||||||
<li>{'بچهها فقط همراه یک بزرگتر بازی کنند.'}</li>
|
<li key={line}>{line}</li>
|
||||||
<li>{'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال میشود.'}</li>
|
))}
|
||||||
<li>{'موقعیت شما فقط روی همین گوشی میماند و جایی ارسال نمیشود.'}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<GameNode
|
<GameNode
|
||||||
@@ -34,7 +31,7 @@ export function RealIntroDialog({ nodes, actions }) {
|
|||||||
model={nodes['real-go']}
|
model={nodes['real-go']}
|
||||||
onAction={() => actions.emit('real-go')}
|
onAction={() => actions.emit('real-go')}
|
||||||
>
|
>
|
||||||
{'قبول دارم، شروع کن'}
|
{strings.realIntro.accept}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
@@ -44,7 +41,7 @@ export function RealIntroDialog({ nodes, actions }) {
|
|||||||
model={nodes['real-cancel']}
|
model={nodes['real-cancel']}
|
||||||
onAction={() => actions.emit('real-cancel')}
|
onAction={() => actions.emit('real-cancel')}
|
||||||
>
|
>
|
||||||
{'انصراف'}
|
{strings.common.cancel}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</div>
|
</div>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
import { ViewLists } from './ViewLists.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
|
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
|
||||||
/** Presents the ShopDialog view using plain models and semantic native elements.
|
/** Presents the ShopDialog view using plain models and semantic native elements.
|
||||||
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
|
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
|
||||||
@@ -20,7 +21,7 @@ export function ShopDialog({ nodes, actions }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="close"
|
className="close"
|
||||||
id="shop-close"
|
id="shop-close"
|
||||||
aria-label="بستن"
|
aria-label={strings.shop.close}
|
||||||
model={nodes['shop-close']}
|
model={nodes['shop-close']}
|
||||||
onAction={() => actions.emit('shop-close')}
|
onAction={() => actions.emit('shop-close')}
|
||||||
>
|
>
|
||||||
@@ -35,9 +36,7 @@ export function ShopDialog({ nodes, actions }) {
|
|||||||
onAction={(name, value) => actions.emit(name, value)}
|
onAction={(name, value) => actions.emit(name, value)}
|
||||||
/>
|
/>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<p className="rate">
|
<p className="rate">{strings.shop.note}</p>
|
||||||
{'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشیاند و پرداخت واقعی انجام نمیشود.'}
|
|
||||||
</p>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
|
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
|
||||||
|
import { robotFace } from '@/config/assets.js';
|
||||||
|
import { COMPANY } from '@/config/company.js';
|
||||||
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
|
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
|
||||||
import { ShowroomImage } from './ShowroomImage.jsx';
|
import { ShowroomImage } from './ShowroomImage.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
|
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
|
||||||
/** Presents the factory sample catalog and questions answered by the robot host.
|
/** Presents the factory sample catalog and questions answered by the robot host.
|
||||||
@@ -11,6 +14,7 @@ import { ShowroomImage } from './ShowroomImage.jsx';
|
|||||||
export function ShowroomDialog({ nodes, actions }) {
|
export function ShowroomDialog({ nodes, actions }) {
|
||||||
const model = showroomModelById(nodes.showroom?.modelId);
|
const model = showroomModelById(nodes.showroom?.modelId);
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
const isOrdered = nodes.showroom.ordered?.includes(model.id) ?? false;
|
||||||
return (
|
return (
|
||||||
<GameNode
|
<GameNode
|
||||||
as="dialog"
|
as="dialog"
|
||||||
@@ -22,17 +26,17 @@ export function ShowroomDialog({ nodes, actions }) {
|
|||||||
>
|
>
|
||||||
<header className="mb-5 flex items-center justify-between gap-4">
|
<header className="mb-5 flex items-center justify-between gap-4">
|
||||||
<h2 id="showroom-title" className="text-xl font-bold">
|
<h2 id="showroom-title" className="text-xl font-bold">
|
||||||
نمایشگاه کارخانه · {model.name}
|
{strings.showroom.heading(model.name)}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="بستن معرفی دستگاه"
|
aria-label={strings.showroom.close}
|
||||||
onClick={() => actions.close('showroom')}
|
onClick={() => actions.close('showroom')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
|
<nav aria-label={strings.showroom.models} className="mb-5 flex flex-wrap gap-3">
|
||||||
{SHOWROOM_CATALOG.map((item) => (
|
{SHOWROOM_CATALOG.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -49,16 +53,14 @@ export function ShowroomDialog({ nodes, actions }) {
|
|||||||
<ShowroomImage key={model.id} model={model} />
|
<ShowroomImage key={model.id} model={model} />
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
|
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
|
||||||
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
|
<p className="mb-3 text-gold">{strings.showroom.price(formatRial(model.priceNumeric))}</p>
|
||||||
<p>{model.description}</p>
|
<p>{model.description}</p>
|
||||||
<ul className="my-4 list-inside list-disc">
|
<ul className="my-4 list-inside list-disc">
|
||||||
{model.highlights.map((item) => (
|
{model.highlights.map((item) => (
|
||||||
<li key={item}>{item}</li>
|
<li key={item}>{item}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-sm opacity-75">
|
<p className="text-sm opacity-75">{strings.showroom.displayOnly}</p>
|
||||||
این بخش معرفی محصول است؛ خرید واقعی انجام نمیشود و بودجهٔ هتومان تغییر نمیکند.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
@@ -72,7 +74,7 @@ export function ShowroomDialog({ nodes, actions }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
روبات، قیمتش چنده؟
|
{strings.showroom.askPrice}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded border border-teal px-4 py-3"
|
className="rounded border border-teal px-4 py-3"
|
||||||
@@ -84,19 +86,29 @@ export function ShowroomDialog({ nodes, actions }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
روبات، امکاناتش چیه؟
|
{strings.showroom.askFeatures}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="start-option start-option-primary"
|
||||||
|
disabled={isOrdered}
|
||||||
|
onClick={() => actions.emit('showroom-order')}
|
||||||
|
>
|
||||||
|
{isOrdered ? strings.showroom.ordered : strings.showroom.order}
|
||||||
|
</button>
|
||||||
|
<a className="start-chip" href={`tel:${COMPANY.contact.phone}`}>
|
||||||
|
{strings.showroom.contact}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
|
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
|
||||||
<img
|
<img
|
||||||
className="h-16 w-16 shrink-0 object-contain"
|
className="h-16 w-16 shrink-0 object-contain"
|
||||||
src="./robot/happy.webp"
|
src={robotFace('happy')}
|
||||||
alt="روبات راهنمای نمایشگاه"
|
alt={strings.showroom.robotAlt}
|
||||||
/>
|
/>
|
||||||
<p role="status">
|
<p role="status">{nodes['showroom-response']?.text ?? strings.showroom.greeting}</p>
|
||||||
{nodes['showroom-response']?.text ??
|
|
||||||
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
import { ShowroomDialog } from './ShowroomDialog.jsx';
|
import { ShowroomDialog } from './ShowroomDialog.jsx';
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
describe('showroom robot questions', () => {
|
describe('showroom robot questions', () => {
|
||||||
@@ -17,4 +18,25 @@ describe('showroom robot questions', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true }));
|
fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true }));
|
||||||
expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60');
|
expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends a purchase request for the selected model once', () => {
|
||||||
|
const actions = { emit: vi.fn(), close: vi.fn() };
|
||||||
|
const { rerender } = render(
|
||||||
|
<ShowroomDialog nodes={{ showroom: { modelId: 'rm-35' } }} actions={actions} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: strings.showroom.order, hidden: true }));
|
||||||
|
expect(actions.emit).toHaveBeenCalledWith('showroom-order');
|
||||||
|
rerender(
|
||||||
|
<ShowroomDialog
|
||||||
|
nodes={{ showroom: { modelId: 'rm-35', ordered: ['rm-35'] } }}
|
||||||
|
actions={actions}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: strings.showroom.ordered, hidden: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: strings.showroom.contact, hidden: true }),
|
||||||
|
).toHaveAttribute('href', expect.stringMatching(/^tel:/));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
/** @typedef {{model:object}} ShowroomImageProps */
|
/** @typedef {{model:object}} ShowroomImageProps */
|
||||||
/** Shows supplied catalog assets, with an explicit fallback while files are unavailable.
|
/** Shows supplied catalog assets, with an explicit fallback while files are unavailable.
|
||||||
@@ -10,7 +11,7 @@ export function ShowroomImage({ model }) {
|
|||||||
const [failed, setFailed] = useState({});
|
const [failed, setFailed] = useState({});
|
||||||
const path = model[variant];
|
const path = model[variant];
|
||||||
const unavailable = failed[path];
|
const unavailable = failed[path];
|
||||||
const src = new URL(`.${path}`, document.baseURI).href;
|
const src = new URL(path, document.baseURI).href;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{unavailable ? (
|
{unavailable ? (
|
||||||
@@ -18,13 +19,13 @@ export function ShowroomImage({ model }) {
|
|||||||
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
|
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
نمای سهبعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.
|
{strings.showroom.noImage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
className="mx-auto max-h-64 max-w-full object-contain"
|
className="mx-auto max-h-64 max-w-full object-contain"
|
||||||
src={src}
|
src={src}
|
||||||
alt={`${model.name} · ${variant === 'imageAlt' ? 'رنگ سفید' : variant === 'gif' ? 'نمای متحرک' : 'نمای اصلی'}`}
|
alt={`${model.name} · ${strings.showroom.views[variant]}`}
|
||||||
onError={() =>
|
onError={() =>
|
||||||
setFailed((previous) => ({
|
setFailed((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
@@ -34,11 +35,7 @@ export function ShowroomImage({ model }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{Object.entries(strings.showroom.views).map(([key, label]) => (
|
||||||
['image', 'نمای اصلی'],
|
|
||||||
['imageAlt', 'رنگ سفید'],
|
|
||||||
['gif', 'نمای متحرک'],
|
|
||||||
].map(([key, label]) => (
|
|
||||||
<button
|
<button
|
||||||
className="rounded border border-white/20 px-3 py-2"
|
className="rounded border border-white/20 px-3 py-2"
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
|
|
||||||
/** Presents the StatusBar using the current interface snapshot. */
|
|
||||||
export function StatusBar({ nodes }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="status" model={nodes['status']}>
|
|
||||||
<div className="stat">
|
|
||||||
<small>{'بودجه'}</small>
|
|
||||||
<GameNode as="strong" id="balance" model={nodes['balance']}>
|
|
||||||
{'۵۰۰'}
|
|
||||||
</GameNode>
|
|
||||||
<span>{'هتومان'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
<small>{'ساعت'}</small>
|
|
||||||
<GameNode as="strong" id="clock" model={nodes['clock']}>
|
|
||||||
{'۰۸:۳۰'}
|
|
||||||
</GameNode>
|
|
||||||
</div>
|
|
||||||
<GameNode as="div" className="stat" id="speed-stat" hidden model={nodes['speed-stat']}>
|
|
||||||
<small>{'سرعت'}</small>
|
|
||||||
<GameNode as="strong" id="speed" model={nodes['speed']}>
|
|
||||||
{'۰'}
|
|
||||||
</GameNode>
|
|
||||||
<span>{'km/h'}</span>
|
|
||||||
</GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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}} TouchControlsProps */
|
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
|
||||||
/** Presents the TouchControls using the current interface snapshot. */
|
/** Presents the TouchControls using the current interface snapshot. */
|
||||||
export function TouchControls({ nodes, actions }) {
|
export function TouchControls({ nodes, actions }) {
|
||||||
@@ -36,7 +37,7 @@ export function TouchControls({ nodes, actions }) {
|
|||||||
model={nodes['touch-shift']}
|
model={nodes['touch-shift']}
|
||||||
onAction={() => actions.emit('touch-shift')}
|
onAction={() => actions.emit('touch-shift')}
|
||||||
>
|
>
|
||||||
{'دویدن'}
|
{strings.status.run}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</div>
|
</div>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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}} UpdateNoticeProps */
|
/** @typedef {{nodes:object,actions:object}} UpdateNoticeProps */
|
||||||
/** Presents the UpdateNotice view using plain models and semantic native elements.
|
/** Presents the UpdateNotice view using plain models and semantic native elements.
|
||||||
* @param {UpdateNoticeProps} props - Interface snapshots and callbacks.
|
* @param {UpdateNoticeProps} props - Interface snapshots and callbacks.
|
||||||
@@ -8,7 +9,7 @@ export function UpdateNotice({ nodes, actions }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GameNode as="div" id="update-toast" hidden model={nodes['update-toast']}>
|
<GameNode as="div" id="update-toast" hidden model={nodes['update-toast']}>
|
||||||
{'نسخهٔ جدید بازی آماده است '}
|
{strings.app.updateReady}
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -16,7 +17,7 @@ export function UpdateNotice({ nodes, actions }) {
|
|||||||
model={nodes['update-reload']}
|
model={nodes['update-reload']}
|
||||||
onAction={() => actions.emit('update-reload')}
|
onAction={() => actions.emit('update-reload')}
|
||||||
>
|
>
|
||||||
{'بهروزرسانی'}
|
{strings.app.update}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
import { formatHetoman } from '@/game/economy/gameState.js';
|
import { formatHetoman } from '@/game/economy/gameState.js';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
|
// Class for a mission row: finished, current or still to come.
|
||||||
|
const MISSION_STATE = { done: 'done', active: 'active' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One mission row; the current mission also lists its steps with ticks.
|
||||||
|
* @param {{item: {title: string, done: boolean, active: boolean, steps: object[]}}} props -
|
||||||
|
* Mission list item.
|
||||||
|
* @returns {import('react').ReactElement} List item.
|
||||||
|
*/
|
||||||
|
function MissionItem({ item }) {
|
||||||
|
const state = (item.done && MISSION_STATE.done) || (item.active && MISSION_STATE.active) || '';
|
||||||
|
return (
|
||||||
|
<li className={state}>
|
||||||
|
{item.title}
|
||||||
|
{item.steps?.length > 0 && (
|
||||||
|
<ol className="mission-steps">
|
||||||
|
{item.steps.map((step) => (
|
||||||
|
<li key={step.text} className={step.done ? 'done' : ''}>
|
||||||
|
{step.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
|
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
|
||||||
/** Renders structured game lists as JSX; strings are never interpreted as HTML.
|
/** Renders structured game lists as JSX; strings are never interpreted as HTML.
|
||||||
@@ -20,17 +48,12 @@ export function ViewLists({ id, model = {}, onAction }) {
|
|||||||
<span className="price">{formatHetoman(item.price)}</span>
|
<span className="price">{formatHetoman(item.price)}</span>
|
||||||
{item.price !== item.cost && <s>{formatHetoman(item.cost)}</s>}
|
{item.price !== item.cost && <s>{formatHetoman(item.cost)}</s>}
|
||||||
<small>
|
<small>
|
||||||
{item.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}
|
{item.type === 'drink' ? strings.lists.drink : strings.lists.snack}
|
||||||
{item.blocked === 'funds' ? ' · موجودی کافی نیست' : ''}
|
{item.blocked === 'funds' ? strings.lists.noFunds : ''}
|
||||||
</small>
|
</small>
|
||||||
</button>
|
</button>
|
||||||
));
|
));
|
||||||
if (id === 'mission-list')
|
if (id === 'mission-list') return items.map((item) => <MissionItem key={item.id} item={item} />);
|
||||||
return items.map((item) => (
|
|
||||||
<li key={item.id} className={item.done ? 'done' : item.active ? 'active' : ''}>
|
|
||||||
{item.title}
|
|
||||||
</li>
|
|
||||||
));
|
|
||||||
if (id === 'end-stats')
|
if (id === 'end-stats')
|
||||||
return items.map(([label, value]) => (
|
return items.map(([label, value]) => (
|
||||||
<li key={label}>
|
<li key={label}>
|
||||||
@@ -52,7 +75,7 @@ export function ViewLists({ id, model = {}, onAction }) {
|
|||||||
{item.name}
|
{item.name}
|
||||||
{item.visited ? ' ✓' : ''}
|
{item.visited ? ' ✓' : ''}
|
||||||
<span className="meta">
|
<span className="meta">
|
||||||
{item.locked ? item.note : `${item.category} · ${item.open ? 'باز' : 'بسته'}`}
|
{item.locked ? item.note : strings.lists.machineState(item.category, item.open)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="dist">{item.distance}</span>
|
<span className="dist">{item.distance}</span>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { MenuItem } from './MenuItem.jsx';
|
||||||
|
|
||||||
|
// Menu actions in display order: [element id, icon, label key, what a click does]. `emit` sends
|
||||||
|
// the id to the game runtime; `sheet` opens a HUD sheet; `fullscreen` is handled here. Items
|
||||||
|
// marked `contextual` stay hidden until the runtime shows them (products in the factory, camera
|
||||||
|
// and stable mode in real-world play).
|
||||||
|
const ITEMS = [
|
||||||
|
['menu-map', 'map', 'map', { emit: 'minimap-button' }],
|
||||||
|
['menu-missions', 'list', 'missions', { sheet: 'missions' }],
|
||||||
|
['menu-status', 'chart', 'status', { sheet: 'status' }],
|
||||||
|
['showroom-open', 'grid', 'products', { emit: 'showroom-open', contextual: true }],
|
||||||
|
['gps-toggle', 'pin', 'realWorld', { emit: 'gps-toggle' }],
|
||||||
|
['ar-toggle', 'camera', 'camera', { emit: 'ar-toggle', contextual: true }],
|
||||||
|
['stable-toggle', 'target', 'stable', { emit: 'stable-toggle', contextual: true }],
|
||||||
|
['sound', 'sound', 'sound', { emit: 'sound' }],
|
||||||
|
['quality', 'sliders', 'graphics', { emit: 'quality' }],
|
||||||
|
['minimap-toggle', 'compass', 'minimap', { emit: 'minimap-toggle' }],
|
||||||
|
['menu-fullscreen', 'expand', 'fullscreen', { fullscreen: true }],
|
||||||
|
['menu-help', 'help', 'help', { sheet: 'help' }],
|
||||||
|
['restart', 'refresh', 'restart', { emit: 'restart' }],
|
||||||
|
['menu-home', 'home', 'home', { emit: 'back-to-menu' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the page in or out of fullscreen.
|
||||||
|
*/
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.();
|
||||||
|
else document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, actions: object, onOpenSheet: function}} FloatingMenuProps */
|
||||||
|
/**
|
||||||
|
* The single floating menu button; it reveals every secondary action in a list and closes on a
|
||||||
|
* second tap, a tap outside, Escape, or after choosing an action.
|
||||||
|
* @param {FloatingMenuProps} props - Interface snapshots, actions and the sheet opener.
|
||||||
|
* @returns {import('react').ReactElement} Menu.
|
||||||
|
*/
|
||||||
|
export function FloatingMenu({ nodes, actions, onOpenSheet }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const root = useRef(null);
|
||||||
|
const canFullscreen = Boolean(document.documentElement.requestFullscreen);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a menu action and closes the menu.
|
||||||
|
* @param {object} action - One of the ITEMS actions.
|
||||||
|
*/
|
||||||
|
const handleSelect = (action) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (action.emit) actions.emit(action.emit);
|
||||||
|
if (action.sheet) onOpenSheet(action.sheet);
|
||||||
|
if (action.fullscreen) toggleFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
// While open, close on a press outside the menu or on Escape; listeners go when it closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const handlePointer = (event) => !root.current?.contains(event.target) && setIsOpen(false);
|
||||||
|
const handleKey = (event) => event.key === 'Escape' && setIsOpen(false);
|
||||||
|
document.addEventListener('pointerdown', handlePointer);
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointer);
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={root} id="game-menu" className={isOpen ? 'open' : undefined}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="menu-button"
|
||||||
|
aria-label={isOpen ? strings.menu.close : strings.menu.open}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls="menu-items"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<Icon name={isOpen ? 'close' : 'menu'} />
|
||||||
|
</button>
|
||||||
|
<div id="menu-items" role="menu" aria-label={strings.menu.label} hidden={!isOpen}>
|
||||||
|
{ITEMS.map(([id, icon, labelKey, action]) => (
|
||||||
|
<MenuItem
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
icon={icon}
|
||||||
|
label={strings.menu[labelKey]}
|
||||||
|
hiddenByDefault={Boolean(action.contextual)}
|
||||||
|
model={action.fullscreen && !canFullscreen ? { hidden: true } : nodes[id]}
|
||||||
|
onSelect={() => handleSelect(action)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { FloatingMenu } from './FloatingMenu.jsx';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the menu with spies for runtime actions and sheets.
|
||||||
|
* @param {object} nodes - Interface snapshots.
|
||||||
|
* @returns {{actions: {emit: import('vitest').Mock}, onOpenSheet: import('vitest').Mock}} Spies.
|
||||||
|
*/
|
||||||
|
function renderMenu(nodes = {}) {
|
||||||
|
const actions = { emit: vi.fn() },
|
||||||
|
onOpenSheet = vi.fn();
|
||||||
|
render(<FloatingMenu nodes={nodes} actions={actions} onOpenSheet={onOpenSheet} />);
|
||||||
|
return { actions, onOpenSheet };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the menu with its button. */
|
||||||
|
const openMenu = () => fireEvent.click(screen.getByRole('button', { name: strings.menu.open }));
|
||||||
|
|
||||||
|
describe('FloatingMenu', () => {
|
||||||
|
it('reveals the actions on tap and hides them again on a second tap', () => {
|
||||||
|
renderMenu();
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
openMenu();
|
||||||
|
expect(screen.getByRole('menu')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: strings.menu.close })).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: strings.menu.close }));
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs an action and closes, and closes on Escape or a tap outside', () => {
|
||||||
|
const { actions, onOpenSheet } = renderMenu();
|
||||||
|
openMenu();
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: strings.menu.home }));
|
||||||
|
expect(actions.emit).toHaveBeenCalledWith('back-to-menu');
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
openMenu();
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: strings.menu.missions }));
|
||||||
|
expect(onOpenSheet).toHaveBeenCalledWith('missions');
|
||||||
|
openMenu();
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
openMenu();
|
||||||
|
fireEvent.pointerDown(document.body);
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps real-world only items hidden until the game shows them', () => {
|
||||||
|
renderMenu({ 'ar-toggle': { hidden: false } });
|
||||||
|
openMenu();
|
||||||
|
expect(screen.getByRole('menuitem', { name: strings.menu.camera })).toBeVisible();
|
||||||
|
expect(screen.queryByRole('menuitem', { name: strings.menu.stable })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { Sheet } from '@/components/shared/Sheet.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { ViewLists } from '../ViewLists.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission list with the active one highlighted and finished ones ticked.
|
||||||
|
* @param {{nodes: object, actions: object}} props - Interface snapshots and actions.
|
||||||
|
* @returns {import('react').ReactElement} Ordered list.
|
||||||
|
*/
|
||||||
|
function MissionsBody({ nodes, actions }) {
|
||||||
|
return (
|
||||||
|
<ol id="mission-list" className="mission-list">
|
||||||
|
<ViewLists
|
||||||
|
id="mission-list"
|
||||||
|
model={nodes['mission-list']}
|
||||||
|
onAction={(name, value) => actions.emit(name, value)}
|
||||||
|
/>
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget, game clock, rank and experience.
|
||||||
|
* @param {{nodes: object}} props - Interface snapshots.
|
||||||
|
* @returns {import('react').ReactElement} Status rows.
|
||||||
|
*/
|
||||||
|
function StatusBody({ nodes }) {
|
||||||
|
const rows = [
|
||||||
|
[strings.status.budget, 'balance', strings.status.budgetStart, strings.units.hetomanName],
|
||||||
|
[strings.status.clock, 'clock', strings.status.clockStart, ''],
|
||||||
|
[strings.status.rank, 'rank-name', strings.status.rankStart, ''],
|
||||||
|
[strings.status.xp, 'xp', strings.status.xpStart, ''],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3">
|
||||||
|
{rows.map(([label, id, fallback, unit]) => (
|
||||||
|
<div key={id} className="contents">
|
||||||
|
<dt className="opacity-70">{label}</dt>
|
||||||
|
<dd className="text-end font-bold">
|
||||||
|
<GameNode as="span" id={id} model={nodes[id]}>
|
||||||
|
{fallback}
|
||||||
|
</GameNode>{' '}
|
||||||
|
{unit}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls for keyboard and touch.
|
||||||
|
* @returns {import('react').ReactElement} Two short tables.
|
||||||
|
*/
|
||||||
|
function HelpBody() {
|
||||||
|
const groups = [
|
||||||
|
[strings.help.keyboardTitle, strings.help.keyboard],
|
||||||
|
[strings.help.touchTitle, strings.help.touch],
|
||||||
|
];
|
||||||
|
return groups.map(([title, rows]) => (
|
||||||
|
<section key={title} className="mb-4">
|
||||||
|
<h3 className="mb-2 font-bold text-primary">{title}</h3>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
{rows.map(([key, action]) => (
|
||||||
|
<div key={key} className="contents">
|
||||||
|
<dt>
|
||||||
|
<kbd>{key}</kbd>
|
||||||
|
</dt>
|
||||||
|
<dd className="opacity-80">{action}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sheet key → [title, body component].
|
||||||
|
const SHEETS = {
|
||||||
|
missions: [strings.menu.missions, MissionsBody],
|
||||||
|
status: [strings.menu.status, StatusBody],
|
||||||
|
help: [strings.menu.help, HelpBody],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @typedef {{sheet: string | null, nodes: object, actions: object, onClose: function}}
|
||||||
|
* HudSheetsProps */
|
||||||
|
/**
|
||||||
|
* The HUD's on-demand panels; longer text lives here instead of on screen.
|
||||||
|
* @param {HudSheetsProps} props - Open sheet key (or null), snapshots, actions and close callback.
|
||||||
|
* @returns {import('react').ReactElement} Sheet.
|
||||||
|
*/
|
||||||
|
export function HudSheets({ sheet, nodes, actions, onClose }) {
|
||||||
|
const [title, Body] = SHEETS[sheet] ?? ['', null];
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
id="hud-sheet"
|
||||||
|
open={Boolean(Body)}
|
||||||
|
title={title}
|
||||||
|
closeLabel={strings.menu.close}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{Body && <Body nodes={nodes} actions={actions} />}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{id: string, icon: string, label: string, model?: object, hiddenByDefault?: boolean,
|
||||||
|
* onSelect: function}} MenuItemProps */
|
||||||
|
/**
|
||||||
|
* One floating-menu action. Runtime state (hidden, pressed, disabled) comes from `model`; the
|
||||||
|
* label always comes from the strings file so every item keeps its icon and short name.
|
||||||
|
* @param {MenuItemProps} props - Element id, icon, label, runtime snapshot, whether it stays hidden
|
||||||
|
* until the runtime shows it, and click handler.
|
||||||
|
* @returns {import('react').ReactElement} Menu button.
|
||||||
|
*/
|
||||||
|
export function MenuItem({ id, icon, label, model = {}, hiddenByDefault = false, onSelect }) {
|
||||||
|
const { hidden, pressed, disabled } = model;
|
||||||
|
return (
|
||||||
|
<GameNode
|
||||||
|
as="button"
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="menu-item"
|
||||||
|
hidden={hiddenByDefault}
|
||||||
|
model={{ hidden, pressed, disabled }}
|
||||||
|
onAction={onSelect}
|
||||||
|
>
|
||||||
|
<Icon name={icon} className="h-5 w-5 shrink-0" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, onOpen: function}} ObjectiveChipProps */
|
||||||
|
/**
|
||||||
|
* Compact current-objective indicator: an arrow towards the target (relative to the camera),
|
||||||
|
* the objective in one line and its distance. Tapping it opens the mission list.
|
||||||
|
* @param {ObjectiveChipProps} props - Interface snapshots and the mission sheet opener.
|
||||||
|
* @returns {import('react').ReactElement} Objective button.
|
||||||
|
*/
|
||||||
|
export function ObjectiveChip({ nodes, onOpen }) {
|
||||||
|
return (
|
||||||
|
<GameNode
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
id="objective"
|
||||||
|
hidden
|
||||||
|
aria-label={strings.objective.open}
|
||||||
|
model={{ hidden: nodes.objective?.hidden ?? true }}
|
||||||
|
onAction={onOpen}
|
||||||
|
>
|
||||||
|
<GameNode as="span" id="objective-arrow" model={nodes['objective-arrow']}>
|
||||||
|
<Icon name="arrow" className="h-5 w-5" />
|
||||||
|
</GameNode>
|
||||||
|
<span className="grid min-w-0 text-start">
|
||||||
|
<small className="text-primary">{strings.objective.label}</small>
|
||||||
|
<GameNode as="b" id="objective-title" model={nodes['objective-title']} />
|
||||||
|
</span>
|
||||||
|
<GameNode as="span" id="objective-distance" model={nodes['objective-distance']} />
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object}} SpeedChipProps */
|
||||||
|
/**
|
||||||
|
* Small speedometer, shown only while driving.
|
||||||
|
* @param {SpeedChipProps} props - Interface snapshots.
|
||||||
|
* @returns {import('react').ReactElement} Speed readout.
|
||||||
|
*/
|
||||||
|
export function SpeedChip({ nodes }) {
|
||||||
|
return (
|
||||||
|
<GameNode as="div" id="speed-stat" hidden model={nodes['speed-stat']}>
|
||||||
|
<GameNode as="strong" id="speed" model={nodes.speed}>
|
||||||
|
{strings.status.speedStart}
|
||||||
|
</GameNode>
|
||||||
|
<small>{strings.units.speed}</small>
|
||||||
|
</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,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user