feat: add factory sample machines and robot pricing conversations
This commit is contained in:
+23
-4
@@ -2,8 +2,27 @@ import js from '@eslint/js';
|
|||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import hooks from 'eslint-plugin-react-hooks';
|
import hooks from 'eslint-plugin-react-hooks';
|
||||||
export default [
|
export default [
|
||||||
{ignores:['dist/**','node_modules/**','public/**','src/data/**','docs/**']},
|
{ ignores: ['dist/**', 'node_modules/**', 'public/**', 'src/data/**', 'docs/**'] },
|
||||||
js.configs.recommended,
|
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'}},
|
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' },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+21
-14
@@ -1,17 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0d1412">
|
<meta name="theme-color" content="#0d1412" />
|
||||||
<title>عملیات مشهد | روبات مارکت</title>
|
<title>عملیات مشهد | روبات مارکت</title>
|
||||||
<meta name="description" content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاههای وندینگ را در مشهد پیدا کن و امتیاز بگیر.">
|
<meta
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
name="description"
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاههای وندینگ را در مشهد پیدا کن و امتیاز بگیر."
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
/>
|
||||||
<meta name="apple-mobile-web-app-title" content="روبات مارکت">
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link rel="manifest" href="./manifest.webmanifest">
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<link rel="icon" type="image/png" href="./icons/favicon-64.png">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
|
<meta name="apple-mobile-web-app-title" content="روبات مارکت" />
|
||||||
</head>
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
|
<link rel="icon" type="image/png" href="./icons/favicon-64.png" />
|
||||||
|
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" 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\" vite.config.js",
|
"format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" \"*.{js,html}\" \"scripts/*.mjs\"",
|
||||||
"predev": "npm run format",
|
"predev": "npm run format",
|
||||||
"prebuild": "npm run format",
|
"prebuild": "npm run format",
|
||||||
"test": "npm run test:logic && npm run test:ui",
|
"test": "npm run test:logic && npm run test:ui",
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export default {plugins:{tailwindcss:{},autoprefixer:{}}};
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||||
|
|||||||
+119
-95
@@ -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,
|
// vertex count u8, height in 0.5 m u8, first vertex (x u16, z u16) in `step` metres from that origin,
|
||||||
// then each further vertex as (dx i16, dz i16) from the one before. Buildings are sorted west to
|
// then each further vertex as (dx i16, dz i16) from the one before. Buildings are sorted west to
|
||||||
// east in 100 m strips, so neighbours sit next to each other and the small deltas gzip well.
|
// east in 100 m strips, so neighbours sit next to each other and the small deltas gzip well.
|
||||||
import {createReadStream, readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
import { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
import {createGunzip} from 'node:zlib';
|
import { createGunzip } from 'node:zlib';
|
||||||
import {createInterface} from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import {toLocal} from '../src/utils/geo.js';
|
import { toLocal } from '../src/utils/geo.js';
|
||||||
import {FACTORY} from '../src/constants/locations.js';
|
import { FACTORY } from '../src/constants/locations.js';
|
||||||
import {createCollisionWorld, blocked} from '../src/game/world/collision.js';
|
import { createCollisionWorld, blocked } from '../src/game/world/collision.js';
|
||||||
|
|
||||||
const [source] = process.argv.slice(2);
|
const [source] = process.argv.slice(2);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
|
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const STEP = .5;
|
const STEP = 0.5;
|
||||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
||||||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
const osm = createCollisionWorld(data.buildings.map(b => b.slice(1)));
|
const osm = createCollisionWorld(data.buildings.map((b) => b.slice(1)));
|
||||||
const areasOf = kind => data.areas.filter(a => data.areaKinds[a[0]] === kind).map(a => a.slice(1));
|
const areasOf = (kind) =>
|
||||||
const industrial = createCollisionWorld(areasOf('industrial')), parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
|
data.areas.filter((a) => data.areaKinds[a[0]] === kind).map((a) => a.slice(1));
|
||||||
|
const industrial = createCollisionWorld(areasOf('industrial')),
|
||||||
|
parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
|
||||||
|
|
||||||
// The factory compound in its own frame (u along the driveway, v across), with a margin.
|
// The factory compound in its own frame (u along the driveway, v across), with a margin.
|
||||||
const normal = {x: -FACTORY.axis.z, z: FACTORY.axis.x};
|
const normal = { x: -FACTORY.axis.z, z: FACTORY.axis.x };
|
||||||
const inFactory = (x, z) => {
|
const inFactory = (x, z) => {
|
||||||
const dx = x - FACTORY.center.x, dz = z - FACTORY.center.z;
|
const dx = x - FACTORY.center.x,
|
||||||
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, v = dx * normal.x + dz * normal.z;
|
dz = z - FACTORY.center.z;
|
||||||
return u > -115 && u < 85 && v > -55 && v < 55;
|
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 => {
|
const hash = (n) => {
|
||||||
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
|
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outside the Payambar-e Azam ring (west of ~59.47°E or north of ~36.40°N) the region is industrial
|
// 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;
|
const outskirts = (x, z) => x < -6000 || z < -9400;
|
||||||
|
|
||||||
// Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey.
|
// Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey.
|
||||||
export function estimateHeight(area, {inIndustrial, inPark, inOutskirts}, seed) {
|
export function estimateHeight(area, { inIndustrial, inPark, inOutskirts }, seed) {
|
||||||
const r = hash(seed), pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
|
const r = hash(seed),
|
||||||
if (inPark) return 3.2;
|
pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
|
||||||
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
|
if (inPark) return 3.2;
|
||||||
if (inOutskirts) return pick(1, 2) * 3.2;
|
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
|
||||||
if (inIndustrial && area >= 150) return 7 + r * 3;
|
if (inOutskirts) return pick(1, 2) * 3.2;
|
||||||
if (area < 40) return 3.2;
|
if (inIndustrial && area >= 150) return 7 + r * 3;
|
||||||
if (area < 120) return pick(2, 3) * 3.2;
|
if (area < 40) return 3.2;
|
||||||
if (area < 300) return pick(3, 4) * 3.2;
|
if (area < 120) return pick(2, 3) * 3.2;
|
||||||
if (area < 800) return pick(4, 6) * 3.2;
|
if (area < 300) return pick(3, 4) * 3.2;
|
||||||
return pick(5, 8) * 3.2;
|
if (area < 800) return pick(4, 6) * 3.2;
|
||||||
|
return pick(5, 8) * 3.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const polygonArea = pts => {
|
const polygonArea = (pts) => {
|
||||||
let a = 0;
|
let a = 0;
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
const j = (i + 2) % pts.length;
|
const j = (i + 2) % pts.length;
|
||||||
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
|
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
|
||||||
}
|
}
|
||||||
return Math.abs(a / 2);
|
return Math.abs(a / 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const out = [];
|
const out = [];
|
||||||
let read = 0, outside = 0, duplicate = 0, factory = 0, tiny = 0;
|
let read = 0,
|
||||||
const lines = createInterface({input: createReadStream(source).pipe(createGunzip())});
|
outside = 0,
|
||||||
|
duplicate = 0,
|
||||||
|
factory = 0,
|
||||||
|
tiny = 0;
|
||||||
|
const lines = createInterface({ input: createReadStream(source).pipe(createGunzip()) });
|
||||||
for await (const line of lines) {
|
for await (const line of lines) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
read++;
|
read++;
|
||||||
const ring = JSON.parse(line).geometry.coordinates[0];
|
const ring = JSON.parse(line).geometry.coordinates[0];
|
||||||
const pts = [];
|
const pts = [];
|
||||||
for (const [lon, lat] of ring.slice(0, -1)) {
|
for (const [lon, lat] of ring.slice(0, -1)) {
|
||||||
const p = toLocal(lat, lon);
|
const p = toLocal(lat, lon);
|
||||||
pts.push(p.x, p.z);
|
pts.push(p.x, p.z);
|
||||||
}
|
}
|
||||||
let cx = 0, cz = 0;
|
let cx = 0,
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
cz = 0;
|
||||||
cx += pts[i];
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
cz += pts[i + 1];
|
cx += pts[i];
|
||||||
}
|
cz += pts[i + 1];
|
||||||
cx /= pts.length / 2;
|
}
|
||||||
cz /= pts.length / 2;
|
cx /= pts.length / 2;
|
||||||
if (cx < minX || cx > maxX || cz < minZ || cz > maxZ) {
|
cz /= pts.length / 2;
|
||||||
outside++;
|
if (cx < minX || cx > maxX || cz < minZ || cz > maxZ) {
|
||||||
continue;
|
outside++;
|
||||||
}
|
continue;
|
||||||
if (pts.length / 2 > 255 || pts.length < 6) continue;
|
}
|
||||||
const area = polygonArea(pts);
|
if (pts.length / 2 > 255 || pts.length < 6) continue;
|
||||||
if (area < 12) {
|
const area = polygonArea(pts);
|
||||||
tiny++;
|
if (area < 12) {
|
||||||
continue;
|
tiny++;
|
||||||
}
|
continue;
|
||||||
if (blocked(osm, cx, cz)) {
|
}
|
||||||
duplicate++;
|
if (blocked(osm, cx, cz)) {
|
||||||
continue;
|
duplicate++;
|
||||||
}
|
continue;
|
||||||
if (inFactory(cx, cz)) {
|
}
|
||||||
factory++;
|
if (inFactory(cx, cz)) {
|
||||||
continue;
|
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});
|
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;
|
let bytes = 20;
|
||||||
for (const b of out) bytes += 2 + b.pts.length * 2;
|
for (const b of out) bytes += 2 + b.pts.length * 2;
|
||||||
const buffer = new ArrayBuffer(bytes), view = new DataView(buffer);
|
const buffer = new ArrayBuffer(bytes),
|
||||||
|
view = new DataView(buffer);
|
||||||
[0x52, 0x4d, 0x42, 0x32].forEach((c, i) => view.setUint8(i, c));
|
[0x52, 0x4d, 0x42, 0x32].forEach((c, i) => view.setUint8(i, c));
|
||||||
out.sort((a, b) => Math.floor(a.pts[1] / 100) - Math.floor(b.pts[1] / 100) || a.pts[0] - b.pts[0]);
|
out.sort((a, b) => Math.floor(a.pts[1] / 100) - Math.floor(b.pts[1] / 100) || a.pts[0] - b.pts[0]);
|
||||||
view.setUint32(4, out.length, true);
|
view.setUint32(4, out.length, true);
|
||||||
// Coordinates are stored from a point 200 m outside the region, so edge buildings never go negative.
|
// Coordinates are stored from a point 200 m outside the region, so edge buildings never go negative.
|
||||||
const originX = minX - 200, originZ = minZ - 200;
|
const originX = minX - 200,
|
||||||
|
originZ = minZ - 200;
|
||||||
view.setFloat32(8, originX, true);
|
view.setFloat32(8, originX, true);
|
||||||
view.setFloat32(12, originZ, true);
|
view.setFloat32(12, originZ, true);
|
||||||
view.setFloat32(16, STEP, true);
|
view.setFloat32(16, STEP, true);
|
||||||
let o = 20;
|
let o = 20;
|
||||||
for (const b of out) {
|
for (const b of out) {
|
||||||
view.setUint8(o++, b.pts.length / 2);
|
view.setUint8(o++, b.pts.length / 2);
|
||||||
view.setUint8(o++, Math.min(255, Math.round(b.height / .5)));
|
view.setUint8(o++, Math.min(255, Math.round(b.height / 0.5)));
|
||||||
let px = 0, pz = 0;
|
let px = 0,
|
||||||
for (let i = 0; i < b.pts.length; i += 2) {
|
pz = 0;
|
||||||
const qx = Math.round((b.pts[i] - originX) / STEP), qz = Math.round((b.pts[i + 1] - originZ) / STEP);
|
for (let i = 0; i < b.pts.length; i += 2) {
|
||||||
if (i === 0) {
|
const qx = Math.round((b.pts[i] - originX) / STEP),
|
||||||
view.setUint16(o, qx, true);
|
qz = Math.round((b.pts[i + 1] - originZ) / STEP);
|
||||||
view.setUint16(o + 2, qz, true);
|
if (i === 0) {
|
||||||
} else {
|
view.setUint16(o, qx, true);
|
||||||
view.setInt16(o, qx - px, true);
|
view.setUint16(o + 2, qz, true);
|
||||||
view.setInt16(o + 2, qz - pz, true);
|
} else {
|
||||||
}
|
view.setInt16(o, qx - px, true);
|
||||||
px = qx;
|
view.setInt16(o + 2, qz - pz, true);
|
||||||
pz = qz;
|
|
||||||
o += 4;
|
|
||||||
}
|
}
|
||||||
|
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));
|
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`,
|
||||||
|
);
|
||||||
|
|||||||
+248
-141
@@ -14,194 +14,301 @@
|
|||||||
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
|
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
|
||||||
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
|
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
|
||||||
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
|
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
|
||||||
import {readFileSync, writeFileSync} from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import {ORIGIN, toLocal} from '../src/utils/geo.js';
|
import { ORIGIN, toLocal } from '../src/utils/geo.js';
|
||||||
|
|
||||||
const args = {osm: []};
|
const args = { osm: [] };
|
||||||
for (let i = 2, key = null; i < process.argv.length; i++) {
|
for (let i = 2, key = null; i < process.argv.length; i++) {
|
||||||
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
|
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
|
||||||
else if (key === 'osm') args.osm.push(process.argv[i]);
|
else if (key === 'osm') args.osm.push(process.argv[i]);
|
||||||
else args[key] = process.argv[i];
|
else args[key] = process.argv[i];
|
||||||
}
|
}
|
||||||
if (!args.osm.length || !args.poi || !args.provinces) {
|
if (!args.osm.length || !args.poi || !args.provinces) {
|
||||||
console.error('usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>');
|
console.error(
|
||||||
process.exit(1);
|
'usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
// Points of interest are kept only inside the detailed city zone.
|
// Points of interest are kept only inside the detailed city zone.
|
||||||
const BBOX = {minLat: 36.300, maxLat: 36.330, minLon: 59.512, maxLon: 59.566};
|
const BBOX = { minLat: 36.3, maxLat: 36.33, minLon: 59.512, maxLon: 59.566 };
|
||||||
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
|
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
|
||||||
const REGION = {minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572};
|
const REGION = { minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572 };
|
||||||
const merged = new Map();
|
const merged = new Map();
|
||||||
for (const file of args.osm) for (const e of JSON.parse(readFileSync(file, 'utf8')).elements) merged.set(`${e.type}/${e.id}`, e);
|
for (const file of args.osm)
|
||||||
|
for (const e of JSON.parse(readFileSync(file, 'utf8')).elements)
|
||||||
|
merged.set(`${e.type}/${e.id}`, e);
|
||||||
const city = [...merged.values()];
|
const city = [...merged.values()];
|
||||||
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
|
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
|
||||||
const provincesPath = args.provinces;
|
const provincesPath = args.provinces;
|
||||||
const r1 = v => Math.round(v * 10) / 10;
|
const r1 = (v) => Math.round(v * 10) / 10;
|
||||||
const flat = geometry => geometry.flatMap(({lat, lon}) => {
|
const flat = (geometry) =>
|
||||||
const {x, z} = toLocal(lat, lon);
|
geometry.flatMap(({ lat, lon }) => {
|
||||||
|
const { x, z } = toLocal(lat, lon);
|
||||||
return [r1(x), r1(z)];
|
return [r1(x), r1(z)];
|
||||||
});
|
});
|
||||||
// Closed OSM rings repeat their first node; the game stores each vertex once.
|
// Closed OSM rings repeat their first node; the game stores each vertex once.
|
||||||
const ring = geometry => {
|
const ring = (geometry) => {
|
||||||
const pts = flat(geometry);
|
const pts = flat(geometry);
|
||||||
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
|
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
|
||||||
return pts;
|
return pts;
|
||||||
};
|
};
|
||||||
const hash = n => {
|
const hash = (n) => {
|
||||||
n = (n ^ 61) ^ (n >>> 16);
|
n = n ^ 61 ^ (n >>> 16);
|
||||||
n = Math.imul(n, 9);
|
n = Math.imul(n, 9);
|
||||||
n ^= n >>> 4;
|
n ^= n >>> 4;
|
||||||
n = Math.imul(n, 0x27d4eb2d);
|
n = Math.imul(n, 0x27d4eb2d);
|
||||||
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
|
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
|
||||||
};
|
};
|
||||||
|
|
||||||
const roadTypes = ['trunk', 'primary', 'secondary', 'tertiary', 'unclassified', 'residential', 'living_street', 'service', 'pedestrian', 'footway', 'cycleway', 'path', 'track', 'steps'];
|
const roadTypes = [
|
||||||
const names = [], nameIndex = new Map();
|
'trunk',
|
||||||
const nameId = name => {
|
'primary',
|
||||||
if (!name) return -1;
|
'secondary',
|
||||||
if (!nameIndex.has(name)) {
|
'tertiary',
|
||||||
nameIndex.set(name, names.length);
|
'unclassified',
|
||||||
names.push(name);
|
'residential',
|
||||||
}
|
'living_street',
|
||||||
return nameIndex.get(name);
|
'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 areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
|
||||||
const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park'
|
const areaKind = (t) =>
|
||||||
: t.landuse === 'grass' ? 'grass'
|
t.leisure === 'park' ||
|
||||||
: t.landuse === 'forest' || t.natural === 'wood' ? 'forest'
|
t.leisure === 'garden' ||
|
||||||
: t.leisure === 'pitch' ? 'pitch'
|
t.landuse === 'park' ||
|
||||||
: t.natural === 'water' ? 'water'
|
t.landuse === 'recreation_ground'
|
||||||
: ['school', 'university', 'college'].includes(t.amenity) ? 'campus'
|
? 'park'
|
||||||
: t.landuse === 'industrial' ? 'industrial'
|
: t.landuse === 'grass'
|
||||||
: t.landuse === 'farmland' ? 'farmland' : null;
|
? 'grass'
|
||||||
|
: t.landuse === 'forest' || t.natural === 'wood'
|
||||||
|
? 'forest'
|
||||||
|
: t.leisure === 'pitch'
|
||||||
|
? 'pitch'
|
||||||
|
: t.natural === 'water'
|
||||||
|
? 'water'
|
||||||
|
: ['school', 'university', 'college'].includes(t.amenity)
|
||||||
|
? 'campus'
|
||||||
|
: t.landuse === 'industrial'
|
||||||
|
? 'industrial'
|
||||||
|
: t.landuse === 'farmland'
|
||||||
|
? 'farmland'
|
||||||
|
: null;
|
||||||
|
|
||||||
// Joins a relation's outer member ways end to end into closed rings.
|
// Joins a relation's outer member ways end to end into closed rings.
|
||||||
function joinRings(members) {
|
function joinRings(members) {
|
||||||
const parts = members.filter(m => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1).map(m => m.geometry.slice());
|
const parts = members
|
||||||
const rings = [], same = (a, b) => a.lat === b.lat && a.lon === b.lon;
|
.filter((m) => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1)
|
||||||
while (parts.length) {
|
.map((m) => m.geometry.slice());
|
||||||
let current = parts.shift();
|
const rings = [],
|
||||||
for (let grown = true; grown && !same(current[0], current.at(-1));) {
|
same = (a, b) => a.lat === b.lat && a.lon === b.lon;
|
||||||
grown = false;
|
while (parts.length) {
|
||||||
for (let i = 0; i < parts.length; i++) {
|
let current = parts.shift();
|
||||||
const p = parts[i];
|
for (let grown = true; grown && !same(current[0], current.at(-1));) {
|
||||||
if (same(current.at(-1), p[0])) current = current.concat(p.slice(1));
|
grown = false;
|
||||||
else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse());
|
for (let i = 0; i < parts.length; i++) {
|
||||||
else continue;
|
const p = parts[i];
|
||||||
parts.splice(i, 1);
|
if (same(current.at(-1), p[0])) current = current.concat(p.slice(1));
|
||||||
grown = true;
|
else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse());
|
||||||
break;
|
else continue;
|
||||||
}
|
parts.splice(i, 1);
|
||||||
}
|
grown = true;
|
||||||
if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current);
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rings;
|
if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current);
|
||||||
|
}
|
||||||
|
return rings;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of city) {
|
for (const e of city) {
|
||||||
const t = e.tags ?? {};
|
const t = e.tags ?? {};
|
||||||
// Motorways are drawn and routed like trunk roads.
|
// Motorways are drawn and routed like trunk roads.
|
||||||
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
|
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
|
||||||
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
|
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
|
||||||
// One-way roads are stored pointing in their direction of travel; dual carriageways
|
// One-way roads are stored pointing in their direction of travel; dual carriageways
|
||||||
// (each side its own way) are one-way, as are motorways and roundabouts by default.
|
// (each side its own way) are one-way, as are motorways and roundabouts by default.
|
||||||
const oneway = ['yes', 'true', '1', '-1'].includes(t.oneway) || (t.oneway !== 'no' && (t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
|
const oneway =
|
||||||
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
|
['yes', 'true', '1', '-1'].includes(t.oneway) ||
|
||||||
// Whole ways come back from Overpass, so cut them where they leave the region.
|
(t.oneway !== 'no' &&
|
||||||
const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
|
(t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
|
||||||
let run = [];
|
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
|
||||||
for (const point of [...geometry, null]) {
|
// Whole ways come back from Overpass, so cut them where they leave the region.
|
||||||
if (point && inRegion(point)) run.push(point);
|
const inRegion = ({ lat, lon }) =>
|
||||||
else {
|
lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
|
||||||
if (run.length > 1) {
|
let run = [];
|
||||||
if (oneway) onewayRoads.push(roads.length);
|
for (const point of [...geometry, null]) {
|
||||||
roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
|
if (point && inRegion(point)) run.push(point);
|
||||||
}
|
else {
|
||||||
run = [];
|
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) {
|
run = [];
|
||||||
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))});
|
|
||||||
}
|
}
|
||||||
|
} 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) {
|
for (const e of poi) {
|
||||||
const t = e.tags ?? {}, c = e.center ?? e;
|
const t = e.tags ?? {},
|
||||||
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon) continue;
|
c = e.center ?? e;
|
||||||
if (t.railway === 'station' || t.shop === 'mall') labels.push({name: t.name, kind: t.railway ? 'metro' : 'mall', ...roundPoint(toLocal(c.lat, c.lon))});
|
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon)
|
||||||
|
continue;
|
||||||
|
if (t.railway === 'station' || t.shop === 'mall')
|
||||||
|
labels.push({
|
||||||
|
name: t.name,
|
||||||
|
kind: t.railway ? 'metro' : 'mall',
|
||||||
|
...roundPoint(toLocal(c.lat, c.lon)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundPoint({x, z}) {
|
function roundPoint({ x, z }) {
|
||||||
return {x: r1(x), z: r1(z)};
|
return { x: r1(x), z: r1(z) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Areas draw largest first, so small pitches and ponds stay visible on top of parks.
|
// Areas draw largest first, so small pitches and ponds stay visible on top of parks.
|
||||||
const polygonArea = pts => {
|
const polygonArea = (pts) => {
|
||||||
let a = 0;
|
let a = 0;
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
const j = (i + 2) % pts.length;
|
const j = (i + 2) % pts.length;
|
||||||
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
|
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
|
||||||
}
|
}
|
||||||
return Math.abs(a / 2);
|
return Math.abs(a / 2);
|
||||||
};
|
};
|
||||||
areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1)));
|
areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1)));
|
||||||
// The world spans every road we kept, plus a margin.
|
// The world spans every road we kept, plus a margin.
|
||||||
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
let minX = Infinity,
|
||||||
for (const road of roads) for (let i = 2; i < road.length; i += 2) {
|
minZ = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxZ = -Infinity;
|
||||||
|
for (const road of roads)
|
||||||
|
for (let i = 2; i < road.length; i += 2) {
|
||||||
minX = Math.min(minX, road[i]);
|
minX = Math.min(minX, road[i]);
|
||||||
maxX = Math.max(maxX, road[i]);
|
maxX = Math.max(maxX, road[i]);
|
||||||
minZ = Math.min(minZ, road[i + 1]);
|
minZ = Math.min(minZ, road[i + 1]);
|
||||||
maxZ = Math.max(maxZ, road[i + 1]);
|
maxZ = Math.max(maxZ, road[i + 1]);
|
||||||
}
|
}
|
||||||
const min = {x: Math.floor(minX - 200), z: Math.floor(minZ - 200)}, max = {x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200)};
|
const min = { x: Math.floor(minX - 200), z: Math.floor(minZ - 200) },
|
||||||
|
max = { x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200) };
|
||||||
const city_ = {
|
const city_ = {
|
||||||
origin: ORIGIN,
|
origin: ORIGIN,
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z},
|
bounds: { minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z },
|
||||||
roadTypes, areaKinds, names, roads, buildings, areas,
|
roadTypes,
|
||||||
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
|
areaKinds,
|
||||||
oneway: onewayRoads,
|
names,
|
||||||
labels: [...new Map(labels.map(l => [l.name, l])).values()]
|
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_));
|
writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_));
|
||||||
|
|
||||||
// Douglas–Peucker keeps the province shapes recognisable at a fraction of the size.
|
// Douglas–Peucker keeps the province shapes recognisable at a fraction of the size.
|
||||||
function simplify(points, tolerance) {
|
function simplify(points, tolerance) {
|
||||||
if (points.length < 3) return points;
|
if (points.length < 3) return points;
|
||||||
const [ax, ay] = points[0], [bx, by] = points.at(-1);
|
const [ax, ay] = points[0],
|
||||||
let index = 0, distance = 0;
|
[bx, by] = points.at(-1);
|
||||||
for (let i = 1; i < points.length - 1; i++) {
|
let index = 0,
|
||||||
const [px, py] = points[i], dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy);
|
distance = 0;
|
||||||
// A closed ring starts and ends on the same point, so measure from that point instead.
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
const d = len ? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len : Math.hypot(px - ax, py - ay);
|
const [px, py] = points[i],
|
||||||
if (d > distance) {
|
dx = bx - ax,
|
||||||
index = i;
|
dy = by - ay,
|
||||||
distance = d;
|
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
|
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8'))
|
||||||
.filter(f => f.properties.adm0_a3 === 'IRN')
|
.features.filter((f) => f.properties.adm0_a3 === 'IRN')
|
||||||
.map(f => {
|
.map((f) => {
|
||||||
const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
const polygons =
|
||||||
return {
|
f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
||||||
id: f.properties.name,
|
return {
|
||||||
name: f.properties.name_fa.replace(/^استان\s+/, ''),
|
id: f.properties.name,
|
||||||
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)
|
name: f.properties.name_fa.replace(/^استان\s+/, ''),
|
||||||
};
|
rings: polygons
|
||||||
});
|
.map((p) =>
|
||||||
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({provinces}));
|
simplify(p[0], 0.02).flatMap(([lon, lat]) => [
|
||||||
console.log(`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`);
|
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}`,
|
||||||
|
);
|
||||||
|
|||||||
+65
-46
@@ -1,3 +1,4 @@
|
|||||||
|
import { downloadImagery } from '../src/services/imageryDownloadService.js';
|
||||||
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
|
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
|
||||||
// writes public/imagery/index.json listing them.
|
// writes public/imagery/index.json listing them.
|
||||||
//
|
//
|
||||||
@@ -9,69 +10,87 @@
|
|||||||
//
|
//
|
||||||
// Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and
|
// Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and
|
||||||
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
|
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
|
||||||
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import {toLatLon} from '../src/utils/geo.js';
|
import { toLatLon } from '../src/utils/geo.js';
|
||||||
import {buildGraph, routeBetween} from '../src/game/world/routing.js';
|
import { buildGraph, routeBetween } from '../src/game/world/routing.js';
|
||||||
import {FACTORY, machineById} from '../src/constants/locations.js';
|
import { FACTORY, machineById } from '../src/constants/locations.js';
|
||||||
|
|
||||||
const URL_TEMPLATE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
const URL_TEMPLATE =
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
||||||
const OUT = new URL('../public/imagery/', import.meta.url);
|
const OUT = new URL('../public/imagery/', import.meta.url);
|
||||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
|
||||||
|
|
||||||
export const tileX = (lon, z) => Math.floor((lon + 180) / 360 * 2 ** z);
|
export const tileX = (lon, z) => Math.floor(((lon + 180) / 360) * 2 ** z);
|
||||||
export const tileY = (lat, z) => Math.floor((1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * 2 ** z);
|
export const tileY = (lat, z) =>
|
||||||
|
Math.floor(((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z);
|
||||||
|
|
||||||
const tiles = {15: new Set(), 17: new Set()};
|
const tiles = { 15: new Set(), 17: new Set() };
|
||||||
const addBox = (z, lat0, lon0, lat1, lon1) => {
|
const addBox = (z, lat0, lon0, lat1, lon1) => {
|
||||||
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
|
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
|
||||||
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
|
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
|
||||||
};
|
};
|
||||||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
const nw = toLatLon(minX, minZ), se = toLatLon(maxX, maxZ);
|
const nw = toLatLon(minX, minZ),
|
||||||
|
se = toLatLon(maxX, maxZ);
|
||||||
addBox(15, se.lat, nw.lon, nw.lat, se.lon);
|
addBox(15, se.lat, nw.lon, nw.lat, se.lon);
|
||||||
addBox(17, 36.300, 59.512, 36.330, 59.566);
|
addBox(17, 36.3, 59.512, 36.33, 59.566);
|
||||||
addBox(17, 36.443, 59.414, 36.458, 59.434);
|
addBox(17, 36.443, 59.414, 36.458, 59.434);
|
||||||
// The drive along the real roads, widened by 300 m.
|
// The drive along the real roads, widened by 300 m.
|
||||||
const drive = buildGraph(data.roads, {drivable: true}), park = machineById('mellat-park').anchor;
|
const drive = buildGraph(data.roads, { drivable: true }),
|
||||||
|
park = machineById('mellat-park').anchor;
|
||||||
const route = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, park.x, park.z);
|
const route = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, park.x, park.z);
|
||||||
for (let i = 1; i < route.points.length; i++) {
|
for (let i = 1; i < route.points.length; i++) {
|
||||||
const a = route.points[i - 1], b = route.points[i], steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
|
const a = route.points[i - 1],
|
||||||
for (let s = 0; s <= steps; s++) {
|
b = route.points[i],
|
||||||
const x = a.x + (b.x - a.x) * s / steps, z = a.z + (b.z - a.z) * s / steps;
|
steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
|
||||||
const p0 = toLatLon(x - 300, z + 300), p1 = toLatLon(x + 300, z - 300);
|
for (let s = 0; s <= steps; s++) {
|
||||||
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
|
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}`);
|
console.log(`tiles: zoom 15 ${tiles[15].size}, zoom 17 ${tiles[17].size}`);
|
||||||
let done = 0, fetched = 0, bytes = 0;
|
let done = 0,
|
||||||
|
fetched = 0,
|
||||||
|
bytes = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const {z, key} = queue.pop(), [x, y] = key.split('/');
|
const { z, key } = queue.pop(),
|
||||||
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
|
[x, y] = key.split('/');
|
||||||
if (!existsSync(file)) {
|
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
|
||||||
for (let attempt = 0; attempt < 4; attempt++) {
|
if (!existsSync(file)) {
|
||||||
try {
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
const res = await fetch(URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x), {headers: {'User-Agent': 'robot-market-game/1.0'}});
|
try {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
const body = new Uint8Array(
|
||||||
const body = new Uint8Array(await res.arrayBuffer());
|
await downloadImagery(
|
||||||
mkdirSync(new URL(`${z}/${x}/`, OUT), {recursive: true});
|
URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x),
|
||||||
writeFileSync(file, body);
|
),
|
||||||
fetched++;
|
);
|
||||||
bytes += body.length;
|
mkdirSync(new URL(`${z}/${x}/`, OUT), { recursive: true });
|
||||||
break;
|
writeFileSync(file, body);
|
||||||
} catch (error) {
|
fetched++;
|
||||||
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
|
bytes += body.length;
|
||||||
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
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));
|
await Promise.all(Array.from({ length: 6 }, worker));
|
||||||
const index = {attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community'};
|
const index = { attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community' };
|
||||||
for (const [z, set] of Object.entries(tiles)) index[z] = [...set].filter(key => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
|
for (const [z, set] of Object.entries(tiles))
|
||||||
|
index[z] = [...set].filter((key) => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
|
||||||
writeFileSync(new URL('index.json', OUT), JSON.stringify(index));
|
writeFileSync(new URL('index.json', OUT), JSON.stringify(index));
|
||||||
console.log(`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`);
|
console.log(
|
||||||
|
`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`,
|
||||||
|
);
|
||||||
|
|||||||
+91
-25
@@ -1,26 +1,50 @@
|
|||||||
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
|
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
|
||||||
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
||||||
export const CATEGORIES = {
|
export const CATEGORIES = {
|
||||||
factory: { label: 'کارخانه', icon: '🏭' },
|
factory: {
|
||||||
park: { label: 'پارک', icon: '🌳' },
|
label: 'کارخانه',
|
||||||
university: { label: 'دانشگاه', icon: '🎓' },
|
icon: '🏭',
|
||||||
school: { label: 'مدرسه', icon: '🏫' },
|
},
|
||||||
metro: { label: 'ایستگاه مترو', icon: 'Ⓜ' },
|
park: {
|
||||||
mall: { label: 'مرکز خرید', icon: '🛍' },
|
label: 'پارک',
|
||||||
test: { label: 'نقطهٔ آزمایشی GPS', icon: '🧪' },
|
icon: '🌳',
|
||||||
|
},
|
||||||
|
university: {
|
||||||
|
label: 'دانشگاه',
|
||||||
|
icon: '🎓',
|
||||||
|
},
|
||||||
|
school: {
|
||||||
|
label: 'مدرسه',
|
||||||
|
icon: '🏫',
|
||||||
|
},
|
||||||
|
metro: {
|
||||||
|
label: 'ایستگاه مترو',
|
||||||
|
icon: 'Ⓜ',
|
||||||
|
},
|
||||||
|
mall: {
|
||||||
|
label: 'مرکز خرید',
|
||||||
|
icon: '🛍',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
label: 'نقطهٔ آزمایشی GPS',
|
||||||
|
icon: '🧪',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_DAY = [7 * 60, 23 * 60];
|
const ALL_DAY = [7 * 60, 23 * 60];
|
||||||
|
|
||||||
export const FACTORY = {
|
export const FACTORY = {
|
||||||
name: 'شرکت روبات مارکت',
|
name: 'شرکت روبات مارکت',
|
||||||
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
||||||
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
||||||
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
||||||
center: { x: -10308.4, z: -14964.2 },
|
center: {
|
||||||
axis: { x: Math.SQRT1_2, z: Math.SQRT1_2 },
|
x: -10308.4,
|
||||||
|
z: -14964.2,
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
x: Math.SQRT1_2,
|
||||||
|
z: Math.SQRT1_2,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const machines = [
|
export const machines = [
|
||||||
{
|
{
|
||||||
id: 'factory',
|
id: 'factory',
|
||||||
@@ -38,7 +62,10 @@ export const machines = [
|
|||||||
name: 'بوستان ملت',
|
name: 'بوستان ملت',
|
||||||
category: 'park',
|
category: 'park',
|
||||||
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
||||||
anchor: { x: -487.6, z: -326.4 },
|
anchor: {
|
||||||
|
x: -487.6,
|
||||||
|
z: -326.4,
|
||||||
|
},
|
||||||
hours: ALL_DAY,
|
hours: ALL_DAY,
|
||||||
priceFactor: 1,
|
priceFactor: 1,
|
||||||
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
||||||
@@ -49,7 +76,10 @@ export const machines = [
|
|||||||
name: 'مجموعه آموزشی راحیل',
|
name: 'مجموعه آموزشی راحیل',
|
||||||
category: 'school',
|
category: 'school',
|
||||||
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
||||||
anchor: { x: -554.6, z: -569.7 },
|
anchor: {
|
||||||
|
x: -554.6,
|
||||||
|
z: -569.7,
|
||||||
|
},
|
||||||
hours: [7 * 60 + 30, 13 * 60 + 30],
|
hours: [7 * 60 + 30, 13 * 60 + 30],
|
||||||
priceFactor: 0.9,
|
priceFactor: 0.9,
|
||||||
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
|
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
|
||||||
@@ -60,7 +90,10 @@ export const machines = [
|
|||||||
name: 'دانشگاه فردوسی مشهد',
|
name: 'دانشگاه فردوسی مشهد',
|
||||||
category: 'university',
|
category: 'university',
|
||||||
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
||||||
anchor: { x: -936, z: 645 },
|
anchor: {
|
||||||
|
x: -936,
|
||||||
|
z: 645,
|
||||||
|
},
|
||||||
hours: ALL_DAY,
|
hours: ALL_DAY,
|
||||||
priceFactor: 0.8,
|
priceFactor: 0.8,
|
||||||
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
|
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
|
||||||
@@ -71,7 +104,10 @@ export const machines = [
|
|||||||
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
|
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
|
||||||
category: 'university',
|
category: 'university',
|
||||||
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
||||||
anchor: { x: -417.1, z: 966.3 },
|
anchor: {
|
||||||
|
x: -417.1,
|
||||||
|
z: 966.3,
|
||||||
|
},
|
||||||
hours: ALL_DAY,
|
hours: ALL_DAY,
|
||||||
priceFactor: 0.8,
|
priceFactor: 0.8,
|
||||||
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
|
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
|
||||||
@@ -82,7 +118,10 @@ export const machines = [
|
|||||||
name: 'دانشگاه علوم پزشکی مشهد',
|
name: 'دانشگاه علوم پزشکی مشهد',
|
||||||
category: 'university',
|
category: 'university',
|
||||||
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
||||||
anchor: { x: -272.1, z: 150.6 },
|
anchor: {
|
||||||
|
x: -272.1,
|
||||||
|
z: 150.6,
|
||||||
|
},
|
||||||
hours: ALL_DAY,
|
hours: ALL_DAY,
|
||||||
priceFactor: 0.8,
|
priceFactor: 0.8,
|
||||||
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
|
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
|
||||||
@@ -93,7 +132,10 @@ export const machines = [
|
|||||||
name: 'ایستگاه مترو آزادی',
|
name: 'ایستگاه مترو آزادی',
|
||||||
category: 'metro',
|
category: 'metro',
|
||||||
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
||||||
anchor: { x: 494.1, z: 280.3 },
|
anchor: {
|
||||||
|
x: 494.1,
|
||||||
|
z: 280.3,
|
||||||
|
},
|
||||||
hours: [6 * 60, 22 * 60],
|
hours: [6 * 60, 22 * 60],
|
||||||
priceFactor: 1,
|
priceFactor: 1,
|
||||||
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
|
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
|
||||||
@@ -104,7 +146,10 @@ export const machines = [
|
|||||||
name: 'مشهد مال',
|
name: 'مشهد مال',
|
||||||
category: 'mall',
|
category: 'mall',
|
||||||
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
||||||
anchor: { x: 1940.6, z: -367.8 },
|
anchor: {
|
||||||
|
x: 1940.6,
|
||||||
|
z: -367.8,
|
||||||
|
},
|
||||||
hours: [10 * 60, 23 * 60],
|
hours: [10 * 60, 23 * 60],
|
||||||
priceFactor: 1.1,
|
priceFactor: 1.1,
|
||||||
products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
|
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.
|
// 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.
|
// 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) => ({
|
const testPoint = (n, lat, lon) => ({
|
||||||
id: `test-${n}`,
|
id: `test-${n}`,
|
||||||
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
|
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
|
||||||
@@ -121,7 +173,10 @@ const testPoint = (n, lat, lon) => ({
|
|||||||
test: true,
|
test: true,
|
||||||
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
||||||
anchor: null,
|
anchor: null,
|
||||||
spot: { lat, lon },
|
spot: {
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
},
|
||||||
hours: [0, 24 * 60],
|
hours: [0, 24 * 60],
|
||||||
priceFactor: 0.5,
|
priceFactor: 0.5,
|
||||||
products: ['water', 'biscuit'],
|
products: ['water', 'biscuit'],
|
||||||
@@ -135,11 +190,22 @@ machines.push(
|
|||||||
|
|
||||||
// Shown on the map as the next area to unlock.
|
// Shown on the map as the next area to unlock.
|
||||||
export const lockedPlaces = [
|
export const lockedPlaces = [
|
||||||
{ name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · بهزودی' },
|
{
|
||||||
{ name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · بهزودی' },
|
name: 'پارک کوهسنگی',
|
||||||
|
note: 'خارج از محدودهٔ فعلی · بهزودی',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'حرم و مرکز شهر',
|
||||||
|
note: 'خارج از محدودهٔ فعلی · بهزودی',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
export const START = {
|
||||||
export const START = { machineId: 'factory' };
|
machineId: 'factory',
|
||||||
|
};
|
||||||
export const CITY_START = 'mellat-park';
|
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;
|
export const machineById = (id) => machines.find((m) => m.id === id) ?? null;
|
||||||
|
|||||||
@@ -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;
|
||||||
+18
-2301
File diff suppressed because it is too large
Load Diff
+49
-7
@@ -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°.
|
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
|
||||||
const CAMERA_LONG_FOV = 65;
|
const CAMERA_LONG_FOV = 65;
|
||||||
|
|
||||||
const zee = new THREE.Vector3(0, 0, 1),
|
const zee = new THREE.Vector3(0, 0, 1),
|
||||||
euler = new THREE.Euler(),
|
euler = new THREE.Euler(),
|
||||||
q0 = new THREE.Quaternion();
|
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.
|
// 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.
|
// `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) {
|
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
|
||||||
const r = Math.PI / 180;
|
const r = Math.PI / 180;
|
||||||
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
|
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.
|
// 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) {
|
export function coverFov(videoAspect, screenAspect) {
|
||||||
const long = Math.tan((CAMERA_LONG_FOV * Math.PI) / 360);
|
const long = Math.tan((CAMERA_LONG_FOV * Math.PI) / 360);
|
||||||
// The video's own vertical and horizontal half-angle tangents.
|
// 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;
|
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
|
||||||
return (Math.atan(visibleV) * 360) / Math.PI;
|
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) {
|
export function createArView(video) {
|
||||||
let stream = null,
|
let stream = null,
|
||||||
orientation = null,
|
orientation = null,
|
||||||
@@ -42,11 +60,25 @@ export function createArView(video) {
|
|||||||
const target = new THREE.Quaternion();
|
const target = new THREE.Quaternion();
|
||||||
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
|
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
|
||||||
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
|
// 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) => {
|
const onAbsolute = (event) => {
|
||||||
if (event.alpha == null) return;
|
if (event.alpha == null) return;
|
||||||
absoluteSeen = true;
|
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) => {
|
const onOrientation = (event) => {
|
||||||
if (event.alpha == null || absoluteSeen) return;
|
if (event.alpha == null || absoluteSeen) return;
|
||||||
let alpha = event.alpha;
|
let alpha = event.alpha;
|
||||||
@@ -55,7 +87,11 @@ export function createArView(video) {
|
|||||||
compassOffset = 360 - event.webkitCompassHeading - event.alpha;
|
compassOffset = 360 - event.webkitCompassHeading - event.alpha;
|
||||||
}
|
}
|
||||||
if (!event.absolute && compassOffset != null) alpha += compassOffset;
|
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;
|
let sensing = false;
|
||||||
return {
|
return {
|
||||||
@@ -88,9 +124,15 @@ export function createArView(video) {
|
|||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
facingMode: { ideal: 'environment' },
|
facingMode: {
|
||||||
width: { ideal: 1280 },
|
ideal: 'environment',
|
||||||
height: { ideal: 720 },
|
},
|
||||||
|
width: {
|
||||||
|
ideal: 1280,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
ideal: 720,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
audio: false,
|
audio: false,
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-9
@@ -14,30 +14,50 @@ export function setupPwa(ui) {
|
|||||||
const ios =
|
const ios =
|
||||||
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
if (!standalone && ios) ui.patch('install', { hidden: false });
|
if (!standalone && ios)
|
||||||
|
ui.patch('install', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
'beforeinstallprompt',
|
'beforeinstallprompt',
|
||||||
(event) => {
|
(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
deferred = event;
|
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 () => {
|
ui.on('install', async () => {
|
||||||
if (deferred) {
|
if (deferred) {
|
||||||
deferred.prompt();
|
deferred.prompt();
|
||||||
await deferred.userChoice;
|
await deferred.userChoice;
|
||||||
deferred = null;
|
deferred = null;
|
||||||
ui.patch('install', { hidden: true });
|
ui.patch('install', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
} else if (ios) ui.open('ios-install');
|
} else if (ios) ui.open('ios-install');
|
||||||
});
|
});
|
||||||
ui.on('ios-close', () => ui.close('ios-install'));
|
ui.on('ios-close', () => ui.close('ios-install'));
|
||||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||||
/** Offers a version change only after a worker has installed successfully. */
|
/** Offers a version change only after a worker has installed successfully. */
|
||||||
const offer = (worker) => {
|
const offer = (worker) => {
|
||||||
ui.patch('update-toast', { hidden: false });
|
ui.patch('update-toast', {
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
ui.on('update-reload', () => worker.postMessage('skip-waiting'));
|
ui.on('update-reload', () => worker.postMessage('skip-waiting'));
|
||||||
};
|
};
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
@@ -55,10 +75,14 @@ export function setupPwa(ui) {
|
|||||||
if (worker.state === 'installed' && navigator.serviceWorker.controller)
|
if (worker.state === 'installed' && navigator.serviceWorker.controller)
|
||||||
offer(worker);
|
offer(worker);
|
||||||
},
|
},
|
||||||
{ signal },
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ signal },
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -69,7 +93,9 @@ export function setupPwa(ui) {
|
|||||||
reloading = true;
|
reloading = true;
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
{ signal },
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -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.
|
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.
|
// 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) {
|
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 weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
|
||||||
const dt = Math.max(0.001, (fix.time - previous.time) / 1000);
|
const dt = Math.max(0.001, (fix.time - previous.time) / 1000);
|
||||||
const x = previous.x + (fix.x - previous.x) * weight,
|
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);
|
// 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,
|
// - 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.
|
// 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 } = {}) {
|
export function createStabilizer({ maxSpeed = 3 } = {}) {
|
||||||
let estimate = null,
|
let estimate = null,
|
||||||
shown = null,
|
shown = null,
|
||||||
@@ -48,14 +62,20 @@ export function createStabilizer({ maxSpeed = 3 } = {}) {
|
|||||||
rejected < 3
|
rejected < 3
|
||||||
) {
|
) {
|
||||||
rejected++;
|
rejected++;
|
||||||
return shown && { ...shown };
|
return (
|
||||||
|
shown && {
|
||||||
|
...shown,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rejected = 0;
|
rejected = 0;
|
||||||
estimate = smoothFix(estimate, fix);
|
estimate = smoothFix(estimate, fix);
|
||||||
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
|
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)
|
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > 0.7)
|
||||||
shown = { ...estimate };
|
shown = {
|
||||||
|
...estimate,
|
||||||
|
};
|
||||||
else
|
else
|
||||||
shown = {
|
shown = {
|
||||||
...shown,
|
...shown,
|
||||||
@@ -63,21 +83,39 @@ export function createStabilizer({ maxSpeed = 3 } = {}) {
|
|||||||
time: estimate.time,
|
time: estimate.time,
|
||||||
speed: fix.speed ?? 0,
|
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,
|
// 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.
|
// 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));
|
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) =>
|
export const insideWorld = (bounds, x, z) =>
|
||||||
x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ;
|
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}),
|
// 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
|
// 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).
|
// 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({
|
export function startTracking({
|
||||||
onFix,
|
onFix,
|
||||||
onHeading,
|
onHeading,
|
||||||
@@ -94,6 +132,11 @@ export function startTracking({
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
let fixes = 0;
|
let fixes = 0;
|
||||||
|
/**
|
||||||
|
* Runs the handle operation for this subsystem.
|
||||||
|
* @param {*} position - position.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
const handle = (position) => {
|
const handle = (position) => {
|
||||||
const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
|
const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
|
||||||
local = toLocal(lat, lon);
|
local = toLocal(lat, lon);
|
||||||
@@ -104,8 +147,18 @@ export function startTracking({
|
|||||||
time: position.timestamp,
|
time: position.timestamp,
|
||||||
});
|
});
|
||||||
fixes++;
|
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) => {
|
const fail = (error) => {
|
||||||
if (error.code === error.PERMISSION_DENIED)
|
if (error.code === error.PERMISSION_DENIED)
|
||||||
onError(
|
onError(
|
||||||
@@ -131,6 +184,11 @@ export function startTracking({
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
|
// 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 orientation = (event) => {
|
||||||
const compass =
|
const compass =
|
||||||
event.webkitCompassHeading ??
|
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.
|
// 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() {
|
export async function requestCompass() {
|
||||||
try {
|
try {
|
||||||
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission)
|
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission)
|
||||||
|
|||||||
+119
-13
@@ -1,34 +1,121 @@
|
|||||||
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
||||||
export const INITIAL_BALANCE = 500;
|
export const INITIAL_BALANCE = 500;
|
||||||
export const START_MINUTES = 8 * 60 + 30;
|
export const START_MINUTES = 8 * 60 + 30;
|
||||||
|
/**
|
||||||
|
* Runs the fa operation for this subsystem.
|
||||||
|
* @param {*} n - n.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
export const fa = (n) => n.toLocaleString('fa-IR');
|
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)} هتومان`;
|
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;
|
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) => {
|
export const formatClock = (minutes) => {
|
||||||
const m = Math.floor(minutes) % (24 * 60);
|
const m = Math.floor(minutes) % (24 * 60);
|
||||||
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
|
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const catalog = {
|
export const catalog = {
|
||||||
juice: { id: 'juice', name: 'آبمیوه', icon: '🧃', cost: 35, type: 'drink' },
|
juice: {
|
||||||
water: { id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink' },
|
id: 'juice',
|
||||||
milk: { id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink' },
|
name: 'آبمیوه',
|
||||||
tea: { id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink' },
|
icon: '🧃',
|
||||||
lemonade: { id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink' },
|
cost: 35,
|
||||||
biscuit: { id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack' },
|
type: 'drink',
|
||||||
chocolate: { id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack' },
|
},
|
||||||
nuts: { id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack' },
|
water: {
|
||||||
cake: { id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack' },
|
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) =>
|
export const priceAt = (machine, productId) =>
|
||||||
Math.round(catalog[productId].cost * machine.priceFactor);
|
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) {
|
export function isOpen(machine, minutes) {
|
||||||
const m = minutes % (24 * 60);
|
const m = minutes % (24 * 60);
|
||||||
return m >= machine.hours[0] && m < machine.hours[1];
|
return m >= machine.hours[0] && m < machine.hours[1];
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Creates state resources for the game.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
export function createState() {
|
export function createState() {
|
||||||
return {
|
return {
|
||||||
balance: INITIAL_BALANCE,
|
balance: INITIAL_BALANCE,
|
||||||
@@ -41,6 +128,13 @@ export function createState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns why a purchase cannot happen, or null when it can.
|
// 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) {
|
export function purchaseBlocker(state, machine, productId) {
|
||||||
if (!machine || !machine.products.includes(productId)) return 'unknown';
|
if (!machine || !machine.products.includes(productId)) return 'unknown';
|
||||||
if (state.busy) return 'busy';
|
if (state.busy) return 'busy';
|
||||||
@@ -48,7 +142,13 @@ export function purchaseBlocker(state, machine, productId) {
|
|||||||
if (state.balance < priceAt(machine, productId)) return 'funds';
|
if (state.balance < priceAt(machine, productId)) return 'funds';
|
||||||
return null;
|
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) {
|
export function purchase(state, machine, productId) {
|
||||||
if (purchaseBlocker(state, machine, productId)) return null;
|
if (purchaseBlocker(state, machine, productId)) return null;
|
||||||
const product = catalog[productId],
|
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.
|
// 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) {
|
export function canAffordAnything(state, machines) {
|
||||||
return machines.some(
|
return machines.some(
|
||||||
(m) =>
|
(m) =>
|
||||||
|
|||||||
@@ -1,23 +1,54 @@
|
|||||||
import { purchase, purchaseBlocker } from './gameState.js';
|
import { purchase, purchaseBlocker } from './gameState.js';
|
||||||
|
|
||||||
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
// 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) {
|
export function beginPayment(state, payment, machine, productId) {
|
||||||
if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
|
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;
|
state.busy = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Runs the ready payment operation for this subsystem.
|
||||||
|
* @param {*} payment - payment.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
export function readyPayment(payment) {
|
export function readyPayment(payment) {
|
||||||
if (payment.phase !== 'approaching') return false;
|
if (payment.phase !== 'approaching') return false;
|
||||||
payment.phase = 'ready';
|
payment.phase = 'ready';
|
||||||
return true;
|
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) {
|
export function swipePayment(state, payment) {
|
||||||
if (payment.phase !== 'ready') return false;
|
if (payment.phase !== 'ready') return false;
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
@@ -31,7 +62,12 @@ export function swipePayment(state, payment) {
|
|||||||
payment.phase = 'approved';
|
payment.phase = 'approved';
|
||||||
return true;
|
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) {
|
export function cancelPayment(state, payment) {
|
||||||
if (!['approaching', 'ready'].includes(payment.phase)) return false;
|
if (!['approaching', 'ready'].includes(payment.phase)) return false;
|
||||||
Object.assign(payment, createPayment());
|
Object.assign(payment, createPayment());
|
||||||
|
|||||||
+75
-17
@@ -2,27 +2,66 @@ import { CATEGORIES } from '../../constants/locations.js';
|
|||||||
|
|
||||||
// The bonus for visiting every kind of place counts city locations, not the factory.
|
// The bonus for visiting every kind of place counts city locations, not the factory.
|
||||||
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
||||||
|
export const POINTS = {
|
||||||
export const POINTS = { purchase: 100, discovery: 250, combo: 150, allCategories: 500 };
|
purchase: 100,
|
||||||
|
discovery: 250,
|
||||||
|
combo: 150,
|
||||||
|
allCategories: 500,
|
||||||
|
};
|
||||||
export const RANKS = [
|
export const RANKS = [
|
||||||
{ name: 'تازهوارد', xp: 0 },
|
{
|
||||||
{ name: 'کاوشگر', xp: 600 },
|
name: 'تازهوارد',
|
||||||
{ name: 'راهبلد', xp: 1400 },
|
xp: 0,
|
||||||
{ name: 'شکارچی دستگاه', xp: 2600 },
|
},
|
||||||
{ name: 'استاد نقشه', xp: 4200 },
|
{
|
||||||
{ name: 'افسانهٔ مشهد', xp: 6500 },
|
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.
|
// 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) {
|
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);
|
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.
|
// Paid once per machine: the first time it has sold both a drink and a snack.
|
||||||
const typesHere = new Set(here.map((p) => p.type));
|
const typesHere = new Set(here.map((p) => p.type));
|
||||||
if (typesHere.size === 1 && !typesHere.has(record.type))
|
if (typesHere.size === 1 && !typesHere.has(record.type))
|
||||||
awards.push({ label: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo });
|
awards.push({
|
||||||
|
label: 'ترکیب نوشیدنی و خوراکی',
|
||||||
|
points: POINTS.combo,
|
||||||
|
});
|
||||||
const categories = new Set(
|
const categories = new Set(
|
||||||
previous.map((p) => p.category).filter((c) => CITY_CATEGORIES.includes(c)),
|
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.has(record.category) &&
|
||||||
categories.size === CITY_CATEGORIES.length - 1
|
categories.size === CITY_CATEGORIES.length - 1
|
||||||
)
|
)
|
||||||
awards.push({ label: 'همهٔ انواع مکان', points: POINTS.allCategories });
|
awards.push({
|
||||||
|
label: 'همهٔ انواع مکان',
|
||||||
|
points: POINTS.allCategories,
|
||||||
|
});
|
||||||
return awards;
|
return awards;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Runs the rank for operation for this subsystem.
|
||||||
|
* @param {*} xp - xp.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
export function rankFor(xp) {
|
export function rankFor(xp) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
|
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,
|
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;
|
const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).size;
|
||||||
|
|
||||||
export const MISSIONS = [
|
export const MISSIONS = [
|
||||||
{
|
{
|
||||||
id: 'factory',
|
id: 'factory',
|
||||||
@@ -110,6 +159,11 @@ export const MISSIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Marks and returns missions that the current purchases complete for the first time.
|
// 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) {
|
export function completeMissions(state) {
|
||||||
const fresh = MISSIONS.filter(
|
const fresh = MISSIONS.filter(
|
||||||
(m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
|
(m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
|
||||||
@@ -121,6 +175,10 @@ export function completeMissions(state) {
|
|||||||
}
|
}
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Runs the active mission operation for this subsystem.
|
||||||
|
* @param {*} state - state.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
export const activeMission = (state) =>
|
export const activeMission = (state) =>
|
||||||
MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null;
|
MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null;
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { publishProgress } from '../store/slices/gameSlice.js';
|
|||||||
* @returns {object} View publication and action registration API.
|
* @returns {object} View publication and action registration API.
|
||||||
*/
|
*/
|
||||||
export function createInterfaceBridge(store) {
|
export function createInterfaceBridge(store) {
|
||||||
let nodes = { briefing: { hidden: false }, hud: { hidden: true } },
|
let nodes = {
|
||||||
|
briefing: {
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
hud: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
actions = new Map(),
|
actions = new Map(),
|
||||||
timer = null,
|
timer = null,
|
||||||
disposed = false;
|
disposed = false;
|
||||||
@@ -19,18 +26,45 @@ export function createInterfaceBridge(store) {
|
|||||||
function patch(id, values) {
|
function patch(id, values) {
|
||||||
const previous = nodes[id] ?? {};
|
const previous = nodes[id] ?? {};
|
||||||
if (Object.keys(values).every((key) => Object.is(previous[key], values[key]))) return;
|
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);
|
if (timer === null && !disposed) timer = setTimeout(flush, 100);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
activate() {
|
||||||
|
disposed = false;
|
||||||
|
},
|
||||||
patch,
|
patch,
|
||||||
read: (id) => nodes[id] ?? {},
|
read: (id) => nodes[id] ?? {},
|
||||||
open: (id) => patch(id, { open: true }),
|
open: (id) =>
|
||||||
close: (id) => patch(id, { open: false }),
|
patch(id, {
|
||||||
|
open: true,
|
||||||
|
}),
|
||||||
|
close: (id) =>
|
||||||
|
patch(id, {
|
||||||
|
open: false,
|
||||||
|
}),
|
||||||
on: (id, handler) => actions.set(id, handler),
|
on: (id, handler) => actions.set(id, handler),
|
||||||
emit: (id, value) => actions.get(id)?.(value),
|
emit: (id, value) => actions.get(id)?.(value),
|
||||||
style: (id, style) => patch(id, { style: { ...nodes[id]?.style, ...style } }),
|
style: (id, style) =>
|
||||||
classState: (id, name, on) => patch(id, { classes: { ...nodes[id]?.classes, [name]: on } }),
|
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))),
|
publishGame: (state) => store.dispatch(publishProgress(structuredClone(state))),
|
||||||
clearActions: () => actions.clear(),
|
clearActions: () => actions.clear(),
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|||||||
+164
-27
@@ -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
|
// 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.
|
// 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 = {}) =>
|
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.
|
// 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) {
|
function extrudeProfile(points, width, material, bevel = 0.1) {
|
||||||
const shape = new THREE.Shape();
|
const shape = new THREE.Shape();
|
||||||
points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
|
points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
|
||||||
@@ -25,6 +43,15 @@ function extrudeProfile(points, width, material, bevel = 0.1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lower-body outline with wheel arches cut around the wheel centres.
|
// 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) {
|
function bodyOutline(front, rear, arches, radius, top) {
|
||||||
const shape = new THREE.Shape();
|
const shape = new THREE.Shape();
|
||||||
shape.moveTo(rear[0][0], rear[0][1]);
|
shape.moveTo(rear[0][0], rear[0][1]);
|
||||||
@@ -39,11 +66,27 @@ function bodyOutline(front, rear, arches, radius, top) {
|
|||||||
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 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) {
|
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
|
||||||
const tyre = mat('#151719', { roughness: 0.9 }),
|
const tyre = mat('#151719', {
|
||||||
rim = mat(rimColor, { metalness: 0.85, roughness: 0.25 }),
|
roughness: 0.9,
|
||||||
brake = mat('#3a3d40', { metalness: 0.6 });
|
}),
|
||||||
|
rim = mat(rimColor, {
|
||||||
|
metalness: 0.85,
|
||||||
|
roughness: 0.25,
|
||||||
|
}),
|
||||||
|
brake = mat('#3a3d40', {
|
||||||
|
metalness: 0.6,
|
||||||
|
});
|
||||||
const wheels = [];
|
const wheels = [];
|
||||||
for (const [x, z] of positions) {
|
for (const [x, z] of positions) {
|
||||||
const wheel = new THREE.Group();
|
const wheel = new THREE.Group();
|
||||||
@@ -86,10 +129,21 @@ function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', sp
|
|||||||
*/
|
*/
|
||||||
export function createSportSedan(color = '#1d3f78') {
|
export function createSportSedan(color = '#1d3f78') {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
const paint = mat(color, { metalness: 0.7, roughness: 0.28 }),
|
const paint = mat(color, {
|
||||||
glass = mat('#0f1a22', { metalness: 0.7, roughness: 0.08 });
|
metalness: 0.7,
|
||||||
const black = mat('#0b0c0d', { roughness: 0.6 }),
|
roughness: 0.28,
|
||||||
chrome = mat('#d8dcdf', { metalness: 0.95, roughness: 0.15 });
|
}),
|
||||||
|
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 W = 1.86;
|
||||||
const outline = bodyOutline(
|
const outline = bodyOutline(
|
||||||
[
|
[
|
||||||
@@ -203,8 +257,14 @@ export function createSportSedan(color = '#1d3f78') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Headlights with two light rings each, and slim L-shaped tail lights.
|
// Headlights with two light rings each, and slim L-shaped tail lights.
|
||||||
const ring = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 });
|
const ring = mat('#eaf6ff', {
|
||||||
const tail = mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 });
|
emissive: '#cfe9ff',
|
||||||
|
emissiveIntensity: 1.6,
|
||||||
|
});
|
||||||
|
const tail = mat('#9e1410', {
|
||||||
|
emissive: '#e0261c',
|
||||||
|
emissiveIntensity: 0.9,
|
||||||
|
});
|
||||||
for (const side of [-1, 1]) {
|
for (const side of [-1, 1]) {
|
||||||
const housing = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.14, 0.44), black);
|
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);
|
housing.position.set(2.42, 0.72, side * 0.6);
|
||||||
@@ -245,7 +305,10 @@ export function createSportSedan(color = '#1d3f78') {
|
|||||||
'#b8bcc0',
|
'#b8bcc0',
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
return { group, wheels };
|
return {
|
||||||
|
group,
|
||||||
|
wheels,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,10 +317,32 @@ export function createSportSedan(color = '#1d3f78') {
|
|||||||
*/
|
*/
|
||||||
export function createPickup(color = '#e8e8e4') {
|
export function createPickup(color = '#e8e8e4') {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
const paint = mat(color, { metalness: 0.5, roughness: 0.32 }),
|
const paint = mat(color, {
|
||||||
glass = mat('#13202a', { metalness: 0.6, roughness: 0.1 });
|
metalness: 0.5,
|
||||||
const black = mat('#1a1c1e', { roughness: 0.7 }),
|
roughness: 0.32,
|
||||||
chrome = mat('#d0d4d7', { metalness: 0.9, roughness: 0.2 });
|
}),
|
||||||
|
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 box = (w, h, d, m, x, y, z) => {
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
mesh.position.set(x, y, z);
|
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.
|
// 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);
|
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);
|
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]) {
|
for (const side of [-1, 1]) {
|
||||||
box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
|
box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
|
||||||
box(
|
box(
|
||||||
0.06,
|
0.06,
|
||||||
0.26,
|
0.26,
|
||||||
0.1,
|
0.1,
|
||||||
mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.7 }),
|
mat('#9e1410', {
|
||||||
|
emissive: '#c4241b',
|
||||||
|
emissiveIntensity: 0.7,
|
||||||
|
}),
|
||||||
-2.7,
|
-2.7,
|
||||||
1.0,
|
1.0,
|
||||||
side * 0.86,
|
side * 0.86,
|
||||||
@@ -349,7 +440,10 @@ export function createPickup(color = '#e8e8e4') {
|
|||||||
);
|
);
|
||||||
for (const x of [-1.7, 1.7])
|
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);
|
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') {
|
export function createTruck(color = '#e9eef0') {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
const paint = mat(color, { metalness: 0.35, roughness: 0.4 }),
|
const paint = mat(color, {
|
||||||
glass = mat('#14212b', { metalness: 0.6, roughness: 0.1 });
|
metalness: 0.35,
|
||||||
const black = mat('#1b1d1f', { roughness: 0.7 }),
|
roughness: 0.4,
|
||||||
teal = mat('#0b8f88', { metalness: 0.3, 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 box = (w, h, d, m, x, y, z, parent = group) => {
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
mesh.position.set(x, y, z);
|
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);
|
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.08, 0.3, 2.2, teal, 3.92, 1.35, 0);
|
||||||
box(0.25, 0.35, 2.36, black, 3.95, 0.85, 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]) {
|
for (const side of [-1, 1]) {
|
||||||
box(0.06, 0.16, 0.3, lamp, 3.98, 1.1, side * 0.9);
|
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);
|
box(0.3, 0.3, 0.08, black, 3.4, 2.7, side * 1.25);
|
||||||
}
|
}
|
||||||
// Chassis and the flatbed with low side rails.
|
// Chassis and the flatbed with low side rails.
|
||||||
box(7.6, 0.3, 1.0, black, 0, 0.75, 0);
|
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);
|
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.06, 0.5, 2.4, teal, -4.0, 1.32, 0);
|
||||||
box(0.1, 1.6, 2.4, black, 1.9, 1.85, 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.1,
|
||||||
0.22,
|
0.22,
|
||||||
0.26,
|
0.26,
|
||||||
mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.6 }),
|
mat('#9e1410', {
|
||||||
|
emissive: '#c4241b',
|
||||||
|
emissiveIntensity: 0.6,
|
||||||
|
}),
|
||||||
-4.02,
|
-4.02,
|
||||||
0.82,
|
0.82,
|
||||||
side * 0.95,
|
side * 0.95,
|
||||||
@@ -412,5 +545,9 @@ export function createTruck(color = '#e9eef0') {
|
|||||||
'#c2c6c9',
|
'#c2c6c9',
|
||||||
8,
|
8,
|
||||||
);
|
);
|
||||||
return { group, wheels, bed };
|
return {
|
||||||
|
group,
|
||||||
|
wheels,
|
||||||
|
bed,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+538
-390
@@ -2,310 +2,410 @@ import * as THREE from 'three';
|
|||||||
|
|
||||||
// Lightweight articulated characters; all coordinates below are in the group.
|
// 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.
|
// 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 } = {}) {
|
export function createFamily({ robot: robotOnly = false } = {}) {
|
||||||
const group = new THREE.Group();
|
const build = {
|
||||||
const mat = (color) => new THREE.MeshStandardMaterial({ color, roughness: 0.82 });
|
robotOnly,
|
||||||
const skin = mat('#edbd98'),
|
};
|
||||||
hair = mat('#49382d'),
|
for (const [name, method] of Object.entries(FAMILY_METHODS))
|
||||||
shoes = mat('#273c36');
|
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) {
|
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
||||||
const m = new THREE.Mesh(geometry, material);
|
/**
|
||||||
m.position.set(...position);
|
* Runs the family make robot operation for this subsystem.
|
||||||
m.castShadow = true;
|
* @param {*} build - build.
|
||||||
m.receiveShadow = true;
|
* @param {*} z - z.
|
||||||
parent.add(m);
|
* @returns {*} Operation result, when applicable.
|
||||||
return m;
|
*/
|
||||||
}
|
function familyMakeRobot(build, z) {
|
||||||
|
const scale = 0.69,
|
||||||
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
|
root = new THREE.Group(),
|
||||||
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
torso = new THREE.Group();
|
||||||
m.scale.set(...scale);
|
root.name = 'robot-market-mascot';
|
||||||
return m;
|
root.position.z = z;
|
||||||
}
|
build.group.add(root);
|
||||||
|
root.add(torso);
|
||||||
function segment(parent, a, b, r, material) {
|
const teal = new THREE.MeshStandardMaterial({
|
||||||
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
color: '#079f98',
|
||||||
return { mesh, a, b };
|
metalness: 0.4,
|
||||||
}
|
roughness: 0.28,
|
||||||
|
});
|
||||||
function updateSegment(bone, a, b) {
|
const white = new THREE.MeshStandardMaterial({
|
||||||
const delta = b.clone().sub(a);
|
color: '#edf6f4',
|
||||||
bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
|
metalness: 0.22,
|
||||||
bone.mesh.scale.y = delta.length();
|
roughness: 0.32,
|
||||||
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
});
|
||||||
}
|
const joint = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#102b30',
|
||||||
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
metalness: 0.5,
|
||||||
function makeRobot(z) {
|
roughness: 0.35,
|
||||||
const scale = 0.69,
|
});
|
||||||
root = new THREE.Group(),
|
const glow = new THREE.MeshStandardMaterial({
|
||||||
torso = new THREE.Group();
|
color: '#c6f7ff',
|
||||||
root.name = 'robot-market-mascot';
|
emissive: '#23bcff',
|
||||||
root.position.z = z;
|
emissiveIntensity: 2.5,
|
||||||
group.add(root);
|
roughness: 0.2,
|
||||||
root.add(torso);
|
});
|
||||||
const teal = new THREE.MeshStandardMaterial({
|
const visor = new THREE.MeshStandardMaterial({
|
||||||
color: '#079f98',
|
color: '#020d16',
|
||||||
metalness: 0.4,
|
metalness: 0.5,
|
||||||
roughness: 0.28,
|
roughness: 0.16,
|
||||||
});
|
});
|
||||||
const white = new THREE.MeshStandardMaterial({
|
build.sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]);
|
||||||
color: '#edf6f4',
|
build.sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]);
|
||||||
metalness: 0.22,
|
for (const side of [-1, 1])
|
||||||
roughness: 0.32,
|
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 joint = new THREE.MeshStandardMaterial({
|
const head = new THREE.Group();
|
||||||
color: '#102b30',
|
head.position.y = 1.61;
|
||||||
metalness: 0.5,
|
torso.add(head);
|
||||||
roughness: 0.35,
|
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]);
|
||||||
const glow = new THREE.MeshStandardMaterial({
|
build.sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
|
||||||
color: '#c6f7ff',
|
const eyes = [];
|
||||||
emissive: '#23bcff',
|
/**
|
||||||
emissiveIntensity: 2.5,
|
* Runs the arc operation for this subsystem.
|
||||||
roughness: 0.2,
|
* @param {*} parent - parent.
|
||||||
});
|
* @param {*} radius - radius.
|
||||||
const visor = new THREE.MeshStandardMaterial({
|
* @param {*} thickness - thickness.
|
||||||
color: '#020d16',
|
* @param {*} pos - pos.
|
||||||
metalness: 0.5,
|
* @param {*} rotation - rotation.
|
||||||
roughness: 0.16,
|
* @param {*} material - material.
|
||||||
});
|
* @returns {*} Operation result, when applicable.
|
||||||
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]);
|
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
|
||||||
for (const side of [-1, 1]) sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]);
|
const line = build.shape(
|
||||||
shape(torso, new THREE.CylinderGeometry(0.09, 0.09, 0.12, 12), joint, [0, 1.27, 0]);
|
parent,
|
||||||
const head = new THREE.Group();
|
new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
|
||||||
head.position.y = 1.61;
|
material,
|
||||||
torso.add(head);
|
pos,
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
smile.rotation.z = Math.PI;
|
line.rotation.z = rotation;
|
||||||
if (isChild) {
|
return line;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
const father = robotOnly ? null : makePerson(false, -0.45),
|
eyes.push(arc(head, 0.065, 0.016, [side * 0.16, 0.025, 0.423]));
|
||||||
child = robotOnly ? null : makePerson(true, 0.45);
|
const ear = build.shape(head, new THREE.CylinderGeometry(0.14, 0.14, 0.085, 24), joint, [
|
||||||
const robot = robotOnly ? makeRobot(0) : null;
|
side * 0.47,
|
||||||
const actors = robotOnly ? [robot] : [father, child];
|
0,
|
||||||
|
0,
|
||||||
// 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.
|
ear.rotation.z = Math.PI / 2;
|
||||||
function update({
|
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,
|
time,
|
||||||
gait,
|
gait,
|
||||||
moving,
|
moving,
|
||||||
@@ -317,95 +417,143 @@ export function createFamily({ robot: robotOnly = false } = {}) {
|
|||||||
wave = false,
|
wave = false,
|
||||||
point = false,
|
point = false,
|
||||||
dt = 1 / 60,
|
dt = 1 / 60,
|
||||||
}) {
|
},
|
||||||
actors.forEach((actor, index) => {
|
) {
|
||||||
const blend = 1 - Math.exp(-dt * 7),
|
build.actors.forEach((actor, index) => {
|
||||||
isRobot = actor === robot;
|
const blend = 1 - Math.exp(-dt * 7),
|
||||||
actor.root.position.x = THREE.MathUtils.lerp(
|
isRobot = actor === build.robot;
|
||||||
actor.root.position.x,
|
actor.root.position.x = THREE.MathUtils.lerp(
|
||||||
finished ? (index === 0 ? -0.55 : 0.55) : shopping && !isRobot ? [-0.55, 0.25][index] : 0,
|
actor.root.position.x,
|
||||||
blend,
|
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);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
// 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;
|
||||||
update({ time: 0, gait: 0, moving: false });
|
actor.root.position.y = THREE.MathUtils.lerp(
|
||||||
return { group, update };
|
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];
|
||||||
|
|||||||
+1323
-536
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+94
-15
@@ -7,13 +7,35 @@ import { textTexture } from './textures.js';
|
|||||||
// People face their local +Z; `root.rotation.y` turns them.
|
// People face their local +Z; `root.rotation.y` turns them.
|
||||||
|
|
||||||
const materials = new Map();
|
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 mat = (color, extra = {}) => {
|
||||||
const key = color + JSON.stringify(extra);
|
const key = color + JSON.stringify(extra);
|
||||||
if (!materials.has(key))
|
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);
|
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) {
|
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
mesh.position.set(x, y, z);
|
mesh.position.set(x, y, z);
|
||||||
@@ -77,7 +99,9 @@ export function createPerson({
|
|||||||
const shirt = part(
|
const shirt = part(
|
||||||
torso,
|
torso,
|
||||||
new THREE.PlaneGeometry(0.12, 0.2),
|
new THREE.PlaneGeometry(0.12, 0.2),
|
||||||
mat('#f4f4f2', { side: THREE.DoubleSide }),
|
mat('#f4f4f2', {
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
0,
|
0,
|
||||||
0.49,
|
0.49,
|
||||||
0.133,
|
0.133,
|
||||||
@@ -88,7 +112,9 @@ export function createPerson({
|
|||||||
const lapel = part(
|
const lapel = part(
|
||||||
torso,
|
torso,
|
||||||
new THREE.BoxGeometry(0.05, 0.22, 0.01),
|
new THREE.BoxGeometry(0.05, 0.22, 0.01),
|
||||||
mat(top, { roughness: 0.5 }),
|
mat(top, {
|
||||||
|
roughness: 0.5,
|
||||||
|
}),
|
||||||
side * 0.07,
|
side * 0.07,
|
||||||
0.47,
|
0.47,
|
||||||
0.132,
|
0.132,
|
||||||
@@ -134,7 +160,10 @@ export function createPerson({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (glasses) {
|
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])
|
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.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);
|
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);
|
shoulder.add(elbow);
|
||||||
part(elbow, new THREE.CapsuleGeometry(0.043, 0.18, 4, 8), topMat, 0, -0.12, 0);
|
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);
|
part(elbow, new THREE.SphereGeometry(0.045, 8, 6), skinMat, 0, -0.27, 0);
|
||||||
return { shoulder, elbow };
|
return {
|
||||||
|
shoulder,
|
||||||
|
elbow,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
// Legs: hip → knee → foot.
|
// Legs: hip → knee → foot.
|
||||||
const legs = [-1, 1].map((side) => {
|
const legs = [-1, 1].map((side) => {
|
||||||
@@ -164,9 +196,11 @@ export function createPerson({
|
|||||||
hip.add(knee);
|
hip.add(knee);
|
||||||
part(knee, new THREE.CapsuleGeometry(0.058, 0.32, 4, 8), bottomMat, 0, -0.22, 0);
|
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);
|
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 = {
|
const person = {
|
||||||
root,
|
root,
|
||||||
head,
|
head,
|
||||||
@@ -217,6 +251,12 @@ export function createPerson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Floating name badge above a person's head.
|
// 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 = '') {
|
export function nameTag(name, role = '') {
|
||||||
const texture = textTexture(512, 160, (c, w, h) => {
|
const texture = textTexture(512, 160, (c, w, h) => {
|
||||||
c.clearRect(0, 0, w, h);
|
c.clearRect(0, 0, w, h);
|
||||||
@@ -239,7 +279,11 @@ export function nameTag(name, role = '') {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const sprite = new THREE.Sprite(
|
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);
|
sprite.scale.set(1.3, 0.41, 1);
|
||||||
return sprite;
|
return sprite;
|
||||||
@@ -253,6 +297,13 @@ export function createCrew(parent, workers) {
|
|||||||
const n = workers.length,
|
const n = workers.length,
|
||||||
group = new THREE.Group();
|
group = new THREE.Group();
|
||||||
parent.add(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 instanced = (geometry, material, count = n) => {
|
||||||
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
@@ -260,7 +311,10 @@ export function createCrew(parent, workers) {
|
|||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
return 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 torso = instanced(new THREE.BoxGeometry(0.42, 0.6, 0.26), white);
|
||||||
const legs = instanced(
|
const legs = instanced(
|
||||||
new THREE.BoxGeometry(0.15, 0.9, 0.17).translate(0, -0.45, 0),
|
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(
|
const heads = instanced(
|
||||||
new THREE.SphereGeometry(0.12, 10, 8),
|
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(
|
const hats = instanced(
|
||||||
new THREE.SphereGeometry(0.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2),
|
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 skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map((c) => new THREE.Color(c));
|
||||||
const color = new THREE.Color();
|
const color = new THREE.Color();
|
||||||
@@ -295,18 +355,34 @@ export function createCrew(parent, workers) {
|
|||||||
color.set(w.hat ?? '#f2c94c');
|
color.set(w.hat ?? '#f2c94c');
|
||||||
hats.setColorAt(i, color);
|
hats.setColorAt(i, color);
|
||||||
w.phase = i * 1.37;
|
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(),
|
const base = new THREE.Matrix4(),
|
||||||
m = new THREE.Matrix4(),
|
m = new THREE.Matrix4(),
|
||||||
local = new THREE.Matrix4(),
|
local = new THREE.Matrix4(),
|
||||||
q = new THREE.Quaternion(),
|
q = new THREE.Quaternion(),
|
||||||
up = new THREE.Vector3(0, 1, 0);
|
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) => {
|
const setLimb = (mesh, index, x, y, angle) => {
|
||||||
local.makeRotationX(angle).setPosition(x, y, 0);
|
local.makeRotationX(angle).setPosition(x, y, 0);
|
||||||
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
|
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Updates update from the active simulation.
|
||||||
|
* @param {*} time - time.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
function update(time) {
|
function update(time) {
|
||||||
workers.forEach((w, i) => {
|
workers.forEach((w, i) => {
|
||||||
let x = w.x,
|
let x = w.x,
|
||||||
@@ -361,5 +437,8 @@ export function createCrew(parent, workers) {
|
|||||||
update(0);
|
update(0);
|
||||||
for (const mesh of [torso, legs, arms, heads, hats])
|
for (const mesh of [torso, legs, arms, heads, hats])
|
||||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
return { group, update };
|
return {
|
||||||
|
group,
|
||||||
|
update,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
|
// 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) {
|
export function textTexture(width, height, draw) {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
/**
|
||||||
|
* Runs the paint operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
const paint = () => {
|
const paint = () => {
|
||||||
draw(canvas.getContext('2d'), width, height);
|
draw(canvas.getContext('2d'), width, height);
|
||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
|
|||||||
+98
-16
@@ -1,7 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { catalog } from '../economy/gameState.js';
|
import { catalog } from '../economy/gameState.js';
|
||||||
import { textTexture } from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
|
||||||
const PRODUCT_COLORS = {
|
const PRODUCT_COLORS = {
|
||||||
juice: '#e5ae47',
|
juice: '#e5ae47',
|
||||||
water: '#9fd3e6',
|
water: '#9fd3e6',
|
||||||
@@ -15,6 +14,12 @@ const PRODUCT_COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Sign panel; the shared helper redraws it once the Persian web font has loaded.
|
// 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) =>
|
const label = (text, sub) =>
|
||||||
textTexture(512, 160, (c, width, height) => {
|
textTexture(512, 160, (c, width, height) => {
|
||||||
c.fillStyle = '#0f2a2a';
|
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.
|
// 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) {
|
export function createVendingMachine(def) {
|
||||||
const group = new THREE.Group();
|
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 = {}) =>
|
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 box = (w, h, d, material, x, y, z, parent = group) => {
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
|
||||||
mesh.position.set(x, y, z);
|
mesh.position.set(x, y, z);
|
||||||
@@ -49,9 +81,15 @@ export function createVendingMachine(def) {
|
|||||||
emissive: '#0b8f88',
|
emissive: '#0b8f88',
|
||||||
emissiveIntensity: 0.15,
|
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'),
|
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.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.32, 1.14, white, 0, 2.62, 0);
|
||||||
box(1.94, 0.12, 1.14, dark, 0, 0.06, 0);
|
box(1.94, 0.12, 1.14, dark, 0, 0.06, 0);
|
||||||
@@ -60,23 +98,45 @@ export function createVendingMachine(def) {
|
|||||||
1.3,
|
1.3,
|
||||||
1.8,
|
1.8,
|
||||||
0.06,
|
0.06,
|
||||||
mat('#13292b', { emissive: '#2a7f73', emissiveIntensity: 0.5 }),
|
mat('#13292b', {
|
||||||
|
emissive: '#2a7f73',
|
||||||
|
emissiveIntensity: 0.5,
|
||||||
|
}),
|
||||||
-0.2,
|
-0.2,
|
||||||
1.45,
|
1.45,
|
||||||
0.56,
|
0.56,
|
||||||
);
|
);
|
||||||
// Six shelves of products behind the glass, coloured by what this machine sells.
|
// Retail models retain 48 slots; showroom samples use their catalog channel count.
|
||||||
for (let row = 0; row < 6; row++) {
|
const rows = def.channels === 35 ? 5 : 6;
|
||||||
const y = 0.78 + row * 0.28;
|
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);
|
box(1.2, 0.02, 0.25, steel, -0.2, y, 0.45);
|
||||||
for (let col = 0; col < 8; col++) {
|
for (let col = 0; col < columns; col++) {
|
||||||
const id = def.products[(row * 8 + col) % def.products.length];
|
const id = def.products[(row * columns + col) % def.products.length];
|
||||||
const color = mat(PRODUCT_COLORS[id]);
|
const color = mat(PRODUCT_COLORS[id]);
|
||||||
|
let channelMesh;
|
||||||
if (catalog[id].type === 'drink') {
|
if (catalog[id].type === 'drink') {
|
||||||
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.17, 8), color);
|
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);
|
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(
|
const glass = new THREE.Mesh(
|
||||||
@@ -115,17 +175,40 @@ export function createVendingMachine(def) {
|
|||||||
const flap = new THREE.Group();
|
const flap = new THREE.Group();
|
||||||
flap.position.set(-0.2, 0.53, 0.61);
|
flap.position.set(-0.2, 0.53, 0.61);
|
||||||
group.add(flap);
|
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(
|
const sign = new THREE.Mesh(
|
||||||
new THREE.PlaneGeometry(1.86, 0.3),
|
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);
|
sign.position.set(0, 2.62, 0.575);
|
||||||
group.add(sign);
|
group.add(sign);
|
||||||
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
|
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
|
||||||
delivery.visible = false;
|
delivery.visible = false;
|
||||||
group.add(delivery);
|
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;
|
card.visible = false;
|
||||||
|
|
||||||
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
||||||
@@ -156,7 +239,6 @@ export function createVendingMachine(def) {
|
|||||||
ring.rotation.x = -Math.PI / 2;
|
ring.rotation.x = -Math.PI / 2;
|
||||||
ring.position.set(0, 0.09, 1.6);
|
ring.position.set(0, 0.09, 1.6);
|
||||||
group.add(ring);
|
group.add(ring);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
flap,
|
flap,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Runs the say operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} speaker - speaker.
|
||||||
|
* @param {*} text - text.
|
||||||
|
* @param {*} face - face.
|
||||||
|
* @param {*} seconds - seconds.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export // `face` picks the robot's expression portrait (public/robot/<face>.webp).
|
||||||
|
function say(context, speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) {
|
||||||
|
if (context.lines.some((l) => l.text === text)) return;
|
||||||
|
context.lines.push({
|
||||||
|
speaker,
|
||||||
|
text,
|
||||||
|
face,
|
||||||
|
seconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates subtitles from the active simulation.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} dt - dt.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function updateSubtitles(context, dt) {
|
||||||
|
context.lineTimer -= dt;
|
||||||
|
if (context.lineTimer > 0) return;
|
||||||
|
const next = context.lines.shift();
|
||||||
|
context.ui.patch('subtitle', {
|
||||||
|
hidden: !next,
|
||||||
|
});
|
||||||
|
context.talking = next?.speaker === 'robot';
|
||||||
|
if (!next) return;
|
||||||
|
context.ui.patch('subtitle-speaker', {
|
||||||
|
text: `${context.SPEAKERS[next.speaker]}:`,
|
||||||
|
});
|
||||||
|
context.ui.patch('subtitle-speaker', {
|
||||||
|
className: ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff',
|
||||||
|
});
|
||||||
|
context.ui.patch('subtitle-text', {
|
||||||
|
text: next.text,
|
||||||
|
});
|
||||||
|
context.ui.patch('subtitle-face', {
|
||||||
|
hidden: next.speaker !== 'robot',
|
||||||
|
});
|
||||||
|
if (next.speaker === 'robot')
|
||||||
|
context.ui.patch('subtitle-face', {
|
||||||
|
src: `./robot/${next.face}.webp`,
|
||||||
|
});
|
||||||
|
context.lineTimer = next.seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sound ----------
|
||||||
|
/**
|
||||||
|
* Runs the tone operation for this subsystem.
|
||||||
|
* @param {*} context - context.
|
||||||
|
* @param {*} freq - freq.
|
||||||
|
* @param {*} duration - duration.
|
||||||
|
* @param {*} volume - volume.
|
||||||
|
* @param {*} type - type.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
|
export function tone(context, freq, duration = 0.12, volume = 0.05, type = 'sine') {
|
||||||
|
if (!context.soundOn) return;
|
||||||
|
const o = context.audio.createOscillator(),
|
||||||
|
g = context.audio.createGain();
|
||||||
|
o.type = type;
|
||||||
|
o.frequency.setValueAtTime(freq, context.audio.currentTime);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(freq * 0.7, context.audio.currentTime + duration);
|
||||||
|
g.gain.setValueAtTime(volume, context.audio.currentTime);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, context.audio.currentTime + duration);
|
||||||
|
o.connect(g).connect(context.audio.destination);
|
||||||
|
o.start();
|
||||||
|
o.stop(context.audio.currentTime + duration);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 ----------
|
||||||
@@ -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.
|
||||||
@@ -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<function>} Runtime disposer.
|
||||||
|
*/
|
||||||
|
const RUNTIME_METHODS = {
|
||||||
|
placeStation,
|
||||||
|
updateReachCircles,
|
||||||
|
reset,
|
||||||
|
look,
|
||||||
|
say,
|
||||||
|
updateSubtitles,
|
||||||
|
tone,
|
||||||
|
toggleCar,
|
||||||
|
openShowroom,
|
||||||
|
interact,
|
||||||
|
renderShop,
|
||||||
|
startPayment,
|
||||||
|
swipe,
|
||||||
|
cancel,
|
||||||
|
updatePayment,
|
||||||
|
updateDelivery,
|
||||||
|
reward,
|
||||||
|
showBanner,
|
||||||
|
checkEnd,
|
||||||
|
rebuildRouteMesh,
|
||||||
|
updateRoute,
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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?.(() => {});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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', 'راه طولانیه؛ بهتره با ماشین بریم.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
-13
@@ -1,14 +1,49 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
// Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long).
|
// 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') {
|
export function createCarModel(color = '#d9d9d4') {
|
||||||
const group = new THREE.Group();
|
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 = {}) =>
|
const mat = (c, extra = {}) =>
|
||||||
new THREE.MeshStandardMaterial({ color: c, roughness: 0.45, ...extra });
|
new THREE.MeshStandardMaterial({
|
||||||
const paint = mat(color, { metalness: 0.55, roughness: 0.35 }),
|
color: c,
|
||||||
glass = mat('#1e2c33', { metalness: 0.6, roughness: 0.15 });
|
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'),
|
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 box = (w, h, d, m, x, y, z) => {
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
mesh.position.set(x, y, z);
|
mesh.position.set(x, y, z);
|
||||||
@@ -27,7 +62,10 @@ export function createCarModel(color = '#d9d9d4') {
|
|||||||
0.06,
|
0.06,
|
||||||
0.18,
|
0.18,
|
||||||
0.34,
|
0.34,
|
||||||
mat('#fff4cc', { emissive: '#fff1b0', emissiveIntensity: 0.6 }),
|
mat('#fff4cc', {
|
||||||
|
emissive: '#fff1b0',
|
||||||
|
emissiveIntensity: 0.6,
|
||||||
|
}),
|
||||||
2.21,
|
2.21,
|
||||||
0.78,
|
0.78,
|
||||||
z,
|
z,
|
||||||
@@ -36,7 +74,10 @@ export function createCarModel(color = '#d9d9d4') {
|
|||||||
0.06,
|
0.06,
|
||||||
0.16,
|
0.16,
|
||||||
0.34,
|
0.34,
|
||||||
mat('#b3261e', { emissive: '#7a0f0b', emissiveIntensity: 0.5 }),
|
mat('#b3261e', {
|
||||||
|
emissive: '#7a0f0b',
|
||||||
|
emissiveIntensity: 0.5,
|
||||||
|
}),
|
||||||
-2.21,
|
-2.21,
|
||||||
0.8,
|
0.8,
|
||||||
z,
|
z,
|
||||||
@@ -58,9 +99,11 @@ export function createCarModel(color = '#d9d9d4') {
|
|||||||
group.add(wheel);
|
group.add(wheel);
|
||||||
wheels.push(wheel);
|
wheels.push(wheel);
|
||||||
}
|
}
|
||||||
return { group, wheels };
|
return {
|
||||||
|
group,
|
||||||
|
wheels,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'#e9e9e4',
|
'#e9e9e4',
|
||||||
'#1d1f22',
|
'#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.
|
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
|
||||||
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
|
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
export function createTraffic(scene, data, count = 28) {
|
||||||
const oneway = new Set(data.oneway ?? []);
|
const oneway = new Set(data.oneway ?? []);
|
||||||
const roads = data.roads
|
const roads = data.roads
|
||||||
@@ -105,22 +155,47 @@ export function createTraffic(scene, data, count = 28) {
|
|||||||
];
|
];
|
||||||
})
|
})
|
||||||
.filter((r) => r.total > 80);
|
.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) => {
|
const sample = (road, d) => {
|
||||||
let i = 1;
|
let i = 1;
|
||||||
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
|
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
|
||||||
const a = road.pts[i - 1],
|
const a = road.pts[i - 1],
|
||||||
b = road.pts[i],
|
b = road.pts[i],
|
||||||
t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
|
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;
|
let seed = 7;
|
||||||
|
/**
|
||||||
|
* Runs the random operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
||||||
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
|
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
|
||||||
let nearby = [],
|
let nearby = [],
|
||||||
nearbyAt = null;
|
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) => {
|
const roadsNear = (x, z) => {
|
||||||
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
|
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
|
||||||
nearbyAt = { x, z };
|
nearbyAt = {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
};
|
||||||
nearby = roads.filter(
|
nearby = roads.filter(
|
||||||
({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600,
|
({ 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;
|
return nearby;
|
||||||
};
|
};
|
||||||
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
|
// 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) {
|
function respawn(car, x, z) {
|
||||||
const candidates = roadsNear(x, z);
|
const candidates = roadsNear(x, z);
|
||||||
for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
|
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]);
|
const model = createCarModel(COLORS[i % COLORS.length]);
|
||||||
group.add(model.group);
|
group.add(model.group);
|
||||||
model.group.visible = false;
|
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.
|
// 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) {
|
function remove(car) {
|
||||||
car.road = null;
|
car.road = null;
|
||||||
car.x = car.z = 1e9;
|
car.x = car.z = 1e9;
|
||||||
car.model.group.visible = false;
|
car.model.group.visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = 0;
|
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) {
|
function update(dt, obstacles, around) {
|
||||||
// Re-home a couple of distant cars per frame, which keeps the cost per frame small.
|
// Re-home a couple of distant cars per frame, which keeps the cost per frame small.
|
||||||
for (let n = 0; n < 2; n++) {
|
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;
|
for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { group, cars, update, remove };
|
return {
|
||||||
|
group,
|
||||||
|
cars,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,29 @@ export const CAR = {
|
|||||||
wheelBase: 2.7,
|
wheelBase: 2.7,
|
||||||
radius: 1.6,
|
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) {
|
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) {
|
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) {
|
||||||
car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6);
|
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;
|
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;
|
car.z -= Math.sin(car.heading) * car.speed * dt;
|
||||||
return car;
|
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);
|
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
|
||||||
export function decodeBuildings(buffer) {
|
export function decodeBuildings(buffer) {
|
||||||
|
|||||||
+535
-207
File diff suppressed because it is too large
Load Diff
+92
-11
@@ -1,13 +1,31 @@
|
|||||||
// 2D collision against building footprints, stored as flat [x0, z0, x1, z1, …] rings.
|
// 2D collision against building footprints, stored as flat [x0, z0, x1, z1, …] rings.
|
||||||
const CELL = 24;
|
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;
|
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) {
|
export function createCollisionWorld(polygons) {
|
||||||
const world = { polygons: [], grid: new Map() };
|
const world = {
|
||||||
|
polygons: [],
|
||||||
|
grid: new Map(),
|
||||||
|
};
|
||||||
polygons.forEach((p) => addPolygon(world, p));
|
polygons.forEach((p) => addPolygon(world, p));
|
||||||
return world;
|
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) {
|
export function addPolygon(world, pts) {
|
||||||
let minX = Infinity,
|
let minX = Infinity,
|
||||||
minZ = Infinity,
|
minZ = Infinity,
|
||||||
@@ -19,7 +37,14 @@ export function addPolygon(world, pts) {
|
|||||||
minZ = Math.min(minZ, pts[i + 1]);
|
minZ = Math.min(minZ, pts[i + 1]);
|
||||||
maxZ = Math.max(maxZ, 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 cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
|
||||||
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
|
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
|
||||||
const k = key(cx, cz);
|
const k = key(cx, cz);
|
||||||
@@ -27,7 +52,14 @@ export function addPolygon(world, pts) {
|
|||||||
world.grid.get(k).push(index);
|
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) {
|
function nearby(world, x, z, r) {
|
||||||
const found = new Set();
|
const found = new Set();
|
||||||
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
|
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])
|
.map((i) => world.polygons[i])
|
||||||
.filter((p) => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
|
.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) {
|
export function insidePolygon(pts, x, z) {
|
||||||
let inside = false;
|
let inside = false;
|
||||||
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
|
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;
|
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) {
|
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) {
|
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
|
||||||
const ax = pts[j],
|
const ax = pts[j],
|
||||||
az = pts[j + 1],
|
az = pts[j + 1],
|
||||||
@@ -61,12 +109,25 @@ function closestOnEdges(pts, x, z) {
|
|||||||
const px = ax + dx * t,
|
const px = ax + dx * t,
|
||||||
pz = az + dz * t,
|
pz = az + dz * t,
|
||||||
d = Math.hypot(x - px, z - pz);
|
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;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
|
// 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) {
|
export function resolveCircle(world, x, z, r) {
|
||||||
let hit = false;
|
let hit = false;
|
||||||
for (let pass = 0; pass < 3; pass++) {
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
@@ -84,13 +145,33 @@ export function resolveCircle(world, x, z, r) {
|
|||||||
}
|
}
|
||||||
if (!moved) break;
|
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) =>
|
export const blocked = (world, x, z) =>
|
||||||
nearby(world, x, z, 0).some((p) => insidePolygon(p.pts, 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.
|
// 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) {
|
export function clearFraction(world, ax, az, bx, bz, step = 1) {
|
||||||
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
|
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
|
||||||
for (let i = 1; i <= steps; i++) {
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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;
|
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;
|
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.
|
// 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) {
|
export function tileCorners(x, y, z) {
|
||||||
const n = tileLat(y, z),
|
const n = tileLat(y, z),
|
||||||
s = tileLat(y + 1, z),
|
s = tileLat(y + 1, z),
|
||||||
@@ -17,7 +36,13 @@ export function tileCorners(x, y, z) {
|
|||||||
e = tileLon(x + 1, z);
|
e = tileLon(x + 1, z);
|
||||||
return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
|
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(
|
export function createImagery(
|
||||||
scene,
|
scene,
|
||||||
index,
|
index,
|
||||||
@@ -28,8 +53,18 @@ export function createImagery(
|
|||||||
const loader = new THREE.TextureLoader();
|
const loader = new THREE.TextureLoader();
|
||||||
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
|
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
|
||||||
const levels = [
|
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) => ({
|
].map((level) => ({
|
||||||
...level,
|
...level,
|
||||||
tiles: (index[level.z] ?? []).map((key) => {
|
tiles: (index[level.z] ?? []).map((key) => {
|
||||||
@@ -37,13 +72,27 @@ export function createImagery(
|
|||||||
corners = tileCorners(x, y, level.z);
|
corners = tileCorners(x, y, level.z);
|
||||||
const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
|
const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
|
||||||
cz = corners.reduce((s, c) => s + c.z, 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 loads = 0;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
const controller = new AbortController();
|
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) {
|
async function show(level, tile) {
|
||||||
if (tile.mesh || tile.loading || loads >= 6) return;
|
if (tile.mesh || tile.loading || loads >= 6) return;
|
||||||
tile.loading = true;
|
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) {
|
function hide(tile) {
|
||||||
if (!tile.mesh) return;
|
if (!tile.mesh) return;
|
||||||
group.remove(tile.mesh);
|
group.remove(tile.mesh);
|
||||||
@@ -114,7 +167,6 @@ export function createImagery(
|
|||||||
tile.mesh.material.dispose();
|
tile.mesh.material.dispose();
|
||||||
tile.mesh = null;
|
tile.mesh = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let last = null;
|
let last = null;
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
@@ -128,7 +180,10 @@ export function createImagery(
|
|||||||
update(x, z) {
|
update(x, z) {
|
||||||
const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
|
const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
|
||||||
if (!moved && loads >= 6) return;
|
if (!moved && loads >= 6) return;
|
||||||
last = { x, z };
|
last = {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
};
|
||||||
for (const level of levels) {
|
for (const level of levels) {
|
||||||
const wanted = [];
|
const wanted = [];
|
||||||
for (const tile of level.tiles) {
|
for (const tile of level.tiles) {
|
||||||
|
|||||||
+130
-9
@@ -16,9 +16,25 @@ const AREA = {
|
|||||||
farmland: '#2a3326',
|
farmland: '#2a3326',
|
||||||
};
|
};
|
||||||
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
|
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');
|
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;
|
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) {
|
function path(c, pts) {
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.moveTo(pts[0], pts[1]);
|
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.
|
// 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) {
|
function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
|
||||||
for (const area of areas) {
|
for (const area of areas) {
|
||||||
c.fillStyle = AREA[data.areaKinds[area[0]]];
|
c.fillStyle = AREA[data.areaKinds[area[0]]];
|
||||||
@@ -45,11 +69,23 @@ function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
|
|||||||
c.fill();
|
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 = []) {
|
export function createMapAssets(data, extraLabels = []) {
|
||||||
const { minX, minZ, maxX, maxZ } = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
// Bin every feature into the tiles its bounding box touches.
|
// Bin every feature into the tiles its bounding box touches.
|
||||||
const bins = new Map();
|
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) => {
|
const bin = (list, feature, first) => {
|
||||||
let x0 = Infinity,
|
let x0 = Infinity,
|
||||||
z0 = 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 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++) {
|
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
|
||||||
const k = tileKey(tx, 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);
|
bins.get(k)[list].push(feature);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
data.areas.forEach((a) => bin('areas', a, 1));
|
data.areas.forEach((a) => bin('areas', a, 1));
|
||||||
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
|
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
|
||||||
data.buildings.forEach((b) => bin('buildings', b, 1));
|
data.buildings.forEach((b) => bin('buildings', b, 1));
|
||||||
|
|
||||||
const cache = new Map();
|
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) {
|
function tile(tx, tz) {
|
||||||
const k = tileKey(tx, tz);
|
const k = tileKey(tx, tz);
|
||||||
if (cache.has(k)) {
|
if (cache.has(k)) {
|
||||||
@@ -87,12 +133,20 @@ export function createMapAssets(data, extraLabels = []) {
|
|||||||
c.fillStyle = '#1b231f';
|
c.fillStyle = '#1b231f';
|
||||||
c.fillRect(0, 0, TILE_PX, TILE_PX);
|
c.fillRect(0, 0, TILE_PX, TILE_PX);
|
||||||
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
|
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);
|
cache.set(k, canvas);
|
||||||
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
|
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overview = document.createElement('canvas');
|
const overview = document.createElement('canvas');
|
||||||
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
|
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
|
||||||
overview.height = Math.ceil((maxZ - minZ) * 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 + 3, road.length - 1)] - road[mid - 1],
|
||||||
road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
|
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 {
|
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
|
// 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.
|
// 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) {
|
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
|
||||||
const { minX, minZ } = assets.bounds;
|
const { minX, minZ } = assets.bounds;
|
||||||
c.fillStyle = '#141b18';
|
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++)
|
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);
|
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) {
|
function rotatePoint(x, z, cx, cz, angle, k) {
|
||||||
const dx = (x - cx) * k,
|
const dx = (x - cx) * k,
|
||||||
dz = (z - cz) * k,
|
dz = (z - cz) * k,
|
||||||
@@ -172,7 +252,14 @@ function rotatePoint(x, z, cx, cz, angle, k) {
|
|||||||
sin = Math.sin(angle);
|
sin = Math.sin(angle);
|
||||||
return [dx * cos - dz * sin, dx * sin + dz * cos];
|
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 }) {
|
function machineIcon(c, x, y, { objective, visited, open }) {
|
||||||
c.save();
|
c.save();
|
||||||
c.translate(x, y);
|
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.
|
// 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) {
|
export function drawMinimap(ctx, assets, view) {
|
||||||
const { size, x, z, heading, camForward, radius, markers, route } = view;
|
const { size, x, z, heading, camForward, radius, markers, route } = view;
|
||||||
const r = size / 2,
|
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).
|
// 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) {
|
export function drawFullMap(ctx, assets, view, state) {
|
||||||
const { width, height } = ctx.canvas,
|
const { width, height } = ctx.canvas,
|
||||||
dpr = state.dpr,
|
dpr = state.dpr,
|
||||||
w = width / dpr,
|
w = width / dpr,
|
||||||
h = height / 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];
|
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.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.fillStyle = '#101614';
|
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.
|
// 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(
|
export function drawIran(
|
||||||
ctx,
|
ctx,
|
||||||
iran,
|
iran,
|
||||||
@@ -420,6 +535,12 @@ export function drawIran(
|
|||||||
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
|
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
|
||||||
const offX = (width - (lon1 - lon0) * kx * k) / 2,
|
const offX = (width - (lon1 - lon0) * kx * k) / 2,
|
||||||
offY = (height - (lat1 - lat0) * 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];
|
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
for (const province of iran.provinces) {
|
for (const province of iran.provinces) {
|
||||||
|
|||||||
+103
-11
@@ -2,11 +2,23 @@
|
|||||||
// A road is [typeIndex, nameIndex, x0, z0, x1, z1, …]; ways that share an OSM node share coordinates.
|
// 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
|
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 } = {}) {
|
export function buildGraph(roads, { drivable = false } = {}) {
|
||||||
const ids = new Map(),
|
const ids = new Map(),
|
||||||
xs = [],
|
xs = [],
|
||||||
zs = [],
|
zs = [],
|
||||||
adj = [];
|
adj = [];
|
||||||
|
/**
|
||||||
|
* Runs the node operation for this subsystem.
|
||||||
|
* @param {*} x - x.
|
||||||
|
* @param {*} z - z.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
const node = (x, z) => {
|
const node = (x, z) => {
|
||||||
const k = `${x},${z}`;
|
const k = `${x},${z}`;
|
||||||
if (!ids.has(k)) {
|
if (!ids.has(k)) {
|
||||||
@@ -30,7 +42,13 @@ export function buildGraph(roads, { drivable = false } = {}) {
|
|||||||
prev = next;
|
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) => {
|
graph.main.forEach((inMain, i) => {
|
||||||
if (!inMain) return;
|
if (!inMain) return;
|
||||||
const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
|
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;
|
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;
|
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) {
|
function largestComponent(adj) {
|
||||||
const label = new Int32Array(adj.length).fill(-1),
|
const label = new Int32Array(adj.length).fill(-1),
|
||||||
sizes = [];
|
sizes = [];
|
||||||
@@ -67,6 +94,13 @@ function largestComponent(adj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nearest node of the main connected component, searching outward ring by ring.
|
// 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) {
|
export function nearestNode(graph, x, z) {
|
||||||
const cx = Math.floor(x / 50),
|
const cx = Math.floor(x / 50),
|
||||||
cz = Math.floor(z / 50);
|
cz = Math.floor(z / 50);
|
||||||
@@ -88,15 +122,31 @@ export function nearestNode(graph, x, z) {
|
|||||||
}
|
}
|
||||||
return best;
|
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) {
|
export function findPath(graph, start, goal) {
|
||||||
if (start < 0 || goal < 0) return null;
|
if (start < 0 || goal < 0) return null;
|
||||||
const { xs, zs, adj } = graph,
|
const { xs, zs, adj } = graph,
|
||||||
g = new Map([[start, 0]]),
|
g = new Map([[start, 0]]),
|
||||||
from = new Map(),
|
from = new Map(),
|
||||||
closed = new Set();
|
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 h = (n) => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
|
||||||
const heap = [[h(start), start]];
|
const heap = [[h(start), start]];
|
||||||
|
/**
|
||||||
|
* Runs the push operation for this subsystem.
|
||||||
|
* @param {*} item - item.
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
const push = (item) => {
|
const push = (item) => {
|
||||||
heap.push(item);
|
heap.push(item);
|
||||||
for (let i = heap.length - 1; i > 0;) {
|
for (let i = heap.length - 1; i > 0;) {
|
||||||
@@ -106,6 +156,11 @@ export function findPath(graph, start, goal) {
|
|||||||
i = p;
|
i = p;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Runs the pop operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
const pop = () => {
|
const pop = () => {
|
||||||
const top = heap[0],
|
const top = heap[0],
|
||||||
last = heap.pop();
|
last = heap.pop();
|
||||||
@@ -145,18 +200,42 @@ export function findPath(graph, start, goal) {
|
|||||||
}
|
}
|
||||||
return null;
|
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) {
|
export function routeBetween(graph, ax, az, bx, bz) {
|
||||||
const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
|
const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
const points = [
|
const points = [
|
||||||
{ x: ax, z: az },
|
{
|
||||||
...path.map((n) => ({ x: graph.xs[n], z: graph.zs[n] })),
|
x: ax,
|
||||||
{ x: bx, z: bz },
|
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) {
|
export function polylineLength(points) {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let i = 1; i < points.length; i++)
|
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.
|
// 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) {
|
export function buildSegmentIndex(roads) {
|
||||||
const grid = new Map();
|
const grid = new Map();
|
||||||
for (const road of roads)
|
for (const road of roads)
|
||||||
@@ -187,7 +271,15 @@ export function buildSegmentIndex(roads) {
|
|||||||
}
|
}
|
||||||
return grid;
|
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) {
|
export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
|
||||||
let best = null,
|
let best = null,
|
||||||
bestD = maxDistance;
|
bestD = maxDistance;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export function useGameEngine(root, bridge) {
|
|||||||
const disposeRef = useRef(null);
|
const disposeRef = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
/**
|
||||||
|
* Runs the start operation for this subsystem.
|
||||||
|
|
||||||
|
* @returns {*} Operation result, when applicable.
|
||||||
|
*/
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
const { createGame } = await import('@/game/createGame.js');
|
const { createGame } = await import('@/game/createGame.js');
|
||||||
@@ -22,7 +27,10 @@ export function useGameEngine(root, bridge) {
|
|||||||
if (controller.signal.aborted) dispose();
|
if (controller.signal.aborted) dispose();
|
||||||
else disposeRef.current = dispose;
|
else disposeRef.current = dispose;
|
||||||
} catch (problem) {
|
} catch (problem) {
|
||||||
if (!controller.signal.aborted) setError(problem.message);
|
if (!controller.signal.aborted) {
|
||||||
|
setError(problem.message);
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start();
|
start();
|
||||||
@@ -33,5 +41,7 @@ export function useGameEngine(root, bridge) {
|
|||||||
disposeRef.current = null;
|
disposeRef.current = null;
|
||||||
};
|
};
|
||||||
}, [root, bridge]);
|
}, [root, bridge]);
|
||||||
return { error };
|
return {
|
||||||
|
error,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-7
@@ -1,18 +1,13 @@
|
|||||||
import React, { Suspense, lazy } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
import { AppRoutes } from '@/routes/AppRoutes.jsx';
|
||||||
import { store } from '@/store/index.js';
|
import { store } from '@/store/index.js';
|
||||||
import '@fontsource/vazirmatn/400.css';
|
import '@fontsource/vazirmatn/400.css';
|
||||||
import '@fontsource/vazirmatn/600.css';
|
import '@fontsource/vazirmatn/600.css';
|
||||||
import '@fontsource/vazirmatn/800.css';
|
import '@fontsource/vazirmatn/800.css';
|
||||||
import '@/styles/game.css';
|
import '@/styles/game.css';
|
||||||
const GamePage = lazy(() =>
|
|
||||||
import('@/pages/game/GamePage.jsx').then((module) => ({ default: module.GamePage })),
|
|
||||||
);
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Suspense fallback={<p role="status">در حال آمادهسازی بازی…</p>}>
|
<AppRoutes />
|
||||||
<GamePage />
|
|
||||||
</Suspense>
|
|
||||||
</Provider>,
|
</Provider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ShowroomDialog } from './ShowroomDialog.jsx';
|
||||||
import { PaymentPanel } from './PaymentPanel.jsx';
|
import { PaymentPanel } from './PaymentPanel.jsx';
|
||||||
import { MapDialog } from './MapDialog.jsx';
|
import { MapDialog } from './MapDialog.jsx';
|
||||||
import { ShopDialog } from './ShopDialog.jsx';
|
import { ShopDialog } from './ShopDialog.jsx';
|
||||||
@@ -9,6 +10,7 @@ import { EndDialog } from './EndDialog.jsx';
|
|||||||
export function GameDialogs(props) {
|
export function GameDialogs(props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ShowroomDialog {...props} />
|
||||||
<PaymentPanel {...props} />
|
<PaymentPanel {...props} />
|
||||||
<MapDialog {...props} />
|
<MapDialog {...props} />
|
||||||
<ShopDialog {...props} />
|
<ShopDialog {...props} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function GamePage() {
|
|||||||
const nodes = useAppSelector(selectNodes);
|
const nodes = useAppSelector(selectNodes);
|
||||||
const { error } = useGameEngine(root, bridge);
|
const { error } = useGameEngine(root, bridge);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
bridge.activate();
|
||||||
const dispose = setupPwa(bridge);
|
const dispose = setupPwa(bridge);
|
||||||
return () => {
|
return () => {
|
||||||
dispose();
|
dispose();
|
||||||
@@ -32,7 +33,10 @@ export function GamePage() {
|
|||||||
for (const name of Object.keys(classes)) document.body.classList.remove(name);
|
for (const name of Object.keys(classes)) document.body.classList.remove(name);
|
||||||
};
|
};
|
||||||
}, [nodes.body]);
|
}, [nodes.body]);
|
||||||
const props = { nodes, actions: bridge };
|
const props = {
|
||||||
|
nodes,
|
||||||
|
actions: bridge,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<main ref={root}>
|
<main ref={root}>
|
||||||
<GameSurfaces {...props} />
|
<GameSurfaces {...props} />
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
|
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
|
||||||
/** Presents the GameToolbar using the current interface snapshot. */
|
/** Presents the GameToolbar using the current interface snapshot. */
|
||||||
export function GameToolbar({ nodes, actions }) {
|
export function GameToolbar({ nodes, actions }) {
|
||||||
return (
|
return (
|
||||||
<GameNode as="div" id="toolbar" model={nodes['toolbar']}>
|
<GameNode as="div" id="toolbar" model={nodes['toolbar']}>
|
||||||
|
<GameNode
|
||||||
|
as="button"
|
||||||
|
id="showroom-open"
|
||||||
|
type="button"
|
||||||
|
hidden
|
||||||
|
model={nodes['showroom-open']}
|
||||||
|
onAction={() => actions.emit('showroom-open')}
|
||||||
|
>
|
||||||
|
مدلهای کارخانه · Q
|
||||||
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
id="gps-toggle"
|
id="gps-toggle"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
|
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
|
||||||
/** Presents the LocationStatus using the current interface snapshot. */
|
/** Presents the LocationStatus using the current interface snapshot. */
|
||||||
export function LocationStatus({ nodes }) {
|
export function LocationStatus({ nodes }) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
|
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
|
||||||
/** Presents the MachineDetails using the current interface snapshot. */
|
/** Presents the MachineDetails using the current interface snapshot. */
|
||||||
export function MachineDetails({ nodes, actions }) {
|
export function MachineDetails({ nodes, actions }) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { MachineDetails } from './MachineDetails.jsx';
|
import { MachineDetails } from './MachineDetails.jsx';
|
||||||
import { MachineList } from './MachineList.jsx';
|
import { MachineList } from './MachineList.jsx';
|
||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
|
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
|
||||||
/** Presents the MapDialog view using plain models and semantic native elements.
|
/** Presents the MapDialog view using plain models and semantic native elements.
|
||||||
* @param {MapDialogProps} props - Interface snapshots and callbacks.
|
* @param {MapDialogProps} props - Interface snapshots and callbacks.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
|
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
|
||||||
/** Presents the RankProgress using the current interface snapshot. */
|
/** Presents the RankProgress using the current interface snapshot. */
|
||||||
export function RankProgress({ nodes }) {
|
export function RankProgress({ nodes }) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
|
||||||
|
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
|
||||||
|
import { ShowroomImage } from './ShowroomImage.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
|
||||||
|
/** Presents the factory sample catalog and questions answered by the robot host.
|
||||||
|
* @param {ShowroomDialogProps} props - Showroom state and game commands.
|
||||||
|
* @returns {import('react').ReactNode} Sample dialog, or null before a sample is selected.
|
||||||
|
*/
|
||||||
|
export function ShowroomDialog({ nodes, actions }) {
|
||||||
|
const model = showroomModelById(nodes.showroom?.modelId);
|
||||||
|
if (!model) return null;
|
||||||
|
return (
|
||||||
|
<GameNode
|
||||||
|
as="dialog"
|
||||||
|
id="showroom"
|
||||||
|
model={nodes.showroom}
|
||||||
|
onClose={() => 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"
|
||||||
|
>
|
||||||
|
<header className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<h2 id="showroom-title" className="text-xl font-bold">
|
||||||
|
نمایشگاه کارخانه · {model.name}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="بستن معرفی دستگاه"
|
||||||
|
onClick={() => actions.close('showroom')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
|
||||||
|
{SHOWROOM_CATALOG.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className="rounded border border-teal px-4 py-2"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={item.id === model.id}
|
||||||
|
onClick={() => actions.emit('showroom-select', item.id)}
|
||||||
|
>
|
||||||
|
{item.shortLabel}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<ShowroomImage key={model.id} model={model} />
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
|
||||||
|
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
|
||||||
|
<p>{model.description}</p>
|
||||||
|
<ul className="my-4 list-inside list-disc">
|
||||||
|
{model.highlights.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm opacity-75">
|
||||||
|
این بخش معرفی محصول است؛ خرید واقعی انجام نمیشود و بودجهٔ هتومان تغییر نمیکند.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
className="rounded bg-teal px-4 py-3 font-bold text-surface"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
actions.emit('showroom-question', {
|
||||||
|
modelId: model.id,
|
||||||
|
topic: 'price',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
روبات، قیمتش چنده؟
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded border border-teal px-4 py-3"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
actions.emit('showroom-question', {
|
||||||
|
modelId: model.id,
|
||||||
|
topic: 'features',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
روبات، امکاناتش چیه؟
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
|
||||||
|
<img
|
||||||
|
className="h-16 w-16 shrink-0 object-contain"
|
||||||
|
src="./robot/happy.webp"
|
||||||
|
alt="روبات راهنمای نمایشگاه"
|
||||||
|
/>
|
||||||
|
<p role="status">
|
||||||
|
{nodes['showroom-response']?.text ??
|
||||||
|
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<ShowroomDialog nodes={nodes} actions={actions} />);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{unavailable ? (
|
||||||
|
<div
|
||||||
|
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
نمای سهبعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className="mx-auto max-h-64 max-w-full object-contain"
|
||||||
|
src={src}
|
||||||
|
alt={`${model.name} · ${variant === 'imageAlt' ? 'رنگ سفید' : variant === 'gif' ? 'نمای متحرک' : 'نمای اصلی'}`}
|
||||||
|
onError={() =>
|
||||||
|
setFailed((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[path]: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
['image', 'نمای اصلی'],
|
||||||
|
['imageAlt', 'رنگ سفید'],
|
||||||
|
['gif', 'نمای متحرک'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
className="rounded border border-white/20 px-3 py-2"
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={variant === key}
|
||||||
|
onClick={() => setVariant(key)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
|
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
|
||||||
/** Presents the StatusBar using the current interface snapshot. */
|
/** Presents the StatusBar using the current interface snapshot. */
|
||||||
export function StatusBar({ nodes }) {
|
export function StatusBar({ nodes }) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
|
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
|
||||||
/** Presents the TouchControls using the current interface snapshot. */
|
/** Presents the TouchControls using the current interface snapshot. */
|
||||||
export function TouchControls({ nodes, actions }) {
|
export function TouchControls({ nodes, actions }) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Suspense fallback={<p role="status">در حال آمادهسازی بازی…</p>}>
|
||||||
|
<GamePage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ArrayBuffer>} 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { apiClient } from './apiClient.js';
|
import { apiClient } from './apiClient.js';
|
||||||
|
const WORLD_FILES = {
|
||||||
const WORLD_FILES = { buildings: './data/buildings.bin', imagery: './imagery/index.json' };
|
buildings: './data/buildings.bin',
|
||||||
|
imagery: './imagery/index.json',
|
||||||
|
};
|
||||||
|
|
||||||
/** Loads optional world assets without preventing the OSM fallback on network failure.
|
/** Loads optional world assets without preventing the OSM fallback on network failure.
|
||||||
* @param {AbortSignal} signal - Cancels startup when the game unmounts.
|
* @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) {
|
export async function loadWorldFiles(signal) {
|
||||||
const requests = [
|
const requests = [
|
||||||
apiClient.get(WORLD_FILES.buildings, { responseType: 'arraybuffer', signal }),
|
apiClient.get(WORLD_FILES.buildings, {
|
||||||
apiClient.get(WORLD_FILES.imagery, { signal }),
|
responseType: 'arraybuffer',
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
apiClient.get(WORLD_FILES.imagery, {
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
const results = await Promise.allSettled(requests);
|
const results = await Promise.allSettled(requests);
|
||||||
return results.map((result) => (result.status === 'fulfilled' ? result.value.data : null));
|
return results.map((result) => (result.status === 'fulfilled' ? result.value.data : null));
|
||||||
@@ -21,6 +28,9 @@ export async function loadWorldFiles(signal) {
|
|||||||
* @returns {Promise<string>} Temporary URL, to revoke after image decoding.
|
* @returns {Promise<string>} Temporary URL, to revoke after image decoding.
|
||||||
*/
|
*/
|
||||||
export async function loadTileUrl(path, signal) {
|
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);
|
return URL.createObjectURL(response.data);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -3,6 +3,11 @@ import { interfaceReducer } from './slices/interfaceSlice.js';
|
|||||||
import { gameReducer } from './slices/gameSlice.js';
|
import { gameReducer } from './slices/gameSlice.js';
|
||||||
/** Creates one Redux store for interface snapshots and shared economic progress. */
|
/** Creates one Redux store for interface snapshots and shared economic progress. */
|
||||||
export function createAppStore() {
|
export function createAppStore() {
|
||||||
return configureStore({ reducer: { interface: interfaceReducer, game: gameReducer } });
|
return configureStore({
|
||||||
|
reducer: {
|
||||||
|
interface: interfaceReducer,
|
||||||
|
game: gameReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
export const store = createAppStore();
|
export const store = createAppStore();
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
const gameSlice = createSlice({
|
const gameSlice = createSlice({
|
||||||
name: 'game',
|
name: 'game',
|
||||||
initialState: { balance: 500, xp: 0, purchases: [], missionsDone: [], minutes: 510 },
|
initialState: {
|
||||||
|
balance: 500,
|
||||||
|
xp: 0,
|
||||||
|
purchases: [],
|
||||||
|
missionsDone: [],
|
||||||
|
minutes: 510,
|
||||||
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
publishProgress(_state, action) {
|
publishProgress(_state, action) {
|
||||||
return action.payload;
|
return action.payload;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
const initialState = {
|
||||||
const initialState = { nodes: { briefing: { hidden: false }, hud: { hidden: true } } };
|
nodes: {
|
||||||
|
briefing: {
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
hud: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
const interfaceSlice = createSlice({
|
const interfaceSlice = createSlice({
|
||||||
name: 'interface',
|
name: 'interface',
|
||||||
initialState,
|
initialState,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+35
-6
@@ -1,22 +1,51 @@
|
|||||||
// Local tangent-plane projection around the playable Park Mellat area.
|
// Local tangent-plane projection around the playable Park Mellat area.
|
||||||
// World axes: +x east, -z north, 1 unit = 1 metre.
|
// 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_LAT = 110574;
|
||||||
const METRES_PER_DEG_LON = 111320 * Math.cos((ORIGIN.lat * Math.PI) / 180);
|
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) {
|
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) {
|
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.
|
// 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) {
|
export function bearing(dx, dz) {
|
||||||
return ((Math.atan2(dx, -dz) * 180) / Math.PI + 360) % 360;
|
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) {
|
export function formatDistance(metres) {
|
||||||
return metres < 1000
|
return metres < 1000
|
||||||
? `${Math.round(metres).toLocaleString('fa-IR')} متر`
|
? `${Math.round(metres).toLocaleString('fa-IR')} متر`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-2
@@ -1,3 +1,11 @@
|
|||||||
import plugin from 'tailwindcss/plugin.js';
|
import plugin from 'tailwindcss/plugin.js';
|
||||||
import {gameTheme} from './src/styles/gameTheme.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))]};
|
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))],
|
||||||
|
};
|
||||||
|
|||||||
+11
-3
@@ -1,4 +1,12 @@
|
|||||||
import {defineConfig} from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import {fileURLToPath} from 'node:url';
|
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']}});
|
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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user