Compare commits
16
Commits
751af768ce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6f472f08 | ||
|
|
53caa0a208 | ||
|
|
71ae4ffa9b | ||
|
|
bced4dc6f6 | ||
|
|
5a4dffc9a3 | ||
|
|
d9ae8a45da | ||
|
|
611960511e | ||
|
|
8dbaf3d97e | ||
|
|
e02347142b | ||
|
|
526796126d | ||
|
|
df930694f7 | ||
|
|
fbfa8d011d | ||
|
|
55d4123fe5 | ||
|
|
d0da416923 | ||
|
|
6bd9b17173 | ||
|
|
08b6cd464b |
@@ -41,8 +41,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install
|
- name: Install
|
||||||
run: npm ci --no-audit --no-fund
|
run: npm ci --no-audit --no-fund
|
||||||
|
- name: Check formatting
|
||||||
|
run: npm run format:check
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
- name: Test
|
- name: Test
|
||||||
run: node --test tests/*.test.js
|
run: npm test
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- name: Check formatting
|
||||||
|
run: npm run format:check
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
- name: Test game logic
|
- name: Test game logic
|
||||||
run: node --test tests/*.test.js
|
run: npm test
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- uses: actions/configure-pages@v5
|
- uses: actions/configure-pages@v5
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
src/data
|
||||||
|
public
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
+1
-1
@@ -6,7 +6,7 @@ ARG NPM_REGISTRY=https://registry.npmjs.org/
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" --fetch-retries=3 --fetch-timeout=60000
|
RUN npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" --fetch-retries=3 --fetch-timeout=60000
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node --test tests/*.test.js && npm run build
|
RUN npm run format:check && npm run lint && npm test && npm run build
|
||||||
|
|
||||||
# Stage 2: serve dist/ with Caddy, which also obtains and renews the HTTPS certificate.
|
# Stage 2: serve dist/ with Caddy, which also obtains and renews the HTTPS certificate.
|
||||||
FROM caddy:2.8.4-alpine
|
FROM caddy:2.8.4-alpine
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
# عملیات مشهد — روبات مارکت
|
# عملیات مشهد — روبات مارکت
|
||||||
|
|
||||||
بازی سهبعدی مرورگری (Three.js) با حالوهوای بازیهای اکشن سومشخص، بدون سلاح و خشونت. یک پدر و فرزندش از کارخانهٔ سازندهٔ دستگاهها راه میافتند، در خیابانهای واقعی مشهد میگردند، دستگاههای وندینگ روبات مارکت را پیدا میکنند و با هر خرید امتیاز میگیرند. روبات راهنمای روبات مارکت داخل دستگاهها زندگی میکند: وقتی خانواده به یک دستگاه میرسند، از آن بیرون میآید، خوشآمد میگوید و راهنماییشان میکند.
|
📚 [مستندات کامل بازی و راهنمای توسعه برای Agent](docs/README.md) — قوانین، سناریو، معماری، تاریخچه و بکلاگ بر اساس بررسی کد فعلی.
|
||||||
|
|
||||||
|
بازی سهبعدی مرورگری (React 18 + Three.js، JavaScript، Tailwind CSS) با حالوهوای بازیهای اکشن سومشخص، بدون سلاح و خشونت. یک پدر و فرزندش از کارخانهٔ سازندهٔ دستگاهها راه میافتند، در خیابانهای واقعی مشهد میگردند، دستگاههای وندینگ روبات مارکت را پیدا میکنند و با هر خرید امتیاز میگیرند. روبات راهنمای روبات مارکت داخل دستگاهها زندگی میکند: وقتی خانواده به یک دستگاه میرسند، از آن بیرون میآید، خوشآمد میگوید و راهنماییشان میکند.
|
||||||
|
|
||||||
بازی یک PWA است: روی گوشی نصب میشود و آفلاین هم اجرا میشود.
|
بازی یک PWA است: روی گوشی نصب میشود و آفلاین هم اجرا میشود.
|
||||||
|
|
||||||
🎮 [اجرای آنلاین بازی](https://hamedabdollahzade.github.io/vending-machine-game/)
|
🎮 [اجرای آنلاین بازی](https://hamedabdollahzade.github.io/vending-machine-game/)
|
||||||
|
|
||||||
|
## نسخهٔ React و نمایشگاه کارخانه
|
||||||
|
|
||||||
|
رابط با React، وضعیت مشترک با Redux Toolkit و درخواستها با Axios پیاده شدهاند. موتور Three.js از رابط جداست. [معماری جدید و راهنمای Agent](docs/11-react-migration.md) مرجع ساختار فعلی است.
|
||||||
|
|
||||||
|
سه دستگاه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال در حیاط کارخانه قرار دارند. با Q نزدیک نمونهها یا دکمهٔ «مدلهای کارخانه» داخل محوطه، قیمت و امکانات را از روبات بپرسید. قیمت دستگاهها بهترتیب ۴٬۶۸۰٬۰۰۰٬۰۰۰، ۵٬۴۸۰٬۰۰۰٬۰۰۰ و ۵٬۹۸۰٬۰۰۰٬۰۰۰ ریال است؛ این معرفی بودجهٔ هتومان را کم نمیکند. تصاویر/GIFهای کاتالوگ هنوز اضافه نشدهاند؛ نمونههای سهبعدی موجودند و رابط برای نبود فایل پیام جایگزین دارد.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
## صفحهٔ شروع
|
||||||
|
|
||||||
|
صفحهٔ شروع شبیه صفحهٔ اصلی سایت است: لوگو و شعار شرکت روی نمای سهبعدی کارخانه که آرام دور آن میچرخد. چهار راه ورود به بازی دارد:
|
||||||
|
|
||||||
|
- **بازی در دنیای واقعی:** بعد از تذکر ایمنی، موقعیت GPS گوشی بازیکن را جابهجا میکند. اگر اجازهٔ موقعیت داده نشود یا GPS در دسترس نباشد، پیامی ساده نشان داده میشود و پیشنهاد بازی آفلاین میآید. هدف اول: رفتن تا کارخانه و خرید.
|
||||||
|
- **بازی آفلاین:** شروع از بوستان ملت، با ماشین خانواده کنار بازیکن و مسیر طلایی تا کارخانه. هدف اول: رفتن تا کارخانه و خرید.
|
||||||
|
- **دربارهٔ ما:** شروع در حیاط کارخانه برای آشنایی با شرکت.
|
||||||
|
- **محصولات:** شروع کنار نمونهدستگاههای کارخانه با پنجرهٔ معرفی محصول باز.
|
||||||
|
|
||||||
|
زیر این دکمهها، بخشهای معمول سایت (معرفی شرکت، تماس، آدرس و نقشه، پرسشهای متداول، فرصتهای شغلی، گالری و شبکههای اجتماعی) در پنجرهای کوچک باز میشوند و محتوایشان از `src/config/company.js` میآید. «شروع دوباره» بازی را در همان حالت انتخابشده از نو شروع میکند.
|
||||||
|
|
||||||
## داستان و هدف
|
## داستان و هدف
|
||||||
|
|
||||||
- **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه این بخشها را دارد:
|
- **کارخانه:** حیاط کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه این بخشها را دارد:
|
||||||
- **سالن تولید** با پنج بخش خمکاری، برش لیزر، رنگکاری، یخچالسازی و مونتاژ، و ۴۰ کارگر مشغول کار؛
|
- **سالن تولید** با پنج بخش خمکاری، برش لیزر، رنگکاری، یخچالسازی و مونتاژ، و ۴۰ کارگر مشغول کار؛
|
||||||
- **کامیون ارسال**: دستگاههای بستهبندیشده از سکوی بارگیری روی کامیون میروند. کامیون پر که شد از دروازه بیرون میرود و بعد خالی برمیگردد؛
|
- **کامیون ارسال**: دستگاههای بستهبندیشده از سکوی بارگیری روی کامیون میروند. کامیون پر که شد از دروازه بیرون میرود و بعد خالی برمیگردد؛
|
||||||
- **دفتر فنی مهندسی** با پنج برنامهنویس که اسمشان بالای سرشان دیده میشود: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کتوشلوار) و محمد. دستگاه وندینگ اول بازی هم همینجاست؛
|
- **دفتر فنی مهندسی** با پنج برنامهنویس که اسمشان بالای سرشان دیده میشود: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کتوشلوار) و محمد. دستگاه وندینگ اول بازی هم همینجاست؛
|
||||||
- **واحد حسابداری** با چهار همکار خانم؛
|
- **واحد حسابداری** با چهار همکار خانم؛
|
||||||
- **اتاق مدیریت**: آقای محمدی، مدیر شرکت، اول بازی با وانت KMC T8 وارد حیاط میشود و به اتاقش میرود.
|
- **اتاق مدیریت**: آقای محمدی، مدیر شرکت، اول بازی با بوگاتی مشکی وارد حیاط میشود و به اتاقش میرود.
|
||||||
|
|
||||||
با نزدیک شدن به هر کدام از این آدمها، با خانواده حرف میزنند. ماشین خانواده، یک سدان اسپرت به سبک BMW با سرعت تا ۲۰۰ کیلومتر بر ساعت، درست کنار نقطهٔ شروع پارک است.
|
با نزدیک شدن به هر کدام از این آدمها، با خانواده حرف میزنند. ماشین خانواده، یک سدان اسپرت به سبک BMW با سرعت تا ۲۰۰ کیلومتر بر ساعت، درست کنار نقطهٔ شروع پارک است.
|
||||||
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیلآباد.
|
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیلآباد.
|
||||||
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استانهای ایران را نشان میدهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفلاند.
|
- **نقشهٔ ایران:** زبانهٔ ایران در نقشهٔ بازی استانها را نشان میدهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفلاند.
|
||||||
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمالغرب تا غرب مشهد، همه از دادههای واقعی OpenStreetMap:
|
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمالغرب تا غرب مشهد، همه از دادههای واقعی OpenStreetMap:
|
||||||
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
|
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
|
||||||
- همهٔ خیابانها و ساختمانها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
|
- همهٔ خیابانها و ساختمانها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
|
||||||
@@ -36,12 +61,18 @@
|
|||||||
| خرید از دستگاه | E | دکمهٔ E |
|
| خرید از دستگاه | E | دکمهٔ E |
|
||||||
| کشیدن کارت | C | دکمهٔ E |
|
| کشیدن کارت | C | دکمهٔ E |
|
||||||
| ترمز دستی | Space | — |
|
| ترمز دستی | Space | — |
|
||||||
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
|
| نقشهٔ شهر | M یا «نقشه» در منو | «نقشه» در منو |
|
||||||
|
|
||||||
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین در بازی عادی سومشخص است و در حالت دنیای واقعی اولشخص.
|
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین در بازی عادی سومشخص است و در حالت دنیای واقعی اولشخص.
|
||||||
|
|
||||||
روی گوشی، اعلانها (گفتوگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده میشوند. خود دکمهٔ خرید هم قابل لمس است.
|
روی گوشی، اعلانها (گفتوگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده میشوند. خود دکمهٔ خرید هم قابل لمس است.
|
||||||
|
|
||||||
|
### رابط کاربری کممتن
|
||||||
|
|
||||||
|
روی صفحه فقط اینها همیشه دیده میشوند: دکمهٔ گرد منو (گوشهٔ بالا)، نشانگر هدف بعدی (بالای صفحه، با فلشی که به سمت هدف میچرخد و فاصله؛ لمسش فهرست مأموریتها را باز میکند) و کنترلهای لمسی. سرعت فقط هنگام رانندگی دیده میشود.
|
||||||
|
|
||||||
|
بقیه در منو هستند: نقشه، مأموریتها، وضعیت (بودجه، ساعت، درجه و امتیاز)، محصولات (فقط داخل کارخانه)، دنیای واقعی، دوربین و حالت پایدار (فقط در حالت دنیای واقعی)، صدا، گرافیک، نقشهٔ کوچک (بهطور پیشفرض خاموش)، تمامصفحه، راهنما، شروع دوباره و بازگشت به منوی اصلی. منو با لمس دوباره، لمس بیرون آن یا Escape بسته میشود.
|
||||||
|
|
||||||
## نقشه و مسیریابی
|
## نقشه و مسیریابی
|
||||||
|
|
||||||
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
||||||
@@ -56,7 +87,24 @@
|
|||||||
| خرید از هر پنج نوع مکان شهری | ۵۰۰ |
|
| خرید از هر پنج نوع مکان شهری | ۵۰۰ |
|
||||||
| خرید در حالت دنیای واقعی | ۲۰۰ |
|
| خرید در حالت دنیای واقعی | ۲۰۰ |
|
||||||
|
|
||||||
هفت مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجهها از «تازهوارد» تا «افسانهٔ مشهد» بالا میروند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریتها انجام شود، عملیات تمام میشود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
|
مأموریتها امتیاز و پاداش نقدی (هتومان) دارند. درجهها از «تازهوارد» تا «افسانهٔ مشهد» بالا میروند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریتها انجام شود، عملیات تمام میشود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
|
||||||
|
|
||||||
|
## مأموریتها
|
||||||
|
|
||||||
|
هر مأموریت چند گام کوتاه دارد و نشانگر هدف بالای صفحه همیشه گام فعلی را نشان میدهد. یک فلش سهبعدی به رنگ برند هم بالای دستگاهِ هدف شناور است و مسیر طلایی تا آن کشیده میشود. بیشتر مأموریتها دربارهٔ دستگاههای نصبشده در بوستان ملتاند (ورودی جنوبی، کنار دریاچه و زمینهای ورزشی):
|
||||||
|
|
||||||
|
| مأموریت | گامها |
|
||||||
|
|---|---|
|
||||||
|
| اولین خرید در کارخانه | رفتن به دستگاه دفتر فنی ← خرید |
|
||||||
|
| دستگاه ورودی بوستان ملت | پیدا کردن روی نقشه ← رفتن ← خرید |
|
||||||
|
| بازدید از دستگاه کنار دریاچه | رفتن ← ایستادن کنار دستگاه (کارت مشخصات باز میشود) |
|
||||||
|
| سفارش دستگاه برای زمین ورزشی | رفتن ← ایستادن کنار دستگاه ← «ثبت درخواست سفارش» در کارت |
|
||||||
|
| گشت دستگاههای بوستان | خرید از دستگاه دریاچه ← خرید از دستگاه زمین ورزشی |
|
||||||
|
| کاشف شهر | خرید از پنج دستگاه مختلف شهر |
|
||||||
|
|
||||||
|
کارت دستگاه مدل نصبشده در آن محل (از فهرست محصولات `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)
|
||||||
|
|
||||||
@@ -96,7 +144,7 @@
|
|||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run build
|
npm run build
|
||||||
node --test tests/*.test.js
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
- `?quality=low` یا دکمهٔ «گرافیک» سایهها را خاموش میکند (پیشفرض گوشیها).
|
- `?quality=low` یا دکمهٔ «گرافیک» سایهها را خاموش میکند (پیشفرض گوشیها).
|
||||||
@@ -132,29 +180,14 @@ docker compose logs -f
|
|||||||
|
|
||||||
## CI/CD روی Gitea
|
## CI/CD روی Gitea
|
||||||
|
|
||||||
مخزن اصلی `ssh://git@git.my-rm.com:2222/Hamed/vending-machine-game.git` با نام `origin` است. GitHub با نام `github` باقی مانده. گردشکار [.gitea/workflows/ci-cd.yml](.gitea/workflows/ci-cd.yml) با هر push خودش اجرا میشود:
|
گردشکار `.gitea/workflows/ci-cd.yml` تست و build را اجرا میکند. برای main تصویر Docker در Registry ساخته و منتشر میشود؛ سپس runner از Docker socket مشترک سرور و فایل `deploy/docker-compose.yml` برای pull و up استفاده میکند. انتشار فعلی SSH نیست.
|
||||||
|
|
||||||
- **هر push و pull request:** نصب، تستها و build
|
- Secrets لازم: `REGISTRY_USERNAME` و `REGISTRY_TOKEN` با دسترسی read/write به package.
|
||||||
- **push به `main`:** بعد از قبولی تستها، کد از راه SSH به سرور فرستاده میشود و `docker compose up -d --build` اجرا میشود.
|
- Variables اختیاری: `NPM_REGISTRY` برای mirror و `HEALTH_URL` برای بررسی پاسخ سایت.
|
||||||
|
- شبکهٔ خارجی `frontend_proxy` و Nginx Proxy Manager برای HTTPS در این روش لازماند؛ خود کانتینر روی پورت ۸۰ داخلی است.
|
||||||
|
- Compose ریشه روش مستقل دیگری است که Caddy مستقیماً TLS میدهد؛ با Compose مخصوص deploy اشتباه نشود.
|
||||||
|
|
||||||
**راهاندازی یکباره:**
|
پیش از push نام remote و branch مقصد را بررسی کنید. [جزئیات انتشار و تفاوت روشها](docs/06-development-and-deployment.md).
|
||||||
1. در سرور، Docker و Docker Compose نصب باشد و کاربر SSH بتواند `docker` اجرا کند (عضو گروه `docker` یا root).
|
|
||||||
2. یک کلید SSH مخصوص انتشار بسازید و کلید عمومی را به `~/.ssh/authorized_keys` سرور اضافه کنید:
|
|
||||||
```sh
|
|
||||||
ssh-keygen -t ed25519 -N '' -f deploy_key -C gitea-deploy
|
|
||||||
ssh-copy-id -i deploy_key.pub user@server
|
|
||||||
```
|
|
||||||
3. در Gitea، بخش Settings → Actions → Secrets مخزن، اینها را تعریف کنید:
|
|
||||||
- `DEPLOY_HOST`: آدرس سرور
|
|
||||||
- `DEPLOY_SSH_KEY`: محتوای فایل `deploy_key`
|
|
||||||
- در صورت نیاز: `DEPLOY_USER` (پیشفرض root)، `DEPLOY_PORT` (پیشفرض ۲۲) و `DEPLOY_KNOWN_HOSTS` (خروجی `ssh-keyscan server`)
|
|
||||||
4. اختیاری، در بخش Variables:
|
|
||||||
- `DEPLOY_PATH`: پوشهٔ روی سرور
|
|
||||||
- `SITE_ADDRESS`: دامنهها
|
|
||||||
- `HEALTH_URL`: مثلاً `https://robotmarkets.ir/`، تا بعد از انتشار بالا آمدن سایت چک شود
|
|
||||||
- `NPM_REGISTRY`: اگر runner یا سرور به `registry.npmjs.org` دسترسی ندارد (معمول در سرورهای ایران)، آدرس یک mirror در دسترس npm را اینجا بگذارید. همین مقدار برای build روی سرور هم استفاده میشود.
|
|
||||||
|
|
||||||
تا وقتی `DEPLOY_HOST` تعریف نشده، مرحلهٔ انتشار رد میشود و فقط تست و build اجرا میشوند. هر مرحله محدودیت زمانی دارد (تست ۱۵ دقیقه، انتشار ۳۰ دقیقه) تا runner هیچوقت بیپایان معطل نماند.
|
|
||||||
|
|
||||||
## ساخت دوبارهٔ دادهٔ نقشه
|
## ساخت دوبارهٔ دادهٔ نقشه
|
||||||
|
|
||||||
@@ -191,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
|
||||||
@@ -204,31 +237,60 @@ 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/main.js`: صحنه، دوربین سومشخص، کنترلها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
|
- `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگهای برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکههای اجتماعی، دربارهما و فهرست محصولات. مقدارهای `TODO(company)` جایگزینشدنیاند.
|
||||||
- `src/city.js`: ساخت شهر سهبعدی از دادهها (خیابان، پیادهرو، ساختمان، پارک، درخت، ورودی مترو، کوهها) در تکههای ۲ کیلومتری که دورها پنهان میشوند
|
- `src/config/assets.js`: مسیر همهٔ فایلها (لوگو، تصویر محصولات، چهرههای روبات، مدلهای سهبعدی و دادهٔ نقشه) با قالب و اندازهٔ مورد انتظار هر کدام. موارد `TODO(asset)` جای فایل واقعیاند.
|
||||||
- `src/map-render.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشیهای کششده و نمای کلی) و نقشهٔ ایران
|
- `src/i18n/fa.js`: همهٔ متنهای فارسی رابط کاربری، گفتوگوها، مأموریتها و نوشتههای روی صحنه. تست `tests/content.test.js` جلوی متن فارسی مستقیم در کد را میگیرد.
|
||||||
- `src/routing.js`: گراف خیابانها، مسیریابی A* و پیدا کردن نام خیابان
|
- `src/game/createGame.js و src/game/runtime/`: صحنه، دوربین سومشخص، کنترلها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
|
||||||
- `src/collision.js`: برخورد با ساختمانها برای شخصیتها، ماشین و دوربین
|
- `src/game/world/city.js`: ساخت شهر سهبعدی از دادهها (خیابان، پیادهرو، ساختمان، پارک، درخت، ورودی مترو، کوهها) در تکههای ۲ کیلومتری که دورها پنهان میشوند
|
||||||
- `src/locations.js`: دستگاهها، آدرسها، ساعت کاری و محصولات
|
- `src/game/world/mapRender.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشیهای کششده و نمای کلی) و نقشهٔ ایران
|
||||||
- `src/game-state.js`، `src/payment.js`، `src/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریتها
|
- `src/game/world/routing.js`: گراف خیابانها، مسیریابی A* و پیدا کردن نام خیابان
|
||||||
- `src/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاهها بیرون میآید
|
- `src/game/world/collision.js`: برخورد با ساختمانها برای شخصیتها، ماشین و دوربین
|
||||||
- `src/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، بههمراه دفترها، کامیون و ورود مدیر
|
- `src/constants/locations.js`: دستگاههای نصبشده در شهر، آدرسها، ساعت کاری و محصولات
|
||||||
- `src/staff.js`: آدمهای کارخانه، یعنی کارمندهای مفصل (هودی، کتوشلوار، مانتو و روسری) با برچسب اسم، و ۴۰ کارگر بهصورت instanced
|
- `src/game/economy/gameState.js`، `src/game/economy/payment.js`، `src/game/economy/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریتها
|
||||||
- `src/car-models.js`: ماشین خانواده (سدان اسپرت)، وانت مدیر و کامیون
|
- `src/game/models/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاهها بیرون میآید
|
||||||
- `src/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام بهروزرسانی
|
- `src/game/models/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، بههمراه دفترها، کامیون و ورود مدیر
|
||||||
- `src/realworld.js`: GPS، قطبنما، نرمسازی و حالت پایدار موقعیت، و شعاع رسیدن
|
- `src/game/models/staff.js`: آدمهای کارخانه، یعنی کارمندهای مفصل (هودی، کتوشلوار، مانتو و روسری) با برچسب اسم، و ۴۰ کارگر بهصورت instanced
|
||||||
- `src/imagery.js`: زمین ماهوارهای که کاشیهایش با نزدیک شدن بارگذاری و با دور شدن آزاد میشوند
|
- `src/game/models/carModels.js`: ماشین خانواده (سدان اسپرت)، وانت مدیر و کامیون
|
||||||
- `src/buildings-data.js`: خواندن فایل ساختمانهای Microsoft
|
- `src/game/device/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام بهروزرسانی
|
||||||
- `src/ar.js`: نمای دوربین (AR)، شامل تصویر دوربین پشت گوشی، جهت گوشی و زاویهٔ دید
|
- `src/game/device/realworld.js`: GPS، قطبنما، نرمسازی و حالت پایدار موقعیت، و شعاع رسیدن
|
||||||
- `src/vending.js`، `src/vehicle.js`، `src/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
|
- `src/game/world/imagery.js`: زمین ماهوارهای که کاشیهایش با نزدیک شدن بارگذاری و با دور شدن آزاد میشوند
|
||||||
- `src/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
|
- `src/game/world/buildingsData.js`: خواندن فایل ساختمانهای Microsoft
|
||||||
|
- `src/game/device/ar.js`: نمای دوربین (AR)، شامل تصویر دوربین پشت گوشی، جهت گوشی و زاویهٔ دید
|
||||||
|
- `src/game/models/vending.js`، `src/game/systems/vehicle.js`، `src/game/systems/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
|
||||||
|
- `src/utils/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
|
||||||
|
|
||||||
## محدودیتها و گامهای بعدی
|
## محدودیتها و گامهای بعدی
|
||||||
|
|
||||||
- مدلها هندسی و سبکاند و کیفیت گرافیکی بازیهای AAA را ندارند.
|
- مدلها هندسی و سبکاند و کیفیت گرافیکی بازیهای AAA را ندارند.
|
||||||
- خیابانها یکطرفه و چراغ راهنمایی ندارند، و ترافیک فقط روی بلوارهای اصلی است.
|
- داده و ترافیک جهت یکطرفه دارند، اما مسیریابی هنوز دوطرفه است و چراغ راهنمایی شبیهسازی نمیشود؛ ترافیک روی خیابانهای اصلی است.
|
||||||
- پیشنهادهای بعدی: امتیاز ایمنی (خط عابر و چراغ قرمز)، تاکسی و قطار شهری، چرخهٔ روز و شب، ذخیرهٔ پیشرفت، جدول امتیاز آنلاین، و محلهها و شهرهای دیگر.
|
- پیشنهادهای بعدی: امتیاز ایمنی (خط عابر و چراغ قرمز)، تاکسی و قطار شهری، چرخهٔ روز و شب، ذخیرهٔ پیشرفت، جدول امتیاز آنلاین، و محلهها و شهرهای دیگر.
|
||||||
|
|
||||||
نقشه: © مشارکتکنندگان OpenStreetMap (ODbL). ساختمانها: © Microsoft (ODbL). تصویر ماهوارهای: Esri, Maxar, Earthstar Geographics, and the GIS User Community. مرز استانها: Natural Earth.
|
نقشه: © مشارکتکنندگان OpenStreetMap (ODbL). ساختمانها: © Microsoft (ODbL). تصویر ماهوارهای: Esri, Maxar, Earthstar Geographics, and the GIS User Community. مرز استانها: Natural Earth.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# محصول، سناریو و دامنهٔ فعلی
|
||||||
|
|
||||||
|
## هویت
|
||||||
|
|
||||||
|
نام فعلی: «عملیات مشهد — روبات مارکت». بازی مرورگری فارسی، راستبهچپ، سهبعدی، بدون سلاح و خشونت است. هدف اصلی کشف دستگاههای فرضی وندینگ در جغرافیای واقعی مشهد، خرید نمایشی و پیشرفت در مأموریتهاست. مختصات و نقشه واقعیاند؛ وجود یک دستگاه در بازی به معنای وجود دستگاه واقعی در آن نشانی نیست.
|
||||||
|
|
||||||
|
## سناریوی قابل اجرا
|
||||||
|
|
||||||
|
1. صفحهٔ briefing نقشهٔ ایران و متن مأموریت را نشان میدهد. فقط مشهد فعال است؛ شهرهای دیگر نمایش قفل دارند.
|
||||||
|
2. پس از شروع، پدر و کودک در حیاط کارخانهٔ شرکت روبات مارکت، کنار ماشین خانواده و رو به دفتر مهندسی هستند. README فعلی شروع را «سالن تولید» توصیف میکند؛ محل spawn واقعی در `factory.start` حیاط است.
|
||||||
|
3. خانواده میتواند سالن تولید، دفاتر و کارکنان را ببیند. گفتوگوهای آغازین به معرفی کارخانه و ورود مدیر اشاره میکنند.
|
||||||
|
4. اولین هدف، خرید از دستگاه دفتر فنی مهندسی است. روبات از کنار دستگاه بیرون میآید، خوشآمد میگوید و کنترل E و ماشین F را معرفی میکند.
|
||||||
|
5. خانواده با خودرو از کارخانه به شهر میرود؛ گراف فعلی مسیر کارخانه تا بوستان ملت را حدود ۱۸ تا ۲۳ کیلومتر محاسبه میکند (تست محدودهٔ طول مسیر).
|
||||||
|
6. اهداف شهری شامل بوستان ملت، مدرسه، دانشگاه فردوسی، مترو و مشهد مال هستند. خریدهای دیگر و کشف آزاد نیز ممکناند.
|
||||||
|
7. تکمیل همهٔ هفت مأموریت، یا نبود هیچ خرید قابل پرداخت مطابق منطق فعلی پایان بودجه، پنجرهٔ پایان را باز میکند. پس از تکمیل همهٔ مأموریتها دکمهٔ ادامه وجود دارد؛ «شروع دوباره» عملیات تازه میسازد.
|
||||||
|
|
||||||
|
ترتیب نمایشی مأموریتها خطی است، اما شرایط تکمیل همهٔ مأموریتها پس از هر خرید بررسی میشوند؛ انجام زودتر یک هدف ممنوع نیست. الزام «رفتن با ماشین» در متن بعضی مأموریتهاست و بهعنوان شرط در رکورد خرید ذخیره/بررسی نمیشود.
|
||||||
|
|
||||||
|
## شخصیتها
|
||||||
|
|
||||||
|
- پدر و کودک انسانی تیم قابل کنترلاند؛ یک player مشترک، جهت و انیمیشن آنها را هدایت میکند.
|
||||||
|
- روبات فیروزهای/سفید Robot Market راهنمای دستگاههاست. با نزدیکشدن ظاهر، با دورشدن جمع و در سفر همراه خانواده نمیشود. مدل هندسی در `characters.js` و صورتهای تصویری در `public/robot` هستند.
|
||||||
|
- روبات میتواند دست تکان بدهد، اشاره کند و همراه زیرنویس حرف بزند. صورت زیرنویس بر اساس نام فایل تصویری انتخاب میشود.
|
||||||
|
- کارکنان دفتر: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کتوشلوار)، محمد؛ چهار همکار حسابداری و آقای محمدی مدیر.
|
||||||
|
- نام React، Go و C++ در گفتوگو و مانیتور کارکنان بخشی از داستان کارخانه است؛ بازی با این فناوریها نوشته نشده است.
|
||||||
|
|
||||||
|
## کارخانه
|
||||||
|
|
||||||
|
پنج بخش تولید: خمکاری، برش لیزر، رنگکاری، یخچالسازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاههای آماده، بستهبندی و بارگیری. کامیون هشت بسته را بار میزند، خارج میشود، مدتی غایب میماند، بازمیگردد و دندهعقب به سکو میرسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمیدهد.
|
||||||
|
|
||||||
|
مدیر با بوگاتی مشکی وارد میشود، پارک میکند (از آن به بعد ماشینش سوارشدنی است)، تا دفتر راه میرود و مینشیند؛ گفتوگوی او بعد از نشستن فعال میشود. گفتوگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیکشدن فعال میشود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجعاند.
|
||||||
|
|
||||||
|
## خارج از دامنهٔ فعلی
|
||||||
|
|
||||||
|
پرداخت بانکی، سرور حساب کاربری، ذخیرهٔ پیشرفت، leaderboard آنلاین، شهر قابل بازی غیر از مشهد، مأموریت مبارزه، چرخهٔ روز/شب و شبیهسازی کامل قوانین ترافیک پیادهسازی نشدهاند. پارک کوهسنگی و حرم/مرکز شهر در فهرست مناطق آیندهاند، نه مأموریت فعال.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# قوانین بازی، اقتصاد و قراردادهای رفتاری
|
||||||
|
|
||||||
|
## حالت، زمان و ورودی
|
||||||
|
|
||||||
|
- حالت جابهجایی `walk` یا `drive` است. GPS حالت مستقل `real.on` است و حرکت را به موقعیت گوشی میسپارد.
|
||||||
|
- شروع: ۵۰۰ هتومان، صفر خرید، XP صفر، مأموریت انجامشدهٔ خالی، ساعت ۰۸:۳۰.
|
||||||
|
- هر ثانیهٔ واقعی فعال = ده ثانیهٔ بازی؛ `state.minutes += dt * 10 / 60`.
|
||||||
|
- briefing، فروشگاه، نقشه، پنجرهٔ پایان و مخفیبودن document شبیهسازی را متوقف میکنند. زیرنویسها بهتنهایی حرکت را نمیبندند. پنل پرداخت شبیهسازی را متوقف نمیکند، اما `busy` حرکت پیاده را محدود میکند.
|
||||||
|
- WASD و جهتها: حرکت نسبی به دوربین/کنترل ماشین؛ Shift: دویدن؛ Space: ترمز دستی؛ F: سوار/پیاده؛ E: فروشگاه؛ C: کشیدن کارت؛ Escape: انصراف پیش از تأیید؛ M: نقشه.
|
||||||
|
- کنترل حروف با `KeyboardEvent.code` است تا کیبورد فارسی هم کار کند. چرخ ماوس فاصلهٔ دوربین را بین ۳ و ۱۲ تغییر میدهد. روی دسکتاپ Pointer Lock و در fallback/موبایل drag وجود دارد.
|
||||||
|
- موبایل joystick و دکمههای F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت میکشد.
|
||||||
|
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرمسازی است. برخورد دایرهای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود میشود.
|
||||||
|
|
||||||
|
## ماشینها
|
||||||
|
|
||||||
|
همهٔ وسیلههای حیاط کارخانه مشکیاند و سوارشدنی: بیاموی خانواده کنار نقطهٔ شروع، لامبورگینی، مزدا و نیسان در پارکینگ کارکنان، و بوگاتی آقای محمدی پس از اینکه پارک کرد. هر ماشین ترافیک شهر هم با F گرفته میشود و تا reset همانجا که رها شده میماند. کامیون بارگیری تزئینی است و سوار نمیشود. همه در `runtime/garage.js` نگهداری میشوند.
|
||||||
|
|
||||||
|
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیادهشدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیادهشدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان میدهد. سمت خروج راننده بررسی برخورد میشود.
|
||||||
|
|
||||||
|
ثابتهای `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر میشود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. برخورد بین وسیلهها با جعبهٔ جهتدار به اندازهٔ واقعی مدل (SAT در `world/vehicleCollision.js`) است، پس فقط وقتی بدنهها واقعاً به هم برسند سرعت کم میشود و ماشین از هر فاصلهای که واقعاً جا شود رد میشود. کاهش سرعت به زاویهٔ برخورد بستگی دارد: شاخبهشاخ ۶۰٪، مالیدن کناری تقریباً هیچ. ترافیک فقط برای چیزی که واقعاً در عرض خطش باشد ترمز میکند و NPC پس از تماس طولانی حذف/بازپدید میشود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
|
||||||
|
|
||||||
|
## پول و محصول
|
||||||
|
|
||||||
|
۱۰ هتومان = ۱۰۰٬۰۰۰ ریال؛ محاسبات با هتومان صحیح و نمایش با رقم فارسی است. قیمت هر دستگاه `Math.round(baseCost * priceFactor)` است؛ مثال آب کارخانه `round(15 × .5) = 8`.
|
||||||
|
|
||||||
|
| محصول | نوع | قیمت پایه هتومان |
|
||||||
|
|---|---|---:|
|
||||||
|
| آبمیوه | drink | ۳۵ |
|
||||||
|
| آب معدنی | drink | ۱۵ |
|
||||||
|
| شیر کاکائو | drink | ۴۰ |
|
||||||
|
| چای سرد | drink | ۴۵ |
|
||||||
|
| لیموناد | drink | ۴۰ |
|
||||||
|
| بیسکویت | snack | ۲۵ |
|
||||||
|
| شکلات | snack | ۳۰ |
|
||||||
|
| آجیل | snack | ۶۵ |
|
||||||
|
| کیک | snack | ۳۵ |
|
||||||
|
|
||||||
|
خرید نامحدود تا حد بودجه است؛ موجودی انبار، مصرف محصول، فروش دوباره و رزرو پول برای مأموریت نداریم. کانالهای عددی ۲۱ تا ۶۰ متعلق به نسخهٔ قدیمیاند؛ فروشگاه فعلی کارت محصول با productId دارد.
|
||||||
|
|
||||||
|
`purchaseBlocker` به ترتیب دستگاه/محصول ناشناخته، busy، بستهبودن و کمبود پول را کنترل میکند. بازبودن شامل ساعت شروع و فاقد ساعت پایان است: `start <= minutes % 1440 < end`. بازهٔ عبور از نیمهشب پشتیبانی نشده است.
|
||||||
|
|
||||||
|
## پرداخت و تحویل
|
||||||
|
|
||||||
|
`idle → approaching → ready → approved → delivery → idle`
|
||||||
|
|
||||||
|
- بازکردن فروشگاه: پیاده، دستگاه نزدیک (۳٫۴ متر در حالت مجازی)، عملیات تمامنشده و بدون busy/پرداخت فعال.
|
||||||
|
- انتخاب محصول و `beginPayment`: بدون برداشت؛ busy روشن و دوربین نزدیک کارتخوان.
|
||||||
|
- بعد از بیش از ۱ ثانیه آماده؛ تنها C/دکمهٔ کشیدن کارت خرید را ثبت و مبلغ را یک بار کسر میکند.
|
||||||
|
- انصراف در approaching یا ready بدون تغییر پول/خرید؛ پس از approved مجاز نیست.
|
||||||
|
- پس از بیش از ۱٫۲ ثانیهٔ approved، تحویل ۲٫۶ ثانیهای آغاز میشود. busy تا پایان تحویل باقی میماند.
|
||||||
|
- رکورد خرید شامل machineId، category، productId، type، price و minutes است. XP و جایزهٔ مأموریت در پایان تحویل اعمال میشوند، نه لحظهٔ انتخاب.
|
||||||
|
- همهچیز نمایشی و داخل مرورگر است؛ اتصال درگاه یا تراکنش واقعی وجود ندارد.
|
||||||
|
|
||||||
|
## امتیاز و درجه
|
||||||
|
|
||||||
|
هر خرید ۱۰۰ XP؛ اولین خرید از هر machineId، ۲۵۰؛ اولین ترکیب drink و snack در همان دستگاه، ۱۵۰؛ تکمیل پنج دستهٔ شهری park/university/school/metro/mall، ۵۰۰. factory و test در پاداش پنج دسته و explorer شمرده نمیشوند. خرید در real.on بعد از تحویل ۲۰۰ XP اضافه میدهد؛ کشف/ترکیب برای دستگاه آزمایشی هم ممکن است.
|
||||||
|
|
||||||
|
| درجه | حداقل XP |
|
||||||
|
|---|---:|
|
||||||
|
| تازهوارد | ۰ |
|
||||||
|
| کاوشگر | ۶۰۰ |
|
||||||
|
| راهبلد | ۱۴۰۰ |
|
||||||
|
| شکارچی دستگاه | ۲۶۰۰ |
|
||||||
|
| استاد نقشه | ۴۲۰۰ |
|
||||||
|
| افسانهٔ مشهد | ۶۵۰۰ |
|
||||||
|
|
||||||
|
مأموریتها تنها یک بار با missionsDone جایزه میدهند؛ مشخصات کامل در [مرجع](10-game-reference.md). activeMission اولین مأموریت انجامنشده است، ولی completeMissions همهٔ شرطها را بررسی میکند.
|
||||||
|
|
||||||
|
## پایان و reset
|
||||||
|
|
||||||
|
`checkEnd` در پایان خرید و تقریباً هر نیمثانیه بررسی میشود: همهٔ مأموریتها کامل یا `canAffordAnything` منفی. تابع بودجه دستگاه ارزان قابل خرید فعلی/با پایان ساعت دیرتر در همان روز را میشمارد، نه امکان کسب پول در آینده؛ محدودیتهای این رفتار در بازبینی آمده است.
|
||||||
|
|
||||||
|
reset پول، خرید، XP، ساعت، payment، spawn، ماشین، مأموریت، مقصد و greeting روبات را تازه میکند. reset فعلی پاکسازی کامل همهٔ timerها، گفتوگوها، GPS و انیمیشن کامیون نیست؛ Agent نباید آن را بدون تست کامل فرض کند.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# معماری و فناوری
|
||||||
|
|
||||||
|
## پشتهٔ فعلی
|
||||||
|
|
||||||
|
JavaScript ES Modules، Three.js/WebGL، Vite، HTML و CSS فارسی RTL و Canvas 2D برای نقشه، تابلو، تکسچر و portrait. فونت Vazirmatn با Fontsource داخل bundle میآید. نسخهٔ lock فعلی: Three.js `0.180.0`، Vite `6.4.4`، Fontsource `5.2.5`؛ در package.json نسخهها بازهٔ caret دارند. Node 22 در CI/Docker استفاده میشود؛ target خروجی ES2022 برای top-level await است. TypeScript، React، موتور فیزیک بیرونی و framework رابط در بازی وجود ندارند.
|
||||||
|
|
||||||
|
## نقشهٔ ماژولها
|
||||||
|
|
||||||
|
| فایل | مسئولیت |
|
||||||
|
|---|---|
|
||||||
|
| main.js | bootstrap، DOM، player/mode/camera، حلقه، اتصال پرداخت و جایزه، GPS/AR، UI و debug |
|
||||||
|
| game-state.js | catalog، پول و ساعت، خرید، blockers و امکان ادامه با بودجه |
|
||||||
|
| payment.js | ماشین حالت تراکنش و جلوگیری از برداشت دوباره |
|
||||||
|
| scoring.js | XP، درجه، مأموریت و جایزهٔ مأموریت |
|
||||||
|
| locations.js | مشخصات کارخانه، دستگاهها، test points و مناطق قفل |
|
||||||
|
| characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن |
|
||||||
|
| factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون |
|
||||||
|
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
|
||||||
|
| car-models.js | سدان خانواده، کامیون و helperهای مشترک بدنه |
|
||||||
|
| luxuryCars.js | ماشینهای لوکس مشکی به سبک بوگاتی، لامبورگینی، مزدا و نیسان |
|
||||||
|
| vehicle.js | state و فیزیک آرکید ماشین (`CAR`) |
|
||||||
|
| vehicleCollision.js | برخورد جعبهٔ جهتدار وسیلهها و هلدادن پیاده از وسیلهٔ پارکشده |
|
||||||
|
| garage.js | فهرست وسیلههای سوارشدنی، سوار/پیاده، برخورد رانندگی و reset |
|
||||||
|
| traffic.js | ماشینهای NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
|
||||||
|
| city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility |
|
||||||
|
| collision.js | spatial grid چندضلعیها، resolveCircle و clearFraction دوربین |
|
||||||
|
| routing.js | گراف پیاده/سواره، A*، نزدیکترین node/segment و طول مسیر |
|
||||||
|
| geo.js | تبدیل lat/lon و مختصات محلی، bearing و فاصلهٔ فارسی |
|
||||||
|
| map-render.js | minimap، fullmap و نقشهٔ استانهای ایران با Canvas |
|
||||||
|
| vending.js | مدل دستگاه، slot، card، flap، beacon و محصول تحویلی |
|
||||||
|
| imagery.js | کاشی ماهوارهای، تبدیل Web Mercator، بارگذاری/آزادسازی texture |
|
||||||
|
| buildings-data.js | decode فایل باینری RMB2 |
|
||||||
|
| realworld.js | smoothFix، stabilizer، geolocation/compass و شعاع رسیدن |
|
||||||
|
| ar.js | rear camera، حسگر جهت، quaternion و تطبیق FOV |
|
||||||
|
| pwa.js | نصب، ثبت SW، اعلان نسخه و reload |
|
||||||
|
| textures.js | ساخت CanvasTexture مشترک |
|
||||||
|
| style.css / index.html | ساختار DOM، HUD، modalها و ظاهر desktop/mobile |
|
||||||
|
|
||||||
|
## جریان داده
|
||||||
|
|
||||||
|
`locations/catalog + map JSON + public binary/index → ساخت world/graph/collision → reset → requestAnimationFrame`.
|
||||||
|
|
||||||
|
حلقهٔ اصلی dt را عادی به ۰٫۲۵ ثانیه محدود و با گامهای حداکثر ۱/۳۰ شبیهسازی میکند؛ debug حداکثر ۱ ثانیه دارد. در حالت فعال: ساعت، حرکت، payment، delivery، traffic؛ سپس route، hints، talks و guide. پس از آن انیمیشنها، دوربین، HUD و render اجرا میشوند. توقف شبیهسازی الزاماً همهٔ انیمیشنهای بصری را متوقف نمیکند.
|
||||||
|
|
||||||
|
`main.js` وضعیت مرکزی را در متغیرهای ماژولی نگه میدارد؛ event bus یا store جدا نداریم. `state` اقتصادی از `player/car/cam/real` جداست. `payment` در اتصال UI فیلدهای اضافی station/timer/delivering میگیرد که در createPayment تعریف نشدهاند. Object.assign به createPayment فیلدهای اضافی را حذف نمیکند؛ هنگام refactor به این وابستگی توجه شود.
|
||||||
|
|
||||||
|
## قرارداد توسعه
|
||||||
|
|
||||||
|
- محاسبهٔ قیمت فقط از priceAt و catalog؛ UI نسخهٔ دوم قیمت نسازد.
|
||||||
|
- برداشت فقط در swipe موفق؛ XP/مأموریت پس از تحویل؛ busy تا پایان تحویل.
|
||||||
|
- دستگاه با id پایدار معرفی شود؛ مأموریتها و خریدهای ذخیرهشوندهٔ آینده به id وابستهاند.
|
||||||
|
- مبنای geo تغییر نکند مگر همهٔ JSON/binary/anchorها و تستها با هم مهاجرت کنند.
|
||||||
|
- تغییر mode باید مسیر، دوربین، ورودی و HUD را در نظر بگیرد؛ تغییر real/AR باید lifecycle stream/sensor را هم رعایت کند.
|
||||||
|
- geometry، material و texture پویا هنگام حذف آزاد شوند؛ مدل تازه نباید هر frame asset بسازد.
|
||||||
|
- main.js حدود ۱۴۴۳ و factory.js حدود ۶۹۸ خطاند؛ جداسازی تدریجی ممکن است، ولی بازنویسی سراسری برای یک تسک کوچک ضرورت ندارد.
|
||||||
|
|
||||||
|
backend اختصاصی بازی وجود ندارد. Gitea Registry و Caddy زیرساخت build/میزبانیاند، نه API بازی. GPS و تصویر دوربین در این کد به سرور بازی ارسال نمیشوند؛ سرویس مکانیابی مرورگر و دانلود assetها همچنان محیط بیرونی خود را دارند.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# جهان، نقشه، دادهها و داراییها
|
||||||
|
|
||||||
|
## مختصات و قرارداد فایل
|
||||||
|
|
||||||
|
مبدأ `ORIGIN = {lat:36.315, lon:59.539}`، هر واحد یک متر، +X شرق و -Z شمال. تبدیل صفحهٔ مماس محلی با latitude/longitude انجام میشود؛ terrain ارتفاع جغرافیایی واقعی ندارد. جهت heading ماشین با `(cos(h), -sin(h))` در صفحهٔ XZ است. bearing صفر شمال و ۹۰ شرق است.
|
||||||
|
|
||||||
|
محدودهٔ فعلی: X از -۱۲۲۲۰ تا ۳۱۵۹؛ Z از -۱۶۴۵۱ تا ۲۰۷۸. `scripts/build-map-data.mjs` REGION را از lat 36.298 تا 36.462 و lon 59.405 تا 59.572 میگیرد.
|
||||||
|
|
||||||
|
`src/data/mellat.json`: origin، attribution، bounds، roadTypes، areaKinds، names، roads، buildings، areas، oneway، labels. snapshot: ۶۱۹۶ road، ۳۹۸۶ ساختمان OSM، ۲۶۸ area، ۲۹۱۰ شاخص road یکطرفه و ۹۵ label. این شمارشها تعداد رکورد فایلاند، نه شمارش همهٔ خیابانهای واقعی.
|
||||||
|
|
||||||
|
- road: `[typeIndex, nameIndex, x0, z0, x1, z1, ...]`
|
||||||
|
- building در runtime: `[height, x0, z0, ...]`
|
||||||
|
- جدول names برای lookup نام road استفاده میشود.
|
||||||
|
- دادهٔ استانها در `src/data/iran.json` است.
|
||||||
|
|
||||||
|
## گراف و برخورد
|
||||||
|
|
||||||
|
buildGraph گراف پیاده با همهٔ مسیرها، و سواره با road type ≤ 7 میسازد. نزدیکترین node از بزرگترین مؤلفهٔ متصل انتخاب میشود؛ A* با هزینهٔ طول کار میکند. connector ابتدا/انتهای مسیر به node نزدیک، خط مستقیم است. گراف فعلی دوطرفه است؛ oneway در routing مصرف نمیشود، هرچند traffic از آن استفاده میکند.
|
||||||
|
|
||||||
|
خط طلایی روی زمین فقط تا حدود ۷۰۰ متر از polyline ساخته میشود. نقشهٔ کامل و minimap مسیر کامل را میگیرند. هدف معمولاً مأموریت فعال است؛ waypoint دستی مقدم است و پس از خرید/اعلام مأموریت بعدی میتواند پاک شود.
|
||||||
|
|
||||||
|
برخورد بر مبنای footprintهای OSM + Microsoft + factory است؛ spatial cell برابر ۲۴ متر. resolveCircle برای player، خودرو و نقاط spawn استفاده میشود. clearFraction مانع ردشدن خط دید دوربین از دیوار میشود. ساختمانهای Microsoft داخل محوطهٔ کارخانه کنار گذاشته میشوند.
|
||||||
|
|
||||||
|
## داراییها و بهینهسازی
|
||||||
|
|
||||||
|
- `public/data/buildings.bin`: قالب RMB2؛ header شامل count، minX، minZ، quantization step؛ هر polygon تعداد رأس/ارتفاع نیممتری و مختصات delta دارد. در snapshot بررسیشده ۱۴۱٬۴۹۷ ساختمان و ۳٬۴۷۸٬۹۱۰ بایت دارد. ارتفاعها تخمینیاند. decoder فعلی برای فایل کوتاه/خراب اعتبارسنجی کامل ندارد.
|
||||||
|
- city بخشهای ۲۰۰۰ متری و ساختمانها بلوکهای ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند.
|
||||||
|
- `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery همزمان حداکثر ۶ درخواست میفرستد؛ tileهای دور geometry/material/texture آزاد میکنند.
|
||||||
|
- زوم۱۷: شعاع ۴۵۰ متر در low و ۷۵۰ در high؛ زوم۱۵: ۱۸۰۰ و ۲۸۰۰. محدودهٔ حذف ۱٫۲ برابر range است.
|
||||||
|
- زمین ماهوارهای با `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 زیرنویس تطبیق دارد.
|
||||||
|
- آیکونهای PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند.
|
||||||
|
|
||||||
|
## بازتولید داده
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/build-map-data.mjs --osm city.json zone-factory.json corridor.json major.json --poi poi.json --provinces ne_10m_admin_1_states_provinces.geojson
|
||||||
|
node scripts/build-buildings.mjs <iran-quadkey.csv.gz>
|
||||||
|
node scripts/fetch-imagery.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
اینها عملیات جدا از npm build هستند. raw exportها ورودی بیرونیاند؛ خودکار دانلود/بازنویسی نکنید مگر تسک نیاز داشته باشد. پس از تغییر REGION یا مبدأ باید anchors، factory، graph، binary، tile index و تست دسترسپذیری با هم بررسی شوند. `npm run build` فقط فایلهای موجود را بستهبندی میکند.
|
||||||
|
|
||||||
|
Attribution در کد/رابط وجود دارد: OpenStreetMap، Microsoft، Esri/Maxar/Earthstar و Natural Earth. متن مجوز و شرایط استفادهٔ بیرونی در این بازبینی حقوقی بررسی نشده است؛ attribution فعلی در تغییر داراییها حفظ شود و متن README دربارهٔ منبع تصاویر مرجع پروژه باقی بماند.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# GPS، دوربین، حسگر و PWA
|
||||||
|
|
||||||
|
## حالت دنیای واقعی
|
||||||
|
|
||||||
|
کاربر از briefing یا نوار پایین real mode را میزند؛ modal معرفی/ایمنی و سپس اجازهٔ قطبنما و مکانیابی درخواست میشود. HTTPS یا localhost لازم است. joystick و صفحهکلید جای player را تغییر نمیدهند؛ updateReal موقعیت را از گوشی میگیرد. ماشین مجازی در این حالت قابل استفاده نیست. خانواده پنهان و دوربین در ارتفاع ۱٫۶ متر قرار میگیرد.
|
||||||
|
|
||||||
|
مکانیابی ابتدا getCurrentPosition تقریبی (۱۵ ثانیه timeout، maximumAge ۶۰ ثانیه) و سپس watchPosition دقیق (۳۰ ثانیه timeout و maximumAge ۳ ثانیه) دارد. timeout اولیهٔ watch فقط وضعیت جستوجو است؛ کد۱ permission، کد۲ unavailable و کد۳ timeout.
|
||||||
|
|
||||||
|
smoothFix وزن را با واریانس accuracy دو fix میسازد؛ پس از فاصلهٔ زمانی بیش از ۳۰ ثانیه fix تازه جایگزین میشود. accuracy کف ۳ متر دارد. stabilizer پرش خارج از دسترس را رد میکند، پس از سه رد پیاپی نمونهٔ بعدی امکان پذیرش دارد؛ تا drift بیش از max(3, accuracy/2) یا speed گزارششده بیش از .7 نباشد مکان نمایشی ثابت میماند.
|
||||||
|
|
||||||
|
updateReal نقطه را از برخورد ساختمان بیرون میآورد؛ اختلاف بالای ۱۵۰ متر teleport، در فاصلههای کمتر حرکت نرم تا حداکثر ۸ m/s دارد. فقط fix داخل bounds اعمال میشود؛ خارج bounds پیام نزدیکترین دستگاه نمایش داده میشود.
|
||||||
|
|
||||||
|
شعاع تعامل `min(50, max(20, accuracy × 2.5))` متر است. نزدیکترین دستگاه فعال در این شعاع انتخاب میشود؛ سه test point فقط در real فعالاند. شرط سرعت بیش از ۶ m/s در interact جلوی بازکردن فروشگاه را میگیرد. این شرط در swipePayment تکرار نشده؛ محدودیت تست/بکلاگ محسوب میشود.
|
||||||
|
|
||||||
|
## AR
|
||||||
|
|
||||||
|
getUserMedia با rear/environment، video مطلوب ۱۲۸۰×۷۲۰ و بدون audio؛ تصویر video پشت WebGL alpha قرار میگیرد. city، imagery، factory، traffic، car، sky و parkGate در AR پنهاناند؛ دستگاه و روبات و مسیر روی تصویر میمانند. این AR بر پایهٔ GPS/قطبنماست، نه WebXR، تشخیص سطح یا occlusion واقعی. انتظار دقت سانتیمتری درست نیست.
|
||||||
|
|
||||||
|
زاویهٔ گوشی به quaternion با deviceorientationabsolute یا fallback deviceorientation تبدیل میشود؛ iOS compassHeading میتواند offset شمال را اصلاح کند. FOV از فرض زاویهٔ ضلع بلند ۶۵ درجه و نسبت video/screen محاسبه میشود. stable mode چرخش را آرامتر میکند. بدون حسگر drag و جهت حرکت/compass fallback هستند.
|
||||||
|
|
||||||
|
خاموشکردن AR trackهای دوربین را stop میکند ولی حسگر برای نمای اولشخص ادامه دارد؛ خاموشکردن real باید tracking، AR و sensors را متوقف کند. reset معمول بازی فعلاً معادل خاموشکردن real نیست.
|
||||||
|
|
||||||
|
## PWA و ذخیره
|
||||||
|
|
||||||
|
setupPwa در build service worker نسبی `./sw.js` ثبت میکند؛ dev server ثبت نمیکند. نصب با beforeinstallprompt و راهنمای Add to Home Screen برای iOS فراهم است. manifest و icon محلی هستند.
|
||||||
|
|
||||||
|
Vite plugin فهرست assetهای build و public را precache میکند؛ JPGهای imagery مستثنا هستند و فقط پس از مشاهده cache میشوند. بنابراین «آفلاین» برای همهٔ نقاط ماهوارهای پس از اولین اجرا تضمین نشده است. Navigation network-first با fallback صفحهٔ cache؛ assetهای دیگر cache-first؛ imagery cache جدا دارد. نسخهٔ تازه با skip-waiting و controllerchange reload میشود.
|
||||||
|
|
||||||
|
localStorage فعلی فقط کیفیت گرافیک را نگه میدارد. پول، خرید، XP، مأموریت و موقعیت player ذخیرهٔ ماندگار ندارند. نصب PWA با ذخیرهٔ پیشرفت یکی نیست. حالت low پیشفرض ورودی coarse است: سایه خاموش و pixelRatio ۱؛ high تا ۱٫۷۵. query `?quality=low` و کلید گرافیک انتخاب را تغییر میدهند.
|
||||||
|
|
||||||
|
## آزمون دستی ضروری
|
||||||
|
|
||||||
|
مجوز ردشده/دادهشده، قطع GPS، fix خارج جهان، نمونهٔ قدیمی و jump، نزدیکترین test point، سرعت بالا حین پرداخت، دو بار روشن/خاموش، screen rotation، توقف camera track، بازگشت از background، Android/iOS، آفلاین مسیر بازدیدشده/جدید و update از نسخهٔ نصبشده. اینها در تست Node بهطور انتهابهانتها پوشش ندارند.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# اجرا، تست، انتشار و نگهداری
|
||||||
|
|
||||||
|
## محیط توسعه
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
node --test tests/*.test.js
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
در محیط این مخزن طبق `/home/hamed/.codex/RTK.md` فرمانهای shell با `rtk` پیشوند اجرا میشوند؛ مثال `rtk npm run build`. خروجی dist تولیدی و در gitignore است. Node 22 با CI هماهنگ است. query `?debug` شیء window.game را برای بررسی state/player/car/cam/payment/route و ابزارهای موجود در انتهای main در دسترس میگذارد؛ این دسترسی فقط ابزار توسعه است.
|
||||||
|
|
||||||
|
## پوشش تست فعلی
|
||||||
|
|
||||||
|
| فایل | تعریف تست | پوشش |
|
||||||
|
|---|---:|---|
|
||||||
|
| payment.test.js | ۳ | برداشت یک بار، مرحلهٔ ready، انصراف/بسته/نامعتبر/پول، قیمت و ساعت |
|
||||||
|
| scoring.test.js | ۲ | کشف/ترکیب/پنج دسته، درجه و جایزهٔ مأموریت |
|
||||||
|
| realworld.test.js | ۴ | smoothFix، arrival/bounds، stabilizer، quaternion و FOV |
|
||||||
|
| world.test.js | ۷ | geo، collision، مسیر کارخانه/دستگاه، خودرو، one-way ترافیک، binary و tile |
|
||||||
|
|
||||||
|
در تاریخ مبنا همهٔ این فایلها موفق اجرا شدند. runner خروجی را به چهار فایل تجمیع کرده؛ عدد چهار در خلاصهٔ خروجی تعداد فایلهاست. تست browser E2E، اجرای فیزیکی گوشی، CI واقعی، build Docker و سایت زنده در این بازبینی اجرا نشدهاند.
|
||||||
|
|
||||||
|
## GitHub Pages
|
||||||
|
|
||||||
|
`.github/workflows/pages.yml`: push به main یا اجرای دستی؛ checkout، Node22/npm ci، تست، build، configure-pages، upload فقط dist و deploy-pages با مجوز pages/id-token. در Settings → Pages، Source باید GitHub Actions باشد. `base:'./'` برای مسیر مخزن است. لینک ثبتشدهٔ پروژه: https://hamedabdollahzade.github.io/vending-machine-game/ (دسترسپذیری فعلی بررسی نشده).
|
||||||
|
|
||||||
|
## Docker مستقل
|
||||||
|
|
||||||
|
Dockerfile: build با node:22-alpine و npm ci + تست + build؛ runtime caddy:2.8.4-alpine و dist در /srv. Compose ریشه از سورس build، پورتهای ۸۰/۴۴۳ و volume گواهی دارد؛ SITE_ADDRESS پیشفرض robotmarkets.ir و www.robotmarkets.ir، NPM_REGISTRY قابل تنظیم. در این مدل Caddy مسئول TLS است.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy assets hashed را immutable یکساله؛ HTML/SW/manifest را no-cache؛ mapdata را یک هفته cache میکند. zstd/gzip و headerهای geolocation/camera/sensor و nosniff تنظیماند. try_files ناشناخته را به index.html میفرستد؛ برای فایل دادهٔ گمشده باید این رفتار در QA در نظر گرفته شود.
|
||||||
|
|
||||||
|
## Gitea فعلی؛ تفاوت مهم با README قدیمی
|
||||||
|
|
||||||
|
گردشکار `.gitea/workflows/ci-cd.yml` فعلی از SSH deployment استفاده نمیکند. توضیح قدیمی DEPLOY_HOST/DEPLOY_SSH_KEY در README برای این workflow معتبر نیست.
|
||||||
|
|
||||||
|
1. test job روی Node22: npm ci، تست و build؛ timeout پانزده دقیقه.
|
||||||
|
2. publish برای main و غیر PR: Docker build، push تصویر با tag زمان/run/SHA به git.my-rm.com، سپس تلاش link package به مخزن. ناموفق بودن link مانع انتشار تصویر نمیشود. timeout سی دقیقه.
|
||||||
|
3. deploy با image خروجی publish و Docker daemon مشترک runner/server: compose pull و up از `deploy/docker-compose.yml`، نمایش ps و image prune. timeout پانزده دقیقه.
|
||||||
|
4. HEALTH_URL اختیاری برای پاسخ HTTP تست میشود؛ این تست صحت بازی یا CSS/WebGL را تضمین نمیکند.
|
||||||
|
|
||||||
|
نیازها: secretهای REGISTRY_USERNAME و REGISTRY_TOKEN با مجوز package read/write؛ variable اختیاری NPM_REGISTRY و HEALTH_URL؛ runner دارای Docker CLI/Compose و دسترسی socket daemon میزبان. مقدار secretها در مستندات نوشته نشود.
|
||||||
|
|
||||||
|
Compose مخصوص deploy تصویر آمادهٔ IMAGE را میگیرد، container_name برابر vending-machine-game و شبکهٔ خارجی frontend_proxy دارد. پورت میزبان publish نمیکند؛ SITE_ADDRESS=:80 و TLS با Nginx Proxy Manager بیرون container است. پس آن را با compose ریشه که Caddy TLS دارد یکی ندانید.
|
||||||
|
|
||||||
|
## کنترل تغییر
|
||||||
|
|
||||||
|
قبل از کار git status و remote را بررسی کنید؛ origin را GitHub فرض نکنید (README از origin در Gitea و remote github یاد میکند). build محلی به معنی انتشار نیست. برای هر تسک، تست مرتبط + build و بررسی diff لازم است؛ تغییر داده/زیرساخت نیازمند بررسی خاص خود است. این مستندات خودشان دستور push یا deploy نیستند.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# برنامههای اجراشده و تاریخچهٔ تغییر جهت
|
||||||
|
|
||||||
|
این جدول از git log محلی استخراج شده؛ تاریخ دقیق و جزئیات هر مرحله را با `git show <sha>` بررسی کنید. پیامهای «update» نقشهٔ محصول قابل استناد ندارند و از آنها قابلیت حدس زده نشده است.
|
||||||
|
|
||||||
|
| مرحله | شاهد گیت | نتیجه/وضعیت کنونی |
|
||||||
|
|---|---|---|
|
||||||
|
| پایهٔ اولیه | f7c6aee | شروع مخزن |
|
||||||
|
| سفر جنگل و پیکنیک | 5857b11 | داستان و مراحل تعاملی؛ سناریوی تاریخی، با شهر جایگزین شده |
|
||||||
|
| Pages و لینک | 6d4c4b7 | workflow ساخت dist و README؛ workflow هنوز هست |
|
||||||
|
| روبات جای کودک | 8a49dc0 | در آن مرحله child تعویض شد؛ در نسخهٔ فعلی کودک انسانی برگشته و روبات نقش راهنمای دستگاه دارد |
|
||||||
|
| تبدیل به شهر مشهد | 95a2ae8 | جابهجایی آزاد سومشخص، دستگاه شهری، خرید/امتیاز و نقشه |
|
||||||
|
| کارخانه/PWA/GPS | 0939dac | شروع کارخانه، نصب و حالت real |
|
||||||
|
| گیر ماشین بزرگراه | 2f74788 | اصلاح حرکت/برخورد در مسیر چناران |
|
||||||
|
| Docker/HTTPS | 47b2b72 | build چندمرحله و Caddy/Compose مستقل |
|
||||||
|
| Gitea CI/CD | 22aa11b | گردشکار اولیه |
|
||||||
|
| محدودیت registry | 9832f30 | registry قابل تنظیم و timeout/retry نصب |
|
||||||
|
| تصویر registry | 894e3e2 | انتشار image بهجای build سورس روی سرور |
|
||||||
|
| نام کارخانه/GPS | 8cc0e8c | Robot Market، status موقعیت و سه نقطهٔ آزمایشی |
|
||||||
|
| Docker socket deploy | c1141ad | جایگزینی deploy SSH با daemon مشترک runner |
|
||||||
|
| تحمل خطای package link | 9564880 | خطای link مانع job نمیشود |
|
||||||
|
| AR و stable GPS | 6ce5600 | rear camera، نرمسازی و شعاع تعامل گسترده |
|
||||||
|
| real اولشخص/UI موبایل | 5cea802 | دوربین گوشی و اعلانهای وسط صفحه |
|
||||||
|
| ماهواره/ساختمان | 9b289f7 | Esri tile و footprint Microsoft، lazy rendering |
|
||||||
|
| کارخانه مفصل و خودرو سریع | 751af76 | کارکنان و بخشها، مدیر و کامیون، سقف سرعت حدود ۲۰۰ km/h |
|
||||||
|
|
||||||
|
## چیزهایی که نباید بهعنوان برنامهٔ انجامشده ثبت شوند
|
||||||
|
|
||||||
|
ذخیرهٔ پیشرفت، leaderboard آنلاین، مأموریت ایمنی، چراغ قرمز، تاکسی/قطار شهری، شهر دیگر و چرخهٔ روز/شب صرفاً پیشنهادند. وجود نام فناوری در مانیتور کارکنان به معنای backend واقعی Go یا frontend React نیست. نمایش مقصد قفلشده به معنای منطقهٔ قابل بازی نیست.
|
||||||
|
|
||||||
|
## تصمیمهای بالفعل که Agent باید حفظ کند
|
||||||
|
|
||||||
|
- تجربهٔ فعلی خانوادهٔ انسانی + روبات در دستگاه است؛ رجوع به پیام تاریخی جایگزینی روبات بدون بررسی کد، نقشها را اشتباه میکند.
|
||||||
|
- اقتصاد، زمان و امتیاز در ماژول جدا؛ پرداخت نمایشی و برداشت پس از swipe.
|
||||||
|
- map و data محلیاند؛ runtime برای راهاندازی به سرویس route آنلاین متکی نیست.
|
||||||
|
- اسکریپتهای بازتولید داده بخشی از build روزمره نیستند.
|
||||||
|
- مسیر deploy جاری Gitea image/socket است؛ SSH فقط شرح قدیمی README است.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# بازبینی کد و بکلاگ پیشنهادی
|
||||||
|
|
||||||
|
تاریخ: ۲۰۲۶/۱۰/۲۸، مبنا 751af76. بررسی: منبع همهٔ حوزههای اصلی، دادهٔ محلی، تستها، build و فایلهای انتشار. اجرای تعاملی WebGL/گوشی یا محیط production انجام نشده است. موارد زیر اصلاحشده محسوب نمیشوند. اولویتها پیشنهاد مهندسیاند، نه تسک مصوب.
|
||||||
|
|
||||||
|
## یافتههای قابل پیگیری
|
||||||
|
|
||||||
|
| شناسه | اولویت | شاهد و اثر | معیار پذیرش پیشنهادی |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R01 | بالا | `swipePayment` شکست خرید را به idle برمیگرداند، ولی `swipe()` در main روی false فوراً return میکند. اگر دستگاه بین آمادهشدن و کشیدن کارت بسته شود، پنل پرداخت ممکن است باز و بدون مسیر بازیابی بماند؛ cancel در idle false است. | بازتولید عبور ساعت پایان بین begin/ready/swipe؛ بدون برداشت، پیام خطا و پنل قابل بستن/تلاش مجدد، busy خاموش؛ تست اتصال UI |
|
||||||
|
| R02 | بالا | `canAffordAnything(state,machines)` در `checkEnd` test pointهای .5 قیمت را حتی در حالت غیرواقعی میشمارد. در UI و منطق بودجه تعریف «دستگاه فعال» یکسان نیست. با این حال factory نیز ارزان و شبانهروزی است، پس صرف حذف test در همین دادهها الزاماً نتیجهٔ تابع پایان را عوض نمیکند. دسترسی جغرافیایی نیز در تابع منظور نیست. | تعریف دستگاههای قابل احتساب بر اساس mode و دسترسی؛ تست حذف نقاط test در حالت عادی و مرز پول/ساعت |
|
||||||
|
| R03 | متوسط | `buildGraph` همهٔ یالها را دوطرفه میسازد؛ oneway فقط در traffic است. خط مسیریابی ماشین ممکن است خلاف جهت راهنمایی کند. | گراف رانندگی جهتدار، پیاده طبق سیاست مطلوب؛ تست مسیر مجاز/ممنوع در دو جهت و مسیر کارخانه |
|
||||||
|
| R04 | متوسط | reset متغیرهای lines/lineTimer، timeoutهای reward/banner و stick را پاک نمیکند؛ real tracking/AR را خاموش نمیکند؛ factory.reset فقط resetArrival است و چرخهٔ کامیون ادامه دارد. | ابتدا تعریف سیاست reset؛ سپس reset وسط دیالوگ/پاداش/joystick/GPS/بارگیری بدون پیام یا ورودی عملیات قبلی؛ تست lifecycle |
|
||||||
|
| R05 | متوسط | ساعت بازی حین پرداخت ادامه دارد؛ checkEnd هر نیمثانیه خریدها را میبیند ولی پاداش مأموریت بعد از تحویل اعمال میشود. اگر آخرین خرید پول را تمام کند، پایان بودجه میتواند پیش از جایزهٔ نجاتدهنده نمایش داده شود. | تست خرید آخر با جایزهٔ نقدی، درحالتحویل پایان ندهد؛ پس از تسویهٔ پاداش وضعیت نهایی محاسبه شود |
|
||||||
|
| R06 | متوسط | شرط سرعت GPS فقط interact است. پس از بازشدن shop/آغاز payment سرعت و fix در swipe مجدداً اعتبارسنجی نمیشوند؛ paused بودن shop نیز updateReal را متوقف میکند. | سیاست اعتبار fix/سرعت/رسیدن در تأیید خرید تعریف شود؛ سناریؤ تغییر سرعت یا مکان وسط پرداخت بدون برداشت نامعتبر |
|
||||||
|
| R07 | متوسط | SW نسخه را از نام فایلها میسازد و assets بدون hash مثل buildings.bin یا portrait در cache-first میمانند. تغییر فقط محتوای public با نام ثابت لزوماً cache جدید نمیسازد؛ cache imagery هم نسخه ندارد. | build با تغییر یک asset همنام؛ نسخهٔ نصبشده فایل تازه بگیرد و tile قدیمی طبق سیاست مشخص invalidate شود |
|
||||||
|
| R08 | متوسط | main دانلود فایل optional را catch میکند، ولی decodeBuildings خارج catch است. پاسخ موفق با bin خراب یا HTML fallback میتواند bootstrap را قطع کند. | header/length/count معتبر یا خطای کنترلشده و fallback OSM؛ تست فایل کوتاه، magic غلط، truncated vertex |
|
||||||
|
| R09 | متوسط | وقتی imagery index موجود است city از satellite mode استفاده میکند، اما tile غایب/failed فقط failed علامت میخورد و دوباره تلاش نمیشود. آفلاین در منطقهٔ دیدهنشده احتمالاً زمین کامل ندارد. | آزمون index موجود+JPG 404/offline؛ fallback خوانا و سیاست retry روشن |
|
||||||
|
| R10 | متوسط | `canAffordAnything` پس از بستهشدن همهٔ دستگاههای مقرونبهصرفه به فردا نگاه نمیکند، ولی ساعت نمایش و isOpen روزانه modulo دارند. مأموریت مدرسه بدون پنجرهٔ شکست/انتظار ممکن است معطل بماند. | تصمیم محصول دربارهٔ روز دوم، شکست/تکرار مأموریت و انتظار؛ تست ۱۳:۳۰، ۲۳:۰۰ و ۰۰:۰۰ |
|
||||||
|
| R11 | پایین | `places` پایان از همهٔ خریدها و denominator از کل machines است؛ testها حتی وقتی غیرفعالاند در کل مکانها میآیند. متن «با ماشین» فقط متن است و نه شرط مأموریت. | شمارش UI با دستگاه فعال و سیاست مأموریت مطابقت داشته باشد؛ پوشش test/real و خرید پیاده |
|
||||||
|
| R12 | متوسط | bundle JS حدود ۱٫۷۹ مگابایت و main بزرگ؛ world JSON و Three.js در payload شروعاند. تست فعلی FPS/زمان شروع یا مصرف حافظه را نمیسنجد. | baseline روی گوشی ضعیف قبل refactor؛ هدف عددی زمان شروع/FPS با توافق صاحب محصول، بدون حذف قابلیت |
|
||||||
|
|
||||||
|
R01 و R05 با import مستقیم توابع فعلی در Node نیز بررسی شدند:
|
||||||
|
|
||||||
|
- R01: مدرسه در minutes=809.98 وارد پرداخت/ready شد؛ در minutes=810، swipe برابر false، phase برابر idle، busy برابر false و balance همچنان 500 بود. مسیر main پس از false پنل را نمیبندد؛ اثر بصری پنل نیازمند بازتولید مرورگر است.
|
||||||
|
- R05: خرید آب کارخانه با balance=8، پس از swipe balance=0 و busy=true و canAffordAnything=false شد؛ completeMissions سپس balance=20 و canAffordAnything=true ساخت. این اختلاف پنجرهٔ checkEnd پیش از delivery را تأیید میکند؛ ترتیب نمایش modal نیازمند تست یکپارچه است.
|
||||||
|
- R02: در ۲۳:۰۰ با balance=8، با همهٔ تعاریف و بدون testها هر دو canAffordAnything=true بودند؛ کارخانه دلیل باقیماندن نتیجه است. این مورد ناهماهنگی دامنه/سیاست است، نه ادعای اثباتشدهٔ تغییر خروجی پایان صرفاً بهخاطر testها.
|
||||||
|
|
||||||
|
passing بودن تستهای فعلی رد سناریوهای بالا نیست.
|
||||||
|
|
||||||
|
## ناهماهنگی اسناد قدیمی
|
||||||
|
|
||||||
|
- README هنوز Gitea deploy را SSH و DEPLOY_HOST توضیح میدهد؛ workflow فعلی registry + Docker socket است.
|
||||||
|
- README میگوید خیابانها یکطرفه ندارند؛ داده و traffic one-way دارند اما routing ندارد. بهجای حکم کلی این تفکیک لازم است.
|
||||||
|
- README شروع را سالن تولید نوشته؛ spawn واقعی حیاط کنار ماشین است.
|
||||||
|
- آرشیو مکالمهٔ قدیمی از روبات جای کودک و جنگل میگوید؛ وضعیت فعلی خانواده انسانی و روبات دستگاه است.
|
||||||
|
|
||||||
|
## پیشنهادهای محصول؛ نیازمند تصمیم
|
||||||
|
|
||||||
|
ذخیرهٔ پیشرفت محلی با schemaVersion و migration؛ برنامهٔ آزمون کامل موبایل؛ قواعد واقعیتر ترافیک/چراغ؛ پیشرفت مناطق و شهرها؛ مأموریت کارخانه/نقش کارکنان؛ تجربهٔ GPS با اعتبارسنجی روشن؛ accessibility و کاهش حرکت/مصرف باتری. backend و leaderboard تصمیم معماری/محصول جدا میخواهند؛ آنها را صرفاً برای رفع یک UI bug اضافه نکنید.
|
||||||
|
|
||||||
|
## ترتیب اجرایی پیشنهادی
|
||||||
|
|
||||||
|
ابتدا صحت تراکنش و reset (R01/R04/R05)، سپس منطق mode/پایان (R02/R06/R10/R11)، سپس قابلیت اطمینان offline/data (R07/R08/R09)، سپس route direction (R03) و اندازهگیری کارایی (R12). هر تغییر بهتر است تسک مستقل با تست مربوط باشد.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# قرارداد تحویل تسک به Agent
|
||||||
|
|
||||||
|
## متن شروع پیشنهادی
|
||||||
|
|
||||||
|
> ابتدا docs/README.md و سندهای مرتبط با این تسک را بخوان. سپس کد فعلی و git status را بررسی کن؛ مستندات snapshot هستند و ممکن است پس از commit مبنا تغییر کرده باشند. رفتار فعلی، تصمیم محصول و پیشنهادهای آینده را یکی نگیر. فقط محدودهٔ این تسک را پیاده کن و مدارک تست، محدودیت و مستندات بهروز را تحویل بده.
|
||||||
|
|
||||||
|
## قالب آمادهٔ تسک
|
||||||
|
|
||||||
|
```text
|
||||||
|
عنوان:
|
||||||
|
هدف کاربر و مسئله:
|
||||||
|
مراجع docs و شناسهٔ بکلاگ:
|
||||||
|
رفتار فعلی و مراحل بازتولید:
|
||||||
|
رفتار مطلوب (تصمیم صاحب بازی):
|
||||||
|
دامنهٔ تغییر / فایلهای احتمالی:
|
||||||
|
خارج از دامنه:
|
||||||
|
قواعدی که باید حفظ شوند:
|
||||||
|
معیار پذیرش قابل مشاهده:
|
||||||
|
سناریوهای خطا و مرزها:
|
||||||
|
تست خودکار مرتبط:
|
||||||
|
تست دستی desktop/mobile/GPS/offline در صورت نیاز:
|
||||||
|
نیاز به asset یا دادهٔ بیرونی:
|
||||||
|
خروجی مورد انتظار (کد، سند، نتیجهٔ تست، تصویر در صورت نیاز):
|
||||||
|
مجوز commit/push/deploy و remote مقصد، اگر خواسته شده:
|
||||||
|
```
|
||||||
|
|
||||||
|
## چکلیست Agent
|
||||||
|
|
||||||
|
1. دستورهای AGENTS/RTK قابل اعمال و وضعیت تغییرات کاربر را بخوان؛ remote و branch را فرض نکن.
|
||||||
|
2. منبع حقیقت تسک را مشخص کن: اقتصاد game-state/scoring؛ مکان locations؛ نمایش main/style؛ حسگر realworld/ar؛ انتشار workflow/compose.
|
||||||
|
3. قبل از تغییر، تست مربوط و مسیر وابستگی را بفهم. برای bug ابتدا failing regression معنادار بساز؛ برای متن صرف تست تقلیدی نساز.
|
||||||
|
4. تغییر را با دامنهٔ کوچک انجام بده. محاسبهٔ قیمت یا شرط مأموریت را در UI دوباره تعریف نکن.
|
||||||
|
5. مقدار پول قبل/پس، busy، تراکنش یکتا، mode، timer و رفتار لغو را در تسکهای مرتبط حفظ کن.
|
||||||
|
6. تست مرتبط و build را اجرا کن؛ برای GPU/permissions/offline از تست Node نتیجهٔ بصری استنتاج نکن.
|
||||||
|
7. سند مرتبط را هم اصلاح کن؛ قابلیت آینده را «انجامشده» علامت نزن مگر معیار پذیرش محقق شده باشد.
|
||||||
|
8. نتیجه را با فایلها، شواهد، ریسک/محدودیت و عملیات واقعاً انجامشده گزارش کن.
|
||||||
|
|
||||||
|
## نمونهٔ تسک مستقل
|
||||||
|
|
||||||
|
عنوان: بازیابی UI پس از بستهشدن دستگاه در مرحلهٔ پرداخت (R01).
|
||||||
|
|
||||||
|
معیار پذیرش: در ۱۳:۲۹:۵۹ از مدرسه پرداخت را شروع کن، قبل swipe ساعت از ۱۳:۳۰ بگذرد؛ پول/خرید تغییر نکنند، پیام بستهبودن نمایش داده شود، پنل گیر نکند، کنترل حرکت برگردد و کاربر دستگاه دیگری انتخاب کند. لغو عادی و خرید موفق/برداشت یکباره همچنان پاس باشند. تغییر به حفظ ساعت پرداخت تنها با تصمیم صریح محصول انجام شود.
|
||||||
|
|
||||||
|
این نمونه پیشنهاد است و اجرای آن با ساخت این سند مجاز/انجامشده تلقی نمیشود.
|
||||||
|
|
||||||
|
## دستورهای مخزن و توصیف فناوری
|
||||||
|
|
||||||
|
در پایان این بررسی یک فایل AGENTS.md با دستورهای عمومی React/Next.js در workspace دیده شد. آن فایل از تغییرات این نوبت نیست. Agent توسعه باید نسخهٔ حاضر آن را پیش از کار بخواند و دستورهای قابل اعمال را رعایت کند؛ توصیف فناوری در این اسناد گزارش وضعیت واقعی کد است، نه جایگزین AGENTS یا مجوز مهاجرت به React/Redux/Tailwind. انتخاب زبان/UI برای توسعهٔ بعدی و هر تعارض عملی باید در همان تسک روشن شود. برای این تسک مستندسازی، زبان برنامه و کتابخانهٔ رابط تغییر نکردهاند.
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# مرجع دقیق دادههای قابل بازی
|
||||||
|
|
||||||
|
این جدول از exportهای کد در snapshot 751af76 استخراج شده است؛ با تغییر locations/game-state/scoring باید بهروزرسانی شود.
|
||||||
|
|
||||||
|
## دستگاهها
|
||||||
|
|
||||||
|
### دستگاه دفتر فنی مهندسی — factory
|
||||||
|
|
||||||
|
- دسته: کارخانه (factory)
|
||||||
|
- نشانی: بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان، ساختمان اداری، دفتر فنی مهندسی
|
||||||
|
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
|
||||||
|
- موقعیت تعریفشده: factory.demo در factory.js
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 8 |
|
||||||
|
| آبمیوه | juice | drink | 18 |
|
||||||
|
| چای سرد | tea | drink | 23 |
|
||||||
|
| بیسکویت | biscuit | snack | 13 |
|
||||||
|
| شکلات | chocolate | snack | 15 |
|
||||||
|
|
||||||
|
### بوستان ملت — mellat-park
|
||||||
|
|
||||||
|
- دسته: پارک (park)
|
||||||
|
- نشانی: بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت
|
||||||
|
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 1
|
||||||
|
- موقعیت تعریفشده: anchor X=-487.6, Z=-326.4
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: نوشیدنیهای خنک برای پیادهروی در پارک
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 15 |
|
||||||
|
| آبمیوه | juice | drink | 35 |
|
||||||
|
| لیموناد | lemonade | drink | 40 |
|
||||||
|
| چای سرد | tea | drink | 45 |
|
||||||
|
| بیسکویت | biscuit | snack | 25 |
|
||||||
|
| شکلات | chocolate | snack | 30 |
|
||||||
|
| کیک | cake | snack | 35 |
|
||||||
|
| آجیل | nuts | snack | 65 |
|
||||||
|
|
||||||
|
### مجموعه آموزشی راحیل — school-rahil
|
||||||
|
|
||||||
|
- دسته: مدرسه (school)
|
||||||
|
- نشانی: بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه
|
||||||
|
- ساعت: 07:30 تا 13:30؛ ضریب قیمت 0.9
|
||||||
|
- موقعیت تعریفشده: anchor X=-554.6, Z=-569.7
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 14 |
|
||||||
|
| شیر کاکائو | milk | drink | 36 |
|
||||||
|
| آبمیوه | juice | drink | 32 |
|
||||||
|
| بیسکویت | biscuit | snack | 23 |
|
||||||
|
| کیک | cake | snack | 32 |
|
||||||
|
|
||||||
|
### دانشگاه فردوسی مشهد — ferdowsi-university
|
||||||
|
|
||||||
|
- دسته: دانشگاه (university)
|
||||||
|
- نشانی: میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی
|
||||||
|
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 0.8
|
||||||
|
- موقعیت تعریفشده: anchor X=-936, Z=645
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: تخفیف دانشجویی: ۲۰٪ ارزانتر
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 12 |
|
||||||
|
| چای سرد | tea | drink | 36 |
|
||||||
|
| شیر کاکائو | milk | drink | 32 |
|
||||||
|
| بیسکویت | biscuit | snack | 20 |
|
||||||
|
| شکلات | chocolate | snack | 24 |
|
||||||
|
| آجیل | nuts | snack | 52 |
|
||||||
|
| کیک | cake | snack | 28 |
|
||||||
|
|
||||||
|
### دانشکدهٔ ادبیات دکتر شریعتی — literature-faculty
|
||||||
|
|
||||||
|
- دسته: دانشگاه (university)
|
||||||
|
- نشانی: پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات
|
||||||
|
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 0.8
|
||||||
|
- موقعیت تعریفشده: anchor X=-417.1, Z=966.3
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: تخفیف دانشجویی: ۲۰٪ ارزانتر
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 12 |
|
||||||
|
| چای سرد | tea | drink | 36 |
|
||||||
|
| لیموناد | lemonade | drink | 32 |
|
||||||
|
| بیسکویت | biscuit | snack | 20 |
|
||||||
|
| شکلات | chocolate | snack | 24 |
|
||||||
|
|
||||||
|
### دانشگاه علوم پزشکی مشهد — medical-university
|
||||||
|
|
||||||
|
- دسته: دانشگاه (university)
|
||||||
|
- نشانی: میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد
|
||||||
|
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 0.8
|
||||||
|
- موقعیت تعریفشده: anchor X=-272.1, Z=150.6
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: تخفیف دانشجویی: ۲۰٪ ارزانتر
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 12 |
|
||||||
|
| آبمیوه | juice | drink | 28 |
|
||||||
|
| شیر کاکائو | milk | drink | 32 |
|
||||||
|
| آجیل | nuts | snack | 52 |
|
||||||
|
| بیسکویت | biscuit | snack | 20 |
|
||||||
|
|
||||||
|
### ایستگاه مترو آزادی — azadi-metro
|
||||||
|
|
||||||
|
- دسته: ایستگاه مترو (metro)
|
||||||
|
- نشانی: بولوار ملکآباد، ورودی ایستگاه مترو آزادی
|
||||||
|
- ساعت: 06:00 تا 22:00؛ ضریب قیمت 1
|
||||||
|
- موقعیت تعریفشده: anchor X=494.1, Z=280.3
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: دستگاه کنار گیتهای مترو
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 15 |
|
||||||
|
| چای سرد | tea | drink | 45 |
|
||||||
|
| آبمیوه | juice | drink | 35 |
|
||||||
|
| شکلات | chocolate | snack | 30 |
|
||||||
|
| بیسکویت | biscuit | snack | 25 |
|
||||||
|
|
||||||
|
### مشهد مال — mashhad-mall
|
||||||
|
|
||||||
|
- دسته: مرکز خرید (mall)
|
||||||
|
- نشانی: خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال
|
||||||
|
- ساعت: 10:00 تا 23:00؛ ضریب قیمت 1.1
|
||||||
|
- موقعیت تعریفشده: anchor X=1940.6, Z=-367.8
|
||||||
|
- دستگاه عادی
|
||||||
|
- توضیح: بیشترین تنوع محصول؛ قیمت کمی بالاتر
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 17 |
|
||||||
|
| آبمیوه | juice | drink | 39 |
|
||||||
|
| شیر کاکائو | milk | drink | 44 |
|
||||||
|
| چای سرد | tea | drink | 50 |
|
||||||
|
| لیموناد | lemonade | drink | 44 |
|
||||||
|
| شکلات | chocolate | snack | 33 |
|
||||||
|
| کیک | cake | snack | 39 |
|
||||||
|
| آجیل | nuts | snack | 72 |
|
||||||
|
| بیسکویت | biscuit | snack | 28 |
|
||||||
|
|
||||||
|
### نقطهٔ آزمایشی ۱ — test-1
|
||||||
|
|
||||||
|
- دسته: نقطهٔ آزمایشی GPS (test)
|
||||||
|
- نشانی: کنار کارخانهٔ روبات مارکت · 36.450276, 59.423431
|
||||||
|
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
|
||||||
|
- موقعیت تعریفشده: GPS 36.450276, 59.423431
|
||||||
|
- فقط حالت دنیای واقعی؛ نقطهٔ آزمایشی
|
||||||
|
- توضیح: برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 8 |
|
||||||
|
| بیسکویت | biscuit | snack | 13 |
|
||||||
|
|
||||||
|
### نقطهٔ آزمایشی ۲ — test-2
|
||||||
|
|
||||||
|
- دسته: نقطهٔ آزمایشی GPS (test)
|
||||||
|
- نشانی: کنار کارخانهٔ روبات مارکت · 36.450172, 59.423209
|
||||||
|
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
|
||||||
|
- موقعیت تعریفشده: GPS 36.450172, 59.423209
|
||||||
|
- فقط حالت دنیای واقعی؛ نقطهٔ آزمایشی
|
||||||
|
- توضیح: برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 8 |
|
||||||
|
| بیسکویت | biscuit | snack | 13 |
|
||||||
|
|
||||||
|
### نقطهٔ آزمایشی ۳ — test-3
|
||||||
|
|
||||||
|
- دسته: نقطهٔ آزمایشی GPS (test)
|
||||||
|
- نشانی: کنار کارخانهٔ روبات مارکت · 36.449976, 59.422866
|
||||||
|
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
|
||||||
|
- موقعیت تعریفشده: GPS 36.449976, 59.422866
|
||||||
|
- فقط حالت دنیای واقعی؛ نقطهٔ آزمایشی
|
||||||
|
- توضیح: برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید
|
||||||
|
|
||||||
|
| محصول | productId | نوع | قیمت نهایی هتومان |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| آب معدنی | water | drink | 8 |
|
||||||
|
| بیسکویت | biscuit | snack | 13 |
|
||||||
|
|
||||||
|
## مأموریتها
|
||||||
|
|
||||||
|
| ترتیب | id | عنوان | target | XP | هتومان | شرط تکمیل واقعی |
|
||||||
|
|---:|---|---|---|---:|---:|---|
|
||||||
|
| 1 | factory | در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن | factory | 100 | 20 | وجود خرید factory |
|
||||||
|
| 2 | first | با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده | mellat-park | 150 | 30 | وجود خرید mellat-park |
|
||||||
|
| 3 | school | قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن | school-rahil | 200 | 40 | وجود خرید school-rahil؛ ساعت خرید از اعتبارسنجی خرید میآید |
|
||||||
|
| 4 | campus | به دانشگاه فردوسی برو و یک خوراکی بخر | ferdowsi-university | 200 | 40 | خرید snack از ferdowsi-university |
|
||||||
|
| 5 | metro | از دستگاه ایستگاه مترو آزادی خرید کن | azadi-metro | 150 | 30 | وجود خرید azadi-metro |
|
||||||
|
| 6 | mall | با ماشین تا مشهد مال برو و خرید کن | mashhad-mall | 250 | 50 | وجود خرید mashhad-mall |
|
||||||
|
| 7 | explorer | از ۵ مکان متفاوت شهر خرید کن | پویا/نزدیکترین بازدیدنشده | 400 | 80 | حداقل ۵ machineId متفاوت در پنج دستهٔ شهری؛ factory/test حذف |
|
||||||
|
|
||||||
|
«پنج مکان» با «پنج دسته» متفاوت است: explorer مکان متمایز میشمارد؛ پاداش allCategories دسته متمایز. خریدهای از پیش انجامشده میتوانند مأموریت غیر فعال را هم تکمیل کنند. رسیدن جغرافیایی یا سوارشدن ماشین در شرط مأموریت ذخیره نمیشود.
|
||||||
|
|
||||||
|
## نقاط ثابت
|
||||||
|
|
||||||
|
مرکز کارخانه X=-10308.4, Z=-14964.2 و محور (0.7071067811865476, 0.7071067811865476)؛ مکان دقیق ماشین و خانواده از factory.start/car محاسبه میشود.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# وضعیت پس از انتقال به React
|
||||||
|
|
||||||
|
این سند بر اسناد snapshot پیش از ریفکتور اولویت دارد. زبان JavaScript و رابط React 18 + Vite است؛ Ant Design و TypeScript اضافه نشدهاند. Tailwind CSS استایل رابط را تولید میکند. Three.js و نسخهٔ قبلی Vite ارتقا داده نشدهاند.
|
||||||
|
|
||||||
|
## معماری
|
||||||
|
|
||||||
|
- `src/main.jsx` و `routes/AppRoutes.jsx`: Provider و lazy/Suspense برای صفحهٔ بازی.
|
||||||
|
- `pages/game/`: briefing، HUD، نقشه، خرید/پرداخت، زیرنویس و نمایشگاه با JSX؛ بدون innerHTML و بدون ارسال مستقیم HTTP از کامپوننت.
|
||||||
|
- `components/shared/GameNode.jsx`: نمایش props و هماهنگی modal بومی؛ از store یا services وابستگی ندارد.
|
||||||
|
- `hooks/useGameEngine.js`: بارگذاری موتور و cleanup در unmount.
|
||||||
|
- `game/createGame.js`: راهاندازی مرتب زیرسیستمها از runtimeBindings؛ توقف مشترک با AbortSignal.
|
||||||
|
- `game/runtime/`: چرخهٔ عمر، ورودی/حرکت، خرید، نقشه، HUD، برخوردهای داستانی، GPS/AR و render loop جدا هستند. context فقط برای state زندهٔ شبیهسازی و اشیای غیرقابل سریالسازی است؛ داخل Redux renderer/Scene/Texture نمیگذاریم.
|
||||||
|
- `game/models/`: ساخت هندسهٔ کارخانه، خانواده، کارکنان، خودرو، دستگاه و نمونههای نمایشگاه. factory/city/family اکنون مراحل مستقل ساخت و helperهای نامدار دارند.
|
||||||
|
- `game/economy/`: gameState، payment و scoring با قرارداد اقتصادی قبلی.
|
||||||
|
- `constants/`: مکانها و showroomCatalog؛ `utils/`: geo و showroomAnswers؛ `types/`: JSDoc قراردادها.
|
||||||
|
- `services/apiClient.js`: تنها Axios instance، timeout و error normalization. worldService دادهٔ optional و tileها را میگیرد؛ imageryDownloadService اسکریپت آمادهسازی تصویر را پوشش میدهد.
|
||||||
|
|
||||||
|
## مرز Redux و موتور
|
||||||
|
|
||||||
|
slice `game` تصویر قابل سریالسازی پیشرفت اقتصادی را برای بخشهای مشترک رابط دارد. موتور تنها نویسندهٔ قواعد شبیهسازی است؛ رابط فرمان میفرستد و مستقلاً پول/XP تغییر نمیدهد. `interface` snapshot نمایش را نگه میدارد. bridge وضعیت pause را همزمان برای موتور در دسترس میگذارد و انتشار React را به ده بار در ثانیه محدود میکند؛ WebGL مستقل render میکند. callbackها در registry محلی bridgeاند و در Redux قرار ندارند. وضعیت موقت preview تصویر داخل کامپوننت است.
|
||||||
|
|
||||||
|
## استایل
|
||||||
|
|
||||||
|
`styles/game.css` ورودی Tailwind است. `styles/gameTheme.js` لایهٔ پایه و tokenهای ظاهر قبلی را برای جلوگیری از تغییر ناخواستهٔ UI نگه میدارد و از plugin تنظیمات Tailwind تولید میشود. کامپوننتهای جدید نمایشگاه از utilityهای Tailwind استفاده میکنند. inline style تنها برای مختصات پویا/پیشرفت به کار میرود. ادامهٔ طراحی نباید لایهٔ CSS مستقل موازی بسازد.
|
||||||
|
|
||||||
|
## نمایشگاه
|
||||||
|
|
||||||
|
سه مدل rm-35/rm-48/rm-60 در حیاط، نزدیک نقطهٔ شروع و خودرو هستند. نمایش سهبعدی نمونهها بهترتیب ۳۵، ۴۸ و ۶۰ slot دارد. با نزدیکشدن، نشان تعامل Q ظاهر میشود؛ در محوطه، دکمهٔ «مدلهای کارخانه» نیز کاتالوگ را باز میکند. روبات قیمت و امکانات را از یک کاتالوگ مشترک پاسخ میدهد.
|
||||||
|
|
||||||
|
قیمتها به ریالاند: ۴٬۶۸۰٬۰۰۰٬۰۰۰؛ ۵٬۴۸۰٬۰۰۰٬۰۰۰؛ ۵٬۹۸۰٬۰۰۰٬۰۰۰. اینها قیمت دستگاه کاملاند و به اقتصاد خوراکی/هتومان، XP، موجودی و مأموریت وصل نیستند. پاسخها محلی و تعیینشدهاند؛ backend یا مدل زبانی بیرونی ندارند.
|
||||||
|
|
||||||
|
مسیر فایلهای `images/machine-*-base.webp`، `images/machine-*-white.webp` و `gifs/machine-*.gif` ثبت شده، اما فایل اصلی هنوز تحویل نشده است. UI نبود تصویر را بهوضوح نشان میدهد و مدل سهبعدی مستقل وجود دارد. مسیرهای ریشهای کاتالوگ هنگام نمایش نسبت به پوشهٔ انتشار حل میشوند تا GitHub Pages هم کار کند.
|
||||||
|
|
||||||
|
## آزمون و انتشار
|
||||||
|
|
||||||
|
`npm test` شامل تست Node قبلی و Vitest/React Testing Library است. CIهای GitHub/Gitea و Dockerfile نیز همین فرمان را اجرا میکنند. `npm run lint`، `npm run format:check` و `npm run build` بررسیهای دیگرند. نسخهٔ Vitest تازه انتخاب شده فاقد هشدارهای audit مرحلهٔ نصب است؛ نسخههای قبلی بازی ارتقا نیافتند.
|
||||||
|
|
||||||
|
Service Worker همچنان در Vite تولید میشود. با تأیید کاربر، `fetch` بومی داخل آن استثنای قاعدهٔ Axios است؛ interception و cache آفلاین آن حفظ شدهاند. assetهای جدید build در precache میآیند؛ tileهای ماهوارهای فقط پس از مشاهده cache میشوند. صف ارسال آنلاین جدید اضافه نشده است.
|
||||||
|
|
||||||
|
## محدودیت بررسی
|
||||||
|
|
||||||
|
اجرای مرورگر محلی برای briefing، شروع، HUD، نقشه و پرسیدن قیمت نمایشگاه انجام شده است. تست حسگر واقعی، GPS میدانی، camera permission روی گوشی و انتشار روی سرور در این ریفکتور انجام نشدهاند. یافتههای رفتاری قدیمی docs/08 الزاماً اصلاح نشدهاند؛ آنها تسکهای جدا هستند. بلوک داده/هندسه و بعضی الگوریتمهای قدیمی هنوز میتوانند طولانیتر از هدف تقریبی ۳۰ خط باشند؛ ادعای انطباق صددرصد تمام توابع و nesting وجود ندارد. برای تغییر بعدی هر ماژول، قواعد AGENTS روی کد تازه الزامی است.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# مرجع توسعهٔ بازی روبات مارکت
|
||||||
|
|
||||||
|
تاریخ بررسی: ۲۰۲۶/۱۰/۲۸ — مبنا: commit `751af76` در checkout محلی. این مجموعه از کد فعلی، تستها، فایلهای انتشار و تاریخچهٔ گیت استخراج شده است. وضعیت سرویسهای آنلاین و اجرای بصری روی گوشی در این بررسی تأیید نشدهاند.
|
||||||
|
|
||||||
|
## وضعیت جدید React
|
||||||
|
|
||||||
|
ابتدا [معماری و قابلیتهای پس از انتقال به React](11-react-migration.md) را بخوانید. اسناد زیر snapshot پیش از ریفکتورند؛ قواعد اقتصادی حفظ شدهاند، اما مسیر فایلها، فناوری UI و فرمان تست تغییر کردهاند.
|
||||||
|
|
||||||
|
## مسیر مطالعه برای Agent
|
||||||
|
|
||||||
|
۱. [وضعیت و دامنه](01-product-and-scenario.md)
|
||||||
|
۲. [قوانین و اقتصاد](02-game-rules.md)
|
||||||
|
۳. [معماری و فناوری](03-architecture.md)
|
||||||
|
۴. [جهان، نقشه و داده](04-world-and-data.md)
|
||||||
|
۵. [GPS، AR و آفلاین](05-device-and-offline.md)
|
||||||
|
۶. [اجرا، تست و انتشار](06-development-and-deployment.md)
|
||||||
|
۷. [تاریخچهٔ کارهای انجامشده](07-implemented-plans.md)
|
||||||
|
۸. [بازبینی کد و بکلاگ پیشنهادی](08-review-and-backlog.md)
|
||||||
|
۹. [راهنمای تحویل تسک به Agent](09-agent-task-guide.md)
|
||||||
|
۱۰. [فهرست دقیق دستگاهها و مأموریتها](10-game-reference.md)
|
||||||
|
|
||||||
|
## نحوهٔ استفاده
|
||||||
|
|
||||||
|
- «پیادهسازیشده» یعنی در کد وجود دارد؛ تضمین صحت بصری یا آمادهبودن محیط production نیست.
|
||||||
|
- «یافتهٔ کد» از بررسی استاتیک به دست آمده است؛ موارد نیازمند بازتولید صریحاً مشخص شدهاند.
|
||||||
|
- «پیشنهاد» تصمیم محصول یا تسک مصوب محسوب نمیشود. صاحب بازی باید اولویت و رفتار مطلوب را تعیین کند.
|
||||||
|
- کد مرجع رفتار فعلی است. تغییر اقتصاد، سناریو، داده یا انتشار باید همراه بهروزرسانی سند مربوط انجام شود.
|
||||||
|
- سناریوی جنگل و پیکنیک در تاریخچه قرار دارد؛ شرح نسخهٔ فعال نیست. روبات فعلی راهنمای دستگاه است و جای کودک راه نمیرود.
|
||||||
|
- مستندات هیچ مجوزی برای push، deploy، تغییر سرور، دانلود مجدد داده یا استفاده از secret ایجاد نمیکنند؛ محدودهٔ تسک کاربر تعیینکننده است.
|
||||||
|
|
||||||
|
## نتیجهٔ بررسی این نوبت
|
||||||
|
|
||||||
|
چهار فایل تست فعلی با ۱۶ تعریف تست بررسی و اجرای آنها موفق شد. `npm run build` موفق بود: ۳۳ ماژول، JavaScript حدود ۱٬۷۹۴٫۳۵ کیلوبایت (gzip حدود ۵۷۶٫۶۱)، CSS حدود ۱۹٫۷۰ کیلوبایت. هشدار chunk بزرگتر از ۵۰۰ کیلوبایت باقی است. این اعداد اندازهٔ bundle هستند، نه اندازهٔ کل public یا اندازهگیری FPS.
|
||||||
|
|
||||||
|
در این نوبت فقط مستندات اضافه/اصلاح میشوند؛ یافتههای گزارششده بهصورت ضمنی اصلاح نشدهاند. جزئیات محدودیت آزمون و کارهای پیشنهادی در سند بازبینی آمده است.
|
||||||
|
|
||||||
|
## محدودهٔ فایلهای این تحویل
|
||||||
|
|
||||||
|
خروجی این کار پوشهٔ docs و لینک آن در README ریشه است. هنگام پایان بررسی، AGENTS.md و دو فایل src/data/iran.json و src/data/mellat.json نیز در وضعیت تغییرکردهٔ workspace دیده شدند؛ آنها توسط این کار ایجاد/ویرایش نشدهاند و در تحویل مستندسازی منظور نیستند. مبنای کد برنامه 751af76 است؛ شمارش دادهها از فایلهای حاضر هنگام بررسی گرفته شده و باید پس از هر بازتولید داده تازه شود.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import hooks from 'eslint-plugin-react-hooks';
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**', 'public/**', 'src/data/**', 'docs/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx,mjs}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^[A-Z]', ignoreRestSiblings: true },
|
||||||
|
],
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{js,jsx}'],
|
||||||
|
plugins: { 'react-hooks': hooks },
|
||||||
|
rules: { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'error' },
|
||||||
|
},
|
||||||
|
];
|
||||||
+19
-173
@@ -1,176 +1,22 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<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 name="description" content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاههای وندینگ را در مشهد پیدا کن و امتیاز بگیر.">
|
<!--seo-head-->
|
||||||
<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" />
|
||||||
<meta name="apple-mobile-web-app-title" content="روبات مارکت">
|
<meta name="apple-mobile-web-app-title" content="روبات مارکت" />
|
||||||
<link rel="manifest" href="./manifest.webmanifest">
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/png" href="./icons/favicon-64.png">
|
<link rel="icon" type="image/png" href="./icons/favicon-64.png" />
|
||||||
<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>
|
||||||
<video id="camera" playsinline muted autoplay hidden></video>
|
<!--seo-summary-->
|
||||||
<canvas id="world"></canvas>
|
<div id="root"></div>
|
||||||
<div id="loading">در حال ساختن شهر مشهد…</div>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
<!-- Mission briefing over the map of Iran -->
|
|
||||||
<section id="briefing">
|
|
||||||
<div class="brief-map"><canvas id="iran"></canvas></div>
|
|
||||||
<div class="brief-panel">
|
|
||||||
<img class="brief-robot" src="./robot/front.webp" alt="روبات راهنمای روبات مارکت" width="136" height="223">
|
|
||||||
<small class="tag">عملیات ۰۱ · خراسان رضوی</small>
|
|
||||||
<h1>عملیات مشهد</h1>
|
|
||||||
<p class="sub">روبات مارکت · شکار دستگاههای وندینگ</p>
|
|
||||||
<pre id="brief-text" aria-live="polite"></pre>
|
|
||||||
<button id="start-mission" type="button">شروع عملیات</button>
|
|
||||||
<div class="brief-actions">
|
|
||||||
<button id="real-mode" type="button" class="ghost">📍 بازی در دنیای واقعی (GPS)</button>
|
|
||||||
<button id="install" type="button" class="ghost" hidden>⬇ نصب بازی روی گوشی</button>
|
|
||||||
</div>
|
|
||||||
<p class="keys">حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- In-game HUD -->
|
|
||||||
<div id="hud" hidden>
|
|
||||||
<canvas id="compass" width="560" height="44"></canvas>
|
|
||||||
<button id="minimap-button" type="button" aria-label="باز کردن نقشهٔ شهر (M)">
|
|
||||||
<canvas id="minimap" width="220" height="220"></canvas>
|
|
||||||
<span class="map-hint">نقشه · M</span>
|
|
||||||
</button>
|
|
||||||
<div id="location"><span id="street">مشهد</span><small id="mode-label">پیاده</small><small id="gps-status" hidden></small></div>
|
|
||||||
<aside id="objectives">
|
|
||||||
<h2>مأموریتها</h2>
|
|
||||||
<ol id="mission-list"></ol>
|
|
||||||
</aside>
|
|
||||||
<div id="waypoint" hidden><i></i><b id="waypoint-name"></b><span id="waypoint-distance"></span></div>
|
|
||||||
<div id="prompt" hidden></div>
|
|
||||||
<div id="subtitle" hidden><img id="subtitle-face" alt="" hidden><b id="subtitle-speaker"></b><span id="subtitle-text"></span></div>
|
|
||||||
<div id="awards" aria-live="polite"></div>
|
|
||||||
<div id="banner" hidden></div>
|
|
||||||
<div id="status">
|
|
||||||
<div class="stat"><small>بودجه</small><strong id="balance">۵۰۰</strong><span>هتومان</span></div>
|
|
||||||
<div class="stat"><small>ساعت</small><strong id="clock">۰۸:۳۰</strong></div>
|
|
||||||
<div class="stat" id="speed-stat" hidden><small>سرعت</small><strong id="speed">۰</strong><span>km/h</span></div>
|
|
||||||
</div>
|
|
||||||
<div id="rank"><b id="rank-name">تازهوارد</b><span id="xp">۰ XP</span><div class="bar"><i id="xp-bar"></i></div></div>
|
|
||||||
<div id="toolbar">
|
|
||||||
<button id="gps-toggle" type="button">📍 دنیای واقعی</button>
|
|
||||||
<button id="ar-toggle" type="button" hidden>📷 دوربین</button>
|
|
||||||
<button id="stable-toggle" type="button" class="on" hidden>🎯 پایدار: روشن</button>
|
|
||||||
<button id="sound" type="button" aria-pressed="false">صدا: خاموش</button>
|
|
||||||
<button id="quality" type="button">گرافیک</button>
|
|
||||||
<button id="restart" type="button">شروع دوباره</button>
|
|
||||||
</div>
|
|
||||||
<div id="touch" hidden>
|
|
||||||
<div id="stick"><i></i></div>
|
|
||||||
<div class="touch-buttons">
|
|
||||||
<button type="button" data-key="f">F</button>
|
|
||||||
<button type="button" data-key="e">E</button>
|
|
||||||
<button type="button" data-key="shift">دویدن</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="crosshair"></div>
|
|
||||||
<small id="attribution">نقشه: © مشارکتکنندگان OpenStreetMap</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment at the card reader -->
|
|
||||||
<section id="payment" hidden>
|
|
||||||
<small>کارتخوان روبات مارکت</small>
|
|
||||||
<h2 id="payment-title"></h2>
|
|
||||||
<p id="payment-step"></p>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="swipe" type="button" disabled>کشیدن کارت · C</button>
|
|
||||||
<button id="cancel-payment" type="button" class="ghost">انصراف</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<dialog id="map-dialog">
|
|
||||||
<header>
|
|
||||||
<div class="tabs" role="tablist">
|
|
||||||
<button type="button" role="tab" data-tab="city" aria-selected="true">مشهد</button>
|
|
||||||
<button type="button" role="tab" data-tab="iran" aria-selected="false">ایران</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="close" id="map-close" aria-label="بستن">✕</button>
|
|
||||||
</header>
|
|
||||||
<div class="map-body">
|
|
||||||
<div class="map-view">
|
|
||||||
<canvas id="fullmap"></canvas>
|
|
||||||
<canvas id="iran-map" hidden></canvas>
|
|
||||||
<p class="map-help">کشیدن: جابهجایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب</p>
|
|
||||||
</div>
|
|
||||||
<aside class="map-side">
|
|
||||||
<h3>دستگاههای روبات مارکت</h3>
|
|
||||||
<div id="machine-detail" hidden>
|
|
||||||
<h4 id="detail-name"></h4>
|
|
||||||
<p id="detail-address"></p>
|
|
||||||
<p id="detail-meta"></p>
|
|
||||||
<p id="detail-products"></p>
|
|
||||||
<button id="route-button" type="button">مسیریابی تا اینجا</button>
|
|
||||||
</div>
|
|
||||||
<ul id="machine-list"></ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="shop">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<small id="shop-category"></small>
|
|
||||||
<h2 id="shop-name"></h2>
|
|
||||||
<p id="shop-address"></p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="close" id="shop-close" aria-label="بستن">✕</button>
|
|
||||||
</header>
|
|
||||||
<p id="shop-note"></p>
|
|
||||||
<div id="products"></div>
|
|
||||||
<p class="rate">۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشیاند و پرداخت واقعی انجام نمیشود.</p>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="real-intro">
|
|
||||||
<img src="./robot/serious.webp" alt="" width="72" height="68">
|
|
||||||
<h2>بازی در دنیای واقعی</h2>
|
|
||||||
<p>با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده میشود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، میتوانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.</p>
|
|
||||||
<ul>
|
|
||||||
<li>هنگام راه رفتن به خیابان و ماشینها نگاه کنید، نه به صفحه.</li>
|
|
||||||
<li>بچهها فقط همراه یک بزرگتر بازی کنند.</li>
|
|
||||||
<li>هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال میشود.</li>
|
|
||||||
<li>موقعیت شما فقط روی همین گوشی میماند و جایی ارسال نمیشود.</li>
|
|
||||||
</ul>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" id="real-go">قبول دارم، شروع کن</button>
|
|
||||||
<button type="button" id="real-cancel" class="ghost">انصراف</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="ios-install">
|
|
||||||
<h2>نصب روی آیفون</h2>
|
|
||||||
<ol>
|
|
||||||
<li>در Safari دکمهٔ <b>Share</b> (مربع با فلش رو به بالا) را بزنید.</li>
|
|
||||||
<li>گزینهٔ <b>Add to Home Screen</b> را انتخاب کنید.</li>
|
|
||||||
<li>روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار میگیرد و آفلاین هم اجرا میشود.</li>
|
|
||||||
</ol>
|
|
||||||
<button type="button" id="ios-close">متوجه شدم</button>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<div id="update-toast" hidden>نسخهٔ جدید بازی آماده است <button type="button" id="update-reload">بهروزرسانی</button></div>
|
|
||||||
|
|
||||||
<dialog id="end">
|
|
||||||
<small id="end-tag">پایان عملیات</small>
|
|
||||||
<h2 id="end-title"></h2>
|
|
||||||
<p id="end-text"></p>
|
|
||||||
<ul id="end-stats"></ul>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="again" type="button">عملیات دوباره</button>
|
|
||||||
<button id="continue" type="button" class="ghost">ادامهٔ گشت در شهر</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+4247
-4
File diff suppressed because it is too large
Load Diff
+30
-2
@@ -5,7 +5,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"format": "prettier --write \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" \"*.{js,html}\" \"scripts/*.mjs\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" \"*.{js,html}\" \"scripts/*.mjs\"",
|
||||||
|
"predev": "npm run format",
|
||||||
|
"prebuild": "npm run format",
|
||||||
|
"test": "npm run test:logic && npm run test:ui",
|
||||||
|
"test:logic": "node --test tests/*.test.js",
|
||||||
|
"test:ui": "vitest run",
|
||||||
|
"lint": "eslint . --max-warnings 0"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -13,9 +21,29 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/vazirmatn": "^5.2.5",
|
"@fontsource/vazirmatn": "^5.2.5",
|
||||||
|
"@reduxjs/toolkit": "^2.13.0",
|
||||||
|
"axios": "^1.21.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.3.1",
|
||||||
"three": "^0.180.0"
|
"three": "^0.180.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.4.3"
|
"@eslint/js": "^9.39.5",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"autoprefixer": "^10.6.2",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.7",
|
||||||
|
"globals": "^17.13.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"postcss": "^8.5.32",
|
||||||
|
"prettier": "^3.9.10",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"vite": "^6.4.3",
|
||||||
|
"vitest": "^4.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||||
+54
-30
@@ -14,34 +14,38 @@
|
|||||||
// vertex count u8, height in 0.5 m u8, first vertex (x u16, z u16) in `step` metres from that origin,
|
// vertex count u8, height in 0.5 m u8, first vertex (x u16, z u16) in `step` metres from that origin,
|
||||||
// then each further vertex as (dx i16, dz i16) from the one before. Buildings are sorted west to
|
// then each further vertex as (dx i16, dz i16) from the one before. Buildings are sorted west to
|
||||||
// east in 100 m strips, so neighbours sit next to each other and the small deltas gzip well.
|
// east in 100 m strips, so neighbours sit next to each other and the small deltas gzip well.
|
||||||
import {createReadStream, readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
import { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
import {createGunzip} from 'node:zlib';
|
import { createGunzip } from 'node:zlib';
|
||||||
import {createInterface} from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import {toLocal} from '../src/geo.js';
|
import { toLocal } from '../src/utils/geo.js';
|
||||||
import {FACTORY} from '../src/locations.js';
|
import { FACTORY } from '../src/constants/locations.js';
|
||||||
import {createCollisionWorld, blocked} from '../src/collision.js';
|
import { createCollisionWorld, blocked } from '../src/game/world/collision.js';
|
||||||
|
|
||||||
const [source] = process.argv.slice(2);
|
const [source] = process.argv.slice(2);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
|
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const STEP = .5;
|
const STEP = 0.5;
|
||||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
||||||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
const osm = createCollisionWorld(data.buildings.map(b => b.slice(1)));
|
const osm = createCollisionWorld(data.buildings.map((b) => b.slice(1)));
|
||||||
const areasOf = kind => data.areas.filter(a => data.areaKinds[a[0]] === kind).map(a => a.slice(1));
|
const areasOf = (kind) =>
|
||||||
const industrial = createCollisionWorld(areasOf('industrial')), parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
|
data.areas.filter((a) => data.areaKinds[a[0]] === kind).map((a) => a.slice(1));
|
||||||
|
const industrial = createCollisionWorld(areasOf('industrial')),
|
||||||
|
parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
|
||||||
|
|
||||||
// The factory compound in its own frame (u along the driveway, v across), with a margin.
|
// The factory compound in its own frame (u along the driveway, v across), with a margin.
|
||||||
const normal = {x: -FACTORY.axis.z, z: FACTORY.axis.x};
|
const normal = { x: -FACTORY.axis.z, z: FACTORY.axis.x };
|
||||||
const inFactory = (x, z) => {
|
const inFactory = (x, z) => {
|
||||||
const dx = x - FACTORY.center.x, dz = z - FACTORY.center.z;
|
const dx = x - FACTORY.center.x,
|
||||||
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, v = dx * normal.x + dz * normal.z;
|
dz = z - FACTORY.center.z;
|
||||||
|
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z,
|
||||||
|
v = dx * normal.x + dz * normal.z;
|
||||||
return u > -115 && u < 85 && v > -55 && v < 55;
|
return u > -115 && u < 85 && v > -55 && v < 55;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hash = n => {
|
const hash = (n) => {
|
||||||
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
|
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
|
||||||
@@ -52,8 +56,9 @@ const hash = n => {
|
|||||||
const outskirts = (x, z) => x < -6000 || z < -9400;
|
const outskirts = (x, z) => x < -6000 || z < -9400;
|
||||||
|
|
||||||
// Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey.
|
// Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey.
|
||||||
export function estimateHeight(area, {inIndustrial, inPark, inOutskirts}, seed) {
|
export function estimateHeight(area, { inIndustrial, inPark, inOutskirts }, seed) {
|
||||||
const r = hash(seed), pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
|
const r = hash(seed),
|
||||||
|
pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
|
||||||
if (inPark) return 3.2;
|
if (inPark) return 3.2;
|
||||||
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
|
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
|
||||||
if (inOutskirts) return pick(1, 2) * 3.2;
|
if (inOutskirts) return pick(1, 2) * 3.2;
|
||||||
@@ -65,7 +70,7 @@ export function estimateHeight(area, {inIndustrial, inPark, inOutskirts}, seed)
|
|||||||
return pick(5, 8) * 3.2;
|
return pick(5, 8) * 3.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const polygonArea = pts => {
|
const polygonArea = (pts) => {
|
||||||
let a = 0;
|
let a = 0;
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
const j = (i + 2) % pts.length;
|
const j = (i + 2) % pts.length;
|
||||||
@@ -75,8 +80,12 @@ const polygonArea = pts => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const out = [];
|
const out = [];
|
||||||
let read = 0, outside = 0, duplicate = 0, factory = 0, tiny = 0;
|
let read = 0,
|
||||||
const lines = createInterface({input: createReadStream(source).pipe(createGunzip())});
|
outside = 0,
|
||||||
|
duplicate = 0,
|
||||||
|
factory = 0,
|
||||||
|
tiny = 0;
|
||||||
|
const lines = createInterface({ input: createReadStream(source).pipe(createGunzip()) });
|
||||||
for await (const line of lines) {
|
for await (const line of lines) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
read++;
|
read++;
|
||||||
@@ -86,7 +95,8 @@ for await (const line of lines) {
|
|||||||
const p = toLocal(lat, lon);
|
const p = toLocal(lat, lon);
|
||||||
pts.push(p.x, p.z);
|
pts.push(p.x, p.z);
|
||||||
}
|
}
|
||||||
let cx = 0, cz = 0;
|
let cx = 0,
|
||||||
|
cz = 0;
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
cx += pts[i];
|
cx += pts[i];
|
||||||
cz += pts[i + 1];
|
cz += pts[i + 1];
|
||||||
@@ -111,28 +121,40 @@ for await (const line of lines) {
|
|||||||
factory++;
|
factory++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const height = estimateHeight(area, {inIndustrial: blocked(industrial, cx, cz), inPark: blocked(parks, cx, cz), inOutskirts: outskirts(cx, cz)}, Math.round(cx * 7 + cz * 13));
|
const height = estimateHeight(
|
||||||
out.push({height, pts});
|
area,
|
||||||
|
{
|
||||||
|
inIndustrial: blocked(industrial, cx, cz),
|
||||||
|
inPark: blocked(parks, cx, cz),
|
||||||
|
inOutskirts: outskirts(cx, cz),
|
||||||
|
},
|
||||||
|
Math.round(cx * 7 + cz * 13),
|
||||||
|
);
|
||||||
|
out.push({ height, pts });
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = 20;
|
let bytes = 20;
|
||||||
for (const b of out) bytes += 2 + b.pts.length * 2;
|
for (const b of out) bytes += 2 + b.pts.length * 2;
|
||||||
const buffer = new ArrayBuffer(bytes), view = new DataView(buffer);
|
const buffer = new ArrayBuffer(bytes),
|
||||||
|
view = new DataView(buffer);
|
||||||
[0x52, 0x4d, 0x42, 0x32].forEach((c, i) => view.setUint8(i, c));
|
[0x52, 0x4d, 0x42, 0x32].forEach((c, i) => view.setUint8(i, c));
|
||||||
out.sort((a, b) => Math.floor(a.pts[1] / 100) - Math.floor(b.pts[1] / 100) || a.pts[0] - b.pts[0]);
|
out.sort((a, b) => Math.floor(a.pts[1] / 100) - Math.floor(b.pts[1] / 100) || a.pts[0] - b.pts[0]);
|
||||||
view.setUint32(4, out.length, true);
|
view.setUint32(4, out.length, true);
|
||||||
// Coordinates are stored from a point 200 m outside the region, so edge buildings never go negative.
|
// Coordinates are stored from a point 200 m outside the region, so edge buildings never go negative.
|
||||||
const originX = minX - 200, originZ = minZ - 200;
|
const originX = minX - 200,
|
||||||
|
originZ = minZ - 200;
|
||||||
view.setFloat32(8, originX, true);
|
view.setFloat32(8, originX, true);
|
||||||
view.setFloat32(12, originZ, true);
|
view.setFloat32(12, originZ, true);
|
||||||
view.setFloat32(16, STEP, true);
|
view.setFloat32(16, STEP, true);
|
||||||
let o = 20;
|
let o = 20;
|
||||||
for (const b of out) {
|
for (const b of out) {
|
||||||
view.setUint8(o++, b.pts.length / 2);
|
view.setUint8(o++, b.pts.length / 2);
|
||||||
view.setUint8(o++, Math.min(255, Math.round(b.height / .5)));
|
view.setUint8(o++, Math.min(255, Math.round(b.height / 0.5)));
|
||||||
let px = 0, pz = 0;
|
let px = 0,
|
||||||
|
pz = 0;
|
||||||
for (let i = 0; i < b.pts.length; i += 2) {
|
for (let i = 0; i < b.pts.length; i += 2) {
|
||||||
const qx = Math.round((b.pts[i] - originX) / STEP), qz = Math.round((b.pts[i + 1] - originZ) / STEP);
|
const qx = Math.round((b.pts[i] - originX) / STEP),
|
||||||
|
qz = Math.round((b.pts[i + 1] - originZ) / STEP);
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
view.setUint16(o, qx, true);
|
view.setUint16(o, qx, true);
|
||||||
view.setUint16(o + 2, qz, true);
|
view.setUint16(o + 2, qz, true);
|
||||||
@@ -145,6 +167,8 @@ for (const b of out) {
|
|||||||
o += 4;
|
o += 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mkdirSync(new URL('../public/data/', import.meta.url), {recursive: true});
|
mkdirSync(new URL('../public/data/', import.meta.url), { recursive: true });
|
||||||
writeFileSync(new URL('../public/data/buildings.bin', import.meta.url), new Uint8Array(buffer));
|
writeFileSync(new URL('../public/data/buildings.bin', import.meta.url), new Uint8Array(buffer));
|
||||||
console.log(`read ${read}, kept ${out.length}, outside ${outside}, OSM duplicates ${duplicate}, factory ${factory}, tiny ${tiny}, ${(bytes / 1e6).toFixed(1)} MB`);
|
console.log(
|
||||||
|
`read ${read}, kept ${out.length}, outside ${outside}, OSM duplicates ${duplicate}, factory ${factory}, tiny ${tiny}, ${(bytes / 1e6).toFixed(1)} MB`,
|
||||||
|
);
|
||||||
|
|||||||
+168
-61
@@ -14,50 +14,71 @@
|
|||||||
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
|
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
|
||||||
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
|
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
|
||||||
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
|
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
|
||||||
import {readFileSync, writeFileSync} from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import {ORIGIN, toLocal} from '../src/geo.js';
|
import { ORIGIN, toLocal } from '../src/utils/geo.js';
|
||||||
|
|
||||||
const args = {osm: []};
|
const args = { osm: [] };
|
||||||
for (let i = 2, key = null; i < process.argv.length; i++) {
|
for (let i = 2, key = null; i < process.argv.length; i++) {
|
||||||
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
|
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
|
||||||
else if (key === 'osm') args.osm.push(process.argv[i]);
|
else if (key === 'osm') args.osm.push(process.argv[i]);
|
||||||
else args[key] = process.argv[i];
|
else args[key] = process.argv[i];
|
||||||
}
|
}
|
||||||
if (!args.osm.length || !args.poi || !args.provinces) {
|
if (!args.osm.length || !args.poi || !args.provinces) {
|
||||||
console.error('usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>');
|
console.error(
|
||||||
|
'usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>',
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
// Points of interest are kept only inside the detailed city zone.
|
// Points of interest are kept only inside the detailed city zone.
|
||||||
const BBOX = {minLat: 36.300, maxLat: 36.330, minLon: 59.512, maxLon: 59.566};
|
const BBOX = { minLat: 36.3, maxLat: 36.33, minLon: 59.512, maxLon: 59.566 };
|
||||||
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
|
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
|
||||||
const REGION = {minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572};
|
const REGION = { minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572 };
|
||||||
const merged = new Map();
|
const merged = new Map();
|
||||||
for (const file of args.osm) for (const e of JSON.parse(readFileSync(file, 'utf8')).elements) merged.set(`${e.type}/${e.id}`, e);
|
for (const file of args.osm)
|
||||||
|
for (const e of JSON.parse(readFileSync(file, 'utf8')).elements)
|
||||||
|
merged.set(`${e.type}/${e.id}`, e);
|
||||||
const city = [...merged.values()];
|
const city = [...merged.values()];
|
||||||
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
|
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
|
||||||
const provincesPath = args.provinces;
|
const provincesPath = args.provinces;
|
||||||
const r1 = v => Math.round(v * 10) / 10;
|
const r1 = (v) => Math.round(v * 10) / 10;
|
||||||
const flat = geometry => geometry.flatMap(({lat, lon}) => {
|
const flat = (geometry) =>
|
||||||
const {x, z} = toLocal(lat, lon);
|
geometry.flatMap(({ lat, lon }) => {
|
||||||
|
const { x, z } = toLocal(lat, lon);
|
||||||
return [r1(x), r1(z)];
|
return [r1(x), r1(z)];
|
||||||
});
|
});
|
||||||
// Closed OSM rings repeat their first node; the game stores each vertex once.
|
// Closed OSM rings repeat their first node; the game stores each vertex once.
|
||||||
const ring = geometry => {
|
const ring = (geometry) => {
|
||||||
const pts = flat(geometry);
|
const pts = flat(geometry);
|
||||||
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
|
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
|
||||||
return pts;
|
return pts;
|
||||||
};
|
};
|
||||||
const hash = n => {
|
const hash = (n) => {
|
||||||
n = (n ^ 61) ^ (n >>> 16);
|
n = n ^ 61 ^ (n >>> 16);
|
||||||
n = Math.imul(n, 9);
|
n = Math.imul(n, 9);
|
||||||
n ^= n >>> 4;
|
n ^= n >>> 4;
|
||||||
n = Math.imul(n, 0x27d4eb2d);
|
n = Math.imul(n, 0x27d4eb2d);
|
||||||
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
|
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
|
||||||
};
|
};
|
||||||
|
|
||||||
const roadTypes = ['trunk', 'primary', 'secondary', 'tertiary', 'unclassified', 'residential', 'living_street', 'service', 'pedestrian', 'footway', 'cycleway', 'path', 'track', 'steps'];
|
const roadTypes = [
|
||||||
const names = [], nameIndex = new Map();
|
'trunk',
|
||||||
const nameId = name => {
|
'primary',
|
||||||
|
'secondary',
|
||||||
|
'tertiary',
|
||||||
|
'unclassified',
|
||||||
|
'residential',
|
||||||
|
'living_street',
|
||||||
|
'service',
|
||||||
|
'pedestrian',
|
||||||
|
'footway',
|
||||||
|
'cycleway',
|
||||||
|
'path',
|
||||||
|
'track',
|
||||||
|
'steps',
|
||||||
|
];
|
||||||
|
const names = [],
|
||||||
|
nameIndex = new Map();
|
||||||
|
const nameId = (name) => {
|
||||||
if (!name) return -1;
|
if (!name) return -1;
|
||||||
if (!nameIndex.has(name)) {
|
if (!nameIndex.has(name)) {
|
||||||
nameIndex.set(name, names.length);
|
nameIndex.set(name, names.length);
|
||||||
@@ -65,21 +86,41 @@ const nameId = name => {
|
|||||||
}
|
}
|
||||||
return nameIndex.get(name);
|
return nameIndex.get(name);
|
||||||
};
|
};
|
||||||
const roads = [], buildings = [], areas = [], labels = [], onewayRoads = [];
|
const roads = [],
|
||||||
|
buildings = [],
|
||||||
|
areas = [],
|
||||||
|
labels = [],
|
||||||
|
onewayRoads = [];
|
||||||
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
|
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
|
||||||
const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park'
|
const areaKind = (t) =>
|
||||||
: t.landuse === 'grass' ? 'grass'
|
t.leisure === 'park' ||
|
||||||
: t.landuse === 'forest' || t.natural === 'wood' ? 'forest'
|
t.leisure === 'garden' ||
|
||||||
: t.leisure === 'pitch' ? 'pitch'
|
t.landuse === 'park' ||
|
||||||
: t.natural === 'water' ? 'water'
|
t.landuse === 'recreation_ground'
|
||||||
: ['school', 'university', 'college'].includes(t.amenity) ? 'campus'
|
? 'park'
|
||||||
: t.landuse === 'industrial' ? 'industrial'
|
: t.landuse === 'grass'
|
||||||
: t.landuse === 'farmland' ? 'farmland' : null;
|
? 'grass'
|
||||||
|
: t.landuse === 'forest' || t.natural === 'wood'
|
||||||
|
? 'forest'
|
||||||
|
: t.leisure === 'pitch'
|
||||||
|
? 'pitch'
|
||||||
|
: t.natural === 'water'
|
||||||
|
? 'water'
|
||||||
|
: ['school', 'university', 'college'].includes(t.amenity)
|
||||||
|
? 'campus'
|
||||||
|
: t.landuse === 'industrial'
|
||||||
|
? 'industrial'
|
||||||
|
: t.landuse === 'farmland'
|
||||||
|
? 'farmland'
|
||||||
|
: null;
|
||||||
|
|
||||||
// Joins a relation's outer member ways end to end into closed rings.
|
// Joins a relation's outer member ways end to end into closed rings.
|
||||||
function joinRings(members) {
|
function joinRings(members) {
|
||||||
const parts = members.filter(m => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1).map(m => m.geometry.slice());
|
const parts = members
|
||||||
const rings = [], same = (a, b) => a.lat === b.lat && a.lon === b.lon;
|
.filter((m) => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1)
|
||||||
|
.map((m) => m.geometry.slice());
|
||||||
|
const rings = [],
|
||||||
|
same = (a, b) => a.lat === b.lat && a.lon === b.lon;
|
||||||
while (parts.length) {
|
while (parts.length) {
|
||||||
let current = parts.shift();
|
let current = parts.shift();
|
||||||
for (let grown = true; grown && !same(current[0], current.at(-1));) {
|
for (let grown = true; grown && !same(current[0], current.at(-1));) {
|
||||||
@@ -106,10 +147,14 @@ for (const e of city) {
|
|||||||
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
|
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
|
||||||
// One-way roads are stored pointing in their direction of travel; dual carriageways
|
// One-way roads are stored pointing in their direction of travel; dual carriageways
|
||||||
// (each side its own way) are one-way, as are motorways and roundabouts by default.
|
// (each side its own way) are one-way, as are motorways and roundabouts by default.
|
||||||
const oneway = ['yes', 'true', '1', '-1'].includes(t.oneway) || (t.oneway !== 'no' && (t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
|
const oneway =
|
||||||
|
['yes', 'true', '1', '-1'].includes(t.oneway) ||
|
||||||
|
(t.oneway !== 'no' &&
|
||||||
|
(t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
|
||||||
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
|
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
|
||||||
// Whole ways come back from Overpass, so cut them where they leave the region.
|
// Whole ways come back from Overpass, so cut them where they leave the region.
|
||||||
const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
|
const inRegion = ({ lat, lon }) =>
|
||||||
|
lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
|
||||||
let run = [];
|
let run = [];
|
||||||
for (const point of [...geometry, null]) {
|
for (const point of [...geometry, null]) {
|
||||||
if (point && inRegion(point)) run.push(point);
|
if (point && inRegion(point)) run.push(point);
|
||||||
@@ -122,31 +167,63 @@ for (const e of city) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
|
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
|
||||||
const levels = Number.parseFloat(t['building:levels']), height = Number.parseFloat(t.height);
|
const levels = Number.parseFloat(t['building:levels']),
|
||||||
const h = Number.isFinite(height) ? height : Number.isFinite(levels) ? levels * 3.2 : (2 + Math.floor(hash(e.id) * 4)) * 3.2;
|
height = Number.parseFloat(t.height);
|
||||||
|
const h = Number.isFinite(height)
|
||||||
|
? height
|
||||||
|
: Number.isFinite(levels)
|
||||||
|
? levels * 3.2
|
||||||
|
: (2 + Math.floor(hash(e.id) * 4)) * 3.2;
|
||||||
buildings.push([r1(Math.min(h, 80)), ...ring(e.geometry)]);
|
buildings.push([r1(Math.min(h, 80)), ...ring(e.geometry)]);
|
||||||
} else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) {
|
} else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) {
|
||||||
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]);
|
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]);
|
||||||
} else if (e.type === 'relation' && areaKind(t)) {
|
} else if (e.type === 'relation' && areaKind(t)) {
|
||||||
for (const r of joinRings(e.members ?? [])) areas.push([areaKinds.indexOf(areaKind(t)), ...ring(r)]);
|
for (const r of joinRings(e.members ?? []))
|
||||||
|
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(r)]);
|
||||||
}
|
}
|
||||||
if (t.name && (t.amenity === 'school' || t.amenity === 'university' || t.leisure === 'park' || t.landuse === 'industrial')) {
|
if (
|
||||||
const c = e.type === 'node' ? e : e.bounds ? {lat: (e.bounds.minlat + e.bounds.maxlat) / 2, lon: (e.bounds.minlon + e.bounds.maxlon) / 2} : null;
|
t.name &&
|
||||||
if (c) labels.push({name: t.name, kind: t.landuse === 'industrial' ? 'industrial' : t.amenity ?? 'park', ...roundPoint(toLocal(c.lat, c.lon))});
|
(t.amenity === 'school' ||
|
||||||
|
t.amenity === 'university' ||
|
||||||
|
t.leisure === 'park' ||
|
||||||
|
t.landuse === 'industrial')
|
||||||
|
) {
|
||||||
|
const c =
|
||||||
|
e.type === 'node'
|
||||||
|
? e
|
||||||
|
: e.bounds
|
||||||
|
? {
|
||||||
|
lat: (e.bounds.minlat + e.bounds.maxlat) / 2,
|
||||||
|
lon: (e.bounds.minlon + e.bounds.maxlon) / 2,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (c)
|
||||||
|
labels.push({
|
||||||
|
name: t.name,
|
||||||
|
kind: t.landuse === 'industrial' ? 'industrial' : (t.amenity ?? 'park'),
|
||||||
|
...roundPoint(toLocal(c.lat, c.lon)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const e of poi) {
|
for (const e of poi) {
|
||||||
const t = e.tags ?? {}, c = e.center ?? e;
|
const t = e.tags ?? {},
|
||||||
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon) continue;
|
c = e.center ?? e;
|
||||||
if (t.railway === 'station' || t.shop === 'mall') labels.push({name: t.name, kind: t.railway ? 'metro' : 'mall', ...roundPoint(toLocal(c.lat, c.lon))});
|
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon)
|
||||||
|
continue;
|
||||||
|
if (t.railway === 'station' || t.shop === 'mall')
|
||||||
|
labels.push({
|
||||||
|
name: t.name,
|
||||||
|
kind: t.railway ? 'metro' : 'mall',
|
||||||
|
...roundPoint(toLocal(c.lat, c.lon)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundPoint({x, z}) {
|
function roundPoint({ x, z }) {
|
||||||
return {x: r1(x), z: r1(z)};
|
return { x: r1(x), z: r1(z) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Areas draw largest first, so small pitches and ponds stay visible on top of parks.
|
// Areas draw largest first, so small pitches and ponds stay visible on top of parks.
|
||||||
const polygonArea = pts => {
|
const polygonArea = (pts) => {
|
||||||
let a = 0;
|
let a = 0;
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
const j = (i + 2) % pts.length;
|
const j = (i + 2) % pts.length;
|
||||||
@@ -156,52 +233,82 @@ const polygonArea = pts => {
|
|||||||
};
|
};
|
||||||
areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1)));
|
areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1)));
|
||||||
// The world spans every road we kept, plus a margin.
|
// The world spans every road we kept, plus a margin.
|
||||||
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
let minX = Infinity,
|
||||||
for (const road of roads) for (let i = 2; i < road.length; i += 2) {
|
minZ = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxZ = -Infinity;
|
||||||
|
for (const road of roads)
|
||||||
|
for (let i = 2; i < road.length; i += 2) {
|
||||||
minX = Math.min(minX, road[i]);
|
minX = Math.min(minX, road[i]);
|
||||||
maxX = Math.max(maxX, road[i]);
|
maxX = Math.max(maxX, road[i]);
|
||||||
minZ = Math.min(minZ, road[i + 1]);
|
minZ = Math.min(minZ, road[i + 1]);
|
||||||
maxZ = Math.max(maxZ, road[i + 1]);
|
maxZ = Math.max(maxZ, road[i + 1]);
|
||||||
}
|
}
|
||||||
const min = {x: Math.floor(minX - 200), z: Math.floor(minZ - 200)}, max = {x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200)};
|
const min = { x: Math.floor(minX - 200), z: Math.floor(minZ - 200) },
|
||||||
|
max = { x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200) };
|
||||||
const city_ = {
|
const city_ = {
|
||||||
origin: ORIGIN,
|
origin: ORIGIN,
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z},
|
bounds: { minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z },
|
||||||
roadTypes, areaKinds, names, roads, buildings, areas,
|
roadTypes,
|
||||||
|
areaKinds,
|
||||||
|
names,
|
||||||
|
roads,
|
||||||
|
buildings,
|
||||||
|
areas,
|
||||||
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
|
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
|
||||||
oneway: onewayRoads,
|
oneway: onewayRoads,
|
||||||
labels: [...new Map(labels.map(l => [l.name, l])).values()]
|
labels: [...new Map(labels.map((l) => [l.name, l])).values()],
|
||||||
};
|
};
|
||||||
writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_));
|
writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_));
|
||||||
|
|
||||||
// Douglas–Peucker keeps the province shapes recognisable at a fraction of the size.
|
// Douglas–Peucker keeps the province shapes recognisable at a fraction of the size.
|
||||||
function simplify(points, tolerance) {
|
function simplify(points, tolerance) {
|
||||||
if (points.length < 3) return points;
|
if (points.length < 3) return points;
|
||||||
const [ax, ay] = points[0], [bx, by] = points.at(-1);
|
const [ax, ay] = points[0],
|
||||||
let index = 0, distance = 0;
|
[bx, by] = points.at(-1);
|
||||||
|
let index = 0,
|
||||||
|
distance = 0;
|
||||||
for (let i = 1; i < points.length - 1; i++) {
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
const [px, py] = points[i], dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy);
|
const [px, py] = points[i],
|
||||||
|
dx = bx - ax,
|
||||||
|
dy = by - ay,
|
||||||
|
len = Math.hypot(dx, dy);
|
||||||
// A closed ring starts and ends on the same point, so measure from that point instead.
|
// A closed ring starts and ends on the same point, so measure from that point instead.
|
||||||
const d = len ? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len : Math.hypot(px - ax, py - ay);
|
const d = len
|
||||||
|
? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len
|
||||||
|
: Math.hypot(px - ax, py - ay);
|
||||||
if (d > distance) {
|
if (d > distance) {
|
||||||
index = i;
|
index = i;
|
||||||
distance = d;
|
distance = d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (distance <= tolerance) return [points[0], points.at(-1)];
|
if (distance <= tolerance) return [points[0], points.at(-1)];
|
||||||
return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)];
|
return [
|
||||||
|
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
|
||||||
|
...simplify(points.slice(index), tolerance),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8')).features
|
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8'))
|
||||||
.filter(f => f.properties.adm0_a3 === 'IRN')
|
.features.filter((f) => f.properties.adm0_a3 === 'IRN')
|
||||||
.map(f => {
|
.map((f) => {
|
||||||
const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
const polygons =
|
||||||
|
f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
||||||
return {
|
return {
|
||||||
id: f.properties.name,
|
id: f.properties.name,
|
||||||
name: f.properties.name_fa.replace(/^استان\s+/, ''),
|
name: f.properties.name_fa.replace(/^استان\s+/, ''),
|
||||||
rings: polygons.map(p => simplify(p[0], .02).flatMap(([lon, lat]) => [Math.round(lon * 1000) / 1000, Math.round(lat * 1000) / 1000])).filter(r => r.length >= 8)
|
rings: polygons
|
||||||
|
.map((p) =>
|
||||||
|
simplify(p[0], 0.02).flatMap(([lon, lat]) => [
|
||||||
|
Math.round(lon * 1000) / 1000,
|
||||||
|
Math.round(lat * 1000) / 1000,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.filter((r) => r.length >= 8),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({provinces}));
|
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({ provinces }));
|
||||||
console.log(`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`);
|
console.log(
|
||||||
|
`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`,
|
||||||
|
);
|
||||||
|
|||||||
+46
-27
@@ -1,3 +1,4 @@
|
|||||||
|
import { downloadImagery } from '../src/services/imageryDownloadService.js';
|
||||||
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
|
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
|
||||||
// writes public/imagery/index.json listing them.
|
// writes public/imagery/index.json listing them.
|
||||||
//
|
//
|
||||||
@@ -9,69 +10,87 @@
|
|||||||
//
|
//
|
||||||
// Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and
|
// Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and
|
||||||
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
|
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
|
||||||
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import {toLatLon} from '../src/geo.js';
|
import { toLatLon } from '../src/utils/geo.js';
|
||||||
import {buildGraph, routeBetween} from '../src/routing.js';
|
import { buildGraph, routeBetween } from '../src/game/world/routing.js';
|
||||||
import {FACTORY, machineById} from '../src/locations.js';
|
import { FACTORY, machineById } from '../src/constants/locations.js';
|
||||||
|
|
||||||
const URL_TEMPLATE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
const URL_TEMPLATE =
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
||||||
const OUT = new URL('../public/imagery/', import.meta.url);
|
const OUT = new URL('../public/imagery/', import.meta.url);
|
||||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
||||||
|
|
||||||
export const tileX = (lon, z) => Math.floor((lon + 180) / 360 * 2 ** z);
|
export const tileX = (lon, z) => Math.floor(((lon + 180) / 360) * 2 ** z);
|
||||||
export const tileY = (lat, z) => Math.floor((1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * 2 ** z);
|
export const tileY = (lat, z) =>
|
||||||
|
Math.floor(((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z);
|
||||||
|
|
||||||
const tiles = {15: new Set(), 17: new Set()};
|
const tiles = { 15: new Set(), 17: new Set() };
|
||||||
const addBox = (z, lat0, lon0, lat1, lon1) => {
|
const addBox = (z, lat0, lon0, lat1, lon1) => {
|
||||||
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
|
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
|
||||||
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
|
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
|
||||||
};
|
};
|
||||||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
const nw = toLatLon(minX, minZ), se = toLatLon(maxX, maxZ);
|
const nw = toLatLon(minX, minZ),
|
||||||
|
se = toLatLon(maxX, maxZ);
|
||||||
addBox(15, se.lat, nw.lon, nw.lat, se.lon);
|
addBox(15, se.lat, nw.lon, nw.lat, se.lon);
|
||||||
addBox(17, 36.300, 59.512, 36.330, 59.566);
|
addBox(17, 36.3, 59.512, 36.33, 59.566);
|
||||||
addBox(17, 36.443, 59.414, 36.458, 59.434);
|
addBox(17, 36.443, 59.414, 36.458, 59.434);
|
||||||
// The drive along the real roads, widened by 300 m.
|
// The drive along the real roads, widened by 300 m.
|
||||||
const drive = buildGraph(data.roads, {drivable: true}), park = machineById('mellat-park').anchor;
|
const drive = buildGraph(data.roads, { drivable: true }),
|
||||||
|
park = machineById('mellat-park').anchor;
|
||||||
const route = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, park.x, park.z);
|
const route = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, park.x, park.z);
|
||||||
for (let i = 1; i < route.points.length; i++) {
|
for (let i = 1; i < route.points.length; i++) {
|
||||||
const a = route.points[i - 1], b = route.points[i], steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
|
const a = route.points[i - 1],
|
||||||
|
b = route.points[i],
|
||||||
|
steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
|
||||||
for (let s = 0; s <= steps; s++) {
|
for (let s = 0; s <= steps; s++) {
|
||||||
const x = a.x + (b.x - a.x) * s / steps, z = a.z + (b.z - a.z) * s / steps;
|
const x = a.x + ((b.x - a.x) * s) / steps,
|
||||||
const p0 = toLatLon(x - 300, z + 300), p1 = toLatLon(x + 300, z - 300);
|
z = a.z + ((b.z - a.z) * s) / steps;
|
||||||
|
const p0 = toLatLon(x - 300, z + 300),
|
||||||
|
p1 = toLatLon(x + 300, z - 300);
|
||||||
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
|
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = Object.entries(tiles).flatMap(([z, set]) => [...set].map(key => ({z: Number(z), key})));
|
const queue = Object.entries(tiles).flatMap(([z, set]) =>
|
||||||
|
[...set].map((key) => ({ z: Number(z), key })),
|
||||||
|
);
|
||||||
console.log(`tiles: zoom 15 ${tiles[15].size}, zoom 17 ${tiles[17].size}`);
|
console.log(`tiles: zoom 15 ${tiles[15].size}, zoom 17 ${tiles[17].size}`);
|
||||||
let done = 0, fetched = 0, bytes = 0;
|
let done = 0,
|
||||||
|
fetched = 0,
|
||||||
|
bytes = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const {z, key} = queue.pop(), [x, y] = key.split('/');
|
const { z, key } = queue.pop(),
|
||||||
|
[x, y] = key.split('/');
|
||||||
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
|
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
|
||||||
if (!existsSync(file)) {
|
if (!existsSync(file)) {
|
||||||
for (let attempt = 0; attempt < 4; attempt++) {
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x), {headers: {'User-Agent': 'robot-market-game/1.0'}});
|
const body = new Uint8Array(
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
await downloadImagery(
|
||||||
const body = new Uint8Array(await res.arrayBuffer());
|
URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x),
|
||||||
mkdirSync(new URL(`${z}/${x}/`, OUT), {recursive: true});
|
),
|
||||||
|
);
|
||||||
|
mkdirSync(new URL(`${z}/${x}/`, OUT), { recursive: true });
|
||||||
writeFileSync(file, body);
|
writeFileSync(file, body);
|
||||||
fetched++;
|
fetched++;
|
||||||
bytes += body.length;
|
bytes += body.length;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
|
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
|
||||||
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (++done % 100 === 0) console.log(`${done} tiles…`);
|
if (++done % 100 === 0) console.log(`${done} tiles…`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Promise.all(Array.from({length: 6}, worker));
|
await Promise.all(Array.from({ length: 6 }, worker));
|
||||||
const index = {attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community'};
|
const index = { attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community' };
|
||||||
for (const [z, set] of Object.entries(tiles)) index[z] = [...set].filter(key => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
|
for (const [z, set] of Object.entries(tiles))
|
||||||
|
index[z] = [...set].filter((key) => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
|
||||||
writeFileSync(new URL('index.json', OUT), JSON.stringify(index));
|
writeFileSync(new URL('index.json', OUT), JSON.stringify(index));
|
||||||
console.log(`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`);
|
console.log(
|
||||||
|
`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// robot appear in their real direction over the camera picture.
|
|
||||||
|
|
||||||
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
|
|
||||||
const CAMERA_LONG_FOV = 65;
|
|
||||||
|
|
||||||
const zee = new THREE.Vector3(0, 0, 1), euler = new THREE.Euler(), q0 = new THREE.Quaternion();
|
|
||||||
const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera looks out of the back, not the top
|
|
||||||
|
|
||||||
// Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
|
|
||||||
// `alpha` must be measured from north (absolute), counter-clockwise.
|
|
||||||
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
|
|
||||||
const r = Math.PI / 180;
|
|
||||||
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
|
|
||||||
target.setFromEuler(euler);
|
|
||||||
target.multiply(q1);
|
|
||||||
target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical field of view of what is visible once the video is scaled to cover the screen.
|
|
||||||
export function coverFov(videoAspect, screenAspect) {
|
|
||||||
const long = Math.tan(CAMERA_LONG_FOV * Math.PI / 360);
|
|
||||||
// The video's own vertical and horizontal half-angle tangents.
|
|
||||||
const vTan = videoAspect >= 1 ? long / videoAspect : long, hTan = videoAspect >= 1 ? long : long * videoAspect;
|
|
||||||
// "cover" fills the screen: if the screen is wider than the video, the full width shows.
|
|
||||||
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
|
|
||||||
return Math.atan(visibleV) * 360 / Math.PI;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createArView(video) {
|
|
||||||
let stream = null, orientation = null, compassOffset = null, absoluteSeen = false;
|
|
||||||
const target = new THREE.Quaternion();
|
|
||||||
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
|
|
||||||
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
|
|
||||||
const onAbsolute = event => {
|
|
||||||
if (event.alpha == null) return;
|
|
||||||
absoluteSeen = true;
|
|
||||||
orientation = {alpha: event.alpha, beta: event.beta, gamma: event.gamma};
|
|
||||||
};
|
|
||||||
const onOrientation = event => {
|
|
||||||
if (event.alpha == null || absoluteSeen) return;
|
|
||||||
let alpha = event.alpha;
|
|
||||||
if (event.webkitCompassHeading != null) {
|
|
||||||
// iOS: alpha is relative to the start pose; the compass heading pins it to north.
|
|
||||||
compassOffset = (360 - event.webkitCompassHeading) - event.alpha;
|
|
||||||
}
|
|
||||||
if (!event.absolute && compassOffset != null) alpha += compassOffset;
|
|
||||||
orientation = {alpha, beta: event.beta, gamma: event.gamma};
|
|
||||||
};
|
|
||||||
let sensing = false;
|
|
||||||
return {
|
|
||||||
// Camera picture on (AR) or off (the 3D world fills the screen).
|
|
||||||
get active() {
|
|
||||||
return Boolean(stream);
|
|
||||||
},
|
|
||||||
get hasOrientation() {
|
|
||||||
return Boolean(orientation);
|
|
||||||
},
|
|
||||||
// Phone orientation, used by the first-person view with or without the camera picture.
|
|
||||||
startSensors() {
|
|
||||||
if (sensing) return;
|
|
||||||
sensing = true;
|
|
||||||
window.addEventListener('deviceorientationabsolute', onAbsolute);
|
|
||||||
window.addEventListener('deviceorientation', onOrientation);
|
|
||||||
},
|
|
||||||
stopSensors() {
|
|
||||||
sensing = false;
|
|
||||||
orientation = null;
|
|
||||||
absoluteSeen = false;
|
|
||||||
window.removeEventListener('deviceorientationabsolute', onAbsolute);
|
|
||||||
window.removeEventListener('deviceorientation', onOrientation);
|
|
||||||
},
|
|
||||||
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
|
|
||||||
async start() {
|
|
||||||
if (stream) return null;
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
|
|
||||||
try {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: {ideal: 'environment'}, width: {ideal: 1280}, height: {ideal: 720}}, audio: false});
|
|
||||||
} catch (error) {
|
|
||||||
stream = null;
|
|
||||||
return error?.name === 'NotAllowedError' ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.' : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
|
|
||||||
}
|
|
||||||
video.srcObject = stream;
|
|
||||||
video.hidden = false;
|
|
||||||
await video.play().catch(() => {});
|
|
||||||
this.startSensors();
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
// Closes the camera picture; the sensors keep running for the first-person view.
|
|
||||||
stop() {
|
|
||||||
stream?.getTracks().forEach(track => track.stop());
|
|
||||||
stream = null;
|
|
||||||
video.srcObject = null;
|
|
||||||
video.hidden = true;
|
|
||||||
},
|
|
||||||
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
|
|
||||||
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
|
|
||||||
update(camera, dt, smoothing = 8) {
|
|
||||||
const fov = stream ? coverFov(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3, innerWidth / innerHeight) : 62;
|
|
||||||
if (Math.abs(camera.fov - fov) > .1) {
|
|
||||||
camera.fov = fov;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
}
|
|
||||||
if (!orientation) return false;
|
|
||||||
const angle = screen.orientation?.angle ?? window.orientation ?? 0;
|
|
||||||
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
|
|
||||||
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
|
|
||||||
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
|
|
||||||
export function decodeBuildings(buffer) {
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
|
|
||||||
if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`);
|
|
||||||
const count = view.getUint32(4, true), minX = view.getFloat32(8, true), minZ = view.getFloat32(12, true), step = view.getFloat32(16, true);
|
|
||||||
const buildings = new Array(count);
|
|
||||||
let o = 20;
|
|
||||||
for (let b = 0; b < count; b++) {
|
|
||||||
const n = view.getUint8(o), height = view.getUint8(o + 1) * .5;
|
|
||||||
o += 2;
|
|
||||||
const building = new Array(1 + n * 2);
|
|
||||||
building[0] = height;
|
|
||||||
let qx = 0, qz = 0;
|
|
||||||
for (let i = 0; i < n; i++, o += 4) {
|
|
||||||
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
|
|
||||||
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
|
|
||||||
building[1 + i * 2] = minX + qx * step;
|
|
||||||
building[2 + i * 2] = minZ + qz * step;
|
|
||||||
}
|
|
||||||
buildings[b] = building;
|
|
||||||
}
|
|
||||||
return buildings;
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
|
|
||||||
// Detailed vehicles for the family and the factory. 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.
|
|
||||||
|
|
||||||
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .45, ...extra});
|
|
||||||
|
|
||||||
// A side profile (x along the car, y up) extruded across its width, with rounded edges.
|
|
||||||
function extrudeProfile(points, width, material, bevel = .1) {
|
|
||||||
const shape = new THREE.Shape();
|
|
||||||
points.forEach(([x, y], i) => i ? shape.lineTo(x, y) : shape.moveTo(x, y));
|
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {depth: width - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel * .8, bevelSegments: 3, curveSegments: 6});
|
|
||||||
geometry.translate(0, 0, -(width - bevel * 2) / 2);
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lower-body outline with wheel arches cut around the wheel centres.
|
|
||||||
function bodyOutline(front, rear, arches, radius, top) {
|
|
||||||
const shape = new THREE.Shape();
|
|
||||||
shape.moveTo(rear[0][0], rear[0][1]);
|
|
||||||
const bottom = .3;
|
|
||||||
// Underside from the rear towards the front, arching over each wheel.
|
|
||||||
for (const ax of [...arches].sort((a, b) => a - b)) {
|
|
||||||
shape.lineTo(ax - radius, bottom);
|
|
||||||
shape.absarc(ax, .36, radius, Math.PI + .14, -.14, true);
|
|
||||||
}
|
|
||||||
for (const [x, y] of front) shape.lineTo(x, y);
|
|
||||||
for (const [x, y] of top) shape.lineTo(x, y);
|
|
||||||
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
|
||||||
return shape;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
|
|
||||||
const tyre = mat('#151719', {roughness: .9}), rim = mat(rimColor, {metalness: .85, roughness: .25}), brake = mat('#3a3d40', {metalness: .6});
|
|
||||||
const wheels = [];
|
|
||||||
for (const [x, z] of positions) {
|
|
||||||
const wheel = new THREE.Group();
|
|
||||||
wheel.position.set(x, radius, z);
|
|
||||||
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
|
|
||||||
t.rotation.x = Math.PI / 2;
|
|
||||||
t.castShadow = true;
|
|
||||||
wheel.add(t);
|
|
||||||
const side = Math.sign(z);
|
|
||||||
const face = new THREE.Mesh(new THREE.CylinderGeometry(radius * .68, radius * .68, .02, 24), brake);
|
|
||||||
face.rotation.x = Math.PI / 2;
|
|
||||||
face.position.z = side * tyreWidth * .44;
|
|
||||||
wheel.add(face);
|
|
||||||
for (let i = 0; i < spokes; i++) {
|
|
||||||
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * .16, .04), rim);
|
|
||||||
spoke.position.z = side * tyreWidth * .5;
|
|
||||||
spoke.rotation.z = i * Math.PI / spokes;
|
|
||||||
wheel.add(spoke);
|
|
||||||
}
|
|
||||||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(radius * .2, radius * .2, .05, 12), rim);
|
|
||||||
hub.rotation.x = Math.PI / 2;
|
|
||||||
hub.position.z = side * tyreWidth * .52;
|
|
||||||
wheel.add(hub);
|
|
||||||
group.add(wheel);
|
|
||||||
wheels.push(wheel);
|
|
||||||
}
|
|
||||||
return wheels;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sports saloon in the style of a German executive car: long bonnet, twin kidney grille,
|
|
||||||
* ring-shaped "angel eye" headlights, kinked rear side window and five-spoke rims. 4.7 m long.
|
|
||||||
* No manufacturer badges.
|
|
||||||
*/
|
|
||||||
export function createSportSedan(color = '#1d3f78') {
|
|
||||||
const group = new THREE.Group();
|
|
||||||
const paint = mat(color, {metalness: .7, roughness: .28}), glass = mat('#0f1a22', {metalness: .7, roughness: .08});
|
|
||||||
const black = mat('#0b0c0d', {roughness: .6}), chrome = mat('#d8dcdf', {metalness: .95, roughness: .15});
|
|
||||||
const W = 1.86;
|
|
||||||
const outline = bodyOutline(
|
|
||||||
[[2.24, .3], [2.36, .42], [2.38, .62], [2.28, .78]],
|
|
||||||
[[-2.3, .3], [-2.36, .55], [-2.3, .86], [-2.12, .97]],
|
|
||||||
[-1.42, 1.43], .41,
|
|
||||||
[[1.9, .86], [.95, 1.0], [-1.2, 1.0], [-1.6, .99]]
|
|
||||||
);
|
|
||||||
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {depth: W - .2, bevelEnabled: true, bevelThickness: .1, bevelSize: .08, bevelSegments: 3, curveSegments: 10});
|
|
||||||
bodyGeometry.translate(0, 0, -(W - .2) / 2);
|
|
||||||
const body = new THREE.Mesh(bodyGeometry, paint);
|
|
||||||
body.castShadow = body.receiveShadow = true;
|
|
||||||
group.add(body);
|
|
||||||
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
|
|
||||||
const cabin = extrudeProfile([[.95, .98], [.05, 1.39], [-.9, 1.41], [-1.62, 1.0], [-1.5, .98]], 1.5, glass, .08);
|
|
||||||
group.add(cabin);
|
|
||||||
const roof = extrudeProfile([[.0, 1.37], [-.88, 1.39], [-1.0, 1.32], [.12, 1.32]], 1.36, paint, .06);
|
|
||||||
roof.position.y = .045;
|
|
||||||
group.add(roof);
|
|
||||||
const pillar = new THREE.ShapeGeometry(new THREE.Shape([new THREE.Vector2(-1.05, 1.0), new THREE.Vector2(-1.55, 1.0), new THREE.Vector2(-1.08, 1.33), new THREE.Vector2(-.96, 1.33), new THREE.Vector2(-1.18, 1.12)]));
|
|
||||||
const pillarPaint = paint.clone();
|
|
||||||
pillarPaint.side = THREE.DoubleSide;
|
|
||||||
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
const z = side * .72;
|
|
||||||
const b = new THREE.Mesh(new THREE.BoxGeometry(.09, .4, .04), black);
|
|
||||||
b.position.set(-.35, 1.18, z);
|
|
||||||
group.add(b);
|
|
||||||
const c = new THREE.Mesh(pillar, pillarPaint);
|
|
||||||
c.position.z = z + side * .035;
|
|
||||||
group.add(c);
|
|
||||||
// Door handles, mirror and the chrome window line.
|
|
||||||
for (const x of [.15, -.75]) {
|
|
||||||
const handle = new THREE.Mesh(new THREE.BoxGeometry(.18, .04, .03), chrome);
|
|
||||||
handle.position.set(x, .9, side * .94);
|
|
||||||
group.add(handle);
|
|
||||||
}
|
|
||||||
const mirror = new THREE.Mesh(new THREE.BoxGeometry(.18, .12, .2), paint);
|
|
||||||
mirror.position.set(.78, 1.05, side * 1.0);
|
|
||||||
group.add(mirror);
|
|
||||||
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, .025, .02), chrome);
|
|
||||||
trim.position.set(-.3, .985, side * .76);
|
|
||||||
group.add(trim);
|
|
||||||
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, .08, .04), black);
|
|
||||||
skirt.position.set(0, .34, side * .93);
|
|
||||||
group.add(skirt);
|
|
||||||
}
|
|
||||||
// Twin kidney grille with chrome surrounds.
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
const surround = new THREE.Mesh(new THREE.BoxGeometry(.06, .2, .3), chrome);
|
|
||||||
surround.position.set(2.46, .66, side * .18);
|
|
||||||
group.add(surround);
|
|
||||||
const grille = new THREE.Mesh(new THREE.BoxGeometry(.07, .16, .25), black);
|
|
||||||
grille.position.set(2.47, .66, side * .18);
|
|
||||||
group.add(grille);
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(.075, .15, .012), chrome);
|
|
||||||
bar.position.set(2.47, .66, side * .18 + (i - 2) * .05);
|
|
||||||
group.add(bar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Headlights with two light rings each, and slim L-shaped tail lights.
|
|
||||||
const ring = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.6});
|
|
||||||
const tail = mat('#9e1410', {emissive: '#e0261c', emissiveIntensity: .9});
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
const housing = new THREE.Mesh(new THREE.BoxGeometry(.12, .14, .44), black);
|
|
||||||
housing.position.set(2.42, .72, side * .6);
|
|
||||||
group.add(housing);
|
|
||||||
for (const dz of [-.1, .1]) {
|
|
||||||
const eye = new THREE.Mesh(new THREE.TorusGeometry(.05, .012, 6, 18), ring);
|
|
||||||
eye.rotation.y = Math.PI / 2;
|
|
||||||
eye.position.set(2.48, .72, side * .6 + dz);
|
|
||||||
group.add(eye);
|
|
||||||
}
|
|
||||||
const tl = new THREE.Mesh(new THREE.BoxGeometry(.06, .1, .42), tail);
|
|
||||||
tl.position.set(-2.45, .82, side * .62);
|
|
||||||
group.add(tl);
|
|
||||||
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(.3, .1, .05), tail);
|
|
||||||
tlSide.position.set(-2.2, .82, side * .9);
|
|
||||||
group.add(tlSide);
|
|
||||||
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .12, 12), chrome);
|
|
||||||
exhaust.rotation.z = Math.PI / 2;
|
|
||||||
exhaust.position.set(-2.44, .36, side * .45);
|
|
||||||
group.add(exhaust);
|
|
||||||
}
|
|
||||||
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(.05, .12, 1.2), black);
|
|
||||||
lowerGrille.position.set(2.45, .42, 0);
|
|
||||||
group.add(lowerGrille);
|
|
||||||
const plate = new THREE.Mesh(new THREE.BoxGeometry(.03, .11, .5), mat('#f4f4f0'));
|
|
||||||
plate.position.set(2.47, .5, 0);
|
|
||||||
group.add(plate);
|
|
||||||
const wheels = addWheels(group, [[-1.42, -.82], [-1.42, .82], [1.43, -.82], [1.43, .82]], .36, .26, '#b8bcc0', 5);
|
|
||||||
return {group, wheels};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: .5, roughness: .32}), glass = mat('#13202a', {metalness: .6, roughness: .1});
|
|
||||||
const black = mat('#1a1c1e', {roughness: .7}), chrome = mat('#d0d4d7', {metalness: .9, roughness: .2});
|
|
||||||
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, .5], [2.66, .95], [2.5, 1.18], [1.3, 1.24], [1.3, .5]], 1.9, paint, .1));
|
|
||||||
group.add(extrudeProfile([[1.3, .5], [1.3, 1.24], [.7, 1.86], [-.95, 1.88], [-1.0, .5]], 1.88, paint, .1));
|
|
||||||
group.add(extrudeProfile([[1.24, 1.26], [.68, 1.82], [-.9, 1.84], [-.94, 1.26]], 1.92, glass, .02));
|
|
||||||
// Cargo bed: floor, sides and tailgate.
|
|
||||||
box(1.62, .1, 1.8, black, -1.85, .72, 0);
|
|
||||||
for (const side of [-1, 1]) box(1.66, .5, .07, paint, -1.85, .95, side * .9);
|
|
||||||
box(.07, .5, 1.86, paint, -2.66, .95, 0);
|
|
||||||
box(1.66, .46, 1.9, paint, -1.85, .5, 0);
|
|
||||||
box(.3, .06, 1.86, black, -1.02, 1.26, 0);
|
|
||||||
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
|
|
||||||
box(.06, .34, 1.1, black, 2.67, .92, 0);
|
|
||||||
for (let i = 0; i < 3; i++) box(.07, .04, 1.12, chrome, 2.68, .82 + i * .1, 0);
|
|
||||||
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.2});
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
box(.08, .14, .32, lamp, 2.62, 1.02, side * .74);
|
|
||||||
box(.06, .26, .1, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .7}), -2.7, 1.0, side * .86);
|
|
||||||
box(.2, .14, .2, black, 1.2, 1.36, side * 1.02);
|
|
||||||
box(1.0, .05, .06, chrome, -.1, 1.25, side * .97);
|
|
||||||
for (const x of [.6, -.4]) box(.16, .04, .03, chrome, x, 1.12, side * .97);
|
|
||||||
}
|
|
||||||
box(.16, .2, 2.0, black, 2.7, .55, 0);
|
|
||||||
box(.16, .2, 2.0, black, -2.72, .55, 0);
|
|
||||||
box(.04, .12, .5, mat('#f4f4f0'), 2.79, .6, 0);
|
|
||||||
const wheels = addWheels(group, [[-1.7, -.86], [-1.7, .86], [1.7, -.86], [1.7, .86]], .4, .3, '#a9adb1', 6);
|
|
||||||
for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, .1, .2, black, x, .86, side * .96);
|
|
||||||
return {group, wheels};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function createTruck(color = '#e9eef0') {
|
|
||||||
const group = new THREE.Group();
|
|
||||||
const paint = mat(color, {metalness: .35, roughness: .4}), glass = mat('#14212b', {metalness: .6, roughness: .1});
|
|
||||||
const black = mat('#1b1d1f', {roughness: .7}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
|
|
||||||
const box = (w, h, d, m, x, y, z, parent = group) => {
|
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
|
||||||
mesh.position.set(x, y, z);
|
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
|
||||||
parent.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
// Cab over the front axle.
|
|
||||||
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
|
|
||||||
box(.05, .9, 2.0, glass, 3.91, 2.35, 0);
|
|
||||||
for (const side of [-1, 1]) box(1.0, .7, .04, glass, 3.1, 2.4, side * 1.16);
|
|
||||||
box(.08, .3, 2.2, teal, 3.92, 1.35, 0);
|
|
||||||
box(.25, .35, 2.36, black, 3.95, .85, 0);
|
|
||||||
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1});
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
box(.06, .16, .3, lamp, 3.98, 1.1, side * .9);
|
|
||||||
box(.3, .3, .08, black, 3.4, 2.7, side * 1.25);
|
|
||||||
}
|
|
||||||
// Chassis and the flatbed with low side rails.
|
|
||||||
box(7.6, .3, 1.0, black, 0, .75, 0);
|
|
||||||
box(5.9, .14, 2.4, mat('#6b5a45', {roughness: .9}), -1.05, 1.0, 0);
|
|
||||||
for (const side of [-1, 1]) box(5.9, .5, .06, teal, -1.05, 1.32, side * 1.19);
|
|
||||||
box(.06, .5, 2.4, teal, -4.0, 1.32, 0);
|
|
||||||
box(.1, 1.6, 2.4, black, 1.9, 1.85, 0);
|
|
||||||
for (const side of [-1, 1]) box(.1, .22, .26, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .6}), -4.02, .82, side * .95);
|
|
||||||
const bed = new THREE.Group();
|
|
||||||
bed.position.set(-1.05, 1.07, 0);
|
|
||||||
group.add(bed);
|
|
||||||
const wheels = addWheels(group, [[-2.9, -1.0], [-2.9, 1.0], [2.9, -1.0], [2.9, 1.0]], .5, .38, '#c2c6c9', 8);
|
|
||||||
return {group, wheels, bed};
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
|
|
||||||
// Lightweight articulated characters; all coordinates below are in the group.
|
|
||||||
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
|
|
||||||
export function createFamily({robot: robotOnly = false} = {}) {
|
|
||||||
const group = new THREE.Group();
|
|
||||||
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
|
|
||||||
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36');
|
|
||||||
|
|
||||||
function shape(parent, geometry, material, position) {
|
|
||||||
const m = new THREE.Mesh(geometry, material);
|
|
||||||
m.position.set(...position);
|
|
||||||
m.castShadow = true;
|
|
||||||
m.receiveShadow = true;
|
|
||||||
parent.add(m);
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
|
|
||||||
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
|
||||||
m.scale.set(...scale);
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
function segment(parent, a, b, r, material) {
|
|
||||||
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
|
||||||
return {mesh, a, b};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSegment(bone, a, b) {
|
|
||||||
const delta = b.clone().sub(a);
|
|
||||||
bone.mesh.position.copy(a).add(b).multiplyScalar(.5);
|
|
||||||
bone.mesh.scale.y = delta.length();
|
|
||||||
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
|
||||||
function makeRobot(z) {
|
|
||||||
const scale = .69, root = new THREE.Group(), torso = new THREE.Group();
|
|
||||||
root.name = 'robot-market-mascot';
|
|
||||||
root.position.z = z;
|
|
||||||
group.add(root);
|
|
||||||
root.add(torso);
|
|
||||||
const teal = new THREE.MeshStandardMaterial({color: '#079f98', metalness: .4, roughness: .28});
|
|
||||||
const white = new THREE.MeshStandardMaterial({color: '#edf6f4', metalness: .22, roughness: .32});
|
|
||||||
const joint = new THREE.MeshStandardMaterial({color: '#102b30', metalness: .5, roughness: .35});
|
|
||||||
const glow = new THREE.MeshStandardMaterial({color: '#c6f7ff', emissive: '#23bcff', emissiveIntensity: 2.5, roughness: .2});
|
|
||||||
const visor = new THREE.MeshStandardMaterial({color: '#020d16', metalness: .5, roughness: .16});
|
|
||||||
sphere(torso, .25, teal, [0, .76, 0], [1, .65, .75]);
|
|
||||||
sphere(torso, .31, white, [0, .97, 0], [1, 1.05, .72]);
|
|
||||||
for (const side of [-1, 1]) sphere(torso, .17, teal, [side * .24, 1.03, -.04], [.55, 1, 1]);
|
|
||||||
shape(torso, new THREE.CylinderGeometry(.09, .09, .12, 12), joint, [0, 1.27, 0]);
|
|
||||||
const head = new THREE.Group();
|
|
||||||
head.position.y = 1.61;
|
|
||||||
torso.add(head);
|
|
||||||
sphere(head, .43, teal, [0, 0, 0], [1.12, .91, .88]);
|
|
||||||
sphere(head, .40, white, [0, -.015, .12], [1.12, .86, .77]);
|
|
||||||
sphere(head, .35, visor, [0, -.015, .255], [1.13, .83, .48]);
|
|
||||||
const eyes = [];
|
|
||||||
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
|
|
||||||
const line = shape(parent, new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI), material, pos);
|
|
||||||
line.rotation.z = rotation;
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
eyes.push(arc(head, .065, .016, [side * .16, .025, .423]));
|
|
||||||
const ear = shape(head, new THREE.CylinderGeometry(.14, .14, .085, 24), joint, [side * .47, 0, 0]);
|
|
||||||
ear.rotation.z = Math.PI / 2;
|
|
||||||
const ring = shape(head, new THREE.TorusGeometry(.105, .012, 8, 24), glow, [side * .518, 0, 0]);
|
|
||||||
ring.rotation.y = Math.PI / 2;
|
|
||||||
sphere(head, .12, teal, [side * .49, 0, 0], [.4, 1, 1]);
|
|
||||||
}
|
|
||||||
arc(head, .075, .016, [0, -.085, .425], Math.PI);
|
|
||||||
sphere(head, .10, teal, [0, .38, 0], [1, .55, 1]);
|
|
||||||
shape(head, new THREE.CylinderGeometry(.017, .022, .18, 10), teal, [0, .49, 0]);
|
|
||||||
sphere(head, .055, teal, [0, .60, 0]);
|
|
||||||
// Draw the chest mark as a texture so it remains attached to the 3D body.
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 256; canvas.height = 256;
|
|
||||||
const c = canvas.getContext('2d');
|
|
||||||
if (c) {
|
|
||||||
c.strokeStyle = '#102b30'; c.fillStyle = '#102b30';
|
|
||||||
c.lineWidth = 12; c.lineJoin = 'round';
|
|
||||||
c.beginPath(); c.moveTo(65, 40); c.lineTo(82, 40); c.lineTo(99, 110); c.lineTo(170, 110); c.lineTo(186, 58); c.lineTo(89, 58); c.stroke();
|
|
||||||
for (const x of [111, 162]) {c.beginPath(); c.arc(x, 130, 9, 0, Math.PI * 2); c.fill();}
|
|
||||||
c.textAlign = 'center'; c.font = 'bold 34px sans-serif';
|
|
||||||
c.fillText('ROBOT', 128, 181); c.fillText('MARKET', 128, 218);
|
|
||||||
shape(torso, new THREE.PlaneGeometry(.31, .31), new THREE.MeshBasicMaterial({map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false}), [0, .99, .224]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const legs = [-1, 1].map(side => {
|
|
||||||
const hip = new THREE.Group(); hip.position.set(side * .15, .65, 0); root.add(hip);
|
|
||||||
sphere(hip, .09, joint, [0, 0, 0]);
|
|
||||||
shape(hip, new THREE.CapsuleGeometry(.095, .12, 4, 12), white, [0, -.12, 0]);
|
|
||||||
const knee = new THREE.Group(); knee.position.y = -.25; hip.add(knee);
|
|
||||||
sphere(knee, .105, teal, [0, 0, .035]);
|
|
||||||
shape(knee, new THREE.CapsuleGeometry(.10, .12, 4, 12), white, [0, -.13, 0]);
|
|
||||||
const foot = sphere(knee, .13, teal, [0, -.29, .07], [1, .65, 1.6]);
|
|
||||||
sphere(knee, .13, joint, [0, -.34, .07], [1.06, .22, 1.65]);
|
|
||||||
return {hip, knee, foot};
|
|
||||||
});
|
|
||||||
const arms = [-1, 1].map(() => ({
|
|
||||||
upper: segment(group, null, null, .09, teal),
|
|
||||||
lower: segment(group, null, null, .085, white),
|
|
||||||
hand: sphere(group, .09, joint, [0, 0, 0], [1, 1.1, 1])
|
|
||||||
}));
|
|
||||||
return {root, torso, head, legs, arms, scale, z, eyes};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePerson(isChild, z) {
|
|
||||||
const scale = isChild ? .69 : 1.05, root = new THREE.Group();
|
|
||||||
root.position.z = z;
|
|
||||||
group.add(root);
|
|
||||||
const torso = new THREE.Group();
|
|
||||||
root.add(torso);
|
|
||||||
const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474');
|
|
||||||
// Tapered jacket, hips and neck replace the single rounded toy-like body.
|
|
||||||
shape(torso, new THREE.CylinderGeometry(.255 * scale, .22 * scale, .62 * scale, 16), cloth, [0, 1.2 * scale, 0]).scale.z = .72;
|
|
||||||
sphere(torso, .23, pants, [0, .88 * scale, 0], [scale, .55 * scale, .72 * scale]);
|
|
||||||
shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]);
|
|
||||||
const seam = shape(torso, new THREE.BoxGeometry(.014 * scale, .5 * scale, .014 * scale), mat('#bda77f'), [0, 1.2 * scale, .19 * scale]);
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
const collar = shape(torso, new THREE.BoxGeometry(.12 * scale, .09 * scale, .025 * scale), cloth, [side * .07 * scale, 1.49 * scale, .15 * scale]);
|
|
||||||
collar.rotation.z = side * .4;
|
|
||||||
}
|
|
||||||
const head = new THREE.Group();
|
|
||||||
head.position.y = 1.77 * scale;
|
|
||||||
torso.add(head);
|
|
||||||
head.scale.setScalar(isChild ? .9 : .77);
|
|
||||||
const eyes = [];
|
|
||||||
sphere(head, .27 * scale, skin, [0, 0, 0]);
|
|
||||||
sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]);
|
|
||||||
sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]);
|
|
||||||
for (const side of [-1, 1]) {
|
|
||||||
sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]);
|
|
||||||
sphere(head, .037 * scale, mat('#f6f1e7'), [side * .09 * scale, .03 * scale, .241 * scale], [1, .65, .5]);
|
|
||||||
const eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]);
|
|
||||||
eyes.push(eye);
|
|
||||||
const brow = shape(head, new THREE.BoxGeometry(.075 * scale, .015 * scale, .015 * scale), hair, [side * .09 * scale, .087 * scale, .237 * scale]);
|
|
||||||
brow.rotation.z = side * -.08;
|
|
||||||
sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]);
|
|
||||||
}
|
|
||||||
const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]);
|
|
||||||
smile.rotation.z = Math.PI;
|
|
||||||
if (isChild) {
|
|
||||||
sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]);
|
|
||||||
shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]);
|
|
||||||
} else {
|
|
||||||
sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]);
|
|
||||||
}
|
|
||||||
const legs = [-1, 1].map(side => {
|
|
||||||
const hip = new THREE.Group();
|
|
||||||
hip.position.set(side * .15 * scale, .83 * scale, 0);
|
|
||||||
root.add(hip);
|
|
||||||
shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
|
|
||||||
const knee = new THREE.Group();
|
|
||||||
knee.position.y = -.34 * scale;
|
|
||||||
hip.add(knee);
|
|
||||||
shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
|
|
||||||
const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]);
|
|
||||||
foot.scale.set(.85, .65, 1.5);
|
|
||||||
return {hip, knee, foot};
|
|
||||||
});
|
|
||||||
const arms = [-1, 1].map(() => ({
|
|
||||||
upper: segment(group, null, null, .072 * scale, cloth),
|
|
||||||
lower: segment(group, null, null, .061 * scale, skin),
|
|
||||||
hand: sphere(group, .075 * scale, skin, [0, 0, 0])
|
|
||||||
}));
|
|
||||||
return {root, torso, head, legs, arms, scale, z, eyes};
|
|
||||||
}
|
|
||||||
|
|
||||||
const father = robotOnly ? null : makePerson(false, -.45), child = robotOnly ? null : makePerson(true, .45);
|
|
||||||
const robot = robotOnly ? makeRobot(0) : null;
|
|
||||||
const actors = robotOnly ? [robot] : [father, child];
|
|
||||||
|
|
||||||
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
|
|
||||||
// `wave` raises the robot's right hand, `point` stretches it forward.
|
|
||||||
function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, wave = false, point = false, dt = 1 / 60}) {
|
|
||||||
actors.forEach((actor, index) => {
|
|
||||||
const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
|
|
||||||
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend);
|
|
||||||
// The robot hovers; it bobs faster while it is speaking.
|
|
||||||
const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0;
|
|
||||||
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : hover, isRobot ? 1 : blend);
|
|
||||||
actor.root.position.z = finished ? 0 : actor.z;
|
|
||||||
actor.root.rotation.y = facing;
|
|
||||||
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
|
|
||||||
actor.torso.position.y = bob;
|
|
||||||
actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0;
|
|
||||||
const blinking = Math.sin(time * 1.3 + index) > .993;
|
|
||||||
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
|
|
||||||
actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06;
|
|
||||||
actor.torso.rotation.x = delivery && actor === child ? .22 : 0;
|
|
||||||
actor.legs.forEach((leg, i) => {
|
|
||||||
const phase = gait + i * Math.PI;
|
|
||||||
const stride = isRobot ? .08 : .34;
|
|
||||||
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
|
|
||||||
leg.foot.rotation.x = moving ? Math.sin(phase + .5) * .12 : 0;
|
|
||||||
leg.knee.rotation.x = finished ? 2.5 : isRobot ? .25 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
group.updateMatrixWorld(true);
|
|
||||||
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
|
|
||||||
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, (actor === robot ? 1.12 : 1.43) * actor.scale, 0));
|
|
||||||
// With characters facing +X, father's left and child's right hands meet.
|
|
||||||
const contact = new THREE.Vector3(0, 1.06, 0);
|
|
||||||
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => {
|
|
||||||
const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1;
|
|
||||||
const a = shoulder(actor, side);
|
|
||||||
let target;
|
|
||||||
if (inside && !shopping && !finished) target = contact.clone();
|
|
||||||
else if (actor === child && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
|
|
||||||
else if (actor === robot && side === 1 && wave) target = toFamily(actor, new THREE.Vector3(.42, 1.62 + Math.sin(time * 9) * .07, .12));
|
|
||||||
else if (actor === robot && side === 1 && point) target = toFamily(actor, new THREE.Vector3(.3, 1.25, .62));
|
|
||||||
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
|
|
||||||
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
|
|
||||||
updateSegment(arm.upper, a, elbow);
|
|
||||||
updateSegment(arm.lower, elbow, target);
|
|
||||||
arm.hand.position.copy(target);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
update({time: 0, gait: 0, moving: false});
|
|
||||||
return {group, update};
|
|
||||||
}
|
|
||||||
-469
@@ -1,469 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
import {nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
|
|
||||||
import {blocked, insidePolygon} from './collision.js';
|
|
||||||
|
|
||||||
// Road widths in metres per OSM highway type. Dual carriageways are two separate one-way ways.
|
|
||||||
const WIDTH = {trunk: 11, primary: 10, secondary: 9, tertiary: 8, unclassified: 6.5, residential: 6, living_street: 5, service: 4, pedestrian: 4, footway: 2.4, cycleway: 2, path: 1.6, track: 3, steps: 2.2};
|
|
||||||
const BUCKET = {trunk: 'major', primary: 'major', secondary: 'major', tertiary: 'minor', unclassified: 'minor', residential: 'minor', living_street: 'minor', service: 'minor', pedestrian: 'paved', footway: 'paved', cycleway: 'paved', steps: 'paved', path: 'dirt', track: 'dirt'};
|
|
||||||
// Ground layers: height above the ground plane and polygon offset, so overlaps never flicker.
|
|
||||||
const LAYER = {industrial: [.006, 1], farmland: [.008, 1], campus: [.01, 1], park: [.02, 2], grass: [.025, 3], forest: [.025, 3], pitch: [.03, 4], water: [.035, 5], sidewalk: [.04, 6], dirt: [.045, 7], paved: [.05, 8], minor: [.06, 9], major: [.07, 10]};
|
|
||||||
const AREA_COLOR = {campus: '#d8ccb2', park: '#7a9a55', grass: '#8aa660', forest: '#5d7f47', pitch: '#5f9a58', water: '#4f93a8', industrial: '#bdb6a6', farmland: '#a8a46e'};
|
|
||||||
const BUILDING_COLORS = ['#dccbaa', '#e7dcc4', '#c9a585', '#bdb4a3', '#ece6da', '#d2b48f', '#b7957a'].map(c => new THREE.Color(c));
|
|
||||||
|
|
||||||
function canvasTexture(width, height, draw, repeat = true) {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
draw(canvas.getContext('2d'), width, height);
|
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
|
||||||
texture.anisotropy = 8;
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
|
|
||||||
function noise(c, w, h, base, spread, count) {
|
|
||||||
c.fillStyle = base;
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const v = Math.random() * spread - spread / 2;
|
|
||||||
c.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`;
|
|
||||||
c.fillRect(Math.random() * w, Math.random() * h, 2, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textures = () => ({
|
|
||||||
major: canvasTexture(128, 256, (c, w, h) => {
|
|
||||||
noise(c, w, h, '#4a4d50', .12, 1800);
|
|
||||||
c.fillStyle = '#e8e4d6';
|
|
||||||
c.fillRect(6, 0, 4, h);
|
|
||||||
c.fillRect(w - 10, 0, 4, h);
|
|
||||||
c.fillRect(w / 2 - 2, 0, 4, h * .45);
|
|
||||||
}),
|
|
||||||
minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', .12, 1600)),
|
|
||||||
paved: canvasTexture(128, 128, (c, w, h) => {
|
|
||||||
noise(c, w, h, '#c4b597', .1, 900);
|
|
||||||
c.strokeStyle = 'rgba(90,70,50,.25)';
|
|
||||||
for (let i = 0; i <= 4; i++) {
|
|
||||||
c.beginPath();
|
|
||||||
c.moveTo(0, i * 32);
|
|
||||||
c.lineTo(w, i * 32);
|
|
||||||
c.moveTo(i * 32 + (i % 2) * 16, 0);
|
|
||||||
c.lineTo(i * 32 + (i % 2) * 16, h);
|
|
||||||
c.stroke();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
sidewalk: canvasTexture(128, 128, (c, w, h) => {
|
|
||||||
noise(c, w, h, '#b9b1a1', .1, 900);
|
|
||||||
c.fillStyle = '#d9d4c8';
|
|
||||||
c.fillRect(0, 0, 10, h);
|
|
||||||
c.fillRect(w - 10, 0, 10, h);
|
|
||||||
}),
|
|
||||||
dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', .15, 500)),
|
|
||||||
walls: canvasTexture(128, 128, (c, w, h) => {
|
|
||||||
noise(c, w, h, '#f1ede4', .06, 600);
|
|
||||||
const g = c.createLinearGradient(0, 30, 0, 96);
|
|
||||||
g.addColorStop(0, '#7c95a3');
|
|
||||||
g.addColorStop(1, '#3b4b55');
|
|
||||||
c.fillStyle = '#8e8679';
|
|
||||||
c.fillRect(28, 26, 72, 70);
|
|
||||||
c.fillStyle = g;
|
|
||||||
c.fillRect(32, 30, 64, 62);
|
|
||||||
c.fillStyle = 'rgba(255,255,255,.18)';
|
|
||||||
c.fillRect(34, 32, 18, 58);
|
|
||||||
c.fillStyle = '#8e8679';
|
|
||||||
c.fillRect(62, 30, 4, 62);
|
|
||||||
c.fillStyle = 'rgba(0,0,0,.12)';
|
|
||||||
c.fillRect(0, 118, w, 10);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pushes a strip of quads along a polyline into a geometry buffer.
|
|
||||||
function ribbon(out, pts, width, y, repeat) {
|
|
||||||
const n = pts.length / 2;
|
|
||||||
if (n < 2) return;
|
|
||||||
const base = out.pos.length / 3, half = width / 2;
|
|
||||||
let along = 0;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const x = pts[i * 2], z = pts[i * 2 + 1];
|
|
||||||
if (i) along += Math.hypot(x - pts[i * 2 - 2], z - pts[i * 2 - 1]);
|
|
||||||
const seg = (a, b) => {
|
|
||||||
const dx = pts[b * 2] - pts[a * 2], dz = pts[b * 2 + 1] - pts[a * 2 + 1], len = Math.hypot(dx, dz) || 1;
|
|
||||||
return [-dz / len, dx / len];
|
|
||||||
};
|
|
||||||
const n1 = seg(Math.max(0, i - 1), Math.max(1, i)), n2 = seg(Math.min(n - 2, i), Math.min(n - 1, i + 1));
|
|
||||||
let nx = n1[0] + n2[0], nz = n1[1] + n2[1];
|
|
||||||
const len = Math.hypot(nx, nz) || 1;
|
|
||||||
nx /= len;
|
|
||||||
nz /= len;
|
|
||||||
const miter = half / Math.max(.45, nx * n1[0] + nz * n1[1]);
|
|
||||||
out.pos.push(x + nx * miter, y, z + nz * miter, x - nx * miter, y, z - nz * miter);
|
|
||||||
out.uv.push(0, along / repeat, 1, along / repeat);
|
|
||||||
if (i) {
|
|
||||||
const a = base + (i - 1) * 2;
|
|
||||||
// Wound so the strip faces up (+y) and survives back-face culling.
|
|
||||||
out.idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disc(out, x, z, radius, y) {
|
|
||||||
const base = out.pos.length / 3;
|
|
||||||
out.pos.push(x, y, z);
|
|
||||||
out.uv.push(.25, 0);
|
|
||||||
for (let i = 0; i <= 10; i++) {
|
|
||||||
const a = i / 10 * Math.PI * 2;
|
|
||||||
out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius);
|
|
||||||
out.uv.push(.25, 0);
|
|
||||||
if (i) out.idx.push(base, base + i + 1, base + i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function polygonTriangles(pts) {
|
|
||||||
const contour = [];
|
|
||||||
for (let i = 0; i < pts.length; i += 2) contour.push(new THREE.Vector2(pts[i], pts[i + 1]));
|
|
||||||
try {
|
|
||||||
return THREE.ShapeUtils.triangulateShape(contour, []);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat polygon facing up; triangles are flipped where needed so normals point to the sky.
|
|
||||||
function flatPolygon(out, pts, y, uvScale = 0, color = null) {
|
|
||||||
const base = out.pos.length / 3;
|
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
out.pos.push(pts[i], y, pts[i + 1]);
|
|
||||||
out.uv?.push(pts[i] * uvScale, pts[i + 1] * uvScale);
|
|
||||||
if (color) out.col.push(color.r, color.g, color.b);
|
|
||||||
}
|
|
||||||
for (const [a, b, c] of polygonTriangles(pts)) {
|
|
||||||
const cross = (pts[b * 2 + 1] - pts[a * 2 + 1]) * (pts[c * 2] - pts[a * 2]) - (pts[b * 2] - pts[a * 2]) * (pts[c * 2 + 1] - pts[a * 2 + 1]);
|
|
||||||
out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMesh(out, material, normals = 'up') {
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(out.pos, 3));
|
|
||||||
if (out.uv?.length) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2));
|
|
||||||
if (out.col?.length) geometry.setAttribute('color', new THREE.Float32BufferAttribute(out.col, 3));
|
|
||||||
geometry.setIndex(out.idx);
|
|
||||||
if (normals === 'up') geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(out.pos.length).map((_, i) => i % 3 === 1 ? 1 : 0), 3));
|
|
||||||
else geometry.computeVertexNormals();
|
|
||||||
geometry.computeBoundingSphere();
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.receiveShadow = true;
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groundMaterial = (options, layer) => new THREE.MeshStandardMaterial({
|
|
||||||
roughness: .95, ...options,
|
|
||||||
polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2
|
|
||||||
});
|
|
||||||
|
|
||||||
function seeded(seed) {
|
|
||||||
return () => {
|
|
||||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
||||||
return seed / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function signedArea(pts) {
|
|
||||||
let a = 0;
|
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
const j = (i + 2) % pts.length;
|
|
||||||
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
|
|
||||||
}
|
|
||||||
return a / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The world is split into 2 km chunks (buildings: 500 m blocks); meshes out of view range are hidden.
|
|
||||||
const CHUNK = 2000, BLOCK = 500;
|
|
||||||
|
|
||||||
// With satellite imagery the photo shows the real roads and land, so the drawn roads and areas are
|
|
||||||
// left out; `extraBuildings` are footprints beyond OSM (Microsoft's, see buildings-data.js).
|
|
||||||
export function createCity(scene, data, {collision, segments, extraBuildings = [], satellite = false, lowQuality = false}) {
|
|
||||||
const VIEW_RANGE = lowQuality ? 1500 : 2400;
|
|
||||||
const tex = textures(), group = new THREE.Group();
|
|
||||||
scene.add(group);
|
|
||||||
const random = seeded(1405);
|
|
||||||
const chunked = [], buckets = new Map();
|
|
||||||
const bucket = (name, x, z, make) => {
|
|
||||||
const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
|
|
||||||
if (!buckets.has(key)) buckets.set(key, {name, out: make()});
|
|
||||||
return buckets.get(key).out;
|
|
||||||
};
|
|
||||||
const addChunked = mesh => {
|
|
||||||
mesh.geometry.computeBoundingSphere();
|
|
||||||
chunked.push(mesh);
|
|
||||||
group.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Open ground that follows the camera, so the horizon never shows an edge.
|
|
||||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(14000, 14000), groundMaterial({color: '#c6baa0'}, 0));
|
|
||||||
ground.rotation.x = -Math.PI / 2;
|
|
||||||
ground.position.y = -.02;
|
|
||||||
ground.receiveShadow = true;
|
|
||||||
group.add(ground);
|
|
||||||
|
|
||||||
for (const area of satellite ? [] : data.areas) {
|
|
||||||
const kind = data.areaKinds[area[0]];
|
|
||||||
flatPolygon(bucket(`area:${kind}`, area[1], area[2], () => ({pos: [], idx: []})), area.slice(1), LAYER[kind][0]);
|
|
||||||
}
|
|
||||||
const areaMaterials = {};
|
|
||||||
for (const kind of data.areaKinds) areaMaterials[kind] = groundMaterial({color: AREA_COLOR[kind], roughness: kind === 'water' ? .25 : .95, metalness: kind === 'water' ? .2 : 0}, LAYER[kind][1]);
|
|
||||||
|
|
||||||
const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({pos: [], uv: [], idx: []}));
|
|
||||||
for (const r of satellite ? [] : data.roads) {
|
|
||||||
const type = data.roadTypes[r[0]], kind = BUCKET[type], pts = r.slice(2), width = WIDTH[type];
|
|
||||||
ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8);
|
|
||||||
if (r[0] <= DRIVABLE_MAX_TYPE) {
|
|
||||||
if (type !== 'service') ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6);
|
|
||||||
disc(road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]);
|
|
||||||
disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const roadMaterials = {};
|
|
||||||
for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) roadMaterials[kind] = groundMaterial({map: tex[kind]}, LAYER[kind][1]);
|
|
||||||
|
|
||||||
// Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs. There are
|
|
||||||
// ~145,000 of them (OSM plus Microsoft footprints), so each 500 m block is built only when the
|
|
||||||
// camera first comes near it, one block per frame.
|
|
||||||
const wallMaterial = new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85});
|
|
||||||
const roofMaterial = new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9});
|
|
||||||
const blocks = new Map();
|
|
||||||
for (const building of [...data.buildings, ...extraBuildings]) {
|
|
||||||
const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`;
|
|
||||||
if (!blocks.has(key)) {
|
|
||||||
const [bx, bz] = key.split(',').map(Number);
|
|
||||||
blocks.set(key, {x: (bx + .5) * BLOCK, z: (bz + .5) * BLOCK, buildings: [], meshes: null});
|
|
||||||
}
|
|
||||||
blocks.get(key).buildings.push(building);
|
|
||||||
}
|
|
||||||
const pendingBlocks = [...blocks.values()];
|
|
||||||
|
|
||||||
function buildBlock(block) {
|
|
||||||
const walls = {pos: [], uv: [], col: [], idx: []}, roofs = {pos: [], col: [], idx: []};
|
|
||||||
for (const building of block.buildings) {
|
|
||||||
const height = building[0];
|
|
||||||
let pts = building.slice(1);
|
|
||||||
if (signedArea(pts) > 0) {
|
|
||||||
const reversed = [];
|
|
||||||
for (let i = pts.length - 2; i >= 0; i -= 2) reversed.push(pts[i], pts[i + 1]);
|
|
||||||
pts = reversed;
|
|
||||||
}
|
|
||||||
// Colour picked from the position, so a block looks the same every time it is built.
|
|
||||||
const shade = Math.abs(Math.sin(pts[0] * 12.9898 + pts[1] * 78.233) * 43758.5453) % 1;
|
|
||||||
const color = BUILDING_COLORS[Math.floor(shade * BUILDING_COLORS.length)].clone().multiplyScalar(.9 + (shade * 7 % 1) * .15);
|
|
||||||
let along = 0;
|
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
const j = (i + 2) % pts.length, ax = pts[i], az = pts[i + 1], bx = pts[j], bz = pts[j + 1];
|
|
||||||
const len = Math.hypot(bx - ax, bz - az), base = walls.pos.length / 3;
|
|
||||||
walls.pos.push(ax, 0, az, bx, 0, bz, bx, height, bz, ax, height, az);
|
|
||||||
walls.uv.push(along / 3.6, 0, (along + len) / 3.6, 0, (along + len) / 3.6, height / 3.2, along / 3.6, height / 3.2);
|
|
||||||
for (let k = 0; k < 4; k++) walls.col.push(color.r, color.g, color.b);
|
|
||||||
walls.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
|
||||||
along += len;
|
|
||||||
}
|
|
||||||
flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(.78));
|
|
||||||
}
|
|
||||||
block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)];
|
|
||||||
for (const mesh of block.meshes) {
|
|
||||||
mesh.castShadow = true;
|
|
||||||
addChunked(mesh);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings.
|
|
||||||
const tanks = [], units = [];
|
|
||||||
for (const building of data.buildings) {
|
|
||||||
const height = building[0], pts = building.slice(1);
|
|
||||||
let cx = 0, cz = 0;
|
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
cx += pts[i];
|
|
||||||
cz += pts[i + 1];
|
|
||||||
}
|
|
||||||
cx /= pts.length / 2;
|
|
||||||
cz /= pts.length / 2;
|
|
||||||
if (insidePolygon(pts, cx, cz) && random() < .6) {
|
|
||||||
(random() < .5 ? tanks : units).push([cx + (random() - .5) * 2, height, random() * Math.PI, cz + (random() - .5) * 2, 0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const {name, out} of buckets.values()) {
|
|
||||||
const [kind, sub] = name.split(':');
|
|
||||||
addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub]) : toMesh(out, roadMaterials[sub]));
|
|
||||||
}
|
|
||||||
const dummy = new THREE.Object3D();
|
|
||||||
// One instanced mesh per chunk, so small props are culled with the rest of their chunk.
|
|
||||||
// Each item is an array whose first entry is x and second-to-last entry is z.
|
|
||||||
const instanced = (geometry, material, items, place) => {
|
|
||||||
const byChunk = new Map();
|
|
||||||
items.forEach((item, i) => {
|
|
||||||
const key = `${Math.floor(item[0] / CHUNK)},${Math.floor(item.at(-2) / CHUNK)}`;
|
|
||||||
if (!byChunk.has(key)) byChunk.set(key, []);
|
|
||||||
byChunk.get(key).push([item, i]);
|
|
||||||
});
|
|
||||||
const meshes = [];
|
|
||||||
for (const list of byChunk.values()) {
|
|
||||||
const mesh = new THREE.InstancedMesh(geometry, material, list.length);
|
|
||||||
list.forEach(([item], i) => {
|
|
||||||
place(item);
|
|
||||||
dummy.updateMatrix();
|
|
||||||
mesh.setMatrixAt(i, dummy.matrix);
|
|
||||||
});
|
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
|
||||||
mesh.computeBoundingSphere();
|
|
||||||
mesh.userData.items = list.map(([, i]) => i);
|
|
||||||
chunked.push(mesh);
|
|
||||||
group.add(mesh);
|
|
||||||
meshes.push(mesh);
|
|
||||||
}
|
|
||||||
return meshes;
|
|
||||||
};
|
|
||||||
instanced(new THREE.CylinderGeometry(.55, .55, 1.1, 10), new THREE.MeshStandardMaterial({color: '#d8d8d2', metalness: .4, roughness: .4}), tanks, ([x, y, , z]) => {
|
|
||||||
dummy.position.set(x, y + .55, z);
|
|
||||||
dummy.rotation.set(0, 0, 0);
|
|
||||||
dummy.scale.setScalar(1);
|
|
||||||
});
|
|
||||||
instanced(new THREE.BoxGeometry(1, .8, .9), new THREE.MeshStandardMaterial({color: '#a9aaa4', roughness: .6}), units, ([x, y, r, z]) => {
|
|
||||||
dummy.position.set(x, y + .4, z);
|
|
||||||
dummy.rotation.set(0, r, 0);
|
|
||||||
dummy.scale.setScalar(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trees fill parks and line the boulevards, kept off paths and out of buildings.
|
|
||||||
const trees = [];
|
|
||||||
const freeSpot = (x, z) => !blocked(collision, x, z) && !nearestSegment(segments, x, z, 3);
|
|
||||||
for (const area of data.areas) {
|
|
||||||
const kind = data.areaKinds[area[0]];
|
|
||||||
if (!['park', 'forest', 'grass'].includes(kind)) continue;
|
|
||||||
const pts = area.slice(1), step = kind === 'grass' ? 16 : 10;
|
|
||||||
let ax = Infinity, az = Infinity, bx = -Infinity, bz = -Infinity;
|
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
ax = Math.min(ax, pts[i]);
|
|
||||||
bx = Math.max(bx, pts[i]);
|
|
||||||
az = Math.min(az, pts[i + 1]);
|
|
||||||
bz = Math.max(bz, pts[i + 1]);
|
|
||||||
}
|
|
||||||
for (let x = ax; x < bx; x += step) for (let z = az; z < bz; z += step) {
|
|
||||||
const tx = x + (random() - .5) * step * .8, tz = z + (random() - .5) * step * .8;
|
|
||||||
if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) trees.push([tx, .8 + random() * .6, tz, 0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const road of data.roads) {
|
|
||||||
const type = data.roadTypes[road[0]];
|
|
||||||
if (road[0] > 3) continue;
|
|
||||||
const offset = WIDTH[type] / 2 + 2.6;
|
|
||||||
for (let i = 4; i < road.length; i += 2) {
|
|
||||||
const ax = road[i - 2], az = road[i - 1], dx = road[i] - ax, dz = road[i + 1] - az, len = Math.hypot(dx, dz);
|
|
||||||
for (let d = random() * 12; d < len; d += 12) for (const side of [-1, 1]) {
|
|
||||||
const tx = ax + dx / len * d - dz / len * offset * side, tz = az + dz / len * d + dx / len * offset * side;
|
|
||||||
if (!blocked(collision, tx, tz) && !nearestSegment(segments, tx, tz, WIDTH[type] / 2 + 1.5, s => s[4] <= DRIVABLE_MAX_TYPE)) trees.push([tx, .7 + random() * .4, tz, 0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
instanced(new THREE.CylinderGeometry(.14, .22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({color: '#6a5039', roughness: .9}), trees, ([x, s, z]) => {
|
|
||||||
dummy.position.set(x, 1.3 * s, z);
|
|
||||||
dummy.rotation.set(0, 0, 0);
|
|
||||||
dummy.scale.setScalar(s);
|
|
||||||
});
|
|
||||||
const crowns = instanced(new THREE.IcosahedronGeometry(1.7, 0), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .85, flatShading: true}), trees, ([x, s, z]) => {
|
|
||||||
dummy.position.set(x, 3.4 * s, z);
|
|
||||||
dummy.rotation.set(0, x * 13 % 6, 0);
|
|
||||||
dummy.scale.set(s, s * (1 + (x * 7 % 1) * .3), s);
|
|
||||||
});
|
|
||||||
const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map(c => new THREE.Color(c));
|
|
||||||
for (const mesh of crowns) mesh.userData.items.forEach((tree, i) => mesh.setColorAt(i, crownColors[tree % crownColors.length]));
|
|
||||||
|
|
||||||
// Metro station entrances with the Mashhad Urban Railway "M" sign.
|
|
||||||
const signTexture = canvasTexture(128, 128, (c, w, h) => {
|
|
||||||
c.fillStyle = '#d2232a';
|
|
||||||
c.beginPath();
|
|
||||||
c.arc(w / 2, h / 2, 60, 0, Math.PI * 2);
|
|
||||||
c.fill();
|
|
||||||
c.fillStyle = '#fff';
|
|
||||||
c.font = 'bold 84px sans-serif';
|
|
||||||
c.textAlign = 'center';
|
|
||||||
c.fillText('M', w / 2, h / 2 + 30);
|
|
||||||
}, false);
|
|
||||||
const canopy = new THREE.MeshStandardMaterial({color: '#59646b', metalness: .5, roughness: .4});
|
|
||||||
const glass = new THREE.MeshStandardMaterial({color: '#9fc4cf', transparent: true, opacity: .45, roughness: .1});
|
|
||||||
for (const label of data.labels.filter(l => l.kind === 'metro')) {
|
|
||||||
const entrance = new THREE.Group();
|
|
||||||
entrance.position.set(label.x, 0, label.z);
|
|
||||||
const road = nearestSegment(segments, label.x, label.z, 40, s => s[4] <= DRIVABLE_MAX_TYPE);
|
|
||||||
if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz);
|
|
||||||
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;
|
|
||||||
entrance.add(mesh);
|
|
||||||
};
|
|
||||||
box(4.4, .2, 7, canopy, 0, 3, 0);
|
|
||||||
box(.15, 3, 7, glass, -2.1, 1.5, 0);
|
|
||||||
box(.15, 3, 7, glass, 2.1, 1.5, 0);
|
|
||||||
box(.25, 4.2, .25, canopy, 2.6, 2.1, 3.6);
|
|
||||||
const sign = new THREE.Mesh(new THREE.CircleGeometry(.6, 24), new THREE.MeshBasicMaterial({map: signTexture}));
|
|
||||||
sign.position.set(2.6, 4.4, 3.75);
|
|
||||||
entrance.add(sign);
|
|
||||||
const back = sign.clone();
|
|
||||||
back.rotation.y = Math.PI;
|
|
||||||
back.position.z = 3.45;
|
|
||||||
entrance.add(back);
|
|
||||||
group.add(entrance);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A continuous ridge line around the horizon: the Binalood range high to the south-west,
|
|
||||||
// the lower Hezar Masjed hills to the north-east. Hazy colours stand in for distance fog.
|
|
||||||
const ridgePos = [], ridgeCol = [], ridgeIdx = [], segmentsAround = 240;
|
|
||||||
const foot = new THREE.Color('#cfc5b0'), crest = new THREE.Color('#a39a8a');
|
|
||||||
for (let i = 0; i <= segmentsAround; i++) {
|
|
||||||
const a = i / segmentsAround * Math.PI * 2, x = Math.cos(a), z = Math.sin(a);
|
|
||||||
// South-west is −x (west) and +z (south); peaks grow with how far this direction points there.
|
|
||||||
const southWest = Math.max(0, (-x + z) / Math.SQRT2), rough = Math.sin(a * 7) * .5 + Math.sin(a * 17 + 1) * .3 + Math.sin(a * 41) * .15;
|
|
||||||
const height = 90 + southWest ** 1.5 * 420 + rough * (60 + southWest * 120), r = 4300;
|
|
||||||
ridgePos.push(x * r, -20, z * r, x * r, height, z * r);
|
|
||||||
ridgeCol.push(foot.r, foot.g, foot.b, crest.r, crest.g, crest.b);
|
|
||||||
if (i) {
|
|
||||||
const b = (i - 1) * 2;
|
|
||||||
ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ridgeGeometry = new THREE.BufferGeometry();
|
|
||||||
ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3));
|
|
||||||
ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3));
|
|
||||||
ridgeGeometry.setIndex(ridgeIdx);
|
|
||||||
const ridge = new THREE.Mesh(ridgeGeometry, new THREE.MeshBasicMaterial({vertexColors: true, fog: false, side: THREE.DoubleSide}));
|
|
||||||
group.add(ridge);
|
|
||||||
let lastCheck = null;
|
|
||||||
return {
|
|
||||||
group,
|
|
||||||
// Keeps the ground and horizon around the camera and shows only chunks within range.
|
|
||||||
update(camera) {
|
|
||||||
ground.position.set(camera.x, -.02, camera.z);
|
|
||||||
ridge.position.set(camera.x, 0, camera.z);
|
|
||||||
// Build the nearest unbuilt block in range, one per frame to avoid stutter.
|
|
||||||
let next = null, nextDistance = VIEW_RANGE + BLOCK;
|
|
||||||
for (const block of pendingBlocks) {
|
|
||||||
const d = Math.hypot(block.x - camera.x, block.z - camera.z);
|
|
||||||
if (d < nextDistance) {
|
|
||||||
next = block;
|
|
||||||
nextDistance = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (next) {
|
|
||||||
pendingBlocks.splice(pendingBlocks.indexOf(next), 1);
|
|
||||||
buildBlock(next);
|
|
||||||
lastCheck = null;
|
|
||||||
}
|
|
||||||
if (lastCheck && Math.hypot(camera.x - lastCheck.x, camera.z - lastCheck.z) < 60) return;
|
|
||||||
lastCheck = {x: camera.x, z: camera.z};
|
|
||||||
for (const mesh of chunked) {
|
|
||||||
const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere;
|
|
||||||
mesh.visible = Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius < VIEW_RANGE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// 2D collision against building footprints, stored as flat [x0, z0, x1, z1, …] rings.
|
|
||||||
const CELL = 24;
|
|
||||||
const key = (cx, cz) => cx * 100003 + cz;
|
|
||||||
|
|
||||||
export function createCollisionWorld(polygons) {
|
|
||||||
const world = {polygons: [], grid: new Map()};
|
|
||||||
polygons.forEach(p => addPolygon(world, p));
|
|
||||||
return world;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addPolygon(world, pts) {
|
|
||||||
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
minX = Math.min(minX, pts[i]);
|
|
||||||
maxX = Math.max(maxX, pts[i]);
|
|
||||||
minZ = Math.min(minZ, pts[i + 1]);
|
|
||||||
maxZ = Math.max(maxZ, pts[i + 1]);
|
|
||||||
}
|
|
||||||
const index = world.polygons.push({pts, minX, minZ, maxX, maxZ}) - 1;
|
|
||||||
for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
|
|
||||||
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
|
|
||||||
const k = key(cx, cz);
|
|
||||||
if (!world.grid.has(k)) world.grid.set(k, []);
|
|
||||||
world.grid.get(k).push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nearby(world, x, z, r) {
|
|
||||||
const found = new Set();
|
|
||||||
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
|
|
||||||
for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++)
|
|
||||||
for (const i of world.grid.get(key(cx, cz)) ?? []) found.add(i);
|
|
||||||
return [...found].map(i => world.polygons[i]).filter(p => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insidePolygon(pts, x, z) {
|
|
||||||
let inside = false;
|
|
||||||
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
|
|
||||||
const xi = pts[i], zi = pts[i + 1], xj = pts[j], zj = pts[j + 1];
|
|
||||||
if ((zi > z) !== (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) inside = !inside;
|
|
||||||
}
|
|
||||||
return inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closestOnEdges(pts, x, z) {
|
|
||||||
let best = {d: Infinity, x: 0, z: 0};
|
|
||||||
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
|
|
||||||
const ax = pts[j], az = pts[j + 1], dx = pts[i] - ax, dz = pts[i + 1] - az;
|
|
||||||
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
|
|
||||||
const px = ax + dx * t, pz = az + dz * t, d = Math.hypot(x - px, z - pz);
|
|
||||||
if (d < best.d) best = {d, x: px, z: pz};
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
|
|
||||||
export function resolveCircle(world, x, z, r) {
|
|
||||||
let hit = false;
|
|
||||||
for (let pass = 0; pass < 3; pass++) {
|
|
||||||
let moved = false;
|
|
||||||
for (const p of nearby(world, x, z, r)) {
|
|
||||||
const inside = insidePolygon(p.pts, x, z), c = closestOnEdges(p.pts, x, z);
|
|
||||||
if (!inside && c.d >= r) continue;
|
|
||||||
const len = c.d || 1e-6, nx = (x - c.x) / len * (inside ? -1 : 1), nz = (z - c.z) / len * (inside ? -1 : 1);
|
|
||||||
x = c.x + nx * r;
|
|
||||||
z = c.z + nz * r;
|
|
||||||
moved = hit = true;
|
|
||||||
}
|
|
||||||
if (!moved) break;
|
|
||||||
}
|
|
||||||
return {x, z, hit};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const blocked = (world, x, z) => nearby(world, x, z, 0).some(p => insidePolygon(p.pts, x, z));
|
|
||||||
|
|
||||||
// Fraction (0–1) of the segment a→b that is free before it enters a footprint.
|
|
||||||
export function clearFraction(world, ax, az, bx, bz, step = 1) {
|
|
||||||
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
|
|
||||||
for (let i = 1; i <= steps; i++) {
|
|
||||||
const t = i / steps;
|
|
||||||
if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
/** @typedef {{as?:string, model?:object, children?:import('react').ReactNode, onAction?:function, onClose?:function}} GameNodeProps */
|
||||||
|
/** Renders an accessible view-model node without exposing the game engine to DOM mutations.
|
||||||
|
* @param {GameNodeProps} props - Plain presentation properties and callbacks.
|
||||||
|
* @returns {import('react').ReactElement} Native element.
|
||||||
|
*/
|
||||||
|
export function GameNode({ as: Tag = 'div', model = {}, children, onAction, onClose, ...props }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (Tag !== 'dialog' || !ref.current) return;
|
||||||
|
if (model.open && !ref.current.open) ref.current.showModal();
|
||||||
|
if (!model.open && ref.current.open) ref.current.close();
|
||||||
|
}, [Tag, model.open]);
|
||||||
|
const activeClasses = Object.entries(model.classes ?? {})
|
||||||
|
.filter(([, active]) => active)
|
||||||
|
.map(([name]) => name);
|
||||||
|
const selected = model.selected ?? props['aria-selected'];
|
||||||
|
const hidden = model.hidden ?? props.hidden;
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
hidden={hidden}
|
||||||
|
disabled={model.disabled ?? props.disabled}
|
||||||
|
className={clsx(model.className ?? props.className, activeClasses, {
|
||||||
|
on: model.pressed === true,
|
||||||
|
})}
|
||||||
|
style={model.style}
|
||||||
|
src={model.src ?? props.src}
|
||||||
|
aria-pressed={model.pressed ?? props['aria-pressed']}
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={onAction}
|
||||||
|
onCancel={onClose}
|
||||||
|
onClose={Tag === 'dialog' ? onClose : undefined}
|
||||||
|
>
|
||||||
|
{model.text ?? children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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
|
||||||
|
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
||||||
|
export const CATEGORIES = {
|
||||||
|
factory: {
|
||||||
|
label: strings.categories.factory,
|
||||||
|
icon: '🏭',
|
||||||
|
},
|
||||||
|
park: {
|
||||||
|
label: strings.categories.park,
|
||||||
|
icon: '🌳',
|
||||||
|
},
|
||||||
|
university: {
|
||||||
|
label: strings.categories.university,
|
||||||
|
icon: '🎓',
|
||||||
|
},
|
||||||
|
school: {
|
||||||
|
label: strings.categories.school,
|
||||||
|
icon: '🏫',
|
||||||
|
},
|
||||||
|
metro: {
|
||||||
|
label: strings.categories.metro,
|
||||||
|
icon: 'Ⓜ',
|
||||||
|
},
|
||||||
|
mall: {
|
||||||
|
label: strings.categories.mall,
|
||||||
|
icon: '🛍',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
label: strings.categories.test,
|
||||||
|
icon: '🧪',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ALL_DAY = [7 * 60, 23 * 60];
|
||||||
|
export const FACTORY = {
|
||||||
|
name: COMPANY.name,
|
||||||
|
tagline: COMPANY.tagline,
|
||||||
|
address: COMPANY.contact.address,
|
||||||
|
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
||||||
|
center: {
|
||||||
|
x: -10308.4,
|
||||||
|
z: -14964.2,
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
x: Math.SQRT1_2,
|
||||||
|
z: Math.SQRT1_2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const machines = [
|
||||||
|
{
|
||||||
|
id: 'factory',
|
||||||
|
model: 'rm-35',
|
||||||
|
name: strings.places.officeMachine,
|
||||||
|
category: 'factory',
|
||||||
|
address: strings.places.officeAddress(FACTORY.address),
|
||||||
|
anchor: null,
|
||||||
|
hours: [0, 24 * 60],
|
||||||
|
priceFactor: 0.5,
|
||||||
|
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
|
||||||
|
note: strings.places.officeNote,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mellat-park',
|
||||||
|
model: 'rm-48',
|
||||||
|
name: 'بوستان ملت · ورودی جنوبی',
|
||||||
|
category: 'park',
|
||||||
|
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
||||||
|
anchor: {
|
||||||
|
x: -487.6,
|
||||||
|
z: -326.4,
|
||||||
|
},
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 1,
|
||||||
|
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
||||||
|
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',
|
||||||
|
model: 'rm-35',
|
||||||
|
name: 'مجموعه آموزشی راحیل',
|
||||||
|
category: 'school',
|
||||||
|
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
||||||
|
anchor: {
|
||||||
|
x: -554.6,
|
||||||
|
z: -569.7,
|
||||||
|
},
|
||||||
|
hours: [7 * 60 + 30, 13 * 60 + 30],
|
||||||
|
priceFactor: 0.9,
|
||||||
|
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
|
||||||
|
note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdowsi-university',
|
||||||
|
model: 'rm-60',
|
||||||
|
name: 'دانشگاه فردوسی مشهد',
|
||||||
|
category: 'university',
|
||||||
|
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
||||||
|
anchor: {
|
||||||
|
x: -936,
|
||||||
|
z: 645,
|
||||||
|
},
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 0.8,
|
||||||
|
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
|
||||||
|
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'literature-faculty',
|
||||||
|
model: 'rm-48',
|
||||||
|
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
|
||||||
|
category: 'university',
|
||||||
|
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
||||||
|
anchor: {
|
||||||
|
x: -417.1,
|
||||||
|
z: 966.3,
|
||||||
|
},
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 0.8,
|
||||||
|
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
|
||||||
|
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'medical-university',
|
||||||
|
model: 'rm-48',
|
||||||
|
name: 'دانشگاه علوم پزشکی مشهد',
|
||||||
|
category: 'university',
|
||||||
|
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
||||||
|
anchor: {
|
||||||
|
x: -272.1,
|
||||||
|
z: 150.6,
|
||||||
|
},
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 0.8,
|
||||||
|
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
|
||||||
|
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'azadi-metro',
|
||||||
|
model: 'rm-60',
|
||||||
|
name: 'ایستگاه مترو آزادی',
|
||||||
|
category: 'metro',
|
||||||
|
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
||||||
|
anchor: {
|
||||||
|
x: 494.1,
|
||||||
|
z: 280.3,
|
||||||
|
},
|
||||||
|
hours: [6 * 60, 22 * 60],
|
||||||
|
priceFactor: 1,
|
||||||
|
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
|
||||||
|
note: 'دستگاه کنار گیتهای مترو',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mashhad-mall',
|
||||||
|
model: 'rm-60',
|
||||||
|
name: 'مشهد مال',
|
||||||
|
category: 'mall',
|
||||||
|
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
||||||
|
anchor: {
|
||||||
|
x: 1940.6,
|
||||||
|
z: -367.8,
|
||||||
|
},
|
||||||
|
hours: [10 * 60, 23 * 60],
|
||||||
|
priceFactor: 1.1,
|
||||||
|
products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
|
||||||
|
note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone.
|
||||||
|
// They exist only while real-world mode is on; `spot` is where the player stands to buy.
|
||||||
|
/**
|
||||||
|
* Runs the test point operation for this subsystem.
|
||||||
|
* @param {*} n - n.
|
||||||
|
* @param {*} lat - lat.
|
||||||
|
* @param {*} lon - lon.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const testPoint = (n, lat, lon) => ({
|
||||||
|
id: `test-${n}`,
|
||||||
|
model: 'rm-35',
|
||||||
|
name: strings.places.testPoint(n.toLocaleString('fa-IR')),
|
||||||
|
category: 'test',
|
||||||
|
test: true,
|
||||||
|
address: strings.places.testAddress(`${lat.toFixed(6)}, ${lon.toFixed(6)}`),
|
||||||
|
anchor: null,
|
||||||
|
spot: {
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
},
|
||||||
|
hours: [0, 24 * 60],
|
||||||
|
priceFactor: 0.5,
|
||||||
|
products: ['water', 'biscuit'],
|
||||||
|
note: strings.places.testNote,
|
||||||
|
});
|
||||||
|
machines.push(
|
||||||
|
testPoint(1, 36.450276, 59.423431),
|
||||||
|
testPoint(2, 36.450172, 59.423209),
|
||||||
|
testPoint(3, 36.449976, 59.422866),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shown on the map as the next area to unlock.
|
||||||
|
export const lockedPlaces = [
|
||||||
|
{
|
||||||
|
name: 'پارک کوهسنگی',
|
||||||
|
note: strings.places.comingSoon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'حرم و مرکز شهر',
|
||||||
|
note: strings.places.comingSoon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const START = {
|
||||||
|
machineId: 'factory',
|
||||||
|
};
|
||||||
|
export const CITY_START = 'mellat-park';
|
||||||
|
/**
|
||||||
|
* Runs the machine by id operation for this subsystem.
|
||||||
|
* @param {*} id - id.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const machineById = (id) => machines.find((m) => m.id === id) ?? null;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { COMPANY } from '../config/company.js';
|
||||||
|
|
||||||
|
/** Display-only machine models; the product line itself lives in config/company.js. */
|
||||||
|
export const SHOWROOM_CATALOG = COMPANY.products;
|
||||||
|
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
-698
@@ -1,698 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
import {createVendingMachine} from './vending.js';
|
|
||||||
import {createCarModel} from './traffic.js';
|
|
||||||
import {createPickup, createTruck} from './car-models.js';
|
|
||||||
import {createPerson, createCrew, nameTag} from './staff.js';
|
|
||||||
import {FACTORY} from './locations.js';
|
|
||||||
import {textTexture} from './textures.js';
|
|
||||||
|
|
||||||
function stripes(c, w, h, base, line, every) {
|
|
||||||
c.fillStyle = base;
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.fillStyle = line;
|
|
||||||
for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * .35, h);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persian sign: white text on the company's dark teal.
|
|
||||||
const sign = (text, sub = '') => textTexture(512, 160, (c, w, h) => {
|
|
||||||
c.fillStyle = '#0d2f2c';
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.fillStyle = '#3ee0c9';
|
|
||||||
c.fillRect(0, h - 12, w, 12);
|
|
||||||
c.textAlign = 'center';
|
|
||||||
c.direction = 'rtl';
|
|
||||||
c.fillStyle = '#ffffff';
|
|
||||||
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
|
||||||
c.fillText(text, w / 2, sub ? 72 : 100);
|
|
||||||
if (sub) {
|
|
||||||
c.fillStyle = '#9fe9da';
|
|
||||||
c.font = '36px Vazirmatn, Tahoma, sans-serif';
|
|
||||||
c.fillText(sub, w / 2, 128);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Monitor contents: a few lines of code (or rows) on a dark editor background.
|
|
||||||
const screen = (lines, background = '#14181f') => textTexture(512, 300, (c, w, h) => {
|
|
||||||
c.fillStyle = background;
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.fillStyle = '#232a35';
|
|
||||||
c.fillRect(0, 0, w, 26);
|
|
||||||
c.font = '21px monospace';
|
|
||||||
lines.forEach(([text, color], i) => {
|
|
||||||
c.fillStyle = color;
|
|
||||||
c.fillText(text, 16, 58 + i * 29);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const CODE = {
|
|
||||||
react: screen([['export function Shelf({items}) {', '#c792ea'], [' const [slot, setSlot] = useState();', '#82aaff'], [' return (', '#eeffff'], [' <Grid cols={4}>', '#f07178'], [' {items.map(i => <Item {...i} />)}', '#c3e88d'], [' </Grid>', '#f07178'], [' );', '#eeffff'], ['}', '#c792ea']]),
|
|
||||||
go: screen([['func (s *Server) Pay(w http.Res', '#82aaff'], [' tx, err := s.bank.Charge(ctx, req)', '#eeffff'], [' if err != nil {', '#c792ea'], [' http.Error(w, err.Error(), 402)', '#f07178'], [' return', '#c792ea'], [' }', '#c792ea'], [' s.events <- Sale{tx.ID, req.Slot}', '#c3e88d'], ['}', '#82aaff']]),
|
|
||||||
cpp: screen([['void Motor::dispense(int slot) {', '#82aaff'], [' auto& coil = coils_[slot];', '#eeffff'], [' coil.rotate(360_deg);', '#c3e88d'], [' while (!sensor_.dropped())', '#c792ea'], [' std::this_thread::yield();', '#eeffff'], [' fridge_.hold(4.0_C);', '#f78c6c'], [' log("slot", slot, "ok");', '#c3e88d'], ['}', '#82aaff']]),
|
|
||||||
terminal: screen([['$ go test ./...', '#c3e88d'], ['ok payments 0.42s', '#eeffff'], ['$ cmake --build build', '#c3e88d'], ['[100%] Built target firmware', '#eeffff'], ['$ npm run build', '#c3e88d'], ['✓ built in 2.1s', '#eeffff'], ['$ git push origin main', '#c3e88d'], ['', '#fff']], '#0c0f12'),
|
|
||||||
sheet: screen([['فاکتور دستگاه مبلغ', '#1d3b2f'], ['۱۰۴۲ بوستان ملت ۴۸۰', '#333'], ['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'], ['۱۰۴۴ مترو آزادی ۲۹۰', '#333'], ['۱۰۴۵ مشهد مال ۶۱۰', '#333'], ['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'], ['جمع ۱۸۸۰', '#0b8f88']], '#f4f7f2'),
|
|
||||||
dashboard: screen([['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'], ['تولید هفته: ۴۶ دستگاه', '#f2c94c'], ['ارسال شده: ۳۸', '#eeffff'], ['', '#fff'], ['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'], ['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'], ['▇▇▇▇▇▇▇ رنگ', '#82aaff']], '#101820')
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* arrival in his pickup, 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
|
|
||||||
* people the family can talk to.
|
|
||||||
*/
|
|
||||||
export function createFactory(scene) {
|
|
||||||
const {center, axis} = FACTORY, normal = {x: -axis.z, z: axis.x};
|
|
||||||
const rot = Math.atan2(-axis.z, axis.x);
|
|
||||||
const toWorld = (u, v) => ({x: center.x + axis.x * u + normal.x * v, z: center.z + axis.z * u + normal.z * v});
|
|
||||||
// A heading in the local frame (u, v direction) as a world-space car heading.
|
|
||||||
const worldHeading = (du, dv) => {
|
|
||||||
const x = axis.x * du + normal.x * dv, z = axis.z * du + normal.z * dv;
|
|
||||||
return Math.atan2(-z, x);
|
|
||||||
};
|
|
||||||
const group = new THREE.Group();
|
|
||||||
group.position.set(center.x, 0, center.z);
|
|
||||||
group.rotation.y = rot;
|
|
||||||
scene.add(group);
|
|
||||||
const footprints = [];
|
|
||||||
const solid = (u0, u1, v0, v1) => footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
|
|
||||||
// Axis-aligned box in the local frame; solid ones also block movement.
|
|
||||||
const box = (a0, a1, b0, b1, y0, y1, material, isSolid = true, parent = group) => {
|
|
||||||
const u0 = Math.min(a0, a1), u1 = Math.max(a0, a1), v0 = Math.min(b0, b1), v1 = Math.max(b0, b1);
|
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
|
|
||||||
mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
|
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
|
||||||
parent.add(mesh);
|
|
||||||
if (isSolid) solid(u0, u1, v0, v1);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
const cylinder = (r, h, material, u, y, v, parent = group) => {
|
|
||||||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 16), material);
|
|
||||||
mesh.position.set(u, y, v);
|
|
||||||
mesh.castShadow = true;
|
|
||||||
parent.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
// A sign plane; `facing` is the local direction its front faces: 1 = +v, -1 = −v.
|
|
||||||
const panel = (texture, w, h, u, y, v, facing = 1) => {
|
|
||||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({map: texture, side: THREE.DoubleSide}));
|
|
||||||
mesh.position.set(u, y, v);
|
|
||||||
if (facing < 0) mesh.rotation.y = Math.PI;
|
|
||||||
group.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
|
|
||||||
const floorMat = (color, extra = {}) => mat(color, {polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32, ...extra});
|
|
||||||
const flat = (u0, u1, v0, v1, y, material) => {
|
|
||||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, v1 - v0), material);
|
|
||||||
mesh.rotation.x = -Math.PI / 2;
|
|
||||||
mesh.position.set((u0 + u1) / 2, y, (v0 + v1) / 2);
|
|
||||||
mesh.receiveShadow = true;
|
|
||||||
group.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
const steel = mat('#59646b', {metalness: .5}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
|
|
||||||
const yellow = mat('#f2c94c', {roughness: .5}), darkSteel = mat('#3d4549', {metalness: .5}), silver = mat('#c4ccd0', {metalness: .8, roughness: .3});
|
|
||||||
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
|
|
||||||
const animated = [];
|
|
||||||
|
|
||||||
// ---------- Yard, fence, gate ----------
|
|
||||||
flat(-110, 80, -50, 50, .08, floorMat('#8f8c86', {roughness: .95, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
|
|
||||||
const fence = mat('#d9d3c4');
|
|
||||||
box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
|
|
||||||
box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
|
|
||||||
box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
|
|
||||||
box(79.7, 80.3, -50, -9, 0, 2.4, fence);
|
|
||||||
box(79.7, 80.3, 9, 50, 0, 2.4, fence);
|
|
||||||
for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
|
|
||||||
// Painted parking bays in front of the office.
|
|
||||||
const paint = floorMat('#f1efe6', {roughness: .9, polygonOffsetFactor: -18, polygonOffsetUnits: -36});
|
|
||||||
for (let u = 38; u <= 70; u += 4) flat(u - .06, u + .06, -21.5, -16.5, .09, paint);
|
|
||||||
|
|
||||||
const nameSign = textTexture(1024, 320, (c, w, h) => {
|
|
||||||
c.fillStyle = '#0d2f2c';
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.fillStyle = '#3ee0c9';
|
|
||||||
c.fillRect(0, h - 18, w, 18);
|
|
||||||
c.textAlign = 'center';
|
|
||||||
c.direction = 'rtl';
|
|
||||||
c.fillStyle = '#ffffff';
|
|
||||||
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
|
|
||||||
c.fillText(FACTORY.name, w / 2, 120);
|
|
||||||
c.fillStyle = '#9fe9da';
|
|
||||||
c.font = '44px Vazirmatn, Tahoma, sans-serif';
|
|
||||||
c.fillText(FACTORY.tagline, w / 2, 210);
|
|
||||||
c.font = 'bold 34px sans-serif';
|
|
||||||
c.fillStyle = '#f2c94c';
|
|
||||||
c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
|
|
||||||
});
|
|
||||||
const signMaterial = new THREE.MeshBasicMaterial({map: nameSign, side: THREE.DoubleSide});
|
|
||||||
// Two faces, so the name also reads correctly from inside the yard.
|
|
||||||
const frontSign = new THREE.MeshBasicMaterial({map: nameSign});
|
|
||||||
for (const side of [1, -1]) {
|
|
||||||
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), frontSign);
|
|
||||||
gateSign.position.set(82 + side * .02, 6.6, -20);
|
|
||||||
gateSign.rotation.y = side * Math.PI / 2;
|
|
||||||
group.add(gateSign);
|
|
||||||
}
|
|
||||||
for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, steel, false);
|
|
||||||
|
|
||||||
// ---------- Production hall ----------
|
|
||||||
// Corrugated walls, a wide door onto the yard (v = V0) and a loading dock door at the east end.
|
|
||||||
const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
|
|
||||||
corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
|
|
||||||
corrugated.repeat.set(18, 2);
|
|
||||||
const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
|
|
||||||
const H = 10, U0 = -104, U1 = 0, V0 = 10, V1 = 42, D0 = -36, D1 = -20, DOCK0 = 27, DOCK1 = 35;
|
|
||||||
box(U0, U1, V1, V1 + .4, 0, H, wall);
|
|
||||||
box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
|
|
||||||
box(U1, U1 + .4, V0 - .4, DOCK0, 0, H, wall);
|
|
||||||
box(U1, U1 + .4, DOCK1, V1 + .4, 0, H, wall);
|
|
||||||
box(U1, U1 + .4, DOCK0, DOCK1, 5, H, wall, false);
|
|
||||||
box(U0 - .4, D0, V0 - .4, V0, 0, H, wall);
|
|
||||||
box(D1, U1 + .4, V0 - .4, V0, 0, H, wall);
|
|
||||||
box(D0, D1, V0 - .4, V0, 7, H, wall, false);
|
|
||||||
box(U0 - .4, U1 + .4, V0 - .5, V0 - .3, H - 1.6, H - .6, teal, false);
|
|
||||||
const roof = box(U0 - 1, U1 + 1, V0 - 1, V1 + 1, H, H + .4, mat('#9aa6aa', {metalness: .4}), false);
|
|
||||||
// The roof stays out of the shadow map, so daylight still reaches the hall floor.
|
|
||||||
roof.castShadow = false;
|
|
||||||
flat(U0, U1, V0, V1, .1, floorMat('#b9c4bd', {roughness: .35}));
|
|
||||||
// Safety walkway along the door side.
|
|
||||||
flat(U0 + 1, U1 - 1, 18.6, 19, .11, floorMat('#f2c94c', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
|
|
||||||
for (let u = U0 + 8; u < U1; u += 12) for (const v of [18, 32]) box(u - 3, u + 3, v - 1, v + 1, H - .3, H - .1, lamp, false).castShadow = false;
|
|
||||||
panel(nameSign, 16, 5, (D0 + D1) / 2, H + 3, V0 - .6, -1);
|
|
||||||
for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, steel, false);
|
|
||||||
// Work-area signs hang above the walkway, readable from the door.
|
|
||||||
const zone = (text, sub, u) => {
|
|
||||||
panel(sign(text, sub), 5.4, 1.7, u, 6.6, 21, -1);
|
|
||||||
for (const du of [-2.2, 2.2]) box(u + du - .03, u + du + .03, 20.97, 21.03, 7.45, H, steel, false);
|
|
||||||
};
|
|
||||||
zone('خمکاری', 'ورقکاری بدنهٔ دستگاه', -93);
|
|
||||||
zone('برش لیزر', 'برش ورق و قطعات', -71);
|
|
||||||
zone('رنگکاری', 'کابین رنگ پودری', -52);
|
|
||||||
zone('یخچالسازی', 'کمپرسور و عایقکاری', -31);
|
|
||||||
zone('مونتاژ', 'خط مونتاژ نهایی', -13);
|
|
||||||
zone('بستهبندی', 'ارسال به شهر', -3);
|
|
||||||
|
|
||||||
// Bending: two press brakes with a moving ram, sheet stacks behind.
|
|
||||||
const sheet = mat('#d4dadd', {metalness: .85, roughness: .25});
|
|
||||||
for (const u of [-98, -88]) {
|
|
||||||
box(u - 1.8, u + 1.8, 29.4, 30.6, 0, 1.0, mat('#4a6b80', {metalness: .4}));
|
|
||||||
for (const du of [-1.8, 1.5]) box(u + du, u + du + .3, 29.2, 30.8, 0, 2.9, mat('#4a6b80', {metalness: .4}), false);
|
|
||||||
const ram = box(u - 1.5, u + 1.5, 29.6, 30.4, 1.9, 2.5, yellow, false);
|
|
||||||
animated.push(t => ram.position.y = 2.2 + Math.max(0, Math.sin(t * 1.4 + u)) * -.6);
|
|
||||||
box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, sheet, false);
|
|
||||||
}
|
|
||||||
for (const u of [-101, -95, -90, -85]) {
|
|
||||||
box(u - 1, u + 1, 38.4, 39.6, 0, .15, mat('#8a6a45'));
|
|
||||||
for (let i = 0; i < 6; i++) box(u - .95, u + .95, 38.45, 39.55, .15 + i * .05, .19 + i * .05, sheet, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Laser cutting: a bed with a moving gantry and glowing cutting head, plus a shear.
|
|
||||||
box(-78, -66, 26, 32, 0, .9, darkSteel);
|
|
||||||
const slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12));
|
|
||||||
flat(-77.8, -66.2, 26.2, 31.8, .91, new THREE.MeshStandardMaterial({map: slats, metalness: .6, roughness: .4}));
|
|
||||||
const gantry = new THREE.Group();
|
|
||||||
group.add(gantry);
|
|
||||||
box(-.3, .3, 25.4, 32.6, 1.6, 1.9, yellow, false, gantry);
|
|
||||||
for (const v of [25.5, 32.5]) box(-.25, .25, v - .2, v + .2, .9, 1.9, yellow, false, gantry);
|
|
||||||
const head = box(-.2, .2, -.2, .2, 1.1, 1.6, darkSteel, false, gantry);
|
|
||||||
const spark = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({color: '#ff5a3c'}));
|
|
||||||
gantry.add(spark);
|
|
||||||
animated.push(t => {
|
|
||||||
gantry.position.x = -72 + Math.sin(t * .45) * 5;
|
|
||||||
head.position.z = spark.position.z = 29 + Math.sin(t * 1.3) * 2.5;
|
|
||||||
spark.position.y = .95;
|
|
||||||
spark.scale.setScalar(.7 + Math.random() * .6);
|
|
||||||
});
|
|
||||||
box(-65, -64, 33.2, 33.8, 0, 1.4, mat('#2e3a40'));
|
|
||||||
box(-73.5, -70.5, 37.9, 39.1, 0, 1.6, mat('#4a6b80', {metalness: .4}));
|
|
||||||
|
|
||||||
// Powder painting booth: see-through panels, an overhead rail carrying panels through it.
|
|
||||||
const glass = mat('#9fd3e6', {transparent: true, opacity: .28, depthWrite: false, roughness: .1});
|
|
||||||
box(-58, -46, 36.8, 37, 0, 4.2, glass);
|
|
||||||
box(-58, -57.8, 25, 37, 0, 4.2, glass);
|
|
||||||
box(-46.2, -46, 25, 37, 0, 4.2, glass);
|
|
||||||
box(-58, -46, 25, 37, 4.2, 4.3, glass, false);
|
|
||||||
flat(-57.8, -46.2, 25.2, 36.8, .12, floorMat('#5d7f86', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
|
|
||||||
box(-61, -43, 30.9, 31.1, 3.7, 3.8, steel, false);
|
|
||||||
const hung = [];
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const piece = box(-.6, .6, 30.97, 31.03, 1.4, 3.2, i % 2 ? teal : mat('#eef4f2'), false);
|
|
||||||
hung.push(piece);
|
|
||||||
}
|
|
||||||
animated.push(t => hung.forEach((piece, i) => piece.position.x = -60 + ((t * .5 + i * 3.4) % 17)));
|
|
||||||
|
|
||||||
// Refrigeration: work tables with insulated cabinets, compressors and copper coils.
|
|
||||||
const copper = mat('#c27a45', {metalness: .9, roughness: .3}), insulated = mat('#f2f4f3', {roughness: .5});
|
|
||||||
for (const u of [-37, -31, -25]) {
|
|
||||||
box(u - 2, u + 2, 29.2, 30.8, 0, .9, steel);
|
|
||||||
box(u - 1.6, u - .2, 29.4, 30.6, .9, 2.2, insulated, false);
|
|
||||||
cylinder(.28, .55, mat('#16191c', {metalness: .5}), u + .7, 1.18, 30);
|
|
||||||
const coil = new THREE.Mesh(new THREE.TorusGeometry(.35, .04, 6, 20), copper);
|
|
||||||
coil.position.set(u + 1.4, 1.3, 30);
|
|
||||||
coil.rotation.y = Math.PI / 2;
|
|
||||||
group.add(coil);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 4; i++) box(-35 + i * 2.1, -33.2 + i * 2.1, 38.6, 40.4, 0, 1.9, insulated);
|
|
||||||
|
|
||||||
// Assembly: conveyor carrying machine frames between robot arms, finished machines at the end.
|
|
||||||
box(-20, -6, 32.4, 34.6, 0, .9, darkSteel);
|
|
||||||
const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
|
|
||||||
for (let u = -19.4; u < -6; u += 1.2) {
|
|
||||||
const roller = cylinder(.12, 2.2, rollers, u, .95, 33.5);
|
|
||||||
roller.rotation.x = Math.PI / 2;
|
|
||||||
}
|
|
||||||
const frames = [], frameMaterial = mat('#0b8f88', {wireframe: true});
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.1, 2.4, 1.8), i % 2 ? frameMaterial : teal);
|
|
||||||
frame.position.set(-19 + i * 3.5, 2.2, 33.5);
|
|
||||||
frame.castShadow = true;
|
|
||||||
group.add(frame);
|
|
||||||
frames.push(frame);
|
|
||||||
}
|
|
||||||
const arms = [];
|
|
||||||
for (const u of [-16, -10]) {
|
|
||||||
box(u - .6, u + .6, 36, 37.2, 0, 1.2, yellow);
|
|
||||||
const arm = new THREE.Group();
|
|
||||||
arm.position.set(u, 1.2, 36.6);
|
|
||||||
group.add(arm);
|
|
||||||
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), yellow);
|
|
||||||
upper.position.y = 1.1;
|
|
||||||
upper.castShadow = true;
|
|
||||||
const fore = new THREE.Mesh(new THREE.BoxGeometry(.28, .28, 2.4), mat('#e0b23a'));
|
|
||||||
fore.position.set(0, 2.1, -1.1);
|
|
||||||
fore.castShadow = true;
|
|
||||||
arm.add(upper, fore);
|
|
||||||
arms.push(arm);
|
|
||||||
}
|
|
||||||
animated.push(t => {
|
|
||||||
arms.forEach((arm, i) => arm.rotation.y = Math.sin(t * .8 + i * 2) * .9);
|
|
||||||
frames.forEach((frame, i) => frame.position.x = -19.5 + ((t * .6 + i * 3.5) % 14));
|
|
||||||
});
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
|
|
||||||
finished.beacon.visible = finished.ring.visible = false;
|
|
||||||
const u = -19.2 + i * 4.2;
|
|
||||||
finished.group.position.set(u, 0, 40.6);
|
|
||||||
finished.group.rotation.y = Math.PI;
|
|
||||||
group.add(finished.group);
|
|
||||||
solid(u - 1, u + 1, 40, 41.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Packing: wrapped machines and a roller conveyor out through the dock door to the truck.
|
|
||||||
const carton = textTexture(256, 512, (c, w, h) => {
|
|
||||||
c.fillStyle = '#c49a64';
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.fillStyle = 'rgba(255,255,255,.18)';
|
|
||||||
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
|
|
||||||
c.fillStyle = '#0b8f88';
|
|
||||||
c.fillRect(0, h * .42, w, 34);
|
|
||||||
c.fillStyle = '#1d2a2a';
|
|
||||||
c.textAlign = 'center';
|
|
||||||
c.font = 'bold 34px sans-serif';
|
|
||||||
c.fillText('ROBOT', w / 2, h * .3);
|
|
||||||
c.fillText('MARKET', w / 2, h * .3 + 40);
|
|
||||||
c.font = '60px sans-serif';
|
|
||||||
c.fillText('⇧', w / 2, h * .75);
|
|
||||||
});
|
|
||||||
const cartonMat = mat('#ffffff', {map: carton, roughness: .85});
|
|
||||||
const cartonGeometry = new THREE.BoxGeometry(.95, 1.9, 1.05);
|
|
||||||
const packaged = () => {
|
|
||||||
const mesh = new THREE.Mesh(cartonGeometry, cartonMat);
|
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
for (const v of [37, 38.4, 39.8]) {
|
|
||||||
const p = packaged();
|
|
||||||
p.position.set(-3.2, .95, v);
|
|
||||||
group.add(p);
|
|
||||||
solid(-3.7, -2.7, v - .55, v + .55);
|
|
||||||
}
|
|
||||||
box(-6, 1.6, 30.4, 31.6, 0, 1.0, darkSteel);
|
|
||||||
for (let u = -5.6; u < 1.6; u += .8) cylinder(.08, 1.1, rollers, u, 1.04, 31).rotation.x = Math.PI / 2;
|
|
||||||
|
|
||||||
// ---------- Production crew: 40 workers ----------
|
|
||||||
const overall = '#2f4e7a', vest = '#e8742c', paintSuit = '#eef1f2';
|
|
||||||
const W = (x, z, facing, kind = 'work', extra = {}) => ({x, z, facing, kind, suit: overall, ...extra});
|
|
||||||
const walk = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'walk', {to: {x: tx, z: tz}, ...extra});
|
|
||||||
const carry = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'carry', {to: {x: tx, z: tz}, ...extra});
|
|
||||||
const crew = [
|
|
||||||
// Bending: two at each press, four carrying sheets.
|
|
||||||
W(-98.8, 32.3, Math.PI), W(-97.2, 32.3, Math.PI), W(-88.8, 32.3, Math.PI), W(-87.2, 32.3, Math.PI, 'work', {suit: vest}),
|
|
||||||
carry(-101, 37.3, -99.5, 33.6), carry(-95, 37.3, -96.2, 33.6, {suit: vest}), carry(-90, 37.3, -90.5, 33.6), carry(-85, 37.3, -86.2, 33.6),
|
|
||||||
// Laser cutting: operators at the bed and control panel, two at the shear, three moving parts.
|
|
||||||
W(-64.5, 34.6, Math.PI), W(-68, 33.4, Math.PI), W(-75, 33.4, Math.PI, 'work', {suit: vest}),
|
|
||||||
W(-73, 37.1, 0), W(-71, 37.1, 0),
|
|
||||||
walk(-79, 35.3, -62, 35.3), carry(-68, 40.4, -62, 40.4), walk(-80, 37, -76, 40.4, {suit: vest}),
|
|
||||||
// Painting: four spraying inside the booth in white suits, three outside.
|
|
||||||
W(-55, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-51.5, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}),
|
|
||||||
W(-53, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-49, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}),
|
|
||||||
walk(-60, 23, -44, 23), carry(-59, 39.4, -45, 39.4), walk(-44.5, 26, -44.5, 37, {suit: vest}),
|
|
||||||
// Refrigeration: two at each table, three moving cabinets and coils.
|
|
||||||
W(-38, 28.4, 0), W(-36, 28.4, 0), W(-32, 28.4, 0, 'work', {suit: vest}), W(-30, 28.4, 0), W(-26, 28.4, 0), W(-24, 28.4, 0),
|
|
||||||
walk(-39, 34.4, -22, 34.4), carry(-24, 37, -38, 37), walk(-31, 32.4, -31, 36),
|
|
||||||
// Assembly and packing.
|
|
||||||
W(-18, 31, 0), W(-14.4, 31, 0), W(-11, 31, 0, 'work', {suit: vest}), W(-7.6, 31, 0),
|
|
||||||
W(-17.1, 38.4, 0), W(-12.9, 38.4, 0),
|
|
||||||
carry(-20, 24, -4, 24), carry(-3, 22.5, -3, 29)
|
|
||||||
];
|
|
||||||
const workers = createCrew(group, crew);
|
|
||||||
for (const w of crew) if (w.kind === 'work') solid(w.x - .3, w.x + .3, w.z - .3, w.z + .3);
|
|
||||||
|
|
||||||
// ---------- Office building: engineering, accounting, management ----------
|
|
||||||
// One storey, rooms side by side with their own doors onto the yard (front at v = -24).
|
|
||||||
const windows = textTexture(128, 128, (c, w, h) => {
|
|
||||||
c.fillStyle = '#e9e4d8';
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.fillStyle = '#4c6672';
|
|
||||||
c.fillRect(14, 26, 100, 64);
|
|
||||||
c.fillStyle = 'rgba(255,255,255,.25)';
|
|
||||||
c.fillRect(18, 30, 28, 56);
|
|
||||||
});
|
|
||||||
windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
|
|
||||||
const facade = mat('#ffffff', {map: windows}), plaster = mat('#ece8df', {roughness: .9});
|
|
||||||
const OH = 3.8, O0 = 6, O1 = 50, F = -24, B = -46;
|
|
||||||
const doors = [[14, 17], [31, 34], [43.5, 46.5]];
|
|
||||||
let from = O0;
|
|
||||||
for (const [d0, d1] of doors) {
|
|
||||||
box(from, d0, F - .3, F, 0, OH, plaster);
|
|
||||||
box(d0, d1, F - .3, F, 2.7, OH, plaster, false);
|
|
||||||
from = d1;
|
|
||||||
}
|
|
||||||
box(from, O1, F - .3, F, 0, OH, plaster);
|
|
||||||
box(O0, O1, B - .3, B, 0, OH, plaster);
|
|
||||||
box(O0 - .3, O0, B - .3, F, 0, OH, plaster);
|
|
||||||
box(O1, O1 + .3, B - .3, F, 0, OH, plaster);
|
|
||||||
box(25.85, 26.15, B, F - .3, 0, OH, plaster);
|
|
||||||
box(39.85, 40.15, B, F - .3, 0, OH, plaster);
|
|
||||||
const officeRoof = box(O0 - .5, O1 + .5, B - .8, F + .5, OH, OH + .25, mat('#bdb5a5'), false);
|
|
||||||
officeRoof.castShadow = false;
|
|
||||||
box(O0 - .5, O1 + .5, F + .4, F + .5, OH - .5, OH, teal, false);
|
|
||||||
// Windows on the front wall between the doors, and on the end walls.
|
|
||||||
for (const [u0, u1] of [[7, 13.2], [18, 30.2], [35, 42.6], [47.4, 49.4]]) {
|
|
||||||
const pane = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, 1.4), facade);
|
|
||||||
pane.position.set((u0 + u1) / 2, 1.9, F + .02);
|
|
||||||
facade.map.repeat.set(1, 1);
|
|
||||||
group.add(pane);
|
|
||||||
}
|
|
||||||
panel(nameSign, 22, 3.4, 28, OH + 2, F + .1);
|
|
||||||
for (const u of [18, 38]) box(u - .15, u + .15, F - .1, F + .05, OH, OH + 3.7, steel, false);
|
|
||||||
flat(O0, 26, B, F, .1, floorMat('#a9b4bb'));
|
|
||||||
flat(26, 40, B, F, .1, floorMat('#c8bba4'));
|
|
||||||
flat(40, O1, B, F, .1, floorMat('#7b5a44', {roughness: .5}));
|
|
||||||
for (const [u0, u1] of [[8, 24], [28, 38], [42, 48]]) box(u0, u1, -36, -34, OH - .08, OH - .02, lamp, false).castShadow = false;
|
|
||||||
panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, F + .05);
|
|
||||||
panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, F + .05);
|
|
||||||
panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, F + .05);
|
|
||||||
|
|
||||||
const wood = mat('#c8a477', {roughness: .6}), black = mat('#1b1d20', {roughness: .5}), fabric = mat('#2c3238');
|
|
||||||
// Desk facing `facing` (the side the person sits on) with one or two monitors.
|
|
||||||
function desk(u, v, sitSide, screens, width = 1.8) {
|
|
||||||
box(u - width / 2, u + width / 2, v - .45, v + .45, .72, .77, wood);
|
|
||||||
for (const du of [-1, 1]) box(u + du * (width / 2 - .05) - .03, u + du * (width / 2 - .05) + .03, v - .4, v + .4, 0, .72, black, false);
|
|
||||||
screens.forEach((texture, i) => {
|
|
||||||
const du = screens.length > 1 ? (i - .5) * .78 : 0;
|
|
||||||
const mats = [black, black, black, black, black, black];
|
|
||||||
mats[sitSide > 0 ? 4 : 5] = new THREE.MeshBasicMaterial({map: texture});
|
|
||||||
const monitor = new THREE.Mesh(new THREE.BoxGeometry(.72, .44, .04), mats);
|
|
||||||
monitor.position.set(u + du, 1.12, v - sitSide * .2);
|
|
||||||
monitor.rotation.y = (i - .5) * -.25 * sitSide * (screens.length > 1 ? 1 : 0);
|
|
||||||
group.add(monitor);
|
|
||||||
box(u + du - .03, u + du + .03, v - sitSide * .22 - .03, v - sitSide * .22 + .03, .77, .9, black, false);
|
|
||||||
});
|
|
||||||
box(u - .25, u + .25, v + sitSide * .12, v + sitSide * .28, .77, .79, mat('#30353b'), false);
|
|
||||||
}
|
|
||||||
// Office chair behind a seated person; the back is on the far side from the desk.
|
|
||||||
function chair(u, v, sitSide) {
|
|
||||||
box(u - .26, u + .26, v - .26, v + .26, .4, .47, fabric, false);
|
|
||||||
box(u - .25, u + .25, v + sitSide * .26, v + sitSide * .32, .47, 1.1, fabric, false);
|
|
||||||
cylinder(.035, .4, black, u, .2, v);
|
|
||||||
box(u - .3, u + .3, v - .03, v + .03, .02, .06, black, false);
|
|
||||||
box(u - .03, u + .03, v - .3, v + .3, .02, .06, black, false);
|
|
||||||
solid(u - .35, u + .35, v - .35, v + .35);
|
|
||||||
}
|
|
||||||
const people = [], talks = [];
|
|
||||||
// A seated person at a desk; `sitSide` is +1 when they sit on the +v side facing −v.
|
|
||||||
function seat(person, u, v, sitSide, tag) {
|
|
||||||
person.root.position.set(u, 0, v);
|
|
||||||
person.root.rotation.y = sitSide > 0 ? Math.PI : 0;
|
|
||||||
group.add(person.root);
|
|
||||||
if (tag) {
|
|
||||||
tag.position.set(0, 1.78, 0);
|
|
||||||
person.root.add(tag);
|
|
||||||
}
|
|
||||||
chair(u, v, sitSide);
|
|
||||||
return person;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Engineering: five developers, a whiteboard, and the office's vending machine by the door.
|
|
||||||
const developers = [
|
|
||||||
{name: 'حامد', role: 'React', u: 11, v: -31.6, look: {outfit: 'hoodie', top: '#151517', bottom: '#2d3d57'}, code: CODE.react, speaker: 'hamed',
|
|
||||||
lines: ['سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاهها رو با React میسازم.', 'هر چی روی صفحهٔ دستگاه میبینین، از همین مانیتور اومده!']},
|
|
||||||
{name: 'رایان', role: 'Go', u: 16, v: -31.6, look: {outfit: 'hoodie', top: '#2f6fd6', bottom: '#33363b', skin: '#e9bf9c'}, code: CODE.go, speaker: 'ryan',
|
|
||||||
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاهها رو با Go مینویسم.']},
|
|
||||||
{name: 'علیرضا', role: 'C++', u: 21, v: -31.6, look: {outfit: 'hoodie', top: '#1a1a1c', bottom: '#3b4a5e', hair: '#1a1410'}, code: CODE.cpp, speaker: 'alireza',
|
|
||||||
lines: ['سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم میکنم.']},
|
|
||||||
{name: 'رضا', role: 'C++', u: 13.5, v: -38.6, look: {outfit: 'suit', top: '#2b2f3a', bottom: '#2b2f3a', tie: '#7a1f2b', shoes: '#111'}, code: CODE.cpp, speaker: 'reza',
|
|
||||||
lines: ['سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.', 'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!']},
|
|
||||||
{name: 'محمد', role: '', u: 18.5, v: -38.6, look: {outfit: 'hoodie', top: '#6f767d', bottom: '#2a2f36', skin: '#d9a985'}, code: CODE.terminal, speaker: 'mohammad',
|
|
||||||
lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!']}
|
|
||||||
];
|
|
||||||
for (const dev of developers) {
|
|
||||||
desk(dev.u, dev.v - 1.0, 1, [dev.code, CODE.terminal]);
|
|
||||||
const person = seat(createPerson(dev.look), dev.u, dev.v, 1, nameTag(dev.name, dev.role));
|
|
||||||
people.push({person, pose: 'type'});
|
|
||||||
talks.push({speaker: dev.speaker, label: dev.name, lines: dev.lines, radius: 2.6, pos: () => toWorld(dev.u, dev.v)});
|
|
||||||
}
|
|
||||||
const board = textTexture(512, 256, (c, w, h) => {
|
|
||||||
c.fillStyle = '#fbfbf8';
|
|
||||||
c.fillRect(0, 0, w, h);
|
|
||||||
c.strokeStyle = '#9aa4a8';
|
|
||||||
c.lineWidth = 8;
|
|
||||||
c.strokeRect(0, 0, w, h);
|
|
||||||
c.textAlign = 'center';
|
|
||||||
c.direction = 'rtl';
|
|
||||||
c.fillStyle = '#1d4f91';
|
|
||||||
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
|
|
||||||
c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
|
|
||||||
c.font = '28px Vazirmatn, Tahoma, sans-serif';
|
|
||||||
c.fillStyle = '#333';
|
|
||||||
['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
|
|
||||||
});
|
|
||||||
panel(board, 4, 2, 16, 1.9, B + .05);
|
|
||||||
const officeMachine = {u: 6.8, v: -28.6};
|
|
||||||
|
|
||||||
// Accounting: four colleagues at desks, filing cabinets along the wall.
|
|
||||||
const accountants = [
|
|
||||||
{u: 29.5, v: -31.6, look: {top: '#2d3550', scarf: '#c25b7a'}},
|
|
||||||
{u: 36.5, v: -31.6, look: {top: '#1f1f22', scarf: '#3e6e8e', skin: '#edc2a0'}},
|
|
||||||
{u: 29.5, v: -38.6, look: {top: '#8a6e55', scarf: '#e3d3b9'}},
|
|
||||||
{u: 36.5, v: -38.6, look: {top: '#4a5a48', scarf: '#7a3b52', skin: '#d9a985'}}
|
|
||||||
];
|
|
||||||
for (const a of accountants) {
|
|
||||||
desk(a.u, a.v - 1.0, 1, [CODE.sheet]);
|
|
||||||
people.push({person: seat(createPerson({outfit: 'manteau', bottom: '#24262b', shoes: '#111', ...a.look}), a.u, a.v, 1), pose: 'type'});
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 4; i++) box(26.2, 26.9, -44.5 + i * 1, -43.6 + i * 1, 0, 1.4, mat('#9aa3a8', {metalness: .5}));
|
|
||||||
talks.push({speaker: 'accountant', label: 'واحد حسابداری', radius: 4, pos: () => toWorld(33, -29),
|
|
||||||
lines: ['سلام! اینجا فروش همهٔ دستگاهها ثبت میشه. هر خریدی بکنین، فاکتورش اینجا میاد!']});
|
|
||||||
|
|
||||||
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
|
|
||||||
desk(45, -39.7, -1, [CODE.dashboard], 2.4);
|
|
||||||
chair(45, -40.7, -1);
|
|
||||||
for (const u of [43.8, 46.2]) {
|
|
||||||
box(u - .28, u + .28, -37.1, -36.5, .42, .5, mat('#5a3a2a'), false);
|
|
||||||
box(u - .28, u + .28, -36.5, -36.4, .5, 1.0, mat('#5a3a2a'), false);
|
|
||||||
solid(u - .3, u + .3, -37.1, -36.4);
|
|
||||||
}
|
|
||||||
box(41, 49, -45.9, -45.4, 0, 2.6, mat('#6b4a33'));
|
|
||||||
for (let i = 0; i < 4; i++) for (let j = 0; j < 9; j++) box(41.3 + j * .85, 41.3 + j * .85 + .5, -45.4, -45.2, .3 + i * .6, .78 + i * .6, mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]), false);
|
|
||||||
box(49, 49.5, -27, -26.5, 0, .5, mat('#7a5a3c'), true);
|
|
||||||
const leaves = new THREE.Mesh(new THREE.SphereGeometry(.45, 10, 8), mat('#3e7a3e'));
|
|
||||||
leaves.position.set(49.25, 1.0, -26.75);
|
|
||||||
group.add(leaves);
|
|
||||||
const nameplate = panel(sign('محمدی', 'مدیر شرکت'), .6, .19, 45, .87, -39.2, 1);
|
|
||||||
nameplate.rotation.x = -.3;
|
|
||||||
|
|
||||||
// ---------- Staff car park ----------
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
|
|
||||||
const u = 56 + i * 4;
|
|
||||||
parked.group.position.set(u, 0, -19);
|
|
||||||
parked.group.rotation.y = Math.PI / 2;
|
|
||||||
group.add(parked.group);
|
|
||||||
solid(u - 1.1, u + 1.1, -21.3, -16.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- The manager arrives in his pickup ----------
|
|
||||||
// Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
|
|
||||||
// his room and sits at his desk.
|
|
||||||
const pickup = createPickup('#f1f1ee');
|
|
||||||
group.add(pickup.group);
|
|
||||||
const manager = createPerson({outfit: 'suit', top: '#3a3f47', bottom: '#3a3f47', hair: '#8d8a86', glasses: true, shoes: '#111'});
|
|
||||||
const managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
|
|
||||||
managerTag.position.y = 2.15;
|
|
||||||
manager.root.add(managerTag);
|
|
||||||
group.add(manager.root);
|
|
||||||
const DRIVE_IN = [[98, -3], [80, -3], [62, -5], [51, -9], [46, -16]];
|
|
||||||
const WALK_IN = [[47.4, -16.9], [45, -21], [45, -25.6], [48.5, -29], [48.5, -40.7], [45, -40.7]];
|
|
||||||
{
|
|
||||||
// The parked pickup blocks its bay.
|
|
||||||
const [ax, az] = DRIVE_IN.at(-2), [bx, bz] = DRIVE_IN.at(-1), len = Math.hypot(bx - ax, bz - az);
|
|
||||||
const du = (bx - ax) / len, dv = (bz - az) / len;
|
|
||||||
footprints.push([[2.7, 1], [2.7, -1], [-2.7, -1], [-2.7, 1]].flatMap(([a, b]) => Object.values(toWorld(bx + du * a - dv * b, bz + dv * a + du * b))));
|
|
||||||
}
|
|
||||||
const arrival = {stage: 'drive', s: 0, heading: Math.PI, sat: false};
|
|
||||||
talks.push({speaker: 'mohammadi', label: 'آقای محمدی', radius: 3.4, pos: () => toWorld(manager.root.position.x, manager.root.position.z), ready: () => arrival.stage === 'seated',
|
|
||||||
lines: ['سلام، خوش اومدین! من محمدیام، مدیر روبات مارکت.', 'امروز چهل نفر توی سالن تولید کار میکنن: خمکاری، برش، رنگ، یخچالسازی و مونتاژ.', 'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاههای شهر!']});
|
|
||||||
|
|
||||||
// Position and direction a distance `s` along a polyline of [u, v] points.
|
|
||||||
const along = (path, s) => {
|
|
||||||
for (let i = 1; i < path.length; i++) {
|
|
||||||
const [ax, az] = path[i - 1], [bx, bz] = path[i], len = Math.hypot(bx - ax, bz - az);
|
|
||||||
if (s <= len || i === path.length - 1) {
|
|
||||||
const t = Math.min(1, s / len);
|
|
||||||
return {u: ax + (bx - ax) * t, v: az + (bz - az) * t, du: (bx - ax) / len, dv: (bz - az) / len, done: s >= len && i === path.length - 1};
|
|
||||||
}
|
|
||||||
s -= len;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const turn = (from, to, k) => from + Math.atan2(Math.sin(to - from), Math.cos(to - from)) * k;
|
|
||||||
const placeVehicle = (vehicle, state, p, dt, reverse = false) => {
|
|
||||||
const want = reverse ? Math.atan2(p.dv, -p.du) : Math.atan2(-p.dv, p.du);
|
|
||||||
state.heading = dt ? turn(state.heading, want, Math.min(1, dt * 3)) : want;
|
|
||||||
vehicle.group.position.set(p.u, 0, p.v);
|
|
||||||
vehicle.group.rotation.y = state.heading;
|
|
||||||
};
|
|
||||||
function updateArrival(dt, time) {
|
|
||||||
if (arrival.stage === 'drive') {
|
|
||||||
arrival.s += dt * 7;
|
|
||||||
const p = along(DRIVE_IN, arrival.s);
|
|
||||||
placeVehicle(pickup, arrival, p, dt);
|
|
||||||
pickup.wheels.forEach(w => w.rotation.z -= dt * 7 / .4);
|
|
||||||
manager.root.visible = false;
|
|
||||||
if (p.done) Object.assign(arrival, {stage: 'walk', s: 0});
|
|
||||||
} else if (arrival.stage === 'walk') {
|
|
||||||
arrival.s += dt * 1.3;
|
|
||||||
const p = along(WALK_IN, arrival.s);
|
|
||||||
manager.root.visible = true;
|
|
||||||
manager.root.position.set(p.u, 0, p.v);
|
|
||||||
manager.root.rotation.y = Math.atan2(p.du, p.dv);
|
|
||||||
manager.pose('walk', time);
|
|
||||||
if (p.done) arrival.stage = 'seated';
|
|
||||||
} else if (!arrival.sat) {
|
|
||||||
arrival.sat = true;
|
|
||||||
manager.root.position.set(45, 0, -40.7);
|
|
||||||
manager.root.rotation.y = 0;
|
|
||||||
manager.pose('sit');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function resetArrival() {
|
|
||||||
Object.assign(arrival, {stage: 'drive', s: 0, sat: false});
|
|
||||||
placeVehicle(pickup, arrival, along(DRIVE_IN, 0), 0);
|
|
||||||
manager.root.visible = false;
|
|
||||||
}
|
|
||||||
resetArrival();
|
|
||||||
|
|
||||||
// ---------- Delivery truck ----------
|
|
||||||
// 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.
|
|
||||||
const truck = createTruck();
|
|
||||||
group.add(truck.group);
|
|
||||||
const DOCK = [5.5, 31];
|
|
||||||
const DEPART = [DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
|
|
||||||
const RETURN = [[100, 0], [80, 0], [60, 6], [40, 14], [27, 18]];
|
|
||||||
const BACK_IN = [[27, 18], [16, 28.5], DOCK];
|
|
||||||
const SLOTS = [2.1, .95, -.2, -1.35].flatMap(x => [[x, -.6], [x, .6]]);
|
|
||||||
const loaded = [];
|
|
||||||
const moving = packaged();
|
|
||||||
group.add(moving);
|
|
||||||
const delivery = {stage: 'load', t: 0, s: 0, heading: 0};
|
|
||||||
const truckAtDock = () => {
|
|
||||||
truck.group.visible = true;
|
|
||||||
truck.group.position.set(DOCK[0], 0, DOCK[1]);
|
|
||||||
truck.group.rotation.y = delivery.heading = 0;
|
|
||||||
};
|
|
||||||
truckAtDock();
|
|
||||||
function updateTruck(dt) {
|
|
||||||
const d = delivery;
|
|
||||||
d.t += dt;
|
|
||||||
moving.visible = d.stage === 'load';
|
|
||||||
if (d.stage === 'load') {
|
|
||||||
// Each box: two seconds along the conveyor, one second lifted onto its slot.
|
|
||||||
const [sx, sz] = SLOTS[loaded.length], bed = truck.bed.position;
|
|
||||||
const slot = {u: DOCK[0] + bed.x + sx, y: bed.y + .95, v: DOCK[1] + sz};
|
|
||||||
if (d.t < 2) moving.position.set(-6 + (1.6 + 6) * d.t / 2, 1.05 + .95, 31);
|
|
||||||
else {
|
|
||||||
const k = Math.min(1, d.t - 2);
|
|
||||||
moving.position.set(1.6 + (slot.u - 1.6) * k, 2.0 + (slot.y - 2.0) * k + Math.sin(k * Math.PI) * .5, 31 + (slot.v - 31) * k);
|
|
||||||
}
|
|
||||||
if (d.t >= 3) {
|
|
||||||
const box = packaged();
|
|
||||||
box.position.set(sx, .95, sz);
|
|
||||||
truck.bed.add(box);
|
|
||||||
loaded.push(box);
|
|
||||||
d.t = 0;
|
|
||||||
if (loaded.length === SLOTS.length) d.stage = 'full';
|
|
||||||
}
|
|
||||||
} else if (d.stage === 'full' && d.t > 2) Object.assign(d, {stage: 'depart', s: 0});
|
|
||||||
else if (d.stage === 'depart' || d.stage === 'return' || d.stage === 'back') {
|
|
||||||
const speed = d.stage === 'back' ? 2.5 : 6;
|
|
||||||
d.s += dt * speed;
|
|
||||||
const path = {depart: DEPART, return: RETURN, back: BACK_IN}[d.stage];
|
|
||||||
const p = along(path, d.s);
|
|
||||||
placeVehicle(truck, d, p, dt, d.stage === 'back');
|
|
||||||
truck.wheels.forEach(w => w.rotation.z -= dt * speed / .5 * (d.stage === 'back' ? -1 : 1));
|
|
||||||
if (p.done) {
|
|
||||||
if (d.stage === 'depart') {
|
|
||||||
Object.assign(d, {stage: 'away', t: 0});
|
|
||||||
truck.group.visible = false;
|
|
||||||
loaded.splice(0).forEach(box => truck.bed.remove(box));
|
|
||||||
} else if (d.stage === 'return') Object.assign(d, {stage: 'back', s: 0});
|
|
||||||
else {
|
|
||||||
truckAtDock();
|
|
||||||
Object.assign(d, {stage: 'load', t: 0});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (d.stage === 'away' && d.t > 18) {
|
|
||||||
truck.group.visible = true;
|
|
||||||
Object.assign(d, {stage: 'return', s: 0, heading: Math.PI});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
talks.push({speaker: 'child', radius: 9, pos: () => toWorld(4, 31), once: true,
|
|
||||||
lines: ['بابا ببین! کامیون داره دستگاههای بستهبندیشده رو بار میزنه که ببره شهر!']});
|
|
||||||
// The family comments on each work area the first time they walk into it.
|
|
||||||
for (const [u, text, speaker] of [
|
|
||||||
[-93, 'اینجا خمکاریه؛ ورقهای فلزی بدنهٔ دستگاه رو با پرس خم میکنن.', 'father'],
|
|
||||||
[-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ میبُره.', 'child'],
|
|
||||||
[-52, 'این کابین رنگه؛ بدنهها اینجا رنگ فیروزهای روبات مارکت میگیرن.', 'father'],
|
|
||||||
[-31, 'یخچالسازی! برای همینه که نوشیدنیهای دستگاه همیشه خنکن.', 'child'],
|
|
||||||
[-13, 'خط مونتاژ: روباتها و کارگرها قطعهها رو کنار هم میذارن و دستگاه کامل میشه.', 'father']
|
|
||||||
]) talks.push({speaker, radius: 8, pos: () => toWorld(u, 25), once: true, lines: [text]});
|
|
||||||
|
|
||||||
// ---------- Key positions ----------
|
|
||||||
// The family starts in the yard between the hall and the office, the car parked right beside them.
|
|
||||||
const demo = toWorld(officeMachine.u, officeMachine.v), facing = {x: axis.x, z: axis.z};
|
|
||||||
const car = toWorld(1.5, -7);
|
|
||||||
const start = toWorld(-1, -3.6), door = toWorld(15.5, -24);
|
|
||||||
return {
|
|
||||||
group,
|
|
||||||
footprints,
|
|
||||||
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
|
|
||||||
car: {x: car.x, z: car.z, heading: worldHeading(1, 0)},
|
|
||||||
start: {x: start.x, z: start.z, heading: Math.atan2(-(door.z - start.z), door.x - start.x)},
|
|
||||||
label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
|
|
||||||
talks,
|
|
||||||
reset: resetArrival,
|
|
||||||
// `dt` is 0 while the game is paused; distant viewers skip the people and machines.
|
|
||||||
update(time, dt, viewer) {
|
|
||||||
const near = !viewer || Math.hypot(viewer.x - center.x, viewer.z - center.z) < 260;
|
|
||||||
updateArrival(dt, time);
|
|
||||||
updateTruck(dt);
|
|
||||||
workers.group.visible = near;
|
|
||||||
if (!near) return;
|
|
||||||
for (const step of animated) step(time);
|
|
||||||
workers.update(time);
|
|
||||||
people.forEach(({person, pose}, i) => person.pose(pose, time, i * 1.7));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
|
||||||
export const INITIAL_BALANCE = 500;
|
|
||||||
export const START_MINUTES = 8 * 60 + 30;
|
|
||||||
export const fa = n => n.toLocaleString('fa-IR');
|
|
||||||
export const formatHetoman = value => `${fa(value)} هتومان`;
|
|
||||||
export const hetomanToRial = value => value * 10000;
|
|
||||||
export const formatClock = minutes => {
|
|
||||||
const m = Math.floor(minutes) % (24 * 60);
|
|
||||||
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const catalog = {
|
|
||||||
juice: {id: 'juice', name: 'آبمیوه', icon: '🧃', cost: 35, type: 'drink'},
|
|
||||||
water: {id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink'},
|
|
||||||
milk: {id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink'},
|
|
||||||
tea: {id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink'},
|
|
||||||
lemonade: {id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink'},
|
|
||||||
biscuit: {id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack'},
|
|
||||||
chocolate: {id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack'},
|
|
||||||
nuts: {id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack'},
|
|
||||||
cake: {id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack'}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const priceAt = (machine, productId) => Math.round(catalog[productId].cost * machine.priceFactor);
|
|
||||||
|
|
||||||
export function isOpen(machine, minutes) {
|
|
||||||
const m = minutes % (24 * 60);
|
|
||||||
return m >= machine.hours[0] && m < machine.hours[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createState() {
|
|
||||||
return {balance: INITIAL_BALANCE, purchases: [], xp: 0, missionsDone: [], minutes: START_MINUTES, busy: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns why a purchase cannot happen, or null when it can.
|
|
||||||
export function purchaseBlocker(state, machine, productId) {
|
|
||||||
if (!machine || !machine.products.includes(productId)) return 'unknown';
|
|
||||||
if (state.busy) return 'busy';
|
|
||||||
if (!isOpen(machine, state.minutes)) return 'closed';
|
|
||||||
if (state.balance < priceAt(machine, productId)) return 'funds';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function purchase(state, machine, productId) {
|
|
||||||
if (purchaseBlocker(state, machine, productId)) return null;
|
|
||||||
const product = catalog[productId], price = priceAt(machine, productId);
|
|
||||||
const record = {machineId: machine.id, category: machine.category, productId, type: product.type, price, minutes: state.minutes};
|
|
||||||
state.balance -= price;
|
|
||||||
state.purchases.push(record);
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The run ends once no open or later-opening machine sells anything the player can afford.
|
|
||||||
export function canAffordAnything(state, machines) {
|
|
||||||
return machines.some(m => m.products.some(id => priceAt(m, id) <= state.balance) && (state.minutes % 1440) < m.hours[1]);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js';
|
||||||
|
/** Starts isolated systems in dependency order and returns their shared lifecycle cleanup.
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function createGame({ root, ui, signal, startMode }) {
|
||||||
|
const context = {
|
||||||
|
root,
|
||||||
|
ui,
|
||||||
|
signal,
|
||||||
|
startMode: null,
|
||||||
|
requestedMode: startMode ?? null,
|
||||||
|
};
|
||||||
|
bindRuntime(context);
|
||||||
|
try {
|
||||||
|
for (const [index, initialize] of RUNTIME_INITIALIZERS.entries()) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
await initialize(context);
|
||||||
|
ui.patch('loading', { progress: (index + 1) / RUNTIME_INITIALIZERS.length });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
context.renderer?.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return context.dispose ?? (() => context.renderer?.dispose());
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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
|
||||||
|
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
|
||||||
|
// robot appear in their real direction over the camera picture.
|
||||||
|
|
||||||
|
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
|
||||||
|
const CAMERA_LONG_FOV = 65;
|
||||||
|
const zee = new THREE.Vector3(0, 0, 1),
|
||||||
|
euler = new THREE.Euler(),
|
||||||
|
q0 = new THREE.Quaternion();
|
||||||
|
const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera looks out of the back, not the top
|
||||||
|
|
||||||
|
// Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
|
||||||
|
// `alpha` must be measured from north (absolute), counter-clockwise.
|
||||||
|
/**
|
||||||
|
* Runs the orientation quaternion operation for this subsystem.
|
||||||
|
* @param {*} target - target.
|
||||||
|
* @param {*} alpha - alpha.
|
||||||
|
* @param {*} beta - beta.
|
||||||
|
* @param {*} gamma - gamma.
|
||||||
|
* @param {*} screenAngle - screen Angle.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
|
||||||
|
const r = Math.PI / 180;
|
||||||
|
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
|
||||||
|
target.setFromEuler(euler);
|
||||||
|
target.multiply(q1);
|
||||||
|
target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical field of view of what is visible once the video is scaled to cover the screen.
|
||||||
|
/**
|
||||||
|
* Runs the cover fov operation for this subsystem.
|
||||||
|
* @param {*} videoAspect - video Aspect.
|
||||||
|
* @param {*} screenAspect - screen Aspect.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function coverFov(videoAspect, screenAspect) {
|
||||||
|
const long = Math.tan((CAMERA_LONG_FOV * Math.PI) / 360);
|
||||||
|
// The video's own vertical and horizontal half-angle tangents.
|
||||||
|
const vTan = videoAspect >= 1 ? long / videoAspect : long,
|
||||||
|
hTan = videoAspect >= 1 ? long : long * videoAspect;
|
||||||
|
// "cover" fills the screen: if the screen is wider than the video, the full width shows.
|
||||||
|
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
|
||||||
|
return (Math.atan(visibleV) * 360) / Math.PI;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates ar view resources for the game.
|
||||||
|
* @param {*} video - video.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createArView(video) {
|
||||||
|
let stream = null,
|
||||||
|
orientation = null,
|
||||||
|
compassOffset = null,
|
||||||
|
absoluteSeen = false;
|
||||||
|
const target = new THREE.Quaternion();
|
||||||
|
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
|
||||||
|
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
|
||||||
|
/**
|
||||||
|
* Runs the on absolute operation for this subsystem.
|
||||||
|
* @param {*} event - event.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const onAbsolute = (event) => {
|
||||||
|
if (event.alpha == null) return;
|
||||||
|
absoluteSeen = true;
|
||||||
|
orientation = {
|
||||||
|
alpha: event.alpha,
|
||||||
|
beta: event.beta,
|
||||||
|
gamma: event.gamma,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Runs the on orientation operation for this subsystem.
|
||||||
|
* @param {*} event - event.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const onOrientation = (event) => {
|
||||||
|
if (event.alpha == null || absoluteSeen) return;
|
||||||
|
let alpha = event.alpha;
|
||||||
|
if (event.webkitCompassHeading != null) {
|
||||||
|
// iOS: alpha is relative to the start pose; the compass heading pins it to north.
|
||||||
|
compassOffset = 360 - event.webkitCompassHeading - event.alpha;
|
||||||
|
}
|
||||||
|
if (!event.absolute && compassOffset != null) alpha += compassOffset;
|
||||||
|
orientation = {
|
||||||
|
alpha,
|
||||||
|
beta: event.beta,
|
||||||
|
gamma: event.gamma,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let sensing = false;
|
||||||
|
return {
|
||||||
|
// Camera picture on (AR) or off (the 3D world fills the screen).
|
||||||
|
get active() {
|
||||||
|
return Boolean(stream);
|
||||||
|
},
|
||||||
|
get hasOrientation() {
|
||||||
|
return Boolean(orientation);
|
||||||
|
},
|
||||||
|
// Phone orientation, used by the first-person view with or without the camera picture.
|
||||||
|
startSensors() {
|
||||||
|
if (sensing) return;
|
||||||
|
sensing = true;
|
||||||
|
window.addEventListener('deviceorientationabsolute', onAbsolute);
|
||||||
|
window.addEventListener('deviceorientation', onOrientation);
|
||||||
|
},
|
||||||
|
stopSensors() {
|
||||||
|
sensing = false;
|
||||||
|
orientation = null;
|
||||||
|
absoluteSeen = false;
|
||||||
|
window.removeEventListener('deviceorientationabsolute', onAbsolute);
|
||||||
|
window.removeEventListener('deviceorientation', onOrientation);
|
||||||
|
},
|
||||||
|
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
|
||||||
|
async start() {
|
||||||
|
if (stream) return null;
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) return strings.real.cameraNeedsHttps;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: {
|
||||||
|
ideal: 'environment',
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
ideal: 1280,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
ideal: 720,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
stream = null;
|
||||||
|
return error?.name === 'NotAllowedError'
|
||||||
|
? strings.real.cameraDenied
|
||||||
|
: strings.real.cameraBusy;
|
||||||
|
}
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.hidden = false;
|
||||||
|
await video.play().catch(() => {});
|
||||||
|
this.startSensors();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
// Closes the camera picture; the sensors keep running for the first-person view.
|
||||||
|
stop() {
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
stream = null;
|
||||||
|
video.srcObject = null;
|
||||||
|
video.hidden = true;
|
||||||
|
},
|
||||||
|
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
|
||||||
|
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
|
||||||
|
update(camera, dt, smoothing = 8) {
|
||||||
|
const fov = stream
|
||||||
|
? coverFov(
|
||||||
|
video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3,
|
||||||
|
innerWidth / innerHeight,
|
||||||
|
)
|
||||||
|
: 62;
|
||||||
|
if (Math.abs(camera.fov - fov) > 0.1) {
|
||||||
|
camera.fov = fov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
if (!orientation) return false;
|
||||||
|
const angle = screen.orientation?.angle ?? window.orientation ?? 0;
|
||||||
|
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
|
||||||
|
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/** Connects install/update lifecycle to the React interface and returns cleanup.
|
||||||
|
* @param {object} ui - Interface bridge.
|
||||||
|
* @returns {function} Releases browser event subscriptions.
|
||||||
|
*/
|
||||||
|
export function setupPwa(ui) {
|
||||||
|
const controller = new AbortController(),
|
||||||
|
{ signal } = controller;
|
||||||
|
let deferred = null,
|
||||||
|
reloading = false,
|
||||||
|
disposed = false;
|
||||||
|
const standalone =
|
||||||
|
matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches ||
|
||||||
|
navigator.standalone;
|
||||||
|
const ios =
|
||||||
|
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
if (!standalone && ios)
|
||||||
|
ui.patch('install', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
window.addEventListener(
|
||||||
|
'beforeinstallprompt',
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
deferred = event;
|
||||||
|
ui.patch('install', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'appinstalled',
|
||||||
|
() =>
|
||||||
|
ui.patch('install', {
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui.on('install', async () => {
|
||||||
|
if (deferred) {
|
||||||
|
deferred.prompt();
|
||||||
|
await deferred.userChoice;
|
||||||
|
deferred = null;
|
||||||
|
ui.patch('install', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
} else if (ios) ui.open('ios-install');
|
||||||
|
});
|
||||||
|
ui.on('ios-close', () => ui.close('ios-install'));
|
||||||
|
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||||
|
/** Offers a version change only after a worker has installed successfully. */
|
||||||
|
const offer = (worker) => {
|
||||||
|
ui.patch('update-toast', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
ui.on('update-reload', () => worker.postMessage('skip-waiting'));
|
||||||
|
};
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('./sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
|
||||||
|
registration.addEventListener(
|
||||||
|
'updatefound',
|
||||||
|
() => {
|
||||||
|
const worker = registration.installing;
|
||||||
|
worker?.addEventListener(
|
||||||
|
'statechange',
|
||||||
|
() => {
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller)
|
||||||
|
offer(worker);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.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(
|
||||||
|
'controllerchange',
|
||||||
|
() => {
|
||||||
|
if (reloading || !hadController) return;
|
||||||
|
reloading = true;
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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
|
||||||
|
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
|
||||||
|
|
||||||
|
export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, so buying pauses.
|
||||||
|
|
||||||
|
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
|
||||||
|
/**
|
||||||
|
* Runs the smooth fix operation for this subsystem.
|
||||||
|
* @param {*} previous - previous.
|
||||||
|
* @param {*} fix - fix.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function smoothFix(previous, fix) {
|
||||||
|
if (!previous || fix.time - previous.time > 30000)
|
||||||
|
return {
|
||||||
|
...fix,
|
||||||
|
};
|
||||||
|
const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
|
||||||
|
const dt = Math.max(0.001, (fix.time - previous.time) / 1000);
|
||||||
|
const x = previous.x + (fix.x - previous.x) * weight,
|
||||||
|
z = previous.z + (fix.z - previous.z) * weight;
|
||||||
|
// The blended estimate is a little better than either input, but never better than 3 m.
|
||||||
|
const accuracy = Math.max(
|
||||||
|
3,
|
||||||
|
Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) +
|
||||||
|
Math.hypot(x - previous.x, z - previous.z) * 0.2,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
accuracy,
|
||||||
|
time: fix.time,
|
||||||
|
speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable mode on top of smoothFix, against phone GPS jitter:
|
||||||
|
// - a fix further from the estimate than both errors plus a brisk walk allows is a jump and is
|
||||||
|
// dropped (after three in a row it is accepted: the phone really moved, e.g. got in a car);
|
||||||
|
// - the shown position stays put until the estimate drifts beyond about half the GPS error,
|
||||||
|
// or the phone reports walking speed. Without stable mode every smoothed fix is shown.
|
||||||
|
/**
|
||||||
|
* Creates stabilizer resources for the game.
|
||||||
|
* @param {*} options - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createStabilizer({ maxSpeed = 3 } = {}) {
|
||||||
|
let estimate = null,
|
||||||
|
shown = null,
|
||||||
|
rejected = 0;
|
||||||
|
return {
|
||||||
|
stable: true,
|
||||||
|
push(fix) {
|
||||||
|
if (estimate && this.stable && fix.time - estimate.time < 30000) {
|
||||||
|
const dt = Math.max(0.001, (fix.time - estimate.time) / 1000);
|
||||||
|
const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
|
||||||
|
if (
|
||||||
|
Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach &&
|
||||||
|
fix.accuracy > estimate.accuracy / 2 &&
|
||||||
|
rejected < 3
|
||||||
|
) {
|
||||||
|
rejected++;
|
||||||
|
return (
|
||||||
|
shown && {
|
||||||
|
...shown,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rejected = 0;
|
||||||
|
estimate = smoothFix(estimate, fix);
|
||||||
|
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
|
||||||
|
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > 0.7)
|
||||||
|
shown = {
|
||||||
|
...estimate,
|
||||||
|
};
|
||||||
|
else
|
||||||
|
shown = {
|
||||||
|
...shown,
|
||||||
|
accuracy: estimate.accuracy,
|
||||||
|
time: estimate.time,
|
||||||
|
speed: fix.speed ?? 0,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...shown,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m,
|
||||||
|
// so reaching the area around a machine is enough.
|
||||||
|
/**
|
||||||
|
* Runs the arrival radius operation for this subsystem.
|
||||||
|
* @param {*} accuracy - accuracy.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const arrivalRadius = (accuracy) => Math.min(50, Math.max(20, accuracy * 2.5));
|
||||||
|
/**
|
||||||
|
* Runs the inside world operation for this subsystem.
|
||||||
|
* @param {*} bounds - bounds.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const insideWorld = (bounds, x, z) =>
|
||||||
|
x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ;
|
||||||
|
|
||||||
|
// Watches the phone's position and compass. Calls onFix({x, z, accuracy, speed, lat, lon}),
|
||||||
|
// onHeading(radians in the game's heading convention), onError(message) for a real problem and
|
||||||
|
// onStatus(message) while it is still searching. `stabilizer` filters the fixes (see above).
|
||||||
|
/**
|
||||||
|
* Runs the start tracking operation for this subsystem.
|
||||||
|
* @param {*} options - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function startTracking({
|
||||||
|
onFix,
|
||||||
|
onHeading,
|
||||||
|
onError,
|
||||||
|
onStatus = () => {},
|
||||||
|
stabilizer = createStabilizer(),
|
||||||
|
}) {
|
||||||
|
if (!('geolocation' in navigator)) {
|
||||||
|
onError(strings.real.noGps);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
onError(strings.real.needsHttps);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
let fixes = 0;
|
||||||
|
/**
|
||||||
|
* Runs the handle operation for this subsystem.
|
||||||
|
* @param {*} position - position.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const handle = (position) => {
|
||||||
|
const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
|
||||||
|
local = toLocal(lat, lon);
|
||||||
|
const shown = stabilizer.push({
|
||||||
|
...local,
|
||||||
|
accuracy: Math.max(3, accuracy),
|
||||||
|
speed: Number.isFinite(speed) ? speed : null,
|
||||||
|
time: position.timestamp,
|
||||||
|
});
|
||||||
|
fixes++;
|
||||||
|
if (shown)
|
||||||
|
onFix({
|
||||||
|
...shown,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Runs the fail operation for this subsystem.
|
||||||
|
* @param {*} error - error.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const fail = (error) => {
|
||||||
|
if (error.code === error.PERMISSION_DENIED) onError(strings.real.denied);
|
||||||
|
else if (error.code === error.POSITION_UNAVAILABLE) onError(strings.real.unavailable);
|
||||||
|
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
|
||||||
|
else if (!fixes) onStatus(strings.real.timeout);
|
||||||
|
};
|
||||||
|
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
|
||||||
|
navigator.geolocation.getCurrentPosition(handle, () => {}, {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60000,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
const watch = navigator.geolocation.watchPosition(handle, fail, {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
maximumAge: 3000,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
|
||||||
|
/**
|
||||||
|
* Runs the orientation operation for this subsystem.
|
||||||
|
* @param {*} event - event.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const orientation = (event) => {
|
||||||
|
const compass =
|
||||||
|
event.webkitCompassHeading ??
|
||||||
|
(event.absolute && event.alpha != null ? 360 - event.alpha : null);
|
||||||
|
// Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
|
||||||
|
if (compass != null) onHeading(Math.PI / 2 - (compass * Math.PI) / 180);
|
||||||
|
};
|
||||||
|
const eventName =
|
||||||
|
'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
|
||||||
|
window.addEventListener(eventName, orientation);
|
||||||
|
return () => {
|
||||||
|
navigator.geolocation.clearWatch(watch);
|
||||||
|
window.removeEventListener(eventName, orientation);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS needs an explicit permission prompt for the compass, and it must come from a tap.
|
||||||
|
/**
|
||||||
|
* Runs the request compass operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export async function requestCompass() {
|
||||||
|
try {
|
||||||
|
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission)
|
||||||
|
await DeviceOrientationEvent.requestPermission();
|
||||||
|
} catch {
|
||||||
|
// Without the compass the family faces the direction of travel instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { missionOrder } from '../missions/missions.js';
|
||||||
|
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
||||||
|
export const INITIAL_BALANCE = 500;
|
||||||
|
export const START_MINUTES = 8 * 60 + 30;
|
||||||
|
/**
|
||||||
|
* Runs the fa operation for this subsystem.
|
||||||
|
* @param {*} n - n.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const fa = (n) => n.toLocaleString('fa-IR');
|
||||||
|
/**
|
||||||
|
* Runs the format hetoman operation for this subsystem.
|
||||||
|
* @param {*} value - value.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const formatHetoman = (value) => strings.units.hetoman(fa(value));
|
||||||
|
/**
|
||||||
|
* Runs the hetoman to rial operation for this subsystem.
|
||||||
|
* @param {*} value - value.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const hetomanToRial = (value) => value * 10000;
|
||||||
|
/**
|
||||||
|
* Runs the format clock operation for this subsystem.
|
||||||
|
* @param {*} minutes - minutes.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const formatClock = (minutes) => {
|
||||||
|
const m = Math.floor(minutes) % (24 * 60);
|
||||||
|
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
|
||||||
|
};
|
||||||
|
export const catalog = {
|
||||||
|
juice: {
|
||||||
|
id: 'juice',
|
||||||
|
name: strings.snacks.juice,
|
||||||
|
icon: '🧃',
|
||||||
|
cost: 35,
|
||||||
|
type: 'drink',
|
||||||
|
},
|
||||||
|
water: {
|
||||||
|
id: 'water',
|
||||||
|
name: strings.snacks.water,
|
||||||
|
icon: '💧',
|
||||||
|
cost: 15,
|
||||||
|
type: 'drink',
|
||||||
|
},
|
||||||
|
milk: {
|
||||||
|
id: 'milk',
|
||||||
|
name: strings.snacks.milk,
|
||||||
|
icon: '🥛',
|
||||||
|
cost: 40,
|
||||||
|
type: 'drink',
|
||||||
|
},
|
||||||
|
tea: {
|
||||||
|
id: 'tea',
|
||||||
|
name: strings.snacks.tea,
|
||||||
|
icon: '🧋',
|
||||||
|
cost: 45,
|
||||||
|
type: 'drink',
|
||||||
|
},
|
||||||
|
lemonade: {
|
||||||
|
id: 'lemonade',
|
||||||
|
name: strings.snacks.lemonade,
|
||||||
|
icon: '🍋',
|
||||||
|
cost: 40,
|
||||||
|
type: 'drink',
|
||||||
|
},
|
||||||
|
biscuit: {
|
||||||
|
id: 'biscuit',
|
||||||
|
name: strings.snacks.biscuit,
|
||||||
|
icon: '🍪',
|
||||||
|
cost: 25,
|
||||||
|
type: 'snack',
|
||||||
|
},
|
||||||
|
chocolate: {
|
||||||
|
id: 'chocolate',
|
||||||
|
name: strings.snacks.chocolate,
|
||||||
|
icon: '🍫',
|
||||||
|
cost: 30,
|
||||||
|
type: 'snack',
|
||||||
|
},
|
||||||
|
nuts: {
|
||||||
|
id: 'nuts',
|
||||||
|
name: strings.snacks.nuts,
|
||||||
|
icon: '🥜',
|
||||||
|
cost: 65,
|
||||||
|
type: 'snack',
|
||||||
|
},
|
||||||
|
cake: {
|
||||||
|
id: 'cake',
|
||||||
|
name: strings.snacks.cake,
|
||||||
|
icon: '🧁',
|
||||||
|
cost: 35,
|
||||||
|
type: 'snack',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Runs the price at operation for this subsystem.
|
||||||
|
* @param {*} machine - machine.
|
||||||
|
* @param {*} productId - product Id.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const priceAt = (machine, productId) =>
|
||||||
|
Math.round(catalog[productId].cost * machine.priceFactor);
|
||||||
|
/**
|
||||||
|
* Runs the is open operation for this subsystem.
|
||||||
|
* @param {*} machine - machine.
|
||||||
|
* @param {*} minutes - minutes.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function isOpen(machine, minutes) {
|
||||||
|
const m = minutes % (24 * 60);
|
||||||
|
return m >= machine.hours[0] && m < machine.hours[1];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates state resources for the game.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createState() {
|
||||||
|
return {
|
||||||
|
balance: INITIAL_BALANCE,
|
||||||
|
purchases: [],
|
||||||
|
xp: 0,
|
||||||
|
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,
|
||||||
|
busy: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns why a purchase cannot happen, or null when it can.
|
||||||
|
/**
|
||||||
|
* Runs the purchase blocker operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @param {*} machine - machine.
|
||||||
|
* @param {*} productId - product Id.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function purchaseBlocker(state, machine, productId) {
|
||||||
|
if (!machine || !machine.products.includes(productId)) return 'unknown';
|
||||||
|
if (state.busy) return 'busy';
|
||||||
|
if (!isOpen(machine, state.minutes)) return 'closed';
|
||||||
|
if (state.balance < priceAt(machine, productId)) return 'funds';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the purchase operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @param {*} machine - machine.
|
||||||
|
* @param {*} productId - product Id.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function purchase(state, machine, productId) {
|
||||||
|
if (purchaseBlocker(state, machine, productId)) return null;
|
||||||
|
const product = catalog[productId],
|
||||||
|
price = priceAt(machine, productId);
|
||||||
|
const record = {
|
||||||
|
machineId: machine.id,
|
||||||
|
category: machine.category,
|
||||||
|
productId,
|
||||||
|
type: product.type,
|
||||||
|
price,
|
||||||
|
minutes: state.minutes,
|
||||||
|
};
|
||||||
|
state.balance -= price;
|
||||||
|
state.purchases.push(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The run ends once no open or later-opening machine sells anything the player can afford.
|
||||||
|
/**
|
||||||
|
* Runs the can afford anything operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @param {*} machines - machines.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function canAffordAnything(state, machines) {
|
||||||
|
return machines.some(
|
||||||
|
(m) =>
|
||||||
|
m.products.some((id) => priceAt(m, id) <= state.balance) && state.minutes % 1440 < m.hours[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { purchase, purchaseBlocker } from './gameState.js';
|
||||||
|
|
||||||
|
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
||||||
|
/**
|
||||||
|
* Creates payment resources for the game.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createPayment() {
|
||||||
|
return {
|
||||||
|
phase: 'idle',
|
||||||
|
machine: null,
|
||||||
|
productId: null,
|
||||||
|
record: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the begin payment operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @param {*} payment - payment.
|
||||||
|
* @param {*} machine - machine.
|
||||||
|
* @param {*} productId - product Id.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function beginPayment(state, payment, machine, productId) {
|
||||||
|
if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
|
||||||
|
Object.assign(payment, {
|
||||||
|
phase: 'approaching',
|
||||||
|
machine,
|
||||||
|
productId,
|
||||||
|
record: null,
|
||||||
|
});
|
||||||
|
state.busy = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the ready payment operation for this subsystem.
|
||||||
|
* @param {*} payment - payment.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function readyPayment(payment) {
|
||||||
|
if (payment.phase !== 'approaching') return false;
|
||||||
|
payment.phase = 'ready';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the swipe payment operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @param {*} payment - payment.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function swipePayment(state, payment) {
|
||||||
|
if (payment.phase !== 'ready') return false;
|
||||||
|
state.busy = false;
|
||||||
|
const record = purchase(state, payment.machine, payment.productId);
|
||||||
|
if (!record) {
|
||||||
|
Object.assign(payment, createPayment());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.busy = true;
|
||||||
|
payment.record = record;
|
||||||
|
payment.phase = 'approved';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the cancel payment operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @param {*} payment - payment.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function cancelPayment(state, payment) {
|
||||||
|
if (!['approaching', 'ready'].includes(payment.phase)) return false;
|
||||||
|
Object.assign(payment, createPayment());
|
||||||
|
state.busy = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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.
|
||||||
|
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
||||||
|
export const POINTS = {
|
||||||
|
purchase: 100,
|
||||||
|
discovery: 250,
|
||||||
|
combo: 150,
|
||||||
|
allCategories: 500,
|
||||||
|
};
|
||||||
|
export const RANKS = [
|
||||||
|
{
|
||||||
|
name: strings.ranks[0],
|
||||||
|
xp: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: strings.ranks[1],
|
||||||
|
xp: 600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: strings.ranks[2],
|
||||||
|
xp: 1400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: strings.ranks[3],
|
||||||
|
xp: 2600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: strings.ranks[4],
|
||||||
|
xp: 4200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: strings.ranks[5],
|
||||||
|
xp: 6500,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Awards for `record`, given the purchases that happened before it.
|
||||||
|
/**
|
||||||
|
* Runs the awards for operation for this subsystem.
|
||||||
|
* @param {*} previous - previous.
|
||||||
|
* @param {*} record - record.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function awardsFor(previous, record) {
|
||||||
|
const awards = [
|
||||||
|
{
|
||||||
|
label: strings.bonuses.purchase,
|
||||||
|
points: POINTS.purchase,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const here = previous.filter((p) => p.machineId === record.machineId);
|
||||||
|
if (!here.length)
|
||||||
|
awards.push({
|
||||||
|
label: strings.bonuses.newPlace,
|
||||||
|
points: POINTS.discovery,
|
||||||
|
});
|
||||||
|
// 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));
|
||||||
|
if (typesHere.size === 1 && !typesHere.has(record.type))
|
||||||
|
awards.push({
|
||||||
|
label: strings.bonuses.combo,
|
||||||
|
points: POINTS.combo,
|
||||||
|
});
|
||||||
|
const categories = new Set(
|
||||||
|
previous.map((p) => p.category).filter((c) => CITY_CATEGORIES.includes(c)),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
CITY_CATEGORIES.includes(record.category) &&
|
||||||
|
!categories.has(record.category) &&
|
||||||
|
categories.size === CITY_CATEGORIES.length - 1
|
||||||
|
)
|
||||||
|
awards.push({
|
||||||
|
label: strings.bonuses.allCategories,
|
||||||
|
points: POINTS.allCategories,
|
||||||
|
});
|
||||||
|
return awards;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the rank for operation for this subsystem.
|
||||||
|
* @param {*} xp - xp.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function rankFor(xp) {
|
||||||
|
let index = 0;
|
||||||
|
while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
|
||||||
|
const next = RANKS[index + 1] ?? null;
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
name: RANKS[index].name,
|
||||||
|
min: RANKS[index].xp,
|
||||||
|
next: next?.xp ?? null,
|
||||||
|
progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { publishInterface } from '../store/slices/interfaceSlice.js';
|
||||||
|
import { publishProgress } from '../store/slices/gameSlice.js';
|
||||||
|
|
||||||
|
/** Bridges the high-frequency renderer and React using serializable, batched snapshots.
|
||||||
|
* @param {object} store - Application Redux store.
|
||||||
|
* @returns {object} View publication and action registration API.
|
||||||
|
*/
|
||||||
|
export function createInterfaceBridge(store) {
|
||||||
|
let nodes = {
|
||||||
|
landing: {
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
hud: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions = new Map(),
|
||||||
|
timer = null,
|
||||||
|
disposed = false;
|
||||||
|
/** Publishes at most ten interface frames per second, independently of WebGL FPS. */
|
||||||
|
function flush() {
|
||||||
|
timer = null;
|
||||||
|
if (!disposed) store.dispatch(publishInterface(structuredClone(nodes)));
|
||||||
|
}
|
||||||
|
/** Merges a node view model, scheduling only actual changes. */
|
||||||
|
function patch(id, values) {
|
||||||
|
const previous = nodes[id] ?? {};
|
||||||
|
if (Object.keys(values).every((key) => Object.is(previous[key], values[key]))) return;
|
||||||
|
nodes = {
|
||||||
|
...nodes,
|
||||||
|
[id]: {
|
||||||
|
...previous,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (timer === null && !disposed) timer = setTimeout(flush, 100);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activate() {
|
||||||
|
disposed = false;
|
||||||
|
},
|
||||||
|
patch,
|
||||||
|
read: (id) => nodes[id] ?? {},
|
||||||
|
open: (id) =>
|
||||||
|
patch(id, {
|
||||||
|
open: true,
|
||||||
|
}),
|
||||||
|
close: (id) =>
|
||||||
|
patch(id, {
|
||||||
|
open: false,
|
||||||
|
}),
|
||||||
|
on: (id, handler) => actions.set(id, handler),
|
||||||
|
emit: (id, value) => actions.get(id)?.(value),
|
||||||
|
style: (id, style) =>
|
||||||
|
patch(id, {
|
||||||
|
style: {
|
||||||
|
...nodes[id]?.style,
|
||||||
|
...style,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
classState: (id, name, on) =>
|
||||||
|
patch(id, {
|
||||||
|
classes: {
|
||||||
|
...nodes[id]?.classes,
|
||||||
|
[name]: on,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
publishGame: (state) => store.dispatch(publishProgress(structuredClone(state))),
|
||||||
|
clearActions: () => actions.clear(),
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
actions.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createAppStore } from '../store/index.js';
|
||||||
|
import { createInterfaceBridge } from './interfaceBridge.js';
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
describe('renderer to React bridge', () => {
|
||||||
|
it('batches UI changes, retains synchronous pause state and serializes economic progress', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const store = createAppStore(),
|
||||||
|
ui = createInterfaceBridge(store);
|
||||||
|
ui.open('shop');
|
||||||
|
expect(ui.read('shop').open).toBe(true);
|
||||||
|
expect(store.getState().interface.nodes.shop).toBeUndefined();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(store.getState().interface.nodes.shop.open).toBe(true);
|
||||||
|
ui.publishGame({ balance: 465, purchases: [], missionsDone: [], xp: 350, minutes: 510 });
|
||||||
|
expect(store.getState().game.balance).toBe(465);
|
||||||
|
ui.dispose();
|
||||||
|
});
|
||||||
|
it('runs registered commands and does not publish stale UI after disposal', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const store = createAppStore(),
|
||||||
|
ui = createInterfaceBridge(store),
|
||||||
|
handler = vi.fn();
|
||||||
|
ui.on('select-product', handler);
|
||||||
|
ui.emit('select-product', 'water');
|
||||||
|
expect(handler).toHaveBeenCalledWith('water');
|
||||||
|
ui.patch('balance', { text: 'test' });
|
||||||
|
ui.dispose();
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(store.getState().interface.nodes.balance).toBeUndefined();
|
||||||
|
ui.emit('select-product', 'water');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { COMPANY } from '../../config/company.js';
|
||||||
|
|
||||||
|
// Brand colours for materials and canvas drawings.
|
||||||
|
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.
|
||||||
|
* @param {*} color - color.
|
||||||
|
* @param {*} extra - extra.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const mat = (color, extra = {}) =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color,
|
||||||
|
roughness: 0.45,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A side profile (x along the car, y up) extruded across its width, with rounded edges.
|
||||||
|
/**
|
||||||
|
* Runs the extrude profile operation for this subsystem.
|
||||||
|
* @param {*} points - points.
|
||||||
|
* @param {*} width - width.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @param {*} bevel - bevel.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function extrudeProfile(points, width, material, bevel = 0.1) {
|
||||||
|
const shape = new THREE.Shape();
|
||||||
|
points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
|
||||||
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
|
depth: width - bevel * 2,
|
||||||
|
bevelEnabled: true,
|
||||||
|
bevelThickness: bevel,
|
||||||
|
bevelSize: bevel * 0.8,
|
||||||
|
bevelSegments: 3,
|
||||||
|
curveSegments: 6,
|
||||||
|
});
|
||||||
|
geometry.translate(0, 0, -(width - bevel * 2) / 2);
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower-body outline with wheel arches cut around the wheel centres.
|
||||||
|
/**
|
||||||
|
* Runs the body outline operation for this subsystem.
|
||||||
|
* @param {*} front - front.
|
||||||
|
* @param {*} rear - rear.
|
||||||
|
* @param {*} arches - arches.
|
||||||
|
* @param {*} radius - radius.
|
||||||
|
* @param {*} top - top.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function bodyOutline(front, rear, arches, radius, top) {
|
||||||
|
const shape = new THREE.Shape();
|
||||||
|
shape.moveTo(rear[0][0], rear[0][1]);
|
||||||
|
const bottom = 0.3;
|
||||||
|
// Underside from the rear towards the front, arching over each wheel.
|
||||||
|
for (const ax of [...arches].sort((a, b) => a - b)) {
|
||||||
|
shape.lineTo(ax - radius, bottom);
|
||||||
|
shape.absarc(ax, 0.36, radius, Math.PI + 0.14, -0.14, true);
|
||||||
|
}
|
||||||
|
for (const [x, y] of front) shape.lineTo(x, y);
|
||||||
|
for (const [x, y] of top) shape.lineTo(x, y);
|
||||||
|
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
||||||
|
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.
|
||||||
|
* @param {*} group - group.
|
||||||
|
* @param {*} positions - positions.
|
||||||
|
* @param {*} radius - radius.
|
||||||
|
* @param {*} tyreWidth - tyre Width.
|
||||||
|
* @param {*} rimColor - rim Color.
|
||||||
|
* @param {*} spokes - spokes.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
|
||||||
|
const tyre = mat('#151719', {
|
||||||
|
roughness: 0.9,
|
||||||
|
}),
|
||||||
|
rim = mat(rimColor, {
|
||||||
|
metalness: 0.85,
|
||||||
|
roughness: 0.25,
|
||||||
|
}),
|
||||||
|
brake = mat('#3a3d40', {
|
||||||
|
metalness: 0.6,
|
||||||
|
});
|
||||||
|
const wheels = [];
|
||||||
|
for (const [x, z] of positions) {
|
||||||
|
const wheel = new THREE.Group();
|
||||||
|
wheel.position.set(x, radius, z);
|
||||||
|
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
|
||||||
|
t.rotation.x = Math.PI / 2;
|
||||||
|
t.castShadow = true;
|
||||||
|
wheel.add(t);
|
||||||
|
const side = Math.sign(z);
|
||||||
|
const face = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(radius * 0.68, radius * 0.68, 0.02, 24),
|
||||||
|
brake,
|
||||||
|
);
|
||||||
|
face.rotation.x = Math.PI / 2;
|
||||||
|
face.position.z = side * tyreWidth * 0.44;
|
||||||
|
wheel.add(face);
|
||||||
|
for (let i = 0; i < spokes; i++) {
|
||||||
|
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * 0.16, 0.04), rim);
|
||||||
|
spoke.position.z = side * tyreWidth * 0.5;
|
||||||
|
spoke.rotation.z = (i * Math.PI) / spokes;
|
||||||
|
wheel.add(spoke);
|
||||||
|
}
|
||||||
|
const hub = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(radius * 0.2, radius * 0.2, 0.05, 12),
|
||||||
|
rim,
|
||||||
|
);
|
||||||
|
hub.rotation.x = Math.PI / 2;
|
||||||
|
hub.position.z = side * tyreWidth * 0.52;
|
||||||
|
wheel.add(hub);
|
||||||
|
group.add(wheel);
|
||||||
|
wheels.push(wheel);
|
||||||
|
}
|
||||||
|
return wheels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sports saloon in the style of a German executive car: long bonnet, twin kidney grille,
|
||||||
|
* ring-shaped "angel eye" headlights, kinked rear side window and five-spoke rims. 4.7 m long.
|
||||||
|
* No manufacturer badges.
|
||||||
|
*/
|
||||||
|
export function createSportSedan(color = '#1d3f78') {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const paint = mat(color, {
|
||||||
|
metalness: 0.7,
|
||||||
|
roughness: 0.28,
|
||||||
|
}),
|
||||||
|
glass = mat('#0f1a22', {
|
||||||
|
metalness: 0.7,
|
||||||
|
roughness: 0.08,
|
||||||
|
});
|
||||||
|
const black = mat('#0b0c0d', {
|
||||||
|
roughness: 0.6,
|
||||||
|
}),
|
||||||
|
chrome = mat('#d8dcdf', {
|
||||||
|
metalness: 0.95,
|
||||||
|
roughness: 0.15,
|
||||||
|
});
|
||||||
|
const W = 1.86;
|
||||||
|
const outline = bodyOutline(
|
||||||
|
[
|
||||||
|
[2.24, 0.3],
|
||||||
|
[2.36, 0.42],
|
||||||
|
[2.38, 0.62],
|
||||||
|
[2.28, 0.78],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-2.3, 0.3],
|
||||||
|
[-2.36, 0.55],
|
||||||
|
[-2.3, 0.86],
|
||||||
|
[-2.12, 0.97],
|
||||||
|
],
|
||||||
|
[-1.42, 1.43],
|
||||||
|
0.41,
|
||||||
|
[
|
||||||
|
[1.9, 0.86],
|
||||||
|
[0.95, 1.0],
|
||||||
|
[-1.2, 1.0],
|
||||||
|
[-1.6, 0.99],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {
|
||||||
|
depth: W - 0.2,
|
||||||
|
bevelEnabled: true,
|
||||||
|
bevelThickness: 0.1,
|
||||||
|
bevelSize: 0.08,
|
||||||
|
bevelSegments: 3,
|
||||||
|
curveSegments: 10,
|
||||||
|
});
|
||||||
|
bodyGeometry.translate(0, 0, -(W - 0.2) / 2);
|
||||||
|
const body = new THREE.Mesh(bodyGeometry, paint);
|
||||||
|
body.castShadow = body.receiveShadow = true;
|
||||||
|
group.add(body);
|
||||||
|
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
|
||||||
|
const cabin = extrudeProfile(
|
||||||
|
[
|
||||||
|
[0.95, 0.98],
|
||||||
|
[0.05, 1.39],
|
||||||
|
[-0.9, 1.41],
|
||||||
|
[-1.62, 1.0],
|
||||||
|
[-1.5, 0.98],
|
||||||
|
],
|
||||||
|
1.5,
|
||||||
|
glass,
|
||||||
|
0.08,
|
||||||
|
);
|
||||||
|
group.add(cabin);
|
||||||
|
const roof = extrudeProfile(
|
||||||
|
[
|
||||||
|
[0.0, 1.37],
|
||||||
|
[-0.88, 1.39],
|
||||||
|
[-1.0, 1.32],
|
||||||
|
[0.12, 1.32],
|
||||||
|
],
|
||||||
|
1.36,
|
||||||
|
paint,
|
||||||
|
0.06,
|
||||||
|
);
|
||||||
|
roof.position.y = 0.045;
|
||||||
|
group.add(roof);
|
||||||
|
const pillar = new THREE.ShapeGeometry(
|
||||||
|
new THREE.Shape([
|
||||||
|
new THREE.Vector2(-1.05, 1.0),
|
||||||
|
new THREE.Vector2(-1.55, 1.0),
|
||||||
|
new THREE.Vector2(-1.08, 1.33),
|
||||||
|
new THREE.Vector2(-0.96, 1.33),
|
||||||
|
new THREE.Vector2(-1.18, 1.12),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const pillarPaint = paint.clone();
|
||||||
|
pillarPaint.side = THREE.DoubleSide;
|
||||||
|
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const z = side * 0.72;
|
||||||
|
const b = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.4, 0.04), black);
|
||||||
|
b.position.set(-0.35, 1.18, z);
|
||||||
|
group.add(b);
|
||||||
|
const c = new THREE.Mesh(pillar, pillarPaint);
|
||||||
|
c.position.z = z + side * 0.035;
|
||||||
|
group.add(c);
|
||||||
|
// Door handles, mirror and the chrome window line.
|
||||||
|
for (const x of [0.15, -0.75]) {
|
||||||
|
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.04, 0.03), chrome);
|
||||||
|
handle.position.set(x, 0.9, side * 0.94);
|
||||||
|
group.add(handle);
|
||||||
|
}
|
||||||
|
const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.2), paint);
|
||||||
|
mirror.position.set(0.78, 1.05, side * 1.0);
|
||||||
|
group.add(mirror);
|
||||||
|
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.025, 0.02), chrome);
|
||||||
|
trim.position.set(-0.3, 0.985, side * 0.76);
|
||||||
|
group.add(trim);
|
||||||
|
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.08, 0.04), black);
|
||||||
|
skirt.position.set(0, 0.34, side * 0.93);
|
||||||
|
group.add(skirt);
|
||||||
|
}
|
||||||
|
// Twin kidney grille with chrome surrounds.
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const surround = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, 0.3), chrome);
|
||||||
|
surround.position.set(2.46, 0.66, side * 0.18);
|
||||||
|
group.add(surround);
|
||||||
|
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.16, 0.25), black);
|
||||||
|
grille.position.set(2.47, 0.66, side * 0.18);
|
||||||
|
group.add(grille);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.075, 0.15, 0.012), chrome);
|
||||||
|
bar.position.set(2.47, 0.66, side * 0.18 + (i - 2) * 0.05);
|
||||||
|
group.add(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Headlights with two light rings each, and slim L-shaped tail lights.
|
||||||
|
const ring = mat('#eaf6ff', {
|
||||||
|
emissive: '#cfe9ff',
|
||||||
|
emissiveIntensity: 1.6,
|
||||||
|
});
|
||||||
|
const tail = mat('#9e1410', {
|
||||||
|
emissive: '#e0261c',
|
||||||
|
emissiveIntensity: 0.9,
|
||||||
|
});
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const housing = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.14, 0.44), black);
|
||||||
|
housing.position.set(2.42, 0.72, side * 0.6);
|
||||||
|
group.add(housing);
|
||||||
|
for (const dz of [-0.1, 0.1]) {
|
||||||
|
const eye = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 6, 18), ring);
|
||||||
|
eye.rotation.y = Math.PI / 2;
|
||||||
|
eye.position.set(2.48, 0.72, side * 0.6 + dz);
|
||||||
|
group.add(eye);
|
||||||
|
}
|
||||||
|
const tl = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.1, 0.42), tail);
|
||||||
|
tl.position.set(-2.45, 0.82, side * 0.62);
|
||||||
|
group.add(tl);
|
||||||
|
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.1, 0.05), tail);
|
||||||
|
tlSide.position.set(-2.2, 0.82, side * 0.9);
|
||||||
|
group.add(tlSide);
|
||||||
|
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.12, 12), chrome);
|
||||||
|
exhaust.rotation.z = Math.PI / 2;
|
||||||
|
exhaust.position.set(-2.44, 0.36, side * 0.45);
|
||||||
|
group.add(exhaust);
|
||||||
|
}
|
||||||
|
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.12, 1.2), black);
|
||||||
|
lowerGrille.position.set(2.45, 0.42, 0);
|
||||||
|
group.add(lowerGrille);
|
||||||
|
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.11, 0.5), mat('#f4f4f0'));
|
||||||
|
plate.position.set(2.47, 0.5, 0);
|
||||||
|
group.add(plate);
|
||||||
|
const wheels = addWheels(
|
||||||
|
group,
|
||||||
|
[
|
||||||
|
[-1.42, -0.82],
|
||||||
|
[-1.42, 0.82],
|
||||||
|
[1.43, -0.82],
|
||||||
|
[1.43, 0.82],
|
||||||
|
],
|
||||||
|
0.36,
|
||||||
|
0.26,
|
||||||
|
'#b8bcc0',
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
wheels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function createTruck(color = '#e9eef0') {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const paint = mat(color, {
|
||||||
|
metalness: 0.35,
|
||||||
|
roughness: 0.4,
|
||||||
|
}),
|
||||||
|
glass = mat('#14212b', {
|
||||||
|
metalness: 0.6,
|
||||||
|
roughness: 0.1,
|
||||||
|
});
|
||||||
|
const black = mat('#1b1d1f', {
|
||||||
|
roughness: 0.7,
|
||||||
|
}),
|
||||||
|
teal = mat(BRAND.primary, {
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.4,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const box = (w, h, d, m, x, y, z, parent = group) => {
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
|
mesh.position.set(x, y, z);
|
||||||
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
|
parent.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
};
|
||||||
|
// Cab over the front axle.
|
||||||
|
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
|
||||||
|
box(0.05, 0.9, 2.0, glass, 3.91, 2.35, 0);
|
||||||
|
for (const side of [-1, 1]) box(1.0, 0.7, 0.04, glass, 3.1, 2.4, side * 1.16);
|
||||||
|
box(0.08, 0.3, 2.2, teal, 3.92, 1.35, 0);
|
||||||
|
box(0.25, 0.35, 2.36, black, 3.95, 0.85, 0);
|
||||||
|
const lamp = mat('#eaf6ff', {
|
||||||
|
emissive: '#cfe9ff',
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
});
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
box(0.06, 0.16, 0.3, lamp, 3.98, 1.1, side * 0.9);
|
||||||
|
box(0.3, 0.3, 0.08, black, 3.4, 2.7, side * 1.25);
|
||||||
|
}
|
||||||
|
// Chassis and the flatbed with low side rails.
|
||||||
|
box(7.6, 0.3, 1.0, black, 0, 0.75, 0);
|
||||||
|
box(
|
||||||
|
5.9,
|
||||||
|
0.14,
|
||||||
|
2.4,
|
||||||
|
mat('#6b5a45', {
|
||||||
|
roughness: 0.9,
|
||||||
|
}),
|
||||||
|
-1.05,
|
||||||
|
1.0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
for (const side of [-1, 1]) box(5.9, 0.5, 0.06, teal, -1.05, 1.32, side * 1.19);
|
||||||
|
box(0.06, 0.5, 2.4, teal, -4.0, 1.32, 0);
|
||||||
|
box(0.1, 1.6, 2.4, black, 1.9, 1.85, 0);
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
box(
|
||||||
|
0.1,
|
||||||
|
0.22,
|
||||||
|
0.26,
|
||||||
|
mat('#9e1410', {
|
||||||
|
emissive: '#c4241b',
|
||||||
|
emissiveIntensity: 0.6,
|
||||||
|
}),
|
||||||
|
-4.02,
|
||||||
|
0.82,
|
||||||
|
side * 0.95,
|
||||||
|
);
|
||||||
|
const bed = new THREE.Group();
|
||||||
|
bed.position.set(-1.05, 1.07, 0);
|
||||||
|
group.add(bed);
|
||||||
|
const wheels = addWheels(
|
||||||
|
group,
|
||||||
|
[
|
||||||
|
[-2.9, -1.0],
|
||||||
|
[-2.9, 1.0],
|
||||||
|
[2.9, -1.0],
|
||||||
|
[2.9, 1.0],
|
||||||
|
],
|
||||||
|
0.5,
|
||||||
|
0.38,
|
||||||
|
'#c2c6c9',
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
wheels,
|
||||||
|
bed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Lightweight articulated characters; all coordinates below are in the group.
|
||||||
|
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
|
||||||
|
/**
|
||||||
|
* Creates family resources for the game.
|
||||||
|
* @param {*} options - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createFamily({ robot: robotOnly = false } = {}) {
|
||||||
|
const build = {
|
||||||
|
robotOnly,
|
||||||
|
};
|
||||||
|
for (const [name, method] of Object.entries(FAMILY_METHODS))
|
||||||
|
build[name] = (...args) => method(build, ...args);
|
||||||
|
for (const initialize of FAMILY_STEPS) initialize(build);
|
||||||
|
return build.result;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the family shape operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @param {*} geometry - geometry.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @param {*} position - position.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familyShape(build, parent, geometry, material, position) {
|
||||||
|
const m = new THREE.Mesh(geometry, material);
|
||||||
|
m.position.set(...position);
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
parent.add(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the family sphere operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @param {*} r - r.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @param {*} pos - pos.
|
||||||
|
* @param {*} scale - scale.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familySphere(build, parent, r, material, pos, scale = [1, 1, 1]) {
|
||||||
|
const m = build.shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
||||||
|
m.scale.set(...scale);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the family segment operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @param {*} a - a.
|
||||||
|
* @param {*} b - b.
|
||||||
|
* @param {*} r - r.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familySegment(build, parent, a, b, r, material) {
|
||||||
|
const mesh = build.shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
||||||
|
return {
|
||||||
|
mesh,
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the family update segment operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} bone - bone.
|
||||||
|
* @param {*} a - a.
|
||||||
|
* @param {*} b - b.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familyUpdateSegment(build, bone, a, b) {
|
||||||
|
const delta = b.clone().sub(a);
|
||||||
|
bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
|
||||||
|
bone.mesh.scale.y = delta.length();
|
||||||
|
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
||||||
|
/**
|
||||||
|
* Runs the family make robot operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familyMakeRobot(build, z) {
|
||||||
|
const scale = 0.69,
|
||||||
|
root = new THREE.Group(),
|
||||||
|
torso = new THREE.Group();
|
||||||
|
root.name = 'robot-market-mascot';
|
||||||
|
root.position.z = z;
|
||||||
|
build.group.add(root);
|
||||||
|
root.add(torso);
|
||||||
|
const teal = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#079f98',
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.28,
|
||||||
|
});
|
||||||
|
const white = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#edf6f4',
|
||||||
|
metalness: 0.22,
|
||||||
|
roughness: 0.32,
|
||||||
|
});
|
||||||
|
const joint = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#102b30',
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.35,
|
||||||
|
});
|
||||||
|
const glow = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#c6f7ff',
|
||||||
|
emissive: '#23bcff',
|
||||||
|
emissiveIntensity: 2.5,
|
||||||
|
roughness: 0.2,
|
||||||
|
});
|
||||||
|
const visor = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#020d16',
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.16,
|
||||||
|
});
|
||||||
|
build.sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]);
|
||||||
|
build.sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]);
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
build.sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]);
|
||||||
|
build.shape(torso, new THREE.CylinderGeometry(0.09, 0.09, 0.12, 12), joint, [0, 1.27, 0]);
|
||||||
|
const head = new THREE.Group();
|
||||||
|
head.position.y = 1.61;
|
||||||
|
torso.add(head);
|
||||||
|
build.sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]);
|
||||||
|
build.sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]);
|
||||||
|
build.sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
|
||||||
|
const eyes = [];
|
||||||
|
/**
|
||||||
|
* Runs the arc operation for this subsystem.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @param {*} radius - radius.
|
||||||
|
* @param {*} thickness - thickness.
|
||||||
|
* @param {*} pos - pos.
|
||||||
|
* @param {*} rotation - rotation.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
|
||||||
|
const line = build.shape(
|
||||||
|
parent,
|
||||||
|
new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
|
||||||
|
material,
|
||||||
|
pos,
|
||||||
|
);
|
||||||
|
line.rotation.z = rotation;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
eyes.push(arc(head, 0.065, 0.016, [side * 0.16, 0.025, 0.423]));
|
||||||
|
const ear = build.shape(head, new THREE.CylinderGeometry(0.14, 0.14, 0.085, 24), joint, [
|
||||||
|
side * 0.47,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
ear.rotation.z = Math.PI / 2;
|
||||||
|
const ring = build.shape(head, new THREE.TorusGeometry(0.105, 0.012, 8, 24), glow, [
|
||||||
|
side * 0.518,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
ring.rotation.y = Math.PI / 2;
|
||||||
|
build.sphere(head, 0.12, teal, [side * 0.49, 0, 0], [0.4, 1, 1]);
|
||||||
|
}
|
||||||
|
arc(head, 0.075, 0.016, [0, -0.085, 0.425], Math.PI);
|
||||||
|
build.sphere(head, 0.1, teal, [0, 0.38, 0], [1, 0.55, 1]);
|
||||||
|
build.shape(head, new THREE.CylinderGeometry(0.017, 0.022, 0.18, 10), teal, [0, 0.49, 0]);
|
||||||
|
build.sphere(head, 0.055, teal, [0, 0.6, 0]);
|
||||||
|
// Draw the chest mark as a texture so it remains attached to the 3D body.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 256;
|
||||||
|
canvas.height = 256;
|
||||||
|
const c = canvas.getContext('2d');
|
||||||
|
if (c) {
|
||||||
|
c.strokeStyle = '#102b30';
|
||||||
|
c.fillStyle = '#102b30';
|
||||||
|
c.lineWidth = 12;
|
||||||
|
c.lineJoin = 'round';
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(65, 40);
|
||||||
|
c.lineTo(82, 40);
|
||||||
|
c.lineTo(99, 110);
|
||||||
|
c.lineTo(170, 110);
|
||||||
|
c.lineTo(186, 58);
|
||||||
|
c.lineTo(89, 58);
|
||||||
|
c.stroke();
|
||||||
|
for (const x of [111, 162]) {
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(x, 130, 9, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
c.textAlign = 'center';
|
||||||
|
c.font = 'bold 34px sans-serif';
|
||||||
|
c.fillText('ROBOT', 128, 181);
|
||||||
|
c.fillText('MARKET', 128, 218);
|
||||||
|
build.shape(
|
||||||
|
torso,
|
||||||
|
new THREE.PlaneGeometry(0.31, 0.31),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
map: new THREE.CanvasTexture(canvas),
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
[0, 0.99, 0.224],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const legs = [-1, 1].map((side) => {
|
||||||
|
const hip = new THREE.Group();
|
||||||
|
hip.position.set(side * 0.15, 0.65, 0);
|
||||||
|
root.add(hip);
|
||||||
|
build.sphere(hip, 0.09, joint, [0, 0, 0]);
|
||||||
|
build.shape(hip, new THREE.CapsuleGeometry(0.095, 0.12, 4, 12), white, [0, -0.12, 0]);
|
||||||
|
const knee = new THREE.Group();
|
||||||
|
knee.position.y = -0.25;
|
||||||
|
hip.add(knee);
|
||||||
|
build.sphere(knee, 0.105, teal, [0, 0, 0.035]);
|
||||||
|
build.shape(knee, new THREE.CapsuleGeometry(0.1, 0.12, 4, 12), white, [0, -0.13, 0]);
|
||||||
|
const foot = build.sphere(knee, 0.13, teal, [0, -0.29, 0.07], [1, 0.65, 1.6]);
|
||||||
|
build.sphere(knee, 0.13, joint, [0, -0.34, 0.07], [1.06, 0.22, 1.65]);
|
||||||
|
return {
|
||||||
|
hip,
|
||||||
|
knee,
|
||||||
|
foot,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const arms = [-1, 1].map(() => ({
|
||||||
|
upper: build.segment(build.group, null, null, 0.09, teal),
|
||||||
|
lower: build.segment(build.group, null, null, 0.085, white),
|
||||||
|
hand: build.sphere(build.group, 0.09, joint, [0, 0, 0], [1, 1.1, 1]),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
torso,
|
||||||
|
head,
|
||||||
|
legs,
|
||||||
|
arms,
|
||||||
|
scale,
|
||||||
|
z,
|
||||||
|
eyes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the family make person operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} isChild - is Child.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familyMakePerson(build, isChild, z) {
|
||||||
|
const scale = isChild ? 0.69 : 1.05,
|
||||||
|
root = new THREE.Group();
|
||||||
|
root.position.z = z;
|
||||||
|
build.group.add(root);
|
||||||
|
const torso = new THREE.Group();
|
||||||
|
root.add(torso);
|
||||||
|
const cloth = build.mat(isChild ? '#d98150' : '#e4c695'),
|
||||||
|
pants = build.mat(isChild ? '#527463' : '#496474');
|
||||||
|
// Tapered jacket, hips and neck replace the single rounded toy-like body.
|
||||||
|
build.shape(
|
||||||
|
torso,
|
||||||
|
new THREE.CylinderGeometry(0.255 * scale, 0.22 * scale, 0.62 * scale, 16),
|
||||||
|
cloth,
|
||||||
|
[0, 1.2 * scale, 0],
|
||||||
|
).scale.z = 0.72;
|
||||||
|
build.sphere(torso, 0.23, pants, [0, 0.88 * scale, 0], [scale, 0.55 * scale, 0.72 * scale]);
|
||||||
|
build.shape(
|
||||||
|
torso,
|
||||||
|
new THREE.CylinderGeometry(0.075 * scale, 0.09 * scale, 0.18 * scale, 12),
|
||||||
|
build.skin,
|
||||||
|
[0, 1.59 * scale, 0],
|
||||||
|
);
|
||||||
|
build.shape(
|
||||||
|
torso,
|
||||||
|
new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale),
|
||||||
|
build.mat('#bda77f'),
|
||||||
|
[0, 1.2 * scale, 0.19 * scale],
|
||||||
|
);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const collar = build.shape(
|
||||||
|
torso,
|
||||||
|
new THREE.BoxGeometry(0.12 * scale, 0.09 * scale, 0.025 * scale),
|
||||||
|
cloth,
|
||||||
|
[side * 0.07 * scale, 1.49 * scale, 0.15 * scale],
|
||||||
|
);
|
||||||
|
collar.rotation.z = side * 0.4;
|
||||||
|
}
|
||||||
|
const head = new THREE.Group();
|
||||||
|
head.position.y = 1.77 * scale;
|
||||||
|
torso.add(head);
|
||||||
|
head.scale.setScalar(isChild ? 0.9 : 0.77);
|
||||||
|
const eyes = [];
|
||||||
|
build.sphere(head, 0.27 * scale, build.skin, [0, 0, 0]);
|
||||||
|
build.sphere(head, 0.275 * scale, build.hair, [0, 0.11 * scale, -0.035 * scale], [1, 0.65, 1]);
|
||||||
|
build.sphere(head, 0.058 * scale, build.skin, [0, -0.02 * scale, 0.25 * scale]);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
build.sphere(head, 0.06 * scale, build.skin, [side * 0.26 * scale, 0, 0]);
|
||||||
|
build.sphere(
|
||||||
|
head,
|
||||||
|
0.037 * scale,
|
||||||
|
build.mat('#f6f1e7'),
|
||||||
|
[side * 0.09 * scale, 0.03 * scale, 0.241 * scale],
|
||||||
|
[1, 0.65, 0.5],
|
||||||
|
);
|
||||||
|
const eye = build.sphere(
|
||||||
|
head,
|
||||||
|
0.022 * scale,
|
||||||
|
build.shoes,
|
||||||
|
[side * 0.09 * scale, 0.03 * scale, 0.258 * scale],
|
||||||
|
[0.8, 1, 0.45],
|
||||||
|
);
|
||||||
|
eyes.push(eye);
|
||||||
|
const brow = build.shape(
|
||||||
|
head,
|
||||||
|
new THREE.BoxGeometry(0.075 * scale, 0.015 * scale, 0.015 * scale),
|
||||||
|
build.hair,
|
||||||
|
[side * 0.09 * scale, 0.087 * scale, 0.237 * scale],
|
||||||
|
);
|
||||||
|
brow.rotation.z = side * -0.08;
|
||||||
|
build.sphere(head, 0.04 * scale, build.mat('#d79379'), [
|
||||||
|
side * 0.15 * scale,
|
||||||
|
-0.055 * scale,
|
||||||
|
0.215 * scale,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const smile = build.shape(
|
||||||
|
head,
|
||||||
|
new THREE.TorusGeometry(0.068 * scale, 0.012 * scale, 6, 14, Math.PI),
|
||||||
|
build.shoes,
|
||||||
|
[0, -0.075 * scale, 0.252 * scale],
|
||||||
|
);
|
||||||
|
smile.rotation.z = Math.PI;
|
||||||
|
if (isChild) {
|
||||||
|
build.sphere(head, 0.29 * scale, build.mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]);
|
||||||
|
build.shape(
|
||||||
|
head,
|
||||||
|
new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale),
|
||||||
|
build.mat('#e8b857'),
|
||||||
|
[0, 0.13 * scale, 0.12 * scale],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
build.sphere(torso, 0.22, build.mat('#a77950'), [0, 1.2 * scale, -0.24 * scale], [1, 1.2, 0.5]);
|
||||||
|
}
|
||||||
|
const legs = [-1, 1].map((side) => {
|
||||||
|
const hip = new THREE.Group();
|
||||||
|
hip.position.set(side * 0.15 * scale, 0.83 * scale, 0);
|
||||||
|
root.add(hip);
|
||||||
|
build.shape(hip, new THREE.CapsuleGeometry(0.09 * scale, 0.23 * scale, 4, 10), pants, [
|
||||||
|
0,
|
||||||
|
-0.16 * scale,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
const knee = new THREE.Group();
|
||||||
|
knee.position.y = -0.34 * scale;
|
||||||
|
hip.add(knee);
|
||||||
|
build.shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [
|
||||||
|
0,
|
||||||
|
-0.16 * scale,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
const foot = build.shape(knee, new THREE.SphereGeometry(0.12 * scale, 12, 8), build.shoes, [
|
||||||
|
0,
|
||||||
|
-0.32 * scale,
|
||||||
|
0.065 * scale,
|
||||||
|
]);
|
||||||
|
foot.scale.set(0.85, 0.65, 1.5);
|
||||||
|
return {
|
||||||
|
hip,
|
||||||
|
knee,
|
||||||
|
foot,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const arms = [-1, 1].map(() => ({
|
||||||
|
upper: build.segment(build.group, null, null, 0.072 * scale, cloth),
|
||||||
|
lower: build.segment(build.group, null, null, 0.061 * scale, build.skin),
|
||||||
|
hand: build.sphere(build.group, 0.075 * scale, build.skin, [0, 0, 0]),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
torso,
|
||||||
|
head,
|
||||||
|
legs,
|
||||||
|
arms,
|
||||||
|
scale,
|
||||||
|
z,
|
||||||
|
eyes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
|
||||||
|
// `wave` raises the robot's right hand, `point` stretches it forward.
|
||||||
|
/**
|
||||||
|
* Runs the family update operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @param {*} options1 - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familyUpdate(
|
||||||
|
build,
|
||||||
|
{
|
||||||
|
time,
|
||||||
|
gait,
|
||||||
|
moving,
|
||||||
|
facing = Math.PI / 2,
|
||||||
|
shopping,
|
||||||
|
delivery,
|
||||||
|
finished,
|
||||||
|
talking = false,
|
||||||
|
wave = false,
|
||||||
|
point = false,
|
||||||
|
dt = 1 / 60,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
build.actors.forEach((actor, index) => {
|
||||||
|
const blend = 1 - Math.exp(-dt * 7),
|
||||||
|
isRobot = actor === build.robot;
|
||||||
|
actor.root.position.x = THREE.MathUtils.lerp(
|
||||||
|
actor.root.position.x,
|
||||||
|
finished ? (index === 0 ? -0.55 : 0.55) : shopping && !isRobot ? [-0.55, 0.25][index] : 0,
|
||||||
|
blend,
|
||||||
|
);
|
||||||
|
// The robot hovers; it bobs faster while it is speaking.
|
||||||
|
const hover = isRobot ? 0.35 + Math.sin(time * (talking ? 6 : 2.2)) * 0.07 : 0;
|
||||||
|
actor.root.position.y = THREE.MathUtils.lerp(
|
||||||
|
actor.root.position.y,
|
||||||
|
finished ? -0.6 * actor.scale : hover,
|
||||||
|
isRobot ? 1 : blend,
|
||||||
|
);
|
||||||
|
actor.root.position.z = finished ? 0 : actor.z;
|
||||||
|
actor.root.rotation.y = facing;
|
||||||
|
const bob = moving ? Math.abs(Math.sin(gait)) * 0.022 : Math.sin(time * 2) * 0.006;
|
||||||
|
actor.torso.position.y = bob;
|
||||||
|
actor.torso.rotation.z = moving ? Math.sin(gait) * 0.018 : 0;
|
||||||
|
const blinking = Math.sin(time * 1.3 + index) > 0.993;
|
||||||
|
actor.eyes.forEach((eye) => (eye.scale.y = blinking ? 0.12 : 1));
|
||||||
|
actor.head.rotation.y = shopping
|
||||||
|
? -0.65
|
||||||
|
: isRobot
|
||||||
|
? Math.sin(time * 0.9) * 0.35
|
||||||
|
: Math.sin(time * 0.7) * 0.06;
|
||||||
|
actor.torso.rotation.x = delivery && actor === build.child ? 0.22 : 0;
|
||||||
|
actor.legs.forEach((leg, i) => {
|
||||||
|
const phase = gait + i * Math.PI;
|
||||||
|
const stride = isRobot ? 0.08 : 0.34;
|
||||||
|
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
|
||||||
|
leg.foot.rotation.x = moving ? Math.sin(phase + 0.5) * 0.12 : 0;
|
||||||
|
leg.knee.rotation.x = finished
|
||||||
|
? 2.5
|
||||||
|
: isRobot
|
||||||
|
? 0.25
|
||||||
|
: moving
|
||||||
|
? Math.max(0, -Math.sin(phase)) * 0.5
|
||||||
|
: 0.05;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
build.group.updateMatrixWorld(true);
|
||||||
|
/**
|
||||||
|
* Runs the to family operation for this subsystem.
|
||||||
|
* @param {*} actor - actor.
|
||||||
|
* @param {*} point - point.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
|
||||||
|
/**
|
||||||
|
* Runs the shoulder operation for this subsystem.
|
||||||
|
* @param {*} actor - actor.
|
||||||
|
* @param {*} side - side.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const shoulder = (actor, side) =>
|
||||||
|
toFamily(
|
||||||
|
actor,
|
||||||
|
new THREE.Vector3(
|
||||||
|
side * 0.29 * actor.scale,
|
||||||
|
(actor === build.robot ? 1.12 : 1.43) * actor.scale,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// With characters facing +X, father's left and child's right hands meet.
|
||||||
|
const contact = new THREE.Vector3(0, 1.06, 0);
|
||||||
|
build.actors.forEach((actor) =>
|
||||||
|
actor.arms.forEach((arm, i) => {
|
||||||
|
const side = i === 0 ? -1 : 1,
|
||||||
|
inside = actor === build.father ? side === -1 : actor === build.child && side === 1;
|
||||||
|
const a = shoulder(actor, side);
|
||||||
|
let target;
|
||||||
|
if (inside && !shopping && !finished) target = contact.clone();
|
||||||
|
else if (actor === build.child && shopping && side === -1)
|
||||||
|
target = new THREE.Vector3(0.5, delivery ? 0.65 : 1.1, -0.15);
|
||||||
|
else if (actor === build.robot && side === 1 && wave)
|
||||||
|
target = toFamily(actor, new THREE.Vector3(0.42, 1.62 + Math.sin(time * 9) * 0.07, 0.12));
|
||||||
|
else if (actor === build.robot && side === 1 && point)
|
||||||
|
target = toFamily(actor, new THREE.Vector3(0.3, 1.25, 0.62));
|
||||||
|
else
|
||||||
|
target = toFamily(
|
||||||
|
actor,
|
||||||
|
new THREE.Vector3(
|
||||||
|
side * 0.34 * actor.scale,
|
||||||
|
0.88 * actor.scale,
|
||||||
|
moving ? Math.sin(gait + (i ? Math.PI : 0)) * 0.2 : 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const elbow = a
|
||||||
|
.clone()
|
||||||
|
.lerp(target, 0.5)
|
||||||
|
.add(new THREE.Vector3(0.07, 0, inside ? 0 : 0.07));
|
||||||
|
build.updateSegment(arm.upper, a, elbow);
|
||||||
|
build.updateSegment(arm.lower, elbow, target);
|
||||||
|
arm.hand.position.copy(target);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the family build group operation for this subsystem.
|
||||||
|
* @param {*} build - build.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function familyBuildGroup(build) {
|
||||||
|
build.group = new THREE.Group();
|
||||||
|
build.mat = (color) =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color,
|
||||||
|
roughness: 0.82,
|
||||||
|
});
|
||||||
|
build.skin = build.mat('#edbd98');
|
||||||
|
build.hair = build.mat('#49382d');
|
||||||
|
build.shoes = build.mat('#273c36');
|
||||||
|
build.father = build.robotOnly ? null : build.makePerson(false, -0.45);
|
||||||
|
build.child = build.robotOnly ? null : build.makePerson(true, 0.45);
|
||||||
|
build.robot = build.robotOnly ? build.makeRobot(0) : null;
|
||||||
|
build.actors = build.robotOnly ? [build.robot] : [build.father, build.child];
|
||||||
|
build.update({
|
||||||
|
time: 0,
|
||||||
|
gait: 0,
|
||||||
|
moving: false,
|
||||||
|
});
|
||||||
|
build.result = {
|
||||||
|
group: build.group,
|
||||||
|
update: build.update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const FAMILY_METHODS = {
|
||||||
|
shape: familyShape,
|
||||||
|
sphere: familySphere,
|
||||||
|
segment: familySegment,
|
||||||
|
updateSegment: familyUpdateSegment,
|
||||||
|
makeRobot: familyMakeRobot,
|
||||||
|
makePerson: familyMakePerson,
|
||||||
|
update: familyUpdate,
|
||||||
|
};
|
||||||
|
const FAMILY_STEPS = [familyBuildGroup];
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js';
|
||||||
|
import { createVendingMachine } from './vending.js';
|
||||||
|
import { createFamily } from './characters.js';
|
||||||
|
import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js';
|
||||||
|
|
||||||
|
/** Creates one display-only sample, with a footprint and interaction position.
|
||||||
|
* @param {{scene:object,model:object,index:number}} options - Sample placement context.
|
||||||
|
* @returns {object} Sample data and model references.
|
||||||
|
*/
|
||||||
|
function createSample({ scene, model, index }) {
|
||||||
|
const u = -12 + index * 4,
|
||||||
|
v = -15;
|
||||||
|
const machine = createVendingMachine({
|
||||||
|
name: model.shortLabel,
|
||||||
|
channels: model.channels,
|
||||||
|
products: ['water', 'juice', 'biscuit', 'chocolate'],
|
||||||
|
});
|
||||||
|
const position = factoryToWorld(u, v),
|
||||||
|
front = factoryToWorld(u, v + 2);
|
||||||
|
machine.group.name = `showroom-${model.id}`;
|
||||||
|
machine.group.position.set(position.x, 0, position.z);
|
||||||
|
machine.group.rotation.y = FACTORY_ROTATION;
|
||||||
|
machine.beacon.visible = machine.ring.visible = false;
|
||||||
|
scene.add(machine.group);
|
||||||
|
const footprint = factoryFootprint({ u, v, halfU: 1, halfV: 0.6 });
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
machine,
|
||||||
|
front,
|
||||||
|
position,
|
||||||
|
footprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the factory sample row and its robot host; it does not register retail stations.
|
||||||
|
* @param {object} scene - Parent Three.js scene.
|
||||||
|
* @returns {object} Display samples, nearest lookup, and host update.
|
||||||
|
*/
|
||||||
|
export function createShowroom(scene) {
|
||||||
|
const samples = SHOWROOM_CATALOG.map((model, index) =>
|
||||||
|
createSample({
|
||||||
|
scene,
|
||||||
|
model,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const host = createFamily({
|
||||||
|
robot: true,
|
||||||
|
});
|
||||||
|
scene.add(host.group);
|
||||||
|
host.group.visible = false;
|
||||||
|
return {
|
||||||
|
samples,
|
||||||
|
nearest(player) {
|
||||||
|
return (
|
||||||
|
samples
|
||||||
|
.map((sample) => ({
|
||||||
|
sample,
|
||||||
|
distance: Math.hypot(sample.front.x - player.x, sample.front.z - player.z),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.distance < SHOWROOM_REACH)
|
||||||
|
.sort((a, b) => a.distance - b.distance)[0]?.sample ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
update({ player, time, dt, enabled, talking }) {
|
||||||
|
const sample = this.nearest(player);
|
||||||
|
host.group.visible = Boolean(sample && enabled);
|
||||||
|
if (!sample || !enabled) return;
|
||||||
|
host.group.position.set(sample.position.x - 1.5, 0, sample.position.z + 1.5);
|
||||||
|
host.group.rotation.y = Math.atan2(
|
||||||
|
-(player.z - host.group.position.z),
|
||||||
|
player.x - host.group.position.x,
|
||||||
|
);
|
||||||
|
host.update({
|
||||||
|
dt,
|
||||||
|
time,
|
||||||
|
gait: 0,
|
||||||
|
moving: false,
|
||||||
|
talking,
|
||||||
|
wave: !talking,
|
||||||
|
point: talking,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { Scene } from 'three';
|
||||||
|
import { stubCanvas } from '../../tests/canvasStub.js';
|
||||||
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
|
let mock;
|
||||||
|
afterEach(() => mock?.mockRestore());
|
||||||
|
describe('procedural factory and sample construction', () => {
|
||||||
|
it('constructs the factory, human family and three distinct channel-count samples', async () => {
|
||||||
|
mock = stubCanvas();
|
||||||
|
const { createFactory } = await import('./factory.js');
|
||||||
|
const { createFamily } = await import('./characters.js');
|
||||||
|
const { createShowroom } = await import('./showroom.js');
|
||||||
|
const scene = new Scene(),
|
||||||
|
factory = createFactory(scene),
|
||||||
|
family = createFamily(),
|
||||||
|
showroom = createShowroom(scene);
|
||||||
|
expect(factory.talks.length).toBeGreaterThan(5);
|
||||||
|
expect(factory.footprints.length).toBeGreaterThan(10);
|
||||||
|
expect(showroom.samples.map((sample) => sample.machine.group.userData.channels)).toEqual([
|
||||||
|
35, 48, 60,
|
||||||
|
]);
|
||||||
|
for (const sample of showroom.samples) {
|
||||||
|
const slots = [];
|
||||||
|
sample.machine.group.traverse((object) => {
|
||||||
|
if (object.userData.channel) slots.push(object.userData.channel);
|
||||||
|
});
|
||||||
|
expect(slots.length).toBe(sample.model.channels);
|
||||||
|
expect(new Set(slots).size).toBe(sample.model.channels);
|
||||||
|
}
|
||||||
|
for (const sample of showroom.samples)
|
||||||
|
expect(showroom.nearest(sample.front)?.model.id).toBe(sample.model.id);
|
||||||
|
family.update({ dt: 0.016, time: 1, gait: 1, moving: true });
|
||||||
|
family.group.updateMatrixWorld(true);
|
||||||
|
family.group.traverse((object) =>
|
||||||
|
expect(object.matrixWorld.elements.every(Number.isFinite)).toBe(true),
|
||||||
|
);
|
||||||
|
factory.update(1, 0.016, { x: FACTORY.center.x, y: 2, z: FACTORY.center.z });
|
||||||
|
showroom.update({
|
||||||
|
player: showroom.samples[0].front,
|
||||||
|
time: 1,
|
||||||
|
dt: 0.016,
|
||||||
|
enabled: true,
|
||||||
|
talking: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
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
|
||||||
|
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
|
||||||
|
// as a handful of instanced meshes, so forty of them cost only a few draw calls.
|
||||||
|
// People face their local +Z; `root.rotation.y` turns them.
|
||||||
|
|
||||||
|
const materials = new Map();
|
||||||
|
/**
|
||||||
|
* Runs the mat operation for this subsystem.
|
||||||
|
* @param {*} color - color.
|
||||||
|
* @param {*} extra - extra.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const mat = (color, extra = {}) => {
|
||||||
|
const key = color + JSON.stringify(extra);
|
||||||
|
if (!materials.has(key))
|
||||||
|
materials.set(
|
||||||
|
key,
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color,
|
||||||
|
roughness: 0.8,
|
||||||
|
...extra,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return materials.get(key);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Runs the part operation for this subsystem.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @param {*} geometry - geometry.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} y - y.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.position.set(x, y, z);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
parent.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* outfit: 'hoodie' | 'suit' | 'manteau'. Colours: top (hoodie, jacket or manteau), bottom, skin,
|
||||||
|
* hair, scarf (manteau only), tie (suit only, optional), glasses.
|
||||||
|
*/
|
||||||
|
export function createPerson({
|
||||||
|
outfit = 'hoodie',
|
||||||
|
top = '#222',
|
||||||
|
bottom = '#2c3e5a',
|
||||||
|
skin = '#e2b18e',
|
||||||
|
hair = '#2a211c',
|
||||||
|
scarf = '#3b3f6b',
|
||||||
|
tie = null,
|
||||||
|
glasses = false,
|
||||||
|
shoes = '#f1f1f1',
|
||||||
|
} = {}) {
|
||||||
|
const root = new THREE.Group();
|
||||||
|
const pelvis = new THREE.Group();
|
||||||
|
pelvis.position.y = 0.94;
|
||||||
|
root.add(pelvis);
|
||||||
|
const topMat = mat(top),
|
||||||
|
bottomMat = mat(bottom),
|
||||||
|
skinMat = mat(skin),
|
||||||
|
hairMat = mat(hair);
|
||||||
|
const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
|
||||||
|
|
||||||
|
// Torso, neck and head.
|
||||||
|
const torso = new THREE.Group();
|
||||||
|
pelvis.add(torso);
|
||||||
|
const long = outfit === 'manteau';
|
||||||
|
part(torso, new THREE.CylinderGeometry(0.2, 0.17, 0.6, 12), topMat, 0, 0.3, 0).scale.z = 0.65;
|
||||||
|
if (long)
|
||||||
|
part(torso, new THREE.CylinderGeometry(0.18, 0.2, 0.3, 12), topMat, 0, -0.08, 0).scale.z = 0.7;
|
||||||
|
part(torso, new THREE.CylinderGeometry(0.05, 0.055, 0.1, 10), skinMat, 0, 0.64, 0);
|
||||||
|
if (outfit === 'hoodie') {
|
||||||
|
// Hood folded behind the neck, front pocket and drawstrings.
|
||||||
|
part(torso, new THREE.SphereGeometry(0.13, 12, 8), topMat, 0, 0.58, -0.1).scale.set(
|
||||||
|
1.2,
|
||||||
|
0.6,
|
||||||
|
0.7,
|
||||||
|
);
|
||||||
|
part(torso, new THREE.BoxGeometry(0.24, 0.12, 0.02), topMat, 0, 0.12, 0.125);
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
part(
|
||||||
|
torso,
|
||||||
|
new THREE.CylinderGeometry(0.006, 0.006, 0.16, 4),
|
||||||
|
mat('#f2f2f2'),
|
||||||
|
side * 0.04,
|
||||||
|
0.48,
|
||||||
|
0.13,
|
||||||
|
);
|
||||||
|
} else if (outfit === 'suit') {
|
||||||
|
// White shirt opening, optional tie, lapels.
|
||||||
|
const shirt = part(
|
||||||
|
torso,
|
||||||
|
new THREE.PlaneGeometry(0.12, 0.2),
|
||||||
|
mat('#f4f4f2', {
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
0.49,
|
||||||
|
0.133,
|
||||||
|
);
|
||||||
|
shirt.rotation.x = -0.12;
|
||||||
|
if (tie) part(torso, new THREE.BoxGeometry(0.035, 0.22, 0.01), mat(tie), 0, 0.45, 0.138);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const lapel = part(
|
||||||
|
torso,
|
||||||
|
new THREE.BoxGeometry(0.05, 0.22, 0.01),
|
||||||
|
mat(top, {
|
||||||
|
roughness: 0.5,
|
||||||
|
}),
|
||||||
|
side * 0.07,
|
||||||
|
0.47,
|
||||||
|
0.132,
|
||||||
|
);
|
||||||
|
lapel.rotation.z = side * 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const head = new THREE.Group();
|
||||||
|
head.position.y = 0.8;
|
||||||
|
torso.add(head);
|
||||||
|
part(head, new THREE.SphereGeometry(0.11, 16, 12), skinMat).scale.set(0.92, 1.05, 1);
|
||||||
|
part(head, new THREE.SphereGeometry(0.02, 6, 4), skinMat, 0, -0.01, 0.105);
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
part(head, new THREE.SphereGeometry(0.012, 6, 4), mat('#1b1512'), side * 0.038, 0.02, 0.098);
|
||||||
|
if (long) {
|
||||||
|
// Headscarf: covers the hair and the neck, leaving the face open.
|
||||||
|
part(head, new THREE.SphereGeometry(0.128, 16, 12), mat(scarf), 0, 0.012, -0.022).scale.set(
|
||||||
|
1,
|
||||||
|
1.05,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
part(
|
||||||
|
head,
|
||||||
|
new THREE.CylinderGeometry(0.09, 0.17, 0.2, 14),
|
||||||
|
mat(scarf),
|
||||||
|
0,
|
||||||
|
-0.13,
|
||||||
|
-0.01,
|
||||||
|
).scale.z = 0.75;
|
||||||
|
part(
|
||||||
|
head,
|
||||||
|
new THREE.TorusGeometry(0.105, 0.018, 6, 20, Math.PI * 1.2),
|
||||||
|
mat(scarf),
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0.04,
|
||||||
|
).rotation.set(0, 0, -0.1 * Math.PI);
|
||||||
|
} else {
|
||||||
|
part(head, new THREE.SphereGeometry(0.115, 14, 10), hairMat, 0, 0.035, -0.015).scale.set(
|
||||||
|
1,
|
||||||
|
0.75,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (glasses) {
|
||||||
|
const frame = mat('#222', {
|
||||||
|
metalness: 0.6,
|
||||||
|
roughness: 0.3,
|
||||||
|
});
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
part(head, new THREE.TorusGeometry(0.024, 0.004, 4, 14), frame, side * 0.04, 0.02, 0.11);
|
||||||
|
part(head, new THREE.BoxGeometry(0.03, 0.004, 0.004), frame, 0, 0.022, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
|
||||||
|
const arms = [-1, 1].map((side) => {
|
||||||
|
const shoulder = new THREE.Group();
|
||||||
|
shoulder.position.set(side * 0.23, 0.55, 0);
|
||||||
|
torso.add(shoulder);
|
||||||
|
part(shoulder, new THREE.CapsuleGeometry(0.05, 0.2, 4, 8), topMat, 0, -0.14, 0);
|
||||||
|
const elbow = new THREE.Group();
|
||||||
|
elbow.position.y = -0.29;
|
||||||
|
shoulder.add(elbow);
|
||||||
|
part(elbow, new THREE.CapsuleGeometry(0.043, 0.18, 4, 8), topMat, 0, -0.12, 0);
|
||||||
|
part(elbow, new THREE.SphereGeometry(0.045, 8, 6), skinMat, 0, -0.27, 0);
|
||||||
|
return {
|
||||||
|
shoulder,
|
||||||
|
elbow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Legs: hip → knee → foot.
|
||||||
|
const legs = [-1, 1].map((side) => {
|
||||||
|
const hip = new THREE.Group();
|
||||||
|
hip.position.set(side * 0.095, 0, 0);
|
||||||
|
pelvis.add(hip);
|
||||||
|
part(hip, new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), long ? topMat : bottomMat, 0, -0.22, 0);
|
||||||
|
const knee = new THREE.Group();
|
||||||
|
knee.position.y = -0.45;
|
||||||
|
hip.add(knee);
|
||||||
|
part(knee, new THREE.CapsuleGeometry(0.058, 0.32, 4, 8), bottomMat, 0, -0.22, 0);
|
||||||
|
part(knee, new THREE.BoxGeometry(0.1, 0.07, 0.24), shoeMat, 0, -0.45, 0.05);
|
||||||
|
return {
|
||||||
|
hip,
|
||||||
|
knee,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const person = {
|
||||||
|
root,
|
||||||
|
head,
|
||||||
|
// pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
|
||||||
|
pose(kind, t = 0, phase = 0) {
|
||||||
|
const sitting = kind === 'sit' || kind === 'type';
|
||||||
|
pelvis.position.y = sitting
|
||||||
|
? 0.5
|
||||||
|
: 0.94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * 0.02 : 0);
|
||||||
|
torso.rotation.x = kind === 'type' ? 0.18 : sitting ? -0.05 : 0;
|
||||||
|
head.rotation.y =
|
||||||
|
kind === 'type' ? Math.sin(t * 0.4 + phase) * 0.15 : Math.sin(t * 0.6 + phase) * 0.2;
|
||||||
|
head.rotation.x = kind === 'type' ? 0.12 : 0;
|
||||||
|
legs.forEach(({ hip, knee }, i) => {
|
||||||
|
const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * 0.45 : 0;
|
||||||
|
hip.rotation.x = sitting ? -Math.PI / 2 : swing;
|
||||||
|
knee.rotation.x = sitting
|
||||||
|
? Math.PI / 2
|
||||||
|
: kind === 'walk'
|
||||||
|
? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * 0.5
|
||||||
|
: 0;
|
||||||
|
});
|
||||||
|
arms.forEach(({ shoulder, elbow }, i) => {
|
||||||
|
if (kind === 'type') {
|
||||||
|
// Hands on the keyboard, fingers busy.
|
||||||
|
shoulder.rotation.set(
|
||||||
|
-0.7 + Math.sin(t * 13 + phase + i * 2) * 0.025,
|
||||||
|
0,
|
||||||
|
(i ? 1 : -1) * -0.12,
|
||||||
|
);
|
||||||
|
elbow.rotation.x = -1.15;
|
||||||
|
} else if (kind === 'sit') {
|
||||||
|
shoulder.rotation.set(-0.3, 0, 0);
|
||||||
|
elbow.rotation.x = -1.0;
|
||||||
|
} else {
|
||||||
|
const swing =
|
||||||
|
kind === 'walk'
|
||||||
|
? -Math.sin(t * 6 + phase + i * Math.PI) * 0.4
|
||||||
|
: Math.sin(t * 1.2 + phase + i) * 0.03;
|
||||||
|
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * 0.06);
|
||||||
|
elbow.rotation.x = kind === 'walk' ? -0.25 : -0.1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
person.pose('stand');
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating name badge above a person's head.
|
||||||
|
/**
|
||||||
|
* Runs the name tag operation for this subsystem.
|
||||||
|
* @param {*} name - name.
|
||||||
|
* @param {*} role - role.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function nameTag(name, role = '') {
|
||||||
|
const texture = textTexture(512, 160, (c, w, h) => {
|
||||||
|
c.clearRect(0, 0, w, h);
|
||||||
|
c.fillStyle = 'rgba(8, 24, 28, .82)';
|
||||||
|
c.beginPath();
|
||||||
|
c.roundRect(6, 6, w - 12, h - 12, 26);
|
||||||
|
c.fill();
|
||||||
|
c.fillStyle = BRAND.primary;
|
||||||
|
c.fillRect(6, h - 18, w - 12, 8);
|
||||||
|
c.textAlign = 'center';
|
||||||
|
c.direction = 'rtl';
|
||||||
|
c.fillStyle = '#ffffff';
|
||||||
|
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
||||||
|
c.fillText(name, w / 2, role ? 72 : 100);
|
||||||
|
if (role) {
|
||||||
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
|
c.font = '40px Vazirmatn, Tahoma, sans-serif';
|
||||||
|
c.direction = 'ltr';
|
||||||
|
c.fillText(role, w / 2, 126);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sprite = new THREE.Sprite(
|
||||||
|
new THREE.SpriteMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sprite.scale.set(1.3, 0.41, 1);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production workers as instanced meshes, in the parent's local frame. Each worker:
|
||||||
|
* {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}.
|
||||||
|
*/
|
||||||
|
export function createCrew(parent, workers) {
|
||||||
|
const n = workers.length,
|
||||||
|
group = new THREE.Group();
|
||||||
|
parent.add(group);
|
||||||
|
/**
|
||||||
|
* Runs the instanced operation for this subsystem.
|
||||||
|
* @param {*} geometry - geometry.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @param {*} count - count.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const instanced = (geometry, material, count = n) => {
|
||||||
|
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
group.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
};
|
||||||
|
const white = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const torso = instanced(new THREE.BoxGeometry(0.42, 0.6, 0.26), white);
|
||||||
|
const legs = instanced(
|
||||||
|
new THREE.BoxGeometry(0.15, 0.9, 0.17).translate(0, -0.45, 0),
|
||||||
|
white,
|
||||||
|
n * 2,
|
||||||
|
);
|
||||||
|
const arms = instanced(
|
||||||
|
new THREE.BoxGeometry(0.11, 0.62, 0.11).translate(0, -0.31, 0),
|
||||||
|
white,
|
||||||
|
n * 2,
|
||||||
|
);
|
||||||
|
const heads = instanced(
|
||||||
|
new THREE.SphereGeometry(0.12, 10, 8),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.8,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const hats = instanced(
|
||||||
|
new THREE.SphereGeometry(0.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.4,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map((c) => new THREE.Color(c));
|
||||||
|
const color = new THREE.Color();
|
||||||
|
workers.forEach((w, i) => {
|
||||||
|
color.set(w.suit);
|
||||||
|
torso.setColorAt(i, color);
|
||||||
|
color.set(w.pants ?? '#2d3a4d');
|
||||||
|
legs.setColorAt(i * 2, color);
|
||||||
|
legs.setColorAt(i * 2 + 1, color);
|
||||||
|
color.set(w.suit);
|
||||||
|
arms.setColorAt(i * 2, color);
|
||||||
|
arms.setColorAt(i * 2 + 1, color);
|
||||||
|
heads.setColorAt(i, skins[i % skins.length]);
|
||||||
|
color.set(w.hat ?? '#f2c94c');
|
||||||
|
hats.setColorAt(i, color);
|
||||||
|
w.phase = i * 1.37;
|
||||||
|
w.from = {
|
||||||
|
x: w.x,
|
||||||
|
z: w.z,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const base = new THREE.Matrix4(),
|
||||||
|
m = new THREE.Matrix4(),
|
||||||
|
local = new THREE.Matrix4(),
|
||||||
|
q = new THREE.Quaternion(),
|
||||||
|
up = new THREE.Vector3(0, 1, 0);
|
||||||
|
/**
|
||||||
|
* Runs the set limb operation for this subsystem.
|
||||||
|
* @param {*} mesh - mesh.
|
||||||
|
* @param {*} index - index.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} y - y.
|
||||||
|
* @param {*} angle - angle.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const setLimb = (mesh, index, x, y, angle) => {
|
||||||
|
local.makeRotationX(angle).setPosition(x, y, 0);
|
||||||
|
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Updates update from the active simulation.
|
||||||
|
* @param {*} time - time.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function update(time) {
|
||||||
|
workers.forEach((w, i) => {
|
||||||
|
let x = w.x,
|
||||||
|
z = w.z,
|
||||||
|
facing = w.facing,
|
||||||
|
walking = false;
|
||||||
|
if (w.kind === 'walk' || w.kind === 'carry') {
|
||||||
|
// Walk between two points, pausing at each end.
|
||||||
|
const dx = w.to.x - w.from.x,
|
||||||
|
dz = w.to.z - w.from.z,
|
||||||
|
len = Math.hypot(dx, dz);
|
||||||
|
const cycle = (len / 1.2) * 2 + 6,
|
||||||
|
c = (time + w.phase * 3) % cycle,
|
||||||
|
leg = len / 1.2;
|
||||||
|
let s;
|
||||||
|
if (c < leg) ((s = c / leg), (walking = true), (facing = Math.atan2(dx, dz)));
|
||||||
|
else if (c < leg + 3) ((s = 1), (facing = Math.atan2(dx, dz)));
|
||||||
|
else if (c < leg * 2 + 3)
|
||||||
|
((s = 1 - (c - leg - 3) / leg), (walking = true), (facing = Math.atan2(-dx, -dz)));
|
||||||
|
else ((s = 0), (facing = Math.atan2(-dx, -dz)));
|
||||||
|
x = w.from.x + dx * s;
|
||||||
|
z = w.from.z + dz * s;
|
||||||
|
}
|
||||||
|
const t = time + w.phase;
|
||||||
|
base.compose(
|
||||||
|
new THREE.Vector3(x, walking ? Math.abs(Math.sin(t * 6)) * 0.03 : 0, z),
|
||||||
|
q.setFromAxisAngle(up, facing),
|
||||||
|
new THREE.Vector3(1, 1, 1),
|
||||||
|
);
|
||||||
|
m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
|
||||||
|
torso.setMatrixAt(i, m);
|
||||||
|
m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
|
||||||
|
heads.setMatrixAt(i, m);
|
||||||
|
m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
|
||||||
|
hats.setMatrixAt(i, m);
|
||||||
|
for (const side of [0, 1]) {
|
||||||
|
const sign = side ? 1 : -1;
|
||||||
|
const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * 0.45 : 0;
|
||||||
|
setLimb(legs, i * 2 + side, sign * 0.1, 0.9, legSwing);
|
||||||
|
let arm;
|
||||||
|
if (w.kind === 'carry' && walking) arm = -1.1;
|
||||||
|
else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * 0.4;
|
||||||
|
// Working at a bench: both hands forward, one hammering or turning a tool.
|
||||||
|
else if (w.kind === 'work')
|
||||||
|
arm = -1.0 + (side ? Math.sin(t * 4) * 0.35 : Math.sin(t * 1.3) * 0.08);
|
||||||
|
else arm = -0.9;
|
||||||
|
setLimb(arms, i * 2 + side, sign * 0.27, 1.46, arm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
update(0);
|
||||||
|
for (const mesh of [torso, legs, arms, heads, hats])
|
||||||
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
|
||||||
|
/**
|
||||||
|
* Runs the text texture operation for this subsystem.
|
||||||
|
* @param {*} width - width.
|
||||||
|
* @param {*} height - height.
|
||||||
|
* @param {*} draw - draw.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function textTexture(width, height, draw) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
/**
|
||||||
|
* Runs the paint operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const paint = () => {
|
||||||
|
draw(canvas.getContext('2d'), width, height);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
};
|
||||||
|
paint();
|
||||||
|
document.fonts?.ready.then(paint);
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { catalog } from '../economy/gameState.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 = {
|
||||||
|
juice: '#e5ae47',
|
||||||
|
water: '#9fd3e6',
|
||||||
|
milk: '#8a5a3c',
|
||||||
|
tea: '#c7783a',
|
||||||
|
lemonade: '#f0dc5a',
|
||||||
|
biscuit: '#c9935a',
|
||||||
|
chocolate: '#5a3524',
|
||||||
|
nuts: '#b9884f',
|
||||||
|
cake: '#e58fa6',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign panel; the shared helper redraws it once the Persian web font has loaded.
|
||||||
|
/**
|
||||||
|
* Runs the label operation for this subsystem.
|
||||||
|
* @param {*} text - text.
|
||||||
|
* @param {*} sub - sub.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const label = (text, sub) =>
|
||||||
|
textTexture(512, 160, (c, width, height) => {
|
||||||
|
c.fillStyle = '#0f2a2a';
|
||||||
|
c.fillRect(0, 0, width, height);
|
||||||
|
c.fillStyle = BRAND.primary;
|
||||||
|
c.fillRect(0, height - 10, width, 10);
|
||||||
|
c.textAlign = 'center';
|
||||||
|
c.direction = 'rtl';
|
||||||
|
c.fillStyle = '#e9fff8';
|
||||||
|
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
|
||||||
|
c.fillText(text, width / 2, 72);
|
||||||
|
c.fillStyle = BRAND.primaryLighter;
|
||||||
|
c.font = '34px Vazirmatn, Tahoma, sans-serif';
|
||||||
|
c.fillText(sub, width / 2, 126);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Robot Market machine. Local +z is the front face; the group is placed facing the path.
|
||||||
|
/**
|
||||||
|
* Creates vending machine resources for the game.
|
||||||
|
* @param {*} def - def.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createVendingMachine(def) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
/**
|
||||||
|
* Runs the mat operation for this subsystem.
|
||||||
|
* @param {*} color - color.
|
||||||
|
* @param {*} extra - extra.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const mat = (color, extra = {}) =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color,
|
||||||
|
roughness: 0.5,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Runs the box operation for this subsystem.
|
||||||
|
* @param {*} w - w.
|
||||||
|
* @param {*} h - h.
|
||||||
|
* @param {*} d - d.
|
||||||
|
* @param {*} material - material.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} y - y.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @param {*} parent - parent.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const box = (w, h, d, material, x, y, z, parent = group) => {
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
|
||||||
|
mesh.position.set(x, y, z);
|
||||||
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
|
parent.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
};
|
||||||
|
const shell = mat(BRAND.primary, {
|
||||||
|
metalness: 0.45,
|
||||||
|
roughness: 0.3,
|
||||||
|
emissive: BRAND.primary,
|
||||||
|
emissiveIntensity: 0.15,
|
||||||
|
}),
|
||||||
|
white = mat('#eef5f3', {
|
||||||
|
metalness: 0.2,
|
||||||
|
roughness: 0.35,
|
||||||
|
});
|
||||||
|
const dark = mat('#13292b'),
|
||||||
|
steel = mat('#b7c1c0', {
|
||||||
|
metalness: 0.8,
|
||||||
|
roughness: 0.3,
|
||||||
|
});
|
||||||
|
box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
|
||||||
|
box(1.94, 0.32, 1.14, white, 0, 2.62, 0);
|
||||||
|
box(1.94, 0.12, 1.14, dark, 0, 0.06, 0);
|
||||||
|
// The lit interior glows instead of using a point light, which would cost every pixel in the scene.
|
||||||
|
box(
|
||||||
|
1.3,
|
||||||
|
1.8,
|
||||||
|
0.06,
|
||||||
|
mat('#13292b', {
|
||||||
|
emissive: '#2a7f73',
|
||||||
|
emissiveIntensity: 0.5,
|
||||||
|
}),
|
||||||
|
-0.2,
|
||||||
|
1.45,
|
||||||
|
0.56,
|
||||||
|
);
|
||||||
|
// Retail models retain 48 slots; showroom samples use their catalog channel count.
|
||||||
|
const rows = def.channels === 35 ? 5 : 6;
|
||||||
|
const columns = Math.ceil((def.channels ?? 48) / rows);
|
||||||
|
group.userData.channels = def.channels ?? 48;
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const y = 0.78 + row * (1.4 / (rows - 1));
|
||||||
|
box(1.2, 0.02, 0.25, steel, -0.2, y, 0.45);
|
||||||
|
for (let col = 0; col < columns; col++) {
|
||||||
|
const id = def.products[(row * columns + col) % def.products.length];
|
||||||
|
const color = mat(PRODUCT_COLORS[id]);
|
||||||
|
let channelMesh;
|
||||||
|
if (catalog[id].type === 'drink') {
|
||||||
|
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.17, 8), color);
|
||||||
|
bottle.position.set(
|
||||||
|
-0.73 + col * (def.channels ? 1.06 / (columns - 1) : 0.15),
|
||||||
|
y + 0.1,
|
||||||
|
0.47,
|
||||||
|
);
|
||||||
|
group.add(bottle);
|
||||||
|
channelMesh = bottle;
|
||||||
|
} else
|
||||||
|
channelMesh = box(
|
||||||
|
0.09,
|
||||||
|
0.14,
|
||||||
|
0.06,
|
||||||
|
color,
|
||||||
|
-0.73 + col * (def.channels ? 1.06 / (columns - 1) : 0.15),
|
||||||
|
y + 0.08,
|
||||||
|
0.47,
|
||||||
|
);
|
||||||
|
channelMesh.userData.channel = row * columns + col + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const glass = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(1.32, 1.82),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: '#cdeeea',
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.16,
|
||||||
|
roughness: 0.05,
|
||||||
|
metalness: 0.3,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
glass.position.set(-0.2, 1.45, 0.6);
|
||||||
|
group.add(glass);
|
||||||
|
// Card reader panel on the right of the front.
|
||||||
|
box(0.42, 1.8, 0.05, white, 0.7, 1.45, 0.56);
|
||||||
|
const screen = box(
|
||||||
|
0.3,
|
||||||
|
0.22,
|
||||||
|
0.02,
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: '#7ef0d6',
|
||||||
|
emissive: '#2bbfa0',
|
||||||
|
emissiveIntensity: 0.8,
|
||||||
|
}),
|
||||||
|
0.7,
|
||||||
|
1.92,
|
||||||
|
0.6,
|
||||||
|
);
|
||||||
|
box(0.3, 0.2, 0.03, dark, 0.7, 1.55, 0.6);
|
||||||
|
box(0.22, 0.03, 0.04, steel, 0.7, 1.62, 0.62);
|
||||||
|
for (let r = 0; r < 3; r++)
|
||||||
|
for (let c = 0; c < 3; c++) box(0.07, 0.05, 0.02, steel, 0.62 + c * 0.08, 1.32 - r * 0.07, 0.6);
|
||||||
|
box(0.9, 0.3, 0.08, mat('#050f10'), -0.2, 0.38, 0.56);
|
||||||
|
const flap = new THREE.Group();
|
||||||
|
flap.position.set(-0.2, 0.53, 0.61);
|
||||||
|
group.add(flap);
|
||||||
|
box(
|
||||||
|
0.88,
|
||||||
|
0.28,
|
||||||
|
0.03,
|
||||||
|
mat('#4f6f68', {
|
||||||
|
metalness: 0.4,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
-0.14,
|
||||||
|
0,
|
||||||
|
flap,
|
||||||
|
);
|
||||||
|
const sign = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(1.86, 0.3),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
map: label(COMPANY.shortName, def.name),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sign.position.set(0, 2.62, 0.575);
|
||||||
|
group.add(sign);
|
||||||
|
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
|
||||||
|
delivery.visible = false;
|
||||||
|
group.add(delivery);
|
||||||
|
const card = box(
|
||||||
|
0.2,
|
||||||
|
0.13,
|
||||||
|
0.01,
|
||||||
|
mat('#e2d6a8', {
|
||||||
|
metalness: 0.3,
|
||||||
|
}),
|
||||||
|
0.95,
|
||||||
|
1.7,
|
||||||
|
0.66,
|
||||||
|
);
|
||||||
|
card.visible = false;
|
||||||
|
|
||||||
|
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
||||||
|
const beaconMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: BRAND.primary,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.22,
|
||||||
|
depthWrite: false,
|
||||||
|
fog: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const beacon = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.7, 0.7, 90, 16, 1, true),
|
||||||
|
beaconMaterial,
|
||||||
|
);
|
||||||
|
beacon.position.y = 45;
|
||||||
|
group.add(beacon);
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(1.6, 1.9, 40),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: BRAND.primary,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.6,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.set(0, 0.09, 1.6);
|
||||||
|
group.add(ring);
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
flap,
|
||||||
|
delivery,
|
||||||
|
card,
|
||||||
|
screen,
|
||||||
|
beacon,
|
||||||
|
ring,
|
||||||
|
setDelivery(productId) {
|
||||||
|
delivery.material = mat(PRODUCT_COLORS[productId]);
|
||||||
|
delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
|
||||||
|
},
|
||||||
|
// Points in machine space, converted to world space by the caller.
|
||||||
|
readerPoint: new THREE.Vector3(0.7, 1.6, 0.62),
|
||||||
|
slotPoint: new THREE.Vector3(-0.2, 0.38, 0.66),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Runs the say operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} speaker - speaker.
|
||||||
|
* @param {*} text - text.
|
||||||
|
* @param {*} face - face.
|
||||||
|
* @param {*} seconds - seconds.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export // `face` picks the robot's expression portrait (public/robot/<face>.webp).
|
||||||
|
function say(context, speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) {
|
||||||
|
if (context.lines.some((l) => l.text === text)) return;
|
||||||
|
context.lines.push({
|
||||||
|
speaker,
|
||||||
|
text,
|
||||||
|
face,
|
||||||
|
seconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates subtitles from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateSubtitles(context, dt) {
|
||||||
|
context.lineTimer -= dt;
|
||||||
|
if (context.lineTimer > 0) return;
|
||||||
|
const next = context.lines.shift();
|
||||||
|
context.ui.patch('subtitle', {
|
||||||
|
hidden: !next,
|
||||||
|
});
|
||||||
|
context.talking = next?.speaker === 'robot';
|
||||||
|
if (!next) return;
|
||||||
|
context.ui.patch('subtitle-speaker', {
|
||||||
|
text: `${context.SPEAKERS[next.speaker]}:`,
|
||||||
|
});
|
||||||
|
context.ui.patch('subtitle-speaker', {
|
||||||
|
className: ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff',
|
||||||
|
});
|
||||||
|
context.ui.patch('subtitle-text', {
|
||||||
|
text: next.text,
|
||||||
|
});
|
||||||
|
context.ui.patch('subtitle-face', {
|
||||||
|
hidden: next.speaker !== 'robot',
|
||||||
|
});
|
||||||
|
if (next.speaker === 'robot')
|
||||||
|
context.ui.patch('subtitle-face', {
|
||||||
|
src: `./robot/${next.face}.webp`,
|
||||||
|
});
|
||||||
|
context.lineTimer = next.seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sound ----------
|
||||||
|
/**
|
||||||
|
* Runs the tone operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} freq - freq.
|
||||||
|
* @param {*} duration - duration.
|
||||||
|
* @param {*} volume - volume.
|
||||||
|
* @param {*} type - type.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function tone(context, freq, duration = 0.12, volume = 0.05, type = 'sine') {
|
||||||
|
if (!context.soundOn) return;
|
||||||
|
const o = context.audio.createOscillator(),
|
||||||
|
g = context.audio.createGain();
|
||||||
|
o.type = type;
|
||||||
|
o.frequency.setValueAtTime(freq, context.audio.currentTime);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(freq * 0.7, context.audio.currentTime + duration);
|
||||||
|
g.gain.setValueAtTime(volume, context.audio.currentTime);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, context.audio.currentTime + duration);
|
||||||
|
o.connect(g).connect(context.audio.destination);
|
||||||
|
o.start();
|
||||||
|
o.stop(context.audio.currentTime + duration);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import iran from '../../data/iran.json';
|
||||||
|
import { CATEGORIES, lockedPlaces } from '../../constants/locations.js';
|
||||||
|
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
|
||||||
|
import { drawFullMap, drawIran } from '../world/mapRender.js';
|
||||||
|
import { formatDistance } from '../../utils/geo.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
/**
|
||||||
|
* Runs the size canvas operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} c - c.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function sizeCanvas(context, c) {
|
||||||
|
const r = c.getBoundingClientRect(),
|
||||||
|
dpr = Math.min(devicePixelRatio, 2);
|
||||||
|
c.width = Math.max(1, r.width * dpr);
|
||||||
|
c.height = Math.max(1, r.height * dpr);
|
||||||
|
return dpr;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the render map operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function renderMap(context) {
|
||||||
|
if (!context.ui.read('map-dialog').open) return;
|
||||||
|
if (context.mapTab === 'city') {
|
||||||
|
const dpr = context.sizeCanvas(context.mapCanvas);
|
||||||
|
const pos = context.mode === 'drive' ? context.car : context.player;
|
||||||
|
drawFullMap(context.mapCanvas.getContext('2d'), context.mapAssets, context.mapView, {
|
||||||
|
dpr,
|
||||||
|
markers: context.markers(),
|
||||||
|
route: context.route?.points,
|
||||||
|
selected: context.selected,
|
||||||
|
player: {
|
||||||
|
x: pos.x,
|
||||||
|
z: pos.z,
|
||||||
|
heading: context.mode === 'drive' ? context.car.heading : context.player.heading,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const dpr = context.sizeCanvas(context.iranCanvas),
|
||||||
|
c = context.iranCanvas.getContext('2d');
|
||||||
|
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
drawIran(c, iran, {
|
||||||
|
width: context.iranCanvas.width / dpr,
|
||||||
|
height: context.iranCanvas.height / dpr,
|
||||||
|
t: context.elapsed,
|
||||||
|
cities: context.CITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the render machine list operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function renderMachineList(context) {
|
||||||
|
const pos = context.mode === 'drive' ? context.car : context.player;
|
||||||
|
const visited = new Set(context.state.purchases.map((p) => p.machineId));
|
||||||
|
const items = context.activeStations().map((s) => ({
|
||||||
|
id: s.def.id,
|
||||||
|
name: s.def.name,
|
||||||
|
icon: CATEGORIES[s.def.category].icon,
|
||||||
|
category: CATEGORIES[s.def.category].label,
|
||||||
|
visited: visited.has(s.def.id),
|
||||||
|
open: isOpen(s.def, context.state.minutes),
|
||||||
|
selected: s.def.id === context.selected,
|
||||||
|
distance: formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z)),
|
||||||
|
}));
|
||||||
|
context.ui.patch('machine-list', {
|
||||||
|
items: [
|
||||||
|
...items,
|
||||||
|
...lockedPlaces.map((p) => ({
|
||||||
|
...p,
|
||||||
|
id: p.name,
|
||||||
|
locked: true,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the select machine operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} id - id.
|
||||||
|
* @param {*} center - center.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function selectMachine(context, id, center = false) {
|
||||||
|
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),
|
||||||
|
def = s.def;
|
||||||
|
if (center) {
|
||||||
|
context.mapView.cx = s.x;
|
||||||
|
context.mapView.cz = s.z;
|
||||||
|
context.mapView.mpp = Math.min(context.mapView.mpp, 1.4);
|
||||||
|
}
|
||||||
|
context.ui.patch('machine-detail', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
context.ui.patch('detail-name', {
|
||||||
|
text: `${CATEGORIES[def.category].icon} ${def.name}`,
|
||||||
|
});
|
||||||
|
context.ui.patch('detail-address', {
|
||||||
|
text: `📍 ${def.address}`,
|
||||||
|
});
|
||||||
|
context.ui.patch('detail-meta', {
|
||||||
|
text: strings.map.details(context.hoursText(def), isOpen(def, context.state.minutes), def.note),
|
||||||
|
});
|
||||||
|
context.ui.patch('detail-products', {
|
||||||
|
text: def.products
|
||||||
|
.map((id) => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`)
|
||||||
|
.join(' · '),
|
||||||
|
});
|
||||||
|
context.ui.patch('route-button', {
|
||||||
|
text: context.waypoint === id ? strings.map.routeActive : strings.map.route,
|
||||||
|
});
|
||||||
|
context.renderMachineList();
|
||||||
|
context.renderMap();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the open map operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function openMap(context) {
|
||||||
|
if (context.anyDialogOpen()) return;
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.keys.clear();
|
||||||
|
const pos = context.mode === 'drive' ? context.car : context.player;
|
||||||
|
Object.assign(context.mapView, {
|
||||||
|
cx: pos.x,
|
||||||
|
cz: pos.z,
|
||||||
|
mpp: 2.2,
|
||||||
|
});
|
||||||
|
context.ui.open('map-dialog');
|
||||||
|
// 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);
|
||||||
|
context.renderMachineList();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import data from '../../data/mellat.json';
|
||||||
|
import { resolveCircle } from '../world/collision.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.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dx - dx.
|
||||||
|
* @param {*} dy - dy.
|
||||||
|
* @param {*} k - k.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function look(context, dx, dy, k) {
|
||||||
|
// In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor.
|
||||||
|
if (context.real.on) {
|
||||||
|
if (context.arView.hasOrientation) return;
|
||||||
|
context.cam.yaw -= dx * k;
|
||||||
|
context.cam.arPitch = THREE.MathUtils.clamp(
|
||||||
|
(context.cam.arPitch ?? 0) - dy * k * 0.8,
|
||||||
|
-1.2,
|
||||||
|
1.2,
|
||||||
|
);
|
||||||
|
context.cam.lastMouse = context.elapsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.cam.yaw -= dx * k;
|
||||||
|
context.cam.pitch = THREE.MathUtils.clamp(context.cam.pitch + dy * k * 0.8, -0.15, 1.15);
|
||||||
|
context.cam.lastMouse = context.elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch joystick: x steers / strafes, y walks / throttles.
|
||||||
|
/**
|
||||||
|
* Gets the player into the nearest vehicle, or out of the current one.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export // ---------- Car ----------
|
||||||
|
function toggleCar(context) {
|
||||||
|
if (context.payment.phase !== 'idle' || context.state.busy) return;
|
||||||
|
if (context.real.on) {
|
||||||
|
context.say('father', strings.real.noVirtualCar);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.mode === 'walk') getIn(context);
|
||||||
|
else getOut(context);
|
||||||
|
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.cam.distance = Math.max(context.cam.distance, 8);
|
||||||
|
context.tone(320, 0.2, 0.05, 'square');
|
||||||
|
context.say('father', strings.dialogue.carOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
const left = { x: -Math.sin(state.heading), z: -Math.cos(state.heading) };
|
||||||
|
const step = spec.halfWidth + 1.8;
|
||||||
|
const spot = resolveCircle(
|
||||||
|
context.collision,
|
||||||
|
state.x + left.x * step,
|
||||||
|
state.z + left.z * step,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
Object.assign(context.player, { x: spot.x, z: spot.z, heading: state.heading });
|
||||||
|
state.speed = 0;
|
||||||
|
context.mode = 'walk';
|
||||||
|
context.cam.distance = 5.5;
|
||||||
|
context.tone(260, 0.2, 0.05, 'square');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Machines, shop and payment ----------
|
||||||
|
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
|
||||||
|
/**
|
||||||
|
* Updates walk from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateWalk(context, dt) {
|
||||||
|
const forward = {
|
||||||
|
x: -Math.sin(context.cam.yaw),
|
||||||
|
z: -Math.cos(context.cam.yaw),
|
||||||
|
},
|
||||||
|
right = {
|
||||||
|
x: Math.cos(context.cam.yaw),
|
||||||
|
z: -Math.sin(context.cam.yaw),
|
||||||
|
};
|
||||||
|
const iy = context.axis(['w', 'arrowup'], ['s', 'arrowdown']) + context.stick.y,
|
||||||
|
ix = context.axis(['d', 'arrowright'], ['a', 'arrowleft']) + context.stick.x;
|
||||||
|
const frozen = context.state.busy || context.payment.phase !== 'idle';
|
||||||
|
let dx = forward.x * iy + right.x * ix,
|
||||||
|
dz = forward.z * iy + right.z * ix;
|
||||||
|
const len = Math.hypot(dx, dz);
|
||||||
|
const run = context.keys.has('shift');
|
||||||
|
const target = frozen || len < 0.1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len);
|
||||||
|
context.player.speed += (target - context.player.speed) * Math.min(1, dt * 8);
|
||||||
|
if (len > 0.1 && !frozen) {
|
||||||
|
dx /= len;
|
||||||
|
dz /= len;
|
||||||
|
const want = Math.atan2(-dz, dx);
|
||||||
|
context.player.heading +=
|
||||||
|
Math.atan2(Math.sin(want - context.player.heading), Math.cos(want - context.player.heading)) *
|
||||||
|
Math.min(1, dt * 9);
|
||||||
|
}
|
||||||
|
// Face the machine while buying.
|
||||||
|
const station = context.payment.station ?? context.payment.delivering?.station;
|
||||||
|
if (frozen && station) {
|
||||||
|
const want = Math.atan2(-(station.z - context.player.z), station.x - context.player.x);
|
||||||
|
context.player.heading +=
|
||||||
|
Math.atan2(Math.sin(want - context.player.heading), Math.cos(want - context.player.heading)) *
|
||||||
|
Math.min(1, dt * 6);
|
||||||
|
}
|
||||||
|
const mx = Math.cos(context.player.heading) * context.player.speed * dt,
|
||||||
|
mz = -Math.sin(context.player.heading) * context.player.speed * dt;
|
||||||
|
const p = walkAroundVehicles(
|
||||||
|
context,
|
||||||
|
resolveCircle(context.collision, context.player.x + mx, context.player.z + mz, 1),
|
||||||
|
);
|
||||||
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
|
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.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.
|
||||||
|
/**
|
||||||
|
* Updates drive from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateDrive(context, dt) {
|
||||||
|
if (context.debugHits.length > 200) context.debugHits.splice(0, 100);
|
||||||
|
const throttle = THREE.MathUtils.clamp(
|
||||||
|
context.axis(['w', 'arrowup'], ['s', 'arrowdown']) + context.stick.y,
|
||||||
|
-1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const steer = THREE.MathUtils.clamp(
|
||||||
|
context.axis(['d', 'arrowright'], ['a', 'arrowleft']) + context.stick.x,
|
||||||
|
-1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// 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, a fence or another vehicle.
|
||||||
|
const slices = Math.max(1, Math.ceil((Math.abs(context.car.speed) * dt) / 0.6));
|
||||||
|
for (let i = 0; i < slices; i++) {
|
||||||
|
stepCar(
|
||||||
|
context.car,
|
||||||
|
{ throttle, steer, handbrake: context.keys.has(' ') },
|
||||||
|
dt / slices,
|
||||||
|
context.vehicle.spec,
|
||||||
|
);
|
||||||
|
keepOutOfBuildings(context);
|
||||||
|
collideDriving(context, dt / slices);
|
||||||
|
}
|
||||||
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
|
context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15);
|
||||||
|
context.car.z = THREE.MathUtils.clamp(context.car.z, minZ + 15, maxZ - 15);
|
||||||
|
Object.assign(context.player, {
|
||||||
|
x: context.car.x,
|
||||||
|
z: context.car.z,
|
||||||
|
heading: context.car.heading,
|
||||||
|
});
|
||||||
|
// Chase camera swings back behind the car when the mouse is idle.
|
||||||
|
if (context.elapsed - context.cam.lastMouse > 1.4 && Math.abs(context.car.speed) > 1) {
|
||||||
|
const want =
|
||||||
|
Math.atan2(-Math.cos(context.car.heading), Math.sin(context.car.heading)) +
|
||||||
|
(context.car.speed < 0 ? Math.PI : 0);
|
||||||
|
context.cam.yaw +=
|
||||||
|
Math.atan2(Math.sin(want - context.cam.yaw), Math.cos(want - context.cam.yaw)) *
|
||||||
|
Math.min(1, dt * 2.2);
|
||||||
|
context.cam.pitch += (0.28 - context.cam.pitch) * Math.min(1, dt * 1.5);
|
||||||
|
}
|
||||||
|
if (context.engine && context.soundOn) {
|
||||||
|
context.engine.o.frequency.setTargetAtTime(
|
||||||
|
45 + Math.abs(context.car.speed) * 5 + Math.max(0, throttle) * 15,
|
||||||
|
context.audio.currentTime,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
context.engine.g.gain.setTargetAtTime(0.025, context.audio.currentTime, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { routeBetween } from '../world/routing.js';
|
||||||
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
|
import { isOpen, formatClock, fa } from '../economy/gameState.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 { nearestRide } from './garage.js';
|
||||||
|
import { drawMinimap } from '../world/mapRender.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.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function rebuildRouteMesh(context) {
|
||||||
|
if (context.routeMesh) {
|
||||||
|
context.scene.remove(context.routeMesh);
|
||||||
|
context.routeMesh.geometry.dispose();
|
||||||
|
context.routeMesh = null;
|
||||||
|
}
|
||||||
|
if (!context.route) return;
|
||||||
|
const pts = [],
|
||||||
|
pos = [],
|
||||||
|
idx = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const p of context.route.points) {
|
||||||
|
if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z);
|
||||||
|
if (total > 700) break;
|
||||||
|
pts.push(p);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[Math.max(0, i - 1)],
|
||||||
|
b = pts[Math.min(pts.length - 1, i + 1)];
|
||||||
|
const dx = b.x - a.x,
|
||||||
|
dz = b.z - a.z,
|
||||||
|
len = Math.hypot(dx, dz) || 1,
|
||||||
|
w = context.mode === 'drive' ? 1.1 : 0.55;
|
||||||
|
pos.push(
|
||||||
|
pts[i].x - (dz / len) * w,
|
||||||
|
0.14,
|
||||||
|
pts[i].z + (dx / len) * w,
|
||||||
|
pts[i].x + (dz / len) * w,
|
||||||
|
0.14,
|
||||||
|
pts[i].z - (dx / len) * w,
|
||||||
|
);
|
||||||
|
if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1);
|
||||||
|
}
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||||
|
g.setIndex(idx);
|
||||||
|
context.routeMesh = new THREE.Mesh(g, context.routeMaterial);
|
||||||
|
context.scene.add(context.routeMesh);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates route from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateRoute(context, dt) {
|
||||||
|
context.routeTimer -= dt;
|
||||||
|
const target = context.objectiveStation();
|
||||||
|
if (!target) {
|
||||||
|
if (context.route) {
|
||||||
|
context.route = null;
|
||||||
|
context.rebuildRouteMesh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pos = context.mode === 'drive' ? context.car : context.player;
|
||||||
|
const changed =
|
||||||
|
target !== context.routeTarget || context.routeMode !== context.mode || !context.route;
|
||||||
|
if (
|
||||||
|
!changed &&
|
||||||
|
(context.routeTimer > 0 ||
|
||||||
|
Math.hypot(pos.x - context.routeFrom.x, pos.z - context.routeFrom.z) < 10)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
context.routeTimer = 1.2;
|
||||||
|
context.routeTarget = target;
|
||||||
|
context.routeMode = context.mode;
|
||||||
|
context.routeFrom = {
|
||||||
|
x: pos.x,
|
||||||
|
z: pos.z,
|
||||||
|
};
|
||||||
|
// Close targets are reached directly; the street network would detour out of buildings.
|
||||||
|
const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z);
|
||||||
|
context.route =
|
||||||
|
direct < 60
|
||||||
|
? {
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
x: pos.x,
|
||||||
|
z: pos.z,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: target.front.x,
|
||||||
|
z: target.front.z,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
length: direct,
|
||||||
|
}
|
||||||
|
: routeBetween(
|
||||||
|
context.mode === 'drive' ? context.driveGraph : context.walkGraph,
|
||||||
|
pos.x,
|
||||||
|
pos.z,
|
||||||
|
target.front.x,
|
||||||
|
target.front.z,
|
||||||
|
);
|
||||||
|
context.rebuildRouteMesh();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates the objective chip: what to do next, how far it is, and an arrow that points at it
|
||||||
|
* relative to where the camera looks (0° = straight ahead).
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {{objective: object | null, pos: {x: number, z: number}, headingDeg: number}} view -
|
||||||
|
* Objective station, player position and camera bearing.
|
||||||
|
*/
|
||||||
|
function updateObjectiveChip(context, { objective, pos, headingDeg }) {
|
||||||
|
// A route the player picked on the map shows the place; otherwise show the current step.
|
||||||
|
const ownRoute = context.waypoint && context.waypoint !== stepTarget(context.state);
|
||||||
|
const title = ownRoute ? objective?.def.name : currentStepText(context.state);
|
||||||
|
context.ui.patch('objective', { hidden: !title });
|
||||||
|
if (!title) return;
|
||||||
|
context.ui.patch('objective-title', { text: title });
|
||||||
|
context.ui.patch('objective-distance', {
|
||||||
|
text: objective ? formatDistance(Math.hypot(objective.x - pos.x, objective.z - pos.z)) : '',
|
||||||
|
});
|
||||||
|
if (!objective) return;
|
||||||
|
const turn = bearing(objective.x - pos.x, objective.z - pos.z) - headingDeg;
|
||||||
|
context.ui.style('objective-arrow', { transform: `rotate(${Math.round(turn)}deg)` });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the refresh hud operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function refreshHUD(context) {
|
||||||
|
context.ui.patch('balance', {
|
||||||
|
text: fa(context.state.balance),
|
||||||
|
});
|
||||||
|
const rank = rankFor(context.state.xp);
|
||||||
|
context.ui.patch('rank-name', {
|
||||||
|
text: rank.name,
|
||||||
|
});
|
||||||
|
context.ui.patch('xp', {
|
||||||
|
text: rank.next
|
||||||
|
? `${fa(context.state.xp)} / ${fa(rank.next)} XP`
|
||||||
|
: `${fa(context.state.xp)} XP`,
|
||||||
|
});
|
||||||
|
context.ui.style('xp-bar', {
|
||||||
|
width: `${Math.round(rank.progress * 100)}%`,
|
||||||
|
});
|
||||||
|
context.ui.patch('mission-list', { items: missionListItems(context.state) });
|
||||||
|
context.ui.publishGame(context.state);
|
||||||
|
context.ui.patch('speed-stat', {
|
||||||
|
hidden: context.mode !== 'drive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates prompt from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updatePrompt(context) {
|
||||||
|
const sample = context.nearShowroom();
|
||||||
|
if (sample && !context.state.busy && context.payment.phase === 'idle') {
|
||||||
|
context.ui.patch('prompt', {
|
||||||
|
text: strings.prompts.showroom(sample.model.name),
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
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 = '';
|
||||||
|
const station = context.nearStation();
|
||||||
|
const ride = context.mode === 'walk' ? nearestRide(context) : null;
|
||||||
|
if (!context.state.busy && context.payment.phase === 'idle') {
|
||||||
|
if (station)
|
||||||
|
text = isOpen(station.def, context.state.minutes)
|
||||||
|
? strings.prompts.buy(station.def.name)
|
||||||
|
: strings.prompts.closed(context.hoursText(station.def));
|
||||||
|
else if (ride) text = strings.prompts.getIn(ride.label);
|
||||||
|
else if (
|
||||||
|
context.mode === 'drive' &&
|
||||||
|
Math.abs(context.car.speed) < 1 &&
|
||||||
|
context.stations.some(
|
||||||
|
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text = strings.prompts.getOutNearMachine;
|
||||||
|
}
|
||||||
|
context.ui.patch('prompt', {
|
||||||
|
text,
|
||||||
|
hidden: !text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Map dialog ----------
|
||||||
|
/**
|
||||||
|
* Updates hud from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateHUD(context) {
|
||||||
|
context.ui.patch('showroom-open', {
|
||||||
|
hidden:
|
||||||
|
context.real.on ||
|
||||||
|
context.mode !== 'walk' ||
|
||||||
|
Math.hypot(context.player.x - FACTORY.center.x, context.player.z - FACTORY.center.z) >= 130,
|
||||||
|
});
|
||||||
|
const pos = context.mode === 'drive' ? context.car : context.player;
|
||||||
|
context.ui.patch('clock', {
|
||||||
|
text: formatClock(context.state.minutes),
|
||||||
|
});
|
||||||
|
if (context.mode === 'drive')
|
||||||
|
context.ui.patch('speed', {
|
||||||
|
text: fa(kmh(context.car)),
|
||||||
|
});
|
||||||
|
// Where the camera actually looks: the orbit camera, or the phone itself in AR view.
|
||||||
|
const look3 = context.camera.getWorldDirection(new THREE.Vector3()),
|
||||||
|
flat = Math.hypot(look3.x, look3.z);
|
||||||
|
const forward =
|
||||||
|
flat > 0.05
|
||||||
|
? {
|
||||||
|
x: look3.x / flat,
|
||||||
|
z: look3.z / flat,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
x: -Math.sin(context.cam.yaw),
|
||||||
|
z: -Math.cos(context.cam.yaw),
|
||||||
|
};
|
||||||
|
const headingDeg = bearing(forward.x, forward.z);
|
||||||
|
const objective = context.objectiveStation();
|
||||||
|
context.objectiveArrow.update(objective, context.elapsed);
|
||||||
|
updateObjectiveChip(context, { objective, pos, headingDeg });
|
||||||
|
// The minimap is optional (floating menu); skip drawing it while it is hidden.
|
||||||
|
if (context.showMinimap)
|
||||||
|
drawMinimap(context.minimap, context.mapAssets, {
|
||||||
|
size: 220,
|
||||||
|
x: pos.x,
|
||||||
|
z: pos.z,
|
||||||
|
heading: context.mode === 'drive' ? context.car.heading : context.player.heading,
|
||||||
|
camForward: forward,
|
||||||
|
radius: context.mode === 'drive' ? 650 : 200,
|
||||||
|
markers: context.markers(),
|
||||||
|
route: context.route?.points,
|
||||||
|
});
|
||||||
|
// Objective marker above the machine, clamped to the screen edge when off-screen.
|
||||||
|
context.ui.patch('waypoint', {
|
||||||
|
hidden: !objective,
|
||||||
|
});
|
||||||
|
if (objective) {
|
||||||
|
const d = Math.hypot(objective.x - pos.x, objective.z - pos.z),
|
||||||
|
p = new THREE.Vector3(objective.x, 3.4, objective.z).project(context.camera);
|
||||||
|
let sx = (p.x * 0.5 + 0.5) * innerWidth,
|
||||||
|
sy = (-p.y * 0.5 + 0.5) * innerHeight;
|
||||||
|
if (p.z > 1) {
|
||||||
|
sx = innerWidth - sx;
|
||||||
|
sy = innerHeight - 60;
|
||||||
|
}
|
||||||
|
context.ui.style('waypoint', {
|
||||||
|
left: `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`,
|
||||||
|
});
|
||||||
|
context.ui.style('waypoint', {
|
||||||
|
top: `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`,
|
||||||
|
});
|
||||||
|
context.ui.patch('waypoint-name', {
|
||||||
|
text: objective.def.name,
|
||||||
|
});
|
||||||
|
context.ui.patch('waypoint-distance', {
|
||||||
|
text: formatDistance(d),
|
||||||
|
});
|
||||||
|
context.ui.patch('waypoint', {
|
||||||
|
hidden: d < 6,
|
||||||
|
});
|
||||||
|
for (const s of context.stations) {
|
||||||
|
const isObjective = s === objective;
|
||||||
|
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : BRAND.primary);
|
||||||
|
s.model.beacon.material.opacity =
|
||||||
|
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.updatePrompt();
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { MAX_WALKING_SPEED } from '../device/realworld.js';
|
||||||
|
import { machines, CATEGORIES, FACTORY } from '../../constants/locations.js';
|
||||||
|
import {
|
||||||
|
catalog,
|
||||||
|
priceAt,
|
||||||
|
isOpen,
|
||||||
|
purchaseBlocker,
|
||||||
|
formatHetoman,
|
||||||
|
fa,
|
||||||
|
canAffordAnything,
|
||||||
|
} from '../economy/gameState.js';
|
||||||
|
import {
|
||||||
|
createPayment,
|
||||||
|
beginPayment,
|
||||||
|
readyPayment,
|
||||||
|
swipePayment,
|
||||||
|
cancelPayment,
|
||||||
|
} from '../economy/payment.js';
|
||||||
|
import { awardsFor, rankFor } from '../economy/scoring.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
import { MISSIONS } from '../missions/missions.js';
|
||||||
|
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
||||||
|
/**
|
||||||
|
* Runs the open showroom operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function openShowroom(context) {
|
||||||
|
const inFactory =
|
||||||
|
Math.hypot(context.player.x - FACTORY.center.x, context.player.z - FACTORY.center.z) < 130;
|
||||||
|
const sample =
|
||||||
|
context.nearShowroom() ??
|
||||||
|
(inFactory && !context.real.on && context.mode === 'walk' ? context.showroom.samples[0] : null);
|
||||||
|
if (!sample || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
|
||||||
|
context.ui.patch('showroom', {
|
||||||
|
modelId: sample.model.id,
|
||||||
|
});
|
||||||
|
context.ui.patch('showroom-response', {
|
||||||
|
text: strings.showroom.allModels,
|
||||||
|
});
|
||||||
|
context.keys.clear();
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.ui.open('showroom');
|
||||||
|
context.reportMissionEvent({ type: 'view', machineId: SHOWROOM_POINT_ID });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the interact operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
|
||||||
|
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
|
||||||
|
context.say('father', strings.dialogue.tooFastToBuy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isOpen(station.def, context.state.minutes)) {
|
||||||
|
context.say(
|
||||||
|
context.guideOut() ? 'robot' : 'father',
|
||||||
|
strings.dialogue.machineClosed(context.hoursText(station.def)),
|
||||||
|
'serious',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.shopStation = station;
|
||||||
|
context.renderShop();
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.keys.clear();
|
||||||
|
context.ui.open('shop');
|
||||||
|
context.tone(750);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the render shop operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function renderShop(context) {
|
||||||
|
const def = context.shopStation.def;
|
||||||
|
context.ui.patch('shop-category', {
|
||||||
|
text: strings.shop.category(
|
||||||
|
CATEGORIES[def.category].icon,
|
||||||
|
CATEGORIES[def.category].label,
|
||||||
|
context.hoursText(def),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
context.ui.patch('shop-name', {
|
||||||
|
text: strings.shop.title(def.name),
|
||||||
|
});
|
||||||
|
context.ui.patch('shop-address', {
|
||||||
|
text: def.address,
|
||||||
|
});
|
||||||
|
context.ui.patch('shop-note', {
|
||||||
|
text: strings.shop.balance(def.note, formatHetoman(context.state.balance)),
|
||||||
|
});
|
||||||
|
context.ui.patch('products', {
|
||||||
|
items: def.products.map((id) => ({
|
||||||
|
id,
|
||||||
|
...catalog[id],
|
||||||
|
price: priceAt(def, id),
|
||||||
|
blocked: purchaseBlocker(context.state, def, id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the start payment operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} productId - product Id.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function startPayment(context, productId) {
|
||||||
|
if (!beginPayment(context.state, context.payment, context.shopStation.def, productId)) return;
|
||||||
|
context.payment.station = context.shopStation;
|
||||||
|
context.payment.timer = 0;
|
||||||
|
context.ui.close('shop');
|
||||||
|
const p = catalog[productId];
|
||||||
|
context.ui.patch('payment', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
context.ui.patch('payment-title', {
|
||||||
|
text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(context.shopStation.def, productId))}`,
|
||||||
|
});
|
||||||
|
context.ui.patch('payment-step', {
|
||||||
|
text: strings.payment.approaching,
|
||||||
|
});
|
||||||
|
context.ui.patch('swipe', {
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
context.ui.patch('cancel-payment', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
context.say('child', strings.dialogue.childSwipes);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the swipe operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function swipe(context) {
|
||||||
|
if (!swipePayment(context.state, context.payment)) return;
|
||||||
|
context.payment.timer = 0;
|
||||||
|
context.payment.station.model.card.visible = true;
|
||||||
|
context.ui.patch('swipe', {
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
context.ui.patch('cancel-payment', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
context.ui.patch('payment-step', {
|
||||||
|
text: strings.payment.success,
|
||||||
|
});
|
||||||
|
context.tone(1100, 0.3);
|
||||||
|
context.refreshHUD();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the cancel operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function cancel(context) {
|
||||||
|
if (!cancelPayment(context.state, context.payment)) return;
|
||||||
|
context.ui.patch('payment', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates payment from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updatePayment(context, dt) {
|
||||||
|
if (context.payment.phase === 'idle') return;
|
||||||
|
context.payment.timer += dt;
|
||||||
|
const model = context.payment.station.model;
|
||||||
|
if (context.payment.phase === 'approaching' && context.payment.timer > 1) {
|
||||||
|
readyPayment(context.payment);
|
||||||
|
context.ui.patch('swipe', {
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
context.ui.patch('payment-step', {
|
||||||
|
text: strings.payment.swipeHint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (context.payment.phase === 'approved') {
|
||||||
|
const t = Math.min(1, context.payment.timer);
|
||||||
|
model.card.position.set(0.95 - t * 0.25, 1.7 - t * 0.1, 0.66);
|
||||||
|
if (context.payment.timer > 1.2) {
|
||||||
|
model.card.visible = false;
|
||||||
|
context.ui.patch('payment', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
model.setDelivery(context.payment.productId);
|
||||||
|
context.deliveryTimer = 2.6;
|
||||||
|
context.payment.delivering = {
|
||||||
|
station: context.payment.station,
|
||||||
|
record: context.payment.record,
|
||||||
|
};
|
||||||
|
Object.assign(context.payment, createPayment());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates delivery from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateDelivery(context, dt) {
|
||||||
|
if (context.deliveryTimer <= 0) return;
|
||||||
|
context.deliveryTimer = Math.max(0, context.deliveryTimer - dt);
|
||||||
|
const { station, record } = context.payment.delivering,
|
||||||
|
model = station.model,
|
||||||
|
t = 2.6 - context.deliveryTimer;
|
||||||
|
const slot = record ? catalog[record.productId] : null;
|
||||||
|
model.delivery.visible = t < 2.4;
|
||||||
|
// Falls from a shelf, waits in the tray, then lifts toward the child's hands.
|
||||||
|
const shelf = new THREE.Vector3(
|
||||||
|
-0.5 + (record.price % 7) * 0.1,
|
||||||
|
1.2 + (record.price % 5) * 0.15,
|
||||||
|
0.45,
|
||||||
|
);
|
||||||
|
if (t < 0.8) model.delivery.position.lerpVectors(shelf, model.slotPoint, t / 0.8);
|
||||||
|
else
|
||||||
|
model.delivery.position
|
||||||
|
.copy(model.slotPoint)
|
||||||
|
.add(new THREE.Vector3(0, Math.max(0, t - 1.6) * 0.6, Math.max(0, t - 1.6) * 0.8));
|
||||||
|
model.flap.rotation.x = t > 0.7 && t < 2 ? -1 : 0;
|
||||||
|
if (context.deliveryTimer > 0) return;
|
||||||
|
model.flap.rotation.x = 0;
|
||||||
|
context.state.busy = false;
|
||||||
|
context.tone(980, 0.18);
|
||||||
|
context.reward(record, slot);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the reward operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} record - record.
|
||||||
|
* @param {*} product - product.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function reward(context, record, product) {
|
||||||
|
const awards = awardsFor(context.state.purchases.slice(0, -1), record),
|
||||||
|
rankBefore = rankFor(context.state.xp).index;
|
||||||
|
if (context.real.on)
|
||||||
|
awards.push({
|
||||||
|
label: strings.payment.realPurchase,
|
||||||
|
points: 200,
|
||||||
|
});
|
||||||
|
const total = awards.reduce((s, a) => s + a.points, 0);
|
||||||
|
context.state.xp += total;
|
||||||
|
context.ui.patch('awards', {
|
||||||
|
items: awards,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
context.clearTimeout(context.reward.timeout);
|
||||||
|
context.reward.timeout = context.setTimeout(
|
||||||
|
() =>
|
||||||
|
context.ui.patch('awards', {
|
||||||
|
items: [],
|
||||||
|
}),
|
||||||
|
3200,
|
||||||
|
);
|
||||||
|
context.say('child', strings.dialogue.childGotIt(product.icon, product.name));
|
||||||
|
const { mission } = context.reportMissionEvent({ type: 'buy', machineId: record.machineId });
|
||||||
|
if (rankFor(context.state.xp).index > rankBefore)
|
||||||
|
context.setTimeout(
|
||||||
|
() =>
|
||||||
|
context.showBanner(
|
||||||
|
strings.rewards.rankUp,
|
||||||
|
rankFor(context.state.xp).name,
|
||||||
|
strings.rewards.findMore,
|
||||||
|
),
|
||||||
|
mission ? 3400 : 0,
|
||||||
|
);
|
||||||
|
context.refreshHUD();
|
||||||
|
context.checkEnd();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the show banner operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} tag - tag.
|
||||||
|
* @param {*} title - title.
|
||||||
|
* @param {*} sub - sub.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function showBanner(context, tag, title, sub) {
|
||||||
|
context.ui.patch('banner', {
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
context.clearTimeout(context.showBanner.timeout);
|
||||||
|
context.showBanner.timeout = context.setTimeout(
|
||||||
|
() =>
|
||||||
|
context.ui.patch('banner', {
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
3200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the check end operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function checkEnd(context) {
|
||||||
|
const allDone = context.state.missionsDone.length === MISSIONS.length;
|
||||||
|
if (context.ended || (!allDone && canAffordAnything(context.state, machines))) return;
|
||||||
|
context.ended = true;
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.ui.patch('end-tag', {
|
||||||
|
text: allDone ? strings.end.success : strings.end.outOfBudget,
|
||||||
|
});
|
||||||
|
context.ui.patch('end-title', {
|
||||||
|
text: allDone ? strings.end.successHeading : strings.end.budgetHeading,
|
||||||
|
});
|
||||||
|
context.ui.patch('end-text', {
|
||||||
|
text: allDone ? strings.end.successText : strings.end.budgetText,
|
||||||
|
});
|
||||||
|
const places = new Set(context.state.purchases.map((p) => p.machineId)).size;
|
||||||
|
context.ui.patch('end-stats', {
|
||||||
|
items: [
|
||||||
|
[strings.end.score, `${fa(context.state.xp)} XP`],
|
||||||
|
[strings.end.rank, rankFor(context.state.xp).name],
|
||||||
|
[strings.end.purchases, fa(context.state.purchases.length)],
|
||||||
|
[strings.end.places, strings.end.outOf(fa(places), fa(machines.length))],
|
||||||
|
[
|
||||||
|
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', {
|
||||||
|
hidden: !allDone,
|
||||||
|
});
|
||||||
|
context.ui.open('end');
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import {
|
||||||
|
startTracking,
|
||||||
|
requestCompass,
|
||||||
|
insideWorld,
|
||||||
|
createStabilizer,
|
||||||
|
MAX_WALKING_SPEED,
|
||||||
|
} from '../device/realworld.js';
|
||||||
|
import data from '../../data/mellat.json';
|
||||||
|
import { resolveCircle } from '../world/collision.js';
|
||||||
|
import { fa } from '../economy/gameState.js';
|
||||||
|
import { formatDistance } from '../../utils/geo.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
/**
|
||||||
|
* Runs the set real mode operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} on - on.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function setRealMode(context, on) {
|
||||||
|
if (on === context.real.on) return;
|
||||||
|
context.real.stop?.();
|
||||||
|
Object.assign(context.real, {
|
||||||
|
on,
|
||||||
|
fix: null,
|
||||||
|
heading: null,
|
||||||
|
error: null,
|
||||||
|
waiting: null,
|
||||||
|
stop: null,
|
||||||
|
offeredOffline: false,
|
||||||
|
});
|
||||||
|
for (const s of context.stations) if (s.def.test) s.model.group.visible = on;
|
||||||
|
context.ui.classState('gps-toggle', 'on', on);
|
||||||
|
context.ui.patch('gps-status', {
|
||||||
|
hidden: !on,
|
||||||
|
});
|
||||||
|
context.ui.patch('ar-toggle', {
|
||||||
|
hidden: context.ui.patch('stable-toggle', {
|
||||||
|
hidden: !on,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Real-world mode is first person: the player is where the phone is and sees what it faces.
|
||||||
|
context.ui.classState('body', 'real', on);
|
||||||
|
if (!on) {
|
||||||
|
context.setArMode(false);
|
||||||
|
context.arView.stopSensors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.arView.startSensors();
|
||||||
|
context.cam.arPitch = 0;
|
||||||
|
context.cam.yaw = Math.atan2(-Math.cos(context.player.heading), Math.sin(context.player.heading));
|
||||||
|
if (context.mode === 'drive') context.toggleCar();
|
||||||
|
context.mode = 'walk';
|
||||||
|
context.real.stop = startTracking({
|
||||||
|
onFix: (fix) => {
|
||||||
|
context.real.fix = fix;
|
||||||
|
context.real.error = null;
|
||||||
|
},
|
||||||
|
onHeading: (heading) => (context.real.heading = heading),
|
||||||
|
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),
|
||||||
|
stabilizer: (context.real.stabilizer = createStabilizer()),
|
||||||
|
});
|
||||||
|
context.real.stabilizer.stable = context.stableMode;
|
||||||
|
context.say('father', strings.real.on);
|
||||||
|
context.setArMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- First-person real-world view ----------
|
||||||
|
// The view stands at the GPS position at eye height and turns with the phone. With the camera
|
||||||
|
// picture on (AR) the city model is hidden and only the machines, their reach circles, the route
|
||||||
|
// and the robot are drawn over the real street; with it off, the 3D city is shown first person.
|
||||||
|
/**
|
||||||
|
* Runs the set ar mode operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} on - on.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export async function setArMode(context, on) {
|
||||||
|
if (on && !context.arMode) {
|
||||||
|
const problem = await context.arView.start();
|
||||||
|
if (problem) {
|
||||||
|
context.say('father', strings.real.cameraFailed(problem));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.real.on) {
|
||||||
|
context.arView.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.say('robot', strings.real.cameraOn, 'excited');
|
||||||
|
context.setTimeout(() => {
|
||||||
|
if (context.real.on && !context.arView.hasOrientation)
|
||||||
|
context.say('father', strings.real.noOrientation);
|
||||||
|
}, 3000);
|
||||||
|
} else if (!on && context.arMode) context.arView.stop();
|
||||||
|
else return;
|
||||||
|
context.arMode = on;
|
||||||
|
for (const object of context.hiddenInAr()) object.visible = !on;
|
||||||
|
context.scene.fog = on ? null : context.fog;
|
||||||
|
context.ui.classState('body', 'ar', on);
|
||||||
|
context.ui.classState('ar-toggle', 'on', on);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the ask real mode operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} thenStart - then Start.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export // The safety notice comes first; permissions are requested from the tap on its button (iOS needs that).
|
||||||
|
function askRealMode(context, thenStart) {
|
||||||
|
if (context.real.on) {
|
||||||
|
context.setRealMode(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.ui.open('real-intro');
|
||||||
|
context.ui.on('real-go', async () => {
|
||||||
|
context.ui.close('real-intro');
|
||||||
|
await requestCompass();
|
||||||
|
// Record the mode first: without any GPS the error arrives during setRealMode itself.
|
||||||
|
if (thenStart) context.startMode = 'real';
|
||||||
|
context.setRealMode(true);
|
||||||
|
if (thenStart) context.startGame('real');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates real from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateReal(context, dt) {
|
||||||
|
let uiStatus;
|
||||||
|
if (context.real.error) uiStatus = `⚠ ${context.real.error}`;
|
||||||
|
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)) {
|
||||||
|
const nearest = Math.min(
|
||||||
|
...context
|
||||||
|
.activeStations()
|
||||||
|
.map((s) => Math.hypot(s.x - context.real.fix.x, s.z - context.real.fix.z)),
|
||||||
|
);
|
||||||
|
uiStatus = strings.real.outOfArea(formatDistance(nearest));
|
||||||
|
} else
|
||||||
|
uiStatus = strings.real.accuracy(
|
||||||
|
fa(Math.round(context.real.fix.accuracy)),
|
||||||
|
context.real.fix.speed > MAX_WALKING_SPEED,
|
||||||
|
);
|
||||||
|
context.ui.patch('gps-status', {
|
||||||
|
text: uiStatus,
|
||||||
|
});
|
||||||
|
if (!context.real.fix || !insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
|
||||||
|
context.player.speed = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Walk the avatar towards the (building-free) GPS point instead of teleporting it.
|
||||||
|
const target = resolveCircle(context.collision, context.real.fix.x, context.real.fix.z, 1);
|
||||||
|
const dx = target.x - context.player.x,
|
||||||
|
dz = target.z - context.player.z,
|
||||||
|
d = Math.hypot(dx, dz);
|
||||||
|
if (d > 150)
|
||||||
|
Object.assign(context.player, {
|
||||||
|
x: target.x,
|
||||||
|
z: target.z,
|
||||||
|
});
|
||||||
|
const speed = d > 0.5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0;
|
||||||
|
context.player.speed += (speed - context.player.speed) * Math.min(1, dt * 6);
|
||||||
|
if (d > 0.5) {
|
||||||
|
const step = Math.min(d, context.player.speed * dt);
|
||||||
|
context.player.x += (dx / d) * step;
|
||||||
|
context.player.z += (dz / d) * step;
|
||||||
|
}
|
||||||
|
const want = context.real.heading ?? (d > 0.5 ? Math.atan2(-dz, dx) : context.player.heading);
|
||||||
|
// Stable mode also calms the compass, which jitters near cars and metal.
|
||||||
|
context.player.heading +=
|
||||||
|
Math.atan2(Math.sin(want - context.player.heading), Math.cos(want - context.player.heading)) *
|
||||||
|
Math.min(1, dt * (context.stableMode ? 2 : 5));
|
||||||
|
context.player.gait += dt * context.player.speed * 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Loop ----------
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { clearFraction } from '../world/collision.js';
|
||||||
|
import { syncActiveVehicle, trafficObstacles, updateGarage } from './garage.js';
|
||||||
|
/**
|
||||||
|
* Runs the resize operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function resize(context) {
|
||||||
|
context.renderer.setSize(innerWidth, innerHeight);
|
||||||
|
context.camera.aspect = innerWidth / innerHeight;
|
||||||
|
context.camera.updateProjectionMatrix();
|
||||||
|
context.renderMap();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates camera from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateCamera(context, dt) {
|
||||||
|
const station = context.payment.station;
|
||||||
|
if (station && context.payment.phase !== 'idle') {
|
||||||
|
// Close-up of the card reader during payment.
|
||||||
|
const reader = station.model.readerPoint.clone();
|
||||||
|
station.model.group.localToWorld(reader);
|
||||||
|
const side = context.rotateLocal(1.2, 1.9, station.rot);
|
||||||
|
context.camTarget.set(reader.x + side.x, reader.y + 0.4, reader.z + side.z);
|
||||||
|
context.lookAt.lerp(reader, 1 - Math.exp(-dt * 6));
|
||||||
|
} else {
|
||||||
|
const driving = context.mode === 'drive',
|
||||||
|
height = driving ? 1.6 : 1.75;
|
||||||
|
const focus = new THREE.Vector3(context.player.x, height, context.player.z);
|
||||||
|
// Over-the-shoulder offset to the right, like a third-person shooter.
|
||||||
|
const shoulder = driving ? 0 : 0.75;
|
||||||
|
focus.x += Math.cos(context.cam.yaw) * shoulder;
|
||||||
|
focus.z += -Math.sin(context.cam.yaw) * shoulder;
|
||||||
|
let distance = context.cam.distance;
|
||||||
|
const ox = Math.sin(context.cam.yaw) * Math.cos(context.cam.pitch),
|
||||||
|
oz = Math.cos(context.cam.yaw) * Math.cos(context.cam.pitch);
|
||||||
|
const free = clearFraction(
|
||||||
|
context.collision,
|
||||||
|
focus.x,
|
||||||
|
focus.z,
|
||||||
|
focus.x + ox * distance,
|
||||||
|
focus.z + oz * distance,
|
||||||
|
0.8,
|
||||||
|
);
|
||||||
|
if (context.cam.pitch < 0.9) distance = Math.max(1.6, distance * free - 0.7);
|
||||||
|
context.camTarget.set(
|
||||||
|
focus.x + ox * distance,
|
||||||
|
Math.max(0.6, focus.y + Math.sin(context.cam.pitch) * distance),
|
||||||
|
focus.z + oz * distance,
|
||||||
|
);
|
||||||
|
context.lookAt.lerp(focus, 1 - Math.exp(-dt * 14));
|
||||||
|
}
|
||||||
|
context.camPos.lerp(context.camTarget, 1 - Math.exp(-dt * 12));
|
||||||
|
context.camera.position.copy(context.camPos);
|
||||||
|
context.camera.lookAt(context.lookAt);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the frame operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function frame(context) {
|
||||||
|
const now = performance.now();
|
||||||
|
context.frameId = requestAnimationFrame(context.frame);
|
||||||
|
// The first rAF timestamp can predate `last`, so never let time run backwards. Long frames are
|
||||||
|
// simulated in short substeps, so slow devices keep real-time speed without unstable physics.
|
||||||
|
const dt = THREE.MathUtils.clamp((now - context.last) / 1000, 0, context.MAX_FRAME);
|
||||||
|
context.last = now;
|
||||||
|
context.elapsed += dt;
|
||||||
|
const playing = context.ui.read('landing').hidden;
|
||||||
|
// Any open dialog (or the start screen) pauses the world.
|
||||||
|
const paused = context.anyDialogOpen() || document.hidden;
|
||||||
|
const steps = Math.max(1, Math.ceil(dt / (1 / 30))),
|
||||||
|
step = dt / steps;
|
||||||
|
if (!paused) {
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
context.state.minutes += (step * 10) / 60;
|
||||||
|
if (context.real.on) context.updateReal(step);
|
||||||
|
else if (context.mode === 'drive') context.updateDrive(step);
|
||||||
|
else context.updateWalk(step);
|
||||||
|
context.updatePayment(step);
|
||||||
|
context.updateDelivery(step);
|
||||||
|
context.updateMissions(step);
|
||||||
|
context.traffic.update(step, trafficObstacles(context), context.player);
|
||||||
|
}
|
||||||
|
context.updateRoute(dt);
|
||||||
|
context.updateHints();
|
||||||
|
context.updateTalks();
|
||||||
|
context.updateGuide(dt);
|
||||||
|
if (Math.floor(context.elapsed * 2) !== Math.floor((context.elapsed - dt) * 2))
|
||||||
|
context.checkEnd();
|
||||||
|
} else if (context.engine && context.soundOn)
|
||||||
|
context.engine.g.gain.setTargetAtTime(0, context.audio.currentTime, 0.1);
|
||||||
|
if (context.mode === 'walk' && context.engine && context.soundOn)
|
||||||
|
context.engine.g.gain.setTargetAtTime(0, context.audio.currentTime, 0.2);
|
||||||
|
context.updateSubtitles(dt);
|
||||||
|
context.family.group.visible = context.mode === 'walk' && !context.real.on;
|
||||||
|
context.family.group.position.set(context.player.x, 0, context.player.z);
|
||||||
|
context.family.group.rotation.y = context.player.heading;
|
||||||
|
context.family.update({
|
||||||
|
dt,
|
||||||
|
time: context.elapsed,
|
||||||
|
gait: context.player.gait,
|
||||||
|
moving: context.player.speed > 0.4,
|
||||||
|
shopping: context.state.busy || context.payment.phase !== 'idle',
|
||||||
|
delivery: context.deliveryTimer > 0,
|
||||||
|
});
|
||||||
|
context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position);
|
||||||
|
updateGarage(context);
|
||||||
|
context.showroom.update({
|
||||||
|
player: context.player,
|
||||||
|
time: context.elapsed,
|
||||||
|
dt,
|
||||||
|
enabled: context.mode === 'walk' && !context.real.on,
|
||||||
|
talking: context.ui.read('showroom').open,
|
||||||
|
});
|
||||||
|
syncActiveVehicle(context, dt);
|
||||||
|
for (const s of context.stations)
|
||||||
|
s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25;
|
||||||
|
context.updateReachCircles();
|
||||||
|
if (context.routeMesh)
|
||||||
|
context.routeMaterial.opacity = 0.55 + Math.sin(context.elapsed * 4) * 0.15;
|
||||||
|
if (playing && context.real.on) {
|
||||||
|
// First person: the phone is the camera, at the player's GPS position and eye height.
|
||||||
|
context.camera.position.set(context.player.x, 1.6, context.player.z);
|
||||||
|
if (!context.arView.update(context.camera, dt, context.stableMode ? 5 : 10)) {
|
||||||
|
// No orientation sensor: face the compass or walking direction, or wherever the player drags.
|
||||||
|
if (context.real.heading != null && context.elapsed - context.cam.lastMouse > 2) {
|
||||||
|
const want = Math.atan2(
|
||||||
|
-Math.cos(context.player.heading),
|
||||||
|
Math.sin(context.player.heading),
|
||||||
|
);
|
||||||
|
context.cam.yaw +=
|
||||||
|
Math.atan2(Math.sin(want - context.cam.yaw), Math.cos(want - context.cam.yaw)) *
|
||||||
|
Math.min(1, dt * 3);
|
||||||
|
}
|
||||||
|
context.camera.rotation.set(context.cam.arPitch ?? 0, context.cam.yaw, 0, 'YXZ');
|
||||||
|
}
|
||||||
|
context.camPos.copy(context.camera.position);
|
||||||
|
} else if (playing) {
|
||||||
|
if (context.camera.fov !== 62) {
|
||||||
|
context.camera.fov = 62;
|
||||||
|
context.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
context.updateCamera(dt);
|
||||||
|
} else {
|
||||||
|
// Start screen: a slow orbit around the factory yard behind the menu.
|
||||||
|
const a = context.elapsed * 0.08;
|
||||||
|
context.camera.position.set(
|
||||||
|
context.player.x + Math.cos(a) * 30,
|
||||||
|
18,
|
||||||
|
context.player.z + Math.sin(a) * 30,
|
||||||
|
);
|
||||||
|
context.camera.lookAt(context.player.x, 2, context.player.z);
|
||||||
|
context.camPos.copy(context.camera.position);
|
||||||
|
}
|
||||||
|
context.sun.position.set(context.player.x + 160, 230, context.player.z + 110);
|
||||||
|
context.sun.target.position.set(context.player.x, 0, context.player.z);
|
||||||
|
context.sky.position.copy(context.camera.position);
|
||||||
|
context.city.update(context.camera.position);
|
||||||
|
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
|
||||||
|
if (playing) {
|
||||||
|
context.hudTimer -= dt;
|
||||||
|
context.updateHUD();
|
||||||
|
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
|
||||||
|
context.hudTimer = 0.5;
|
||||||
|
context.renderMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.renderer.render(context.scene, context.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?debug exposes live game state in the console for development and automated checks.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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 { say, updateSubtitles, tone } from './audioAndDialogue.js';
|
||||||
|
import {
|
||||||
|
openShowroom,
|
||||||
|
interact,
|
||||||
|
renderShop,
|
||||||
|
startPayment,
|
||||||
|
swipe,
|
||||||
|
cancel,
|
||||||
|
updatePayment,
|
||||||
|
updateDelivery,
|
||||||
|
reward,
|
||||||
|
showBanner,
|
||||||
|
checkEnd,
|
||||||
|
} from './purchaseFlow.js';
|
||||||
|
import {
|
||||||
|
rebuildRouteMesh,
|
||||||
|
updateRoute,
|
||||||
|
refreshHUD,
|
||||||
|
updatePrompt,
|
||||||
|
updateHUD,
|
||||||
|
} from './navigationHud.js';
|
||||||
|
import {
|
||||||
|
sizeCanvas,
|
||||||
|
renderMap,
|
||||||
|
renderMachineList,
|
||||||
|
selectMachine,
|
||||||
|
openMap,
|
||||||
|
} from './mapInteraction.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 { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js';
|
||||||
|
import { initializeFactoryPoints, pointById, nearBoard, openBoard } from './factoryPoints.js';
|
||||||
|
import {
|
||||||
|
initializeTimers,
|
||||||
|
initializeRenderer,
|
||||||
|
initializeScene,
|
||||||
|
initializeSky,
|
||||||
|
initializeLightingAndWorldFiles,
|
||||||
|
initializeCollisionAndRoads,
|
||||||
|
initializeFactory,
|
||||||
|
initializeStations,
|
||||||
|
initializeStationById,
|
||||||
|
initializeClosestStation,
|
||||||
|
initializeParkGate,
|
||||||
|
buildParkGate,
|
||||||
|
initializeFamily,
|
||||||
|
initializeSpawnCar,
|
||||||
|
registerKeyboard,
|
||||||
|
registerPointerLook,
|
||||||
|
registerCameraZoom,
|
||||||
|
registerTouchInput,
|
||||||
|
initializeAxis,
|
||||||
|
registerSoundControl,
|
||||||
|
initializeReachRadius,
|
||||||
|
registerPurchaseControls,
|
||||||
|
registerPreferences,
|
||||||
|
initializeRouteMaterial,
|
||||||
|
initializeMapView,
|
||||||
|
registerRouteSelection,
|
||||||
|
registerMapTabs,
|
||||||
|
registerMapDragging,
|
||||||
|
registerMapSelectionAndZoom,
|
||||||
|
initializeCities,
|
||||||
|
initializeBriefingAndTracking,
|
||||||
|
initializeArMode,
|
||||||
|
initializeLast,
|
||||||
|
exposeDebugTools,
|
||||||
|
startGameLoop,
|
||||||
|
initializeDispose,
|
||||||
|
} from './runtimeSetup.js';
|
||||||
|
|
||||||
|
/** Starts the ordered runtime systems; UI rendering remains owned by React.
|
||||||
|
* @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount lifecycle.
|
||||||
|
* @returns {Promise<function>} Runtime disposer.
|
||||||
|
*/
|
||||||
|
const RUNTIME_METHODS = {
|
||||||
|
placeStation,
|
||||||
|
updateReachCircles,
|
||||||
|
reset,
|
||||||
|
look,
|
||||||
|
say,
|
||||||
|
updateSubtitles,
|
||||||
|
tone,
|
||||||
|
toggleCar,
|
||||||
|
openShowroom,
|
||||||
|
interact,
|
||||||
|
renderShop,
|
||||||
|
startPayment,
|
||||||
|
swipe,
|
||||||
|
cancel,
|
||||||
|
updatePayment,
|
||||||
|
updateDelivery,
|
||||||
|
reward,
|
||||||
|
showBanner,
|
||||||
|
checkEnd,
|
||||||
|
rebuildRouteMesh,
|
||||||
|
updateRoute,
|
||||||
|
refreshHUD,
|
||||||
|
updatePrompt,
|
||||||
|
sizeCanvas,
|
||||||
|
renderMap,
|
||||||
|
renderMachineList,
|
||||||
|
selectMachine,
|
||||||
|
openMap,
|
||||||
|
startGame,
|
||||||
|
reportMissionEvent,
|
||||||
|
updateMissions,
|
||||||
|
openMachineCard,
|
||||||
|
orderFromCard,
|
||||||
|
orderFromShowroom,
|
||||||
|
pointById,
|
||||||
|
nearBoard,
|
||||||
|
openBoard,
|
||||||
|
restartGame,
|
||||||
|
backToMenu,
|
||||||
|
offerOffline,
|
||||||
|
switchToOffline,
|
||||||
|
setRealMode,
|
||||||
|
setArMode,
|
||||||
|
askRealMode,
|
||||||
|
updateReal,
|
||||||
|
resize,
|
||||||
|
updateWalk,
|
||||||
|
updateDrive,
|
||||||
|
updateCamera,
|
||||||
|
updateHUD,
|
||||||
|
updateTalks,
|
||||||
|
greet,
|
||||||
|
updateGuide,
|
||||||
|
updateHints,
|
||||||
|
frame,
|
||||||
|
};
|
||||||
|
export const RUNTIME_INITIALIZERS = [
|
||||||
|
initializeTimers,
|
||||||
|
initializeRenderer,
|
||||||
|
initializeScene,
|
||||||
|
initializeSky,
|
||||||
|
initializeLightingAndWorldFiles,
|
||||||
|
initializeCollisionAndRoads,
|
||||||
|
initializeFactory,
|
||||||
|
initializeFactoryPoints,
|
||||||
|
initializeStations,
|
||||||
|
initializeStationById,
|
||||||
|
initializeClosestStation,
|
||||||
|
initializeParkGate,
|
||||||
|
buildParkGate,
|
||||||
|
initializeFamily,
|
||||||
|
initializeSpawnCar,
|
||||||
|
registerKeyboard,
|
||||||
|
registerPointerLook,
|
||||||
|
registerCameraZoom,
|
||||||
|
registerTouchInput,
|
||||||
|
initializeAxis,
|
||||||
|
registerSoundControl,
|
||||||
|
initializeReachRadius,
|
||||||
|
registerPurchaseControls,
|
||||||
|
registerPreferences,
|
||||||
|
initializeRouteMaterial,
|
||||||
|
initializeMapView,
|
||||||
|
registerRouteSelection,
|
||||||
|
registerMapTabs,
|
||||||
|
registerMapDragging,
|
||||||
|
registerMapSelectionAndZoom,
|
||||||
|
initializeCities,
|
||||||
|
initializeBriefingAndTracking,
|
||||||
|
initializeArMode,
|
||||||
|
initializeLast,
|
||||||
|
exposeDebugTools,
|
||||||
|
startGameLoop,
|
||||||
|
initializeDispose,
|
||||||
|
];
|
||||||
|
/** Binds subsystem commands to one mutable renderer context; GPU objects never enter Redux. */
|
||||||
|
export function bindRuntime(context) {
|
||||||
|
for (const [name, method] of Object.entries(RUNTIME_METHODS))
|
||||||
|
context[name] = (...args) => method(context, ...args);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { addPolygon, resolveCircle } from '../world/collision.js';
|
||||||
|
import { createState } from '../economy/gameState.js';
|
||||||
|
import { createPayment } from '../economy/payment.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.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} def - def.
|
||||||
|
* @param {*} pos - pos.
|
||||||
|
* @param {*} rot - rot.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function placeStation(context, def, pos, rot) {
|
||||||
|
const model = createVendingMachine(def);
|
||||||
|
model.group.position.set(pos.x, 0, pos.z);
|
||||||
|
model.group.rotation.y = rot;
|
||||||
|
context.scene.add(model.group);
|
||||||
|
addPolygon(
|
||||||
|
context.collision,
|
||||||
|
[
|
||||||
|
[-1, -0.6],
|
||||||
|
[1, -0.6],
|
||||||
|
[1, 0.6],
|
||||||
|
[-1, 0.6],
|
||||||
|
].flatMap(([lx, lz]) => {
|
||||||
|
const p = context.rotateLocal(lx, lz, rot);
|
||||||
|
return [pos.x + p.x, pos.z + p.z];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const front = context.rotateLocal(0, 1.9, rot);
|
||||||
|
return {
|
||||||
|
def,
|
||||||
|
model,
|
||||||
|
x: pos.x,
|
||||||
|
z: pos.z,
|
||||||
|
rot,
|
||||||
|
front: {
|
||||||
|
x: pos.x + front.x,
|
||||||
|
z: pos.z + front.z,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates reach circles from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateReachCircles(context) {
|
||||||
|
const radius = context.reachRadius(),
|
||||||
|
objective = context.real.on ? context.objectiveStation() : null;
|
||||||
|
for (const s of context.stations) {
|
||||||
|
s.reach.visible = context.real.on;
|
||||||
|
if (!context.real.on) continue;
|
||||||
|
s.reach.scale.setScalar(radius);
|
||||||
|
s.reach.material.color.set(s === objective ? '#f2c94c' : BRAND.primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GPS test points only take part while real-world mode is on.
|
||||||
|
/**
|
||||||
|
* Runs the reset operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function reset(context) {
|
||||||
|
context.state = createState();
|
||||||
|
context.payment = createPayment();
|
||||||
|
// The family starts in the factory yard, facing the engineering office.
|
||||||
|
const spot = resolveCircle(
|
||||||
|
context.collision,
|
||||||
|
context.factory.start.x,
|
||||||
|
context.factory.start.z,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
context.player = {
|
||||||
|
x: spot.x,
|
||||||
|
z: spot.z,
|
||||||
|
heading: context.factory.start.heading,
|
||||||
|
speed: 0,
|
||||||
|
gait: 0,
|
||||||
|
};
|
||||||
|
resetGarage(context);
|
||||||
|
context.factory.reset();
|
||||||
|
for (const t of context.factory.talks) t.done = false;
|
||||||
|
context.mode = 'walk';
|
||||||
|
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
|
||||||
|
context.cam = {
|
||||||
|
yaw: Math.atan2(-Math.cos(context.player.heading), Math.sin(context.player.heading)),
|
||||||
|
pitch: 0.32,
|
||||||
|
distance: 5.5,
|
||||||
|
lastMouse: -10,
|
||||||
|
};
|
||||||
|
context.waypoint = null;
|
||||||
|
context.route = null;
|
||||||
|
context.routeTimer = 0;
|
||||||
|
context.deliveryTimer = 0;
|
||||||
|
context.ended = false;
|
||||||
|
context.hints = {
|
||||||
|
closed: false,
|
||||||
|
car: false,
|
||||||
|
};
|
||||||
|
Object.assign(context.guideState, {
|
||||||
|
station: null,
|
||||||
|
t: 0,
|
||||||
|
greeted: new Set(),
|
||||||
|
arrived: 0,
|
||||||
|
});
|
||||||
|
context.ui.patch('payment', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
for (const s of context.stations) {
|
||||||
|
s.model.delivery.visible = s.model.card.visible = false;
|
||||||
|
s.model.flap.rotation.x = 0;
|
||||||
|
}
|
||||||
|
for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board'])
|
||||||
|
context.ui.close(id);
|
||||||
|
context.keys.clear();
|
||||||
|
context.refreshHUD();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
|
import { isOpen } from '../economy/gameState.js';
|
||||||
|
import { strings } from '../../i18n/fa.js';
|
||||||
|
/**
|
||||||
|
* Distance from the player to someone who talks.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
* @param {object} talk - Factory talk.
|
||||||
|
* @returns {number} Metres.
|
||||||
|
*/
|
||||||
|
function talkDistance(context, talk) {
|
||||||
|
const p = talk.pos();
|
||||||
|
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) {
|
||||||
|
if (t.repeat && t.done && talkDistance(context, t) > t.radius * 2) t.done = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ----------
|
||||||
|
// 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.
|
||||||
|
/**
|
||||||
|
* Runs the greet operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} station - station.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function greet(context, station) {
|
||||||
|
const def = station.def,
|
||||||
|
first = !context.guideState.greeted.has(def.id);
|
||||||
|
context.guideState.greeted.add(def.id);
|
||||||
|
if (def.id === 'factory' && first) {
|
||||||
|
context.say('robot', strings.dialogue.robotHello, 'excited');
|
||||||
|
context.say('robot', strings.dialogue.robotWelcome(FACTORY.name), 'happy');
|
||||||
|
context.say('robot', strings.dialogue.robotFirstBuy, 'wink');
|
||||||
|
context.say('robot', strings.dialogue.robotGoToPark, 'love');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isOpen(def, context.state.minutes)) {
|
||||||
|
context.say('robot', strings.dialogue.robotClosed(context.hoursText(def)), 'serious');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (first) {
|
||||||
|
context.say('robot', strings.dialogue.robotWelcomeBack(def.name), 'excited');
|
||||||
|
context.say('robot', strings.dialogue.robotNote(def.note), 'happy');
|
||||||
|
} else context.say('robot', strings.dialogue.robotAgain, 'wink');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates guide from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateGuide(context, dt) {
|
||||||
|
const near =
|
||||||
|
context.mode === 'walk'
|
||||||
|
? context.closestStation(
|
||||||
|
context.player.x,
|
||||||
|
context.player.z,
|
||||||
|
Math.max(9, context.reachRadius()),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
// The robot only moves to another machine after it has gone back into the previous one.
|
||||||
|
if (near && !context.guideState.station) context.guideState.station = near;
|
||||||
|
const s = context.guideState.station,
|
||||||
|
wasOut = context.guideOut();
|
||||||
|
if (!s) return;
|
||||||
|
context.guideState.t = THREE.MathUtils.clamp(
|
||||||
|
context.guideState.t + (near === s ? dt : -dt) / 1.1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (context.guideOut() && !wasOut) {
|
||||||
|
context.guideState.arrived = context.elapsed;
|
||||||
|
context.greet(s);
|
||||||
|
context.tone(990, 0.15);
|
||||||
|
context.tone(1320, 0.2);
|
||||||
|
}
|
||||||
|
if (wasOut && near !== s) context.say('robot', strings.dialogue.robotBye, 'happy');
|
||||||
|
if (!context.guideState.t) {
|
||||||
|
context.guideState.station = null;
|
||||||
|
context.guide.group.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// From inside the machine front out to its left side (the card reader is on the right).
|
||||||
|
const e = context.easeOutBack(context.guideState.t),
|
||||||
|
from = {
|
||||||
|
x: -0.2,
|
||||||
|
y: 0.9,
|
||||||
|
z: 0.2,
|
||||||
|
},
|
||||||
|
to = {
|
||||||
|
x: -1.7,
|
||||||
|
y: 0,
|
||||||
|
z: 1.3,
|
||||||
|
};
|
||||||
|
const local = context.rotateLocal(
|
||||||
|
from.x + (to.x - from.x) * e,
|
||||||
|
from.z + (to.z - from.z) * e,
|
||||||
|
s.rot,
|
||||||
|
);
|
||||||
|
context.guide.group.visible = true;
|
||||||
|
context.guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z);
|
||||||
|
context.guide.group.scale.setScalar(0.15 + 0.85 * Math.min(1, e));
|
||||||
|
// Face the family (the rig looks along its local +X).
|
||||||
|
context.guide.group.rotation.y = Math.atan2(
|
||||||
|
-(context.player.z - context.guide.group.position.z),
|
||||||
|
context.player.x - context.guide.group.position.x,
|
||||||
|
);
|
||||||
|
if (!context.state.busy && context.payment.phase === 'idle')
|
||||||
|
s.model.flap.rotation.x = context.guideState.t < 1 ? -1 : 0;
|
||||||
|
context.guide.update({
|
||||||
|
dt,
|
||||||
|
time: context.elapsed,
|
||||||
|
gait: 0,
|
||||||
|
moving: false,
|
||||||
|
talking: context.talking,
|
||||||
|
wave: context.elapsed - context.guideState.arrived < 2.5 || !context.guideOut(),
|
||||||
|
point: context.talking && context.guideOut(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates hints from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateHints(context) {
|
||||||
|
const target = context.objectiveStation();
|
||||||
|
// 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.say(
|
||||||
|
'robot',
|
||||||
|
strings.dialogue.targetClosed(target.def.name, context.hoursText(target.def)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context.route &&
|
||||||
|
context.route.length > 900 &&
|
||||||
|
context.mode === 'walk' &&
|
||||||
|
!context.hints.car
|
||||||
|
) {
|
||||||
|
context.hints.car = true;
|
||||||
|
context.say('father', strings.dialogue.longWay);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long).
|
||||||
|
/**
|
||||||
|
* Creates car model resources for the game.
|
||||||
|
* @param {*} color - color.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createCarModel(color = '#d9d9d4') {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
/**
|
||||||
|
* Runs the mat operation for this subsystem.
|
||||||
|
* @param {*} c - c.
|
||||||
|
* @param {*} extra - extra.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const mat = (c, extra = {}) =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: c,
|
||||||
|
roughness: 0.45,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const paint = mat(color, {
|
||||||
|
metalness: 0.55,
|
||||||
|
roughness: 0.35,
|
||||||
|
}),
|
||||||
|
glass = mat('#1e2c33', {
|
||||||
|
metalness: 0.6,
|
||||||
|
roughness: 0.15,
|
||||||
|
});
|
||||||
|
const dark = mat('#141618'),
|
||||||
|
chrome = mat('#c8ccce', {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
box(4.4, 0.62, 1.78, paint, 0, 0.68, 0);
|
||||||
|
box(4.42, 0.16, 1.8, dark, 0, 0.38, 0);
|
||||||
|
const cabin = box(2.3, 0.62, 1.6, glass, -0.25, 1.28, 0);
|
||||||
|
cabin.scale.set(1, 1, 0.98);
|
||||||
|
box(2.1, 0.08, 1.62, paint, -0.25, 1.62, 0);
|
||||||
|
box(0.9, 0.06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -0.12;
|
||||||
|
for (const z of [-0.62, 0.62]) {
|
||||||
|
box(
|
||||||
|
0.06,
|
||||||
|
0.18,
|
||||||
|
0.34,
|
||||||
|
mat('#fff4cc', {
|
||||||
|
emissive: '#fff1b0',
|
||||||
|
emissiveIntensity: 0.6,
|
||||||
|
}),
|
||||||
|
2.21,
|
||||||
|
0.78,
|
||||||
|
z,
|
||||||
|
);
|
||||||
|
box(
|
||||||
|
0.06,
|
||||||
|
0.16,
|
||||||
|
0.34,
|
||||||
|
mat('#b3261e', {
|
||||||
|
emissive: '#7a0f0b',
|
||||||
|
emissiveIntensity: 0.5,
|
||||||
|
}),
|
||||||
|
-2.21,
|
||||||
|
0.8,
|
||||||
|
z,
|
||||||
|
);
|
||||||
|
box(0.18, 0.08, 0.1, chrome, 0.6, 1.02, z * 1.47);
|
||||||
|
}
|
||||||
|
box(0.04, 0.12, 1.0, chrome, 2.22, 0.6, 0);
|
||||||
|
const wheels = [];
|
||||||
|
for (const x of [-1.38, 1.38])
|
||||||
|
for (const z of [-0.86, 0.86]) {
|
||||||
|
const wheel = new THREE.Group();
|
||||||
|
wheel.position.set(x, 0.36, z);
|
||||||
|
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.26, 18), dark);
|
||||||
|
tyre.rotation.x = Math.PI / 2;
|
||||||
|
tyre.castShadow = true;
|
||||||
|
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.27, 10), chrome);
|
||||||
|
hub.rotation.x = Math.PI / 2;
|
||||||
|
wheel.add(tyre, hub);
|
||||||
|
group.add(wheel);
|
||||||
|
wheels.push(wheel);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
wheels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const COLORS = [
|
||||||
|
'#e9e9e4',
|
||||||
|
'#1d1f22',
|
||||||
|
'#9aa3a8',
|
||||||
|
'#d6c9a5',
|
||||||
|
'#7e1f1d',
|
||||||
|
'#2f4a6b',
|
||||||
|
'#f2c94c',
|
||||||
|
'#efefea',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ambient cars on the main roads. They stay within a few hundred metres of the player: a car that
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
* @param {*} scene - scene.
|
||||||
|
* @param {*} data - data.
|
||||||
|
* @param {*} count - count.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createTraffic(scene, data, count = 28) {
|
||||||
|
const oneway = new Set(data.oneway ?? []);
|
||||||
|
const roads = data.roads
|
||||||
|
.flatMap((r, index) => {
|
||||||
|
if (r[0] > 3) return [];
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
|
||||||
|
const lengths = [0];
|
||||||
|
for (let i = 1; i < pts.length; i++)
|
||||||
|
lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
|
||||||
|
const xs = pts.map((p) => p.x),
|
||||||
|
zs = pts.map((p) => p.y);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
pts,
|
||||||
|
lengths,
|
||||||
|
total: lengths.at(-1),
|
||||||
|
width: WIDTH[r[0]],
|
||||||
|
oneway: oneway.has(index),
|
||||||
|
box: {
|
||||||
|
x0: Math.min(...xs),
|
||||||
|
x1: Math.max(...xs),
|
||||||
|
z0: Math.min(...zs),
|
||||||
|
z1: Math.max(...zs),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.filter((r) => r.total > 80);
|
||||||
|
/**
|
||||||
|
* Runs the sample operation for this subsystem.
|
||||||
|
* @param {*} road - road.
|
||||||
|
* @param {*} d - d.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const sample = (road, d) => {
|
||||||
|
let i = 1;
|
||||||
|
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
|
||||||
|
const a = road.pts[i - 1],
|
||||||
|
b = road.pts[i],
|
||||||
|
t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
|
||||||
|
return {
|
||||||
|
x: a.x + (b.x - a.x) * t,
|
||||||
|
z: a.y + (b.y - a.y) * t,
|
||||||
|
dx: b.x - a.x,
|
||||||
|
dz: b.y - a.y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let seed = 7;
|
||||||
|
/**
|
||||||
|
* Runs the random operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
||||||
|
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
|
||||||
|
let nearby = [],
|
||||||
|
nearbyAt = null;
|
||||||
|
/**
|
||||||
|
* Runs the roads near operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const roadsNear = (x, z) => {
|
||||||
|
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
|
||||||
|
nearbyAt = {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
};
|
||||||
|
nearby = roads.filter(
|
||||||
|
({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nearby;
|
||||||
|
};
|
||||||
|
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
|
||||||
|
/**
|
||||||
|
* Runs the respawn operation for this subsystem.
|
||||||
|
* @param {*} car - car.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function respawn(car, x, z) {
|
||||||
|
const candidates = roadsNear(x, z);
|
||||||
|
for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
|
||||||
|
const road = candidates[Math.floor(random() * candidates.length)],
|
||||||
|
d = random() * road.total,
|
||||||
|
p = sample(road, d);
|
||||||
|
const dist = Math.hypot(p.x - x, p.z - z);
|
||||||
|
if (dist < 120 || dist > 600) continue;
|
||||||
|
// One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half.
|
||||||
|
const lane = road.oneway ? ((random() < 0.5 ? -1 : 1) * road.width) / 4 : road.width / 4;
|
||||||
|
Object.assign(car, {
|
||||||
|
road,
|
||||||
|
d,
|
||||||
|
dir: road.oneway || random() < 0.5 ? 1 : -1,
|
||||||
|
lane,
|
||||||
|
speed: 9 + random() * 7,
|
||||||
|
});
|
||||||
|
car.model.group.visible = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
car.model.group.visible = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// All traffic sits in one group so it can be hidden as a whole (camera AR view).
|
||||||
|
const group = new THREE.Group(),
|
||||||
|
cars = [];
|
||||||
|
scene.add(group);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
const model = createCarModel(color);
|
||||||
|
group.add(model.group);
|
||||||
|
model.group.visible = false;
|
||||||
|
cars.push({
|
||||||
|
model,
|
||||||
|
color,
|
||||||
|
halfWidth: TRAFFIC_CAR.halfWidth,
|
||||||
|
road: null,
|
||||||
|
d: 0,
|
||||||
|
dir: 1,
|
||||||
|
lane: 2.2,
|
||||||
|
speed: 10,
|
||||||
|
x: 1e9,
|
||||||
|
z: 1e9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Takes a car off the road; it respawns out of sight on a later frame.
|
||||||
|
/**
|
||||||
|
* Runs the remove operation for this subsystem.
|
||||||
|
* @param {*} car - car.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function remove(car) {
|
||||||
|
car.road = null;
|
||||||
|
car.x = car.z = 1e9;
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Updates update from the active simulation.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @param {*} obstacles - obstacles.
|
||||||
|
* @param {*} around - around.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function update(dt, obstacles, around) {
|
||||||
|
// Re-home a couple of distant cars per frame, which keeps the cost per frame small.
|
||||||
|
for (let n = 0; n < 2; n++) {
|
||||||
|
const car = cars[(cursor = (cursor + 1) % cars.length)];
|
||||||
|
if (!car.road || Math.hypot(car.x - around.x, car.z - around.z) > 800)
|
||||||
|
respawn(car, around.x, around.z);
|
||||||
|
}
|
||||||
|
for (const car of cars) {
|
||||||
|
if (!car.road || !car.model.group.visible) continue;
|
||||||
|
const here = sample(car.road, car.d),
|
||||||
|
len = Math.hypot(here.dx, here.dz) || 1;
|
||||||
|
const fx = (here.dx / len) * car.dir,
|
||||||
|
fz = (here.dz / len) * car.dir;
|
||||||
|
const ahead = obstacles
|
||||||
|
.concat(cars.filter((c) => c !== car && c.model.group.visible))
|
||||||
|
.some((o) => {
|
||||||
|
const ox = o.x - car.x,
|
||||||
|
oz = o.z - car.z,
|
||||||
|
along = ox * fx + oz * fz;
|
||||||
|
// 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;
|
||||||
|
car.d += speed * car.dir * dt;
|
||||||
|
if (car.d > car.road.total || car.d < 0) {
|
||||||
|
// At the end of a one-way road the car leaves; on a two-way road it turns back.
|
||||||
|
if (car.road.oneway) {
|
||||||
|
remove(car);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
car.dir *= -1;
|
||||||
|
car.d = Math.max(0, Math.min(car.road.total, car.d));
|
||||||
|
}
|
||||||
|
// Keep to the right-hand lane relative to the direction of travel.
|
||||||
|
const p = sample(car.road, car.d);
|
||||||
|
car.x = p.x - fz * car.lane;
|
||||||
|
car.z = p.z + fx * car.lane;
|
||||||
|
car.model.group.position.set(car.x, 0, car.z);
|
||||||
|
car.model.group.rotation.y = Math.atan2(-fz, fx);
|
||||||
|
for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
cars,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
adopt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Arcade vehicle physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's
|
||||||
|
// 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 = {
|
||||||
|
maxSpeed: 55.6,
|
||||||
|
maxReverse: 6,
|
||||||
|
accel: 9.5,
|
||||||
|
brake: 14,
|
||||||
|
drag: 1.6,
|
||||||
|
maxSteer: 0.55,
|
||||||
|
wheelBase: 2.7,
|
||||||
|
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 the moving state of a vehicle.
|
||||||
|
* @param {number} x - World x.
|
||||||
|
* @param {number} z - World z.
|
||||||
|
* @param {number} heading - Heading in radians.
|
||||||
|
* @returns {{x: number, z: number, heading: number, speed: number, steer: number}} The state.
|
||||||
|
*/
|
||||||
|
export function createCar(x, z, heading = 0) {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
heading,
|
||||||
|
speed: 0,
|
||||||
|
steer: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies throttle and brakes to the speed for one step.
|
||||||
|
* @param {object} car - Vehicle state, changed in place.
|
||||||
|
* @param {{throttle: number, handbrake: boolean}} input - Driver input.
|
||||||
|
* @param {{dt: number, spec: object}} step - Time step and vehicle spec.
|
||||||
|
*/
|
||||||
|
function applyThrottle(car, { throttle, handbrake }, { dt, spec }) {
|
||||||
|
if (throttle > 0) car.speed += (car.speed < 0 ? spec.brake : spec.accel) * throttle * dt;
|
||||||
|
else if (throttle < 0)
|
||||||
|
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), spec.drag * dt);
|
||||||
|
if (handbrake)
|
||||||
|
car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), spec.brake * 1.4 * dt);
|
||||||
|
car.speed = Math.max(-spec.maxReverse, Math.min(spec.maxSpeed, car.speed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
car.heading -= ((car.speed * Math.tan(steerAngle)) / spec.wheelBase) * dt;
|
||||||
|
car.x += Math.cos(car.heading) * car.speed * dt;
|
||||||
|
car.z -= Math.sin(car.heading) * car.speed * dt;
|
||||||
|
return car;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a vehicle's speed to whole km/h for the HUD.
|
||||||
|
* @param {{speed: number}} car - Vehicle state.
|
||||||
|
* @returns {number} Speed in km/h.
|
||||||
|
*/
|
||||||
|
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Runs the decode buildings operation for this subsystem.
|
||||||
|
* @param {*} buffer - buffer.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
|
||||||
|
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
|
||||||
|
export function decodeBuildings(buffer) {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
|
||||||
|
if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`);
|
||||||
|
const count = view.getUint32(4, true),
|
||||||
|
minX = view.getFloat32(8, true),
|
||||||
|
minZ = view.getFloat32(12, true),
|
||||||
|
step = view.getFloat32(16, true);
|
||||||
|
const buildings = new Array(count);
|
||||||
|
let o = 20;
|
||||||
|
for (let b = 0; b < count; b++) {
|
||||||
|
const n = view.getUint8(o),
|
||||||
|
height = view.getUint8(o + 1) * 0.5;
|
||||||
|
o += 2;
|
||||||
|
const building = new Array(1 + n * 2);
|
||||||
|
building[0] = height;
|
||||||
|
let qx = 0,
|
||||||
|
qz = 0;
|
||||||
|
for (let i = 0; i < n; i++, o += 4) {
|
||||||
|
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
|
||||||
|
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
|
||||||
|
building[1 + i * 2] = minX + qx * step;
|
||||||
|
building[2 + i * 2] = minZ + qz * step;
|
||||||
|
}
|
||||||
|
buildings[b] = building;
|
||||||
|
}
|
||||||
|
return buildings;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
// 2D collision against building footprints, stored as flat [x0, z0, x1, z1, …] rings.
|
||||||
|
const CELL = 24;
|
||||||
|
/**
|
||||||
|
* Runs the key operation for this subsystem.
|
||||||
|
* @param {*} cx - cx.
|
||||||
|
* @param {*} cz - cz.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const key = (cx, cz) => cx * 100003 + cz;
|
||||||
|
/**
|
||||||
|
* Creates collision world resources for the game.
|
||||||
|
* @param {*} polygons - polygons.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createCollisionWorld(polygons) {
|
||||||
|
const world = {
|
||||||
|
polygons: [],
|
||||||
|
grid: new Map(),
|
||||||
|
};
|
||||||
|
polygons.forEach((p) => addPolygon(world, p));
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the add polygon operation for this subsystem.
|
||||||
|
* @param {*} world - world.
|
||||||
|
* @param {*} pts - pts.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function addPolygon(world, pts) {
|
||||||
|
let minX = Infinity,
|
||||||
|
minZ = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxZ = -Infinity;
|
||||||
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
|
minX = Math.min(minX, pts[i]);
|
||||||
|
maxX = Math.max(maxX, pts[i]);
|
||||||
|
minZ = Math.min(minZ, pts[i + 1]);
|
||||||
|
maxZ = Math.max(maxZ, pts[i + 1]);
|
||||||
|
}
|
||||||
|
const index =
|
||||||
|
world.polygons.push({
|
||||||
|
pts,
|
||||||
|
minX,
|
||||||
|
minZ,
|
||||||
|
maxX,
|
||||||
|
maxZ,
|
||||||
|
}) - 1;
|
||||||
|
for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
|
||||||
|
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
|
||||||
|
const k = key(cx, cz);
|
||||||
|
if (!world.grid.has(k)) world.grid.set(k, []);
|
||||||
|
world.grid.get(k).push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the nearby operation for this subsystem.
|
||||||
|
* @param {*} world - world.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @param {*} r - r.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function nearby(world, x, z, r) {
|
||||||
|
const found = new Set();
|
||||||
|
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
|
||||||
|
for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++)
|
||||||
|
for (const i of world.grid.get(key(cx, cz)) ?? []) found.add(i);
|
||||||
|
return [...found]
|
||||||
|
.map((i) => world.polygons[i])
|
||||||
|
.filter((p) => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the inside polygon operation for this subsystem.
|
||||||
|
* @param {*} pts - pts.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function insidePolygon(pts, x, z) {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
|
||||||
|
const xi = pts[i],
|
||||||
|
zi = pts[i + 1],
|
||||||
|
xj = pts[j],
|
||||||
|
zj = pts[j + 1];
|
||||||
|
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the closest on edges operation for this subsystem.
|
||||||
|
* @param {*} pts - pts.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function closestOnEdges(pts, x, z) {
|
||||||
|
let best = {
|
||||||
|
d: Infinity,
|
||||||
|
x: 0,
|
||||||
|
z: 0,
|
||||||
|
};
|
||||||
|
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
|
||||||
|
const ax = pts[j],
|
||||||
|
az = pts[j + 1],
|
||||||
|
dx = pts[i] - ax,
|
||||||
|
dz = pts[i + 1] - az;
|
||||||
|
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
|
||||||
|
const px = ax + dx * t,
|
||||||
|
pz = az + dz * t,
|
||||||
|
d = Math.hypot(x - px, z - pz);
|
||||||
|
if (d < best.d)
|
||||||
|
best = {
|
||||||
|
d,
|
||||||
|
x: px,
|
||||||
|
z: pz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
|
||||||
|
/**
|
||||||
|
* Runs the resolve circle operation for this subsystem.
|
||||||
|
* @param {*} world - world.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @param {*} r - r.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function resolveCircle(world, x, z, r) {
|
||||||
|
let hit = false;
|
||||||
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
|
let moved = false;
|
||||||
|
for (const p of nearby(world, x, z, r)) {
|
||||||
|
const inside = insidePolygon(p.pts, x, z),
|
||||||
|
c = closestOnEdges(p.pts, x, z);
|
||||||
|
if (!inside && c.d >= r) continue;
|
||||||
|
const len = c.d || 1e-6,
|
||||||
|
nx = ((x - c.x) / len) * (inside ? -1 : 1),
|
||||||
|
nz = ((z - c.z) / len) * (inside ? -1 : 1);
|
||||||
|
x = c.x + nx * r;
|
||||||
|
z = c.z + nz * r;
|
||||||
|
moved = hit = true;
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
hit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the blocked operation for this subsystem.
|
||||||
|
* @param {*} world - world.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export const blocked = (world, x, z) =>
|
||||||
|
nearby(world, x, z, 0).some((p) => insidePolygon(p.pts, x, z));
|
||||||
|
|
||||||
|
// Fraction (0–1) of the segment a→b that is free before it enters a footprint.
|
||||||
|
/**
|
||||||
|
* Runs the clear fraction operation for this subsystem.
|
||||||
|
* @param {*} world - world.
|
||||||
|
* @param {*} ax - ax.
|
||||||
|
* @param {*} az - az.
|
||||||
|
* @param {*} bx - bx.
|
||||||
|
* @param {*} bz - bz.
|
||||||
|
* @param {*} step - step.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function clearFraction(world, ax, az, bx, bz, step = 1) {
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { loadTileUrl } from '../../services/worldService.js';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { toLocal } from '../../utils/geo.js';
|
||||||
|
|
||||||
|
// Satellite ground: Esri World Imagery tiles (public/imagery, see scripts/fetch-imagery.mjs) laid on
|
||||||
|
// the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further
|
||||||
|
// out underneath them, and tiles out of range are released.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the tile lon operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const tileLon = (x, z) => (x / 2 ** z) * 360 - 180;
|
||||||
|
/**
|
||||||
|
* Runs the tile lat operation for this subsystem.
|
||||||
|
* @param {*} y - y.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const tileLat = (y, z) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / 2 ** z))) * 180) / Math.PI;
|
||||||
|
|
||||||
|
// World-space corners of a Web Mercator tile: north-west, north-east, south-west, south-east.
|
||||||
|
/**
|
||||||
|
* Runs the tile corners operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} y - y.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function tileCorners(x, y, z) {
|
||||||
|
const n = tileLat(y, z),
|
||||||
|
s = tileLat(y + 1, z),
|
||||||
|
w = tileLon(x, z),
|
||||||
|
e = tileLon(x + 1, z);
|
||||||
|
return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates imagery resources for the game.
|
||||||
|
* @param {*} scene - scene.
|
||||||
|
* @param {*} index - index.
|
||||||
|
* @param {*} options2 - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createImagery(
|
||||||
|
scene,
|
||||||
|
index,
|
||||||
|
{ baseUrl = './imagery/', lowQuality = false, anisotropy = 4 } = {},
|
||||||
|
) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
scene.add(group);
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
|
||||||
|
const levels = [
|
||||||
|
{
|
||||||
|
z: 17,
|
||||||
|
range: lowQuality ? 450 : 750,
|
||||||
|
y: 0.012,
|
||||||
|
offset: -3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
z: 15,
|
||||||
|
range: lowQuality ? 1800 : 2800,
|
||||||
|
y: 0.004,
|
||||||
|
offset: -1,
|
||||||
|
},
|
||||||
|
].map((level) => ({
|
||||||
|
...level,
|
||||||
|
tiles: (index[level.z] ?? []).map((key) => {
|
||||||
|
const [x, y] = key.split('/').map(Number),
|
||||||
|
corners = tileCorners(x, y, level.z);
|
||||||
|
const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
|
||||||
|
cz = corners.reduce((s, c) => s + c.z, 0) / 4;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
corners,
|
||||||
|
cx,
|
||||||
|
cz,
|
||||||
|
mesh: null,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
let loads = 0;
|
||||||
|
let disposed = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
/**
|
||||||
|
* Runs the show operation for this subsystem.
|
||||||
|
* @param {*} level - level.
|
||||||
|
* @param {*} tile - tile.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
async function show(level, tile) {
|
||||||
|
if (tile.mesh || tile.loading || loads >= 6) return;
|
||||||
|
tile.loading = true;
|
||||||
|
loads++;
|
||||||
|
let tileUrl;
|
||||||
|
try {
|
||||||
|
tileUrl = await loadTileUrl(
|
||||||
|
`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
loads--;
|
||||||
|
tile.loading = false;
|
||||||
|
tile.failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loader.load(
|
||||||
|
tileUrl,
|
||||||
|
(texture) => {
|
||||||
|
URL.revokeObjectURL(tileUrl);
|
||||||
|
loads--;
|
||||||
|
tile.loading = false;
|
||||||
|
if (disposed) {
|
||||||
|
texture.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
// Sharp at the low angles a driver sees the road from.
|
||||||
|
texture.anisotropy = anisotropy;
|
||||||
|
const [nw, ne, sw, se] = tile.corners,
|
||||||
|
geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute(
|
||||||
|
'position',
|
||||||
|
new THREE.Float32BufferAttribute(
|
||||||
|
[nw.x, level.y, nw.z, ne.x, level.y, ne.z, sw.x, level.y, sw.z, se.x, level.y, se.z],
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
|
||||||
|
geometry.setIndex([0, 2, 1, 1, 2, 3]);
|
||||||
|
tile.mesh = new THREE.Mesh(
|
||||||
|
geometry,
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
toneMapped: false,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: level.offset,
|
||||||
|
polygonOffsetUnits: level.offset * 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
group.add(tile.mesh);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => {
|
||||||
|
URL.revokeObjectURL(tileUrl);
|
||||||
|
loads--;
|
||||||
|
tile.loading = false;
|
||||||
|
tile.failed = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the hide operation for this subsystem.
|
||||||
|
* @param {*} tile - tile.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function hide(tile) {
|
||||||
|
if (!tile.mesh) return;
|
||||||
|
group.remove(tile.mesh);
|
||||||
|
tile.mesh.geometry.dispose();
|
||||||
|
tile.mesh.material.map.dispose();
|
||||||
|
tile.mesh.material.dispose();
|
||||||
|
tile.mesh = null;
|
||||||
|
}
|
||||||
|
let last = null;
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
controller.abort();
|
||||||
|
for (const level of levels) for (const tile of level.tiles) hide(tile);
|
||||||
|
},
|
||||||
|
available: levels.some((level) => level.tiles.length),
|
||||||
|
// Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
|
||||||
|
update(x, z) {
|
||||||
|
const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
|
||||||
|
if (!moved && loads >= 6) return;
|
||||||
|
last = {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
};
|
||||||
|
for (const level of levels) {
|
||||||
|
const wanted = [];
|
||||||
|
for (const tile of level.tiles) {
|
||||||
|
const d = Math.hypot(tile.cx - x, tile.cz - z);
|
||||||
|
if (d > level.range * 1.2) hide(tile);
|
||||||
|
else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
|
||||||
|
}
|
||||||
|
wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
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
|
||||||
|
// 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 TILE_PX = 512,
|
||||||
|
TILE_M = TILE_PX / SCALE,
|
||||||
|
MAX_TILES = 48;
|
||||||
|
const OVERVIEW_SCALE = 0.07;
|
||||||
|
const AREA = {
|
||||||
|
campus: '#28322c',
|
||||||
|
park: '#2c4a32',
|
||||||
|
grass: '#30503a',
|
||||||
|
forest: '#27432e',
|
||||||
|
pitch: '#33603c',
|
||||||
|
water: '#2b5b6e',
|
||||||
|
industrial: '#262c29',
|
||||||
|
farmland: '#2a3326',
|
||||||
|
};
|
||||||
|
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
|
||||||
|
/**
|
||||||
|
* Runs the road color operation for this subsystem.
|
||||||
|
* @param {*} type - type.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const roadColor = (type) => (type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63');
|
||||||
|
/**
|
||||||
|
* Runs the tile key operation for this subsystem.
|
||||||
|
* @param {*} tx - tx.
|
||||||
|
* @param {*} tz - tz.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const tileKey = (tx, tz) => tx * 100003 + tz;
|
||||||
|
/**
|
||||||
|
* Runs the path operation for this subsystem.
|
||||||
|
* @param {*} c - c.
|
||||||
|
* @param {*} pts - pts.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function path(c, pts) {
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(pts[0], pts[1]);
|
||||||
|
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
|
||||||
|
/**
|
||||||
|
* Runs the draw features operation for this subsystem.
|
||||||
|
* @param {*} c - c.
|
||||||
|
* @param {*} options1 - Operation options.
|
||||||
|
* @param {*} data - data.
|
||||||
|
* @param {*} minWidth - min Width.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
|
||||||
|
for (const area of areas) {
|
||||||
|
c.fillStyle = AREA[data.areaKinds[area[0]]];
|
||||||
|
path(c, area.slice(1));
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
c.lineCap = c.lineJoin = 'round';
|
||||||
|
for (const road of roads) {
|
||||||
|
c.strokeStyle = roadColor(road[0]);
|
||||||
|
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
|
||||||
|
path(c, road.slice(2));
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
c.fillStyle = '#3c4943';
|
||||||
|
for (const b of buildings) {
|
||||||
|
path(c, b.slice(1));
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates map assets resources for the game.
|
||||||
|
* @param {*} data - data.
|
||||||
|
* @param {*} extraLabels - extra Labels.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function createMapAssets(data, extraLabels = []) {
|
||||||
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
|
// Bin every feature into the tiles its bounding box touches.
|
||||||
|
const bins = new Map();
|
||||||
|
/**
|
||||||
|
* Runs the bin operation for this subsystem.
|
||||||
|
* @param {*} list - list.
|
||||||
|
* @param {*} feature - feature.
|
||||||
|
* @param {*} first - first.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const bin = (list, feature, first) => {
|
||||||
|
let x0 = Infinity,
|
||||||
|
z0 = Infinity,
|
||||||
|
x1 = -Infinity,
|
||||||
|
z1 = -Infinity;
|
||||||
|
for (let i = first; i < feature.length; i += 2) {
|
||||||
|
x0 = Math.min(x0, feature[i]);
|
||||||
|
x1 = Math.max(x1, feature[i]);
|
||||||
|
z0 = Math.min(z0, feature[i + 1]);
|
||||||
|
z1 = Math.max(z1, feature[i + 1]);
|
||||||
|
}
|
||||||
|
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
|
||||||
|
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
|
||||||
|
const k = tileKey(tx, tz);
|
||||||
|
if (!bins.has(k))
|
||||||
|
bins.set(k, {
|
||||||
|
areas: [],
|
||||||
|
roads: [],
|
||||||
|
buildings: [],
|
||||||
|
});
|
||||||
|
bins.get(k)[list].push(feature);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
data.areas.forEach((a) => bin('areas', a, 1));
|
||||||
|
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
|
||||||
|
data.buildings.forEach((b) => bin('buildings', b, 1));
|
||||||
|
const cache = new Map();
|
||||||
|
/**
|
||||||
|
* Runs the tile operation for this subsystem.
|
||||||
|
* @param {*} tx - tx.
|
||||||
|
* @param {*} tz - tz.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function tile(tx, tz) {
|
||||||
|
const k = tileKey(tx, tz);
|
||||||
|
if (cache.has(k)) {
|
||||||
|
const canvas = cache.get(k);
|
||||||
|
cache.delete(k);
|
||||||
|
cache.set(k, canvas);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = TILE_PX;
|
||||||
|
const c = canvas.getContext('2d');
|
||||||
|
c.fillStyle = '#1b231f';
|
||||||
|
c.fillRect(0, 0, TILE_PX, TILE_PX);
|
||||||
|
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
|
||||||
|
drawFeatures(
|
||||||
|
c,
|
||||||
|
bins.get(k) ?? {
|
||||||
|
areas: [],
|
||||||
|
roads: [],
|
||||||
|
buildings: [],
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
1.6,
|
||||||
|
);
|
||||||
|
cache.set(k, canvas);
|
||||||
|
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
const overview = document.createElement('canvas');
|
||||||
|
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
|
||||||
|
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
|
||||||
|
const o = overview.getContext('2d');
|
||||||
|
o.fillStyle = '#1b231f';
|
||||||
|
o.fillRect(0, 0, overview.width, overview.height);
|
||||||
|
o.setTransform(
|
||||||
|
OVERVIEW_SCALE,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
OVERVIEW_SCALE,
|
||||||
|
-minX * OVERVIEW_SCALE,
|
||||||
|
-minZ * OVERVIEW_SCALE,
|
||||||
|
);
|
||||||
|
drawFeatures(
|
||||||
|
o,
|
||||||
|
{
|
||||||
|
areas: data.areas,
|
||||||
|
roads: data.roads.filter((r) => r[0] <= 5).sort((a, b) => b[0] - a[0]),
|
||||||
|
buildings: [],
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
14,
|
||||||
|
);
|
||||||
|
|
||||||
|
// One label per named boulevard, at the middle of its longest way.
|
||||||
|
const streets = new Map();
|
||||||
|
for (const road of data.roads) {
|
||||||
|
if (road[1] < 0 || road[0] > 3) continue;
|
||||||
|
let len = 0;
|
||||||
|
for (let i = 4; i < road.length; i += 2)
|
||||||
|
len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
|
||||||
|
const name = data.names[road[1]];
|
||||||
|
if ((streets.get(name)?.len ?? 0) < len) {
|
||||||
|
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
|
||||||
|
const angle = Math.atan2(
|
||||||
|
road[Math.min(mid + 3, road.length - 1)] - road[mid - 1],
|
||||||
|
road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
|
||||||
|
);
|
||||||
|
streets.set(name, {
|
||||||
|
name,
|
||||||
|
len,
|
||||||
|
x: road[mid],
|
||||||
|
z: road[mid + 1],
|
||||||
|
angle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tile,
|
||||||
|
overview,
|
||||||
|
bounds: data.bounds,
|
||||||
|
labels: [...data.labels, ...extraLabels],
|
||||||
|
streets: [...streets.values()].filter((s) => s.len > 150),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
|
||||||
|
// metres; pxPerMetre decides between detailed tiles and the overview.
|
||||||
|
/**
|
||||||
|
* Runs the paint world operation for this subsystem.
|
||||||
|
* @param {*} c - c.
|
||||||
|
* @param {*} assets - assets.
|
||||||
|
* @param {*} x0 - x0.
|
||||||
|
* @param {*} z0 - z0.
|
||||||
|
* @param {*} x1 - x1.
|
||||||
|
* @param {*} z1 - z1.
|
||||||
|
* @param {*} pxPerMetre - px Per Metre.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
|
||||||
|
const { minX, minZ } = assets.bounds;
|
||||||
|
c.fillStyle = '#141b18';
|
||||||
|
c.fillRect(x0, z0, x1 - x0, z1 - z0);
|
||||||
|
if (pxPerMetre < 0.22) {
|
||||||
|
c.drawImage(
|
||||||
|
assets.overview,
|
||||||
|
minX,
|
||||||
|
minZ,
|
||||||
|
assets.overview.width / OVERVIEW_SCALE,
|
||||||
|
assets.overview.height / OVERVIEW_SCALE,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
|
||||||
|
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
|
||||||
|
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + 0.5, TILE_M + 0.5);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the rotate point operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @param {*} cx - cx.
|
||||||
|
* @param {*} cz - cz.
|
||||||
|
* @param {*} angle - angle.
|
||||||
|
* @param {*} k - k.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function rotatePoint(x, z, cx, cz, angle, k) {
|
||||||
|
const dx = (x - cx) * k,
|
||||||
|
dz = (z - cz) * k,
|
||||||
|
cos = Math.cos(angle),
|
||||||
|
sin = Math.sin(angle);
|
||||||
|
return [dx * cos - dz * sin, dx * sin + dz * cos];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the machine icon operation for this subsystem.
|
||||||
|
* @param {*} c - c.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} y - y.
|
||||||
|
* @param {*} options3 - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function machineIcon(c, x, y, { objective, visited, open }) {
|
||||||
|
c.save();
|
||||||
|
c.translate(x, y);
|
||||||
|
if (objective) {
|
||||||
|
c.rotate(Math.PI / 4);
|
||||||
|
c.fillStyle = '#f2c94c';
|
||||||
|
c.strokeStyle = '#1b1406';
|
||||||
|
c.lineWidth = 2;
|
||||||
|
c.fillRect(-7, -7, 14, 14);
|
||||||
|
c.strokeRect(-7, -7, 14, 14);
|
||||||
|
} else {
|
||||||
|
c.fillStyle = open ? (visited ? '#7fb8ad' : BRAND.primary) : '#7b817f';
|
||||||
|
c.strokeStyle = '#08201c';
|
||||||
|
c.lineWidth = 2;
|
||||||
|
c.beginPath();
|
||||||
|
c.roundRect(-6, -8, 12, 16, 3);
|
||||||
|
c.fill();
|
||||||
|
c.stroke();
|
||||||
|
c.fillStyle = '#08201c';
|
||||||
|
c.fillRect(-3, -5, 6, 6);
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
|
if (visited) {
|
||||||
|
c.fillStyle = '#fff';
|
||||||
|
c.font = 'bold 11px sans-serif';
|
||||||
|
c.fillText('✓', x + 8, y - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circular minimap rotated so the camera's forward direction points up.
|
||||||
|
/**
|
||||||
|
* Runs the draw minimap operation for this subsystem.
|
||||||
|
* @param {*} ctx - ctx.
|
||||||
|
* @param {*} assets - assets.
|
||||||
|
* @param {*} view - view.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function drawMinimap(ctx, assets, view) {
|
||||||
|
const { size, x, z, heading, camForward, radius, markers, route } = view;
|
||||||
|
const r = size / 2,
|
||||||
|
k = r / radius,
|
||||||
|
angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.fillStyle = '#141b18';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.translate(r, r);
|
||||||
|
ctx.save();
|
||||||
|
ctx.rotate(angle);
|
||||||
|
ctx.scale(k, k);
|
||||||
|
ctx.translate(-x, -z);
|
||||||
|
paintWorld(
|
||||||
|
ctx,
|
||||||
|
assets,
|
||||||
|
x - radius * 1.5,
|
||||||
|
z - radius * 1.5,
|
||||||
|
x + radius * 1.5,
|
||||||
|
z + radius * 1.5,
|
||||||
|
k,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
if (route?.length > 1) {
|
||||||
|
ctx.strokeStyle = '#f2c94c';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.setLineDash([6, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
route.forEach((p, i) => {
|
||||||
|
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
|
||||||
|
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
for (const m of markers) {
|
||||||
|
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
|
||||||
|
const d = Math.hypot(px, py);
|
||||||
|
if (d > r - 12) {
|
||||||
|
if (!m.objective) continue;
|
||||||
|
px *= (r - 12) / d;
|
||||||
|
py *= (r - 12) / d;
|
||||||
|
}
|
||||||
|
machineIcon(ctx, px, py, m);
|
||||||
|
}
|
||||||
|
// Player arrow, then north marker on the rim.
|
||||||
|
ctx.save();
|
||||||
|
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.strokeStyle = '#0b100e';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -10);
|
||||||
|
ctx.lineTo(7, 8);
|
||||||
|
ctx.lineTo(0, 4);
|
||||||
|
ctx.lineTo(-7, 8);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
|
||||||
|
const nl = Math.hypot(nx, ny) || 1;
|
||||||
|
ctx.fillStyle = '#f2c94c';
|
||||||
|
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(strings.hud.minimapNorth, (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
||||||
|
ctx.restore();
|
||||||
|
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
|
||||||
|
/**
|
||||||
|
* Runs the draw full map operation for this subsystem.
|
||||||
|
* @param {*} ctx - ctx.
|
||||||
|
* @param {*} assets - assets.
|
||||||
|
* @param {*} view - view.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function drawFullMap(ctx, assets, view, state) {
|
||||||
|
const { width, height } = ctx.canvas,
|
||||||
|
dpr = state.dpr,
|
||||||
|
w = width / dpr,
|
||||||
|
h = height / dpr;
|
||||||
|
/**
|
||||||
|
* Runs the to screen operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.fillStyle = '#101614';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(w / 2, h / 2);
|
||||||
|
ctx.scale(1 / view.mpp, 1 / view.mpp);
|
||||||
|
ctx.translate(-view.cx, -view.cz);
|
||||||
|
paintWorld(
|
||||||
|
ctx,
|
||||||
|
assets,
|
||||||
|
view.cx - (w / 2) * view.mpp,
|
||||||
|
view.cz - (h / 2) * view.mpp,
|
||||||
|
view.cx + (w / 2) * view.mpp,
|
||||||
|
view.cz + (h / 2) * view.mpp,
|
||||||
|
1 / view.mpp,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
// Grid lines every 500 m, like a tactical map.
|
||||||
|
ctx.strokeStyle = 'rgba(160,200,185,.08)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
|
||||||
|
const [sx] = toScreen(gx, 0);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, 0);
|
||||||
|
ctx.lineTo(sx, h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
|
||||||
|
const [, sy] = toScreen(0, gz);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, sy);
|
||||||
|
ctx.lineTo(w, sy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
if (view.mpp < 3) {
|
||||||
|
ctx.font = '11px Vazirmatn, sans-serif';
|
||||||
|
ctx.fillStyle = 'rgba(235,240,232,.75)';
|
||||||
|
for (const s of assets.streets) {
|
||||||
|
const [sx, sy] = toScreen(s.x, s.z);
|
||||||
|
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(sx, sy);
|
||||||
|
let a = s.angle;
|
||||||
|
if (a > Math.PI / 2) a -= Math.PI;
|
||||||
|
if (a < -Math.PI / 2) a += Math.PI;
|
||||||
|
ctx.rotate(a);
|
||||||
|
ctx.fillText(s.name, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const l of assets.labels) {
|
||||||
|
if (l.kind === 'school' && view.mpp > 1.6) continue;
|
||||||
|
const [sx, sy] = toScreen(l.x, l.z);
|
||||||
|
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
|
||||||
|
if (l.kind === 'industrial' && view.mpp > 6) continue;
|
||||||
|
ctx.fillStyle = {
|
||||||
|
park: '#9fd49a',
|
||||||
|
university: '#c9b6f2',
|
||||||
|
school: '#e8c79a',
|
||||||
|
metro: '#ff8a80',
|
||||||
|
mall: '#f5b8d9',
|
||||||
|
industrial: '#a9b2ad',
|
||||||
|
factory: BRAND.primary,
|
||||||
|
}[l.kind];
|
||||||
|
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
||||||
|
ctx.fillText(
|
||||||
|
l.kind === 'metro' ? `Ⓜ ${l.name.replace(strings.hud.metroPrefix, '')}` : l.name,
|
||||||
|
sx,
|
||||||
|
sy + 14,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.route?.length > 1) {
|
||||||
|
ctx.strokeStyle = '#f2c94c';
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.setLineDash([8, 5]);
|
||||||
|
ctx.beginPath();
|
||||||
|
state.route.forEach((p, i) => {
|
||||||
|
const [sx, sy] = toScreen(p.x, p.z);
|
||||||
|
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
for (const m of state.markers) {
|
||||||
|
const [sx, sy] = toScreen(m.x, m.z);
|
||||||
|
if (m.id === state.selected) {
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
machineIcon(ctx, sx, sy, m);
|
||||||
|
}
|
||||||
|
const [px, py] = toScreen(state.player.x, state.player.z);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(px, py);
|
||||||
|
ctx.rotate(
|
||||||
|
Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
|
||||||
|
);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -12);
|
||||||
|
ctx.lineTo(8, 9);
|
||||||
|
ctx.lineTo(0, 5);
|
||||||
|
ctx.lineTo(-8, 9);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
return toScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
|
||||||
|
/**
|
||||||
|
* Runs the draw iran operation for this subsystem.
|
||||||
|
* @param {*} ctx - ctx.
|
||||||
|
* @param {*} iran - iran.
|
||||||
|
* @param {*} options2 - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function drawIran(
|
||||||
|
ctx,
|
||||||
|
iran,
|
||||||
|
{ width, height, t = 0, highlight = 'Razavi Khorasan', cities = [] },
|
||||||
|
) {
|
||||||
|
const lon0 = 43.8,
|
||||||
|
lon1 = 63.6,
|
||||||
|
lat0 = 24.8,
|
||||||
|
lat1 = 40;
|
||||||
|
const kx = 0.84,
|
||||||
|
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
|
||||||
|
const offX = (width - (lon1 - lon0) * kx * k) / 2,
|
||||||
|
offY = (height - (lat1 - lat0) * k) / 2;
|
||||||
|
/**
|
||||||
|
* Runs the p operation for this subsystem.
|
||||||
|
* @param {*} lon - lon.
|
||||||
|
* @param {*} lat - lat.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
for (const province of iran.provinces) {
|
||||||
|
const active = province.id === highlight;
|
||||||
|
ctx.fillStyle = active
|
||||||
|
? `rgba(0,166,147,${0.35 + Math.sin(t * 3) * 0.12})`
|
||||||
|
: 'rgba(40,56,50,.85)';
|
||||||
|
ctx.strokeStyle = active ? BRAND.primary : 'rgba(150,190,175,.35)';
|
||||||
|
ctx.lineWidth = active ? 2 : 1;
|
||||||
|
for (const ring of province.rings) {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < ring.length; i += 2) {
|
||||||
|
const [x, y] = p(ring[i], ring[i + 1]);
|
||||||
|
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
for (const city of cities) {
|
||||||
|
const [x, y] = p(city.lon, city.lat);
|
||||||
|
if (city.active) {
|
||||||
|
ctx.strokeStyle = '#f2c94c';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
|
||||||
|
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
|
||||||
|
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// Road graph, A* routing and nearest-street lookup over the OSM road polylines.
|
||||||
|
// A road is [typeIndex, nameIndex, x0, z0, x1, z1, …]; ways that share an OSM node share coordinates.
|
||||||
|
export const DRIVABLE_MAX_TYPE = 7; // trunk … service in data.roadTypes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the build graph operation for this subsystem.
|
||||||
|
* @param {*} roads - roads.
|
||||||
|
* @param {*} options1 - Operation options.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function buildGraph(roads, { drivable = false } = {}) {
|
||||||
|
const ids = new Map(),
|
||||||
|
xs = [],
|
||||||
|
zs = [],
|
||||||
|
adj = [];
|
||||||
|
/**
|
||||||
|
* Runs the node operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const node = (x, z) => {
|
||||||
|
const k = `${x},${z}`;
|
||||||
|
if (!ids.has(k)) {
|
||||||
|
ids.set(k, xs.length);
|
||||||
|
xs.push(x);
|
||||||
|
zs.push(z);
|
||||||
|
adj.push([]);
|
||||||
|
}
|
||||||
|
return ids.get(k);
|
||||||
|
};
|
||||||
|
for (const road of roads) {
|
||||||
|
if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
|
||||||
|
let prev = node(road[2], road[3]);
|
||||||
|
for (let i = 4; i < road.length; i += 2) {
|
||||||
|
const next = node(road[i], road[i + 1]),
|
||||||
|
cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
|
||||||
|
if (next !== prev) {
|
||||||
|
adj[prev].push(next, cost);
|
||||||
|
adj[next].push(prev, cost);
|
||||||
|
}
|
||||||
|
prev = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graph = {
|
||||||
|
xs,
|
||||||
|
zs,
|
||||||
|
adj,
|
||||||
|
main: largestComponent(adj),
|
||||||
|
grid: new Map(),
|
||||||
|
};
|
||||||
|
graph.main.forEach((inMain, i) => {
|
||||||
|
if (!inMain) return;
|
||||||
|
const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
|
||||||
|
if (!graph.grid.has(k)) graph.grid.set(k, []);
|
||||||
|
graph.grid.get(k).push(i);
|
||||||
|
});
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the cell key operation for this subsystem.
|
||||||
|
* @param {*} cx - cx.
|
||||||
|
* @param {*} cz - cz.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const cellKey = (cx, cz) => cx * 100003 + cz;
|
||||||
|
/**
|
||||||
|
* Runs the largest component operation for this subsystem.
|
||||||
|
* @param {*} adj - adj.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
function largestComponent(adj) {
|
||||||
|
const label = new Int32Array(adj.length).fill(-1),
|
||||||
|
sizes = [];
|
||||||
|
for (let s = 0; s < adj.length; s++) {
|
||||||
|
if (label[s] >= 0) continue;
|
||||||
|
const id = sizes.length,
|
||||||
|
stack = [s];
|
||||||
|
let size = 0;
|
||||||
|
label[s] = id;
|
||||||
|
while (stack.length) {
|
||||||
|
const n = stack.pop();
|
||||||
|
size++;
|
||||||
|
for (let i = 0; i < adj[n].length; i += 2)
|
||||||
|
if (label[adj[n][i]] < 0) {
|
||||||
|
label[adj[n][i]] = id;
|
||||||
|
stack.push(adj[n][i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizes.push(size);
|
||||||
|
}
|
||||||
|
const best = sizes.indexOf(Math.max(...sizes));
|
||||||
|
return Array.from(label, (l) => l === best);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest node of the main connected component, searching outward ring by ring.
|
||||||
|
/**
|
||||||
|
* Runs the nearest node operation for this subsystem.
|
||||||
|
* @param {*} graph - graph.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function nearestNode(graph, x, z) {
|
||||||
|
const cx = Math.floor(x / 50),
|
||||||
|
cz = Math.floor(z / 50);
|
||||||
|
let best = -1,
|
||||||
|
bestD = Infinity;
|
||||||
|
for (let ring = 0; ring < 40; ring++) {
|
||||||
|
for (let i = cx - ring; i <= cx + ring; i++)
|
||||||
|
for (let j = cz - ring; j <= cz + ring; j++) {
|
||||||
|
if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
|
||||||
|
for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
|
||||||
|
const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best >= 0 && bestD < ring * 50) break;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the find path operation for this subsystem.
|
||||||
|
* @param {*} graph - graph.
|
||||||
|
* @param {*} start - start.
|
||||||
|
* @param {*} goal - goal.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function findPath(graph, start, goal) {
|
||||||
|
if (start < 0 || goal < 0) return null;
|
||||||
|
const { xs, zs, adj } = graph,
|
||||||
|
g = new Map([[start, 0]]),
|
||||||
|
from = new Map(),
|
||||||
|
closed = new Set();
|
||||||
|
/**
|
||||||
|
* Runs the h operation for this subsystem.
|
||||||
|
* @param {*} n - n.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const h = (n) => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
|
||||||
|
const heap = [[h(start), start]];
|
||||||
|
/**
|
||||||
|
* Runs the push operation for this subsystem.
|
||||||
|
* @param {*} item - item.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const push = (item) => {
|
||||||
|
heap.push(item);
|
||||||
|
for (let i = heap.length - 1; i > 0;) {
|
||||||
|
const p = (i - 1) >> 1;
|
||||||
|
if (heap[p][0] <= heap[i][0]) break;
|
||||||
|
[heap[p], heap[i]] = [heap[i], heap[p]];
|
||||||
|
i = p;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Runs the pop operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
const pop = () => {
|
||||||
|
const top = heap[0],
|
||||||
|
last = heap.pop();
|
||||||
|
if (heap.length) {
|
||||||
|
heap[0] = last;
|
||||||
|
for (let i = 0; ;) {
|
||||||
|
const l = i * 2 + 1,
|
||||||
|
r = l + 1;
|
||||||
|
let m = i;
|
||||||
|
if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
|
||||||
|
if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
|
||||||
|
if (m === i) break;
|
||||||
|
[heap[m], heap[i]] = [heap[i], heap[m]];
|
||||||
|
i = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
};
|
||||||
|
while (heap.length) {
|
||||||
|
const [, n] = pop();
|
||||||
|
if (n === goal) {
|
||||||
|
const path = [n];
|
||||||
|
while (from.has(path[0])) path.unshift(from.get(path[0]));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (closed.has(n)) continue;
|
||||||
|
closed.add(n);
|
||||||
|
for (let i = 0; i < adj[n].length; i += 2) {
|
||||||
|
const m = adj[n][i],
|
||||||
|
cost = g.get(n) + adj[n][i + 1];
|
||||||
|
if (cost < (g.get(m) ?? Infinity)) {
|
||||||
|
g.set(m, cost);
|
||||||
|
from.set(m, n);
|
||||||
|
push([cost + h(m), m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the route between operation for this subsystem.
|
||||||
|
* @param {*} graph - graph.
|
||||||
|
* @param {*} ax - ax.
|
||||||
|
* @param {*} az - az.
|
||||||
|
* @param {*} bx - bx.
|
||||||
|
* @param {*} bz - bz.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function routeBetween(graph, ax, az, bx, bz) {
|
||||||
|
const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
|
||||||
|
if (!path) return null;
|
||||||
|
const points = [
|
||||||
|
{
|
||||||
|
x: ax,
|
||||||
|
z: az,
|
||||||
|
},
|
||||||
|
...path.map((n) => ({
|
||||||
|
x: graph.xs[n],
|
||||||
|
z: graph.zs[n],
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
x: bx,
|
||||||
|
z: bz,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
length: polylineLength(points),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the polyline length operation for this subsystem.
|
||||||
|
* @param {*} points - points.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function polylineLength(points) {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 1; i < points.length; i++)
|
||||||
|
total += Math.hypot(points[i].x - points[i - 1].x, points[i].z - points[i - 1].z);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spatial index of road segments, used for street names and keeping trees off paths.
|
||||||
|
/**
|
||||||
|
* Runs the build segment index operation for this subsystem.
|
||||||
|
* @param {*} roads - roads.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function buildSegmentIndex(roads) {
|
||||||
|
const grid = new Map();
|
||||||
|
for (const road of roads)
|
||||||
|
for (let i = 4; i < road.length; i += 2) {
|
||||||
|
const seg = [road[i - 2], road[i - 1], road[i], road[i + 1], road[0], road[1]];
|
||||||
|
for (
|
||||||
|
let cx = Math.floor(Math.min(seg[0], seg[2]) / 40);
|
||||||
|
cx <= Math.floor(Math.max(seg[0], seg[2]) / 40);
|
||||||
|
cx++
|
||||||
|
)
|
||||||
|
for (
|
||||||
|
let cz = Math.floor(Math.min(seg[1], seg[3]) / 40);
|
||||||
|
cz <= Math.floor(Math.max(seg[1], seg[3]) / 40);
|
||||||
|
cz++
|
||||||
|
) {
|
||||||
|
const k = cellKey(cx, cz);
|
||||||
|
if (!grid.has(k)) grid.set(k, []);
|
||||||
|
grid.get(k).push(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Runs the nearest segment operation for this subsystem.
|
||||||
|
* @param {*} index - index.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @param {*} maxDistance - max Distance.
|
||||||
|
* @param {*} filter - filter.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
|
||||||
|
let best = null,
|
||||||
|
bestD = maxDistance;
|
||||||
|
for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++)
|
||||||
|
for (
|
||||||
|
let cz = Math.floor((z - maxDistance) / 40);
|
||||||
|
cz <= Math.floor((z + maxDistance) / 40);
|
||||||
|
cz++
|
||||||
|
)
|
||||||
|
for (const s of index.get(cellKey(cx, cz)) ?? []) {
|
||||||
|
if (!filter(s)) continue;
|
||||||
|
const dx = s[2] - s[0],
|
||||||
|
dz = s[3] - s[1];
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1)),
|
||||||
|
);
|
||||||
|
const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = {
|
||||||
|
type: s[4],
|
||||||
|
name: s[5],
|
||||||
|
distance: d,
|
||||||
|
x: s[0] + dx * t,
|
||||||
|
z: s[1] + dz * t,
|
||||||
|
dx,
|
||||||
|
dz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
// Local tangent-plane projection around the playable Park Mellat area.
|
|
||||||
// World axes: +x east, -z north, 1 unit = 1 metre.
|
|
||||||
export const ORIGIN = {lat: 36.315, lon: 59.539};
|
|
||||||
const METRES_PER_DEG_LAT = 110574;
|
|
||||||
const METRES_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * Math.PI / 180);
|
|
||||||
|
|
||||||
export function toLocal(lat, lon) {
|
|
||||||
return {x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toLatLon(x, z) {
|
|
||||||
return {lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compass bearing in degrees (0 = north, 90 = east) of a world-space direction.
|
|
||||||
export function bearing(dx, dz) {
|
|
||||||
return (Math.atan2(dx, -dz) * 180 / Math.PI + 360) % 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDistance(metres) {
|
|
||||||
return metres < 1000 ? `${Math.round(metres).toLocaleString('fa-IR')} متر` : `${(Math.round(metres / 100) / 10).toLocaleString('fa-IR')} کیلومتر`;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/** Mounts one game runtime and releases its GPU, input, sensor and timer resources.
|
||||||
|
* @param {object} root - React ref containing all render surfaces.
|
||||||
|
* @param {object} bridge - Interface publisher owned by this page.
|
||||||
|
* @param {string} [startMode] - Mode to start in directly, skipping the start screen.
|
||||||
|
* @returns {{error:string|null}} Startup failure for the accessible loading surface.
|
||||||
|
*/
|
||||||
|
export function useGameEngine(root, bridge, startMode) {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const disposeRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
/**
|
||||||
|
* Runs the start operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
const { createGame } = await import('@/game/createGame.js');
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const dispose = await createGame({
|
||||||
|
root: root.current,
|
||||||
|
ui: bridge,
|
||||||
|
signal: controller.signal,
|
||||||
|
startMode,
|
||||||
|
});
|
||||||
|
if (controller.signal.aborted) dispose();
|
||||||
|
else disposeRef.current = dispose;
|
||||||
|
} catch (problem) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(problem.message);
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
// Abort-scoped input and network work must stop before disposing graphics.
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
disposeRef.current?.();
|
||||||
|
disposeRef.current = null;
|
||||||
|
};
|
||||||
|
}, [root, bridge, startMode]);
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user