chore: establish formatting baseline and verification scripts

This commit is contained in:
2026-10-28 12:11:36 +03:30
parent 751af768ce
commit 08b6cd464b
33 changed files with 7264 additions and 5006 deletions
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
src/data
public
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}
+17
View File
@@ -13,6 +13,7 @@
"three": "^0.180.0" "three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.9.10",
"vite": "^6.4.3" "vite": "^6.4.3"
} }
}, },
@@ -1026,6 +1027,22 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rollup": {
"version": "4.64.10", "version": "4.64.10",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz",
+7 -1
View File
@@ -5,7 +5,12 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0",
"format": "prettier --write \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" 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": [], "keywords": [],
"author": "", "author": "",
@@ -16,6 +21,7 @@
"three": "^0.180.0" "three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.9.10",
"vite": "^6.4.3" "vite": "^6.4.3"
} }
} }
+36 -15
View File
@@ -7,7 +7,9 @@ import * as THREE from 'three';
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°. // The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
const CAMERA_LONG_FOV = 65; const CAMERA_LONG_FOV = 65;
const zee = new THREE.Vector3(0, 0, 1), 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 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. // Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
@@ -23,30 +25,34 @@ export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
// Vertical field of view of what is visible once the video is scaled to cover the screen. // Vertical field of view of what is visible once the video is scaled to cover the screen.
export function coverFov(videoAspect, screenAspect) { export function coverFov(videoAspect, screenAspect) {
const long = Math.tan(CAMERA_LONG_FOV * Math.PI / 360); const long = Math.tan((CAMERA_LONG_FOV * Math.PI) / 360);
// The video's own vertical and horizontal half-angle tangents. // The video's own vertical and horizontal half-angle tangents.
const vTan = videoAspect >= 1 ? long / videoAspect : long, hTan = videoAspect >= 1 ? long : long * videoAspect; 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. // "cover" fills the screen: if the screen is wider than the video, the full width shows.
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan; const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
return Math.atan(visibleV) * 360 / Math.PI; return (Math.atan(visibleV) * 360) / Math.PI;
} }
export function createArView(video) { export function createArView(video) {
let stream = null, orientation = null, compassOffset = null, absoluteSeen = false; let stream = null,
orientation = null,
compassOffset = null,
absoluteSeen = false;
const target = new THREE.Quaternion(); const target = new THREE.Quaternion();
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers // Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive. // only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
const onAbsolute = event => { const onAbsolute = (event) => {
if (event.alpha == null) return; if (event.alpha == null) return;
absoluteSeen = true; absoluteSeen = true;
orientation = { alpha: event.alpha, beta: event.beta, gamma: event.gamma }; orientation = { alpha: event.alpha, beta: event.beta, gamma: event.gamma };
}; };
const onOrientation = event => { const onOrientation = (event) => {
if (event.alpha == null || absoluteSeen) return; if (event.alpha == null || absoluteSeen) return;
let alpha = event.alpha; let alpha = event.alpha;
if (event.webkitCompassHeading != null) { if (event.webkitCompassHeading != null) {
// iOS: alpha is relative to the start pose; the compass heading pins it to north. // iOS: alpha is relative to the start pose; the compass heading pins it to north.
compassOffset = (360 - event.webkitCompassHeading) - event.alpha; compassOffset = 360 - event.webkitCompassHeading - event.alpha;
} }
if (!event.absolute && compassOffset != null) alpha += compassOffset; if (!event.absolute && compassOffset != null) alpha += compassOffset;
orientation = { alpha, beta: event.beta, gamma: event.gamma }; orientation = { alpha, beta: event.beta, gamma: event.gamma };
@@ -77,12 +83,22 @@ export function createArView(video) {
// Opens the rear camera. Resolves to null on success or to a message explaining the failure. // Opens the rear camera. Resolves to null on success or to a message explaining the failure.
async start() { async start() {
if (stream) return null; if (stream) return null;
if (!navigator.mediaDevices?.getUserMedia) return 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).'; if (!navigator.mediaDevices?.getUserMedia)
return 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).';
try { try {
stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: {ideal: 'environment'}, width: {ideal: 1280}, height: {ideal: 720}}, audio: false}); stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
} catch (error) { } catch (error) {
stream = null; stream = null;
return error?.name === 'NotAllowedError' ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.' : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟'; return error?.name === 'NotAllowedError'
? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟';
} }
video.srcObject = stream; video.srcObject = stream;
video.hidden = false; video.hidden = false;
@@ -92,7 +108,7 @@ export function createArView(video) {
}, },
// Closes the camera picture; the sensors keep running for the first-person view. // Closes the camera picture; the sensors keep running for the first-person view.
stop() { stop() {
stream?.getTracks().forEach(track => track.stop()); stream?.getTracks().forEach((track) => track.stop());
stream = null; stream = null;
video.srcObject = null; video.srcObject = null;
video.hidden = true; video.hidden = true;
@@ -100,8 +116,13 @@ export function createArView(video) {
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it. // 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. // `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
update(camera, dt, smoothing = 8) { update(camera, dt, smoothing = 8) {
const fov = stream ? coverFov(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3, innerWidth / innerHeight) : 62; const fov = stream
if (Math.abs(camera.fov - fov) > .1) { ? 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.fov = fov;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
@@ -110,6 +131,6 @@ export function createArView(video) {
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle); orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing)); camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
return true; return true;
} },
}; };
} }
+8 -3
View File
@@ -4,15 +4,20 @@ export function decodeBuildings(buffer) {
const view = new DataView(buffer); const view = new DataView(buffer);
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4)); const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`); 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 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); const buildings = new Array(count);
let o = 20; let o = 20;
for (let b = 0; b < count; b++) { for (let b = 0; b < count; b++) {
const n = view.getUint8(o), height = view.getUint8(o + 1) * .5; const n = view.getUint8(o),
height = view.getUint8(o + 1) * 0.5;
o += 2; o += 2;
const building = new Array(1 + n * 2); const building = new Array(1 + n * 2);
building[0] = height; building[0] = height;
let qx = 0, qz = 0; let qx = 0,
qz = 0;
for (let i = 0; i < n; i++, o += 4) { for (let i = 0; i < n; i++, o += 4) {
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true); qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true); qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
+265 -99
View File
@@ -3,13 +3,21 @@ import * as THREE from 'three';
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like // Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones. // createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
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. // A side profile (x along the car, y up) extruded across its width, with rounded edges.
function extrudeProfile(points, width, material, bevel = .1) { function extrudeProfile(points, width, material, bevel = 0.1) {
const shape = new THREE.Shape(); const shape = new THREE.Shape();
points.forEach(([x, y], i) => i ? shape.lineTo(x, y) : shape.moveTo(x, y)); points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
const geometry = new THREE.ExtrudeGeometry(shape, {depth: width - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel * .8, bevelSegments: 3, curveSegments: 6}); 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); geometry.translate(0, 0, -(width - bevel * 2) / 2);
const mesh = new THREE.Mesh(geometry, material); const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = mesh.receiveShadow = true; mesh.castShadow = mesh.receiveShadow = true;
@@ -20,11 +28,11 @@ function extrudeProfile(points, width, material, bevel = .1) {
function bodyOutline(front, rear, arches, radius, top) { function bodyOutline(front, rear, arches, radius, top) {
const shape = new THREE.Shape(); const shape = new THREE.Shape();
shape.moveTo(rear[0][0], rear[0][1]); shape.moveTo(rear[0][0], rear[0][1]);
const bottom = .3; const bottom = 0.3;
// Underside from the rear towards the front, arching over each wheel. // Underside from the rear towards the front, arching over each wheel.
for (const ax of [...arches].sort((a, b) => a - b)) { for (const ax of [...arches].sort((a, b) => a - b)) {
shape.lineTo(ax - radius, bottom); shape.lineTo(ax - radius, bottom);
shape.absarc(ax, .36, radius, Math.PI + .14, -.14, true); 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 front) shape.lineTo(x, y);
for (const [x, y] of top) shape.lineTo(x, y); for (const [x, y] of top) shape.lineTo(x, y);
@@ -33,7 +41,9 @@ function bodyOutline(front, rear, arches, radius, top) {
} }
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) { function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
const tyre = mat('#151719', {roughness: .9}), rim = mat(rimColor, {metalness: .85, roughness: .25}), brake = mat('#3a3d40', {metalness: .6}); const tyre = mat('#151719', { roughness: 0.9 }),
rim = mat(rimColor, { metalness: 0.85, roughness: 0.25 }),
brake = mat('#3a3d40', { metalness: 0.6 });
const wheels = []; const wheels = [];
for (const [x, z] of positions) { for (const [x, z] of positions) {
const wheel = new THREE.Group(); const wheel = new THREE.Group();
@@ -43,19 +53,25 @@ function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', sp
t.castShadow = true; t.castShadow = true;
wheel.add(t); wheel.add(t);
const side = Math.sign(z); const side = Math.sign(z);
const face = new THREE.Mesh(new THREE.CylinderGeometry(radius * .68, radius * .68, .02, 24), brake); 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.rotation.x = Math.PI / 2;
face.position.z = side * tyreWidth * .44; face.position.z = side * tyreWidth * 0.44;
wheel.add(face); wheel.add(face);
for (let i = 0; i < spokes; i++) { for (let i = 0; i < spokes; i++) {
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * .16, .04), rim); const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * 0.16, 0.04), rim);
spoke.position.z = side * tyreWidth * .5; spoke.position.z = side * tyreWidth * 0.5;
spoke.rotation.z = i * Math.PI / spokes; spoke.rotation.z = (i * Math.PI) / spokes;
wheel.add(spoke); wheel.add(spoke);
} }
const hub = new THREE.Mesh(new THREE.CylinderGeometry(radius * .2, radius * .2, .05, 12), rim); 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.rotation.x = Math.PI / 2;
hub.position.z = side * tyreWidth * .52; hub.position.z = side * tyreWidth * 0.52;
wheel.add(hub); wheel.add(hub);
group.add(wheel); group.add(wheel);
wheels.push(wheel); wheels.push(wheel);
@@ -70,99 +86,165 @@ function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', sp
*/ */
export function createSportSedan(color = '#1d3f78') { export function createSportSedan(color = '#1d3f78') {
const group = new THREE.Group(); const group = new THREE.Group();
const paint = mat(color, {metalness: .7, roughness: .28}), glass = mat('#0f1a22', {metalness: .7, roughness: .08}); const paint = mat(color, { metalness: 0.7, roughness: 0.28 }),
const black = mat('#0b0c0d', {roughness: .6}), chrome = mat('#d8dcdf', {metalness: .95, roughness: .15}); glass = mat('#0f1a22', { metalness: 0.7, roughness: 0.08 });
const black = mat('#0b0c0d', { roughness: 0.6 }),
chrome = mat('#d8dcdf', { metalness: 0.95, roughness: 0.15 });
const W = 1.86; const W = 1.86;
const outline = bodyOutline( const outline = bodyOutline(
[[2.24, .3], [2.36, .42], [2.38, .62], [2.28, .78]], [
[[-2.3, .3], [-2.36, .55], [-2.3, .86], [-2.12, .97]], [2.24, 0.3],
[-1.42, 1.43], .41, [2.36, 0.42],
[[1.9, .86], [.95, 1.0], [-1.2, 1.0], [-1.6, .99]] [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 - .2, bevelEnabled: true, bevelThickness: .1, bevelSize: .08, bevelSegments: 3, curveSegments: 10}); const bodyGeometry = new THREE.ExtrudeGeometry(outline, {
bodyGeometry.translate(0, 0, -(W - .2) / 2); 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); const body = new THREE.Mesh(bodyGeometry, paint);
body.castShadow = body.receiveShadow = true; body.castShadow = body.receiveShadow = true;
group.add(body); group.add(body);
// Greenhouse: windscreen, roof and the fast rear window, narrower than the 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); 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); group.add(cabin);
const roof = extrudeProfile([[.0, 1.37], [-.88, 1.39], [-1.0, 1.32], [.12, 1.32]], 1.36, paint, .06); const roof = extrudeProfile(
roof.position.y = .045; [
[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); 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 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(); const pillarPaint = paint.clone();
pillarPaint.side = THREE.DoubleSide; pillarPaint.side = THREE.DoubleSide;
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides. // Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const z = side * .72; const z = side * 0.72;
const b = new THREE.Mesh(new THREE.BoxGeometry(.09, .4, .04), black); const b = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.4, 0.04), black);
b.position.set(-.35, 1.18, z); b.position.set(-0.35, 1.18, z);
group.add(b); group.add(b);
const c = new THREE.Mesh(pillar, pillarPaint); const c = new THREE.Mesh(pillar, pillarPaint);
c.position.z = z + side * .035; c.position.z = z + side * 0.035;
group.add(c); group.add(c);
// Door handles, mirror and the chrome window line. // Door handles, mirror and the chrome window line.
for (const x of [.15, -.75]) { for (const x of [0.15, -0.75]) {
const handle = new THREE.Mesh(new THREE.BoxGeometry(.18, .04, .03), chrome); const handle = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.04, 0.03), chrome);
handle.position.set(x, .9, side * .94); handle.position.set(x, 0.9, side * 0.94);
group.add(handle); group.add(handle);
} }
const mirror = new THREE.Mesh(new THREE.BoxGeometry(.18, .12, .2), paint); const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.2), paint);
mirror.position.set(.78, 1.05, side * 1.0); mirror.position.set(0.78, 1.05, side * 1.0);
group.add(mirror); group.add(mirror);
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, .025, .02), chrome); const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.025, 0.02), chrome);
trim.position.set(-.3, .985, side * .76); trim.position.set(-0.3, 0.985, side * 0.76);
group.add(trim); group.add(trim);
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, .08, .04), black); const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.08, 0.04), black);
skirt.position.set(0, .34, side * .93); skirt.position.set(0, 0.34, side * 0.93);
group.add(skirt); group.add(skirt);
} }
// Twin kidney grille with chrome surrounds. // Twin kidney grille with chrome surrounds.
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const surround = new THREE.Mesh(new THREE.BoxGeometry(.06, .2, .3), chrome); const surround = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, 0.3), chrome);
surround.position.set(2.46, .66, side * .18); surround.position.set(2.46, 0.66, side * 0.18);
group.add(surround); group.add(surround);
const grille = new THREE.Mesh(new THREE.BoxGeometry(.07, .16, .25), black); const grille = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.16, 0.25), black);
grille.position.set(2.47, .66, side * .18); grille.position.set(2.47, 0.66, side * 0.18);
group.add(grille); group.add(grille);
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(.075, .15, .012), chrome); const bar = new THREE.Mesh(new THREE.BoxGeometry(0.075, 0.15, 0.012), chrome);
bar.position.set(2.47, .66, side * .18 + (i - 2) * .05); bar.position.set(2.47, 0.66, side * 0.18 + (i - 2) * 0.05);
group.add(bar); group.add(bar);
} }
} }
// Headlights with two light rings each, and slim L-shaped tail lights. // Headlights with two light rings each, and slim L-shaped tail lights.
const ring = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 }); const ring = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 });
const tail = mat('#9e1410', {emissive: '#e0261c', emissiveIntensity: .9}); const tail = mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const housing = new THREE.Mesh(new THREE.BoxGeometry(.12, .14, .44), black); const housing = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.14, 0.44), black);
housing.position.set(2.42, .72, side * .6); housing.position.set(2.42, 0.72, side * 0.6);
group.add(housing); group.add(housing);
for (const dz of [-.1, .1]) { for (const dz of [-0.1, 0.1]) {
const eye = new THREE.Mesh(new THREE.TorusGeometry(.05, .012, 6, 18), ring); const eye = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 6, 18), ring);
eye.rotation.y = Math.PI / 2; eye.rotation.y = Math.PI / 2;
eye.position.set(2.48, .72, side * .6 + dz); eye.position.set(2.48, 0.72, side * 0.6 + dz);
group.add(eye); group.add(eye);
} }
const tl = new THREE.Mesh(new THREE.BoxGeometry(.06, .1, .42), tail); const tl = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.1, 0.42), tail);
tl.position.set(-2.45, .82, side * .62); tl.position.set(-2.45, 0.82, side * 0.62);
group.add(tl); group.add(tl);
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(.3, .1, .05), tail); const tlSide = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.1, 0.05), tail);
tlSide.position.set(-2.2, .82, side * .9); tlSide.position.set(-2.2, 0.82, side * 0.9);
group.add(tlSide); group.add(tlSide);
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .12, 12), chrome); const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.12, 12), chrome);
exhaust.rotation.z = Math.PI / 2; exhaust.rotation.z = Math.PI / 2;
exhaust.position.set(-2.44, .36, side * .45); exhaust.position.set(-2.44, 0.36, side * 0.45);
group.add(exhaust); group.add(exhaust);
} }
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(.05, .12, 1.2), black); const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.12, 1.2), black);
lowerGrille.position.set(2.45, .42, 0); lowerGrille.position.set(2.45, 0.42, 0);
group.add(lowerGrille); group.add(lowerGrille);
const plate = new THREE.Mesh(new THREE.BoxGeometry(.03, .11, .5), mat('#f4f4f0')); const plate = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.11, 0.5), mat('#f4f4f0'));
plate.position.set(2.47, .5, 0); plate.position.set(2.47, 0.5, 0);
group.add(plate); group.add(plate);
const wheels = addWheels(group, [[-1.42, -.82], [-1.42, .82], [1.43, -.82], [1.43, .82]], .36, .26, '#b8bcc0', 5); 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 }; return { group, wheels };
} }
@@ -172,8 +254,10 @@ export function createSportSedan(color = '#1d3f78') {
*/ */
export function createPickup(color = '#e8e8e4') { export function createPickup(color = '#e8e8e4') {
const group = new THREE.Group(); const group = new THREE.Group();
const paint = mat(color, {metalness: .5, roughness: .32}), glass = mat('#13202a', {metalness: .6, roughness: .1}); const paint = mat(color, { metalness: 0.5, roughness: 0.32 }),
const black = mat('#1a1c1e', {roughness: .7}), chrome = mat('#d0d4d7', {metalness: .9, roughness: .2}); glass = mat('#13202a', { metalness: 0.6, roughness: 0.1 });
const black = mat('#1a1c1e', { roughness: 0.7 }),
chrome = mat('#d0d4d7', { metalness: 0.9, roughness: 0.2 });
const box = (w, h, d, m, x, y, z) => { const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z); mesh.position.set(x, y, z);
@@ -181,31 +265,90 @@ export function createPickup(color = '#e8e8e4') {
group.add(mesh); group.add(mesh);
return 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(
group.add(extrudeProfile([[1.3, .5], [1.3, 1.24], [.7, 1.86], [-.95, 1.88], [-1.0, .5]], 1.88, paint, .1)); extrudeProfile(
group.add(extrudeProfile([[1.24, 1.26], [.68, 1.82], [-.9, 1.84], [-.94, 1.26]], 1.92, glass, .02)); [
[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. // Cargo bed: floor, sides and tailgate.
box(1.62, .1, 1.8, black, -1.85, .72, 0); box(1.62, 0.1, 1.8, black, -1.85, 0.72, 0);
for (const side of [-1, 1]) box(1.66, .5, .07, paint, -1.85, .95, side * .9); for (const side of [-1, 1]) box(1.66, 0.5, 0.07, paint, -1.85, 0.95, side * 0.9);
box(.07, .5, 1.86, paint, -2.66, .95, 0); box(0.07, 0.5, 1.86, paint, -2.66, 0.95, 0);
box(1.66, .46, 1.9, paint, -1.85, .5, 0); box(1.66, 0.46, 1.9, paint, -1.85, 0.5, 0);
box(.3, .06, 1.86, black, -1.02, 1.26, 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. // Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
box(.06, .34, 1.1, black, 2.67, .92, 0); box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0);
for (let i = 0; i < 3; i++) box(.07, .04, 1.12, chrome, 2.68, .82 + i * .1, 0); for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0);
const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.2 }); const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.2 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
box(.08, .14, .32, lamp, 2.62, 1.02, side * .74); box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
box(.06, .26, .1, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .7}), -2.7, 1.0, side * .86); box(
box(.2, .14, .2, black, 1.2, 1.36, side * 1.02); 0.06,
box(1.0, .05, .06, chrome, -.1, 1.25, side * .97); 0.26,
for (const x of [.6, -.4]) box(.16, .04, .03, chrome, x, 1.12, side * .97); 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(.16, .2, 2.0, black, 2.7, .55, 0); box(0.16, 0.2, 2.0, black, 2.7, 0.55, 0);
box(.16, .2, 2.0, black, -2.72, .55, 0); box(0.16, 0.2, 2.0, black, -2.72, 0.55, 0);
box(.04, .12, .5, mat('#f4f4f0'), 2.79, .6, 0); box(0.04, 0.12, 0.5, mat('#f4f4f0'), 2.79, 0.6, 0);
const wheels = addWheels(group, [[-1.7, -.86], [-1.7, .86], [1.7, -.86], [1.7, .86]], .4, .3, '#a9adb1', 6); const wheels = addWheels(
for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, .1, .2, black, x, .86, side * .96); 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 }; return { group, wheels };
} }
@@ -215,8 +358,10 @@ export function createPickup(color = '#e8e8e4') {
*/ */
export function createTruck(color = '#e9eef0') { export function createTruck(color = '#e9eef0') {
const group = new THREE.Group(); const group = new THREE.Group();
const paint = mat(color, {metalness: .35, roughness: .4}), glass = mat('#14212b', {metalness: .6, roughness: .1}); const paint = mat(color, { metalness: 0.35, roughness: 0.4 }),
const black = mat('#1b1d1f', {roughness: .7}), teal = mat('#0b8f88', {metalness: .3, roughness: .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 box = (w, h, d, m, x, y, z, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z); mesh.position.set(x, y, z);
@@ -226,25 +371,46 @@ export function createTruck(color = '#e9eef0') {
}; };
// Cab over the front axle. // Cab over the front axle.
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0); box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
box(.05, .9, 2.0, glass, 3.91, 2.35, 0); box(0.05, 0.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); for (const side of [-1, 1]) box(1.0, 0.7, 0.04, glass, 3.1, 2.4, side * 1.16);
box(.08, .3, 2.2, teal, 3.92, 1.35, 0); box(0.08, 0.3, 2.2, teal, 3.92, 1.35, 0);
box(.25, .35, 2.36, black, 3.95, .85, 0); box(0.25, 0.35, 2.36, black, 3.95, 0.85, 0);
const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1 }); const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1 });
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
box(.06, .16, .3, lamp, 3.98, 1.1, side * .9); box(0.06, 0.16, 0.3, lamp, 3.98, 1.1, side * 0.9);
box(.3, .3, .08, black, 3.4, 2.7, side * 1.25); box(0.3, 0.3, 0.08, black, 3.4, 2.7, side * 1.25);
} }
// Chassis and the flatbed with low side rails. // Chassis and the flatbed with low side rails.
box(7.6, .3, 1.0, black, 0, .75, 0); box(7.6, 0.3, 1.0, black, 0, 0.75, 0);
box(5.9, .14, 2.4, mat('#6b5a45', {roughness: .9}), -1.05, 1.0, 0); box(5.9, 0.14, 2.4, mat('#6b5a45', { roughness: 0.9 }), -1.05, 1.0, 0);
for (const side of [-1, 1]) box(5.9, .5, .06, teal, -1.05, 1.32, side * 1.19); for (const side of [-1, 1]) box(5.9, 0.5, 0.06, teal, -1.05, 1.32, side * 1.19);
box(.06, .5, 2.4, teal, -4.0, 1.32, 0); box(0.06, 0.5, 2.4, teal, -4.0, 1.32, 0);
box(.1, 1.6, 2.4, black, 1.9, 1.85, 0); box(0.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); 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(); const bed = new THREE.Group();
bed.position.set(-1.05, 1.07, 0); bed.position.set(-1.05, 1.07, 0);
group.add(bed); 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); 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 }; return { group, wheels, bed };
} }
+287 -101
View File
@@ -4,8 +4,10 @@ import * as THREE from 'three';
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone. // createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
export function createFamily({ robot: robotOnly = false } = {}) { export function createFamily({ robot: robotOnly = false } = {}) {
const group = new THREE.Group(); const group = new THREE.Group();
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82}); const mat = (color) => new THREE.MeshStandardMaterial({ color, roughness: 0.82 });
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36'); const skin = mat('#edbd98'),
hair = mat('#49382d'),
shoes = mat('#273c36');
function shape(parent, geometry, material, position) { function shape(parent, geometry, material, position) {
const m = new THREE.Mesh(geometry, material); const m = new THREE.Mesh(geometry, material);
@@ -29,196 +31,380 @@ export function createFamily({robot: robotOnly = false} = {}) {
function updateSegment(bone, a, b) { function updateSegment(bone, a, b) {
const delta = b.clone().sub(a); const delta = b.clone().sub(a);
bone.mesh.position.copy(a).add(b).multiplyScalar(.5); bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
bone.mesh.scale.y = delta.length(); bone.mesh.scale.y = delta.length();
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize()); 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. // Procedural Robot Market mascot: a hovering guide that follows the father and child.
function makeRobot(z) { function makeRobot(z) {
const scale = .69, root = new THREE.Group(), torso = new THREE.Group(); const scale = 0.69,
root = new THREE.Group(),
torso = new THREE.Group();
root.name = 'robot-market-mascot'; root.name = 'robot-market-mascot';
root.position.z = z; root.position.z = z;
group.add(root); group.add(root);
root.add(torso); root.add(torso);
const teal = new THREE.MeshStandardMaterial({color: '#079f98', metalness: .4, roughness: .28}); const teal = new THREE.MeshStandardMaterial({
const white = new THREE.MeshStandardMaterial({color: '#edf6f4', metalness: .22, roughness: .32}); color: '#079f98',
const joint = new THREE.MeshStandardMaterial({color: '#102b30', metalness: .5, roughness: .35}); metalness: 0.4,
const glow = new THREE.MeshStandardMaterial({color: '#c6f7ff', emissive: '#23bcff', emissiveIntensity: 2.5, roughness: .2}); roughness: 0.28,
const visor = new THREE.MeshStandardMaterial({color: '#020d16', metalness: .5, roughness: .16}); });
sphere(torso, .25, teal, [0, .76, 0], [1, .65, .75]); const white = new THREE.MeshStandardMaterial({
sphere(torso, .31, white, [0, .97, 0], [1, 1.05, .72]); color: '#edf6f4',
for (const side of [-1, 1]) sphere(torso, .17, teal, [side * .24, 1.03, -.04], [.55, 1, 1]); metalness: 0.22,
shape(torso, new THREE.CylinderGeometry(.09, .09, .12, 12), joint, [0, 1.27, 0]); 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(); const head = new THREE.Group();
head.position.y = 1.61; head.position.y = 1.61;
torso.add(head); torso.add(head);
sphere(head, .43, teal, [0, 0, 0], [1.12, .91, .88]); sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]);
sphere(head, .40, white, [0, -.015, .12], [1.12, .86, .77]); sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]);
sphere(head, .35, visor, [0, -.015, .255], [1.13, .83, .48]); sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
const eyes = []; const eyes = [];
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) { 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); const line = shape(
parent,
new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
material,
pos,
);
line.rotation.z = rotation; line.rotation.z = rotation;
return line; return line;
} }
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
eyes.push(arc(head, .065, .016, [side * .16, .025, .423])); eyes.push(arc(head, 0.065, 0.016, [side * 0.16, 0.025, 0.423]));
const ear = shape(head, new THREE.CylinderGeometry(.14, .14, .085, 24), joint, [side * .47, 0, 0]); 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; ear.rotation.z = Math.PI / 2;
const ring = shape(head, new THREE.TorusGeometry(.105, .012, 8, 24), glow, [side * .518, 0, 0]); 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; ring.rotation.y = Math.PI / 2;
sphere(head, .12, teal, [side * .49, 0, 0], [.4, 1, 1]); sphere(head, 0.12, teal, [side * 0.49, 0, 0], [0.4, 1, 1]);
} }
arc(head, .075, .016, [0, -.085, .425], Math.PI); arc(head, 0.075, 0.016, [0, -0.085, 0.425], Math.PI);
sphere(head, .10, teal, [0, .38, 0], [1, .55, 1]); sphere(head, 0.1, teal, [0, 0.38, 0], [1, 0.55, 1]);
shape(head, new THREE.CylinderGeometry(.017, .022, .18, 10), teal, [0, .49, 0]); shape(head, new THREE.CylinderGeometry(0.017, 0.022, 0.18, 10), teal, [0, 0.49, 0]);
sphere(head, .055, teal, [0, .60, 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. // Draw the chest mark as a texture so it remains attached to the 3D body.
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 256; canvas.width = 256;
canvas.height = 256;
const c = canvas.getContext('2d'); const c = canvas.getContext('2d');
if (c) { if (c) {
c.strokeStyle = '#102b30'; c.fillStyle = '#102b30'; c.strokeStyle = '#102b30';
c.lineWidth = 12; c.lineJoin = 'round'; c.fillStyle = '#102b30';
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(); c.lineWidth = 12;
for (const x of [111, 162]) {c.beginPath(); c.arc(x, 130, 9, 0, Math.PI * 2); c.fill();} c.lineJoin = 'round';
c.textAlign = 'center'; c.font = 'bold 34px sans-serif'; c.beginPath();
c.fillText('ROBOT', 128, 181); c.fillText('MARKET', 128, 218); c.moveTo(65, 40);
shape(torso, new THREE.PlaneGeometry(.31, .31), new THREE.MeshBasicMaterial({map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false}), [0, .99, .224]); c.lineTo(82, 40);
c.lineTo(99, 110);
c.lineTo(170, 110);
c.lineTo(186, 58);
c.lineTo(89, 58);
c.stroke();
for (const x of [111, 162]) {
c.beginPath();
c.arc(x, 130, 9, 0, Math.PI * 2);
c.fill();
}
c.textAlign = 'center';
c.font = 'bold 34px sans-serif';
c.fillText('ROBOT', 128, 181);
c.fillText('MARKET', 128, 218);
shape(
torso,
new THREE.PlaneGeometry(0.31, 0.31),
new THREE.MeshBasicMaterial({
map: new THREE.CanvasTexture(canvas),
transparent: true,
depthWrite: false,
}),
[0, 0.99, 0.224],
);
} }
} }
const legs = [-1, 1].map(side => { const legs = [-1, 1].map((side) => {
const hip = new THREE.Group(); hip.position.set(side * .15, .65, 0); root.add(hip); const hip = new THREE.Group();
sphere(hip, .09, joint, [0, 0, 0]); hip.position.set(side * 0.15, 0.65, 0);
shape(hip, new THREE.CapsuleGeometry(.095, .12, 4, 12), white, [0, -.12, 0]); root.add(hip);
const knee = new THREE.Group(); knee.position.y = -.25; hip.add(knee); sphere(hip, 0.09, joint, [0, 0, 0]);
sphere(knee, .105, teal, [0, 0, .035]); shape(hip, new THREE.CapsuleGeometry(0.095, 0.12, 4, 12), white, [0, -0.12, 0]);
shape(knee, new THREE.CapsuleGeometry(.10, .12, 4, 12), white, [0, -.13, 0]); const knee = new THREE.Group();
const foot = sphere(knee, .13, teal, [0, -.29, .07], [1, .65, 1.6]); knee.position.y = -0.25;
sphere(knee, .13, joint, [0, -.34, .07], [1.06, .22, 1.65]); 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 }; return { hip, knee, foot };
}); });
const arms = [-1, 1].map(() => ({ const arms = [-1, 1].map(() => ({
upper: segment(group, null, null, .09, teal), upper: segment(group, null, null, 0.09, teal),
lower: segment(group, null, null, .085, white), lower: segment(group, null, null, 0.085, white),
hand: sphere(group, .09, joint, [0, 0, 0], [1, 1.1, 1]) hand: sphere(group, 0.09, joint, [0, 0, 0], [1, 1.1, 1]),
})); }));
return { root, torso, head, legs, arms, scale, z, eyes }; return { root, torso, head, legs, arms, scale, z, eyes };
} }
function makePerson(isChild, z) { function makePerson(isChild, z) {
const scale = isChild ? .69 : 1.05, root = new THREE.Group(); const scale = isChild ? 0.69 : 1.05,
root = new THREE.Group();
root.position.z = z; root.position.z = z;
group.add(root); group.add(root);
const torso = new THREE.Group(); const torso = new THREE.Group();
root.add(torso); root.add(torso);
const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474'); const cloth = mat(isChild ? '#d98150' : '#e4c695'),
pants = mat(isChild ? '#527463' : '#496474');
// Tapered jacket, hips and neck replace the single rounded toy-like body. // 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; shape(torso, new THREE.CylinderGeometry(0.255 * scale, 0.22 * scale, 0.62 * scale, 16), cloth, [
sphere(torso, .23, pants, [0, .88 * scale, 0], [scale, .55 * scale, .72 * scale]); 0,
shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]); 1.2 * scale,
const seam = shape(torso, new THREE.BoxGeometry(.014 * scale, .5 * scale, .014 * scale), mat('#bda77f'), [0, 1.2 * scale, .19 * 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]) { 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]); const collar = shape(
collar.rotation.z = side * .4; torso,
new THREE.BoxGeometry(0.12 * scale, 0.09 * scale, 0.025 * scale),
cloth,
[side * 0.07 * scale, 1.49 * scale, 0.15 * scale],
);
collar.rotation.z = side * 0.4;
} }
const head = new THREE.Group(); const head = new THREE.Group();
head.position.y = 1.77 * scale; head.position.y = 1.77 * scale;
torso.add(head); torso.add(head);
head.scale.setScalar(isChild ? .9 : .77); head.scale.setScalar(isChild ? 0.9 : 0.77);
const eyes = []; const eyes = [];
sphere(head, .27 * scale, skin, [0, 0, 0]); sphere(head, 0.27 * scale, skin, [0, 0, 0]);
sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]); sphere(head, 0.275 * scale, hair, [0, 0.11 * scale, -0.035 * scale], [1, 0.65, 1]);
sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]); sphere(head, 0.058 * scale, skin, [0, -0.02 * scale, 0.25 * scale]);
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]); sphere(head, 0.06 * scale, skin, [side * 0.26 * scale, 0, 0]);
sphere(head, .037 * scale, mat('#f6f1e7'), [side * .09 * scale, .03 * scale, .241 * scale], [1, .65, .5]); sphere(
const eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]); 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); eyes.push(eye);
const brow = shape(head, new THREE.BoxGeometry(.075 * scale, .015 * scale, .015 * scale), hair, [side * .09 * scale, .087 * scale, .237 * scale]); const brow = shape(
brow.rotation.z = side * -.08; head,
sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]); 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(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * 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; smile.rotation.z = Math.PI;
if (isChild) { if (isChild) {
sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]); sphere(head, 0.29 * scale, mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]);
shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]); shape(head, new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale), mat('#e8b857'), [
0,
0.13 * scale,
0.12 * scale,
]);
} else { } else {
sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]); 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 legs = [-1, 1].map((side) => {
const hip = new THREE.Group(); const hip = new THREE.Group();
hip.position.set(side * .15 * scale, .83 * scale, 0); hip.position.set(side * 0.15 * scale, 0.83 * scale, 0);
root.add(hip); root.add(hip);
shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]); shape(hip, new THREE.CapsuleGeometry(0.09 * scale, 0.23 * scale, 4, 10), pants, [
0,
-0.16 * scale,
0,
]);
const knee = new THREE.Group(); const knee = new THREE.Group();
knee.position.y = -.34 * scale; knee.position.y = -0.34 * scale;
hip.add(knee); hip.add(knee);
shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]); shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [
const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]); 0,
foot.scale.set(.85, .65, 1.5); -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 }; return { hip, knee, foot };
}); });
const arms = [-1, 1].map(() => ({ const arms = [-1, 1].map(() => ({
upper: segment(group, null, null, .072 * scale, cloth), upper: segment(group, null, null, 0.072 * scale, cloth),
lower: segment(group, null, null, .061 * scale, skin), lower: segment(group, null, null, 0.061 * scale, skin),
hand: sphere(group, .075 * scale, skin, [0, 0, 0]) hand: sphere(group, 0.075 * scale, skin, [0, 0, 0]),
})); }));
return { root, torso, head, legs, arms, scale, z, eyes }; return { root, torso, head, legs, arms, scale, z, eyes };
} }
const father = robotOnly ? null : makePerson(false, -.45), child = robotOnly ? null : makePerson(true, .45); const father = robotOnly ? null : makePerson(false, -0.45),
child = robotOnly ? null : makePerson(true, 0.45);
const robot = robotOnly ? makeRobot(0) : null; const robot = robotOnly ? makeRobot(0) : null;
const actors = robotOnly ? [robot] : [father, child]; const actors = robotOnly ? [robot] : [father, child];
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X). // 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. // `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}) { 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) => { actors.forEach((actor, index) => {
const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot; const blend = 1 - Math.exp(-dt * 7),
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend); 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. // The robot hovers; it bobs faster while it is speaking.
const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0; 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 ? -.6 * actor.scale : hover, isRobot ? 1 : blend); 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.position.z = finished ? 0 : actor.z;
actor.root.rotation.y = facing; actor.root.rotation.y = facing;
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006; const bob = moving ? Math.abs(Math.sin(gait)) * 0.022 : Math.sin(time * 2) * 0.006;
actor.torso.position.y = bob; actor.torso.position.y = bob;
actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0; actor.torso.rotation.z = moving ? Math.sin(gait) * 0.018 : 0;
const blinking = Math.sin(time * 1.3 + index) > .993; const blinking = Math.sin(time * 1.3 + index) > 0.993;
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1); actor.eyes.forEach((eye) => (eye.scale.y = blinking ? 0.12 : 1));
actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06; actor.head.rotation.y = shopping
actor.torso.rotation.x = delivery && actor === child ? .22 : 0; ? -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) => { actor.legs.forEach((leg, i) => {
const phase = gait + i * Math.PI; const phase = gait + i * Math.PI;
const stride = isRobot ? .08 : .34; const stride = isRobot ? 0.08 : 0.34;
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0; 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.foot.rotation.x = moving ? Math.sin(phase + 0.5) * 0.12 : 0;
leg.knee.rotation.x = finished ? 2.5 : isRobot ? .25 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05; leg.knee.rotation.x = finished
? 2.5
: isRobot
? 0.25
: moving
? Math.max(0, -Math.sin(phase)) * 0.5
: 0.05;
}); });
}); });
group.updateMatrixWorld(true); group.updateMatrixWorld(true);
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone(); 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)); 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. // With characters facing +X, father's left and child's right hands meet.
const contact = new THREE.Vector3(0, 1.06, 0); const contact = new THREE.Vector3(0, 1.06, 0);
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => { actors.forEach((actor, index) =>
const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1; 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); const a = shoulder(actor, side);
let target; let target;
if (inside && !shopping && !finished) target = contact.clone(); 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 === child && shopping && side === -1)
else if (actor === robot && side === 1 && wave) target = toFamily(actor, new THREE.Vector3(.42, 1.62 + Math.sin(time * 9) * .07, .12)); target = new THREE.Vector3(0.5, delivery ? 0.65 : 1.1, -0.15);
else if (actor === robot && side === 1 && point) target = toFamily(actor, new THREE.Vector3(.3, 1.25, .62)); else if (actor === robot && side === 1 && wave)
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0)); target = toFamily(actor, new THREE.Vector3(0.42, 1.62 + Math.sin(time * 9) * 0.07, 0.12));
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07)); 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.upper, a, elbow);
updateSegment(arm.lower, elbow, target); updateSegment(arm.lower, elbow, target);
arm.hand.position.copy(target); arm.hand.position.copy(target);
})); }),
);
} }
update({ time: 0, gait: 0, moving: false }); update({ time: 0, gait: 0, moving: false });
+316 -97
View File
@@ -3,12 +3,73 @@ import {nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
import { blocked, insidePolygon } from './collision.js'; import { blocked, insidePolygon } from './collision.js';
// Road widths in metres per OSM highway type. Dual carriageways are two separate one-way ways. // 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 WIDTH = {
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'}; 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. // 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 LAYER = {
const AREA_COLOR = {campus: '#d8ccb2', park: '#7a9a55', grass: '#8aa660', forest: '#5d7f47', pitch: '#5f9a58', water: '#4f93a8', industrial: '#bdb6a6', farmland: '#a8a46e'}; industrial: [0.006, 1],
const BUILDING_COLORS = ['#dccbaa', '#e7dcc4', '#c9a585', '#bdb4a3', '#ece6da', '#d2b48f', '#b7957a'].map(c => new THREE.Color(c)); 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) { function canvasTexture(width, height, draw, repeat = true) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -34,15 +95,15 @@ function noise(c, w, h, base, spread, count) {
const textures = () => ({ const textures = () => ({
major: canvasTexture(128, 256, (c, w, h) => { major: canvasTexture(128, 256, (c, w, h) => {
noise(c, w, h, '#4a4d50', .12, 1800); noise(c, w, h, '#4a4d50', 0.12, 1800);
c.fillStyle = '#e8e4d6'; c.fillStyle = '#e8e4d6';
c.fillRect(6, 0, 4, h); c.fillRect(6, 0, 4, h);
c.fillRect(w - 10, 0, 4, h); c.fillRect(w - 10, 0, 4, h);
c.fillRect(w / 2 - 2, 0, 4, h * .45); c.fillRect(w / 2 - 2, 0, 4, h * 0.45);
}), }),
minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', .12, 1600)), minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', 0.12, 1600)),
paved: canvasTexture(128, 128, (c, w, h) => { paved: canvasTexture(128, 128, (c, w, h) => {
noise(c, w, h, '#c4b597', .1, 900); noise(c, w, h, '#c4b597', 0.1, 900);
c.strokeStyle = 'rgba(90,70,50,.25)'; c.strokeStyle = 'rgba(90,70,50,.25)';
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
c.beginPath(); c.beginPath();
@@ -54,14 +115,14 @@ const textures = () => ({
} }
}), }),
sidewalk: canvasTexture(128, 128, (c, w, h) => { sidewalk: canvasTexture(128, 128, (c, w, h) => {
noise(c, w, h, '#b9b1a1', .1, 900); noise(c, w, h, '#b9b1a1', 0.1, 900);
c.fillStyle = '#d9d4c8'; c.fillStyle = '#d9d4c8';
c.fillRect(0, 0, 10, h); c.fillRect(0, 0, 10, h);
c.fillRect(w - 10, 0, 10, h); c.fillRect(w - 10, 0, 10, h);
}), }),
dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', .15, 500)), dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', 0.15, 500)),
walls: canvasTexture(128, 128, (c, w, h) => { walls: canvasTexture(128, 128, (c, w, h) => {
noise(c, w, h, '#f1ede4', .06, 600); noise(c, w, h, '#f1ede4', 0.06, 600);
const g = c.createLinearGradient(0, 30, 0, 96); const g = c.createLinearGradient(0, 30, 0, 96);
g.addColorStop(0, '#7c95a3'); g.addColorStop(0, '#7c95a3');
g.addColorStop(1, '#3b4b55'); g.addColorStop(1, '#3b4b55');
@@ -75,28 +136,34 @@ const textures = () => ({
c.fillRect(62, 30, 4, 62); c.fillRect(62, 30, 4, 62);
c.fillStyle = 'rgba(0,0,0,.12)'; c.fillStyle = 'rgba(0,0,0,.12)';
c.fillRect(0, 118, w, 10); c.fillRect(0, 118, w, 10);
}) }),
}); });
// Pushes a strip of quads along a polyline into a geometry buffer. // Pushes a strip of quads along a polyline into a geometry buffer.
function ribbon(out, pts, width, y, repeat) { function ribbon(out, pts, width, y, repeat) {
const n = pts.length / 2; const n = pts.length / 2;
if (n < 2) return; if (n < 2) return;
const base = out.pos.length / 3, half = width / 2; const base = out.pos.length / 3,
half = width / 2;
let along = 0; let along = 0;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const x = pts[i * 2], z = pts[i * 2 + 1]; 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]); if (i) along += Math.hypot(x - pts[i * 2 - 2], z - pts[i * 2 - 1]);
const seg = (a, b) => { 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; 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]; 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)); const n1 = seg(Math.max(0, i - 1), Math.max(1, i)),
let nx = n1[0] + n2[0], nz = n1[1] + n2[1]; 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; const len = Math.hypot(nx, nz) || 1;
nx /= len; nx /= len;
nz /= len; nz /= len;
const miter = half / Math.max(.45, nx * n1[0] + nz * n1[1]); 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.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); out.uv.push(0, along / repeat, 1, along / repeat);
if (i) { if (i) {
@@ -110,11 +177,11 @@ function ribbon(out, pts, width, y, repeat) {
function disc(out, x, z, radius, y) { function disc(out, x, z, radius, y) {
const base = out.pos.length / 3; const base = out.pos.length / 3;
out.pos.push(x, y, z); out.pos.push(x, y, z);
out.uv.push(.25, 0); out.uv.push(0.25, 0);
for (let i = 0; i <= 10; i++) { for (let i = 0; i <= 10; i++) {
const a = i / 10 * Math.PI * 2; const a = (i / 10) * Math.PI * 2;
out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius); out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius);
out.uv.push(.25, 0); out.uv.push(0.25, 0);
if (i) out.idx.push(base, base + i + 1, base + i); if (i) out.idx.push(base, base + i + 1, base + i);
} }
} }
@@ -138,7 +205,9 @@ function flatPolygon(out, pts, y, uvScale = 0, color = null) {
if (color) out.col.push(color.r, color.g, color.b); if (color) out.col.push(color.r, color.g, color.b);
} }
for (const [a, b, c] of polygonTriangles(pts)) { 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]); 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])); out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b]));
} }
} }
@@ -149,7 +218,14 @@ function toMesh(out, material, normals = 'up') {
if (out.uv?.length) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2)); 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)); if (out.col?.length) geometry.setAttribute('color', new THREE.Float32BufferAttribute(out.col, 3));
geometry.setIndex(out.idx); 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)); 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(); else geometry.computeVertexNormals();
geometry.computeBoundingSphere(); geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(geometry, material); const mesh = new THREE.Mesh(geometry, material);
@@ -157,9 +233,13 @@ function toMesh(out, material, normals = 'up') {
return mesh; return mesh;
} }
const groundMaterial = (options, layer) => new THREE.MeshStandardMaterial({ const groundMaterial = (options, layer) =>
roughness: .95, ...options, new THREE.MeshStandardMaterial({
polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 roughness: 0.95,
...options,
polygonOffset: true,
polygonOffsetFactor: -layer,
polygonOffsetUnits: -layer * 2,
}); });
function seeded(seed) { function seeded(seed) {
@@ -179,22 +259,29 @@ function signedArea(pts) {
} }
// The world is split into 2 km chunks (buildings: 500 m blocks); meshes out of view range are hidden. // 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 // 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). // 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}) { export function createCity(
scene,
data,
{ collision, segments, extraBuildings = [], satellite = false, lowQuality = false },
) {
const VIEW_RANGE = lowQuality ? 1500 : 2400; const VIEW_RANGE = lowQuality ? 1500 : 2400;
const tex = textures(), group = new THREE.Group(); const tex = textures(),
group = new THREE.Group();
scene.add(group); scene.add(group);
const random = seeded(1405); const random = seeded(1405);
const chunked = [], buckets = new Map(); const chunked = [],
buckets = new Map();
const bucket = (name, x, z, make) => { const bucket = (name, x, z, make) => {
const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`; const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
if (!buckets.has(key)) buckets.set(key, { name, out: make() }); if (!buckets.has(key)) buckets.set(key, { name, out: make() });
return buckets.get(key).out; return buckets.get(key).out;
}; };
const addChunked = mesh => { const addChunked = (mesh) => {
mesh.geometry.computeBoundingSphere(); mesh.geometry.computeBoundingSphere();
chunked.push(mesh); chunked.push(mesh);
group.add(mesh); group.add(mesh);
@@ -202,50 +289,80 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
}; };
// Open ground that follows the camera, so the horizon never shows an edge. // 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)); const ground = new THREE.Mesh(
new THREE.PlaneGeometry(14000, 14000),
groundMaterial({ color: '#c6baa0' }, 0),
);
ground.rotation.x = -Math.PI / 2; ground.rotation.x = -Math.PI / 2;
ground.position.y = -.02; ground.position.y = -0.02;
ground.receiveShadow = true; ground.receiveShadow = true;
group.add(ground); group.add(ground);
for (const area of satellite ? [] : data.areas) { for (const area of satellite ? [] : data.areas) {
const kind = data.areaKinds[area[0]]; const kind = data.areaKinds[area[0]];
flatPolygon(bucket(`area:${kind}`, area[1], area[2], () => ({pos: [], idx: []})), area.slice(1), LAYER[kind][0]); flatPolygon(
bucket(`area:${kind}`, area[1], area[2], () => ({ pos: [], idx: [] })),
area.slice(1),
LAYER[kind][0],
);
} }
const areaMaterials = {}; 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 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: [] })); const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({ pos: [], uv: [], idx: [] }));
for (const r of satellite ? [] : data.roads) { for (const r of satellite ? [] : data.roads) {
const type = data.roadTypes[r[0]], kind = BUCKET[type], pts = r.slice(2), width = WIDTH[type]; 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); ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8);
if (r[0] <= DRIVABLE_MAX_TYPE) { if (r[0] <= DRIVABLE_MAX_TYPE) {
if (type !== 'service') ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6); 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[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]); disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
} }
} }
const roadMaterials = {}; const roadMaterials = {};
for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) roadMaterials[kind] = groundMaterial({map: tex[kind]}, LAYER[kind][1]); 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 // 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 // ~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. // camera first comes near it, one block per frame.
const wallMaterial = new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85}); const wallMaterial = new THREE.MeshStandardMaterial({
const roofMaterial = new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9}); map: tex.walls,
vertexColors: true,
roughness: 0.85,
});
const roofMaterial = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.9 });
const blocks = new Map(); const blocks = new Map();
for (const building of [...data.buildings, ...extraBuildings]) { for (const building of [...data.buildings, ...extraBuildings]) {
const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`; const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`;
if (!blocks.has(key)) { if (!blocks.has(key)) {
const [bx, bz] = key.split(',').map(Number); const [bx, bz] = key.split(',').map(Number);
blocks.set(key, {x: (bx + .5) * BLOCK, z: (bz + .5) * BLOCK, buildings: [], meshes: null}); blocks.set(key, {
x: (bx + 0.5) * BLOCK,
z: (bz + 0.5) * BLOCK,
buildings: [],
meshes: null,
});
} }
blocks.get(key).buildings.push(building); blocks.get(key).buildings.push(building);
} }
const pendingBlocks = [...blocks.values()]; const pendingBlocks = [...blocks.values()];
function buildBlock(block) { function buildBlock(block) {
const walls = {pos: [], uv: [], col: [], idx: []}, roofs = {pos: [], col: [], idx: []}; const walls = { pos: [], uv: [], col: [], idx: [] },
roofs = { pos: [], col: [], idx: [] };
for (const building of block.buildings) { for (const building of block.buildings) {
const height = building[0]; const height = building[0];
let pts = building.slice(1); let pts = building.slice(1);
@@ -256,18 +373,34 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
} }
// Colour picked from the position, so a block looks the same every time it is built. // 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 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); const color = BUILDING_COLORS[Math.floor(shade * BUILDING_COLORS.length)]
.clone()
.multiplyScalar(0.9 + ((shade * 7) % 1) * 0.15);
let along = 0; let along = 0;
for (let i = 0; i < pts.length; i += 2) { for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length, ax = pts[i], az = pts[i + 1], bx = pts[j], bz = pts[j + 1]; const j = (i + 2) % pts.length,
const len = Math.hypot(bx - ax, bz - az), base = walls.pos.length / 3; 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.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); 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); 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); walls.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
along += len; along += len;
} }
flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(.78)); flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(0.78));
} }
block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)]; block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)];
for (const mesh of block.meshes) { for (const mesh of block.meshes) {
@@ -277,18 +410,27 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
} }
// Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings. // Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings.
const tanks = [], units = []; const tanks = [],
units = [];
for (const building of data.buildings) { for (const building of data.buildings) {
const height = building[0], pts = building.slice(1); const height = building[0],
let cx = 0, cz = 0; pts = building.slice(1);
let cx = 0,
cz = 0;
for (let i = 0; i < pts.length; i += 2) { for (let i = 0; i < pts.length; i += 2) {
cx += pts[i]; cx += pts[i];
cz += pts[i + 1]; cz += pts[i + 1];
} }
cx /= pts.length / 2; cx /= pts.length / 2;
cz /= pts.length / 2; cz /= pts.length / 2;
if (insidePolygon(pts, cx, cz) && random() < .6) { if (insidePolygon(pts, cx, cz) && random() < 0.6) {
(random() < .5 ? tanks : units).push([cx + (random() - .5) * 2, height, random() * Math.PI, cz + (random() - .5) * 2, 0]); (random() < 0.5 ? tanks : units).push([
cx + (random() - 0.5) * 2,
height,
random() * Math.PI,
cz + (random() - 0.5) * 2,
0,
]);
} }
} }
for (const { name, out } of buckets.values()) { for (const { name, out } of buckets.values()) {
@@ -322,16 +464,26 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
} }
return meshes; return meshes;
}; };
instanced(new THREE.CylinderGeometry(.55, .55, 1.1, 10), new THREE.MeshStandardMaterial({color: '#d8d8d2', metalness: .4, roughness: .4}), tanks, ([x, y, , z]) => { instanced(
dummy.position.set(x, y + .55, z); 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.rotation.set(0, 0, 0);
dummy.scale.setScalar(1); 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); 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.rotation.set(0, r, 0);
dummy.scale.setScalar(1); dummy.scale.setScalar(1);
}); },
);
// Trees fill parks and line the boulevards, kept off paths and out of buildings. // Trees fill parks and line the boulevards, kept off paths and out of buildings.
const trees = []; const trees = [];
@@ -339,17 +491,24 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
for (const area of data.areas) { for (const area of data.areas) {
const kind = data.areaKinds[area[0]]; const kind = data.areaKinds[area[0]];
if (!['park', 'forest', 'grass'].includes(kind)) continue; if (!['park', 'forest', 'grass'].includes(kind)) continue;
const pts = area.slice(1), step = kind === 'grass' ? 16 : 10; const pts = area.slice(1),
let ax = Infinity, az = Infinity, bx = -Infinity, bz = -Infinity; step = kind === 'grass' ? 16 : 10;
let ax = Infinity,
az = Infinity,
bx = -Infinity,
bz = -Infinity;
for (let i = 0; i < pts.length; i += 2) { for (let i = 0; i < pts.length; i += 2) {
ax = Math.min(ax, pts[i]); ax = Math.min(ax, pts[i]);
bx = Math.max(bx, pts[i]); bx = Math.max(bx, pts[i]);
az = Math.min(az, pts[i + 1]); az = Math.min(az, pts[i + 1]);
bz = Math.max(bz, 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) { for (let x = ax; x < bx; x += step)
const tx = x + (random() - .5) * step * .8, tz = z + (random() - .5) * step * .8; for (let z = az; z < bz; z += step) {
if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) trees.push([tx, .8 + random() * .6, tz, 0]); 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) { for (const road of data.roads) {
@@ -357,28 +516,60 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
if (road[0] > 3) continue; if (road[0] > 3) continue;
const offset = WIDTH[type] / 2 + 2.6; const offset = WIDTH[type] / 2 + 2.6;
for (let i = 4; i < road.length; i += 2) { 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); const ax = road[i - 2],
for (let d = random() * 12; d < len; d += 12) for (const side of [-1, 1]) { az = road[i - 1],
const tx = ax + dx / len * d - dz / len * offset * side, tz = az + dz / len * d + dx / len * offset * side; dx = road[i] - ax,
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]); 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]);
} }
} }
} }
instanced(new THREE.CylinderGeometry(.14, .22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({color: '#6a5039', roughness: .9}), trees, ([x, s, z]) => { 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.position.set(x, 1.3 * s, z);
dummy.rotation.set(0, 0, 0); dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(s); 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]) => { );
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.position.set(x, 3.4 * s, z);
dummy.rotation.set(0, x * 13 % 6, 0); dummy.rotation.set(0, (x * 13) % 6, 0);
dummy.scale.set(s, s * (1 + (x * 7 % 1) * .3), s); 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])); 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. // Metro station entrances with the Mashhad Urban Railway "M" sign.
const signTexture = canvasTexture(128, 128, (c, w, h) => { const signTexture = canvasTexture(
128,
128,
(c, w, h) => {
c.fillStyle = '#d2232a'; c.fillStyle = '#d2232a';
c.beginPath(); c.beginPath();
c.arc(w / 2, h / 2, 60, 0, Math.PI * 2); c.arc(w / 2, h / 2, 60, 0, Math.PI * 2);
@@ -387,13 +578,24 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
c.font = 'bold 84px sans-serif'; c.font = 'bold 84px sans-serif';
c.textAlign = 'center'; c.textAlign = 'center';
c.fillText('M', w / 2, h / 2 + 30); c.fillText('M', w / 2, h / 2 + 30);
}, false); },
const canopy = new THREE.MeshStandardMaterial({color: '#59646b', metalness: .5, roughness: .4}); false,
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 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(); const entrance = new THREE.Group();
entrance.position.set(label.x, 0, label.z); entrance.position.set(label.x, 0, label.z);
const road = nearestSegment(segments, label.x, label.z, 40, s => s[4] <= DRIVABLE_MAX_TYPE); const road = nearestSegment(segments, label.x, label.z, 40, (s) => s[4] <= DRIVABLE_MAX_TYPE);
if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz); if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz);
const box = (w, h, d, m, x, y, z) => { const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
@@ -401,11 +603,14 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
mesh.castShadow = mesh.receiveShadow = true; mesh.castShadow = mesh.receiveShadow = true;
entrance.add(mesh); entrance.add(mesh);
}; };
box(4.4, .2, 7, canopy, 0, 3, 0); box(4.4, 0.2, 7, canopy, 0, 3, 0);
box(.15, 3, 7, glass, -2.1, 1.5, 0); box(0.15, 3, 7, glass, -2.1, 1.5, 0);
box(.15, 3, 7, glass, 2.1, 1.5, 0); box(0.15, 3, 7, glass, 2.1, 1.5, 0);
box(.25, 4.2, .25, canopy, 2.6, 2.1, 3.6); box(0.25, 4.2, 0.25, canopy, 2.6, 2.1, 3.6);
const sign = new THREE.Mesh(new THREE.CircleGeometry(.6, 24), new THREE.MeshBasicMaterial({map: signTexture})); 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); sign.position.set(2.6, 4.4, 3.75);
entrance.add(sign); entrance.add(sign);
const back = sign.clone(); const back = sign.clone();
@@ -417,13 +622,21 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
// A continuous ridge line around the horizon: the Binalood range high to the south-west, // 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. // the lower Hezar Masjed hills to the north-east. Hazy colours stand in for distance fog.
const ridgePos = [], ridgeCol = [], ridgeIdx = [], segmentsAround = 240; const ridgePos = [],
const foot = new THREE.Color('#cfc5b0'), crest = new THREE.Color('#a39a8a'); ridgeCol = [],
ridgeIdx = [],
segmentsAround = 240;
const foot = new THREE.Color('#cfc5b0'),
crest = new THREE.Color('#a39a8a');
for (let i = 0; i <= segmentsAround; i++) { for (let i = 0; i <= segmentsAround; i++) {
const a = i / segmentsAround * Math.PI * 2, x = Math.cos(a), z = Math.sin(a); 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. // 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 southWest = Math.max(0, (-x + z) / Math.SQRT2),
const height = 90 + southWest ** 1.5 * 420 + rough * (60 + southWest * 120), r = 4300; 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); 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); ridgeCol.push(foot.r, foot.g, foot.b, crest.r, crest.g, crest.b);
if (i) { if (i) {
@@ -435,17 +648,21 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3)); ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3));
ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3)); ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3));
ridgeGeometry.setIndex(ridgeIdx); ridgeGeometry.setIndex(ridgeIdx);
const ridge = new THREE.Mesh(ridgeGeometry, new THREE.MeshBasicMaterial({vertexColors: true, fog: false, side: THREE.DoubleSide})); const ridge = new THREE.Mesh(
ridgeGeometry,
new THREE.MeshBasicMaterial({ vertexColors: true, fog: false, side: THREE.DoubleSide }),
);
group.add(ridge); group.add(ridge);
let lastCheck = null; let lastCheck = null;
return { return {
group, group,
// Keeps the ground and horizon around the camera and shows only chunks within range. // Keeps the ground and horizon around the camera and shows only chunks within range.
update(camera) { update(camera) {
ground.position.set(camera.x, -.02, camera.z); ground.position.set(camera.x, -0.02, camera.z);
ridge.position.set(camera.x, 0, camera.z); ridge.position.set(camera.x, 0, camera.z);
// Build the nearest unbuilt block in range, one per frame to avoid stutter. // Build the nearest unbuilt block in range, one per frame to avoid stutter.
let next = null, nextDistance = VIEW_RANGE + BLOCK; let next = null,
nextDistance = VIEW_RANGE + BLOCK;
for (const block of pendingBlocks) { for (const block of pendingBlocks) {
const d = Math.hypot(block.x - camera.x, block.z - camera.z); const d = Math.hypot(block.x - camera.x, block.z - camera.z);
if (d < nextDistance) { if (d < nextDistance) {
@@ -462,8 +679,10 @@ export function createCity(scene, data, {collision, segments, extraBuildings = [
lastCheck = { x: camera.x, z: camera.z }; lastCheck = { x: camera.x, z: camera.z };
for (const mesh of chunked) { for (const mesh of chunked) {
const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere; 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; mesh.visible =
} Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius <
VIEW_RANGE;
} }
},
}; };
} }
+27 -10
View File
@@ -4,12 +4,15 @@ const key = (cx, cz) => cx * 100003 + cz;
export function createCollisionWorld(polygons) { export function createCollisionWorld(polygons) {
const world = { polygons: [], grid: new Map() }; const world = { polygons: [], grid: new Map() };
polygons.forEach(p => addPolygon(world, p)); polygons.forEach((p) => addPolygon(world, p));
return world; return world;
} }
export function addPolygon(world, pts) { export function addPolygon(world, pts) {
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity; let minX = Infinity,
minZ = Infinity,
maxX = -Infinity,
maxZ = -Infinity;
for (let i = 0; i < pts.length; i += 2) { for (let i = 0; i < pts.length; i += 2) {
minX = Math.min(minX, pts[i]); minX = Math.min(minX, pts[i]);
maxX = Math.max(maxX, pts[i]); maxX = Math.max(maxX, pts[i]);
@@ -30,14 +33,19 @@ function nearby(world, x, z, r) {
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++) 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); 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); 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) { export function insidePolygon(pts, x, z) {
let inside = false; let inside = false;
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) { for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
const xi = pts[i], zi = pts[i + 1], xj = pts[j], zj = pts[j + 1]; const xi = pts[i],
if ((zi > z) !== (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) inside = !inside; 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; return inside;
} }
@@ -45,9 +53,14 @@ export function insidePolygon(pts, x, z) {
function closestOnEdges(pts, x, z) { function closestOnEdges(pts, x, z) {
let best = { d: Infinity, x: 0, z: 0 }; let best = { d: Infinity, x: 0, z: 0 };
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) { for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
const ax = pts[j], az = pts[j + 1], dx = pts[i] - ax, dz = pts[i + 1] - az; 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 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); const px = ax + dx * t,
pz = az + dz * t,
d = Math.hypot(x - px, z - pz);
if (d < best.d) best = { d, x: px, z: pz }; if (d < best.d) best = { d, x: px, z: pz };
} }
return best; return best;
@@ -59,9 +72,12 @@ export function resolveCircle(world, x, z, r) {
for (let pass = 0; pass < 3; pass++) { for (let pass = 0; pass < 3; pass++) {
let moved = false; let moved = false;
for (const p of nearby(world, x, z, r)) { for (const p of nearby(world, x, z, r)) {
const inside = insidePolygon(p.pts, x, z), c = closestOnEdges(p.pts, x, z); const inside = insidePolygon(p.pts, x, z),
c = closestOnEdges(p.pts, x, z);
if (!inside && c.d >= r) continue; 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); 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; x = c.x + nx * r;
z = c.z + nz * r; z = c.z + nz * r;
moved = hit = true; moved = hit = true;
@@ -71,7 +87,8 @@ export function resolveCircle(world, x, z, r) {
return { x, z, hit }; 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. // 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) { export function clearFraction(world, ax, az, bx, bz, step = 1) {
+619 -200
View File
File diff suppressed because it is too large Load Diff
+29 -10
View File
@@ -1,10 +1,10 @@
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial. // Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500; export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30; export const START_MINUTES = 8 * 60 + 30;
export const fa = n => n.toLocaleString('fa-IR'); export const fa = (n) => n.toLocaleString('fa-IR');
export const formatHetoman = value => `${fa(value)} هتومان`; export const formatHetoman = (value) => `${fa(value)} هتومان`;
export const hetomanToRial = value => value * 10000; export const hetomanToRial = (value) => value * 10000;
export const formatClock = minutes => { export const formatClock = (minutes) => {
const m = Math.floor(minutes) % (24 * 60); const m = Math.floor(minutes) % (24 * 60);
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`; return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
}; };
@@ -18,10 +18,11 @@ export const catalog = {
biscuit: { id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack' }, biscuit: { id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack' },
chocolate: { id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack' }, chocolate: { id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack' },
nuts: { id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack' }, nuts: { id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack' },
cake: {id: 'cake', name: 'کیک', icon: '🧁', cost: 35, 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) { export function isOpen(machine, minutes) {
const m = minutes % (24 * 60); const m = minutes % (24 * 60);
@@ -29,7 +30,14 @@ export function isOpen(machine, minutes) {
} }
export function createState() { 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. // Returns why a purchase cannot happen, or null when it can.
@@ -43,8 +51,16 @@ export function purchaseBlocker(state, machine, productId) {
export function purchase(state, machine, productId) { export function purchase(state, machine, productId) {
if (purchaseBlocker(state, machine, productId)) return null; if (purchaseBlocker(state, machine, productId)) return null;
const product = catalog[productId], price = priceAt(machine, productId); const product = catalog[productId],
const record = {machineId: machine.id, category: machine.category, productId, type: product.type, price, minutes: state.minutes}; price = priceAt(machine, productId);
const record = {
machineId: machine.id,
category: machine.category,
productId,
type: product.type,
price,
minutes: state.minutes,
};
state.balance -= price; state.balance -= price;
state.purchases.push(record); state.purchases.push(record);
return record; return record;
@@ -52,5 +68,8 @@ export function purchase(state, machine, productId) {
// The run ends once no open or later-opening machine sells anything the player can afford. // The run ends once no open or later-opening machine sells anything the player can afford.
export function canAffordAnything(state, machines) { 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],
);
} }
+5 -3
View File
@@ -2,7 +2,7 @@
// World axes: +x east, -z north, 1 unit = 1 metre. // World axes: +x east, -z north, 1 unit = 1 metre.
export const ORIGIN = { lat: 36.315, lon: 59.539 }; export const ORIGIN = { lat: 36.315, lon: 59.539 };
const METRES_PER_DEG_LAT = 110574; const METRES_PER_DEG_LAT = 110574;
const METRES_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * Math.PI / 180); const METRES_PER_DEG_LON = 111320 * Math.cos((ORIGIN.lat * Math.PI) / 180);
export function toLocal(lat, lon) { export function toLocal(lat, lon) {
return { x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT }; return { x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT };
@@ -14,9 +14,11 @@ export function toLatLon(x, z) {
// Compass bearing in degrees (0 = north, 90 = east) of a world-space direction. // Compass bearing in degrees (0 = north, 90 = east) of a world-space direction.
export function bearing(dx, dz) { export function bearing(dx, dz) {
return (Math.atan2(dx, -dz) * 180 / Math.PI + 360) % 360; return ((Math.atan2(dx, -dz) * 180) / Math.PI + 360) % 360;
} }
export function formatDistance(metres) { 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')} کیلومتر`;
} }
+49 -21
View File
@@ -5,30 +5,39 @@ import {toLocal} from './geo.js';
// the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further // the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further
// out underneath them, and tiles out of range are released. // out underneath them, and tiles out of range are released.
const tileLon = (x, z) => x / 2 ** z * 360 - 180; 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 tileLat = (y, z) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / 2 ** z))) * 180) / Math.PI;
// World-space corners of a Web Mercator tile: north-west, north-east, south-west, south-east. // World-space corners of a Web Mercator tile: north-west, north-east, south-west, south-east.
export function tileCorners(x, y, z) { 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); 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)]; return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
} }
export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality = false, anisotropy = 4} = {}) { export function createImagery(
scene,
index,
{ baseUrl = './imagery/', lowQuality = false, anisotropy = 4 } = {},
) {
const group = new THREE.Group(); const group = new THREE.Group();
scene.add(group); scene.add(group);
const loader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist. // Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
const levels = [ const levels = [
{z: 17, range: lowQuality ? 450 : 750, y: .012, offset: -3}, { z: 17, range: lowQuality ? 450 : 750, y: 0.012, offset: -3 },
{z: 15, range: lowQuality ? 1800 : 2800, y: .004, offset: -1} { z: 15, range: lowQuality ? 1800 : 2800, y: 0.004, offset: -1 },
].map(level => ({ ].map((level) => ({
...level, ...level,
tiles: (index[level.z] ?? []).map(key => { tiles: (index[level.z] ?? []).map((key) => {
const [x, y] = key.split('/').map(Number), corners = tileCorners(x, y, level.z); const [x, y] = key.split('/').map(Number),
const cx = corners.reduce((s, c) => s + c.x, 0) / 4, cz = corners.reduce((s, c) => s + c.z, 0) / 4; corners = tileCorners(x, y, level.z);
const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
cz = corners.reduce((s, c) => s + c.z, 0) / 4;
return { key, x, y, corners, cx, cz, mesh: null, loading: false }; return { key, x, y, corners, cx, cz, mesh: null, loading: false };
}) }),
})); }));
let loads = 0; let loads = 0;
@@ -36,25 +45,44 @@ export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality
if (tile.mesh || tile.loading || loads >= 6) return; if (tile.mesh || tile.loading || loads >= 6) return;
tile.loading = true; tile.loading = true;
loads++; loads++;
loader.load(`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`, texture => { loader.load(
`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`,
(texture) => {
loads--; loads--;
tile.loading = false; tile.loading = false;
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
// Sharp at the low angles a driver sees the road from. // Sharp at the low angles a driver sees the road from.
texture.anisotropy = anisotropy; texture.anisotropy = anisotropy;
const [nw, ne, sw, se] = tile.corners, geometry = new THREE.BufferGeometry(); const [nw, ne, sw, se] = tile.corners,
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 = 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.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
geometry.setIndex([0, 2, 1, 1, 2, 3]); geometry.setIndex([0, 2, 1, 1, 2, 3]);
tile.mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ tile.mesh = new THREE.Mesh(
map: texture, toneMapped: false, polygonOffset: true, polygonOffsetFactor: level.offset, polygonOffsetUnits: level.offset * 2 geometry,
})); new THREE.MeshBasicMaterial({
map: texture,
toneMapped: false,
polygonOffset: true,
polygonOffsetFactor: level.offset,
polygonOffsetUnits: level.offset * 2,
}),
);
group.add(tile.mesh); group.add(tile.mesh);
}, undefined, () => { },
undefined,
() => {
loads--; loads--;
tile.loading = false; tile.loading = false;
tile.failed = true; tile.failed = true;
}); },
);
} }
function hide(tile) { function hide(tile) {
@@ -69,7 +97,7 @@ export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality
let last = null; let last = null;
return { return {
group, group,
available: levels.some(level => level.tiles.length), available: levels.some((level) => level.tiles.length),
// Loads tiles in range of (x, z), nearest first, and releases ones well out of range. // Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
update(x, z) { update(x, z) {
const moved = !last || Math.hypot(x - last.x, z - last.z) > 25; const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
@@ -84,6 +112,6 @@ export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality
} }
wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile)); wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
} }
} },
}; };
} }
+75 -33
View File
@@ -7,7 +7,7 @@ export const CATEGORIES = {
school: { label: 'مدرسه', icon: '🏫' }, school: { label: 'مدرسه', icon: '🏫' },
metro: { label: 'ایستگاه مترو', icon: 'Ⓜ' }, metro: { label: 'ایستگاه مترو', icon: 'Ⓜ' },
mall: { label: 'مرکز خرید', icon: '🛍' }, mall: { label: 'مرکز خرید', icon: '🛍' },
test: {label: 'نقطهٔ آزمایشی GPS', icon: '🧪'} test: { label: 'نقطهٔ آزمایشی GPS', icon: '🧪' },
}; };
const ALL_DAY = [7 * 60, 23 * 60]; const ALL_DAY = [7 * 60, 23 * 60];
@@ -18,86 +18,128 @@ export const FACTORY = {
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان', address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street). // Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
center: { x: -10308.4, z: -14964.2 }, center: { x: -10308.4, z: -14964.2 },
axis: {x: Math.SQRT1_2, z: Math.SQRT1_2} axis: { x: Math.SQRT1_2, z: Math.SQRT1_2 },
}; };
export const machines = [ export const machines = [
{ {
id: 'factory', name: 'دستگاه دفتر فنی مهندسی', category: 'factory', id: 'factory',
name: 'دستگاه دفتر فنی مهندسی',
category: 'factory',
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`, address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
anchor: null, hours: [0, 24 * 60], priceFactor: .5, anchor: null,
hours: [0, 24 * 60],
priceFactor: 0.5,
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'], products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
note: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه' note: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه',
}, },
{ {
id: 'mellat-park', name: 'بوستان ملت', category: 'park', id: 'mellat-park',
name: 'بوستان ملت',
category: 'park',
address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت', address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
anchor: {x: -487.6, z: -326.4}, hours: ALL_DAY, priceFactor: 1, anchor: { x: -487.6, z: -326.4 },
hours: ALL_DAY,
priceFactor: 1,
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'], products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
note: 'نوشیدنی‌های خنک برای پیاده‌روی در پارک' note: 'نوشیدنی‌های خنک برای پیاده‌روی در پارک',
}, },
{ {
id: 'school-rahil', name: 'مجموعه آموزشی راحیل', category: 'school', id: 'school-rahil',
name: 'مجموعه آموزشی راحیل',
category: 'school',
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه', address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
anchor: {x: -554.6, z: -569.7}, hours: [7 * 60 + 30, 13 * 60 + 30], priceFactor: .9, anchor: { x: -554.6, z: -569.7 },
hours: [7 * 60 + 30, 13 * 60 + 30],
priceFactor: 0.9,
products: ['water', 'milk', 'juice', 'biscuit', 'cake'], products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است' note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است',
}, },
{ {
id: 'ferdowsi-university', name: 'دانشگاه فردوسی مشهد', category: 'university', id: 'ferdowsi-university',
name: 'دانشگاه فردوسی مشهد',
category: 'university',
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی', address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی',
anchor: {x: -936, z: 645}, hours: ALL_DAY, priceFactor: .8, anchor: { x: -936, z: 645 },
hours: ALL_DAY,
priceFactor: 0.8,
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'], products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر' note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر',
}, },
{ {
id: 'literature-faculty', name: 'دانشکدهٔ ادبیات دکتر شریعتی', category: 'university', id: 'literature-faculty',
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
category: 'university',
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات', address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
anchor: {x: -417.1, z: 966.3}, hours: ALL_DAY, priceFactor: .8, anchor: { x: -417.1, z: 966.3 },
hours: ALL_DAY,
priceFactor: 0.8,
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'], products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر' note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر',
}, },
{ {
id: 'medical-university', name: 'دانشگاه علوم پزشکی مشهد', category: 'university', id: 'medical-university',
name: 'دانشگاه علوم پزشکی مشهد',
category: 'university',
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد', address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
anchor: {x: -272.1, z: 150.6}, hours: ALL_DAY, priceFactor: .8, anchor: { x: -272.1, z: 150.6 },
hours: ALL_DAY,
priceFactor: 0.8,
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'], products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر' note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر',
}, },
{ {
id: 'azadi-metro', name: 'ایستگاه مترو آزادی', category: 'metro', id: 'azadi-metro',
name: 'ایستگاه مترو آزادی',
category: 'metro',
address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی', address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی',
anchor: {x: 494.1, z: 280.3}, hours: [6 * 60, 22 * 60], priceFactor: 1, anchor: { x: 494.1, z: 280.3 },
hours: [6 * 60, 22 * 60],
priceFactor: 1,
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'], products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
note: 'دستگاه کنار گیت‌های مترو' note: 'دستگاه کنار گیت‌های مترو',
}, },
{ {
id: 'mashhad-mall', name: 'مشهد مال', category: 'mall', id: 'mashhad-mall',
name: 'مشهد مال',
category: 'mall',
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال', address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
anchor: {x: 1940.6, z: -367.8}, hours: [10 * 60, 23 * 60], priceFactor: 1.1, 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'], products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر' note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر',
} },
]; ];
// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone. // GPS test points at real coordinates beside the factory, for checking real-world mode on a phone.
// They exist only while real-world mode is on; `spot` is where the player stands to buy. // They exist only while real-world mode is on; `spot` is where the player stands to buy.
const testPoint = (n, lat, lon) => ({ const testPoint = (n, lat, lon) => ({
id: `test-${n}`, name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`, category: 'test', test: true, id: `test-${n}`,
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
category: 'test',
test: true,
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`, address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
anchor: null, spot: {lat, lon}, hours: [0, 24 * 60], priceFactor: .5, anchor: null,
spot: { lat, lon },
hours: [0, 24 * 60],
priceFactor: 0.5,
products: ['water', 'biscuit'], products: ['water', 'biscuit'],
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید' 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. // Shown on the map as the next area to unlock.
export const lockedPlaces = [ export const lockedPlaces = [
{ name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی' }, { name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی' },
{name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · به‌زودی'} { name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · به‌زودی' },
]; ];
export const START = { machineId: 'factory' }; export const START = { machineId: 'factory' };
export const CITY_START = 'mellat-park'; 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;
+767 -282
View File
File diff suppressed because it is too large Load Diff
+127 -32
View File
@@ -1,11 +1,22 @@
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one // 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. // 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 SCALE = 0.75; // tile pixels per metre
const TILE_PX = 512, TILE_M = TILE_PX / SCALE, MAX_TILES = 48; const TILE_PX = 512,
const OVERVIEW_SCALE = .07; TILE_M = TILE_PX / SCALE,
const AREA = {campus: '#28322c', park: '#2c4a32', grass: '#30503a', forest: '#27432e', pitch: '#33603c', water: '#2b5b6e', industrial: '#262c29', farmland: '#2a3326'}; 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 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; const tileKey = (tx, tz) => tx * 100003 + tz;
function path(c, pts) { function path(c, pts) {
@@ -40,7 +51,10 @@ export function createMapAssets(data, extraLabels = []) {
// Bin every feature into the tiles its bounding box touches. // Bin every feature into the tiles its bounding box touches.
const bins = new Map(); const bins = new Map();
const bin = (list, feature, first) => { const bin = (list, feature, first) => {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity; let x0 = Infinity,
z0 = Infinity,
x1 = -Infinity,
z1 = -Infinity;
for (let i = first; i < feature.length; i += 2) { for (let i = first; i < feature.length; i += 2) {
x0 = Math.min(x0, feature[i]); x0 = Math.min(x0, feature[i]);
x1 = Math.max(x1, feature[i]); x1 = Math.max(x1, feature[i]);
@@ -54,9 +68,9 @@ export function createMapAssets(data, extraLabels = []) {
bins.get(k)[list].push(feature); bins.get(k)[list].push(feature);
} }
}; };
data.areas.forEach(a => bin('areas', a, 1)); data.areas.forEach((a) => bin('areas', a, 1));
[...data.roads].sort((a, b) => b[0] - a[0]).forEach(r => bin('roads', r, 2)); [...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
data.buildings.forEach(b => bin('buildings', b, 1)); data.buildings.forEach((b) => bin('buildings', b, 1));
const cache = new Map(); const cache = new Map();
function tile(tx, tz) { function tile(tx, tz) {
@@ -85,23 +99,49 @@ export function createMapAssets(data, extraLabels = []) {
const o = overview.getContext('2d'); const o = overview.getContext('2d');
o.fillStyle = '#1b231f'; o.fillStyle = '#1b231f';
o.fillRect(0, 0, overview.width, overview.height); o.fillRect(0, 0, overview.width, overview.height);
o.setTransform(OVERVIEW_SCALE, 0, 0, OVERVIEW_SCALE, -minX * OVERVIEW_SCALE, -minZ * OVERVIEW_SCALE); o.setTransform(
drawFeatures(o, {areas: data.areas, roads: data.roads.filter(r => r[0] <= 5).sort((a, b) => b[0] - a[0]), buildings: []}, data, 14); 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. // One label per named boulevard, at the middle of its longest way.
const streets = new Map(); const streets = new Map();
for (const road of data.roads) { for (const road of data.roads) {
if (road[1] < 0 || road[0] > 3) continue; if (road[1] < 0 || road[0] > 3) continue;
let len = 0; 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]); 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]]; const name = data.names[road[1]];
if ((streets.get(name)?.len ?? 0) < len) { if ((streets.get(name)?.len ?? 0) < len) {
const mid = Math.floor((road.length - 2) / 4) * 2 + 2; 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]); 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 }); 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)}; 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 // Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
@@ -110,17 +150,26 @@ function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
const { minX, minZ } = assets.bounds; const { minX, minZ } = assets.bounds;
c.fillStyle = '#141b18'; c.fillStyle = '#141b18';
c.fillRect(x0, z0, x1 - x0, z1 - z0); c.fillRect(x0, z0, x1 - x0, z1 - z0);
if (pxPerMetre < .22) { if (pxPerMetre < 0.22) {
c.drawImage(assets.overview, minX, minZ, assets.overview.width / OVERVIEW_SCALE, assets.overview.height / OVERVIEW_SCALE); c.drawImage(
assets.overview,
minX,
minZ,
assets.overview.width / OVERVIEW_SCALE,
assets.overview.height / OVERVIEW_SCALE,
);
return; return;
} }
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++) 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++) 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); 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) { 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); 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]; return [dx * cos - dz * sin, dx * sin + dz * cos];
} }
@@ -156,7 +205,9 @@ function machineIcon(c, x, y, {objective, visited, open}) {
// Circular minimap rotated so the camera's forward direction points up. // Circular minimap rotated so the camera's forward direction points up.
export function drawMinimap(ctx, assets, view) { export function drawMinimap(ctx, assets, view) {
const { size, x, z, heading, camForward, radius, markers, route } = view; const { size, x, z, heading, camForward, radius, markers, route } = view;
const r = size / 2, k = r / radius, angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x); const r = size / 2,
k = r / radius,
angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
ctx.clearRect(0, 0, size, size); ctx.clearRect(0, 0, size, size);
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
@@ -169,7 +220,15 @@ export function drawMinimap(ctx, assets, view) {
ctx.rotate(angle); ctx.rotate(angle);
ctx.scale(k, k); ctx.scale(k, k);
ctx.translate(-x, -z); ctx.translate(-x, -z);
paintWorld(ctx, assets, x - radius * 1.5, z - radius * 1.5, x + radius * 1.5, z + radius * 1.5, k); paintWorld(
ctx,
assets,
x - radius * 1.5,
z - radius * 1.5,
x + radius * 1.5,
z + radius * 1.5,
k,
);
ctx.restore(); ctx.restore();
if (route?.length > 1) { if (route?.length > 1) {
ctx.strokeStyle = '#f2c94c'; ctx.strokeStyle = '#f2c94c';
@@ -214,7 +273,7 @@ export function drawMinimap(ctx, assets, view) {
ctx.font = 'bold 13px Vazirmatn, sans-serif'; ctx.font = 'bold 13px Vazirmatn, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText('ش', nx / nl * (r - 12), ny / nl * (r - 12)); ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.restore(); ctx.restore();
ctx.strokeStyle = 'rgba(230,240,235,.55)'; ctx.strokeStyle = 'rgba(230,240,235,.55)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
@@ -225,7 +284,10 @@ export function drawMinimap(ctx, assets, view) {
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel). // Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
export function drawFullMap(ctx, assets, view, state) { export function drawFullMap(ctx, assets, view, state) {
const {width, height} = ctx.canvas, dpr = state.dpr, w = width / dpr, h = height / dpr; 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]; const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#101614'; ctx.fillStyle = '#101614';
@@ -234,7 +296,15 @@ export function drawFullMap(ctx, assets, view, state) {
ctx.translate(w / 2, h / 2); ctx.translate(w / 2, h / 2);
ctx.scale(1 / view.mpp, 1 / view.mpp); ctx.scale(1 / view.mpp, 1 / view.mpp);
ctx.translate(-view.cx, -view.cz); 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); 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(); ctx.restore();
// Grid lines every 500 m, like a tactical map. // Grid lines every 500 m, like a tactical map.
ctx.strokeStyle = 'rgba(160,200,185,.08)'; ctx.strokeStyle = 'rgba(160,200,185,.08)';
@@ -276,9 +346,21 @@ export function drawFullMap(ctx, assets, view, state) {
const [sx, sy] = toScreen(l.x, l.z); const [sx, sy] = toScreen(l.x, l.z);
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`; ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
if (l.kind === 'industrial' && view.mpp > 6) continue; 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]; 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'; if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name, sx, sy + 14); ctx.fillText(
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
sx,
sy + 14,
);
} }
if (state.route?.length > 1) { if (state.route?.length > 1) {
ctx.strokeStyle = '#f2c94c'; ctx.strokeStyle = '#f2c94c';
@@ -306,7 +388,9 @@ export function drawFullMap(ctx, assets, view, state) {
const [px, py] = toScreen(state.player.x, state.player.z); const [px, py] = toScreen(state.player.x, state.player.z);
ctx.save(); ctx.save();
ctx.translate(px, py); ctx.translate(px, py);
ctx.rotate(Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2); ctx.rotate(
Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
);
ctx.fillStyle = '#fff'; ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000'; ctx.strokeStyle = '#000';
ctx.lineWidth = 2; ctx.lineWidth = 2;
@@ -323,15 +407,26 @@ export function drawFullMap(ctx, assets, view, state) {
} }
// Iran overview for the briefing and the map's country tab. `t` animates the highlight. // Iran overview for the briefing and the map's country tab. `t` animates the highlight.
export function drawIran(ctx, iran, {width, height, t = 0, highlight = 'Razavi Khorasan', cities = []}) { export function drawIran(
const lon0 = 43.8, lon1 = 63.6, lat0 = 24.8, lat1 = 40; ctx,
const kx = .84, k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * .94; iran,
const offX = (width - (lon1 - lon0) * kx * k) / 2, offY = (height - (lat1 - lat0) * k) / 2; { 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]; const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
for (const province of iran.provinces) { for (const province of iran.provinces) {
const active = province.id === highlight; const active = province.id === highlight;
ctx.fillStyle = active ? `rgba(62,224,201,${.35 + Math.sin(t * 3) * .12})` : 'rgba(40,56,50,.85)'; 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.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
ctx.lineWidth = active ? 2 : 1; ctx.lineWidth = active ? 2 : 1;
for (const ring of province.rings) { for (const ring of province.rings) {
@@ -352,7 +447,7 @@ export function drawIran(ctx, iran, {width, height, t = 0, highlight = 'Razavi K
ctx.strokeStyle = '#f2c94c'; ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, 6 + (t * 18) % 18, 0, Math.PI * 2); ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18; ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
ctx.stroke(); ctx.stroke();
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
+15 -8
View File
@@ -1,20 +1,24 @@
// Installable app: registers the offline service worker, offers "install" where the browser allows // 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. // 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() { export function setupPwa() {
const installButton = $('#install'); const installButton = $('#install');
const standalone = matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches || navigator.standalone; const standalone =
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 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; let deferred = null;
if (!standalone && ios) installButton.hidden = false; if (!standalone && ios) installButton.hidden = false;
window.addEventListener('beforeinstallprompt', event => { window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault(); event.preventDefault();
deferred = event; deferred = event;
installButton.hidden = false; installButton.hidden = false;
}); });
window.addEventListener('appinstalled', () => installButton.hidden = true); window.addEventListener('appinstalled', () => (installButton.hidden = true));
installButton.onclick = async () => { installButton.onclick = async () => {
if (deferred) { if (deferred) {
deferred.prompt(); deferred.prompt();
@@ -27,8 +31,10 @@ export function setupPwa() {
// Only built pages have sw.js; the dev server serves source files directly. // Only built pages have sw.js; the dev server serves source files directly.
if (!('serviceWorker' in navigator) || import.meta.env.DEV) return; if (!('serviceWorker' in navigator) || import.meta.env.DEV) return;
navigator.serviceWorker.register('./sw.js').then(registration => { navigator.serviceWorker
const offer = worker => { .register('./sw.js')
.then((registration) => {
const offer = (worker) => {
$('#update-toast').hidden = false; $('#update-toast').hidden = false;
$('#update-reload').onclick = () => worker.postMessage('skip-waiting'); $('#update-reload').onclick = () => worker.postMessage('skip-waiting');
}; };
@@ -39,7 +45,8 @@ export function setupPwa() {
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker); if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
}); });
}); });
}).catch(() => {}); })
.catch(() => {});
let reloading = false; let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return; if (reloading) return;
+83 -27
View File
@@ -9,11 +9,22 @@ export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, s
export function smoothFix(previous, fix) { export function smoothFix(previous, fix) {
if (!previous || fix.time - previous.time > 30000) return { ...fix }; if (!previous || fix.time - previous.time > 30000) return { ...fix };
const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2); const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
const dt = Math.max(.001, (fix.time - previous.time) / 1000); const dt = Math.max(0.001, (fix.time - previous.time) / 1000);
const x = previous.x + (fix.x - previous.x) * weight, z = previous.z + (fix.z - previous.z) * weight; 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. // 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); const accuracy = Math.max(
return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt}; 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: // Stable mode on top of smoothFix, against phone GPS jitter:
@@ -22,14 +33,20 @@ export function smoothFix(previous, fix) {
// - the shown position stays put until the estimate drifts beyond about half the GPS error, // - the shown position stays put until the estimate drifts beyond about half the GPS error,
// or the phone reports walking speed. Without stable mode every smoothed fix is shown. // or the phone reports walking speed. Without stable mode every smoothed fix is shown.
export function createStabilizer({ maxSpeed = 3 } = {}) { export function createStabilizer({ maxSpeed = 3 } = {}) {
let estimate = null, shown = null, rejected = 0; let estimate = null,
shown = null,
rejected = 0;
return { return {
stable: true, stable: true,
push(fix) { push(fix) {
if (estimate && this.stable && fix.time - estimate.time < 30000) { if (estimate && this.stable && fix.time - estimate.time < 30000) {
const dt = Math.max(.001, (fix.time - estimate.time) / 1000); const dt = Math.max(0.001, (fix.time - estimate.time) / 1000);
const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5; 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) { if (
Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach &&
fix.accuracy > estimate.accuracy / 2 &&
rejected < 3
) {
rejected++; rejected++;
return shown && { ...shown }; return shown && { ...shown };
} }
@@ -37,23 +54,37 @@ export function createStabilizer({maxSpeed = 3} = {}) {
rejected = 0; rejected = 0;
estimate = smoothFix(estimate, fix); estimate = smoothFix(estimate, fix);
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity; const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > .7) shown = {...estimate}; if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > 0.7)
else shown = {...shown, accuracy: estimate.accuracy, time: estimate.time, speed: fix.speed ?? 0}; shown = { ...estimate };
else
shown = {
...shown,
accuracy: estimate.accuracy,
time: estimate.time,
speed: fix.speed ?? 0,
};
return { ...shown }; return { ...shown };
} },
}; };
} }
// How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m, // How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m,
// so reaching the area around a machine is enough. // so reaching the area around a machine is enough.
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}), // Watches the phone's position and compass. Calls onFix({x, z, accuracy, speed, lat, lon}),
// onHeading(radians in the game's heading convention), onError(message) for a real problem and // onHeading(radians in the game's heading convention), onError(message) for a real problem and
// onStatus(message) while it is still searching. `stabilizer` filters the fixes (see above). // onStatus(message) while it is still searching. `stabilizer` filters the fixes (see above).
export function startTracking({onFix, onHeading, onError, onStatus = () => {}, stabilizer = createStabilizer()}) { export function startTracking({
onFix,
onHeading,
onError,
onStatus = () => {},
stabilizer = createStabilizer(),
}) {
if (!('geolocation' in navigator)) { if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.'); onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
return () => {}; return () => {};
@@ -63,28 +94,52 @@ export function startTracking({onFix, onHeading, onError, onStatus = () => {}, s
return () => {}; return () => {};
} }
let fixes = 0; let fixes = 0;
const handle = position => { const handle = (position) => {
const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon); const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
const shown = stabilizer.push({...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp}); local = toLocal(lat, lon);
const shown = stabilizer.push({
...local,
accuracy: Math.max(3, accuracy),
speed: Number.isFinite(speed) ? speed : null,
time: position.timestamp,
});
fixes++; fixes++;
if (shown) onFix({ ...shown, lat, lon }); if (shown) onFix({ ...shown, lat, lon });
}; };
const fail = error => { const fail = (error) => {
if (error.code === error.PERMISSION_DENIED) onError('اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)'); if (error.code === error.PERMISSION_DENIED)
else if (error.code === error.POSITION_UNAVAILABLE) onError('موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیت‌یابی کار نمی‌کند. (کد ۲)'); 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. // A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
else if (!fixes) onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)'); else if (!fixes)
onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)');
}; };
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it. // 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}); navigator.geolocation.getCurrentPosition(handle, () => {}, {
const watch = navigator.geolocation.watchPosition(handle, fail, {enableHighAccuracy: true, maximumAge: 3000, timeout: 30000}); 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). // Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
const orientation = event => { const orientation = (event) => {
const compass = event.webkitCompassHeading ?? (event.absolute && event.alpha != null ? 360 - event.alpha : null); 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. // 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); if (compass != null) onHeading(Math.PI / 2 - (compass * Math.PI) / 180);
}; };
const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation'; const eventName =
'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(eventName, orientation); window.addEventListener(eventName, orientation);
return () => { return () => {
navigator.geolocation.clearWatch(watch); navigator.geolocation.clearWatch(watch);
@@ -95,7 +150,8 @@ export function startTracking({onFix, onHeading, onError, onStatus = () => {}, s
// iOS needs an explicit permission prompt for the compass, and it must come from a tap. // iOS needs an explicit permission prompt for the compass, and it must come from a tap.
export async function requestCompass() { export async function requestCompass() {
try { try {
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) await DeviceOrientationEvent.requestPermission(); if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission)
await DeviceOrientationEvent.requestPermission();
} catch { } catch {
// Without the compass the family faces the direction of travel instead. // Without the compass the family faces the direction of travel instead.
} }
+72 -25
View File
@@ -3,7 +3,10 @@
export const DRIVABLE_MAX_TYPE = 7; // trunk … service in data.roadTypes export const DRIVABLE_MAX_TYPE = 7; // trunk … service in data.roadTypes
export function buildGraph(roads, { drivable = false } = {}) { export function buildGraph(roads, { drivable = false } = {}) {
const ids = new Map(), xs = [], zs = [], adj = []; const ids = new Map(),
xs = [],
zs = [],
adj = [];
const node = (x, z) => { const node = (x, z) => {
const k = `${x},${z}`; const k = `${x},${z}`;
if (!ids.has(k)) { if (!ids.has(k)) {
@@ -18,7 +21,8 @@ export function buildGraph(roads, {drivable = false} = {}) {
if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue; if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
let prev = node(road[2], road[3]); let prev = node(road[2], road[3]);
for (let i = 4; i < road.length; i += 2) { 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]); const next = node(road[i], road[i + 1]),
cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
if (next !== prev) { if (next !== prev) {
adj[prev].push(next, cost); adj[prev].push(next, cost);
adj[next].push(prev, cost); adj[next].push(prev, cost);
@@ -39,16 +43,19 @@ export function buildGraph(roads, {drivable = false} = {}) {
const cellKey = (cx, cz) => cx * 100003 + cz; const cellKey = (cx, cz) => cx * 100003 + cz;
function largestComponent(adj) { function largestComponent(adj) {
const label = new Int32Array(adj.length).fill(-1), sizes = []; const label = new Int32Array(adj.length).fill(-1),
sizes = [];
for (let s = 0; s < adj.length; s++) { for (let s = 0; s < adj.length; s++) {
if (label[s] >= 0) continue; if (label[s] >= 0) continue;
const id = sizes.length, stack = [s]; const id = sizes.length,
stack = [s];
let size = 0; let size = 0;
label[s] = id; label[s] = id;
while (stack.length) { while (stack.length) {
const n = stack.pop(); const n = stack.pop();
size++; size++;
for (let i = 0; i < adj[n].length; i += 2) if (label[adj[n][i]] < 0) { for (let i = 0; i < adj[n].length; i += 2)
if (label[adj[n][i]] < 0) {
label[adj[n][i]] = id; label[adj[n][i]] = id;
stack.push(adj[n][i]); stack.push(adj[n][i]);
} }
@@ -56,15 +63,18 @@ function largestComponent(adj) {
sizes.push(size); sizes.push(size);
} }
const best = sizes.indexOf(Math.max(...sizes)); const best = sizes.indexOf(Math.max(...sizes));
return Array.from(label, l => l === best); return Array.from(label, (l) => l === best);
} }
// Nearest node of the main connected component, searching outward ring by ring. // Nearest node of the main connected component, searching outward ring by ring.
export function nearestNode(graph, x, z) { export function nearestNode(graph, x, z) {
const cx = Math.floor(x / 50), cz = Math.floor(z / 50); const cx = Math.floor(x / 50),
let best = -1, bestD = Infinity; cz = Math.floor(z / 50);
let best = -1,
bestD = Infinity;
for (let ring = 0; ring < 40; ring++) { 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++) { 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; if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
for (const n of graph.grid.get(cellKey(i, j)) ?? []) { for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z); const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
@@ -81,10 +91,13 @@ export function nearestNode(graph, x, z) {
export function findPath(graph, start, goal) { export function findPath(graph, start, goal) {
if (start < 0 || goal < 0) return null; if (start < 0 || goal < 0) return null;
const {xs, zs, adj} = graph, g = new Map([[start, 0]]), from = new Map(), closed = new Set(); const { xs, zs, adj } = graph,
const h = n => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]); 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 heap = [[h(start), start]];
const push = item => { const push = (item) => {
heap.push(item); heap.push(item);
for (let i = heap.length - 1; i > 0;) { for (let i = heap.length - 1; i > 0;) {
const p = (i - 1) >> 1; const p = (i - 1) >> 1;
@@ -94,11 +107,13 @@ export function findPath(graph, start, goal) {
} }
}; };
const pop = () => { const pop = () => {
const top = heap[0], last = heap.pop(); const top = heap[0],
last = heap.pop();
if (heap.length) { if (heap.length) {
heap[0] = last; heap[0] = last;
for (let i = 0; ;) { for (let i = 0; ;) {
const l = i * 2 + 1, r = l + 1; const l = i * 2 + 1,
r = l + 1;
let m = i; let m = i;
if (l < heap.length && heap[l][0] < heap[m][0]) m = l; 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 (r < heap.length && heap[r][0] < heap[m][0]) m = r;
@@ -119,7 +134,8 @@ export function findPath(graph, start, goal) {
if (closed.has(n)) continue; if (closed.has(n)) continue;
closed.add(n); closed.add(n);
for (let i = 0; i < adj[n].length; i += 2) { for (let i = 0; i < adj[n].length; i += 2) {
const m = adj[n][i], cost = g.get(n) + adj[n][i + 1]; const m = adj[n][i],
cost = g.get(n) + adj[n][i + 1];
if (cost < (g.get(m) ?? Infinity)) { if (cost < (g.get(m) ?? Infinity)) {
g.set(m, cost); g.set(m, cost);
from.set(m, n); from.set(m, n);
@@ -133,23 +149,37 @@ export function findPath(graph, start, goal) {
export function routeBetween(graph, ax, az, bx, bz) { export function routeBetween(graph, ax, az, bx, bz) {
const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz)); const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
if (!path) return null; if (!path) return null;
const points = [{x: ax, z: az}, ...path.map(n => ({x: graph.xs[n], z: graph.zs[n]})), {x: bx, z: bz}]; 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) }; return { points, length: polylineLength(points) };
} }
export function polylineLength(points) { export function polylineLength(points) {
let total = 0; 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); 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; return total;
} }
// Spatial index of road segments, used for street names and keeping trees off paths. // Spatial index of road segments, used for street names and keeping trees off paths.
export function buildSegmentIndex(roads) { export function buildSegmentIndex(roads) {
const grid = new Map(); const grid = new Map();
for (const road of roads) for (let i = 4; i < road.length; i += 2) { 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]]; 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 (
for (let cz = Math.floor(Math.min(seg[1], seg[3]) / 40); cz <= Math.floor(Math.max(seg[1], seg[3]) / 40); cz++) { 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); const k = cellKey(cx, cz);
if (!grid.has(k)) grid.set(k, []); if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(seg); grid.get(k).push(seg);
@@ -159,17 +189,34 @@ export function buildSegmentIndex(roads) {
} }
export function nearestSegment(index, x, z, maxDistance, filter = () => true) { export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
let best = null, bestD = maxDistance; let best = null,
bestD = maxDistance;
for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++) 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 (
let cz = Math.floor((z - maxDistance) / 40);
cz <= Math.floor((z + maxDistance) / 40);
cz++
)
for (const s of index.get(cellKey(cx, cz)) ?? []) { for (const s of index.get(cellKey(cx, cz)) ?? []) {
if (!filter(s)) continue; if (!filter(s)) continue;
const dx = s[2] - s[0], dz = s[3] - s[1]; const dx = s[2] - s[0],
const t = Math.max(0, Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1))); 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); const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
if (d < bestD) { if (d < bestD) {
bestD = d; bestD = d;
best = {type: s[4], name: s[5], distance: d, x: s[0] + dx * t, z: s[1] + dz * t, dx, dz}; best = {
type: s[4],
name: s[5],
distance: d,
x: s[0] + dx * t,
z: s[1] + dz * t,
dx,
dz,
};
} }
} }
return best; return best;
+70 -26
View File
@@ -1,7 +1,7 @@
import { CATEGORIES } from './locations.js'; import { CATEGORIES } from './locations.js';
// The bonus for visiting every kind of place counts city locations, not the factory. // The bonus for visiting every kind of place counts city locations, not the factory.
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter(c => c !== 'factory' && c !== 'test'); const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
export const POINTS = { purchase: 100, discovery: 250, combo: 150, allCategories: 500 }; export const POINTS = { purchase: 100, discovery: 250, combo: 150, allCategories: 500 };
@@ -11,19 +11,26 @@ export const RANKS = [
{ name: 'راه‌بلد', xp: 1400 }, { name: 'راه‌بلد', xp: 1400 },
{ name: 'شکارچی دستگاه', xp: 2600 }, { name: 'شکارچی دستگاه', xp: 2600 },
{ name: 'استاد نقشه', xp: 4200 }, { name: 'استاد نقشه', xp: 4200 },
{name: 'افسانهٔ مشهد', xp: 6500} { name: 'افسانهٔ مشهد', xp: 6500 },
]; ];
// Awards for `record`, given the purchases that happened before it. // Awards for `record`, given the purchases that happened before it.
export function awardsFor(previous, record) { export function awardsFor(previous, record) {
const awards = [{ label: 'خرید موفق', points: POINTS.purchase }]; const awards = [{ label: 'خرید موفق', points: POINTS.purchase }];
const here = previous.filter(p => p.machineId === record.machineId); const here = previous.filter((p) => p.machineId === record.machineId);
if (!here.length) awards.push({ label: 'کشف مکان جدید', points: POINTS.discovery }); if (!here.length) awards.push({ label: 'کشف مکان جدید', points: POINTS.discovery });
// Paid once per machine: the first time it has sold both a drink and a snack. // Paid once per machine: the first time it has sold both a drink and a snack.
const typesHere = new Set(here.map(p => p.type)); const typesHere = new Set(here.map((p) => p.type));
if (typesHere.size === 1 && !typesHere.has(record.type)) awards.push({label: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo}); if (typesHere.size === 1 && !typesHere.has(record.type))
const categories = new Set(previous.map(p => p.category).filter(c => CITY_CATEGORIES.includes(c))); awards.push({ label: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo });
if (CITY_CATEGORIES.includes(record.category) && !categories.has(record.category) && categories.size === CITY_CATEGORIES.length - 1) 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 }); awards.push({ label: 'همهٔ انواع مکان', points: POINTS.allCategories });
return awards; return awards;
} }
@@ -32,45 +39,81 @@ export function rankFor(xp) {
let index = 0; let index = 0;
while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++; while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
const next = RANKS[index + 1] ?? null; 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}; 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 = [ export const MISSIONS = [
{ {
id: 'factory', title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن', target: 'factory', xp: 100, hetoman: 20, id: 'factory',
done: ps => ps.some(p => p.machineId === '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, id: 'first',
done: ps => ps.some(p => p.machineId === 'mellat-park') 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, id: 'school',
done: ps => ps.some(p => p.machineId === 'school-rahil') 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, id: 'campus',
done: ps => ps.some(p => p.machineId === 'ferdowsi-university' && p.type === 'snack') 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, id: 'metro',
done: ps => ps.some(p => p.machineId === 'azadi-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, id: 'mall',
done: ps => ps.some(p => p.machineId === 'mashhad-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, id: 'explorer',
done: ps => placesVisited(ps.filter(p => CITY_CATEGORIES.includes(p.category))) >= 5 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. // Marks and returns missions that the current purchases complete for the first time.
export function completeMissions(state) { export function completeMissions(state) {
const fresh = MISSIONS.filter(m => !state.missionsDone.includes(m.id) && m.done(state.purchases)); const fresh = MISSIONS.filter(
(m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
);
for (const m of fresh) { for (const m of fresh) {
state.missionsDone.push(m.id); state.missionsDone.push(m.id);
state.xp += m.xp; state.xp += m.xp;
@@ -79,4 +122,5 @@ export function completeMissions(state) {
return fresh; 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;
+192 -77
View File
@@ -9,7 +9,8 @@ import {textTexture} from './textures.js';
const materials = new Map(); const materials = new Map();
const mat = (color, extra = {}) => { const mat = (color, extra = {}) => {
const key = color + JSON.stringify(extra); const key = color + JSON.stringify(extra);
if (!materials.has(key)) materials.set(key, new THREE.MeshStandardMaterial({color, roughness: .8, ...extra})); if (!materials.has(key))
materials.set(key, new THREE.MeshStandardMaterial({ color, roughness: 0.8, ...extra }));
return materials.get(key); return materials.get(key);
}; };
@@ -25,112 +26,191 @@ function part(parent, geometry, material, x = 0, y = 0, z = 0) {
* outfit: 'hoodie' | 'suit' | 'manteau'. Colours: top (hoodie, jacket or manteau), bottom, skin, * outfit: 'hoodie' | 'suit' | 'manteau'. Colours: top (hoodie, jacket or manteau), bottom, skin,
* hair, scarf (manteau only), tie (suit only, optional), glasses. * 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'} = {}) { 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 root = new THREE.Group();
const pelvis = new THREE.Group(); const pelvis = new THREE.Group();
pelvis.position.y = .94; pelvis.position.y = 0.94;
root.add(pelvis); root.add(pelvis);
const topMat = mat(top), bottomMat = mat(bottom), skinMat = mat(skin), hairMat = mat(hair); const topMat = mat(top),
bottomMat = mat(bottom),
skinMat = mat(skin),
hairMat = mat(hair);
const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a'); const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
// Torso, neck and head. // Torso, neck and head.
const torso = new THREE.Group(); const torso = new THREE.Group();
pelvis.add(torso); pelvis.add(torso);
const long = outfit === 'manteau'; const long = outfit === 'manteau';
part(torso, new THREE.CylinderGeometry(.2, .17, .6, 12), topMat, 0, .3, 0).scale.z = .65; 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(.18, .2, .3, 12), topMat, 0, -.08, 0).scale.z = .7; if (long)
part(torso, new THREE.CylinderGeometry(.05, .055, .1, 10), skinMat, 0, .64, 0); 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') { if (outfit === 'hoodie') {
// Hood folded behind the neck, front pocket and drawstrings. // 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.SphereGeometry(0.13, 12, 8), topMat, 0, 0.58, -0.1).scale.set(
part(torso, new THREE.BoxGeometry(.24, .12, .02), topMat, 0, .12, .125); 1.2,
for (const side of [-1, 1]) part(torso, new THREE.CylinderGeometry(.006, .006, .16, 4), mat('#f2f2f2'), side * .04, .48, .13); 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') { } else if (outfit === 'suit') {
// White shirt opening, optional tie, lapels. // White shirt opening, optional tie, lapels.
const shirt = part(torso, new THREE.PlaneGeometry(.12, .2), mat('#f4f4f2', {side: THREE.DoubleSide}), 0, .49, .133); const shirt = part(
shirt.rotation.x = -.12; torso,
if (tie) part(torso, new THREE.BoxGeometry(.035, .22, .01), mat(tie), 0, .45, .138); 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]) { for (const side of [-1, 1]) {
const lapel = part(torso, new THREE.BoxGeometry(.05, .22, .01), mat(top, {roughness: .5}), side * .07, .47, .132); const lapel = part(
lapel.rotation.z = side * .35; 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(); const head = new THREE.Group();
head.position.y = .8; head.position.y = 0.8;
torso.add(head); torso.add(head);
part(head, new THREE.SphereGeometry(.11, 16, 12), skinMat).scale.set(.92, 1.05, 1); part(head, new THREE.SphereGeometry(0.11, 16, 12), skinMat).scale.set(0.92, 1.05, 1);
part(head, new THREE.SphereGeometry(.02, 6, 4), skinMat, 0, -.01, .105); 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(.012, 6, 4), mat('#1b1512'), side * .038, .02, .098); 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) { if (long) {
// Headscarf: covers the hair and the neck, leaving the face open. // 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.SphereGeometry(0.128, 16, 12), mat(scarf), 0, 0.012, -0.022).scale.set(
part(head, new THREE.CylinderGeometry(.09, .17, .2, 14), mat(scarf), 0, -.13, -.01).scale.z = .75; 1,
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); 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 { } else {
part(head, new THREE.SphereGeometry(.115, 14, 10), hairMat, 0, .035, -.015).scale.set(1, .75, 1); part(head, new THREE.SphereGeometry(0.115, 14, 10), hairMat, 0, 0.035, -0.015).scale.set(
1,
0.75,
1,
);
} }
if (glasses) { if (glasses) {
const frame = mat('#222', {metalness: .6, roughness: .3}); const frame = mat('#222', { metalness: 0.6, roughness: 0.3 });
for (const side of [-1, 1]) part(head, new THREE.TorusGeometry(.024, .004, 4, 14), frame, side * .04, .02, .11); for (const side of [-1, 1])
part(head, new THREE.BoxGeometry(.03, .004, .004), frame, 0, .022, .11); part(head, new THREE.TorusGeometry(0.024, 0.004, 4, 14), frame, side * 0.04, 0.02, 0.11);
part(head, new THREE.BoxGeometry(0.03, 0.004, 0.004), frame, 0, 0.022, 0.11);
} }
// Arms: shoulder → elbow → hand. Rotation x < 0 swings forward. // Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
const arms = [-1, 1].map(side => { const arms = [-1, 1].map((side) => {
const shoulder = new THREE.Group(); const shoulder = new THREE.Group();
shoulder.position.set(side * .23, .55, 0); shoulder.position.set(side * 0.23, 0.55, 0);
torso.add(shoulder); torso.add(shoulder);
part(shoulder, new THREE.CapsuleGeometry(.05, .2, 4, 8), topMat, 0, -.14, 0); part(shoulder, new THREE.CapsuleGeometry(0.05, 0.2, 4, 8), topMat, 0, -0.14, 0);
const elbow = new THREE.Group(); const elbow = new THREE.Group();
elbow.position.y = -.29; elbow.position.y = -0.29;
shoulder.add(elbow); shoulder.add(elbow);
part(elbow, new THREE.CapsuleGeometry(.043, .18, 4, 8), topMat, 0, -.12, 0); part(elbow, new THREE.CapsuleGeometry(0.043, 0.18, 4, 8), topMat, 0, -0.12, 0);
part(elbow, new THREE.SphereGeometry(.045, 8, 6), skinMat, 0, -.27, 0); part(elbow, new THREE.SphereGeometry(0.045, 8, 6), skinMat, 0, -0.27, 0);
return { shoulder, elbow }; return { shoulder, elbow };
}); });
// Legs: hip → knee → foot. // Legs: hip → knee → foot.
const legs = [-1, 1].map(side => { const legs = [-1, 1].map((side) => {
const hip = new THREE.Group(); const hip = new THREE.Group();
hip.position.set(side * .095, 0, 0); hip.position.set(side * 0.095, 0, 0);
pelvis.add(hip); pelvis.add(hip);
part(hip, new THREE.CapsuleGeometry(.07, .3, 4, 8), long ? topMat : bottomMat, 0, -.22, 0); part(hip, new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), long ? topMat : bottomMat, 0, -0.22, 0);
const knee = new THREE.Group(); const knee = new THREE.Group();
knee.position.y = -.45; knee.position.y = -0.45;
hip.add(knee); hip.add(knee);
part(knee, new THREE.CapsuleGeometry(.058, .32, 4, 8), bottomMat, 0, -.22, 0); part(knee, new THREE.CapsuleGeometry(0.058, 0.32, 4, 8), bottomMat, 0, -0.22, 0);
part(knee, new THREE.BoxGeometry(.1, .07, .24), shoeMat, 0, -.45, .05); part(knee, new THREE.BoxGeometry(0.1, 0.07, 0.24), shoeMat, 0, -0.45, 0.05);
return { hip, knee }; return { hip, knee };
}); });
const person = { const person = {
root, head, root,
head,
// pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it. // pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
pose(kind, t = 0, phase = 0) { pose(kind, t = 0, phase = 0) {
const sitting = kind === 'sit' || kind === 'type'; const sitting = kind === 'sit' || kind === 'type';
pelvis.position.y = sitting ? .5 : .94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * .02 : 0); pelvis.position.y = sitting
torso.rotation.x = kind === 'type' ? .18 : sitting ? -.05 : 0; ? 0.5
head.rotation.y = kind === 'type' ? Math.sin(t * .4 + phase) * .15 : Math.sin(t * .6 + phase) * .2; : 0.94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * 0.02 : 0);
head.rotation.x = kind === 'type' ? .12 : 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) => { legs.forEach(({ hip, knee }, i) => {
const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * .45 : 0; const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * 0.45 : 0;
hip.rotation.x = sitting ? -Math.PI / 2 : swing; 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; 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) => { arms.forEach(({ shoulder, elbow }, i) => {
if (kind === 'type') { if (kind === 'type') {
// Hands on the keyboard, fingers busy. // Hands on the keyboard, fingers busy.
shoulder.rotation.set(-.7 + Math.sin(t * 13 + phase + i * 2) * .025, 0, (i ? 1 : -1) * -.12); 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; elbow.rotation.x = -1.15;
} else if (kind === 'sit') { } else if (kind === 'sit') {
shoulder.rotation.set(-.3, 0, 0); shoulder.rotation.set(-0.3, 0, 0);
elbow.rotation.x = -1.0; elbow.rotation.x = -1.0;
} else { } else {
const swing = kind === 'walk' ? -Math.sin(t * 6 + phase + i * Math.PI) * .4 : Math.sin(t * 1.2 + phase + i) * .03; const swing =
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * .06); kind === 'walk'
elbow.rotation.x = kind === 'walk' ? -.25 : -.1; ? -Math.sin(t * 6 + phase + i * Math.PI) * 0.4
: Math.sin(t * 1.2 + phase + i) * 0.03;
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * 0.06);
elbow.rotation.x = kind === 'walk' ? -0.25 : -0.1;
} }
}); });
} },
}; };
person.pose('stand'); person.pose('stand');
return person; return person;
@@ -158,8 +238,10 @@ export function nameTag(name, role = '') {
c.fillText(role, w / 2, 126); c.fillText(role, w / 2, 126);
} }
}); });
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({map: texture, transparent: true, depthWrite: false})); const sprite = new THREE.Sprite(
sprite.scale.set(1.3, .41, 1); new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }),
);
sprite.scale.set(1.3, 0.41, 1);
return sprite; return sprite;
} }
@@ -168,7 +250,8 @@ export function nameTag(name, role = '') {
* {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}. * {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}.
*/ */
export function createCrew(parent, workers) { export function createCrew(parent, workers) {
const n = workers.length, group = new THREE.Group(); const n = workers.length,
group = new THREE.Group();
parent.add(group); parent.add(group);
const instanced = (geometry, material, count = n) => { const instanced = (geometry, material, count = n) => {
const mesh = new THREE.InstancedMesh(geometry, material, count); const mesh = new THREE.InstancedMesh(geometry, material, count);
@@ -177,13 +260,27 @@ export function createCrew(parent, workers) {
group.add(mesh); group.add(mesh);
return mesh; return mesh;
}; };
const white = new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8}); const white = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 });
const torso = instanced(new THREE.BoxGeometry(.42, .6, .26), white); const torso = instanced(new THREE.BoxGeometry(0.42, 0.6, 0.26), white);
const legs = instanced(new THREE.BoxGeometry(.15, .9, .17).translate(0, -.45, 0), white, n * 2); const legs = instanced(
const arms = instanced(new THREE.BoxGeometry(.11, .62, .11).translate(0, -.31, 0), white, n * 2); new THREE.BoxGeometry(0.15, 0.9, 0.17).translate(0, -0.45, 0),
const heads = instanced(new THREE.SphereGeometry(.12, 10, 8), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8})); white,
const hats = instanced(new THREE.SphereGeometry(.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .4})); n * 2,
const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map(c => new THREE.Color(c)); );
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(); const color = new THREE.Color();
workers.forEach((w, i) => { workers.forEach((w, i) => {
color.set(w.suit); color.set(w.suit);
@@ -200,7 +297,11 @@ export function createCrew(parent, workers) {
w.phase = i * 1.37; w.phase = i * 1.37;
w.from = { x: w.x, z: w.z }; w.from = { x: w.x, z: w.z };
}); });
const base = new THREE.Matrix4(), m = new THREE.Matrix4(), local = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0); 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) => { const setLimb = (mesh, index, x, y, angle) => {
local.makeRotationX(angle).setPosition(x, y, 0); local.makeRotationX(angle).setPosition(x, y, 0);
mesh.setMatrixAt(index, m.multiplyMatrices(base, local)); mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
@@ -208,21 +309,33 @@ export function createCrew(parent, workers) {
function update(time) { function update(time) {
workers.forEach((w, i) => { workers.forEach((w, i) => {
let x = w.x, z = w.z, facing = w.facing, walking = false; let x = w.x,
z = w.z,
facing = w.facing,
walking = false;
if (w.kind === 'walk' || w.kind === 'carry') { if (w.kind === 'walk' || w.kind === 'carry') {
// Walk between two points, pausing at each end. // 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 dx = w.to.x - w.from.x,
const cycle = len / 1.2 * 2 + 6, c = (time + w.phase * 3) % cycle, leg = len / 1.2; 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; let s;
if (c < leg) s = c / leg, walking = true, facing = Math.atan2(dx, dz); 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 + 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 if (c < leg * 2 + 3)
else s = 0, facing = Math.atan2(-dx, -dz); ((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; x = w.from.x + dx * s;
z = w.from.z + dz * s; z = w.from.z + dz * s;
} }
const t = time + w.phase; 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)); 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)); m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
torso.setMatrixAt(i, m); torso.setMatrixAt(i, m);
m.copy(base).multiply(local.makeTranslation(0, 1.64, 0)); m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
@@ -231,20 +344,22 @@ export function createCrew(parent, workers) {
hats.setMatrixAt(i, m); hats.setMatrixAt(i, m);
for (const side of [0, 1]) { for (const side of [0, 1]) {
const sign = side ? 1 : -1; const sign = side ? 1 : -1;
const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * .45 : 0; const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * 0.45 : 0;
setLimb(legs, i * 2 + side, sign * .1, .9, legSwing); setLimb(legs, i * 2 + side, sign * 0.1, 0.9, legSwing);
let arm; let arm;
if (w.kind === 'carry' && walking) arm = -1.1; if (w.kind === 'carry' && walking) arm = -1.1;
else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * .4; 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. // 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 if (w.kind === 'work')
else arm = -.9; arm = -1.0 + (side ? Math.sin(t * 4) * 0.35 : Math.sin(t * 1.3) * 0.08);
setLimb(arms, i * 2 + side, sign * .27, 1.46, arm); 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; for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
} }
update(0); update(0);
for (const mesh of [torso, legs, arms, heads, hats]) if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; for (const mesh of [torso, legs, arms, heads, hats])
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
return { group, update }; return { group, update };
} }
+65 -43
View File
@@ -1,9 +1,9 @@
:root { :root {
--hud: rgba(236, 242, 238, .92); --hud: rgba(236, 242, 238, 0.92);
--dim: rgba(200, 214, 207, .65); --dim: rgba(200, 214, 207, 0.65);
--panel: rgba(10, 16, 14, .62); --panel: rgba(10, 16, 14, 0.62);
--panel-solid: rgba(10, 16, 14, .9); --panel-solid: rgba(10, 16, 14, 0.9);
--line: rgba(220, 235, 228, .2); --line: rgba(220, 235, 228, 0.2);
--gold: #f2c94c; --gold: #f2c94c;
--teal: #3ee0c9; --teal: #3ee0c9;
--red: #ff6b5e; --red: #ff6b5e;
@@ -15,7 +15,8 @@
box-sizing: border-box; box-sizing: border-box;
} }
html, body { html,
body {
margin: 0; margin: 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
@@ -49,7 +50,7 @@ button {
background: #0d1412; background: #0d1412;
color: var(--teal); color: var(--teal);
font-size: 18px; font-size: 18px;
letter-spacing: .5px; letter-spacing: 0.5px;
} }
/* ---------- Briefing ---------- */ /* ---------- Briefing ---------- */
@@ -75,7 +76,11 @@ button {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(255, 255, 255, .025) 0 1px, transparent 1px 4px); background: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0 1px,
transparent 1px 4px
);
} }
#iran { #iran {
@@ -129,7 +134,7 @@ button {
color: #1b1406; color: #1b1406;
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
letter-spacing: .5px; letter-spacing: 0.5px;
} }
#start-mission:hover { #start-mission:hover {
@@ -177,7 +182,7 @@ button {
width: 220px; width: 220px;
height: 220px; height: 220px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 4px rgba(0, 0, 0, .35); box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.35);
} }
.map-hint { .map-hint {
@@ -250,7 +255,7 @@ button {
#mission-list li.done { #mission-list li.done {
text-decoration: line-through; text-decoration: line-through;
opacity: .55; opacity: 0.55;
} }
#mission-list li.done::before { #mission-list li.done::before {
@@ -281,7 +286,7 @@ button {
height: 16px; height: 16px;
background: var(--gold); background: var(--gold);
transform: rotate(45deg); transform: rotate(45deg);
box-shadow: 0 0 10px rgba(242, 201, 76, .8); box-shadow: 0 0 10px rgba(242, 201, 76, 0.8);
} }
#waypoint b { #waypoint b {
@@ -300,7 +305,7 @@ button {
height: 4px; height: 4px;
margin: -2px; margin: -2px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, .7); background: rgba(255, 255, 255, 0.7);
pointer-events: none; pointer-events: none;
} }
@@ -334,7 +339,7 @@ button {
transform: translateX(-50%); transform: translateX(-50%);
max-width: min(720px, 90vw); max-width: min(720px, 90vw);
padding: 8px 16px; padding: 8px 16px;
background: rgba(0, 0, 0, .55); background: rgba(0, 0, 0, 0.55);
text-align: center; text-align: center;
font-size: 16px; font-size: 16px;
line-height: 1.7; line-height: 1.7;
@@ -399,13 +404,13 @@ button {
font-size: 34px; font-size: 34px;
font-weight: 800; font-weight: 800;
color: var(--gold); color: var(--gold);
animation: pop .35s ease-out; animation: pop 0.35s ease-out;
} }
#awards .line { #awards .line {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
animation: slide .4s ease-out backwards; animation: slide 0.4s ease-out backwards;
} }
#awards .line b { #awards .line b {
@@ -434,8 +439,14 @@ button {
padding: 14px; padding: 14px;
text-align: center; text-align: center;
pointer-events: none; pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(10, 16, 14, .82) 25%, rgba(10, 16, 14, .82) 75%, transparent); background: linear-gradient(
animation: pop .4s ease-out; 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 { #banner small {
@@ -513,7 +524,7 @@ button {
#rank .bar { #rank .bar {
height: 4px; height: 4px;
margin-top: 4px; margin-top: 4px;
background: rgba(255, 255, 255, .15); background: rgba(255, 255, 255, 0.15);
} }
#rank .bar i { #rank .bar i {
@@ -521,7 +532,7 @@ button {
height: 100%; height: 100%;
width: 0; width: 0;
background: var(--gold); background: var(--gold);
transition: width .6s; transition: width 0.6s;
} }
#toolbar { #toolbar {
@@ -531,7 +542,8 @@ button {
gap: 6px; gap: 6px;
} }
#toolbar button, .ghost { #toolbar button,
.ghost {
padding: 6px 12px; padding: 6px 12px;
background: var(--panel); background: var(--panel);
color: var(--hud); color: var(--hud);
@@ -584,7 +596,9 @@ button {
gap: 8px; gap: 8px;
} }
#swipe, #again, #route-button { #swipe,
#again,
#route-button {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
border: 0; border: 0;
@@ -594,7 +608,7 @@ button {
} }
#swipe:disabled { #swipe:disabled {
opacity: .45; opacity: 0.45;
cursor: wait; cursor: wait;
} }
@@ -609,7 +623,7 @@ dialog {
} }
dialog::backdrop { dialog::backdrop {
background: rgba(0, 0, 0, .45); background: rgba(0, 0, 0, 0.45);
} }
dialog header { dialog header {
@@ -670,7 +684,8 @@ dialog header {
min-height: 0; min-height: 0;
} }
#fullmap, #iran-map { #fullmap,
#iran-map {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
@@ -679,7 +694,8 @@ dialog header {
touch-action: none; touch-action: none;
} }
#fullmap[hidden], #iran-map[hidden] { #fullmap[hidden],
#iran-map[hidden] {
display: none; display: none;
} }
@@ -721,7 +737,7 @@ dialog header {
gap: 8px; gap: 8px;
text-align: right; text-align: right;
padding: 8px; padding: 8px;
background: rgba(255, 255, 255, .04); background: rgba(255, 255, 255, 0.04);
color: var(--hud); color: var(--hud);
border: 1px solid transparent; border: 1px solid transparent;
} }
@@ -731,7 +747,7 @@ dialog header {
} }
#machine-list button.locked { #machine-list button.locked {
opacity: .45; opacity: 0.45;
cursor: default; cursor: default;
} }
@@ -750,7 +766,7 @@ dialog header {
#machine-detail { #machine-detail {
margin-bottom: 12px; margin-bottom: 12px;
padding: 12px; padding: 12px;
background: rgba(255, 255, 255, .05); background: rgba(255, 255, 255, 0.05);
border-top: 2px solid var(--gold); border-top: 2px solid var(--gold);
} }
@@ -806,17 +822,17 @@ dialog header {
justify-items: center; justify-items: center;
padding: 12px 8px; padding: 12px 8px;
color: var(--hud); color: var(--hud);
background: rgba(255, 255, 255, .05); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line); border: 1px solid var(--line);
} }
#products button:hover:not(:disabled) { #products button:hover:not(:disabled) {
border-color: var(--teal); border-color: var(--teal);
background: rgba(62, 224, 201, .1); background: rgba(62, 224, 201, 0.1);
} }
#products button:disabled { #products button:disabled {
opacity: .4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -870,7 +886,7 @@ dialog header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 6px 10px; padding: 6px 10px;
background: rgba(255, 255, 255, .05); background: rgba(255, 255, 255, 0.05);
} }
/* ---------- Touch ---------- */ /* ---------- Touch ---------- */
@@ -891,7 +907,7 @@ dialog header {
height: 130px; height: 130px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--line); border: 2px solid var(--line);
background: rgba(0, 0, 0, .25); background: rgba(0, 0, 0, 0.25);
position: relative; position: relative;
pointer-events: auto; pointer-events: auto;
touch-action: none; touch-action: none;
@@ -905,7 +921,7 @@ dialog header {
width: 50px; width: 50px;
height: 50px; height: 50px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, .5); background: rgba(255, 255, 255, 0.5);
} }
.touch-buttons { .touch-buttons {
@@ -947,7 +963,8 @@ dialog header {
font-size: 13px; font-size: 13px;
} }
#minimap-button, #minimap { #minimap-button,
#minimap {
width: 130px; width: 130px;
height: 130px; height: 130px;
} }
@@ -1029,7 +1046,7 @@ dialog header {
width: 110px; width: 110px;
height: auto; height: auto;
margin: -8px 0 0 8px; margin: -8px 0 0 8px;
filter: drop-shadow(0 8px 18px rgba(62, 224, 201, .25)); filter: drop-shadow(0 8px 18px rgba(62, 224, 201, 0.25));
} }
.brief-actions { .brief-actions {
@@ -1138,7 +1155,8 @@ dialog header {
margin: 0 0 8px; margin: 0 0 8px;
} }
#real-intro p, #real-intro li { #real-intro p,
#real-intro li {
line-height: 1.9; line-height: 1.9;
font-size: 14px; font-size: 14px;
} }
@@ -1184,18 +1202,21 @@ body.ar #crosshair {
display: none; display: none;
} }
#ar-toggle[hidden], #stable-toggle[hidden] { #ar-toggle[hidden],
#stable-toggle[hidden] {
display: none; display: none;
} }
#ar-toggle.on, #stable-toggle.on { #ar-toggle.on,
#stable-toggle.on {
background: var(--teal); background: var(--teal);
color: #04201b; color: #04201b;
border-color: var(--teal); border-color: var(--teal);
} }
/* ---------- Real-world first person, and notifications in the middle on phones ---------- */ /* ---------- Real-world first person, and notifications in the middle on phones ---------- */
body.real #crosshair, body.real #stick { body.real #crosshair,
body.real #stick {
display: none; display: none;
} }
@@ -1223,7 +1244,7 @@ body.real #crosshair, body.real #stick {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: min(92vw, 520px); width: min(92vw, 520px);
font-size: 15px; font-size: 15px;
background: rgba(0, 0, 0, .7); background: rgba(0, 0, 0, 0.7);
border-radius: 10px; border-radius: 10px;
} }
@@ -1261,7 +1282,8 @@ body.real #crosshair, body.real #stick {
/* Phone layout: one scrollable toolbar row, stats above it, compact rank bar above the compass, /* 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. */ 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'] { body.real .touch-buttons [data-key='f'],
body.real .touch-buttons [data-key='shift'] {
display: none; display: none;
} }
+106 -39
View File
@@ -3,9 +3,12 @@ import * as THREE from 'three';
// Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long). // Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long).
export function createCarModel(color = '#d9d9d4') { export function createCarModel(color = '#d9d9d4') {
const group = new THREE.Group(); const group = new THREE.Group();
const mat = (c, extra = {}) => new THREE.MeshStandardMaterial({color: c, roughness: .45, ...extra}); const mat = (c, extra = {}) =>
const paint = mat(color, {metalness: .55, roughness: .35}), glass = mat('#1e2c33', {metalness: .6, roughness: .15}); new THREE.MeshStandardMaterial({ color: c, roughness: 0.45, ...extra });
const dark = mat('#141618'), chrome = mat('#c8ccce', {metalness: .9, roughness: .2}); 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 box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z); mesh.position.set(x, y, z);
@@ -13,26 +16,43 @@ export function createCarModel(color = '#d9d9d4') {
group.add(mesh); group.add(mesh);
return mesh; return mesh;
}; };
box(4.4, .62, 1.78, paint, 0, .68, 0); box(4.4, 0.62, 1.78, paint, 0, 0.68, 0);
box(4.42, .16, 1.8, dark, 0, .38, 0); box(4.42, 0.16, 1.8, dark, 0, 0.38, 0);
const cabin = box(2.3, .62, 1.6, glass, -.25, 1.28, 0); const cabin = box(2.3, 0.62, 1.6, glass, -0.25, 1.28, 0);
cabin.scale.set(1, 1, .98); cabin.scale.set(1, 1, 0.98);
box(2.1, .08, 1.62, paint, -.25, 1.62, 0); box(2.1, 0.08, 1.62, paint, -0.25, 1.62, 0);
box(.9, .06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -.12; box(0.9, 0.06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -0.12;
for (const z of [-.62, .62]) { for (const z of [-0.62, 0.62]) {
box(.06, .18, .34, mat('#fff4cc', {emissive: '#fff1b0', emissiveIntensity: .6}), 2.21, .78, z); box(
box(.06, .16, .34, mat('#b3261e', {emissive: '#7a0f0b', emissiveIntensity: .5}), -2.21, .8, z); 0.06,
box(.18, .08, .1, chrome, .6, 1.02, z * 1.47); 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(.04, .12, 1.0, chrome, 2.22, .6, 0); box(0.04, 0.12, 1.0, chrome, 2.22, 0.6, 0);
const wheels = []; const wheels = [];
for (const x of [-1.38, 1.38]) for (const z of [-.86, .86]) { for (const x of [-1.38, 1.38])
for (const z of [-0.86, 0.86]) {
const wheel = new THREE.Group(); const wheel = new THREE.Group();
wheel.position.set(x, .36, z); wheel.position.set(x, 0.36, z);
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(.36, .36, .26, 18), dark); const tyre = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.26, 18), dark);
tyre.rotation.x = Math.PI / 2; tyre.rotation.x = Math.PI / 2;
tyre.castShadow = true; tyre.castShadow = true;
const hub = new THREE.Mesh(new THREE.CylinderGeometry(.2, .2, .27, 10), chrome); const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.27, 10), chrome);
hub.rotation.x = Math.PI / 2; hub.rotation.x = Math.PI / 2;
wheel.add(tyre, hub); wheel.add(tyre, hub);
group.add(wheel); group.add(wheel);
@@ -41,7 +61,16 @@ export function createCarModel(color = '#d9d9d4') {
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 // 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. // falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
@@ -49,30 +78,52 @@ const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in
export function createTraffic(scene, data, count = 28) { export function createTraffic(scene, data, count = 28) {
const oneway = new Set(data.oneway ?? []); const oneway = new Set(data.oneway ?? []);
const roads = data.roads.flatMap((r, index) => { const roads = data.roads
.flatMap((r, index) => {
if (r[0] > 3) return []; if (r[0] > 3) return [];
const pts = []; const pts = [];
for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1])); for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
const lengths = [0]; const lengths = [0];
for (let i = 1; i < pts.length; i++) lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1])); for (let i = 1; i < pts.length; i++)
const xs = pts.map(p => p.x), zs = pts.map(p => p.y); lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
return [{pts, lengths, total: lengths.at(-1), width: WIDTH[r[0]], oneway: oneway.has(index), const xs = pts.map((p) => p.x),
box: {x0: Math.min(...xs), x1: Math.max(...xs), z0: Math.min(...zs), z1: Math.max(...zs)}}]; zs = pts.map((p) => p.y);
}).filter(r => r.total > 80); 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) => { const sample = (road, d) => {
let i = 1; let i = 1;
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++; while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
const a = road.pts[i - 1], b = road.pts[i], t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1); const a = road.pts[i - 1],
b = road.pts[i],
t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
return { x: a.x + (b.x - a.x) * t, z: a.y + (b.y - a.y) * t, dx: b.x - a.x, dz: b.y - a.y }; return { x: a.x + (b.x - a.x) * t, z: a.y + (b.y - a.y) * t, dx: b.x - a.x, dz: b.y - a.y };
}; };
let seed = 7; let seed = 7;
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296; const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel. // Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
let nearby = [], nearbyAt = null; let nearby = [],
nearbyAt = null;
const roadsNear = (x, z) => { const roadsNear = (x, z) => {
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) { if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
nearbyAt = { x, z }; nearbyAt = { x, z };
nearby = roads.filter(({box}) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600); nearby = roads.filter(
({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600,
);
} }
return nearby; return nearby;
}; };
@@ -80,12 +131,20 @@ export function createTraffic(scene, data, count = 28) {
function respawn(car, x, z) { function respawn(car, x, z) {
const candidates = roadsNear(x, z); const candidates = roadsNear(x, z);
for (let attempt = 0; attempt < 30 && candidates.length; attempt++) { for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
const road = candidates[Math.floor(random() * candidates.length)], d = random() * road.total, p = sample(road, d); 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); const dist = Math.hypot(p.x - x, p.z - z);
if (dist < 120 || dist > 600) continue; if (dist < 120 || dist > 600) continue;
// One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half. // 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; const lane = road.oneway ? ((random() < 0.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}); Object.assign(car, {
road,
d,
dir: road.oneway || random() < 0.5 ? 1 : -1,
lane,
speed: 9 + random() * 7,
});
car.model.group.visible = true; car.model.group.visible = true;
return true; return true;
} }
@@ -93,7 +152,8 @@ export function createTraffic(scene, data, count = 28) {
return false; return false;
} }
// All traffic sits in one group so it can be hidden as a whole (camera AR view). // All traffic sits in one group so it can be hidden as a whole (camera AR view).
const group = new THREE.Group(), cars = []; const group = new THREE.Group(),
cars = [];
scene.add(group); scene.add(group);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const model = createCarModel(COLORS[i % COLORS.length]); const model = createCarModel(COLORS[i % COLORS.length]);
@@ -112,15 +172,22 @@ export function createTraffic(scene, data, count = 28) {
function update(dt, obstacles, around) { function update(dt, obstacles, around) {
// Re-home a couple of distant cars per frame, which keeps the cost per frame small. // Re-home a couple of distant cars per frame, which keeps the cost per frame small.
for (let n = 0; n < 2; n++) { for (let n = 0; n < 2; n++) {
const car = cars[cursor = (cursor + 1) % cars.length]; 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); 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) { for (const car of cars) {
if (!car.road || !car.model.group.visible) continue; if (!car.road || !car.model.group.visible) continue;
const here = sample(car.road, car.d), len = Math.hypot(here.dx, here.dz) || 1; const here = sample(car.road, car.d),
const fx = here.dx / len * car.dir, fz = here.dz / len * car.dir; len = Math.hypot(here.dx, here.dz) || 1;
const ahead = obstacles.concat(cars.filter(c => c !== car && c.model.group.visible)).some(o => { const fx = (here.dx / len) * car.dir,
const ox = o.x - car.x, oz = o.z - car.z, along = ox * fx + oz * fz; 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; return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
}); });
const speed = ahead ? 0 : car.speed; const speed = ahead ? 0 : car.speed;
@@ -140,7 +207,7 @@ export function createTraffic(scene, data, count = 28) {
car.z = p.z + fx * car.lane; car.z = p.z + fx * car.lane;
car.model.group.position.set(car.x, 0, car.z); car.model.group.position.set(car.x, 0, car.z);
car.model.group.rotation.y = Math.atan2(-fz, fx); car.model.group.rotation.y = Math.atan2(-fz, fx);
for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36; for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36;
} }
} }
return { group, cars, update, remove }; return { group, cars, update, remove };
+17 -6
View File
@@ -1,6 +1,15 @@
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y. // 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. // 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) { export function createCar(x, z, heading = 0) {
return { x, z, heading, speed: 0, steer: 0 }; return { x, z, heading, speed: 0, steer: 0 };
@@ -9,16 +18,18 @@ export function createCar(x, z, heading = 0) {
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) { export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) {
car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6); car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6);
if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt; if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt;
else if (throttle < 0) car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * .6) * -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); 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); 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)); car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
// High speed narrows the usable steering angle so the car stays controllable. // High speed narrows the usable steering angle so the car stays controllable.
const steerAngle = car.steer / (1 + Math.abs(car.speed) * .045); const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
car.heading -= car.speed * Math.tan(steerAngle) / CAR.wheelBase * dt; car.heading -= ((car.speed * Math.tan(steerAngle)) / CAR.wheelBase) * dt;
car.x += Math.cos(car.heading) * car.speed * dt; car.x += Math.cos(car.heading) * car.speed * dt;
car.z -= Math.sin(car.heading) * car.speed * dt; car.z -= Math.sin(car.heading) * car.speed * dt;
return car; 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);
+112 -34
View File
@@ -2,10 +2,21 @@ import * as THREE from 'three';
import { catalog } from './game-state.js'; import { catalog } from './game-state.js';
import { textTexture } from './textures.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. // 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.fillStyle = '#0f2a2a';
c.fillRect(0, 0, width, height); c.fillRect(0, 0, width, height);
c.fillStyle = '#3ee0c9'; c.fillStyle = '#3ee0c9';
@@ -23,7 +34,8 @@ const label = (text, sub) => textTexture(512, 160, (c, width, height) => {
// A Robot Market machine. Local +z is the front face; the group is placed facing the path. // A Robot Market machine. Local +z is the front face; the group is placed facing the path.
export function createVendingMachine(def) { export function createVendingMachine(def) {
const group = new THREE.Group(); const group = new THREE.Group();
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .5, ...extra}); const mat = (color, extra = {}) =>
new THREE.MeshStandardMaterial({ color, roughness: 0.5, ...extra });
const box = (w, h, d, material, x, y, z, parent = group) => { const box = (w, h, d, material, x, y, z, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material); const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
mesh.position.set(x, y, z); mesh.position.set(x, y, z);
@@ -31,68 +43,134 @@ export function createVendingMachine(def) {
parent.add(mesh); parent.add(mesh);
return mesh; return mesh;
}; };
const shell = mat('#0b8f88', {metalness: .45, roughness: .3, emissive: '#0b8f88', emissiveIntensity: .15}), white = mat('#eef5f3', {metalness: .2, roughness: .35}); const shell = mat('#0b8f88', {
const dark = mat('#13292b'), steel = mat('#b7c1c0', {metalness: .8, roughness: .3}); 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.9, 2.4, 1.1, shell, 0, 1.3, 0);
box(1.94, .32, 1.14, white, 0, 2.62, 0); box(1.94, 0.32, 1.14, white, 0, 2.62, 0);
box(1.94, .12, 1.14, dark, 0, .06, 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. // 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); 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. // Six shelves of products behind the glass, coloured by what this machine sells.
for (let row = 0; row < 6; row++) { for (let row = 0; row < 6; row++) {
const y = .78 + row * .28; const y = 0.78 + row * 0.28;
box(1.2, .02, .25, steel, -.2, y, .45); box(1.2, 0.02, 0.25, steel, -0.2, y, 0.45);
for (let col = 0; col < 8; col++) { for (let col = 0; col < 8; col++) {
const id = def.products[(row * 8 + col) % def.products.length]; const id = def.products[(row * 8 + col) % def.products.length];
const color = mat(PRODUCT_COLORS[id]); const color = mat(PRODUCT_COLORS[id]);
if (catalog[id].type === 'drink') { if (catalog[id].type === 'drink') {
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(.035, .04, .17, 8), color); const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.17, 8), color);
bottle.position.set(-.73 + col * .15, y + .1, .47); bottle.position.set(-0.73 + col * 0.15, y + 0.1, 0.47);
group.add(bottle); group.add(bottle);
} else box(.09, .14, .06, color, -.73 + col * .15, y + .08, .47); } 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})); const glass = new THREE.Mesh(
glass.position.set(-.2, 1.45, .6); 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); group.add(glass);
// Card reader panel on the right of the front. // Card reader panel on the right of the front.
box(.42, 1.8, .05, white, .7, 1.45, .56); box(0.42, 1.8, 0.05, white, 0.7, 1.45, 0.56);
const screen = box(.3, .22, .02, new THREE.MeshStandardMaterial({color: '#7ef0d6', emissive: '#2bbfa0', emissiveIntensity: .8}), .7, 1.92, .6); const screen = box(
box(.3, .2, .03, dark, .7, 1.55, .6); 0.3,
box(.22, .03, .04, steel, .7, 1.62, .62); 0.22,
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); 0.02,
box(.9, .3, .08, mat('#050f10'), -.2, .38, .56); 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(); const flap = new THREE.Group();
flap.position.set(-.2, .53, .61); flap.position.set(-0.2, 0.53, 0.61);
group.add(flap); group.add(flap);
box(.88, .28, .03, mat('#4f6f68', {metalness: .4}), 0, -.14, 0, flap); box(0.88, 0.28, 0.03, mat('#4f6f68', { metalness: 0.4 }), 0, -0.14, 0, flap);
const sign = new THREE.Mesh(new THREE.PlaneGeometry(1.86, .3), new THREE.MeshBasicMaterial({map: label('روبات مارکت', def.name)})); const sign = new THREE.Mesh(
sign.position.set(0, 2.62, .575); 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); group.add(sign);
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(.06, .06, .2, 10), mat('#e5ae47')); const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
delivery.visible = false; delivery.visible = false;
group.add(delivery); group.add(delivery);
const card = box(.2, .13, .01, mat('#e2d6a8', {metalness: .3}), .95, 1.7, .66); const card = box(0.2, 0.13, 0.01, mat('#e2d6a8', { metalness: 0.3 }), 0.95, 1.7, 0.66);
card.visible = false; card.visible = false;
// Objective beacon: a tall column of light visible across the city, like a mission marker. // Objective beacon: a tall column of light visible across the city, like a mission marker.
const beaconMaterial = new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .22, depthWrite: false, fog: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide}); const beaconMaterial = new THREE.MeshBasicMaterial({
const beacon = new THREE.Mesh(new THREE.CylinderGeometry(.7, .7, 90, 16, 1, true), beaconMaterial); 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; beacon.position.y = 45;
group.add(beacon); 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})); 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.rotation.x = -Math.PI / 2;
ring.position.set(0, .09, 1.6); ring.position.set(0, 0.09, 1.6);
group.add(ring); group.add(ring);
return { return {
group, flap, delivery, card, screen, beacon, ring, group,
flap,
delivery,
card,
screen,
beacon,
ring,
setDelivery(productId) { setDelivery(productId) {
delivery.material = mat(PRODUCT_COLORS[productId]); delivery.material = mat(PRODUCT_COLORS[productId]);
delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1); delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
}, },
// Points in machine space, converted to world space by the caller. // Points in machine space, converted to world space by the caller.
readerPoint: new THREE.Vector3(.7, 1.6, .62), readerPoint: new THREE.Vector3(0.7, 1.6, 0.62),
slotPoint: new THREE.Vector3(-.2, .38, .66) slotPoint: new THREE.Vector3(-0.2, 0.38, 0.66),
}; };
} }
+26 -6
View File
@@ -1,13 +1,28 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {createState, purchaseBlocker, priceAt, canAffordAnything, formatClock} from '../src/game-state.js'; import {
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from '../src/payment.js'; 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 { 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', () => { test('Payment charges once, only after ready and an explicit swipe', () => {
const state = createState(), payment = createPayment(); const state = createState(),
payment = createPayment();
assert.ok(beginPayment(state, payment, park, 'juice')); assert.ok(beginPayment(state, payment, park, 'juice'));
assert.equal(state.balance, 500); assert.equal(state.balance, 500);
assert.equal(swipePayment(state, payment), false, 'not ready yet'); assert.equal(swipePayment(state, payment), false, 'not ready yet');
@@ -16,11 +31,16 @@ test('Payment charges once, only after ready and an explicit swipe', () => {
assert.equal(state.balance, 465); assert.equal(state.balance, 465);
assert.equal(swipePayment(state, payment), false, 'no double charge'); assert.equal(swipePayment(state, payment), false, 'no double charge');
assert.equal(state.purchases.length, 1); assert.equal(state.purchases.length, 1);
assert.equal(beginPayment(state, createPayment(), park, 'water'), false, 'busy until delivery ends'); assert.equal(
beginPayment(state, createPayment(), park, 'water'),
false,
'busy until delivery ends',
);
}); });
test('Cancel, closed machines, unknown products and low funds never charge', () => { test('Cancel, closed machines, unknown products and low funds never charge', () => {
const state = createState(), payment = createPayment(); const state = createState(),
payment = createPayment();
assert.ok(beginPayment(state, payment, park, 'cake')); assert.ok(beginPayment(state, payment, park, 'cake'));
assert.ok(cancelPayment(state, payment)); assert.ok(cancelPayment(state, payment));
assert.equal(state.balance, 500); assert.equal(state.balance, 500);
+11 -7
View File
@@ -28,14 +28,15 @@ test('Stable mode holds still through jitter, drops jumps and follows real walki
s.push({ x: 0, z: 0, accuracy: 8, time: 0, speed: 0 }); 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. // Standing still: fixes wander by a few metres, the shown position does not move.
for (let i = 1; i <= 10; i++) { 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}); 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]); assert.deepEqual([p.x, p.z], [0, 0]);
} }
// A single 80 m jump one second later is impossible on foot and is dropped. // 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]); 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. // Walking east at 1.4 m/s: the shown position follows.
let p; 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}); 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})`); assert.ok(p.x > 15, `followed the walk (${p.x})`);
// With stable mode off every smoothed fix is shown. // With stable mode off every smoothed fix is shown.
const raw = createStabilizer(); const raw = createStabilizer();
@@ -47,13 +48,16 @@ test('Stable mode holds still through jitter, drops jumps and follows real walki
test('AR camera faces north/west with the phone held upright, and the FOV fits the screen', async () => { 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 { Quaternion, Vector3 } = await import('three');
const { orientationQuaternion, coverFov } = await import('../src/ar.js'); 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 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); const north = look(0);
assert.ok(north.z < -.99, 'upright phone with alpha 0 looks north (−z)'); assert.ok(north.z < -0.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(90).x < -0.99, 'alpha 90 (turned left) looks west (−x)');
assert.ok(look(0, 0).y < -.99, 'phone lying flat looks at the ground'); 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. // 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); 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. // 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); assert.ok(coverFov(4 / 3, 19.5 / 9) < 35);
}); });
+20 -6
View File
@@ -1,13 +1,21 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {awardsFor, rankFor, completeMissions, activeMission, POINTS, RANKS} from '../src/scoring.js'; import {
awardsFor,
rankFor,
completeMissions,
activeMission,
POINTS,
RANKS,
} from '../src/scoring.js';
import { createState, purchase } from '../src/game-state.js'; import { createState, purchase } from '../src/game-state.js';
import { machineById } from '../src/locations.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', () => { test('Discovery, combo and all-category bonuses are paid once', () => {
const state = createState(), park = machineById('mellat-park'); const state = createState(),
park = machineById('mellat-park');
const buy = (machine, product) => { const buy = (machine, product) => {
const before = state.purchases.slice(); const before = state.purchases.slice();
return awardsFor(before, purchase(state, machine, product)); return awardsFor(before, purchase(state, machine, product));
@@ -22,7 +30,7 @@ test('Discovery, combo and all-category bonuses are paid once', () => {
buy(machineById('azadi-metro'), 'water'); buy(machineById('azadi-metro'), 'water');
state.minutes = 11 * 60; state.minutes = 11 * 60;
const last = buy(machineById('mashhad-mall'), 'water'); const last = buy(machineById('mashhad-mall'), 'water');
assert.ok(last.some(a => a.points === POINTS.allCategories)); assert.ok(last.some((a) => a.points === POINTS.allCategories));
}); });
test('Ranks and missions advance with progress', () => { test('Ranks and missions advance with progress', () => {
@@ -32,10 +40,16 @@ test('Ranks and missions advance with progress', () => {
const state = createState(); const state = createState();
assert.equal(activeMission(state).id, 'factory'); assert.equal(activeMission(state).id, 'factory');
purchase(state, machineById('factory'), 'water'); purchase(state, machineById('factory'), 'water');
assert.deepEqual(completeMissions(state).map(m => m.id), ['factory']); assert.deepEqual(
completeMissions(state).map((m) => m.id),
['factory'],
);
assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price'); assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
purchase(state, machineById('mellat-park'), 'water'); purchase(state, machineById('mellat-park'), 'water');
assert.deepEqual(completeMissions(state).map(m => m.id), ['first']); assert.deepEqual(
completeMissions(state).map((m) => m.id),
['first'],
);
assert.deepEqual(completeMissions(state), [], 'rewards are not repeated'); assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
assert.equal(activeMission(state).id, 'school'); assert.equal(activeMission(state).id, 'school');
}); });
+51 -21
View File
@@ -12,7 +12,8 @@ import {machines, machineById, FACTORY} from '../src/locations.js';
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url))); const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
test('Projection round-trips and bearings follow the compass', () => { test('Projection round-trips and bearings follow the compass', () => {
const p = toLocal(36.32092, 59.53744), back = toLatLon(p.x, p.z); 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(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.ok(p.z < 0, 'north of the origin is -z');
assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), { x: 0, z: 0 }); assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), { x: 0, z: 0 });
@@ -26,21 +27,28 @@ test('Circles are pushed out of footprints and segments stop at walls', () => {
const out = resolveCircle(world, 9.5, 5, 1); const out = resolveCircle(world, 9.5, 5, 1);
assert.ok(out.hit && out.x >= 10.99); assert.ok(out.hit && out.x >= 10.99);
assert.equal(resolveCircle(world, 20, 20, 1).hit, false); assert.equal(resolveCircle(world, 20, 20, 1).hit, false);
assert.ok(Math.abs(clearFraction(world, -10, 5, 10, 5) - .5) < .06); 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', () => { 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 walk = buildGraph(data.roads),
drive = buildGraph(data.roads, { drivable: true });
const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7); 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})`); assert.ok(
toCity && toCity.length > 18000 && toCity.length < 23000,
`factory → Park Mellat is ~20 km (${toCity?.length})`,
);
const start = machineById('mellat-park').anchor; const start = machineById('mellat-park').anchor;
for (const m of machines.filter(m => m.anchor)) { for (const m of machines.filter((m) => m.anchor)) {
const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z); 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, `walk route to ${m.id}`);
assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1); 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}`); 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); const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, (s) => s[5] >= 0);
assert.equal(data.names[street.name], 'بولوار ملک آباد'); assert.equal(data.names[street.name], 'بولوار ملک آباد');
}); });
@@ -56,12 +64,16 @@ test('Car accelerates, steers right with positive input and brakes', () => {
test('Traffic drives with the flow on one-way carriageways and stays inside them', () => { test('Traffic drives with the flow on one-way carriageways and stays inside them', () => {
const oneway = new Set(data.oneway); 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'); assert.ok(
const traffic = createTraffic(new Scene(), data, 28), around = {x: -10008, z: -15049}; 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); for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
const active = traffic.cars.filter(c => c.road); const active = traffic.cars.filter((c) => c.road);
assert.ok(active.length > 10, `cars spawned near the player (${active.length})`); assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
for (const car of active.filter(c => c.road.oneway)) { for (const car of active.filter((c) => c.road.oneway)) {
assert.equal(car.dir, 1); assert.equal(car.dir, 1);
assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway'); assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
} }
@@ -70,28 +82,46 @@ test('Traffic drives with the flow on one-way carriageways and stays inside them
test('Microsoft buildings decode into the world, stay clear of the factory and leave machines reachable', async () => { 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 { decodeBuildings } = await import('../src/buildings-data.js');
const file = readFileSync(new URL('../public/data/buildings.bin', import.meta.url)); const file = readFileSync(new URL('../public/data/buildings.bin', import.meta.url));
const buildings = decodeBuildings(file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength)); const buildings = decodeBuildings(
file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength),
);
assert.ok(buildings.length > 100000, `${buildings.length} buildings`); assert.ok(buildings.length > 100000, `${buildings.length} buildings`);
const { minX, minZ, maxX, maxZ } = data.bounds; const { minX, minZ, maxX, maxZ } = data.bounds;
for (const b of buildings) { for (const b of buildings) {
assert.ok(b[0] >= 3 && b[0] <= 30, `height ${b[0]}`); 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'); 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))); 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'); 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. // 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); 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 () => { 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 { tileCorners } = await import('../src/imagery.js');
const index = JSON.parse(readFileSync(new URL('../public/imagery/index.json', import.meta.url))); const index = JSON.parse(readFileSync(new URL('../public/imagery/index.json', import.meta.url)));
assert.ok(index[17].length > 100 && index[15].length > 100); assert.ok(index[17].length > 100 && index[15].length > 100);
const park = toLocal(36.32092, 59.53744), z = 17; const park = toLocal(36.32092, 59.53744),
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); 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'); assert.ok(index[17].includes(`${x}/${y}`), 'the Park Mellat tile was downloaded');
const [nw, ne, sw, se] = tileCorners(x, y, z); 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(
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'); nw.x < ne.x && nw.z < sw.z && se.x > sw.x,
assert.ok(Math.abs((ne.x - nw.x) - 247) < 5, 'a zoom-17 tile is ~247 m wide at Mashhad'); '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');
}); });
+16 -11
View File
@@ -3,7 +3,8 @@ import {readdirSync, statSync} from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const listFiles = dir => readdirSync(dir).flatMap(name => { const listFiles = (dir) =>
readdirSync(dir).flatMap((name) => {
const path = join(dir, name); const path = join(dir, name);
return statSync(path).isDirectory() ? listFiles(path) : [path]; return statSync(path).isDirectory() ? listFiles(path) : [path];
}); });
@@ -16,14 +17,21 @@ function offlineServiceWorker() {
name: 'offline-service-worker', name: 'offline-service-worker',
apply: 'build', apply: 'build',
generateBundle(_, bundle) { generateBundle(_, bundle) {
const files = [...Object.keys(bundle), ...listFiles('public').map(f => relative('public', f).replaceAll('\\', '/'))] const files = [
.filter(f => f !== 'sw.js' && !f.endsWith('.map') && !(f.startsWith('imagery/') && f.endsWith('.jpg'))); ...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); const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
this.emitFile({ this.emitFile({
type: 'asset', fileName: 'sw.js', source: `// Generated by vite.config.js — do not edit. type: 'asset',
fileName: 'sw.js',
source: `// Generated by vite.config.js — do not edit.
const CACHE = 'robot-market-${version}'; const CACHE = 'robot-market-${version}';
const TILES = 'robot-market-imagery'; 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('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES))));
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => 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()))); Promise.all(keys.filter(k => k.startsWith('robot-market-') && k !== CACHE && k !== TILES).map(k => caches.delete(k)))).then(() => self.clients.claim())));
@@ -46,9 +54,9 @@ self.addEventListener('fetch', event => {
} }
event.respondWith(caches.match(request, {ignoreSearch: true}).then(hit => hit ?? fetch(request))); event.respondWith(caches.match(request, {ignoreSearch: true}).then(hit => hit ?? fetch(request)));
}); });
` `,
}); });
} },
}; };
} }
@@ -60,9 +68,6 @@ export default defineConfig({
build: { target: 'es2022' }, build: { target: 'es2022' },
server: { server: {
host: true, host: true,
allowedHosts: [ allowedHosts: ['robotmarkets.ir', 'www.robotmarkets.ir'],
'robotmarkets.ir',
'www.robotmarkets.ir',
],
}, },
}); });