From 55d4123fe594a96d55d486525a9bb7d75549c5cb Mon Sep 17 00:00:00 2001 From: "hamed@B" Date: Wed, 28 Oct 2026 12:53:07 +0330 Subject: [PATCH] feat: add factory sample machines and robot pricing conversations --- eslint.config.js | 27 +- index.html | 35 +- package.json | 4 +- postcss.config.js | 2 +- scripts/build-buildings.mjs | 214 ++- scripts/build-map-data.mjs | 389 ++-- scripts/fetch-imagery.mjs | 111 +- src/constants/locations.js | 116 +- src/constants/showroomCatalog.js | 47 + src/game/createGame.js | 2319 +----------------------- src/game/device/ar.js | 56 +- src/game/device/pwa.js | 44 +- src/game/device/realworld.js | 75 +- src/game/economy/gameState.js | 132 +- src/game/economy/payment.js | 50 +- src/game/economy/scoring.js | 92 +- src/game/interfaceBridge.js | 46 +- src/game/models/carModels.js | 191 +- src/game/models/characters.js | 928 ++++++---- src/game/models/factory.js | 1859 +++++++++++++------ src/game/models/showroom.js | 102 ++ src/game/models/showroom.test.js | 46 + src/game/models/staff.js | 109 +- src/game/models/textures.js | 12 + src/game/models/vending.js | 114 +- src/game/runtime/audioAndDialogue.js | 76 + src/game/runtime/mapInteraction.js | 140 ++ src/game/runtime/movement.js | 239 +++ src/game/runtime/navigationHud.js | 348 ++++ src/game/runtime/purchaseFlow.js | 356 ++++ src/game/runtime/realMode.js | 178 ++ src/game/runtime/renderLoop.js | 217 +++ src/game/runtime/runtimeBindings.js | 166 ++ src/game/runtime/runtimeLifecycle.js | 146 ++ src/game/runtime/runtimeSetup.js | 1196 ++++++++++++ src/game/runtime/worldEncounters.js | 164 ++ src/game/systems/traffic.js | 133 +- src/game/systems/vehicle.js | 30 +- src/game/world/buildingsData.js | 5 + src/game/world/city.js | 742 +++++--- src/game/world/collision.js | 103 +- src/game/world/imagery.js | 71 +- src/game/world/mapRender.js | 139 +- src/game/world/routing.js | 114 +- src/hooks/useGameEngine.js | 14 +- src/main.jsx | 9 +- src/pages/game/GameDialogs.jsx | 2 + src/pages/game/GamePage.jsx | 6 +- src/pages/game/GameToolbar.jsx | 11 +- src/pages/game/LocationStatus.jsx | 1 - src/pages/game/MachineDetails.jsx | 1 - src/pages/game/MapDialog.jsx | 1 - src/pages/game/RankProgress.jsx | 1 - src/pages/game/ShowroomDialog.jsx | 103 ++ src/pages/game/ShowroomDialog.test.jsx | 20 + src/pages/game/ShowroomImage.jsx | 55 + src/pages/game/StatusBar.jsx | 1 - src/pages/game/TouchControls.jsx | 1 - src/routes/AppRoutes.jsx | 14 + src/services/apiClient.test.js | 25 + src/services/imageryDownloadService.js | 14 + src/services/worldService.js | 20 +- src/store/index.js | 7 +- src/store/slices/gameSlice.js | 8 +- src/store/slices/interfaceSlice.js | 12 +- src/tests/canvasStub.js | 22 + src/utils/geo.js | 41 +- src/utils/showroomAnswers.js | 31 + src/utils/showroomAnswers.test.js | 28 + tailwind.config.js | 12 +- vitest.config.js | 14 +- 71 files changed, 8179 insertions(+), 3978 deletions(-) create mode 100644 src/constants/showroomCatalog.js create mode 100644 src/game/models/showroom.js create mode 100644 src/game/models/showroom.test.js create mode 100644 src/game/runtime/audioAndDialogue.js create mode 100644 src/game/runtime/mapInteraction.js create mode 100644 src/game/runtime/movement.js create mode 100644 src/game/runtime/navigationHud.js create mode 100644 src/game/runtime/purchaseFlow.js create mode 100644 src/game/runtime/realMode.js create mode 100644 src/game/runtime/renderLoop.js create mode 100644 src/game/runtime/runtimeBindings.js create mode 100644 src/game/runtime/runtimeLifecycle.js create mode 100644 src/game/runtime/runtimeSetup.js create mode 100644 src/game/runtime/worldEncounters.js create mode 100644 src/pages/game/ShowroomDialog.jsx create mode 100644 src/pages/game/ShowroomDialog.test.jsx create mode 100644 src/pages/game/ShowroomImage.jsx create mode 100644 src/routes/AppRoutes.jsx create mode 100644 src/services/apiClient.test.js create mode 100644 src/services/imageryDownloadService.js create mode 100644 src/tests/canvasStub.js create mode 100644 src/utils/showroomAnswers.js create mode 100644 src/utils/showroomAnswers.test.js diff --git a/eslint.config.js b/eslint.config.js index ac170ae..0da09fb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,8 +2,27 @@ 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'}}, + { 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' }, + }, ]; diff --git a/index.html b/index.html index ebc7ff0..8ce6716 100644 --- a/index.html +++ b/index.html @@ -1,17 +1,24 @@ - - - - + + + + عملیات مشهد | روبات مارکت - - - - - - - - - -
+ + + + + + + + + + +
+ + + diff --git a/package.json b/package.json index a98e3f3..55fea48 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview --host 0.0.0.0", - "format": "prettier --write \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" vite.config.js", - "format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" vite.config.js", + "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", diff --git a/postcss.config.js b/postcss.config.js index e24d46b..99417eb 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1 +1 @@ -export default {plugins:{tailwindcss:{},autoprefixer:{}}}; +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/scripts/build-buildings.mjs b/scripts/build-buildings.mjs index c91e09d..493ed91 100644 --- a/scripts/build-buildings.mjs +++ b/scripts/build-buildings.mjs @@ -14,37 +14,41 @@ // 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 // 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 {createGunzip} from 'node:zlib'; -import {createInterface} from 'node:readline'; -import {toLocal} from '../src/utils/geo.js'; -import {FACTORY} from '../src/constants/locations.js'; -import {createCollisionWorld, blocked} from '../src/game/world/collision.js'; +import { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createGunzip } from 'node:zlib'; +import { createInterface } from 'node:readline'; +import { toLocal } from '../src/utils/geo.js'; +import { FACTORY } from '../src/constants/locations.js'; +import { createCollisionWorld, blocked } from '../src/game/world/collision.js'; const [source] = process.argv.slice(2); if (!source) { - console.error('usage: node scripts/build-buildings.mjs '); - process.exit(1); + console.error('usage: node scripts/build-buildings.mjs '); + 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 {minX, minZ, maxX, maxZ} = data.bounds; -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 industrial = createCollisionWorld(areasOf('industrial')), parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]); +const { minX, minZ, maxX, maxZ } = data.bounds; +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 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. -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 dx = x - FACTORY.center.x, 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; + const dx = x - FACTORY.center.x, + 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; }; -const hash = n => { - n = Math.imul(n ^ (n >>> 16), 0x45d9f3b); - n = Math.imul(n ^ (n >>> 16), 0x45d9f3b); - return ((n ^ (n >>> 16)) >>> 0) / 4294967296; +const hash = (n) => { + n = Math.imul(n ^ (n >>> 16), 0x45d9f3b); + n = Math.imul(n ^ (n >>> 16), 0x45d9f3b); + return ((n ^ (n >>> 16)) >>> 0) / 4294967296; }; // Outside the Payambar-e Azam ring (west of ~59.47°E or north of ~36.40°N) the region is industrial @@ -52,99 +56,119 @@ const hash = n => { const outskirts = (x, z) => x < -6000 || z < -9400; // Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey. -export function estimateHeight(area, {inIndustrial, inPark, inOutskirts}, seed) { - const r = hash(seed), pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1)); - if (inPark) return 3.2; - if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3; - if (inOutskirts) return pick(1, 2) * 3.2; - if (inIndustrial && area >= 150) return 7 + r * 3; - if (area < 40) return 3.2; - if (area < 120) return pick(2, 3) * 3.2; - if (area < 300) return pick(3, 4) * 3.2; - if (area < 800) return pick(4, 6) * 3.2; - return pick(5, 8) * 3.2; +export function estimateHeight(area, { inIndustrial, inPark, inOutskirts }, seed) { + const r = hash(seed), + pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1)); + if (inPark) return 3.2; + if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3; + if (inOutskirts) return pick(1, 2) * 3.2; + if (inIndustrial && area >= 150) return 7 + r * 3; + if (area < 40) return 3.2; + if (area < 120) return pick(2, 3) * 3.2; + if (area < 300) return pick(3, 4) * 3.2; + if (area < 800) return pick(4, 6) * 3.2; + return pick(5, 8) * 3.2; } -const polygonArea = 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 Math.abs(a / 2); +const polygonArea = (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 Math.abs(a / 2); }; const out = []; -let read = 0, outside = 0, duplicate = 0, factory = 0, tiny = 0; -const lines = createInterface({input: createReadStream(source).pipe(createGunzip())}); +let read = 0, + outside = 0, + duplicate = 0, + factory = 0, + tiny = 0; +const lines = createInterface({ input: createReadStream(source).pipe(createGunzip()) }); for await (const line of lines) { - if (!line.trim()) continue; - read++; - const ring = JSON.parse(line).geometry.coordinates[0]; - const pts = []; - for (const [lon, lat] of ring.slice(0, -1)) { - const p = toLocal(lat, lon); - pts.push(p.x, p.z); - } - 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 (cx < minX || cx > maxX || cz < minZ || cz > maxZ) { - outside++; - continue; - } - if (pts.length / 2 > 255 || pts.length < 6) continue; - const area = polygonArea(pts); - if (area < 12) { - tiny++; - continue; - } - if (blocked(osm, cx, cz)) { - duplicate++; - continue; - } - if (inFactory(cx, cz)) { - factory++; - 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)); - out.push({height, pts}); + if (!line.trim()) continue; + read++; + const ring = JSON.parse(line).geometry.coordinates[0]; + const pts = []; + for (const [lon, lat] of ring.slice(0, -1)) { + const p = toLocal(lat, lon); + pts.push(p.x, p.z); + } + 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 (cx < minX || cx > maxX || cz < minZ || cz > maxZ) { + outside++; + continue; + } + if (pts.length / 2 > 255 || pts.length < 6) continue; + const area = polygonArea(pts); + if (area < 12) { + tiny++; + continue; + } + if (blocked(osm, cx, cz)) { + duplicate++; + continue; + } + if (inFactory(cx, cz)) { + factory++; + 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), + ); + out.push({ height, pts }); } let bytes = 20; 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)); 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); // 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(12, originZ, true); view.setFloat32(16, STEP, true); let o = 20; for (const b of out) { - view.setUint8(o++, b.pts.length / 2); - view.setUint8(o++, Math.min(255, Math.round(b.height / .5))); - let px = 0, pz = 0; - 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); - if (i === 0) { - view.setUint16(o, qx, true); - view.setUint16(o + 2, qz, true); - } else { - view.setInt16(o, qx - px, true); - view.setInt16(o + 2, qz - pz, true); - } - px = qx; - pz = qz; - o += 4; + view.setUint8(o++, b.pts.length / 2); + view.setUint8(o++, Math.min(255, Math.round(b.height / 0.5))); + let px = 0, + pz = 0; + 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); + if (i === 0) { + view.setUint16(o, qx, true); + view.setUint16(o + 2, qz, true); + } else { + view.setInt16(o, qx - px, true); + view.setInt16(o + 2, qz - pz, true); } + px = qx; + pz = qz; + 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)); -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`, +); diff --git a/scripts/build-map-data.mjs b/scripts/build-map-data.mjs index a638057..b319f84 100644 --- a/scripts/build-map-data.mjs +++ b/scripts/build-map-data.mjs @@ -14,194 +14,301 @@ // 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; // Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain). -import {readFileSync, writeFileSync} from 'node:fs'; -import {ORIGIN, toLocal} from '../src/utils/geo.js'; +import { readFileSync, writeFileSync } from 'node:fs'; +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++) { - if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2); - else if (key === 'osm') args.osm.push(process.argv[i]); - else args[key] = process.argv[i]; + if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2); + else if (key === 'osm') args.osm.push(process.argv[i]); + else args[key] = process.argv[i]; } if (!args.osm.length || !args.poi || !args.provinces) { - console.error('usage: node scripts/build-map-data.mjs --osm … --poi --provinces '); - process.exit(1); + console.error( + 'usage: node scripts/build-map-data.mjs --osm … --poi --provinces ', + ); + process.exit(1); } // 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. -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(); -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 poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements; const provincesPath = args.provinces; -const r1 = v => Math.round(v * 10) / 10; -const flat = geometry => geometry.flatMap(({lat, lon}) => { - const {x, z} = toLocal(lat, lon); +const r1 = (v) => Math.round(v * 10) / 10; +const flat = (geometry) => + geometry.flatMap(({ lat, lon }) => { + const { x, z } = toLocal(lat, lon); return [r1(x), r1(z)]; -}); + }); // Closed OSM rings repeat their first node; the game stores each vertex once. -const ring = geometry => { - const pts = flat(geometry); - if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2; - return pts; +const ring = (geometry) => { + const pts = flat(geometry); + if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2; + return pts; }; -const hash = n => { - n = (n ^ 61) ^ (n >>> 16); - n = Math.imul(n, 9); - n ^= n >>> 4; - n = Math.imul(n, 0x27d4eb2d); - return ((n ^ (n >>> 15)) >>> 0) / 4294967296; +const hash = (n) => { + n = n ^ 61 ^ (n >>> 16); + n = Math.imul(n, 9); + n ^= n >>> 4; + n = Math.imul(n, 0x27d4eb2d); + return ((n ^ (n >>> 15)) >>> 0) / 4294967296; }; -const roadTypes = ['trunk', '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 (!nameIndex.has(name)) { - nameIndex.set(name, names.length); - names.push(name); - } - return nameIndex.get(name); +const roadTypes = [ + 'trunk', + '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 (!nameIndex.has(name)) { + nameIndex.set(name, names.length); + names.push(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 areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park' - : t.landuse === 'grass' ? '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; +const areaKind = (t) => + t.leisure === 'park' || + t.leisure === 'garden' || + t.landuse === 'park' || + t.landuse === 'recreation_ground' + ? 'park' + : t.landuse === 'grass' + ? '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. function joinRings(members) { - const parts = members.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) { - let current = parts.shift(); - for (let grown = true; grown && !same(current[0], current.at(-1));) { - grown = false; - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - if (same(current.at(-1), p[0])) current = current.concat(p.slice(1)); - else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse()); - else continue; - parts.splice(i, 1); - grown = true; - break; - } - } - if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current); + const parts = members + .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) { + let current = parts.shift(); + for (let grown = true; grown && !same(current[0], current.at(-1));) { + grown = false; + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (same(current.at(-1), p[0])) current = current.concat(p.slice(1)); + else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse()); + else continue; + parts.splice(i, 1); + grown = true; + break; + } } - return rings; + if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current); + } + return rings; } for (const e of city) { - const t = e.tags ?? {}; - // Motorways are drawn and routed like trunk roads. - const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk'); - 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 - // (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 geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry; - // 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; - let run = []; - for (const point of [...geometry, null]) { - if (point && inRegion(point)) run.push(point); - else { - if (run.length > 1) { - if (oneway) onewayRoads.push(roads.length); - roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]); - } - run = []; - } + const t = e.tags ?? {}; + // Motorways are drawn and routed like trunk roads. + const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk'); + 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 + // (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 geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry; + // 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; + let run = []; + for (const point of [...geometry, null]) { + if (point && inRegion(point)) run.push(point); + else { + if (run.length > 1) { + if (oneway) onewayRoads.push(roads.length); + roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]); } - } else if (e.type === 'way' && t.building && e.geometry?.length > 3) { - const levels = Number.parseFloat(t['building:levels']), 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)]); - } else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) { - areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]); - } else if (e.type === 'relation' && areaKind(t)) { - 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')) { - 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))}); + run = []; + } } + } else if (e.type === 'way' && t.building && e.geometry?.length > 3) { + const levels = Number.parseFloat(t['building:levels']), + 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)]); + } else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) { + areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]); + } else if (e.type === 'relation' && areaKind(t)) { + 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') + ) { + 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) { - const t = e.tags ?? {}, c = e.center ?? e; - 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))}); + const t = e.tags ?? {}, + c = e.center ?? e; + 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}) { - return {x: r1(x), z: r1(z)}; +function roundPoint({ x, z }) { + return { x: r1(x), z: r1(z) }; } // Areas draw largest first, so small pitches and ponds stay visible on top of parks. -const polygonArea = 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 Math.abs(a / 2); +const polygonArea = (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 Math.abs(a / 2); }; areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1))); // The world spans every road we kept, plus a margin. -let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity; -for (const road of roads) for (let i = 2; i < road.length; i += 2) { +let minX = Infinity, + 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]); maxX = Math.max(maxX, road[i]); minZ = Math.min(minZ, 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_ = { - origin: ORIGIN, - attribution: '© OpenStreetMap contributors', - bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z}, - roadTypes, areaKinds, names, roads, buildings, areas, - // Indices into `roads` of one-way roads, whose points run in the direction of travel. - oneway: onewayRoads, - labels: [...new Map(labels.map(l => [l.name, l])).values()] + origin: ORIGIN, + attribution: '© OpenStreetMap contributors', + bounds: { minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z }, + roadTypes, + areaKinds, + names, + roads, + buildings, + areas, + // Indices into `roads` of one-way roads, whose points run in the direction of travel. + oneway: onewayRoads, + labels: [...new Map(labels.map((l) => [l.name, l])).values()], }; 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. function simplify(points, tolerance) { - if (points.length < 3) return points; - const [ax, ay] = points[0], [bx, by] = points.at(-1); - let index = 0, distance = 0; - 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); - // 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); - if (d > distance) { - index = i; - distance = d; - } + if (points.length < 3) return points; + const [ax, ay] = points[0], + [bx, by] = points.at(-1); + let index = 0, + distance = 0; + 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); + // 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); + if (d > distance) { + index = i; + distance = d; } - 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)]; + } + 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), + ]; } -const provinces = JSON.parse(readFileSync(provincesPath, 'utf8')).features - .filter(f => f.properties.adm0_a3 === 'IRN') - .map(f => { - const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates; - return { - id: f.properties.name, - 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) - }; - }); -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}`); +const provinces = JSON.parse(readFileSync(provincesPath, 'utf8')) + .features.filter((f) => f.properties.adm0_a3 === 'IRN') + .map((f) => { + const polygons = + f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates; + return { + id: f.properties.name, + name: f.properties.name_fa.replace(/^استان\s+/, ''), + 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 })); +console.log( + `roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`, +); diff --git a/scripts/fetch-imagery.mjs b/scripts/fetch-imagery.mjs index 4ab1fc3..10b4b5a 100644 --- a/scripts/fetch-imagery.mjs +++ b/scripts/fetch-imagery.mjs @@ -1,3 +1,4 @@ +import { downloadImagery } from '../src/services/imageryDownloadService.js'; // Downloads Esri World Imagery tiles for the game region into public/imagery///.jpg and // writes public/imagery/index.json listing them. // @@ -9,69 +10,87 @@ // // 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. -import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs'; -import {toLatLon} from '../src/utils/geo.js'; -import {buildGraph, routeBetween} from '../src/game/world/routing.js'; -import {FACTORY, machineById} from '../src/constants/locations.js'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { toLatLon } from '../src/utils/geo.js'; +import { buildGraph, routeBetween } from '../src/game/world/routing.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 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 tileY = (lat, z) => Math.floor((1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * 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); -const tiles = {15: new Set(), 17: new Set()}; +const tiles = { 15: new Set(), 17: new Set() }; const addBox = (z, lat0, lon0, lat1, lon1) => { - 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 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}`); }; -const {minX, minZ, maxX, maxZ} = data.bounds; -const nw = toLatLon(minX, minZ), se = toLatLon(maxX, maxZ); +const { minX, minZ, maxX, maxZ } = data.bounds; +const nw = toLatLon(minX, minZ), + se = toLatLon(maxX, maxZ); 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); // 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); 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); - 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 p0 = toLatLon(x - 300, z + 300), p1 = toLatLon(x + 300, z - 300); - addBox(17, p0.lat, p0.lon, p1.lat, p1.lon); - } + 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++) { + const x = a.x + ((b.x - a.x) * s) / steps, + 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); + } } -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}`); -let done = 0, fetched = 0, bytes = 0; +let done = 0, + fetched = 0, + bytes = 0; async function worker() { - while (queue.length) { - const {z, key} = queue.pop(), [x, y] = key.split('/'); - const file = new URL(`${z}/${x}/${y}.jpg`, OUT); - if (!existsSync(file)) { - for (let attempt = 0; attempt < 4; attempt++) { - try { - const res = await fetch(URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x), {headers: {'User-Agent': 'robot-market-game/1.0'}}); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const body = new Uint8Array(await res.arrayBuffer()); - mkdirSync(new URL(`${z}/${x}/`, OUT), {recursive: true}); - writeFileSync(file, body); - fetched++; - bytes += body.length; - break; - } catch (error) { - if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`); - await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); - } - } + while (queue.length) { + const { z, key } = queue.pop(), + [x, y] = key.split('/'); + const file = new URL(`${z}/${x}/${y}.jpg`, OUT); + if (!existsSync(file)) { + for (let attempt = 0; attempt < 4; attempt++) { + try { + const body = new Uint8Array( + await downloadImagery( + URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x), + ), + ); + mkdirSync(new URL(`${z}/${x}/`, OUT), { recursive: true }); + writeFileSync(file, body); + fetched++; + bytes += body.length; + break; + } catch (error) { + if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`); + 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)); -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(); +await Promise.all(Array.from({ length: 6 }, worker)); +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(); 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}`, +); diff --git a/src/constants/locations.js b/src/constants/locations.js index 9bcf6c8..f34e8c4 100644 --- a/src/constants/locations.js +++ b/src/constants/locations.js @@ -1,26 +1,50 @@ // 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: 'کارخانه', icon: '🏭' }, - park: { label: 'پارک', icon: '🌳' }, - university: { label: 'دانشگاه', icon: '🎓' }, - school: { label: 'مدرسه', icon: '🏫' }, - metro: { label: 'ایستگاه مترو', icon: 'Ⓜ' }, - mall: { label: 'مرکز خرید', icon: '🛍' }, - test: { label: 'نقطهٔ آزمایشی GPS', icon: '🧪' }, + factory: { + label: 'کارخانه', + icon: '🏭', + }, + park: { + label: 'پارک', + icon: '🌳', + }, + university: { + label: 'دانشگاه', + icon: '🎓', + }, + school: { + label: 'مدرسه', + icon: '🏫', + }, + metro: { + label: 'ایستگاه مترو', + icon: 'Ⓜ', + }, + mall: { + label: 'مرکز خرید', + icon: '🛍', + }, + test: { + label: 'نقطهٔ آزمایشی GPS', + icon: '🧪', + }, }; - const ALL_DAY = [7 * 60, 23 * 60]; - export const FACTORY = { name: 'شرکت روبات مارکت', tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند', 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 }, + center: { + x: -10308.4, + z: -14964.2, + }, + axis: { + x: Math.SQRT1_2, + z: Math.SQRT1_2, + }, }; - export const machines = [ { id: 'factory', @@ -38,7 +62,10 @@ export const machines = [ name: 'بوستان ملت', category: 'park', address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت', - anchor: { x: -487.6, z: -326.4 }, + anchor: { + x: -487.6, + z: -326.4, + }, hours: ALL_DAY, priceFactor: 1, products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'], @@ -49,7 +76,10 @@ export const machines = [ name: 'مجموعه آموزشی راحیل', category: 'school', address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه', - anchor: { x: -554.6, z: -569.7 }, + anchor: { + x: -554.6, + z: -569.7, + }, hours: [7 * 60 + 30, 13 * 60 + 30], priceFactor: 0.9, products: ['water', 'milk', 'juice', 'biscuit', 'cake'], @@ -60,7 +90,10 @@ export const machines = [ name: 'دانشگاه فردوسی مشهد', category: 'university', address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی', - anchor: { x: -936, z: 645 }, + anchor: { + x: -936, + z: 645, + }, hours: ALL_DAY, priceFactor: 0.8, products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'], @@ -71,7 +104,10 @@ export const machines = [ name: 'دانشکدهٔ ادبیات دکتر شریعتی', category: 'university', address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات', - anchor: { x: -417.1, z: 966.3 }, + anchor: { + x: -417.1, + z: 966.3, + }, hours: ALL_DAY, priceFactor: 0.8, products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'], @@ -82,7 +118,10 @@ export const machines = [ name: 'دانشگاه علوم پزشکی مشهد', category: 'university', address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد', - anchor: { x: -272.1, z: 150.6 }, + anchor: { + x: -272.1, + z: 150.6, + }, hours: ALL_DAY, priceFactor: 0.8, products: ['water', 'juice', 'milk', 'nuts', 'biscuit'], @@ -93,7 +132,10 @@ export const machines = [ name: 'ایستگاه مترو آزادی', category: 'metro', address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی', - anchor: { x: 494.1, z: 280.3 }, + anchor: { + x: 494.1, + z: 280.3, + }, hours: [6 * 60, 22 * 60], priceFactor: 1, products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'], @@ -104,7 +146,10 @@ export const machines = [ name: 'مشهد مال', category: 'mall', address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال', - anchor: { x: 1940.6, z: -367.8 }, + 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'], @@ -114,6 +159,13 @@ export const machines = [ // 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}`, name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`, @@ -121,7 +173,10 @@ const testPoint = (n, lat, lon) => ({ test: true, address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`, anchor: null, - spot: { lat, lon }, + spot: { + lat, + lon, + }, hours: [0, 24 * 60], priceFactor: 0.5, products: ['water', 'biscuit'], @@ -135,11 +190,22 @@ machines.push( // Shown on the map as the next area to unlock. export const lockedPlaces = [ - { name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی' }, - { name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · به‌زودی' }, + { + name: 'پارک کوهسنگی', + note: 'خارج از محدودهٔ فعلی · به‌زودی', + }, + { + name: 'حرم و مرکز شهر', + note: 'خارج از محدودهٔ فعلی · به‌زودی', + }, ]; - -export const START = { machineId: 'factory' }; +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; diff --git a/src/constants/showroomCatalog.js b/src/constants/showroomCatalog.js new file mode 100644 index 0000000..3ba65a8 --- /dev/null +++ b/src/constants/showroomCatalog.js @@ -0,0 +1,47 @@ +/** Display-only machine prices in rial, independent of the consumable economy. */ +export const SHOWROOM_CATALOG = [ + { + id: 'rm-35', + category: 'warm-vending', + name: 'روبات مارکت ۳۵ کانال', + shortLabel: '۳۵ کانال', + channels: 35, + priceLabel: '4,680,000,000 ریال', + priceNumeric: 4680000000, + description: + 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.', + highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'], + image: '/images/machine-35-base.webp', + imageAlt: '/images/machine-35-white.webp', + gif: '/gifs/machine-35.gif', + }, + { + id: 'rm-48', + category: 'warm-vending', + name: 'روبات مارکت ۴۸ کانال', + shortLabel: '۴۸ کانال', + channels: 48, + priceLabel: '5,480,000,000 ریال', + priceNumeric: 5480000000, + description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیت‌های پیشرفته مدیریت آنلاین و تحویل کالا.', + highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'], + image: '/images/machine-48-base.webp', + imageAlt: '/images/machine-48-white.webp', + gif: '/gifs/machine-48.gif', + }, + { + id: 'rm-60', + category: 'warm-vending', + name: 'روبات مارکت ۶۰ کانال', + shortLabel: '۶۰ کانال', + channels: 60, + priceLabel: '5,980,000,000 ریال', + priceNumeric: 5980000000, + description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسب‌وکارهای پرتردد.', + highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'], + image: '/images/machine-60-base.webp', + imageAlt: '/images/machine-60-white.webp', + gif: '/gifs/machine-60.gif', + }, +]; +export const SHOWROOM_REACH = 4; diff --git a/src/game/createGame.js b/src/game/createGame.js index 38fbb0f..4cb3094 100644 --- a/src/game/createGame.js +++ b/src/game/createGame.js @@ -1,2306 +1,23 @@ -import { loadWorldFiles } from '../services/worldService.js'; -import * as THREE from 'three'; -import '../styles/game.css'; -import { - startTracking, - requestCompass, - arrivalRadius, - insideWorld, - createStabilizer, - MAX_WALKING_SPEED, -} from './device/realworld.js'; -import { createArView } from './device/ar.js'; -import data from '../data/mellat.json'; -import iran from '../data/iran.json'; -import { createCity } from './world/city.js'; -import { createImagery } from './world/imagery.js'; -import { decodeBuildings } from './world/buildingsData.js'; -import { - createCollisionWorld, - addPolygon, - resolveCircle, - clearFraction, -} from './world/collision.js'; -import { - buildGraph, - routeBetween, - buildSegmentIndex, - nearestSegment, - DRIVABLE_MAX_TYPE, -} from './world/routing.js'; -import { machines, CATEGORIES, CITY_START, FACTORY, lockedPlaces } from '../constants/locations.js'; -import { - createState, - catalog, - priceAt, - isOpen, - purchaseBlocker, - formatHetoman, - formatClock, - fa, - canAffordAnything, -} from './economy/gameState.js'; -import { - createPayment, - beginPayment, - readyPayment, - swipePayment, - cancelPayment, -} from './economy/payment.js'; -import { - awardsFor, - rankFor, - completeMissions, - activeMission, - MISSIONS, -} from './economy/scoring.js'; -import { createFamily } from './models/characters.js'; -import { createVendingMachine } from './models/vending.js'; -import { createFactory } from './models/factory.js'; -import { createCar, stepCar, kmh } from './systems/vehicle.js'; -import { createTraffic } from './systems/traffic.js'; -import { createSportSedan } from './models/carModels.js'; -import { createMapAssets, drawMinimap, drawFullMap, drawIran } from './world/mapRender.js'; -import { bearing, formatDistance, toLocal } from '../utils/geo.js'; +import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js'; +/** Starts isolated systems in dependency order and returns their shared lifecycle cleanup. + * @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount context. + * @returns {Promise} Disposes input, timers, sensors and graphics. + */ export async function createGame({ root, ui, signal }) { - const timers = new Set(); - const setTimeout = (callback, delay) => { - const timer = window.setTimeout(() => { - timers.delete(timer); - if (!signal.aborted) callback(); - }, delay); - timers.add(timer); - return timer; + const context = { + root, + ui, + signal, }; - const clearTimeout = (timer) => { - timers.delete(timer); - window.clearTimeout(timer); - }; - const listen = (target, type, handler, options = {}) => - target.addEventListener(type, handler, { ...options, signal }); - let frameId; - - const $ = (s) => root.querySelector(s); - const canvas = $('#world'); - let renderer; + bindRuntime(context); try { - // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn. - renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - alpha: true, - powerPreference: 'high-performance', - }); - } catch { - ui.patch('loading', { - text: 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.', - }); - throw new Error('WebGL unavailable'); + for (const initialize of RUNTIME_INITIALIZERS) { + if (signal.aborted) break; + await initialize(context); + } + } catch (error) { + context.renderer?.dispose(); + throw error; } - // Low quality (no shadows, 1× pixels) for weak phones; chosen with ?quality=low or the toolbar button. - const quality = - new URLSearchParams(location.search).get('quality') ?? - (() => { - try { - return localStorage.getItem('quality'); - } catch { - return null; - } - })() ?? - (matchMedia('(pointer: coarse)').matches ? 'low' : 'high'); - renderer.setPixelRatio(quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75)); - renderer.shadowMap.enabled = quality !== 'low'; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.toneMapping = THREE.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.05; - const scene = new THREE.Scene(); - const fog = new THREE.Fog('#d8cdb4', 220, 1500); - scene.fog = fog; - const camera = new THREE.PerspectiveCamera(62, 1, 0.3, 7000); - - // Morning sky over Mashhad: a gradient dome, warm haze at the horizon. - const sky = new THREE.Mesh( - new THREE.SphereGeometry(5000, 32, 16), - new THREE.ShaderMaterial({ - side: THREE.BackSide, - depthWrite: false, - fog: false, - uniforms: { - top: { - value: new THREE.Color('#6f9fd0'), - }, - horizon: { - value: new THREE.Color('#e6dbc3'), - }, - }, - vertexShader: - 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', - fragmentShader: - 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }', - }), - ); - scene.add(sky); - scene.add(new THREE.HemisphereLight('#fff4dc', '#7d715d', 1.6)); - const sun = new THREE.DirectionalLight('#ffe9c2', 3.2); - sun.castShadow = true; - sun.shadow.mapSize.set(2048, 2048); - Object.assign(sun.shadow.camera, { - left: -75, - right: 75, - top: 75, - bottom: -75, - near: 10, - far: 700, - }); - sun.shadow.bias = -0.0004; - sun.shadow.normalBias = 0.4; - scene.add(sun, sun.target); - - // ---------- World data ---------- - // Microsoft's building footprints and the satellite tile list are downloaded files, not bundled code. - // The game still runs without them (OSM buildings and drawn roads). - const [buildingsFile, imageryIndex] = await loadWorldFiles(signal); - if (signal.aborted) { - renderer.dispose(); - return () => {}; - } - // Footprints reaching into the factory compound are left out: the game's own buildings stand there. - const inFactory = (x, z) => { - const dx = x - FACTORY.center.x, - dz = z - FACTORY.center.z; - const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, - v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x; - return u > -112 && u < 82 && v > -52 && v < 52; - }; - const extraBuildings = (buildingsFile ? decodeBuildings(buildingsFile) : []).filter((b) => { - for (let i = 1; i < b.length; i += 2) if (inFactory(b[i], b[i + 1])) return false; - return true; - }); - const collision = createCollisionWorld( - [...data.buildings, ...extraBuildings].map((b) => b.slice(1)), - ); - const segments = buildSegmentIndex(data.roads); - const walkGraph = buildGraph(data.roads), - driveGraph = buildGraph(data.roads, { - drivable: true, - }); - const ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2]; - const rotateLocal = (lx, lz, rot) => ({ - x: lx * Math.cos(rot) + lz * Math.sin(rot), - z: -lx * Math.sin(rot) + lz * Math.cos(rot), - }); - - // The game starts inside the vending-machine factory on the Mashhad–Chenaran road. - const factory = createFactory(scene); - factory.footprints.forEach((f) => addPolygon(collision, f)); - - // City machines stand beside their path point, on whichever side is free, facing the path; - // the factory's demo machine has a fixed place inside the production hall. - const stations = machines.map((def) => { - if (def.spot) { - // Test points: the given coordinate is where the player stands; the machine faces the factory. - const front = toLocal(def.spot.lat, def.spot.lon); - const dx = FACTORY.center.x - front.x, - dz = FACTORY.center.z - front.z, - len = Math.hypot(dx, dz) || 1; - return placeStation( - def, - { - x: front.x - (dx / len) * 1.9, - z: front.z - (dz / len) * 1.9, - }, - Math.atan2(dx, dz), - ); - } - if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot); - const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? { - dx: 1, - dz: 0, - type: 9, - }; - const len = Math.hypot(seg.dx, seg.dz) || 1, - nx = -seg.dz / len, - nz = seg.dx / len; - const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4; - let pos = null; - for (const d of [off, -off, off + 1.5, -off - 1.5]) { - const x = def.anchor.x + nx * d, - z = def.anchor.z + nz * d; - if (!resolveCircle(collision, x, z, 1.4).hit) { - pos = { - x, - z, - }; - break; - } - } - pos ??= { - x: def.anchor.x + nx * off, - z: def.anchor.z + nz * off, - }; - return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z)); - }); - function placeStation(def, pos, rot) { - const model = createVendingMachine(def); - model.group.position.set(pos.x, 0, pos.z); - model.group.rotation.y = rot; - scene.add(model.group); - addPolygon( - collision, - [ - [-1, -0.6], - [1, -0.6], - [1, 0.6], - [-1, 0.6], - ].flatMap(([lx, lz]) => { - const p = rotateLocal(lx, lz, rot); - return [pos.x + p.x, pos.z + p.z]; - }), - ); - const front = 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, - }, - }; - } - const stationById = (id) => stations.find((s) => s.def.id === id); - for (const s of stations) if (s.def.test) s.model.group.visible = false; - - // In real-world mode a circle on the ground marks how close counts as "at the machine". - for (const s of stations) { - s.reach = new THREE.Mesh( - new THREE.RingGeometry(0.96, 1, 96), - new THREE.MeshBasicMaterial({ - color: '#3ee0c9', - transparent: true, - opacity: 0.55, - depthWrite: false, - fog: false, - side: THREE.DoubleSide, - }), - ); - s.reach.rotation.x = -Math.PI / 2; - s.reach.position.set(s.front.x, 0.08, s.front.z); - s.reach.visible = false; - scene.add(s.reach); - } - function updateReachCircles() { - const radius = reachRadius(), - objective = real.on ? objectiveStation() : null; - for (const s of stations) { - s.reach.visible = real.on; - if (!real.on) continue; - s.reach.scale.setScalar(radius); - s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9'); - } - } - // GPS test points only take part while real-world mode is on. - const activeStations = () => stations.filter((s) => !s.def.test || real.on); - // The closest active machine whose front is within `radius` of (x, z). - const closestStation = (x, z, radius) => - activeStations() - .map((s) => ({ - s, - d: Math.hypot(s.front.x - x, s.front.z - z), - })) - .filter(({ d }) => d < radius) - .sort((a, b) => a.d - b.d)[0]?.s ?? null; - const imagery = createImagery(scene, imageryIndex ?? {}, { - lowQuality: quality === 'low', - anisotropy: Math.min(16, renderer.capabilities.getMaxAnisotropy()), - }); - const city = createCity(scene, data, { - collision, - segments, - extraBuildings, - satellite: imagery.available, - lowQuality: quality === 'low', - }); - if (imagery.available) - ui.patch('attribution', { - text: `نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${imageryIndex.attribution}`, - }); - const traffic = createTraffic(scene, data, 28); - const mapAssets = createMapAssets(data, [factory.label]); - - // Park gate sign at the first city machine. - const parkGate = new THREE.Group(); - { - const start = stationById(CITY_START), - sign = document.createElement('canvas'); - sign.width = 512; - sign.height = 128; - const c = sign.getContext('2d'); - c.fillStyle = '#244d2e'; - c.fillRect(0, 0, 512, 128); - c.fillStyle = '#f4efd9'; - c.textAlign = 'center'; - c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif'; - c.fillText('بوستان ملت', 256, 86); - const texture = new THREE.CanvasTexture(sign); - texture.colorSpace = THREE.SRGBColorSpace; - const gate = parkGate, - post = new THREE.MeshStandardMaterial({ - color: '#6b5a49', - }); - for (const side of [-1, 1]) { - const p = new THREE.Mesh(new THREE.BoxGeometry(0.3, 4, 0.3), post); - p.position.set(side * 2.6, 2, 0); - p.castShadow = true; - gate.add(p); - } - const board = new THREE.Mesh( - new THREE.PlaneGeometry(5.6, 1.4), - new THREE.MeshBasicMaterial({ - map: texture, - side: THREE.DoubleSide, - }), - ); - board.position.y = 4; - gate.add(board); - const at = rotateLocal(-4.5, 1.5, start.rot); - gate.position.set(start.x + at.x, 0, start.z + at.z); - gate.rotation.y = start.rot; - scene.add(gate); - } - - // ---------- Actors ---------- - const family = createFamily(); - scene.add(family.group); - // The Robot Market guide lives inside the machines and comes out when the family arrives. - const guide = createFamily({ - robot: true, - }); - guide.group.visible = false; - scene.add(guide.group); - const carModel = createSportSedan('#1d3f78'); - scene.add(carModel.group); - let state, payment, player, car, mode, cam, waypoint, route, routeTimer, deliveryTimer, ended; - const keys = new Set(); - - // The family car waits in the factory yard right beside them, pointing at the gate. - const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading); - function reset() { - state = createState(); - payment = createPayment(); - // The family starts in the factory yard, facing the engineering office. - const spot = resolveCircle(collision, factory.start.x, factory.start.z, 1); - player = { - x: spot.x, - z: spot.z, - heading: factory.start.heading, - speed: 0, - gait: 0, - }; - factory.reset(); - for (const t of factory.talks) t.done = false; - car = spawnCar(); - mode = 'walk'; - // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos). - cam = { - yaw: Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)), - pitch: 0.32, - distance: 5.5, - lastMouse: -10, - }; - waypoint = null; - route = null; - routeTimer = 0; - deliveryTimer = 0; - ended = false; - hints = { - closed: false, - car: false, - }; - Object.assign(guideState, { - station: null, - t: 0, - greeted: new Set(), - arrived: 0, - }); - ui.patch('payment', { - hidden: true, - }); - for (const s of stations) { - s.model.delivery.visible = s.model.card.visible = false; - s.model.flap.rotation.x = 0; - } - for (const id of ['shop', 'map-dialog', 'end']) ui.close(id); - keys.clear(); - refreshHUD(); - } - - // ---------- Input ---------- - const isTouch = matchMedia('(pointer: coarse)').matches; - const anyDialogOpen = () => - ui.read('shop').open || - ui.read('map-dialog').open || - ui.read('end').open || - !ui.read('briefing').hidden; - // Physical key codes, so the controls also work with a Persian keyboard layout. - const keyName = (e) => - e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase(); - listen(window, 'keydown', (e) => { - if (e.target instanceof HTMLInputElement) return; - const k = keyName(e); - if (k === 'm' && !e.repeat && ui.read('briefing').hidden) { - e.preventDefault(); - ui.read('map-dialog').open ? ui.close('map-dialog') : openMap(); - return; - } - if (anyDialogOpen()) return; - if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k)) - e.preventDefault(); - if (e.repeat) return; - keys.add(k); - if (k === 'f') toggleCar(); - if (k === 'e') interact(); - if (k === 'c') swipe(); - if (k === 'escape' && payment.phase !== 'idle') cancel(); - }); - listen(window, 'keyup', (e) => keys.delete(keyName(e))); - listen(window, 'blur', () => keys.clear()); - - // Pointer lock gives a shooter-style mouse look; dragging works as a fallback and on touch. - listen(canvas, 'click', () => { - if (!isTouch && !anyDialogOpen() && document.pointerLockElement !== canvas) - canvas.requestPointerLock?.()?.catch?.(() => {}); - }); - listen(document, 'mousemove', (e) => { - if (document.pointerLockElement !== canvas) return; - look(e.movementX, e.movementY, 0.0024); - }); - let drag = null; - listen(canvas, 'pointerdown', (e) => { - if (document.pointerLockElement === canvas) return; - drag = { - id: e.pointerId, - x: e.clientX, - y: e.clientY, - }; - canvas.setPointerCapture(e.pointerId); - }); - listen(canvas, 'pointermove', (e) => { - if (!drag || drag.id !== e.pointerId) return; - look(e.clientX - drag.x, e.clientY - drag.y, 0.005); - drag.x = e.clientX; - drag.y = e.clientY; - }); - listen(canvas, 'pointerup', () => (drag = null)); - listen(canvas, 'pointercancel', () => (drag = null)); - listen( - canvas, - 'wheel', - (e) => { - cam.distance = THREE.MathUtils.clamp(cam.distance + Math.sign(e.deltaY) * 0.6, 3, 12); - }, - { - passive: true, - }, - ); - function look(dx, dy, k) { - // In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor. - if (real.on) { - if (arView.hasOrientation) return; - cam.yaw -= dx * k; - cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * 0.8, -1.2, 1.2); - cam.lastMouse = elapsed; - return; - } - cam.yaw -= dx * k; - cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * 0.8, -0.15, 1.15); - cam.lastMouse = elapsed; - } - - // Touch joystick: x steers / strafes, y walks / throttles. - const stick = { - x: 0, - y: 0, - id: null, - }; - if (isTouch) { - ui.patch('touch', { - hidden: false, - }); - const pad = $('#stick'), - knob = pad.querySelector('i'); - const move = (e) => { - const r = pad.getBoundingClientRect(), - dx = e.clientX - (r.left + r.width / 2), - dy = e.clientY - (r.top + r.height / 2); - const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)), - a = Math.atan2(dy, dx); - stick.x = Math.cos(a) * len; - stick.y = -Math.sin(a) * len; - knob.style.transform = `translate(${stick.x * 40}px, ${-stick.y * 40}px)`; - }; - listen(pad, 'pointerdown', (e) => { - stick.id = e.pointerId; - pad.setPointerCapture(e.pointerId); - move(e); - }); - listen(pad, 'pointermove', (e) => e.pointerId === stick.id && move(e)); - const end = () => { - stick.id = null; - stick.x = stick.y = 0; - knob.style.transform = ''; - }; - listen(pad, 'pointerup', end); - listen(pad, 'pointercancel', end); - for (const k of ['shift', 'f', 'e']) - ui.on(`touch-${k}`, () => { - if (k === 'shift') { - if (keys.has('shift')) keys.delete('shift'); - else keys.add('shift'); - ui.patch('touch-shift', { - pressed: keys.has('shift'), - }); - } - if (k === 'f') toggleCar(); - if (k === 'e') payment.phase === 'ready' ? swipe() : interact(); - }); - } - const axis = (pos, neg) => - Number(pos.some((k) => keys.has(k))) - Number(neg.some((k) => keys.has(k))); - - // ---------- Subtitles ---------- - const SPEAKERS = { - robot: 'روبات', - father: 'پدر', - child: 'بچه', - }; - // Factory staff speak under their own names. - for (const t of factory.talks) if (t.label) SPEAKERS[t.speaker] = t.label; - let lines = [], - lineTimer = 0, - talking = false; - - // `face` picks the robot's expression portrait (public/robot/.webp). - function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) { - if (lines.some((l) => l.text === text)) return; - lines.push({ - speaker, - text, - face, - seconds, - }); - } - function updateSubtitles(dt) { - lineTimer -= dt; - if (lineTimer > 0) return; - const next = lines.shift(); - ui.patch('subtitle', { - hidden: !next, - }); - talking = next?.speaker === 'robot'; - if (!next) return; - ui.patch('subtitle-speaker', { - text: `${SPEAKERS[next.speaker]}:`, - }); - ui.patch('subtitle-speaker', { - className: ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff', - }); - ui.patch('subtitle-text', { - text: next.text, - }); - ui.patch('subtitle-face', { - hidden: next.speaker !== 'robot', - }); - if (next.speaker === 'robot') - ui.patch('subtitle-face', { - src: `./robot/${next.face}.webp`, - }); - lineTimer = next.seconds; - } - - // ---------- Sound ---------- - let audio = null, - soundOn = false, - engine = null; - function tone(freq, duration = 0.12, volume = 0.05, type = 'sine') { - if (!soundOn) return; - const o = audio.createOscillator(), - g = audio.createGain(); - o.type = type; - o.frequency.setValueAtTime(freq, audio.currentTime); - o.frequency.exponentialRampToValueAtTime(freq * 0.7, audio.currentTime + duration); - g.gain.setValueAtTime(volume, audio.currentTime); - g.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + duration); - o.connect(g).connect(audio.destination); - o.start(); - o.stop(audio.currentTime + duration); - } - ui.on('sound', async () => { - try { - audio ??= new AudioContext(); - await audio.resume(); - soundOn = !soundOn; - if (!engine) { - const o = audio.createOscillator(), - g = audio.createGain(); - o.type = 'sawtooth'; - g.gain.value = 0; - o.connect(g).connect(audio.destination); - o.start(); - engine = { - o, - g, - }; - } - ui.patch('sound', { - text: soundOn ? 'صدا: روشن' : 'صدا: خاموش', - }); - ui.patch('sound', { - pressed: String(soundOn), - }); - tone(700); - } catch { - ui.patch('sound', { - text: 'صدا در دسترس نیست', - }); - } - }); - - // ---------- Car ---------- - function toggleCar() { - if (payment.phase !== 'idle' || state.busy) return; - if (real.on) { - say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.'); - return; - } - if (mode === 'walk') { - if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return; - mode = 'drive'; - cam.distance = Math.max(cam.distance, 8); - tone(320, 0.2, 0.05, 'square'); - say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.'); - } else { - if (Math.abs(car.speed) > 3) { - say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.'); - return; - } - // Step out on the driver's (left) side, or wherever is free. - const left = { - x: -Math.sin(car.heading), - z: -Math.cos(car.heading), - }; - const spot = resolveCircle(collision, car.x + left.x * 2.8, car.z + left.z * 2.8, 1); - Object.assign(player, { - x: spot.x, - z: spot.z, - heading: car.heading, - }); - car.speed = 0; - mode = 'walk'; - cam.distance = 5.5; - tone(260, 0.2, 0.05, 'square'); - } - refreshHUD(); - } - - // ---------- Machines, shop and payment ---------- - // In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres. - const reachRadius = () => (real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4); - const nearStation = () => - mode === 'walk' ? closestStation(player.x, player.z, reachRadius()) : null; - const hoursText = (m) => - m.hours[1] - m.hours[0] >= 24 * 60 - ? 'شبانه‌روزی' - : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`; - let shopStation = null; - function interact() { - const station = nearStation(); - if (!station || state.busy || payment.phase !== 'idle' || ended) return; - if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) { - say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.'); - return; - } - if (!isOpen(station.def, state.minutes)) { - say( - guideOut() ? 'robot' : 'father', - `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`, - 'serious', - ); - return; - } - shopStation = station; - renderShop(); - document.exitPointerLock?.(); - keys.clear(); - ui.open('shop'); - tone(750); - } - function renderShop() { - const def = shopStation.def; - ui.patch('shop-category', { - text: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${hoursText(def)}`, - }); - ui.patch('shop-name', { - text: `روبات مارکت · ${def.name}`, - }); - ui.patch('shop-address', { - text: def.address, - }); - ui.patch('shop-note', { - text: `${def.note} · موجودی شما: ${formatHetoman(state.balance)}`, - }); - ui.patch('products', { - items: def.products.map((id) => ({ - id, - ...catalog[id], - price: priceAt(def, id), - blocked: purchaseBlocker(state, def, id), - })), - }); - } - function startPayment(productId) { - if (!beginPayment(state, payment, shopStation.def, productId)) return; - payment.station = shopStation; - payment.timer = 0; - ui.close('shop'); - const p = catalog[productId]; - ui.patch('payment', { - hidden: false, - }); - ui.patch('payment-title', { - text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(shopStation.def, productId))}`, - }); - ui.patch('payment-step', { - text: 'در حال نزدیک شدن به کارت‌خوان…', - }); - ui.patch('swipe', { - disabled: true, - }); - ui.patch('cancel-payment', { - hidden: false, - }); - say('child', 'بابا، کارت رو بده من بکشم!'); - } - function swipe() { - if (!swipePayment(state, payment)) return; - payment.timer = 0; - payment.station.model.card.visible = true; - ui.patch('swipe', { - disabled: true, - }); - ui.patch('cancel-payment', { - hidden: true, - }); - ui.patch('payment-step', { - text: 'پرداخت موفق ✓ محصول در حال تحویل است', - }); - tone(1100, 0.3); - refreshHUD(); - } - function cancel() { - if (!cancelPayment(state, payment)) return; - ui.patch('payment', { - hidden: true, - }); - } - ui.on('swipe', swipe); - // On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car. - ui.on('prompt', () => { - if (payment.phase === 'ready') swipe(); - else if (nearStation()) interact(); - else toggleCar(); - }); - ui.on('cancel-payment', cancel); - ui.on('shop-close', () => ui.close('shop')); - function updatePayment(dt) { - if (payment.phase === 'idle') return; - payment.timer += dt; - const model = payment.station.model; - if (payment.phase === 'approaching' && payment.timer > 1) { - readyPayment(payment); - ui.patch('swipe', { - disabled: false, - }); - ui.patch('payment-step', { - text: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C', - }); - } - if (payment.phase === 'approved') { - const t = Math.min(1, payment.timer); - model.card.position.set(0.95 - t * 0.25, 1.7 - t * 0.1, 0.66); - if (payment.timer > 1.2) { - model.card.visible = false; - ui.patch('payment', { - hidden: true, - }); - model.setDelivery(payment.productId); - deliveryTimer = 2.6; - payment.delivering = { - station: payment.station, - record: payment.record, - }; - Object.assign(payment, createPayment()); - } - } - } - function updateDelivery(dt) { - if (deliveryTimer <= 0) return; - deliveryTimer = Math.max(0, deliveryTimer - dt); - const { station, record } = payment.delivering, - model = station.model, - t = 2.6 - 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 (deliveryTimer > 0) return; - model.flap.rotation.x = 0; - state.busy = false; - tone(980, 0.18); - reward(record, slot); - } - function reward(record, product) { - const awards = awardsFor(state.purchases.slice(0, -1), record), - rankBefore = rankFor(state.xp).index; - if (real.on) - awards.push({ - label: 'خرید در دنیای واقعی', - points: 200, - }); - const total = awards.reduce((s, a) => s + a.points, 0); - state.xp += total; - ui.patch('awards', { - items: awards, - total, - }); - clearTimeout(reward.timeout); - reward.timeout = setTimeout( - () => - ui.patch('awards', { - items: [], - }), - 3200, - ); - say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`); - const done = completeMissions(state); - for (const m of done) { - showBanner( - 'مأموریت انجام شد', - m.title, - `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`, - ); - tone(660, 0.5); - } - if (rankFor(state.xp).index > rankBefore) - setTimeout( - () => showBanner('ارتقای درجه', rankFor(state.xp).name, 'دستگاه‌های بیشتری پیدا کن'), - done.length ? 3400 : 0, - ); - const next = activeMission(state); - if (next) { - waypoint = null; - route = null; - setTimeout( - () => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), - 1500, - ); - } - refreshHUD(); - checkEnd(); - } - function showBanner(tag, title, sub) { - ui.patch('banner', { - tag, - title, - sub, - hidden: false, - }); - clearTimeout(showBanner.timeout); - showBanner.timeout = setTimeout( - () => - ui.patch('banner', { - hidden: true, - }), - 3200, - ); - } - function checkEnd() { - const allDone = state.missionsDone.length === MISSIONS.length; - if (ended || (!allDone && canAffordAnything(state, machines))) return; - ended = true; - document.exitPointerLock?.(); - ui.patch('end-tag', { - text: allDone ? 'عملیات موفق' : 'پایان بودجه', - }); - ui.patch('end-title', { - text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد', - }); - ui.patch('end-text', { - text: allDone - ? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.' - : 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:', - }); - const places = new Set(state.purchases.map((p) => p.machineId)).size; - ui.patch('end-stats', { - items: [ - ['امتیاز', `${fa(state.xp)} XP`], - ['درجه', rankFor(state.xp).name], - ['خریدها', fa(state.purchases.length)], - ['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`], - ['مأموریت‌ها', `${fa(state.missionsDone.length)} از ${fa(MISSIONS.length)}`], - ['باقی‌مانده', formatHetoman(state.balance)], - ], - }); - ui.patch('continue', { - hidden: !allDone, - }); - ui.open('end'); - } - ui.patch('quality', { - text: quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل', - }); - ui.on('quality', () => { - try { - localStorage.setItem('quality', quality === 'low' ? 'high' : 'low'); - } catch {} - location.replace(location.pathname); - }); - ui.on('again', reset); - ui.on('continue', () => ui.close('end')); - ui.on('restart', reset); - - // ---------- Objective, route and HUD ---------- - const objectiveStation = () => { - if (waypoint) return stationById(waypoint); - const mission = activeMission(state); - if (mission?.target) return stationById(mission.target); - // Open-ended missions point to the nearest place not bought from yet. - const visited = new Set(state.purchases.map((p) => p.machineId)); - return ( - activeStations() - .filter((s) => !visited.has(s.def.id)) - .sort( - (a, b) => - Math.hypot(a.x - player.x, a.z - player.z) - Math.hypot(b.x - player.x, b.z - player.z), - )[0] ?? null - ); - }; - - // The gold route ribbon on the ground, rebuilt when the route changes. - const routeMaterial = new THREE.MeshBasicMaterial({ - color: '#f2c94c', - transparent: true, - opacity: 0.75, - depthWrite: false, - polygonOffset: true, - polygonOffsetFactor: -12, - polygonOffsetUnits: -24, - }); - let routeMesh = null; - function rebuildRouteMesh() { - if (routeMesh) { - scene.remove(routeMesh); - routeMesh.geometry.dispose(); - routeMesh = null; - } - if (!route) return; - const pts = [], - pos = [], - idx = []; - let total = 0; - for (const p of 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 = 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); - routeMesh = new THREE.Mesh(g, routeMaterial); - scene.add(routeMesh); - } - let routeTarget = null, - routeFrom = { - x: 0, - z: 0, - }, - routeMode = null; - function updateRoute(dt) { - routeTimer -= dt; - const target = objectiveStation(); - if (!target) { - if (route) { - route = null; - rebuildRouteMesh(); - } - return; - } - const pos = mode === 'drive' ? car : player; - const changed = target !== routeTarget || routeMode !== mode || !route; - if (!changed && (routeTimer > 0 || Math.hypot(pos.x - routeFrom.x, pos.z - routeFrom.z) < 10)) - return; - routeTimer = 1.2; - routeTarget = target; - routeMode = mode; - 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); - route = - direct < 60 - ? { - points: [ - { - x: pos.x, - z: pos.z, - }, - { - x: target.front.x, - z: target.front.z, - }, - ], - length: direct, - } - : routeBetween( - mode === 'drive' ? driveGraph : walkGraph, - pos.x, - pos.z, - target.front.x, - target.front.z, - ); - rebuildRouteMesh(); - } - const compass = $('#compass').getContext('2d'), - minimap = $('#minimap').getContext('2d'); - function drawCompass(headingDeg, markers) { - const w = 560, - h = 44; - compass.clearRect(0, 0, w, h); - compass.fillStyle = 'rgba(10,16,14,.45)'; - compass.fillRect(0, 10, w, 24); - compass.textAlign = 'center'; - compass.textBaseline = 'middle'; - const labels = { - 0: 'شمال', - 90: 'شرق', - 180: 'جنوب', - 270: 'غرب', - }; - for (let d = -100; d <= 100; d += 5) { - const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360, - x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6; - if (x < 10 || x > w - 10) continue; - compass.fillStyle = 'rgba(236,242,238,.8)'; - if (labels[deg] !== undefined) { - compass.font = 'bold 13px Vazirmatn, sans-serif'; - compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee'; - compass.fillText(labels[deg], x, 22); - } else if (deg % 15 === 0) { - compass.font = '10px Vazirmatn, sans-serif'; - compass.fillText(fa(deg), x, 22); - } else compass.fillRect(x - 0.5, 18, 1, 8); - } - for (const m of markers) { - const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6; - if (x < 10 || x > w - 10) continue; - compass.save(); - compass.translate(x, 6); - compass.rotate(Math.PI / 4); - compass.fillStyle = m.color; - compass.fillRect(-4, -4, 8, 8); - compass.restore(); - } - compass.fillStyle = '#ecf2ee'; - compass.fillRect(w / 2 - 1, 34, 2, 8); - compass.font = 'bold 11px Vazirmatn, sans-serif'; - compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6); - } - function refreshHUD() { - ui.patch('balance', { - text: fa(state.balance), - }); - const rank = rankFor(state.xp); - ui.patch('rank-name', { - text: rank.name, - }); - ui.patch('xp', { - text: rank.next ? `${fa(state.xp)} / ${fa(rank.next)} XP` : `${fa(state.xp)} XP`, - }); - ui.style('xp-bar', { - width: `${Math.round(rank.progress * 100)}%`, - }); - const active = activeMission(state); - ui.patch('mission-list', { - items: MISSIONS.map((m) => ({ - id: m.id, - title: m.title, - done: state.missionsDone.includes(m.id), - active: m === active, - })), - }); - ui.publishGame(state); - ui.patch('mode-label', { - text: mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده', - }); - ui.patch('speed-stat', { - hidden: mode !== 'drive', - }); - } - function updatePrompt() { - let text = ''; - const station = nearStation(); - if (!state.busy && payment.phase === 'idle') { - if (station) - text = isOpen(station.def, state.minutes) - ? `E · خرید از روبات مارکت · ${station.def.name}` - : `دستگاه بسته است · ${hoursText(station.def)}`; - else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5) - text = 'F · سوار ماشین شو'; - else if ( - mode === 'drive' && - Math.abs(car.speed) < 1 && - stations.some((s) => Math.hypot(s.front.x - car.x, s.front.z - car.z) < 25) - ) - text = 'F · پیاده شو؛ دستگاه نزدیک است'; - } - ui.patch('prompt', { - text, - hidden: !text, - }); - } - - // ---------- Map dialog ---------- - const mapView = { - cx: 0, - cz: 0, - mpp: 2, - }, - mapCanvas = $('#fullmap'), - iranCanvas = $('#iran-map'); - let selected = null, - mapTab = 'city'; - const markers = () => { - const visited = new Set(state.purchases.map((p) => p.machineId)), - objective = objectiveStation(); - return activeStations().map((s) => ({ - id: s.def.id, - x: s.x, - z: s.z, - visited: visited.has(s.def.id), - open: isOpen(s.def, state.minutes), - objective: s === objective, - })); - }; - function sizeCanvas(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; - } - function renderMap() { - if (!ui.read('map-dialog').open) return; - if (mapTab === 'city') { - const dpr = sizeCanvas(mapCanvas); - const pos = mode === 'drive' ? car : player; - drawFullMap(mapCanvas.getContext('2d'), mapAssets, mapView, { - dpr, - markers: markers(), - route: route?.points, - selected, - player: { - x: pos.x, - z: pos.z, - heading: mode === 'drive' ? car.heading : player.heading, - }, - }); - } else { - const dpr = sizeCanvas(iranCanvas), - c = iranCanvas.getContext('2d'); - c.setTransform(dpr, 0, 0, dpr, 0, 0); - drawIran(c, iran, { - width: iranCanvas.width / dpr, - height: iranCanvas.height / dpr, - t: elapsed, - cities: CITIES, - }); - } - } - function renderMachineList() { - const pos = mode === 'drive' ? car : player; - const visited = new Set(state.purchases.map((p) => p.machineId)); - const items = 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, state.minutes), - selected: s.def.id === selected, - distance: formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z)), - })); - ui.patch('machine-list', { - items: [ - ...items, - ...lockedPlaces.map((p) => ({ - ...p, - id: p.name, - locked: true, - })), - ], - }); - } - function selectMachine(id, center = false) { - selected = id; - const s = stationById(id), - def = s.def; - if (center) { - mapView.cx = s.x; - mapView.cz = s.z; - mapView.mpp = Math.min(mapView.mpp, 1.4); - } - ui.patch('machine-detail', { - hidden: false, - }); - ui.patch('detail-name', { - text: `${CATEGORIES[def.category].icon} ${def.name}`, - }); - ui.patch('detail-address', { - text: `📍 ${def.address}`, - }); - ui.patch('detail-meta', { - text: `🕒 ${hoursText(def)} · ${isOpen(def, state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`, - }); - ui.patch('detail-products', { - text: def.products - .map((id) => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`) - .join(' · '), - }); - ui.patch('route-button', { - text: waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا', - }); - renderMachineList(); - renderMap(); - } - ui.on('route-button', () => { - if (!selected) return; - waypoint = selected; - route = null; - routeTimer = 0; - updateRoute(0); - const s = stationById(selected); - say( - 'father', - `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`, - ); - selectMachine(selected); - }); - function openMap() { - if (anyDialogOpen()) return; - document.exitPointerLock?.(); - keys.clear(); - const pos = mode === 'drive' ? car : player; - Object.assign(mapView, { - cx: pos.x, - cz: pos.z, - mpp: 2.2, - }); - ui.open('map-dialog'); - selected ??= objectiveStation()?.def.id ?? null; - if (selected) selectMachine(selected); - renderMachineList(); - renderMap(); - } - ui.on('minimap-button', openMap); - ui.on('map-close', () => ui.close('map-dialog')); - for (const tab of ['city', 'iran']) - ui.on(`tab-${tab}`, () => { - mapTab = tab; - ui.patch('tab-city', { - selected: tab === 'city', - }); - ui.patch('tab-iran', { - selected: tab === 'iran', - }); - ui.patch('fullmap', { hidden: tab !== 'city' }); - ui.patch('iran-map', { hidden: tab !== 'iran' }); - renderMap(); - }); - ui.on('select-machine', (id) => selectMachine(id, true)); - ui.on('select-product', startPayment); - let mapDrag = null; - listen(mapCanvas, 'pointerdown', (e) => { - mapDrag = { - x: e.clientX, - y: e.clientY, - moved: 0, - }; - mapCanvas.setPointerCapture(e.pointerId); - }); - listen(mapCanvas, 'pointermove', (e) => { - if (!mapDrag) return; - mapView.cx -= (e.clientX - mapDrag.x) * mapView.mpp; - mapView.cz -= (e.clientY - mapDrag.y) * mapView.mpp; - mapDrag.moved += Math.abs(e.clientX - mapDrag.x) + Math.abs(e.clientY - mapDrag.y); - mapDrag.x = e.clientX; - mapDrag.y = e.clientY; - renderMap(); - }); - listen(mapCanvas, 'pointerup', (e) => { - if (mapDrag && mapDrag.moved < 6) { - const r = mapCanvas.getBoundingClientRect(); - const wx = mapView.cx + (e.clientX - r.left - r.width / 2) * mapView.mpp, - wz = mapView.cz + (e.clientY - r.top - r.height / 2) * mapView.mpp; - const hit = activeStations().find((s) => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp); - if (hit) selectMachine(hit.def.id); - } - mapDrag = null; - }); - listen( - mapCanvas, - 'wheel', - (e) => { - e.preventDefault(); - mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), 0.35, 30); - renderMap(); - }, - { - passive: false, - }, - ); - - // ---------- Briefing ---------- - const CITIES = [ - { - name: 'مشهد', - lat: 36.3, - lon: 59.6, - active: true, - }, - { - name: 'تهران', - lat: 35.69, - lon: 51.39, - }, - { - name: 'اصفهان', - lat: 32.65, - lon: 51.67, - }, - { - name: 'شیراز', - lat: 29.59, - lon: 52.58, - }, - { - name: 'تبریز', - lat: 38.08, - lon: 46.29, - }, - { - name: 'اهواز', - lat: 31.32, - lon: 48.67, - }, - { - name: 'کرمان', - lat: 30.28, - lon: 57.08, - }, - { - name: 'رشت', - lat: 37.28, - lon: 49.58, - }, - { - name: 'یزد', - lat: 31.9, - lon: 54.37, - }, - { - name: 'زاهدان', - lat: 29.5, - lon: 60.86, - }, - { - name: 'کرمانشاه', - lat: 34.31, - lon: 47.07, - }, - { - name: 'بندرعباس', - lat: 27.18, - lon: 56.27, - }, - ]; - const BRIEF = [ - `نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`, - 'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست', - 'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.', - 'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.', - 'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.', - 'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.', - ].join('\n'); - let briefChars = 0; - function startMission() { - ui.patch('briefing', { - hidden: true, - }); - ui.patch('hud', { - hidden: false, - }); - say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟'); - say('father', 'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!'); - say('child', 'اون وانت سفیده کیه داره میاد تو؟'); - say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.'); - if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {}); - } - ui.on('start-mission', startMission); - - // ---------- Real-world (GPS) mode ---------- - // The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where - // a machine stands lets the player buy there, with a bonus. - const real = { - on: false, - fix: null, - heading: null, - error: null, - waiting: null, - stop: null, - stabilizer: createStabilizer(), - }; - function setRealMode(on) { - if (on === real.on) return; - real.stop?.(); - Object.assign(real, { - on, - fix: null, - heading: null, - error: null, - waiting: null, - stop: null, - }); - for (const s of stations) if (s.def.test) s.model.group.visible = on; - ui.classState('gps-toggle', 'on', on); - ui.patch('gps-status', { - hidden: !on, - }); - ui.patch('ar-toggle', { - hidden: ui.patch('stable-toggle', { - hidden: !on, - }), - }); - // Real-world mode is first person: the player is where the phone is and sees what it faces. - ui.classState('body', 'real', on); - if (!on) { - setArMode(false); - arView.stopSensors(); - return; - } - arView.startSensors(); - cam.arPitch = 0; - cam.yaw = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)); - if (mode === 'drive') toggleCar(); - mode = 'walk'; - real.stop = startTracking({ - onFix: (fix) => { - real.fix = fix; - real.error = null; - }, - onHeading: (heading) => (real.heading = heading), - onError: (message) => (real.error = message), - onStatus: (message) => (real.waiting = message), - stabilizer: (real.stabilizer = createStabilizer()), - }); - real.stabilizer.stable = stableMode; - say( - 'father', - 'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.', - ); - 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. - const arView = createArView($('#camera')); - let arMode = false, - stableMode = true; - const hiddenInAr = () => [ - sky, - city.group, - imagery.group, - factory.group, - traffic.group, - carModel.group, - parkGate, - ]; - async function setArMode(on) { - if (on && !arMode) { - const problem = await arView.start(); - if (problem) { - say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`); - return; - } - if (!real.on) { - arView.stop(); - return; - } - say( - 'robot', - 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', - 'excited', - ); - setTimeout(() => { - if (real.on && !arView.hasOrientation) - say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.'); - }, 3000); - } else if (!on && arMode) arView.stop(); - else return; - arMode = on; - for (const object of hiddenInAr()) object.visible = !on; - scene.fog = on ? null : fog; - ui.classState('body', 'ar', on); - ui.classState('ar-toggle', 'on', on); - } - ui.on('ar-toggle', () => setArMode(!arMode)); - ui.on('stable-toggle', () => { - stableMode = !stableMode; - real.stabilizer.stable = stableMode; - ui.classState('stable-toggle', 'on', stableMode); - ui.patch('stable-toggle', { - text: stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش', - }); - }); - - // The safety notice comes first; permissions are requested from the tap on its button (iOS needs that). - function askRealMode(thenStart) { - if (real.on) { - setRealMode(false); - return; - } - ui.open('real-intro'); - ui.on('real-go', async () => { - ui.close('real-intro'); - await requestCompass(); - setRealMode(true); - if (thenStart) startMission(); - }); - } - ui.on('real-mode', () => askRealMode(true)); - ui.on('gps-toggle', () => askRealMode(false)); - ui.on('real-cancel', () => ui.close('real-intro')); - function updateReal(dt) { - let uiStatus; - if (real.error) uiStatus = `⚠ ${real.error}`; - else if (!real.fix) uiStatus = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`; - else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) { - const nearest = Math.min( - ...activeStations().map((s) => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)), - ); - uiStatus = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`; - } else - uiStatus = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`; - ui.patch('gps-status', { - text: uiStatus, - }); - if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) { - player.speed = 0; - return; - } - // Walk the avatar towards the (building-free) GPS point instead of teleporting it. - const target = resolveCircle(collision, real.fix.x, real.fix.z, 1); - const dx = target.x - player.x, - dz = target.z - player.z, - d = Math.hypot(dx, dz); - if (d > 150) - Object.assign(player, { - x: target.x, - z: target.z, - }); - const speed = d > 0.5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0; - player.speed += (speed - player.speed) * Math.min(1, dt * 6); - if (d > 0.5) { - const step = Math.min(d, player.speed * dt); - player.x += (dx / d) * step; - player.z += (dz / d) * step; - } - const want = real.heading ?? (d > 0.5 ? Math.atan2(-dz, dx) : player.heading); - // Stable mode also calms the compass, which jitters near cars and metal. - player.heading += - Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * - Math.min(1, dt * (stableMode ? 2 : 5)); - player.gait += dt * player.speed * 1.9; - } - - // ---------- Loop ---------- - const camPos = new THREE.Vector3(), - camTarget = new THREE.Vector3(), - lookAt = new THREE.Vector3(); - let elapsed = 0, - last = performance.now(), - hudTimer = 0, - streetTimer = 0; - // Debug runs (headless checks render ~1 fps) may simulate a whole second per frame, still in substeps. - const MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : 0.25; - function resize() { - renderer.setSize(innerWidth, innerHeight); - camera.aspect = innerWidth / innerHeight; - camera.updateProjectionMatrix(); - renderMap(); - } - listen(window, 'resize', resize); - function updateWalk(dt) { - const forward = { - x: -Math.sin(cam.yaw), - z: -Math.cos(cam.yaw), - }, - right = { - x: Math.cos(cam.yaw), - z: -Math.sin(cam.yaw), - }; - const iy = axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, - ix = axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x; - const frozen = state.busy || 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 = keys.has('shift'); - const target = frozen || len < 0.1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len); - player.speed += (target - player.speed) * Math.min(1, dt * 8); - if (len > 0.1 && !frozen) { - dx /= len; - dz /= len; - const want = Math.atan2(-dz, dx); - player.heading += - Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * - Math.min(1, dt * 9); - } - // Face the machine while buying. - const station = payment.station ?? payment.delivering?.station; - if (frozen && station) { - const want = Math.atan2(-(station.z - player.z), station.x - player.x); - player.heading += - Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * - Math.min(1, dt * 6); - } - const mx = Math.cos(player.heading) * player.speed * dt, - mz = -Math.sin(player.heading) * player.speed * dt; - const p = resolveCircle(collision, player.x + mx, player.z + mz, 1); - const { minX, minZ, maxX, maxZ } = data.bounds; - player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15); - player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15); - player.gait += dt * player.speed * 1.9; - } - - // Collision log for ?debug runs; trimmed so it never grows without bound. - const debugHits = []; - function updateDrive(dt) { - if (debugHits.length > 200) debugHits.splice(0, 100); - const throttle = THREE.MathUtils.clamp( - axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, - -1, - 1, - ); - const steer = THREE.MathUtils.clamp( - axis(['d', 'arrowright'], ['a', 'arrowleft']) + 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 or fence. - const slices = Math.max(1, Math.ceil((Math.abs(car.speed) * dt) / 0.6)); - for (let i = 0; i < slices; i++) { - stepCar( - car, - { - throttle, - steer, - handbrake: keys.has(' '), - }, - dt / slices, - ); - // Two collision circles along the body keep the nose and tail out of buildings. - for (const along of [1.3, -1.3]) { - const cx = car.x + Math.cos(car.heading) * along, - cz = car.z - Math.sin(car.heading) * along; - const p = resolveCircle(collision, cx, cz, 1.05); - if (p.hit) { - 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) tone(90, 0.2, 0.08, 'square'); - car.speed *= 0.35; - } - } - } - // Bumping a traffic car slows you down but never throws you backwards. If you stay pressed - // against one, it pulls away (respawns out of sight) so you can never get stuck behind it. - for (const npc of traffic.cars) { - const dx = car.x - npc.x, - dz = car.z - npc.z, - d = Math.hypot(dx, dz); - if (d >= 3.4 || d === 0) { - if (npc.contact) npc.contact = 0; - continue; - } - debugHits.push({ - kind: 'traffic', - x: Math.round(car.x), - z: Math.round(car.z), - v: +car.speed.toFixed(1), - npcDir: npc.dir, - }); - car.x = npc.x + (dx / d) * 3.4; - car.z = npc.z + (dz / d) * 3.4; - const towards = - (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0; - if (towards && Math.abs(car.speed) > 4) tone(110, 0.25, 0.08, 'square'); - if (towards) car.speed *= 0.4; - npc.contact = (npc.contact ?? 0) + dt; - if (npc.contact > 0.8) { - npc.contact = 0; - traffic.remove(npc); - } - } - const { minX, minZ, maxX, maxZ } = data.bounds; - car.x = THREE.MathUtils.clamp(car.x, minX + 15, maxX - 15); - car.z = THREE.MathUtils.clamp(car.z, minZ + 15, maxZ - 15); - Object.assign(player, { - x: car.x, - z: car.z, - heading: car.heading, - }); - // Chase camera swings back behind the car when the mouse is idle. - if (elapsed - cam.lastMouse > 1.4 && Math.abs(car.speed) > 1) { - const want = - Math.atan2(-Math.cos(car.heading), Math.sin(car.heading)) + (car.speed < 0 ? Math.PI : 0); - cam.yaw += - Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 2.2); - cam.pitch += (0.28 - cam.pitch) * Math.min(1, dt * 1.5); - } - if (engine && soundOn) { - engine.o.frequency.setTargetAtTime( - 45 + Math.abs(car.speed) * 5 + Math.max(0, throttle) * 15, - audio.currentTime, - 0.1, - ); - engine.g.gain.setTargetAtTime(0.025, audio.currentTime, 0.2); - } - } - function updateCamera(dt) { - const station = payment.station; - if (station && payment.phase !== 'idle') { - // Close-up of the card reader during payment. - const reader = station.model.readerPoint.clone(); - station.model.group.localToWorld(reader); - const side = rotateLocal(1.2, 1.9, station.rot); - camTarget.set(reader.x + side.x, reader.y + 0.4, reader.z + side.z); - lookAt.lerp(reader, 1 - Math.exp(-dt * 6)); - } else { - const driving = mode === 'drive', - height = driving ? 1.6 : 1.75; - const focus = new THREE.Vector3(player.x, height, player.z); - // Over-the-shoulder offset to the right, like a third-person shooter. - const shoulder = driving ? 0 : 0.75; - focus.x += Math.cos(cam.yaw) * shoulder; - focus.z += -Math.sin(cam.yaw) * shoulder; - let distance = cam.distance; - const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch), - oz = Math.cos(cam.yaw) * Math.cos(cam.pitch); - const free = clearFraction( - collision, - focus.x, - focus.z, - focus.x + ox * distance, - focus.z + oz * distance, - 0.8, - ); - if (cam.pitch < 0.9) distance = Math.max(1.6, distance * free - 0.7); - camTarget.set( - focus.x + ox * distance, - Math.max(0.6, focus.y + Math.sin(cam.pitch) * distance), - focus.z + oz * distance, - ); - lookAt.lerp(focus, 1 - Math.exp(-dt * 14)); - } - camPos.lerp(camTarget, 1 - Math.exp(-dt * 12)); - camera.position.copy(camPos); - camera.lookAt(lookAt); - } - function updateHUD(dt) { - const pos = mode === 'drive' ? car : player; - ui.patch('clock', { - text: formatClock(state.minutes), - }); - if (mode === 'drive') - ui.patch('speed', { - text: fa(kmh(car)), - }); - // Where the camera actually looks: the orbit camera, or the phone itself in AR view. - const look3 = 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(cam.yaw), - z: -Math.cos(cam.yaw), - }; - const headingDeg = bearing(forward.x, forward.z); - const objective = objectiveStation(); - drawCompass( - headingDeg, - objective - ? [ - { - bearing: bearing(objective.x - pos.x, objective.z - pos.z), - color: '#f2c94c', - }, - ] - : [], - ); - drawMinimap(minimap, mapAssets, { - size: 220, - x: pos.x, - z: pos.z, - heading: mode === 'drive' ? car.heading : player.heading, - camForward: forward, - radius: mode === 'drive' ? 650 : 200, - markers: markers(), - route: route?.points, - }); - // Objective marker above the machine, clamped to the screen edge when off-screen. - 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(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; - } - ui.style('waypoint', { - left: `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`, - }); - ui.style('waypoint', { - top: `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`, - }); - ui.patch('waypoint-name', { - text: objective.def.name, - }); - ui.patch('waypoint-distance', { - text: formatDistance(d), - }); - ui.patch('waypoint', { - hidden: d < 6, - }); - for (const s of stations) { - const isObjective = s === objective; - s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9'); - s.model.beacon.material.opacity = - (isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40); - } - } - streetTimer -= dt; - if (streetTimer <= 0) { - streetTimer = 0.6; - const street = nearestSegment(segments, pos.x, pos.z, 35, (s) => s[5] >= 0); - const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130; - ui.patch('street', { - text: inFactory - ? `کارخانهٔ ${FACTORY.name}` - : street - ? `مشهد · ${data.names[street.name]}` - : 'حومهٔ مشهد', - }); - } - updatePrompt(); - } - let hints = { - closed: false, - car: false, - }; - - // People in the factory say their lines once, when the family walks up to them. - function updateTalks() { - if (mode !== 'walk' || real.on || lines.length > 2) return; - for (const t of factory.talks) { - if (t.done || (t.ready && !t.ready())) continue; - const p = t.pos(); - if (Math.hypot(p.x - player.x, p.z - player.z) > t.radius) continue; - t.done = true; - for (const text of t.lines) say(t.speaker, text); - return; - } - } - - // ---------- 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. - const guideState = { - station: null, - t: 0, - greeted: new Set(), - arrived: 0, - }; - const guideOut = () => guideState.t >= 1; - const easeOutBack = (t) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2; - function greet(station) { - const def = station.def, - first = !guideState.greeted.has(def.id); - guideState.greeted.add(def.id); - if (def.id === 'factory' && first) { - say( - 'robot', - 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!', - 'excited', - ); - say( - 'robot', - `به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, - 'happy', - ); - say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink'); - say( - 'robot', - 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', - 'love', - ); - return; - } - if (!isOpen(def, state.minutes)) { - say( - 'robot', - `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`, - 'serious', - ); - return; - } - if (first) { - say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited'); - say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy'); - } else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink'); - } - function updateGuide(dt) { - const near = - mode === 'walk' ? closestStation(player.x, player.z, Math.max(9, reachRadius())) : null; - // The robot only moves to another machine after it has gone back into the previous one. - if (near && !guideState.station) guideState.station = near; - const s = guideState.station, - wasOut = guideOut(); - if (!s) return; - guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1); - if (guideOut() && !wasOut) { - guideState.arrived = elapsed; - greet(s); - tone(990, 0.15); - tone(1320, 0.2); - } - if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy'); - if (!guideState.t) { - guideState.station = null; - guide.group.visible = false; - return; - } - // From inside the machine front out to its left side (the card reader is on the right). - const e = easeOutBack(guideState.t), - from = { - x: -0.2, - y: 0.9, - z: 0.2, - }, - to = { - x: -1.7, - y: 0, - z: 1.3, - }; - const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot); - guide.group.visible = true; - guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z); - guide.group.scale.setScalar(0.15 + 0.85 * Math.min(1, e)); - // Face the family (the rig looks along its local +X). - guide.group.rotation.y = Math.atan2( - -(player.z - guide.group.position.z), - player.x - guide.group.position.x, - ); - if (!state.busy && payment.phase === 'idle') - s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0; - guide.update({ - dt, - time: elapsed, - gait: 0, - moving: false, - talking, - wave: elapsed - guideState.arrived < 2.5 || !guideOut(), - point: talking && guideOut(), - }); - } - function updateHints() { - const target = objectiveStation(); - if (target && !isOpen(target.def, state.minutes) && !hints.closed) { - hints.closed = true; - say( - 'robot', - `${target.def.name} الان بسته است (${hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`, - ); - } - if (route && route.length > 900 && mode === 'walk' && !hints.car) { - hints.car = true; - say('father', 'راه طولانیه؛ بهتره با ماشین بریم.'); - } - } - function frame() { - const now = performance.now(); - frameId = requestAnimationFrame(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 - last) / 1000, 0, MAX_FRAME); - last = now; - elapsed += dt; - const playing = ui.read('briefing').hidden; - const paused = - !playing || - ui.read('shop').open || - ui.read('map-dialog').open || - ui.read('end').open || - document.hidden; - const steps = Math.max(1, Math.ceil(dt / (1 / 30))), - step = dt / steps; - if (!playing) { - // Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay. - const ic = $('#iran'), - dpr = sizeCanvas(ic), - c = ic.getContext('2d'); - c.setTransform(dpr, 0, 0, dpr, 0, 0); - drawIran(c, iran, { - width: ic.width / dpr, - height: ic.height / dpr, - t: elapsed, - cities: CITIES, - }); - briefChars = Math.min(BRIEF.length, briefChars + dt * 55); - ui.patch('brief-text', { - text: BRIEF.slice(0, Math.floor(briefChars)) + (briefChars < BRIEF.length ? '▌' : ''), - }); - } - if (!paused) { - for (let i = 0; i < steps; i++) { - state.minutes += (step * 10) / 60; - if (real.on) updateReal(step); - else if (mode === 'drive') updateDrive(step); - else updateWalk(step); - updatePayment(step); - updateDelivery(step); - traffic.update( - step, - [ - { - x: player.x, - z: player.z, - }, - { - x: car.x, - z: car.z, - }, - ], - player, - ); - } - updateRoute(dt); - updateHints(); - updateTalks(); - updateGuide(dt); - if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd(); - } else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.1); - if (mode === 'walk' && engine && soundOn) - engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.2); - updateSubtitles(dt); - family.group.visible = mode === 'walk' && !real.on; - family.group.position.set(player.x, 0, player.z); - family.group.rotation.y = player.heading; - family.update({ - dt, - time: elapsed, - gait: player.gait, - moving: player.speed > 0.4, - shopping: state.busy || payment.phase !== 'idle', - delivery: deliveryTimer > 0, - }); - factory.update(elapsed, paused ? 0 : dt, camera.position); - carModel.group.position.set(car.x, 0, car.z); - carModel.group.rotation.y = car.heading; - for (const [i, wheel] of carModel.wheels.entries()) { - wheel.rotation.z -= (car.speed * dt) / 0.36; - if (i >= 2) wheel.rotation.y = -car.steer; - } - for (const s of stations) s.model.ring.material.opacity = 0.35 + Math.sin(elapsed * 3) * 0.25; - updateReachCircles(); - if (routeMesh) routeMaterial.opacity = 0.55 + Math.sin(elapsed * 4) * 0.15; - if (playing && real.on) { - // First person: the phone is the camera, at the player's GPS position and eye height. - camera.position.set(player.x, 1.6, player.z); - if (!arView.update(camera, dt, stableMode ? 5 : 10)) { - // No orientation sensor: face the compass or walking direction, or wherever the player drags. - if (real.heading != null && elapsed - cam.lastMouse > 2) { - const want = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)); - cam.yaw += - Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 3); - } - camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ'); - } - camPos.copy(camera.position); - } else if (playing) { - if (camera.fov !== 62) { - camera.fov = 62; - camera.updateProjectionMatrix(); - } - updateCamera(dt); - } else { - const a = elapsed * 0.08; - camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30); - camera.lookAt(player.x, 2, player.z); - camPos.copy(camera.position); - } - sun.position.set(player.x + 160, 230, player.z + 110); - sun.target.position.set(player.x, 0, player.z); - sky.position.copy(camera.position); - city.update(camera.position); - if (!arMode) imagery.update(camera.position.x, camera.position.z); - if (playing) { - hudTimer -= dt; - updateHUD(dt); - if (ui.read('map-dialog').open && hudTimer <= 0) { - hudTimer = 0.5; - renderMap(); - } - } - renderer.render(scene, camera); - } - - // ?debug exposes live game state in the console for development and automated checks. - if (new URLSearchParams(location.search).has('debug')) - window.game = { - get state() { - return state; - }, - get player() { - return player; - }, - get cam() { - return cam; - }, - factory, - get car() { - return car; - }, - get mode() { - return mode; - }, - get payment() { - return payment; - }, - get elapsed() { - return elapsed; - }, - get route() { - return route; - }, - setWaypoint(id) { - waypoint = id; - route = null; - routeTimer = 0; - }, - get arMode() { - return arMode; - }, - arView, - setArMode, - debugHits, - nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, (s) => s[4] <= 3), - stations, - guideState, - real, - keys, - traffic, - }; - reset(); - resize(); - - ui.patch('loading', { - hidden: true, - }); - frameId = requestAnimationFrame(frame); - return () => { - cancelAnimationFrame(frameId); - timers.forEach(window.clearTimeout); - real.stop?.(); - arView.stop(); - arView.stopSensors(); - imagery.dispose?.(); - audio?.close(); - const geometries = new Set(), - materials = new Set(), - textures = new Set(); - scene.traverse((object) => { - if (object.geometry) geometries.add(object.geometry); - const list = Array.isArray(object.material) ? object.material : [object.material]; - for (const material of list) { - if (!material) continue; - materials.add(material); - for (const value of Object.values(material)) if (value?.isTexture) textures.add(value); - } - }); - geometries.forEach((item) => item.dispose()); - materials.forEach((item) => item.dispose()); - textures.forEach((item) => item.dispose()); - renderer.dispose(); - if (document.pointerLockElement === canvas) document.exitPointerLock?.(); - if (window.game) delete window.game; - ui.clearActions(); - }; + return context.dispose ?? (() => context.renderer?.dispose()); } diff --git a/src/game/device/ar.js b/src/game/device/ar.js index 913c660..0c0c362 100644 --- a/src/game/device/ar.js +++ b/src/game/device/ar.js @@ -6,7 +6,6 @@ import * as THREE from 'three'; // 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(); @@ -14,6 +13,15 @@ const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera lo // 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'); @@ -24,6 +32,12 @@ export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) { } // 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. @@ -33,7 +47,11 @@ export function coverFov(videoAspect, screenAspect) { 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, @@ -42,11 +60,25 @@ export function createArView(video) { 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 }; + 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; @@ -55,7 +87,11 @@ export function createArView(video) { compassOffset = 360 - event.webkitCompassHeading - event.alpha; } if (!event.absolute && compassOffset != null) alpha += compassOffset; - orientation = { alpha, beta: event.beta, gamma: event.gamma }; + orientation = { + alpha, + beta: event.beta, + gamma: event.gamma, + }; }; let sensing = false; return { @@ -88,9 +124,15 @@ export function createArView(video) { try { stream = await navigator.mediaDevices.getUserMedia({ video: { - facingMode: { ideal: 'environment' }, - width: { ideal: 1280 }, - height: { ideal: 720 }, + facingMode: { + ideal: 'environment', + }, + width: { + ideal: 1280, + }, + height: { + ideal: 720, + }, }, audio: false, }); diff --git a/src/game/device/pwa.js b/src/game/device/pwa.js index 62a0a68..7371de1 100644 --- a/src/game/device/pwa.js +++ b/src/game/device/pwa.js @@ -14,30 +14,50 @@ export function setupPwa(ui) { const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - if (!standalone && ios) ui.patch('install', { hidden: false }); + if (!standalone && ios) + ui.patch('install', { + hidden: false, + }); window.addEventListener( 'beforeinstallprompt', (event) => { event.preventDefault(); deferred = event; - ui.patch('install', { hidden: false }); + ui.patch('install', { + hidden: false, + }); + }, + { + signal, + }, + ); + window.addEventListener( + 'appinstalled', + () => + ui.patch('install', { + hidden: true, + }), + { + signal, }, - { 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 }); + 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.patch('update-toast', { + hidden: false, + }); ui.on('update-reload', () => worker.postMessage('skip-waiting')); }; navigator.serviceWorker @@ -55,10 +75,14 @@ export function setupPwa(ui) { if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker); }, - { signal }, + { + signal, + }, ); }, - { signal }, + { + signal, + }, ); }) .catch(() => {}); @@ -69,7 +93,9 @@ export function setupPwa(ui) { reloading = true; location.reload(); }, - { signal }, + { + signal, + }, ); } return () => { diff --git a/src/game/device/realworld.js b/src/game/device/realworld.js index 050e22a..64570c3 100644 --- a/src/game/device/realworld.js +++ b/src/game/device/realworld.js @@ -6,8 +6,17 @@ import { toLocal } from '../../utils/geo.js'; 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 }; + 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, @@ -32,6 +41,11 @@ export function smoothFix(previous, fix) { // 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, @@ -48,14 +62,20 @@ export function createStabilizer({ maxSpeed = 3 } = {}) { rejected < 3 ) { rejected++; - return shown && { ...shown }; + 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 }; + shown = { + ...estimate, + }; else shown = { ...shown, @@ -63,21 +83,39 @@ export function createStabilizer({ maxSpeed = 3 } = {}) { time: estimate.time, speed: fix.speed ?? 0, }; - return { ...shown }; + 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, @@ -94,6 +132,11 @@ export function startTracking({ 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); @@ -104,8 +147,18 @@ export function startTracking({ time: position.timestamp, }); fixes++; - if (shown) onFix({ ...shown, lat, lon }); + 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( @@ -131,6 +184,11 @@ export function startTracking({ 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 ?? @@ -148,6 +206,11 @@ export function startTracking({ } // 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) diff --git a/src/game/economy/gameState.js b/src/game/economy/gameState.js index 9f7980c..687caf9 100644 --- a/src/game/economy/gameState.js +++ b/src/game/economy/gameState.js @@ -1,34 +1,121 @@ // 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) => `${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: 'آب‌میوه', 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' }, + 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', + }, }; - +/** + * 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, @@ -41,6 +128,13 @@ export function createState() { } // 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'; @@ -48,7 +142,13 @@ export function purchaseBlocker(state, machine, productId) { 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], @@ -67,6 +167,12 @@ export function purchase(state, machine, productId) { } // 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) => diff --git a/src/game/economy/payment.js b/src/game/economy/payment.js index 6d62d7a..f8df6c9 100644 --- a/src/game/economy/payment.js +++ b/src/game/economy/payment.js @@ -1,23 +1,54 @@ import { purchase, purchaseBlocker } from './gameState.js'; // idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once). -export function createPayment() { - return { phase: 'idle', machine: null, productId: null, record: null }; -} +/** + * 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 }); + 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; @@ -31,7 +62,12 @@ export function swipePayment(state, payment) { 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()); diff --git a/src/game/economy/scoring.js b/src/game/economy/scoring.js index adbef0d..c4e72cc 100644 --- a/src/game/economy/scoring.js +++ b/src/game/economy/scoring.js @@ -2,27 +2,66 @@ import { CATEGORIES } from '../../constants/locations.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 POINTS = { + purchase: 100, + discovery: 250, + combo: 150, + allCategories: 500, +}; export const RANKS = [ - { name: 'تازه‌وارد', xp: 0 }, - { name: 'کاوشگر', xp: 600 }, - { name: 'راه‌بلد', xp: 1400 }, - { name: 'شکارچی دستگاه', xp: 2600 }, - { name: 'استاد نقشه', xp: 4200 }, - { name: 'افسانهٔ مشهد', xp: 6500 }, + { + name: 'تازه‌وارد', + xp: 0, + }, + { + name: 'کاوشگر', + xp: 600, + }, + { + name: 'راه‌بلد', + xp: 1400, + }, + { + name: 'شکارچی دستگاه', + xp: 2600, + }, + { + name: 'استاد نقشه', + xp: 4200, + }, + { + name: 'افسانهٔ مشهد', + 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: 'خرید موفق', points: POINTS.purchase }]; + const awards = [ + { + label: 'خرید موفق', + points: POINTS.purchase, + }, + ]; const here = previous.filter((p) => p.machineId === record.machineId); - if (!here.length) awards.push({ label: 'کشف مکان جدید', points: POINTS.discovery }); + if (!here.length) + awards.push({ + label: 'کشف مکان جدید', + 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: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo }); + awards.push({ + label: 'ترکیب نوشیدنی و خوراکی', + points: POINTS.combo, + }); const categories = new Set( previous.map((p) => p.category).filter((c) => CITY_CATEGORIES.includes(c)), ); @@ -31,10 +70,17 @@ export function awardsFor(previous, record) { !categories.has(record.category) && categories.size === CITY_CATEGORIES.length - 1 ) - awards.push({ label: 'همهٔ انواع مکان', points: POINTS.allCategories }); + awards.push({ + label: 'همهٔ انواع مکان', + 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++; @@ -47,9 +93,12 @@ export function rankFor(xp) { progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1, }; } - +/** + * Runs the places visited operation for this subsystem. + * @param {*} purchases - purchases. + * @returns {*} Operation result, when applicable. + */ const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).size; - export const MISSIONS = [ { id: 'factory', @@ -110,6 +159,11 @@ export const MISSIONS = [ ]; // Marks and returns missions that the current purchases complete for the first time. +/** + * Runs the complete missions operation for this subsystem. + * @param {*} state - state. + * @returns {*} Operation result, when applicable. + */ export function completeMissions(state) { const fresh = MISSIONS.filter( (m) => !state.missionsDone.includes(m.id) && m.done(state.purchases), @@ -121,6 +175,10 @@ export function completeMissions(state) { } return fresh; } - +/** + * Runs the active mission operation for this subsystem. + * @param {*} state - state. + * @returns {*} Operation result, when applicable. + */ export const activeMission = (state) => MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null; diff --git a/src/game/interfaceBridge.js b/src/game/interfaceBridge.js index e2e62f6..12750a4 100644 --- a/src/game/interfaceBridge.js +++ b/src/game/interfaceBridge.js @@ -6,7 +6,14 @@ import { publishProgress } from '../store/slices/gameSlice.js'; * @returns {object} View publication and action registration API. */ export function createInterfaceBridge(store) { - let nodes = { briefing: { hidden: false }, hud: { hidden: true } }, + let nodes = { + briefing: { + hidden: false, + }, + hud: { + hidden: true, + }, + }, actions = new Map(), timer = null, disposed = false; @@ -19,18 +26,45 @@ export function createInterfaceBridge(store) { 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 } }; + 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 }), + 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 } }), + 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() { diff --git a/src/game/models/carModels.js b/src/game/models/carModels.js index 657810d..fb0663d 100644 --- a/src/game/models/carModels.js +++ b/src/game/models/carModels.js @@ -3,10 +3,28 @@ 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. +/** + * 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.45, ...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. + */ 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))); @@ -25,6 +43,15 @@ function extrudeProfile(points, width, material, bevel = 0.1) { } // 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. + */ function bodyOutline(front, rear, arches, radius, top) { const shape = new THREE.Shape(); shape.moveTo(rear[0][0], rear[0][1]); @@ -39,11 +66,27 @@ function bodyOutline(front, rear, arches, radius, top) { for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y); return shape; } - +/** + * 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. + */ 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 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(); @@ -86,10 +129,21 @@ function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', sp */ 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 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( [ @@ -203,8 +257,14 @@ export function createSportSedan(color = '#1d3f78') { } } // 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 }); + 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); @@ -245,7 +305,10 @@ export function createSportSedan(color = '#1d3f78') { '#b8bcc0', 5, ); - return { group, wheels }; + return { + group, + wheels, + }; } /** @@ -254,10 +317,32 @@ export function createSportSedan(color = '#1d3f78') { */ export function createPickup(color = '#e8e8e4') { const group = new THREE.Group(); - const paint = mat(color, { metalness: 0.5, roughness: 0.32 }), - glass = mat('#13202a', { metalness: 0.6, roughness: 0.1 }); - const black = mat('#1a1c1e', { roughness: 0.7 }), - chrome = mat('#d0d4d7', { metalness: 0.9, roughness: 0.2 }); + const paint = mat(color, { + metalness: 0.5, + roughness: 0.32, + }), + glass = mat('#13202a', { + metalness: 0.6, + roughness: 0.1, + }); + const black = mat('#1a1c1e', { + roughness: 0.7, + }), + chrome = mat('#d0d4d7', { + metalness: 0.9, + roughness: 0.2, + }); + /** + * Runs the box operation for this subsystem. + * @param {*} w - w. + * @param {*} h - h. + * @param {*} d - d. + * @param {*} m - m. + * @param {*} x - x. + * @param {*} y - y. + * @param {*} z - z. + * @returns {*} Operation result, when applicable. + */ const box = (w, h, d, m, x, y, z) => { const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); mesh.position.set(x, y, z); @@ -315,14 +400,20 @@ export function createPickup(color = '#e8e8e4') { // Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate. box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0); for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0); - const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.2 }); + const lamp = mat('#eaf6ff', { + emissive: '#cfe9ff', + emissiveIntensity: 1.2, + }); for (const side of [-1, 1]) { box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74); box( 0.06, 0.26, 0.1, - mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.7 }), + mat('#9e1410', { + emissive: '#c4241b', + emissiveIntensity: 0.7, + }), -2.7, 1.0, side * 0.86, @@ -349,7 +440,10 @@ export function createPickup(color = '#e8e8e4') { ); for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, 0.1, 0.2, black, x, 0.86, side * 0.96); - return { group, wheels }; + return { + group, + wheels, + }; } /** @@ -358,10 +452,33 @@ export function createPickup(color = '#e8e8e4') { */ 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('#0b8f88', { metalness: 0.3, roughness: 0.4 }); + 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('#0b8f88', { + 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); @@ -375,14 +492,27 @@ export function createTruck(color = '#e9eef0') { 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 }); + 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); + 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); @@ -391,7 +521,10 @@ export function createTruck(color = '#e9eef0') { 0.1, 0.22, 0.26, - mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.6 }), + mat('#9e1410', { + emissive: '#c4241b', + emissiveIntensity: 0.6, + }), -4.02, 0.82, side * 0.95, @@ -412,5 +545,9 @@ export function createTruck(color = '#e9eef0') { '#c2c6c9', 8, ); - return { group, wheels, bed }; + return { + group, + wheels, + bed, + }; } diff --git a/src/game/models/characters.js b/src/game/models/characters.js index 1719b78..b1675c7 100644 --- a/src/game/models/characters.js +++ b/src/game/models/characters.js @@ -2,310 +2,410 @@ 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 group = new THREE.Group(); - const mat = (color) => new THREE.MeshStandardMaterial({ color, roughness: 0.82 }); - const skin = mat('#edbd98'), - hair = mat('#49382d'), - shoes = mat('#273c36'); + 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()); +} - 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(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. - function makeRobot(z) { - const scale = 0.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: 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, - }); - sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]); - sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]); - for (const side of [-1, 1]) sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]); - 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); - sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]); - sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]); - sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.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, 0.065, 0.016, [side * 0.16, 0.025, 0.423])); - const ear = 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 = shape(head, new THREE.TorusGeometry(0.105, 0.012, 8, 24), glow, [ - side * 0.518, - 0, - 0, - ]); - ring.rotation.y = Math.PI / 2; - 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); - sphere(head, 0.1, teal, [0, 0.38, 0], [1, 0.55, 1]); - shape(head, new THREE.CylinderGeometry(0.017, 0.022, 0.18, 10), teal, [0, 0.49, 0]); - 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); - 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); - sphere(hip, 0.09, joint, [0, 0, 0]); - 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); - sphere(knee, 0.105, teal, [0, 0, 0.035]); - shape(knee, new THREE.CapsuleGeometry(0.1, 0.12, 4, 12), white, [0, -0.13, 0]); - const foot = sphere(knee, 0.13, teal, [0, -0.29, 0.07], [1, 0.65, 1.6]); - 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: segment(group, null, null, 0.09, teal), - lower: segment(group, null, null, 0.085, white), - hand: sphere(group, 0.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 ? 0.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(0.255 * scale, 0.22 * scale, 0.62 * scale, 16), cloth, [ - 0, - 1.2 * scale, - 0, - ]).scale.z = 0.72; - sphere(torso, 0.23, pants, [0, 0.88 * scale, 0], [scale, 0.55 * scale, 0.72 * scale]); - shape(torso, new THREE.CylinderGeometry(0.075 * scale, 0.09 * scale, 0.18 * scale, 12), skin, [ - 0, - 1.59 * scale, - 0, - ]); - shape(torso, new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale), mat('#bda77f'), [ - 0, - 1.2 * scale, - 0.19 * scale, - ]); - for (const side of [-1, 1]) { - const collar = 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 = []; - sphere(head, 0.27 * scale, skin, [0, 0, 0]); - sphere(head, 0.275 * scale, hair, [0, 0.11 * scale, -0.035 * scale], [1, 0.65, 1]); - sphere(head, 0.058 * scale, skin, [0, -0.02 * scale, 0.25 * scale]); - for (const side of [-1, 1]) { - sphere(head, 0.06 * scale, skin, [side * 0.26 * scale, 0, 0]); - sphere( - head, - 0.037 * scale, - mat('#f6f1e7'), - [side * 0.09 * scale, 0.03 * scale, 0.241 * scale], - [1, 0.65, 0.5], - ); - const eye = sphere( - head, - 0.022 * scale, - shoes, - [side * 0.09 * scale, 0.03 * scale, 0.258 * scale], - [0.8, 1, 0.45], - ); - eyes.push(eye); - const brow = shape( - head, - new THREE.BoxGeometry(0.075 * scale, 0.015 * scale, 0.015 * scale), - hair, - [side * 0.09 * scale, 0.087 * scale, 0.237 * scale], - ); - brow.rotation.z = side * -0.08; - sphere(head, 0.04 * scale, mat('#d79379'), [ - side * 0.15 * scale, - -0.055 * scale, - 0.215 * scale, - ]); - } - const smile = shape( - head, - new THREE.TorusGeometry(0.068 * scale, 0.012 * scale, 6, 14, Math.PI), - shoes, - [0, -0.075 * scale, 0.252 * scale], +// 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, ); - smile.rotation.z = Math.PI; - if (isChild) { - sphere(head, 0.29 * scale, mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]); - shape(head, new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale), mat('#e8b857'), [ - 0, - 0.13 * scale, - 0.12 * scale, - ]); - } else { - sphere(torso, 0.22, 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); - 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); - shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [ - 0, - -0.16 * scale, - 0, - ]); - const foot = shape(knee, new THREE.SphereGeometry(0.12 * scale, 12, 8), 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: segment(group, null, null, 0.072 * scale, cloth), - lower: segment(group, null, null, 0.061 * scale, skin), - hand: sphere(group, 0.075 * scale, skin, [0, 0, 0]), - })); - return { root, torso, head, legs, arms, scale, z, eyes }; + line.rotation.z = rotation; + return line; } - - const father = robotOnly ? null : makePerson(false, -0.45), - child = robotOnly ? null : makePerson(true, 0.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({ + 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, @@ -317,95 +417,143 @@ export function createFamily({ robot: robotOnly = 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 ? -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 === 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; - }); - }); - group.updateMatrixWorld(true); - const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone(); - const shoulder = (actor, side) => - toFamily( - actor, - new THREE.Vector3( - side * 0.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) => - 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(0.5, delivery ? 0.65 : 1.1, -0.15); - else if (actor === 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 === 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)); - updateSegment(arm.upper, a, elbow); - updateSegment(arm.lower, elbow, target); - arm.hand.position.copy(target); - }), + }, +) { + 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, ); - } - - update({ time: 0, gait: 0, moving: false }); - return { group, update }; + // 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]; diff --git a/src/game/models/factory.js b/src/game/models/factory.js index 2ef2540..6e06817 100644 --- a/src/game/models/factory.js +++ b/src/game/models/factory.js @@ -5,7 +5,16 @@ import { createPickup, createTruck } from './carModels.js'; import { createPerson, createCrew, nameTag } from './staff.js'; import { FACTORY } from '../../constants/locations.js'; import { textTexture } from './textures.js'; - +/** + * Runs the stripes operation for this subsystem. + * @param {*} c - c. + * @param {*} w - w. + * @param {*} h - h. + * @param {*} base - base. + * @param {*} line - line. + * @param {*} every - every. + * @returns {*} Operation result, when applicable. + */ function stripes(c, w, h, base, line, every) { c.fillStyle = base; c.fillRect(0, 0, w, h); @@ -14,6 +23,12 @@ function stripes(c, w, h, base, line, every) { } // Persian sign: white text on the company's dark teal. +/** + * Runs the sign operation for this subsystem. + * @param {*} text - text. + * @param {*} sub - sub. + * @returns {*} Operation result, when applicable. + */ const sign = (text, sub = '') => textTexture(512, 160, (c, w, h) => { c.fillStyle = '#0d2f2c'; @@ -33,6 +48,12 @@ const sign = (text, sub = '') => }); // Monitor contents: a few lines of code (or rows) on a dark editor background. +/** + * Runs the screen operation for this subsystem. + * @param {*} lines - lines. + * @param {*} background - background. + * @returns {*} Operation result, when applicable. + */ const screen = (lines, background = '#14181f') => textTexture(512, 300, (c, w, h) => { c.fillStyle = background; @@ -123,35 +144,298 @@ const CODE = { * 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, + const build = { + scene, + }; + for (const [name, method] of Object.entries(FACTORY_METHODS)) + build[name] = (...args) => method(build, ...args); + for (const initialize of FACTORY_STEPS) initialize(build); + return build.result; +} +// Desk facing `facing` (the side the person sits on) with one or two monitors. +/** + * Runs the factory desk operation for this subsystem. + * @param {*} build - build. + * @param {*} u - u. + * @param {*} v - v. + * @param {*} sitSide - sit Side. + * @param {*} screens - screens. + * @param {*} width - width. + * @returns {*} Operation result, when applicable. + */ +function factoryDesk(build, u, v, sitSide, screens, width = 1.8) { + build.box(u - width / 2, u + width / 2, v - 0.45, v + 0.45, 0.72, 0.77, build.wood); + for (const du of [-1, 1]) + build.box( + u + du * (width / 2 - 0.05) - 0.03, + u + du * (width / 2 - 0.05) + 0.03, + v - 0.4, + v + 0.4, + 0, + 0.72, + build.black, + false, + ); + screens.forEach((texture, i) => { + const du = screens.length > 1 ? (i - 0.5) * 0.78 : 0; + const mats = [build.black, build.black, build.black, build.black, build.black, build.black]; + mats[sitSide > 0 ? 4 : 5] = new THREE.MeshBasicMaterial({ + map: texture, + }); + const monitor = new THREE.Mesh(new THREE.BoxGeometry(0.72, 0.44, 0.04), mats); + monitor.position.set(u + du, 1.12, v - sitSide * 0.2); + monitor.rotation.y = (i - 0.5) * -0.25 * sitSide * (screens.length > 1 ? 1 : 0); + build.group.add(monitor); + build.box( + u + du - 0.03, + u + du + 0.03, + v - sitSide * 0.22 - 0.03, + v - sitSide * 0.22 + 0.03, + 0.77, + 0.9, + build.black, + false, + ); + }); + build.box( + u - 0.25, + u + 0.25, + v + sitSide * 0.12, + v + sitSide * 0.28, + 0.77, + 0.79, + build.mat('#30353b'), + false, + ); +} +// Office chair behind a seated person; the back is on the far side from the desk. +/** + * Runs the factory chair operation for this subsystem. + * @param {*} build - build. + * @param {*} u - u. + * @param {*} v - v. + * @param {*} sitSide - sit Side. + * @returns {*} Operation result, when applicable. + */ +function factoryChair(build, u, v, sitSide) { + build.box(u - 0.26, u + 0.26, v - 0.26, v + 0.26, 0.4, 0.47, build.fabric, false); + build.box( + u - 0.25, + u + 0.25, + v + sitSide * 0.26, + v + sitSide * 0.32, + 0.47, + 1.1, + build.fabric, + false, + ); + build.cylinder(0.035, 0.4, build.black, u, 0.2, v); + build.box(u - 0.3, u + 0.3, v - 0.03, v + 0.03, 0.02, 0.06, build.black, false); + build.box(u - 0.03, u + 0.03, v - 0.3, v + 0.3, 0.02, 0.06, build.black, false); + build.solid(u - 0.35, u + 0.35, v - 0.35, v + 0.35); +} +// A seated person at a desk; `sitSide` is +1 when they sit on the +v side facing −v. +/** + * Runs the factory seat operation for this subsystem. + * @param {*} build - build. + * @param {*} person - person. + * @param {*} u - u. + * @param {*} v - v. + * @param {*} sitSide - sit Side. + * @param {*} tag - tag. + * @returns {*} Operation result, when applicable. + */ +function factorySeat(build, person, u, v, sitSide, tag) { + person.root.position.set(u, 0, v); + person.root.rotation.y = sitSide > 0 ? Math.PI : 0; + build.group.add(person.root); + if (tag) { + tag.position.set(0, 1.78, 0); + person.root.add(tag); + } + build.chair(u, v, sitSide); + return person; +} + +// Engineering: five developers, a whiteboard, and the office's vending machine by the door. + +/** + * Runs the factory update arrival operation for this subsystem. + * @param {*} build - build. + * @param {*} dt - dt. + * @param {*} time - time. + * @returns {*} Operation result, when applicable. + */ +function factoryUpdateArrival(build, dt, time) { + if (build.arrival.stage === 'drive') { + build.arrival.s += dt * 7; + const p = build.along(build.DRIVE_IN, build.arrival.s); + build.placeVehicle(build.pickup, build.arrival, p, dt); + build.pickup.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4)); + build.manager.root.visible = false; + if (p.done) + Object.assign(build.arrival, { + stage: 'walk', + s: 0, + }); + } else if (build.arrival.stage === 'walk') { + build.arrival.s += dt * 1.3; + const p = build.along(build.WALK_IN, build.arrival.s); + build.manager.root.visible = true; + build.manager.root.position.set(p.u, 0, p.v); + build.manager.root.rotation.y = Math.atan2(p.du, p.dv); + build.manager.pose('walk', time); + if (p.done) build.arrival.stage = 'seated'; + } else if (!build.arrival.sat) { + build.arrival.sat = true; + build.manager.root.position.set(45, 0, -40.7); + build.manager.root.rotation.y = 0; + build.manager.pose('sit'); + } +} +/** + * Runs the factory reset arrival operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryResetArrival(build) { + Object.assign(build.arrival, { + stage: 'drive', + s: 0, + sat: false, + }); + build.placeVehicle(build.pickup, build.arrival, build.along(build.DRIVE_IN, 0), 0); + build.manager.root.visible = false; +} +/** + * Runs the factory update truck operation for this subsystem. + * @param {*} build - build. + * @param {*} dt - dt. + * @returns {*} Operation result, when applicable. + */ +function factoryUpdateTruck(build, dt) { + const d = build.delivery; + d.t += dt; + build.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] = build.SLOTS[build.loaded.length], + bed = build.truck.bed.position; + const slot = { + u: build.DOCK[0] + bed.x + sx, + y: bed.y + 0.95, + v: build.DOCK[1] + sz, + }; + if (d.t < 2) build.moving.position.set(-6 + ((1.6 + 6) * d.t) / 2, 1.05 + 0.95, 31); + else { + const k = Math.min(1, d.t - 2); + build.moving.position.set( + 1.6 + (slot.u - 1.6) * k, + 2.0 + (slot.y - 2.0) * k + Math.sin(k * Math.PI) * 0.5, + 31 + (slot.v - 31) * k, + ); + } + if (d.t >= 3) { + const box = build.packaged(); + box.position.set(sx, 0.95, sz); + build.truck.bed.add(box); + build.loaded.push(box); + d.t = 0; + if (build.loaded.length === build.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: build.DEPART, + return: build.RETURN, + back: build.BACK_IN, + }[d.stage]; + const p = build.along(path, d.s); + build.placeVehicle(build.truck, d, p, dt, d.stage === 'back'); + build.truck.wheels.forEach( + (w) => (w.rotation.z -= ((dt * speed) / 0.5) * (d.stage === 'back' ? -1 : 1)), + ); + if (p.done) { + if (d.stage === 'depart') { + Object.assign(d, { + stage: 'away', + t: 0, + }); + build.truck.group.visible = false; + build.loaded.splice(0).forEach((box) => build.truck.bed.remove(box)); + } else if (d.stage === 'return') + Object.assign(d, { + stage: 'back', + s: 0, + }); + else { + build.truckAtDock(); + Object.assign(d, { + stage: 'load', + t: 0, + }); + } + } + } else if (d.stage === 'away' && d.t > 18) { + build.truck.group.visible = true; + Object.assign(d, { + stage: 'return', + s: 0, + heading: Math.PI, + }); + } +} +/** + * Runs the factory build geometry operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry(build) { + ({ center: build.center, axis: build.axis } = FACTORY); + build.normal = { + x: -build.axis.z, + z: build.axis.x, + }; + build.rot = Math.atan2(-build.axis.z, build.axis.x); + build.toWorld = (u, v) => ({ + x: build.center.x + build.axis.x * u + build.normal.x * v, + z: build.center.z + build.axis.z * u + build.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; + build.worldHeading = (du, dv) => { + const x = build.axis.x * du + build.normal.x * dv, + z = build.axis.z * du + build.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( + build.group = new THREE.Group(); + build.group.position.set(build.center.x, 0, build.center.z); + build.group.rotation.y = build.rot; + build.scene.add(build.group); + build.footprints = []; +} +/** + * Runs the factory build solid operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildSolid(build) { + build.solid = (u0, u1, v0, v1) => + build.footprints.push( [ [u0, v0], [u1, v0], [u1, v1], [u0, v1], - ].flatMap(([u, v]) => Object.values(toWorld(u, v))), + ].flatMap(([u, v]) => Object.values(build.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) => { + build.box = (a0, a1, b0, b1, y0, y1, material, isSolid = true, parent = build.group) => { const u0 = Math.min(a0, a1), u1 = Math.max(a0, a1), v0 = Math.min(b0, b1), @@ -160,76 +444,124 @@ export function createFactory(scene) { 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); + if (isSolid) build.solid(u0, u1, v0, v1); return mesh; }; - const cylinder = (r, h, material, u, y, v, parent = group) => { + build.cylinder = (r, h, material, u, y, v, parent = build.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; }; +} +/** + * Runs the factory build panel operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildPanel(build) { // 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) => { + build.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 }), + 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); + build.group.add(mesh); return mesh; }; - const mat = (color, extra = {}) => - new THREE.MeshStandardMaterial({ color, roughness: 0.7, ...extra }); - const floorMat = (color, extra = {}) => - mat(color, { + build.mat = (color, extra = {}) => + new THREE.MeshStandardMaterial({ + color, + roughness: 0.7, + ...extra, + }); + build.floorMat = (color, extra = {}) => + build.mat(color, { polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32, ...extra, }); - const flat = (u0, u1, v0, v1, y, material) => { +} +/** + * Runs the factory build flat operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildFlat(build) { + build.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); + build.group.add(mesh); return mesh; }; - const steel = mat('#59646b', { metalness: 0.5 }), - teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 }); - const yellow = mat('#f2c94c', { roughness: 0.5 }), - darkSteel = mat('#3d4549', { metalness: 0.5 }); - const lamp = new THREE.MeshBasicMaterial({ color: '#fffbe8' }); - const animated = []; - + build.steel = build.mat('#59646b', { + metalness: 0.5, + }); + build.teal = build.mat('#0b8f88', { + metalness: 0.3, + roughness: 0.4, + }); + build.yellow = build.mat('#f2c94c', { + roughness: 0.5, + }); + build.darkSteel = build.mat('#3d4549', { + metalness: 0.5, + }); + build.lamp = new THREE.MeshBasicMaterial({ + color: '#fffbe8', + }); +} +/** + * Runs the factory build animated operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildAnimated(build) { + build.animated = []; // ---------- Yard, fence, gate ---------- - flat( + build.flat( -110, 80, -50, 50, 0.08, - floorMat('#8f8c86', { roughness: 0.95, polygonOffsetFactor: -14, polygonOffsetUnits: -28 }), + build.floorMat('#8f8c86', { + roughness: 0.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); + build.fence = build.mat('#d9d3c4'); + build.box(-110, 80, -50.3, -49.7, 0, 2.4, build.fence); + build.box(-110, 80, 49.7, 50.3, 0, 2.4, build.fence); + build.box(-110.3, -109.7, -50, 50, 0, 2.4, build.fence); + build.box(79.7, 80.3, -50, -9, 0, 2.4, build.fence); + build.box(79.7, 80.3, 9, 50, 0, 2.4, build.fence); + for (const v of [-9.5, 9.5]) build.box(79, 81, v - 1, v + 1, 0, 4.2, build.teal); // Painted parking bays in front of the office. - const paint = floorMat('#f1efe6', { + build.paint = build.floorMat('#f1efe6', { roughness: 0.9, polygonOffsetFactor: -18, polygonOffsetUnits: -36, }); - for (let u = 38; u <= 70; u += 4) flat(u - 0.06, u + 0.06, -21.5, -16.5, 0.09, paint); - - const nameSign = textTexture(1024, 320, (c, w, h) => { + for (let u = 38; u <= 70; u += 4) build.flat(u - 0.06, u + 0.06, -21.5, -16.5, 0.09, build.paint); +} +/** + * Runs the factory build name sign operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildNameSign(build) { + build.nameSign = textTexture(1024, 320, (c, w, h) => { c.fillStyle = '#0d2f2c'; c.fillRect(0, 0, w, h); c.fillStyle = '#3ee0c9'; @@ -247,207 +579,414 @@ export function createFactory(scene) { c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270); }); // Two faces, so the name also reads correctly from inside the yard. - const frontSign = new THREE.MeshBasicMaterial({ map: nameSign }); + build.frontSign = new THREE.MeshBasicMaterial({ + map: build.nameSign, + }); +} +/** + * Runs the factory build geometry2 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry2(build) { for (const side of [1, -1]) { - const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), frontSign); + const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), build.frontSign); gateSign.position.set(82 + side * 0.02, 6.6, -20); gateSign.rotation.y = (side * Math.PI) / 2; - group.add(gateSign); + build.group.add(gateSign); } - for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - 0.3, v + 0.3, 0, 8.8, steel, false); + for (const v of [-25.5, -14.5]) + build.box(81.6, 82.4, v - 0.3, v + 0.3, 0, 8.8, build.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: 0.35, roughness: 0.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 + 0.4, 0, H, wall); - box(U0 - 0.4, U0, V0, V1 + 0.4, 0, H, wall); - box(U1, U1 + 0.4, V0 - 0.4, DOCK0, 0, H, wall); - box(U1, U1 + 0.4, DOCK1, V1 + 0.4, 0, H, wall); - box(U1, U1 + 0.4, DOCK0, DOCK1, 5, H, wall, false); - box(U0 - 0.4, D0, V0 - 0.4, V0, 0, H, wall); - box(D1, U1 + 0.4, V0 - 0.4, V0, 0, H, wall); - box(D0, D1, V0 - 0.4, V0, 7, H, wall, false); - box(U0 - 0.4, U1 + 0.4, V0 - 0.5, V0 - 0.3, H - 1.6, H - 0.6, teal, false); - const roof = box( - U0 - 1, - U1 + 1, - V0 - 1, - V1 + 1, - H, - H + 0.4, - mat('#9aa6aa', { metalness: 0.4 }), + build.corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16)); + build.corrugated.wrapS = build.corrugated.wrapT = THREE.RepeatWrapping; + build.corrugated.repeat.set(18, 2); + build.wall = build.mat('#ffffff', { + map: build.corrugated, + metalness: 0.35, + roughness: 0.55, + }); + build.H = 10; + build.U0 = -104; + build.U1 = 0; + build.V0 = 10; +} +/** + * Runs the factory build v1 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildV1(build) { + build.V1 = 42; + build.D0 = -36; + build.D1 = -20; + build.DOCK0 = 27; + build.DOCK1 = 35; + build.box(build.U0, build.U1, build.V1, build.V1 + 0.4, 0, build.H, build.wall); + build.box(build.U0 - 0.4, build.U0, build.V0, build.V1 + 0.4, 0, build.H, build.wall); + build.box(build.U1, build.U1 + 0.4, build.V0 - 0.4, build.DOCK0, 0, build.H, build.wall); + build.box(build.U1, build.U1 + 0.4, build.DOCK1, build.V1 + 0.4, 0, build.H, build.wall); + build.box(build.U1, build.U1 + 0.4, build.DOCK0, build.DOCK1, 5, build.H, build.wall, false); + build.box(build.U0 - 0.4, build.D0, build.V0 - 0.4, build.V0, 0, build.H, build.wall); + build.box(build.D1, build.U1 + 0.4, build.V0 - 0.4, build.V0, 0, build.H, build.wall); + build.box(build.D0, build.D1, build.V0 - 0.4, build.V0, 7, build.H, build.wall, false); + build.box( + build.U0 - 0.4, + build.U1 + 0.4, + build.V0 - 0.5, + build.V0 - 0.3, + build.H - 1.6, + build.H - 0.6, + build.teal, + false, + ); + build.roof = build.box( + build.U0 - 1, + build.U1 + 1, + build.V0 - 1, + build.V1 + 1, + build.H, + build.H + 0.4, + build.mat('#9aa6aa', { + metalness: 0.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, 0.1, floorMat('#b9c4bd', { roughness: 0.35 })); + build.roof.castShadow = false; + build.flat( + build.U0, + build.U1, + build.V0, + build.V1, + 0.1, + build.floorMat('#b9c4bd', { + roughness: 0.35, + }), + ); // Safety walkway along the door side. - flat( - U0 + 1, - U1 - 1, +} +/** + * Runs the factory build geometry3 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry3(build) { + build.flat( + build.U0 + 1, + build.U1 - 1, 18.6, 19, 0.11, - floorMat('#f2c94c', { polygonOffsetFactor: -18, polygonOffsetUnits: -36 }), + build.floorMat('#f2c94c', { + polygonOffsetFactor: -18, + polygonOffsetUnits: -36, + }), ); - for (let u = U0 + 8; u < U1; u += 12) + for (let u = build.U0 + 8; u < build.U1; u += 12) for (const v of [18, 32]) - box(u - 3, u + 3, v - 1, v + 1, H - 0.3, H - 0.1, lamp, false).castShadow = false; - panel(nameSign, 16, 5, (D0 + D1) / 2, H + 3, V0 - 0.6, -1); - for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) - box(u - 0.2, u + 0.2, V0 - 0.7, V0 - 0.5, H, H + 5.5, steel, false); + build.box( + u - 3, + u + 3, + v - 1, + v + 1, + build.H - 0.3, + build.H - 0.1, + build.lamp, + false, + ).castShadow = false; + build.panel(build.nameSign, 16, 5, (build.D0 + build.D1) / 2, build.H + 3, build.V0 - 0.6, -1); + for (const u of [(build.D0 + build.D1) / 2 - 7.5, (build.D0 + build.D1) / 2 + 7.5]) + build.box( + u - 0.2, + u + 0.2, + build.V0 - 0.7, + build.V0 - 0.5, + build.H, + build.H + 5.5, + build.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); + build.zone = (text, sub, u) => { + build.panel(sign(text, sub), 5.4, 1.7, u, 6.6, 21, -1); for (const du of [-2.2, 2.2]) - box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, H, steel, false); + build.box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, build.H, build.steel, false); }; - zone('خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه', -93); - zone('برش لیزر', 'برش ورق و قطعات', -71); - zone('رنگ‌کاری', 'کابین رنگ پودری', -52); - zone('یخچال‌سازی', 'کمپرسور و عایق‌کاری', -31); - zone('مونتاژ', 'خط مونتاژ نهایی', -13); - zone('بسته‌بندی', 'ارسال به شهر', -3); + build.zone('خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه', -93); + build.zone('برش لیزر', 'برش ورق و قطعات', -71); + build.zone('رنگ‌کاری', 'کابین رنگ پودری', -52); + build.zone('یخچال‌سازی', 'کمپرسور و عایق‌کاری', -31); + build.zone('مونتاژ', 'خط مونتاژ نهایی', -13); + build.zone('بسته‌بندی', 'ارسال به شهر', -3); // Bending: two press brakes with a moving ram, sheet stacks behind. - const sheet = mat('#d4dadd', { metalness: 0.85, roughness: 0.25 }); +} +/** + * Runs the factory build sheet operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildSheet(build) { + build.sheet = build.mat('#d4dadd', { + metalness: 0.85, + roughness: 0.25, + }); for (const u of [-98, -88]) { - box(u - 1.8, u + 1.8, 29.4, 30.6, 0, 1.0, mat('#4a6b80', { metalness: 0.4 })); + build.box( + u - 1.8, + u + 1.8, + 29.4, + 30.6, + 0, + 1.0, + build.mat('#4a6b80', { + metalness: 0.4, + }), + ); for (const du of [-1.8, 1.5]) - box(u + du, u + du + 0.3, 29.2, 30.8, 0, 2.9, mat('#4a6b80', { metalness: 0.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)) * -0.6)); - box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, sheet, false); + build.box( + u + du, + u + du + 0.3, + 29.2, + 30.8, + 0, + 2.9, + build.mat('#4a6b80', { + metalness: 0.4, + }), + false, + ); + const ram = build.box(u - 1.5, u + 1.5, 29.6, 30.4, 1.9, 2.5, build.yellow, false); + build.animated.push((t) => (ram.position.y = 2.2 + Math.max(0, Math.sin(t * 1.4 + u)) * -0.6)); + build.box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, build.sheet, false); } for (const u of [-101, -95, -90, -85]) { - box(u - 1, u + 1, 38.4, 39.6, 0, 0.15, mat('#8a6a45')); + build.box(u - 1, u + 1, 38.4, 39.6, 0, 0.15, build.mat('#8a6a45')); for (let i = 0; i < 6; i++) - box(u - 0.95, u + 0.95, 38.45, 39.55, 0.15 + i * 0.05, 0.19 + i * 0.05, sheet, false); + build.box( + u - 0.95, + u + 0.95, + 38.45, + 39.55, + 0.15 + i * 0.05, + 0.19 + i * 0.05, + build.sheet, + false, + ); } // Laser cutting: a bed with a moving gantry and glowing cutting head, plus a shear. - box(-78, -66, 26, 32, 0, 0.9, darkSteel); - const slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12)); - flat( + build.box(-78, -66, 26, 32, 0, 0.9, build.darkSteel); +} +/** + * Runs the factory build slats operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildSlats(build) { + build.slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12)); + build.flat( -77.8, -66.2, 26.2, 31.8, 0.91, - new THREE.MeshStandardMaterial({ map: slats, metalness: 0.6, roughness: 0.4 }), + new THREE.MeshStandardMaterial({ + map: build.slats, + metalness: 0.6, + roughness: 0.4, + }), ); - const gantry = new THREE.Group(); - group.add(gantry); - box(-0.3, 0.3, 25.4, 32.6, 1.6, 1.9, yellow, false, gantry); - for (const v of [25.5, 32.5]) box(-0.25, 0.25, v - 0.2, v + 0.2, 0.9, 1.9, yellow, false, gantry); - const head = box(-0.2, 0.2, -0.2, 0.2, 1.1, 1.6, darkSteel, false, gantry); - const spark = new THREE.Mesh( + build.gantry = new THREE.Group(); + build.group.add(build.gantry); + build.box(-0.3, 0.3, 25.4, 32.6, 1.6, 1.9, build.yellow, false, build.gantry); + for (const v of [25.5, 32.5]) + build.box(-0.25, 0.25, v - 0.2, v + 0.2, 0.9, 1.9, build.yellow, false, build.gantry); + build.head = build.box(-0.2, 0.2, -0.2, 0.2, 1.1, 1.6, build.darkSteel, false, build.gantry); + build.spark = new THREE.Mesh( new THREE.SphereGeometry(0.09, 8, 6), - new THREE.MeshBasicMaterial({ color: '#ff5a3c' }), + new THREE.MeshBasicMaterial({ + color: '#ff5a3c', + }), ); - gantry.add(spark); - animated.push((t) => { - gantry.position.x = -72 + Math.sin(t * 0.45) * 5; - head.position.z = spark.position.z = 29 + Math.sin(t * 1.3) * 2.5; - spark.position.y = 0.95; - spark.scale.setScalar(0.7 + Math.random() * 0.6); + build.gantry.add(build.spark); + build.animated.push((t) => { + build.gantry.position.x = -72 + Math.sin(t * 0.45) * 5; + build.head.position.z = build.spark.position.z = 29 + Math.sin(t * 1.3) * 2.5; + build.spark.position.y = 0.95; + build.spark.scale.setScalar(0.7 + Math.random() * 0.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: 0.4 })); + build.box(-65, -64, 33.2, 33.8, 0, 1.4, build.mat('#2e3a40')); +} +/** + * Runs the factory build geometry4 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry4(build) { + build.box( + -73.5, + -70.5, + 37.9, + 39.1, + 0, + 1.6, + build.mat('#4a6b80', { + metalness: 0.4, + }), + ); // Powder painting booth: see-through panels, an overhead rail carrying panels through it. - const glass = mat('#9fd3e6', { + build.glass = build.mat('#9fd3e6', { transparent: true, opacity: 0.28, depthWrite: false, roughness: 0.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( + build.box(-58, -46, 36.8, 37, 0, 4.2, build.glass); + build.box(-58, -57.8, 25, 37, 0, 4.2, build.glass); + build.box(-46.2, -46, 25, 37, 0, 4.2, build.glass); + build.box(-58, -46, 25, 37, 4.2, 4.3, build.glass, false); + build.flat( -57.8, -46.2, 25.2, 36.8, 0.12, - floorMat('#5d7f86', { polygonOffsetFactor: -18, polygonOffsetUnits: -36 }), + build.floorMat('#5d7f86', { + polygonOffsetFactor: -18, + polygonOffsetUnits: -36, + }), ); - box(-61, -43, 30.9, 31.1, 3.7, 3.8, steel, false); - const hung = []; + build.box(-61, -43, 30.9, 31.1, 3.7, 3.8, build.steel, false); + build.hung = []; +} +/** + * Runs the factory build geometry5 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry5(build) { for (let i = 0; i < 5; i++) { - const piece = box(-0.6, 0.6, 30.97, 31.03, 1.4, 3.2, i % 2 ? teal : mat('#eef4f2'), false); - hung.push(piece); + const piece = build.box( + -0.6, + 0.6, + 30.97, + 31.03, + 1.4, + 3.2, + i % 2 ? build.teal : build.mat('#eef4f2'), + false, + ); + build.hung.push(piece); } - animated.push((t) => - hung.forEach((piece, i) => (piece.position.x = -60 + ((t * 0.5 + i * 3.4) % 17))), + build.animated.push((t) => + build.hung.forEach((piece, i) => (piece.position.x = -60 + ((t * 0.5 + i * 3.4) % 17))), ); // Refrigeration: work tables with insulated cabinets, compressors and copper coils. - const copper = mat('#c27a45', { metalness: 0.9, roughness: 0.3 }), - insulated = mat('#f2f4f3', { roughness: 0.5 }); + build.copper = build.mat('#c27a45', { + metalness: 0.9, + roughness: 0.3, + }); + build.insulated = build.mat('#f2f4f3', { + roughness: 0.5, + }); +} +/** + * Runs the factory build geometry6 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry6(build) { for (const u of [-37, -31, -25]) { - box(u - 2, u + 2, 29.2, 30.8, 0, 0.9, steel); - box(u - 1.6, u - 0.2, 29.4, 30.6, 0.9, 2.2, insulated, false); - cylinder(0.28, 0.55, mat('#16191c', { metalness: 0.5 }), u + 0.7, 1.18, 30); - const coil = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 6, 20), copper); + build.box(u - 2, u + 2, 29.2, 30.8, 0, 0.9, build.steel); + build.box(u - 1.6, u - 0.2, 29.4, 30.6, 0.9, 2.2, build.insulated, false); + build.cylinder( + 0.28, + 0.55, + build.mat('#16191c', { + metalness: 0.5, + }), + u + 0.7, + 1.18, + 30, + ); + const coil = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 6, 20), build.copper); coil.position.set(u + 1.4, 1.3, 30); coil.rotation.y = Math.PI / 2; - group.add(coil); + build.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); + for (let i = 0; i < 4; i++) + build.box(-35 + i * 2.1, -33.2 + i * 2.1, 38.6, 40.4, 0, 1.9, build.insulated); // Assembly: conveyor carrying machine frames between robot arms, finished machines at the end. - box(-20, -6, 32.4, 34.6, 0, 0.9, darkSteel); - const rollers = mat('#9aa4a8', { metalness: 0.8, roughness: 0.3 }); + build.box(-20, -6, 32.4, 34.6, 0, 0.9, build.darkSteel); + build.rollers = build.mat('#9aa4a8', { + metalness: 0.8, + roughness: 0.3, + }); for (let u = -19.4; u < -6; u += 1.2) { - const roller = cylinder(0.12, 2.2, rollers, u, 0.95, 33.5); + const roller = build.cylinder(0.12, 2.2, build.rollers, u, 0.95, 33.5); roller.rotation.x = Math.PI / 2; } - const frames = [], - frameMaterial = mat('#0b8f88', { wireframe: true }); +} +/** + * Runs the factory build frames operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildFrames(build) { + build.frames = []; + build.frameMaterial = build.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, + i % 2 ? build.frameMaterial : build.teal, ); frame.position.set(-19 + i * 3.5, 2.2, 33.5); frame.castShadow = true; - group.add(frame); - frames.push(frame); + build.group.add(frame); + build.frames.push(frame); } - const arms = []; + build.arms = []; +} +/** + * Runs the factory build geometry7 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry7(build) { for (const u of [-16, -10]) { - box(u - 0.6, u + 0.6, 36, 37.2, 0, 1.2, yellow); + build.box(u - 0.6, u + 0.6, 36, 37.2, 0, 1.2, build.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(0.35, 2.2, 0.35), yellow); + build.group.add(arm); + const upper = new THREE.Mesh(new THREE.BoxGeometry(0.35, 2.2, 0.35), build.yellow); upper.position.y = 1.1; upper.castShadow = true; - const fore = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.28, 2.4), mat('#e0b23a')); + const fore = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.28, 2.4), build.mat('#e0b23a')); fore.position.set(0, 2.1, -1.1); fore.castShadow = true; arm.add(upper, fore); - arms.push(arm); + build.arms.push(arm); } - animated.push((t) => { - arms.forEach((arm, i) => (arm.rotation.y = Math.sin(t * 0.8 + i * 2) * 0.9)); - frames.forEach((frame, i) => (frame.position.x = -19.5 + ((t * 0.6 + i * 3.5) % 14))); + build.animated.push((t) => { + build.arms.forEach((arm, i) => (arm.rotation.y = Math.sin(t * 0.8 + i * 2) * 0.9)); + build.frames.forEach((frame, i) => (frame.position.x = -19.5 + ((t * 0.6 + i * 3.5) % 14))); }); +} +/** + * Runs the factory build geometry8 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry8(build) { for (let i = 0; i < 4; i++) { const finished = createVendingMachine({ name: 'آمادهٔ نصب', @@ -457,12 +996,19 @@ export function createFactory(scene) { 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); + build.group.add(finished.group); + build.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) => { +} +/** + * Runs the factory build carton operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildCarton(build) { + build.carton = textTexture(256, 512, (c, w, h) => { c.fillStyle = '#c49a64'; c.fillRect(0, 0, w, h); c.fillStyle = 'rgba(255,255,255,.18)'; @@ -477,91 +1023,162 @@ export function createFactory(scene) { c.font = '60px sans-serif'; c.fillText('⇧', w / 2, h * 0.75); }); - const cartonMat = mat('#ffffff', { map: carton, roughness: 0.85 }); - const cartonGeometry = new THREE.BoxGeometry(0.95, 1.9, 1.05); - const packaged = () => { - const mesh = new THREE.Mesh(cartonGeometry, cartonMat); + build.cartonMat = build.mat('#ffffff', { + map: build.carton, + roughness: 0.85, + }); + build.cartonGeometry = new THREE.BoxGeometry(0.95, 1.9, 1.05); +} +/** + * Runs the factory build packaged operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildPackaged(build) { + build.packaged = () => { + const mesh = new THREE.Mesh(build.cartonGeometry, build.cartonMat); mesh.castShadow = mesh.receiveShadow = true; return mesh; }; for (const v of [37, 38.4, 39.8]) { - const p = packaged(); + const p = build.packaged(); p.position.set(-3.2, 0.95, v); - group.add(p); - solid(-3.7, -2.7, v - 0.55, v + 0.55); + build.group.add(p); + build.solid(-3.7, -2.7, v - 0.55, v + 0.55); } - box(-6, 1.6, 30.4, 31.6, 0, 1.0, darkSteel); + build.box(-6, 1.6, 30.4, 31.6, 0, 1.0, build.darkSteel); for (let u = -5.6; u < 1.6; u += 0.8) - cylinder(0.08, 1.1, rollers, u, 1.04, 31).rotation.x = Math.PI / 2; + build.cylinder(0.08, 1.1, build.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 = {}) => ({ + build.overall = '#2f4e7a'; + build.vest = '#e8742c'; + build.paintSuit = '#eef1f2'; +} +/** + * Runs the factory build w operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildW(build) { + build.W = (x, z, facing, kind = 'work', extra = {}) => ({ x, z, facing, kind, - suit: overall, + suit: build.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 = [ + build.walk = (x, z, tx, tz, extra = {}) => + build.W(x, z, 0, 'walk', { + to: { + x: tx, + z: tz, + }, + ...extra, + }); + build.carry = (x, z, tx, tz, extra = {}) => + build.W(x, z, 0, 'carry', { + to: { + x: tx, + z: tz, + }, + ...extra, + }); +} +/** + * Runs the factory build crew operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildCrew(build) { + build.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), + build.W(-98.8, 32.3, Math.PI), + build.W(-97.2, 32.3, Math.PI), + build.W(-88.8, 32.3, Math.PI), + build.W(-87.2, 32.3, Math.PI, 'work', { + suit: build.vest, + }), + build.carry(-101, 37.3, -99.5, 33.6), + build.carry(-95, 37.3, -96.2, 33.6, { + suit: build.vest, + }), + build.carry(-90, 37.3, -90.5, 33.6), + build.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 }), + build.W(-64.5, 34.6, Math.PI), + build.W(-68, 33.4, Math.PI), + build.W(-75, 33.4, Math.PI, 'work', { + suit: build.vest, + }), + build.W(-73, 37.1, 0), + build.W(-71, 37.1, 0), + build.walk(-79, 35.3, -62, 35.3), + build.carry(-68, 40.4, -62, 40.4), + build.walk(-80, 37, -76, 40.4, { + suit: build.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 }), + build.W(-55, 29.4, 0, 'work', { + suit: build.paintSuit, + hat: '#ffffff', + }), + build.W(-51.5, 29.4, 0, 'work', { + suit: build.paintSuit, + hat: '#ffffff', + }), + build.W(-53, 32.6, Math.PI, 'work', { + suit: build.paintSuit, + hat: '#ffffff', + }), + build.W(-49, 32.6, Math.PI, 'work', { + suit: build.paintSuit, + hat: '#ffffff', + }), + build.walk(-60, 23, -44, 23), + build.carry(-59, 39.4, -45, 39.4), + build.walk(-44.5, 26, -44.5, 37, { + suit: build.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), + build.W(-38, 28.4, 0), + build.W(-36, 28.4, 0), + build.W(-32, 28.4, 0, 'work', { + suit: build.vest, + }), + build.W(-30, 28.4, 0), + build.W(-26, 28.4, 0), + build.W(-24, 28.4, 0), + build.walk(-39, 34.4, -22, 34.4), + build.carry(-24, 37, -38, 37), + build.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), + build.W(-18, 31, 0), + build.W(-14.4, 31, 0), + build.W(-11, 31, 0, 'work', { + suit: build.vest, + }), + build.W(-7.6, 31, 0), + build.W(-17.1, 38.4, 0), + build.W(-12.9, 38.4, 0), + build.carry(-20, 24, -4, 24), + build.carry(-3, 22.5, -3, 29), ]; - const workers = createCrew(group, crew); - for (const w of crew) if (w.kind === 'work') solid(w.x - 0.3, w.x + 0.3, w.z - 0.3, w.z + 0.3); +} +/** + * Runs the factory build workers operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildWorkers(build) { + build.workers = createCrew(build.group, build.crew); + for (const w of build.crew) + if (w.kind === 'work') build.solid(w.x - 0.3, w.x + 0.3, w.z - 0.3, w.z + 0.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) => { + build.windows = textTexture(128, 128, (c, w, h) => { c.fillStyle = '#e9e4d8'; c.fillRect(0, 0, w, h); c.fillStyle = '#4c6672'; @@ -569,149 +1186,150 @@ export function createFactory(scene) { 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: 0.9 }); - const OH = 3.8, - O0 = 6, - O1 = 50, - F = -24, - B = -46; - const doors = [ + build.windows.wrapS = build.windows.wrapT = THREE.RepeatWrapping; + build.facade = build.mat('#ffffff', { + map: build.windows, + }); + build.plaster = build.mat('#ece8df', { + roughness: 0.9, + }); + build.OH = 3.8; + build.O0 = 6; +} +/** + * Runs the factory build o1 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildO1(build) { + build.O1 = 50; + build.F = -24; + build.B = -46; + build.doors = [ [14, 17], [31, 34], [43.5, 46.5], ]; - let from = O0; - for (const [d0, d1] of doors) { - box(from, d0, F - 0.3, F, 0, OH, plaster); - box(d0, d1, F - 0.3, F, 2.7, OH, plaster, false); - from = d1; + build.from = build.O0; + for (const [d0, d1] of build.doors) { + build.box(build.from, d0, build.F - 0.3, build.F, 0, build.OH, build.plaster); + build.box(d0, d1, build.F - 0.3, build.F, 2.7, build.OH, build.plaster, false); + build.from = d1; } - box(from, O1, F - 0.3, F, 0, OH, plaster); - box(O0, O1, B - 0.3, B, 0, OH, plaster); - box(O0 - 0.3, O0, B - 0.3, F, 0, OH, plaster); - box(O1, O1 + 0.3, B - 0.3, F, 0, OH, plaster); - box(25.85, 26.15, B, F - 0.3, 0, OH, plaster); - box(39.85, 40.15, B, F - 0.3, 0, OH, plaster); - const officeRoof = box( - O0 - 0.5, - O1 + 0.5, - B - 0.8, - F + 0.5, - OH, - OH + 0.25, - mat('#bdb5a5'), + build.box(build.from, build.O1, build.F - 0.3, build.F, 0, build.OH, build.plaster); + build.box(build.O0, build.O1, build.B - 0.3, build.B, 0, build.OH, build.plaster); + build.box(build.O0 - 0.3, build.O0, build.B - 0.3, build.F, 0, build.OH, build.plaster); + build.box(build.O1, build.O1 + 0.3, build.B - 0.3, build.F, 0, build.OH, build.plaster); + build.box(25.85, 26.15, build.B, build.F - 0.3, 0, build.OH, build.plaster); + build.box(39.85, 40.15, build.B, build.F - 0.3, 0, build.OH, build.plaster); + build.officeRoof = build.box( + build.O0 - 0.5, + build.O1 + 0.5, + build.B - 0.8, + build.F + 0.5, + build.OH, + build.OH + 0.25, + build.mat('#bdb5a5'), + false, + ); + build.officeRoof.castShadow = false; + build.box( + build.O0 - 0.5, + build.O1 + 0.5, + build.F + 0.4, + build.F + 0.5, + build.OH - 0.5, + build.OH, + build.teal, false, ); - officeRoof.castShadow = false; - box(O0 - 0.5, O1 + 0.5, F + 0.4, F + 0.5, OH - 0.5, OH, teal, false); // Windows on the front wall between the doors, and on the end walls. +} +/** + * Runs the factory build geometry9 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry9(build) { 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 + 0.02); - facade.map.repeat.set(1, 1); - group.add(pane); + const pane = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, 1.4), build.facade); + pane.position.set((u0 + u1) / 2, 1.9, build.F + 0.02); + build.facade.map.repeat.set(1, 1); + build.group.add(pane); } - panel(nameSign, 22, 3.4, 28, OH + 2, F + 0.1); - for (const u of [18, 38]) box(u - 0.15, u + 0.15, F - 0.1, F + 0.05, OH, OH + 3.7, steel, false); - flat(O0, 26, B, F, 0.1, floorMat('#a9b4bb')); - flat(26, 40, B, F, 0.1, floorMat('#c8bba4')); - flat(40, O1, B, F, 0.1, floorMat('#7b5a44', { roughness: 0.5 })); + build.panel(build.nameSign, 22, 3.4, 28, build.OH + 2, build.F + 0.1); + for (const u of [18, 38]) + build.box( + u - 0.15, + u + 0.15, + build.F - 0.1, + build.F + 0.05, + build.OH, + build.OH + 3.7, + build.steel, + false, + ); + build.flat(build.O0, 26, build.B, build.F, 0.1, build.floorMat('#a9b4bb')); + build.flat(26, 40, build.B, build.F, 0.1, build.floorMat('#c8bba4')); + build.flat( + 40, + build.O1, + build.B, + build.F, + 0.1, + build.floorMat('#7b5a44', { + roughness: 0.5, + }), + ); for (const [u0, u1] of [ [8, 24], [28, 38], [42, 48], ]) - box(u0, u1, -36, -34, OH - 0.08, OH - 0.02, lamp, false).castShadow = false; - panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, F + 0.05); - panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, F + 0.05); - panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, F + 0.05); - - const wood = mat('#c8a477', { roughness: 0.6 }), - black = mat('#1b1d20', { roughness: 0.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 - 0.45, v + 0.45, 0.72, 0.77, wood); - for (const du of [-1, 1]) - box( - u + du * (width / 2 - 0.05) - 0.03, - u + du * (width / 2 - 0.05) + 0.03, - v - 0.4, - v + 0.4, - 0, - 0.72, - black, - false, - ); - screens.forEach((texture, i) => { - const du = screens.length > 1 ? (i - 0.5) * 0.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(0.72, 0.44, 0.04), mats); - monitor.position.set(u + du, 1.12, v - sitSide * 0.2); - monitor.rotation.y = (i - 0.5) * -0.25 * sitSide * (screens.length > 1 ? 1 : 0); - group.add(monitor); - box( - u + du - 0.03, - u + du + 0.03, - v - sitSide * 0.22 - 0.03, - v - sitSide * 0.22 + 0.03, - 0.77, - 0.9, - black, - false, - ); - }); - box( - u - 0.25, - u + 0.25, - v + sitSide * 0.12, - v + sitSide * 0.28, - 0.77, - 0.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 - 0.26, u + 0.26, v - 0.26, v + 0.26, 0.4, 0.47, fabric, false); - box(u - 0.25, u + 0.25, v + sitSide * 0.26, v + sitSide * 0.32, 0.47, 1.1, fabric, false); - cylinder(0.035, 0.4, black, u, 0.2, v); - box(u - 0.3, u + 0.3, v - 0.03, v + 0.03, 0.02, 0.06, black, false); - box(u - 0.03, u + 0.03, v - 0.3, v + 0.3, 0.02, 0.06, black, false); - solid(u - 0.35, u + 0.35, v - 0.35, v + 0.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 = [ + build.box(u0, u1, -36, -34, build.OH - 0.08, build.OH - 0.02, build.lamp, false).castShadow = + false; + build.panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, build.F + 0.05); + build.panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, build.F + 0.05); + build.panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, build.F + 0.05); + build.wood = build.mat('#c8a477', { + roughness: 0.6, + }); + build.black = build.mat('#1b1d20', { + roughness: 0.5, + }); +} +/** + * Runs the factory build fabric operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildFabric(build) { + build.fabric = build.mat('#2c3238'); + build.people = []; + build.talks = []; +} +/** + * Runs the factory build developers operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildDevelopers(build) { + build.developers = [ { name: 'حامد', role: 'React', u: 11, v: -31.6, - look: { outfit: 'hoodie', top: '#151517', bottom: '#2d3d57' }, + look: { + outfit: 'hoodie', + top: '#151517', + bottom: '#2d3d57', + }, code: CODE.react, speaker: 'hamed', lines: [ @@ -724,7 +1342,12 @@ export function createFactory(scene) { role: 'Go', u: 16, v: -31.6, - look: { outfit: 'hoodie', top: '#2f6fd6', bottom: '#33363b', skin: '#e9bf9c' }, + look: { + outfit: 'hoodie', + top: '#2f6fd6', + bottom: '#33363b', + skin: '#e9bf9c', + }, code: CODE.go, speaker: 'ryan', lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاه‌ها رو با Go می‌نویسم.'], @@ -734,7 +1357,12 @@ export function createFactory(scene) { role: 'C++', u: 21, v: -31.6, - look: { outfit: 'hoodie', top: '#1a1a1c', bottom: '#3b4a5e', hair: '#1a1410' }, + look: { + outfit: 'hoodie', + top: '#1a1a1c', + bottom: '#3b4a5e', + hair: '#1a1410', + }, code: CODE.cpp, speaker: 'alireza', lines: [ @@ -746,7 +1374,13 @@ export function createFactory(scene) { role: 'C++', u: 13.5, v: -38.6, - look: { outfit: 'suit', top: '#2b2f3a', bottom: '#2b2f3a', tie: '#7a1f2b', shoes: '#111' }, + look: { + outfit: 'suit', + top: '#2b2f3a', + bottom: '#2b2f3a', + tie: '#7a1f2b', + shoes: '#111', + }, code: CODE.cpp, speaker: 'reza', lines: [ @@ -759,25 +1393,47 @@ export function createFactory(scene) { role: '', u: 18.5, v: -38.6, - look: { outfit: 'hoodie', top: '#6f767d', bottom: '#2a2f36', skin: '#d9a985' }, + 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({ +} +/** + * Runs the factory build geometry10 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry10(build) { + for (const dev of build.developers) { + build.desk(dev.u, dev.v - 1.0, 1, [dev.code, CODE.terminal]); + const person = build.seat(createPerson(dev.look), dev.u, dev.v, 1, nameTag(dev.name, dev.role)); + build.people.push({ + person, + pose: 'type', + }); + build.talks.push({ speaker: dev.speaker, label: dev.name, lines: dev.lines, radius: 2.6, - pos: () => toWorld(dev.u, dev.v), + pos: () => build.toWorld(dev.u, dev.v), }); } - const board = textTexture(512, 256, (c, w, h) => { +} +/** + * Runs the factory build board operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildBoard(build) { + build.board = textTexture(512, 256, (c, w, h) => { c.fillStyle = '#fbfbf8'; c.fillRect(0, 0, w, h); c.strokeStyle = '#9aa4a8'; @@ -794,21 +1450,72 @@ export function createFactory(scene) { c.fillText(t, w / 2, 110 + i * 44), ); }); - panel(board, 4, 2, 16, 1.9, B + 0.05); - const officeMachine = { u: 6.8, v: -28.6 }; - + build.panel(build.board, 4, 2, 16, 1.9, build.B + 0.05); + build.officeMachine = { + u: 6.8, + v: -28.6, + }; +} +/** + * Runs the factory build accountants operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildAccountants(build) { // 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' } }, + build.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 }), +} +/** + * Runs the factory build geometry11 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry11(build) { + for (const a of build.accountants) { + build.desk(a.u, a.v - 1.0, 1, [CODE.sheet]); + build.people.push({ + person: build.seat( + createPerson({ + outfit: 'manteau', + bottom: '#24262b', + shoes: '#111', + ...a.look, + }), a.u, a.v, 1, @@ -817,59 +1524,90 @@ export function createFactory(scene) { }); } 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: 0.5 })); - talks.push({ + build.box( + 26.2, + 26.9, + -44.5 + i * 1, + -43.6 + i * 1, + 0, + 1.4, + build.mat('#9aa3a8', { + metalness: 0.5, + }), + ); + build.talks.push({ speaker: 'accountant', label: 'واحد حسابداری', radius: 4, - pos: () => toWorld(33, -29), + pos: () => build.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); +} +/** + * Runs the factory build geometry12 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry12(build) { + build.desk(45, -39.7, -1, [CODE.dashboard], 2.4); + build.chair(45, -40.7, -1); for (const u of [43.8, 46.2]) { - box(u - 0.28, u + 0.28, -37.1, -36.5, 0.42, 0.5, mat('#5a3a2a'), false); - box(u - 0.28, u + 0.28, -36.5, -36.4, 0.5, 1.0, mat('#5a3a2a'), false); - solid(u - 0.3, u + 0.3, -37.1, -36.4); + build.box(u - 0.28, u + 0.28, -37.1, -36.5, 0.42, 0.5, build.mat('#5a3a2a'), false); + build.box(u - 0.28, u + 0.28, -36.5, -36.4, 0.5, 1.0, build.mat('#5a3a2a'), false); + build.solid(u - 0.3, u + 0.3, -37.1, -36.4); } - box(41, 49, -45.9, -45.4, 0, 2.6, mat('#6b4a33')); + build.box(41, 49, -45.9, -45.4, 0, 2.6, build.mat('#6b4a33')); for (let i = 0; i < 4; i++) for (let j = 0; j < 9; j++) - box( + build.box( 41.3 + j * 0.85, 41.3 + j * 0.85 + 0.5, -45.4, -45.2, 0.3 + i * 0.6, 0.78 + i * 0.6, - mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]), + build.mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]), false, ); - box(49, 49.5, -27, -26.5, 0, 0.5, mat('#7a5a3c'), true); - const leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), mat('#3e7a3e')); - leaves.position.set(49.25, 1.0, -26.75); - group.add(leaves); - const nameplate = panel(sign('محمدی', 'مدیر شرکت'), 0.6, 0.19, 45, 0.87, -39.2, 1); - nameplate.rotation.x = -0.3; + build.box(49, 49.5, -27, -26.5, 0, 0.5, build.mat('#7a5a3c'), true); + build.leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), build.mat('#3e7a3e')); + build.leaves.position.set(49.25, 1.0, -26.75); + build.group.add(build.leaves); + build.nameplate = build.panel(sign('محمدی', 'مدیر شرکت'), 0.6, 0.19, 45, 0.87, -39.2, 1); + build.nameplate.rotation.x = -0.3; // ---------- Staff car park ---------- +} +/** + * Runs the factory build geometry13 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry13(build) { 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); + build.group.add(parked.group); + build.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({ + build.pickup = createPickup('#f1f1ee'); + build.group.add(build.pickup.group); +} +/** + * Runs the factory build manager operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildManager(build) { + build.manager = createPerson({ outfit: 'suit', top: '#3a3f47', bottom: '#3a3f47', @@ -877,18 +1615,18 @@ export function createFactory(scene) { glasses: true, shoes: '#111', }); - const managerTag = nameTag('آقای محمدی', 'مدیر شرکت'); - managerTag.position.y = 2.15; - manager.root.add(managerTag); - group.add(manager.root); - const DRIVE_IN = [ + build.managerTag = nameTag('آقای محمدی', 'مدیر شرکت'); + build.managerTag.position.y = 2.15; + build.manager.root.add(build.managerTag); + build.group.add(build.manager.root); + build.DRIVE_IN = [ [98, -3], [80, -3], [62, -5], [51, -9], [46, -16], ]; - const WALK_IN = [ + build.WALK_IN = [ [47.4, -16.9], [45, -21], [45, -25.6], @@ -898,27 +1636,41 @@ export function createFactory(scene) { ]; { // The parked pickup blocks its bay. - const [ax, az] = DRIVE_IN.at(-2), - [bx, bz] = DRIVE_IN.at(-1), + const [ax, az] = build.DRIVE_IN.at(-2), + [bx, bz] = build.DRIVE_IN.at(-1), len = Math.hypot(bx - ax, bz - az); const du = (bx - ax) / len, dv = (bz - az) / len; - footprints.push( + build.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))), + ].flatMap(([a, b]) => + Object.values(build.toWorld(bx + du * a - dv * b, bz + dv * a + du * b)), + ), ); } - const arrival = { stage: 'drive', s: 0, heading: Math.PI, sat: false }; - talks.push({ +} +/** + * Runs the factory build arrival operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildArrival(build) { + build.arrival = { + stage: 'drive', + s: 0, + heading: Math.PI, + sat: false, + }; + build.talks.push({ speaker: 'mohammadi', label: 'آقای محمدی', radius: 3.4, - pos: () => toWorld(manager.root.position.x, manager.root.position.z), - ready: () => arrival.stage === 'seated', + pos: () => build.toWorld(build.manager.root.position.x, build.manager.root.position.z), + ready: () => build.arrival.stage === 'seated', lines: [ 'سلام، خوش اومدین! من محمدی‌ام، مدیر روبات مارکت.', 'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.', @@ -927,7 +1679,14 @@ export function createFactory(scene) { }); // Position and direction a distance `s` along a polyline of [u, v] points. - const along = (path, s) => { +} +/** + * Runs the factory build along operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildAlong(build) { + build.along = (path, s) => { for (let i = 1; i < path.length; i++) { const [ax, az] = path[i - 1], [bx, bz] = path[i], @@ -945,132 +1704,78 @@ export function createFactory(scene) { 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) => { + build.turn = (from, to, k) => from + Math.atan2(Math.sin(to - from), Math.cos(to - from)) * k; +} +/** + * Runs the factory build place vehicle operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildPlaceVehicle(build) { + build.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; + state.heading = dt ? build.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) / 0.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(); + build.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 = [ + build.truck = createTruck(); + build.group.add(build.truck.group); + build.DOCK = [5.5, 31]; + build.DEPART = [build.DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]]; + build.RETURN = [ [100, 0], [80, 0], [60, 6], [40, 14], [27, 18], ]; - const BACK_IN = [[27, 18], [16, 28.5], DOCK]; - const SLOTS = [2.1, 0.95, -0.2, -1.35].flatMap((x) => [ + build.BACK_IN = [[27, 18], [16, 28.5], build.DOCK]; + build.SLOTS = [2.1, 0.95, -0.2, -1.35].flatMap((x) => [ [x, -0.6], [x, 0.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; + build.loaded = []; + build.moving = build.packaged(); + build.group.add(build.moving); +} +/** + * Runs the factory build delivery operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildDelivery(build) { + build.delivery = { + stage: 'load', + t: 0, + s: 0, + 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 + 0.95, v: DOCK[1] + sz }; - if (d.t < 2) moving.position.set(-6 + ((1.6 + 6) * d.t) / 2, 1.05 + 0.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) * 0.5, - 31 + (slot.v - 31) * k, - ); - } - if (d.t >= 3) { - const box = packaged(); - box.position.set(sx, 0.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) / 0.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({ + build.truckAtDock = () => { + build.truck.group.visible = true; + build.truck.group.position.set(build.DOCK[0], 0, build.DOCK[1]); + build.truck.group.rotation.y = build.delivery.heading = 0; + }; + build.truckAtDock(); + build.talks.push({ speaker: 'child', radius: 9, - pos: () => toWorld(4, 31), + pos: () => build.toWorld(4, 31), once: true, lines: ['بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!'], }); // The family comments on each work area the first time they walk into it. +} +/** + * Runs the factory build geometry14 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildGeometry14(build) { for (const [u, text, speaker] of [ [-93, 'اینجا خم‌کاریه؛ ورق‌های فلزی بدنهٔ دستگاه رو با پرس خم می‌کنن.', 'father'], [-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ می‌بُره.', 'child'], @@ -1082,34 +1787,116 @@ export function createFactory(scene) { 'father', ], ]) - talks.push({ speaker, radius: 8, pos: () => toWorld(u, 25), once: true, lines: [text] }); + build.talks.push({ + speaker, + radius: 8, + pos: () => build.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, + build.demo = build.toWorld(build.officeMachine.u, build.officeMachine.v); + build.facing = { + x: build.axis.x, + z: build.axis.z, + }; + build.car = build.toWorld(1.5, -7); + build.start = build.toWorld(-1, -3.6); + build.door = build.toWorld(15.5, -24); +} +/** + * Runs the factory build result operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function factoryBuildResult(build) { + build.result = { + group: build.group, + footprints: build.footprints, + demo: { + x: build.demo.x, + z: build.demo.z, + rot: Math.atan2(build.facing.x, build.facing.z), + }, + car: { + x: build.car.x, + z: build.car.z, + heading: build.worldHeading(1, 0), + }, + start: { + x: build.start.x, + z: build.start.z, + heading: Math.atan2(-(build.door.z - build.start.z), build.door.x - build.start.x), + }, + label: { + name: FACTORY.name, + kind: 'factory', + ...build.toWorld(-20, 0), + }, + talks: build.talks, + reset: build.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; + const near = + !viewer || Math.hypot(viewer.x - build.center.x, viewer.z - build.center.z) < 260; + build.updateArrival(dt, time); + build.updateTruck(dt); + build.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)); + for (const step of build.animated) step(time); + build.workers.update(time); + build.people.forEach(({ person, pose }, i) => person.pose(pose, time, i * 1.7)); }, }; } +const FACTORY_METHODS = { + desk: factoryDesk, + chair: factoryChair, + seat: factorySeat, + updateArrival: factoryUpdateArrival, + resetArrival: factoryResetArrival, + updateTruck: factoryUpdateTruck, +}; +const FACTORY_STEPS = [ + factoryBuildGeometry, + factoryBuildSolid, + factoryBuildPanel, + factoryBuildFlat, + factoryBuildAnimated, + factoryBuildNameSign, + factoryBuildGeometry2, + factoryBuildV1, + factoryBuildGeometry3, + factoryBuildSheet, + factoryBuildSlats, + factoryBuildGeometry4, + factoryBuildGeometry5, + factoryBuildGeometry6, + factoryBuildFrames, + factoryBuildGeometry7, + factoryBuildGeometry8, + factoryBuildCarton, + factoryBuildPackaged, + factoryBuildW, + factoryBuildCrew, + factoryBuildWorkers, + factoryBuildO1, + factoryBuildGeometry9, + factoryBuildFabric, + factoryBuildDevelopers, + factoryBuildGeometry10, + factoryBuildBoard, + factoryBuildAccountants, + factoryBuildGeometry11, + factoryBuildGeometry12, + factoryBuildGeometry13, + factoryBuildManager, + factoryBuildArrival, + factoryBuildAlong, + factoryBuildPlaceVehicle, + factoryBuildDelivery, + factoryBuildGeometry14, + factoryBuildResult, +]; diff --git a/src/game/models/showroom.js b/src/game/models/showroom.js new file mode 100644 index 0000000..1ed4daa --- /dev/null +++ b/src/game/models/showroom.js @@ -0,0 +1,102 @@ +import { FACTORY } from '../../constants/locations.js'; +import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js'; +import { createVendingMachine } from './vending.js'; +import { createFamily } from './characters.js'; + +/** Projects factory-local coordinates into the shared world metre system. + * @param {number} u - Position along the factory axis. + * @param {number} v - Position normal to the factory axis. + * @returns {{x:number,z:number}} World position. + */ +function showroomPosition(u, v) { + return { + x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v, + z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v, + }; +} + +/** Creates one display-only sample, with a footprint and interaction position. + * @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 = showroomPosition(u, v), + front = showroomPosition(u, v + 2); + machine.group.name = `showroom-${model.id}`; + machine.group.position.set(position.x, 0, position.z); + machine.group.rotation.y = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x); + machine.beacon.visible = machine.ring.visible = false; + scene.add(machine.group); + const footprint = [ + [-1, -0.6], + [1, -0.6], + [1, 0.6], + [-1, 0.6], + ].flatMap(([x, z]) => Object.values(showroomPosition(u + x, v + z))); + 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, + }); + }, + }; +} diff --git a/src/game/models/showroom.test.js b/src/game/models/showroom.test.js new file mode 100644 index 0000000..c956639 --- /dev/null +++ b/src/game/models/showroom.test.js @@ -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, + }); + }); +}); diff --git a/src/game/models/staff.js b/src/game/models/staff.js index a007a39..242243a 100644 --- a/src/game/models/staff.js +++ b/src/game/models/staff.js @@ -7,13 +7,35 @@ import { textTexture } from './textures.js'; // 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 })); + 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); @@ -77,7 +99,9 @@ export function createPerson({ const shirt = part( torso, new THREE.PlaneGeometry(0.12, 0.2), - mat('#f4f4f2', { side: THREE.DoubleSide }), + mat('#f4f4f2', { + side: THREE.DoubleSide, + }), 0, 0.49, 0.133, @@ -88,7 +112,9 @@ export function createPerson({ const lapel = part( torso, new THREE.BoxGeometry(0.05, 0.22, 0.01), - mat(top, { roughness: 0.5 }), + mat(top, { + roughness: 0.5, + }), side * 0.07, 0.47, 0.132, @@ -134,7 +160,10 @@ export function createPerson({ ); } if (glasses) { - const frame = mat('#222', { metalness: 0.6, roughness: 0.3 }); + 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); @@ -151,7 +180,10 @@ export function createPerson({ 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 }; + return { + shoulder, + elbow, + }; }); // Legs: hip → knee → foot. const legs = [-1, 1].map((side) => { @@ -164,9 +196,11 @@ export function createPerson({ 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 }; + return { + hip, + knee, + }; }); - const person = { root, head, @@ -217,6 +251,12 @@ export function createPerson({ } // 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); @@ -239,7 +279,11 @@ export function nameTag(name, role = '') { } }); const sprite = new THREE.Sprite( - new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }), + new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + }), ); sprite.scale.set(1.3, 0.41, 1); return sprite; @@ -253,6 +297,13 @@ 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; @@ -260,7 +311,10 @@ export function createCrew(parent, workers) { group.add(mesh); return mesh; }; - const white = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 }); + 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), @@ -274,11 +328,17 @@ export function createCrew(parent, workers) { ); const heads = instanced( new THREE.SphereGeometry(0.12, 10, 8), - new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.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 }), + 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(); @@ -295,18 +355,34 @@ export function createCrew(parent, workers) { color.set(w.hat ?? '#f2c94c'); hats.setColorAt(i, color); w.phase = i * 1.37; - w.from = { x: w.x, z: w.z }; + 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, @@ -361,5 +437,8 @@ export function createCrew(parent, workers) { update(0); for (const mesh of [torso, legs, arms, heads, hats]) if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - return { group, update }; + return { + group, + update, + }; } diff --git a/src/game/models/textures.js b/src/game/models/textures.js index a9a2644..9035fc5 100644 --- a/src/game/models/textures.js +++ b/src/game/models/textures.js @@ -1,12 +1,24 @@ 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; diff --git a/src/game/models/vending.js b/src/game/models/vending.js index c6bf41c..885c08f 100644 --- a/src/game/models/vending.js +++ b/src/game/models/vending.js @@ -1,7 +1,6 @@ import * as THREE from 'three'; import { catalog } from '../economy/gameState.js'; import { textTexture } from './textures.js'; - const PRODUCT_COLORS = { juice: '#e5ae47', water: '#9fd3e6', @@ -15,6 +14,12 @@ const PRODUCT_COLORS = { }; // 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'; @@ -32,10 +37,37 @@ const label = (text, sub) => }); // 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 }); + 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); @@ -49,9 +81,15 @@ export function createVendingMachine(def) { emissive: '#0b8f88', emissiveIntensity: 0.15, }), - white = mat('#eef5f3', { metalness: 0.2, roughness: 0.35 }); + white = mat('#eef5f3', { + metalness: 0.2, + roughness: 0.35, + }); const dark = mat('#13292b'), - steel = mat('#b7c1c0', { metalness: 0.8, roughness: 0.3 }); + 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); @@ -60,23 +98,45 @@ export function createVendingMachine(def) { 1.3, 1.8, 0.06, - mat('#13292b', { emissive: '#2a7f73', emissiveIntensity: 0.5 }), + mat('#13292b', { + emissive: '#2a7f73', + emissiveIntensity: 0.5, + }), -0.2, 1.45, 0.56, ); - // Six shelves of products behind the glass, coloured by what this machine sells. - for (let row = 0; row < 6; row++) { - const y = 0.78 + row * 0.28; + // 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 < 8; col++) { - const id = def.products[(row * 8 + col) % def.products.length]; + 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 * 0.15, y + 0.1, 0.47); + bottle.position.set( + -0.73 + col * (def.channels ? 1.06 / (columns - 1) : 0.15), + y + 0.1, + 0.47, + ); group.add(bottle); - } else box(0.09, 0.14, 0.06, color, -0.73 + col * 0.15, y + 0.08, 0.47); + 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( @@ -115,17 +175,40 @@ export function createVendingMachine(def) { 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); + 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('روبات مارکت', def.name) }), + new THREE.MeshBasicMaterial({ + map: label('روبات مارکت', 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); + 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. @@ -156,7 +239,6 @@ export function createVendingMachine(def) { ring.rotation.x = -Math.PI / 2; ring.position.set(0, 0.09, 1.6); group.add(ring); - return { group, flap, diff --git a/src/game/runtime/audioAndDialogue.js b/src/game/runtime/audioAndDialogue.js new file mode 100644 index 0000000..79795a2 --- /dev/null +++ b/src/game/runtime/audioAndDialogue.js @@ -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/.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); +} diff --git a/src/game/runtime/mapInteraction.js b/src/game/runtime/mapInteraction.js new file mode 100644 index 0000000..3ce0fa4 --- /dev/null +++ b/src/game/runtime/mapInteraction.js @@ -0,0 +1,140 @@ +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'; +/** + * 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; + 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: `🕒 ${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 ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا', + }); + 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'); + context.selected ??= context.objectiveStation()?.def.id ?? null; + if (context.selected) context.selectMachine(context.selected); + context.renderMachineList(); + context.renderMap(); +} diff --git a/src/game/runtime/movement.js b/src/game/runtime/movement.js new file mode 100644 index 0000000..99d6d5c --- /dev/null +++ b/src/game/runtime/movement.js @@ -0,0 +1,239 @@ +import * as THREE from 'three'; +import data from '../../data/mellat.json'; +import { resolveCircle } from '../world/collision.js'; +import { stepCar } from '../systems/vehicle.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. +/** + * Runs the toggle car operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export // ---------- Car ---------- +function toggleCar(context) { + if (context.payment.phase !== 'idle' || context.state.busy) return; + if (context.real.on) { + context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.'); + return; + } + if (context.mode === 'walk') { + if (Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) > 4.5) + return; + context.mode = 'drive'; + context.cam.distance = Math.max(context.cam.distance, 8); + context.tone(320, 0.2, 0.05, 'square'); + context.say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.'); + } else { + if (Math.abs(context.car.speed) > 3) { + context.say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.'); + return; + } + // Step out on the driver's (left) side, or wherever is free. + const left = { + x: -Math.sin(context.car.heading), + z: -Math.cos(context.car.heading), + }; + const spot = resolveCircle( + context.collision, + context.car.x + left.x * 2.8, + context.car.z + left.z * 2.8, + 1, + ); + Object.assign(context.player, { + x: spot.x, + z: spot.z, + heading: context.car.heading, + }); + context.car.speed = 0; + context.mode = 'walk'; + context.cam.distance = 5.5; + context.tone(260, 0.2, 0.05, 'square'); + } + context.refreshHUD(); +} + +// ---------- 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 = 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; +} + +// 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 or fence. + 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, + ); + // Two collision circles along the body keep the nose and tail out of buildings. + for (const along of [1.3, -1.3]) { + const cx = context.car.x + Math.cos(context.car.heading) * along, + cz = context.car.z - Math.sin(context.car.heading) * along; + const p = resolveCircle(context.collision, cx, cz, 1.05); + if (p.hit) { + context.debugHits.push({ + kind: 'building', + x: Math.round(cx), + z: Math.round(cz), + v: +context.car.speed.toFixed(1), + }); + context.car.x += p.x - cx; + context.car.z += p.z - cz; + if (Math.abs(context.car.speed) > 4) context.tone(90, 0.2, 0.08, 'square'); + context.car.speed *= 0.35; + } + } + } + // Bumping a traffic car slows you down but never throws you backwards. If you stay pressed + // against one, it pulls away (respawns out of sight) so you can never get stuck behind it. + for (const npc of context.traffic.cars) { + const dx = context.car.x - npc.x, + dz = context.car.z - npc.z, + d = Math.hypot(dx, dz); + if (d >= 3.4 || d === 0) { + if (npc.contact) npc.contact = 0; + continue; + } + context.debugHits.push({ + kind: 'traffic', + x: Math.round(context.car.x), + z: Math.round(context.car.z), + v: +context.car.speed.toFixed(1), + npcDir: npc.dir, + }); + context.car.x = npc.x + (dx / d) * 3.4; + context.car.z = npc.z + (dz / d) * 3.4; + const towards = + (Math.cos(context.car.heading) * -dx - Math.sin(context.car.heading) * -dz) * + Math.sign(context.car.speed) > + 0; + if (towards && Math.abs(context.car.speed) > 4) context.tone(110, 0.25, 0.08, 'square'); + if (towards) context.car.speed *= 0.4; + npc.contact = (npc.contact ?? 0) + dt; + if (npc.contact > 0.8) { + npc.contact = 0; + context.traffic.remove(npc); + } + } + const { minX, minZ, maxX, maxZ } = data.bounds; + 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); + } +} diff --git a/src/game/runtime/navigationHud.js b/src/game/runtime/navigationHud.js new file mode 100644 index 0000000..bc869da --- /dev/null +++ b/src/game/runtime/navigationHud.js @@ -0,0 +1,348 @@ +import * as THREE from 'three'; +import data from '../../data/mellat.json'; +import { routeBetween, nearestSegment } from '../world/routing.js'; +import { FACTORY } from '../../constants/locations.js'; +import { isOpen, formatClock, fa } from '../economy/gameState.js'; +import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js'; +import { kmh } from '../systems/vehicle.js'; +import { drawMinimap } from '../world/mapRender.js'; +import { bearing, formatDistance } from '../../utils/geo.js'; +/** + * 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(); +} +/** + * Runs the draw compass operation for this subsystem. + * @param {*} context - context. + * @param {*} headingDeg - heading Deg. + * @param {*} markers - markers. + * @returns {*} Operation result, when applicable. + */ +export function drawCompass(context, headingDeg, markers) { + const w = 560, + h = 44; + context.compass.clearRect(0, 0, w, h); + context.compass.fillStyle = 'rgba(10,16,14,.45)'; + context.compass.fillRect(0, 10, w, 24); + context.compass.textAlign = 'center'; + context.compass.textBaseline = 'middle'; + const labels = { + 0: 'شمال', + 90: 'شرق', + 180: 'جنوب', + 270: 'غرب', + }; + for (let d = -100; d <= 100; d += 5) { + const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360, + x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6; + if (x < 10 || x > w - 10) continue; + context.compass.fillStyle = 'rgba(236,242,238,.8)'; + if (labels[deg] !== undefined) { + context.compass.font = 'bold 13px Vazirmatn, sans-serif'; + context.compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee'; + context.compass.fillText(labels[deg], x, 22); + } else if (deg % 15 === 0) { + context.compass.font = '10px Vazirmatn, sans-serif'; + context.compass.fillText(fa(deg), x, 22); + } else context.compass.fillRect(x - 0.5, 18, 1, 8); + } + for (const m of markers) { + const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6; + if (x < 10 || x > w - 10) continue; + context.compass.save(); + context.compass.translate(x, 6); + context.compass.rotate(Math.PI / 4); + context.compass.fillStyle = m.color; + context.compass.fillRect(-4, -4, 8, 8); + context.compass.restore(); + } + context.compass.fillStyle = '#ecf2ee'; + context.compass.fillRect(w / 2 - 1, 34, 2, 8); + context.compass.font = 'bold 11px Vazirmatn, sans-serif'; + context.compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6); +} +/** + * Runs the refresh hud operation for this subsystem. + * @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)}%`, + }); + const active = activeMission(context.state); + context.ui.patch('mission-list', { + items: MISSIONS.map((m) => ({ + id: m.id, + title: m.title, + done: context.state.missionsDone.includes(m.id), + active: m === active, + })), + }); + context.ui.publishGame(context.state); + context.ui.patch('mode-label', { + text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده', + }); + 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: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`, + hidden: false, + }); + return; + } + let text = ''; + const station = context.nearStation(); + if (!context.state.busy && context.payment.phase === 'idle') { + if (station) + text = isOpen(station.def, context.state.minutes) + ? `E · خرید از روبات مارکت · ${station.def.name}` + : `دستگاه بسته است · ${context.hoursText(station.def)}`; + else if ( + context.mode === 'walk' && + Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5 + ) + text = 'F · سوار ماشین شو'; + 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 = 'F · پیاده شو؛ دستگاه نزدیک است'; + } + context.ui.patch('prompt', { + text, + hidden: !text, + }); +} + +// ---------- Map dialog ---------- +/** + * Updates hud from the active simulation. + * @param {*} context - context. + * @param {*} dt - dt. + * @returns {*} Operation result, when applicable. + */ +export function updateHUD(context, dt) { + 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.drawCompass( + headingDeg, + objective + ? [ + { + bearing: bearing(objective.x - pos.x, objective.z - pos.z), + color: '#f2c94c', + }, + ] + : [], + ); + 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' : '#3ee0c9'); + 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.streetTimer -= dt; + if (context.streetTimer <= 0) { + context.streetTimer = 0.6; + const street = nearestSegment(context.segments, pos.x, pos.z, 35, (s) => s[5] >= 0); + const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130; + context.ui.patch('street', { + text: inFactory + ? `کارخانهٔ ${FACTORY.name}` + : street + ? `مشهد · ${data.names[street.name]}` + : 'حومهٔ مشهد', + }); + } + context.updatePrompt(); +} diff --git a/src/game/runtime/purchaseFlow.js b/src/game/runtime/purchaseFlow.js new file mode 100644 index 0000000..d2ca9bc --- /dev/null +++ b/src/game/runtime/purchaseFlow.js @@ -0,0 +1,356 @@ +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, + completeMissions, + activeMission, + MISSIONS, +} from '../economy/scoring.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: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.', + }); + context.keys.clear(); + document.exitPointerLock?.(); + context.ui.open('showroom'); +} +/** + * Runs the interact operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export function interact(context) { + 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', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.'); + return; + } + if (!isOpen(station.def, context.state.minutes)) { + context.say( + context.guideOut() ? 'robot' : 'father', + `این دستگاه الان بسته است. ساعت کاری: ${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: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${context.hoursText(def)}`, + }); + context.ui.patch('shop-name', { + text: `روبات مارکت · ${def.name}`, + }); + context.ui.patch('shop-address', { + text: def.address, + }); + context.ui.patch('shop-note', { + text: `${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: 'در حال نزدیک شدن به کارت‌خوان…', + }); + context.ui.patch('swipe', { + disabled: true, + }); + context.ui.patch('cancel-payment', { + hidden: false, + }); + context.say('child', 'بابا، کارت رو بده من بکشم!'); +} +/** + * 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: 'پرداخت موفق ✓ محصول در حال تحویل است', + }); + 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: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C', + }); + } + 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: 'خرید در دنیای واقعی', + 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', `${product.icon} ${product.name} گرفتم! مرسی بابا.`); + const done = completeMissions(context.state); + for (const m of done) { + context.showBanner( + 'مأموریت انجام شد', + m.title, + `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`, + ); + context.tone(660, 0.5); + } + if (rankFor(context.state.xp).index > rankBefore) + context.setTimeout( + () => + context.showBanner( + 'ارتقای درجه', + rankFor(context.state.xp).name, + 'دستگاه‌های بیشتری پیدا کن', + ), + done.length ? 3400 : 0, + ); + const next = activeMission(context.state); + if (next) { + context.waypoint = null; + context.route = null; + context.setTimeout( + () => + context.say(context.guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), + 1500, + ); + } + context.refreshHUD(); + context.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 ? 'عملیات موفق' : 'پایان بودجه', + }); + context.ui.patch('end-title', { + text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد', + }); + context.ui.patch('end-text', { + text: allDone + ? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.' + : 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:', + }); + const places = new Set(context.state.purchases.map((p) => p.machineId)).size; + context.ui.patch('end-stats', { + items: [ + ['امتیاز', `${fa(context.state.xp)} XP`], + ['درجه', rankFor(context.state.xp).name], + ['خریدها', fa(context.state.purchases.length)], + ['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`], + ['مأموریت‌ها', `${fa(context.state.missionsDone.length)} از ${fa(MISSIONS.length)}`], + ['باقی‌مانده', formatHetoman(context.state.balance)], + ], + }); + context.ui.patch('continue', { + hidden: !allDone, + }); + context.ui.open('end'); +} diff --git a/src/game/runtime/realMode.js b/src/game/runtime/realMode.js new file mode 100644 index 0000000..a93b2ec --- /dev/null +++ b/src/game/runtime/realMode.js @@ -0,0 +1,178 @@ +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'; +/** + * 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, + }); + 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), + onStatus: (message) => (context.real.waiting = message), + stabilizer: (context.real.stabilizer = createStabilizer()), + }); + context.real.stabilizer.stable = context.stableMode; + context.say( + 'father', + 'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.', + ); + 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', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`); + return; + } + if (!context.real.on) { + context.arView.stop(); + return; + } + context.say( + 'robot', + 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', + 'excited', + ); + context.setTimeout(() => { + if (context.real.on && !context.arView.hasOrientation) + context.say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.'); + }, 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(); + context.setRealMode(true); + if (thenStart) context.startMission(); + }); +} +/** + * 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 ?? 'در حال یافتن موقعیت…'}`; + 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 = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`; + } else + uiStatus = `📍 GPS ±${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 ---------- diff --git a/src/game/runtime/renderLoop.js b/src/game/runtime/renderLoop.js new file mode 100644 index 0000000..13a606c --- /dev/null +++ b/src/game/runtime/renderLoop.js @@ -0,0 +1,217 @@ +import * as THREE from 'three'; +import iran from '../../data/iran.json'; +import { clearFraction } from '../world/collision.js'; +import { drawIran } from '../world/mapRender.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('briefing').hidden; + const paused = + !playing || + context.ui.read('shop').open || + context.ui.read('map-dialog').open || + context.ui.read('end').open || + context.ui.read('showroom').open || + document.hidden; + const steps = Math.max(1, Math.ceil(dt / (1 / 30))), + step = dt / steps; + if (!playing) { + // Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay. + const ic = context.$('#iran'), + dpr = context.sizeCanvas(ic), + c = ic.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + drawIran(c, iran, { + width: ic.width / dpr, + height: ic.height / dpr, + t: context.elapsed, + cities: context.CITIES, + }); + context.briefChars = Math.min(context.BRIEF.length, context.briefChars + dt * 55); + context.ui.patch('brief-text', { + text: + context.BRIEF.slice(0, Math.floor(context.briefChars)) + + (context.briefChars < context.BRIEF.length ? '▌' : ''), + }); + } + if (!paused) { + 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.traffic.update( + step, + [ + { + x: context.player.x, + z: context.player.z, + }, + { + x: context.car.x, + z: context.car.z, + }, + ], + 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); + context.showroom.update({ + player: context.player, + time: context.elapsed, + dt, + enabled: context.mode === 'walk' && !context.real.on, + talking: context.ui.read('showroom').open, + }); + context.carModel.group.position.set(context.car.x, 0, context.car.z); + context.carModel.group.rotation.y = context.car.heading; + for (const [i, wheel] of context.carModel.wheels.entries()) { + wheel.rotation.z -= (context.car.speed * dt) / 0.36; + if (i >= 2) wheel.rotation.y = -context.car.steer; + } + for (const s of context.stations) + 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 { + 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(dt); + 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. diff --git a/src/game/runtime/runtimeBindings.js b/src/game/runtime/runtimeBindings.js new file mode 100644 index 0000000..1612667 --- /dev/null +++ b/src/game/runtime/runtimeBindings.js @@ -0,0 +1,166 @@ +import { placeStation, updateReachCircles, reset, startMission } from './runtimeLifecycle.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, + drawCompass, + refreshHUD, + updatePrompt, + updateHUD, +} from './navigationHud.js'; +import { + sizeCanvas, + renderMap, + renderMachineList, + selectMachine, + openMap, +} from './mapInteraction.js'; +import { setRealMode, setArMode, askRealMode, updateReal } from './realMode.js'; +import { resize, updateCamera, frame } from './renderLoop.js'; +import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.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} 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, + drawCompass, + refreshHUD, + updatePrompt, + sizeCanvas, + renderMap, + renderMachineList, + selectMachine, + openMap, + startMission, + 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, + 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); +} diff --git a/src/game/runtime/runtimeLifecycle.js b/src/game/runtime/runtimeLifecycle.js new file mode 100644 index 0000000..96713d4 --- /dev/null +++ b/src/game/runtime/runtimeLifecycle.js @@ -0,0 +1,146 @@ +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'; +/** + * 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' : '#3ee0c9'); + } +} +// 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, + }; + context.factory.reset(); + for (const t of context.factory.talks) t.done = false; + context.car = context.spawnCar(); + 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']) context.ui.close(id); + context.keys.clear(); + context.refreshHUD(); +} + +// ---------- Input ---------- +/** + * Runs the start mission operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export function startMission(context) { + context.ui.patch('briefing', { + hidden: true, + }); + context.ui.patch('hud', { + hidden: false, + }); + context.say( + 'robot', + 'سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدل‌های کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.', + 'wink', + ); + context.say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟'); + context.say( + 'father', + 'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!', + ); + context.say('child', 'اون وانت سفیده کیه داره میاد تو؟'); + context.say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.'); + if (!context.isTouch && !context.real.on) + context.canvas.requestPointerLock?.()?.catch?.(() => {}); +} diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js new file mode 100644 index 0000000..03da211 --- /dev/null +++ b/src/game/runtime/runtimeSetup.js @@ -0,0 +1,1196 @@ +import { createShowroom } from '../models/showroom.js'; +import { showroomAnswer, showroomModelById } from '../../utils/showroomAnswers.js'; +import { loadWorldFiles } from '../../services/worldService.js'; +import * as THREE from 'three'; +import { arrivalRadius, createStabilizer } from '../device/realworld.js'; +import { createArView } from '../device/ar.js'; +import data from '../../data/mellat.json'; +import { createCity } from '../world/city.js'; +import { createImagery } from '../world/imagery.js'; +import { decodeBuildings } from '../world/buildingsData.js'; +import { createCollisionWorld, addPolygon, resolveCircle } from '../world/collision.js'; +import { + buildGraph, + buildSegmentIndex, + nearestSegment, + DRIVABLE_MAX_TYPE, +} from '../world/routing.js'; +import { machines, CITY_START, FACTORY } from '../../constants/locations.js'; +import { isOpen, formatClock } from '../economy/gameState.js'; +import { activeMission } from '../economy/scoring.js'; +import { createFamily } from '../models/characters.js'; +import { createFactory } from '../models/factory.js'; +import { createCar } from '../systems/vehicle.js'; +import { createTraffic } from '../systems/traffic.js'; +import { createSportSedan } from '../models/carModels.js'; +import { createMapAssets } from '../world/mapRender.js'; +import { formatDistance, toLocal } from '../../utils/geo.js'; +/** + * Initializes timers in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeTimers(context) { + context.timers = new Set(); + context.setTimeout = (callback, delay) => { + const timer = window.setTimeout(() => { + context.timers.delete(timer); + if (!context.signal.aborted) callback(); + }, delay); + context.timers.add(timer); + return timer; + }; + context.clearTimeout = (timer) => { + context.timers.delete(timer); + window.clearTimeout(timer); + }; + context.listen = (target, type, handler, options = {}) => + target.addEventListener(type, handler, { + ...options, + signal: context.signal, + }); + context.frameId = undefined; + context.$ = (s) => context.root.querySelector(s); + context.canvas = context.$('#world'); + context.renderer = undefined; +} +/** + * Initializes renderer in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeRenderer(context) { + try { + // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn. + context.renderer = new THREE.WebGLRenderer({ + canvas: context.canvas, + antialias: true, + alpha: true, + powerPreference: 'high-performance', + }); + } catch { + context.ui.patch('loading', { + text: 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.', + }); + throw new Error('WebGL unavailable'); + } + // Low quality (no shadows, 1× pixels) for weak phones; chosen with ?quality=low or the toolbar button. + context.quality = + new URLSearchParams(location.search).get('quality') ?? + (() => { + try { + return localStorage.getItem('quality'); + } catch { + return null; + } + })() ?? + (matchMedia('(pointer: coarse)').matches ? 'low' : 'high'); + context.renderer.setPixelRatio(context.quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75)); +} +/** + * Initializes scene in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeScene(context) { + context.renderer.shadowMap.enabled = context.quality !== 'low'; + context.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + context.renderer.outputColorSpace = THREE.SRGBColorSpace; + context.renderer.toneMapping = THREE.ACESFilmicToneMapping; + context.renderer.toneMappingExposure = 1.05; + context.scene = new THREE.Scene(); + context.fog = new THREE.Fog('#d8cdb4', 220, 1500); + context.scene.fog = context.fog; + context.camera = new THREE.PerspectiveCamera(62, 1, 0.3, 7000); +} +/** + * Initializes sky in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeSky(context) { + // Morning sky over Mashhad: a gradient dome, warm haze at the horizon. + context.sky = new THREE.Mesh( + new THREE.SphereGeometry(5000, 32, 16), + new THREE.ShaderMaterial({ + side: THREE.BackSide, + depthWrite: false, + fog: false, + uniforms: { + top: { + value: new THREE.Color('#6f9fd0'), + }, + horizon: { + value: new THREE.Color('#e6dbc3'), + }, + }, + vertexShader: + 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', + fragmentShader: + 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }', + }), + ); + context.scene.add(context.sky); + context.scene.add(new THREE.HemisphereLight('#fff4dc', '#7d715d', 1.6)); + context.sun = new THREE.DirectionalLight('#ffe9c2', 3.2); + context.sun.castShadow = true; + context.sun.shadow.mapSize.set(2048, 2048); +} +/** + * Initializes lighting and world files in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeLightingAndWorldFiles(context) { + Object.assign(context.sun.shadow.camera, { + left: -75, + right: 75, + top: 75, + bottom: -75, + near: 10, + far: 700, + }); + context.sun.shadow.bias = -0.0004; + context.sun.shadow.normalBias = 0.4; + context.scene.add(context.sun, context.sun.target); + + // ---------- World data ---------- + // Microsoft's building footprints and the satellite tile list are downloaded files, not bundled code. + // The game still runs without them (OSM buildings and drawn roads). + [context.buildingsFile, context.imageryIndex] = await loadWorldFiles(context.signal); + if (context.signal.aborted) { + context.renderer?.dispose(); + return () => {}; + } + // Footprints reaching into the factory compound are left out: the game's own buildings stand there. +} +/** + * Initializes collision and roads in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeCollisionAndRoads(context) { + context.inFactory = (x, z) => { + const dx = x - FACTORY.center.x, + dz = z - FACTORY.center.z; + const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, + v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x; + return u > -112 && u < 82 && v > -52 && v < 52; + }; + context.extraBuildings = ( + context.buildingsFile ? decodeBuildings(context.buildingsFile) : [] + ).filter((b) => { + for (let i = 1; i < b.length; i += 2) if (context.inFactory(b[i], b[i + 1])) return false; + return true; + }); + context.collision = createCollisionWorld( + [...data.buildings, ...context.extraBuildings].map((b) => b.slice(1)), + ); + context.segments = buildSegmentIndex(data.roads); + context.walkGraph = buildGraph(data.roads); + context.driveGraph = buildGraph(data.roads, { + drivable: true, + }); + context.ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2]; + context.rotateLocal = (lx, lz, rot) => ({ + x: lx * Math.cos(rot) + lz * Math.sin(rot), + z: -lx * Math.sin(rot) + lz * Math.cos(rot), + }); +} +/** + * Initializes factory in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeFactory(context) { + // The game starts inside the vending-machine factory on the Mashhad–Chenaran road. + context.factory = createFactory(context.scene); + context.showroom = createShowroom(context.scene); + for (const sample of context.showroom.samples) addPolygon(context.collision, sample.footprint); + context.factory.footprints.forEach((f) => addPolygon(context.collision, f)); + + // City machines stand beside their path point, on whichever side is free, facing the path; + // the factory's demo machine has a fixed place inside the production hall. +} +/** + * Initializes stations in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeStations(context) { + context.stations = machines.map((def) => { + if (def.spot) { + // Test points: the given coordinate is where the player stands; the machine faces the factory. + const front = toLocal(def.spot.lat, def.spot.lon); + const dx = FACTORY.center.x - front.x, + dz = FACTORY.center.z - front.z, + len = Math.hypot(dx, dz) || 1; + return context.placeStation( + def, + { + x: front.x - (dx / len) * 1.9, + z: front.z - (dz / len) * 1.9, + }, + Math.atan2(dx, dz), + ); + } + if (!def.anchor) + return context.placeStation(def, context.factory.demo, context.factory.demo.rot); + const seg = nearestSegment(context.segments, def.anchor.x, def.anchor.z, 40) ?? { + dx: 1, + dz: 0, + type: 9, + }; + const len = Math.hypot(seg.dx, seg.dz) || 1, + nx = -seg.dz / len, + nz = seg.dx / len; + const off = (seg.type <= DRIVABLE_MAX_TYPE ? context.ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4; + let pos = null; + for (const d of [off, -off, off + 1.5, -off - 1.5]) { + const x = def.anchor.x + nx * d, + z = def.anchor.z + nz * d; + if (!resolveCircle(context.collision, x, z, 1.4).hit) { + pos = { + x, + z, + }; + break; + } + } + pos ??= { + x: def.anchor.x + nx * off, + z: def.anchor.z + nz * off, + }; + return context.placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z)); + }); +} +/** + * Initializes station by id in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeStationById(context) { + context.stationById = (id) => context.stations.find((s) => s.def.id === id); + for (const s of context.stations) if (s.def.test) s.model.group.visible = false; + + // In real-world mode a circle on the ground marks how close counts as "at the machine". + for (const s of context.stations) { + s.reach = new THREE.Mesh( + new THREE.RingGeometry(0.96, 1, 96), + new THREE.MeshBasicMaterial({ + color: '#3ee0c9', + transparent: true, + opacity: 0.55, + depthWrite: false, + fog: false, + side: THREE.DoubleSide, + }), + ); + s.reach.rotation.x = -Math.PI / 2; + s.reach.position.set(s.front.x, 0.08, s.front.z); + s.reach.visible = false; + context.scene.add(s.reach); + } + // GPS test points only take part while real-world mode is on. + context.activeStations = () => context.stations.filter((s) => !s.def.test || context.real.on); +} +/** + * Initializes closest station in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeClosestStation(context) { + // The closest active machine whose front is within `radius` of (x, z). + context.closestStation = (x, z, radius) => + context + .activeStations() + .map((s) => ({ + s, + d: Math.hypot(s.front.x - x, s.front.z - z), + })) + .filter(({ d }) => d < radius) + .sort((a, b) => a.d - b.d)[0]?.s ?? null; + context.imagery = createImagery(context.scene, context.imageryIndex ?? {}, { + lowQuality: context.quality === 'low', + anisotropy: Math.min(16, context.renderer.capabilities.getMaxAnisotropy()), + }); + context.city = createCity(context.scene, data, { + collision: context.collision, + segments: context.segments, + extraBuildings: context.extraBuildings, + satellite: context.imagery.available, + lowQuality: context.quality === 'low', + }); + if (context.imagery.available) + context.ui.patch('attribution', { + text: `نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${context.imageryIndex.attribution}`, + }); + context.traffic = createTraffic(context.scene, data, 28); + context.mapAssets = createMapAssets(data, [context.factory.label]); +} +/** + * Initializes park gate in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeParkGate(context) { + // Park gate sign at the first city machine. + context.parkGate = new THREE.Group(); +} +/** + * Runs the build park gate operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function buildParkGate(context) { + { + const start = context.stationById(CITY_START), + sign = document.createElement('canvas'); + sign.width = 512; + sign.height = 128; + const c = sign.getContext('2d'); + c.fillStyle = '#244d2e'; + c.fillRect(0, 0, 512, 128); + c.fillStyle = '#f4efd9'; + c.textAlign = 'center'; + c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif'; + c.fillText('بوستان ملت', 256, 86); + const texture = new THREE.CanvasTexture(sign); + texture.colorSpace = THREE.SRGBColorSpace; + const gate = context.parkGate, + post = new THREE.MeshStandardMaterial({ + color: '#6b5a49', + }); + for (const side of [-1, 1]) { + const p = new THREE.Mesh(new THREE.BoxGeometry(0.3, 4, 0.3), post); + p.position.set(side * 2.6, 2, 0); + p.castShadow = true; + gate.add(p); + } + const board = new THREE.Mesh( + new THREE.PlaneGeometry(5.6, 1.4), + new THREE.MeshBasicMaterial({ + map: texture, + side: THREE.DoubleSide, + }), + ); + board.position.y = 4; + gate.add(board); + const at = context.rotateLocal(-4.5, 1.5, start.rot); + gate.position.set(start.x + at.x, 0, start.z + at.z); + gate.rotation.y = start.rot; + context.scene.add(gate); + } + + // ---------- Actors ---------- +} +/** + * Initializes family in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeFamily(context) { + context.family = createFamily(); + context.scene.add(context.family.group); + // The Robot Market guide lives inside the machines and comes out when the family arrives. + context.guide = createFamily({ + robot: true, + }); + context.guide.group.visible = false; + context.scene.add(context.guide.group); + context.carModel = createSportSedan('#1d3f78'); + context.scene.add(context.carModel.group); + context.state = undefined; + context.payment = undefined; + context.player = undefined; + context.car = undefined; + context.mode = undefined; + context.cam = undefined; + context.waypoint = undefined; + context.route = undefined; + context.routeTimer = undefined; + context.deliveryTimer = undefined; + context.ended = undefined; + context.keys = new Set(); +} +/** + * Initializes spawn car in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeSpawnCar(context) { + // The family car waits in the factory yard right beside them, pointing at the gate. + context.spawnCar = () => + createCar(context.factory.car.x, context.factory.car.z, context.factory.car.heading); + // ---------- Input ---------- + context.isTouch = matchMedia('(pointer: coarse)').matches; + context.anyDialogOpen = () => + context.ui.read('shop').open || + context.ui.read('map-dialog').open || + context.ui.read('end').open || + context.ui.read('showroom').open || + !context.ui.read('briefing').hidden; + // Physical key codes, so the controls also work with a Persian keyboard layout. + context.keyName = (e) => + e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase(); +} +/** + * Runs the register keyboard operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerKeyboard(context) { + context.listen(window, 'keydown', (e) => { + if (e.target instanceof HTMLInputElement) return; + const k = context.keyName(e); + if (k === 'm' && !e.repeat && context.ui.read('briefing').hidden) { + e.preventDefault(); + context.ui.read('map-dialog').open ? context.ui.close('map-dialog') : context.openMap(); + return; + } + if (context.anyDialogOpen()) return; + if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k)) + e.preventDefault(); + if (e.repeat) return; + context.keys.add(k); + if (k === 'f') context.toggleCar(); + if (k === 'e') context.interact(); + if (k === 'q') context.openShowroom(); + if (k === 'c') context.swipe(); + if (k === 'escape' && context.payment.phase !== 'idle') context.cancel(); + }); + context.listen(window, 'keyup', (e) => context.keys.delete(context.keyName(e))); + context.listen(window, 'blur', () => context.keys.clear()); + + // Pointer lock gives a shooter-style mouse look; dragging works as a fallback and on touch. +} +/** + * Runs the register pointer look operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerPointerLook(context) { + context.listen(context.canvas, 'click', () => { + if ( + !context.isTouch && + !context.anyDialogOpen() && + document.pointerLockElement !== context.canvas + ) + context.canvas.requestPointerLock?.()?.catch?.(() => {}); + }); + context.listen(document, 'mousemove', (e) => { + if (document.pointerLockElement !== context.canvas) return; + context.look(e.movementX, e.movementY, 0.0024); + }); + context.drag = null; + context.listen(context.canvas, 'pointerdown', (e) => { + if (document.pointerLockElement === context.canvas) return; + context.drag = { + id: e.pointerId, + x: e.clientX, + y: e.clientY, + }; + context.canvas.setPointerCapture(e.pointerId); + }); + context.listen(context.canvas, 'pointermove', (e) => { + if (!context.drag || context.drag.id !== e.pointerId) return; + context.look(e.clientX - context.drag.x, e.clientY - context.drag.y, 0.005); + context.drag.x = e.clientX; + context.drag.y = e.clientY; + }); +} +/** + * Runs the register camera zoom operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerCameraZoom(context) { + context.listen(context.canvas, 'pointerup', () => (context.drag = null)); + context.listen(context.canvas, 'pointercancel', () => (context.drag = null)); + context.listen( + context.canvas, + 'wheel', + (e) => { + context.cam.distance = THREE.MathUtils.clamp( + context.cam.distance + Math.sign(e.deltaY) * 0.6, + 3, + 12, + ); + }, + { + passive: true, + }, + ); + // Touch joystick: x steers / strafes, y walks / throttles. + context.stick = { + x: 0, + y: 0, + id: null, + }; +} +/** + * Runs the register touch input operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerTouchInput(context) { + if (context.isTouch) { + context.ui.patch('touch', { + hidden: false, + }); + const pad = context.$('#stick'), + knob = pad.querySelector('i'); + /** + * Runs the move operation for this subsystem. + * @param {*} e - e. + * @returns {*} Operation result, when applicable. + */ + const move = (e) => { + const r = pad.getBoundingClientRect(), + dx = e.clientX - (r.left + r.width / 2), + dy = e.clientY - (r.top + r.height / 2); + const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)), + a = Math.atan2(dy, dx); + context.stick.x = Math.cos(a) * len; + context.stick.y = -Math.sin(a) * len; + knob.style.transform = `translate(${context.stick.x * 40}px, ${-context.stick.y * 40}px)`; + }; + context.listen(pad, 'pointerdown', (e) => { + context.stick.id = e.pointerId; + pad.setPointerCapture(e.pointerId); + move(e); + }); + context.listen(pad, 'pointermove', (e) => e.pointerId === context.stick.id && move(e)); + /** + * Runs the end operation for this subsystem. + + * @returns {*} Operation result, when applicable. + */ + const end = () => { + context.stick.id = null; + context.stick.x = context.stick.y = 0; + knob.style.transform = ''; + }; + context.listen(pad, 'pointerup', end); + context.listen(pad, 'pointercancel', end); + for (const k of ['shift', 'f', 'e']) + context.ui.on(`touch-${k}`, () => { + if (k === 'shift') { + if (context.keys.has('shift')) context.keys.delete('shift'); + else context.keys.add('shift'); + context.ui.patch('touch-shift', { + pressed: context.keys.has('shift'), + }); + } + if (k === 'f') context.toggleCar(); + if (k === 'e') context.payment.phase === 'ready' ? context.swipe() : context.interact(); + }); + } +} +/** + * Initializes axis in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeAxis(context) { + context.axis = (pos, neg) => + Number(pos.some((k) => context.keys.has(k))) - Number(neg.some((k) => context.keys.has(k))); + // ---------- Subtitles ---------- + context.SPEAKERS = { + robot: 'روبات', + father: 'پدر', + child: 'بچه', + }; + // Factory staff speak under their own names. + for (const t of context.factory.talks) if (t.label) context.SPEAKERS[t.speaker] = t.label; + context.lines = []; + context.lineTimer = 0; + context.talking = false; + // ---------- Sound ---------- + context.audio = null; + context.soundOn = false; + context.engine = null; +} +/** + * Runs the register sound control operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerSoundControl(context) { + context.ui.on('sound', async () => { + try { + context.audio ??= new AudioContext(); + await context.audio.resume(); + context.soundOn = !context.soundOn; + if (!context.engine) { + const o = context.audio.createOscillator(), + g = context.audio.createGain(); + o.type = 'sawtooth'; + g.gain.value = 0; + o.connect(g).connect(context.audio.destination); + o.start(); + context.engine = { + o, + g, + }; + } + context.ui.patch('sound', { + text: context.soundOn ? 'صدا: روشن' : 'صدا: خاموش', + }); + context.ui.patch('sound', { + pressed: String(context.soundOn), + }); + context.tone(700); + } catch { + context.ui.patch('sound', { + text: 'صدا در دسترس نیست', + }); + } + }); + + // ---------- Car ---------- +} +/** + * Initializes reach radius in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeReachRadius(context) { + // ---------- Machines, shop and payment ---------- + // In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres. + context.reachRadius = () => + context.real.on && context.real.fix ? arrivalRadius(context.real.fix.accuracy) : 3.4; + context.nearStation = () => + context.mode === 'walk' + ? context.closestStation(context.player.x, context.player.z, context.reachRadius()) + : null; + context.hoursText = (m) => + m.hours[1] - m.hours[0] >= 24 * 60 + ? 'شبانه‌روزی' + : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`; + context.shopStation = null; + context.nearShowroom = () => + !context.real.on && context.mode === 'walk' ? context.showroom.nearest(context.player) : null; + context.ui.on('showroom-select', (id) => { + if (!context.ui.read('showroom').open || !showroomModelById(id)) return; + context.ui.patch('showroom', { + modelId: id, + }); + context.ui.patch('showroom-response', { + text: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟', + }); + }); +} +/** + * Runs the register purchase controls operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerPurchaseControls(context) { + context.ui.on('showroom-open', context.openShowroom); + context.ui.on('showroom-question', (question) => { + if ( + !context.ui.read('showroom').open || + question.modelId !== context.ui.read('showroom').modelId + ) + return; + const answer = showroomAnswer(question); + if (!answer) return; + context.ui.patch('showroom-response', { + text: `روبات: ${answer}`, + }); + context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink'); + }); + context.ui.on('swipe', context.swipe); + // On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car. + context.ui.on('prompt', () => { + if (context.nearShowroom()) { + context.openShowroom(); + return; + } + if (context.payment.phase === 'ready') context.swipe(); + else if (context.nearStation()) context.interact(); + else context.toggleCar(); + }); + context.ui.on('cancel-payment', context.cancel); + context.ui.on('shop-close', () => context.ui.close('shop')); + context.ui.patch('quality', { + text: context.quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل', + }); +} +/** + * Runs the register preferences operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerPreferences(context) { + context.ui.on('quality', () => { + try { + localStorage.setItem('quality', context.quality === 'low' ? 'high' : 'low'); + } catch {} + location.replace(location.pathname); + }); + context.ui.on('again', context.reset); + context.ui.on('continue', () => context.ui.close('end')); + context.ui.on('restart', context.reset); + + // ---------- Objective, route and HUD ---------- + context.objectiveStation = () => { + if (context.waypoint) return context.stationById(context.waypoint); + const mission = activeMission(context.state); + if (mission?.target) return context.stationById(mission.target); + // Open-ended missions point to the nearest place not bought from yet. + const visited = new Set(context.state.purchases.map((p) => p.machineId)); + return ( + context + .activeStations() + .filter((s) => !visited.has(s.def.id)) + .sort( + (a, b) => + Math.hypot(a.x - context.player.x, a.z - context.player.z) - + Math.hypot(b.x - context.player.x, b.z - context.player.z), + )[0] ?? null + ); + }; +} +/** + * Initializes route material in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeRouteMaterial(context) { + // The gold route ribbon on the ground, rebuilt when the route changes. + context.routeMaterial = new THREE.MeshBasicMaterial({ + color: '#f2c94c', + transparent: true, + opacity: 0.75, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -12, + polygonOffsetUnits: -24, + }); + context.routeMesh = null; + context.routeTarget = null; + context.routeFrom = { + x: 0, + z: 0, + }; + context.routeMode = null; + context.compass = context.$('#compass').getContext('2d'); + context.minimap = context.$('#minimap').getContext('2d'); +} +/** + * Initializes map view in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeMapView(context) { + // ---------- Map dialog ---------- + context.mapView = { + cx: 0, + cz: 0, + mpp: 2, + }; + context.mapCanvas = context.$('#fullmap'); + context.iranCanvas = context.$('#iran-map'); + context.selected = null; + context.mapTab = 'city'; + context.markers = () => { + const visited = new Set(context.state.purchases.map((p) => p.machineId)), + objective = context.objectiveStation(); + return context.activeStations().map((s) => ({ + id: s.def.id, + x: s.x, + z: s.z, + visited: visited.has(s.def.id), + open: isOpen(s.def, context.state.minutes), + objective: s === objective, + })); + }; +} +/** + * Runs the register route selection operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerRouteSelection(context) { + context.ui.on('route-button', () => { + if (!context.selected) return; + context.waypoint = context.selected; + context.route = null; + context.routeTimer = 0; + context.updateRoute(0); + const s = context.stationById(context.selected); + context.say( + 'father', + `مسیر تا ${s.def.name} رو گذاشتم؛ ${context.route ? formatDistance(context.route.length) : ''}. دنبال خط طلایی بریم.`, + ); + context.selectMachine(context.selected); + }); + context.ui.on('minimap-button', context.openMap); + context.ui.on('map-close', () => context.ui.close('map-dialog')); +} +/** + * Runs the register map tabs operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerMapTabs(context) { + for (const tab of ['city', 'iran']) + context.ui.on(`tab-${tab}`, () => { + context.mapTab = tab; + context.ui.patch('tab-city', { + selected: tab === 'city', + }); + context.ui.patch('tab-iran', { + selected: tab === 'iran', + }); + context.ui.patch('fullmap', { + hidden: tab !== 'city', + }); + context.ui.patch('iran-map', { + hidden: tab !== 'iran', + }); + context.renderMap(); + }); + context.ui.on('select-machine', (id) => context.selectMachine(id, true)); + context.ui.on('select-product', context.startPayment); + context.mapDrag = null; +} +/** + * Runs the register map dragging operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerMapDragging(context) { + context.listen(context.mapCanvas, 'pointerdown', (e) => { + context.mapDrag = { + x: e.clientX, + y: e.clientY, + moved: 0, + }; + context.mapCanvas.setPointerCapture(e.pointerId); + }); + context.listen(context.mapCanvas, 'pointermove', (e) => { + if (!context.mapDrag) return; + context.mapView.cx -= (e.clientX - context.mapDrag.x) * context.mapView.mpp; + context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp; + context.mapDrag.moved += + Math.abs(e.clientX - context.mapDrag.x) + Math.abs(e.clientY - context.mapDrag.y); + context.mapDrag.x = e.clientX; + context.mapDrag.y = e.clientY; + context.renderMap(); + }); +} +/** + * Runs the register map selection and zoom operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function registerMapSelectionAndZoom(context) { + context.listen(context.mapCanvas, 'pointerup', (e) => { + if (context.mapDrag && context.mapDrag.moved < 6) { + const r = context.mapCanvas.getBoundingClientRect(); + const wx = context.mapView.cx + (e.clientX - r.left - r.width / 2) * context.mapView.mpp, + wz = context.mapView.cz + (e.clientY - r.top - r.height / 2) * context.mapView.mpp; + const hit = context + .activeStations() + .find((s) => Math.hypot(s.x - wx, s.z - wz) < 14 * context.mapView.mpp); + if (hit) context.selectMachine(hit.def.id); + } + context.mapDrag = null; + }); + context.listen( + context.mapCanvas, + 'wheel', + (e) => { + e.preventDefault(); + context.mapView.mpp = THREE.MathUtils.clamp( + context.mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), + 0.35, + 30, + ); + context.renderMap(); + }, + { + passive: false, + }, + ); + + // ---------- Briefing ---------- +} +/** + * Initializes cities in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeCities(context) { + context.CITIES = [ + { + name: 'مشهد', + lat: 36.3, + lon: 59.6, + active: true, + }, + { + name: 'تهران', + lat: 35.69, + lon: 51.39, + }, + { + name: 'اصفهان', + lat: 32.65, + lon: 51.67, + }, + { + name: 'شیراز', + lat: 29.59, + lon: 52.58, + }, + { + name: 'تبریز', + lat: 38.08, + lon: 46.29, + }, + { + name: 'اهواز', + lat: 31.32, + lon: 48.67, + }, + { + name: 'کرمان', + lat: 30.28, + lon: 57.08, + }, + { + name: 'رشت', + lat: 37.28, + lon: 49.58, + }, + { + name: 'یزد', + lat: 31.9, + lon: 54.37, + }, + { + name: 'زاهدان', + lat: 29.5, + lon: 60.86, + }, + { + name: 'کرمانشاه', + lat: 34.31, + lon: 47.07, + }, + { + name: 'بندرعباس', + lat: 27.18, + lon: 56.27, + }, + ]; +} +/** + * Initializes briefing and tracking in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeBriefingAndTracking(context) { + context.BRIEF = [ + `نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`, + 'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست', + 'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.', + 'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.', + 'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.', + 'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.', + ].join('\n'); + context.briefChars = 0; + context.ui.on('start-mission', context.startMission); + + // ---------- Real-world (GPS) mode ---------- + // The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where + // a machine stands lets the player buy there, with a bonus. + context.real = { + on: false, + fix: null, + heading: null, + error: null, + waiting: null, + stop: null, + stabilizer: createStabilizer(), + }; + // ---------- 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. + context.arView = createArView(context.$('#camera')); +} +/** + * Initializes ar mode in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeArMode(context) { + context.arMode = false; + context.stableMode = true; + context.hiddenInAr = () => [ + context.sky, + context.city.group, + context.imagery.group, + context.factory.group, + context.traffic.group, + context.carModel.group, + context.parkGate, + ...context.showroom.samples.map((sample) => sample.machine.group), + ]; + context.ui.on('ar-toggle', () => context.setArMode(!context.arMode)); + context.ui.on('stable-toggle', () => { + context.stableMode = !context.stableMode; + context.real.stabilizer.stable = context.stableMode; + context.ui.classState('stable-toggle', 'on', context.stableMode); + context.ui.patch('stable-toggle', { + text: context.stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش', + }); + }); + + // The safety notice comes first; permissions are requested from the tap on its button (iOS needs that). + + context.ui.on('real-mode', () => context.askRealMode(true)); + context.ui.on('gps-toggle', () => context.askRealMode(false)); + context.ui.on('real-cancel', () => context.ui.close('real-intro')); + // ---------- Loop ---------- + context.camPos = new THREE.Vector3(); + context.camTarget = new THREE.Vector3(); + context.lookAt = new THREE.Vector3(); + context.elapsed = 0; +} +/** + * Initializes last in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeLast(context) { + context.last = performance.now(); + context.hudTimer = 0; + context.streetTimer = 0; + // Debug runs (headless checks render ~1 fps) may simulate a whole second per frame, still in substeps. + context.MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : 0.25; + context.listen(window, 'resize', context.resize); + // Collision log for ?debug runs; trimmed so it never grows without bound. + context.debugHits = []; + context.hints = { + closed: false, + car: false, + }; + // ---------- 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. + context.guideState = { + station: null, + t: 0, + greeted: new Set(), + arrived: 0, + }; + context.guideOut = () => context.guideState.t >= 1; + context.easeOutBack = (t) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2; +} +/** + * Runs the expose debug tools operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function exposeDebugTools(context) { + // ?debug exposes live game state in the console for development and automated checks. + if (new URLSearchParams(location.search).has('debug')) + window.game = { + get state() { + return context.state; + }, + get player() { + return context.player; + }, + get cam() { + return context.cam; + }, + factory: context.factory, + get car() { + return context.car; + }, + get mode() { + return context.mode; + }, + get payment() { + return context.payment; + }, + get elapsed() { + return context.elapsed; + }, + get route() { + return context.route; + }, + setWaypoint(id) { + context.waypoint = id; + context.route = null; + context.routeTimer = 0; + }, + get arMode() { + return context.arMode; + }, + arView: context.arView, + setArMode: context.setArMode, + debugHits: context.debugHits, + nearestRoad: (x, z) => nearestSegment(context.segments, x, z, 40, (s) => s[4] <= 3), + stations: context.stations, + guideState: context.guideState, + real: context.real, + keys: context.keys, + traffic: context.traffic, + }; +} +/** + * Runs the start game loop operation for this subsystem. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function startGameLoop(context) { + context.reset(); + context.resize(); + context.ui.patch('loading', { + hidden: true, + }); + context.frameId = requestAnimationFrame(context.frame); +} +/** + * Initializes dispose in dependency order. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export async function initializeDispose(context) { + context.dispose = () => { + cancelAnimationFrame(context.frameId); + context.timers?.forEach(window.clearTimeout); + context.real?.stop?.(); + context.arView?.stop(); + context.arView?.stopSensors(); + context.imagery?.dispose?.(); + context.audio?.close(); + const geometries = new Set(), + materials = new Set(), + textures = new Set(); + context.scene?.traverse((object) => { + if (object.geometry) geometries.add(object.geometry); + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) { + if (!material) continue; + materials.add(material); + for (const value of Object.values(material)) if (value?.isTexture) textures.add(value); + } + }); + geometries.forEach((item) => item.dispose()); + materials.forEach((item) => item.dispose()); + textures.forEach((item) => item.dispose()); + context.renderer?.dispose(); + if (document.pointerLockElement === context.canvas) document.exitPointerLock?.(); + if (window.game) delete window.game; + context.ui.clearActions(); + }; +} diff --git a/src/game/runtime/worldEncounters.js b/src/game/runtime/worldEncounters.js new file mode 100644 index 0000000..2b70349 --- /dev/null +++ b/src/game/runtime/worldEncounters.js @@ -0,0 +1,164 @@ +import * as THREE from 'three'; +import { FACTORY } from '../../constants/locations.js'; +import { isOpen } from '../economy/gameState.js'; +/** + * Updates talks from the active simulation. + * @param {*} context - context. + * @returns {*} Operation result, when applicable. + */ +export // People in the factory say their lines once, when the family walks up to them. +function updateTalks(context) { + if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return; + for (const t of context.factory.talks) { + if (t.done || (t.ready && !t.ready())) continue; + const p = t.pos(); + if (Math.hypot(p.x - context.player.x, p.z - context.player.z) > t.radius) continue; + t.done = true; + for (const text of t.lines) context.say(t.speaker, text); + return; + } +} + +// ---------- 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', + 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!', + 'excited', + ); + context.say( + 'robot', + `به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, + 'happy', + ); + context.say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink'); + context.say( + 'robot', + 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', + 'love', + ); + return; + } + if (!isOpen(def, context.state.minutes)) { + context.say( + 'robot', + `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(def)}.`, + 'serious', + ); + return; + } + if (first) { + context.say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited'); + context.say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy'); + } else context.say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', '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', 'خداحافظ! مسیر طلایی رو دنبال کنین.', '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(); + if (target && !isOpen(target.def, context.state.minutes) && !context.hints.closed) { + context.hints.closed = true; + context.say( + 'robot', + `${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', 'راه طولانیه؛ بهتره با ماشین بریم.'); + } +} diff --git a/src/game/systems/traffic.js b/src/game/systems/traffic.js index 8ae5a11..35eb95e 100644 --- a/src/game/systems/traffic.js +++ b/src/game/systems/traffic.js @@ -1,14 +1,49 @@ 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 }); + 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 }); + 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); @@ -27,7 +62,10 @@ export function createCarModel(color = '#d9d9d4') { 0.06, 0.18, 0.34, - mat('#fff4cc', { emissive: '#fff1b0', emissiveIntensity: 0.6 }), + mat('#fff4cc', { + emissive: '#fff1b0', + emissiveIntensity: 0.6, + }), 2.21, 0.78, z, @@ -36,7 +74,10 @@ export function createCarModel(color = '#d9d9d4') { 0.06, 0.16, 0.34, - mat('#b3261e', { emissive: '#7a0f0b', emissiveIntensity: 0.5 }), + mat('#b3261e', { + emissive: '#7a0f0b', + emissiveIntensity: 0.5, + }), -2.21, 0.8, z, @@ -58,9 +99,11 @@ export function createCarModel(color = '#d9d9d4') { group.add(wheel); wheels.push(wheel); } - return { group, wheels }; + return { + group, + wheels, + }; } - const COLORS = [ '#e9e9e4', '#1d1f22', @@ -76,6 +119,13 @@ const COLORS = [ // falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane. const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js +/** + * 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 @@ -105,22 +155,47 @@ export function createTraffic(scene, data, count = 28) { ]; }) .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 }; + 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 }; + nearbyAt = { + x, + z, + }; nearby = roads.filter( ({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600, ); @@ -128,6 +203,13 @@ export function createTraffic(scene, data, count = 28) { 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++) { @@ -159,16 +241,36 @@ export function createTraffic(scene, data, count = 28) { const model = createCarModel(COLORS[i % COLORS.length]); group.add(model.group); model.group.visible = false; - cars.push({ model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9 }); + cars.push({ + model, + 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; } - 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++) { @@ -210,5 +312,10 @@ export function createTraffic(scene, data, count = 28) { for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36; } } - return { group, cars, update, remove }; + return { + group, + cars, + update, + remove, + }; } diff --git a/src/game/systems/vehicle.js b/src/game/systems/vehicle.js index cb08c3f..2578606 100644 --- a/src/game/systems/vehicle.js +++ b/src/game/systems/vehicle.js @@ -10,11 +10,29 @@ export const CAR = { wheelBase: 2.7, radius: 1.6, }; - +/** + * Creates car resources for the game. + * @param {*} x - x. + * @param {*} z - z. + * @param {*} heading - heading. + * @returns {*} Operation result, when applicable. + */ export function createCar(x, z, heading = 0) { - return { x, z, heading, speed: 0, steer: 0 }; + return { + x, + z, + heading, + speed: 0, + steer: 0, + }; } - +/** + * Runs the step car operation for this subsystem. + * @param {*} car - car. + * @param {*} options1 - Operation options. + * @param {*} dt - dt. + * @returns {*} Operation result, when applicable. + */ export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) { car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6); if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt; @@ -31,5 +49,9 @@ export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) car.z -= Math.sin(car.heading) * car.speed * dt; return car; } - +/** + * Runs the kmh operation for this subsystem. + * @param {*} car - car. + * @returns {*} Operation result, when applicable. + */ export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6); diff --git a/src/game/world/buildingsData.js b/src/game/world/buildingsData.js index 9c9f68f..b3fbc25 100644 --- a/src/game/world/buildingsData.js +++ b/src/game/world/buildingsData.js @@ -1,3 +1,8 @@ +/** + * 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) { diff --git a/src/game/world/city.js b/src/game/world/city.js index 6ff7f0e..d014ad2 100644 --- a/src/game/world/city.js +++ b/src/game/world/city.js @@ -70,7 +70,14 @@ const BUILDING_COLORS = [ '#d2b48f', '#b7957a', ].map((c) => new THREE.Color(c)); - +/** + * Runs the canvas texture operation for this subsystem. + * @param {*} width - width. + * @param {*} height - height. + * @param {*} draw - draw. + * @param {*} repeat - repeat. + * @returns {*} Operation result, when applicable. + */ function canvasTexture(width, height, draw, repeat = true) { const canvas = document.createElement('canvas'); canvas.width = width; @@ -82,7 +89,16 @@ function canvasTexture(width, height, draw, repeat = true) { texture.anisotropy = 8; return texture; } - +/** + * Runs the noise operation for this subsystem. + * @param {*} c - c. + * @param {*} w - w. + * @param {*} h - h. + * @param {*} base - base. + * @param {*} spread - spread. + * @param {*} count - count. + * @returns {*} Operation result, when applicable. + */ function noise(c, w, h, base, spread, count) { c.fillStyle = base; c.fillRect(0, 0, w, h); @@ -92,7 +108,11 @@ function noise(c, w, h, base, spread, count) { c.fillRect(Math.random() * w, Math.random() * h, 2, 2); } } +/** + * Runs the textures operation for this subsystem. + * @returns {*} Operation result, when applicable. + */ const textures = () => ({ major: canvasTexture(128, 256, (c, w, h) => { noise(c, w, h, '#4a4d50', 0.12, 1800); @@ -140,6 +160,15 @@ const textures = () => ({ }); // Pushes a strip of quads along a polyline into a geometry buffer. +/** + * Runs the ribbon operation for this subsystem. + * @param {*} out - out. + * @param {*} pts - pts. + * @param {*} width - width. + * @param {*} y - y. + * @param {*} repeat - repeat. + * @returns {*} Operation result, when applicable. + */ function ribbon(out, pts, width, y, repeat) { const n = pts.length / 2; if (n < 2) return; @@ -150,6 +179,12 @@ function ribbon(out, pts, width, y, repeat) { 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]); + /** + * Runs the seg operation for this subsystem. + * @param {*} a - a. + * @param {*} b - b. + * @returns {*} Operation result, when applicable. + */ const seg = (a, b) => { const dx = pts[b * 2] - pts[a * 2], dz = pts[b * 2 + 1] - pts[a * 2 + 1], @@ -173,7 +208,15 @@ function ribbon(out, pts, width, y, repeat) { } } } - +/** + * Runs the disc operation for this subsystem. + * @param {*} out - out. + * @param {*} x - x. + * @param {*} z - z. + * @param {*} radius - radius. + * @param {*} y - y. + * @returns {*} Operation result, when applicable. + */ function disc(out, x, z, radius, y) { const base = out.pos.length / 3; out.pos.push(x, y, z); @@ -185,7 +228,11 @@ function disc(out, x, z, radius, y) { if (i) out.idx.push(base, base + i + 1, base + i); } } - +/** + * Runs the polygon triangles operation for this subsystem. + * @param {*} pts - pts. + * @returns {*} Operation result, when applicable. + */ function polygonTriangles(pts) { const contour = []; for (let i = 0; i < pts.length; i += 2) contour.push(new THREE.Vector2(pts[i], pts[i + 1])); @@ -197,6 +244,15 @@ function polygonTriangles(pts) { } // Flat polygon facing up; triangles are flipped where needed so normals point to the sky. +/** + * Runs the flat polygon operation for this subsystem. + * @param {*} out - out. + * @param {*} pts - pts. + * @param {*} y - y. + * @param {*} uvScale - uv Scale. + * @param {*} color - color. + * @returns {*} Operation result, when applicable. + */ function flatPolygon(out, pts, y, uvScale = 0, color = null) { const base = out.pos.length / 3; for (let i = 0; i < pts.length; i += 2) { @@ -211,7 +267,13 @@ function flatPolygon(out, pts, y, uvScale = 0, color = null) { out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b])); } } - +/** + * Runs the to mesh operation for this subsystem. + * @param {*} out - out. + * @param {*} material - material. + * @param {*} normals - normals. + * @returns {*} Operation result, when applicable. + */ function toMesh(out, material, normals = 'up') { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(out.pos, 3)); @@ -232,7 +294,12 @@ function toMesh(out, material, normals = 'up') { mesh.receiveShadow = true; return mesh; } - +/** + * Runs the ground material operation for this subsystem. + * @param {*} options - options. + * @param {*} layer - layer. + * @returns {*} Operation result, when applicable. + */ const groundMaterial = (options, layer) => new THREE.MeshStandardMaterial({ roughness: 0.95, @@ -241,14 +308,22 @@ const groundMaterial = (options, layer) => polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2, }); - +/** + * Runs the seeded operation for this subsystem. + * @param {*} seed - seed. + * @returns {*} Operation result, when applicable. + */ function seeded(seed) { return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; } - +/** + * Runs the signed area operation for this subsystem. + * @param {*} pts - pts. + * @returns {*} Operation result, when applicable. + */ function signedArea(pts) { let a = 0; for (let i = 0; i < pts.length; i += 2) { @@ -264,51 +339,160 @@ const CHUNK = 2000, // 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). +/** + * Creates city resources for the game. + * @param {*} scene - scene. + * @param {*} data - data. + * @param {*} options2 - Operation options. + * @returns {*} Operation result, when applicable. + */ 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 build = { + scene, + data, + collision, + segments, + extraBuildings, + satellite, + lowQuality, }; - const addChunked = (mesh) => { + for (const [name, method] of Object.entries(CITY_METHODS)) + build[name] = (...args) => method(build, ...args); + for (const initialize of CITY_STEPS) initialize(build); + return build.result; +} +/** + * Runs the city build block operation for this subsystem. + * @param {*} build - build. + * @param {*} block - block. + * @returns {*} Operation result, when applicable. + */ +function cityBuildBlock(build, 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(0.9 + ((shade * 7) % 1) * 0.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(0.78)); + } + block.meshes = [toMesh(walls, build.wallMaterial, 'compute'), toMesh(roofs, build.roofMaterial)]; + for (const mesh of block.meshes) { + mesh.castShadow = true; + build.addChunked(mesh); + } +} + +// Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings. +/** + * Runs the city build view_range operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildVIEW_RANGE(build) { + build.VIEW_RANGE = build.lowQuality ? 1500 : 2400; + build.tex = textures(); + build.group = new THREE.Group(); + build.scene.add(build.group); + build.random = seeded(1405); + build.chunked = []; + build.buckets = new Map(); + build.bucket = (name, x, z, make) => { + const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`; + if (!build.buckets.has(key)) + build.buckets.set(key, { + name, + out: make(), + }); + return build.buckets.get(key).out; + }; + build.addChunked = (mesh) => { mesh.geometry.computeBoundingSphere(); - chunked.push(mesh); - group.add(mesh); + build.chunked.push(mesh); + build.group.add(mesh); return mesh; }; - +} +/** + * Runs the city build ground operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGround(build) { // Open ground that follows the camera, so the horizon never shows an edge. - const ground = new THREE.Mesh( + build.ground = new THREE.Mesh( new THREE.PlaneGeometry(14000, 14000), - groundMaterial({ color: '#c6baa0' }, 0), + groundMaterial( + { + color: '#c6baa0', + }, + 0, + ), ); - ground.rotation.x = -Math.PI / 2; - ground.position.y = -0.02; - ground.receiveShadow = true; - group.add(ground); - - for (const area of satellite ? [] : data.areas) { - const kind = data.areaKinds[area[0]]; + build.ground.rotation.x = -Math.PI / 2; + build.ground.position.y = -0.02; + build.ground.receiveShadow = true; + build.group.add(build.ground); + for (const area of build.satellite ? [] : build.data.areas) { + const kind = build.data.areaKinds[area[0]]; flatPolygon( - bucket(`area:${kind}`, area[1], area[2], () => ({ pos: [], idx: [] })), + build.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( + build.areaMaterials = {}; + for (const kind of build.data.areaKinds) + build.areaMaterials[kind] = groundMaterial( { color: AREA_COLOR[kind], roughness: kind === 'water' ? 0.25 : 0.95, @@ -316,103 +500,92 @@ export function createCity( }, 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]], +} +/** + * Runs the city build road operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildRoad(build) { + build.road = (name, x, z) => + build.bucket(`road:${name}`, x, z, () => ({ + pos: [], + uv: [], + idx: [], + })); + for (const r of build.satellite ? [] : build.data.roads) { + const type = build.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); + ribbon(build.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]); + ribbon(build.road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6); + disc(build.road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]); + disc(build.road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]); } } - const roadMaterials = {}; + build.roadMaterials = {}; +} +/** + * Runs the city build geometry operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry(build) { for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) - roadMaterials[kind] = groundMaterial({ map: tex[kind] }, LAYER[kind][1]); + build.roadMaterials[kind] = groundMaterial( + { + map: build.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, + build.wallMaterial = new THREE.MeshStandardMaterial({ + map: build.tex.walls, vertexColors: true, roughness: 0.85, }); - const roofMaterial = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.9 }); - const blocks = new Map(); - for (const building of [...data.buildings, ...extraBuildings]) { + build.roofMaterial = new THREE.MeshStandardMaterial({ + vertexColors: true, + roughness: 0.9, + }); + build.blocks = new Map(); +} +/** + * Runs the city build geometry2 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry2(build) { + for (const building of [...build.data.buildings, ...build.extraBuildings]) { const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`; - if (!blocks.has(key)) { + if (!build.blocks.has(key)) { const [bx, bz] = key.split(',').map(Number); - blocks.set(key, { + build.blocks.set(key, { x: (bx + 0.5) * BLOCK, z: (bz + 0.5) * BLOCK, buildings: [], meshes: null, }); } - blocks.get(key).buildings.push(building); + build.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(0.9 + ((shade * 7) % 1) * 0.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(0.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) { + build.pendingBlocks = [...build.blocks.values()]; + build.tanks = []; + build.units = []; +} +/** + * Runs the city build geometry3 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry3(build) { + for (const building of build.data.buildings) { const height = building[0], pts = building.slice(1); let cx = 0, @@ -423,24 +596,35 @@ export function createCity( } cx /= pts.length / 2; cz /= pts.length / 2; - if (insidePolygon(pts, cx, cz) && random() < 0.6) { - (random() < 0.5 ? tanks : units).push([ - cx + (random() - 0.5) * 2, + if (insidePolygon(pts, cx, cz) && build.random() < 0.6) { + (build.random() < 0.5 ? build.tanks : build.units).push([ + cx + (build.random() - 0.5) * 2, height, - random() * Math.PI, - cz + (random() - 0.5) * 2, + build.random() * Math.PI, + cz + (build.random() - 0.5) * 2, 0, ]); } } - for (const { name, out } of buckets.values()) { + for (const { name, out } of build.buckets.values()) { const [kind, sub] = name.split(':'); - addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub]) : toMesh(out, roadMaterials[sub])); + build.addChunked( + kind === 'area' + ? toMesh(out, build.areaMaterials[sub]) + : toMesh(out, build.roadMaterials[sub]), + ); } - const dummy = new THREE.Object3D(); + build.dummy = new THREE.Object3D(); +} +/** + * Runs the city build instanced operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildInstanced(build) { // 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) => { + build.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)}`; @@ -452,44 +636,66 @@ export function createCity( const mesh = new THREE.InstancedMesh(geometry, material, list.length); list.forEach(([item], i) => { place(item); - dummy.updateMatrix(); - mesh.setMatrixAt(i, dummy.matrix); + build.dummy.updateMatrix(); + mesh.setMatrixAt(i, build.dummy.matrix); }); mesh.castShadow = mesh.receiveShadow = true; mesh.computeBoundingSphere(); mesh.userData.items = list.map(([, i]) => i); - chunked.push(mesh); - group.add(mesh); + build.chunked.push(mesh); + build.group.add(mesh); meshes.push(mesh); } return meshes; }; - instanced( +} +/** + * Runs the city build geometry4 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry4(build) { + build.instanced( new THREE.CylinderGeometry(0.55, 0.55, 1.1, 10), - new THREE.MeshStandardMaterial({ color: '#d8d8d2', metalness: 0.4, roughness: 0.4 }), - tanks, + new THREE.MeshStandardMaterial({ + color: '#d8d8d2', + metalness: 0.4, + roughness: 0.4, + }), + build.tanks, ([x, y, , z]) => { - dummy.position.set(x, y + 0.55, z); - dummy.rotation.set(0, 0, 0); - dummy.scale.setScalar(1); + build.dummy.position.set(x, y + 0.55, z); + build.dummy.rotation.set(0, 0, 0); + build.dummy.scale.setScalar(1); }, ); - instanced( + build.instanced( new THREE.BoxGeometry(1, 0.8, 0.9), - new THREE.MeshStandardMaterial({ color: '#a9aaa4', roughness: 0.6 }), - units, + new THREE.MeshStandardMaterial({ + color: '#a9aaa4', + roughness: 0.6, + }), + build.units, ([x, y, r, z]) => { - dummy.position.set(x, y + 0.4, z); - dummy.rotation.set(0, r, 0); - dummy.scale.setScalar(1); + build.dummy.position.set(x, y + 0.4, z); + build.dummy.rotation.set(0, r, 0); + build.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]]; + build.trees = []; + build.freeSpot = (x, z) => + !blocked(build.collision, x, z) && !nearestSegment(build.segments, x, z, 3); +} +/** + * Runs the city build geometry5 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry5(build) { + for (const area of build.data.areas) { + const kind = build.data.areaKinds[area[0]]; if (!['park', 'forest', 'grass'].includes(kind)) continue; const pts = area.slice(1), step = kind === 'grass' ? 16 : 10; @@ -505,14 +711,21 @@ export function createCity( } for (let x = ax; x < bx; x += step) for (let z = az; z < bz; z += step) { - const tx = x + (random() - 0.5) * step * 0.8, - tz = z + (random() - 0.5) * step * 0.8; - if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) - trees.push([tx, 0.8 + random() * 0.6, tz, 0]); + const tx = x + (build.random() - 0.5) * step * 0.8, + tz = z + (build.random() - 0.5) * step * 0.8; + if (insidePolygon(pts, tx, tz) && build.freeSpot(tx, tz)) + build.trees.push([tx, 0.8 + build.random() * 0.6, tz, 0]); } } - for (const road of data.roads) { - const type = data.roadTypes[road[0]]; +} +/** + * Runs the city build geometry6 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry6(build) { + for (const road of build.data.roads) { + const type = build.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) { @@ -521,52 +734,73 @@ export function createCity( dx = road[i] - ax, dz = road[i + 1] - az, len = Math.hypot(dx, dz); - for (let d = random() * 12; d < len; d += 12) + for (let d = build.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) && + !blocked(build.collision, tx, tz) && !nearestSegment( - segments, + build.segments, tx, tz, WIDTH[type] / 2 + 1.5, (s) => s[4] <= DRIVABLE_MAX_TYPE, ) ) - trees.push([tx, 0.7 + random() * 0.4, tz, 0]); + build.trees.push([tx, 0.7 + build.random() * 0.4, tz, 0]); } } } - instanced( +} +/** + * Runs the city build geometry7 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry7(build) { + build.instanced( new THREE.CylinderGeometry(0.14, 0.22, 2.6, 5, 1, true), - new THREE.MeshStandardMaterial({ color: '#6a5039', roughness: 0.9 }), - trees, + new THREE.MeshStandardMaterial({ + color: '#6a5039', + roughness: 0.9, + }), + build.trees, ([x, s, z]) => { - dummy.position.set(x, 1.3 * s, z); - dummy.rotation.set(0, 0, 0); - dummy.scale.setScalar(s); + build.dummy.position.set(x, 1.3 * s, z); + build.dummy.rotation.set(0, 0, 0); + build.dummy.scale.setScalar(s); }, ); - const crowns = instanced( + build.crowns = build.instanced( new THREE.IcosahedronGeometry(1.7, 0), - new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.85, flatShading: true }), - trees, + new THREE.MeshStandardMaterial({ + color: '#ffffff', + roughness: 0.85, + flatShading: true, + }), + build.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) * 0.3), s); + build.dummy.position.set(x, 3.4 * s, z); + build.dummy.rotation.set(0, (x * 13) % 6, 0); + build.dummy.scale.set(s, s * (1 + ((x * 7) % 1) * 0.3), s); }, ); - const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map((c) => new THREE.Color(c)); - for (const mesh of crowns) + build.crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map((c) => new THREE.Color(c)); + for (const mesh of build.crowns) mesh.userData.items.forEach((tree, i) => - mesh.setColorAt(i, crownColors[tree % crownColors.length]), + mesh.setColorAt(i, build.crownColors[tree % build.crownColors.length]), ); // Metro station entrances with the Mashhad Urban Railway "M" sign. - const signTexture = canvasTexture( +} +/** + * Runs the city build sign texture operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildSignTexture(build) { + build.signTexture = canvasTexture( 128, 128, (c, w, h) => { @@ -581,35 +815,61 @@ export function createCity( }, false, ); - const canopy = new THREE.MeshStandardMaterial({ + build.canopy = new THREE.MeshStandardMaterial({ color: '#59646b', metalness: 0.5, roughness: 0.4, }); - const glass = new THREE.MeshStandardMaterial({ + build.glass = new THREE.MeshStandardMaterial({ color: '#9fc4cf', transparent: true, opacity: 0.45, roughness: 0.1, }); - for (const label of data.labels.filter((l) => l.kind === 'metro')) { +} +/** + * Runs the city build geometry8 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry8(build) { + for (const label of build.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); + const road = nearestSegment( + build.segments, + label.x, + label.z, + 40, + (s) => s[4] <= DRIVABLE_MAX_TYPE, + ); if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz); + /** + * 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; entrance.add(mesh); }; - box(4.4, 0.2, 7, canopy, 0, 3, 0); - box(0.15, 3, 7, glass, -2.1, 1.5, 0); - box(0.15, 3, 7, glass, 2.1, 1.5, 0); - box(0.25, 4.2, 0.25, canopy, 2.6, 2.1, 3.6); + box(4.4, 0.2, 7, build.canopy, 0, 3, 0); + box(0.15, 3, 7, build.glass, -2.1, 1.5, 0); + box(0.15, 3, 7, build.glass, 2.1, 1.5, 0); + box(0.25, 4.2, 0.25, build.canopy, 2.6, 2.1, 3.6); const sign = new THREE.Mesh( new THREE.CircleGeometry(0.6, 24), - new THREE.MeshBasicMaterial({ map: signTexture }), + new THREE.MeshBasicMaterial({ + map: build.signTexture, + }), ); sign.position.set(2.6, 4.4, 3.75); entrance.add(sign); @@ -617,19 +877,33 @@ export function createCity( back.rotation.y = Math.PI; back.position.z = 3.45; entrance.add(back); - group.add(entrance); + build.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, +} +/** + * Runs the city build ridge pos operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildRidgePos(build) { + build.ridgePos = []; + build.ridgeCol = []; + build.ridgeIdx = []; + build.segmentsAround = 240; + build.foot = new THREE.Color('#cfc5b0'); + build.crest = new THREE.Color('#a39a8a'); +} +/** + * Runs the city build geometry9 operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildGeometry9(build) { + for (let i = 0; i <= build.segmentsAround; i++) { + const a = (i / build.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. @@ -637,33 +911,58 @@ export function createCity( rough = Math.sin(a * 7) * 0.5 + Math.sin(a * 17 + 1) * 0.3 + Math.sin(a * 41) * 0.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); + build.ridgePos.push(x * r, -20, z * r, x * r, height, z * r); + build.ridgeCol.push( + build.foot.r, + build.foot.g, + build.foot.b, + build.crest.r, + build.crest.g, + build.crest.b, + ); if (i) { const b = (i - 1) * 2; - ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2); + build.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 }), + build.ridgeGeometry = new THREE.BufferGeometry(); + build.ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(build.ridgePos, 3)); + build.ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(build.ridgeCol, 3)); + build.ridgeGeometry.setIndex(build.ridgeIdx); +} +/** + * Runs the city build ridge operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildRidge(build) { + build.ridge = new THREE.Mesh( + build.ridgeGeometry, + new THREE.MeshBasicMaterial({ + vertexColors: true, + fog: false, + side: THREE.DoubleSide, + }), ); - group.add(ridge); - let lastCheck = null; - return { - group, + build.group.add(build.ridge); + build.lastCheck = null; +} +/** + * Runs the city build result operation for this subsystem. + * @param {*} build - build. + * @returns {*} Operation result, when applicable. + */ +function cityBuildResult(build) { + build.result = { + group: build.group, // Keeps the ground and horizon around the camera and shows only chunks within range. update(camera) { - ground.position.set(camera.x, -0.02, camera.z); - ridge.position.set(camera.x, 0, camera.z); + build.ground.position.set(camera.x, -0.02, camera.z); + build.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) { + nextDistance = build.VIEW_RANGE + BLOCK; + for (const block of build.pendingBlocks) { const d = Math.hypot(block.x - camera.x, block.z - camera.z); if (d < nextDistance) { next = block; @@ -671,18 +970,47 @@ export function createCity( } } if (next) { - pendingBlocks.splice(pendingBlocks.indexOf(next), 1); - buildBlock(next); - lastCheck = null; + build.pendingBlocks.splice(build.pendingBlocks.indexOf(next), 1); + build.buildBlock(next); + build.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) { + if ( + build.lastCheck && + Math.hypot(camera.x - build.lastCheck.x, camera.z - build.lastCheck.z) < 60 + ) + return; + build.lastCheck = { + x: camera.x, + z: camera.z, + }; + for (const mesh of build.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; + build.VIEW_RANGE; } }, }; } +const CITY_METHODS = { + buildBlock: cityBuildBlock, +}; +const CITY_STEPS = [ + cityBuildVIEW_RANGE, + cityBuildGround, + cityBuildRoad, + cityBuildGeometry, + cityBuildGeometry2, + cityBuildGeometry3, + cityBuildInstanced, + cityBuildGeometry4, + cityBuildGeometry5, + cityBuildGeometry6, + cityBuildGeometry7, + cityBuildSignTexture, + cityBuildGeometry8, + cityBuildRidgePos, + cityBuildGeometry9, + cityBuildRidge, + cityBuildResult, +]; diff --git a/src/game/world/collision.js b/src/game/world/collision.js index 2e278fa..5c36938 100644 --- a/src/game/world/collision.js +++ b/src/game/world/collision.js @@ -1,13 +1,31 @@ // 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() }; + 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, @@ -19,7 +37,14 @@ export function addPolygon(world, pts) { 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; + 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); @@ -27,7 +52,14 @@ export function addPolygon(world, pts) { 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++) @@ -37,7 +69,13 @@ function nearby(world, x, z, r) { .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) { @@ -49,9 +87,19 @@ export function insidePolygon(pts, x, z) { } 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 }; + 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], @@ -61,12 +109,25 @@ function closestOnEdges(pts, x, z) { 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 }; + 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++) { @@ -84,13 +145,33 @@ export function resolveCircle(world, x, z, r) { } if (!moved) break; } - return { x, z, hit }; + 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++) { diff --git a/src/game/world/imagery.js b/src/game/world/imagery.js index dbeb107..0d11549 100644 --- a/src/game/world/imagery.js +++ b/src/game/world/imagery.js @@ -6,10 +6,29 @@ import { toLocal } from '../../utils/geo.js'; // 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), @@ -17,7 +36,13 @@ export function tileCorners(x, y, 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, @@ -28,8 +53,18 @@ export function createImagery( 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 }, + { + 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) => { @@ -37,13 +72,27 @@ export function createImagery( 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 }; + 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; @@ -105,7 +154,11 @@ export function createImagery( }, ); } - + /** + * 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); @@ -114,7 +167,6 @@ export function createImagery( tile.mesh.material.dispose(); tile.mesh = null; } - let last = null; return { group, @@ -128,7 +180,10 @@ export function createImagery( update(x, z) { const moved = !last || Math.hypot(x - last.x, z - last.z) > 25; if (!moved && loads >= 6) return; - last = { x, z }; + last = { + x, + z, + }; for (const level of levels) { const wanted = []; for (const tile of level.tiles) { diff --git a/src/game/world/mapRender.js b/src/game/world/mapRender.js index 8cf47a1..773dc78 100644 --- a/src/game/world/mapRender.js +++ b/src/game/world/mapRender.js @@ -16,9 +16,25 @@ const AREA = { 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]); @@ -26,6 +42,14 @@ function path(c, pts) { } // 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]]]; @@ -45,11 +69,23 @@ function drawFeatures(c, { areas, roads, buildings }, data, minWidth) { 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, @@ -64,15 +100,25 @@ export function createMapAssets(data, extraLabels = []) { 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: [] }); + 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)) { @@ -87,12 +133,20 @@ export function createMapAssets(data, extraLabels = []) { 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); + 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); @@ -132,7 +186,13 @@ export function createMapAssets(data, extraLabels = []) { 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 }); + streets.set(name, { + name, + len, + x: road[mid], + z: road[mid + 1], + angle, + }); } } return { @@ -146,6 +206,17 @@ export function createMapAssets(data, extraLabels = []) { // 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'; @@ -164,7 +235,16 @@ function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) { 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, @@ -172,7 +252,14 @@ function rotatePoint(x, z, cx, cz, angle, k) { 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); @@ -203,6 +290,13 @@ function machineIcon(c, x, y, { objective, visited, open }) { } // 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, @@ -283,11 +377,25 @@ export function drawMinimap(ctx, assets, view) { } // 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'; @@ -407,6 +515,13 @@ export function drawFullMap(ctx, assets, view, state) { } // 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, @@ -420,6 +535,12 @@ export function drawIran( 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) { diff --git a/src/game/world/routing.js b/src/game/world/routing.js index 2ed0de1..b632738 100644 --- a/src/game/world/routing.js +++ b/src/game/world/routing.js @@ -2,11 +2,23 @@ // 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)) { @@ -30,7 +42,13 @@ export function buildGraph(roads, { drivable = false } = {}) { prev = next; } } - const graph = { xs, zs, adj, main: largestComponent(adj), grid: new Map() }; + 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)); @@ -39,9 +57,18 @@ export function buildGraph(roads, { drivable = false } = {}) { }); 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 = []; @@ -67,6 +94,13 @@ function largestComponent(adj) { } // 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); @@ -88,15 +122,31 @@ export function nearestNode(graph, x, z) { } 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;) { @@ -106,6 +156,11 @@ export function findPath(graph, start, goal) { i = p; } }; + /** + * Runs the pop operation for this subsystem. + + * @returns {*} Operation result, when applicable. + */ const pop = () => { const top = heap[0], last = heap.pop(); @@ -145,18 +200,42 @@ export function findPath(graph, start, goal) { } 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 }, + { + x: ax, + z: az, + }, + ...path.map((n) => ({ + x: graph.xs[n], + z: graph.zs[n], + })), + { + x: bx, + z: bz, + }, ]; - return { points, length: polylineLength(points) }; + 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++) @@ -165,6 +244,11 @@ export function polylineLength(points) { } // 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) @@ -187,7 +271,15 @@ export function buildSegmentIndex(roads) { } 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; diff --git a/src/hooks/useGameEngine.js b/src/hooks/useGameEngine.js index 11171a3..0e439b6 100644 --- a/src/hooks/useGameEngine.js +++ b/src/hooks/useGameEngine.js @@ -10,6 +10,11 @@ export function useGameEngine(root, bridge) { 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'); @@ -22,7 +27,10 @@ export function useGameEngine(root, bridge) { if (controller.signal.aborted) dispose(); else disposeRef.current = dispose; } catch (problem) { - if (!controller.signal.aborted) setError(problem.message); + if (!controller.signal.aborted) { + setError(problem.message); + controller.abort(); + } } } start(); @@ -33,5 +41,7 @@ export function useGameEngine(root, bridge) { disposeRef.current = null; }; }, [root, bridge]); - return { error }; + return { + error, + }; } diff --git a/src/main.jsx b/src/main.jsx index 85746c5..ae84265 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,18 +1,13 @@ -import React, { Suspense, lazy } from 'react'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; +import { AppRoutes } from '@/routes/AppRoutes.jsx'; import { store } from '@/store/index.js'; import '@fontsource/vazirmatn/400.css'; import '@fontsource/vazirmatn/600.css'; import '@fontsource/vazirmatn/800.css'; import '@/styles/game.css'; -const GamePage = lazy(() => - import('@/pages/game/GamePage.jsx').then((module) => ({ default: module.GamePage })), -); createRoot(document.getElementById('root')).render( - در حال آماده‌سازی بازی…

}> - -
+
, ); diff --git a/src/pages/game/GameDialogs.jsx b/src/pages/game/GameDialogs.jsx index 1a52034..4b0dd77 100644 --- a/src/pages/game/GameDialogs.jsx +++ b/src/pages/game/GameDialogs.jsx @@ -1,3 +1,4 @@ +import { ShowroomDialog } from './ShowroomDialog.jsx'; import { PaymentPanel } from './PaymentPanel.jsx'; import { MapDialog } from './MapDialog.jsx'; import { ShopDialog } from './ShopDialog.jsx'; @@ -9,6 +10,7 @@ import { EndDialog } from './EndDialog.jsx'; export function GameDialogs(props) { return ( <> + diff --git a/src/pages/game/GamePage.jsx b/src/pages/game/GamePage.jsx index 38a9c5b..2f2cd0f 100644 --- a/src/pages/game/GamePage.jsx +++ b/src/pages/game/GamePage.jsx @@ -18,6 +18,7 @@ export function GamePage() { const nodes = useAppSelector(selectNodes); const { error } = useGameEngine(root, bridge); useEffect(() => { + bridge.activate(); const dispose = setupPwa(bridge); return () => { dispose(); @@ -32,7 +33,10 @@ export function GamePage() { for (const name of Object.keys(classes)) document.body.classList.remove(name); }; }, [nodes.body]); - const props = { nodes, actions: bridge }; + const props = { + nodes, + actions: bridge, + }; return (
diff --git a/src/pages/game/GameToolbar.jsx b/src/pages/game/GameToolbar.jsx index 717682d..beda6fe 100644 --- a/src/pages/game/GameToolbar.jsx +++ b/src/pages/game/GameToolbar.jsx @@ -1,10 +1,19 @@ import { GameNode } from '@/components/shared/GameNode.jsx'; -import { ViewLists } from './ViewLists.jsx'; /** @typedef {{nodes:object,actions:object}} GameToolbarProps */ /** Presents the GameToolbar using the current interface snapshot. */ export function GameToolbar({ nodes, actions }) { return ( + actions.close('showroom')} + aria-labelledby="showroom-title" + className="w-[min(92vw,52rem)] max-h-[90dvh] overflow-y-auto rounded-xl border border-white/20 bg-surface p-6 text-hud" + > +
+

+ نمایشگاه کارخانه · {model.name} +

+ +
+ +
+ +
+

{model.name}

+

قیمت خود دستگاه: {formatRial(model.priceNumeric)}

+

{model.description}

+
    + {model.highlights.map((item) => ( +
  • {item}
  • + ))} +
+

+ این بخش معرفی محصول است؛ خرید واقعی انجام نمی‌شود و بودجهٔ هتومان تغییر نمی‌کند. +

+
+
+
+ + +
+
+ روبات راهنمای نمایشگاه +

+ {nodes['showroom-response']?.text ?? + 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'} +

+
+
+ ); +} diff --git a/src/pages/game/ShowroomDialog.test.jsx b/src/pages/game/ShowroomDialog.test.jsx new file mode 100644 index 0000000..870d64a --- /dev/null +++ b/src/pages/game/ShowroomDialog.test.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { ShowroomDialog } from './ShowroomDialog.jsx'; +afterEach(cleanup); +describe('showroom robot questions', () => { + it('asks about the selected model and displays catalog data in rial', () => { + const actions = { emit: vi.fn(), close: vi.fn() }, + nodes = { showroom: { modelId: 'rm-48', open: false } }; + render(); + fireEvent.click(screen.getByRole('button', { name: 'روبات، قیمتش چنده؟', hidden: true })); + expect(actions.emit).toHaveBeenCalledWith('showroom-question', { + modelId: 'rm-48', + topic: 'price', + }); + expect(screen.getByText(/۵٬۴۸۰٬۰۰۰٬۰۰۰ ریال/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true })); + expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60'); + }); +}); diff --git a/src/pages/game/ShowroomImage.jsx b/src/pages/game/ShowroomImage.jsx new file mode 100644 index 0000000..c842f35 --- /dev/null +++ b/src/pages/game/ShowroomImage.jsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +/** @typedef {{model:object}} ShowroomImageProps */ +/** Shows supplied catalog assets, with an explicit fallback while files are unavailable. + * @param {ShowroomImageProps} props - Selected catalog model. + * @returns {import('react').ReactElement} Product preview and variant selection. + */ +export function ShowroomImage({ model }) { + const [variant, setVariant] = useState('image'); + const [failed, setFailed] = useState({}); + const path = model[variant]; + const unavailable = failed[path]; + const src = new URL(`.${path}`, document.baseURI).href; + return ( +
+ {unavailable ? ( +
+ نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده. +
+ ) : ( + {`${model.name} + setFailed((previous) => ({ + ...previous, + [path]: true, + })) + } + /> + )} +
+ {[ + ['image', 'نمای اصلی'], + ['imageAlt', 'رنگ سفید'], + ['gif', 'نمای متحرک'], + ].map(([key, label]) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/game/StatusBar.jsx b/src/pages/game/StatusBar.jsx index 34dd912..59a6690 100644 --- a/src/pages/game/StatusBar.jsx +++ b/src/pages/game/StatusBar.jsx @@ -1,5 +1,4 @@ import { GameNode } from '@/components/shared/GameNode.jsx'; -import { ViewLists } from './ViewLists.jsx'; /** @typedef {{nodes:object,actions:object}} StatusBarProps */ /** Presents the StatusBar using the current interface snapshot. */ export function StatusBar({ nodes }) { diff --git a/src/pages/game/TouchControls.jsx b/src/pages/game/TouchControls.jsx index 8e7c5d9..0d843d5 100644 --- a/src/pages/game/TouchControls.jsx +++ b/src/pages/game/TouchControls.jsx @@ -1,5 +1,4 @@ import { GameNode } from '@/components/shared/GameNode.jsx'; -import { ViewLists } from './ViewLists.jsx'; /** @typedef {{nodes:object,actions:object}} TouchControlsProps */ /** Presents the TouchControls using the current interface snapshot. */ export function TouchControls({ nodes, actions }) { diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx new file mode 100644 index 0000000..44a59a1 --- /dev/null +++ b/src/routes/AppRoutes.jsx @@ -0,0 +1,14 @@ +import { Suspense, lazy } from 'react'; +const GamePage = lazy(() => + import('@/pages/game/GamePage.jsx').then((module) => ({ + default: module.GamePage, + })), +); +/** Loads the one game page on demand while keeping a visible startup status. */ +export function AppRoutes() { + return ( + در حال آماده‌سازی بازی…

}> + +
+ ); +} diff --git a/src/services/apiClient.test.js b/src/services/apiClient.test.js new file mode 100644 index 0000000..5eea7e8 --- /dev/null +++ b/src/services/apiClient.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +afterEach(() => { + vi.restoreAllMocks(); + history.replaceState({}, '', '/'); +}); +describe('world asset service', () => { + it('resolves a Pages repository directory without inheriting query parameters', async () => { + history.replaceState({}, '', '/vending-machine-game/?debug'); + vi.resetModules(); + const { apiClient } = await import('./apiClient.js'); + expect(apiClient.defaults.baseURL).toBe(`${location.origin}/vending-machine-game/`); + }); + it('retains the optional-data fallback and passes cancellation to both requests', async () => { + const { apiClient } = await import('./apiClient.js'); + const { loadWorldFiles } = await import('./worldService.js'); + const get = vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('offline')); + const controller = new AbortController(); + expect(await loadWorldFiles(controller.signal)).toEqual([null, null]); + expect(get).toHaveBeenCalledWith('./data/buildings.bin', { + responseType: 'arraybuffer', + signal: controller.signal, + }); + expect(get).toHaveBeenCalledWith('./imagery/index.json', { signal: controller.signal }); + }); +}); diff --git a/src/services/imageryDownloadService.js b/src/services/imageryDownloadService.js new file mode 100644 index 0000000..60876cc --- /dev/null +++ b/src/services/imageryDownloadService.js @@ -0,0 +1,14 @@ +import { apiClient } from './apiClient.js'; +/** Downloads an imagery source file for the optional map preparation script. + * @param {string} url - External imagery endpoint from the script constants. + * @returns {Promise} Source image bytes. + */ +export async function downloadImagery(url) { + const response = await apiClient.get(url, { + responseType: 'arraybuffer', + headers: { + 'User-Agent': 'robot-market-game/1.0', + }, + }); + return response.data; +} diff --git a/src/services/worldService.js b/src/services/worldService.js index 55ce126..c5bcb8e 100644 --- a/src/services/worldService.js +++ b/src/services/worldService.js @@ -1,6 +1,8 @@ import { apiClient } from './apiClient.js'; - -const WORLD_FILES = { buildings: './data/buildings.bin', imagery: './imagery/index.json' }; +const WORLD_FILES = { + buildings: './data/buildings.bin', + imagery: './imagery/index.json', +}; /** Loads optional world assets without preventing the OSM fallback on network failure. * @param {AbortSignal} signal - Cancels startup when the game unmounts. @@ -8,8 +10,13 @@ const WORLD_FILES = { buildings: './data/buildings.bin', imagery: './imagery/ind */ export async function loadWorldFiles(signal) { const requests = [ - apiClient.get(WORLD_FILES.buildings, { responseType: 'arraybuffer', signal }), - apiClient.get(WORLD_FILES.imagery, { signal }), + apiClient.get(WORLD_FILES.buildings, { + responseType: 'arraybuffer', + signal, + }), + apiClient.get(WORLD_FILES.imagery, { + signal, + }), ]; const results = await Promise.allSettled(requests); return results.map((result) => (result.status === 'fulfilled' ? result.value.data : null)); @@ -21,6 +28,9 @@ export async function loadWorldFiles(signal) { * @returns {Promise} Temporary URL, to revoke after image decoding. */ export async function loadTileUrl(path, signal) { - const response = await apiClient.get(path, { responseType: 'blob', signal }); + const response = await apiClient.get(path, { + responseType: 'blob', + signal, + }); return URL.createObjectURL(response.data); } diff --git a/src/store/index.js b/src/store/index.js index 86adfe1..54813f6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -3,6 +3,11 @@ import { interfaceReducer } from './slices/interfaceSlice.js'; import { gameReducer } from './slices/gameSlice.js'; /** Creates one Redux store for interface snapshots and shared economic progress. */ export function createAppStore() { - return configureStore({ reducer: { interface: interfaceReducer, game: gameReducer } }); + return configureStore({ + reducer: { + interface: interfaceReducer, + game: gameReducer, + }, + }); } export const store = createAppStore(); diff --git a/src/store/slices/gameSlice.js b/src/store/slices/gameSlice.js index 8a0b778..0dc693f 100644 --- a/src/store/slices/gameSlice.js +++ b/src/store/slices/gameSlice.js @@ -1,7 +1,13 @@ import { createSlice } from '@reduxjs/toolkit'; const gameSlice = createSlice({ name: 'game', - initialState: { balance: 500, xp: 0, purchases: [], missionsDone: [], minutes: 510 }, + initialState: { + balance: 500, + xp: 0, + purchases: [], + missionsDone: [], + minutes: 510, + }, reducers: { publishProgress(_state, action) { return action.payload; diff --git a/src/store/slices/interfaceSlice.js b/src/store/slices/interfaceSlice.js index 53e4135..14e0f52 100644 --- a/src/store/slices/interfaceSlice.js +++ b/src/store/slices/interfaceSlice.js @@ -1,6 +1,14 @@ import { createSlice } from '@reduxjs/toolkit'; - -const initialState = { nodes: { briefing: { hidden: false }, hud: { hidden: true } } }; +const initialState = { + nodes: { + briefing: { + hidden: false, + }, + hud: { + hidden: true, + }, + }, +}; const interfaceSlice = createSlice({ name: 'interface', initialState, diff --git a/src/tests/canvasStub.js b/src/tests/canvasStub.js new file mode 100644 index 0000000..d7e584f --- /dev/null +++ b/src/tests/canvasStub.js @@ -0,0 +1,22 @@ +import { vi } from 'vitest'; +/** Provides the Canvas2D drawing surface needed to construct procedural assets in jsdom. + * @returns {object} Restorable canvas mock; no GPU or network is used. + */ +export function stubCanvas() { + const context = new Proxy( + { + measureText: () => ({ + width: 100, + }), + createLinearGradient: () => ({ + addColorStop() {}, + }), + }, + { + get(target, key) { + return key in target ? target[key] : () => {}; + }, + }, + ); + return vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); +} diff --git a/src/utils/geo.js b/src/utils/geo.js index 803df02..2de59b7 100644 --- a/src/utils/geo.js +++ b/src/utils/geo.js @@ -1,22 +1,51 @@ // 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 }; +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); - +/** + * Runs the to local operation for this subsystem. + * @param {*} lat - lat. + * @param {*} lon - lon. + * @returns {*} Operation result, when applicable. + */ export function toLocal(lat, lon) { - return { x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT }; + return { + x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, + z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT, + }; } - +/** + * Runs the to lat lon operation for this subsystem. + * @param {*} x - x. + * @param {*} z - z. + * @returns {*} Operation result, when applicable. + */ export function toLatLon(x, z) { - return { lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON }; + 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. +/** + * Runs the bearing operation for this subsystem. + * @param {*} dx - dx. + * @param {*} dz - dz. + * @returns {*} Operation result, when applicable. + */ export function bearing(dx, dz) { return ((Math.atan2(dx, -dz) * 180) / Math.PI + 360) % 360; } - +/** + * Runs the format distance operation for this subsystem. + * @param {*} metres - metres. + * @returns {*} Operation result, when applicable. + */ export function formatDistance(metres) { return metres < 1000 ? `${Math.round(metres).toLocaleString('fa-IR')} متر` diff --git a/src/utils/showroomAnswers.js b/src/utils/showroomAnswers.js new file mode 100644 index 0000000..1403dd3 --- /dev/null +++ b/src/utils/showroomAnswers.js @@ -0,0 +1,31 @@ +import { SHOWROOM_CATALOG } from '../constants/showroomCatalog.js'; + +/** Looks up a display model by its stable identifier. + * @param {string} id - Model identifier. + * @returns {object|null} Catalog model, or null for unknown identifiers. + */ +export function showroomModelById(id) { + return SHOWROOM_CATALOG.find((model) => model.id === id) ?? null; +} + +/** Formats a machine purchase price in rial without converting it to game money. + * @param {number} price - Full machine price in rial. + * @returns {string} Persian rial price. + */ +export function formatRial(price) { + return `${price.toLocaleString('fa-IR')} ریال`; +} + +/** Builds the robot's deterministic answer from the shared showroom catalog. + * @param {{modelId:string,topic:'price'|'features'}} question - Requested model and topic. + * @returns {string|null} Robot answer; unknown questions are rejected. + */ +export function showroomAnswer({ modelId, topic }) { + const model = showroomModelById(modelId); + if (!model) return null; + if (topic === 'price') + return `قیمت ${model.name}، ${formatRial(model.priceNumeric)} است. این قیمت خود دستگاه به ریال است و از بودجهٔ بازی کم نمی‌شود.`; + if (topic === 'features') + return `${model.name}: ${model.description} امکانات: ${model.highlights.join('، ')}.`; + return null; +} diff --git a/src/utils/showroomAnswers.test.js b/src/utils/showroomAnswers.test.js new file mode 100644 index 0000000..c348e12 --- /dev/null +++ b/src/utils/showroomAnswers.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { SHOWROOM_CATALOG } from '../constants/showroomCatalog.js'; +import { showroomAnswer, formatRial } from './showroomAnswers.js'; +import { createState } from '../game/economy/gameState.js'; +describe('factory showroom catalog', () => { + it('keeps the supplied channel counts and full rial prices', () => { + expect(SHOWROOM_CATALOG.map((model) => [model.id, model.channels, model.priceNumeric])).toEqual( + [ + ['rm-35', 35, 4680000000], + ['rm-48', 48, 5480000000], + ['rm-60', 60, 5980000000], + ], + ); + }); + it('answers each model accurately without mutating the game wallet or awarding a purchase', () => { + const state = createState(), + before = structuredClone(state); + for (const model of SHOWROOM_CATALOG) { + expect(showroomAnswer({ modelId: model.id, topic: 'price' })).toContain( + formatRial(model.priceNumeric), + ); + expect(showroomAnswer({ modelId: model.id, topic: 'features' })).toContain(model.description); + } + expect(state).toEqual(before); + expect(showroomAnswer({ modelId: 'unknown', topic: 'price' })).toBeNull(); + expect(showroomAnswer({ modelId: 'rm-35', topic: 'unsupported' })).toBeNull(); + }); +}); diff --git a/tailwind.config.js b/tailwind.config.js index 1f0ff39..032b17a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,3 +1,11 @@ import plugin from 'tailwindcss/plugin.js'; -import {gameTheme} from './src/styles/gameTheme.js'; -export default {content:['./index.html','./src/**/*.{js,jsx}'],theme:{extend:{colors:{surface:'#0d1412',hud:'var(--hud)',gold:'var(--gold)',teal:'var(--teal)'}}},plugins:[plugin(({addBase})=>addBase(gameTheme))]}; +import { gameTheme } from './src/styles/gameTheme.js'; +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + colors: { surface: '#0d1412', hud: 'var(--hud)', gold: 'var(--gold)', teal: 'var(--teal)' }, + }, + }, + plugins: [plugin(({ addBase }) => addBase(gameTheme))], +}; diff --git a/vitest.config.js b/vitest.config.js index 54f94fd..a001f7e 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,4 +1,12 @@ -import {defineConfig} from 'vitest/config'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; -import {fileURLToPath} from 'node:url'; -export default defineConfig({plugins:[react()],resolve:{alias:{'@':fileURLToPath(new URL('./src',import.meta.url))}},test:{environment:'jsdom',include:['src/**/*.test.{js,jsx}'],setupFiles:['./src/tests/setup.js']}}); +import { fileURLToPath } from 'node:url'; +export default defineConfig({ + plugins: [react()], + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.{js,jsx}'], + setupFiles: ['./src/tests/setup.js'], + }, +});