diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..9991ef7
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+dist
+node_modules
+src/data
+public
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..7ab2c25
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/package-lock.json b/package-lock.json
index 1737f9a..f621010 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"three": "^0.180.0"
},
"devDependencies": {
+ "prettier": "^3.9.10",
"vite": "^6.4.3"
}
},
@@ -1026,6 +1027,22 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.10",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
+ "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/rollup": {
"version": "4.64.10",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz",
diff --git a/package.json b/package.json
index f037265..fd778d5 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,12 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"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:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" vite.config.js",
+ "predev": "npm run format",
+ "prebuild": "npm run format",
+ "test": "node --test tests/*.test.js"
},
"keywords": [],
"author": "",
@@ -16,6 +21,7 @@
"three": "^0.180.0"
},
"devDependencies": {
+ "prettier": "^3.9.10",
"vite": "^6.4.3"
}
}
diff --git a/src/ar.js b/src/ar.js
index 2f0ffd7..913c660 100644
--- a/src/ar.js
+++ b/src/ar.js
@@ -7,109 +7,130 @@ import * as THREE from 'three';
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
const CAMERA_LONG_FOV = 65;
-const zee = new THREE.Vector3(0, 0, 1), euler = new THREE.Euler(), q0 = new THREE.Quaternion();
+const zee = new THREE.Vector3(0, 0, 1),
+ euler = new THREE.Euler(),
+ q0 = new THREE.Quaternion();
const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera looks out of the back, not the top
// Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
// `alpha` must be measured from north (absolute), counter-clockwise.
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
- const r = Math.PI / 180;
- euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
- target.setFromEuler(euler);
- target.multiply(q1);
- target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
- return target;
+ const r = Math.PI / 180;
+ euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
+ target.setFromEuler(euler);
+ target.multiply(q1);
+ target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
+ return target;
}
// Vertical field of view of what is visible once the video is scaled to cover the screen.
export function coverFov(videoAspect, screenAspect) {
- const long = Math.tan(CAMERA_LONG_FOV * Math.PI / 360);
- // The video's own vertical and horizontal half-angle tangents.
- const vTan = videoAspect >= 1 ? long / videoAspect : long, hTan = videoAspect >= 1 ? long : long * videoAspect;
- // "cover" fills the screen: if the screen is wider than the video, the full width shows.
- const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
- return Math.atan(visibleV) * 360 / Math.PI;
+ const long = Math.tan((CAMERA_LONG_FOV * Math.PI) / 360);
+ // The video's own vertical and horizontal half-angle tangents.
+ const vTan = videoAspect >= 1 ? long / videoAspect : long,
+ hTan = videoAspect >= 1 ? long : long * videoAspect;
+ // "cover" fills the screen: if the screen is wider than the video, the full width shows.
+ const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
+ return (Math.atan(visibleV) * 360) / Math.PI;
}
export function createArView(video) {
- let stream = null, orientation = null, compassOffset = null, absoluteSeen = false;
- const target = new THREE.Quaternion();
- // Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
- // only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
- const onAbsolute = event => {
- if (event.alpha == null) return;
- absoluteSeen = true;
- orientation = {alpha: event.alpha, beta: event.beta, gamma: event.gamma};
- };
- const onOrientation = event => {
- if (event.alpha == null || absoluteSeen) return;
- let alpha = event.alpha;
- if (event.webkitCompassHeading != null) {
- // iOS: alpha is relative to the start pose; the compass heading pins it to north.
- compassOffset = (360 - event.webkitCompassHeading) - event.alpha;
- }
- if (!event.absolute && compassOffset != null) alpha += compassOffset;
- orientation = {alpha, beta: event.beta, gamma: event.gamma};
- };
- let sensing = false;
- return {
- // Camera picture on (AR) or off (the 3D world fills the screen).
- get active() {
- return Boolean(stream);
- },
- get hasOrientation() {
- return Boolean(orientation);
- },
- // Phone orientation, used by the first-person view with or without the camera picture.
- startSensors() {
- if (sensing) return;
- sensing = true;
- window.addEventListener('deviceorientationabsolute', onAbsolute);
- window.addEventListener('deviceorientation', onOrientation);
- },
- stopSensors() {
- sensing = false;
- orientation = null;
- absoluteSeen = false;
- window.removeEventListener('deviceorientationabsolute', onAbsolute);
- window.removeEventListener('deviceorientation', onOrientation);
- },
- // Opens the rear camera. Resolves to null on success or to a message explaining the failure.
- async start() {
- if (stream) return null;
- if (!navigator.mediaDevices?.getUserMedia) return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
- try {
- stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: {ideal: 'environment'}, width: {ideal: 1280}, height: {ideal: 720}}, audio: false});
- } catch (error) {
- stream = null;
- return error?.name === 'NotAllowedError' ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.' : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
- }
- video.srcObject = stream;
- video.hidden = false;
- await video.play().catch(() => {});
- this.startSensors();
- return null;
- },
- // Closes the camera picture; the sensors keep running for the first-person view.
- stop() {
- stream?.getTracks().forEach(track => track.stop());
- stream = null;
- video.srcObject = null;
- video.hidden = true;
- },
- // Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
- // `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
- update(camera, dt, smoothing = 8) {
- const fov = stream ? coverFov(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3, innerWidth / innerHeight) : 62;
- if (Math.abs(camera.fov - fov) > .1) {
- camera.fov = fov;
- camera.updateProjectionMatrix();
- }
- if (!orientation) return false;
- const angle = screen.orientation?.angle ?? window.orientation ?? 0;
- orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
- camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
- return true;
- }
- };
+ let stream = null,
+ orientation = null,
+ compassOffset = null,
+ absoluteSeen = false;
+ const target = new THREE.Quaternion();
+ // Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
+ // only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
+ const onAbsolute = (event) => {
+ if (event.alpha == null) return;
+ absoluteSeen = true;
+ orientation = { alpha: event.alpha, beta: event.beta, gamma: event.gamma };
+ };
+ const onOrientation = (event) => {
+ if (event.alpha == null || absoluteSeen) return;
+ let alpha = event.alpha;
+ if (event.webkitCompassHeading != null) {
+ // iOS: alpha is relative to the start pose; the compass heading pins it to north.
+ compassOffset = 360 - event.webkitCompassHeading - event.alpha;
+ }
+ if (!event.absolute && compassOffset != null) alpha += compassOffset;
+ orientation = { alpha, beta: event.beta, gamma: event.gamma };
+ };
+ let sensing = false;
+ return {
+ // Camera picture on (AR) or off (the 3D world fills the screen).
+ get active() {
+ return Boolean(stream);
+ },
+ get hasOrientation() {
+ return Boolean(orientation);
+ },
+ // Phone orientation, used by the first-person view with or without the camera picture.
+ startSensors() {
+ if (sensing) return;
+ sensing = true;
+ window.addEventListener('deviceorientationabsolute', onAbsolute);
+ window.addEventListener('deviceorientation', onOrientation);
+ },
+ stopSensors() {
+ sensing = false;
+ orientation = null;
+ absoluteSeen = false;
+ window.removeEventListener('deviceorientationabsolute', onAbsolute);
+ window.removeEventListener('deviceorientation', onOrientation);
+ },
+ // Opens the rear camera. Resolves to null on success or to a message explaining the failure.
+ async start() {
+ if (stream) return null;
+ if (!navigator.mediaDevices?.getUserMedia)
+ return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ facingMode: { ideal: 'environment' },
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ },
+ audio: false,
+ });
+ } catch (error) {
+ stream = null;
+ return error?.name === 'NotAllowedError'
+ ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
+ : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
+ }
+ video.srcObject = stream;
+ video.hidden = false;
+ await video.play().catch(() => {});
+ this.startSensors();
+ return null;
+ },
+ // Closes the camera picture; the sensors keep running for the first-person view.
+ stop() {
+ stream?.getTracks().forEach((track) => track.stop());
+ stream = null;
+ video.srcObject = null;
+ video.hidden = true;
+ },
+ // Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
+ // `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
+ update(camera, dt, smoothing = 8) {
+ const fov = stream
+ ? coverFov(
+ video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3,
+ innerWidth / innerHeight,
+ )
+ : 62;
+ if (Math.abs(camera.fov - fov) > 0.1) {
+ camera.fov = fov;
+ camera.updateProjectionMatrix();
+ }
+ if (!orientation) return false;
+ const angle = screen.orientation?.angle ?? window.orientation ?? 0;
+ orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
+ camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
+ return true;
+ },
+ };
}
diff --git a/src/buildings-data.js b/src/buildings-data.js
index 0090ee6..9c9f68f 100644
--- a/src/buildings-data.js
+++ b/src/buildings-data.js
@@ -1,25 +1,30 @@
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
export function decodeBuildings(buffer) {
- const view = new DataView(buffer);
- const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
- if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`);
- const count = view.getUint32(4, true), minX = view.getFloat32(8, true), minZ = view.getFloat32(12, true), step = view.getFloat32(16, true);
- const buildings = new Array(count);
- let o = 20;
- for (let b = 0; b < count; b++) {
- const n = view.getUint8(o), height = view.getUint8(o + 1) * .5;
- o += 2;
- const building = new Array(1 + n * 2);
- building[0] = height;
- let qx = 0, qz = 0;
- for (let i = 0; i < n; i++, o += 4) {
- qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
- qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
- building[1 + i * 2] = minX + qx * step;
- building[2 + i * 2] = minZ + qz * step;
- }
- buildings[b] = building;
+ const view = new DataView(buffer);
+ const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
+ if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`);
+ const count = view.getUint32(4, true),
+ minX = view.getFloat32(8, true),
+ minZ = view.getFloat32(12, true),
+ step = view.getFloat32(16, true);
+ const buildings = new Array(count);
+ let o = 20;
+ for (let b = 0; b < count; b++) {
+ const n = view.getUint8(o),
+ height = view.getUint8(o + 1) * 0.5;
+ o += 2;
+ const building = new Array(1 + n * 2);
+ building[0] = height;
+ let qx = 0,
+ qz = 0;
+ for (let i = 0; i < n; i++, o += 4) {
+ qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
+ qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
+ building[1 + i * 2] = minX + qx * step;
+ building[2 + i * 2] = minZ + qz * step;
}
- return buildings;
+ buildings[b] = building;
+ }
+ return buildings;
}
diff --git a/src/car-models.js b/src/car-models.js
index 573d498..657810d 100644
--- a/src/car-models.js
+++ b/src/car-models.js
@@ -3,64 +3,80 @@ import * as THREE from 'three';
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
-const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .45, ...extra});
+const mat = (color, extra = {}) =>
+ new THREE.MeshStandardMaterial({ color, roughness: 0.45, ...extra });
// A side profile (x along the car, y up) extruded across its width, with rounded edges.
-function extrudeProfile(points, width, material, bevel = .1) {
- const shape = new THREE.Shape();
- points.forEach(([x, y], i) => i ? shape.lineTo(x, y) : shape.moveTo(x, y));
- const geometry = new THREE.ExtrudeGeometry(shape, {depth: width - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel * .8, bevelSegments: 3, curveSegments: 6});
- geometry.translate(0, 0, -(width - bevel * 2) / 2);
- const mesh = new THREE.Mesh(geometry, material);
- mesh.castShadow = mesh.receiveShadow = true;
- return mesh;
+function extrudeProfile(points, width, material, bevel = 0.1) {
+ const shape = new THREE.Shape();
+ points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
+ const geometry = new THREE.ExtrudeGeometry(shape, {
+ depth: width - bevel * 2,
+ bevelEnabled: true,
+ bevelThickness: bevel,
+ bevelSize: bevel * 0.8,
+ bevelSegments: 3,
+ curveSegments: 6,
+ });
+ geometry.translate(0, 0, -(width - bevel * 2) / 2);
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.castShadow = mesh.receiveShadow = true;
+ return mesh;
}
// Lower-body outline with wheel arches cut around the wheel centres.
function bodyOutline(front, rear, arches, radius, top) {
- const shape = new THREE.Shape();
- shape.moveTo(rear[0][0], rear[0][1]);
- const bottom = .3;
- // Underside from the rear towards the front, arching over each wheel.
- for (const ax of [...arches].sort((a, b) => a - b)) {
- shape.lineTo(ax - radius, bottom);
- shape.absarc(ax, .36, radius, Math.PI + .14, -.14, true);
- }
- for (const [x, y] of front) shape.lineTo(x, y);
- for (const [x, y] of top) shape.lineTo(x, y);
- for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
- return shape;
+ const shape = new THREE.Shape();
+ shape.moveTo(rear[0][0], rear[0][1]);
+ const bottom = 0.3;
+ // Underside from the rear towards the front, arching over each wheel.
+ for (const ax of [...arches].sort((a, b) => a - b)) {
+ shape.lineTo(ax - radius, bottom);
+ shape.absarc(ax, 0.36, radius, Math.PI + 0.14, -0.14, true);
+ }
+ for (const [x, y] of front) shape.lineTo(x, y);
+ for (const [x, y] of top) shape.lineTo(x, y);
+ for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
+ return shape;
}
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
- const tyre = mat('#151719', {roughness: .9}), rim = mat(rimColor, {metalness: .85, roughness: .25}), brake = mat('#3a3d40', {metalness: .6});
- const wheels = [];
- for (const [x, z] of positions) {
- const wheel = new THREE.Group();
- wheel.position.set(x, radius, z);
- const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
- t.rotation.x = Math.PI / 2;
- t.castShadow = true;
- wheel.add(t);
- const side = Math.sign(z);
- const face = new THREE.Mesh(new THREE.CylinderGeometry(radius * .68, radius * .68, .02, 24), brake);
- face.rotation.x = Math.PI / 2;
- face.position.z = side * tyreWidth * .44;
- wheel.add(face);
- for (let i = 0; i < spokes; i++) {
- const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * .16, .04), rim);
- spoke.position.z = side * tyreWidth * .5;
- spoke.rotation.z = i * Math.PI / spokes;
- wheel.add(spoke);
- }
- const hub = new THREE.Mesh(new THREE.CylinderGeometry(radius * .2, radius * .2, .05, 12), rim);
- hub.rotation.x = Math.PI / 2;
- hub.position.z = side * tyreWidth * .52;
- wheel.add(hub);
- group.add(wheel);
- wheels.push(wheel);
+ const tyre = mat('#151719', { roughness: 0.9 }),
+ rim = mat(rimColor, { metalness: 0.85, roughness: 0.25 }),
+ brake = mat('#3a3d40', { metalness: 0.6 });
+ const wheels = [];
+ for (const [x, z] of positions) {
+ const wheel = new THREE.Group();
+ wheel.position.set(x, radius, z);
+ const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
+ t.rotation.x = Math.PI / 2;
+ t.castShadow = true;
+ wheel.add(t);
+ const side = Math.sign(z);
+ const face = new THREE.Mesh(
+ new THREE.CylinderGeometry(radius * 0.68, radius * 0.68, 0.02, 24),
+ brake,
+ );
+ face.rotation.x = Math.PI / 2;
+ face.position.z = side * tyreWidth * 0.44;
+ wheel.add(face);
+ for (let i = 0; i < spokes; i++) {
+ const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * 0.16, 0.04), rim);
+ spoke.position.z = side * tyreWidth * 0.5;
+ spoke.rotation.z = (i * Math.PI) / spokes;
+ wheel.add(spoke);
}
- return wheels;
+ const hub = new THREE.Mesh(
+ new THREE.CylinderGeometry(radius * 0.2, radius * 0.2, 0.05, 12),
+ rim,
+ );
+ hub.rotation.x = Math.PI / 2;
+ hub.position.z = side * tyreWidth * 0.52;
+ wheel.add(hub);
+ group.add(wheel);
+ wheels.push(wheel);
+ }
+ return wheels;
}
/**
@@ -69,101 +85,167 @@ function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', sp
* No manufacturer badges.
*/
export function createSportSedan(color = '#1d3f78') {
- const group = new THREE.Group();
- const paint = mat(color, {metalness: .7, roughness: .28}), glass = mat('#0f1a22', {metalness: .7, roughness: .08});
- const black = mat('#0b0c0d', {roughness: .6}), chrome = mat('#d8dcdf', {metalness: .95, roughness: .15});
- const W = 1.86;
- const outline = bodyOutline(
- [[2.24, .3], [2.36, .42], [2.38, .62], [2.28, .78]],
- [[-2.3, .3], [-2.36, .55], [-2.3, .86], [-2.12, .97]],
- [-1.42, 1.43], .41,
- [[1.9, .86], [.95, 1.0], [-1.2, 1.0], [-1.6, .99]]
- );
- const bodyGeometry = new THREE.ExtrudeGeometry(outline, {depth: W - .2, bevelEnabled: true, bevelThickness: .1, bevelSize: .08, bevelSegments: 3, curveSegments: 10});
- bodyGeometry.translate(0, 0, -(W - .2) / 2);
- const body = new THREE.Mesh(bodyGeometry, paint);
- body.castShadow = body.receiveShadow = true;
- group.add(body);
- // Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
- const cabin = extrudeProfile([[.95, .98], [.05, 1.39], [-.9, 1.41], [-1.62, 1.0], [-1.5, .98]], 1.5, glass, .08);
- group.add(cabin);
- const roof = extrudeProfile([[.0, 1.37], [-.88, 1.39], [-1.0, 1.32], [.12, 1.32]], 1.36, paint, .06);
- roof.position.y = .045;
- group.add(roof);
- const pillar = new THREE.ShapeGeometry(new THREE.Shape([new THREE.Vector2(-1.05, 1.0), new THREE.Vector2(-1.55, 1.0), new THREE.Vector2(-1.08, 1.33), new THREE.Vector2(-.96, 1.33), new THREE.Vector2(-1.18, 1.12)]));
- const pillarPaint = paint.clone();
- pillarPaint.side = THREE.DoubleSide;
- // Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
- for (const side of [-1, 1]) {
- const z = side * .72;
- const b = new THREE.Mesh(new THREE.BoxGeometry(.09, .4, .04), black);
- b.position.set(-.35, 1.18, z);
- group.add(b);
- const c = new THREE.Mesh(pillar, pillarPaint);
- c.position.z = z + side * .035;
- group.add(c);
- // Door handles, mirror and the chrome window line.
- for (const x of [.15, -.75]) {
- const handle = new THREE.Mesh(new THREE.BoxGeometry(.18, .04, .03), chrome);
- handle.position.set(x, .9, side * .94);
- group.add(handle);
- }
- const mirror = new THREE.Mesh(new THREE.BoxGeometry(.18, .12, .2), paint);
- mirror.position.set(.78, 1.05, side * 1.0);
- group.add(mirror);
- const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, .025, .02), chrome);
- trim.position.set(-.3, .985, side * .76);
- group.add(trim);
- const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, .08, .04), black);
- skirt.position.set(0, .34, side * .93);
- group.add(skirt);
+ const group = new THREE.Group();
+ const paint = mat(color, { metalness: 0.7, roughness: 0.28 }),
+ glass = mat('#0f1a22', { metalness: 0.7, roughness: 0.08 });
+ const black = mat('#0b0c0d', { roughness: 0.6 }),
+ chrome = mat('#d8dcdf', { metalness: 0.95, roughness: 0.15 });
+ const W = 1.86;
+ const outline = bodyOutline(
+ [
+ [2.24, 0.3],
+ [2.36, 0.42],
+ [2.38, 0.62],
+ [2.28, 0.78],
+ ],
+ [
+ [-2.3, 0.3],
+ [-2.36, 0.55],
+ [-2.3, 0.86],
+ [-2.12, 0.97],
+ ],
+ [-1.42, 1.43],
+ 0.41,
+ [
+ [1.9, 0.86],
+ [0.95, 1.0],
+ [-1.2, 1.0],
+ [-1.6, 0.99],
+ ],
+ );
+ const bodyGeometry = new THREE.ExtrudeGeometry(outline, {
+ depth: W - 0.2,
+ bevelEnabled: true,
+ bevelThickness: 0.1,
+ bevelSize: 0.08,
+ bevelSegments: 3,
+ curveSegments: 10,
+ });
+ bodyGeometry.translate(0, 0, -(W - 0.2) / 2);
+ const body = new THREE.Mesh(bodyGeometry, paint);
+ body.castShadow = body.receiveShadow = true;
+ group.add(body);
+ // Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
+ const cabin = extrudeProfile(
+ [
+ [0.95, 0.98],
+ [0.05, 1.39],
+ [-0.9, 1.41],
+ [-1.62, 1.0],
+ [-1.5, 0.98],
+ ],
+ 1.5,
+ glass,
+ 0.08,
+ );
+ group.add(cabin);
+ const roof = extrudeProfile(
+ [
+ [0.0, 1.37],
+ [-0.88, 1.39],
+ [-1.0, 1.32],
+ [0.12, 1.32],
+ ],
+ 1.36,
+ paint,
+ 0.06,
+ );
+ roof.position.y = 0.045;
+ group.add(roof);
+ const pillar = new THREE.ShapeGeometry(
+ new THREE.Shape([
+ new THREE.Vector2(-1.05, 1.0),
+ new THREE.Vector2(-1.55, 1.0),
+ new THREE.Vector2(-1.08, 1.33),
+ new THREE.Vector2(-0.96, 1.33),
+ new THREE.Vector2(-1.18, 1.12),
+ ]),
+ );
+ const pillarPaint = paint.clone();
+ pillarPaint.side = THREE.DoubleSide;
+ // Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
+ for (const side of [-1, 1]) {
+ const z = side * 0.72;
+ const b = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.4, 0.04), black);
+ b.position.set(-0.35, 1.18, z);
+ group.add(b);
+ const c = new THREE.Mesh(pillar, pillarPaint);
+ c.position.z = z + side * 0.035;
+ group.add(c);
+ // Door handles, mirror and the chrome window line.
+ for (const x of [0.15, -0.75]) {
+ const handle = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.04, 0.03), chrome);
+ handle.position.set(x, 0.9, side * 0.94);
+ group.add(handle);
}
- // Twin kidney grille with chrome surrounds.
- for (const side of [-1, 1]) {
- const surround = new THREE.Mesh(new THREE.BoxGeometry(.06, .2, .3), chrome);
- surround.position.set(2.46, .66, side * .18);
- group.add(surround);
- const grille = new THREE.Mesh(new THREE.BoxGeometry(.07, .16, .25), black);
- grille.position.set(2.47, .66, side * .18);
- group.add(grille);
- for (let i = 0; i < 5; i++) {
- const bar = new THREE.Mesh(new THREE.BoxGeometry(.075, .15, .012), chrome);
- bar.position.set(2.47, .66, side * .18 + (i - 2) * .05);
- group.add(bar);
- }
+ const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.2), paint);
+ mirror.position.set(0.78, 1.05, side * 1.0);
+ group.add(mirror);
+ const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.025, 0.02), chrome);
+ trim.position.set(-0.3, 0.985, side * 0.76);
+ group.add(trim);
+ const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.08, 0.04), black);
+ skirt.position.set(0, 0.34, side * 0.93);
+ group.add(skirt);
+ }
+ // Twin kidney grille with chrome surrounds.
+ for (const side of [-1, 1]) {
+ const surround = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, 0.3), chrome);
+ surround.position.set(2.46, 0.66, side * 0.18);
+ group.add(surround);
+ const grille = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.16, 0.25), black);
+ grille.position.set(2.47, 0.66, side * 0.18);
+ group.add(grille);
+ for (let i = 0; i < 5; i++) {
+ const bar = new THREE.Mesh(new THREE.BoxGeometry(0.075, 0.15, 0.012), chrome);
+ bar.position.set(2.47, 0.66, side * 0.18 + (i - 2) * 0.05);
+ group.add(bar);
}
- // Headlights with two light rings each, and slim L-shaped tail lights.
- const ring = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.6});
- const tail = mat('#9e1410', {emissive: '#e0261c', emissiveIntensity: .9});
- for (const side of [-1, 1]) {
- const housing = new THREE.Mesh(new THREE.BoxGeometry(.12, .14, .44), black);
- housing.position.set(2.42, .72, side * .6);
- group.add(housing);
- for (const dz of [-.1, .1]) {
- const eye = new THREE.Mesh(new THREE.TorusGeometry(.05, .012, 6, 18), ring);
- eye.rotation.y = Math.PI / 2;
- eye.position.set(2.48, .72, side * .6 + dz);
- group.add(eye);
- }
- const tl = new THREE.Mesh(new THREE.BoxGeometry(.06, .1, .42), tail);
- tl.position.set(-2.45, .82, side * .62);
- group.add(tl);
- const tlSide = new THREE.Mesh(new THREE.BoxGeometry(.3, .1, .05), tail);
- tlSide.position.set(-2.2, .82, side * .9);
- group.add(tlSide);
- const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .12, 12), chrome);
- exhaust.rotation.z = Math.PI / 2;
- exhaust.position.set(-2.44, .36, side * .45);
- group.add(exhaust);
+ }
+ // Headlights with two light rings each, and slim L-shaped tail lights.
+ const ring = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 });
+ const tail = mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 });
+ for (const side of [-1, 1]) {
+ const housing = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.14, 0.44), black);
+ housing.position.set(2.42, 0.72, side * 0.6);
+ group.add(housing);
+ for (const dz of [-0.1, 0.1]) {
+ const eye = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 6, 18), ring);
+ eye.rotation.y = Math.PI / 2;
+ eye.position.set(2.48, 0.72, side * 0.6 + dz);
+ group.add(eye);
}
- const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(.05, .12, 1.2), black);
- lowerGrille.position.set(2.45, .42, 0);
- group.add(lowerGrille);
- const plate = new THREE.Mesh(new THREE.BoxGeometry(.03, .11, .5), mat('#f4f4f0'));
- plate.position.set(2.47, .5, 0);
- group.add(plate);
- const wheels = addWheels(group, [[-1.42, -.82], [-1.42, .82], [1.43, -.82], [1.43, .82]], .36, .26, '#b8bcc0', 5);
- return {group, wheels};
+ const tl = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.1, 0.42), tail);
+ tl.position.set(-2.45, 0.82, side * 0.62);
+ group.add(tl);
+ const tlSide = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.1, 0.05), tail);
+ tlSide.position.set(-2.2, 0.82, side * 0.9);
+ group.add(tlSide);
+ const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.12, 12), chrome);
+ exhaust.rotation.z = Math.PI / 2;
+ exhaust.position.set(-2.44, 0.36, side * 0.45);
+ group.add(exhaust);
+ }
+ const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.12, 1.2), black);
+ lowerGrille.position.set(2.45, 0.42, 0);
+ group.add(lowerGrille);
+ const plate = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.11, 0.5), mat('#f4f4f0'));
+ plate.position.set(2.47, 0.5, 0);
+ group.add(plate);
+ const wheels = addWheels(
+ group,
+ [
+ [-1.42, -0.82],
+ [-1.42, 0.82],
+ [1.43, -0.82],
+ [1.43, 0.82],
+ ],
+ 0.36,
+ 0.26,
+ '#b8bcc0',
+ 5,
+ );
+ return { group, wheels };
}
/**
@@ -171,42 +253,103 @@ export function createSportSedan(color = '#1d3f78') {
* bed behind the cab. 5.3 m long.
*/
export function createPickup(color = '#e8e8e4') {
- const group = new THREE.Group();
- const paint = mat(color, {metalness: .5, roughness: .32}), glass = mat('#13202a', {metalness: .6, roughness: .1});
- const black = mat('#1a1c1e', {roughness: .7}), chrome = mat('#d0d4d7', {metalness: .9, roughness: .2});
- const box = (w, h, d, m, x, y, z) => {
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
- mesh.position.set(x, y, z);
- mesh.castShadow = mesh.receiveShadow = true;
- group.add(mesh);
- return mesh;
- };
- group.add(extrudeProfile([[2.62, .5], [2.66, .95], [2.5, 1.18], [1.3, 1.24], [1.3, .5]], 1.9, paint, .1));
- group.add(extrudeProfile([[1.3, .5], [1.3, 1.24], [.7, 1.86], [-.95, 1.88], [-1.0, .5]], 1.88, paint, .1));
- group.add(extrudeProfile([[1.24, 1.26], [.68, 1.82], [-.9, 1.84], [-.94, 1.26]], 1.92, glass, .02));
- // Cargo bed: floor, sides and tailgate.
- box(1.62, .1, 1.8, black, -1.85, .72, 0);
- for (const side of [-1, 1]) box(1.66, .5, .07, paint, -1.85, .95, side * .9);
- box(.07, .5, 1.86, paint, -2.66, .95, 0);
- box(1.66, .46, 1.9, paint, -1.85, .5, 0);
- box(.3, .06, 1.86, black, -1.02, 1.26, 0);
- // Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
- box(.06, .34, 1.1, black, 2.67, .92, 0);
- for (let i = 0; i < 3; i++) box(.07, .04, 1.12, chrome, 2.68, .82 + i * .1, 0);
- const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.2});
- for (const side of [-1, 1]) {
- box(.08, .14, .32, lamp, 2.62, 1.02, side * .74);
- box(.06, .26, .1, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .7}), -2.7, 1.0, side * .86);
- box(.2, .14, .2, black, 1.2, 1.36, side * 1.02);
- box(1.0, .05, .06, chrome, -.1, 1.25, side * .97);
- for (const x of [.6, -.4]) box(.16, .04, .03, chrome, x, 1.12, side * .97);
- }
- box(.16, .2, 2.0, black, 2.7, .55, 0);
- box(.16, .2, 2.0, black, -2.72, .55, 0);
- box(.04, .12, .5, mat('#f4f4f0'), 2.79, .6, 0);
- const wheels = addWheels(group, [[-1.7, -.86], [-1.7, .86], [1.7, -.86], [1.7, .86]], .4, .3, '#a9adb1', 6);
- for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, .1, .2, black, x, .86, side * .96);
- return {group, wheels};
+ const group = new THREE.Group();
+ const paint = mat(color, { metalness: 0.5, roughness: 0.32 }),
+ glass = mat('#13202a', { metalness: 0.6, roughness: 0.1 });
+ const black = mat('#1a1c1e', { roughness: 0.7 }),
+ chrome = mat('#d0d4d7', { metalness: 0.9, roughness: 0.2 });
+ const box = (w, h, d, m, x, y, z) => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = mesh.receiveShadow = true;
+ group.add(mesh);
+ return mesh;
+ };
+ group.add(
+ extrudeProfile(
+ [
+ [2.62, 0.5],
+ [2.66, 0.95],
+ [2.5, 1.18],
+ [1.3, 1.24],
+ [1.3, 0.5],
+ ],
+ 1.9,
+ paint,
+ 0.1,
+ ),
+ );
+ group.add(
+ extrudeProfile(
+ [
+ [1.3, 0.5],
+ [1.3, 1.24],
+ [0.7, 1.86],
+ [-0.95, 1.88],
+ [-1.0, 0.5],
+ ],
+ 1.88,
+ paint,
+ 0.1,
+ ),
+ );
+ group.add(
+ extrudeProfile(
+ [
+ [1.24, 1.26],
+ [0.68, 1.82],
+ [-0.9, 1.84],
+ [-0.94, 1.26],
+ ],
+ 1.92,
+ glass,
+ 0.02,
+ ),
+ );
+ // Cargo bed: floor, sides and tailgate.
+ box(1.62, 0.1, 1.8, black, -1.85, 0.72, 0);
+ for (const side of [-1, 1]) box(1.66, 0.5, 0.07, paint, -1.85, 0.95, side * 0.9);
+ box(0.07, 0.5, 1.86, paint, -2.66, 0.95, 0);
+ box(1.66, 0.46, 1.9, paint, -1.85, 0.5, 0);
+ box(0.3, 0.06, 1.86, black, -1.02, 1.26, 0);
+ // Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
+ box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0);
+ for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0);
+ const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.2 });
+ for (const side of [-1, 1]) {
+ box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
+ box(
+ 0.06,
+ 0.26,
+ 0.1,
+ mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.7 }),
+ -2.7,
+ 1.0,
+ side * 0.86,
+ );
+ box(0.2, 0.14, 0.2, black, 1.2, 1.36, side * 1.02);
+ box(1.0, 0.05, 0.06, chrome, -0.1, 1.25, side * 0.97);
+ for (const x of [0.6, -0.4]) box(0.16, 0.04, 0.03, chrome, x, 1.12, side * 0.97);
+ }
+ box(0.16, 0.2, 2.0, black, 2.7, 0.55, 0);
+ box(0.16, 0.2, 2.0, black, -2.72, 0.55, 0);
+ box(0.04, 0.12, 0.5, mat('#f4f4f0'), 2.79, 0.6, 0);
+ const wheels = addWheels(
+ group,
+ [
+ [-1.7, -0.86],
+ [-1.7, 0.86],
+ [1.7, -0.86],
+ [1.7, 0.86],
+ ],
+ 0.4,
+ 0.3,
+ '#a9adb1',
+ 6,
+ );
+ for (const x of [-1.7, 1.7])
+ for (const side of [-1, 1]) box(1.05, 0.1, 0.2, black, x, 0.86, side * 0.96);
+ return { group, wheels };
}
/**
@@ -214,37 +357,60 @@ export function createPickup(color = '#e8e8e4') {
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
*/
export function createTruck(color = '#e9eef0') {
- const group = new THREE.Group();
- const paint = mat(color, {metalness: .35, roughness: .4}), glass = mat('#14212b', {metalness: .6, roughness: .1});
- const black = mat('#1b1d1f', {roughness: .7}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
- const box = (w, h, d, m, x, y, z, parent = group) => {
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
- mesh.position.set(x, y, z);
- mesh.castShadow = mesh.receiveShadow = true;
- parent.add(mesh);
- return mesh;
- };
- // Cab over the front axle.
- box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
- box(.05, .9, 2.0, glass, 3.91, 2.35, 0);
- for (const side of [-1, 1]) box(1.0, .7, .04, glass, 3.1, 2.4, side * 1.16);
- box(.08, .3, 2.2, teal, 3.92, 1.35, 0);
- box(.25, .35, 2.36, black, 3.95, .85, 0);
- const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1});
- for (const side of [-1, 1]) {
- box(.06, .16, .3, lamp, 3.98, 1.1, side * .9);
- box(.3, .3, .08, black, 3.4, 2.7, side * 1.25);
- }
- // Chassis and the flatbed with low side rails.
- box(7.6, .3, 1.0, black, 0, .75, 0);
- box(5.9, .14, 2.4, mat('#6b5a45', {roughness: .9}), -1.05, 1.0, 0);
- for (const side of [-1, 1]) box(5.9, .5, .06, teal, -1.05, 1.32, side * 1.19);
- box(.06, .5, 2.4, teal, -4.0, 1.32, 0);
- box(.1, 1.6, 2.4, black, 1.9, 1.85, 0);
- for (const side of [-1, 1]) box(.1, .22, .26, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .6}), -4.02, .82, side * .95);
- const bed = new THREE.Group();
- bed.position.set(-1.05, 1.07, 0);
- group.add(bed);
- const wheels = addWheels(group, [[-2.9, -1.0], [-2.9, 1.0], [2.9, -1.0], [2.9, 1.0]], .5, .38, '#c2c6c9', 8);
- return {group, wheels, bed};
+ const group = new THREE.Group();
+ const paint = mat(color, { metalness: 0.35, roughness: 0.4 }),
+ glass = mat('#14212b', { metalness: 0.6, roughness: 0.1 });
+ const black = mat('#1b1d1f', { roughness: 0.7 }),
+ teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 });
+ const box = (w, h, d, m, x, y, z, parent = group) => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = mesh.receiveShadow = true;
+ parent.add(mesh);
+ return mesh;
+ };
+ // Cab over the front axle.
+ box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
+ box(0.05, 0.9, 2.0, glass, 3.91, 2.35, 0);
+ for (const side of [-1, 1]) box(1.0, 0.7, 0.04, glass, 3.1, 2.4, side * 1.16);
+ box(0.08, 0.3, 2.2, teal, 3.92, 1.35, 0);
+ box(0.25, 0.35, 2.36, black, 3.95, 0.85, 0);
+ const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1 });
+ for (const side of [-1, 1]) {
+ box(0.06, 0.16, 0.3, lamp, 3.98, 1.1, side * 0.9);
+ box(0.3, 0.3, 0.08, black, 3.4, 2.7, side * 1.25);
+ }
+ // Chassis and the flatbed with low side rails.
+ box(7.6, 0.3, 1.0, black, 0, 0.75, 0);
+ box(5.9, 0.14, 2.4, mat('#6b5a45', { roughness: 0.9 }), -1.05, 1.0, 0);
+ for (const side of [-1, 1]) box(5.9, 0.5, 0.06, teal, -1.05, 1.32, side * 1.19);
+ box(0.06, 0.5, 2.4, teal, -4.0, 1.32, 0);
+ box(0.1, 1.6, 2.4, black, 1.9, 1.85, 0);
+ for (const side of [-1, 1])
+ box(
+ 0.1,
+ 0.22,
+ 0.26,
+ mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.6 }),
+ -4.02,
+ 0.82,
+ side * 0.95,
+ );
+ const bed = new THREE.Group();
+ bed.position.set(-1.05, 1.07, 0);
+ group.add(bed);
+ const wheels = addWheels(
+ group,
+ [
+ [-2.9, -1.0],
+ [-2.9, 1.0],
+ [2.9, -1.0],
+ [2.9, 1.0],
+ ],
+ 0.5,
+ 0.38,
+ '#c2c6c9',
+ 8,
+ );
+ return { group, wheels, bed };
}
diff --git a/src/characters.js b/src/characters.js
index ed66c10..4439748 100644
--- a/src/characters.js
+++ b/src/characters.js
@@ -2,225 +2,411 @@ import * as THREE from 'three';
// Lightweight articulated characters; all coordinates below are in the group.
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
-export function createFamily({robot: robotOnly = false} = {}) {
- const group = new THREE.Group();
- const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
- const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36');
+export function createFamily({ robot: robotOnly = false } = {}) {
+ const group = new THREE.Group();
+ const mat = (color) => new THREE.MeshStandardMaterial({ color, roughness: 0.82 });
+ const skin = mat('#edbd98'),
+ hair = mat('#49382d'),
+ shoes = mat('#273c36');
- function shape(parent, geometry, material, position) {
- const m = new THREE.Mesh(geometry, material);
- m.position.set(...position);
- m.castShadow = true;
- m.receiveShadow = true;
- parent.add(m);
- return m;
+ function shape(parent, geometry, material, position) {
+ const m = new THREE.Mesh(geometry, material);
+ m.position.set(...position);
+ m.castShadow = true;
+ m.receiveShadow = true;
+ parent.add(m);
+ return m;
+ }
+
+ function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
+ const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
+ m.scale.set(...scale);
+ return m;
+ }
+
+ function segment(parent, a, b, r, material) {
+ const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
+ return { mesh, a, b };
+ }
+
+ function updateSegment(bone, a, b) {
+ const delta = b.clone().sub(a);
+ bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
+ bone.mesh.scale.y = delta.length();
+ bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
+ }
+
+ // Procedural Robot Market mascot: a hovering guide that follows the father and child.
+ function makeRobot(z) {
+ const scale = 0.69,
+ root = new THREE.Group(),
+ torso = new THREE.Group();
+ root.name = 'robot-market-mascot';
+ root.position.z = z;
+ group.add(root);
+ root.add(torso);
+ const teal = new THREE.MeshStandardMaterial({
+ color: '#079f98',
+ metalness: 0.4,
+ roughness: 0.28,
+ });
+ const white = new THREE.MeshStandardMaterial({
+ color: '#edf6f4',
+ metalness: 0.22,
+ roughness: 0.32,
+ });
+ const joint = new THREE.MeshStandardMaterial({
+ color: '#102b30',
+ metalness: 0.5,
+ roughness: 0.35,
+ });
+ const glow = new THREE.MeshStandardMaterial({
+ color: '#c6f7ff',
+ emissive: '#23bcff',
+ emissiveIntensity: 2.5,
+ roughness: 0.2,
+ });
+ const visor = new THREE.MeshStandardMaterial({
+ color: '#020d16',
+ metalness: 0.5,
+ roughness: 0.16,
+ });
+ sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]);
+ sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]);
+ for (const side of [-1, 1]) sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]);
+ shape(torso, new THREE.CylinderGeometry(0.09, 0.09, 0.12, 12), joint, [0, 1.27, 0]);
+ const head = new THREE.Group();
+ head.position.y = 1.61;
+ torso.add(head);
+ sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]);
+ sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]);
+ sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
+ const eyes = [];
+ function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
+ const line = shape(
+ parent,
+ new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
+ material,
+ pos,
+ );
+ line.rotation.z = rotation;
+ return line;
}
-
- function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
- const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
- m.scale.set(...scale);
- return m;
+ 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]);
}
-
- function segment(parent, a, b, r, material) {
- const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
- return {mesh, a, b};
- }
-
- function updateSegment(bone, a, b) {
- const delta = b.clone().sub(a);
- bone.mesh.position.copy(a).add(b).multiplyScalar(.5);
- bone.mesh.scale.y = delta.length();
- bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
- }
-
- // Procedural Robot Market mascot: a hovering guide that follows the father and child.
- function makeRobot(z) {
- const scale = .69, root = new THREE.Group(), torso = new THREE.Group();
- root.name = 'robot-market-mascot';
- root.position.z = z;
- group.add(root);
- root.add(torso);
- const teal = new THREE.MeshStandardMaterial({color: '#079f98', metalness: .4, roughness: .28});
- const white = new THREE.MeshStandardMaterial({color: '#edf6f4', metalness: .22, roughness: .32});
- const joint = new THREE.MeshStandardMaterial({color: '#102b30', metalness: .5, roughness: .35});
- const glow = new THREE.MeshStandardMaterial({color: '#c6f7ff', emissive: '#23bcff', emissiveIntensity: 2.5, roughness: .2});
- const visor = new THREE.MeshStandardMaterial({color: '#020d16', metalness: .5, roughness: .16});
- sphere(torso, .25, teal, [0, .76, 0], [1, .65, .75]);
- sphere(torso, .31, white, [0, .97, 0], [1, 1.05, .72]);
- for (const side of [-1, 1]) sphere(torso, .17, teal, [side * .24, 1.03, -.04], [.55, 1, 1]);
- shape(torso, new THREE.CylinderGeometry(.09, .09, .12, 12), joint, [0, 1.27, 0]);
- const head = new THREE.Group();
- head.position.y = 1.61;
- torso.add(head);
- sphere(head, .43, teal, [0, 0, 0], [1.12, .91, .88]);
- sphere(head, .40, white, [0, -.015, .12], [1.12, .86, .77]);
- sphere(head, .35, visor, [0, -.015, .255], [1.13, .83, .48]);
- const eyes = [];
- function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
- const line = shape(parent, new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI), material, pos);
- line.rotation.z = rotation;
- return line;
+ 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();
}
- for (const side of [-1, 1]) {
- eyes.push(arc(head, .065, .016, [side * .16, .025, .423]));
- const ear = shape(head, new THREE.CylinderGeometry(.14, .14, .085, 24), joint, [side * .47, 0, 0]);
- ear.rotation.z = Math.PI / 2;
- const ring = shape(head, new THREE.TorusGeometry(.105, .012, 8, 24), glow, [side * .518, 0, 0]);
- ring.rotation.y = Math.PI / 2;
- sphere(head, .12, teal, [side * .49, 0, 0], [.4, 1, 1]);
- }
- arc(head, .075, .016, [0, -.085, .425], Math.PI);
- sphere(head, .10, teal, [0, .38, 0], [1, .55, 1]);
- shape(head, new THREE.CylinderGeometry(.017, .022, .18, 10), teal, [0, .49, 0]);
- sphere(head, .055, teal, [0, .60, 0]);
- // Draw the chest mark as a texture so it remains attached to the 3D body.
- if (typeof document !== 'undefined') {
- const canvas = document.createElement('canvas');
- canvas.width = 256; canvas.height = 256;
- const c = canvas.getContext('2d');
- if (c) {
- c.strokeStyle = '#102b30'; c.fillStyle = '#102b30';
- c.lineWidth = 12; c.lineJoin = 'round';
- c.beginPath(); c.moveTo(65, 40); c.lineTo(82, 40); c.lineTo(99, 110); c.lineTo(170, 110); c.lineTo(186, 58); c.lineTo(89, 58); c.stroke();
- for (const x of [111, 162]) {c.beginPath(); c.arc(x, 130, 9, 0, Math.PI * 2); c.fill();}
- c.textAlign = 'center'; c.font = 'bold 34px sans-serif';
- c.fillText('ROBOT', 128, 181); c.fillText('MARKET', 128, 218);
- shape(torso, new THREE.PlaneGeometry(.31, .31), new THREE.MeshBasicMaterial({map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false}), [0, .99, .224]);
- }
- }
- const legs = [-1, 1].map(side => {
- const hip = new THREE.Group(); hip.position.set(side * .15, .65, 0); root.add(hip);
- sphere(hip, .09, joint, [0, 0, 0]);
- shape(hip, new THREE.CapsuleGeometry(.095, .12, 4, 12), white, [0, -.12, 0]);
- const knee = new THREE.Group(); knee.position.y = -.25; hip.add(knee);
- sphere(knee, .105, teal, [0, 0, .035]);
- shape(knee, new THREE.CapsuleGeometry(.10, .12, 4, 12), white, [0, -.13, 0]);
- const foot = sphere(knee, .13, teal, [0, -.29, .07], [1, .65, 1.6]);
- sphere(knee, .13, joint, [0, -.34, .07], [1.06, .22, 1.65]);
- return {hip, knee, foot};
- });
- const arms = [-1, 1].map(() => ({
- upper: segment(group, null, null, .09, teal),
- lower: segment(group, null, null, .085, white),
- hand: sphere(group, .09, joint, [0, 0, 0], [1, 1.1, 1])
- }));
- return {root, torso, head, legs, arms, scale, z, eyes};
+ 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 ? .69 : 1.05, root = new THREE.Group();
- root.position.z = z;
- group.add(root);
- const torso = new THREE.Group();
- root.add(torso);
- const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474');
- // Tapered jacket, hips and neck replace the single rounded toy-like body.
- shape(torso, new THREE.CylinderGeometry(.255 * scale, .22 * scale, .62 * scale, 16), cloth, [0, 1.2 * scale, 0]).scale.z = .72;
- sphere(torso, .23, pants, [0, .88 * scale, 0], [scale, .55 * scale, .72 * scale]);
- shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]);
- const seam = shape(torso, new THREE.BoxGeometry(.014 * scale, .5 * scale, .014 * scale), mat('#bda77f'), [0, 1.2 * scale, .19 * scale]);
- for (const side of [-1, 1]) {
- const collar = shape(torso, new THREE.BoxGeometry(.12 * scale, .09 * scale, .025 * scale), cloth, [side * .07 * scale, 1.49 * scale, .15 * scale]);
- collar.rotation.z = side * .4;
- }
- const head = new THREE.Group();
- head.position.y = 1.77 * scale;
- torso.add(head);
- head.scale.setScalar(isChild ? .9 : .77);
- const eyes = [];
- sphere(head, .27 * scale, skin, [0, 0, 0]);
- sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]);
- sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]);
- for (const side of [-1, 1]) {
- sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]);
- sphere(head, .037 * scale, mat('#f6f1e7'), [side * .09 * scale, .03 * scale, .241 * scale], [1, .65, .5]);
- const eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]);
- eyes.push(eye);
- const brow = shape(head, new THREE.BoxGeometry(.075 * scale, .015 * scale, .015 * scale), hair, [side * .09 * scale, .087 * scale, .237 * scale]);
- brow.rotation.z = side * -.08;
- sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]);
- }
- const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]);
- smile.rotation.z = Math.PI;
- if (isChild) {
- sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]);
- shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]);
- } else {
- sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]);
- }
- const legs = [-1, 1].map(side => {
- const hip = new THREE.Group();
- hip.position.set(side * .15 * scale, .83 * scale, 0);
- root.add(hip);
- shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
- const knee = new THREE.Group();
- knee.position.y = -.34 * scale;
- hip.add(knee);
- shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
- const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]);
- foot.scale.set(.85, .65, 1.5);
- return {hip, knee, foot};
- });
- const arms = [-1, 1].map(() => ({
- upper: segment(group, null, null, .072 * scale, cloth),
- lower: segment(group, null, null, .061 * scale, skin),
- hand: sphere(group, .075 * scale, skin, [0, 0, 0])
- }));
- return {root, torso, head, legs, arms, scale, z, eyes};
+ 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,
+ ]);
+ const seam = 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 father = robotOnly ? null : makePerson(false, -.45), child = robotOnly ? null : makePerson(true, .45);
- const robot = robotOnly ? makeRobot(0) : null;
- const actors = robotOnly ? [robot] : [father, child];
-
- // The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
- // `wave` raises the robot's right hand, `point` stretches it forward.
- function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, wave = false, point = false, dt = 1 / 60}) {
- actors.forEach((actor, index) => {
- const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
- actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend);
- // The robot hovers; it bobs faster while it is speaking.
- const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0;
- actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : hover, isRobot ? 1 : blend);
- actor.root.position.z = finished ? 0 : actor.z;
- actor.root.rotation.y = facing;
- const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
- actor.torso.position.y = bob;
- actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0;
- const blinking = Math.sin(time * 1.3 + index) > .993;
- actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
- actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06;
- actor.torso.rotation.x = delivery && actor === child ? .22 : 0;
- actor.legs.forEach((leg, i) => {
- const phase = gait + i * Math.PI;
- const stride = isRobot ? .08 : .34;
- leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
- leg.foot.rotation.x = moving ? Math.sin(phase + .5) * .12 : 0;
- leg.knee.rotation.x = finished ? 2.5 : isRobot ? .25 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
- });
- });
- group.updateMatrixWorld(true);
- const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
- const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, (actor === robot ? 1.12 : 1.43) * actor.scale, 0));
- // With characters facing +X, father's left and child's right hands meet.
- const contact = new THREE.Vector3(0, 1.06, 0);
- actors.forEach((actor, index) => actor.arms.forEach((arm, i) => {
- const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1;
- const a = shoulder(actor, side);
- let target;
- if (inside && !shopping && !finished) target = contact.clone();
- else if (actor === child && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
- else if (actor === robot && side === 1 && wave) target = toFamily(actor, new THREE.Vector3(.42, 1.62 + Math.sin(time * 9) * .07, .12));
- else if (actor === robot && side === 1 && point) target = toFamily(actor, new THREE.Vector3(.3, 1.25, .62));
- else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
- const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
- updateSegment(arm.upper, a, elbow);
- updateSegment(arm.lower, elbow, target);
- arm.hand.position.copy(target);
- }));
+ 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;
+ if (isChild) {
+ sphere(head, 0.29 * scale, mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]);
+ shape(head, new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale), mat('#e8b857'), [
+ 0,
+ 0.13 * scale,
+ 0.12 * scale,
+ ]);
+ } else {
+ sphere(torso, 0.22, mat('#a77950'), [0, 1.2 * scale, -0.24 * scale], [1, 1.2, 0.5]);
+ }
+ const legs = [-1, 1].map((side) => {
+ const hip = new THREE.Group();
+ hip.position.set(side * 0.15 * scale, 0.83 * scale, 0);
+ root.add(hip);
+ shape(hip, new THREE.CapsuleGeometry(0.09 * scale, 0.23 * scale, 4, 10), pants, [
+ 0,
+ -0.16 * scale,
+ 0,
+ ]);
+ const knee = new THREE.Group();
+ knee.position.y = -0.34 * scale;
+ hip.add(knee);
+ shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [
+ 0,
+ -0.16 * scale,
+ 0,
+ ]);
+ const foot = shape(knee, new THREE.SphereGeometry(0.12 * scale, 12, 8), shoes, [
+ 0,
+ -0.32 * scale,
+ 0.065 * scale,
+ ]);
+ foot.scale.set(0.85, 0.65, 1.5);
+ return { hip, knee, foot };
+ });
+ const arms = [-1, 1].map(() => ({
+ upper: segment(group, null, null, 0.072 * scale, cloth),
+ lower: segment(group, null, null, 0.061 * scale, skin),
+ hand: sphere(group, 0.075 * scale, skin, [0, 0, 0]),
+ }));
+ return { root, torso, head, legs, arms, scale, z, eyes };
+ }
- update({time: 0, gait: 0, moving: false});
- return {group, update};
+ const father = robotOnly ? null : makePerson(false, -0.45),
+ child = robotOnly ? null : makePerson(true, 0.45);
+ const robot = robotOnly ? makeRobot(0) : null;
+ const actors = robotOnly ? [robot] : [father, child];
+
+ // The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
+ // `wave` raises the robot's right hand, `point` stretches it forward.
+ function update({
+ time,
+ gait,
+ moving,
+ facing = Math.PI / 2,
+ shopping,
+ delivery,
+ finished,
+ talking = false,
+ wave = false,
+ point = false,
+ dt = 1 / 60,
+ }) {
+ actors.forEach((actor, index) => {
+ const blend = 1 - Math.exp(-dt * 7),
+ isRobot = actor === robot;
+ actor.root.position.x = THREE.MathUtils.lerp(
+ actor.root.position.x,
+ finished ? (index === 0 ? -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, index) =>
+ actor.arms.forEach((arm, i) => {
+ const side = i === 0 ? -1 : 1,
+ inside = actor === father ? side === -1 : actor === child && side === 1;
+ const a = shoulder(actor, side);
+ let target;
+ if (inside && !shopping && !finished) target = contact.clone();
+ else if (actor === child && shopping && side === -1)
+ target = new THREE.Vector3(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);
+ }),
+ );
+ }
+
+ update({ time: 0, gait: 0, moving: false });
+ return { group, update };
}
diff --git a/src/city.js b/src/city.js
index f72bb48..6ff7f0e 100644
--- a/src/city.js
+++ b/src/city.js
@@ -1,469 +1,688 @@
import * as THREE from 'three';
-import {nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
-import {blocked, insidePolygon} from './collision.js';
+import { nearestSegment, DRIVABLE_MAX_TYPE } from './routing.js';
+import { blocked, insidePolygon } from './collision.js';
// Road widths in metres per OSM highway type. Dual carriageways are two separate one-way ways.
-const WIDTH = {trunk: 11, primary: 10, secondary: 9, tertiary: 8, unclassified: 6.5, residential: 6, living_street: 5, service: 4, pedestrian: 4, footway: 2.4, cycleway: 2, path: 1.6, track: 3, steps: 2.2};
-const BUCKET = {trunk: 'major', primary: 'major', secondary: 'major', tertiary: 'minor', unclassified: 'minor', residential: 'minor', living_street: 'minor', service: 'minor', pedestrian: 'paved', footway: 'paved', cycleway: 'paved', steps: 'paved', path: 'dirt', track: 'dirt'};
+const WIDTH = {
+ trunk: 11,
+ primary: 10,
+ secondary: 9,
+ tertiary: 8,
+ unclassified: 6.5,
+ residential: 6,
+ living_street: 5,
+ service: 4,
+ pedestrian: 4,
+ footway: 2.4,
+ cycleway: 2,
+ path: 1.6,
+ track: 3,
+ steps: 2.2,
+};
+const BUCKET = {
+ trunk: 'major',
+ primary: 'major',
+ secondary: 'major',
+ tertiary: 'minor',
+ unclassified: 'minor',
+ residential: 'minor',
+ living_street: 'minor',
+ service: 'minor',
+ pedestrian: 'paved',
+ footway: 'paved',
+ cycleway: 'paved',
+ steps: 'paved',
+ path: 'dirt',
+ track: 'dirt',
+};
// Ground layers: height above the ground plane and polygon offset, so overlaps never flicker.
-const LAYER = {industrial: [.006, 1], farmland: [.008, 1], campus: [.01, 1], park: [.02, 2], grass: [.025, 3], forest: [.025, 3], pitch: [.03, 4], water: [.035, 5], sidewalk: [.04, 6], dirt: [.045, 7], paved: [.05, 8], minor: [.06, 9], major: [.07, 10]};
-const AREA_COLOR = {campus: '#d8ccb2', park: '#7a9a55', grass: '#8aa660', forest: '#5d7f47', pitch: '#5f9a58', water: '#4f93a8', industrial: '#bdb6a6', farmland: '#a8a46e'};
-const BUILDING_COLORS = ['#dccbaa', '#e7dcc4', '#c9a585', '#bdb4a3', '#ece6da', '#d2b48f', '#b7957a'].map(c => new THREE.Color(c));
+const LAYER = {
+ industrial: [0.006, 1],
+ farmland: [0.008, 1],
+ campus: [0.01, 1],
+ park: [0.02, 2],
+ grass: [0.025, 3],
+ forest: [0.025, 3],
+ pitch: [0.03, 4],
+ water: [0.035, 5],
+ sidewalk: [0.04, 6],
+ dirt: [0.045, 7],
+ paved: [0.05, 8],
+ minor: [0.06, 9],
+ major: [0.07, 10],
+};
+const AREA_COLOR = {
+ campus: '#d8ccb2',
+ park: '#7a9a55',
+ grass: '#8aa660',
+ forest: '#5d7f47',
+ pitch: '#5f9a58',
+ water: '#4f93a8',
+ industrial: '#bdb6a6',
+ farmland: '#a8a46e',
+};
+const BUILDING_COLORS = [
+ '#dccbaa',
+ '#e7dcc4',
+ '#c9a585',
+ '#bdb4a3',
+ '#ece6da',
+ '#d2b48f',
+ '#b7957a',
+].map((c) => new THREE.Color(c));
function canvasTexture(width, height, draw, repeat = true) {
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- draw(canvas.getContext('2d'), width, height);
- const texture = new THREE.CanvasTexture(canvas);
- texture.colorSpace = THREE.SRGBColorSpace;
- if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
- texture.anisotropy = 8;
- return texture;
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ draw(canvas.getContext('2d'), width, height);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
+ texture.anisotropy = 8;
+ return texture;
}
function noise(c, w, h, base, spread, count) {
- c.fillStyle = base;
- c.fillRect(0, 0, w, h);
- for (let i = 0; i < count; i++) {
- const v = Math.random() * spread - spread / 2;
- c.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`;
- c.fillRect(Math.random() * w, Math.random() * h, 2, 2);
- }
+ c.fillStyle = base;
+ c.fillRect(0, 0, w, h);
+ for (let i = 0; i < count; i++) {
+ const v = Math.random() * spread - spread / 2;
+ c.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`;
+ c.fillRect(Math.random() * w, Math.random() * h, 2, 2);
+ }
}
const textures = () => ({
- major: canvasTexture(128, 256, (c, w, h) => {
- noise(c, w, h, '#4a4d50', .12, 1800);
- c.fillStyle = '#e8e4d6';
- c.fillRect(6, 0, 4, h);
- c.fillRect(w - 10, 0, 4, h);
- c.fillRect(w / 2 - 2, 0, 4, h * .45);
- }),
- minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', .12, 1600)),
- paved: canvasTexture(128, 128, (c, w, h) => {
- noise(c, w, h, '#c4b597', .1, 900);
- c.strokeStyle = 'rgba(90,70,50,.25)';
- for (let i = 0; i <= 4; i++) {
- c.beginPath();
- c.moveTo(0, i * 32);
- c.lineTo(w, i * 32);
- c.moveTo(i * 32 + (i % 2) * 16, 0);
- c.lineTo(i * 32 + (i % 2) * 16, h);
- c.stroke();
- }
- }),
- sidewalk: canvasTexture(128, 128, (c, w, h) => {
- noise(c, w, h, '#b9b1a1', .1, 900);
- c.fillStyle = '#d9d4c8';
- c.fillRect(0, 0, 10, h);
- c.fillRect(w - 10, 0, 10, h);
- }),
- dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', .15, 500)),
- walls: canvasTexture(128, 128, (c, w, h) => {
- noise(c, w, h, '#f1ede4', .06, 600);
- const g = c.createLinearGradient(0, 30, 0, 96);
- g.addColorStop(0, '#7c95a3');
- g.addColorStop(1, '#3b4b55');
- c.fillStyle = '#8e8679';
- c.fillRect(28, 26, 72, 70);
- c.fillStyle = g;
- c.fillRect(32, 30, 64, 62);
- c.fillStyle = 'rgba(255,255,255,.18)';
- c.fillRect(34, 32, 18, 58);
- c.fillStyle = '#8e8679';
- c.fillRect(62, 30, 4, 62);
- c.fillStyle = 'rgba(0,0,0,.12)';
- c.fillRect(0, 118, w, 10);
- })
+ major: canvasTexture(128, 256, (c, w, h) => {
+ noise(c, w, h, '#4a4d50', 0.12, 1800);
+ c.fillStyle = '#e8e4d6';
+ c.fillRect(6, 0, 4, h);
+ c.fillRect(w - 10, 0, 4, h);
+ c.fillRect(w / 2 - 2, 0, 4, h * 0.45);
+ }),
+ minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', 0.12, 1600)),
+ paved: canvasTexture(128, 128, (c, w, h) => {
+ noise(c, w, h, '#c4b597', 0.1, 900);
+ c.strokeStyle = 'rgba(90,70,50,.25)';
+ for (let i = 0; i <= 4; i++) {
+ c.beginPath();
+ c.moveTo(0, i * 32);
+ c.lineTo(w, i * 32);
+ c.moveTo(i * 32 + (i % 2) * 16, 0);
+ c.lineTo(i * 32 + (i % 2) * 16, h);
+ c.stroke();
+ }
+ }),
+ sidewalk: canvasTexture(128, 128, (c, w, h) => {
+ noise(c, w, h, '#b9b1a1', 0.1, 900);
+ c.fillStyle = '#d9d4c8';
+ c.fillRect(0, 0, 10, h);
+ c.fillRect(w - 10, 0, 10, h);
+ }),
+ dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', 0.15, 500)),
+ walls: canvasTexture(128, 128, (c, w, h) => {
+ noise(c, w, h, '#f1ede4', 0.06, 600);
+ const g = c.createLinearGradient(0, 30, 0, 96);
+ g.addColorStop(0, '#7c95a3');
+ g.addColorStop(1, '#3b4b55');
+ c.fillStyle = '#8e8679';
+ c.fillRect(28, 26, 72, 70);
+ c.fillStyle = g;
+ c.fillRect(32, 30, 64, 62);
+ c.fillStyle = 'rgba(255,255,255,.18)';
+ c.fillRect(34, 32, 18, 58);
+ c.fillStyle = '#8e8679';
+ c.fillRect(62, 30, 4, 62);
+ c.fillStyle = 'rgba(0,0,0,.12)';
+ c.fillRect(0, 118, w, 10);
+ }),
});
// Pushes a strip of quads along a polyline into a geometry buffer.
function ribbon(out, pts, width, y, repeat) {
- const n = pts.length / 2;
- if (n < 2) return;
- const base = out.pos.length / 3, half = width / 2;
- let along = 0;
- for (let i = 0; i < n; i++) {
- const x = pts[i * 2], z = pts[i * 2 + 1];
- if (i) along += Math.hypot(x - pts[i * 2 - 2], z - pts[i * 2 - 1]);
- const seg = (a, b) => {
- const dx = pts[b * 2] - pts[a * 2], dz = pts[b * 2 + 1] - pts[a * 2 + 1], len = Math.hypot(dx, dz) || 1;
- return [-dz / len, dx / len];
- };
- const n1 = seg(Math.max(0, i - 1), Math.max(1, i)), n2 = seg(Math.min(n - 2, i), Math.min(n - 1, i + 1));
- let nx = n1[0] + n2[0], nz = n1[1] + n2[1];
- const len = Math.hypot(nx, nz) || 1;
- nx /= len;
- nz /= len;
- const miter = half / Math.max(.45, nx * n1[0] + nz * n1[1]);
- out.pos.push(x + nx * miter, y, z + nz * miter, x - nx * miter, y, z - nz * miter);
- out.uv.push(0, along / repeat, 1, along / repeat);
- if (i) {
- const a = base + (i - 1) * 2;
- // Wound so the strip faces up (+y) and survives back-face culling.
- out.idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
- }
+ const n = pts.length / 2;
+ if (n < 2) return;
+ const base = out.pos.length / 3,
+ half = width / 2;
+ let along = 0;
+ for (let i = 0; i < n; i++) {
+ const x = pts[i * 2],
+ z = pts[i * 2 + 1];
+ if (i) along += Math.hypot(x - pts[i * 2 - 2], z - pts[i * 2 - 1]);
+ const seg = (a, b) => {
+ const dx = pts[b * 2] - pts[a * 2],
+ dz = pts[b * 2 + 1] - pts[a * 2 + 1],
+ len = Math.hypot(dx, dz) || 1;
+ return [-dz / len, dx / len];
+ };
+ const n1 = seg(Math.max(0, i - 1), Math.max(1, i)),
+ n2 = seg(Math.min(n - 2, i), Math.min(n - 1, i + 1));
+ let nx = n1[0] + n2[0],
+ nz = n1[1] + n2[1];
+ const len = Math.hypot(nx, nz) || 1;
+ nx /= len;
+ nz /= len;
+ const miter = half / Math.max(0.45, nx * n1[0] + nz * n1[1]);
+ out.pos.push(x + nx * miter, y, z + nz * miter, x - nx * miter, y, z - nz * miter);
+ out.uv.push(0, along / repeat, 1, along / repeat);
+ if (i) {
+ const a = base + (i - 1) * 2;
+ // Wound so the strip faces up (+y) and survives back-face culling.
+ out.idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
}
+ }
}
function disc(out, x, z, radius, y) {
- const base = out.pos.length / 3;
- out.pos.push(x, y, z);
- out.uv.push(.25, 0);
- for (let i = 0; i <= 10; i++) {
- const a = i / 10 * Math.PI * 2;
- out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius);
- out.uv.push(.25, 0);
- if (i) out.idx.push(base, base + i + 1, base + i);
- }
+ const base = out.pos.length / 3;
+ out.pos.push(x, y, z);
+ out.uv.push(0.25, 0);
+ for (let i = 0; i <= 10; i++) {
+ const a = (i / 10) * Math.PI * 2;
+ out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius);
+ out.uv.push(0.25, 0);
+ if (i) out.idx.push(base, base + i + 1, base + i);
+ }
}
function polygonTriangles(pts) {
- const contour = [];
- for (let i = 0; i < pts.length; i += 2) contour.push(new THREE.Vector2(pts[i], pts[i + 1]));
- try {
- return THREE.ShapeUtils.triangulateShape(contour, []);
- } catch {
- return [];
- }
+ const contour = [];
+ for (let i = 0; i < pts.length; i += 2) contour.push(new THREE.Vector2(pts[i], pts[i + 1]));
+ try {
+ return THREE.ShapeUtils.triangulateShape(contour, []);
+ } catch {
+ return [];
+ }
}
// Flat polygon facing up; triangles are flipped where needed so normals point to the sky.
function flatPolygon(out, pts, y, uvScale = 0, color = null) {
- const base = out.pos.length / 3;
- for (let i = 0; i < pts.length; i += 2) {
- out.pos.push(pts[i], y, pts[i + 1]);
- out.uv?.push(pts[i] * uvScale, pts[i + 1] * uvScale);
- if (color) out.col.push(color.r, color.g, color.b);
- }
- for (const [a, b, c] of polygonTriangles(pts)) {
- const cross = (pts[b * 2 + 1] - pts[a * 2 + 1]) * (pts[c * 2] - pts[a * 2]) - (pts[b * 2] - pts[a * 2]) * (pts[c * 2 + 1] - pts[a * 2 + 1]);
- out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b]));
- }
+ const base = out.pos.length / 3;
+ for (let i = 0; i < pts.length; i += 2) {
+ out.pos.push(pts[i], y, pts[i + 1]);
+ out.uv?.push(pts[i] * uvScale, pts[i + 1] * uvScale);
+ if (color) out.col.push(color.r, color.g, color.b);
+ }
+ for (const [a, b, c] of polygonTriangles(pts)) {
+ const cross =
+ (pts[b * 2 + 1] - pts[a * 2 + 1]) * (pts[c * 2] - pts[a * 2]) -
+ (pts[b * 2] - pts[a * 2]) * (pts[c * 2 + 1] - pts[a * 2 + 1]);
+ out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b]));
+ }
}
function toMesh(out, material, normals = 'up') {
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.Float32BufferAttribute(out.pos, 3));
- if (out.uv?.length) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2));
- if (out.col?.length) geometry.setAttribute('color', new THREE.Float32BufferAttribute(out.col, 3));
- geometry.setIndex(out.idx);
- if (normals === 'up') geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(out.pos.length).map((_, i) => i % 3 === 1 ? 1 : 0), 3));
- else geometry.computeVertexNormals();
- geometry.computeBoundingSphere();
- const mesh = new THREE.Mesh(geometry, material);
- mesh.receiveShadow = true;
- return mesh;
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(out.pos, 3));
+ if (out.uv?.length) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2));
+ if (out.col?.length) geometry.setAttribute('color', new THREE.Float32BufferAttribute(out.col, 3));
+ geometry.setIndex(out.idx);
+ if (normals === 'up')
+ geometry.setAttribute(
+ 'normal',
+ new THREE.Float32BufferAttribute(
+ new Float32Array(out.pos.length).map((_, i) => (i % 3 === 1 ? 1 : 0)),
+ 3,
+ ),
+ );
+ else geometry.computeVertexNormals();
+ geometry.computeBoundingSphere();
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.receiveShadow = true;
+ return mesh;
}
-const groundMaterial = (options, layer) => new THREE.MeshStandardMaterial({
- roughness: .95, ...options,
- polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2
-});
+const groundMaterial = (options, layer) =>
+ new THREE.MeshStandardMaterial({
+ roughness: 0.95,
+ ...options,
+ polygonOffset: true,
+ polygonOffsetFactor: -layer,
+ polygonOffsetUnits: -layer * 2,
+ });
function seeded(seed) {
- return () => {
- seed = (seed * 1664525 + 1013904223) >>> 0;
- return seed / 4294967296;
- };
+ return () => {
+ seed = (seed * 1664525 + 1013904223) >>> 0;
+ return seed / 4294967296;
+ };
}
function signedArea(pts) {
- let a = 0;
- for (let i = 0; i < pts.length; i += 2) {
- const j = (i + 2) % pts.length;
- a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
- }
- return a / 2;
+ let a = 0;
+ for (let i = 0; i < pts.length; i += 2) {
+ const j = (i + 2) % pts.length;
+ a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
+ }
+ return a / 2;
}
// The world is split into 2 km chunks (buildings: 500 m blocks); meshes out of view range are hidden.
-const CHUNK = 2000, BLOCK = 500;
+const CHUNK = 2000,
+ BLOCK = 500;
// With satellite imagery the photo shows the real roads and land, so the drawn roads and areas are
// left out; `extraBuildings` are footprints beyond OSM (Microsoft's, see buildings-data.js).
-export function createCity(scene, data, {collision, segments, extraBuildings = [], satellite = false, lowQuality = false}) {
- const VIEW_RANGE = lowQuality ? 1500 : 2400;
- const tex = textures(), group = new THREE.Group();
- scene.add(group);
- const random = seeded(1405);
- const chunked = [], buckets = new Map();
- const bucket = (name, x, z, make) => {
- const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
- if (!buckets.has(key)) buckets.set(key, {name, out: make()});
- return buckets.get(key).out;
- };
- const addChunked = mesh => {
- mesh.geometry.computeBoundingSphere();
- chunked.push(mesh);
- group.add(mesh);
- return mesh;
- };
+export function createCity(
+ scene,
+ data,
+ { collision, segments, extraBuildings = [], satellite = false, lowQuality = false },
+) {
+ const VIEW_RANGE = lowQuality ? 1500 : 2400;
+ const tex = textures(),
+ group = new THREE.Group();
+ scene.add(group);
+ const random = seeded(1405);
+ const chunked = [],
+ buckets = new Map();
+ const bucket = (name, x, z, make) => {
+ const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
+ if (!buckets.has(key)) buckets.set(key, { name, out: make() });
+ return buckets.get(key).out;
+ };
+ const addChunked = (mesh) => {
+ mesh.geometry.computeBoundingSphere();
+ chunked.push(mesh);
+ group.add(mesh);
+ return mesh;
+ };
- // Open ground that follows the camera, so the horizon never shows an edge.
- const ground = new THREE.Mesh(new THREE.PlaneGeometry(14000, 14000), groundMaterial({color: '#c6baa0'}, 0));
- ground.rotation.x = -Math.PI / 2;
- ground.position.y = -.02;
- ground.receiveShadow = true;
- group.add(ground);
+ // Open ground that follows the camera, so the horizon never shows an edge.
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(14000, 14000),
+ groundMaterial({ color: '#c6baa0' }, 0),
+ );
+ ground.rotation.x = -Math.PI / 2;
+ ground.position.y = -0.02;
+ ground.receiveShadow = true;
+ group.add(ground);
- for (const area of satellite ? [] : data.areas) {
- const kind = data.areaKinds[area[0]];
- flatPolygon(bucket(`area:${kind}`, area[1], area[2], () => ({pos: [], idx: []})), area.slice(1), LAYER[kind][0]);
- }
- const areaMaterials = {};
- for (const kind of data.areaKinds) areaMaterials[kind] = groundMaterial({color: AREA_COLOR[kind], roughness: kind === 'water' ? .25 : .95, metalness: kind === 'water' ? .2 : 0}, LAYER[kind][1]);
+ for (const area of satellite ? [] : data.areas) {
+ const kind = data.areaKinds[area[0]];
+ flatPolygon(
+ bucket(`area:${kind}`, area[1], area[2], () => ({ pos: [], idx: [] })),
+ area.slice(1),
+ LAYER[kind][0],
+ );
+ }
+ const areaMaterials = {};
+ for (const kind of data.areaKinds)
+ areaMaterials[kind] = groundMaterial(
+ {
+ color: AREA_COLOR[kind],
+ roughness: kind === 'water' ? 0.25 : 0.95,
+ metalness: kind === 'water' ? 0.2 : 0,
+ },
+ LAYER[kind][1],
+ );
- const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({pos: [], uv: [], idx: []}));
- for (const r of satellite ? [] : data.roads) {
- const type = data.roadTypes[r[0]], kind = BUCKET[type], pts = r.slice(2), width = WIDTH[type];
- ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8);
- if (r[0] <= DRIVABLE_MAX_TYPE) {
- if (type !== 'service') ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6);
- disc(road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]);
- disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
- }
+ const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({ pos: [], uv: [], idx: [] }));
+ for (const r of satellite ? [] : data.roads) {
+ const type = data.roadTypes[r[0]],
+ kind = BUCKET[type],
+ pts = r.slice(2),
+ width = WIDTH[type];
+ ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8);
+ if (r[0] <= DRIVABLE_MAX_TYPE) {
+ if (type !== 'service')
+ ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6);
+ disc(road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]);
+ disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
}
- const roadMaterials = {};
- for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) roadMaterials[kind] = groundMaterial({map: tex[kind]}, LAYER[kind][1]);
+ }
+ const roadMaterials = {};
+ for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk'])
+ roadMaterials[kind] = groundMaterial({ map: tex[kind] }, LAYER[kind][1]);
- // Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs. There are
- // ~145,000 of them (OSM plus Microsoft footprints), so each 500 m block is built only when the
- // camera first comes near it, one block per frame.
- const wallMaterial = new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85});
- const roofMaterial = new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9});
- const blocks = new Map();
- for (const building of [...data.buildings, ...extraBuildings]) {
- const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`;
- if (!blocks.has(key)) {
- const [bx, bz] = key.split(',').map(Number);
- blocks.set(key, {x: (bx + .5) * BLOCK, z: (bz + .5) * BLOCK, buildings: [], meshes: null});
- }
- blocks.get(key).buildings.push(building);
+ // Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs. There are
+ // ~145,000 of them (OSM plus Microsoft footprints), so each 500 m block is built only when the
+ // camera first comes near it, one block per frame.
+ const wallMaterial = new THREE.MeshStandardMaterial({
+ map: tex.walls,
+ vertexColors: true,
+ roughness: 0.85,
+ });
+ const roofMaterial = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.9 });
+ const blocks = new Map();
+ for (const building of [...data.buildings, ...extraBuildings]) {
+ const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`;
+ if (!blocks.has(key)) {
+ const [bx, bz] = key.split(',').map(Number);
+ blocks.set(key, {
+ x: (bx + 0.5) * BLOCK,
+ z: (bz + 0.5) * BLOCK,
+ buildings: [],
+ meshes: null,
+ });
}
- const pendingBlocks = [...blocks.values()];
+ blocks.get(key).buildings.push(building);
+ }
+ const pendingBlocks = [...blocks.values()];
- function buildBlock(block) {
- const walls = {pos: [], uv: [], col: [], idx: []}, roofs = {pos: [], col: [], idx: []};
- for (const building of block.buildings) {
- const height = building[0];
- let pts = building.slice(1);
- if (signedArea(pts) > 0) {
- const reversed = [];
- for (let i = pts.length - 2; i >= 0; i -= 2) reversed.push(pts[i], pts[i + 1]);
- pts = reversed;
- }
- // Colour picked from the position, so a block looks the same every time it is built.
- const shade = Math.abs(Math.sin(pts[0] * 12.9898 + pts[1] * 78.233) * 43758.5453) % 1;
- const color = BUILDING_COLORS[Math.floor(shade * BUILDING_COLORS.length)].clone().multiplyScalar(.9 + (shade * 7 % 1) * .15);
- let along = 0;
- for (let i = 0; i < pts.length; i += 2) {
- const j = (i + 2) % pts.length, ax = pts[i], az = pts[i + 1], bx = pts[j], bz = pts[j + 1];
- const len = Math.hypot(bx - ax, bz - az), base = walls.pos.length / 3;
- walls.pos.push(ax, 0, az, bx, 0, bz, bx, height, bz, ax, height, az);
- walls.uv.push(along / 3.6, 0, (along + len) / 3.6, 0, (along + len) / 3.6, height / 3.2, along / 3.6, height / 3.2);
- for (let k = 0; k < 4; k++) walls.col.push(color.r, color.g, color.b);
- walls.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
- along += len;
- }
- flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(.78));
- }
- block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)];
- for (const mesh of block.meshes) {
- mesh.castShadow = true;
- addChunked(mesh);
- }
+ function buildBlock(block) {
+ const walls = { pos: [], uv: [], col: [], idx: [] },
+ roofs = { pos: [], col: [], idx: [] };
+ for (const building of block.buildings) {
+ const height = building[0];
+ let pts = building.slice(1);
+ if (signedArea(pts) > 0) {
+ const reversed = [];
+ for (let i = pts.length - 2; i >= 0; i -= 2) reversed.push(pts[i], pts[i + 1]);
+ pts = reversed;
+ }
+ // Colour picked from the position, so a block looks the same every time it is built.
+ const shade = Math.abs(Math.sin(pts[0] * 12.9898 + pts[1] * 78.233) * 43758.5453) % 1;
+ const color = BUILDING_COLORS[Math.floor(shade * BUILDING_COLORS.length)]
+ .clone()
+ .multiplyScalar(0.9 + ((shade * 7) % 1) * 0.15);
+ let along = 0;
+ for (let i = 0; i < pts.length; i += 2) {
+ const j = (i + 2) % pts.length,
+ ax = pts[i],
+ az = pts[i + 1],
+ bx = pts[j],
+ bz = pts[j + 1];
+ const len = Math.hypot(bx - ax, bz - az),
+ base = walls.pos.length / 3;
+ walls.pos.push(ax, 0, az, bx, 0, bz, bx, height, bz, ax, height, az);
+ walls.uv.push(
+ along / 3.6,
+ 0,
+ (along + len) / 3.6,
+ 0,
+ (along + len) / 3.6,
+ height / 3.2,
+ along / 3.6,
+ height / 3.2,
+ );
+ for (let k = 0; k < 4; k++) walls.col.push(color.r, color.g, color.b);
+ walls.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
+ along += len;
+ }
+ flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(0.78));
}
+ block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)];
+ for (const mesh of block.meshes) {
+ mesh.castShadow = true;
+ addChunked(mesh);
+ }
+ }
- // Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings.
- const tanks = [], units = [];
- for (const building of data.buildings) {
- const height = building[0], pts = building.slice(1);
- let cx = 0, cz = 0;
- for (let i = 0; i < pts.length; i += 2) {
- cx += pts[i];
- cz += pts[i + 1];
- }
- cx /= pts.length / 2;
- cz /= pts.length / 2;
- if (insidePolygon(pts, cx, cz) && random() < .6) {
- (random() < .5 ? tanks : units).push([cx + (random() - .5) * 2, height, random() * Math.PI, cz + (random() - .5) * 2, 0]);
- }
+ // Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings.
+ const tanks = [],
+ units = [];
+ for (const building of data.buildings) {
+ const height = building[0],
+ pts = building.slice(1);
+ let cx = 0,
+ cz = 0;
+ for (let i = 0; i < pts.length; i += 2) {
+ cx += pts[i];
+ cz += pts[i + 1];
}
- for (const {name, out} of buckets.values()) {
- const [kind, sub] = name.split(':');
- addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub]) : toMesh(out, roadMaterials[sub]));
+ cx /= pts.length / 2;
+ cz /= pts.length / 2;
+ if (insidePolygon(pts, cx, cz) && random() < 0.6) {
+ (random() < 0.5 ? tanks : units).push([
+ cx + (random() - 0.5) * 2,
+ height,
+ random() * Math.PI,
+ cz + (random() - 0.5) * 2,
+ 0,
+ ]);
}
- const dummy = new THREE.Object3D();
- // One instanced mesh per chunk, so small props are culled with the rest of their chunk.
- // Each item is an array whose first entry is x and second-to-last entry is z.
- const instanced = (geometry, material, items, place) => {
- const byChunk = new Map();
- items.forEach((item, i) => {
- const key = `${Math.floor(item[0] / CHUNK)},${Math.floor(item.at(-2) / CHUNK)}`;
- if (!byChunk.has(key)) byChunk.set(key, []);
- byChunk.get(key).push([item, i]);
- });
- const meshes = [];
- for (const list of byChunk.values()) {
- const mesh = new THREE.InstancedMesh(geometry, material, list.length);
- list.forEach(([item], i) => {
- place(item);
- dummy.updateMatrix();
- mesh.setMatrixAt(i, dummy.matrix);
- });
- mesh.castShadow = mesh.receiveShadow = true;
- mesh.computeBoundingSphere();
- mesh.userData.items = list.map(([, i]) => i);
- chunked.push(mesh);
- group.add(mesh);
- meshes.push(mesh);
- }
- return meshes;
- };
- instanced(new THREE.CylinderGeometry(.55, .55, 1.1, 10), new THREE.MeshStandardMaterial({color: '#d8d8d2', metalness: .4, roughness: .4}), tanks, ([x, y, , z]) => {
- dummy.position.set(x, y + .55, z);
- dummy.rotation.set(0, 0, 0);
- dummy.scale.setScalar(1);
- });
- instanced(new THREE.BoxGeometry(1, .8, .9), new THREE.MeshStandardMaterial({color: '#a9aaa4', roughness: .6}), units, ([x, y, r, z]) => {
- dummy.position.set(x, y + .4, z);
- dummy.rotation.set(0, r, 0);
- dummy.scale.setScalar(1);
+ }
+ for (const { name, out } of buckets.values()) {
+ const [kind, sub] = name.split(':');
+ addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub]) : toMesh(out, roadMaterials[sub]));
+ }
+ const dummy = new THREE.Object3D();
+ // One instanced mesh per chunk, so small props are culled with the rest of their chunk.
+ // Each item is an array whose first entry is x and second-to-last entry is z.
+ const instanced = (geometry, material, items, place) => {
+ const byChunk = new Map();
+ items.forEach((item, i) => {
+ const key = `${Math.floor(item[0] / CHUNK)},${Math.floor(item.at(-2) / CHUNK)}`;
+ if (!byChunk.has(key)) byChunk.set(key, []);
+ byChunk.get(key).push([item, i]);
});
+ const meshes = [];
+ for (const list of byChunk.values()) {
+ const mesh = new THREE.InstancedMesh(geometry, material, list.length);
+ list.forEach(([item], i) => {
+ place(item);
+ dummy.updateMatrix();
+ mesh.setMatrixAt(i, dummy.matrix);
+ });
+ mesh.castShadow = mesh.receiveShadow = true;
+ mesh.computeBoundingSphere();
+ mesh.userData.items = list.map(([, i]) => i);
+ chunked.push(mesh);
+ group.add(mesh);
+ meshes.push(mesh);
+ }
+ return meshes;
+ };
+ instanced(
+ new THREE.CylinderGeometry(0.55, 0.55, 1.1, 10),
+ new THREE.MeshStandardMaterial({ color: '#d8d8d2', metalness: 0.4, roughness: 0.4 }),
+ tanks,
+ ([x, y, , z]) => {
+ dummy.position.set(x, y + 0.55, z);
+ dummy.rotation.set(0, 0, 0);
+ dummy.scale.setScalar(1);
+ },
+ );
+ instanced(
+ new THREE.BoxGeometry(1, 0.8, 0.9),
+ new THREE.MeshStandardMaterial({ color: '#a9aaa4', roughness: 0.6 }),
+ units,
+ ([x, y, r, z]) => {
+ dummy.position.set(x, y + 0.4, z);
+ dummy.rotation.set(0, r, 0);
+ dummy.scale.setScalar(1);
+ },
+ );
- // Trees fill parks and line the boulevards, kept off paths and out of buildings.
- const trees = [];
- const freeSpot = (x, z) => !blocked(collision, x, z) && !nearestSegment(segments, x, z, 3);
- for (const area of data.areas) {
- const kind = data.areaKinds[area[0]];
- if (!['park', 'forest', 'grass'].includes(kind)) continue;
- const pts = area.slice(1), step = kind === 'grass' ? 16 : 10;
- let ax = Infinity, az = Infinity, bx = -Infinity, bz = -Infinity;
- for (let i = 0; i < pts.length; i += 2) {
- ax = Math.min(ax, pts[i]);
- bx = Math.max(bx, pts[i]);
- az = Math.min(az, pts[i + 1]);
- bz = Math.max(bz, pts[i + 1]);
- }
- for (let x = ax; x < bx; x += step) for (let z = az; z < bz; z += step) {
- const tx = x + (random() - .5) * step * .8, tz = z + (random() - .5) * step * .8;
- if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) trees.push([tx, .8 + random() * .6, tz, 0]);
+ // Trees fill parks and line the boulevards, kept off paths and out of buildings.
+ const trees = [];
+ const freeSpot = (x, z) => !blocked(collision, x, z) && !nearestSegment(segments, x, z, 3);
+ for (const area of data.areas) {
+ const kind = data.areaKinds[area[0]];
+ if (!['park', 'forest', 'grass'].includes(kind)) continue;
+ const pts = area.slice(1),
+ step = kind === 'grass' ? 16 : 10;
+ let ax = Infinity,
+ az = Infinity,
+ bx = -Infinity,
+ bz = -Infinity;
+ for (let i = 0; i < pts.length; i += 2) {
+ ax = Math.min(ax, pts[i]);
+ bx = Math.max(bx, pts[i]);
+ az = Math.min(az, pts[i + 1]);
+ bz = Math.max(bz, pts[i + 1]);
+ }
+ for (let x = ax; x < bx; x += step)
+ for (let z = az; z < bz; z += step) {
+ const tx = x + (random() - 0.5) * step * 0.8,
+ tz = z + (random() - 0.5) * step * 0.8;
+ if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz))
+ trees.push([tx, 0.8 + random() * 0.6, tz, 0]);
+ }
+ }
+ for (const road of data.roads) {
+ const type = data.roadTypes[road[0]];
+ if (road[0] > 3) continue;
+ const offset = WIDTH[type] / 2 + 2.6;
+ for (let i = 4; i < road.length; i += 2) {
+ const ax = road[i - 2],
+ az = road[i - 1],
+ dx = road[i] - ax,
+ dz = road[i + 1] - az,
+ len = Math.hypot(dx, dz);
+ for (let d = random() * 12; d < len; d += 12)
+ for (const side of [-1, 1]) {
+ const tx = ax + (dx / len) * d - (dz / len) * offset * side,
+ tz = az + (dz / len) * d + (dx / len) * offset * side;
+ if (
+ !blocked(collision, tx, tz) &&
+ !nearestSegment(
+ segments,
+ tx,
+ tz,
+ WIDTH[type] / 2 + 1.5,
+ (s) => s[4] <= DRIVABLE_MAX_TYPE,
+ )
+ )
+ trees.push([tx, 0.7 + random() * 0.4, tz, 0]);
}
}
- for (const road of data.roads) {
- const type = data.roadTypes[road[0]];
- if (road[0] > 3) continue;
- const offset = WIDTH[type] / 2 + 2.6;
- for (let i = 4; i < road.length; i += 2) {
- const ax = road[i - 2], az = road[i - 1], dx = road[i] - ax, dz = road[i + 1] - az, len = Math.hypot(dx, dz);
- for (let d = random() * 12; d < len; d += 12) for (const side of [-1, 1]) {
- const tx = ax + dx / len * d - dz / len * offset * side, tz = az + dz / len * d + dx / len * offset * side;
- if (!blocked(collision, tx, tz) && !nearestSegment(segments, tx, tz, WIDTH[type] / 2 + 1.5, s => s[4] <= DRIVABLE_MAX_TYPE)) trees.push([tx, .7 + random() * .4, tz, 0]);
- }
- }
- }
- instanced(new THREE.CylinderGeometry(.14, .22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({color: '#6a5039', roughness: .9}), trees, ([x, s, z]) => {
- dummy.position.set(x, 1.3 * s, z);
- dummy.rotation.set(0, 0, 0);
- dummy.scale.setScalar(s);
- });
- const crowns = instanced(new THREE.IcosahedronGeometry(1.7, 0), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .85, flatShading: true}), trees, ([x, s, z]) => {
- dummy.position.set(x, 3.4 * s, z);
- dummy.rotation.set(0, x * 13 % 6, 0);
- dummy.scale.set(s, s * (1 + (x * 7 % 1) * .3), s);
- });
- const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map(c => new THREE.Color(c));
- for (const mesh of crowns) mesh.userData.items.forEach((tree, i) => mesh.setColorAt(i, crownColors[tree % crownColors.length]));
+ }
+ instanced(
+ new THREE.CylinderGeometry(0.14, 0.22, 2.6, 5, 1, true),
+ new THREE.MeshStandardMaterial({ color: '#6a5039', roughness: 0.9 }),
+ trees,
+ ([x, s, z]) => {
+ dummy.position.set(x, 1.3 * s, z);
+ dummy.rotation.set(0, 0, 0);
+ dummy.scale.setScalar(s);
+ },
+ );
+ const crowns = instanced(
+ new THREE.IcosahedronGeometry(1.7, 0),
+ new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.85, flatShading: true }),
+ trees,
+ ([x, s, z]) => {
+ dummy.position.set(x, 3.4 * s, z);
+ dummy.rotation.set(0, (x * 13) % 6, 0);
+ dummy.scale.set(s, s * (1 + ((x * 7) % 1) * 0.3), s);
+ },
+ );
+ const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map((c) => new THREE.Color(c));
+ for (const mesh of crowns)
+ mesh.userData.items.forEach((tree, i) =>
+ mesh.setColorAt(i, crownColors[tree % crownColors.length]),
+ );
- // Metro station entrances with the Mashhad Urban Railway "M" sign.
- const signTexture = canvasTexture(128, 128, (c, w, h) => {
- c.fillStyle = '#d2232a';
- c.beginPath();
- c.arc(w / 2, h / 2, 60, 0, Math.PI * 2);
- c.fill();
- c.fillStyle = '#fff';
- c.font = 'bold 84px sans-serif';
- c.textAlign = 'center';
- c.fillText('M', w / 2, h / 2 + 30);
- }, false);
- const canopy = new THREE.MeshStandardMaterial({color: '#59646b', metalness: .5, roughness: .4});
- const glass = new THREE.MeshStandardMaterial({color: '#9fc4cf', transparent: true, opacity: .45, roughness: .1});
- for (const label of data.labels.filter(l => l.kind === 'metro')) {
- const entrance = new THREE.Group();
- entrance.position.set(label.x, 0, label.z);
- const road = nearestSegment(segments, label.x, label.z, 40, s => s[4] <= DRIVABLE_MAX_TYPE);
- if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz);
- const box = (w, h, d, m, x, y, z) => {
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
- mesh.position.set(x, y, z);
- mesh.castShadow = mesh.receiveShadow = true;
- entrance.add(mesh);
- };
- box(4.4, .2, 7, canopy, 0, 3, 0);
- box(.15, 3, 7, glass, -2.1, 1.5, 0);
- box(.15, 3, 7, glass, 2.1, 1.5, 0);
- box(.25, 4.2, .25, canopy, 2.6, 2.1, 3.6);
- const sign = new THREE.Mesh(new THREE.CircleGeometry(.6, 24), new THREE.MeshBasicMaterial({map: signTexture}));
- sign.position.set(2.6, 4.4, 3.75);
- entrance.add(sign);
- const back = sign.clone();
- back.rotation.y = Math.PI;
- back.position.z = 3.45;
- entrance.add(back);
- group.add(entrance);
- }
-
- // A continuous ridge line around the horizon: the Binalood range high to the south-west,
- // the lower Hezar Masjed hills to the north-east. Hazy colours stand in for distance fog.
- const ridgePos = [], ridgeCol = [], ridgeIdx = [], segmentsAround = 240;
- const foot = new THREE.Color('#cfc5b0'), crest = new THREE.Color('#a39a8a');
- for (let i = 0; i <= segmentsAround; i++) {
- const a = i / segmentsAround * Math.PI * 2, x = Math.cos(a), z = Math.sin(a);
- // South-west is −x (west) and +z (south); peaks grow with how far this direction points there.
- const southWest = Math.max(0, (-x + z) / Math.SQRT2), rough = Math.sin(a * 7) * .5 + Math.sin(a * 17 + 1) * .3 + Math.sin(a * 41) * .15;
- const height = 90 + southWest ** 1.5 * 420 + rough * (60 + southWest * 120), r = 4300;
- ridgePos.push(x * r, -20, z * r, x * r, height, z * r);
- ridgeCol.push(foot.r, foot.g, foot.b, crest.r, crest.g, crest.b);
- if (i) {
- const b = (i - 1) * 2;
- ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
- }
- }
- const ridgeGeometry = new THREE.BufferGeometry();
- ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3));
- ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3));
- ridgeGeometry.setIndex(ridgeIdx);
- const ridge = new THREE.Mesh(ridgeGeometry, new THREE.MeshBasicMaterial({vertexColors: true, fog: false, side: THREE.DoubleSide}));
- group.add(ridge);
- let lastCheck = null;
- return {
- group,
- // Keeps the ground and horizon around the camera and shows only chunks within range.
- update(camera) {
- ground.position.set(camera.x, -.02, camera.z);
- ridge.position.set(camera.x, 0, camera.z);
- // Build the nearest unbuilt block in range, one per frame to avoid stutter.
- let next = null, nextDistance = VIEW_RANGE + BLOCK;
- for (const block of pendingBlocks) {
- const d = Math.hypot(block.x - camera.x, block.z - camera.z);
- if (d < nextDistance) {
- next = block;
- nextDistance = d;
- }
- }
- if (next) {
- pendingBlocks.splice(pendingBlocks.indexOf(next), 1);
- buildBlock(next);
- lastCheck = null;
- }
- if (lastCheck && Math.hypot(camera.x - lastCheck.x, camera.z - lastCheck.z) < 60) return;
- lastCheck = {x: camera.x, z: camera.z};
- for (const mesh of chunked) {
- const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere;
- mesh.visible = Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius < VIEW_RANGE;
- }
- }
+ // Metro station entrances with the Mashhad Urban Railway "M" sign.
+ const signTexture = canvasTexture(
+ 128,
+ 128,
+ (c, w, h) => {
+ c.fillStyle = '#d2232a';
+ c.beginPath();
+ c.arc(w / 2, h / 2, 60, 0, Math.PI * 2);
+ c.fill();
+ c.fillStyle = '#fff';
+ c.font = 'bold 84px sans-serif';
+ c.textAlign = 'center';
+ c.fillText('M', w / 2, h / 2 + 30);
+ },
+ false,
+ );
+ const canopy = new THREE.MeshStandardMaterial({
+ color: '#59646b',
+ metalness: 0.5,
+ roughness: 0.4,
+ });
+ const glass = new THREE.MeshStandardMaterial({
+ color: '#9fc4cf',
+ transparent: true,
+ opacity: 0.45,
+ roughness: 0.1,
+ });
+ for (const label of data.labels.filter((l) => l.kind === 'metro')) {
+ const entrance = new THREE.Group();
+ entrance.position.set(label.x, 0, label.z);
+ const road = nearestSegment(segments, label.x, label.z, 40, (s) => s[4] <= DRIVABLE_MAX_TYPE);
+ if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz);
+ const box = (w, h, d, m, x, y, z) => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = mesh.receiveShadow = true;
+ entrance.add(mesh);
};
+ box(4.4, 0.2, 7, canopy, 0, 3, 0);
+ box(0.15, 3, 7, glass, -2.1, 1.5, 0);
+ box(0.15, 3, 7, glass, 2.1, 1.5, 0);
+ box(0.25, 4.2, 0.25, canopy, 2.6, 2.1, 3.6);
+ const sign = new THREE.Mesh(
+ new THREE.CircleGeometry(0.6, 24),
+ new THREE.MeshBasicMaterial({ map: signTexture }),
+ );
+ sign.position.set(2.6, 4.4, 3.75);
+ entrance.add(sign);
+ const back = sign.clone();
+ back.rotation.y = Math.PI;
+ back.position.z = 3.45;
+ entrance.add(back);
+ group.add(entrance);
+ }
+
+ // A continuous ridge line around the horizon: the Binalood range high to the south-west,
+ // the lower Hezar Masjed hills to the north-east. Hazy colours stand in for distance fog.
+ const ridgePos = [],
+ ridgeCol = [],
+ ridgeIdx = [],
+ segmentsAround = 240;
+ const foot = new THREE.Color('#cfc5b0'),
+ crest = new THREE.Color('#a39a8a');
+ for (let i = 0; i <= segmentsAround; i++) {
+ const a = (i / segmentsAround) * Math.PI * 2,
+ x = Math.cos(a),
+ z = Math.sin(a);
+ // South-west is −x (west) and +z (south); peaks grow with how far this direction points there.
+ const southWest = Math.max(0, (-x + z) / Math.SQRT2),
+ rough = Math.sin(a * 7) * 0.5 + Math.sin(a * 17 + 1) * 0.3 + Math.sin(a * 41) * 0.15;
+ const height = 90 + southWest ** 1.5 * 420 + rough * (60 + southWest * 120),
+ r = 4300;
+ ridgePos.push(x * r, -20, z * r, x * r, height, z * r);
+ ridgeCol.push(foot.r, foot.g, foot.b, crest.r, crest.g, crest.b);
+ if (i) {
+ const b = (i - 1) * 2;
+ ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
+ }
+ }
+ const ridgeGeometry = new THREE.BufferGeometry();
+ ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3));
+ ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3));
+ ridgeGeometry.setIndex(ridgeIdx);
+ const ridge = new THREE.Mesh(
+ ridgeGeometry,
+ new THREE.MeshBasicMaterial({ vertexColors: true, fog: false, side: THREE.DoubleSide }),
+ );
+ group.add(ridge);
+ let lastCheck = null;
+ return {
+ group,
+ // Keeps the ground and horizon around the camera and shows only chunks within range.
+ update(camera) {
+ ground.position.set(camera.x, -0.02, camera.z);
+ ridge.position.set(camera.x, 0, camera.z);
+ // Build the nearest unbuilt block in range, one per frame to avoid stutter.
+ let next = null,
+ nextDistance = VIEW_RANGE + BLOCK;
+ for (const block of pendingBlocks) {
+ const d = Math.hypot(block.x - camera.x, block.z - camera.z);
+ if (d < nextDistance) {
+ next = block;
+ nextDistance = d;
+ }
+ }
+ if (next) {
+ pendingBlocks.splice(pendingBlocks.indexOf(next), 1);
+ buildBlock(next);
+ lastCheck = null;
+ }
+ if (lastCheck && Math.hypot(camera.x - lastCheck.x, camera.z - lastCheck.z) < 60) return;
+ lastCheck = { x: camera.x, z: camera.z };
+ for (const mesh of chunked) {
+ const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere;
+ mesh.visible =
+ Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius <
+ VIEW_RANGE;
+ }
+ },
+ };
}
diff --git a/src/collision.js b/src/collision.js
index d927090..2e278fa 100644
--- a/src/collision.js
+++ b/src/collision.js
@@ -3,82 +3,99 @@ const CELL = 24;
const key = (cx, cz) => cx * 100003 + cz;
export function createCollisionWorld(polygons) {
- const world = {polygons: [], grid: new Map()};
- polygons.forEach(p => addPolygon(world, p));
- return world;
+ const world = { polygons: [], grid: new Map() };
+ polygons.forEach((p) => addPolygon(world, p));
+ return world;
}
export function addPolygon(world, pts) {
- let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
- for (let i = 0; i < pts.length; i += 2) {
- minX = Math.min(minX, pts[i]);
- maxX = Math.max(maxX, pts[i]);
- minZ = Math.min(minZ, pts[i + 1]);
- maxZ = Math.max(maxZ, pts[i + 1]);
+ let minX = Infinity,
+ minZ = Infinity,
+ maxX = -Infinity,
+ maxZ = -Infinity;
+ for (let i = 0; i < pts.length; i += 2) {
+ minX = Math.min(minX, pts[i]);
+ maxX = Math.max(maxX, pts[i]);
+ minZ = Math.min(minZ, pts[i + 1]);
+ maxZ = Math.max(maxZ, pts[i + 1]);
+ }
+ const index = world.polygons.push({ pts, minX, minZ, maxX, maxZ }) - 1;
+ for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
+ for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
+ const k = key(cx, cz);
+ if (!world.grid.has(k)) world.grid.set(k, []);
+ world.grid.get(k).push(index);
}
- const index = world.polygons.push({pts, minX, minZ, maxX, maxZ}) - 1;
- for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
- for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
- const k = key(cx, cz);
- if (!world.grid.has(k)) world.grid.set(k, []);
- world.grid.get(k).push(index);
- }
}
function nearby(world, x, z, r) {
- const found = new Set();
- for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
- for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++)
- for (const i of world.grid.get(key(cx, cz)) ?? []) found.add(i);
- return [...found].map(i => world.polygons[i]).filter(p => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
+ const found = new Set();
+ for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
+ for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++)
+ for (const i of world.grid.get(key(cx, cz)) ?? []) found.add(i);
+ return [...found]
+ .map((i) => world.polygons[i])
+ .filter((p) => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
}
export function insidePolygon(pts, x, z) {
- let inside = false;
- for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
- const xi = pts[i], zi = pts[i + 1], xj = pts[j], zj = pts[j + 1];
- if ((zi > z) !== (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) inside = !inside;
- }
- return inside;
+ let inside = false;
+ for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
+ const xi = pts[i],
+ zi = pts[i + 1],
+ xj = pts[j],
+ zj = pts[j + 1];
+ if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
+ }
+ return inside;
}
function closestOnEdges(pts, x, z) {
- let best = {d: Infinity, x: 0, z: 0};
- for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
- const ax = pts[j], az = pts[j + 1], dx = pts[i] - ax, dz = pts[i + 1] - az;
- const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
- const px = ax + dx * t, pz = az + dz * t, d = Math.hypot(x - px, z - pz);
- if (d < best.d) best = {d, x: px, z: pz};
- }
- return best;
+ let best = { d: Infinity, x: 0, z: 0 };
+ for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
+ const ax = pts[j],
+ az = pts[j + 1],
+ dx = pts[i] - ax,
+ dz = pts[i + 1] - az;
+ const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
+ const px = ax + dx * t,
+ pz = az + dz * t,
+ d = Math.hypot(x - px, z - pz);
+ if (d < best.d) best = { d, x: px, z: pz };
+ }
+ return best;
}
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
export function resolveCircle(world, x, z, r) {
- let hit = false;
- for (let pass = 0; pass < 3; pass++) {
- let moved = false;
- for (const p of nearby(world, x, z, r)) {
- const inside = insidePolygon(p.pts, x, z), c = closestOnEdges(p.pts, x, z);
- if (!inside && c.d >= r) continue;
- const len = c.d || 1e-6, nx = (x - c.x) / len * (inside ? -1 : 1), nz = (z - c.z) / len * (inside ? -1 : 1);
- x = c.x + nx * r;
- z = c.z + nz * r;
- moved = hit = true;
- }
- if (!moved) break;
+ let hit = false;
+ for (let pass = 0; pass < 3; pass++) {
+ let moved = false;
+ for (const p of nearby(world, x, z, r)) {
+ const inside = insidePolygon(p.pts, x, z),
+ c = closestOnEdges(p.pts, x, z);
+ if (!inside && c.d >= r) continue;
+ const len = c.d || 1e-6,
+ nx = ((x - c.x) / len) * (inside ? -1 : 1),
+ nz = ((z - c.z) / len) * (inside ? -1 : 1);
+ x = c.x + nx * r;
+ z = c.z + nz * r;
+ moved = hit = true;
}
- return {x, z, hit};
+ if (!moved) break;
+ }
+ return { x, z, hit };
}
-export const blocked = (world, x, z) => nearby(world, x, z, 0).some(p => insidePolygon(p.pts, x, z));
+export const blocked = (world, x, z) =>
+ nearby(world, x, z, 0).some((p) => insidePolygon(p.pts, x, z));
// Fraction (0–1) of the segment a→b that is free before it enters a footprint.
export function clearFraction(world, ax, az, bx, bz, step = 1) {
- const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
- for (let i = 1; i <= steps; i++) {
- const t = i / steps;
- if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
- }
- return 1;
+ const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
+ }
+ return 1;
}
diff --git a/src/factory.js b/src/factory.js
index 735375f..f567af3 100644
--- a/src/factory.js
+++ b/src/factory.js
@@ -1,20 +1,21 @@
import * as THREE from 'three';
-import {createVendingMachine} from './vending.js';
-import {createCarModel} from './traffic.js';
-import {createPickup, createTruck} from './car-models.js';
-import {createPerson, createCrew, nameTag} from './staff.js';
-import {FACTORY} from './locations.js';
-import {textTexture} from './textures.js';
+import { createVendingMachine } from './vending.js';
+import { createCarModel } from './traffic.js';
+import { createPickup, createTruck } from './car-models.js';
+import { createPerson, createCrew, nameTag } from './staff.js';
+import { FACTORY } from './locations.js';
+import { textTexture } from './textures.js';
function stripes(c, w, h, base, line, every) {
- c.fillStyle = base;
- c.fillRect(0, 0, w, h);
- c.fillStyle = line;
- for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * .35, h);
+ c.fillStyle = base;
+ c.fillRect(0, 0, w, h);
+ c.fillStyle = line;
+ for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * 0.35, h);
}
// Persian sign: white text on the company's dark teal.
-const sign = (text, sub = '') => textTexture(512, 160, (c, w, h) => {
+const sign = (text, sub = '') =>
+ textTexture(512, 160, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
@@ -25,31 +26,93 @@ const sign = (text, sub = '') => textTexture(512, 160, (c, w, h) => {
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(text, w / 2, sub ? 72 : 100);
if (sub) {
- c.fillStyle = '#9fe9da';
- c.font = '36px Vazirmatn, Tahoma, sans-serif';
- c.fillText(sub, w / 2, 128);
+ c.fillStyle = '#9fe9da';
+ c.font = '36px Vazirmatn, Tahoma, sans-serif';
+ c.fillText(sub, w / 2, 128);
}
-});
+ });
// Monitor contents: a few lines of code (or rows) on a dark editor background.
-const screen = (lines, background = '#14181f') => textTexture(512, 300, (c, w, h) => {
+const screen = (lines, background = '#14181f') =>
+ textTexture(512, 300, (c, w, h) => {
c.fillStyle = background;
c.fillRect(0, 0, w, h);
c.fillStyle = '#232a35';
c.fillRect(0, 0, w, 26);
c.font = '21px monospace';
lines.forEach(([text, color], i) => {
- c.fillStyle = color;
- c.fillText(text, 16, 58 + i * 29);
+ c.fillStyle = color;
+ c.fillText(text, 16, 58 + i * 29);
});
-});
+ });
const CODE = {
- react: screen([['export function Shelf({items}) {', '#c792ea'], [' const [slot, setSlot] = useState();', '#82aaff'], [' return (', '#eeffff'], [' ', '#f07178'], [' {items.map(i => )}', '#c3e88d'], [' ', '#f07178'], [' );', '#eeffff'], ['}', '#c792ea']]),
- go: screen([['func (s *Server) Pay(w http.Res', '#82aaff'], [' tx, err := s.bank.Charge(ctx, req)', '#eeffff'], [' if err != nil {', '#c792ea'], [' http.Error(w, err.Error(), 402)', '#f07178'], [' return', '#c792ea'], [' }', '#c792ea'], [' s.events <- Sale{tx.ID, req.Slot}', '#c3e88d'], ['}', '#82aaff']]),
- cpp: screen([['void Motor::dispense(int slot) {', '#82aaff'], [' auto& coil = coils_[slot];', '#eeffff'], [' coil.rotate(360_deg);', '#c3e88d'], [' while (!sensor_.dropped())', '#c792ea'], [' std::this_thread::yield();', '#eeffff'], [' fridge_.hold(4.0_C);', '#f78c6c'], [' log("slot", slot, "ok");', '#c3e88d'], ['}', '#82aaff']]),
- terminal: screen([['$ go test ./...', '#c3e88d'], ['ok payments 0.42s', '#eeffff'], ['$ cmake --build build', '#c3e88d'], ['[100%] Built target firmware', '#eeffff'], ['$ npm run build', '#c3e88d'], ['✓ built in 2.1s', '#eeffff'], ['$ git push origin main', '#c3e88d'], ['', '#fff']], '#0c0f12'),
- sheet: screen([['فاکتور دستگاه مبلغ', '#1d3b2f'], ['۱۰۴۲ بوستان ملت ۴۸۰', '#333'], ['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'], ['۱۰۴۴ مترو آزادی ۲۹۰', '#333'], ['۱۰۴۵ مشهد مال ۶۱۰', '#333'], ['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'], ['جمع ۱۸۸۰', '#0b8f88']], '#f4f7f2'),
- dashboard: screen([['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'], ['تولید هفته: ۴۶ دستگاه', '#f2c94c'], ['ارسال شده: ۳۸', '#eeffff'], ['', '#fff'], ['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'], ['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'], ['▇▇▇▇▇▇▇ رنگ', '#82aaff']], '#101820')
+ react: screen([
+ ['export function Shelf({items}) {', '#c792ea'],
+ [' const [slot, setSlot] = useState();', '#82aaff'],
+ [' return (', '#eeffff'],
+ [' ', '#f07178'],
+ [' {items.map(i => )}', '#c3e88d'],
+ [' ', '#f07178'],
+ [' );', '#eeffff'],
+ ['}', '#c792ea'],
+ ]),
+ go: screen([
+ ['func (s *Server) Pay(w http.Res', '#82aaff'],
+ [' tx, err := s.bank.Charge(ctx, req)', '#eeffff'],
+ [' if err != nil {', '#c792ea'],
+ [' http.Error(w, err.Error(), 402)', '#f07178'],
+ [' return', '#c792ea'],
+ [' }', '#c792ea'],
+ [' s.events <- Sale{tx.ID, req.Slot}', '#c3e88d'],
+ ['}', '#82aaff'],
+ ]),
+ cpp: screen([
+ ['void Motor::dispense(int slot) {', '#82aaff'],
+ [' auto& coil = coils_[slot];', '#eeffff'],
+ [' coil.rotate(360_deg);', '#c3e88d'],
+ [' while (!sensor_.dropped())', '#c792ea'],
+ [' std::this_thread::yield();', '#eeffff'],
+ [' fridge_.hold(4.0_C);', '#f78c6c'],
+ [' log("slot", slot, "ok");', '#c3e88d'],
+ ['}', '#82aaff'],
+ ]),
+ terminal: screen(
+ [
+ ['$ go test ./...', '#c3e88d'],
+ ['ok payments 0.42s', '#eeffff'],
+ ['$ cmake --build build', '#c3e88d'],
+ ['[100%] Built target firmware', '#eeffff'],
+ ['$ npm run build', '#c3e88d'],
+ ['✓ built in 2.1s', '#eeffff'],
+ ['$ git push origin main', '#c3e88d'],
+ ['', '#fff'],
+ ],
+ '#0c0f12',
+ ),
+ sheet: screen(
+ [
+ ['فاکتور دستگاه مبلغ', '#1d3b2f'],
+ ['۱۰۴۲ بوستان ملت ۴۸۰', '#333'],
+ ['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'],
+ ['۱۰۴۴ مترو آزادی ۲۹۰', '#333'],
+ ['۱۰۴۵ مشهد مال ۶۱۰', '#333'],
+ ['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'],
+ ['جمع ۱۸۸۰', '#0b8f88'],
+ ],
+ '#f4f7f2',
+ ),
+ dashboard: screen(
+ [
+ ['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'],
+ ['تولید هفته: ۴۶ دستگاه', '#f2c94c'],
+ ['ارسال شده: ۳۸', '#eeffff'],
+ ['', '#fff'],
+ ['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'],
+ ['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'],
+ ['▇▇▇▇▇▇▇ رنگ', '#82aaff'],
+ ],
+ '#101820',
+ ),
};
/**
@@ -60,639 +123,995 @@ const CODE = {
* people the family can talk to.
*/
export function createFactory(scene) {
- const {center, axis} = FACTORY, normal = {x: -axis.z, z: axis.x};
- const rot = Math.atan2(-axis.z, axis.x);
- const toWorld = (u, v) => ({x: center.x + axis.x * u + normal.x * v, z: center.z + axis.z * u + normal.z * v});
- // A heading in the local frame (u, v direction) as a world-space car heading.
- const worldHeading = (du, dv) => {
- const x = axis.x * du + normal.x * dv, z = axis.z * du + normal.z * dv;
- return Math.atan2(-z, x);
- };
- const group = new THREE.Group();
- group.position.set(center.x, 0, center.z);
- group.rotation.y = rot;
- scene.add(group);
- const footprints = [];
- const solid = (u0, u1, v0, v1) => footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
- // Axis-aligned box in the local frame; solid ones also block movement.
- const box = (a0, a1, b0, b1, y0, y1, material, isSolid = true, parent = group) => {
- const u0 = Math.min(a0, a1), u1 = Math.max(a0, a1), v0 = Math.min(b0, b1), v1 = Math.max(b0, b1);
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
- mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
- mesh.castShadow = mesh.receiveShadow = true;
- parent.add(mesh);
- if (isSolid) solid(u0, u1, v0, v1);
- return mesh;
- };
- const cylinder = (r, h, material, u, y, v, parent = group) => {
- const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 16), material);
- mesh.position.set(u, y, v);
- mesh.castShadow = true;
- parent.add(mesh);
- return mesh;
- };
- // A sign plane; `facing` is the local direction its front faces: 1 = +v, -1 = −v.
- const panel = (texture, w, h, u, y, v, facing = 1) => {
- const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({map: texture, side: THREE.DoubleSide}));
- mesh.position.set(u, y, v);
- if (facing < 0) mesh.rotation.y = Math.PI;
- group.add(mesh);
- return mesh;
- };
- const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
- const floorMat = (color, extra = {}) => mat(color, {polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32, ...extra});
- const flat = (u0, u1, v0, v1, y, material) => {
- const mesh = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, v1 - v0), material);
- mesh.rotation.x = -Math.PI / 2;
- mesh.position.set((u0 + u1) / 2, y, (v0 + v1) / 2);
- mesh.receiveShadow = true;
- group.add(mesh);
- return mesh;
- };
- const steel = mat('#59646b', {metalness: .5}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
- const yellow = mat('#f2c94c', {roughness: .5}), darkSteel = mat('#3d4549', {metalness: .5}), silver = mat('#c4ccd0', {metalness: .8, roughness: .3});
- const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
- const animated = [];
-
- // ---------- Yard, fence, gate ----------
- flat(-110, 80, -50, 50, .08, floorMat('#8f8c86', {roughness: .95, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
- const fence = mat('#d9d3c4');
- box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
- box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
- box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
- box(79.7, 80.3, -50, -9, 0, 2.4, fence);
- box(79.7, 80.3, 9, 50, 0, 2.4, fence);
- for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
- // Painted parking bays in front of the office.
- const paint = floorMat('#f1efe6', {roughness: .9, polygonOffsetFactor: -18, polygonOffsetUnits: -36});
- for (let u = 38; u <= 70; u += 4) flat(u - .06, u + .06, -21.5, -16.5, .09, paint);
-
- const nameSign = textTexture(1024, 320, (c, w, h) => {
- c.fillStyle = '#0d2f2c';
- c.fillRect(0, 0, w, h);
- c.fillStyle = '#3ee0c9';
- c.fillRect(0, h - 18, w, 18);
- c.textAlign = 'center';
- c.direction = 'rtl';
- c.fillStyle = '#ffffff';
- c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
- c.fillText(FACTORY.name, w / 2, 120);
- c.fillStyle = '#9fe9da';
- c.font = '44px Vazirmatn, Tahoma, sans-serif';
- c.fillText(FACTORY.tagline, w / 2, 210);
- c.font = 'bold 34px sans-serif';
- c.fillStyle = '#f2c94c';
- c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
+ const { center, axis } = FACTORY,
+ normal = { x: -axis.z, z: axis.x };
+ const rot = Math.atan2(-axis.z, axis.x);
+ const toWorld = (u, v) => ({
+ x: center.x + axis.x * u + normal.x * v,
+ z: center.z + axis.z * u + normal.z * v,
+ });
+ // A heading in the local frame (u, v direction) as a world-space car heading.
+ const worldHeading = (du, dv) => {
+ const x = axis.x * du + normal.x * dv,
+ z = axis.z * du + normal.z * dv;
+ return Math.atan2(-z, x);
+ };
+ const group = new THREE.Group();
+ group.position.set(center.x, 0, center.z);
+ group.rotation.y = rot;
+ scene.add(group);
+ const footprints = [];
+ const solid = (u0, u1, v0, v1) =>
+ footprints.push(
+ [
+ [u0, v0],
+ [u1, v0],
+ [u1, v1],
+ [u0, v1],
+ ].flatMap(([u, v]) => Object.values(toWorld(u, v))),
+ );
+ // Axis-aligned box in the local frame; solid ones also block movement.
+ const box = (a0, a1, b0, b1, y0, y1, material, isSolid = true, parent = group) => {
+ const u0 = Math.min(a0, a1),
+ u1 = Math.max(a0, a1),
+ v0 = Math.min(b0, b1),
+ v1 = Math.max(b0, b1);
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
+ mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
+ mesh.castShadow = mesh.receiveShadow = true;
+ parent.add(mesh);
+ if (isSolid) solid(u0, u1, v0, v1);
+ return mesh;
+ };
+ const cylinder = (r, h, material, u, y, v, parent = group) => {
+ const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 16), material);
+ mesh.position.set(u, y, v);
+ mesh.castShadow = true;
+ parent.add(mesh);
+ return mesh;
+ };
+ // A sign plane; `facing` is the local direction its front faces: 1 = +v, -1 = −v.
+ const panel = (texture, w, h, u, y, v, facing = 1) => {
+ const mesh = new THREE.Mesh(
+ new THREE.PlaneGeometry(w, h),
+ new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide }),
+ );
+ mesh.position.set(u, y, v);
+ if (facing < 0) mesh.rotation.y = Math.PI;
+ group.add(mesh);
+ return mesh;
+ };
+ const mat = (color, extra = {}) =>
+ new THREE.MeshStandardMaterial({ color, roughness: 0.7, ...extra });
+ const floorMat = (color, extra = {}) =>
+ mat(color, {
+ polygonOffset: true,
+ polygonOffsetFactor: -16,
+ polygonOffsetUnits: -32,
+ ...extra,
});
- const signMaterial = new THREE.MeshBasicMaterial({map: nameSign, side: THREE.DoubleSide});
- // Two faces, so the name also reads correctly from inside the yard.
- const frontSign = new THREE.MeshBasicMaterial({map: nameSign});
- for (const side of [1, -1]) {
- const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), frontSign);
- gateSign.position.set(82 + side * .02, 6.6, -20);
- gateSign.rotation.y = side * Math.PI / 2;
- group.add(gateSign);
- }
- for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, steel, false);
+ const flat = (u0, u1, v0, v1, y, material) => {
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, v1 - v0), material);
+ mesh.rotation.x = -Math.PI / 2;
+ mesh.position.set((u0 + u1) / 2, y, (v0 + v1) / 2);
+ mesh.receiveShadow = true;
+ group.add(mesh);
+ return mesh;
+ };
+ const steel = mat('#59646b', { metalness: 0.5 }),
+ teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 });
+ const yellow = mat('#f2c94c', { roughness: 0.5 }),
+ darkSteel = mat('#3d4549', { metalness: 0.5 }),
+ silver = mat('#c4ccd0', { metalness: 0.8, roughness: 0.3 });
+ const lamp = new THREE.MeshBasicMaterial({ color: '#fffbe8' });
+ const animated = [];
- // ---------- Production hall ----------
- // Corrugated walls, a wide door onto the yard (v = V0) and a loading dock door at the east end.
- const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
- corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
- corrugated.repeat.set(18, 2);
- const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
- const H = 10, U0 = -104, U1 = 0, V0 = 10, V1 = 42, D0 = -36, D1 = -20, DOCK0 = 27, DOCK1 = 35;
- box(U0, U1, V1, V1 + .4, 0, H, wall);
- box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
- box(U1, U1 + .4, V0 - .4, DOCK0, 0, H, wall);
- box(U1, U1 + .4, DOCK1, V1 + .4, 0, H, wall);
- box(U1, U1 + .4, DOCK0, DOCK1, 5, H, wall, false);
- box(U0 - .4, D0, V0 - .4, V0, 0, H, wall);
- box(D1, U1 + .4, V0 - .4, V0, 0, H, wall);
- box(D0, D1, V0 - .4, V0, 7, H, wall, false);
- box(U0 - .4, U1 + .4, V0 - .5, V0 - .3, H - 1.6, H - .6, teal, false);
- const roof = box(U0 - 1, U1 + 1, V0 - 1, V1 + 1, H, H + .4, mat('#9aa6aa', {metalness: .4}), false);
- // The roof stays out of the shadow map, so daylight still reaches the hall floor.
- roof.castShadow = false;
- flat(U0, U1, V0, V1, .1, floorMat('#b9c4bd', {roughness: .35}));
- // Safety walkway along the door side.
- flat(U0 + 1, U1 - 1, 18.6, 19, .11, floorMat('#f2c94c', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
- for (let u = U0 + 8; u < U1; u += 12) for (const v of [18, 32]) box(u - 3, u + 3, v - 1, v + 1, H - .3, H - .1, lamp, false).castShadow = false;
- panel(nameSign, 16, 5, (D0 + D1) / 2, H + 3, V0 - .6, -1);
- for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, steel, false);
- // Work-area signs hang above the walkway, readable from the door.
- const zone = (text, sub, u) => {
- panel(sign(text, sub), 5.4, 1.7, u, 6.6, 21, -1);
- for (const du of [-2.2, 2.2]) box(u + du - .03, u + du + .03, 20.97, 21.03, 7.45, H, steel, false);
- };
- zone('خمکاری', 'ورقکاری بدنهٔ دستگاه', -93);
- zone('برش لیزر', 'برش ورق و قطعات', -71);
- zone('رنگکاری', 'کابین رنگ پودری', -52);
- zone('یخچالسازی', 'کمپرسور و عایقکاری', -31);
- zone('مونتاژ', 'خط مونتاژ نهایی', -13);
- zone('بستهبندی', 'ارسال به شهر', -3);
+ // ---------- Yard, fence, gate ----------
+ flat(
+ -110,
+ 80,
+ -50,
+ 50,
+ 0.08,
+ floorMat('#8f8c86', { roughness: 0.95, polygonOffsetFactor: -14, polygonOffsetUnits: -28 }),
+ );
+ const fence = mat('#d9d3c4');
+ box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
+ box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
+ box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
+ box(79.7, 80.3, -50, -9, 0, 2.4, fence);
+ box(79.7, 80.3, 9, 50, 0, 2.4, fence);
+ for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
+ // Painted parking bays in front of the office.
+ const paint = floorMat('#f1efe6', {
+ roughness: 0.9,
+ polygonOffsetFactor: -18,
+ polygonOffsetUnits: -36,
+ });
+ for (let u = 38; u <= 70; u += 4) flat(u - 0.06, u + 0.06, -21.5, -16.5, 0.09, paint);
- // Bending: two press brakes with a moving ram, sheet stacks behind.
- const sheet = mat('#d4dadd', {metalness: .85, roughness: .25});
- for (const u of [-98, -88]) {
- box(u - 1.8, u + 1.8, 29.4, 30.6, 0, 1.0, mat('#4a6b80', {metalness: .4}));
- for (const du of [-1.8, 1.5]) box(u + du, u + du + .3, 29.2, 30.8, 0, 2.9, mat('#4a6b80', {metalness: .4}), false);
- const ram = box(u - 1.5, u + 1.5, 29.6, 30.4, 1.9, 2.5, yellow, false);
- animated.push(t => ram.position.y = 2.2 + Math.max(0, Math.sin(t * 1.4 + u)) * -.6);
- box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, sheet, false);
- }
- for (const u of [-101, -95, -90, -85]) {
- box(u - 1, u + 1, 38.4, 39.6, 0, .15, mat('#8a6a45'));
- for (let i = 0; i < 6; i++) box(u - .95, u + .95, 38.45, 39.55, .15 + i * .05, .19 + i * .05, sheet, false);
- }
+ const nameSign = textTexture(1024, 320, (c, w, h) => {
+ c.fillStyle = '#0d2f2c';
+ c.fillRect(0, 0, w, h);
+ c.fillStyle = '#3ee0c9';
+ c.fillRect(0, h - 18, w, 18);
+ c.textAlign = 'center';
+ c.direction = 'rtl';
+ c.fillStyle = '#ffffff';
+ c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
+ c.fillText(FACTORY.name, w / 2, 120);
+ c.fillStyle = '#9fe9da';
+ c.font = '44px Vazirmatn, Tahoma, sans-serif';
+ c.fillText(FACTORY.tagline, w / 2, 210);
+ c.font = 'bold 34px sans-serif';
+ c.fillStyle = '#f2c94c';
+ c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
+ });
+ const signMaterial = new THREE.MeshBasicMaterial({ map: nameSign, side: THREE.DoubleSide });
+ // Two faces, so the name also reads correctly from inside the yard.
+ const frontSign = new THREE.MeshBasicMaterial({ map: nameSign });
+ for (const side of [1, -1]) {
+ const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), frontSign);
+ gateSign.position.set(82 + side * 0.02, 6.6, -20);
+ gateSign.rotation.y = (side * Math.PI) / 2;
+ group.add(gateSign);
+ }
+ for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - 0.3, v + 0.3, 0, 8.8, steel, false);
- // Laser cutting: a bed with a moving gantry and glowing cutting head, plus a shear.
- box(-78, -66, 26, 32, 0, .9, darkSteel);
- const slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12));
- flat(-77.8, -66.2, 26.2, 31.8, .91, new THREE.MeshStandardMaterial({map: slats, metalness: .6, roughness: .4}));
- const gantry = new THREE.Group();
- group.add(gantry);
- box(-.3, .3, 25.4, 32.6, 1.6, 1.9, yellow, false, gantry);
- for (const v of [25.5, 32.5]) box(-.25, .25, v - .2, v + .2, .9, 1.9, yellow, false, gantry);
- const head = box(-.2, .2, -.2, .2, 1.1, 1.6, darkSteel, false, gantry);
- const spark = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({color: '#ff5a3c'}));
- gantry.add(spark);
- animated.push(t => {
- gantry.position.x = -72 + Math.sin(t * .45) * 5;
- head.position.z = spark.position.z = 29 + Math.sin(t * 1.3) * 2.5;
- spark.position.y = .95;
- spark.scale.setScalar(.7 + Math.random() * .6);
+ // ---------- Production hall ----------
+ // Corrugated walls, a wide door onto the yard (v = V0) and a loading dock door at the east end.
+ const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
+ corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
+ corrugated.repeat.set(18, 2);
+ const wall = mat('#ffffff', { map: corrugated, metalness: 0.35, roughness: 0.55 });
+ const H = 10,
+ U0 = -104,
+ U1 = 0,
+ V0 = 10,
+ V1 = 42,
+ D0 = -36,
+ D1 = -20,
+ DOCK0 = 27,
+ DOCK1 = 35;
+ box(U0, U1, V1, V1 + 0.4, 0, H, wall);
+ box(U0 - 0.4, U0, V0, V1 + 0.4, 0, H, wall);
+ box(U1, U1 + 0.4, V0 - 0.4, DOCK0, 0, H, wall);
+ box(U1, U1 + 0.4, DOCK1, V1 + 0.4, 0, H, wall);
+ box(U1, U1 + 0.4, DOCK0, DOCK1, 5, H, wall, false);
+ box(U0 - 0.4, D0, V0 - 0.4, V0, 0, H, wall);
+ box(D1, U1 + 0.4, V0 - 0.4, V0, 0, H, wall);
+ box(D0, D1, V0 - 0.4, V0, 7, H, wall, false);
+ box(U0 - 0.4, U1 + 0.4, V0 - 0.5, V0 - 0.3, H - 1.6, H - 0.6, teal, false);
+ const roof = box(
+ U0 - 1,
+ U1 + 1,
+ V0 - 1,
+ V1 + 1,
+ H,
+ H + 0.4,
+ mat('#9aa6aa', { metalness: 0.4 }),
+ false,
+ );
+ // The roof stays out of the shadow map, so daylight still reaches the hall floor.
+ roof.castShadow = false;
+ flat(U0, U1, V0, V1, 0.1, floorMat('#b9c4bd', { roughness: 0.35 }));
+ // Safety walkway along the door side.
+ flat(
+ U0 + 1,
+ U1 - 1,
+ 18.6,
+ 19,
+ 0.11,
+ floorMat('#f2c94c', { polygonOffsetFactor: -18, polygonOffsetUnits: -36 }),
+ );
+ for (let u = U0 + 8; u < U1; u += 12)
+ for (const v of [18, 32])
+ box(u - 3, u + 3, v - 1, v + 1, H - 0.3, H - 0.1, lamp, false).castShadow = false;
+ panel(nameSign, 16, 5, (D0 + D1) / 2, H + 3, V0 - 0.6, -1);
+ for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5])
+ box(u - 0.2, u + 0.2, V0 - 0.7, V0 - 0.5, H, H + 5.5, steel, false);
+ // Work-area signs hang above the walkway, readable from the door.
+ const zone = (text, sub, u) => {
+ panel(sign(text, sub), 5.4, 1.7, u, 6.6, 21, -1);
+ for (const du of [-2.2, 2.2])
+ box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, H, steel, false);
+ };
+ zone('خمکاری', 'ورقکاری بدنهٔ دستگاه', -93);
+ zone('برش لیزر', 'برش ورق و قطعات', -71);
+ zone('رنگکاری', 'کابین رنگ پودری', -52);
+ zone('یخچالسازی', 'کمپرسور و عایقکاری', -31);
+ zone('مونتاژ', 'خط مونتاژ نهایی', -13);
+ zone('بستهبندی', 'ارسال به شهر', -3);
+
+ // Bending: two press brakes with a moving ram, sheet stacks behind.
+ const sheet = mat('#d4dadd', { metalness: 0.85, roughness: 0.25 });
+ for (const u of [-98, -88]) {
+ box(u - 1.8, u + 1.8, 29.4, 30.6, 0, 1.0, mat('#4a6b80', { metalness: 0.4 }));
+ for (const du of [-1.8, 1.5])
+ box(u + du, u + du + 0.3, 29.2, 30.8, 0, 2.9, mat('#4a6b80', { metalness: 0.4 }), false);
+ const ram = box(u - 1.5, u + 1.5, 29.6, 30.4, 1.9, 2.5, yellow, false);
+ animated.push((t) => (ram.position.y = 2.2 + Math.max(0, Math.sin(t * 1.4 + u)) * -0.6));
+ box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, sheet, false);
+ }
+ for (const u of [-101, -95, -90, -85]) {
+ box(u - 1, u + 1, 38.4, 39.6, 0, 0.15, mat('#8a6a45'));
+ for (let i = 0; i < 6; i++)
+ box(u - 0.95, u + 0.95, 38.45, 39.55, 0.15 + i * 0.05, 0.19 + i * 0.05, sheet, false);
+ }
+
+ // Laser cutting: a bed with a moving gantry and glowing cutting head, plus a shear.
+ box(-78, -66, 26, 32, 0, 0.9, darkSteel);
+ const slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12));
+ flat(
+ -77.8,
+ -66.2,
+ 26.2,
+ 31.8,
+ 0.91,
+ new THREE.MeshStandardMaterial({ map: slats, metalness: 0.6, roughness: 0.4 }),
+ );
+ const gantry = new THREE.Group();
+ group.add(gantry);
+ box(-0.3, 0.3, 25.4, 32.6, 1.6, 1.9, yellow, false, gantry);
+ for (const v of [25.5, 32.5]) box(-0.25, 0.25, v - 0.2, v + 0.2, 0.9, 1.9, yellow, false, gantry);
+ const head = box(-0.2, 0.2, -0.2, 0.2, 1.1, 1.6, darkSteel, false, gantry);
+ const spark = new THREE.Mesh(
+ new THREE.SphereGeometry(0.09, 8, 6),
+ new THREE.MeshBasicMaterial({ color: '#ff5a3c' }),
+ );
+ gantry.add(spark);
+ animated.push((t) => {
+ gantry.position.x = -72 + Math.sin(t * 0.45) * 5;
+ head.position.z = spark.position.z = 29 + Math.sin(t * 1.3) * 2.5;
+ spark.position.y = 0.95;
+ spark.scale.setScalar(0.7 + Math.random() * 0.6);
+ });
+ box(-65, -64, 33.2, 33.8, 0, 1.4, mat('#2e3a40'));
+ box(-73.5, -70.5, 37.9, 39.1, 0, 1.6, mat('#4a6b80', { metalness: 0.4 }));
+
+ // Powder painting booth: see-through panels, an overhead rail carrying panels through it.
+ const glass = mat('#9fd3e6', {
+ transparent: true,
+ opacity: 0.28,
+ depthWrite: false,
+ roughness: 0.1,
+ });
+ box(-58, -46, 36.8, 37, 0, 4.2, glass);
+ box(-58, -57.8, 25, 37, 0, 4.2, glass);
+ box(-46.2, -46, 25, 37, 0, 4.2, glass);
+ box(-58, -46, 25, 37, 4.2, 4.3, glass, false);
+ flat(
+ -57.8,
+ -46.2,
+ 25.2,
+ 36.8,
+ 0.12,
+ floorMat('#5d7f86', { polygonOffsetFactor: -18, polygonOffsetUnits: -36 }),
+ );
+ box(-61, -43, 30.9, 31.1, 3.7, 3.8, steel, false);
+ const hung = [];
+ for (let i = 0; i < 5; i++) {
+ const piece = box(-0.6, 0.6, 30.97, 31.03, 1.4, 3.2, i % 2 ? teal : mat('#eef4f2'), false);
+ hung.push(piece);
+ }
+ animated.push((t) =>
+ hung.forEach((piece, i) => (piece.position.x = -60 + ((t * 0.5 + i * 3.4) % 17))),
+ );
+
+ // Refrigeration: work tables with insulated cabinets, compressors and copper coils.
+ const copper = mat('#c27a45', { metalness: 0.9, roughness: 0.3 }),
+ insulated = mat('#f2f4f3', { roughness: 0.5 });
+ for (const u of [-37, -31, -25]) {
+ box(u - 2, u + 2, 29.2, 30.8, 0, 0.9, steel);
+ box(u - 1.6, u - 0.2, 29.4, 30.6, 0.9, 2.2, insulated, false);
+ cylinder(0.28, 0.55, mat('#16191c', { metalness: 0.5 }), u + 0.7, 1.18, 30);
+ const coil = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 6, 20), copper);
+ coil.position.set(u + 1.4, 1.3, 30);
+ coil.rotation.y = Math.PI / 2;
+ group.add(coil);
+ }
+ for (let i = 0; i < 4; i++) box(-35 + i * 2.1, -33.2 + i * 2.1, 38.6, 40.4, 0, 1.9, insulated);
+
+ // Assembly: conveyor carrying machine frames between robot arms, finished machines at the end.
+ box(-20, -6, 32.4, 34.6, 0, 0.9, darkSteel);
+ const rollers = mat('#9aa4a8', { metalness: 0.8, roughness: 0.3 });
+ for (let u = -19.4; u < -6; u += 1.2) {
+ const roller = cylinder(0.12, 2.2, rollers, u, 0.95, 33.5);
+ roller.rotation.x = Math.PI / 2;
+ }
+ const frames = [],
+ frameMaterial = mat('#0b8f88', { wireframe: true });
+ for (let i = 0; i < 4; i++) {
+ const frame = new THREE.Mesh(
+ new THREE.BoxGeometry(1.1, 2.4, 1.8),
+ i % 2 ? frameMaterial : teal,
+ );
+ frame.position.set(-19 + i * 3.5, 2.2, 33.5);
+ frame.castShadow = true;
+ group.add(frame);
+ frames.push(frame);
+ }
+ const arms = [];
+ for (const u of [-16, -10]) {
+ box(u - 0.6, u + 0.6, 36, 37.2, 0, 1.2, yellow);
+ const arm = new THREE.Group();
+ arm.position.set(u, 1.2, 36.6);
+ group.add(arm);
+ const upper = new THREE.Mesh(new THREE.BoxGeometry(0.35, 2.2, 0.35), yellow);
+ upper.position.y = 1.1;
+ upper.castShadow = true;
+ const fore = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.28, 2.4), mat('#e0b23a'));
+ fore.position.set(0, 2.1, -1.1);
+ fore.castShadow = true;
+ arm.add(upper, fore);
+ arms.push(arm);
+ }
+ animated.push((t) => {
+ arms.forEach((arm, i) => (arm.rotation.y = Math.sin(t * 0.8 + i * 2) * 0.9));
+ frames.forEach((frame, i) => (frame.position.x = -19.5 + ((t * 0.6 + i * 3.5) % 14)));
+ });
+ for (let i = 0; i < 4; i++) {
+ const finished = createVendingMachine({
+ name: 'آمادهٔ نصب',
+ products: ['water', 'juice', 'biscuit', 'chocolate'],
});
- box(-65, -64, 33.2, 33.8, 0, 1.4, mat('#2e3a40'));
- box(-73.5, -70.5, 37.9, 39.1, 0, 1.6, mat('#4a6b80', {metalness: .4}));
+ finished.beacon.visible = finished.ring.visible = false;
+ const u = -19.2 + i * 4.2;
+ finished.group.position.set(u, 0, 40.6);
+ finished.group.rotation.y = Math.PI;
+ group.add(finished.group);
+ solid(u - 1, u + 1, 40, 41.2);
+ }
- // Powder painting booth: see-through panels, an overhead rail carrying panels through it.
- const glass = mat('#9fd3e6', {transparent: true, opacity: .28, depthWrite: false, roughness: .1});
- box(-58, -46, 36.8, 37, 0, 4.2, glass);
- box(-58, -57.8, 25, 37, 0, 4.2, glass);
- box(-46.2, -46, 25, 37, 0, 4.2, glass);
- box(-58, -46, 25, 37, 4.2, 4.3, glass, false);
- flat(-57.8, -46.2, 25.2, 36.8, .12, floorMat('#5d7f86', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
- box(-61, -43, 30.9, 31.1, 3.7, 3.8, steel, false);
- const hung = [];
- for (let i = 0; i < 5; i++) {
- const piece = box(-.6, .6, 30.97, 31.03, 1.4, 3.2, i % 2 ? teal : mat('#eef4f2'), false);
- hung.push(piece);
- }
- animated.push(t => hung.forEach((piece, i) => piece.position.x = -60 + ((t * .5 + i * 3.4) % 17)));
+ // Packing: wrapped machines and a roller conveyor out through the dock door to the truck.
+ const carton = textTexture(256, 512, (c, w, h) => {
+ c.fillStyle = '#c49a64';
+ c.fillRect(0, 0, w, h);
+ c.fillStyle = 'rgba(255,255,255,.18)';
+ for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
+ c.fillStyle = '#0b8f88';
+ c.fillRect(0, h * 0.42, w, 34);
+ c.fillStyle = '#1d2a2a';
+ c.textAlign = 'center';
+ c.font = 'bold 34px sans-serif';
+ c.fillText('ROBOT', w / 2, h * 0.3);
+ c.fillText('MARKET', w / 2, h * 0.3 + 40);
+ c.font = '60px sans-serif';
+ c.fillText('⇧', w / 2, h * 0.75);
+ });
+ const cartonMat = mat('#ffffff', { map: carton, roughness: 0.85 });
+ const cartonGeometry = new THREE.BoxGeometry(0.95, 1.9, 1.05);
+ const packaged = () => {
+ const mesh = new THREE.Mesh(cartonGeometry, cartonMat);
+ mesh.castShadow = mesh.receiveShadow = true;
+ return mesh;
+ };
+ for (const v of [37, 38.4, 39.8]) {
+ const p = packaged();
+ p.position.set(-3.2, 0.95, v);
+ group.add(p);
+ solid(-3.7, -2.7, v - 0.55, v + 0.55);
+ }
+ box(-6, 1.6, 30.4, 31.6, 0, 1.0, darkSteel);
+ for (let u = -5.6; u < 1.6; u += 0.8)
+ cylinder(0.08, 1.1, rollers, u, 1.04, 31).rotation.x = Math.PI / 2;
- // Refrigeration: work tables with insulated cabinets, compressors and copper coils.
- const copper = mat('#c27a45', {metalness: .9, roughness: .3}), insulated = mat('#f2f4f3', {roughness: .5});
- for (const u of [-37, -31, -25]) {
- box(u - 2, u + 2, 29.2, 30.8, 0, .9, steel);
- box(u - 1.6, u - .2, 29.4, 30.6, .9, 2.2, insulated, false);
- cylinder(.28, .55, mat('#16191c', {metalness: .5}), u + .7, 1.18, 30);
- const coil = new THREE.Mesh(new THREE.TorusGeometry(.35, .04, 6, 20), copper);
- coil.position.set(u + 1.4, 1.3, 30);
- coil.rotation.y = Math.PI / 2;
- group.add(coil);
- }
- for (let i = 0; i < 4; i++) box(-35 + i * 2.1, -33.2 + i * 2.1, 38.6, 40.4, 0, 1.9, insulated);
+ // ---------- Production crew: 40 workers ----------
+ const overall = '#2f4e7a',
+ vest = '#e8742c',
+ paintSuit = '#eef1f2';
+ const W = (x, z, facing, kind = 'work', extra = {}) => ({
+ x,
+ z,
+ facing,
+ kind,
+ suit: overall,
+ ...extra,
+ });
+ const walk = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'walk', { to: { x: tx, z: tz }, ...extra });
+ const carry = (x, z, tx, tz, extra = {}) =>
+ W(x, z, 0, 'carry', { to: { x: tx, z: tz }, ...extra });
+ const crew = [
+ // Bending: two at each press, four carrying sheets.
+ W(-98.8, 32.3, Math.PI),
+ W(-97.2, 32.3, Math.PI),
+ W(-88.8, 32.3, Math.PI),
+ W(-87.2, 32.3, Math.PI, 'work', { suit: vest }),
+ carry(-101, 37.3, -99.5, 33.6),
+ carry(-95, 37.3, -96.2, 33.6, { suit: vest }),
+ carry(-90, 37.3, -90.5, 33.6),
+ carry(-85, 37.3, -86.2, 33.6),
+ // Laser cutting: operators at the bed and control panel, two at the shear, three moving parts.
+ W(-64.5, 34.6, Math.PI),
+ W(-68, 33.4, Math.PI),
+ W(-75, 33.4, Math.PI, 'work', { suit: vest }),
+ W(-73, 37.1, 0),
+ W(-71, 37.1, 0),
+ walk(-79, 35.3, -62, 35.3),
+ carry(-68, 40.4, -62, 40.4),
+ walk(-80, 37, -76, 40.4, { suit: vest }),
+ // Painting: four spraying inside the booth in white suits, three outside.
+ W(-55, 29.4, 0, 'work', { suit: paintSuit, hat: '#ffffff' }),
+ W(-51.5, 29.4, 0, 'work', { suit: paintSuit, hat: '#ffffff' }),
+ W(-53, 32.6, Math.PI, 'work', { suit: paintSuit, hat: '#ffffff' }),
+ W(-49, 32.6, Math.PI, 'work', { suit: paintSuit, hat: '#ffffff' }),
+ walk(-60, 23, -44, 23),
+ carry(-59, 39.4, -45, 39.4),
+ walk(-44.5, 26, -44.5, 37, { suit: vest }),
+ // Refrigeration: two at each table, three moving cabinets and coils.
+ W(-38, 28.4, 0),
+ W(-36, 28.4, 0),
+ W(-32, 28.4, 0, 'work', { suit: vest }),
+ W(-30, 28.4, 0),
+ W(-26, 28.4, 0),
+ W(-24, 28.4, 0),
+ walk(-39, 34.4, -22, 34.4),
+ carry(-24, 37, -38, 37),
+ walk(-31, 32.4, -31, 36),
+ // Assembly and packing.
+ W(-18, 31, 0),
+ W(-14.4, 31, 0),
+ W(-11, 31, 0, 'work', { suit: vest }),
+ W(-7.6, 31, 0),
+ W(-17.1, 38.4, 0),
+ W(-12.9, 38.4, 0),
+ carry(-20, 24, -4, 24),
+ carry(-3, 22.5, -3, 29),
+ ];
+ const workers = createCrew(group, crew);
+ for (const w of crew) if (w.kind === 'work') solid(w.x - 0.3, w.x + 0.3, w.z - 0.3, w.z + 0.3);
- // Assembly: conveyor carrying machine frames between robot arms, finished machines at the end.
- box(-20, -6, 32.4, 34.6, 0, .9, darkSteel);
- const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
- for (let u = -19.4; u < -6; u += 1.2) {
- const roller = cylinder(.12, 2.2, rollers, u, .95, 33.5);
- roller.rotation.x = Math.PI / 2;
- }
- const frames = [], frameMaterial = mat('#0b8f88', {wireframe: true});
- for (let i = 0; i < 4; i++) {
- const frame = new THREE.Mesh(new THREE.BoxGeometry(1.1, 2.4, 1.8), i % 2 ? frameMaterial : teal);
- frame.position.set(-19 + i * 3.5, 2.2, 33.5);
- frame.castShadow = true;
- group.add(frame);
- frames.push(frame);
- }
- const arms = [];
- for (const u of [-16, -10]) {
- box(u - .6, u + .6, 36, 37.2, 0, 1.2, yellow);
- const arm = new THREE.Group();
- arm.position.set(u, 1.2, 36.6);
- group.add(arm);
- const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), yellow);
- upper.position.y = 1.1;
- upper.castShadow = true;
- const fore = new THREE.Mesh(new THREE.BoxGeometry(.28, .28, 2.4), mat('#e0b23a'));
- fore.position.set(0, 2.1, -1.1);
- fore.castShadow = true;
- arm.add(upper, fore);
- arms.push(arm);
- }
- animated.push(t => {
- arms.forEach((arm, i) => arm.rotation.y = Math.sin(t * .8 + i * 2) * .9);
- frames.forEach((frame, i) => frame.position.x = -19.5 + ((t * .6 + i * 3.5) % 14));
+ // ---------- Office building: engineering, accounting, management ----------
+ // One storey, rooms side by side with their own doors onto the yard (front at v = -24).
+ const windows = textTexture(128, 128, (c, w, h) => {
+ c.fillStyle = '#e9e4d8';
+ c.fillRect(0, 0, w, h);
+ c.fillStyle = '#4c6672';
+ c.fillRect(14, 26, 100, 64);
+ c.fillStyle = 'rgba(255,255,255,.25)';
+ c.fillRect(18, 30, 28, 56);
+ });
+ windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
+ const facade = mat('#ffffff', { map: windows }),
+ plaster = mat('#ece8df', { roughness: 0.9 });
+ const OH = 3.8,
+ O0 = 6,
+ O1 = 50,
+ F = -24,
+ B = -46;
+ const doors = [
+ [14, 17],
+ [31, 34],
+ [43.5, 46.5],
+ ];
+ let from = O0;
+ for (const [d0, d1] of doors) {
+ box(from, d0, F - 0.3, F, 0, OH, plaster);
+ box(d0, d1, F - 0.3, F, 2.7, OH, plaster, false);
+ from = d1;
+ }
+ box(from, O1, F - 0.3, F, 0, OH, plaster);
+ box(O0, O1, B - 0.3, B, 0, OH, plaster);
+ box(O0 - 0.3, O0, B - 0.3, F, 0, OH, plaster);
+ box(O1, O1 + 0.3, B - 0.3, F, 0, OH, plaster);
+ box(25.85, 26.15, B, F - 0.3, 0, OH, plaster);
+ box(39.85, 40.15, B, F - 0.3, 0, OH, plaster);
+ const officeRoof = box(
+ O0 - 0.5,
+ O1 + 0.5,
+ B - 0.8,
+ F + 0.5,
+ OH,
+ OH + 0.25,
+ mat('#bdb5a5'),
+ false,
+ );
+ officeRoof.castShadow = false;
+ box(O0 - 0.5, O1 + 0.5, F + 0.4, F + 0.5, OH - 0.5, OH, teal, false);
+ // Windows on the front wall between the doors, and on the end walls.
+ for (const [u0, u1] of [
+ [7, 13.2],
+ [18, 30.2],
+ [35, 42.6],
+ [47.4, 49.4],
+ ]) {
+ const pane = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, 1.4), facade);
+ pane.position.set((u0 + u1) / 2, 1.9, F + 0.02);
+ facade.map.repeat.set(1, 1);
+ group.add(pane);
+ }
+ panel(nameSign, 22, 3.4, 28, OH + 2, F + 0.1);
+ for (const u of [18, 38]) box(u - 0.15, u + 0.15, F - 0.1, F + 0.05, OH, OH + 3.7, steel, false);
+ flat(O0, 26, B, F, 0.1, floorMat('#a9b4bb'));
+ flat(26, 40, B, F, 0.1, floorMat('#c8bba4'));
+ flat(40, O1, B, F, 0.1, floorMat('#7b5a44', { roughness: 0.5 }));
+ for (const [u0, u1] of [
+ [8, 24],
+ [28, 38],
+ [42, 48],
+ ])
+ box(u0, u1, -36, -34, OH - 0.08, OH - 0.02, lamp, false).castShadow = false;
+ panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, F + 0.05);
+ panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, F + 0.05);
+ panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, F + 0.05);
+
+ const wood = mat('#c8a477', { roughness: 0.6 }),
+ black = mat('#1b1d20', { roughness: 0.5 }),
+ fabric = mat('#2c3238');
+ // Desk facing `facing` (the side the person sits on) with one or two monitors.
+ function desk(u, v, sitSide, screens, width = 1.8) {
+ box(u - width / 2, u + width / 2, v - 0.45, v + 0.45, 0.72, 0.77, wood);
+ for (const du of [-1, 1])
+ box(
+ u + du * (width / 2 - 0.05) - 0.03,
+ u + du * (width / 2 - 0.05) + 0.03,
+ v - 0.4,
+ v + 0.4,
+ 0,
+ 0.72,
+ black,
+ false,
+ );
+ screens.forEach((texture, i) => {
+ const du = screens.length > 1 ? (i - 0.5) * 0.78 : 0;
+ const mats = [black, black, black, black, black, black];
+ mats[sitSide > 0 ? 4 : 5] = new THREE.MeshBasicMaterial({ map: texture });
+ const monitor = new THREE.Mesh(new THREE.BoxGeometry(0.72, 0.44, 0.04), mats);
+ monitor.position.set(u + du, 1.12, v - sitSide * 0.2);
+ monitor.rotation.y = (i - 0.5) * -0.25 * sitSide * (screens.length > 1 ? 1 : 0);
+ group.add(monitor);
+ box(
+ u + du - 0.03,
+ u + du + 0.03,
+ v - sitSide * 0.22 - 0.03,
+ v - sitSide * 0.22 + 0.03,
+ 0.77,
+ 0.9,
+ black,
+ false,
+ );
});
- for (let i = 0; i < 4; i++) {
- const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
- finished.beacon.visible = finished.ring.visible = false;
- const u = -19.2 + i * 4.2;
- finished.group.position.set(u, 0, 40.6);
- finished.group.rotation.y = Math.PI;
- group.add(finished.group);
- solid(u - 1, u + 1, 40, 41.2);
+ box(
+ u - 0.25,
+ u + 0.25,
+ v + sitSide * 0.12,
+ v + sitSide * 0.28,
+ 0.77,
+ 0.79,
+ mat('#30353b'),
+ false,
+ );
+ }
+ // Office chair behind a seated person; the back is on the far side from the desk.
+ function chair(u, v, sitSide) {
+ box(u - 0.26, u + 0.26, v - 0.26, v + 0.26, 0.4, 0.47, fabric, false);
+ box(u - 0.25, u + 0.25, v + sitSide * 0.26, v + sitSide * 0.32, 0.47, 1.1, fabric, false);
+ cylinder(0.035, 0.4, black, u, 0.2, v);
+ box(u - 0.3, u + 0.3, v - 0.03, v + 0.03, 0.02, 0.06, black, false);
+ box(u - 0.03, u + 0.03, v - 0.3, v + 0.3, 0.02, 0.06, black, false);
+ solid(u - 0.35, u + 0.35, v - 0.35, v + 0.35);
+ }
+ const people = [],
+ talks = [];
+ // A seated person at a desk; `sitSide` is +1 when they sit on the +v side facing −v.
+ function seat(person, u, v, sitSide, tag) {
+ person.root.position.set(u, 0, v);
+ person.root.rotation.y = sitSide > 0 ? Math.PI : 0;
+ group.add(person.root);
+ if (tag) {
+ tag.position.set(0, 1.78, 0);
+ person.root.add(tag);
}
+ chair(u, v, sitSide);
+ return person;
+ }
- // Packing: wrapped machines and a roller conveyor out through the dock door to the truck.
- const carton = textTexture(256, 512, (c, w, h) => {
- c.fillStyle = '#c49a64';
- c.fillRect(0, 0, w, h);
- c.fillStyle = 'rgba(255,255,255,.18)';
- for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
- c.fillStyle = '#0b8f88';
- c.fillRect(0, h * .42, w, 34);
- c.fillStyle = '#1d2a2a';
- c.textAlign = 'center';
- c.font = 'bold 34px sans-serif';
- c.fillText('ROBOT', w / 2, h * .3);
- c.fillText('MARKET', w / 2, h * .3 + 40);
- c.font = '60px sans-serif';
- c.fillText('⇧', w / 2, h * .75);
- });
- const cartonMat = mat('#ffffff', {map: carton, roughness: .85});
- const cartonGeometry = new THREE.BoxGeometry(.95, 1.9, 1.05);
- const packaged = () => {
- const mesh = new THREE.Mesh(cartonGeometry, cartonMat);
- mesh.castShadow = mesh.receiveShadow = true;
- return mesh;
- };
- for (const v of [37, 38.4, 39.8]) {
- const p = packaged();
- p.position.set(-3.2, .95, v);
- group.add(p);
- solid(-3.7, -2.7, v - .55, v + .55);
- }
- box(-6, 1.6, 30.4, 31.6, 0, 1.0, darkSteel);
- for (let u = -5.6; u < 1.6; u += .8) cylinder(.08, 1.1, rollers, u, 1.04, 31).rotation.x = Math.PI / 2;
-
- // ---------- Production crew: 40 workers ----------
- const overall = '#2f4e7a', vest = '#e8742c', paintSuit = '#eef1f2';
- const W = (x, z, facing, kind = 'work', extra = {}) => ({x, z, facing, kind, suit: overall, ...extra});
- const walk = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'walk', {to: {x: tx, z: tz}, ...extra});
- const carry = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'carry', {to: {x: tx, z: tz}, ...extra});
- const crew = [
- // Bending: two at each press, four carrying sheets.
- W(-98.8, 32.3, Math.PI), W(-97.2, 32.3, Math.PI), W(-88.8, 32.3, Math.PI), W(-87.2, 32.3, Math.PI, 'work', {suit: vest}),
- carry(-101, 37.3, -99.5, 33.6), carry(-95, 37.3, -96.2, 33.6, {suit: vest}), carry(-90, 37.3, -90.5, 33.6), carry(-85, 37.3, -86.2, 33.6),
- // Laser cutting: operators at the bed and control panel, two at the shear, three moving parts.
- W(-64.5, 34.6, Math.PI), W(-68, 33.4, Math.PI), W(-75, 33.4, Math.PI, 'work', {suit: vest}),
- W(-73, 37.1, 0), W(-71, 37.1, 0),
- walk(-79, 35.3, -62, 35.3), carry(-68, 40.4, -62, 40.4), walk(-80, 37, -76, 40.4, {suit: vest}),
- // Painting: four spraying inside the booth in white suits, three outside.
- W(-55, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-51.5, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}),
- W(-53, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-49, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}),
- walk(-60, 23, -44, 23), carry(-59, 39.4, -45, 39.4), walk(-44.5, 26, -44.5, 37, {suit: vest}),
- // Refrigeration: two at each table, three moving cabinets and coils.
- W(-38, 28.4, 0), W(-36, 28.4, 0), W(-32, 28.4, 0, 'work', {suit: vest}), W(-30, 28.4, 0), W(-26, 28.4, 0), W(-24, 28.4, 0),
- walk(-39, 34.4, -22, 34.4), carry(-24, 37, -38, 37), walk(-31, 32.4, -31, 36),
- // Assembly and packing.
- W(-18, 31, 0), W(-14.4, 31, 0), W(-11, 31, 0, 'work', {suit: vest}), W(-7.6, 31, 0),
- W(-17.1, 38.4, 0), W(-12.9, 38.4, 0),
- carry(-20, 24, -4, 24), carry(-3, 22.5, -3, 29)
- ];
- const workers = createCrew(group, crew);
- for (const w of crew) if (w.kind === 'work') solid(w.x - .3, w.x + .3, w.z - .3, w.z + .3);
-
- // ---------- Office building: engineering, accounting, management ----------
- // One storey, rooms side by side with their own doors onto the yard (front at v = -24).
- const windows = textTexture(128, 128, (c, w, h) => {
- c.fillStyle = '#e9e4d8';
- c.fillRect(0, 0, w, h);
- c.fillStyle = '#4c6672';
- c.fillRect(14, 26, 100, 64);
- c.fillStyle = 'rgba(255,255,255,.25)';
- c.fillRect(18, 30, 28, 56);
- });
- windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
- const facade = mat('#ffffff', {map: windows}), plaster = mat('#ece8df', {roughness: .9});
- const OH = 3.8, O0 = 6, O1 = 50, F = -24, B = -46;
- const doors = [[14, 17], [31, 34], [43.5, 46.5]];
- let from = O0;
- for (const [d0, d1] of doors) {
- box(from, d0, F - .3, F, 0, OH, plaster);
- box(d0, d1, F - .3, F, 2.7, OH, plaster, false);
- from = d1;
- }
- box(from, O1, F - .3, F, 0, OH, plaster);
- box(O0, O1, B - .3, B, 0, OH, plaster);
- box(O0 - .3, O0, B - .3, F, 0, OH, plaster);
- box(O1, O1 + .3, B - .3, F, 0, OH, plaster);
- box(25.85, 26.15, B, F - .3, 0, OH, plaster);
- box(39.85, 40.15, B, F - .3, 0, OH, plaster);
- const officeRoof = box(O0 - .5, O1 + .5, B - .8, F + .5, OH, OH + .25, mat('#bdb5a5'), false);
- officeRoof.castShadow = false;
- box(O0 - .5, O1 + .5, F + .4, F + .5, OH - .5, OH, teal, false);
- // Windows on the front wall between the doors, and on the end walls.
- for (const [u0, u1] of [[7, 13.2], [18, 30.2], [35, 42.6], [47.4, 49.4]]) {
- const pane = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, 1.4), facade);
- pane.position.set((u0 + u1) / 2, 1.9, F + .02);
- facade.map.repeat.set(1, 1);
- group.add(pane);
- }
- panel(nameSign, 22, 3.4, 28, OH + 2, F + .1);
- for (const u of [18, 38]) box(u - .15, u + .15, F - .1, F + .05, OH, OH + 3.7, steel, false);
- flat(O0, 26, B, F, .1, floorMat('#a9b4bb'));
- flat(26, 40, B, F, .1, floorMat('#c8bba4'));
- flat(40, O1, B, F, .1, floorMat('#7b5a44', {roughness: .5}));
- for (const [u0, u1] of [[8, 24], [28, 38], [42, 48]]) box(u0, u1, -36, -34, OH - .08, OH - .02, lamp, false).castShadow = false;
- panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, F + .05);
- panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, F + .05);
- panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, F + .05);
-
- const wood = mat('#c8a477', {roughness: .6}), black = mat('#1b1d20', {roughness: .5}), fabric = mat('#2c3238');
- // Desk facing `facing` (the side the person sits on) with one or two monitors.
- function desk(u, v, sitSide, screens, width = 1.8) {
- box(u - width / 2, u + width / 2, v - .45, v + .45, .72, .77, wood);
- for (const du of [-1, 1]) box(u + du * (width / 2 - .05) - .03, u + du * (width / 2 - .05) + .03, v - .4, v + .4, 0, .72, black, false);
- screens.forEach((texture, i) => {
- const du = screens.length > 1 ? (i - .5) * .78 : 0;
- const mats = [black, black, black, black, black, black];
- mats[sitSide > 0 ? 4 : 5] = new THREE.MeshBasicMaterial({map: texture});
- const monitor = new THREE.Mesh(new THREE.BoxGeometry(.72, .44, .04), mats);
- monitor.position.set(u + du, 1.12, v - sitSide * .2);
- monitor.rotation.y = (i - .5) * -.25 * sitSide * (screens.length > 1 ? 1 : 0);
- group.add(monitor);
- box(u + du - .03, u + du + .03, v - sitSide * .22 - .03, v - sitSide * .22 + .03, .77, .9, black, false);
- });
- box(u - .25, u + .25, v + sitSide * .12, v + sitSide * .28, .77, .79, mat('#30353b'), false);
- }
- // Office chair behind a seated person; the back is on the far side from the desk.
- function chair(u, v, sitSide) {
- box(u - .26, u + .26, v - .26, v + .26, .4, .47, fabric, false);
- box(u - .25, u + .25, v + sitSide * .26, v + sitSide * .32, .47, 1.1, fabric, false);
- cylinder(.035, .4, black, u, .2, v);
- box(u - .3, u + .3, v - .03, v + .03, .02, .06, black, false);
- box(u - .03, u + .03, v - .3, v + .3, .02, .06, black, false);
- solid(u - .35, u + .35, v - .35, v + .35);
- }
- const people = [], talks = [];
- // A seated person at a desk; `sitSide` is +1 when they sit on the +v side facing −v.
- function seat(person, u, v, sitSide, tag) {
- person.root.position.set(u, 0, v);
- person.root.rotation.y = sitSide > 0 ? Math.PI : 0;
- group.add(person.root);
- if (tag) {
- tag.position.set(0, 1.78, 0);
- person.root.add(tag);
- }
- chair(u, v, sitSide);
- return person;
- }
-
- // Engineering: five developers, a whiteboard, and the office's vending machine by the door.
- const developers = [
- {name: 'حامد', role: 'React', u: 11, v: -31.6, look: {outfit: 'hoodie', top: '#151517', bottom: '#2d3d57'}, code: CODE.react, speaker: 'hamed',
- lines: ['سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاهها رو با React میسازم.', 'هر چی روی صفحهٔ دستگاه میبینین، از همین مانیتور اومده!']},
- {name: 'رایان', role: 'Go', u: 16, v: -31.6, look: {outfit: 'hoodie', top: '#2f6fd6', bottom: '#33363b', skin: '#e9bf9c'}, code: CODE.go, speaker: 'ryan',
- lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاهها رو با Go مینویسم.']},
- {name: 'علیرضا', role: 'C++', u: 21, v: -31.6, look: {outfit: 'hoodie', top: '#1a1a1c', bottom: '#3b4a5e', hair: '#1a1410'}, code: CODE.cpp, speaker: 'alireza',
- lines: ['سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم میکنم.']},
- {name: 'رضا', role: 'C++', u: 13.5, v: -38.6, look: {outfit: 'suit', top: '#2b2f3a', bottom: '#2b2f3a', tie: '#7a1f2b', shoes: '#111'}, code: CODE.cpp, speaker: 'reza',
- lines: ['سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.', 'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!']},
- {name: 'محمد', role: '', u: 18.5, v: -38.6, look: {outfit: 'hoodie', top: '#6f767d', bottom: '#2a2f36', skin: '#d9a985'}, code: CODE.terminal, speaker: 'mohammad',
- lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!']}
- ];
- for (const dev of developers) {
- desk(dev.u, dev.v - 1.0, 1, [dev.code, CODE.terminal]);
- const person = seat(createPerson(dev.look), dev.u, dev.v, 1, nameTag(dev.name, dev.role));
- people.push({person, pose: 'type'});
- talks.push({speaker: dev.speaker, label: dev.name, lines: dev.lines, radius: 2.6, pos: () => toWorld(dev.u, dev.v)});
- }
- const board = textTexture(512, 256, (c, w, h) => {
- c.fillStyle = '#fbfbf8';
- c.fillRect(0, 0, w, h);
- c.strokeStyle = '#9aa4a8';
- c.lineWidth = 8;
- c.strokeRect(0, 0, w, h);
- c.textAlign = 'center';
- c.direction = 'rtl';
- c.fillStyle = '#1d4f91';
- c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
- c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
- c.font = '28px Vazirmatn, Tahoma, sans-serif';
- c.fillStyle = '#333';
- ['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
- });
- panel(board, 4, 2, 16, 1.9, B + .05);
- const officeMachine = {u: 6.8, v: -28.6};
-
- // Accounting: four colleagues at desks, filing cabinets along the wall.
- const accountants = [
- {u: 29.5, v: -31.6, look: {top: '#2d3550', scarf: '#c25b7a'}},
- {u: 36.5, v: -31.6, look: {top: '#1f1f22', scarf: '#3e6e8e', skin: '#edc2a0'}},
- {u: 29.5, v: -38.6, look: {top: '#8a6e55', scarf: '#e3d3b9'}},
- {u: 36.5, v: -38.6, look: {top: '#4a5a48', scarf: '#7a3b52', skin: '#d9a985'}}
- ];
- for (const a of accountants) {
- desk(a.u, a.v - 1.0, 1, [CODE.sheet]);
- people.push({person: seat(createPerson({outfit: 'manteau', bottom: '#24262b', shoes: '#111', ...a.look}), a.u, a.v, 1), pose: 'type'});
- }
- for (let i = 0; i < 4; i++) box(26.2, 26.9, -44.5 + i * 1, -43.6 + i * 1, 0, 1.4, mat('#9aa3a8', {metalness: .5}));
- talks.push({speaker: 'accountant', label: 'واحد حسابداری', radius: 4, pos: () => toWorld(33, -29),
- lines: ['سلام! اینجا فروش همهٔ دستگاهها ثبت میشه. هر خریدی بکنین، فاکتورش اینجا میاد!']});
-
- // Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
- desk(45, -39.7, -1, [CODE.dashboard], 2.4);
- chair(45, -40.7, -1);
- for (const u of [43.8, 46.2]) {
- box(u - .28, u + .28, -37.1, -36.5, .42, .5, mat('#5a3a2a'), false);
- box(u - .28, u + .28, -36.5, -36.4, .5, 1.0, mat('#5a3a2a'), false);
- solid(u - .3, u + .3, -37.1, -36.4);
- }
- box(41, 49, -45.9, -45.4, 0, 2.6, mat('#6b4a33'));
- for (let i = 0; i < 4; i++) for (let j = 0; j < 9; j++) box(41.3 + j * .85, 41.3 + j * .85 + .5, -45.4, -45.2, .3 + i * .6, .78 + i * .6, mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]), false);
- box(49, 49.5, -27, -26.5, 0, .5, mat('#7a5a3c'), true);
- const leaves = new THREE.Mesh(new THREE.SphereGeometry(.45, 10, 8), mat('#3e7a3e'));
- leaves.position.set(49.25, 1.0, -26.75);
- group.add(leaves);
- const nameplate = panel(sign('محمدی', 'مدیر شرکت'), .6, .19, 45, .87, -39.2, 1);
- nameplate.rotation.x = -.3;
-
- // ---------- Staff car park ----------
- for (let i = 0; i < 4; i++) {
- const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
- const u = 56 + i * 4;
- parked.group.position.set(u, 0, -19);
- parked.group.rotation.y = Math.PI / 2;
- group.add(parked.group);
- solid(u - 1.1, u + 1.1, -21.3, -16.7);
- }
-
- // ---------- The manager arrives in his pickup ----------
- // Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
- // his room and sits at his desk.
- const pickup = createPickup('#f1f1ee');
- group.add(pickup.group);
- const manager = createPerson({outfit: 'suit', top: '#3a3f47', bottom: '#3a3f47', hair: '#8d8a86', glasses: true, shoes: '#111'});
- const managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
- managerTag.position.y = 2.15;
- manager.root.add(managerTag);
- group.add(manager.root);
- const DRIVE_IN = [[98, -3], [80, -3], [62, -5], [51, -9], [46, -16]];
- const WALK_IN = [[47.4, -16.9], [45, -21], [45, -25.6], [48.5, -29], [48.5, -40.7], [45, -40.7]];
+ // Engineering: five developers, a whiteboard, and the office's vending machine by the door.
+ const developers = [
{
- // The parked pickup blocks its bay.
- const [ax, az] = DRIVE_IN.at(-2), [bx, bz] = DRIVE_IN.at(-1), len = Math.hypot(bx - ax, bz - az);
- const du = (bx - ax) / len, dv = (bz - az) / len;
- footprints.push([[2.7, 1], [2.7, -1], [-2.7, -1], [-2.7, 1]].flatMap(([a, b]) => Object.values(toWorld(bx + du * a - dv * b, bz + dv * a + du * b))));
- }
- const arrival = {stage: 'drive', s: 0, heading: Math.PI, sat: false};
- talks.push({speaker: 'mohammadi', label: 'آقای محمدی', radius: 3.4, pos: () => toWorld(manager.root.position.x, manager.root.position.z), ready: () => arrival.stage === 'seated',
- lines: ['سلام، خوش اومدین! من محمدیام، مدیر روبات مارکت.', 'امروز چهل نفر توی سالن تولید کار میکنن: خمکاری، برش، رنگ، یخچالسازی و مونتاژ.', 'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاههای شهر!']});
+ name: 'حامد',
+ role: 'React',
+ u: 11,
+ v: -31.6,
+ look: { outfit: 'hoodie', top: '#151517', bottom: '#2d3d57' },
+ code: CODE.react,
+ speaker: 'hamed',
+ lines: [
+ 'سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاهها رو با React میسازم.',
+ 'هر چی روی صفحهٔ دستگاه میبینین، از همین مانیتور اومده!',
+ ],
+ },
+ {
+ name: 'رایان',
+ role: 'Go',
+ u: 16,
+ v: -31.6,
+ look: { outfit: 'hoodie', top: '#2f6fd6', bottom: '#33363b', skin: '#e9bf9c' },
+ code: CODE.go,
+ speaker: 'ryan',
+ lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاهها رو با Go مینویسم.'],
+ },
+ {
+ name: 'علیرضا',
+ role: 'C++',
+ u: 21,
+ v: -31.6,
+ look: { outfit: 'hoodie', top: '#1a1a1c', bottom: '#3b4a5e', hair: '#1a1410' },
+ code: CODE.cpp,
+ speaker: 'alireza',
+ lines: [
+ 'سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم میکنم.',
+ ],
+ },
+ {
+ name: 'رضا',
+ role: 'C++',
+ u: 13.5,
+ v: -38.6,
+ look: { outfit: 'suit', top: '#2b2f3a', bottom: '#2b2f3a', tie: '#7a1f2b', shoes: '#111' },
+ code: CODE.cpp,
+ speaker: 'reza',
+ lines: [
+ 'سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.',
+ 'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!',
+ ],
+ },
+ {
+ name: 'محمد',
+ role: '',
+ u: 18.5,
+ v: -38.6,
+ look: { outfit: 'hoodie', top: '#6f767d', bottom: '#2a2f36', skin: '#d9a985' },
+ code: CODE.terminal,
+ speaker: 'mohammad',
+ lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!'],
+ },
+ ];
+ for (const dev of developers) {
+ desk(dev.u, dev.v - 1.0, 1, [dev.code, CODE.terminal]);
+ const person = seat(createPerson(dev.look), dev.u, dev.v, 1, nameTag(dev.name, dev.role));
+ people.push({ person, pose: 'type' });
+ talks.push({
+ speaker: dev.speaker,
+ label: dev.name,
+ lines: dev.lines,
+ radius: 2.6,
+ pos: () => toWorld(dev.u, dev.v),
+ });
+ }
+ const board = textTexture(512, 256, (c, w, h) => {
+ c.fillStyle = '#fbfbf8';
+ c.fillRect(0, 0, w, h);
+ c.strokeStyle = '#9aa4a8';
+ c.lineWidth = 8;
+ c.strokeRect(0, 0, w, h);
+ c.textAlign = 'center';
+ c.direction = 'rtl';
+ c.fillStyle = '#1d4f91';
+ c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
+ c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
+ c.font = '28px Vazirmatn, Tahoma, sans-serif';
+ c.fillStyle = '#333';
+ ['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) =>
+ c.fillText(t, w / 2, 110 + i * 44),
+ );
+ });
+ panel(board, 4, 2, 16, 1.9, B + 0.05);
+ const officeMachine = { u: 6.8, v: -28.6 };
- // Position and direction a distance `s` along a polyline of [u, v] points.
- const along = (path, s) => {
- for (let i = 1; i < path.length; i++) {
- const [ax, az] = path[i - 1], [bx, bz] = path[i], len = Math.hypot(bx - ax, bz - az);
- if (s <= len || i === path.length - 1) {
- const t = Math.min(1, s / len);
- return {u: ax + (bx - ax) * t, v: az + (bz - az) * t, du: (bx - ax) / len, dv: (bz - az) / len, done: s >= len && i === path.length - 1};
- }
- s -= len;
- }
- };
- const turn = (from, to, k) => from + Math.atan2(Math.sin(to - from), Math.cos(to - from)) * k;
- const placeVehicle = (vehicle, state, p, dt, reverse = false) => {
- const want = reverse ? Math.atan2(p.dv, -p.du) : Math.atan2(-p.dv, p.du);
- state.heading = dt ? turn(state.heading, want, Math.min(1, dt * 3)) : want;
- vehicle.group.position.set(p.u, 0, p.v);
- vehicle.group.rotation.y = state.heading;
- };
- function updateArrival(dt, time) {
- if (arrival.stage === 'drive') {
- arrival.s += dt * 7;
- const p = along(DRIVE_IN, arrival.s);
- placeVehicle(pickup, arrival, p, dt);
- pickup.wheels.forEach(w => w.rotation.z -= dt * 7 / .4);
- manager.root.visible = false;
- if (p.done) Object.assign(arrival, {stage: 'walk', s: 0});
- } else if (arrival.stage === 'walk') {
- arrival.s += dt * 1.3;
- const p = along(WALK_IN, arrival.s);
- manager.root.visible = true;
- manager.root.position.set(p.u, 0, p.v);
- manager.root.rotation.y = Math.atan2(p.du, p.dv);
- manager.pose('walk', time);
- if (p.done) arrival.stage = 'seated';
- } else if (!arrival.sat) {
- arrival.sat = true;
- manager.root.position.set(45, 0, -40.7);
- manager.root.rotation.y = 0;
- manager.pose('sit');
- }
- }
- function resetArrival() {
- Object.assign(arrival, {stage: 'drive', s: 0, sat: false});
- placeVehicle(pickup, arrival, along(DRIVE_IN, 0), 0);
- manager.root.visible = false;
- }
- resetArrival();
+ // Accounting: four colleagues at desks, filing cabinets along the wall.
+ const accountants = [
+ { u: 29.5, v: -31.6, look: { top: '#2d3550', scarf: '#c25b7a' } },
+ { u: 36.5, v: -31.6, look: { top: '#1f1f22', scarf: '#3e6e8e', skin: '#edc2a0' } },
+ { u: 29.5, v: -38.6, look: { top: '#8a6e55', scarf: '#e3d3b9' } },
+ { u: 36.5, v: -38.6, look: { top: '#4a5a48', scarf: '#7a3b52', skin: '#d9a985' } },
+ ];
+ for (const a of accountants) {
+ desk(a.u, a.v - 1.0, 1, [CODE.sheet]);
+ people.push({
+ person: seat(
+ createPerson({ outfit: 'manteau', bottom: '#24262b', shoes: '#111', ...a.look }),
+ a.u,
+ a.v,
+ 1,
+ ),
+ pose: 'type',
+ });
+ }
+ for (let i = 0; i < 4; i++)
+ box(26.2, 26.9, -44.5 + i * 1, -43.6 + i * 1, 0, 1.4, mat('#9aa3a8', { metalness: 0.5 }));
+ talks.push({
+ speaker: 'accountant',
+ label: 'واحد حسابداری',
+ radius: 4,
+ pos: () => toWorld(33, -29),
+ lines: ['سلام! اینجا فروش همهٔ دستگاهها ثبت میشه. هر خریدی بکنین، فاکتورش اینجا میاد!'],
+ });
- // ---------- Delivery truck ----------
- // Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out
- // of the gate to the city, and comes back empty a while later, reversing up to the dock.
- const truck = createTruck();
- group.add(truck.group);
- const DOCK = [5.5, 31];
- const DEPART = [DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
- const RETURN = [[100, 0], [80, 0], [60, 6], [40, 14], [27, 18]];
- const BACK_IN = [[27, 18], [16, 28.5], DOCK];
- const SLOTS = [2.1, .95, -.2, -1.35].flatMap(x => [[x, -.6], [x, .6]]);
- const loaded = [];
- const moving = packaged();
- group.add(moving);
- const delivery = {stage: 'load', t: 0, s: 0, heading: 0};
- const truckAtDock = () => {
- truck.group.visible = true;
- truck.group.position.set(DOCK[0], 0, DOCK[1]);
- truck.group.rotation.y = delivery.heading = 0;
- };
- truckAtDock();
- function updateTruck(dt) {
- const d = delivery;
- d.t += dt;
- moving.visible = d.stage === 'load';
- if (d.stage === 'load') {
- // Each box: two seconds along the conveyor, one second lifted onto its slot.
- const [sx, sz] = SLOTS[loaded.length], bed = truck.bed.position;
- const slot = {u: DOCK[0] + bed.x + sx, y: bed.y + .95, v: DOCK[1] + sz};
- if (d.t < 2) moving.position.set(-6 + (1.6 + 6) * d.t / 2, 1.05 + .95, 31);
- else {
- const k = Math.min(1, d.t - 2);
- moving.position.set(1.6 + (slot.u - 1.6) * k, 2.0 + (slot.y - 2.0) * k + Math.sin(k * Math.PI) * .5, 31 + (slot.v - 31) * k);
- }
- if (d.t >= 3) {
- const box = packaged();
- box.position.set(sx, .95, sz);
- truck.bed.add(box);
- loaded.push(box);
- d.t = 0;
- if (loaded.length === SLOTS.length) d.stage = 'full';
- }
- } else if (d.stage === 'full' && d.t > 2) Object.assign(d, {stage: 'depart', s: 0});
- else if (d.stage === 'depart' || d.stage === 'return' || d.stage === 'back') {
- const speed = d.stage === 'back' ? 2.5 : 6;
- d.s += dt * speed;
- const path = {depart: DEPART, return: RETURN, back: BACK_IN}[d.stage];
- const p = along(path, d.s);
- placeVehicle(truck, d, p, dt, d.stage === 'back');
- truck.wheels.forEach(w => w.rotation.z -= dt * speed / .5 * (d.stage === 'back' ? -1 : 1));
- if (p.done) {
- if (d.stage === 'depart') {
- Object.assign(d, {stage: 'away', t: 0});
- truck.group.visible = false;
- loaded.splice(0).forEach(box => truck.bed.remove(box));
- } else if (d.stage === 'return') Object.assign(d, {stage: 'back', s: 0});
- else {
- truckAtDock();
- Object.assign(d, {stage: 'load', t: 0});
- }
- }
- } else if (d.stage === 'away' && d.t > 18) {
- truck.group.visible = true;
- Object.assign(d, {stage: 'return', s: 0, heading: Math.PI});
- }
- }
- talks.push({speaker: 'child', radius: 9, pos: () => toWorld(4, 31), once: true,
- lines: ['بابا ببین! کامیون داره دستگاههای بستهبندیشده رو بار میزنه که ببره شهر!']});
- // The family comments on each work area the first time they walk into it.
- for (const [u, text, speaker] of [
- [-93, 'اینجا خمکاریه؛ ورقهای فلزی بدنهٔ دستگاه رو با پرس خم میکنن.', 'father'],
- [-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ میبُره.', 'child'],
- [-52, 'این کابین رنگه؛ بدنهها اینجا رنگ فیروزهای روبات مارکت میگیرن.', 'father'],
- [-31, 'یخچالسازی! برای همینه که نوشیدنیهای دستگاه همیشه خنکن.', 'child'],
- [-13, 'خط مونتاژ: روباتها و کارگرها قطعهها رو کنار هم میذارن و دستگاه کامل میشه.', 'father']
- ]) talks.push({speaker, radius: 8, pos: () => toWorld(u, 25), once: true, lines: [text]});
+ // Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
+ desk(45, -39.7, -1, [CODE.dashboard], 2.4);
+ chair(45, -40.7, -1);
+ for (const u of [43.8, 46.2]) {
+ box(u - 0.28, u + 0.28, -37.1, -36.5, 0.42, 0.5, mat('#5a3a2a'), false);
+ box(u - 0.28, u + 0.28, -36.5, -36.4, 0.5, 1.0, mat('#5a3a2a'), false);
+ solid(u - 0.3, u + 0.3, -37.1, -36.4);
+ }
+ box(41, 49, -45.9, -45.4, 0, 2.6, mat('#6b4a33'));
+ for (let i = 0; i < 4; i++)
+ for (let j = 0; j < 9; j++)
+ box(
+ 41.3 + j * 0.85,
+ 41.3 + j * 0.85 + 0.5,
+ -45.4,
+ -45.2,
+ 0.3 + i * 0.6,
+ 0.78 + i * 0.6,
+ mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]),
+ false,
+ );
+ box(49, 49.5, -27, -26.5, 0, 0.5, mat('#7a5a3c'), true);
+ const leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), mat('#3e7a3e'));
+ leaves.position.set(49.25, 1.0, -26.75);
+ group.add(leaves);
+ const nameplate = panel(sign('محمدی', 'مدیر شرکت'), 0.6, 0.19, 45, 0.87, -39.2, 1);
+ nameplate.rotation.x = -0.3;
- // ---------- Key positions ----------
- // The family starts in the yard between the hall and the office, the car parked right beside them.
- const demo = toWorld(officeMachine.u, officeMachine.v), facing = {x: axis.x, z: axis.z};
- const car = toWorld(1.5, -7);
- const start = toWorld(-1, -3.6), door = toWorld(15.5, -24);
- return {
- group,
- footprints,
- demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
- car: {x: car.x, z: car.z, heading: worldHeading(1, 0)},
- start: {x: start.x, z: start.z, heading: Math.atan2(-(door.z - start.z), door.x - start.x)},
- label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
- talks,
- reset: resetArrival,
- // `dt` is 0 while the game is paused; distant viewers skip the people and machines.
- update(time, dt, viewer) {
- const near = !viewer || Math.hypot(viewer.x - center.x, viewer.z - center.z) < 260;
- updateArrival(dt, time);
- updateTruck(dt);
- workers.group.visible = near;
- if (!near) return;
- for (const step of animated) step(time);
- workers.update(time);
- people.forEach(({person, pose}, i) => person.pose(pose, time, i * 1.7));
+ // ---------- Staff car park ----------
+ for (let i = 0; i < 4; i++) {
+ const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
+ const u = 56 + i * 4;
+ parked.group.position.set(u, 0, -19);
+ parked.group.rotation.y = Math.PI / 2;
+ group.add(parked.group);
+ solid(u - 1.1, u + 1.1, -21.3, -16.7);
+ }
+
+ // ---------- The manager arrives in his pickup ----------
+ // Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
+ // his room and sits at his desk.
+ const pickup = createPickup('#f1f1ee');
+ group.add(pickup.group);
+ const manager = createPerson({
+ outfit: 'suit',
+ top: '#3a3f47',
+ bottom: '#3a3f47',
+ hair: '#8d8a86',
+ glasses: true,
+ shoes: '#111',
+ });
+ const managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
+ managerTag.position.y = 2.15;
+ manager.root.add(managerTag);
+ group.add(manager.root);
+ const DRIVE_IN = [
+ [98, -3],
+ [80, -3],
+ [62, -5],
+ [51, -9],
+ [46, -16],
+ ];
+ const WALK_IN = [
+ [47.4, -16.9],
+ [45, -21],
+ [45, -25.6],
+ [48.5, -29],
+ [48.5, -40.7],
+ [45, -40.7],
+ ];
+ {
+ // The parked pickup blocks its bay.
+ const [ax, az] = DRIVE_IN.at(-2),
+ [bx, bz] = DRIVE_IN.at(-1),
+ len = Math.hypot(bx - ax, bz - az);
+ const du = (bx - ax) / len,
+ dv = (bz - az) / len;
+ footprints.push(
+ [
+ [2.7, 1],
+ [2.7, -1],
+ [-2.7, -1],
+ [-2.7, 1],
+ ].flatMap(([a, b]) => Object.values(toWorld(bx + du * a - dv * b, bz + dv * a + du * b))),
+ );
+ }
+ const arrival = { stage: 'drive', s: 0, heading: Math.PI, sat: false };
+ talks.push({
+ speaker: 'mohammadi',
+ label: 'آقای محمدی',
+ radius: 3.4,
+ pos: () => toWorld(manager.root.position.x, manager.root.position.z),
+ ready: () => arrival.stage === 'seated',
+ lines: [
+ 'سلام، خوش اومدین! من محمدیام، مدیر روبات مارکت.',
+ 'امروز چهل نفر توی سالن تولید کار میکنن: خمکاری، برش، رنگ، یخچالسازی و مونتاژ.',
+ 'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاههای شهر!',
+ ],
+ });
+
+ // Position and direction a distance `s` along a polyline of [u, v] points.
+ const along = (path, s) => {
+ for (let i = 1; i < path.length; i++) {
+ const [ax, az] = path[i - 1],
+ [bx, bz] = path[i],
+ len = Math.hypot(bx - ax, bz - az);
+ if (s <= len || i === path.length - 1) {
+ const t = Math.min(1, s / len);
+ return {
+ u: ax + (bx - ax) * t,
+ v: az + (bz - az) * t,
+ du: (bx - ax) / len,
+ dv: (bz - az) / len,
+ done: s >= len && i === path.length - 1,
+ };
+ }
+ s -= len;
+ }
+ };
+ const turn = (from, to, k) => from + Math.atan2(Math.sin(to - from), Math.cos(to - from)) * k;
+ const placeVehicle = (vehicle, state, p, dt, reverse = false) => {
+ const want = reverse ? Math.atan2(p.dv, -p.du) : Math.atan2(-p.dv, p.du);
+ state.heading = dt ? turn(state.heading, want, Math.min(1, dt * 3)) : want;
+ vehicle.group.position.set(p.u, 0, p.v);
+ vehicle.group.rotation.y = state.heading;
+ };
+ function updateArrival(dt, time) {
+ if (arrival.stage === 'drive') {
+ arrival.s += dt * 7;
+ const p = along(DRIVE_IN, arrival.s);
+ placeVehicle(pickup, arrival, p, dt);
+ pickup.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4));
+ manager.root.visible = false;
+ if (p.done) Object.assign(arrival, { stage: 'walk', s: 0 });
+ } else if (arrival.stage === 'walk') {
+ arrival.s += dt * 1.3;
+ const p = along(WALK_IN, arrival.s);
+ manager.root.visible = true;
+ manager.root.position.set(p.u, 0, p.v);
+ manager.root.rotation.y = Math.atan2(p.du, p.dv);
+ manager.pose('walk', time);
+ if (p.done) arrival.stage = 'seated';
+ } else if (!arrival.sat) {
+ arrival.sat = true;
+ manager.root.position.set(45, 0, -40.7);
+ manager.root.rotation.y = 0;
+ manager.pose('sit');
+ }
+ }
+ function resetArrival() {
+ Object.assign(arrival, { stage: 'drive', s: 0, sat: false });
+ placeVehicle(pickup, arrival, along(DRIVE_IN, 0), 0);
+ manager.root.visible = false;
+ }
+ resetArrival();
+
+ // ---------- Delivery truck ----------
+ // Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out
+ // of the gate to the city, and comes back empty a while later, reversing up to the dock.
+ const truck = createTruck();
+ group.add(truck.group);
+ const DOCK = [5.5, 31];
+ const DEPART = [DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
+ const RETURN = [
+ [100, 0],
+ [80, 0],
+ [60, 6],
+ [40, 14],
+ [27, 18],
+ ];
+ const BACK_IN = [[27, 18], [16, 28.5], DOCK];
+ const SLOTS = [2.1, 0.95, -0.2, -1.35].flatMap((x) => [
+ [x, -0.6],
+ [x, 0.6],
+ ]);
+ const loaded = [];
+ const moving = packaged();
+ group.add(moving);
+ const delivery = { stage: 'load', t: 0, s: 0, heading: 0 };
+ const truckAtDock = () => {
+ truck.group.visible = true;
+ truck.group.position.set(DOCK[0], 0, DOCK[1]);
+ truck.group.rotation.y = delivery.heading = 0;
+ };
+ truckAtDock();
+ function updateTruck(dt) {
+ const d = delivery;
+ d.t += dt;
+ moving.visible = d.stage === 'load';
+ if (d.stage === 'load') {
+ // Each box: two seconds along the conveyor, one second lifted onto its slot.
+ const [sx, sz] = SLOTS[loaded.length],
+ bed = truck.bed.position;
+ const slot = { u: DOCK[0] + bed.x + sx, y: bed.y + 0.95, v: DOCK[1] + sz };
+ if (d.t < 2) moving.position.set(-6 + ((1.6 + 6) * d.t) / 2, 1.05 + 0.95, 31);
+ else {
+ const k = Math.min(1, d.t - 2);
+ moving.position.set(
+ 1.6 + (slot.u - 1.6) * k,
+ 2.0 + (slot.y - 2.0) * k + Math.sin(k * Math.PI) * 0.5,
+ 31 + (slot.v - 31) * k,
+ );
+ }
+ if (d.t >= 3) {
+ const box = packaged();
+ box.position.set(sx, 0.95, sz);
+ truck.bed.add(box);
+ loaded.push(box);
+ d.t = 0;
+ if (loaded.length === SLOTS.length) d.stage = 'full';
+ }
+ } else if (d.stage === 'full' && d.t > 2) Object.assign(d, { stage: 'depart', s: 0 });
+ else if (d.stage === 'depart' || d.stage === 'return' || d.stage === 'back') {
+ const speed = d.stage === 'back' ? 2.5 : 6;
+ d.s += dt * speed;
+ const path = { depart: DEPART, return: RETURN, back: BACK_IN }[d.stage];
+ const p = along(path, d.s);
+ placeVehicle(truck, d, p, dt, d.stage === 'back');
+ truck.wheels.forEach(
+ (w) => (w.rotation.z -= ((dt * speed) / 0.5) * (d.stage === 'back' ? -1 : 1)),
+ );
+ if (p.done) {
+ if (d.stage === 'depart') {
+ Object.assign(d, { stage: 'away', t: 0 });
+ truck.group.visible = false;
+ loaded.splice(0).forEach((box) => truck.bed.remove(box));
+ } else if (d.stage === 'return') Object.assign(d, { stage: 'back', s: 0 });
+ else {
+ truckAtDock();
+ Object.assign(d, { stage: 'load', t: 0 });
}
- };
+ }
+ } else if (d.stage === 'away' && d.t > 18) {
+ truck.group.visible = true;
+ Object.assign(d, { stage: 'return', s: 0, heading: Math.PI });
+ }
+ }
+ talks.push({
+ speaker: 'child',
+ radius: 9,
+ pos: () => toWorld(4, 31),
+ once: true,
+ lines: ['بابا ببین! کامیون داره دستگاههای بستهبندیشده رو بار میزنه که ببره شهر!'],
+ });
+ // The family comments on each work area the first time they walk into it.
+ for (const [u, text, speaker] of [
+ [-93, 'اینجا خمکاریه؛ ورقهای فلزی بدنهٔ دستگاه رو با پرس خم میکنن.', 'father'],
+ [-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ میبُره.', 'child'],
+ [-52, 'این کابین رنگه؛ بدنهها اینجا رنگ فیروزهای روبات مارکت میگیرن.', 'father'],
+ [-31, 'یخچالسازی! برای همینه که نوشیدنیهای دستگاه همیشه خنکن.', 'child'],
+ [
+ -13,
+ 'خط مونتاژ: روباتها و کارگرها قطعهها رو کنار هم میذارن و دستگاه کامل میشه.',
+ 'father',
+ ],
+ ])
+ talks.push({ speaker, radius: 8, pos: () => toWorld(u, 25), once: true, lines: [text] });
+
+ // ---------- Key positions ----------
+ // The family starts in the yard between the hall and the office, the car parked right beside them.
+ const demo = toWorld(officeMachine.u, officeMachine.v),
+ facing = { x: axis.x, z: axis.z };
+ const car = toWorld(1.5, -7);
+ const start = toWorld(-1, -3.6),
+ door = toWorld(15.5, -24);
+ return {
+ group,
+ footprints,
+ demo: { x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z) },
+ car: { x: car.x, z: car.z, heading: worldHeading(1, 0) },
+ start: { x: start.x, z: start.z, heading: Math.atan2(-(door.z - start.z), door.x - start.x) },
+ label: { name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0) },
+ talks,
+ reset: resetArrival,
+ // `dt` is 0 while the game is paused; distant viewers skip the people and machines.
+ update(time, dt, viewer) {
+ const near = !viewer || Math.hypot(viewer.x - center.x, viewer.z - center.z) < 260;
+ updateArrival(dt, time);
+ updateTruck(dt);
+ workers.group.visible = near;
+ if (!near) return;
+ for (const step of animated) step(time);
+ workers.update(time);
+ people.forEach(({ person, pose }, i) => person.pose(pose, time, i * 1.7));
+ },
+ };
}
diff --git a/src/game-state.js b/src/game-state.js
index 3cdb039..9f7980c 100644
--- a/src/game-state.js
+++ b/src/game-state.js
@@ -1,56 +1,75 @@
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30;
-export const fa = n => n.toLocaleString('fa-IR');
-export const formatHetoman = value => `${fa(value)} هتومان`;
-export const hetomanToRial = value => value * 10000;
-export const formatClock = minutes => {
- const m = Math.floor(minutes) % (24 * 60);
- return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
+export const fa = (n) => n.toLocaleString('fa-IR');
+export const formatHetoman = (value) => `${fa(value)} هتومان`;
+export const hetomanToRial = (value) => value * 10000;
+export const formatClock = (minutes) => {
+ const m = Math.floor(minutes) % (24 * 60);
+ return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
};
export const catalog = {
- juice: {id: 'juice', name: 'آبمیوه', icon: '🧃', cost: 35, type: 'drink'},
- water: {id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink'},
- milk: {id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink'},
- tea: {id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink'},
- lemonade: {id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink'},
- biscuit: {id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack'},
- chocolate: {id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack'},
- nuts: {id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack'},
- cake: {id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack'}
+ juice: { id: 'juice', name: 'آبمیوه', icon: '🧃', cost: 35, type: 'drink' },
+ water: { id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink' },
+ milk: { id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink' },
+ tea: { id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink' },
+ lemonade: { id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink' },
+ biscuit: { id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack' },
+ chocolate: { id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack' },
+ nuts: { id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack' },
+ cake: { id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack' },
};
-export const priceAt = (machine, productId) => Math.round(catalog[productId].cost * machine.priceFactor);
+export const priceAt = (machine, productId) =>
+ Math.round(catalog[productId].cost * machine.priceFactor);
export function isOpen(machine, minutes) {
- const m = minutes % (24 * 60);
- return m >= machine.hours[0] && m < machine.hours[1];
+ const m = minutes % (24 * 60);
+ return m >= machine.hours[0] && m < machine.hours[1];
}
export function createState() {
- return {balance: INITIAL_BALANCE, purchases: [], xp: 0, missionsDone: [], minutes: START_MINUTES, busy: false};
+ return {
+ balance: INITIAL_BALANCE,
+ purchases: [],
+ xp: 0,
+ missionsDone: [],
+ minutes: START_MINUTES,
+ busy: false,
+ };
}
// Returns why a purchase cannot happen, or null when it can.
export function purchaseBlocker(state, machine, productId) {
- if (!machine || !machine.products.includes(productId)) return 'unknown';
- if (state.busy) return 'busy';
- if (!isOpen(machine, state.minutes)) return 'closed';
- if (state.balance < priceAt(machine, productId)) return 'funds';
- return null;
+ if (!machine || !machine.products.includes(productId)) return 'unknown';
+ if (state.busy) return 'busy';
+ if (!isOpen(machine, state.minutes)) return 'closed';
+ if (state.balance < priceAt(machine, productId)) return 'funds';
+ return null;
}
export function purchase(state, machine, productId) {
- if (purchaseBlocker(state, machine, productId)) return null;
- const product = catalog[productId], price = priceAt(machine, productId);
- const record = {machineId: machine.id, category: machine.category, productId, type: product.type, price, minutes: state.minutes};
- state.balance -= price;
- state.purchases.push(record);
- return record;
+ if (purchaseBlocker(state, machine, productId)) return null;
+ const product = catalog[productId],
+ price = priceAt(machine, productId);
+ const record = {
+ machineId: machine.id,
+ category: machine.category,
+ productId,
+ type: product.type,
+ price,
+ minutes: state.minutes,
+ };
+ state.balance -= price;
+ state.purchases.push(record);
+ return record;
}
// The run ends once no open or later-opening machine sells anything the player can afford.
export function canAffordAnything(state, machines) {
- return machines.some(m => m.products.some(id => priceAt(m, id) <= state.balance) && (state.minutes % 1440) < m.hours[1]);
+ return machines.some(
+ (m) =>
+ m.products.some((id) => priceAt(m, id) <= state.balance) && state.minutes % 1440 < m.hours[1],
+ );
}
diff --git a/src/geo.js b/src/geo.js
index 138b464..803df02 100644
--- a/src/geo.js
+++ b/src/geo.js
@@ -1,22 +1,24 @@
// Local tangent-plane projection around the playable Park Mellat area.
// World axes: +x east, -z north, 1 unit = 1 metre.
-export const ORIGIN = {lat: 36.315, lon: 59.539};
+export const ORIGIN = { lat: 36.315, lon: 59.539 };
const METRES_PER_DEG_LAT = 110574;
-const METRES_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * Math.PI / 180);
+const METRES_PER_DEG_LON = 111320 * Math.cos((ORIGIN.lat * Math.PI) / 180);
export function toLocal(lat, lon) {
- return {x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT};
+ return { x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT };
}
export function toLatLon(x, z) {
- return {lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON};
+ return { lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON };
}
// Compass bearing in degrees (0 = north, 90 = east) of a world-space direction.
export function bearing(dx, dz) {
- return (Math.atan2(dx, -dz) * 180 / Math.PI + 360) % 360;
+ return ((Math.atan2(dx, -dz) * 180) / Math.PI + 360) % 360;
}
export function formatDistance(metres) {
- return metres < 1000 ? `${Math.round(metres).toLocaleString('fa-IR')} متر` : `${(Math.round(metres / 100) / 10).toLocaleString('fa-IR')} کیلومتر`;
+ return metres < 1000
+ ? `${Math.round(metres).toLocaleString('fa-IR')} متر`
+ : `${(Math.round(metres / 100) / 10).toLocaleString('fa-IR')} کیلومتر`;
}
diff --git a/src/imagery.js b/src/imagery.js
index b700ada..54a7416 100644
--- a/src/imagery.js
+++ b/src/imagery.js
@@ -1,89 +1,117 @@
import * as THREE from 'three';
-import {toLocal} from './geo.js';
+import { toLocal } from './geo.js';
// Satellite ground: Esri World Imagery tiles (public/imagery, see scripts/fetch-imagery.mjs) laid on
// the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further
// out underneath them, and tiles out of range are released.
-const tileLon = (x, z) => x / 2 ** z * 360 - 180;
-const tileLat = (y, z) => Math.atan(Math.sinh(Math.PI * (1 - 2 * y / 2 ** z))) * 180 / Math.PI;
+const tileLon = (x, z) => (x / 2 ** z) * 360 - 180;
+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.
export function tileCorners(x, y, z) {
- const n = tileLat(y, z), s = tileLat(y + 1, z), w = tileLon(x, z), e = tileLon(x + 1, z);
- return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
+ const n = tileLat(y, z),
+ s = tileLat(y + 1, z),
+ w = tileLon(x, z),
+ e = tileLon(x + 1, z);
+ return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
}
-export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality = false, anisotropy = 4} = {}) {
- const group = new THREE.Group();
- scene.add(group);
- const loader = new THREE.TextureLoader();
- // Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
- const levels = [
- {z: 17, range: lowQuality ? 450 : 750, y: .012, offset: -3},
- {z: 15, range: lowQuality ? 1800 : 2800, y: .004, offset: -1}
- ].map(level => ({
- ...level,
- tiles: (index[level.z] ?? []).map(key => {
- const [x, y] = key.split('/').map(Number), corners = tileCorners(x, y, level.z);
- const cx = corners.reduce((s, c) => s + c.x, 0) / 4, cz = corners.reduce((s, c) => s + c.z, 0) / 4;
- return {key, x, y, corners, cx, cz, mesh: null, loading: false};
- })
- }));
- let loads = 0;
+export function createImagery(
+ scene,
+ index,
+ { baseUrl = './imagery/', lowQuality = false, anisotropy = 4 } = {},
+) {
+ const group = new THREE.Group();
+ scene.add(group);
+ const loader = new THREE.TextureLoader();
+ // Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
+ const levels = [
+ { z: 17, range: lowQuality ? 450 : 750, y: 0.012, offset: -3 },
+ { z: 15, range: lowQuality ? 1800 : 2800, y: 0.004, offset: -1 },
+ ].map((level) => ({
+ ...level,
+ tiles: (index[level.z] ?? []).map((key) => {
+ const [x, y] = key.split('/').map(Number),
+ corners = tileCorners(x, y, level.z);
+ const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
+ cz = corners.reduce((s, c) => s + c.z, 0) / 4;
+ return { key, x, y, corners, cx, cz, mesh: null, loading: false };
+ }),
+ }));
+ let loads = 0;
- function show(level, tile) {
- if (tile.mesh || tile.loading || loads >= 6) return;
- tile.loading = true;
- loads++;
- loader.load(`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`, texture => {
- loads--;
- tile.loading = false;
- texture.colorSpace = THREE.SRGBColorSpace;
- // Sharp at the low angles a driver sees the road from.
- texture.anisotropy = anisotropy;
- const [nw, ne, sw, se] = tile.corners, geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([nw.x, level.y, nw.z, ne.x, level.y, ne.z, sw.x, level.y, sw.z, se.x, level.y, se.z], 3));
- geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
- geometry.setIndex([0, 2, 1, 1, 2, 3]);
- tile.mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
- map: texture, toneMapped: false, polygonOffset: true, polygonOffsetFactor: level.offset, polygonOffsetUnits: level.offset * 2
- }));
- group.add(tile.mesh);
- }, undefined, () => {
- loads--;
- tile.loading = false;
- tile.failed = true;
- });
- }
+ function show(level, tile) {
+ if (tile.mesh || tile.loading || loads >= 6) return;
+ tile.loading = true;
+ loads++;
+ loader.load(
+ `${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`,
+ (texture) => {
+ loads--;
+ tile.loading = false;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ // Sharp at the low angles a driver sees the road from.
+ texture.anisotropy = anisotropy;
+ const [nw, ne, sw, se] = tile.corners,
+ geometry = new THREE.BufferGeometry();
+ geometry.setAttribute(
+ 'position',
+ new THREE.Float32BufferAttribute(
+ [nw.x, level.y, nw.z, ne.x, level.y, ne.z, sw.x, level.y, sw.z, se.x, level.y, se.z],
+ 3,
+ ),
+ );
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
+ geometry.setIndex([0, 2, 1, 1, 2, 3]);
+ tile.mesh = new THREE.Mesh(
+ geometry,
+ new THREE.MeshBasicMaterial({
+ map: texture,
+ toneMapped: false,
+ polygonOffset: true,
+ polygonOffsetFactor: level.offset,
+ polygonOffsetUnits: level.offset * 2,
+ }),
+ );
+ group.add(tile.mesh);
+ },
+ undefined,
+ () => {
+ loads--;
+ tile.loading = false;
+ tile.failed = true;
+ },
+ );
+ }
- function hide(tile) {
- if (!tile.mesh) return;
- group.remove(tile.mesh);
- tile.mesh.geometry.dispose();
- tile.mesh.material.map.dispose();
- tile.mesh.material.dispose();
- tile.mesh = null;
- }
+ function hide(tile) {
+ if (!tile.mesh) return;
+ group.remove(tile.mesh);
+ tile.mesh.geometry.dispose();
+ tile.mesh.material.map.dispose();
+ tile.mesh.material.dispose();
+ tile.mesh = null;
+ }
- let last = null;
- return {
- group,
- available: levels.some(level => level.tiles.length),
- // Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
- update(x, z) {
- const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
- if (!moved && loads >= 6) return;
- last = {x, z};
- for (const level of levels) {
- const wanted = [];
- for (const tile of level.tiles) {
- const d = Math.hypot(tile.cx - x, tile.cz - z);
- if (d > level.range * 1.2) hide(tile);
- else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
- }
- wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
- }
+ let last = null;
+ return {
+ group,
+ available: levels.some((level) => level.tiles.length),
+ // Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
+ update(x, z) {
+ const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
+ if (!moved && loads >= 6) return;
+ last = { x, z };
+ for (const level of levels) {
+ const wanted = [];
+ for (const tile of level.tiles) {
+ const d = Math.hypot(tile.cx - x, tile.cz - z);
+ if (d > level.range * 1.2) hide(tile);
+ else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
}
- };
+ wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
+ }
+ },
+ };
}
diff --git a/src/locations.js b/src/locations.js
index 8c51549..9bcf6c8 100644
--- a/src/locations.js
+++ b/src/locations.js
@@ -1,103 +1,145 @@
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
export const CATEGORIES = {
- factory: {label: 'کارخانه', icon: '🏭'},
- park: {label: 'پارک', icon: '🌳'},
- university: {label: 'دانشگاه', icon: '🎓'},
- school: {label: 'مدرسه', icon: '🏫'},
- metro: {label: 'ایستگاه مترو', icon: 'Ⓜ'},
- mall: {label: 'مرکز خرید', icon: '🛍'},
- test: {label: 'نقطهٔ آزمایشی GPS', icon: '🧪'}
+ factory: { label: 'کارخانه', icon: '🏭' },
+ park: { label: 'پارک', icon: '🌳' },
+ university: { label: 'دانشگاه', icon: '🎓' },
+ school: { label: 'مدرسه', icon: '🏫' },
+ metro: { label: 'ایستگاه مترو', icon: 'Ⓜ' },
+ mall: { label: 'مرکز خرید', icon: '🛍' },
+ test: { label: 'نقطهٔ آزمایشی GPS', icon: '🧪' },
};
const ALL_DAY = [7 * 60, 23 * 60];
export const FACTORY = {
- name: 'شرکت روبات مارکت',
- tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
- address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
- // Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
- center: {x: -10308.4, z: -14964.2},
- axis: {x: Math.SQRT1_2, z: Math.SQRT1_2}
+ name: 'شرکت روبات مارکت',
+ tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
+ address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
+ // Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
+ center: { x: -10308.4, z: -14964.2 },
+ axis: { x: Math.SQRT1_2, z: Math.SQRT1_2 },
};
export const machines = [
- {
- id: 'factory', name: 'دستگاه دفتر فنی مهندسی', category: 'factory',
- address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
- anchor: null, hours: [0, 24 * 60], priceFactor: .5,
- products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
- note: 'دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه'
- },
- {
- id: 'mellat-park', name: 'بوستان ملت', category: 'park',
- address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
- anchor: {x: -487.6, z: -326.4}, hours: ALL_DAY, priceFactor: 1,
- products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
- note: 'نوشیدنیهای خنک برای پیادهروی در پارک'
- },
- {
- id: 'school-rahil', name: 'مجموعه آموزشی راحیل', category: 'school',
- address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
- anchor: {x: -554.6, z: -569.7}, hours: [7 * 60 + 30, 13 * 60 + 30], priceFactor: .9,
- products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
- note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است'
- },
- {
- id: 'ferdowsi-university', name: 'دانشگاه فردوسی مشهد', category: 'university',
- address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
- anchor: {x: -936, z: 645}, hours: ALL_DAY, priceFactor: .8,
- products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
- note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر'
- },
- {
- id: 'literature-faculty', name: 'دانشکدهٔ ادبیات دکتر شریعتی', category: 'university',
- address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
- anchor: {x: -417.1, z: 966.3}, hours: ALL_DAY, priceFactor: .8,
- products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
- note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر'
- },
- {
- id: 'medical-university', name: 'دانشگاه علوم پزشکی مشهد', category: 'university',
- address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
- anchor: {x: -272.1, z: 150.6}, hours: ALL_DAY, priceFactor: .8,
- products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
- note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر'
- },
- {
- id: 'azadi-metro', name: 'ایستگاه مترو آزادی', category: 'metro',
- address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
- anchor: {x: 494.1, z: 280.3}, hours: [6 * 60, 22 * 60], priceFactor: 1,
- products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
- note: 'دستگاه کنار گیتهای مترو'
- },
- {
- id: 'mashhad-mall', name: 'مشهد مال', category: 'mall',
- address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
- anchor: {x: 1940.6, z: -367.8}, hours: [10 * 60, 23 * 60], priceFactor: 1.1,
- products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
- note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر'
- }
+ {
+ id: 'factory',
+ name: 'دستگاه دفتر فنی مهندسی',
+ category: 'factory',
+ address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
+ anchor: null,
+ hours: [0, 24 * 60],
+ priceFactor: 0.5,
+ products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
+ note: 'دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه',
+ },
+ {
+ id: 'mellat-park',
+ name: 'بوستان ملت',
+ category: 'park',
+ address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
+ anchor: { x: -487.6, z: -326.4 },
+ hours: ALL_DAY,
+ priceFactor: 1,
+ products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
+ note: 'نوشیدنیهای خنک برای پیادهروی در پارک',
+ },
+ {
+ id: 'school-rahil',
+ name: 'مجموعه آموزشی راحیل',
+ category: 'school',
+ address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
+ anchor: { x: -554.6, z: -569.7 },
+ hours: [7 * 60 + 30, 13 * 60 + 30],
+ priceFactor: 0.9,
+ products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
+ note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است',
+ },
+ {
+ id: 'ferdowsi-university',
+ name: 'دانشگاه فردوسی مشهد',
+ category: 'university',
+ address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
+ anchor: { x: -936, z: 645 },
+ hours: ALL_DAY,
+ priceFactor: 0.8,
+ products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
+ note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
+ },
+ {
+ id: 'literature-faculty',
+ name: 'دانشکدهٔ ادبیات دکتر شریعتی',
+ category: 'university',
+ address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
+ anchor: { x: -417.1, z: 966.3 },
+ hours: ALL_DAY,
+ priceFactor: 0.8,
+ products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
+ note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
+ },
+ {
+ id: 'medical-university',
+ name: 'دانشگاه علوم پزشکی مشهد',
+ category: 'university',
+ address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
+ anchor: { x: -272.1, z: 150.6 },
+ hours: ALL_DAY,
+ priceFactor: 0.8,
+ products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
+ note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
+ },
+ {
+ id: 'azadi-metro',
+ name: 'ایستگاه مترو آزادی',
+ category: 'metro',
+ address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
+ anchor: { x: 494.1, z: 280.3 },
+ hours: [6 * 60, 22 * 60],
+ priceFactor: 1,
+ products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
+ note: 'دستگاه کنار گیتهای مترو',
+ },
+ {
+ id: 'mashhad-mall',
+ name: 'مشهد مال',
+ category: 'mall',
+ address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
+ anchor: { x: 1940.6, z: -367.8 },
+ hours: [10 * 60, 23 * 60],
+ priceFactor: 1.1,
+ products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
+ note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر',
+ },
];
// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone.
// They exist only while real-world mode is on; `spot` is where the player stands to buy.
const testPoint = (n, lat, lon) => ({
- id: `test-${n}`, name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`, category: 'test', test: true,
- address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
- anchor: null, spot: {lat, lon}, hours: [0, 24 * 60], priceFactor: .5,
- products: ['water', 'biscuit'],
- note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید'
+ id: `test-${n}`,
+ name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
+ category: 'test',
+ test: true,
+ address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
+ anchor: null,
+ spot: { lat, lon },
+ hours: [0, 24 * 60],
+ priceFactor: 0.5,
+ products: ['water', 'biscuit'],
+ note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
});
-machines.push(testPoint(1, 36.450276, 59.423431), testPoint(2, 36.450172, 59.423209), testPoint(3, 36.449976, 59.422866));
+machines.push(
+ testPoint(1, 36.450276, 59.423431),
+ testPoint(2, 36.450172, 59.423209),
+ testPoint(3, 36.449976, 59.422866),
+);
// Shown on the map as the next area to unlock.
export const lockedPlaces = [
- {name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · بهزودی'},
- {name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · بهزودی'}
+ { name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · بهزودی' },
+ { name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · بهزودی' },
];
-export const START = {machineId: 'factory'};
+export const START = { machineId: 'factory' };
export const CITY_START = 'mellat-park';
-export const machineById = id => machines.find(m => m.id === id) ?? null;
+export const machineById = (id) => machines.find((m) => m.id === id) ?? null;
diff --git a/src/main.js b/src/main.js
index bd633b5..4d3bae9 100644
--- a/src/main.js
+++ b/src/main.js
@@ -3,47 +3,85 @@ import '@fontsource/vazirmatn/400.css';
import '@fontsource/vazirmatn/600.css';
import '@fontsource/vazirmatn/800.css';
import './style.css';
-import {setupPwa} from './pwa.js';
-import {startTracking, requestCompass, arrivalRadius, insideWorld, createStabilizer, MAX_WALKING_SPEED} from './realworld.js';
-import {createArView} from './ar.js';
+import { setupPwa } from './pwa.js';
+import {
+ startTracking,
+ requestCompass,
+ arrivalRadius,
+ insideWorld,
+ createStabilizer,
+ MAX_WALKING_SPEED,
+} from './realworld.js';
+import { createArView } from './ar.js';
import data from './data/mellat.json';
import iran from './data/iran.json';
-import {createCity} from './city.js';
-import {createImagery} from './imagery.js';
-import {decodeBuildings} from './buildings-data.js';
-import {createCollisionWorld, addPolygon, resolveCircle, clearFraction} from './collision.js';
-import {buildGraph, routeBetween, buildSegmentIndex, nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
-import {machines, CATEGORIES, CITY_START, FACTORY, lockedPlaces} from './locations.js';
-import {createState, catalog, priceAt, isOpen, purchaseBlocker, formatHetoman, formatClock, fa, canAffordAnything} from './game-state.js';
-import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from './payment.js';
-import {awardsFor, rankFor, completeMissions, activeMission, MISSIONS} from './scoring.js';
-import {createFamily} from './characters.js';
-import {createVendingMachine} from './vending.js';
-import {createFactory} from './factory.js';
-import {createCar, stepCar, kmh} from './vehicle.js';
-import {createTraffic} from './traffic.js';
-import {createSportSedan} from './car-models.js';
-import {createMapAssets, drawMinimap, drawFullMap, drawIran} from './map-render.js';
-import {bearing, formatDistance, toLocal} from './geo.js';
+import { createCity } from './city.js';
+import { createImagery } from './imagery.js';
+import { decodeBuildings } from './buildings-data.js';
+import { createCollisionWorld, addPolygon, resolveCircle, clearFraction } from './collision.js';
+import {
+ buildGraph,
+ routeBetween,
+ buildSegmentIndex,
+ nearestSegment,
+ DRIVABLE_MAX_TYPE,
+} from './routing.js';
+import { machines, CATEGORIES, CITY_START, FACTORY, lockedPlaces } from './locations.js';
+import {
+ createState,
+ catalog,
+ priceAt,
+ isOpen,
+ purchaseBlocker,
+ formatHetoman,
+ formatClock,
+ fa,
+ canAffordAnything,
+} from './game-state.js';
+import {
+ createPayment,
+ beginPayment,
+ readyPayment,
+ swipePayment,
+ cancelPayment,
+} from './payment.js';
+import { awardsFor, rankFor, completeMissions, activeMission, MISSIONS } from './scoring.js';
+import { createFamily } from './characters.js';
+import { createVendingMachine } from './vending.js';
+import { createFactory } from './factory.js';
+import { createCar, stepCar, kmh } from './vehicle.js';
+import { createTraffic } from './traffic.js';
+import { createSportSedan } from './car-models.js';
+import { createMapAssets, drawMinimap, drawFullMap, drawIran } from './map-render.js';
+import { bearing, formatDistance, toLocal } from './geo.js';
-const $ = s => document.querySelector(s);
+const $ = (s) => document.querySelector(s);
const canvas = $('#world');
let renderer;
try {
- // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn.
- renderer = new THREE.WebGLRenderer({canvas, antialias: true, alpha: true, powerPreference: 'high-performance'});
+ // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn.
+ renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: true,
+ powerPreference: 'high-performance',
+ });
} catch {
- $('#loading').textContent = 'مرورگر شما گرافیک سهبعدی (WebGL) را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.';
- throw new Error('WebGL unavailable');
+ $('#loading').textContent =
+ 'مرورگر شما گرافیک سهبعدی (WebGL) را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.';
+ throw new Error('WebGL unavailable');
}
// Low quality (no shadows, 1× pixels) for weak phones; chosen with ?quality=low or the toolbar button.
-const quality = new URLSearchParams(location.search).get('quality') ?? (() => {
+const quality =
+ new URLSearchParams(location.search).get('quality') ??
+ (() => {
try {
- return localStorage.getItem('quality');
+ return localStorage.getItem('quality');
} catch {
- return null;
+ return null;
}
-})() ?? (matchMedia('(pointer: coarse)').matches ? 'low' : 'high');
+ })() ??
+ (matchMedia('(pointer: coarse)').matches ? 'low' : 'high');
renderer.setPixelRatio(quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75));
renderer.shadowMap.enabled = quality !== 'low';
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
@@ -54,159 +92,235 @@ renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
const fog = new THREE.Fog('#d8cdb4', 220, 1500);
scene.fog = fog;
-const camera = new THREE.PerspectiveCamera(62, 1, .3, 7000);
+const camera = new THREE.PerspectiveCamera(62, 1, 0.3, 7000);
// Morning sky over Mashhad: a gradient dome, warm haze at the horizon.
-const sky = new THREE.Mesh(new THREE.SphereGeometry(5000, 32, 16), new THREE.ShaderMaterial({
- side: THREE.BackSide, depthWrite: false, fog: false,
- uniforms: {top: {value: new THREE.Color('#6f9fd0')}, horizon: {value: new THREE.Color('#e6dbc3')}},
- vertexShader: 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
- fragmentShader: 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }'
-}));
+const sky = new THREE.Mesh(
+ new THREE.SphereGeometry(5000, 32, 16),
+ new THREE.ShaderMaterial({
+ side: THREE.BackSide,
+ depthWrite: false,
+ fog: false,
+ uniforms: {
+ top: { value: new THREE.Color('#6f9fd0') },
+ horizon: { value: new THREE.Color('#e6dbc3') },
+ },
+ vertexShader:
+ 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
+ fragmentShader:
+ 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }',
+ }),
+);
scene.add(sky);
scene.add(new THREE.HemisphereLight('#fff4dc', '#7d715d', 1.6));
const sun = new THREE.DirectionalLight('#ffe9c2', 3.2);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
-Object.assign(sun.shadow.camera, {left: -75, right: 75, top: 75, bottom: -75, near: 10, far: 700});
-sun.shadow.bias = -.0004;
-sun.shadow.normalBias = .4;
+Object.assign(sun.shadow.camera, {
+ left: -75,
+ right: 75,
+ top: 75,
+ bottom: -75,
+ near: 10,
+ far: 700,
+});
+sun.shadow.bias = -0.0004;
+sun.shadow.normalBias = 0.4;
scene.add(sun, sun.target);
// ---------- World data ----------
// Microsoft's building footprints and the satellite tile list are downloaded files, not bundled code.
// The game still runs without them (OSM buildings and drawn roads).
-const loadFile = (path, read) => fetch(path).then(r => r.ok ? r[read]() : null).catch(() => null);
-const [buildingsFile, imageryIndex] = await Promise.all([loadFile('./data/buildings.bin', 'arrayBuffer'), loadFile('./imagery/index.json', 'json')]);
+const loadFile = (path, read) =>
+ fetch(path)
+ .then((r) => (r.ok ? r[read]() : null))
+ .catch(() => null);
+const [buildingsFile, imageryIndex] = await Promise.all([
+ loadFile('./data/buildings.bin', 'arrayBuffer'),
+ loadFile('./imagery/index.json', 'json'),
+]);
// Footprints reaching into the factory compound are left out: the game's own buildings stand there.
const inFactory = (x, z) => {
- const dx = x - FACTORY.center.x, dz = z - FACTORY.center.z;
- const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x;
- return u > -112 && u < 82 && v > -52 && v < 52;
+ const dx = x - FACTORY.center.x,
+ dz = z - FACTORY.center.z;
+ const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z,
+ v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x;
+ return u > -112 && u < 82 && v > -52 && v < 52;
};
-const extraBuildings = (buildingsFile ? decodeBuildings(buildingsFile) : []).filter(b => {
- for (let i = 1; i < b.length; i += 2) if (inFactory(b[i], b[i + 1])) return false;
- return true;
+const extraBuildings = (buildingsFile ? decodeBuildings(buildingsFile) : []).filter((b) => {
+ for (let i = 1; i < b.length; i += 2) if (inFactory(b[i], b[i + 1])) return false;
+ return true;
});
-const collision = createCollisionWorld([...data.buildings, ...extraBuildings].map(b => b.slice(1)));
+const collision = createCollisionWorld(
+ [...data.buildings, ...extraBuildings].map((b) => b.slice(1)),
+);
const segments = buildSegmentIndex(data.roads);
-const walkGraph = buildGraph(data.roads), driveGraph = buildGraph(data.roads, {drivable: true});
+const walkGraph = buildGraph(data.roads),
+ driveGraph = buildGraph(data.roads, { drivable: true });
const ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2];
-const rotateLocal = (lx, lz, rot) => ({x: lx * Math.cos(rot) + lz * Math.sin(rot), z: -lx * Math.sin(rot) + lz * Math.cos(rot)});
+const rotateLocal = (lx, lz, rot) => ({
+ x: lx * Math.cos(rot) + lz * Math.sin(rot),
+ z: -lx * Math.sin(rot) + lz * Math.cos(rot),
+});
// The game starts inside the vending-machine factory on the Mashhad–Chenaran road.
const factory = createFactory(scene);
-factory.footprints.forEach(f => addPolygon(collision, f));
+factory.footprints.forEach((f) => addPolygon(collision, f));
// City machines stand beside their path point, on whichever side is free, facing the path;
// the factory's demo machine has a fixed place inside the production hall.
-const stations = machines.map(def => {
- if (def.spot) {
- // Test points: the given coordinate is where the player stands; the machine faces the factory.
- const front = toLocal(def.spot.lat, def.spot.lon);
- const dx = FACTORY.center.x - front.x, dz = FACTORY.center.z - front.z, len = Math.hypot(dx, dz) || 1;
- return placeStation(def, {x: front.x - dx / len * 1.9, z: front.z - dz / len * 1.9}, Math.atan2(dx, dz));
+const stations = machines.map((def) => {
+ if (def.spot) {
+ // Test points: the given coordinate is where the player stands; the machine faces the factory.
+ const front = toLocal(def.spot.lat, def.spot.lon);
+ const dx = FACTORY.center.x - front.x,
+ dz = FACTORY.center.z - front.z,
+ len = Math.hypot(dx, dz) || 1;
+ return placeStation(
+ def,
+ { x: front.x - (dx / len) * 1.9, z: front.z - (dz / len) * 1.9 },
+ Math.atan2(dx, dz),
+ );
+ }
+ if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot);
+ const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? { dx: 1, dz: 0, type: 9 };
+ const len = Math.hypot(seg.dx, seg.dz) || 1,
+ nx = -seg.dz / len,
+ nz = seg.dx / len;
+ const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4;
+ let pos = null;
+ for (const d of [off, -off, off + 1.5, -off - 1.5]) {
+ const x = def.anchor.x + nx * d,
+ z = def.anchor.z + nz * d;
+ if (!resolveCircle(collision, x, z, 1.4).hit) {
+ pos = { x, z };
+ break;
}
- if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot);
- const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? {dx: 1, dz: 0, type: 9};
- const len = Math.hypot(seg.dx, seg.dz) || 1, nx = -seg.dz / len, nz = seg.dx / len;
- const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4;
- let pos = null;
- for (const d of [off, -off, off + 1.5, -off - 1.5]) {
- const x = def.anchor.x + nx * d, z = def.anchor.z + nz * d;
- if (!resolveCircle(collision, x, z, 1.4).hit) {
- pos = {x, z};
- break;
- }
- }
- pos ??= {x: def.anchor.x + nx * off, z: def.anchor.z + nz * off};
- return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z));
+ }
+ pos ??= { x: def.anchor.x + nx * off, z: def.anchor.z + nz * off };
+ return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z));
});
function placeStation(def, pos, rot) {
- const model = createVendingMachine(def);
- model.group.position.set(pos.x, 0, pos.z);
- model.group.rotation.y = rot;
- scene.add(model.group);
- addPolygon(collision, [[-1, -.6], [1, -.6], [1, .6], [-1, .6]].flatMap(([lx, lz]) => {
- const p = rotateLocal(lx, lz, rot);
- return [pos.x + p.x, pos.z + p.z];
- }));
- const front = rotateLocal(0, 1.9, rot);
- return {def, model, x: pos.x, z: pos.z, rot, front: {x: pos.x + front.x, z: pos.z + front.z}};
+ const model = createVendingMachine(def);
+ model.group.position.set(pos.x, 0, pos.z);
+ model.group.rotation.y = rot;
+ scene.add(model.group);
+ addPolygon(
+ collision,
+ [
+ [-1, -0.6],
+ [1, -0.6],
+ [1, 0.6],
+ [-1, 0.6],
+ ].flatMap(([lx, lz]) => {
+ const p = rotateLocal(lx, lz, rot);
+ return [pos.x + p.x, pos.z + p.z];
+ }),
+ );
+ const front = rotateLocal(0, 1.9, rot);
+ return { def, model, x: pos.x, z: pos.z, rot, front: { x: pos.x + front.x, z: pos.z + front.z } };
}
-const stationById = id => stations.find(s => s.def.id === id);
+const stationById = (id) => stations.find((s) => s.def.id === id);
for (const s of stations) if (s.def.test) s.model.group.visible = false;
// In real-world mode a circle on the ground marks how close counts as "at the machine".
for (const s of stations) {
- s.reach = new THREE.Mesh(new THREE.RingGeometry(.96, 1, 96), new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .55, depthWrite: false, fog: false, side: THREE.DoubleSide}));
- s.reach.rotation.x = -Math.PI / 2;
- s.reach.position.set(s.front.x, .08, s.front.z);
- s.reach.visible = false;
- scene.add(s.reach);
+ s.reach = new THREE.Mesh(
+ new THREE.RingGeometry(0.96, 1, 96),
+ new THREE.MeshBasicMaterial({
+ color: '#3ee0c9',
+ transparent: true,
+ opacity: 0.55,
+ depthWrite: false,
+ fog: false,
+ side: THREE.DoubleSide,
+ }),
+ );
+ s.reach.rotation.x = -Math.PI / 2;
+ s.reach.position.set(s.front.x, 0.08, s.front.z);
+ s.reach.visible = false;
+ scene.add(s.reach);
}
function updateReachCircles() {
- const radius = reachRadius(), objective = real.on ? objectiveStation() : null;
- for (const s of stations) {
- s.reach.visible = real.on;
- if (!real.on) continue;
- s.reach.scale.setScalar(radius);
- s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9');
- }
+ const radius = reachRadius(),
+ objective = real.on ? objectiveStation() : null;
+ for (const s of stations) {
+ s.reach.visible = real.on;
+ if (!real.on) continue;
+ s.reach.scale.setScalar(radius);
+ s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9');
+ }
}
// GPS test points only take part while real-world mode is on.
-const activeStations = () => stations.filter(s => !s.def.test || real.on);
+const activeStations = () => stations.filter((s) => !s.def.test || real.on);
// The closest active machine whose front is within `radius` of (x, z).
-const closestStation = (x, z, radius) => activeStations()
- .map(s => ({s, d: Math.hypot(s.front.x - x, s.front.z - z)}))
- .filter(({d}) => d < radius).sort((a, b) => a.d - b.d)[0]?.s ?? null;
+const closestStation = (x, z, radius) =>
+ activeStations()
+ .map((s) => ({ s, d: Math.hypot(s.front.x - x, s.front.z - z) }))
+ .filter(({ d }) => d < radius)
+ .sort((a, b) => a.d - b.d)[0]?.s ?? null;
-const imagery = createImagery(scene, imageryIndex ?? {}, {lowQuality: quality === 'low', anisotropy: Math.min(16, renderer.capabilities.getMaxAnisotropy())});
-const city = createCity(scene, data, {collision, segments, extraBuildings, satellite: imagery.available, lowQuality: quality === 'low'});
-if (imagery.available) $('#attribution').textContent += ` · تصویر ماهوارهای: ${imageryIndex.attribution}`;
+const imagery = createImagery(scene, imageryIndex ?? {}, {
+ lowQuality: quality === 'low',
+ anisotropy: Math.min(16, renderer.capabilities.getMaxAnisotropy()),
+});
+const city = createCity(scene, data, {
+ collision,
+ segments,
+ extraBuildings,
+ satellite: imagery.available,
+ lowQuality: quality === 'low',
+});
+if (imagery.available)
+ $('#attribution').textContent += ` · تصویر ماهوارهای: ${imageryIndex.attribution}`;
const traffic = createTraffic(scene, data, 28);
const mapAssets = createMapAssets(data, [factory.label]);
// Park gate sign at the first city machine.
const parkGate = new THREE.Group();
{
- const start = stationById(CITY_START), sign = document.createElement('canvas');
- sign.width = 512;
- sign.height = 128;
- const c = sign.getContext('2d');
- c.fillStyle = '#244d2e';
- c.fillRect(0, 0, 512, 128);
- c.fillStyle = '#f4efd9';
- c.textAlign = 'center';
- c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
- c.fillText('بوستان ملت', 256, 86);
- const texture = new THREE.CanvasTexture(sign);
- texture.colorSpace = THREE.SRGBColorSpace;
- const gate = parkGate, post = new THREE.MeshStandardMaterial({color: '#6b5a49'});
- for (const side of [-1, 1]) {
- const p = new THREE.Mesh(new THREE.BoxGeometry(.3, 4, .3), post);
- p.position.set(side * 2.6, 2, 0);
- p.castShadow = true;
- gate.add(p);
- }
- const board = new THREE.Mesh(new THREE.PlaneGeometry(5.6, 1.4), new THREE.MeshBasicMaterial({map: texture, side: THREE.DoubleSide}));
- board.position.y = 4;
- gate.add(board);
- const at = rotateLocal(-4.5, 1.5, start.rot);
- gate.position.set(start.x + at.x, 0, start.z + at.z);
- gate.rotation.y = start.rot;
- scene.add(gate);
+ const start = stationById(CITY_START),
+ sign = document.createElement('canvas');
+ sign.width = 512;
+ sign.height = 128;
+ const c = sign.getContext('2d');
+ c.fillStyle = '#244d2e';
+ c.fillRect(0, 0, 512, 128);
+ c.fillStyle = '#f4efd9';
+ c.textAlign = 'center';
+ c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
+ c.fillText('بوستان ملت', 256, 86);
+ const texture = new THREE.CanvasTexture(sign);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const gate = parkGate,
+ post = new THREE.MeshStandardMaterial({ color: '#6b5a49' });
+ for (const side of [-1, 1]) {
+ const p = new THREE.Mesh(new THREE.BoxGeometry(0.3, 4, 0.3), post);
+ p.position.set(side * 2.6, 2, 0);
+ p.castShadow = true;
+ gate.add(p);
+ }
+ const board = new THREE.Mesh(
+ new THREE.PlaneGeometry(5.6, 1.4),
+ new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide }),
+ );
+ board.position.y = 4;
+ gate.add(board);
+ const at = rotateLocal(-4.5, 1.5, start.rot);
+ gate.position.set(start.x + at.x, 0, start.z + at.z);
+ gate.rotation.y = start.rot;
+ scene.add(gate);
}
// ---------- Actors ----------
const family = createFamily();
scene.add(family.group);
// The Robot Market guide lives inside the machines and comes out when the family arrives.
-const guide = createFamily({robot: true});
+const guide = createFamily({ robot: true });
guide.group.visible = false;
scene.add(guide.group);
const carModel = createSportSedan('#1d3f78');
@@ -219,418 +333,493 @@ const keys = new Set();
const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading);
function reset() {
- state = createState();
- payment = createPayment();
- // The family starts in the factory yard, facing the engineering office.
- const spot = resolveCircle(collision, factory.start.x, factory.start.z, 1);
- player = {x: spot.x, z: spot.z, heading: factory.start.heading, speed: 0, gait: 0};
- factory.reset();
- for (const t of factory.talks) t.done = false;
- car = spawnCar();
- mode = 'walk';
- // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
- cam = {yaw: Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)), pitch: .32, distance: 5.5, lastMouse: -10};
- waypoint = null;
- route = null;
- routeTimer = 0;
- deliveryTimer = 0;
- ended = false;
- hints = {closed: false, car: false};
- Object.assign(guideState, {station: null, t: 0, greeted: new Set(), arrived: 0});
- $('#payment').hidden = true;
- for (const s of stations) {
- s.model.delivery.visible = s.model.card.visible = false;
- s.model.flap.rotation.x = 0;
- }
- for (const d of ['#shop', '#map-dialog', '#end']) if ($(d).open) $(d).close();
- keys.clear();
- refreshHUD();
+ state = createState();
+ payment = createPayment();
+ // The family starts in the factory yard, facing the engineering office.
+ const spot = resolveCircle(collision, factory.start.x, factory.start.z, 1);
+ player = { x: spot.x, z: spot.z, heading: factory.start.heading, speed: 0, gait: 0 };
+ factory.reset();
+ for (const t of factory.talks) t.done = false;
+ car = spawnCar();
+ mode = 'walk';
+ // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
+ cam = {
+ yaw: Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)),
+ pitch: 0.32,
+ distance: 5.5,
+ lastMouse: -10,
+ };
+ waypoint = null;
+ route = null;
+ routeTimer = 0;
+ deliveryTimer = 0;
+ ended = false;
+ hints = { closed: false, car: false };
+ Object.assign(guideState, { station: null, t: 0, greeted: new Set(), arrived: 0 });
+ $('#payment').hidden = true;
+ for (const s of stations) {
+ s.model.delivery.visible = s.model.card.visible = false;
+ s.model.flap.rotation.x = 0;
+ }
+ for (const d of ['#shop', '#map-dialog', '#end']) if ($(d).open) $(d).close();
+ keys.clear();
+ refreshHUD();
}
// ---------- Input ----------
const isTouch = matchMedia('(pointer: coarse)').matches;
-const anyDialogOpen = () => $('#shop').open || $('#map-dialog').open || $('#end').open || !$('#briefing').hidden;
+const anyDialogOpen = () =>
+ $('#shop').open || $('#map-dialog').open || $('#end').open || !$('#briefing').hidden;
// Physical key codes, so the controls also work with a Persian keyboard layout.
-const keyName = e => e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase();
-window.addEventListener('keydown', e => {
- if (e.target instanceof HTMLInputElement) return;
- const k = keyName(e);
- if (k === 'm' && !e.repeat && $('#briefing').hidden) {
- e.preventDefault();
- $('#map-dialog').open ? $('#map-dialog').close() : openMap();
- return;
- }
- if (anyDialogOpen()) return;
- if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k)) e.preventDefault();
- if (e.repeat) return;
- keys.add(k);
- if (k === 'f') toggleCar();
- if (k === 'e') interact();
- if (k === 'c') swipe();
- if (k === 'escape' && payment.phase !== 'idle') cancel();
+const keyName = (e) =>
+ e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase();
+window.addEventListener('keydown', (e) => {
+ if (e.target instanceof HTMLInputElement) return;
+ const k = keyName(e);
+ if (k === 'm' && !e.repeat && $('#briefing').hidden) {
+ e.preventDefault();
+ $('#map-dialog').open ? $('#map-dialog').close() : openMap();
+ return;
+ }
+ if (anyDialogOpen()) return;
+ if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k))
+ e.preventDefault();
+ if (e.repeat) return;
+ keys.add(k);
+ if (k === 'f') toggleCar();
+ if (k === 'e') interact();
+ if (k === 'c') swipe();
+ if (k === 'escape' && payment.phase !== 'idle') cancel();
});
-window.addEventListener('keyup', e => keys.delete(keyName(e)));
+window.addEventListener('keyup', (e) => keys.delete(keyName(e)));
window.addEventListener('blur', () => keys.clear());
// Pointer lock gives a shooter-style mouse look; dragging works as a fallback and on touch.
canvas.addEventListener('click', () => {
- if (!isTouch && !anyDialogOpen() && document.pointerLockElement !== canvas) canvas.requestPointerLock?.()?.catch?.(() => {});
+ if (!isTouch && !anyDialogOpen() && document.pointerLockElement !== canvas)
+ canvas.requestPointerLock?.()?.catch?.(() => {});
});
-document.addEventListener('mousemove', e => {
- if (document.pointerLockElement !== canvas) return;
- look(e.movementX, e.movementY, .0024);
+document.addEventListener('mousemove', (e) => {
+ if (document.pointerLockElement !== canvas) return;
+ look(e.movementX, e.movementY, 0.0024);
});
let drag = null;
-canvas.addEventListener('pointerdown', e => {
- if (document.pointerLockElement === canvas) return;
- drag = {id: e.pointerId, x: e.clientX, y: e.clientY};
- canvas.setPointerCapture(e.pointerId);
+canvas.addEventListener('pointerdown', (e) => {
+ if (document.pointerLockElement === canvas) return;
+ drag = { id: e.pointerId, x: e.clientX, y: e.clientY };
+ canvas.setPointerCapture(e.pointerId);
});
-canvas.addEventListener('pointermove', e => {
- if (!drag || drag.id !== e.pointerId) return;
- look(e.clientX - drag.x, e.clientY - drag.y, .005);
- drag.x = e.clientX;
- drag.y = e.clientY;
+canvas.addEventListener('pointermove', (e) => {
+ if (!drag || drag.id !== e.pointerId) return;
+ look(e.clientX - drag.x, e.clientY - drag.y, 0.005);
+ drag.x = e.clientX;
+ drag.y = e.clientY;
});
-canvas.addEventListener('pointerup', () => drag = null);
-canvas.addEventListener('pointercancel', () => drag = null);
-canvas.addEventListener('wheel', e => {
- cam.distance = THREE.MathUtils.clamp(cam.distance + Math.sign(e.deltaY) * .6, 3, 12);
-}, {passive: true});
+canvas.addEventListener('pointerup', () => (drag = null));
+canvas.addEventListener('pointercancel', () => (drag = null));
+canvas.addEventListener(
+ 'wheel',
+ (e) => {
+ cam.distance = THREE.MathUtils.clamp(cam.distance + Math.sign(e.deltaY) * 0.6, 3, 12);
+ },
+ { passive: true },
+);
function look(dx, dy, k) {
- // In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor.
- if (real.on) {
- if (arView.hasOrientation) return;
- cam.yaw -= dx * k;
- cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * .8, -1.2, 1.2);
- cam.lastMouse = elapsed;
- return;
- }
+ // In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor.
+ if (real.on) {
+ if (arView.hasOrientation) return;
cam.yaw -= dx * k;
- cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * .8, -.15, 1.15);
+ cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * 0.8, -1.2, 1.2);
cam.lastMouse = elapsed;
+ return;
+ }
+ cam.yaw -= dx * k;
+ cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * 0.8, -0.15, 1.15);
+ cam.lastMouse = elapsed;
}
// Touch joystick: x steers / strafes, y walks / throttles.
-const stick = {x: 0, y: 0, id: null};
+const stick = { x: 0, y: 0, id: null };
if (isTouch) {
- $('#touch').hidden = false;
- const pad = $('#stick'), knob = pad.querySelector('i');
- const move = e => {
- const r = pad.getBoundingClientRect(), dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
- const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)), a = Math.atan2(dy, dx);
- stick.x = Math.cos(a) * len;
- stick.y = -Math.sin(a) * len;
- knob.style.transform = `translate(${stick.x * 40}px, ${-stick.y * 40}px)`;
- };
- pad.addEventListener('pointerdown', e => {
- stick.id = e.pointerId;
- pad.setPointerCapture(e.pointerId);
- move(e);
- });
- pad.addEventListener('pointermove', e => e.pointerId === stick.id && move(e));
- const end = () => {
- stick.id = null;
- stick.x = stick.y = 0;
- knob.style.transform = '';
- };
- pad.addEventListener('pointerup', end);
- pad.addEventListener('pointercancel', end);
- for (const b of document.querySelectorAll('.touch-buttons button')) b.addEventListener('click', () => {
- const k = b.dataset.key;
- if (k === 'shift') b.classList.toggle('on', keys.has('shift') ? (keys.delete('shift'), false) : (keys.add('shift'), true));
- if (k === 'f') toggleCar();
- if (k === 'e') payment.phase === 'ready' ? swipe() : interact();
+ $('#touch').hidden = false;
+ const pad = $('#stick'),
+ knob = pad.querySelector('i');
+ const move = (e) => {
+ const r = pad.getBoundingClientRect(),
+ dx = e.clientX - (r.left + r.width / 2),
+ dy = e.clientY - (r.top + r.height / 2);
+ const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)),
+ a = Math.atan2(dy, dx);
+ stick.x = Math.cos(a) * len;
+ stick.y = -Math.sin(a) * len;
+ knob.style.transform = `translate(${stick.x * 40}px, ${-stick.y * 40}px)`;
+ };
+ pad.addEventListener('pointerdown', (e) => {
+ stick.id = e.pointerId;
+ pad.setPointerCapture(e.pointerId);
+ move(e);
+ });
+ pad.addEventListener('pointermove', (e) => e.pointerId === stick.id && move(e));
+ const end = () => {
+ stick.id = null;
+ stick.x = stick.y = 0;
+ knob.style.transform = '';
+ };
+ pad.addEventListener('pointerup', end);
+ pad.addEventListener('pointercancel', end);
+ for (const b of document.querySelectorAll('.touch-buttons button'))
+ b.addEventListener('click', () => {
+ const k = b.dataset.key;
+ if (k === 'shift')
+ b.classList.toggle(
+ 'on',
+ keys.has('shift') ? (keys.delete('shift'), false) : (keys.add('shift'), true),
+ );
+ if (k === 'f') toggleCar();
+ if (k === 'e') payment.phase === 'ready' ? swipe() : interact();
});
}
-const axis = (pos, neg) => Number(pos.some(k => keys.has(k))) - Number(neg.some(k => keys.has(k)));
+const axis = (pos, neg) =>
+ Number(pos.some((k) => keys.has(k))) - Number(neg.some((k) => keys.has(k)));
// ---------- Subtitles ----------
-const SPEAKERS = {robot: 'روبات', father: 'پدر', child: 'بچه'};
+const SPEAKERS = { robot: 'روبات', father: 'پدر', child: 'بچه' };
// Factory staff speak under their own names.
for (const t of factory.talks) if (t.label) SPEAKERS[t.speaker] = t.label;
-let lines = [], lineTimer = 0, talking = false;
+let lines = [],
+ lineTimer = 0,
+ talking = false;
// `face` picks the robot's expression portrait (public/robot/.webp).
-function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * .06)) {
- if (lines.some(l => l.text === text)) return;
- lines.push({speaker, text, face, seconds});
+function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) {
+ if (lines.some((l) => l.text === text)) return;
+ lines.push({ speaker, text, face, seconds });
}
function updateSubtitles(dt) {
- lineTimer -= dt;
- if (lineTimer > 0) return;
- const next = lines.shift();
- $('#subtitle').hidden = !next;
- talking = next?.speaker === 'robot';
- if (!next) return;
- $('#subtitle-speaker').textContent = `${SPEAKERS[next.speaker]}:`;
- $('#subtitle-speaker').className = ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff';
- $('#subtitle-text').textContent = next.text;
- $('#subtitle-face').hidden = next.speaker !== 'robot';
- if (next.speaker === 'robot') $('#subtitle-face').src = `./robot/${next.face}.webp`;
- lineTimer = next.seconds;
+ lineTimer -= dt;
+ if (lineTimer > 0) return;
+ const next = lines.shift();
+ $('#subtitle').hidden = !next;
+ talking = next?.speaker === 'robot';
+ if (!next) return;
+ $('#subtitle-speaker').textContent = `${SPEAKERS[next.speaker]}:`;
+ $('#subtitle-speaker').className = ['robot', 'father', 'child'].includes(next.speaker)
+ ? next.speaker
+ : 'staff';
+ $('#subtitle-text').textContent = next.text;
+ $('#subtitle-face').hidden = next.speaker !== 'robot';
+ if (next.speaker === 'robot') $('#subtitle-face').src = `./robot/${next.face}.webp`;
+ lineTimer = next.seconds;
}
// ---------- Sound ----------
-let audio = null, soundOn = false, engine = null;
+let audio = null,
+ soundOn = false,
+ engine = null;
-function tone(freq, duration = .12, volume = .05, type = 'sine') {
- if (!soundOn) return;
- const o = audio.createOscillator(), g = audio.createGain();
- o.type = type;
- o.frequency.setValueAtTime(freq, audio.currentTime);
- o.frequency.exponentialRampToValueAtTime(freq * .7, audio.currentTime + duration);
- g.gain.setValueAtTime(volume, audio.currentTime);
- g.gain.exponentialRampToValueAtTime(.001, audio.currentTime + duration);
- o.connect(g).connect(audio.destination);
- o.start();
- o.stop(audio.currentTime + duration);
+function tone(freq, duration = 0.12, volume = 0.05, type = 'sine') {
+ if (!soundOn) return;
+ const o = audio.createOscillator(),
+ g = audio.createGain();
+ o.type = type;
+ o.frequency.setValueAtTime(freq, audio.currentTime);
+ o.frequency.exponentialRampToValueAtTime(freq * 0.7, audio.currentTime + duration);
+ g.gain.setValueAtTime(volume, audio.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + duration);
+ o.connect(g).connect(audio.destination);
+ o.start();
+ o.stop(audio.currentTime + duration);
}
$('#sound').onclick = async () => {
- try {
- audio ??= new AudioContext();
- await audio.resume();
- soundOn = !soundOn;
- if (!engine) {
- const o = audio.createOscillator(), g = audio.createGain();
- o.type = 'sawtooth';
- g.gain.value = 0;
- o.connect(g).connect(audio.destination);
- o.start();
- engine = {o, g};
- }
- $('#sound').textContent = soundOn ? 'صدا: روشن' : 'صدا: خاموش';
- $('#sound').setAttribute('aria-pressed', String(soundOn));
- tone(700);
- } catch {
- $('#sound').textContent = 'صدا در دسترس نیست';
+ try {
+ audio ??= new AudioContext();
+ await audio.resume();
+ soundOn = !soundOn;
+ if (!engine) {
+ const o = audio.createOscillator(),
+ g = audio.createGain();
+ o.type = 'sawtooth';
+ g.gain.value = 0;
+ o.connect(g).connect(audio.destination);
+ o.start();
+ engine = { o, g };
}
+ $('#sound').textContent = soundOn ? 'صدا: روشن' : 'صدا: خاموش';
+ $('#sound').setAttribute('aria-pressed', String(soundOn));
+ tone(700);
+ } catch {
+ $('#sound').textContent = 'صدا در دسترس نیست';
+ }
};
// ---------- Car ----------
function toggleCar() {
- if (payment.phase !== 'idle' || state.busy) return;
- if (real.on) {
- say('father', 'توی حالت دنیای واقعی پیاده راه میریم؛ ماشین مجازی لازم نیست.');
- return;
+ if (payment.phase !== 'idle' || state.busy) return;
+ if (real.on) {
+ say('father', 'توی حالت دنیای واقعی پیاده راه میریم؛ ماشین مجازی لازم نیست.');
+ return;
+ }
+ if (mode === 'walk') {
+ if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return;
+ mode = 'drive';
+ cam.distance = Math.max(cam.distance, 8);
+ tone(320, 0.2, 0.05, 'square');
+ say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.');
+ } else {
+ if (Math.abs(car.speed) > 3) {
+ say('father', 'اول ماشین را نگه دار، بعد پیاده میشویم.');
+ return;
}
- if (mode === 'walk') {
- if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return;
- mode = 'drive';
- cam.distance = Math.max(cam.distance, 8);
- tone(320, .2, .05, 'square');
- say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.');
- } else {
- if (Math.abs(car.speed) > 3) {
- say('father', 'اول ماشین را نگه دار، بعد پیاده میشویم.');
- return;
- }
- // Step out on the driver's (left) side, or wherever is free.
- const left = {x: -Math.sin(car.heading), z: -Math.cos(car.heading)};
- const spot = resolveCircle(collision, car.x + left.x * 2.8, car.z + left.z * 2.8, 1);
- Object.assign(player, {x: spot.x, z: spot.z, heading: car.heading});
- car.speed = 0;
- mode = 'walk';
- cam.distance = 5.5;
- tone(260, .2, .05, 'square');
- }
- refreshHUD();
+ // Step out on the driver's (left) side, or wherever is free.
+ const left = { x: -Math.sin(car.heading), z: -Math.cos(car.heading) };
+ const spot = resolveCircle(collision, car.x + left.x * 2.8, car.z + left.z * 2.8, 1);
+ Object.assign(player, { x: spot.x, z: spot.z, heading: car.heading });
+ car.speed = 0;
+ mode = 'walk';
+ cam.distance = 5.5;
+ tone(260, 0.2, 0.05, 'square');
+ }
+ refreshHUD();
}
// ---------- Machines, shop and payment ----------
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
-const reachRadius = () => real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4;
-const nearStation = () => mode === 'walk' ? closestStation(player.x, player.z, reachRadius()) : null;
-const hoursText = m => m.hours[1] - m.hours[0] >= 24 * 60 ? 'شبانهروزی' : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
+const reachRadius = () => (real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4);
+const nearStation = () =>
+ mode === 'walk' ? closestStation(player.x, player.z, reachRadius()) : null;
+const hoursText = (m) =>
+ m.hours[1] - m.hours[0] >= 24 * 60
+ ? 'شبانهروزی'
+ : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
let shopStation = null;
function interact() {
- const station = nearStation();
- if (!station || state.busy || payment.phase !== 'idle' || ended) return;
- if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
- say('father', 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.');
- return;
- }
- if (!isOpen(station.def, state.minutes)) {
- say(guideOut() ? 'robot' : 'father', `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`, 'serious');
- return;
- }
- shopStation = station;
- renderShop();
- document.exitPointerLock?.();
- keys.clear();
- $('#shop').showModal();
- tone(750);
+ const station = nearStation();
+ if (!station || state.busy || payment.phase !== 'idle' || ended) return;
+ if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
+ say('father', 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.');
+ return;
+ }
+ if (!isOpen(station.def, state.minutes)) {
+ say(
+ guideOut() ? 'robot' : 'father',
+ `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`,
+ 'serious',
+ );
+ return;
+ }
+ shopStation = station;
+ renderShop();
+ document.exitPointerLock?.();
+ keys.clear();
+ $('#shop').showModal();
+ tone(750);
}
function renderShop() {
- const def = shopStation.def;
- $('#shop-category').textContent = `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${hoursText(def)}`;
- $('#shop-name').textContent = `روبات مارکت · ${def.name}`;
- $('#shop-address').textContent = def.address;
- $('#shop-note').textContent = `${def.note} · موجودی شما: ${formatHetoman(state.balance)}`;
- $('#products').replaceChildren(...def.products.map(id => {
- const p = catalog[id], price = priceAt(def, id), b = document.createElement('button');
- b.type = 'button';
- const blocker = purchaseBlocker(state, def, id);
- b.disabled = Boolean(blocker);
- b.innerHTML = `${p.icon}${p.name}${formatHetoman(price)}${price !== p.cost ? `${formatHetoman(p.cost)}` : ''}${p.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}${blocker === 'funds' ? ' · موجودی کافی نیست' : ''}`;
- b.onclick = () => startPayment(id);
- return b;
- }));
+ const def = shopStation.def;
+ $('#shop-category').textContent =
+ `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${hoursText(def)}`;
+ $('#shop-name').textContent = `روبات مارکت · ${def.name}`;
+ $('#shop-address').textContent = def.address;
+ $('#shop-note').textContent = `${def.note} · موجودی شما: ${formatHetoman(state.balance)}`;
+ $('#products').replaceChildren(
+ ...def.products.map((id) => {
+ const p = catalog[id],
+ price = priceAt(def, id),
+ b = document.createElement('button');
+ b.type = 'button';
+ const blocker = purchaseBlocker(state, def, id);
+ b.disabled = Boolean(blocker);
+ b.innerHTML = `${p.icon}${p.name}${formatHetoman(price)}${price !== p.cost ? `${formatHetoman(p.cost)}` : ''}${p.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}${blocker === 'funds' ? ' · موجودی کافی نیست' : ''}`;
+ b.onclick = () => startPayment(id);
+ return b;
+ }),
+ );
}
function startPayment(productId) {
- if (!beginPayment(state, payment, shopStation.def, productId)) return;
- payment.station = shopStation;
- payment.timer = 0;
- $('#shop').close();
- const p = catalog[productId];
- $('#payment').hidden = false;
- $('#payment-title').textContent = `${p.icon} ${p.name} · ${formatHetoman(priceAt(shopStation.def, productId))}`;
- $('#payment-step').textContent = 'در حال نزدیک شدن به کارتخوان…';
- $('#swipe').disabled = true;
- $('#cancel-payment').hidden = false;
- say('child', 'بابا، کارت رو بده من بکشم!');
+ if (!beginPayment(state, payment, shopStation.def, productId)) return;
+ payment.station = shopStation;
+ payment.timer = 0;
+ $('#shop').close();
+ const p = catalog[productId];
+ $('#payment').hidden = false;
+ $('#payment-title').textContent =
+ `${p.icon} ${p.name} · ${formatHetoman(priceAt(shopStation.def, productId))}`;
+ $('#payment-step').textContent = 'در حال نزدیک شدن به کارتخوان…';
+ $('#swipe').disabled = true;
+ $('#cancel-payment').hidden = false;
+ say('child', 'بابا، کارت رو بده من بکشم!');
}
function swipe() {
- if (!swipePayment(state, payment)) return;
- payment.timer = 0;
- payment.station.model.card.visible = true;
- $('#swipe').disabled = true;
- $('#cancel-payment').hidden = true;
- $('#payment-step').textContent = 'پرداخت موفق ✓ محصول در حال تحویل است';
- tone(1100, .3);
- refreshHUD();
+ if (!swipePayment(state, payment)) return;
+ payment.timer = 0;
+ payment.station.model.card.visible = true;
+ $('#swipe').disabled = true;
+ $('#cancel-payment').hidden = true;
+ $('#payment-step').textContent = 'پرداخت موفق ✓ محصول در حال تحویل است';
+ tone(1100, 0.3);
+ refreshHUD();
}
function cancel() {
- if (!cancelPayment(state, payment)) return;
- $('#payment').hidden = true;
+ if (!cancelPayment(state, payment)) return;
+ $('#payment').hidden = true;
}
$('#swipe').onclick = swipe;
// On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car.
$('#prompt').onclick = () => {
- if (payment.phase === 'ready') swipe();
- else if (nearStation()) interact();
- else toggleCar();
+ if (payment.phase === 'ready') swipe();
+ else if (nearStation()) interact();
+ else toggleCar();
};
$('#cancel-payment').onclick = cancel;
$('#shop-close').onclick = () => $('#shop').close();
function updatePayment(dt) {
- if (payment.phase === 'idle') return;
- payment.timer += dt;
- const model = payment.station.model;
- if (payment.phase === 'approaching' && payment.timer > 1) {
- readyPayment(payment);
- $('#swipe').disabled = false;
- $('#payment-step').textContent = 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C';
- }
- if (payment.phase === 'approved') {
- const t = Math.min(1, payment.timer);
- model.card.position.set(.95 - t * .25, 1.7 - t * .1, .66);
- if (payment.timer > 1.2) {
- model.card.visible = false;
- $('#payment').hidden = true;
- model.setDelivery(payment.productId);
- deliveryTimer = 2.6;
- payment.delivering = {station: payment.station, record: payment.record};
- Object.assign(payment, createPayment());
- }
+ if (payment.phase === 'idle') return;
+ payment.timer += dt;
+ const model = payment.station.model;
+ if (payment.phase === 'approaching' && payment.timer > 1) {
+ readyPayment(payment);
+ $('#swipe').disabled = false;
+ $('#payment-step').textContent = 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C';
+ }
+ if (payment.phase === 'approved') {
+ const t = Math.min(1, payment.timer);
+ model.card.position.set(0.95 - t * 0.25, 1.7 - t * 0.1, 0.66);
+ if (payment.timer > 1.2) {
+ model.card.visible = false;
+ $('#payment').hidden = true;
+ model.setDelivery(payment.productId);
+ deliveryTimer = 2.6;
+ payment.delivering = { station: payment.station, record: payment.record };
+ Object.assign(payment, createPayment());
}
+ }
}
function updateDelivery(dt) {
- if (deliveryTimer <= 0) return;
- deliveryTimer = Math.max(0, deliveryTimer - dt);
- const {station, record} = payment.delivering, model = station.model, t = 2.6 - deliveryTimer;
- const slot = record ? catalog[record.productId] : null;
- model.delivery.visible = t < 2.4;
- // Falls from a shelf, waits in the tray, then lifts toward the child's hands.
- const shelf = new THREE.Vector3(-.5 + (record.price % 7) * .1, 1.2 + (record.price % 5) * .15, .45);
- if (t < .8) model.delivery.position.lerpVectors(shelf, model.slotPoint, t / .8);
- else model.delivery.position.copy(model.slotPoint).add(new THREE.Vector3(0, Math.max(0, t - 1.6) * .6, Math.max(0, t - 1.6) * .8));
- model.flap.rotation.x = t > .7 && t < 2 ? -1 : 0;
- if (deliveryTimer > 0) return;
- model.flap.rotation.x = 0;
- state.busy = false;
- tone(980, .18);
- reward(record, slot);
+ if (deliveryTimer <= 0) return;
+ deliveryTimer = Math.max(0, deliveryTimer - dt);
+ const { station, record } = payment.delivering,
+ model = station.model,
+ t = 2.6 - deliveryTimer;
+ const slot = record ? catalog[record.productId] : null;
+ model.delivery.visible = t < 2.4;
+ // Falls from a shelf, waits in the tray, then lifts toward the child's hands.
+ const shelf = new THREE.Vector3(
+ -0.5 + (record.price % 7) * 0.1,
+ 1.2 + (record.price % 5) * 0.15,
+ 0.45,
+ );
+ if (t < 0.8) model.delivery.position.lerpVectors(shelf, model.slotPoint, t / 0.8);
+ else
+ model.delivery.position
+ .copy(model.slotPoint)
+ .add(new THREE.Vector3(0, Math.max(0, t - 1.6) * 0.6, Math.max(0, t - 1.6) * 0.8));
+ model.flap.rotation.x = t > 0.7 && t < 2 ? -1 : 0;
+ if (deliveryTimer > 0) return;
+ model.flap.rotation.x = 0;
+ state.busy = false;
+ tone(980, 0.18);
+ reward(record, slot);
}
function reward(record, product) {
- const awards = awardsFor(state.purchases.slice(0, -1), record), rankBefore = rankFor(state.xp).index;
- if (real.on) awards.push({label: 'خرید در دنیای واقعی', points: 200});
- const total = awards.reduce((s, a) => s + a.points, 0);
- state.xp += total;
- $('#awards').replaceChildren(
- Object.assign(document.createElement('div'), {className: 'total', textContent: `+${fa(total)}`}),
- ...awards.map((a, i) => {
- const line = document.createElement('div');
- line.className = 'line';
- line.style.animationDelay = `${i * .12}s`;
- line.innerHTML = `${a.label}+${fa(a.points)}`;
- return line;
- })
+ const awards = awardsFor(state.purchases.slice(0, -1), record),
+ rankBefore = rankFor(state.xp).index;
+ if (real.on) awards.push({ label: 'خرید در دنیای واقعی', points: 200 });
+ const total = awards.reduce((s, a) => s + a.points, 0);
+ state.xp += total;
+ $('#awards').replaceChildren(
+ Object.assign(document.createElement('div'), {
+ className: 'total',
+ textContent: `+${fa(total)}`,
+ }),
+ ...awards.map((a, i) => {
+ const line = document.createElement('div');
+ line.className = 'line';
+ line.style.animationDelay = `${i * 0.12}s`;
+ line.innerHTML = `${a.label}+${fa(a.points)}`;
+ return line;
+ }),
+ );
+ clearTimeout(reward.timeout);
+ reward.timeout = setTimeout(() => $('#awards').replaceChildren(), 3200);
+ say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
+ const done = completeMissions(state);
+ for (const m of done) {
+ showBanner('مأموریت انجام شد', m.title, `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`);
+ tone(660, 0.5);
+ }
+ if (rankFor(state.xp).index > rankBefore)
+ setTimeout(
+ () => showBanner('ارتقای درجه', rankFor(state.xp).name, 'دستگاههای بیشتری پیدا کن'),
+ done.length ? 3400 : 0,
);
- clearTimeout(reward.timeout);
- reward.timeout = setTimeout(() => $('#awards').replaceChildren(), 3200);
- say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
- const done = completeMissions(state);
- for (const m of done) {
- showBanner('مأموریت انجام شد', m.title, `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`);
- tone(660, .5);
- }
- if (rankFor(state.xp).index > rankBefore) setTimeout(() => showBanner('ارتقای درجه', rankFor(state.xp).name, 'دستگاههای بیشتری پیدا کن'), done.length ? 3400 : 0);
- const next = activeMission(state);
- if (next) {
- waypoint = null;
- route = null;
- setTimeout(() => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), 1500);
- }
- refreshHUD();
- checkEnd();
+ const next = activeMission(state);
+ if (next) {
+ waypoint = null;
+ route = null;
+ setTimeout(
+ () => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'),
+ 1500,
+ );
+ }
+ refreshHUD();
+ checkEnd();
}
function showBanner(tag, title, sub) {
- const b = $('#banner');
- b.innerHTML = `${tag}${title}${sub}`;
- b.hidden = false;
- b.style.animation = 'none';
- void b.offsetWidth;
- b.style.animation = '';
- clearTimeout(showBanner.timeout);
- showBanner.timeout = setTimeout(() => b.hidden = true, 3200);
+ const b = $('#banner');
+ b.innerHTML = `${tag}${title}${sub}`;
+ b.hidden = false;
+ b.style.animation = 'none';
+ void b.offsetWidth;
+ b.style.animation = '';
+ clearTimeout(showBanner.timeout);
+ showBanner.timeout = setTimeout(() => (b.hidden = true), 3200);
}
function checkEnd() {
- const allDone = state.missionsDone.length === MISSIONS.length;
- if (ended || (!allDone && canAffordAnything(state, machines))) return;
- ended = true;
- document.exitPointerLock?.();
- $('#end-tag').textContent = allDone ? 'عملیات موفق' : 'پایان بودجه';
- $('#end-title').textContent = allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد';
- $('#end-text').textContent = allDone ? 'همهٔ مأموریتهای محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد بهزودی باز میشوند.' : 'دیگر نمیتوانید از دستگاهها خرید کنید. امتیاز امروز:';
- const places = new Set(state.purchases.map(p => p.machineId)).size;
- $('#end-stats').innerHTML = [['امتیاز', `${fa(state.xp)} XP`], ['درجه', rankFor(state.xp).name], ['خریدها', fa(state.purchases.length)], ['مکانهای کشفشده', `${fa(places)} از ${fa(machines.length)}`], ['مأموریتها', `${fa(state.missionsDone.length)} از ${fa(MISSIONS.length)}`], ['باقیمانده', formatHetoman(state.balance)]]
- .map(([k, v]) => `${k}${v}`).join('');
- $('#continue').hidden = !allDone;
- $('#end').showModal();
+ const allDone = state.missionsDone.length === MISSIONS.length;
+ if (ended || (!allDone && canAffordAnything(state, machines))) return;
+ ended = true;
+ document.exitPointerLock?.();
+ $('#end-tag').textContent = allDone ? 'عملیات موفق' : 'پایان بودجه';
+ $('#end-title').textContent = allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد';
+ $('#end-text').textContent = allDone
+ ? 'همهٔ مأموریتهای محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد بهزودی باز میشوند.'
+ : 'دیگر نمیتوانید از دستگاهها خرید کنید. امتیاز امروز:';
+ const places = new Set(state.purchases.map((p) => p.machineId)).size;
+ $('#end-stats').innerHTML = [
+ ['امتیاز', `${fa(state.xp)} XP`],
+ ['درجه', rankFor(state.xp).name],
+ ['خریدها', fa(state.purchases.length)],
+ ['مکانهای کشفشده', `${fa(places)} از ${fa(machines.length)}`],
+ ['مأموریتها', `${fa(state.missionsDone.length)} از ${fa(MISSIONS.length)}`],
+ ['باقیمانده', formatHetoman(state.balance)],
+ ]
+ .map(([k, v]) => `${k}${v}`)
+ .join('');
+ $('#continue').hidden = !allDone;
+ $('#end').showModal();
}
$('#quality').textContent = quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل';
$('#quality').onclick = () => {
- try {
- localStorage.setItem('quality', quality === 'low' ? 'high' : 'low');
- } catch {}
- location.replace(location.pathname);
+ try {
+ localStorage.setItem('quality', quality === 'low' ? 'high' : 'low');
+ } catch {}
+ location.replace(location.pathname);
};
$('#again').onclick = reset;
$('#continue').onclick = () => $('#end').close();
@@ -638,289 +827,396 @@ $('#restart').onclick = reset;
// ---------- Objective, route and HUD ----------
const objectiveStation = () => {
- if (waypoint) return stationById(waypoint);
- const mission = activeMission(state);
- if (mission?.target) return stationById(mission.target);
- // Open-ended missions point to the nearest place not bought from yet.
- const visited = new Set(state.purchases.map(p => p.machineId));
- return activeStations().filter(s => !visited.has(s.def.id)).sort((a, b) => Math.hypot(a.x - player.x, a.z - player.z) - Math.hypot(b.x - player.x, b.z - player.z))[0] ?? null;
+ if (waypoint) return stationById(waypoint);
+ const mission = activeMission(state);
+ if (mission?.target) return stationById(mission.target);
+ // Open-ended missions point to the nearest place not bought from yet.
+ const visited = new Set(state.purchases.map((p) => p.machineId));
+ return (
+ activeStations()
+ .filter((s) => !visited.has(s.def.id))
+ .sort(
+ (a, b) =>
+ Math.hypot(a.x - player.x, a.z - player.z) - Math.hypot(b.x - player.x, b.z - player.z),
+ )[0] ?? null
+ );
};
// The gold route ribbon on the ground, rebuilt when the route changes.
-const routeMaterial = new THREE.MeshBasicMaterial({color: '#f2c94c', transparent: true, opacity: .75, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -12, polygonOffsetUnits: -24});
+const routeMaterial = new THREE.MeshBasicMaterial({
+ color: '#f2c94c',
+ transparent: true,
+ opacity: 0.75,
+ depthWrite: false,
+ polygonOffset: true,
+ polygonOffsetFactor: -12,
+ polygonOffsetUnits: -24,
+});
let routeMesh = null;
function rebuildRouteMesh() {
- if (routeMesh) {
- scene.remove(routeMesh);
- routeMesh.geometry.dispose();
- routeMesh = null;
- }
- if (!route) return;
- const pts = [], pos = [], idx = [];
- let total = 0;
- for (const p of route.points) {
- if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z);
- if (total > 700) break;
- pts.push(p);
- }
- for (let i = 0; i < pts.length; i++) {
- const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
- const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1, w = mode === 'drive' ? 1.1 : .55;
- pos.push(pts[i].x - dz / len * w, .14, pts[i].z + dx / len * w, pts[i].x + dz / len * w, .14, pts[i].z - dx / len * w);
- if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1);
- }
- const g = new THREE.BufferGeometry();
- g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
- g.setIndex(idx);
- routeMesh = new THREE.Mesh(g, routeMaterial);
- scene.add(routeMesh);
+ if (routeMesh) {
+ scene.remove(routeMesh);
+ routeMesh.geometry.dispose();
+ routeMesh = null;
+ }
+ if (!route) return;
+ const pts = [],
+ pos = [],
+ idx = [];
+ let total = 0;
+ for (const p of route.points) {
+ if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z);
+ if (total > 700) break;
+ pts.push(p);
+ }
+ for (let i = 0; i < pts.length; i++) {
+ const a = pts[Math.max(0, i - 1)],
+ b = pts[Math.min(pts.length - 1, i + 1)];
+ const dx = b.x - a.x,
+ dz = b.z - a.z,
+ len = Math.hypot(dx, dz) || 1,
+ w = mode === 'drive' ? 1.1 : 0.55;
+ pos.push(
+ pts[i].x - (dz / len) * w,
+ 0.14,
+ pts[i].z + (dx / len) * w,
+ pts[i].x + (dz / len) * w,
+ 0.14,
+ pts[i].z - (dx / len) * w,
+ );
+ if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1);
+ }
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
+ g.setIndex(idx);
+ routeMesh = new THREE.Mesh(g, routeMaterial);
+ scene.add(routeMesh);
}
-let routeTarget = null, routeFrom = {x: 0, z: 0}, routeMode = null;
+let routeTarget = null,
+ routeFrom = { x: 0, z: 0 },
+ routeMode = null;
function updateRoute(dt) {
- routeTimer -= dt;
- const target = objectiveStation();
- if (!target) {
- if (route) {
- route = null;
- rebuildRouteMesh();
- }
- return;
+ routeTimer -= dt;
+ const target = objectiveStation();
+ if (!target) {
+ if (route) {
+ route = null;
+ rebuildRouteMesh();
}
- const pos = mode === 'drive' ? car : player;
- const changed = target !== routeTarget || routeMode !== mode || !route;
- if (!changed && (routeTimer > 0 || Math.hypot(pos.x - routeFrom.x, pos.z - routeFrom.z) < 10)) return;
- routeTimer = 1.2;
- routeTarget = target;
- routeMode = mode;
- routeFrom = {x: pos.x, z: pos.z};
- // Close targets are reached directly; the street network would detour out of buildings.
- const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z);
- route = direct < 60 ? {points: [{x: pos.x, z: pos.z}, {x: target.front.x, z: target.front.z}], length: direct}
- : routeBetween(mode === 'drive' ? driveGraph : walkGraph, pos.x, pos.z, target.front.x, target.front.z);
- rebuildRouteMesh();
+ return;
+ }
+ const pos = mode === 'drive' ? car : player;
+ const changed = target !== routeTarget || routeMode !== mode || !route;
+ if (!changed && (routeTimer > 0 || Math.hypot(pos.x - routeFrom.x, pos.z - routeFrom.z) < 10))
+ return;
+ routeTimer = 1.2;
+ routeTarget = target;
+ routeMode = mode;
+ routeFrom = { x: pos.x, z: pos.z };
+ // Close targets are reached directly; the street network would detour out of buildings.
+ const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z);
+ route =
+ direct < 60
+ ? {
+ points: [
+ { x: pos.x, z: pos.z },
+ { x: target.front.x, z: target.front.z },
+ ],
+ length: direct,
+ }
+ : routeBetween(
+ mode === 'drive' ? driveGraph : walkGraph,
+ pos.x,
+ pos.z,
+ target.front.x,
+ target.front.z,
+ );
+ rebuildRouteMesh();
}
-const compass = $('#compass').getContext('2d'), minimap = $('#minimap').getContext('2d');
+const compass = $('#compass').getContext('2d'),
+ minimap = $('#minimap').getContext('2d');
function drawCompass(headingDeg, markers) {
- const w = 560, h = 44;
- compass.clearRect(0, 0, w, h);
- compass.fillStyle = 'rgba(10,16,14,.45)';
- compass.fillRect(0, 10, w, 24);
- compass.textAlign = 'center';
- compass.textBaseline = 'middle';
- const labels = {0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب'};
- for (let d = -100; d <= 100; d += 5) {
- const deg = ((Math.round(headingDeg / 5) * 5 + d) % 360 + 360) % 360, x = w / 2 + (deg - headingDeg + 540) % 360 * 2.6 - 180 * 2.6;
- if (x < 10 || x > w - 10) continue;
- compass.fillStyle = 'rgba(236,242,238,.8)';
- if (labels[deg] !== undefined) {
- compass.font = 'bold 13px Vazirmatn, sans-serif';
- compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
- compass.fillText(labels[deg], x, 22);
- } else if (deg % 15 === 0) {
- compass.font = '10px Vazirmatn, sans-serif';
- compass.fillText(fa(deg), x, 22);
- } else compass.fillRect(x - .5, 18, 1, 8);
- }
- for (const m of markers) {
- const x = w / 2 + ((m.bearing - headingDeg + 540) % 360 - 180) * 2.6;
- if (x < 10 || x > w - 10) continue;
- compass.save();
- compass.translate(x, 6);
- compass.rotate(Math.PI / 4);
- compass.fillStyle = m.color;
- compass.fillRect(-4, -4, 8, 8);
- compass.restore();
- }
- compass.fillStyle = '#ecf2ee';
- compass.fillRect(w / 2 - 1, 34, 2, 8);
- compass.font = 'bold 11px Vazirmatn, sans-serif';
- compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
+ const w = 560,
+ h = 44;
+ compass.clearRect(0, 0, w, h);
+ compass.fillStyle = 'rgba(10,16,14,.45)';
+ compass.fillRect(0, 10, w, 24);
+ compass.textAlign = 'center';
+ compass.textBaseline = 'middle';
+ const labels = { 0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب' };
+ for (let d = -100; d <= 100; d += 5) {
+ const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
+ x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
+ if (x < 10 || x > w - 10) continue;
+ compass.fillStyle = 'rgba(236,242,238,.8)';
+ if (labels[deg] !== undefined) {
+ compass.font = 'bold 13px Vazirmatn, sans-serif';
+ compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
+ compass.fillText(labels[deg], x, 22);
+ } else if (deg % 15 === 0) {
+ compass.font = '10px Vazirmatn, sans-serif';
+ compass.fillText(fa(deg), x, 22);
+ } else compass.fillRect(x - 0.5, 18, 1, 8);
+ }
+ for (const m of markers) {
+ const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6;
+ if (x < 10 || x > w - 10) continue;
+ compass.save();
+ compass.translate(x, 6);
+ compass.rotate(Math.PI / 4);
+ compass.fillStyle = m.color;
+ compass.fillRect(-4, -4, 8, 8);
+ compass.restore();
+ }
+ compass.fillStyle = '#ecf2ee';
+ compass.fillRect(w / 2 - 1, 34, 2, 8);
+ compass.font = 'bold 11px Vazirmatn, sans-serif';
+ compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
}
function refreshHUD() {
- $('#balance').textContent = fa(state.balance);
- const rank = rankFor(state.xp);
- $('#rank-name').textContent = rank.name;
- $('#xp').textContent = rank.next ? `${fa(state.xp)} / ${fa(rank.next)} XP` : `${fa(state.xp)} XP`;
- $('#xp-bar').style.width = `${Math.round(rank.progress * 100)}%`;
- const active = activeMission(state);
- $('#mission-list').innerHTML = MISSIONS.map(m => {
- const done = state.missionsDone.includes(m.id);
- return `${m.title}`;
- }).join('');
- $('#mode-label').textContent = mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده';
- $('#speed-stat').hidden = mode !== 'drive';
+ $('#balance').textContent = fa(state.balance);
+ const rank = rankFor(state.xp);
+ $('#rank-name').textContent = rank.name;
+ $('#xp').textContent = rank.next ? `${fa(state.xp)} / ${fa(rank.next)} XP` : `${fa(state.xp)} XP`;
+ $('#xp-bar').style.width = `${Math.round(rank.progress * 100)}%`;
+ const active = activeMission(state);
+ $('#mission-list').innerHTML = MISSIONS.map((m) => {
+ const done = state.missionsDone.includes(m.id);
+ return `${m.title}`;
+ }).join('');
+ $('#mode-label').textContent = mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده';
+ $('#speed-stat').hidden = mode !== 'drive';
}
function updatePrompt() {
- let html = '';
- const station = nearStation();
- // The payment panel carries its own swipe button, so no prompt while paying or collecting.
- if (state.busy || payment.phase !== 'idle') html = '';
- else if (station) html = isOpen(station.def, state.minutes) ? `Eخرید از روبات مارکت · ${station.def.name}` : `دستگاه بسته است · ${hoursText(station.def)}`;
- else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5) html = 'Fسوار ماشین شو';
- else if (mode === 'drive' && Math.abs(car.speed) < 1 && stations.some(s => Math.hypot(s.front.x - car.x, s.front.z - car.z) < 25)) html = 'Fپیاده شو؛ دستگاه نزدیک است';
- $('#prompt').hidden = !html;
- if ($('#prompt').innerHTML !== html) $('#prompt').innerHTML = html;
+ let html = '';
+ const station = nearStation();
+ // The payment panel carries its own swipe button, so no prompt while paying or collecting.
+ if (state.busy || payment.phase !== 'idle') html = '';
+ else if (station)
+ html = isOpen(station.def, state.minutes)
+ ? `Eخرید از روبات مارکت · ${station.def.name}`
+ : `دستگاه بسته است · ${hoursText(station.def)}`;
+ else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5)
+ html = 'Fسوار ماشین شو';
+ else if (
+ mode === 'drive' &&
+ Math.abs(car.speed) < 1 &&
+ stations.some((s) => Math.hypot(s.front.x - car.x, s.front.z - car.z) < 25)
+ )
+ html = 'Fپیاده شو؛ دستگاه نزدیک است';
+ $('#prompt').hidden = !html;
+ if ($('#prompt').innerHTML !== html) $('#prompt').innerHTML = html;
}
// ---------- Map dialog ----------
-const mapView = {cx: 0, cz: 0, mpp: 2}, mapCanvas = $('#fullmap'), iranCanvas = $('#iran-map');
-let selected = null, mapTab = 'city';
+const mapView = { cx: 0, cz: 0, mpp: 2 },
+ mapCanvas = $('#fullmap'),
+ iranCanvas = $('#iran-map');
+let selected = null,
+ mapTab = 'city';
const markers = () => {
- const visited = new Set(state.purchases.map(p => p.machineId)), objective = objectiveStation();
- return activeStations().map(s => ({id: s.def.id, x: s.x, z: s.z, visited: visited.has(s.def.id), open: isOpen(s.def, state.minutes), objective: s === objective}));
+ const visited = new Set(state.purchases.map((p) => p.machineId)),
+ objective = objectiveStation();
+ return activeStations().map((s) => ({
+ id: s.def.id,
+ x: s.x,
+ z: s.z,
+ visited: visited.has(s.def.id),
+ open: isOpen(s.def, state.minutes),
+ objective: s === objective,
+ }));
};
function sizeCanvas(c) {
- const r = c.getBoundingClientRect(), dpr = Math.min(devicePixelRatio, 2);
- c.width = Math.max(1, r.width * dpr);
- c.height = Math.max(1, r.height * dpr);
- return dpr;
+ const r = c.getBoundingClientRect(),
+ dpr = Math.min(devicePixelRatio, 2);
+ c.width = Math.max(1, r.width * dpr);
+ c.height = Math.max(1, r.height * dpr);
+ return dpr;
}
function renderMap() {
- if (!$('#map-dialog').open) return;
- if (mapTab === 'city') {
- const dpr = sizeCanvas(mapCanvas);
- const pos = mode === 'drive' ? car : player;
- drawFullMap(mapCanvas.getContext('2d'), mapAssets, mapView, {dpr, markers: markers(), route: route?.points, selected, player: {x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading}});
- } else {
- const dpr = sizeCanvas(iranCanvas), c = iranCanvas.getContext('2d');
- c.setTransform(dpr, 0, 0, dpr, 0, 0);
- drawIran(c, iran, {width: iranCanvas.width / dpr, height: iranCanvas.height / dpr, t: elapsed, cities: CITIES});
- }
+ if (!$('#map-dialog').open) return;
+ if (mapTab === 'city') {
+ const dpr = sizeCanvas(mapCanvas);
+ const pos = mode === 'drive' ? car : player;
+ drawFullMap(mapCanvas.getContext('2d'), mapAssets, mapView, {
+ dpr,
+ markers: markers(),
+ route: route?.points,
+ selected,
+ player: { x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading },
+ });
+ } else {
+ const dpr = sizeCanvas(iranCanvas),
+ c = iranCanvas.getContext('2d');
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ drawIran(c, iran, {
+ width: iranCanvas.width / dpr,
+ height: iranCanvas.height / dpr,
+ t: elapsed,
+ cities: CITIES,
+ });
+ }
}
function renderMachineList() {
- const pos = mode === 'drive' ? car : player, visited = new Set(state.purchases.map(p => p.machineId));
- const items = activeStations().map(s => {
- const li = document.createElement('li'), b = document.createElement('button'), open = isOpen(s.def, state.minutes);
- b.type = 'button';
- b.className = s.def.id === selected ? 'selected' : '';
- b.innerHTML = `${CATEGORIES[s.def.category].icon}${s.def.name}${visited.has(s.def.id) ? ' ✓' : ''}${CATEGORIES[s.def.category].label} · ${open ? 'باز' : 'بسته'}${formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z))}`;
- b.onclick = () => selectMachine(s.def.id, true);
- li.append(b);
- return li;
- });
- for (const place of lockedPlaces) {
- const li = document.createElement('li');
- li.innerHTML = ``;
- items.push(li);
- }
- $('#machine-list').replaceChildren(...items);
+ const pos = mode === 'drive' ? car : player,
+ visited = new Set(state.purchases.map((p) => p.machineId));
+ const items = activeStations().map((s) => {
+ const li = document.createElement('li'),
+ b = document.createElement('button'),
+ open = isOpen(s.def, state.minutes);
+ b.type = 'button';
+ b.className = s.def.id === selected ? 'selected' : '';
+ b.innerHTML = `${CATEGORIES[s.def.category].icon}${s.def.name}${visited.has(s.def.id) ? ' ✓' : ''}${CATEGORIES[s.def.category].label} · ${open ? 'باز' : 'بسته'}${formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z))}`;
+ b.onclick = () => selectMachine(s.def.id, true);
+ li.append(b);
+ return li;
+ });
+ for (const place of lockedPlaces) {
+ const li = document.createElement('li');
+ li.innerHTML = ``;
+ items.push(li);
+ }
+ $('#machine-list').replaceChildren(...items);
}
function selectMachine(id, center = false) {
- selected = id;
- const s = stationById(id), def = s.def;
- if (center) {
- mapView.cx = s.x;
- mapView.cz = s.z;
- mapView.mpp = Math.min(mapView.mpp, 1.4);
- }
- $('#machine-detail').hidden = false;
- $('#detail-name').textContent = `${CATEGORIES[def.category].icon} ${def.name}`;
- $('#detail-address').textContent = `📍 ${def.address}`;
- $('#detail-meta').textContent = `🕒 ${hoursText(def)} · ${isOpen(def, state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`;
- $('#detail-products').textContent = def.products.map(id => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`).join(' · ');
- $('#route-button').textContent = waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا';
- renderMachineList();
- renderMap();
+ selected = id;
+ const s = stationById(id),
+ def = s.def;
+ if (center) {
+ mapView.cx = s.x;
+ mapView.cz = s.z;
+ mapView.mpp = Math.min(mapView.mpp, 1.4);
+ }
+ $('#machine-detail').hidden = false;
+ $('#detail-name').textContent = `${CATEGORIES[def.category].icon} ${def.name}`;
+ $('#detail-address').textContent = `📍 ${def.address}`;
+ $('#detail-meta').textContent =
+ `🕒 ${hoursText(def)} · ${isOpen(def, state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`;
+ $('#detail-products').textContent = def.products
+ .map((id) => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`)
+ .join(' · ');
+ $('#route-button').textContent = waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا';
+ renderMachineList();
+ renderMap();
}
$('#route-button').onclick = () => {
- if (!selected) return;
- waypoint = selected;
- route = null;
- routeTimer = 0;
- updateRoute(0);
- const s = stationById(selected);
- say('father', `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`);
- selectMachine(selected);
+ if (!selected) return;
+ waypoint = selected;
+ route = null;
+ routeTimer = 0;
+ updateRoute(0);
+ const s = stationById(selected);
+ say(
+ 'father',
+ `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`,
+ );
+ selectMachine(selected);
};
function openMap() {
- if (anyDialogOpen()) return;
- document.exitPointerLock?.();
- keys.clear();
- const pos = mode === 'drive' ? car : player;
- Object.assign(mapView, {cx: pos.x, cz: pos.z, mpp: 2.2});
- $('#map-dialog').showModal();
- selected ??= objectiveStation()?.def.id ?? null;
- if (selected) selectMachine(selected);
- renderMachineList();
- renderMap();
+ if (anyDialogOpen()) return;
+ document.exitPointerLock?.();
+ keys.clear();
+ const pos = mode === 'drive' ? car : player;
+ Object.assign(mapView, { cx: pos.x, cz: pos.z, mpp: 2.2 });
+ $('#map-dialog').showModal();
+ selected ??= objectiveStation()?.def.id ?? null;
+ if (selected) selectMachine(selected);
+ renderMachineList();
+ renderMap();
}
$('#minimap-button').onclick = openMap;
$('#map-close').onclick = () => $('#map-dialog').close();
-for (const tab of document.querySelectorAll('.tabs button')) tab.onclick = () => {
+for (const tab of document.querySelectorAll('.tabs button'))
+ tab.onclick = () => {
mapTab = tab.dataset.tab;
- document.querySelectorAll('.tabs button').forEach(t => t.setAttribute('aria-selected', String(t === tab)));
+ document
+ .querySelectorAll('.tabs button')
+ .forEach((t) => t.setAttribute('aria-selected', String(t === tab)));
mapCanvas.hidden = mapTab !== 'city';
iranCanvas.hidden = mapTab !== 'iran';
renderMap();
-};
+ };
let mapDrag = null;
-mapCanvas.addEventListener('pointerdown', e => {
- mapDrag = {x: e.clientX, y: e.clientY, moved: 0};
- mapCanvas.setPointerCapture(e.pointerId);
+mapCanvas.addEventListener('pointerdown', (e) => {
+ mapDrag = { x: e.clientX, y: e.clientY, moved: 0 };
+ mapCanvas.setPointerCapture(e.pointerId);
});
-mapCanvas.addEventListener('pointermove', e => {
- if (!mapDrag) return;
- mapView.cx -= (e.clientX - mapDrag.x) * mapView.mpp;
- mapView.cz -= (e.clientY - mapDrag.y) * mapView.mpp;
- mapDrag.moved += Math.abs(e.clientX - mapDrag.x) + Math.abs(e.clientY - mapDrag.y);
- mapDrag.x = e.clientX;
- mapDrag.y = e.clientY;
- renderMap();
+mapCanvas.addEventListener('pointermove', (e) => {
+ if (!mapDrag) return;
+ mapView.cx -= (e.clientX - mapDrag.x) * mapView.mpp;
+ mapView.cz -= (e.clientY - mapDrag.y) * mapView.mpp;
+ mapDrag.moved += Math.abs(e.clientX - mapDrag.x) + Math.abs(e.clientY - mapDrag.y);
+ mapDrag.x = e.clientX;
+ mapDrag.y = e.clientY;
+ renderMap();
});
-mapCanvas.addEventListener('pointerup', e => {
- if (mapDrag && mapDrag.moved < 6) {
- const r = mapCanvas.getBoundingClientRect();
- const wx = mapView.cx + (e.clientX - r.left - r.width / 2) * mapView.mpp, wz = mapView.cz + (e.clientY - r.top - r.height / 2) * mapView.mpp;
- const hit = activeStations().find(s => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp);
- if (hit) selectMachine(hit.def.id);
- }
- mapDrag = null;
+mapCanvas.addEventListener('pointerup', (e) => {
+ if (mapDrag && mapDrag.moved < 6) {
+ const r = mapCanvas.getBoundingClientRect();
+ const wx = mapView.cx + (e.clientX - r.left - r.width / 2) * mapView.mpp,
+ wz = mapView.cz + (e.clientY - r.top - r.height / 2) * mapView.mpp;
+ const hit = activeStations().find((s) => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp);
+ if (hit) selectMachine(hit.def.id);
+ }
+ mapDrag = null;
});
-mapCanvas.addEventListener('wheel', e => {
+mapCanvas.addEventListener(
+ 'wheel',
+ (e) => {
e.preventDefault();
- mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), .35, 30);
+ mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), 0.35, 30);
renderMap();
-}, {passive: false});
+ },
+ { passive: false },
+);
// ---------- Briefing ----------
const CITIES = [
- {name: 'مشهد', lat: 36.30, lon: 59.60, active: true}, {name: 'تهران', lat: 35.69, lon: 51.39}, {name: 'اصفهان', lat: 32.65, lon: 51.67},
- {name: 'شیراز', lat: 29.59, lon: 52.58}, {name: 'تبریز', lat: 38.08, lon: 46.29}, {name: 'اهواز', lat: 31.32, lon: 48.67},
- {name: 'کرمان', lat: 30.28, lon: 57.08}, {name: 'رشت', lat: 37.28, lon: 49.58}, {name: 'یزد', lat: 31.90, lon: 54.37},
- {name: 'زاهدان', lat: 29.50, lon: 60.86}, {name: 'کرمانشاه', lat: 34.31, lon: 47.07}, {name: 'بندرعباس', lat: 27.18, lon: 56.27}
+ { name: 'مشهد', lat: 36.3, lon: 59.6, active: true },
+ { name: 'تهران', lat: 35.69, lon: 51.39 },
+ { name: 'اصفهان', lat: 32.65, lon: 51.67 },
+ { name: 'شیراز', lat: 29.59, lon: 52.58 },
+ { name: 'تبریز', lat: 38.08, lon: 46.29 },
+ { name: 'اهواز', lat: 31.32, lon: 48.67 },
+ { name: 'کرمان', lat: 30.28, lon: 57.08 },
+ { name: 'رشت', lat: 37.28, lon: 49.58 },
+ { name: 'یزد', lat: 31.9, lon: 54.37 },
+ { name: 'زاهدان', lat: 29.5, lon: 60.86 },
+ { name: 'کرمانشاه', lat: 34.31, lon: 47.07 },
+ { name: 'بندرعباس', lat: 27.18, lon: 56.27 },
];
const BRIEF = [
- `نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
- 'تیم: پدر و فرزند · روبات راهنما در دستگاهها منتظر شماست',
- 'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاههای روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
- 'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
- 'بودجه: ۵۰۰ هتومان · مأموریتها پاداش نقدی دارند.',
- 'رفتوآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاهها را ببینید.'
+ `نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
+ 'تیم: پدر و فرزند · روبات راهنما در دستگاهها منتظر شماست',
+ 'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاههای روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
+ 'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
+ 'بودجه: ۵۰۰ هتومان · مأموریتها پاداش نقدی دارند.',
+ 'رفتوآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاهها را ببینید.',
].join('\n');
let briefChars = 0;
function startMission() {
- $('#briefing').hidden = true;
- $('#hud').hidden = false;
- say('child', 'بابا، اینجا همون کارخونهایه که دستگاههای روبات مارکت رو میسازه؟');
- say('father', 'آره! سالن تولید اونوره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!');
- say('child', 'اون وانت سفیده کیه داره میاد تو؟');
- say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
- if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {});
+ $('#briefing').hidden = true;
+ $('#hud').hidden = false;
+ say('child', 'بابا، اینجا همون کارخونهایه که دستگاههای روبات مارکت رو میسازه؟');
+ say('father', 'آره! سالن تولید اونوره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!');
+ say('child', 'اون وانت سفیده کیه داره میاد تو؟');
+ say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
+ if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {});
}
$('#start-mission').onclick = startMission;
@@ -928,41 +1224,52 @@ $('#start-mission').onclick = startMission;
// ---------- Real-world (GPS) mode ----------
// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where
// a machine stands lets the player buy there, with a bonus.
-const real = {on: false, fix: null, heading: null, error: null, waiting: null, stop: null, stabilizer: createStabilizer()};
+const real = {
+ on: false,
+ fix: null,
+ heading: null,
+ error: null,
+ waiting: null,
+ stop: null,
+ stabilizer: createStabilizer(),
+};
function setRealMode(on) {
- if (on === real.on) return;
- real.stop?.();
- Object.assign(real, {on, fix: null, heading: null, error: null, waiting: null, stop: null});
- for (const s of stations) if (s.def.test) s.model.group.visible = on;
- $('#gps-toggle').classList.toggle('on', on);
- $('#gps-status').hidden = !on;
- $('#ar-toggle').hidden = $('#stable-toggle').hidden = !on;
- // Real-world mode is first person: the player is where the phone is and sees what it faces.
- document.body.classList.toggle('real', on);
- if (!on) {
- setArMode(false);
- arView.stopSensors();
- return;
- }
- arView.startSensors();
- cam.arPitch = 0;
- cam.yaw = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading));
- if (mode === 'drive') toggleCar();
- mode = 'walk';
- real.stop = startTracking({
- onFix: fix => {
- real.fix = fix;
- real.error = null;
- },
- onHeading: heading => real.heading = heading,
- onError: message => real.error = message,
- onStatus: message => real.waiting = message,
- stabilizer: real.stabilizer = createStabilizer()
- });
- real.stabilizer.stable = stableMode;
- say('father', 'حالت دنیای واقعی روشن شد؛ از چشم خودمون میبینیم و هر جا راه بریم، توی بازی هم همونجاییم.');
- setArMode(true);
+ if (on === real.on) return;
+ real.stop?.();
+ Object.assign(real, { on, fix: null, heading: null, error: null, waiting: null, stop: null });
+ for (const s of stations) if (s.def.test) s.model.group.visible = on;
+ $('#gps-toggle').classList.toggle('on', on);
+ $('#gps-status').hidden = !on;
+ $('#ar-toggle').hidden = $('#stable-toggle').hidden = !on;
+ // Real-world mode is first person: the player is where the phone is and sees what it faces.
+ document.body.classList.toggle('real', on);
+ if (!on) {
+ setArMode(false);
+ arView.stopSensors();
+ return;
+ }
+ arView.startSensors();
+ cam.arPitch = 0;
+ cam.yaw = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading));
+ if (mode === 'drive') toggleCar();
+ mode = 'walk';
+ real.stop = startTracking({
+ onFix: (fix) => {
+ real.fix = fix;
+ real.error = null;
+ },
+ onHeading: (heading) => (real.heading = heading),
+ onError: (message) => (real.error = message),
+ onStatus: (message) => (real.waiting = message),
+ stabilizer: (real.stabilizer = createStabilizer()),
+ });
+ real.stabilizer.stable = stableMode;
+ say(
+ 'father',
+ 'حالت دنیای واقعی روشن شد؛ از چشم خودمون میبینیم و هر جا راه بریم، توی بازی هم همونجاییم.',
+ );
+ setArMode(true);
}
// ---------- First-person real-world view ----------
@@ -970,54 +1277,68 @@ function setRealMode(on) {
// picture on (AR) the city model is hidden and only the machines, their reach circles, the route
// and the robot are drawn over the real street; with it off, the 3D city is shown first person.
const arView = createArView($('#camera'));
-let arMode = false, stableMode = true;
-const hiddenInAr = () => [sky, city.group, imagery.group, factory.group, traffic.group, carModel.group, parkGate];
+let arMode = false,
+ stableMode = true;
+const hiddenInAr = () => [
+ sky,
+ city.group,
+ imagery.group,
+ factory.group,
+ traffic.group,
+ carModel.group,
+ parkGate,
+];
async function setArMode(on) {
- if (on && !arMode) {
- const problem = await arView.start();
- if (problem) {
- say('father', `${problem} فعلاً شهر سهبعدی رو از چشم خودمون میبینیم.`);
- return;
- }
- if (!real.on) {
- arView.stop();
- return;
- }
- say('robot', 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', 'excited');
- setTimeout(() => {
- if (real.on && !arView.hasOrientation) say('father', 'حسگر جهت گوشی جواب نمیده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
- }, 3000);
- } else if (!on && arMode) arView.stop();
- else return;
- arMode = on;
- for (const object of hiddenInAr()) object.visible = !on;
- scene.fog = on ? null : fog;
- document.body.classList.toggle('ar', on);
- $('#ar-toggle').classList.toggle('on', on);
+ if (on && !arMode) {
+ const problem = await arView.start();
+ if (problem) {
+ say('father', `${problem} فعلاً شهر سهبعدی رو از چشم خودمون میبینیم.`);
+ return;
+ }
+ if (!real.on) {
+ arView.stop();
+ return;
+ }
+ say(
+ 'robot',
+ 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
+ 'excited',
+ );
+ setTimeout(() => {
+ if (real.on && !arView.hasOrientation)
+ say('father', 'حسگر جهت گوشی جواب نمیده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
+ }, 3000);
+ } else if (!on && arMode) arView.stop();
+ else return;
+ arMode = on;
+ for (const object of hiddenInAr()) object.visible = !on;
+ scene.fog = on ? null : fog;
+ document.body.classList.toggle('ar', on);
+ $('#ar-toggle').classList.toggle('on', on);
}
$('#ar-toggle').onclick = () => setArMode(!arMode);
$('#stable-toggle').onclick = () => {
- stableMode = !stableMode;
- real.stabilizer.stable = stableMode;
- $('#stable-toggle').classList.toggle('on', stableMode);
- $('#stable-toggle').textContent = stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش';
+ stableMode = !stableMode;
+ real.stabilizer.stable = stableMode;
+ $('#stable-toggle').classList.toggle('on', stableMode);
+ $('#stable-toggle').textContent = stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش';
};
// The safety notice comes first; permissions are requested from the tap on its button (iOS needs that).
function askRealMode(thenStart) {
- if (real.on) {
- setRealMode(false);
- return;
- }
- $('#real-intro').showModal();
- $('#real-go').onclick = async () => {
- $('#real-intro').close();
- await requestCompass();
- setRealMode(true);
- if (thenStart) startMission();
- };
+ if (real.on) {
+ setRealMode(false);
+ return;
+ }
+ $('#real-intro').showModal();
+ $('#real-go').onclick = async () => {
+ $('#real-intro').close();
+ await requestCompass();
+ setRealMode(true);
+ if (thenStart) startMission();
+ };
}
$('#real-mode').onclick = () => askRealMode(true);
@@ -1025,417 +1346,581 @@ $('#gps-toggle').onclick = () => askRealMode(false);
$('#real-cancel').onclick = () => $('#real-intro').close();
function updateReal(dt) {
- const status = $('#gps-status');
- if (real.error) status.textContent = `⚠ ${real.error}`;
- else if (!real.fix) status.textContent = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`;
- else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) {
- const nearest = Math.min(...activeStations().map(s => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)));
- status.textContent = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیکترین دستگاه`;
- } else status.textContent = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
- if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) {
- player.speed = 0;
- return;
- }
- // Walk the avatar towards the (building-free) GPS point instead of teleporting it.
- const target = resolveCircle(collision, real.fix.x, real.fix.z, 1);
- const dx = target.x - player.x, dz = target.z - player.z, d = Math.hypot(dx, dz);
- if (d > 150) Object.assign(player, {x: target.x, z: target.z});
- const speed = d > .5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0;
- player.speed += (speed - player.speed) * Math.min(1, dt * 6);
- if (d > .5) {
- const step = Math.min(d, player.speed * dt);
- player.x += dx / d * step;
- player.z += dz / d * step;
- }
- const want = real.heading ?? (d > .5 ? Math.atan2(-dz, dx) : player.heading);
- // Stable mode also calms the compass, which jitters near cars and metal.
- player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * (stableMode ? 2 : 5));
- player.gait += dt * player.speed * 1.9;
+ const status = $('#gps-status');
+ if (real.error) status.textContent = `⚠ ${real.error}`;
+ else if (!real.fix) status.textContent = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`;
+ else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) {
+ const nearest = Math.min(
+ ...activeStations().map((s) => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)),
+ );
+ status.textContent = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیکترین دستگاه`;
+ } else
+ status.textContent = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
+ if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) {
+ player.speed = 0;
+ return;
+ }
+ // Walk the avatar towards the (building-free) GPS point instead of teleporting it.
+ const target = resolveCircle(collision, real.fix.x, real.fix.z, 1);
+ const dx = target.x - player.x,
+ dz = target.z - player.z,
+ d = Math.hypot(dx, dz);
+ if (d > 150) Object.assign(player, { x: target.x, z: target.z });
+ const speed = d > 0.5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0;
+ player.speed += (speed - player.speed) * Math.min(1, dt * 6);
+ if (d > 0.5) {
+ const step = Math.min(d, player.speed * dt);
+ player.x += (dx / d) * step;
+ player.z += (dz / d) * step;
+ }
+ const want = real.heading ?? (d > 0.5 ? Math.atan2(-dz, dx) : player.heading);
+ // Stable mode also calms the compass, which jitters near cars and metal.
+ player.heading +=
+ Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) *
+ Math.min(1, dt * (stableMode ? 2 : 5));
+ player.gait += dt * player.speed * 1.9;
}
// ---------- Loop ----------
-const camPos = new THREE.Vector3(), camTarget = new THREE.Vector3(), lookAt = new THREE.Vector3();
-let elapsed = 0, last = performance.now(), hudTimer = 0, streetTimer = 0;
+const camPos = new THREE.Vector3(),
+ camTarget = new THREE.Vector3(),
+ lookAt = new THREE.Vector3();
+let elapsed = 0,
+ last = performance.now(),
+ hudTimer = 0,
+ streetTimer = 0;
// Debug runs (headless checks render ~1 fps) may simulate a whole second per frame, still in substeps.
-const MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : .25;
+const MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : 0.25;
function resize() {
- renderer.setSize(innerWidth, innerHeight);
- camera.aspect = innerWidth / innerHeight;
- camera.updateProjectionMatrix();
- renderMap();
+ renderer.setSize(innerWidth, innerHeight);
+ camera.aspect = innerWidth / innerHeight;
+ camera.updateProjectionMatrix();
+ renderMap();
}
window.addEventListener('resize', resize);
function updateWalk(dt) {
- const forward = {x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw)}, right = {x: Math.cos(cam.yaw), z: -Math.sin(cam.yaw)};
- const iy = axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, ix = axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x;
- const frozen = state.busy || payment.phase !== 'idle';
- let dx = forward.x * iy + right.x * ix, dz = forward.z * iy + right.z * ix;
- const len = Math.hypot(dx, dz);
- const run = keys.has('shift');
- const target = frozen || len < .1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len);
- player.speed += (target - player.speed) * Math.min(1, dt * 8);
- if (len > .1 && !frozen) {
- dx /= len;
- dz /= len;
- const want = Math.atan2(-dz, dx);
- player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 9);
- }
- // Face the machine while buying.
- const station = payment.station ?? payment.delivering?.station;
- if (frozen && station) {
- const want = Math.atan2(-(station.z - player.z), station.x - player.x);
- player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 6);
- }
- const mx = Math.cos(player.heading) * player.speed * dt, mz = -Math.sin(player.heading) * player.speed * dt;
- const p = resolveCircle(collision, player.x + mx, player.z + mz, 1);
- const {minX, minZ, maxX, maxZ} = data.bounds;
- player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
- player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
- player.gait += dt * player.speed * 1.9;
+ const forward = { x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw) },
+ right = { x: Math.cos(cam.yaw), z: -Math.sin(cam.yaw) };
+ const iy = axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y,
+ ix = axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x;
+ const frozen = state.busy || payment.phase !== 'idle';
+ let dx = forward.x * iy + right.x * ix,
+ dz = forward.z * iy + right.z * ix;
+ const len = Math.hypot(dx, dz);
+ const run = keys.has('shift');
+ const target = frozen || len < 0.1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len);
+ player.speed += (target - player.speed) * Math.min(1, dt * 8);
+ if (len > 0.1 && !frozen) {
+ dx /= len;
+ dz /= len;
+ const want = Math.atan2(-dz, dx);
+ player.heading +=
+ Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) *
+ Math.min(1, dt * 9);
+ }
+ // Face the machine while buying.
+ const station = payment.station ?? payment.delivering?.station;
+ if (frozen && station) {
+ const want = Math.atan2(-(station.z - player.z), station.x - player.x);
+ player.heading +=
+ Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) *
+ Math.min(1, dt * 6);
+ }
+ const mx = Math.cos(player.heading) * player.speed * dt,
+ mz = -Math.sin(player.heading) * player.speed * dt;
+ const p = resolveCircle(collision, player.x + mx, player.z + mz, 1);
+ const { minX, minZ, maxX, maxZ } = data.bounds;
+ player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
+ player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
+ player.gait += dt * player.speed * 1.9;
}
// Collision log for ?debug runs; trimmed so it never grows without bound.
const debugHits = [];
function updateDrive(dt) {
- if (debugHits.length > 200) debugHits.splice(0, 100);
- const throttle = THREE.MathUtils.clamp(axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, -1, 1);
- const steer = THREE.MathUtils.clamp(axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, -1, 1);
- // At top speed the car covers almost 2 m per step, so move it in short slices that can't
- // tunnel through a thin wall or fence.
- const slices = Math.max(1, Math.ceil(Math.abs(car.speed) * dt / .6));
- for (let i = 0; i < slices; i++) {
- stepCar(car, {throttle, steer, handbrake: keys.has(' ')}, dt / slices);
- // Two collision circles along the body keep the nose and tail out of buildings.
- for (const along of [1.3, -1.3]) {
- const cx = car.x + Math.cos(car.heading) * along, cz = car.z - Math.sin(car.heading) * along;
- const p = resolveCircle(collision, cx, cz, 1.05);
- if (p.hit) {
- debugHits.push({kind: 'building', x: Math.round(cx), z: Math.round(cz), v: +car.speed.toFixed(1)});
- car.x += p.x - cx;
- car.z += p.z - cz;
- if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square');
- car.speed *= .35;
- }
- }
+ if (debugHits.length > 200) debugHits.splice(0, 100);
+ const throttle = THREE.MathUtils.clamp(
+ axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y,
+ -1,
+ 1,
+ );
+ const steer = THREE.MathUtils.clamp(
+ axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x,
+ -1,
+ 1,
+ );
+ // At top speed the car covers almost 2 m per step, so move it in short slices that can't
+ // tunnel through a thin wall or fence.
+ const slices = Math.max(1, Math.ceil((Math.abs(car.speed) * dt) / 0.6));
+ for (let i = 0; i < slices; i++) {
+ stepCar(car, { throttle, steer, handbrake: keys.has(' ') }, dt / slices);
+ // Two collision circles along the body keep the nose and tail out of buildings.
+ for (const along of [1.3, -1.3]) {
+ const cx = car.x + Math.cos(car.heading) * along,
+ cz = car.z - Math.sin(car.heading) * along;
+ const p = resolveCircle(collision, cx, cz, 1.05);
+ if (p.hit) {
+ debugHits.push({
+ kind: 'building',
+ x: Math.round(cx),
+ z: Math.round(cz),
+ v: +car.speed.toFixed(1),
+ });
+ car.x += p.x - cx;
+ car.z += p.z - cz;
+ if (Math.abs(car.speed) > 4) tone(90, 0.2, 0.08, 'square');
+ car.speed *= 0.35;
+ }
}
- // Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
- // against one, it pulls away (respawns out of sight) so you can never get stuck behind it.
- for (const npc of traffic.cars) {
- const dx = car.x - npc.x, dz = car.z - npc.z, d = Math.hypot(dx, dz);
- if (d >= 3.4 || d === 0) {
- if (npc.contact) npc.contact = 0;
- continue;
- }
- debugHits.push({kind: 'traffic', x: Math.round(car.x), z: Math.round(car.z), v: +car.speed.toFixed(1), npcDir: npc.dir});
- car.x = npc.x + dx / d * 3.4;
- car.z = npc.z + dz / d * 3.4;
- const towards = (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0;
- if (towards && Math.abs(car.speed) > 4) tone(110, .25, .08, 'square');
- if (towards) car.speed *= .4;
- npc.contact = (npc.contact ?? 0) + dt;
- if (npc.contact > .8) {
- npc.contact = 0;
- traffic.remove(npc);
- }
+ }
+ // Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
+ // against one, it pulls away (respawns out of sight) so you can never get stuck behind it.
+ for (const npc of traffic.cars) {
+ const dx = car.x - npc.x,
+ dz = car.z - npc.z,
+ d = Math.hypot(dx, dz);
+ if (d >= 3.4 || d === 0) {
+ if (npc.contact) npc.contact = 0;
+ continue;
}
- const {minX, minZ, maxX, maxZ} = data.bounds;
- car.x = THREE.MathUtils.clamp(car.x, minX + 15, maxX - 15);
- car.z = THREE.MathUtils.clamp(car.z, minZ + 15, maxZ - 15);
- Object.assign(player, {x: car.x, z: car.z, heading: car.heading});
- // Chase camera swings back behind the car when the mouse is idle.
- if (elapsed - cam.lastMouse > 1.4 && Math.abs(car.speed) > 1) {
- const want = Math.atan2(-Math.cos(car.heading), Math.sin(car.heading)) + (car.speed < 0 ? Math.PI : 0);
- cam.yaw += Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 2.2);
- cam.pitch += (.28 - cam.pitch) * Math.min(1, dt * 1.5);
- }
- if (engine && soundOn) {
- engine.o.frequency.setTargetAtTime(45 + Math.abs(car.speed) * 5 + Math.max(0, throttle) * 15, audio.currentTime, .1);
- engine.g.gain.setTargetAtTime(.025, audio.currentTime, .2);
+ debugHits.push({
+ kind: 'traffic',
+ x: Math.round(car.x),
+ z: Math.round(car.z),
+ v: +car.speed.toFixed(1),
+ npcDir: npc.dir,
+ });
+ car.x = npc.x + (dx / d) * 3.4;
+ car.z = npc.z + (dz / d) * 3.4;
+ const towards =
+ (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0;
+ if (towards && Math.abs(car.speed) > 4) tone(110, 0.25, 0.08, 'square');
+ if (towards) car.speed *= 0.4;
+ npc.contact = (npc.contact ?? 0) + dt;
+ if (npc.contact > 0.8) {
+ npc.contact = 0;
+ traffic.remove(npc);
}
+ }
+ const { minX, minZ, maxX, maxZ } = data.bounds;
+ car.x = THREE.MathUtils.clamp(car.x, minX + 15, maxX - 15);
+ car.z = THREE.MathUtils.clamp(car.z, minZ + 15, maxZ - 15);
+ Object.assign(player, { x: car.x, z: car.z, heading: car.heading });
+ // Chase camera swings back behind the car when the mouse is idle.
+ if (elapsed - cam.lastMouse > 1.4 && Math.abs(car.speed) > 1) {
+ const want =
+ Math.atan2(-Math.cos(car.heading), Math.sin(car.heading)) + (car.speed < 0 ? Math.PI : 0);
+ cam.yaw +=
+ Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 2.2);
+ cam.pitch += (0.28 - cam.pitch) * Math.min(1, dt * 1.5);
+ }
+ if (engine && soundOn) {
+ engine.o.frequency.setTargetAtTime(
+ 45 + Math.abs(car.speed) * 5 + Math.max(0, throttle) * 15,
+ audio.currentTime,
+ 0.1,
+ );
+ engine.g.gain.setTargetAtTime(0.025, audio.currentTime, 0.2);
+ }
}
function updateCamera(dt) {
- const station = payment.station;
- if (station && payment.phase !== 'idle') {
- // Close-up of the card reader during payment.
- const reader = station.model.readerPoint.clone();
- station.model.group.localToWorld(reader);
- const side = rotateLocal(1.2, 1.9, station.rot);
- camTarget.set(reader.x + side.x, reader.y + .4, reader.z + side.z);
- lookAt.lerp(reader, 1 - Math.exp(-dt * 6));
- } else {
- const driving = mode === 'drive', height = driving ? 1.6 : 1.75;
- const focus = new THREE.Vector3(player.x, height, player.z);
- // Over-the-shoulder offset to the right, like a third-person shooter.
- const shoulder = driving ? 0 : .75;
- focus.x += Math.cos(cam.yaw) * shoulder;
- focus.z += -Math.sin(cam.yaw) * shoulder;
- let distance = cam.distance;
- const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch), oz = Math.cos(cam.yaw) * Math.cos(cam.pitch);
- const free = clearFraction(collision, focus.x, focus.z, focus.x + ox * distance, focus.z + oz * distance, .8);
- if (cam.pitch < .9) distance = Math.max(1.6, distance * free - .7);
- camTarget.set(focus.x + ox * distance, Math.max(.6, focus.y + Math.sin(cam.pitch) * distance), focus.z + oz * distance);
- lookAt.lerp(focus, 1 - Math.exp(-dt * 14));
- }
- camPos.lerp(camTarget, 1 - Math.exp(-dt * 12));
- camera.position.copy(camPos);
- camera.lookAt(lookAt);
+ const station = payment.station;
+ if (station && payment.phase !== 'idle') {
+ // Close-up of the card reader during payment.
+ const reader = station.model.readerPoint.clone();
+ station.model.group.localToWorld(reader);
+ const side = rotateLocal(1.2, 1.9, station.rot);
+ camTarget.set(reader.x + side.x, reader.y + 0.4, reader.z + side.z);
+ lookAt.lerp(reader, 1 - Math.exp(-dt * 6));
+ } else {
+ const driving = mode === 'drive',
+ height = driving ? 1.6 : 1.75;
+ const focus = new THREE.Vector3(player.x, height, player.z);
+ // Over-the-shoulder offset to the right, like a third-person shooter.
+ const shoulder = driving ? 0 : 0.75;
+ focus.x += Math.cos(cam.yaw) * shoulder;
+ focus.z += -Math.sin(cam.yaw) * shoulder;
+ let distance = cam.distance;
+ const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch),
+ oz = Math.cos(cam.yaw) * Math.cos(cam.pitch);
+ const free = clearFraction(
+ collision,
+ focus.x,
+ focus.z,
+ focus.x + ox * distance,
+ focus.z + oz * distance,
+ 0.8,
+ );
+ if (cam.pitch < 0.9) distance = Math.max(1.6, distance * free - 0.7);
+ camTarget.set(
+ focus.x + ox * distance,
+ Math.max(0.6, focus.y + Math.sin(cam.pitch) * distance),
+ focus.z + oz * distance,
+ );
+ lookAt.lerp(focus, 1 - Math.exp(-dt * 14));
+ }
+ camPos.lerp(camTarget, 1 - Math.exp(-dt * 12));
+ camera.position.copy(camPos);
+ camera.lookAt(lookAt);
}
function updateHUD(dt) {
- const pos = mode === 'drive' ? car : player;
- $('#clock').textContent = formatClock(state.minutes);
- if (mode === 'drive') $('#speed').textContent = fa(kmh(car));
- // Where the camera actually looks: the orbit camera, or the phone itself in AR view.
- const look3 = camera.getWorldDirection(new THREE.Vector3()), flat = Math.hypot(look3.x, look3.z);
- const forward = flat > .05 ? {x: look3.x / flat, z: look3.z / flat} : {x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw)};
- const headingDeg = bearing(forward.x, forward.z);
- const objective = objectiveStation();
- drawCompass(headingDeg, objective ? [{bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c'}] : []);
- drawMinimap(minimap, mapAssets, {
- size: 220, x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading, camForward: forward,
- radius: mode === 'drive' ? 650 : 200, markers: markers(), route: route?.points
- });
- // Objective marker above the machine, clamped to the screen edge when off-screen.
- const wp = $('#waypoint');
- wp.hidden = !objective;
- if (objective) {
- const d = Math.hypot(objective.x - pos.x, objective.z - pos.z), p = new THREE.Vector3(objective.x, 3.4, objective.z).project(camera);
- let sx = (p.x * .5 + .5) * innerWidth, sy = (-p.y * .5 + .5) * innerHeight;
- if (p.z > 1) {
- sx = innerWidth - sx;
- sy = innerHeight - 60;
- }
- $('#waypoint').style.left = `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`;
- $('#waypoint').style.top = `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`;
- $('#waypoint-name').textContent = objective.def.name;
- $('#waypoint-distance').textContent = formatDistance(d);
- wp.hidden = d < 6;
- for (const s of stations) {
- const isObjective = s === objective;
- s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
- s.model.beacon.material.opacity = (isObjective ? .32 : .14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
- }
+ const pos = mode === 'drive' ? car : player;
+ $('#clock').textContent = formatClock(state.minutes);
+ if (mode === 'drive') $('#speed').textContent = fa(kmh(car));
+ // Where the camera actually looks: the orbit camera, or the phone itself in AR view.
+ const look3 = camera.getWorldDirection(new THREE.Vector3()),
+ flat = Math.hypot(look3.x, look3.z);
+ const forward =
+ flat > 0.05
+ ? { x: look3.x / flat, z: look3.z / flat }
+ : { x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw) };
+ const headingDeg = bearing(forward.x, forward.z);
+ const objective = objectiveStation();
+ drawCompass(
+ headingDeg,
+ objective
+ ? [{ bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c' }]
+ : [],
+ );
+ drawMinimap(minimap, mapAssets, {
+ size: 220,
+ x: pos.x,
+ z: pos.z,
+ heading: mode === 'drive' ? car.heading : player.heading,
+ camForward: forward,
+ radius: mode === 'drive' ? 650 : 200,
+ markers: markers(),
+ route: route?.points,
+ });
+ // Objective marker above the machine, clamped to the screen edge when off-screen.
+ const wp = $('#waypoint');
+ wp.hidden = !objective;
+ if (objective) {
+ const d = Math.hypot(objective.x - pos.x, objective.z - pos.z),
+ p = new THREE.Vector3(objective.x, 3.4, objective.z).project(camera);
+ let sx = (p.x * 0.5 + 0.5) * innerWidth,
+ sy = (-p.y * 0.5 + 0.5) * innerHeight;
+ if (p.z > 1) {
+ sx = innerWidth - sx;
+ sy = innerHeight - 60;
}
- streetTimer -= dt;
- if (streetTimer <= 0) {
- streetTimer = .6;
- const street = nearestSegment(segments, pos.x, pos.z, 35, s => s[5] >= 0);
- const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
- $('#street').textContent = inFactory ? `کارخانهٔ ${FACTORY.name}` : street ? `مشهد · ${data.names[street.name]}` : 'حومهٔ مشهد';
+ $('#waypoint').style.left = `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`;
+ $('#waypoint').style.top = `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`;
+ $('#waypoint-name').textContent = objective.def.name;
+ $('#waypoint-distance').textContent = formatDistance(d);
+ wp.hidden = d < 6;
+ for (const s of stations) {
+ const isObjective = s === objective;
+ s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
+ s.model.beacon.material.opacity =
+ (isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
}
- updatePrompt();
+ }
+ streetTimer -= dt;
+ if (streetTimer <= 0) {
+ streetTimer = 0.6;
+ const street = nearestSegment(segments, pos.x, pos.z, 35, (s) => s[5] >= 0);
+ const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
+ $('#street').textContent = inFactory
+ ? `کارخانهٔ ${FACTORY.name}`
+ : street
+ ? `مشهد · ${data.names[street.name]}`
+ : 'حومهٔ مشهد';
+ }
+ updatePrompt();
}
-let hints = {closed: false, car: false};
+let hints = { closed: false, car: false };
// People in the factory say their lines once, when the family walks up to them.
function updateTalks() {
- if (mode !== 'walk' || real.on || lines.length > 2) return;
- for (const t of factory.talks) {
- if (t.done || (t.ready && !t.ready())) continue;
- const p = t.pos();
- if (Math.hypot(p.x - player.x, p.z - player.z) > t.radius) continue;
- t.done = true;
- for (const text of t.lines) say(t.speaker, text);
- return;
- }
+ if (mode !== 'walk' || real.on || lines.length > 2) return;
+ for (const t of factory.talks) {
+ if (t.done || (t.ready && !t.ready())) continue;
+ const p = t.pos();
+ if (Math.hypot(p.x - player.x, p.z - player.z) > t.radius) continue;
+ t.done = true;
+ for (const text of t.lines) say(t.speaker, text);
+ return;
+ }
}
// ---------- Robot guide ----------
// When the family walks up to a machine the robot pops out of it, greets them and explains what to
// do; when they leave it goes back inside. It is never a walking companion.
-const guideState = {station: null, t: 0, greeted: new Set(), arrived: 0};
+const guideState = { station: null, t: 0, greeted: new Set(), arrived: 0 };
const guideOut = () => guideState.t >= 1;
-const easeOutBack = t => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
+const easeOutBack = (t) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
function greet(station) {
- const def = station.def, first = !guideState.greeted.has(def.id);
- guideState.greeted.add(def.id);
- if (def.id === 'factory' && first) {
- say('robot', 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی میکنم!', 'excited');
- say('robot', `به ${FACTORY.name} خوش اومدین. بچههای همین دفتر نرمافزار دستگاهها رو مینویسن.`, 'happy');
- say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
- say('robot', 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاهها منتظرتونم!', 'love');
- return;
- }
- if (!isOpen(def, state.minutes)) {
- say('robot', `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`, 'serious');
- return;
- }
- if (first) {
- say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
- say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
- } else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
+ const def = station.def,
+ first = !guideState.greeted.has(def.id);
+ guideState.greeted.add(def.id);
+ if (def.id === 'factory' && first) {
+ say(
+ 'robot',
+ 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی میکنم!',
+ 'excited',
+ );
+ say(
+ 'robot',
+ `به ${FACTORY.name} خوش اومدین. بچههای همین دفتر نرمافزار دستگاهها رو مینویسن.`,
+ 'happy',
+ );
+ say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
+ say(
+ 'robot',
+ 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاهها منتظرتونم!',
+ 'love',
+ );
+ return;
+ }
+ if (!isOpen(def, state.minutes)) {
+ say(
+ 'robot',
+ `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`,
+ 'serious',
+ );
+ return;
+ }
+ if (first) {
+ say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
+ say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
+ } else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
}
function updateGuide(dt) {
- const near = mode === 'walk' ? closestStation(player.x, player.z, Math.max(9, reachRadius())) : null;
- // The robot only moves to another machine after it has gone back into the previous one.
- if (near && !guideState.station) guideState.station = near;
- const s = guideState.station, wasOut = guideOut();
- if (!s) return;
- guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1);
- if (guideOut() && !wasOut) {
- guideState.arrived = elapsed;
- greet(s);
- tone(990, .15);
- tone(1320, .2);
- }
- if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
- if (!guideState.t) {
- guideState.station = null;
- guide.group.visible = false;
- return;
- }
- // From inside the machine front out to its left side (the card reader is on the right).
- const e = easeOutBack(guideState.t), from = {x: -.2, y: .9, z: .2}, to = {x: -1.7, y: 0, z: 1.3};
- const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot);
- guide.group.visible = true;
- guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z);
- guide.group.scale.setScalar(.15 + .85 * Math.min(1, e));
- // Face the family (the rig looks along its local +X).
- guide.group.rotation.y = Math.atan2(-(player.z - guide.group.position.z), player.x - guide.group.position.x);
- if (!state.busy && payment.phase === 'idle') s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0;
- guide.update({dt, time: elapsed, gait: 0, moving: false, talking, wave: elapsed - guideState.arrived < 2.5 || !guideOut(), point: talking && guideOut()});
+ const near =
+ mode === 'walk' ? closestStation(player.x, player.z, Math.max(9, reachRadius())) : null;
+ // The robot only moves to another machine after it has gone back into the previous one.
+ if (near && !guideState.station) guideState.station = near;
+ const s = guideState.station,
+ wasOut = guideOut();
+ if (!s) return;
+ guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1);
+ if (guideOut() && !wasOut) {
+ guideState.arrived = elapsed;
+ greet(s);
+ tone(990, 0.15);
+ tone(1320, 0.2);
+ }
+ if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
+ if (!guideState.t) {
+ guideState.station = null;
+ guide.group.visible = false;
+ return;
+ }
+ // From inside the machine front out to its left side (the card reader is on the right).
+ const e = easeOutBack(guideState.t),
+ from = { x: -0.2, y: 0.9, z: 0.2 },
+ to = { x: -1.7, y: 0, z: 1.3 };
+ const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot);
+ guide.group.visible = true;
+ guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z);
+ guide.group.scale.setScalar(0.15 + 0.85 * Math.min(1, e));
+ // Face the family (the rig looks along its local +X).
+ guide.group.rotation.y = Math.atan2(
+ -(player.z - guide.group.position.z),
+ player.x - guide.group.position.x,
+ );
+ if (!state.busy && payment.phase === 'idle') s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0;
+ guide.update({
+ dt,
+ time: elapsed,
+ gait: 0,
+ moving: false,
+ talking,
+ wave: elapsed - guideState.arrived < 2.5 || !guideOut(),
+ point: talking && guideOut(),
+ });
}
function updateHints() {
- const target = objectiveStation();
- if (target && !isOpen(target.def, state.minutes) && !hints.closed) {
- hints.closed = true;
- say('robot', `${target.def.name} الان بسته است (${hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`);
- }
- if (route && route.length > 900 && mode === 'walk' && !hints.car) {
- hints.car = true;
- say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
- }
+ const target = objectiveStation();
+ if (target && !isOpen(target.def, state.minutes) && !hints.closed) {
+ hints.closed = true;
+ say(
+ 'robot',
+ `${target.def.name} الان بسته است (${hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`,
+ );
+ }
+ if (route && route.length > 900 && mode === 'walk' && !hints.car) {
+ hints.car = true;
+ say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
+ }
}
function frame() {
- const now = performance.now();
- requestAnimationFrame(frame);
- // The first rAF timestamp can predate `last`, so never let time run backwards. Long frames are
- // simulated in short substeps, so slow devices keep real-time speed without unstable physics.
- const dt = THREE.MathUtils.clamp((now - last) / 1000, 0, MAX_FRAME);
- last = now;
- elapsed += dt;
- const playing = $('#briefing').hidden;
- const paused = !playing || $('#shop').open || $('#map-dialog').open || $('#end').open || document.hidden;
- const steps = Math.max(1, Math.ceil(dt / (1 / 30))), step = dt / steps;
- if (!playing) {
- // Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay.
- const ic = $('#iran'), dpr = sizeCanvas(ic), c = ic.getContext('2d');
- c.setTransform(dpr, 0, 0, dpr, 0, 0);
- drawIran(c, iran, {width: ic.width / dpr, height: ic.height / dpr, t: elapsed, cities: CITIES});
- briefChars = Math.min(BRIEF.length, briefChars + dt * 55);
- $('#brief-text').textContent = BRIEF.slice(0, Math.floor(briefChars)) + (briefChars < BRIEF.length ? '▌' : '');
+ const now = performance.now();
+ requestAnimationFrame(frame);
+ // The first rAF timestamp can predate `last`, so never let time run backwards. Long frames are
+ // simulated in short substeps, so slow devices keep real-time speed without unstable physics.
+ const dt = THREE.MathUtils.clamp((now - last) / 1000, 0, MAX_FRAME);
+ last = now;
+ elapsed += dt;
+ const playing = $('#briefing').hidden;
+ const paused =
+ !playing || $('#shop').open || $('#map-dialog').open || $('#end').open || document.hidden;
+ const steps = Math.max(1, Math.ceil(dt / (1 / 30))),
+ step = dt / steps;
+ if (!playing) {
+ // Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay.
+ const ic = $('#iran'),
+ dpr = sizeCanvas(ic),
+ c = ic.getContext('2d');
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ drawIran(c, iran, {
+ width: ic.width / dpr,
+ height: ic.height / dpr,
+ t: elapsed,
+ cities: CITIES,
+ });
+ briefChars = Math.min(BRIEF.length, briefChars + dt * 55);
+ $('#brief-text').textContent =
+ BRIEF.slice(0, Math.floor(briefChars)) + (briefChars < BRIEF.length ? '▌' : '');
+ }
+ if (!paused) {
+ for (let i = 0; i < steps; i++) {
+ state.minutes += (step * 10) / 60;
+ if (real.on) updateReal(step);
+ else if (mode === 'drive') updateDrive(step);
+ else updateWalk(step);
+ updatePayment(step);
+ updateDelivery(step);
+ traffic.update(
+ step,
+ [
+ { x: player.x, z: player.z },
+ { x: car.x, z: car.z },
+ ],
+ player,
+ );
}
- if (!paused) {
- for (let i = 0; i < steps; i++) {
- state.minutes += step * 10 / 60;
- if (real.on) updateReal(step);
- else if (mode === 'drive') updateDrive(step);
- else updateWalk(step);
- updatePayment(step);
- updateDelivery(step);
- traffic.update(step, [{x: player.x, z: player.z}, {x: car.x, z: car.z}], player);
- }
- updateRoute(dt);
- updateHints();
- updateTalks();
- updateGuide(dt);
- if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd();
- } else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .1);
- if (mode === 'walk' && engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .2);
- updateSubtitles(dt);
+ updateRoute(dt);
+ updateHints();
+ updateTalks();
+ updateGuide(dt);
+ if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd();
+ } else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.1);
+ if (mode === 'walk' && engine && soundOn)
+ engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.2);
+ updateSubtitles(dt);
- family.group.visible = mode === 'walk' && !real.on;
- family.group.position.set(player.x, 0, player.z);
- family.group.rotation.y = player.heading;
- family.update({dt, time: elapsed, gait: player.gait, moving: player.speed > .4, shopping: state.busy || payment.phase !== 'idle', delivery: deliveryTimer > 0});
- factory.update(elapsed, paused ? 0 : dt, camera.position);
- carModel.group.position.set(car.x, 0, car.z);
- carModel.group.rotation.y = car.heading;
- for (const [i, wheel] of carModel.wheels.entries()) {
- wheel.rotation.z -= car.speed * dt / .36;
- if (i >= 2) wheel.rotation.y = -car.steer;
- }
- for (const s of stations) s.model.ring.material.opacity = .35 + Math.sin(elapsed * 3) * .25;
- updateReachCircles();
- if (routeMesh) routeMaterial.opacity = .55 + Math.sin(elapsed * 4) * .15;
+ family.group.visible = mode === 'walk' && !real.on;
+ family.group.position.set(player.x, 0, player.z);
+ family.group.rotation.y = player.heading;
+ family.update({
+ dt,
+ time: elapsed,
+ gait: player.gait,
+ moving: player.speed > 0.4,
+ shopping: state.busy || payment.phase !== 'idle',
+ delivery: deliveryTimer > 0,
+ });
+ factory.update(elapsed, paused ? 0 : dt, camera.position);
+ carModel.group.position.set(car.x, 0, car.z);
+ carModel.group.rotation.y = car.heading;
+ for (const [i, wheel] of carModel.wheels.entries()) {
+ wheel.rotation.z -= (car.speed * dt) / 0.36;
+ if (i >= 2) wheel.rotation.y = -car.steer;
+ }
+ for (const s of stations) s.model.ring.material.opacity = 0.35 + Math.sin(elapsed * 3) * 0.25;
+ updateReachCircles();
+ if (routeMesh) routeMaterial.opacity = 0.55 + Math.sin(elapsed * 4) * 0.15;
- if (playing && real.on) {
- // First person: the phone is the camera, at the player's GPS position and eye height.
- camera.position.set(player.x, 1.6, player.z);
- if (!arView.update(camera, dt, stableMode ? 5 : 10)) {
- // No orientation sensor: face the compass or walking direction, or wherever the player drags.
- if (real.heading != null && elapsed - cam.lastMouse > 2) {
- const want = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading));
- cam.yaw += Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 3);
- }
- camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ');
- }
- camPos.copy(camera.position);
- } else if (playing) {
- if (camera.fov !== 62) {
- camera.fov = 62;
- camera.updateProjectionMatrix();
- }
- updateCamera(dt);
+ if (playing && real.on) {
+ // First person: the phone is the camera, at the player's GPS position and eye height.
+ camera.position.set(player.x, 1.6, player.z);
+ if (!arView.update(camera, dt, stableMode ? 5 : 10)) {
+ // No orientation sensor: face the compass or walking direction, or wherever the player drags.
+ if (real.heading != null && elapsed - cam.lastMouse > 2) {
+ const want = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading));
+ cam.yaw +=
+ Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 3);
+ }
+ camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ');
}
- else {
- const a = elapsed * .08;
- camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30);
- camera.lookAt(player.x, 2, player.z);
- camPos.copy(camera.position);
+ camPos.copy(camera.position);
+ } else if (playing) {
+ if (camera.fov !== 62) {
+ camera.fov = 62;
+ camera.updateProjectionMatrix();
}
- sun.position.set(player.x + 160, 230, player.z + 110);
- sun.target.position.set(player.x, 0, player.z);
- sky.position.copy(camera.position);
- city.update(camera.position);
- if (!arMode) imagery.update(camera.position.x, camera.position.z);
- if (playing) {
- hudTimer -= dt;
- updateHUD(dt);
- if ($('#map-dialog').open && hudTimer <= 0) {
- hudTimer = .5;
- renderMap();
- }
+ updateCamera(dt);
+ } else {
+ const a = elapsed * 0.08;
+ camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30);
+ camera.lookAt(player.x, 2, player.z);
+ camPos.copy(camera.position);
+ }
+ sun.position.set(player.x + 160, 230, player.z + 110);
+ sun.target.position.set(player.x, 0, player.z);
+ sky.position.copy(camera.position);
+ city.update(camera.position);
+ if (!arMode) imagery.update(camera.position.x, camera.position.z);
+ if (playing) {
+ hudTimer -= dt;
+ updateHUD(dt);
+ if ($('#map-dialog').open && hudTimer <= 0) {
+ hudTimer = 0.5;
+ renderMap();
}
- renderer.render(scene, camera);
+ }
+ renderer.render(scene, camera);
}
// ?debug exposes live game state in the console for development and automated checks.
-if (new URLSearchParams(location.search).has('debug')) window.game = {
+if (new URLSearchParams(location.search).has('debug'))
+ window.game = {
get state() {
- return state;
- }, get player() {
- return player;
- }, get cam() {
- return cam;
- }, factory, get car() {
- return car;
- }, get mode() {
- return mode;
- }, get payment() {
- return payment;
- }, get elapsed() {
- return elapsed;
- }, get route() {
- return route;
- }, setWaypoint(id) {
- waypoint = id;
- route = null;
- routeTimer = 0;
- }, get arMode() {
- return arMode;
- }, arView, setArMode, debugHits, nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, s => s[4] <= 3), stations, guideState, real, keys, traffic
-};
+ return state;
+ },
+ get player() {
+ return player;
+ },
+ get cam() {
+ return cam;
+ },
+ factory,
+ get car() {
+ return car;
+ },
+ get mode() {
+ return mode;
+ },
+ get payment() {
+ return payment;
+ },
+ get elapsed() {
+ return elapsed;
+ },
+ get route() {
+ return route;
+ },
+ setWaypoint(id) {
+ waypoint = id;
+ route = null;
+ routeTimer = 0;
+ },
+ get arMode() {
+ return arMode;
+ },
+ arView,
+ setArMode,
+ debugHits,
+ nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, (s) => s[4] <= 3),
+ stations,
+ guideState,
+ real,
+ keys,
+ traffic,
+ };
reset();
resize();
setupPwa();
diff --git a/src/map-render.js b/src/map-render.js
index 410baff..8cf47a1 100644
--- a/src/map-render.js
+++ b/src/map-render.js
@@ -1,367 +1,462 @@
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
-const SCALE = .75; // tile pixels per metre
-const TILE_PX = 512, TILE_M = TILE_PX / SCALE, MAX_TILES = 48;
-const OVERVIEW_SCALE = .07;
-const AREA = {campus: '#28322c', park: '#2c4a32', grass: '#30503a', forest: '#27432e', pitch: '#33603c', water: '#2b5b6e', industrial: '#262c29', farmland: '#2a3326'};
+const SCALE = 0.75; // tile pixels per metre
+const TILE_PX = 512,
+ TILE_M = TILE_PX / SCALE,
+ MAX_TILES = 48;
+const OVERVIEW_SCALE = 0.07;
+const AREA = {
+ campus: '#28322c',
+ park: '#2c4a32',
+ grass: '#30503a',
+ forest: '#27432e',
+ pitch: '#33603c',
+ water: '#2b5b6e',
+ industrial: '#262c29',
+ farmland: '#2a3326',
+};
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
-const roadColor = type => type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63';
+const roadColor = (type) => (type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63');
const tileKey = (tx, tz) => tx * 100003 + tz;
function path(c, pts) {
- c.beginPath();
- c.moveTo(pts[0], pts[1]);
- for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
+ c.beginPath();
+ c.moveTo(pts[0], pts[1]);
+ for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
}
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
-function drawFeatures(c, {areas, roads, buildings}, data, minWidth) {
- for (const area of areas) {
- c.fillStyle = AREA[data.areaKinds[area[0]]];
- path(c, area.slice(1));
- c.fill();
- }
- c.lineCap = c.lineJoin = 'round';
- for (const road of roads) {
- c.strokeStyle = roadColor(road[0]);
- c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
- path(c, road.slice(2));
- c.stroke();
- }
- c.fillStyle = '#3c4943';
- for (const b of buildings) {
- path(c, b.slice(1));
- c.fill();
- }
+function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
+ for (const area of areas) {
+ c.fillStyle = AREA[data.areaKinds[area[0]]];
+ path(c, area.slice(1));
+ c.fill();
+ }
+ c.lineCap = c.lineJoin = 'round';
+ for (const road of roads) {
+ c.strokeStyle = roadColor(road[0]);
+ c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
+ path(c, road.slice(2));
+ c.stroke();
+ }
+ c.fillStyle = '#3c4943';
+ for (const b of buildings) {
+ path(c, b.slice(1));
+ c.fill();
+ }
}
export function createMapAssets(data, extraLabels = []) {
- const {minX, minZ, maxX, maxZ} = data.bounds;
- // Bin every feature into the tiles its bounding box touches.
- const bins = new Map();
- const bin = (list, feature, first) => {
- let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
- for (let i = first; i < feature.length; i += 2) {
- x0 = Math.min(x0, feature[i]);
- x1 = Math.max(x1, feature[i]);
- z0 = Math.min(z0, feature[i + 1]);
- z1 = Math.max(z1, feature[i + 1]);
- }
- for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
- for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
- const k = tileKey(tx, tz);
- if (!bins.has(k)) bins.set(k, {areas: [], roads: [], buildings: []});
- bins.get(k)[list].push(feature);
- }
- };
- data.areas.forEach(a => bin('areas', a, 1));
- [...data.roads].sort((a, b) => b[0] - a[0]).forEach(r => bin('roads', r, 2));
- data.buildings.forEach(b => bin('buildings', b, 1));
-
- const cache = new Map();
- function tile(tx, tz) {
+ const { minX, minZ, maxX, maxZ } = data.bounds;
+ // Bin every feature into the tiles its bounding box touches.
+ const bins = new Map();
+ const bin = (list, feature, first) => {
+ let x0 = Infinity,
+ z0 = Infinity,
+ x1 = -Infinity,
+ z1 = -Infinity;
+ for (let i = first; i < feature.length; i += 2) {
+ x0 = Math.min(x0, feature[i]);
+ x1 = Math.max(x1, feature[i]);
+ z0 = Math.min(z0, feature[i + 1]);
+ z1 = Math.max(z1, feature[i + 1]);
+ }
+ for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
+ for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
const k = tileKey(tx, tz);
- if (cache.has(k)) {
- const canvas = cache.get(k);
- cache.delete(k);
- cache.set(k, canvas);
- return canvas;
- }
- const canvas = document.createElement('canvas');
- canvas.width = canvas.height = TILE_PX;
- const c = canvas.getContext('2d');
- c.fillStyle = '#1b231f';
- c.fillRect(0, 0, TILE_PX, TILE_PX);
- c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
- drawFeatures(c, bins.get(k) ?? {areas: [], roads: [], buildings: []}, data, 1.6);
- cache.set(k, canvas);
- if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
- return canvas;
- }
+ if (!bins.has(k)) bins.set(k, { areas: [], roads: [], buildings: [] });
+ bins.get(k)[list].push(feature);
+ }
+ };
+ data.areas.forEach((a) => bin('areas', a, 1));
+ [...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
+ data.buildings.forEach((b) => bin('buildings', b, 1));
- const overview = document.createElement('canvas');
- overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
- overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
- const o = overview.getContext('2d');
- o.fillStyle = '#1b231f';
- o.fillRect(0, 0, overview.width, overview.height);
- o.setTransform(OVERVIEW_SCALE, 0, 0, OVERVIEW_SCALE, -minX * OVERVIEW_SCALE, -minZ * OVERVIEW_SCALE);
- drawFeatures(o, {areas: data.areas, roads: data.roads.filter(r => r[0] <= 5).sort((a, b) => b[0] - a[0]), buildings: []}, data, 14);
-
- // One label per named boulevard, at the middle of its longest way.
- const streets = new Map();
- for (const road of data.roads) {
- if (road[1] < 0 || road[0] > 3) continue;
- let len = 0;
- for (let i = 4; i < road.length; i += 2) len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
- const name = data.names[road[1]];
- if ((streets.get(name)?.len ?? 0) < len) {
- const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
- const angle = Math.atan2(road[Math.min(mid + 3, road.length - 1)] - road[mid - 1], road[Math.min(mid + 2, road.length - 2)] - road[mid - 2]);
- streets.set(name, {name, len, x: road[mid], z: road[mid + 1], angle});
- }
+ const cache = new Map();
+ function tile(tx, tz) {
+ const k = tileKey(tx, tz);
+ if (cache.has(k)) {
+ const canvas = cache.get(k);
+ cache.delete(k);
+ cache.set(k, canvas);
+ return canvas;
}
- return {tile, overview, bounds: data.bounds, labels: [...data.labels, ...extraLabels], streets: [...streets.values()].filter(s => s.len > 150)};
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = TILE_PX;
+ const c = canvas.getContext('2d');
+ c.fillStyle = '#1b231f';
+ c.fillRect(0, 0, TILE_PX, TILE_PX);
+ c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
+ drawFeatures(c, bins.get(k) ?? { areas: [], roads: [], buildings: [] }, data, 1.6);
+ cache.set(k, canvas);
+ if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
+ return canvas;
+ }
+
+ const overview = document.createElement('canvas');
+ overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
+ overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
+ const o = overview.getContext('2d');
+ o.fillStyle = '#1b231f';
+ o.fillRect(0, 0, overview.width, overview.height);
+ o.setTransform(
+ OVERVIEW_SCALE,
+ 0,
+ 0,
+ OVERVIEW_SCALE,
+ -minX * OVERVIEW_SCALE,
+ -minZ * OVERVIEW_SCALE,
+ );
+ drawFeatures(
+ o,
+ {
+ areas: data.areas,
+ roads: data.roads.filter((r) => r[0] <= 5).sort((a, b) => b[0] - a[0]),
+ buildings: [],
+ },
+ data,
+ 14,
+ );
+
+ // One label per named boulevard, at the middle of its longest way.
+ const streets = new Map();
+ for (const road of data.roads) {
+ if (road[1] < 0 || road[0] > 3) continue;
+ let len = 0;
+ for (let i = 4; i < road.length; i += 2)
+ len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
+ const name = data.names[road[1]];
+ if ((streets.get(name)?.len ?? 0) < len) {
+ const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
+ const angle = Math.atan2(
+ road[Math.min(mid + 3, road.length - 1)] - road[mid - 1],
+ road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
+ );
+ streets.set(name, { name, len, x: road[mid], z: road[mid + 1], angle });
+ }
+ }
+ return {
+ tile,
+ overview,
+ bounds: data.bounds,
+ labels: [...data.labels, ...extraLabels],
+ streets: [...streets.values()].filter((s) => s.len > 150),
+ };
}
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
// metres; pxPerMetre decides between detailed tiles and the overview.
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
- const {minX, minZ} = assets.bounds;
- c.fillStyle = '#141b18';
- c.fillRect(x0, z0, x1 - x0, z1 - z0);
- if (pxPerMetre < .22) {
- c.drawImage(assets.overview, minX, minZ, assets.overview.width / OVERVIEW_SCALE, assets.overview.height / OVERVIEW_SCALE);
- return;
- }
- for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
- for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
- c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + .5, TILE_M + .5);
+ const { minX, minZ } = assets.bounds;
+ c.fillStyle = '#141b18';
+ c.fillRect(x0, z0, x1 - x0, z1 - z0);
+ if (pxPerMetre < 0.22) {
+ c.drawImage(
+ assets.overview,
+ minX,
+ minZ,
+ assets.overview.width / OVERVIEW_SCALE,
+ assets.overview.height / OVERVIEW_SCALE,
+ );
+ return;
+ }
+ for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
+ for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
+ c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + 0.5, TILE_M + 0.5);
}
function rotatePoint(x, z, cx, cz, angle, k) {
- const dx = (x - cx) * k, dz = (z - cz) * k, cos = Math.cos(angle), sin = Math.sin(angle);
- return [dx * cos - dz * sin, dx * sin + dz * cos];
+ const dx = (x - cx) * k,
+ dz = (z - cz) * k,
+ cos = Math.cos(angle),
+ sin = Math.sin(angle);
+ return [dx * cos - dz * sin, dx * sin + dz * cos];
}
-function machineIcon(c, x, y, {objective, visited, open}) {
- c.save();
- c.translate(x, y);
- if (objective) {
- c.rotate(Math.PI / 4);
- c.fillStyle = '#f2c94c';
- c.strokeStyle = '#1b1406';
- c.lineWidth = 2;
- c.fillRect(-7, -7, 14, 14);
- c.strokeRect(-7, -7, 14, 14);
- } else {
- c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
- c.strokeStyle = '#08201c';
- c.lineWidth = 2;
- c.beginPath();
- c.roundRect(-6, -8, 12, 16, 3);
- c.fill();
- c.stroke();
- c.fillStyle = '#08201c';
- c.fillRect(-3, -5, 6, 6);
- }
- c.restore();
- if (visited) {
- c.fillStyle = '#fff';
- c.font = 'bold 11px sans-serif';
- c.fillText('✓', x + 8, y - 6);
- }
+function machineIcon(c, x, y, { objective, visited, open }) {
+ c.save();
+ c.translate(x, y);
+ if (objective) {
+ c.rotate(Math.PI / 4);
+ c.fillStyle = '#f2c94c';
+ c.strokeStyle = '#1b1406';
+ c.lineWidth = 2;
+ c.fillRect(-7, -7, 14, 14);
+ c.strokeRect(-7, -7, 14, 14);
+ } else {
+ c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
+ c.strokeStyle = '#08201c';
+ c.lineWidth = 2;
+ c.beginPath();
+ c.roundRect(-6, -8, 12, 16, 3);
+ c.fill();
+ c.stroke();
+ c.fillStyle = '#08201c';
+ c.fillRect(-3, -5, 6, 6);
+ }
+ c.restore();
+ if (visited) {
+ c.fillStyle = '#fff';
+ c.font = 'bold 11px sans-serif';
+ c.fillText('✓', x + 8, y - 6);
+ }
}
// Circular minimap rotated so the camera's forward direction points up.
export function drawMinimap(ctx, assets, view) {
- const {size, x, z, heading, camForward, radius, markers, route} = view;
- const r = size / 2, k = r / radius, angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
- ctx.clearRect(0, 0, size, size);
- ctx.save();
+ const { size, x, z, heading, camForward, radius, markers, route } = view;
+ const r = size / 2,
+ k = r / radius,
+ angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
+ ctx.clearRect(0, 0, size, size);
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(r, r, r - 2, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.fillStyle = '#141b18';
+ ctx.fillRect(0, 0, size, size);
+ ctx.translate(r, r);
+ ctx.save();
+ ctx.rotate(angle);
+ ctx.scale(k, k);
+ ctx.translate(-x, -z);
+ paintWorld(
+ ctx,
+ assets,
+ x - radius * 1.5,
+ z - radius * 1.5,
+ x + radius * 1.5,
+ z + radius * 1.5,
+ k,
+ );
+ ctx.restore();
+ if (route?.length > 1) {
+ ctx.strokeStyle = '#f2c94c';
+ ctx.lineWidth = 3;
+ ctx.setLineDash([6, 4]);
ctx.beginPath();
- ctx.arc(r, r, r - 2, 0, Math.PI * 2);
- ctx.clip();
- ctx.fillStyle = '#141b18';
- ctx.fillRect(0, 0, size, size);
- ctx.translate(r, r);
- ctx.save();
- ctx.rotate(angle);
- ctx.scale(k, k);
- ctx.translate(-x, -z);
- paintWorld(ctx, assets, x - radius * 1.5, z - radius * 1.5, x + radius * 1.5, z + radius * 1.5, k);
- ctx.restore();
- if (route?.length > 1) {
- ctx.strokeStyle = '#f2c94c';
- ctx.lineWidth = 3;
- ctx.setLineDash([6, 4]);
- ctx.beginPath();
- route.forEach((p, i) => {
- const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
- i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
- });
- ctx.stroke();
- ctx.setLineDash([]);
- }
- for (const m of markers) {
- let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
- const d = Math.hypot(px, py);
- if (d > r - 12) {
- if (!m.objective) continue;
- px *= (r - 12) / d;
- py *= (r - 12) / d;
- }
- machineIcon(ctx, px, py, m);
- }
- // Player arrow, then north marker on the rim.
- ctx.save();
- ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
- ctx.fillStyle = '#ffffff';
- ctx.strokeStyle = '#0b100e';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.moveTo(0, -10);
- ctx.lineTo(7, 8);
- ctx.lineTo(0, 4);
- ctx.lineTo(-7, 8);
- ctx.closePath();
- ctx.fill();
- ctx.stroke();
- ctx.restore();
- const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
- const nl = Math.hypot(nx, ny) || 1;
- ctx.fillStyle = '#f2c94c';
- ctx.font = 'bold 13px Vazirmatn, sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText('ش', nx / nl * (r - 12), ny / nl * (r - 12));
- ctx.restore();
- ctx.strokeStyle = 'rgba(230,240,235,.55)';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(r, r, r - 2, 0, Math.PI * 2);
+ route.forEach((p, i) => {
+ const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
+ i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
+ });
ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ for (const m of markers) {
+ let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
+ const d = Math.hypot(px, py);
+ if (d > r - 12) {
+ if (!m.objective) continue;
+ px *= (r - 12) / d;
+ py *= (r - 12) / d;
+ }
+ machineIcon(ctx, px, py, m);
+ }
+ // Player arrow, then north marker on the rim.
+ ctx.save();
+ ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = '#0b100e';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(0, -10);
+ ctx.lineTo(7, 8);
+ ctx.lineTo(0, 4);
+ ctx.lineTo(-7, 8);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
+ const nl = Math.hypot(nx, ny) || 1;
+ ctx.fillStyle = '#f2c94c';
+ ctx.font = 'bold 13px Vazirmatn, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(230,240,235,.55)';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(r, r, r - 2, 0, Math.PI * 2);
+ ctx.stroke();
}
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
export function drawFullMap(ctx, assets, view, state) {
- const {width, height} = ctx.canvas, dpr = state.dpr, w = width / dpr, h = height / dpr;
- const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.fillStyle = '#101614';
- ctx.fillRect(0, 0, w, h);
- ctx.save();
- ctx.translate(w / 2, h / 2);
- ctx.scale(1 / view.mpp, 1 / view.mpp);
- ctx.translate(-view.cx, -view.cz);
- paintWorld(ctx, assets, view.cx - w / 2 * view.mpp, view.cz - h / 2 * view.mpp, view.cx + w / 2 * view.mpp, view.cz + h / 2 * view.mpp, 1 / view.mpp);
- ctx.restore();
- // Grid lines every 500 m, like a tactical map.
- ctx.strokeStyle = 'rgba(160,200,185,.08)';
- ctx.lineWidth = 1;
- for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
- const [sx] = toScreen(gx, 0);
- ctx.beginPath();
- ctx.moveTo(sx, 0);
- ctx.lineTo(sx, h);
- ctx.stroke();
- }
- for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
- const [, sy] = toScreen(0, gz);
- ctx.beginPath();
- ctx.moveTo(0, sy);
- ctx.lineTo(w, sy);
- ctx.stroke();
- }
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- if (view.mpp < 3) {
- ctx.font = '11px Vazirmatn, sans-serif';
- ctx.fillStyle = 'rgba(235,240,232,.75)';
- for (const s of assets.streets) {
- const [sx, sy] = toScreen(s.x, s.z);
- if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
- ctx.save();
- ctx.translate(sx, sy);
- let a = s.angle;
- if (a > Math.PI / 2) a -= Math.PI;
- if (a < -Math.PI / 2) a += Math.PI;
- ctx.rotate(a);
- ctx.fillText(s.name, 0, 0);
- ctx.restore();
- }
- }
- for (const l of assets.labels) {
- if (l.kind === 'school' && view.mpp > 1.6) continue;
- const [sx, sy] = toScreen(l.x, l.z);
- ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
- if (l.kind === 'industrial' && view.mpp > 6) continue;
- ctx.fillStyle = {park: '#9fd49a', university: '#c9b6f2', school: '#e8c79a', metro: '#ff8a80', mall: '#f5b8d9', industrial: '#a9b2ad', factory: '#3ee0c9'}[l.kind];
- if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
- ctx.fillText(l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name, sx, sy + 14);
- }
- if (state.route?.length > 1) {
- ctx.strokeStyle = '#f2c94c';
- ctx.lineWidth = 4;
- ctx.setLineDash([8, 5]);
- ctx.beginPath();
- state.route.forEach((p, i) => {
- const [sx, sy] = toScreen(p.x, p.z);
- i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
- });
- ctx.stroke();
- ctx.setLineDash([]);
- }
- for (const m of state.markers) {
- const [sx, sy] = toScreen(m.x, m.z);
- if (m.id === state.selected) {
- ctx.strokeStyle = '#ffffff';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(sx, sy, 16, 0, Math.PI * 2);
- ctx.stroke();
- }
- machineIcon(ctx, sx, sy, m);
- }
- const [px, py] = toScreen(state.player.x, state.player.z);
- ctx.save();
- ctx.translate(px, py);
- ctx.rotate(Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2);
- ctx.fillStyle = '#fff';
- ctx.strokeStyle = '#000';
- ctx.lineWidth = 2;
+ const { width, height } = ctx.canvas,
+ dpr = state.dpr,
+ w = width / dpr,
+ h = height / dpr;
+ const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.fillStyle = '#101614';
+ ctx.fillRect(0, 0, w, h);
+ ctx.save();
+ ctx.translate(w / 2, h / 2);
+ ctx.scale(1 / view.mpp, 1 / view.mpp);
+ ctx.translate(-view.cx, -view.cz);
+ paintWorld(
+ ctx,
+ assets,
+ view.cx - (w / 2) * view.mpp,
+ view.cz - (h / 2) * view.mpp,
+ view.cx + (w / 2) * view.mpp,
+ view.cz + (h / 2) * view.mpp,
+ 1 / view.mpp,
+ );
+ ctx.restore();
+ // Grid lines every 500 m, like a tactical map.
+ ctx.strokeStyle = 'rgba(160,200,185,.08)';
+ ctx.lineWidth = 1;
+ for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
+ const [sx] = toScreen(gx, 0);
ctx.beginPath();
- ctx.moveTo(0, -12);
- ctx.lineTo(8, 9);
- ctx.lineTo(0, 5);
- ctx.lineTo(-8, 9);
- ctx.closePath();
- ctx.fill();
+ ctx.moveTo(sx, 0);
+ ctx.lineTo(sx, h);
ctx.stroke();
- ctx.restore();
- return toScreen;
+ }
+ for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
+ const [, sy] = toScreen(0, gz);
+ ctx.beginPath();
+ ctx.moveTo(0, sy);
+ ctx.lineTo(w, sy);
+ ctx.stroke();
+ }
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ if (view.mpp < 3) {
+ ctx.font = '11px Vazirmatn, sans-serif';
+ ctx.fillStyle = 'rgba(235,240,232,.75)';
+ for (const s of assets.streets) {
+ const [sx, sy] = toScreen(s.x, s.z);
+ if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
+ ctx.save();
+ ctx.translate(sx, sy);
+ let a = s.angle;
+ if (a > Math.PI / 2) a -= Math.PI;
+ if (a < -Math.PI / 2) a += Math.PI;
+ ctx.rotate(a);
+ ctx.fillText(s.name, 0, 0);
+ ctx.restore();
+ }
+ }
+ for (const l of assets.labels) {
+ if (l.kind === 'school' && view.mpp > 1.6) continue;
+ const [sx, sy] = toScreen(l.x, l.z);
+ ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
+ if (l.kind === 'industrial' && view.mpp > 6) continue;
+ ctx.fillStyle = {
+ park: '#9fd49a',
+ university: '#c9b6f2',
+ school: '#e8c79a',
+ metro: '#ff8a80',
+ mall: '#f5b8d9',
+ industrial: '#a9b2ad',
+ factory: '#3ee0c9',
+ }[l.kind];
+ if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
+ ctx.fillText(
+ l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
+ sx,
+ sy + 14,
+ );
+ }
+ if (state.route?.length > 1) {
+ ctx.strokeStyle = '#f2c94c';
+ ctx.lineWidth = 4;
+ ctx.setLineDash([8, 5]);
+ ctx.beginPath();
+ state.route.forEach((p, i) => {
+ const [sx, sy] = toScreen(p.x, p.z);
+ i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
+ });
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ for (const m of state.markers) {
+ const [sx, sy] = toScreen(m.x, m.z);
+ if (m.id === state.selected) {
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(sx, sy, 16, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ machineIcon(ctx, sx, sy, m);
+ }
+ const [px, py] = toScreen(state.player.x, state.player.z);
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(
+ Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
+ );
+ ctx.fillStyle = '#fff';
+ ctx.strokeStyle = '#000';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(0, -12);
+ ctx.lineTo(8, 9);
+ ctx.lineTo(0, 5);
+ ctx.lineTo(-8, 9);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ return toScreen;
}
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
-export function drawIran(ctx, iran, {width, height, t = 0, highlight = 'Razavi Khorasan', cities = []}) {
- const lon0 = 43.8, lon1 = 63.6, lat0 = 24.8, lat1 = 40;
- const kx = .84, k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * .94;
- const offX = (width - (lon1 - lon0) * kx * k) / 2, offY = (height - (lat1 - lat0) * k) / 2;
- const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
- ctx.clearRect(0, 0, width, height);
- for (const province of iran.provinces) {
- const active = province.id === highlight;
- ctx.fillStyle = active ? `rgba(62,224,201,${.35 + Math.sin(t * 3) * .12})` : 'rgba(40,56,50,.85)';
- ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
- ctx.lineWidth = active ? 2 : 1;
- for (const ring of province.rings) {
- ctx.beginPath();
- for (let i = 0; i < ring.length; i += 2) {
- const [x, y] = p(ring[i], ring[i + 1]);
- i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
- }
- ctx.closePath();
- ctx.fill();
- ctx.stroke();
- }
+export function drawIran(
+ ctx,
+ iran,
+ { width, height, t = 0, highlight = 'Razavi Khorasan', cities = [] },
+) {
+ const lon0 = 43.8,
+ lon1 = 63.6,
+ lat0 = 24.8,
+ lat1 = 40;
+ const kx = 0.84,
+ k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
+ const offX = (width - (lon1 - lon0) * kx * k) / 2,
+ offY = (height - (lat1 - lat0) * k) / 2;
+ const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
+ ctx.clearRect(0, 0, width, height);
+ for (const province of iran.provinces) {
+ const active = province.id === highlight;
+ ctx.fillStyle = active
+ ? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
+ : 'rgba(40,56,50,.85)';
+ ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
+ ctx.lineWidth = active ? 2 : 1;
+ for (const ring of province.rings) {
+ ctx.beginPath();
+ for (let i = 0; i < ring.length; i += 2) {
+ const [x, y] = p(ring[i], ring[i + 1]);
+ i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
+ }
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
}
- ctx.textAlign = 'center';
- for (const city of cities) {
- const [x, y] = p(city.lon, city.lat);
- if (city.active) {
- ctx.strokeStyle = '#f2c94c';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(x, y, 6 + (t * 18) % 18, 0, Math.PI * 2);
- ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
- ctx.stroke();
- ctx.globalAlpha = 1;
- }
- ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
- ctx.beginPath();
- ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
- ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
+ }
+ ctx.textAlign = 'center';
+ for (const city of cities) {
+ const [x, y] = p(city.lon, city.lat);
+ if (city.active) {
+ ctx.strokeStyle = '#f2c94c';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
+ ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
+ ctx.stroke();
+ ctx.globalAlpha = 1;
}
+ ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
+ ctx.beginPath();
+ ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
+ ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
+ }
}
diff --git a/src/payment.js b/src/payment.js
index 54799c9..61128d5 100644
--- a/src/payment.js
+++ b/src/payment.js
@@ -1,40 +1,40 @@
-import {purchase, purchaseBlocker} from './game-state.js';
+import { purchase, purchaseBlocker } from './game-state.js';
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
export function createPayment() {
- return {phase: 'idle', machine: null, productId: null, record: null};
+ return { phase: 'idle', machine: null, productId: null, record: null };
}
export function beginPayment(state, payment, machine, productId) {
- if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
- Object.assign(payment, {phase: 'approaching', machine, productId, record: null});
- state.busy = true;
- return true;
+ if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
+ Object.assign(payment, { phase: 'approaching', machine, productId, record: null });
+ state.busy = true;
+ return true;
}
export function readyPayment(payment) {
- if (payment.phase !== 'approaching') return false;
- payment.phase = 'ready';
- return true;
+ if (payment.phase !== 'approaching') return false;
+ payment.phase = 'ready';
+ return true;
}
export function swipePayment(state, payment) {
- if (payment.phase !== 'ready') return false;
- state.busy = false;
- const record = purchase(state, payment.machine, payment.productId);
- if (!record) {
- Object.assign(payment, createPayment());
- return false;
- }
- state.busy = true;
- payment.record = record;
- payment.phase = 'approved';
- return true;
+ if (payment.phase !== 'ready') return false;
+ state.busy = false;
+ const record = purchase(state, payment.machine, payment.productId);
+ if (!record) {
+ Object.assign(payment, createPayment());
+ return false;
+ }
+ state.busy = true;
+ payment.record = record;
+ payment.phase = 'approved';
+ return true;
}
export function cancelPayment(state, payment) {
- if (!['approaching', 'ready'].includes(payment.phase)) return false;
- Object.assign(payment, createPayment());
- state.busy = false;
- return true;
+ if (!['approaching', 'ready'].includes(payment.phase)) return false;
+ Object.assign(payment, createPayment());
+ state.busy = false;
+ return true;
}
diff --git a/src/pwa.js b/src/pwa.js
index 0bdc161..014edc2 100644
--- a/src/pwa.js
+++ b/src/pwa.js
@@ -1,49 +1,56 @@
// Installable app: registers the offline service worker, offers "install" where the browser allows
// it (Android/desktop Chrome), shows Add-to-Home-Screen steps on iOS, and offers a reload on updates.
-const $ = s => document.querySelector(s);
+const $ = (s) => document.querySelector(s);
export function setupPwa() {
- const installButton = $('#install');
- const standalone = matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches || navigator.standalone;
- const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
- let deferred = null;
+ const installButton = $('#install');
+ const standalone =
+ matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches ||
+ navigator.standalone;
+ const ios =
+ /iphone|ipad|ipod/i.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ let deferred = null;
- if (!standalone && ios) installButton.hidden = false;
- window.addEventListener('beforeinstallprompt', event => {
- event.preventDefault();
- deferred = event;
- installButton.hidden = false;
- });
- window.addEventListener('appinstalled', () => installButton.hidden = true);
- installButton.onclick = async () => {
- if (deferred) {
- deferred.prompt();
- await deferred.userChoice;
- deferred = null;
- installButton.hidden = true;
- } else if (ios) $('#ios-install').showModal();
- };
- $('#ios-close').onclick = () => $('#ios-install').close();
+ if (!standalone && ios) installButton.hidden = false;
+ window.addEventListener('beforeinstallprompt', (event) => {
+ event.preventDefault();
+ deferred = event;
+ installButton.hidden = false;
+ });
+ window.addEventListener('appinstalled', () => (installButton.hidden = true));
+ installButton.onclick = async () => {
+ if (deferred) {
+ deferred.prompt();
+ await deferred.userChoice;
+ deferred = null;
+ installButton.hidden = true;
+ } else if (ios) $('#ios-install').showModal();
+ };
+ $('#ios-close').onclick = () => $('#ios-install').close();
- // Only built pages have sw.js; the dev server serves source files directly.
- if (!('serviceWorker' in navigator) || import.meta.env.DEV) return;
- navigator.serviceWorker.register('./sw.js').then(registration => {
- const offer = worker => {
- $('#update-toast').hidden = false;
- $('#update-reload').onclick = () => worker.postMessage('skip-waiting');
- };
- if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
- registration.addEventListener('updatefound', () => {
- const worker = registration.installing;
- worker?.addEventListener('statechange', () => {
- if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
- });
+ // Only built pages have sw.js; the dev server serves source files directly.
+ if (!('serviceWorker' in navigator) || import.meta.env.DEV) return;
+ navigator.serviceWorker
+ .register('./sw.js')
+ .then((registration) => {
+ const offer = (worker) => {
+ $('#update-toast').hidden = false;
+ $('#update-reload').onclick = () => worker.postMessage('skip-waiting');
+ };
+ if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
+ registration.addEventListener('updatefound', () => {
+ const worker = registration.installing;
+ worker?.addEventListener('statechange', () => {
+ if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
});
- }).catch(() => {});
- let reloading = false;
- navigator.serviceWorker.addEventListener('controllerchange', () => {
- if (reloading) return;
- reloading = true;
- location.reload();
- });
+ });
+ })
+ .catch(() => {});
+ let reloading = false;
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ if (reloading) return;
+ reloading = true;
+ location.reload();
+ });
}
diff --git a/src/realworld.js b/src/realworld.js
index ba1ef33..bc04e49 100644
--- a/src/realworld.js
+++ b/src/realworld.js
@@ -1,4 +1,4 @@
-import {toLocal} from './geo.js';
+import { toLocal } from './geo.js';
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
@@ -7,13 +7,24 @@ export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, s
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
export function smoothFix(previous, fix) {
- if (!previous || fix.time - previous.time > 30000) return {...fix};
- const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
- const dt = Math.max(.001, (fix.time - previous.time) / 1000);
- const x = previous.x + (fix.x - previous.x) * weight, z = previous.z + (fix.z - previous.z) * weight;
- // The blended estimate is a little better than either input, but never better than 3 m.
- const accuracy = Math.max(3, Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) + Math.hypot(x - previous.x, z - previous.z) * .2);
- return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt};
+ if (!previous || fix.time - previous.time > 30000) return { ...fix };
+ const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
+ const dt = Math.max(0.001, (fix.time - previous.time) / 1000);
+ const x = previous.x + (fix.x - previous.x) * weight,
+ z = previous.z + (fix.z - previous.z) * weight;
+ // The blended estimate is a little better than either input, but never better than 3 m.
+ const accuracy = Math.max(
+ 3,
+ Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) +
+ Math.hypot(x - previous.x, z - previous.z) * 0.2,
+ );
+ return {
+ x,
+ z,
+ accuracy,
+ time: fix.time,
+ speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt,
+ };
}
// Stable mode on top of smoothFix, against phone GPS jitter:
@@ -21,82 +32,127 @@ export function smoothFix(previous, fix) {
// dropped (after three in a row it is accepted: the phone really moved, e.g. got in a car);
// - the shown position stays put until the estimate drifts beyond about half the GPS error,
// or the phone reports walking speed. Without stable mode every smoothed fix is shown.
-export function createStabilizer({maxSpeed = 3} = {}) {
- let estimate = null, shown = null, rejected = 0;
- return {
- stable: true,
- push(fix) {
- if (estimate && this.stable && fix.time - estimate.time < 30000) {
- const dt = Math.max(.001, (fix.time - estimate.time) / 1000);
- const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
- if (Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach && fix.accuracy > estimate.accuracy / 2 && rejected < 3) {
- rejected++;
- return shown && {...shown};
- }
- }
- rejected = 0;
- estimate = smoothFix(estimate, fix);
- const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
- if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > .7) shown = {...estimate};
- else shown = {...shown, accuracy: estimate.accuracy, time: estimate.time, speed: fix.speed ?? 0};
- return {...shown};
+export function createStabilizer({ maxSpeed = 3 } = {}) {
+ let estimate = null,
+ shown = null,
+ rejected = 0;
+ return {
+ stable: true,
+ push(fix) {
+ if (estimate && this.stable && fix.time - estimate.time < 30000) {
+ const dt = Math.max(0.001, (fix.time - estimate.time) / 1000);
+ const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
+ if (
+ Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach &&
+ fix.accuracy > estimate.accuracy / 2 &&
+ rejected < 3
+ ) {
+ rejected++;
+ return shown && { ...shown };
}
- };
+ }
+ rejected = 0;
+ estimate = smoothFix(estimate, fix);
+ const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
+ if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > 0.7)
+ shown = { ...estimate };
+ else
+ shown = {
+ ...shown,
+ accuracy: estimate.accuracy,
+ time: estimate.time,
+ speed: fix.speed ?? 0,
+ };
+ return { ...shown };
+ },
+ };
}
// How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m,
// so reaching the area around a machine is enough.
-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));
-export const insideWorld = (bounds, x, z) => x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ;
+export const insideWorld = (bounds, x, z) =>
+ x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ;
// Watches the phone's position and compass. Calls onFix({x, z, accuracy, speed, lat, lon}),
// onHeading(radians in the game's heading convention), onError(message) for a real problem and
// onStatus(message) while it is still searching. `stabilizer` filters the fixes (see above).
-export function startTracking({onFix, onHeading, onError, onStatus = () => {}, stabilizer = createStabilizer()}) {
- if (!('geolocation' in navigator)) {
- onError('این دستگاه موقعیتیابی (GPS) ندارد.');
- return () => {};
- }
- if (!window.isSecureContext) {
- onError('موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.');
- return () => {};
- }
- let fixes = 0;
- const handle = position => {
- const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon);
- const shown = stabilizer.push({...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
- fixes++;
- if (shown) onFix({...shown, lat, lon});
- };
- const fail = error => {
- if (error.code === error.PERMISSION_DENIED) onError('اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)');
- else if (error.code === error.POSITION_UNAVAILABLE) onError('موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیتیابی کار نمیکند. (کد ۲)');
- // A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
- else if (!fixes) onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریعتر پیدا میشود. (کد ۳)');
- };
- // A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
- navigator.geolocation.getCurrentPosition(handle, () => {}, {enableHighAccuracy: false, maximumAge: 60000, timeout: 15000});
- const watch = navigator.geolocation.watchPosition(handle, fail, {enableHighAccuracy: true, maximumAge: 3000, timeout: 30000});
- // Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
- const orientation = event => {
- const compass = event.webkitCompassHeading ?? (event.absolute && event.alpha != null ? 360 - event.alpha : null);
- // Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
- if (compass != null) onHeading(Math.PI / 2 - compass * Math.PI / 180);
- };
- const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
- window.addEventListener(eventName, orientation);
- return () => {
- navigator.geolocation.clearWatch(watch);
- window.removeEventListener(eventName, orientation);
- };
+export function startTracking({
+ onFix,
+ onHeading,
+ onError,
+ onStatus = () => {},
+ stabilizer = createStabilizer(),
+}) {
+ if (!('geolocation' in navigator)) {
+ onError('این دستگاه موقعیتیابی (GPS) ندارد.');
+ return () => {};
+ }
+ if (!window.isSecureContext) {
+ onError('موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.');
+ return () => {};
+ }
+ let fixes = 0;
+ const handle = (position) => {
+ const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
+ local = toLocal(lat, lon);
+ const shown = stabilizer.push({
+ ...local,
+ accuracy: Math.max(3, accuracy),
+ speed: Number.isFinite(speed) ? speed : null,
+ time: position.timestamp,
+ });
+ fixes++;
+ if (shown) onFix({ ...shown, lat, lon });
+ };
+ const fail = (error) => {
+ if (error.code === error.PERMISSION_DENIED)
+ onError(
+ 'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
+ );
+ else if (error.code === error.POSITION_UNAVAILABLE)
+ onError(
+ 'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیتیابی کار نمیکند. (کد ۲)',
+ );
+ // A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
+ else if (!fixes)
+ onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریعتر پیدا میشود. (کد ۳)');
+ };
+ // A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
+ navigator.geolocation.getCurrentPosition(handle, () => {}, {
+ enableHighAccuracy: false,
+ maximumAge: 60000,
+ timeout: 15000,
+ });
+ const watch = navigator.geolocation.watchPosition(handle, fail, {
+ enableHighAccuracy: true,
+ maximumAge: 3000,
+ timeout: 30000,
+ });
+ // Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
+ const orientation = (event) => {
+ const compass =
+ event.webkitCompassHeading ??
+ (event.absolute && event.alpha != null ? 360 - event.alpha : null);
+ // Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
+ if (compass != null) onHeading(Math.PI / 2 - (compass * Math.PI) / 180);
+ };
+ const eventName =
+ 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
+ window.addEventListener(eventName, orientation);
+ return () => {
+ navigator.geolocation.clearWatch(watch);
+ window.removeEventListener(eventName, orientation);
+ };
}
// iOS needs an explicit permission prompt for the compass, and it must come from a tap.
export async function requestCompass() {
- try {
- if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) await DeviceOrientationEvent.requestPermission();
- } catch {
- // Without the compass the family faces the direction of travel instead.
- }
+ try {
+ if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission)
+ await DeviceOrientationEvent.requestPermission();
+ } catch {
+ // Without the compass the family faces the direction of travel instead.
+ }
}
diff --git a/src/routing.js b/src/routing.js
index 2f70b6a..2ed0de1 100644
--- a/src/routing.js
+++ b/src/routing.js
@@ -2,175 +2,222 @@
// 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 function buildGraph(roads, {drivable = false} = {}) {
- const ids = new Map(), xs = [], zs = [], adj = [];
- const node = (x, z) => {
- const k = `${x},${z}`;
- if (!ids.has(k)) {
- ids.set(k, xs.length);
- xs.push(x);
- zs.push(z);
- adj.push([]);
- }
- return ids.get(k);
- };
- for (const road of roads) {
- if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
- let prev = node(road[2], road[3]);
- for (let i = 4; i < road.length; i += 2) {
- const next = node(road[i], road[i + 1]), cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
- if (next !== prev) {
- adj[prev].push(next, cost);
- adj[next].push(prev, cost);
- }
- prev = next;
- }
+export function buildGraph(roads, { drivable = false } = {}) {
+ const ids = new Map(),
+ xs = [],
+ zs = [],
+ adj = [];
+ const node = (x, z) => {
+ const k = `${x},${z}`;
+ if (!ids.has(k)) {
+ ids.set(k, xs.length);
+ xs.push(x);
+ zs.push(z);
+ adj.push([]);
}
- const graph = {xs, zs, adj, main: largestComponent(adj), grid: new Map()};
- graph.main.forEach((inMain, i) => {
- if (!inMain) return;
- const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
- if (!graph.grid.has(k)) graph.grid.set(k, []);
- graph.grid.get(k).push(i);
- });
- return graph;
+ return ids.get(k);
+ };
+ for (const road of roads) {
+ if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
+ let prev = node(road[2], road[3]);
+ for (let i = 4; i < road.length; i += 2) {
+ const next = node(road[i], road[i + 1]),
+ cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
+ if (next !== prev) {
+ adj[prev].push(next, cost);
+ adj[next].push(prev, cost);
+ }
+ prev = next;
+ }
+ }
+ const graph = { xs, zs, adj, main: largestComponent(adj), grid: new Map() };
+ graph.main.forEach((inMain, i) => {
+ if (!inMain) return;
+ const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
+ if (!graph.grid.has(k)) graph.grid.set(k, []);
+ graph.grid.get(k).push(i);
+ });
+ return graph;
}
const cellKey = (cx, cz) => cx * 100003 + cz;
function largestComponent(adj) {
- const label = new Int32Array(adj.length).fill(-1), sizes = [];
- for (let s = 0; s < adj.length; s++) {
- if (label[s] >= 0) continue;
- const id = sizes.length, stack = [s];
- let size = 0;
- label[s] = id;
- while (stack.length) {
- const n = stack.pop();
- size++;
- for (let i = 0; i < adj[n].length; i += 2) if (label[adj[n][i]] < 0) {
- label[adj[n][i]] = id;
- stack.push(adj[n][i]);
- }
+ const label = new Int32Array(adj.length).fill(-1),
+ sizes = [];
+ for (let s = 0; s < adj.length; s++) {
+ if (label[s] >= 0) continue;
+ const id = sizes.length,
+ stack = [s];
+ let size = 0;
+ label[s] = id;
+ while (stack.length) {
+ const n = stack.pop();
+ size++;
+ for (let i = 0; i < adj[n].length; i += 2)
+ if (label[adj[n][i]] < 0) {
+ label[adj[n][i]] = id;
+ stack.push(adj[n][i]);
}
- sizes.push(size);
}
- const best = sizes.indexOf(Math.max(...sizes));
- return Array.from(label, l => l === best);
+ sizes.push(size);
+ }
+ const best = sizes.indexOf(Math.max(...sizes));
+ return Array.from(label, (l) => l === best);
}
// Nearest node of the main connected component, searching outward ring by ring.
export function nearestNode(graph, x, z) {
- const cx = Math.floor(x / 50), cz = Math.floor(z / 50);
- let best = -1, bestD = Infinity;
- for (let ring = 0; ring < 40; ring++) {
- for (let i = cx - ring; i <= cx + ring; i++) for (let j = cz - ring; j <= cz + ring; j++) {
- if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
- for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
- const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
- if (d < bestD) {
- bestD = d;
- best = n;
- }
- }
+ const cx = Math.floor(x / 50),
+ cz = Math.floor(z / 50);
+ let best = -1,
+ bestD = Infinity;
+ for (let ring = 0; ring < 40; ring++) {
+ for (let i = cx - ring; i <= cx + ring; i++)
+ for (let j = cz - ring; j <= cz + ring; j++) {
+ if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
+ for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
+ const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
+ if (d < bestD) {
+ bestD = d;
+ best = n;
+ }
}
- if (best >= 0 && bestD < ring * 50) break;
- }
- return best;
+ }
+ if (best >= 0 && bestD < ring * 50) break;
+ }
+ return best;
}
export function findPath(graph, start, goal) {
- if (start < 0 || goal < 0) return null;
- const {xs, zs, adj} = graph, g = new Map([[start, 0]]), from = new Map(), closed = new Set();
- const h = n => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
- const heap = [[h(start), start]];
- const push = item => {
- heap.push(item);
- for (let i = heap.length - 1; i > 0;) {
- const p = (i - 1) >> 1;
- if (heap[p][0] <= heap[i][0]) break;
- [heap[p], heap[i]] = [heap[i], heap[p]];
- i = p;
- }
- };
- const pop = () => {
- const top = heap[0], last = heap.pop();
- if (heap.length) {
- heap[0] = last;
- for (let i = 0; ;) {
- const l = i * 2 + 1, r = l + 1;
- let m = i;
- if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
- if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
- if (m === i) break;
- [heap[m], heap[i]] = [heap[i], heap[m]];
- i = m;
- }
- }
- return top;
- };
- while (heap.length) {
- const [, n] = pop();
- if (n === goal) {
- const path = [n];
- while (from.has(path[0])) path.unshift(from.get(path[0]));
- return path;
- }
- if (closed.has(n)) continue;
- closed.add(n);
- for (let i = 0; i < adj[n].length; i += 2) {
- const m = adj[n][i], cost = g.get(n) + adj[n][i + 1];
- if (cost < (g.get(m) ?? Infinity)) {
- g.set(m, cost);
- from.set(m, n);
- push([cost + h(m), m]);
- }
- }
+ if (start < 0 || goal < 0) return null;
+ const { xs, zs, adj } = graph,
+ g = new Map([[start, 0]]),
+ from = new Map(),
+ closed = new Set();
+ const h = (n) => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
+ const heap = [[h(start), start]];
+ const push = (item) => {
+ heap.push(item);
+ for (let i = heap.length - 1; i > 0;) {
+ const p = (i - 1) >> 1;
+ if (heap[p][0] <= heap[i][0]) break;
+ [heap[p], heap[i]] = [heap[i], heap[p]];
+ i = p;
}
- return null;
+ };
+ const pop = () => {
+ const top = heap[0],
+ last = heap.pop();
+ if (heap.length) {
+ heap[0] = last;
+ for (let i = 0; ;) {
+ const l = i * 2 + 1,
+ r = l + 1;
+ let m = i;
+ if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
+ if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
+ if (m === i) break;
+ [heap[m], heap[i]] = [heap[i], heap[m]];
+ i = m;
+ }
+ }
+ return top;
+ };
+ while (heap.length) {
+ const [, n] = pop();
+ if (n === goal) {
+ const path = [n];
+ while (from.has(path[0])) path.unshift(from.get(path[0]));
+ return path;
+ }
+ if (closed.has(n)) continue;
+ closed.add(n);
+ for (let i = 0; i < adj[n].length; i += 2) {
+ const m = adj[n][i],
+ cost = g.get(n) + adj[n][i + 1];
+ if (cost < (g.get(m) ?? Infinity)) {
+ g.set(m, cost);
+ from.set(m, n);
+ push([cost + h(m), m]);
+ }
+ }
+ }
+ return null;
}
export function routeBetween(graph, ax, az, bx, bz) {
- const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
- if (!path) return null;
- const points = [{x: ax, z: az}, ...path.map(n => ({x: graph.xs[n], z: graph.zs[n]})), {x: bx, z: bz}];
- return {points, length: polylineLength(points)};
+ const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
+ if (!path) return null;
+ const points = [
+ { x: ax, z: az },
+ ...path.map((n) => ({ x: graph.xs[n], z: graph.zs[n] })),
+ { x: bx, z: bz },
+ ];
+ return { points, length: polylineLength(points) };
}
export function polylineLength(points) {
- let total = 0;
- for (let i = 1; i < points.length; i++) total += Math.hypot(points[i].x - points[i - 1].x, points[i].z - points[i - 1].z);
- return total;
+ let total = 0;
+ for (let i = 1; i < points.length; i++)
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].z - points[i - 1].z);
+ return total;
}
// Spatial index of road segments, used for street names and keeping trees off paths.
export function buildSegmentIndex(roads) {
- const grid = new Map();
- for (const road of roads) for (let i = 4; i < road.length; i += 2) {
- const seg = [road[i - 2], road[i - 1], road[i], road[i + 1], road[0], road[1]];
- for (let cx = Math.floor(Math.min(seg[0], seg[2]) / 40); cx <= Math.floor(Math.max(seg[0], seg[2]) / 40); cx++)
- for (let cz = Math.floor(Math.min(seg[1], seg[3]) / 40); cz <= Math.floor(Math.max(seg[1], seg[3]) / 40); cz++) {
- const k = cellKey(cx, cz);
- if (!grid.has(k)) grid.set(k, []);
- grid.get(k).push(seg);
- }
+ const grid = new Map();
+ for (const road of roads)
+ for (let i = 4; i < road.length; i += 2) {
+ const seg = [road[i - 2], road[i - 1], road[i], road[i + 1], road[0], road[1]];
+ for (
+ let cx = Math.floor(Math.min(seg[0], seg[2]) / 40);
+ cx <= Math.floor(Math.max(seg[0], seg[2]) / 40);
+ cx++
+ )
+ for (
+ let cz = Math.floor(Math.min(seg[1], seg[3]) / 40);
+ cz <= Math.floor(Math.max(seg[1], seg[3]) / 40);
+ cz++
+ ) {
+ const k = cellKey(cx, cz);
+ if (!grid.has(k)) grid.set(k, []);
+ grid.get(k).push(seg);
+ }
}
- return grid;
+ return grid;
}
export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
- let best = null, bestD = maxDistance;
- for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++)
- for (let cz = Math.floor((z - maxDistance) / 40); cz <= Math.floor((z + maxDistance) / 40); cz++)
- for (const s of index.get(cellKey(cx, cz)) ?? []) {
- if (!filter(s)) continue;
- const dx = s[2] - s[0], dz = s[3] - s[1];
- const t = Math.max(0, Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1)));
- const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
- if (d < bestD) {
- bestD = d;
- best = {type: s[4], name: s[5], distance: d, x: s[0] + dx * t, z: s[1] + dz * t, dx, dz};
- }
- }
- return best;
+ let best = null,
+ bestD = maxDistance;
+ for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++)
+ for (
+ let cz = Math.floor((z - maxDistance) / 40);
+ cz <= Math.floor((z + maxDistance) / 40);
+ cz++
+ )
+ for (const s of index.get(cellKey(cx, cz)) ?? []) {
+ if (!filter(s)) continue;
+ const dx = s[2] - s[0],
+ dz = s[3] - s[1];
+ const t = Math.max(
+ 0,
+ Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1)),
+ );
+ const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
+ if (d < bestD) {
+ bestD = d;
+ best = {
+ type: s[4],
+ name: s[5],
+ distance: d,
+ x: s[0] + dx * t,
+ z: s[1] + dz * t,
+ dx,
+ dz,
+ };
+ }
+ }
+ return best;
}
diff --git a/src/scoring.js b/src/scoring.js
index f5e2af3..44beb53 100644
--- a/src/scoring.js
+++ b/src/scoring.js
@@ -1,82 +1,126 @@
-import {CATEGORIES} from './locations.js';
+import { CATEGORIES } from './locations.js';
// The bonus for visiting every kind of place counts city locations, not the factory.
-const CITY_CATEGORIES = Object.keys(CATEGORIES).filter(c => c !== 'factory' && c !== 'test');
+const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
-export const POINTS = {purchase: 100, discovery: 250, combo: 150, allCategories: 500};
+export const POINTS = { purchase: 100, discovery: 250, combo: 150, allCategories: 500 };
export const RANKS = [
- {name: 'تازهوارد', xp: 0},
- {name: 'کاوشگر', xp: 600},
- {name: 'راهبلد', xp: 1400},
- {name: 'شکارچی دستگاه', xp: 2600},
- {name: 'استاد نقشه', xp: 4200},
- {name: 'افسانهٔ مشهد', xp: 6500}
+ { name: 'تازهوارد', xp: 0 },
+ { name: 'کاوشگر', xp: 600 },
+ { name: 'راهبلد', xp: 1400 },
+ { name: 'شکارچی دستگاه', xp: 2600 },
+ { name: 'استاد نقشه', xp: 4200 },
+ { name: 'افسانهٔ مشهد', xp: 6500 },
];
// Awards for `record`, given the purchases that happened before it.
export function awardsFor(previous, record) {
- const awards = [{label: 'خرید موفق', points: POINTS.purchase}];
- const here = previous.filter(p => p.machineId === record.machineId);
- if (!here.length) awards.push({label: 'کشف مکان جدید', points: POINTS.discovery});
- // Paid once per machine: the first time it has sold both a drink and a snack.
- const typesHere = new Set(here.map(p => p.type));
- if (typesHere.size === 1 && !typesHere.has(record.type)) awards.push({label: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo});
- const categories = new Set(previous.map(p => p.category).filter(c => CITY_CATEGORIES.includes(c)));
- if (CITY_CATEGORIES.includes(record.category) && !categories.has(record.category) && categories.size === CITY_CATEGORIES.length - 1)
- awards.push({label: 'همهٔ انواع مکان', points: POINTS.allCategories});
- return awards;
+ const awards = [{ label: 'خرید موفق', points: POINTS.purchase }];
+ const here = previous.filter((p) => p.machineId === record.machineId);
+ if (!here.length) awards.push({ label: 'کشف مکان جدید', points: POINTS.discovery });
+ // Paid once per machine: the first time it has sold both a drink and a snack.
+ const typesHere = new Set(here.map((p) => p.type));
+ if (typesHere.size === 1 && !typesHere.has(record.type))
+ awards.push({ label: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo });
+ const categories = new Set(
+ previous.map((p) => p.category).filter((c) => CITY_CATEGORIES.includes(c)),
+ );
+ if (
+ CITY_CATEGORIES.includes(record.category) &&
+ !categories.has(record.category) &&
+ categories.size === CITY_CATEGORIES.length - 1
+ )
+ awards.push({ label: 'همهٔ انواع مکان', points: POINTS.allCategories });
+ return awards;
}
export function rankFor(xp) {
- let index = 0;
- while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
- const next = RANKS[index + 1] ?? null;
- return {index, name: RANKS[index].name, min: RANKS[index].xp, next: next?.xp ?? null, progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1};
+ let index = 0;
+ while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
+ const next = RANKS[index + 1] ?? null;
+ return {
+ index,
+ name: RANKS[index].name,
+ min: RANKS[index].xp,
+ next: next?.xp ?? null,
+ progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1,
+ };
}
-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 = [
- {
- id: 'factory', title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن', target: 'factory', xp: 100, hetoman: 20,
- done: ps => ps.some(p => p.machineId === 'factory')
- },
- {
- id: 'first', title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده', target: 'mellat-park', xp: 150, hetoman: 30,
- done: ps => ps.some(p => p.machineId === 'mellat-park')
- },
- {
- id: 'school', title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن', target: 'school-rahil', xp: 200, hetoman: 40,
- done: ps => ps.some(p => p.machineId === 'school-rahil')
- },
- {
- id: 'campus', title: 'به دانشگاه فردوسی برو و یک خوراکی بخر', target: 'ferdowsi-university', xp: 200, hetoman: 40,
- done: ps => ps.some(p => p.machineId === 'ferdowsi-university' && p.type === 'snack')
- },
- {
- id: 'metro', title: 'از دستگاه ایستگاه مترو آزادی خرید کن', target: 'azadi-metro', xp: 150, hetoman: 30,
- done: ps => ps.some(p => p.machineId === 'azadi-metro')
- },
- {
- id: 'mall', title: 'با ماشین تا مشهد مال برو و خرید کن', target: 'mashhad-mall', xp: 250, hetoman: 50,
- done: ps => ps.some(p => p.machineId === 'mashhad-mall')
- },
- {
- id: 'explorer', title: 'از ۵ مکان متفاوت شهر خرید کن', target: null, xp: 400, hetoman: 80,
- done: ps => placesVisited(ps.filter(p => CITY_CATEGORIES.includes(p.category))) >= 5
- }
+ {
+ id: 'factory',
+ title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن',
+ target: 'factory',
+ xp: 100,
+ hetoman: 20,
+ done: (ps) => ps.some((p) => p.machineId === 'factory'),
+ },
+ {
+ id: 'first',
+ title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
+ target: 'mellat-park',
+ xp: 150,
+ hetoman: 30,
+ done: (ps) => ps.some((p) => p.machineId === 'mellat-park'),
+ },
+ {
+ id: 'school',
+ title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
+ target: 'school-rahil',
+ xp: 200,
+ hetoman: 40,
+ done: (ps) => ps.some((p) => p.machineId === 'school-rahil'),
+ },
+ {
+ id: 'campus',
+ title: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
+ target: 'ferdowsi-university',
+ xp: 200,
+ hetoman: 40,
+ done: (ps) => ps.some((p) => p.machineId === 'ferdowsi-university' && p.type === 'snack'),
+ },
+ {
+ id: 'metro',
+ title: 'از دستگاه ایستگاه مترو آزادی خرید کن',
+ target: 'azadi-metro',
+ xp: 150,
+ hetoman: 30,
+ done: (ps) => ps.some((p) => p.machineId === 'azadi-metro'),
+ },
+ {
+ id: 'mall',
+ title: 'با ماشین تا مشهد مال برو و خرید کن',
+ target: 'mashhad-mall',
+ xp: 250,
+ hetoman: 50,
+ done: (ps) => ps.some((p) => p.machineId === 'mashhad-mall'),
+ },
+ {
+ id: 'explorer',
+ title: 'از ۵ مکان متفاوت شهر خرید کن',
+ target: null,
+ xp: 400,
+ hetoman: 80,
+ done: (ps) => placesVisited(ps.filter((p) => CITY_CATEGORIES.includes(p.category))) >= 5,
+ },
];
// Marks and returns missions that the current purchases complete for the first time.
export function completeMissions(state) {
- const fresh = MISSIONS.filter(m => !state.missionsDone.includes(m.id) && m.done(state.purchases));
- for (const m of fresh) {
- state.missionsDone.push(m.id);
- state.xp += m.xp;
- state.balance += m.hetoman;
- }
- return fresh;
+ const fresh = MISSIONS.filter(
+ (m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
+ );
+ for (const m of fresh) {
+ state.missionsDone.push(m.id);
+ state.xp += m.xp;
+ state.balance += m.hetoman;
+ }
+ return fresh;
}
-export const activeMission = state => MISSIONS.find(m => !state.missionsDone.includes(m.id)) ?? null;
+export const activeMission = (state) =>
+ MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null;
diff --git a/src/staff.js b/src/staff.js
index 7fb0191..a007a39 100644
--- a/src/staff.js
+++ b/src/staff.js
@@ -1,5 +1,5 @@
import * as THREE from 'three';
-import {textTexture} from './textures.js';
+import { textTexture } from './textures.js';
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
@@ -8,159 +8,241 @@ import {textTexture} from './textures.js';
const materials = new Map();
const mat = (color, extra = {}) => {
- const key = color + JSON.stringify(extra);
- if (!materials.has(key)) materials.set(key, new THREE.MeshStandardMaterial({color, roughness: .8, ...extra}));
- return materials.get(key);
+ const key = color + JSON.stringify(extra);
+ if (!materials.has(key))
+ materials.set(key, new THREE.MeshStandardMaterial({ color, roughness: 0.8, ...extra }));
+ return materials.get(key);
};
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
- const mesh = new THREE.Mesh(geometry, material);
- mesh.position.set(x, y, z);
- mesh.castShadow = true;
- parent.add(mesh);
- return mesh;
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = true;
+ parent.add(mesh);
+ return mesh;
}
/**
* outfit: 'hoodie' | 'suit' | 'manteau'. Colours: top (hoodie, jacket or manteau), bottom, skin,
* hair, scarf (manteau only), tie (suit only, optional), glasses.
*/
-export function createPerson({outfit = 'hoodie', top = '#222', bottom = '#2c3e5a', skin = '#e2b18e', hair = '#2a211c', scarf = '#3b3f6b', tie = null, glasses = false, shoes = '#f1f1f1'} = {}) {
- const root = new THREE.Group();
- const pelvis = new THREE.Group();
- pelvis.position.y = .94;
- root.add(pelvis);
- const topMat = mat(top), bottomMat = mat(bottom), skinMat = mat(skin), hairMat = mat(hair);
- const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
+export function createPerson({
+ outfit = 'hoodie',
+ top = '#222',
+ bottom = '#2c3e5a',
+ skin = '#e2b18e',
+ hair = '#2a211c',
+ scarf = '#3b3f6b',
+ tie = null,
+ glasses = false,
+ shoes = '#f1f1f1',
+} = {}) {
+ const root = new THREE.Group();
+ const pelvis = new THREE.Group();
+ pelvis.position.y = 0.94;
+ root.add(pelvis);
+ const topMat = mat(top),
+ bottomMat = mat(bottom),
+ skinMat = mat(skin),
+ hairMat = mat(hair);
+ const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
- // Torso, neck and head.
- const torso = new THREE.Group();
- pelvis.add(torso);
- const long = outfit === 'manteau';
- part(torso, new THREE.CylinderGeometry(.2, .17, .6, 12), topMat, 0, .3, 0).scale.z = .65;
- if (long) part(torso, new THREE.CylinderGeometry(.18, .2, .3, 12), topMat, 0, -.08, 0).scale.z = .7;
- part(torso, new THREE.CylinderGeometry(.05, .055, .1, 10), skinMat, 0, .64, 0);
- if (outfit === 'hoodie') {
- // Hood folded behind the neck, front pocket and drawstrings.
- part(torso, new THREE.SphereGeometry(.13, 12, 8), topMat, 0, .58, -.1).scale.set(1.2, .6, .7);
- part(torso, new THREE.BoxGeometry(.24, .12, .02), topMat, 0, .12, .125);
- for (const side of [-1, 1]) part(torso, new THREE.CylinderGeometry(.006, .006, .16, 4), mat('#f2f2f2'), side * .04, .48, .13);
- } else if (outfit === 'suit') {
- // White shirt opening, optional tie, lapels.
- const shirt = part(torso, new THREE.PlaneGeometry(.12, .2), mat('#f4f4f2', {side: THREE.DoubleSide}), 0, .49, .133);
- shirt.rotation.x = -.12;
- if (tie) part(torso, new THREE.BoxGeometry(.035, .22, .01), mat(tie), 0, .45, .138);
- for (const side of [-1, 1]) {
- const lapel = part(torso, new THREE.BoxGeometry(.05, .22, .01), mat(top, {roughness: .5}), side * .07, .47, .132);
- lapel.rotation.z = side * .35;
+ // Torso, neck and head.
+ const torso = new THREE.Group();
+ pelvis.add(torso);
+ const long = outfit === 'manteau';
+ part(torso, new THREE.CylinderGeometry(0.2, 0.17, 0.6, 12), topMat, 0, 0.3, 0).scale.z = 0.65;
+ if (long)
+ part(torso, new THREE.CylinderGeometry(0.18, 0.2, 0.3, 12), topMat, 0, -0.08, 0).scale.z = 0.7;
+ part(torso, new THREE.CylinderGeometry(0.05, 0.055, 0.1, 10), skinMat, 0, 0.64, 0);
+ if (outfit === 'hoodie') {
+ // Hood folded behind the neck, front pocket and drawstrings.
+ part(torso, new THREE.SphereGeometry(0.13, 12, 8), topMat, 0, 0.58, -0.1).scale.set(
+ 1.2,
+ 0.6,
+ 0.7,
+ );
+ part(torso, new THREE.BoxGeometry(0.24, 0.12, 0.02), topMat, 0, 0.12, 0.125);
+ for (const side of [-1, 1])
+ part(
+ torso,
+ new THREE.CylinderGeometry(0.006, 0.006, 0.16, 4),
+ mat('#f2f2f2'),
+ side * 0.04,
+ 0.48,
+ 0.13,
+ );
+ } else if (outfit === 'suit') {
+ // White shirt opening, optional tie, lapels.
+ const shirt = part(
+ torso,
+ new THREE.PlaneGeometry(0.12, 0.2),
+ mat('#f4f4f2', { side: THREE.DoubleSide }),
+ 0,
+ 0.49,
+ 0.133,
+ );
+ shirt.rotation.x = -0.12;
+ if (tie) part(torso, new THREE.BoxGeometry(0.035, 0.22, 0.01), mat(tie), 0, 0.45, 0.138);
+ for (const side of [-1, 1]) {
+ const lapel = part(
+ torso,
+ new THREE.BoxGeometry(0.05, 0.22, 0.01),
+ mat(top, { roughness: 0.5 }),
+ side * 0.07,
+ 0.47,
+ 0.132,
+ );
+ lapel.rotation.z = side * 0.35;
+ }
+ }
+ const head = new THREE.Group();
+ head.position.y = 0.8;
+ torso.add(head);
+ part(head, new THREE.SphereGeometry(0.11, 16, 12), skinMat).scale.set(0.92, 1.05, 1);
+ part(head, new THREE.SphereGeometry(0.02, 6, 4), skinMat, 0, -0.01, 0.105);
+ for (const side of [-1, 1])
+ part(head, new THREE.SphereGeometry(0.012, 6, 4), mat('#1b1512'), side * 0.038, 0.02, 0.098);
+ if (long) {
+ // Headscarf: covers the hair and the neck, leaving the face open.
+ part(head, new THREE.SphereGeometry(0.128, 16, 12), mat(scarf), 0, 0.012, -0.022).scale.set(
+ 1,
+ 1.05,
+ 1,
+ );
+ part(
+ head,
+ new THREE.CylinderGeometry(0.09, 0.17, 0.2, 14),
+ mat(scarf),
+ 0,
+ -0.13,
+ -0.01,
+ ).scale.z = 0.75;
+ part(
+ head,
+ new THREE.TorusGeometry(0.105, 0.018, 6, 20, Math.PI * 1.2),
+ mat(scarf),
+ 0,
+ 0.01,
+ 0.04,
+ ).rotation.set(0, 0, -0.1 * Math.PI);
+ } else {
+ part(head, new THREE.SphereGeometry(0.115, 14, 10), hairMat, 0, 0.035, -0.015).scale.set(
+ 1,
+ 0.75,
+ 1,
+ );
+ }
+ if (glasses) {
+ const frame = mat('#222', { metalness: 0.6, roughness: 0.3 });
+ for (const side of [-1, 1])
+ part(head, new THREE.TorusGeometry(0.024, 0.004, 4, 14), frame, side * 0.04, 0.02, 0.11);
+ part(head, new THREE.BoxGeometry(0.03, 0.004, 0.004), frame, 0, 0.022, 0.11);
+ }
+
+ // Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
+ const arms = [-1, 1].map((side) => {
+ const shoulder = new THREE.Group();
+ shoulder.position.set(side * 0.23, 0.55, 0);
+ torso.add(shoulder);
+ part(shoulder, new THREE.CapsuleGeometry(0.05, 0.2, 4, 8), topMat, 0, -0.14, 0);
+ const elbow = new THREE.Group();
+ elbow.position.y = -0.29;
+ shoulder.add(elbow);
+ part(elbow, new THREE.CapsuleGeometry(0.043, 0.18, 4, 8), topMat, 0, -0.12, 0);
+ part(elbow, new THREE.SphereGeometry(0.045, 8, 6), skinMat, 0, -0.27, 0);
+ return { shoulder, elbow };
+ });
+ // Legs: hip → knee → foot.
+ const legs = [-1, 1].map((side) => {
+ const hip = new THREE.Group();
+ hip.position.set(side * 0.095, 0, 0);
+ pelvis.add(hip);
+ part(hip, new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), long ? topMat : bottomMat, 0, -0.22, 0);
+ const knee = new THREE.Group();
+ knee.position.y = -0.45;
+ hip.add(knee);
+ part(knee, new THREE.CapsuleGeometry(0.058, 0.32, 4, 8), bottomMat, 0, -0.22, 0);
+ part(knee, new THREE.BoxGeometry(0.1, 0.07, 0.24), shoeMat, 0, -0.45, 0.05);
+ return { hip, knee };
+ });
+
+ const person = {
+ root,
+ head,
+ // pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
+ pose(kind, t = 0, phase = 0) {
+ const sitting = kind === 'sit' || kind === 'type';
+ pelvis.position.y = sitting
+ ? 0.5
+ : 0.94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * 0.02 : 0);
+ torso.rotation.x = kind === 'type' ? 0.18 : sitting ? -0.05 : 0;
+ head.rotation.y =
+ kind === 'type' ? Math.sin(t * 0.4 + phase) * 0.15 : Math.sin(t * 0.6 + phase) * 0.2;
+ head.rotation.x = kind === 'type' ? 0.12 : 0;
+ legs.forEach(({ hip, knee }, i) => {
+ const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * 0.45 : 0;
+ hip.rotation.x = sitting ? -Math.PI / 2 : swing;
+ knee.rotation.x = sitting
+ ? Math.PI / 2
+ : kind === 'walk'
+ ? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * 0.5
+ : 0;
+ });
+ arms.forEach(({ shoulder, elbow }, i) => {
+ if (kind === 'type') {
+ // Hands on the keyboard, fingers busy.
+ shoulder.rotation.set(
+ -0.7 + Math.sin(t * 13 + phase + i * 2) * 0.025,
+ 0,
+ (i ? 1 : -1) * -0.12,
+ );
+ elbow.rotation.x = -1.15;
+ } else if (kind === 'sit') {
+ shoulder.rotation.set(-0.3, 0, 0);
+ elbow.rotation.x = -1.0;
+ } else {
+ const swing =
+ kind === 'walk'
+ ? -Math.sin(t * 6 + phase + i * Math.PI) * 0.4
+ : Math.sin(t * 1.2 + phase + i) * 0.03;
+ shoulder.rotation.set(swing, 0, (i ? 1 : -1) * 0.06);
+ elbow.rotation.x = kind === 'walk' ? -0.25 : -0.1;
}
- }
- const head = new THREE.Group();
- head.position.y = .8;
- torso.add(head);
- part(head, new THREE.SphereGeometry(.11, 16, 12), skinMat).scale.set(.92, 1.05, 1);
- part(head, new THREE.SphereGeometry(.02, 6, 4), skinMat, 0, -.01, .105);
- for (const side of [-1, 1]) part(head, new THREE.SphereGeometry(.012, 6, 4), mat('#1b1512'), side * .038, .02, .098);
- if (long) {
- // Headscarf: covers the hair and the neck, leaving the face open.
- part(head, new THREE.SphereGeometry(.128, 16, 12), mat(scarf), 0, .012, -.022).scale.set(1, 1.05, 1);
- part(head, new THREE.CylinderGeometry(.09, .17, .2, 14), mat(scarf), 0, -.13, -.01).scale.z = .75;
- part(head, new THREE.TorusGeometry(.105, .018, 6, 20, Math.PI * 1.2), mat(scarf), 0, .01, .04).rotation.set(0, 0, -.1 * Math.PI);
- } else {
- part(head, new THREE.SphereGeometry(.115, 14, 10), hairMat, 0, .035, -.015).scale.set(1, .75, 1);
- }
- if (glasses) {
- const frame = mat('#222', {metalness: .6, roughness: .3});
- for (const side of [-1, 1]) part(head, new THREE.TorusGeometry(.024, .004, 4, 14), frame, side * .04, .02, .11);
- part(head, new THREE.BoxGeometry(.03, .004, .004), frame, 0, .022, .11);
- }
-
- // Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
- const arms = [-1, 1].map(side => {
- const shoulder = new THREE.Group();
- shoulder.position.set(side * .23, .55, 0);
- torso.add(shoulder);
- part(shoulder, new THREE.CapsuleGeometry(.05, .2, 4, 8), topMat, 0, -.14, 0);
- const elbow = new THREE.Group();
- elbow.position.y = -.29;
- shoulder.add(elbow);
- part(elbow, new THREE.CapsuleGeometry(.043, .18, 4, 8), topMat, 0, -.12, 0);
- part(elbow, new THREE.SphereGeometry(.045, 8, 6), skinMat, 0, -.27, 0);
- return {shoulder, elbow};
- });
- // Legs: hip → knee → foot.
- const legs = [-1, 1].map(side => {
- const hip = new THREE.Group();
- hip.position.set(side * .095, 0, 0);
- pelvis.add(hip);
- part(hip, new THREE.CapsuleGeometry(.07, .3, 4, 8), long ? topMat : bottomMat, 0, -.22, 0);
- const knee = new THREE.Group();
- knee.position.y = -.45;
- hip.add(knee);
- part(knee, new THREE.CapsuleGeometry(.058, .32, 4, 8), bottomMat, 0, -.22, 0);
- part(knee, new THREE.BoxGeometry(.1, .07, .24), shoeMat, 0, -.45, .05);
- return {hip, knee};
- });
-
- const person = {
- root, head,
- // pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
- pose(kind, t = 0, phase = 0) {
- const sitting = kind === 'sit' || kind === 'type';
- pelvis.position.y = sitting ? .5 : .94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * .02 : 0);
- torso.rotation.x = kind === 'type' ? .18 : sitting ? -.05 : 0;
- head.rotation.y = kind === 'type' ? Math.sin(t * .4 + phase) * .15 : Math.sin(t * .6 + phase) * .2;
- head.rotation.x = kind === 'type' ? .12 : 0;
- legs.forEach(({hip, knee}, i) => {
- const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * .45 : 0;
- hip.rotation.x = sitting ? -Math.PI / 2 : swing;
- knee.rotation.x = sitting ? Math.PI / 2 : kind === 'walk' ? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * .5 : 0;
- });
- arms.forEach(({shoulder, elbow}, i) => {
- if (kind === 'type') {
- // Hands on the keyboard, fingers busy.
- shoulder.rotation.set(-.7 + Math.sin(t * 13 + phase + i * 2) * .025, 0, (i ? 1 : -1) * -.12);
- elbow.rotation.x = -1.15;
- } else if (kind === 'sit') {
- shoulder.rotation.set(-.3, 0, 0);
- elbow.rotation.x = -1.0;
- } else {
- const swing = kind === 'walk' ? -Math.sin(t * 6 + phase + i * Math.PI) * .4 : Math.sin(t * 1.2 + phase + i) * .03;
- shoulder.rotation.set(swing, 0, (i ? 1 : -1) * .06);
- elbow.rotation.x = kind === 'walk' ? -.25 : -.1;
- }
- });
- }
- };
- person.pose('stand');
- return person;
+ });
+ },
+ };
+ person.pose('stand');
+ return person;
}
// Floating name badge above a person's head.
export function nameTag(name, role = '') {
- const texture = textTexture(512, 160, (c, w, h) => {
- c.clearRect(0, 0, w, h);
- c.fillStyle = 'rgba(8, 24, 28, .82)';
- c.beginPath();
- c.roundRect(6, 6, w - 12, h - 12, 26);
- c.fill();
- c.fillStyle = '#3ee0c9';
- c.fillRect(6, h - 18, w - 12, 8);
- c.textAlign = 'center';
- c.direction = 'rtl';
- c.fillStyle = '#ffffff';
- c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
- c.fillText(name, w / 2, role ? 72 : 100);
- if (role) {
- c.fillStyle = '#9fe9da';
- c.font = '40px Vazirmatn, Tahoma, sans-serif';
- c.direction = 'ltr';
- c.fillText(role, w / 2, 126);
- }
- });
- const sprite = new THREE.Sprite(new THREE.SpriteMaterial({map: texture, transparent: true, depthWrite: false}));
- sprite.scale.set(1.3, .41, 1);
- return sprite;
+ const texture = textTexture(512, 160, (c, w, h) => {
+ c.clearRect(0, 0, w, h);
+ c.fillStyle = 'rgba(8, 24, 28, .82)';
+ c.beginPath();
+ c.roundRect(6, 6, w - 12, h - 12, 26);
+ c.fill();
+ c.fillStyle = '#3ee0c9';
+ c.fillRect(6, h - 18, w - 12, 8);
+ c.textAlign = 'center';
+ c.direction = 'rtl';
+ c.fillStyle = '#ffffff';
+ c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
+ c.fillText(name, w / 2, role ? 72 : 100);
+ if (role) {
+ c.fillStyle = '#9fe9da';
+ c.font = '40px Vazirmatn, Tahoma, sans-serif';
+ c.direction = 'ltr';
+ c.fillText(role, w / 2, 126);
+ }
+ });
+ const sprite = new THREE.Sprite(
+ new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }),
+ );
+ sprite.scale.set(1.3, 0.41, 1);
+ return sprite;
}
/**
@@ -168,83 +250,116 @@ export function nameTag(name, role = '') {
* {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}.
*/
export function createCrew(parent, workers) {
- const n = workers.length, group = new THREE.Group();
- parent.add(group);
- const instanced = (geometry, material, count = n) => {
- const mesh = new THREE.InstancedMesh(geometry, material, count);
- mesh.castShadow = true;
- mesh.frustumCulled = false;
- group.add(mesh);
- return mesh;
- };
- const white = new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8});
- const torso = instanced(new THREE.BoxGeometry(.42, .6, .26), white);
- const legs = instanced(new THREE.BoxGeometry(.15, .9, .17).translate(0, -.45, 0), white, n * 2);
- const arms = instanced(new THREE.BoxGeometry(.11, .62, .11).translate(0, -.31, 0), white, n * 2);
- const heads = instanced(new THREE.SphereGeometry(.12, 10, 8), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8}));
- const hats = instanced(new THREE.SphereGeometry(.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .4}));
- const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map(c => new THREE.Color(c));
- const color = new THREE.Color();
- workers.forEach((w, i) => {
- color.set(w.suit);
- torso.setColorAt(i, color);
- color.set(w.pants ?? '#2d3a4d');
- legs.setColorAt(i * 2, color);
- legs.setColorAt(i * 2 + 1, color);
- color.set(w.suit);
- arms.setColorAt(i * 2, color);
- arms.setColorAt(i * 2 + 1, color);
- heads.setColorAt(i, skins[i % skins.length]);
- color.set(w.hat ?? '#f2c94c');
- hats.setColorAt(i, color);
- w.phase = i * 1.37;
- w.from = {x: w.x, z: w.z};
- });
- const base = new THREE.Matrix4(), m = new THREE.Matrix4(), local = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0);
- const setLimb = (mesh, index, x, y, angle) => {
- local.makeRotationX(angle).setPosition(x, y, 0);
- mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
- };
+ const n = workers.length,
+ group = new THREE.Group();
+ parent.add(group);
+ const instanced = (geometry, material, count = n) => {
+ const mesh = new THREE.InstancedMesh(geometry, material, count);
+ mesh.castShadow = true;
+ mesh.frustumCulled = false;
+ group.add(mesh);
+ return mesh;
+ };
+ const white = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 });
+ const torso = instanced(new THREE.BoxGeometry(0.42, 0.6, 0.26), white);
+ const legs = instanced(
+ new THREE.BoxGeometry(0.15, 0.9, 0.17).translate(0, -0.45, 0),
+ white,
+ n * 2,
+ );
+ const arms = instanced(
+ new THREE.BoxGeometry(0.11, 0.62, 0.11).translate(0, -0.31, 0),
+ white,
+ n * 2,
+ );
+ const heads = instanced(
+ new THREE.SphereGeometry(0.12, 10, 8),
+ new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 }),
+ );
+ const hats = instanced(
+ new THREE.SphereGeometry(0.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2),
+ new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.4 }),
+ );
+ const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map((c) => new THREE.Color(c));
+ const color = new THREE.Color();
+ workers.forEach((w, i) => {
+ color.set(w.suit);
+ torso.setColorAt(i, color);
+ color.set(w.pants ?? '#2d3a4d');
+ legs.setColorAt(i * 2, color);
+ legs.setColorAt(i * 2 + 1, color);
+ color.set(w.suit);
+ arms.setColorAt(i * 2, color);
+ arms.setColorAt(i * 2 + 1, color);
+ heads.setColorAt(i, skins[i % skins.length]);
+ color.set(w.hat ?? '#f2c94c');
+ hats.setColorAt(i, color);
+ w.phase = i * 1.37;
+ w.from = { x: w.x, z: w.z };
+ });
+ const base = new THREE.Matrix4(),
+ m = new THREE.Matrix4(),
+ local = new THREE.Matrix4(),
+ q = new THREE.Quaternion(),
+ up = new THREE.Vector3(0, 1, 0);
+ const setLimb = (mesh, index, x, y, angle) => {
+ local.makeRotationX(angle).setPosition(x, y, 0);
+ mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
+ };
- function update(time) {
- workers.forEach((w, i) => {
- let x = w.x, z = w.z, facing = w.facing, walking = false;
- if (w.kind === 'walk' || w.kind === 'carry') {
- // Walk between two points, pausing at each end.
- const dx = w.to.x - w.from.x, dz = w.to.z - w.from.z, len = Math.hypot(dx, dz);
- const cycle = len / 1.2 * 2 + 6, c = (time + w.phase * 3) % cycle, leg = len / 1.2;
- let s;
- if (c < leg) s = c / leg, walking = true, facing = Math.atan2(dx, dz);
- else if (c < leg + 3) s = 1, facing = Math.atan2(dx, dz);
- else if (c < leg * 2 + 3) s = 1 - (c - leg - 3) / leg, walking = true, facing = Math.atan2(-dx, -dz);
- else s = 0, facing = Math.atan2(-dx, -dz);
- x = w.from.x + dx * s;
- z = w.from.z + dz * s;
- }
- const t = time + w.phase;
- base.compose(new THREE.Vector3(x, walking ? Math.abs(Math.sin(t * 6)) * .03 : 0, z), q.setFromAxisAngle(up, facing), new THREE.Vector3(1, 1, 1));
- m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
- torso.setMatrixAt(i, m);
- m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
- heads.setMatrixAt(i, m);
- m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
- hats.setMatrixAt(i, m);
- for (const side of [0, 1]) {
- const sign = side ? 1 : -1;
- const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * .45 : 0;
- setLimb(legs, i * 2 + side, sign * .1, .9, legSwing);
- let arm;
- if (w.kind === 'carry' && walking) arm = -1.1;
- else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * .4;
- // Working at a bench: both hands forward, one hammering or turning a tool.
- else if (w.kind === 'work') arm = -1.0 + (side ? Math.sin(t * 4) * .35 : Math.sin(t * 1.3) * .08);
- else arm = -.9;
- setLimb(arms, i * 2 + side, sign * .27, 1.46, arm);
- }
- });
- for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
- }
- update(0);
- for (const mesh of [torso, legs, arms, heads, hats]) if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
- return {group, update};
+ function update(time) {
+ workers.forEach((w, i) => {
+ let x = w.x,
+ z = w.z,
+ facing = w.facing,
+ walking = false;
+ if (w.kind === 'walk' || w.kind === 'carry') {
+ // Walk between two points, pausing at each end.
+ const dx = w.to.x - w.from.x,
+ dz = w.to.z - w.from.z,
+ len = Math.hypot(dx, dz);
+ const cycle = (len / 1.2) * 2 + 6,
+ c = (time + w.phase * 3) % cycle,
+ leg = len / 1.2;
+ let s;
+ if (c < leg) ((s = c / leg), (walking = true), (facing = Math.atan2(dx, dz)));
+ else if (c < leg + 3) ((s = 1), (facing = Math.atan2(dx, dz)));
+ else if (c < leg * 2 + 3)
+ ((s = 1 - (c - leg - 3) / leg), (walking = true), (facing = Math.atan2(-dx, -dz)));
+ else ((s = 0), (facing = Math.atan2(-dx, -dz)));
+ x = w.from.x + dx * s;
+ z = w.from.z + dz * s;
+ }
+ const t = time + w.phase;
+ base.compose(
+ new THREE.Vector3(x, walking ? Math.abs(Math.sin(t * 6)) * 0.03 : 0, z),
+ q.setFromAxisAngle(up, facing),
+ new THREE.Vector3(1, 1, 1),
+ );
+ m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
+ torso.setMatrixAt(i, m);
+ m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
+ heads.setMatrixAt(i, m);
+ m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
+ hats.setMatrixAt(i, m);
+ for (const side of [0, 1]) {
+ const sign = side ? 1 : -1;
+ const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * 0.45 : 0;
+ setLimb(legs, i * 2 + side, sign * 0.1, 0.9, legSwing);
+ let arm;
+ if (w.kind === 'carry' && walking) arm = -1.1;
+ else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * 0.4;
+ // Working at a bench: both hands forward, one hammering or turning a tool.
+ else if (w.kind === 'work')
+ arm = -1.0 + (side ? Math.sin(t * 4) * 0.35 : Math.sin(t * 1.3) * 0.08);
+ else arm = -0.9;
+ setLimb(arms, i * 2 + side, sign * 0.27, 1.46, arm);
+ }
+ });
+ for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
+ }
+ update(0);
+ for (const mesh of [torso, legs, arms, heads, hats])
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
+ return { group, update };
}
diff --git a/src/style.css b/src/style.css
index 099e527..9d73e86 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,1311 +1,1333 @@
:root {
- --hud: rgba(236, 242, 238, .92);
- --dim: rgba(200, 214, 207, .65);
- --panel: rgba(10, 16, 14, .62);
- --panel-solid: rgba(10, 16, 14, .9);
- --line: rgba(220, 235, 228, .2);
- --gold: #f2c94c;
- --teal: #3ee0c9;
- --red: #ff6b5e;
- --font: 'Vazirmatn', Tahoma, sans-serif;
- --safe-top: env(safe-area-inset-top, 0px);
+ --hud: rgba(236, 242, 238, 0.92);
+ --dim: rgba(200, 214, 207, 0.65);
+ --panel: rgba(10, 16, 14, 0.62);
+ --panel-solid: rgba(10, 16, 14, 0.9);
+ --line: rgba(220, 235, 228, 0.2);
+ --gold: #f2c94c;
+ --teal: #3ee0c9;
+ --red: #ff6b5e;
+ --font: 'Vazirmatn', Tahoma, sans-serif;
+ --safe-top: env(safe-area-inset-top, 0px);
}
* {
- box-sizing: border-box;
+ box-sizing: border-box;
}
-html, body {
- margin: 0;
- height: 100%;
- overflow: hidden;
- background: #0d1412;
- color: var(--hud);
- font-family: var(--font);
- -webkit-user-select: none;
- user-select: none;
+html,
+body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+ background: #0d1412;
+ color: var(--hud);
+ font-family: var(--font);
+ -webkit-user-select: none;
+ user-select: none;
}
button {
- font: inherit;
- cursor: pointer;
+ font: inherit;
+ cursor: pointer;
}
#world {
- position: fixed;
- inset: 0;
- width: 100%;
- height: 100%;
- display: block;
- touch-action: none;
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ touch-action: none;
}
#loading {
- position: fixed;
- inset: 0;
- display: grid;
- place-items: center;
- z-index: 50;
- background: #0d1412;
- color: var(--teal);
- font-size: 18px;
- letter-spacing: .5px;
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ z-index: 50;
+ background: #0d1412;
+ color: var(--teal);
+ font-size: 18px;
+ letter-spacing: 0.5px;
}
/* ---------- Briefing ---------- */
#briefing {
- position: fixed;
- inset: 0;
- z-index: 40;
- display: grid;
- grid-template-columns: 1.3fr 1fr;
- background: radial-gradient(circle at 30% 40%, #16231f 0%, #070b0a 75%);
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ grid-template-columns: 1.3fr 1fr;
+ background: radial-gradient(circle at 30% 40%, #16231f 0%, #070b0a 75%);
}
#briefing[hidden] {
- display: none;
+ display: none;
}
.brief-map {
- position: relative;
+ position: relative;
}
.brief-map::after {
- content: '';
- position: absolute;
- inset: 0;
- pointer-events: none;
- background: repeating-linear-gradient(0deg, rgba(255, 255, 255, .025) 0 1px, transparent 1px 4px);
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ 0deg,
+ rgba(255, 255, 255, 0.025) 0 1px,
+ transparent 1px 4px
+ );
}
#iran {
- width: 100%;
- height: 100%;
- display: block;
+ width: 100%;
+ height: 100%;
+ display: block;
}
.brief-panel {
- align-self: center;
- margin: 24px;
- padding: 28px;
- max-width: 520px;
- background: var(--panel);
- border: 1px solid var(--line);
- border-inline-start: 3px solid var(--gold);
+ align-self: center;
+ margin: 24px;
+ padding: 28px;
+ max-width: 520px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-inline-start: 3px solid var(--gold);
}
.tag {
- color: var(--gold);
- letter-spacing: 1px;
- font-weight: 600;
+ color: var(--gold);
+ letter-spacing: 1px;
+ font-weight: 600;
}
.brief-panel h1 {
- margin: 6px 0 0;
- font-size: clamp(32px, 5vw, 52px);
- font-weight: 800;
+ margin: 6px 0 0;
+ font-size: clamp(32px, 5vw, 52px);
+ font-weight: 800;
}
.sub {
- margin: 0 0 18px;
- color: var(--teal);
+ margin: 0 0 18px;
+ color: var(--teal);
}
#brief-text {
- min-height: 190px;
- margin: 0 0 20px;
- white-space: pre-wrap;
- font-family: var(--font);
- font-size: 15px;
- line-height: 1.9;
- color: var(--hud);
+ min-height: 190px;
+ margin: 0 0 20px;
+ white-space: pre-wrap;
+ font-family: var(--font);
+ font-size: 15px;
+ line-height: 1.9;
+ color: var(--hud);
}
#start-mission {
- width: 100%;
- padding: 14px;
- border: 0;
- background: var(--gold);
- color: #1b1406;
- font-size: 18px;
- font-weight: 800;
- letter-spacing: .5px;
+ width: 100%;
+ padding: 14px;
+ border: 0;
+ background: var(--gold);
+ color: #1b1406;
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: 0.5px;
}
#start-mission:hover {
- filter: brightness(1.08);
+ filter: brightness(1.08);
}
.keys {
- color: var(--dim);
- font-size: 12px;
- line-height: 1.8;
- margin-bottom: 0;
+ color: var(--dim);
+ font-size: 12px;
+ line-height: 1.8;
+ margin-bottom: 0;
}
/* ---------- HUD ---------- */
#hud[hidden] {
- display: none;
+ display: none;
}
#hud > * {
- position: fixed;
- z-index: 10;
+ position: fixed;
+ z-index: 10;
}
#compass {
- top: 14px;
- left: 50%;
- transform: translateX(-50%);
- width: min(560px, 80vw);
- height: 44px;
- pointer-events: none;
+ top: 14px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(560px, 80vw);
+ height: 44px;
+ pointer-events: none;
}
#minimap-button {
- top: 18px;
- left: 18px;
- width: 220px;
- height: 220px;
- padding: 0;
- border: 0;
- border-radius: 50%;
- background: transparent;
+ top: 18px;
+ left: 18px;
+ width: 220px;
+ height: 220px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
}
#minimap {
- width: 220px;
- height: 220px;
- border-radius: 50%;
- box-shadow: 0 0 0 4px rgba(0, 0, 0, .35);
+ width: 220px;
+ height: 220px;
+ border-radius: 50%;
+ box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.35);
}
.map-hint {
- position: absolute;
- bottom: -4px;
- left: 50%;
- transform: translateX(-50%);
- padding: 2px 10px;
- background: var(--panel-solid);
- color: var(--dim);
- font-size: 11px;
- border: 1px solid var(--line);
+ position: absolute;
+ bottom: -4px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 2px 10px;
+ background: var(--panel-solid);
+ color: var(--dim);
+ font-size: 11px;
+ border: 1px solid var(--line);
}
#location {
- top: 252px;
- left: 18px;
- width: 220px;
- text-align: center;
- text-shadow: 0 1px 3px #000;
+ top: 252px;
+ left: 18px;
+ width: 220px;
+ text-align: center;
+ text-shadow: 0 1px 3px #000;
}
#street {
- display: block;
- font-weight: 600;
- font-size: 14px;
+ display: block;
+ font-weight: 600;
+ font-size: 14px;
}
#mode-label {
- color: var(--teal);
+ color: var(--teal);
}
#objectives {
- top: 18px;
- right: 18px;
- width: min(330px, 40vw);
- padding: 12px 14px;
- background: linear-gradient(270deg, var(--panel) 60%, transparent);
- border-inline-start: 3px solid var(--gold);
+ top: 18px;
+ right: 18px;
+ width: min(330px, 40vw);
+ padding: 12px 14px;
+ background: linear-gradient(270deg, var(--panel) 60%, transparent);
+ border-inline-start: 3px solid var(--gold);
}
#objectives h2 {
- margin: 0 0 6px;
- font-size: 13px;
- color: var(--gold);
- letter-spacing: 1px;
+ margin: 0 0 6px;
+ font-size: 13px;
+ color: var(--gold);
+ letter-spacing: 1px;
}
#mission-list {
- margin: 0;
- padding: 0;
- list-style: none;
- font-size: 13px;
- line-height: 1.7;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: 13px;
+ line-height: 1.7;
}
#mission-list li {
- color: var(--dim);
+ color: var(--dim);
}
#mission-list li.active {
- color: var(--hud);
- font-weight: 600;
+ color: var(--hud);
+ font-weight: 600;
}
#mission-list li.active::before {
- content: '◆ ';
- color: var(--gold);
+ content: '◆ ';
+ color: var(--gold);
}
#mission-list li.done {
- text-decoration: line-through;
- opacity: .55;
+ text-decoration: line-through;
+ opacity: 0.55;
}
#mission-list li.done::before {
- content: '✓ ';
- color: var(--teal);
+ content: '✓ ';
+ color: var(--teal);
}
#mission-list li.hidden {
- display: none;
+ display: none;
}
#waypoint {
- transform: translate(-50%, -100%);
- display: grid;
- justify-items: center;
- gap: 2px;
- pointer-events: none;
- text-shadow: 0 1px 3px #000;
- white-space: nowrap;
+ transform: translate(-50%, -100%);
+ display: grid;
+ justify-items: center;
+ gap: 2px;
+ pointer-events: none;
+ text-shadow: 0 1px 3px #000;
+ white-space: nowrap;
}
#waypoint[hidden] {
- display: none;
+ display: none;
}
#waypoint i {
- width: 16px;
- height: 16px;
- background: var(--gold);
- transform: rotate(45deg);
- box-shadow: 0 0 10px rgba(242, 201, 76, .8);
+ width: 16px;
+ height: 16px;
+ background: var(--gold);
+ transform: rotate(45deg);
+ box-shadow: 0 0 10px rgba(242, 201, 76, 0.8);
}
#waypoint b {
- font-size: 12px;
+ font-size: 12px;
}
#waypoint span {
- font-size: 12px;
- color: var(--gold);
+ font-size: 12px;
+ color: var(--gold);
}
#crosshair {
- top: 50%;
- left: 50%;
- width: 4px;
- height: 4px;
- margin: -2px;
- border-radius: 50%;
- background: rgba(255, 255, 255, .7);
- pointer-events: none;
+ top: 50%;
+ left: 50%;
+ width: 4px;
+ height: 4px;
+ margin: -2px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.7);
+ pointer-events: none;
}
#prompt {
- bottom: 28%;
- left: 50%;
- transform: translateX(-50%);
- padding: 8px 18px;
- background: var(--panel-solid);
- border: 1px solid var(--line);
- font-weight: 600;
- white-space: nowrap;
+ bottom: 28%;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 8px 18px;
+ background: var(--panel-solid);
+ border: 1px solid var(--line);
+ font-weight: 600;
+ white-space: nowrap;
}
#prompt kbd {
- display: inline-block;
- min-width: 26px;
- margin-inline-end: 8px;
- padding: 1px 6px;
- text-align: center;
- background: var(--hud);
- color: #111;
- border-radius: 3px;
- font-family: var(--font);
- font-weight: 800;
+ display: inline-block;
+ min-width: 26px;
+ margin-inline-end: 8px;
+ padding: 1px 6px;
+ text-align: center;
+ background: var(--hud);
+ color: #111;
+ border-radius: 3px;
+ font-family: var(--font);
+ font-weight: 800;
}
#subtitle {
- bottom: 12%;
- left: 50%;
- transform: translateX(-50%);
- max-width: min(720px, 90vw);
- padding: 8px 16px;
- background: rgba(0, 0, 0, .55);
- text-align: center;
- font-size: 16px;
- line-height: 1.7;
+ bottom: 12%;
+ left: 50%;
+ transform: translateX(-50%);
+ max-width: min(720px, 90vw);
+ padding: 8px 16px;
+ background: rgba(0, 0, 0, 0.55);
+ text-align: center;
+ font-size: 16px;
+ line-height: 1.7;
}
#subtitle b {
- margin-inline-end: 8px;
+ margin-inline-end: 8px;
}
#subtitle {
- display: flex;
- align-items: center;
- gap: 4px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
}
#subtitle[hidden] {
- display: none;
+ display: none;
}
#subtitle-face {
- width: 52px;
- height: 52px;
- object-fit: cover;
- border-radius: 50%;
- margin-inline-end: 10px;
- background: #f7fcfc;
- border: 2px solid var(--teal);
+ width: 52px;
+ height: 52px;
+ object-fit: cover;
+ border-radius: 50%;
+ margin-inline-end: 10px;
+ background: #f7fcfc;
+ border: 2px solid var(--teal);
}
#subtitle-face[hidden] {
- display: none;
+ display: none;
}
#subtitle b.robot {
- color: var(--teal);
+ color: var(--teal);
}
#subtitle b.father {
- color: var(--gold);
+ color: var(--gold);
}
#subtitle b.child {
- color: #ff9f7a;
+ color: #ff9f7a;
}
#subtitle b.staff {
- color: #9fc8ff;
+ color: #9fc8ff;
}
#awards {
- top: 36%;
- left: 50%;
- transform: translateX(-50%);
- display: grid;
- justify-items: center;
- gap: 2px;
- pointer-events: none;
- text-shadow: 0 2px 4px #000;
+ top: 36%;
+ left: 50%;
+ transform: translateX(-50%);
+ display: grid;
+ justify-items: center;
+ gap: 2px;
+ pointer-events: none;
+ text-shadow: 0 2px 4px #000;
}
#awards .total {
- font-size: 34px;
- font-weight: 800;
- color: var(--gold);
- animation: pop .35s ease-out;
+ font-size: 34px;
+ font-weight: 800;
+ color: var(--gold);
+ animation: pop 0.35s ease-out;
}
#awards .line {
- font-size: 14px;
- font-weight: 600;
- animation: slide .4s ease-out backwards;
+ font-size: 14px;
+ font-weight: 600;
+ animation: slide 0.4s ease-out backwards;
}
#awards .line b {
- color: var(--gold);
- margin-inline-start: 6px;
+ color: var(--gold);
+ margin-inline-start: 6px;
}
@keyframes pop {
- from {
- transform: scale(1.6);
- opacity: 0;
- }
+ from {
+ transform: scale(1.6);
+ opacity: 0;
+ }
}
@keyframes slide {
- from {
- transform: translateY(8px);
- opacity: 0;
- }
+ from {
+ transform: translateY(8px);
+ opacity: 0;
+ }
}
#banner {
- top: 22%;
- left: 0;
- right: 0;
- padding: 14px;
- text-align: center;
- pointer-events: none;
- background: linear-gradient(90deg, transparent, rgba(10, 16, 14, .82) 25%, rgba(10, 16, 14, .82) 75%, transparent);
- animation: pop .4s ease-out;
+ top: 22%;
+ left: 0;
+ right: 0;
+ padding: 14px;
+ text-align: center;
+ pointer-events: none;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(10, 16, 14, 0.82) 25%,
+ rgba(10, 16, 14, 0.82) 75%,
+ transparent
+ );
+ animation: pop 0.4s ease-out;
}
#banner small {
- display: block;
- color: var(--gold);
- letter-spacing: 2px;
- font-weight: 600;
+ display: block;
+ color: var(--gold);
+ letter-spacing: 2px;
+ font-weight: 600;
}
#banner strong {
- font-size: 22px;
+ font-size: 22px;
}
#banner span {
- display: block;
- color: var(--teal);
- font-size: 14px;
+ display: block;
+ color: var(--teal);
+ font-size: 14px;
}
#status {
- bottom: 18px;
- right: 18px;
- display: flex;
- gap: 10px;
+ bottom: 18px;
+ right: 18px;
+ display: flex;
+ gap: 10px;
}
.stat {
- min-width: 96px;
- padding: 6px 12px;
- background: var(--panel);
- border-bottom: 2px solid var(--teal);
- display: grid;
- line-height: 1.3;
+ min-width: 96px;
+ padding: 6px 12px;
+ background: var(--panel);
+ border-bottom: 2px solid var(--teal);
+ display: grid;
+ line-height: 1.3;
}
.stat small {
- color: var(--dim);
- font-size: 11px;
+ color: var(--dim);
+ font-size: 11px;
}
.stat strong {
- font-size: 22px;
- font-weight: 800;
+ font-size: 22px;
+ font-weight: 800;
}
.stat span {
- font-size: 11px;
- color: var(--dim);
+ font-size: 11px;
+ color: var(--dim);
}
.stat[hidden] {
- display: none;
+ display: none;
}
#rank {
- bottom: 18px;
- left: 50%;
- transform: translateX(-50%);
- width: min(380px, 50vw);
- padding: 4px 10px;
- text-align: center;
- font-size: 12px;
- background: var(--panel);
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(380px, 50vw);
+ padding: 4px 10px;
+ text-align: center;
+ font-size: 12px;
+ background: var(--panel);
}
#rank b {
- color: var(--gold);
- margin-inline-end: 8px;
+ color: var(--gold);
+ margin-inline-end: 8px;
}
#rank span {
- color: var(--dim);
+ color: var(--dim);
}
#rank .bar {
- height: 4px;
- margin-top: 4px;
- background: rgba(255, 255, 255, .15);
+ height: 4px;
+ margin-top: 4px;
+ background: rgba(255, 255, 255, 0.15);
}
#rank .bar i {
- display: block;
- height: 100%;
- width: 0;
- background: var(--gold);
- transition: width .6s;
+ display: block;
+ height: 100%;
+ width: 0;
+ background: var(--gold);
+ transition: width 0.6s;
}
#toolbar {
- bottom: 18px;
- left: 18px;
- display: flex;
- gap: 6px;
+ bottom: 18px;
+ left: 18px;
+ display: flex;
+ gap: 6px;
}
-#toolbar button, .ghost {
- padding: 6px 12px;
- background: var(--panel);
- color: var(--hud);
- border: 1px solid var(--line);
- font-size: 12px;
+#toolbar button,
+.ghost {
+ padding: 6px 12px;
+ background: var(--panel);
+ color: var(--hud);
+ border: 1px solid var(--line);
+ font-size: 12px;
}
#attribution {
- bottom: 2px;
- left: 50%;
- transform: translateX(-50%);
- font-size: 10px;
- color: var(--dim);
+ bottom: 2px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 10px;
+ color: var(--dim);
}
/* ---------- Payment ---------- */
#payment {
- position: fixed;
- z-index: 20;
- bottom: 18%;
- right: 50%;
- transform: translateX(50%);
- width: min(380px, 92vw);
- padding: 16px 18px;
- background: var(--panel-solid);
- border: 1px solid var(--line);
- border-top: 3px solid var(--teal);
+ position: fixed;
+ z-index: 20;
+ bottom: 18%;
+ right: 50%;
+ transform: translateX(50%);
+ width: min(380px, 92vw);
+ padding: 16px 18px;
+ background: var(--panel-solid);
+ border: 1px solid var(--line);
+ border-top: 3px solid var(--teal);
}
#payment[hidden] {
- display: none;
+ display: none;
}
#payment small {
- color: var(--teal);
+ color: var(--teal);
}
#payment h2 {
- margin: 4px 0;
- font-size: 18px;
+ margin: 4px 0;
+ font-size: 18px;
}
#payment p {
- margin: 0 0 12px;
- color: var(--dim);
+ margin: 0 0 12px;
+ color: var(--dim);
}
.actions {
- display: flex;
- gap: 8px;
+ display: flex;
+ gap: 8px;
}
-#swipe, #again, #route-button {
- flex: 1;
- padding: 10px;
- border: 0;
- background: var(--teal);
- color: #04201b;
- font-weight: 800;
+#swipe,
+#again,
+#route-button {
+ flex: 1;
+ padding: 10px;
+ border: 0;
+ background: var(--teal);
+ color: #04201b;
+ font-weight: 800;
}
#swipe:disabled {
- opacity: .45;
- cursor: wait;
+ opacity: 0.45;
+ cursor: wait;
}
/* ---------- Dialogs ---------- */
dialog {
- color: var(--hud);
- background: var(--panel-solid);
- border: 1px solid var(--line);
- padding: 0;
- font-family: var(--font);
- backdrop-filter: blur(6px);
+ color: var(--hud);
+ background: var(--panel-solid);
+ border: 1px solid var(--line);
+ padding: 0;
+ font-family: var(--font);
+ backdrop-filter: blur(6px);
}
dialog::backdrop {
- background: rgba(0, 0, 0, .45);
+ background: rgba(0, 0, 0, 0.45);
}
dialog header {
- display: flex;
- justify-content: space-between;
- align-items: start;
- gap: 12px;
- padding: 14px 18px;
- border-bottom: 1px solid var(--line);
+ display: flex;
+ justify-content: space-between;
+ align-items: start;
+ gap: 12px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--line);
}
.close {
- width: 34px;
- height: 34px;
- background: transparent;
- color: var(--hud);
- border: 1px solid var(--line);
+ width: 34px;
+ height: 34px;
+ background: transparent;
+ color: var(--hud);
+ border: 1px solid var(--line);
}
#map-dialog {
- width: min(1180px, 96vw);
- height: min(760px, 92vh);
+ width: min(1180px, 96vw);
+ height: min(760px, 92vh);
}
#map-dialog[open] {
- display: flex;
- flex-direction: column;
+ display: flex;
+ flex-direction: column;
}
.tabs {
- display: flex;
- gap: 4px;
+ display: flex;
+ gap: 4px;
}
.tabs button {
- padding: 6px 14px;
- background: transparent;
- color: var(--dim);
- border: 1px solid var(--line);
+ padding: 6px 14px;
+ background: transparent;
+ color: var(--dim);
+ border: 1px solid var(--line);
}
.tabs button[aria-selected='true'] {
- color: #1b1406;
- background: var(--gold);
- border-color: var(--gold);
- font-weight: 600;
+ color: #1b1406;
+ background: var(--gold);
+ border-color: var(--gold);
+ font-weight: 600;
}
.map-body {
- flex: 1;
- display: grid;
- grid-template-columns: 1fr 320px;
- min-height: 0;
+ flex: 1;
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ min-height: 0;
}
.map-view {
- position: relative;
- min-height: 0;
+ position: relative;
+ min-height: 0;
}
-#fullmap, #iran-map {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- cursor: grab;
- touch-action: none;
+#fullmap,
+#iran-map {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+ touch-action: none;
}
-#fullmap[hidden], #iran-map[hidden] {
- display: none;
+#fullmap[hidden],
+#iran-map[hidden] {
+ display: none;
}
.map-help {
- position: absolute;
- bottom: 6px;
- right: 10px;
- margin: 0;
- font-size: 11px;
- color: var(--dim);
- pointer-events: none;
+ position: absolute;
+ bottom: 6px;
+ right: 10px;
+ margin: 0;
+ font-size: 11px;
+ color: var(--dim);
+ pointer-events: none;
}
.map-side {
- overflow: auto;
- padding: 14px;
- border-inline-start: 1px solid var(--line);
+ overflow: auto;
+ padding: 14px;
+ border-inline-start: 1px solid var(--line);
}
.map-side h3 {
- margin: 0 0 10px;
- font-size: 14px;
- color: var(--gold);
+ margin: 0 0 10px;
+ font-size: 14px;
+ color: var(--gold);
}
#machine-list {
- list-style: none;
- margin: 0 0 12px;
- padding: 0;
- display: grid;
- gap: 6px;
+ list-style: none;
+ margin: 0 0 12px;
+ padding: 0;
+ display: grid;
+ gap: 6px;
}
#machine-list button {
- width: 100%;
- display: grid;
- grid-template-columns: 28px 1fr auto;
- align-items: center;
- gap: 8px;
- text-align: right;
- padding: 8px;
- background: rgba(255, 255, 255, .04);
- color: var(--hud);
- border: 1px solid transparent;
+ width: 100%;
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+ gap: 8px;
+ text-align: right;
+ padding: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--hud);
+ border: 1px solid transparent;
}
#machine-list button.selected {
- border-color: var(--gold);
+ border-color: var(--gold);
}
#machine-list button.locked {
- opacity: .45;
- cursor: default;
+ opacity: 0.45;
+ cursor: default;
}
#machine-list .meta {
- display: block;
- color: var(--dim);
- font-size: 11px;
+ display: block;
+ color: var(--dim);
+ font-size: 11px;
}
#machine-list .dist {
- color: var(--gold);
- font-size: 12px;
- white-space: nowrap;
+ color: var(--gold);
+ font-size: 12px;
+ white-space: nowrap;
}
#machine-detail {
- margin-bottom: 12px;
- padding: 12px;
- background: rgba(255, 255, 255, .05);
- border-top: 2px solid var(--gold);
+ margin-bottom: 12px;
+ padding: 12px;
+ background: rgba(255, 255, 255, 0.05);
+ border-top: 2px solid var(--gold);
}
#machine-detail h4 {
- margin: 0 0 6px;
+ margin: 0 0 6px;
}
#machine-detail p {
- margin: 0 0 8px;
- font-size: 13px;
- color: var(--dim);
- line-height: 1.7;
+ margin: 0 0 8px;
+ font-size: 13px;
+ color: var(--dim);
+ line-height: 1.7;
}
#route-button {
- width: 100%;
+ width: 100%;
}
#shop {
- width: min(620px, 94vw);
+ width: min(620px, 94vw);
}
#shop small {
- color: var(--teal);
+ color: var(--teal);
}
#shop h2 {
- margin: 2px 0;
+ margin: 2px 0;
}
#shop header p {
- margin: 0;
- color: var(--dim);
- font-size: 13px;
+ margin: 0;
+ color: var(--dim);
+ font-size: 13px;
}
#shop-note {
- margin: 10px 18px 0;
- color: var(--gold);
- font-size: 13px;
+ margin: 10px 18px 0;
+ color: var(--gold);
+ font-size: 13px;
}
#products {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 8px;
- padding: 14px 18px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 8px;
+ padding: 14px 18px;
}
#products button {
- display: grid;
- gap: 2px;
- justify-items: center;
- padding: 12px 8px;
- color: var(--hud);
- background: rgba(255, 255, 255, .05);
- border: 1px solid var(--line);
+ display: grid;
+ gap: 2px;
+ justify-items: center;
+ padding: 12px 8px;
+ color: var(--hud);
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--line);
}
#products button:hover:not(:disabled) {
- border-color: var(--teal);
- background: rgba(62, 224, 201, .1);
+ border-color: var(--teal);
+ background: rgba(62, 224, 201, 0.1);
}
#products button:disabled {
- opacity: .4;
- cursor: not-allowed;
+ opacity: 0.4;
+ cursor: not-allowed;
}
#products .icon {
- font-size: 30px;
+ font-size: 30px;
}
#products .price {
- color: var(--gold);
- font-weight: 600;
+ color: var(--gold);
+ font-weight: 600;
}
#products s {
- color: var(--dim);
- font-size: 11px;
+ color: var(--dim);
+ font-size: 11px;
}
.rate {
- margin: 0;
- padding: 0 18px 14px;
- font-size: 11px;
- color: var(--dim);
+ margin: 0;
+ padding: 0 18px 14px;
+ font-size: 11px;
+ color: var(--dim);
}
#end {
- width: min(480px, 92vw);
- padding: 24px;
- text-align: center;
- border-top: 3px solid var(--gold);
+ width: min(480px, 92vw);
+ padding: 24px;
+ text-align: center;
+ border-top: 3px solid var(--gold);
}
#end small {
- color: var(--gold);
- letter-spacing: 1px;
+ color: var(--gold);
+ letter-spacing: 1px;
}
#end h2 {
- margin: 6px 0;
- font-size: 28px;
+ margin: 6px 0;
+ font-size: 28px;
}
#end-stats {
- list-style: none;
- padding: 0;
- margin: 14px 0;
- display: grid;
- gap: 4px;
+ list-style: none;
+ padding: 0;
+ margin: 14px 0;
+ display: grid;
+ gap: 4px;
}
#end-stats li {
- display: flex;
- justify-content: space-between;
- padding: 6px 10px;
- background: rgba(255, 255, 255, .05);
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 10px;
+ background: rgba(255, 255, 255, 0.05);
}
/* ---------- Touch ---------- */
#touch[hidden] {
- display: none;
+ display: none;
}
#touch {
- inset: auto 0 90px 0;
- display: flex;
- justify-content: space-between;
- padding: 0 24px;
- pointer-events: none;
+ inset: auto 0 90px 0;
+ display: flex;
+ justify-content: space-between;
+ padding: 0 24px;
+ pointer-events: none;
}
#stick {
- width: 130px;
- height: 130px;
- border-radius: 50%;
- border: 2px solid var(--line);
- background: rgba(0, 0, 0, .25);
- position: relative;
- pointer-events: auto;
- touch-action: none;
- order: 2;
+ width: 130px;
+ height: 130px;
+ border-radius: 50%;
+ border: 2px solid var(--line);
+ background: rgba(0, 0, 0, 0.25);
+ position: relative;
+ pointer-events: auto;
+ touch-action: none;
+ order: 2;
}
#stick i {
- position: absolute;
- left: 40px;
- top: 40px;
- width: 50px;
- height: 50px;
- border-radius: 50%;
- background: rgba(255, 255, 255, .5);
+ position: absolute;
+ left: 40px;
+ top: 40px;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.5);
}
.touch-buttons {
- display: grid;
- gap: 10px;
- pointer-events: auto;
- align-content: end;
+ display: grid;
+ gap: 10px;
+ pointer-events: auto;
+ align-content: end;
}
.touch-buttons button {
- width: 64px;
- height: 64px;
- border-radius: 50%;
- background: var(--panel);
- color: var(--hud);
- border: 1px solid var(--line);
- font-weight: 800;
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: var(--panel);
+ color: var(--hud);
+ border: 1px solid var(--line);
+ font-weight: 800;
}
.touch-buttons button.on {
- background: var(--teal);
- color: #04201b;
+ background: var(--teal);
+ color: #04201b;
}
@media (max-width: 760px) {
- #briefing {
- grid-template-columns: 1fr;
- grid-template-rows: 38vh 1fr;
- }
+ #briefing {
+ grid-template-columns: 1fr;
+ grid-template-rows: 38vh 1fr;
+ }
- .brief-panel {
- margin: 0 16px 16px;
- padding: 18px;
- align-self: start;
- }
+ .brief-panel {
+ margin: 0 16px 16px;
+ padding: 18px;
+ align-self: start;
+ }
- #brief-text {
- min-height: 150px;
- font-size: 13px;
- }
+ #brief-text {
+ min-height: 150px;
+ font-size: 13px;
+ }
- #minimap-button, #minimap {
- width: 130px;
- height: 130px;
- }
+ #minimap-button,
+ #minimap {
+ width: 130px;
+ height: 130px;
+ }
- #minimap-button {
- top: 62px;
- left: 12px;
- }
+ #minimap-button {
+ top: 62px;
+ left: 12px;
+ }
- #location {
- top: 198px;
- left: 12px;
- width: 130px;
- }
+ #location {
+ top: 198px;
+ left: 12px;
+ width: 130px;
+ }
- #objectives {
- top: 62px;
- right: 12px;
- width: 52vw;
- padding: 8px;
- }
+ #objectives {
+ top: 62px;
+ right: 12px;
+ width: 52vw;
+ padding: 8px;
+ }
- #mission-list li:not(.active) {
- display: none;
- }
+ #mission-list li:not(.active) {
+ display: none;
+ }
- #status {
- right: 12px;
- bottom: 12px;
- }
+ #status {
+ right: 12px;
+ bottom: 12px;
+ }
- .stat {
- min-width: 70px;
- padding: 4px 8px;
- }
+ .stat {
+ min-width: 70px;
+ padding: 4px 8px;
+ }
- .stat strong {
- font-size: 16px;
- }
+ .stat strong {
+ font-size: 16px;
+ }
- #rank {
- bottom: auto;
- top: 6px;
- width: 70vw;
- }
+ #rank {
+ bottom: auto;
+ top: 6px;
+ width: 70vw;
+ }
- #compass {
- top: 30px;
- }
+ #compass {
+ top: 30px;
+ }
- #toolbar {
- left: 12px;
- bottom: 12px;
- }
+ #toolbar {
+ left: 12px;
+ bottom: 12px;
+ }
- #toolbar button {
- padding: 4px 8px;
- font-size: 11px;
- }
+ #toolbar button {
+ padding: 4px 8px;
+ font-size: 11px;
+ }
- #crosshair {
- display: none;
- }
+ #crosshair {
+ display: none;
+ }
- .map-body {
- grid-template-columns: 1fr;
- grid-template-rows: 1fr 40%;
- }
+ .map-body {
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr 40%;
+ }
- .map-side {
- border-inline-start: 0;
- border-top: 1px solid var(--line);
- }
+ .map-side {
+ border-inline-start: 0;
+ border-top: 1px solid var(--line);
+ }
}
/* ---------- PWA: install and update ---------- */
.brief-robot {
- float: left;
- width: 110px;
- height: auto;
- margin: -8px 0 0 8px;
- filter: drop-shadow(0 8px 18px rgba(62, 224, 201, .25));
+ float: left;
+ width: 110px;
+ height: auto;
+ margin: -8px 0 0 8px;
+ filter: drop-shadow(0 8px 18px rgba(62, 224, 201, 0.25));
}
.brief-actions {
- display: flex;
- gap: 8px;
- margin-top: 8px;
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
}
.brief-actions button {
- flex: 1;
- padding: 10px;
- font-size: 13px;
+ flex: 1;
+ padding: 10px;
+ font-size: 13px;
}
.brief-actions button[hidden] {
- display: none;
+ display: none;
}
#ios-install {
- width: min(420px, 92vw);
- padding: 22px;
- border-top: 3px solid var(--teal);
+ width: min(420px, 92vw);
+ padding: 22px;
+ border-top: 3px solid var(--teal);
}
#ios-install h2 {
- margin: 0 0 8px;
+ margin: 0 0 8px;
}
#ios-install ol {
- line-height: 2;
- padding-inline-start: 20px;
+ line-height: 2;
+ padding-inline-start: 20px;
}
#ios-install button {
- width: 100%;
- padding: 10px;
- border: 0;
- background: var(--teal);
- color: #04201b;
- font-weight: 800;
+ width: 100%;
+ padding: 10px;
+ border: 0;
+ background: var(--teal);
+ color: #04201b;
+ font-weight: 800;
}
#update-toast {
- position: fixed;
- z-index: 60;
- top: calc(12px + var(--safe-top));
- left: 50%;
- transform: translateX(-50%);
- padding: 8px 10px 8px 14px;
- background: var(--panel-solid);
- border: 1px solid var(--teal);
- display: flex;
- gap: 10px;
- align-items: center;
+ position: fixed;
+ z-index: 60;
+ top: calc(12px + var(--safe-top));
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 8px 10px 8px 14px;
+ background: var(--panel-solid);
+ border: 1px solid var(--teal);
+ display: flex;
+ gap: 10px;
+ align-items: center;
}
#update-toast[hidden] {
- display: none;
+ display: none;
}
#update-toast button {
- padding: 4px 12px;
- border: 0;
- background: var(--teal);
- color: #04201b;
- font-weight: 800;
+ padding: 4px 12px;
+ border: 0;
+ background: var(--teal);
+ color: #04201b;
+ font-weight: 800;
}
@media (max-width: 760px) {
- .brief-robot {
- width: 70px;
- }
+ .brief-robot {
+ width: 70px;
+ }
}
/* ---------- Real-world mode ---------- */
#gps-status {
- display: block;
- margin-top: 2px;
- color: var(--gold);
- font-size: 12px;
+ display: block;
+ margin-top: 2px;
+ color: var(--gold);
+ font-size: 12px;
}
#gps-status[hidden] {
- display: none;
+ display: none;
}
#gps-toggle.on {
- background: var(--teal);
- color: #04201b;
- border-color: var(--teal);
+ background: var(--teal);
+ color: #04201b;
+ border-color: var(--teal);
}
#real-intro {
- width: min(460px, 92vw);
- padding: 22px;
- border-top: 3px solid var(--gold);
+ width: min(460px, 92vw);
+ padding: 22px;
+ border-top: 3px solid var(--gold);
}
#real-intro img {
- float: left;
- border-radius: 50%;
- background: #f7fcfc;
+ float: left;
+ border-radius: 50%;
+ background: #f7fcfc;
}
#real-intro h2 {
- margin: 0 0 8px;
+ margin: 0 0 8px;
}
-#real-intro p, #real-intro li {
- line-height: 1.9;
- font-size: 14px;
+#real-intro p,
+#real-intro li {
+ line-height: 1.9;
+ font-size: 14px;
}
#real-intro ul {
- padding-inline-start: 18px;
- color: var(--dim);
+ padding-inline-start: 18px;
+ color: var(--dim);
}
#real-go {
- flex: 1;
- padding: 10px;
- border: 0;
- background: var(--gold);
- color: #1b1406;
- font-weight: 800;
+ flex: 1;
+ padding: 10px;
+ border: 0;
+ background: var(--gold);
+ color: #1b1406;
+ font-weight: 800;
}
/* ---------- Camera AR view ---------- */
#camera {
- position: fixed;
- inset: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
- z-index: 0;
- background: #000;
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ z-index: 0;
+ background: #000;
}
#camera[hidden] {
- display: none;
+ display: none;
}
#world {
- z-index: 1;
+ z-index: 1;
}
body.ar {
- background: #000;
+ background: #000;
}
body.ar #crosshair {
- display: none;
+ display: none;
}
-#ar-toggle[hidden], #stable-toggle[hidden] {
- display: none;
+#ar-toggle[hidden],
+#stable-toggle[hidden] {
+ display: none;
}
-#ar-toggle.on, #stable-toggle.on {
- background: var(--teal);
- color: #04201b;
- border-color: var(--teal);
+#ar-toggle.on,
+#stable-toggle.on {
+ background: var(--teal);
+ color: #04201b;
+ border-color: var(--teal);
}
/* ---------- Real-world first person, and notifications in the middle on phones ---------- */
-body.real #crosshair, body.real #stick {
- display: none;
+body.real #crosshair,
+body.real #stick {
+ display: none;
}
#prompt {
- cursor: pointer;
+ cursor: pointer;
}
@media (max-width: 760px), (max-height: 520px) {
- #banner {
- top: 24%;
- padding: 10px;
- }
+ #banner {
+ top: 24%;
+ padding: 10px;
+ }
- #banner strong {
- font-size: 18px;
- }
+ #banner strong {
+ font-size: 18px;
+ }
- #awards {
- top: 36%;
- }
+ #awards {
+ top: 36%;
+ }
- #subtitle {
- top: 50%;
- bottom: auto;
- transform: translate(-50%, -50%);
- width: min(92vw, 520px);
- font-size: 15px;
- background: rgba(0, 0, 0, .7);
- border-radius: 10px;
- }
+ #subtitle {
+ top: 50%;
+ bottom: auto;
+ transform: translate(-50%, -50%);
+ width: min(92vw, 520px);
+ font-size: 15px;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 10px;
+ }
- #prompt {
- top: 64%;
- bottom: auto;
- font-size: 16px;
- padding: 12px 20px;
- border-radius: 10px;
- white-space: normal;
- text-align: center;
- max-width: 90vw;
- }
+ #prompt {
+ top: 64%;
+ bottom: auto;
+ font-size: 16px;
+ padding: 12px 20px;
+ border-radius: 10px;
+ white-space: normal;
+ text-align: center;
+ max-width: 90vw;
+ }
- #gps-status {
- position: fixed;
- top: 76px;
- left: 50%;
- transform: translateX(-50%);
- width: max-content;
- max-width: 92vw;
- padding: 4px 12px;
- background: var(--panel-solid);
- border: 1px solid var(--line);
- border-radius: 999px;
- text-align: center;
- }
+ #gps-status {
+ position: fixed;
+ top: 76px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: max-content;
+ max-width: 92vw;
+ padding: 4px 12px;
+ background: var(--panel-solid);
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ text-align: center;
+ }
- #payment {
- bottom: auto;
- top: 50%;
- transform: translate(50%, -50%);
- }
+ #payment {
+ bottom: auto;
+ top: 50%;
+ transform: translate(50%, -50%);
+ }
}
/* Phone layout: one scrollable toolbar row, stats above it, compact rank bar above the compass,
and no car or run buttons while the GPS moves the player. */
-body.real .touch-buttons [data-key='f'], body.real .touch-buttons [data-key='shift'] {
- display: none;
+body.real .touch-buttons [data-key='f'],
+body.real .touch-buttons [data-key='shift'] {
+ display: none;
}
@media (max-width: 760px) {
- #rank {
- top: 4px;
- padding: 2px 8px;
- font-size: 11px;
- }
+ #rank {
+ top: 4px;
+ padding: 2px 8px;
+ font-size: 11px;
+ }
- #rank .bar {
- height: 3px;
- margin-top: 2px;
- }
+ #rank .bar {
+ height: 3px;
+ margin-top: 2px;
+ }
- #compass {
- top: 36px;
- }
+ #compass {
+ top: 36px;
+ }
- /* Between the mission banner and the dialogue, clear of the minimap and mission list. */
- #gps-status {
- top: 42%;
- }
+ /* Between the mission banner and the dialogue, clear of the minimap and mission list. */
+ #gps-status {
+ top: 42%;
+ }
- #toolbar {
- left: 8px;
- right: 8px;
- bottom: 8px;
- overflow-x: auto;
- flex-wrap: nowrap;
- scrollbar-width: none;
- padding-bottom: env(safe-area-inset-bottom, 0px);
- }
+ #toolbar {
+ left: 8px;
+ right: 8px;
+ bottom: 8px;
+ overflow-x: auto;
+ flex-wrap: nowrap;
+ scrollbar-width: none;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ }
- #toolbar button {
- flex: 0 0 auto;
- white-space: nowrap;
- }
+ #toolbar button {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ }
- #status {
- bottom: 52px;
- }
+ #status {
+ bottom: 52px;
+ }
- #touch {
- bottom: 150px;
- }
+ #touch {
+ bottom: 150px;
+ }
}
diff --git a/src/textures.js b/src/textures.js
index 60f3542..a9a2644 100644
--- a/src/textures.js
+++ b/src/textures.js
@@ -2,16 +2,16 @@ import * as THREE from 'three';
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
export function textTexture(width, height, draw) {
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- const texture = new THREE.CanvasTexture(canvas);
- texture.colorSpace = THREE.SRGBColorSpace;
- const paint = () => {
- draw(canvas.getContext('2d'), width, height);
- texture.needsUpdate = true;
- };
- paint();
- document.fonts?.ready.then(paint);
- return texture;
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const paint = () => {
+ draw(canvas.getContext('2d'), width, height);
+ texture.needsUpdate = true;
+ };
+ paint();
+ document.fonts?.ready.then(paint);
+ return texture;
}
diff --git a/src/traffic.js b/src/traffic.js
index 7f600a3..8ae5a11 100644
--- a/src/traffic.js
+++ b/src/traffic.js
@@ -2,146 +2,213 @@ import * as THREE from 'three';
// Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long).
export function createCarModel(color = '#d9d9d4') {
- const group = new THREE.Group();
- const mat = (c, extra = {}) => new THREE.MeshStandardMaterial({color: c, roughness: .45, ...extra});
- const paint = mat(color, {metalness: .55, roughness: .35}), glass = mat('#1e2c33', {metalness: .6, roughness: .15});
- const dark = mat('#141618'), chrome = mat('#c8ccce', {metalness: .9, roughness: .2});
- const box = (w, h, d, m, x, y, z) => {
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
- mesh.position.set(x, y, z);
- mesh.castShadow = mesh.receiveShadow = true;
- group.add(mesh);
- return mesh;
- };
- box(4.4, .62, 1.78, paint, 0, .68, 0);
- box(4.42, .16, 1.8, dark, 0, .38, 0);
- const cabin = box(2.3, .62, 1.6, glass, -.25, 1.28, 0);
- cabin.scale.set(1, 1, .98);
- box(2.1, .08, 1.62, paint, -.25, 1.62, 0);
- box(.9, .06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -.12;
- for (const z of [-.62, .62]) {
- box(.06, .18, .34, mat('#fff4cc', {emissive: '#fff1b0', emissiveIntensity: .6}), 2.21, .78, z);
- box(.06, .16, .34, mat('#b3261e', {emissive: '#7a0f0b', emissiveIntensity: .5}), -2.21, .8, z);
- box(.18, .08, .1, chrome, .6, 1.02, z * 1.47);
+ const group = new THREE.Group();
+ const mat = (c, extra = {}) =>
+ new THREE.MeshStandardMaterial({ color: c, roughness: 0.45, ...extra });
+ const paint = mat(color, { metalness: 0.55, roughness: 0.35 }),
+ glass = mat('#1e2c33', { metalness: 0.6, roughness: 0.15 });
+ const dark = mat('#141618'),
+ chrome = mat('#c8ccce', { metalness: 0.9, roughness: 0.2 });
+ const box = (w, h, d, m, x, y, z) => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = mesh.receiveShadow = true;
+ group.add(mesh);
+ return mesh;
+ };
+ box(4.4, 0.62, 1.78, paint, 0, 0.68, 0);
+ box(4.42, 0.16, 1.8, dark, 0, 0.38, 0);
+ const cabin = box(2.3, 0.62, 1.6, glass, -0.25, 1.28, 0);
+ cabin.scale.set(1, 1, 0.98);
+ box(2.1, 0.08, 1.62, paint, -0.25, 1.62, 0);
+ box(0.9, 0.06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -0.12;
+ for (const z of [-0.62, 0.62]) {
+ box(
+ 0.06,
+ 0.18,
+ 0.34,
+ mat('#fff4cc', { emissive: '#fff1b0', emissiveIntensity: 0.6 }),
+ 2.21,
+ 0.78,
+ z,
+ );
+ box(
+ 0.06,
+ 0.16,
+ 0.34,
+ mat('#b3261e', { emissive: '#7a0f0b', emissiveIntensity: 0.5 }),
+ -2.21,
+ 0.8,
+ z,
+ );
+ box(0.18, 0.08, 0.1, chrome, 0.6, 1.02, z * 1.47);
+ }
+ box(0.04, 0.12, 1.0, chrome, 2.22, 0.6, 0);
+ const wheels = [];
+ for (const x of [-1.38, 1.38])
+ for (const z of [-0.86, 0.86]) {
+ const wheel = new THREE.Group();
+ wheel.position.set(x, 0.36, z);
+ const tyre = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.26, 18), dark);
+ tyre.rotation.x = Math.PI / 2;
+ tyre.castShadow = true;
+ const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.27, 10), chrome);
+ hub.rotation.x = Math.PI / 2;
+ wheel.add(tyre, hub);
+ group.add(wheel);
+ wheels.push(wheel);
}
- box(.04, .12, 1.0, chrome, 2.22, .6, 0);
- const wheels = [];
- for (const x of [-1.38, 1.38]) for (const z of [-.86, .86]) {
- const wheel = new THREE.Group();
- wheel.position.set(x, .36, z);
- const tyre = new THREE.Mesh(new THREE.CylinderGeometry(.36, .36, .26, 18), dark);
- tyre.rotation.x = Math.PI / 2;
- tyre.castShadow = true;
- const hub = new THREE.Mesh(new THREE.CylinderGeometry(.2, .2, .27, 10), chrome);
- hub.rotation.x = Math.PI / 2;
- wheel.add(tyre, hub);
- group.add(wheel);
- wheels.push(wheel);
- }
- return {group, wheels};
+ return { group, wheels };
}
-const COLORS = ['#e9e9e4', '#1d1f22', '#9aa3a8', '#d6c9a5', '#7e1f1d', '#2f4a6b', '#f2c94c', '#efefea'];
+const COLORS = [
+ '#e9e9e4',
+ '#1d1f22',
+ '#9aa3a8',
+ '#d6c9a5',
+ '#7e1f1d',
+ '#2f4a6b',
+ '#f2c94c',
+ '#efefea',
+];
// Ambient cars on the main roads. They stay within a few hundred metres of the player: a car that
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
export function createTraffic(scene, data, count = 28) {
- const oneway = new Set(data.oneway ?? []);
- const roads = data.roads.flatMap((r, index) => {
- if (r[0] > 3) return [];
- const pts = [];
- for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
- const lengths = [0];
- for (let i = 1; i < pts.length; i++) lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
- const xs = pts.map(p => p.x), zs = pts.map(p => p.y);
- return [{pts, lengths, total: lengths.at(-1), width: WIDTH[r[0]], oneway: oneway.has(index),
- box: {x0: Math.min(...xs), x1: Math.max(...xs), z0: Math.min(...zs), z1: Math.max(...zs)}}];
- }).filter(r => r.total > 80);
- const sample = (road, d) => {
- let i = 1;
- while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
- const a = road.pts[i - 1], b = road.pts[i], t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
- return {x: a.x + (b.x - a.x) * t, z: a.y + (b.y - a.y) * t, dx: b.x - a.x, dz: b.y - a.y};
- };
- let seed = 7;
- const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
- // Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
- let nearby = [], nearbyAt = null;
- const roadsNear = (x, z) => {
- if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
- nearbyAt = {x, z};
- nearby = roads.filter(({box}) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600);
- }
- return nearby;
- };
- // Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
- function respawn(car, x, z) {
- const candidates = roadsNear(x, z);
- for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
- const road = candidates[Math.floor(random() * candidates.length)], d = random() * road.total, p = sample(road, d);
- const dist = Math.hypot(p.x - x, p.z - z);
- if (dist < 120 || dist > 600) continue;
- // One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half.
- const lane = road.oneway ? (random() < .5 ? -1 : 1) * road.width / 4 : road.width / 4;
- Object.assign(car, {road, d, dir: road.oneway || random() < .5 ? 1 : -1, lane, speed: 9 + random() * 7});
- car.model.group.visible = true;
- return true;
- }
- car.model.group.visible = false;
- return false;
+ const oneway = new Set(data.oneway ?? []);
+ const roads = data.roads
+ .flatMap((r, index) => {
+ if (r[0] > 3) return [];
+ const pts = [];
+ for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
+ const lengths = [0];
+ for (let i = 1; i < pts.length; i++)
+ lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
+ const xs = pts.map((p) => p.x),
+ zs = pts.map((p) => p.y);
+ return [
+ {
+ pts,
+ lengths,
+ total: lengths.at(-1),
+ width: WIDTH[r[0]],
+ oneway: oneway.has(index),
+ box: {
+ x0: Math.min(...xs),
+ x1: Math.max(...xs),
+ z0: Math.min(...zs),
+ z1: Math.max(...zs),
+ },
+ },
+ ];
+ })
+ .filter((r) => r.total > 80);
+ const sample = (road, d) => {
+ let i = 1;
+ while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
+ const a = road.pts[i - 1],
+ b = road.pts[i],
+ t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
+ return { x: a.x + (b.x - a.x) * t, z: a.y + (b.y - a.y) * t, dx: b.x - a.x, dz: b.y - a.y };
+ };
+ let seed = 7;
+ const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
+ // Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
+ let nearby = [],
+ nearbyAt = null;
+ const roadsNear = (x, z) => {
+ if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
+ nearbyAt = { x, z };
+ nearby = roads.filter(
+ ({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600,
+ );
}
- // All traffic sits in one group so it can be hidden as a whole (camera AR view).
- const group = new THREE.Group(), cars = [];
- scene.add(group);
- for (let i = 0; i < count; i++) {
- const model = createCarModel(COLORS[i % COLORS.length]);
- group.add(model.group);
- model.group.visible = false;
- cars.push({model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9});
- }
- // Takes a car off the road; it respawns out of sight on a later frame.
- function remove(car) {
- car.road = null;
- car.x = car.z = 1e9;
- car.model.group.visible = false;
+ return nearby;
+ };
+ // Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
+ function respawn(car, x, z) {
+ const candidates = roadsNear(x, z);
+ for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
+ const road = candidates[Math.floor(random() * candidates.length)],
+ d = random() * road.total,
+ p = sample(road, d);
+ const dist = Math.hypot(p.x - x, p.z - z);
+ if (dist < 120 || dist > 600) continue;
+ // One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half.
+ const lane = road.oneway ? ((random() < 0.5 ? -1 : 1) * road.width) / 4 : road.width / 4;
+ Object.assign(car, {
+ road,
+ d,
+ dir: road.oneway || random() < 0.5 ? 1 : -1,
+ lane,
+ speed: 9 + random() * 7,
+ });
+ car.model.group.visible = true;
+ return true;
}
+ car.model.group.visible = false;
+ return false;
+ }
+ // All traffic sits in one group so it can be hidden as a whole (camera AR view).
+ const group = new THREE.Group(),
+ cars = [];
+ scene.add(group);
+ for (let i = 0; i < count; i++) {
+ const model = createCarModel(COLORS[i % COLORS.length]);
+ group.add(model.group);
+ model.group.visible = false;
+ cars.push({ model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9 });
+ }
+ // Takes a car off the road; it respawns out of sight on a later frame.
+ function remove(car) {
+ car.road = null;
+ car.x = car.z = 1e9;
+ car.model.group.visible = false;
+ }
- let cursor = 0;
- function update(dt, obstacles, around) {
- // Re-home a couple of distant cars per frame, which keeps the cost per frame small.
- for (let n = 0; n < 2; n++) {
- const car = cars[cursor = (cursor + 1) % cars.length];
- if (!car.road || Math.hypot(car.x - around.x, car.z - around.z) > 800) respawn(car, around.x, around.z);
- }
- for (const car of cars) {
- if (!car.road || !car.model.group.visible) continue;
- const here = sample(car.road, car.d), len = Math.hypot(here.dx, here.dz) || 1;
- const fx = here.dx / len * car.dir, fz = here.dz / len * car.dir;
- const ahead = obstacles.concat(cars.filter(c => c !== car && c.model.group.visible)).some(o => {
- const ox = o.x - car.x, oz = o.z - car.z, along = ox * fx + oz * fz;
- return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
- });
- const speed = ahead ? 0 : car.speed;
- car.d += speed * car.dir * dt;
- if (car.d > car.road.total || car.d < 0) {
- // At the end of a one-way road the car leaves; on a two-way road it turns back.
- if (car.road.oneway) {
- remove(car);
- continue;
- }
- car.dir *= -1;
- car.d = Math.max(0, Math.min(car.road.total, car.d));
- }
- // Keep to the right-hand lane relative to the direction of travel.
- const p = sample(car.road, car.d);
- car.x = p.x - fz * car.lane;
- car.z = p.z + fx * car.lane;
- car.model.group.position.set(car.x, 0, car.z);
- car.model.group.rotation.y = Math.atan2(-fz, fx);
- for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36;
- }
+ let cursor = 0;
+ function update(dt, obstacles, around) {
+ // Re-home a couple of distant cars per frame, which keeps the cost per frame small.
+ for (let n = 0; n < 2; n++) {
+ const car = cars[(cursor = (cursor + 1) % cars.length)];
+ if (!car.road || Math.hypot(car.x - around.x, car.z - around.z) > 800)
+ respawn(car, around.x, around.z);
}
- return {group, cars, update, remove};
+ for (const car of cars) {
+ if (!car.road || !car.model.group.visible) continue;
+ const here = sample(car.road, car.d),
+ len = Math.hypot(here.dx, here.dz) || 1;
+ const fx = (here.dx / len) * car.dir,
+ fz = (here.dz / len) * car.dir;
+ const ahead = obstacles
+ .concat(cars.filter((c) => c !== car && c.model.group.visible))
+ .some((o) => {
+ const ox = o.x - car.x,
+ oz = o.z - car.z,
+ along = ox * fx + oz * fz;
+ return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
+ });
+ const speed = ahead ? 0 : car.speed;
+ car.d += speed * car.dir * dt;
+ if (car.d > car.road.total || car.d < 0) {
+ // At the end of a one-way road the car leaves; on a two-way road it turns back.
+ if (car.road.oneway) {
+ remove(car);
+ continue;
+ }
+ car.dir *= -1;
+ car.d = Math.max(0, Math.min(car.road.total, car.d));
+ }
+ // Keep to the right-hand lane relative to the direction of travel.
+ const p = sample(car.road, car.d);
+ car.x = p.x - fz * car.lane;
+ car.z = p.z + fx * car.lane;
+ car.model.group.position.set(car.x, 0, car.z);
+ car.model.group.rotation.y = Math.atan2(-fz, fx);
+ for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36;
+ }
+ }
+ return { group, cars, update, remove };
}
diff --git a/src/vehicle.js b/src/vehicle.js
index 618ee96..cb08c3f 100644
--- a/src/vehicle.js
+++ b/src/vehicle.js
@@ -1,24 +1,35 @@
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y.
// maxSpeed 55.6 m/s is 200 km/h.
-export const CAR = {maxSpeed: 55.6, maxReverse: 6, accel: 9.5, brake: 14, drag: 1.6, maxSteer: .55, wheelBase: 2.7, radius: 1.6};
+export const CAR = {
+ maxSpeed: 55.6,
+ maxReverse: 6,
+ accel: 9.5,
+ brake: 14,
+ drag: 1.6,
+ maxSteer: 0.55,
+ wheelBase: 2.7,
+ radius: 1.6,
+};
export function createCar(x, z, heading = 0) {
- return {x, z, heading, speed: 0, steer: 0};
+ return { x, z, heading, speed: 0, steer: 0 };
}
-export function stepCar(car, {throttle = 0, steer = 0, handbrake = false}, dt) {
- car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6);
- if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt;
- else if (throttle < 0) car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * .6) * -throttle * dt;
- else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt);
- if (handbrake) car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt);
- car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
- // High speed narrows the usable steering angle so the car stays controllable.
- const steerAngle = car.steer / (1 + Math.abs(car.speed) * .045);
- car.heading -= car.speed * Math.tan(steerAngle) / CAR.wheelBase * dt;
- car.x += Math.cos(car.heading) * car.speed * dt;
- car.z -= Math.sin(car.heading) * car.speed * dt;
- return car;
+export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) {
+ car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6);
+ if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt;
+ else if (throttle < 0)
+ car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * 0.6) * -throttle * dt;
+ else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt);
+ if (handbrake)
+ car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt);
+ car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
+ // High speed narrows the usable steering angle so the car stays controllable.
+ const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
+ car.heading -= ((car.speed * Math.tan(steerAngle)) / CAR.wheelBase) * dt;
+ car.x += Math.cos(car.heading) * car.speed * dt;
+ car.z -= Math.sin(car.heading) * car.speed * dt;
+ return car;
}
-export const kmh = car => Math.round(Math.abs(car.speed) * 3.6);
+export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
diff --git a/src/vending.js b/src/vending.js
index 0635e78..c72dacb 100644
--- a/src/vending.js
+++ b/src/vending.js
@@ -1,11 +1,22 @@
import * as THREE from 'three';
-import {catalog} from './game-state.js';
-import {textTexture} from './textures.js';
+import { catalog } from './game-state.js';
+import { textTexture } from './textures.js';
-const PRODUCT_COLORS = {juice: '#e5ae47', water: '#9fd3e6', milk: '#8a5a3c', tea: '#c7783a', lemonade: '#f0dc5a', biscuit: '#c9935a', chocolate: '#5a3524', nuts: '#b9884f', cake: '#e58fa6'};
+const PRODUCT_COLORS = {
+ juice: '#e5ae47',
+ water: '#9fd3e6',
+ milk: '#8a5a3c',
+ tea: '#c7783a',
+ lemonade: '#f0dc5a',
+ biscuit: '#c9935a',
+ chocolate: '#5a3524',
+ nuts: '#b9884f',
+ cake: '#e58fa6',
+};
// Sign panel; the shared helper redraws it once the Persian web font has loaded.
-const label = (text, sub) => textTexture(512, 160, (c, width, height) => {
+const label = (text, sub) =>
+ textTexture(512, 160, (c, width, height) => {
c.fillStyle = '#0f2a2a';
c.fillRect(0, 0, width, height);
c.fillStyle = '#3ee0c9';
@@ -18,81 +29,148 @@ const label = (text, sub) => textTexture(512, 160, (c, width, height) => {
c.fillStyle = '#9fe9da';
c.font = '34px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, width / 2, 126);
-});
+ });
// A Robot Market machine. Local +z is the front face; the group is placed facing the path.
export function createVendingMachine(def) {
- const group = new THREE.Group();
- const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .5, ...extra});
- const box = (w, h, d, material, x, y, z, parent = group) => {
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
- mesh.position.set(x, y, z);
- mesh.castShadow = mesh.receiveShadow = true;
- parent.add(mesh);
- return mesh;
- };
- const shell = mat('#0b8f88', {metalness: .45, roughness: .3, emissive: '#0b8f88', emissiveIntensity: .15}), white = mat('#eef5f3', {metalness: .2, roughness: .35});
- const dark = mat('#13292b'), steel = mat('#b7c1c0', {metalness: .8, roughness: .3});
- box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
- box(1.94, .32, 1.14, white, 0, 2.62, 0);
- box(1.94, .12, 1.14, dark, 0, .06, 0);
- // The lit interior glows instead of using a point light, which would cost every pixel in the scene.
- box(1.3, 1.8, .06, mat('#13292b', {emissive: '#2a7f73', emissiveIntensity: .5}), -.2, 1.45, .56);
- // Six shelves of products behind the glass, coloured by what this machine sells.
- for (let row = 0; row < 6; row++) {
- const y = .78 + row * .28;
- box(1.2, .02, .25, steel, -.2, y, .45);
- for (let col = 0; col < 8; col++) {
- const id = def.products[(row * 8 + col) % def.products.length];
- const color = mat(PRODUCT_COLORS[id]);
- if (catalog[id].type === 'drink') {
- const bottle = new THREE.Mesh(new THREE.CylinderGeometry(.035, .04, .17, 8), color);
- bottle.position.set(-.73 + col * .15, y + .1, .47);
- group.add(bottle);
- } else box(.09, .14, .06, color, -.73 + col * .15, y + .08, .47);
- }
+ const group = new THREE.Group();
+ const mat = (color, extra = {}) =>
+ new THREE.MeshStandardMaterial({ color, roughness: 0.5, ...extra });
+ const box = (w, h, d, material, x, y, z, parent = group) => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = mesh.receiveShadow = true;
+ parent.add(mesh);
+ return mesh;
+ };
+ const shell = mat('#0b8f88', {
+ metalness: 0.45,
+ roughness: 0.3,
+ emissive: '#0b8f88',
+ emissiveIntensity: 0.15,
+ }),
+ white = mat('#eef5f3', { metalness: 0.2, roughness: 0.35 });
+ const dark = mat('#13292b'),
+ steel = mat('#b7c1c0', { metalness: 0.8, roughness: 0.3 });
+ box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
+ box(1.94, 0.32, 1.14, white, 0, 2.62, 0);
+ box(1.94, 0.12, 1.14, dark, 0, 0.06, 0);
+ // The lit interior glows instead of using a point light, which would cost every pixel in the scene.
+ box(
+ 1.3,
+ 1.8,
+ 0.06,
+ mat('#13292b', { emissive: '#2a7f73', emissiveIntensity: 0.5 }),
+ -0.2,
+ 1.45,
+ 0.56,
+ );
+ // Six shelves of products behind the glass, coloured by what this machine sells.
+ for (let row = 0; row < 6; row++) {
+ const y = 0.78 + row * 0.28;
+ box(1.2, 0.02, 0.25, steel, -0.2, y, 0.45);
+ for (let col = 0; col < 8; col++) {
+ const id = def.products[(row * 8 + col) % def.products.length];
+ const color = mat(PRODUCT_COLORS[id]);
+ if (catalog[id].type === 'drink') {
+ const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.17, 8), color);
+ bottle.position.set(-0.73 + col * 0.15, y + 0.1, 0.47);
+ group.add(bottle);
+ } else box(0.09, 0.14, 0.06, color, -0.73 + col * 0.15, y + 0.08, 0.47);
}
- const glass = new THREE.Mesh(new THREE.PlaneGeometry(1.32, 1.82), new THREE.MeshStandardMaterial({color: '#cdeeea', transparent: true, opacity: .16, roughness: .05, metalness: .3, depthWrite: false}));
- glass.position.set(-.2, 1.45, .6);
- group.add(glass);
- // Card reader panel on the right of the front.
- box(.42, 1.8, .05, white, .7, 1.45, .56);
- const screen = box(.3, .22, .02, new THREE.MeshStandardMaterial({color: '#7ef0d6', emissive: '#2bbfa0', emissiveIntensity: .8}), .7, 1.92, .6);
- box(.3, .2, .03, dark, .7, 1.55, .6);
- box(.22, .03, .04, steel, .7, 1.62, .62);
- for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) box(.07, .05, .02, steel, .62 + c * .08, 1.32 - r * .07, .6);
- box(.9, .3, .08, mat('#050f10'), -.2, .38, .56);
- const flap = new THREE.Group();
- flap.position.set(-.2, .53, .61);
- group.add(flap);
- box(.88, .28, .03, mat('#4f6f68', {metalness: .4}), 0, -.14, 0, flap);
- const sign = new THREE.Mesh(new THREE.PlaneGeometry(1.86, .3), new THREE.MeshBasicMaterial({map: label('روبات مارکت', def.name)}));
- sign.position.set(0, 2.62, .575);
- group.add(sign);
- const delivery = new THREE.Mesh(new THREE.CylinderGeometry(.06, .06, .2, 10), mat('#e5ae47'));
- delivery.visible = false;
- group.add(delivery);
- const card = box(.2, .13, .01, mat('#e2d6a8', {metalness: .3}), .95, 1.7, .66);
- card.visible = false;
+ }
+ const glass = new THREE.Mesh(
+ new THREE.PlaneGeometry(1.32, 1.82),
+ new THREE.MeshStandardMaterial({
+ color: '#cdeeea',
+ transparent: true,
+ opacity: 0.16,
+ roughness: 0.05,
+ metalness: 0.3,
+ depthWrite: false,
+ }),
+ );
+ glass.position.set(-0.2, 1.45, 0.6);
+ group.add(glass);
+ // Card reader panel on the right of the front.
+ box(0.42, 1.8, 0.05, white, 0.7, 1.45, 0.56);
+ const screen = box(
+ 0.3,
+ 0.22,
+ 0.02,
+ new THREE.MeshStandardMaterial({
+ color: '#7ef0d6',
+ emissive: '#2bbfa0',
+ emissiveIntensity: 0.8,
+ }),
+ 0.7,
+ 1.92,
+ 0.6,
+ );
+ box(0.3, 0.2, 0.03, dark, 0.7, 1.55, 0.6);
+ box(0.22, 0.03, 0.04, steel, 0.7, 1.62, 0.62);
+ for (let r = 0; r < 3; r++)
+ for (let c = 0; c < 3; c++) box(0.07, 0.05, 0.02, steel, 0.62 + c * 0.08, 1.32 - r * 0.07, 0.6);
+ box(0.9, 0.3, 0.08, mat('#050f10'), -0.2, 0.38, 0.56);
+ const flap = new THREE.Group();
+ flap.position.set(-0.2, 0.53, 0.61);
+ group.add(flap);
+ box(0.88, 0.28, 0.03, mat('#4f6f68', { metalness: 0.4 }), 0, -0.14, 0, flap);
+ const sign = new THREE.Mesh(
+ new THREE.PlaneGeometry(1.86, 0.3),
+ new THREE.MeshBasicMaterial({ map: label('روبات مارکت', def.name) }),
+ );
+ sign.position.set(0, 2.62, 0.575);
+ group.add(sign);
+ const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
+ delivery.visible = false;
+ group.add(delivery);
+ const card = box(0.2, 0.13, 0.01, mat('#e2d6a8', { metalness: 0.3 }), 0.95, 1.7, 0.66);
+ card.visible = false;
- // Objective beacon: a tall column of light visible across the city, like a mission marker.
- const beaconMaterial = new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .22, depthWrite: false, fog: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide});
- const beacon = new THREE.Mesh(new THREE.CylinderGeometry(.7, .7, 90, 16, 1, true), beaconMaterial);
- beacon.position.y = 45;
- group.add(beacon);
- const ring = new THREE.Mesh(new THREE.RingGeometry(1.6, 1.9, 40), new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .6, depthWrite: false}));
- ring.rotation.x = -Math.PI / 2;
- ring.position.set(0, .09, 1.6);
- group.add(ring);
+ // Objective beacon: a tall column of light visible across the city, like a mission marker.
+ const beaconMaterial = new THREE.MeshBasicMaterial({
+ color: '#3ee0c9',
+ transparent: true,
+ opacity: 0.22,
+ depthWrite: false,
+ fog: false,
+ blending: THREE.AdditiveBlending,
+ side: THREE.DoubleSide,
+ });
+ const beacon = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.7, 0.7, 90, 16, 1, true),
+ beaconMaterial,
+ );
+ beacon.position.y = 45;
+ group.add(beacon);
+ const ring = new THREE.Mesh(
+ new THREE.RingGeometry(1.6, 1.9, 40),
+ new THREE.MeshBasicMaterial({
+ color: '#3ee0c9',
+ transparent: true,
+ opacity: 0.6,
+ depthWrite: false,
+ }),
+ );
+ ring.rotation.x = -Math.PI / 2;
+ ring.position.set(0, 0.09, 1.6);
+ group.add(ring);
- return {
- group, flap, delivery, card, screen, beacon, ring,
- setDelivery(productId) {
- delivery.material = mat(PRODUCT_COLORS[productId]);
- delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
- },
- // Points in machine space, converted to world space by the caller.
- readerPoint: new THREE.Vector3(.7, 1.6, .62),
- slotPoint: new THREE.Vector3(-.2, .38, .66)
- };
+ return {
+ group,
+ flap,
+ delivery,
+ card,
+ screen,
+ beacon,
+ ring,
+ setDelivery(productId) {
+ delivery.material = mat(PRODUCT_COLORS[productId]);
+ delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
+ },
+ // Points in machine space, converted to world space by the caller.
+ readerPoint: new THREE.Vector3(0.7, 1.6, 0.62),
+ slotPoint: new THREE.Vector3(-0.2, 0.38, 0.66),
+ };
}
diff --git a/tests/payment.test.js b/tests/payment.test.js
index afb67cd..249302c 100644
--- a/tests/payment.test.js
+++ b/tests/payment.test.js
@@ -1,47 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {createState, purchaseBlocker, priceAt, canAffordAnything, formatClock} from '../src/game-state.js';
-import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from '../src/payment.js';
-import {machineById, machines} from '../src/locations.js';
+import {
+ createState,
+ purchaseBlocker,
+ priceAt,
+ canAffordAnything,
+ formatClock,
+} from '../src/game-state.js';
+import {
+ createPayment,
+ beginPayment,
+ readyPayment,
+ swipePayment,
+ cancelPayment,
+} from '../src/payment.js';
+import { machineById, machines } from '../src/locations.js';
-const park = machineById('mellat-park'), school = machineById('school-rahil'), uni = machineById('ferdowsi-university');
+const park = machineById('mellat-park'),
+ school = machineById('school-rahil'),
+ uni = machineById('ferdowsi-university');
test('Payment charges once, only after ready and an explicit swipe', () => {
- const state = createState(), payment = createPayment();
- assert.ok(beginPayment(state, payment, park, 'juice'));
- assert.equal(state.balance, 500);
- assert.equal(swipePayment(state, payment), false, 'not ready yet');
- assert.ok(readyPayment(payment));
- assert.ok(swipePayment(state, payment));
- assert.equal(state.balance, 465);
- assert.equal(swipePayment(state, payment), false, 'no double charge');
- assert.equal(state.purchases.length, 1);
- assert.equal(beginPayment(state, createPayment(), park, 'water'), false, 'busy until delivery ends');
+ const state = createState(),
+ payment = createPayment();
+ assert.ok(beginPayment(state, payment, park, 'juice'));
+ assert.equal(state.balance, 500);
+ assert.equal(swipePayment(state, payment), false, 'not ready yet');
+ assert.ok(readyPayment(payment));
+ assert.ok(swipePayment(state, payment));
+ assert.equal(state.balance, 465);
+ assert.equal(swipePayment(state, payment), false, 'no double charge');
+ assert.equal(state.purchases.length, 1);
+ assert.equal(
+ beginPayment(state, createPayment(), park, 'water'),
+ false,
+ 'busy until delivery ends',
+ );
});
test('Cancel, closed machines, unknown products and low funds never charge', () => {
- const state = createState(), payment = createPayment();
- assert.ok(beginPayment(state, payment, park, 'cake'));
- assert.ok(cancelPayment(state, payment));
- assert.equal(state.balance, 500);
- assert.equal(state.busy, false);
- assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
- assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
- state.minutes = 14 * 60;
- assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
- state.minutes = 9 * 60;
- state.balance = 10;
- assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
- assert.equal(beginPayment(state, payment, park, 'water'), false);
+ const state = createState(),
+ payment = createPayment();
+ assert.ok(beginPayment(state, payment, park, 'cake'));
+ assert.ok(cancelPayment(state, payment));
+ assert.equal(state.balance, 500);
+ assert.equal(state.busy, false);
+ assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
+ assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
+ state.minutes = 14 * 60;
+ assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
+ state.minutes = 9 * 60;
+ state.balance = 10;
+ assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
+ assert.equal(beginPayment(state, payment, park, 'water'), false);
});
test('Prices follow machine discounts and the clock is Persian', () => {
- assert.equal(priceAt(uni, 'nuts'), 52);
- assert.equal(priceAt(park, 'nuts'), 65);
- assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
- const state = createState();
- state.balance = 5;
- assert.equal(canAffordAnything(state, machines), false);
- state.balance = 15;
- assert.ok(canAffordAnything(state, machines));
+ assert.equal(priceAt(uni, 'nuts'), 52);
+ assert.equal(priceAt(park, 'nuts'), 65);
+ assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
+ const state = createState();
+ state.balance = 5;
+ assert.equal(canAffordAnything(state, machines), false);
+ state.balance = 15;
+ assert.ok(canAffordAnything(state, machines));
});
diff --git a/tests/realworld.test.js b/tests/realworld.test.js
index 27774fa..a9da4de 100644
--- a/tests/realworld.test.js
+++ b/tests/realworld.test.js
@@ -1,59 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {smoothFix, arrivalRadius, insideWorld, createStabilizer} from '../src/realworld.js';
+import { smoothFix, arrivalRadius, insideWorld, createStabilizer } from '../src/realworld.js';
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
- const first = smoothFix(null, {x: 0, z: 0, accuracy: 10, time: 0});
- assert.deepEqual([first.x, first.z], [0, 0]);
- const equal = smoothFix(first, {x: 10, z: 0, accuracy: 10, time: 1000});
- assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
- assert.ok(equal.accuracy < 10, 'two fixes are better than one');
- const poor = smoothFix(first, {x: 50, z: 0, accuracy: 60, time: 1000});
- assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
- const stale = smoothFix(first, {x: 99, z: 0, accuracy: 30, time: 60000});
- assert.equal(stale.x, 99);
+ const first = smoothFix(null, { x: 0, z: 0, accuracy: 10, time: 0 });
+ assert.deepEqual([first.x, first.z], [0, 0]);
+ const equal = smoothFix(first, { x: 10, z: 0, accuracy: 10, time: 1000 });
+ assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
+ assert.ok(equal.accuracy < 10, 'two fixes are better than one');
+ const poor = smoothFix(first, { x: 50, z: 0, accuracy: 60, time: 1000 });
+ assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
+ const stale = smoothFix(first, { x: 99, z: 0, accuracy: 30, time: 60000 });
+ assert.equal(stale.x, 99);
});
test('Arrival radius widens with poor GPS and bounds check the world', () => {
- assert.equal(arrivalRadius(4), 20);
- assert.equal(arrivalRadius(12), 30);
- assert.equal(arrivalRadius(100), 50);
- const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
- assert.ok(insideWorld(bounds, 0, 0));
- assert.equal(insideWorld(bounds, 11, 0), false);
+ assert.equal(arrivalRadius(4), 20);
+ assert.equal(arrivalRadius(12), 30);
+ assert.equal(arrivalRadius(100), 50);
+ const bounds = { minX: -10, minZ: -10, maxX: 10, maxZ: 10 };
+ assert.ok(insideWorld(bounds, 0, 0));
+ assert.equal(insideWorld(bounds, 11, 0), false);
});
test('Stable mode holds still through jitter, drops jumps and follows real walking', () => {
- const s = createStabilizer();
- s.push({x: 0, z: 0, accuracy: 8, time: 0, speed: 0});
- // Standing still: fixes wander by a few metres, the shown position does not move.
- for (let i = 1; i <= 10; i++) {
- const p = s.push({x: (i % 2 ? 3 : -3), z: (i % 3) - 1, accuracy: 8, time: i * 1000, speed: 0});
- assert.deepEqual([p.x, p.z], [0, 0]);
- }
- // A single 80 m jump one second later is impossible on foot and is dropped.
- assert.deepEqual([s.push({x: 80, z: 0, accuracy: 10, time: 11000, speed: 0}).x], [0]);
- // Walking east at 1.4 m/s: the shown position follows.
- let p;
- for (let i = 1; i <= 20; i++) p = s.push({x: 1.4 * i, z: 0, accuracy: 6, time: 11000 + i * 1000, speed: 1.4});
- assert.ok(p.x > 15, `followed the walk (${p.x})`);
- // With stable mode off every smoothed fix is shown.
- const raw = createStabilizer();
- raw.stable = false;
- raw.push({x: 0, z: 0, accuracy: 8, time: 0});
- assert.ok(raw.push({x: 3, z: 0, accuracy: 8, time: 1000}).x > 1);
+ const s = createStabilizer();
+ s.push({ x: 0, z: 0, accuracy: 8, time: 0, speed: 0 });
+ // Standing still: fixes wander by a few metres, the shown position does not move.
+ for (let i = 1; i <= 10; i++) {
+ const p = s.push({ x: i % 2 ? 3 : -3, z: (i % 3) - 1, accuracy: 8, time: i * 1000, speed: 0 });
+ assert.deepEqual([p.x, p.z], [0, 0]);
+ }
+ // A single 80 m jump one second later is impossible on foot and is dropped.
+ assert.deepEqual([s.push({ x: 80, z: 0, accuracy: 10, time: 11000, speed: 0 }).x], [0]);
+ // Walking east at 1.4 m/s: the shown position follows.
+ let p;
+ for (let i = 1; i <= 20; i++)
+ p = s.push({ x: 1.4 * i, z: 0, accuracy: 6, time: 11000 + i * 1000, speed: 1.4 });
+ assert.ok(p.x > 15, `followed the walk (${p.x})`);
+ // With stable mode off every smoothed fix is shown.
+ const raw = createStabilizer();
+ raw.stable = false;
+ raw.push({ x: 0, z: 0, accuracy: 8, time: 0 });
+ assert.ok(raw.push({ x: 3, z: 0, accuracy: 8, time: 1000 }).x > 1);
});
test('AR camera faces north/west with the phone held upright, and the FOV fits the screen', async () => {
- const {Quaternion, Vector3} = await import('three');
- const {orientationQuaternion, coverFov} = await import('../src/ar.js');
- const look = (alpha, beta = 90, gamma = 0, angle = 0) => new Vector3(0, 0, -1).applyQuaternion(orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle));
- const north = look(0);
- assert.ok(north.z < -.99, 'upright phone with alpha 0 looks north (−z)');
- assert.ok(look(90).x < -.99, 'alpha 90 (turned left) looks west (−x)');
- assert.ok(look(0, 0).y < -.99, 'phone lying flat looks at the ground');
- // Portrait 9:19.5 screen over a 3:4 portrait video: the full long-side 65° shows vertically.
- assert.ok(Math.abs(coverFov(3 / 4, 9 / 19.5) - 65) < .5);
- // Landscape screen wider than the 4:3 video: the width shows 65°, so the vertical view is narrower.
- assert.ok(coverFov(4 / 3, 19.5 / 9) < 35);
+ const { Quaternion, Vector3 } = await import('three');
+ const { orientationQuaternion, coverFov } = await import('../src/ar.js');
+ const look = (alpha, beta = 90, gamma = 0, angle = 0) =>
+ new Vector3(0, 0, -1).applyQuaternion(
+ orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle),
+ );
+ const north = look(0);
+ assert.ok(north.z < -0.99, 'upright phone with alpha 0 looks north (−z)');
+ assert.ok(look(90).x < -0.99, 'alpha 90 (turned left) looks west (−x)');
+ assert.ok(look(0, 0).y < -0.99, 'phone lying flat looks at the ground');
+ // Portrait 9:19.5 screen over a 3:4 portrait video: the full long-side 65° shows vertically.
+ assert.ok(Math.abs(coverFov(3 / 4, 9 / 19.5) - 65) < 0.5);
+ // Landscape screen wider than the 4:3 video: the width shows 65°, so the vertical view is narrower.
+ assert.ok(coverFov(4 / 3, 19.5 / 9) < 35);
});
diff --git a/tests/scoring.test.js b/tests/scoring.test.js
index ea2ef04..dfab826 100644
--- a/tests/scoring.test.js
+++ b/tests/scoring.test.js
@@ -1,41 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {awardsFor, rankFor, completeMissions, activeMission, POINTS, RANKS} from '../src/scoring.js';
-import {createState, purchase} from '../src/game-state.js';
-import {machineById} from '../src/locations.js';
+import {
+ awardsFor,
+ rankFor,
+ completeMissions,
+ activeMission,
+ POINTS,
+ RANKS,
+} from '../src/scoring.js';
+import { createState, purchase } from '../src/game-state.js';
+import { machineById } from '../src/locations.js';
-const total = awards => awards.reduce((s, a) => s + a.points, 0);
+const total = (awards) => awards.reduce((s, a) => s + a.points, 0);
test('Discovery, combo and all-category bonuses are paid once', () => {
- const state = createState(), park = machineById('mellat-park');
- const buy = (machine, product) => {
- const before = state.purchases.slice();
- return awardsFor(before, purchase(state, machine, product));
- };
- assert.equal(total(buy(park, 'water')), POINTS.purchase + POINTS.discovery);
- assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
- assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
- assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
- buy(machineById('factory'), 'water');
- buy(machineById('school-rahil'), 'water');
- buy(machineById('ferdowsi-university'), 'water');
- buy(machineById('azadi-metro'), 'water');
- state.minutes = 11 * 60;
- const last = buy(machineById('mashhad-mall'), 'water');
- assert.ok(last.some(a => a.points === POINTS.allCategories));
+ const state = createState(),
+ park = machineById('mellat-park');
+ const buy = (machine, product) => {
+ const before = state.purchases.slice();
+ return awardsFor(before, purchase(state, machine, product));
+ };
+ assert.equal(total(buy(park, 'water')), POINTS.purchase + POINTS.discovery);
+ assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
+ assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
+ assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
+ buy(machineById('factory'), 'water');
+ buy(machineById('school-rahil'), 'water');
+ buy(machineById('ferdowsi-university'), 'water');
+ buy(machineById('azadi-metro'), 'water');
+ state.minutes = 11 * 60;
+ const last = buy(machineById('mashhad-mall'), 'water');
+ assert.ok(last.some((a) => a.points === POINTS.allCategories));
});
test('Ranks and missions advance with progress', () => {
- assert.equal(rankFor(0).name, RANKS[0].name);
- assert.equal(rankFor(RANKS[1].xp).index, 1);
- assert.equal(rankFor(1e6).progress, 1);
- const state = createState();
- assert.equal(activeMission(state).id, 'factory');
- purchase(state, machineById('factory'), 'water');
- assert.deepEqual(completeMissions(state).map(m => m.id), ['factory']);
- assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
- purchase(state, machineById('mellat-park'), 'water');
- assert.deepEqual(completeMissions(state).map(m => m.id), ['first']);
- assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
- assert.equal(activeMission(state).id, 'school');
+ assert.equal(rankFor(0).name, RANKS[0].name);
+ assert.equal(rankFor(RANKS[1].xp).index, 1);
+ assert.equal(rankFor(1e6).progress, 1);
+ const state = createState();
+ assert.equal(activeMission(state).id, 'factory');
+ purchase(state, machineById('factory'), 'water');
+ assert.deepEqual(
+ completeMissions(state).map((m) => m.id),
+ ['factory'],
+ );
+ assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
+ purchase(state, machineById('mellat-park'), 'water');
+ assert.deepEqual(
+ completeMissions(state).map((m) => m.id),
+ ['first'],
+ );
+ assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
+ assert.equal(activeMission(state).id, 'school');
});
diff --git a/tests/world.test.js b/tests/world.test.js
index 4ae581a..1bd84d7 100644
--- a/tests/world.test.js
+++ b/tests/world.test.js
@@ -1,97 +1,127 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {readFileSync} from 'node:fs';
-import {toLocal, toLatLon, bearing, ORIGIN} from '../src/geo.js';
-import {createCollisionWorld, resolveCircle, blocked, clearFraction} from '../src/collision.js';
-import {buildGraph, routeBetween, nearestSegment, buildSegmentIndex} from '../src/routing.js';
-import {createCar, stepCar} from '../src/vehicle.js';
-import {createTraffic} from '../src/traffic.js';
-import {Scene} from 'three';
-import {machines, machineById, FACTORY} from '../src/locations.js';
+import { readFileSync } from 'node:fs';
+import { toLocal, toLatLon, bearing, ORIGIN } from '../src/geo.js';
+import { createCollisionWorld, resolveCircle, blocked, clearFraction } from '../src/collision.js';
+import { buildGraph, routeBetween, nearestSegment, buildSegmentIndex } from '../src/routing.js';
+import { createCar, stepCar } from '../src/vehicle.js';
+import { createTraffic } from '../src/traffic.js';
+import { Scene } from 'three';
+import { machines, machineById, FACTORY } from '../src/locations.js';
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
test('Projection round-trips and bearings follow the compass', () => {
- const p = toLocal(36.32092, 59.53744), back = toLatLon(p.x, p.z);
- assert.ok(Math.abs(back.lat - 36.32092) < 1e-9 && Math.abs(back.lon - 59.53744) < 1e-9);
- assert.ok(p.z < 0, 'north of the origin is -z');
- assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), {x: 0, z: 0});
- assert.equal(bearing(0, -1), 0);
- assert.equal(bearing(1, 0), 90);
+ const p = toLocal(36.32092, 59.53744),
+ back = toLatLon(p.x, p.z);
+ assert.ok(Math.abs(back.lat - 36.32092) < 1e-9 && Math.abs(back.lon - 59.53744) < 1e-9);
+ assert.ok(p.z < 0, 'north of the origin is -z');
+ assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), { x: 0, z: 0 });
+ assert.equal(bearing(0, -1), 0);
+ assert.equal(bearing(1, 0), 90);
});
test('Circles are pushed out of footprints and segments stop at walls', () => {
- const world = createCollisionWorld([[0, 0, 10, 0, 10, 10, 0, 10]]);
- assert.ok(blocked(world, 5, 5));
- const out = resolveCircle(world, 9.5, 5, 1);
- assert.ok(out.hit && out.x >= 10.99);
- assert.equal(resolveCircle(world, 20, 20, 1).hit, false);
- assert.ok(Math.abs(clearFraction(world, -10, 5, 10, 5) - .5) < .06);
+ const world = createCollisionWorld([[0, 0, 10, 0, 10, 10, 0, 10]]);
+ assert.ok(blocked(world, 5, 5));
+ const out = resolveCircle(world, 9.5, 5, 1);
+ assert.ok(out.hit && out.x >= 10.99);
+ assert.equal(resolveCircle(world, 20, 20, 1).hit, false);
+ assert.ok(Math.abs(clearFraction(world, -10, 5, 10, 5) - 0.5) < 0.06);
});
test('Real map: the factory connects by car to the city, and every city machine is reachable', () => {
- const walk = buildGraph(data.roads), drive = buildGraph(data.roads, {drivable: true});
- const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
- assert.ok(toCity && toCity.length > 18000 && toCity.length < 23000, `factory → Park Mellat is ~20 km (${toCity?.length})`);
- const start = machineById('mellat-park').anchor;
- for (const m of machines.filter(m => m.anchor)) {
- const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
- assert.ok(onFoot, `walk route to ${m.id}`);
- assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1);
- assert.ok(routeBetween(drive, start.x, start.z, m.anchor.x, m.anchor.z), `drive route to ${m.id}`);
- }
- const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, s => s[5] >= 0);
- assert.equal(data.names[street.name], 'بولوار ملک آباد');
+ const walk = buildGraph(data.roads),
+ drive = buildGraph(data.roads, { drivable: true });
+ const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
+ assert.ok(
+ toCity && toCity.length > 18000 && toCity.length < 23000,
+ `factory → Park Mellat is ~20 km (${toCity?.length})`,
+ );
+ const start = machineById('mellat-park').anchor;
+ for (const m of machines.filter((m) => m.anchor)) {
+ const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
+ assert.ok(onFoot, `walk route to ${m.id}`);
+ assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1);
+ assert.ok(
+ routeBetween(drive, start.x, start.z, m.anchor.x, m.anchor.z),
+ `drive route to ${m.id}`,
+ );
+ }
+ const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, (s) => s[5] >= 0);
+ assert.equal(data.names[street.name], 'بولوار ملک آباد');
});
test('Car accelerates, steers right with positive input and brakes', () => {
- const car = createCar(0, 0, 0);
- for (let i = 0; i < 60; i++) stepCar(car, {throttle: 1}, 1 / 30);
- assert.ok(car.speed > 10 && car.x > 10 && Math.abs(car.z) < 1e-9);
- for (let i = 0; i < 30; i++) stepCar(car, {throttle: 1, steer: 1}, 1 / 30);
- assert.ok(car.z > 0, 'turning right while heading east moves south (+z)');
- for (let i = 0; i < 90; i++) stepCar(car, {throttle: -1}, 1 / 30);
- assert.ok(car.speed <= 0);
+ const car = createCar(0, 0, 0);
+ for (let i = 0; i < 60; i++) stepCar(car, { throttle: 1 }, 1 / 30);
+ assert.ok(car.speed > 10 && car.x > 10 && Math.abs(car.z) < 1e-9);
+ for (let i = 0; i < 30; i++) stepCar(car, { throttle: 1, steer: 1 }, 1 / 30);
+ assert.ok(car.z > 0, 'turning right while heading east moves south (+z)');
+ for (let i = 0; i < 90; i++) stepCar(car, { throttle: -1 }, 1 / 30);
+ assert.ok(car.speed <= 0);
});
test('Traffic drives with the flow on one-way carriageways and stays inside them', () => {
- const oneway = new Set(data.oneway);
- assert.ok(data.roads.some((r, i) => data.names[r[1]]?.includes('چناران') && oneway.has(i)), 'Chenaran highway is one-way per carriageway');
- const traffic = createTraffic(new Scene(), data, 28), around = {x: -10008, z: -15049};
- for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
- const active = traffic.cars.filter(c => c.road);
- assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
- for (const car of active.filter(c => c.road.oneway)) {
- assert.equal(car.dir, 1);
- assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
- }
+ const oneway = new Set(data.oneway);
+ assert.ok(
+ data.roads.some((r, i) => data.names[r[1]]?.includes('چناران') && oneway.has(i)),
+ 'Chenaran highway is one-way per carriageway',
+ );
+ const traffic = createTraffic(new Scene(), data, 28),
+ around = { x: -10008, z: -15049 };
+ for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
+ const active = traffic.cars.filter((c) => c.road);
+ assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
+ for (const car of active.filter((c) => c.road.oneway)) {
+ assert.equal(car.dir, 1);
+ assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
+ }
});
test('Microsoft buildings decode into the world, stay clear of the factory and leave machines reachable', async () => {
- const {decodeBuildings} = await import('../src/buildings-data.js');
- const file = readFileSync(new URL('../public/data/buildings.bin', import.meta.url));
- const buildings = decodeBuildings(file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength));
- assert.ok(buildings.length > 100000, `${buildings.length} buildings`);
- const {minX, minZ, maxX, maxZ} = data.bounds;
- for (const b of buildings) {
- assert.ok(b[0] >= 3 && b[0] <= 30, `height ${b[0]}`);
- assert.ok(b[1] >= minX - 150 && b[1] <= maxX + 150 && b[2] >= minZ - 150 && b[2] <= maxZ + 150, 'near the region');
- }
- const world = createCollisionWorld([...data.buildings, ...buildings].map(b => b.slice(1)));
- assert.equal(blocked(world, FACTORY.center.x, FACTORY.center.z), false, 'factory compound is clear');
- // Every city machine's path point is still outside buildings, so the family can stand there.
- for (const m of machines.filter(m => m.anchor)) assert.equal(blocked(world, m.anchor.x, m.anchor.z), false, m.id);
+ const { decodeBuildings } = await import('../src/buildings-data.js');
+ const file = readFileSync(new URL('../public/data/buildings.bin', import.meta.url));
+ const buildings = decodeBuildings(
+ file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength),
+ );
+ assert.ok(buildings.length > 100000, `${buildings.length} buildings`);
+ const { minX, minZ, maxX, maxZ } = data.bounds;
+ for (const b of buildings) {
+ assert.ok(b[0] >= 3 && b[0] <= 30, `height ${b[0]}`);
+ assert.ok(
+ b[1] >= minX - 150 && b[1] <= maxX + 150 && b[2] >= minZ - 150 && b[2] <= maxZ + 150,
+ 'near the region',
+ );
+ }
+ const world = createCollisionWorld([...data.buildings, ...buildings].map((b) => b.slice(1)));
+ assert.equal(
+ blocked(world, FACTORY.center.x, FACTORY.center.z),
+ false,
+ 'factory compound is clear',
+ );
+ // Every city machine's path point is still outside buildings, so the family can stand there.
+ for (const m of machines.filter((m) => m.anchor))
+ assert.equal(blocked(world, m.anchor.x, m.anchor.z), false, m.id);
});
test('Satellite tiles land where the map is: a tile over Park Mellat covers the park', async () => {
- const {tileCorners} = await import('../src/imagery.js');
- const index = JSON.parse(readFileSync(new URL('../public/imagery/index.json', import.meta.url)));
- assert.ok(index[17].length > 100 && index[15].length > 100);
- const park = toLocal(36.32092, 59.53744), z = 17;
- const x = Math.floor((59.53744 + 180) / 360 * 2 ** z), y = Math.floor((1 - Math.asinh(Math.tan(36.32092 * Math.PI / 180)) / Math.PI) / 2 * 2 ** z);
- assert.ok(index[17].includes(`${x}/${y}`), 'the Park Mellat tile was downloaded');
- const [nw, ne, sw, se] = tileCorners(x, y, z);
- assert.ok(nw.x < ne.x && nw.z < sw.z && se.x > sw.x, 'north-west corner is up-left in world space');
- assert.ok(park.x >= nw.x && park.x <= se.x && park.z >= nw.z && park.z <= se.z, 'the park centre lies inside its tile');
- assert.ok(Math.abs((ne.x - nw.x) - 247) < 5, 'a zoom-17 tile is ~247 m wide at Mashhad');
+ const { tileCorners } = await import('../src/imagery.js');
+ const index = JSON.parse(readFileSync(new URL('../public/imagery/index.json', import.meta.url)));
+ assert.ok(index[17].length > 100 && index[15].length > 100);
+ const park = toLocal(36.32092, 59.53744),
+ z = 17;
+ const x = Math.floor(((59.53744 + 180) / 360) * 2 ** z),
+ y = Math.floor(((1 - Math.asinh(Math.tan((36.32092 * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z);
+ assert.ok(index[17].includes(`${x}/${y}`), 'the Park Mellat tile was downloaded');
+ const [nw, ne, sw, se] = tileCorners(x, y, z);
+ assert.ok(
+ nw.x < ne.x && nw.z < sw.z && se.x > sw.x,
+ 'north-west corner is up-left in world space',
+ );
+ assert.ok(
+ park.x >= nw.x && park.x <= se.x && park.z >= nw.z && park.z <= se.z,
+ 'the park centre lies inside its tile',
+ );
+ assert.ok(Math.abs(ne.x - nw.x - 247) < 5, 'a zoom-17 tile is ~247 m wide at Mashhad');
});
diff --git a/vite.config.js b/vite.config.js
index 6d477ec..0bc4c7b 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,29 +1,37 @@
-import {defineConfig} from 'vite';
-import {readdirSync, statSync} from 'node:fs';
-import {join, relative} from 'node:path';
-import {createHash} from 'node:crypto';
+import { defineConfig } from 'vite';
+import { readdirSync, statSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { createHash } from 'node:crypto';
-const listFiles = dir => readdirSync(dir).flatMap(name => {
+const listFiles = (dir) =>
+ readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
return statSync(path).isDirectory() ? listFiles(path) : [path];
-});
+ });
// Emits sw.js listing every built file, so the installed game works offline. The cache name is a
// hash of that list: a new build gets a new cache and the page offers to reload into it.
// Satellite tiles (16 MB) are not precached; they are kept as the player passes them.
function offlineServiceWorker() {
- return {
- name: 'offline-service-worker',
- apply: 'build',
- generateBundle(_, bundle) {
- const files = [...Object.keys(bundle), ...listFiles('public').map(f => relative('public', f).replaceAll('\\', '/'))]
- .filter(f => f !== 'sw.js' && !f.endsWith('.map') && !(f.startsWith('imagery/') && f.endsWith('.jpg')));
- const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
- this.emitFile({
- type: 'asset', fileName: 'sw.js', source: `// Generated by vite.config.js — do not edit.
+ return {
+ name: 'offline-service-worker',
+ apply: 'build',
+ generateBundle(_, bundle) {
+ const files = [
+ ...Object.keys(bundle),
+ ...listFiles('public').map((f) => relative('public', f).replaceAll('\\', '/')),
+ ].filter(
+ (f) =>
+ f !== 'sw.js' && !f.endsWith('.map') && !(f.startsWith('imagery/') && f.endsWith('.jpg')),
+ );
+ const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
+ this.emitFile({
+ type: 'asset',
+ fileName: 'sw.js',
+ source: `// Generated by vite.config.js — do not edit.
const CACHE = 'robot-market-${version}';
const TILES = 'robot-market-imagery';
-const FILES = ${JSON.stringify(['./', ...files.map(f => `./${f}`)])};
+const FILES = ${JSON.stringify(['./', ...files.map((f) => `./${f}`)])};
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES))));
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k.startsWith('robot-market-') && k !== CACHE && k !== TILES).map(k => caches.delete(k)))).then(() => self.clients.claim())));
@@ -46,23 +54,20 @@ self.addEventListener('fetch', event => {
}
event.respondWith(caches.match(request, {ignoreSearch: true}).then(hit => hit ?? fetch(request)));
});
-`
- });
- }
- };
+`,
+ });
+ },
+ };
}
export default defineConfig({
- // Relative assets also load under a GitHub Pages repository path.
- base: './',
- plugins: [offlineServiceWorker()],
- // Top-level await loads the building and imagery files before the world is built.
- build: {target: 'es2022'},
- server: {
- host: true,
- allowedHosts: [
- 'robotmarkets.ir',
- 'www.robotmarkets.ir',
- ],
- },
+ // Relative assets also load under a GitHub Pages repository path.
+ base: './',
+ plugins: [offlineServiceWorker()],
+ // Top-level await loads the building and imagery files before the world is built.
+ build: { target: 'es2022' },
+ server: {
+ host: true,
+ allowedHosts: ['robotmarkets.ir', 'www.robotmarkets.ir'],
+ },
});