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'], + }, });