chore: establish formatting baseline and verification scripts
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
src/data
|
||||||
|
public
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
Generated
+17
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,109 +7,130 @@ import * as THREE from 'three';
|
|||||||
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
|
// 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.
|
||||||
// `alpha` must be measured from north (absolute), counter-clockwise.
|
// `alpha` must be measured from north (absolute), counter-clockwise.
|
||||||
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
|
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
|
||||||
const r = Math.PI / 180;
|
const r = Math.PI / 180;
|
||||||
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
|
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
|
||||||
target.setFromEuler(euler);
|
target.setFromEuler(euler);
|
||||||
target.multiply(q1);
|
target.multiply(q1);
|
||||||
target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
|
target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
// "cover" fills the screen: if the screen is wider than the video, the full width shows.
|
hTan = videoAspect >= 1 ? long : long * videoAspect;
|
||||||
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
|
// "cover" fills the screen: if the screen is wider than the video, the full width shows.
|
||||||
return Math.atan(visibleV) * 360 / Math.PI;
|
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
|
||||||
|
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,
|
||||||
const target = new THREE.Quaternion();
|
orientation = null,
|
||||||
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
|
compassOffset = null,
|
||||||
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
|
absoluteSeen = false;
|
||||||
const onAbsolute = event => {
|
const target = new THREE.Quaternion();
|
||||||
if (event.alpha == null) return;
|
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
|
||||||
absoluteSeen = true;
|
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
|
||||||
orientation = {alpha: event.alpha, beta: event.beta, gamma: event.gamma};
|
const onAbsolute = (event) => {
|
||||||
};
|
if (event.alpha == null) return;
|
||||||
const onOrientation = event => {
|
absoluteSeen = true;
|
||||||
if (event.alpha == null || absoluteSeen) return;
|
orientation = { alpha: event.alpha, beta: event.beta, gamma: event.gamma };
|
||||||
let alpha = event.alpha;
|
};
|
||||||
if (event.webkitCompassHeading != null) {
|
const onOrientation = (event) => {
|
||||||
// iOS: alpha is relative to the start pose; the compass heading pins it to north.
|
if (event.alpha == null || absoluteSeen) return;
|
||||||
compassOffset = (360 - event.webkitCompassHeading) - event.alpha;
|
let alpha = event.alpha;
|
||||||
}
|
if (event.webkitCompassHeading != null) {
|
||||||
if (!event.absolute && compassOffset != null) alpha += compassOffset;
|
// iOS: alpha is relative to the start pose; the compass heading pins it to north.
|
||||||
orientation = {alpha, beta: event.beta, gamma: event.gamma};
|
compassOffset = 360 - event.webkitCompassHeading - event.alpha;
|
||||||
};
|
}
|
||||||
let sensing = false;
|
if (!event.absolute && compassOffset != null) alpha += compassOffset;
|
||||||
return {
|
orientation = { alpha, beta: event.beta, gamma: event.gamma };
|
||||||
// Camera picture on (AR) or off (the 3D world fills the screen).
|
};
|
||||||
get active() {
|
let sensing = false;
|
||||||
return Boolean(stream);
|
return {
|
||||||
},
|
// Camera picture on (AR) or off (the 3D world fills the screen).
|
||||||
get hasOrientation() {
|
get active() {
|
||||||
return Boolean(orientation);
|
return Boolean(stream);
|
||||||
},
|
},
|
||||||
// Phone orientation, used by the first-person view with or without the camera picture.
|
get hasOrientation() {
|
||||||
startSensors() {
|
return Boolean(orientation);
|
||||||
if (sensing) return;
|
},
|
||||||
sensing = true;
|
// Phone orientation, used by the first-person view with or without the camera picture.
|
||||||
window.addEventListener('deviceorientationabsolute', onAbsolute);
|
startSensors() {
|
||||||
window.addEventListener('deviceorientation', onOrientation);
|
if (sensing) return;
|
||||||
},
|
sensing = true;
|
||||||
stopSensors() {
|
window.addEventListener('deviceorientationabsolute', onAbsolute);
|
||||||
sensing = false;
|
window.addEventListener('deviceorientation', onOrientation);
|
||||||
orientation = null;
|
},
|
||||||
absoluteSeen = false;
|
stopSensors() {
|
||||||
window.removeEventListener('deviceorientationabsolute', onAbsolute);
|
sensing = false;
|
||||||
window.removeEventListener('deviceorientation', onOrientation);
|
orientation = null;
|
||||||
},
|
absoluteSeen = false;
|
||||||
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
|
window.removeEventListener('deviceorientationabsolute', onAbsolute);
|
||||||
async start() {
|
window.removeEventListener('deviceorientation', onOrientation);
|
||||||
if (stream) return null;
|
},
|
||||||
if (!navigator.mediaDevices?.getUserMedia) return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
|
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
|
||||||
try {
|
async start() {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: {ideal: 'environment'}, width: {ideal: 1280}, height: {ideal: 720}}, audio: false});
|
if (stream) return null;
|
||||||
} catch (error) {
|
if (!navigator.mediaDevices?.getUserMedia)
|
||||||
stream = null;
|
return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
|
||||||
return error?.name === 'NotAllowedError' ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.' : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
|
try {
|
||||||
}
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video.srcObject = stream;
|
video: {
|
||||||
video.hidden = false;
|
facingMode: { ideal: 'environment' },
|
||||||
await video.play().catch(() => {});
|
width: { ideal: 1280 },
|
||||||
this.startSensors();
|
height: { ideal: 720 },
|
||||||
return null;
|
},
|
||||||
},
|
audio: false,
|
||||||
// Closes the camera picture; the sensors keep running for the first-person view.
|
});
|
||||||
stop() {
|
} catch (error) {
|
||||||
stream?.getTracks().forEach(track => track.stop());
|
stream = null;
|
||||||
stream = null;
|
return error?.name === 'NotAllowedError'
|
||||||
video.srcObject = null;
|
? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
|
||||||
video.hidden = true;
|
: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
|
||||||
},
|
}
|
||||||
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
|
video.srcObject = stream;
|
||||||
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
|
video.hidden = false;
|
||||||
update(camera, dt, smoothing = 8) {
|
await video.play().catch(() => {});
|
||||||
const fov = stream ? coverFov(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3, innerWidth / innerHeight) : 62;
|
this.startSensors();
|
||||||
if (Math.abs(camera.fov - fov) > .1) {
|
return null;
|
||||||
camera.fov = fov;
|
},
|
||||||
camera.updateProjectionMatrix();
|
// Closes the camera picture; the sensors keep running for the first-person view.
|
||||||
}
|
stop() {
|
||||||
if (!orientation) return false;
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
const angle = screen.orientation?.angle ?? window.orientation ?? 0;
|
stream = null;
|
||||||
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
|
video.srcObject = null;
|
||||||
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
|
video.hidden = true;
|
||||||
return true;
|
},
|
||||||
}
|
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
|
||||||
};
|
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
|
||||||
|
update(camera, dt, smoothing = 8) {
|
||||||
|
const fov = stream
|
||||||
|
? coverFov(
|
||||||
|
video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3,
|
||||||
|
innerWidth / innerHeight,
|
||||||
|
)
|
||||||
|
: 62;
|
||||||
|
if (Math.abs(camera.fov - fov) > 0.1) {
|
||||||
|
camera.fov = fov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
if (!orientation) return false;
|
||||||
|
const angle = screen.orientation?.angle ?? window.orientation ?? 0;
|
||||||
|
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
|
||||||
|
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-20
@@ -1,25 +1,30 @@
|
|||||||
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
|
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
|
||||||
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
|
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
|
||||||
export function decodeBuildings(buffer) {
|
export function decodeBuildings(buffer) {
|
||||||
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),
|
||||||
const buildings = new Array(count);
|
minX = view.getFloat32(8, true),
|
||||||
let o = 20;
|
minZ = view.getFloat32(12, true),
|
||||||
for (let b = 0; b < count; b++) {
|
step = view.getFloat32(16, true);
|
||||||
const n = view.getUint8(o), height = view.getUint8(o + 1) * .5;
|
const buildings = new Array(count);
|
||||||
o += 2;
|
let o = 20;
|
||||||
const building = new Array(1 + n * 2);
|
for (let b = 0; b < count; b++) {
|
||||||
building[0] = height;
|
const n = view.getUint8(o),
|
||||||
let qx = 0, qz = 0;
|
height = view.getUint8(o + 1) * 0.5;
|
||||||
for (let i = 0; i < n; i++, o += 4) {
|
o += 2;
|
||||||
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
|
const building = new Array(1 + n * 2);
|
||||||
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
|
building[0] = height;
|
||||||
building[1 + i * 2] = minX + qx * step;
|
let qx = 0,
|
||||||
building[2 + i * 2] = minZ + qz * step;
|
qz = 0;
|
||||||
}
|
for (let i = 0; i < n; i++, o += 4) {
|
||||||
buildings[b] = building;
|
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
|
||||||
|
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
|
||||||
|
building[1 + i * 2] = minX + qx * step;
|
||||||
|
building[2 + i * 2] = minZ + qz * step;
|
||||||
}
|
}
|
||||||
return buildings;
|
buildings[b] = building;
|
||||||
|
}
|
||||||
|
return buildings;
|
||||||
}
|
}
|
||||||
|
|||||||
+375
-209
@@ -3,64 +3,80 @@ import * as THREE from 'three';
|
|||||||
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
|
// 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, {
|
||||||
geometry.translate(0, 0, -(width - bevel * 2) / 2);
|
depth: width - bevel * 2,
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
bevelEnabled: true,
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
bevelThickness: bevel,
|
||||||
return mesh;
|
bevelSize: bevel * 0.8,
|
||||||
|
bevelSegments: 3,
|
||||||
|
curveSegments: 6,
|
||||||
|
});
|
||||||
|
geometry.translate(0, 0, -(width - bevel * 2) / 2);
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lower-body outline with wheel arches cut around the wheel centres.
|
// Lower-body outline with wheel arches cut around the wheel centres.
|
||||||
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);
|
||||||
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
|
||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }),
|
||||||
const wheels = [];
|
rim = mat(rimColor, { metalness: 0.85, roughness: 0.25 }),
|
||||||
for (const [x, z] of positions) {
|
brake = mat('#3a3d40', { metalness: 0.6 });
|
||||||
const wheel = new THREE.Group();
|
const wheels = [];
|
||||||
wheel.position.set(x, radius, z);
|
for (const [x, z] of positions) {
|
||||||
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
|
const wheel = new THREE.Group();
|
||||||
t.rotation.x = Math.PI / 2;
|
wheel.position.set(x, radius, z);
|
||||||
t.castShadow = true;
|
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
|
||||||
wheel.add(t);
|
t.rotation.x = Math.PI / 2;
|
||||||
const side = Math.sign(z);
|
t.castShadow = true;
|
||||||
const face = new THREE.Mesh(new THREE.CylinderGeometry(radius * .68, radius * .68, .02, 24), brake);
|
wheel.add(t);
|
||||||
face.rotation.x = Math.PI / 2;
|
const side = Math.sign(z);
|
||||||
face.position.z = side * tyreWidth * .44;
|
const face = new THREE.Mesh(
|
||||||
wheel.add(face);
|
new THREE.CylinderGeometry(radius * 0.68, radius * 0.68, 0.02, 24),
|
||||||
for (let i = 0; i < spokes; i++) {
|
brake,
|
||||||
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * .16, .04), rim);
|
);
|
||||||
spoke.position.z = side * tyreWidth * .5;
|
face.rotation.x = Math.PI / 2;
|
||||||
spoke.rotation.z = i * Math.PI / spokes;
|
face.position.z = side * tyreWidth * 0.44;
|
||||||
wheel.add(spoke);
|
wheel.add(face);
|
||||||
}
|
for (let i = 0; i < spokes; i++) {
|
||||||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(radius * .2, radius * .2, .05, 12), rim);
|
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * 0.16, 0.04), rim);
|
||||||
hub.rotation.x = Math.PI / 2;
|
spoke.position.z = side * tyreWidth * 0.5;
|
||||||
hub.position.z = side * tyreWidth * .52;
|
spoke.rotation.z = (i * Math.PI) / spokes;
|
||||||
wheel.add(hub);
|
wheel.add(spoke);
|
||||||
group.add(wheel);
|
|
||||||
wheels.push(wheel);
|
|
||||||
}
|
}
|
||||||
return wheels;
|
const hub = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(radius * 0.2, radius * 0.2, 0.05, 12),
|
||||||
|
rim,
|
||||||
|
);
|
||||||
|
hub.rotation.x = Math.PI / 2;
|
||||||
|
hub.position.z = side * tyreWidth * 0.52;
|
||||||
|
wheel.add(hub);
|
||||||
|
group.add(wheel);
|
||||||
|
wheels.push(wheel);
|
||||||
|
}
|
||||||
|
return wheels;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,101 +85,167 @@ function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', sp
|
|||||||
* No manufacturer badges.
|
* No manufacturer badges.
|
||||||
*/
|
*/
|
||||||
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 W = 1.86;
|
const black = mat('#0b0c0d', { roughness: 0.6 }),
|
||||||
const outline = bodyOutline(
|
chrome = mat('#d8dcdf', { metalness: 0.95, roughness: 0.15 });
|
||||||
[[2.24, .3], [2.36, .42], [2.38, .62], [2.28, .78]],
|
const W = 1.86;
|
||||||
[[-2.3, .3], [-2.36, .55], [-2.3, .86], [-2.12, .97]],
|
const outline = bodyOutline(
|
||||||
[-1.42, 1.43], .41,
|
[
|
||||||
[[1.9, .86], [.95, 1.0], [-1.2, 1.0], [-1.6, .99]]
|
[2.24, 0.3],
|
||||||
);
|
[2.36, 0.42],
|
||||||
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {depth: W - .2, bevelEnabled: true, bevelThickness: .1, bevelSize: .08, bevelSegments: 3, curveSegments: 10});
|
[2.38, 0.62],
|
||||||
bodyGeometry.translate(0, 0, -(W - .2) / 2);
|
[2.28, 0.78],
|
||||||
const body = new THREE.Mesh(bodyGeometry, paint);
|
],
|
||||||
body.castShadow = body.receiveShadow = true;
|
[
|
||||||
group.add(body);
|
[-2.3, 0.3],
|
||||||
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
|
[-2.36, 0.55],
|
||||||
const cabin = extrudeProfile([[.95, .98], [.05, 1.39], [-.9, 1.41], [-1.62, 1.0], [-1.5, .98]], 1.5, glass, .08);
|
[-2.3, 0.86],
|
||||||
group.add(cabin);
|
[-2.12, 0.97],
|
||||||
const roof = extrudeProfile([[.0, 1.37], [-.88, 1.39], [-1.0, 1.32], [.12, 1.32]], 1.36, paint, .06);
|
],
|
||||||
roof.position.y = .045;
|
[-1.42, 1.43],
|
||||||
group.add(roof);
|
0.41,
|
||||||
const pillar = new THREE.ShapeGeometry(new THREE.Shape([new THREE.Vector2(-1.05, 1.0), new THREE.Vector2(-1.55, 1.0), new THREE.Vector2(-1.08, 1.33), new THREE.Vector2(-.96, 1.33), new THREE.Vector2(-1.18, 1.12)]));
|
[
|
||||||
const pillarPaint = paint.clone();
|
[1.9, 0.86],
|
||||||
pillarPaint.side = THREE.DoubleSide;
|
[0.95, 1.0],
|
||||||
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
|
[-1.2, 1.0],
|
||||||
for (const side of [-1, 1]) {
|
[-1.6, 0.99],
|
||||||
const z = side * .72;
|
],
|
||||||
const b = new THREE.Mesh(new THREE.BoxGeometry(.09, .4, .04), black);
|
);
|
||||||
b.position.set(-.35, 1.18, z);
|
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {
|
||||||
group.add(b);
|
depth: W - 0.2,
|
||||||
const c = new THREE.Mesh(pillar, pillarPaint);
|
bevelEnabled: true,
|
||||||
c.position.z = z + side * .035;
|
bevelThickness: 0.1,
|
||||||
group.add(c);
|
bevelSize: 0.08,
|
||||||
// Door handles, mirror and the chrome window line.
|
bevelSegments: 3,
|
||||||
for (const x of [.15, -.75]) {
|
curveSegments: 10,
|
||||||
const handle = new THREE.Mesh(new THREE.BoxGeometry(.18, .04, .03), chrome);
|
});
|
||||||
handle.position.set(x, .9, side * .94);
|
bodyGeometry.translate(0, 0, -(W - 0.2) / 2);
|
||||||
group.add(handle);
|
const body = new THREE.Mesh(bodyGeometry, paint);
|
||||||
}
|
body.castShadow = body.receiveShadow = true;
|
||||||
const mirror = new THREE.Mesh(new THREE.BoxGeometry(.18, .12, .2), paint);
|
group.add(body);
|
||||||
mirror.position.set(.78, 1.05, side * 1.0);
|
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
|
||||||
group.add(mirror);
|
const cabin = extrudeProfile(
|
||||||
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, .025, .02), chrome);
|
[
|
||||||
trim.position.set(-.3, .985, side * .76);
|
[0.95, 0.98],
|
||||||
group.add(trim);
|
[0.05, 1.39],
|
||||||
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, .08, .04), black);
|
[-0.9, 1.41],
|
||||||
skirt.position.set(0, .34, side * .93);
|
[-1.62, 1.0],
|
||||||
group.add(skirt);
|
[-1.5, 0.98],
|
||||||
|
],
|
||||||
|
1.5,
|
||||||
|
glass,
|
||||||
|
0.08,
|
||||||
|
);
|
||||||
|
group.add(cabin);
|
||||||
|
const roof = extrudeProfile(
|
||||||
|
[
|
||||||
|
[0.0, 1.37],
|
||||||
|
[-0.88, 1.39],
|
||||||
|
[-1.0, 1.32],
|
||||||
|
[0.12, 1.32],
|
||||||
|
],
|
||||||
|
1.36,
|
||||||
|
paint,
|
||||||
|
0.06,
|
||||||
|
);
|
||||||
|
roof.position.y = 0.045;
|
||||||
|
group.add(roof);
|
||||||
|
const pillar = new THREE.ShapeGeometry(
|
||||||
|
new THREE.Shape([
|
||||||
|
new THREE.Vector2(-1.05, 1.0),
|
||||||
|
new THREE.Vector2(-1.55, 1.0),
|
||||||
|
new THREE.Vector2(-1.08, 1.33),
|
||||||
|
new THREE.Vector2(-0.96, 1.33),
|
||||||
|
new THREE.Vector2(-1.18, 1.12),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const pillarPaint = paint.clone();
|
||||||
|
pillarPaint.side = THREE.DoubleSide;
|
||||||
|
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const z = side * 0.72;
|
||||||
|
const b = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.4, 0.04), black);
|
||||||
|
b.position.set(-0.35, 1.18, z);
|
||||||
|
group.add(b);
|
||||||
|
const c = new THREE.Mesh(pillar, pillarPaint);
|
||||||
|
c.position.z = z + side * 0.035;
|
||||||
|
group.add(c);
|
||||||
|
// Door handles, mirror and the chrome window line.
|
||||||
|
for (const x of [0.15, -0.75]) {
|
||||||
|
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.04, 0.03), chrome);
|
||||||
|
handle.position.set(x, 0.9, side * 0.94);
|
||||||
|
group.add(handle);
|
||||||
}
|
}
|
||||||
// Twin kidney grille with chrome surrounds.
|
const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.2), paint);
|
||||||
for (const side of [-1, 1]) {
|
mirror.position.set(0.78, 1.05, side * 1.0);
|
||||||
const surround = new THREE.Mesh(new THREE.BoxGeometry(.06, .2, .3), chrome);
|
group.add(mirror);
|
||||||
surround.position.set(2.46, .66, side * .18);
|
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.025, 0.02), chrome);
|
||||||
group.add(surround);
|
trim.position.set(-0.3, 0.985, side * 0.76);
|
||||||
const grille = new THREE.Mesh(new THREE.BoxGeometry(.07, .16, .25), black);
|
group.add(trim);
|
||||||
grille.position.set(2.47, .66, side * .18);
|
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.08, 0.04), black);
|
||||||
group.add(grille);
|
skirt.position.set(0, 0.34, side * 0.93);
|
||||||
for (let i = 0; i < 5; i++) {
|
group.add(skirt);
|
||||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(.075, .15, .012), chrome);
|
}
|
||||||
bar.position.set(2.47, .66, side * .18 + (i - 2) * .05);
|
// Twin kidney grille with chrome surrounds.
|
||||||
group.add(bar);
|
for (const side of [-1, 1]) {
|
||||||
}
|
const surround = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, 0.3), chrome);
|
||||||
|
surround.position.set(2.46, 0.66, side * 0.18);
|
||||||
|
group.add(surround);
|
||||||
|
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.16, 0.25), black);
|
||||||
|
grille.position.set(2.47, 0.66, side * 0.18);
|
||||||
|
group.add(grille);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.075, 0.15, 0.012), chrome);
|
||||||
|
bar.position.set(2.47, 0.66, side * 0.18 + (i - 2) * 0.05);
|
||||||
|
group.add(bar);
|
||||||
}
|
}
|
||||||
// Headlights with two light rings each, and slim L-shaped tail lights.
|
}
|
||||||
const ring = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.6});
|
// Headlights with two light rings each, and slim L-shaped tail lights.
|
||||||
const tail = mat('#9e1410', {emissive: '#e0261c', emissiveIntensity: .9});
|
const ring = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 });
|
||||||
for (const side of [-1, 1]) {
|
const tail = mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 });
|
||||||
const housing = new THREE.Mesh(new THREE.BoxGeometry(.12, .14, .44), black);
|
for (const side of [-1, 1]) {
|
||||||
housing.position.set(2.42, .72, side * .6);
|
const housing = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.14, 0.44), black);
|
||||||
group.add(housing);
|
housing.position.set(2.42, 0.72, side * 0.6);
|
||||||
for (const dz of [-.1, .1]) {
|
group.add(housing);
|
||||||
const eye = new THREE.Mesh(new THREE.TorusGeometry(.05, .012, 6, 18), ring);
|
for (const dz of [-0.1, 0.1]) {
|
||||||
eye.rotation.y = Math.PI / 2;
|
const eye = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 6, 18), ring);
|
||||||
eye.position.set(2.48, .72, side * .6 + dz);
|
eye.rotation.y = Math.PI / 2;
|
||||||
group.add(eye);
|
eye.position.set(2.48, 0.72, side * 0.6 + dz);
|
||||||
}
|
group.add(eye);
|
||||||
const tl = new THREE.Mesh(new THREE.BoxGeometry(.06, .1, .42), tail);
|
|
||||||
tl.position.set(-2.45, .82, side * .62);
|
|
||||||
group.add(tl);
|
|
||||||
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(.3, .1, .05), tail);
|
|
||||||
tlSide.position.set(-2.2, .82, side * .9);
|
|
||||||
group.add(tlSide);
|
|
||||||
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .12, 12), chrome);
|
|
||||||
exhaust.rotation.z = Math.PI / 2;
|
|
||||||
exhaust.position.set(-2.44, .36, side * .45);
|
|
||||||
group.add(exhaust);
|
|
||||||
}
|
}
|
||||||
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(.05, .12, 1.2), black);
|
const tl = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.1, 0.42), tail);
|
||||||
lowerGrille.position.set(2.45, .42, 0);
|
tl.position.set(-2.45, 0.82, side * 0.62);
|
||||||
group.add(lowerGrille);
|
group.add(tl);
|
||||||
const plate = new THREE.Mesh(new THREE.BoxGeometry(.03, .11, .5), mat('#f4f4f0'));
|
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.1, 0.05), tail);
|
||||||
plate.position.set(2.47, .5, 0);
|
tlSide.position.set(-2.2, 0.82, side * 0.9);
|
||||||
group.add(plate);
|
group.add(tlSide);
|
||||||
const wheels = addWheels(group, [[-1.42, -.82], [-1.42, .82], [1.43, -.82], [1.43, .82]], .36, .26, '#b8bcc0', 5);
|
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.12, 12), chrome);
|
||||||
return {group, wheels};
|
exhaust.rotation.z = Math.PI / 2;
|
||||||
|
exhaust.position.set(-2.44, 0.36, side * 0.45);
|
||||||
|
group.add(exhaust);
|
||||||
|
}
|
||||||
|
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.12, 1.2), black);
|
||||||
|
lowerGrille.position.set(2.45, 0.42, 0);
|
||||||
|
group.add(lowerGrille);
|
||||||
|
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.11, 0.5), mat('#f4f4f0'));
|
||||||
|
plate.position.set(2.47, 0.5, 0);
|
||||||
|
group.add(plate);
|
||||||
|
const wheels = addWheels(
|
||||||
|
group,
|
||||||
|
[
|
||||||
|
[-1.42, -0.82],
|
||||||
|
[-1.42, 0.82],
|
||||||
|
[1.43, -0.82],
|
||||||
|
[1.43, 0.82],
|
||||||
|
],
|
||||||
|
0.36,
|
||||||
|
0.26,
|
||||||
|
'#b8bcc0',
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
return { group, wheels };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,42 +253,103 @@ export function createSportSedan(color = '#1d3f78') {
|
|||||||
* bed behind the cab. 5.3 m long.
|
* bed behind the cab. 5.3 m long.
|
||||||
*/
|
*/
|
||||||
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 box = (w, h, d, m, x, y, z) => {
|
const black = mat('#1a1c1e', { roughness: 0.7 }),
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
chrome = mat('#d0d4d7', { metalness: 0.9, roughness: 0.2 });
|
||||||
mesh.position.set(x, y, z);
|
const box = (w, h, d, m, x, y, z) => {
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
group.add(mesh);
|
mesh.position.set(x, y, z);
|
||||||
return mesh;
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
};
|
group.add(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));
|
return mesh;
|
||||||
group.add(extrudeProfile([[1.3, .5], [1.3, 1.24], [.7, 1.86], [-.95, 1.88], [-1.0, .5]], 1.88, paint, .1));
|
};
|
||||||
group.add(extrudeProfile([[1.24, 1.26], [.68, 1.82], [-.9, 1.84], [-.94, 1.26]], 1.92, glass, .02));
|
group.add(
|
||||||
// Cargo bed: floor, sides and tailgate.
|
extrudeProfile(
|
||||||
box(1.62, .1, 1.8, black, -1.85, .72, 0);
|
[
|
||||||
for (const side of [-1, 1]) box(1.66, .5, .07, paint, -1.85, .95, side * .9);
|
[2.62, 0.5],
|
||||||
box(.07, .5, 1.86, paint, -2.66, .95, 0);
|
[2.66, 0.95],
|
||||||
box(1.66, .46, 1.9, paint, -1.85, .5, 0);
|
[2.5, 1.18],
|
||||||
box(.3, .06, 1.86, black, -1.02, 1.26, 0);
|
[1.3, 1.24],
|
||||||
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
|
[1.3, 0.5],
|
||||||
box(.06, .34, 1.1, black, 2.67, .92, 0);
|
],
|
||||||
for (let i = 0; i < 3; i++) box(.07, .04, 1.12, chrome, 2.68, .82 + i * .1, 0);
|
1.9,
|
||||||
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.2});
|
paint,
|
||||||
for (const side of [-1, 1]) {
|
0.1,
|
||||||
box(.08, .14, .32, lamp, 2.62, 1.02, side * .74);
|
),
|
||||||
box(.06, .26, .1, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .7}), -2.7, 1.0, side * .86);
|
);
|
||||||
box(.2, .14, .2, black, 1.2, 1.36, side * 1.02);
|
group.add(
|
||||||
box(1.0, .05, .06, chrome, -.1, 1.25, side * .97);
|
extrudeProfile(
|
||||||
for (const x of [.6, -.4]) box(.16, .04, .03, chrome, x, 1.12, side * .97);
|
[
|
||||||
}
|
[1.3, 0.5],
|
||||||
box(.16, .2, 2.0, black, 2.7, .55, 0);
|
[1.3, 1.24],
|
||||||
box(.16, .2, 2.0, black, -2.72, .55, 0);
|
[0.7, 1.86],
|
||||||
box(.04, .12, .5, mat('#f4f4f0'), 2.79, .6, 0);
|
[-0.95, 1.88],
|
||||||
const wheels = addWheels(group, [[-1.7, -.86], [-1.7, .86], [1.7, -.86], [1.7, .86]], .4, .3, '#a9adb1', 6);
|
[-1.0, 0.5],
|
||||||
for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, .1, .2, black, x, .86, side * .96);
|
],
|
||||||
return {group, wheels};
|
1.88,
|
||||||
|
paint,
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
group.add(
|
||||||
|
extrudeProfile(
|
||||||
|
[
|
||||||
|
[1.24, 1.26],
|
||||||
|
[0.68, 1.82],
|
||||||
|
[-0.9, 1.84],
|
||||||
|
[-0.94, 1.26],
|
||||||
|
],
|
||||||
|
1.92,
|
||||||
|
glass,
|
||||||
|
0.02,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Cargo bed: floor, sides and tailgate.
|
||||||
|
box(1.62, 0.1, 1.8, black, -1.85, 0.72, 0);
|
||||||
|
for (const side of [-1, 1]) box(1.66, 0.5, 0.07, paint, -1.85, 0.95, side * 0.9);
|
||||||
|
box(0.07, 0.5, 1.86, paint, -2.66, 0.95, 0);
|
||||||
|
box(1.66, 0.46, 1.9, paint, -1.85, 0.5, 0);
|
||||||
|
box(0.3, 0.06, 1.86, black, -1.02, 1.26, 0);
|
||||||
|
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
|
||||||
|
box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0);
|
||||||
|
for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0);
|
||||||
|
const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.2 });
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
|
||||||
|
box(
|
||||||
|
0.06,
|
||||||
|
0.26,
|
||||||
|
0.1,
|
||||||
|
mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.7 }),
|
||||||
|
-2.7,
|
||||||
|
1.0,
|
||||||
|
side * 0.86,
|
||||||
|
);
|
||||||
|
box(0.2, 0.14, 0.2, black, 1.2, 1.36, side * 1.02);
|
||||||
|
box(1.0, 0.05, 0.06, chrome, -0.1, 1.25, side * 0.97);
|
||||||
|
for (const x of [0.6, -0.4]) box(0.16, 0.04, 0.03, chrome, x, 1.12, side * 0.97);
|
||||||
|
}
|
||||||
|
box(0.16, 0.2, 2.0, black, 2.7, 0.55, 0);
|
||||||
|
box(0.16, 0.2, 2.0, black, -2.72, 0.55, 0);
|
||||||
|
box(0.04, 0.12, 0.5, mat('#f4f4f0'), 2.79, 0.6, 0);
|
||||||
|
const wheels = addWheels(
|
||||||
|
group,
|
||||||
|
[
|
||||||
|
[-1.7, -0.86],
|
||||||
|
[-1.7, 0.86],
|
||||||
|
[1.7, -0.86],
|
||||||
|
[1.7, 0.86],
|
||||||
|
],
|
||||||
|
0.4,
|
||||||
|
0.3,
|
||||||
|
'#a9adb1',
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
for (const x of [-1.7, 1.7])
|
||||||
|
for (const side of [-1, 1]) box(1.05, 0.1, 0.2, black, x, 0.86, side * 0.96);
|
||||||
|
return { group, wheels };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,37 +357,60 @@ export function createPickup(color = '#e8e8e4') {
|
|||||||
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
|
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
|
||||||
*/
|
*/
|
||||||
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 box = (w, h, d, m, x, y, z, parent = group) => {
|
const black = mat('#1b1d1f', { roughness: 0.7 }),
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 });
|
||||||
mesh.position.set(x, y, z);
|
const box = (w, h, d, m, x, y, z, parent = group) => {
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
parent.add(mesh);
|
mesh.position.set(x, y, z);
|
||||||
return mesh;
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
};
|
parent.add(mesh);
|
||||||
// Cab over the front axle.
|
return mesh;
|
||||||
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
|
};
|
||||||
box(.05, .9, 2.0, glass, 3.91, 2.35, 0);
|
// Cab over the front axle.
|
||||||
for (const side of [-1, 1]) box(1.0, .7, .04, glass, 3.1, 2.4, side * 1.16);
|
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
|
||||||
box(.08, .3, 2.2, teal, 3.92, 1.35, 0);
|
box(0.05, 0.9, 2.0, glass, 3.91, 2.35, 0);
|
||||||
box(.25, .35, 2.36, black, 3.95, .85, 0);
|
for (const side of [-1, 1]) box(1.0, 0.7, 0.04, glass, 3.1, 2.4, side * 1.16);
|
||||||
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1});
|
box(0.08, 0.3, 2.2, teal, 3.92, 1.35, 0);
|
||||||
for (const side of [-1, 1]) {
|
box(0.25, 0.35, 2.36, black, 3.95, 0.85, 0);
|
||||||
box(.06, .16, .3, lamp, 3.98, 1.1, side * .9);
|
const lamp = mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1 });
|
||||||
box(.3, .3, .08, black, 3.4, 2.7, side * 1.25);
|
for (const side of [-1, 1]) {
|
||||||
}
|
box(0.06, 0.16, 0.3, lamp, 3.98, 1.1, side * 0.9);
|
||||||
// Chassis and the flatbed with low side rails.
|
box(0.3, 0.3, 0.08, black, 3.4, 2.7, side * 1.25);
|
||||||
box(7.6, .3, 1.0, black, 0, .75, 0);
|
}
|
||||||
box(5.9, .14, 2.4, mat('#6b5a45', {roughness: .9}), -1.05, 1.0, 0);
|
// Chassis and the flatbed with low side rails.
|
||||||
for (const side of [-1, 1]) box(5.9, .5, .06, teal, -1.05, 1.32, side * 1.19);
|
box(7.6, 0.3, 1.0, black, 0, 0.75, 0);
|
||||||
box(.06, .5, 2.4, teal, -4.0, 1.32, 0);
|
box(5.9, 0.14, 2.4, mat('#6b5a45', { roughness: 0.9 }), -1.05, 1.0, 0);
|
||||||
box(.1, 1.6, 2.4, black, 1.9, 1.85, 0);
|
for (const side of [-1, 1]) box(5.9, 0.5, 0.06, teal, -1.05, 1.32, side * 1.19);
|
||||||
for (const side of [-1, 1]) box(.1, .22, .26, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .6}), -4.02, .82, side * .95);
|
box(0.06, 0.5, 2.4, teal, -4.0, 1.32, 0);
|
||||||
const bed = new THREE.Group();
|
box(0.1, 1.6, 2.4, black, 1.9, 1.85, 0);
|
||||||
bed.position.set(-1.05, 1.07, 0);
|
for (const side of [-1, 1])
|
||||||
group.add(bed);
|
box(
|
||||||
const wheels = addWheels(group, [[-2.9, -1.0], [-2.9, 1.0], [2.9, -1.0], [2.9, 1.0]], .5, .38, '#c2c6c9', 8);
|
0.1,
|
||||||
return {group, wheels, bed};
|
0.22,
|
||||||
|
0.26,
|
||||||
|
mat('#9e1410', { emissive: '#c4241b', emissiveIntensity: 0.6 }),
|
||||||
|
-4.02,
|
||||||
|
0.82,
|
||||||
|
side * 0.95,
|
||||||
|
);
|
||||||
|
const bed = new THREE.Group();
|
||||||
|
bed.position.set(-1.05, 1.07, 0);
|
||||||
|
group.add(bed);
|
||||||
|
const wheels = addWheels(
|
||||||
|
group,
|
||||||
|
[
|
||||||
|
[-2.9, -1.0],
|
||||||
|
[-2.9, 1.0],
|
||||||
|
[2.9, -1.0],
|
||||||
|
[2.9, 1.0],
|
||||||
|
],
|
||||||
|
0.5,
|
||||||
|
0.38,
|
||||||
|
'#c2c6c9',
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
return { group, wheels, bed };
|
||||||
}
|
}
|
||||||
|
|||||||
+398
-212
@@ -2,225 +2,411 @@ import * as THREE from 'three';
|
|||||||
|
|
||||||
// Lightweight articulated characters; all coordinates below are in the group.
|
// Lightweight articulated characters; all coordinates below are in the group.
|
||||||
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
|
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
|
||||||
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);
|
||||||
m.position.set(...position);
|
m.position.set(...position);
|
||||||
m.castShadow = true;
|
m.castShadow = true;
|
||||||
m.receiveShadow = true;
|
m.receiveShadow = true;
|
||||||
parent.add(m);
|
parent.add(m);
|
||||||
return m;
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
|
||||||
|
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
||||||
|
m.scale.set(...scale);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segment(parent, a, b, r, material) {
|
||||||
|
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
||||||
|
return { mesh, a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSegment(bone, a, b) {
|
||||||
|
const delta = b.clone().sub(a);
|
||||||
|
bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
|
||||||
|
bone.mesh.scale.y = delta.length();
|
||||||
|
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
||||||
|
function makeRobot(z) {
|
||||||
|
const scale = 0.69,
|
||||||
|
root = new THREE.Group(),
|
||||||
|
torso = new THREE.Group();
|
||||||
|
root.name = 'robot-market-mascot';
|
||||||
|
root.position.z = z;
|
||||||
|
group.add(root);
|
||||||
|
root.add(torso);
|
||||||
|
const teal = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#079f98',
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.28,
|
||||||
|
});
|
||||||
|
const white = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#edf6f4',
|
||||||
|
metalness: 0.22,
|
||||||
|
roughness: 0.32,
|
||||||
|
});
|
||||||
|
const joint = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#102b30',
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.35,
|
||||||
|
});
|
||||||
|
const glow = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#c6f7ff',
|
||||||
|
emissive: '#23bcff',
|
||||||
|
emissiveIntensity: 2.5,
|
||||||
|
roughness: 0.2,
|
||||||
|
});
|
||||||
|
const visor = new THREE.MeshStandardMaterial({
|
||||||
|
color: '#020d16',
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.16,
|
||||||
|
});
|
||||||
|
sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]);
|
||||||
|
sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]);
|
||||||
|
for (const side of [-1, 1]) sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]);
|
||||||
|
shape(torso, new THREE.CylinderGeometry(0.09, 0.09, 0.12, 12), joint, [0, 1.27, 0]);
|
||||||
|
const head = new THREE.Group();
|
||||||
|
head.position.y = 1.61;
|
||||||
|
torso.add(head);
|
||||||
|
sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]);
|
||||||
|
sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]);
|
||||||
|
sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
|
||||||
|
const eyes = [];
|
||||||
|
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
|
||||||
|
const line = shape(
|
||||||
|
parent,
|
||||||
|
new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
|
||||||
|
material,
|
||||||
|
pos,
|
||||||
|
);
|
||||||
|
line.rotation.z = rotation;
|
||||||
|
return line;
|
||||||
}
|
}
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
|
eyes.push(arc(head, 0.065, 0.016, [side * 0.16, 0.025, 0.423]));
|
||||||
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
const ear = shape(head, new THREE.CylinderGeometry(0.14, 0.14, 0.085, 24), joint, [
|
||||||
m.scale.set(...scale);
|
side * 0.47,
|
||||||
return m;
|
0,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
ear.rotation.z = Math.PI / 2;
|
||||||
|
const ring = shape(head, new THREE.TorusGeometry(0.105, 0.012, 8, 24), glow, [
|
||||||
|
side * 0.518,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
ring.rotation.y = Math.PI / 2;
|
||||||
|
sphere(head, 0.12, teal, [side * 0.49, 0, 0], [0.4, 1, 1]);
|
||||||
}
|
}
|
||||||
|
arc(head, 0.075, 0.016, [0, -0.085, 0.425], Math.PI);
|
||||||
function segment(parent, a, b, r, material) {
|
sphere(head, 0.1, teal, [0, 0.38, 0], [1, 0.55, 1]);
|
||||||
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
shape(head, new THREE.CylinderGeometry(0.017, 0.022, 0.18, 10), teal, [0, 0.49, 0]);
|
||||||
return {mesh, a, b};
|
sphere(head, 0.055, teal, [0, 0.6, 0]);
|
||||||
}
|
// Draw the chest mark as a texture so it remains attached to the 3D body.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
function updateSegment(bone, a, b) {
|
const canvas = document.createElement('canvas');
|
||||||
const delta = b.clone().sub(a);
|
canvas.width = 256;
|
||||||
bone.mesh.position.copy(a).add(b).multiplyScalar(.5);
|
canvas.height = 256;
|
||||||
bone.mesh.scale.y = delta.length();
|
const c = canvas.getContext('2d');
|
||||||
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
if (c) {
|
||||||
}
|
c.strokeStyle = '#102b30';
|
||||||
|
c.fillStyle = '#102b30';
|
||||||
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
c.lineWidth = 12;
|
||||||
function makeRobot(z) {
|
c.lineJoin = 'round';
|
||||||
const scale = .69, root = new THREE.Group(), torso = new THREE.Group();
|
c.beginPath();
|
||||||
root.name = 'robot-market-mascot';
|
c.moveTo(65, 40);
|
||||||
root.position.z = z;
|
c.lineTo(82, 40);
|
||||||
group.add(root);
|
c.lineTo(99, 110);
|
||||||
root.add(torso);
|
c.lineTo(170, 110);
|
||||||
const teal = new THREE.MeshStandardMaterial({color: '#079f98', metalness: .4, roughness: .28});
|
c.lineTo(186, 58);
|
||||||
const white = new THREE.MeshStandardMaterial({color: '#edf6f4', metalness: .22, roughness: .32});
|
c.lineTo(89, 58);
|
||||||
const joint = new THREE.MeshStandardMaterial({color: '#102b30', metalness: .5, roughness: .35});
|
c.stroke();
|
||||||
const glow = new THREE.MeshStandardMaterial({color: '#c6f7ff', emissive: '#23bcff', emissiveIntensity: 2.5, roughness: .2});
|
for (const x of [111, 162]) {
|
||||||
const visor = new THREE.MeshStandardMaterial({color: '#020d16', metalness: .5, roughness: .16});
|
c.beginPath();
|
||||||
sphere(torso, .25, teal, [0, .76, 0], [1, .65, .75]);
|
c.arc(x, 130, 9, 0, Math.PI * 2);
|
||||||
sphere(torso, .31, white, [0, .97, 0], [1, 1.05, .72]);
|
c.fill();
|
||||||
for (const side of [-1, 1]) sphere(torso, .17, teal, [side * .24, 1.03, -.04], [.55, 1, 1]);
|
|
||||||
shape(torso, new THREE.CylinderGeometry(.09, .09, .12, 12), joint, [0, 1.27, 0]);
|
|
||||||
const head = new THREE.Group();
|
|
||||||
head.position.y = 1.61;
|
|
||||||
torso.add(head);
|
|
||||||
sphere(head, .43, teal, [0, 0, 0], [1.12, .91, .88]);
|
|
||||||
sphere(head, .40, white, [0, -.015, .12], [1.12, .86, .77]);
|
|
||||||
sphere(head, .35, visor, [0, -.015, .255], [1.13, .83, .48]);
|
|
||||||
const eyes = [];
|
|
||||||
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
|
|
||||||
const line = shape(parent, new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI), material, pos);
|
|
||||||
line.rotation.z = rotation;
|
|
||||||
return line;
|
|
||||||
}
|
}
|
||||||
for (const side of [-1, 1]) {
|
c.textAlign = 'center';
|
||||||
eyes.push(arc(head, .065, .016, [side * .16, .025, .423]));
|
c.font = 'bold 34px sans-serif';
|
||||||
const ear = shape(head, new THREE.CylinderGeometry(.14, .14, .085, 24), joint, [side * .47, 0, 0]);
|
c.fillText('ROBOT', 128, 181);
|
||||||
ear.rotation.z = Math.PI / 2;
|
c.fillText('MARKET', 128, 218);
|
||||||
const ring = shape(head, new THREE.TorusGeometry(.105, .012, 8, 24), glow, [side * .518, 0, 0]);
|
shape(
|
||||||
ring.rotation.y = Math.PI / 2;
|
torso,
|
||||||
sphere(head, .12, teal, [side * .49, 0, 0], [.4, 1, 1]);
|
new THREE.PlaneGeometry(0.31, 0.31),
|
||||||
}
|
new THREE.MeshBasicMaterial({
|
||||||
arc(head, .075, .016, [0, -.085, .425], Math.PI);
|
map: new THREE.CanvasTexture(canvas),
|
||||||
sphere(head, .10, teal, [0, .38, 0], [1, .55, 1]);
|
transparent: true,
|
||||||
shape(head, new THREE.CylinderGeometry(.017, .022, .18, 10), teal, [0, .49, 0]);
|
depthWrite: false,
|
||||||
sphere(head, .055, teal, [0, .60, 0]);
|
}),
|
||||||
// Draw the chest mark as a texture so it remains attached to the 3D body.
|
[0, 0.99, 0.224],
|
||||||
if (typeof document !== 'undefined') {
|
);
|
||||||
const canvas = document.createElement('canvas');
|
}
|
||||||
canvas.width = 256; canvas.height = 256;
|
|
||||||
const c = canvas.getContext('2d');
|
|
||||||
if (c) {
|
|
||||||
c.strokeStyle = '#102b30'; c.fillStyle = '#102b30';
|
|
||||||
c.lineWidth = 12; c.lineJoin = 'round';
|
|
||||||
c.beginPath(); c.moveTo(65, 40); c.lineTo(82, 40); c.lineTo(99, 110); c.lineTo(170, 110); c.lineTo(186, 58); c.lineTo(89, 58); c.stroke();
|
|
||||||
for (const x of [111, 162]) {c.beginPath(); c.arc(x, 130, 9, 0, Math.PI * 2); c.fill();}
|
|
||||||
c.textAlign = 'center'; c.font = 'bold 34px sans-serif';
|
|
||||||
c.fillText('ROBOT', 128, 181); c.fillText('MARKET', 128, 218);
|
|
||||||
shape(torso, new THREE.PlaneGeometry(.31, .31), new THREE.MeshBasicMaterial({map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false}), [0, .99, .224]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const legs = [-1, 1].map(side => {
|
|
||||||
const hip = new THREE.Group(); hip.position.set(side * .15, .65, 0); root.add(hip);
|
|
||||||
sphere(hip, .09, joint, [0, 0, 0]);
|
|
||||||
shape(hip, new THREE.CapsuleGeometry(.095, .12, 4, 12), white, [0, -.12, 0]);
|
|
||||||
const knee = new THREE.Group(); knee.position.y = -.25; hip.add(knee);
|
|
||||||
sphere(knee, .105, teal, [0, 0, .035]);
|
|
||||||
shape(knee, new THREE.CapsuleGeometry(.10, .12, 4, 12), white, [0, -.13, 0]);
|
|
||||||
const foot = sphere(knee, .13, teal, [0, -.29, .07], [1, .65, 1.6]);
|
|
||||||
sphere(knee, .13, joint, [0, -.34, .07], [1.06, .22, 1.65]);
|
|
||||||
return {hip, knee, foot};
|
|
||||||
});
|
|
||||||
const arms = [-1, 1].map(() => ({
|
|
||||||
upper: segment(group, null, null, .09, teal),
|
|
||||||
lower: segment(group, null, null, .085, white),
|
|
||||||
hand: sphere(group, .09, joint, [0, 0, 0], [1, 1.1, 1])
|
|
||||||
}));
|
|
||||||
return {root, torso, head, legs, arms, scale, z, eyes};
|
|
||||||
}
|
}
|
||||||
|
const legs = [-1, 1].map((side) => {
|
||||||
|
const hip = new THREE.Group();
|
||||||
|
hip.position.set(side * 0.15, 0.65, 0);
|
||||||
|
root.add(hip);
|
||||||
|
sphere(hip, 0.09, joint, [0, 0, 0]);
|
||||||
|
shape(hip, new THREE.CapsuleGeometry(0.095, 0.12, 4, 12), white, [0, -0.12, 0]);
|
||||||
|
const knee = new THREE.Group();
|
||||||
|
knee.position.y = -0.25;
|
||||||
|
hip.add(knee);
|
||||||
|
sphere(knee, 0.105, teal, [0, 0, 0.035]);
|
||||||
|
shape(knee, new THREE.CapsuleGeometry(0.1, 0.12, 4, 12), white, [0, -0.13, 0]);
|
||||||
|
const foot = sphere(knee, 0.13, teal, [0, -0.29, 0.07], [1, 0.65, 1.6]);
|
||||||
|
sphere(knee, 0.13, joint, [0, -0.34, 0.07], [1.06, 0.22, 1.65]);
|
||||||
|
return { hip, knee, foot };
|
||||||
|
});
|
||||||
|
const arms = [-1, 1].map(() => ({
|
||||||
|
upper: segment(group, null, null, 0.09, teal),
|
||||||
|
lower: segment(group, null, null, 0.085, white),
|
||||||
|
hand: sphere(group, 0.09, joint, [0, 0, 0], [1, 1.1, 1]),
|
||||||
|
}));
|
||||||
|
return { root, torso, head, legs, arms, scale, z, eyes };
|
||||||
|
}
|
||||||
|
|
||||||
function makePerson(isChild, z) {
|
function makePerson(isChild, z) {
|
||||||
const scale = isChild ? .69 : 1.05, root = new THREE.Group();
|
const scale = isChild ? 0.69 : 1.05,
|
||||||
root.position.z = z;
|
root = new THREE.Group();
|
||||||
group.add(root);
|
root.position.z = z;
|
||||||
const torso = new THREE.Group();
|
group.add(root);
|
||||||
root.add(torso);
|
const torso = new THREE.Group();
|
||||||
const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474');
|
root.add(torso);
|
||||||
// Tapered jacket, hips and neck replace the single rounded toy-like body.
|
const cloth = mat(isChild ? '#d98150' : '#e4c695'),
|
||||||
shape(torso, new THREE.CylinderGeometry(.255 * scale, .22 * scale, .62 * scale, 16), cloth, [0, 1.2 * scale, 0]).scale.z = .72;
|
pants = mat(isChild ? '#527463' : '#496474');
|
||||||
sphere(torso, .23, pants, [0, .88 * scale, 0], [scale, .55 * scale, .72 * scale]);
|
// Tapered jacket, hips and neck replace the single rounded toy-like body.
|
||||||
shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]);
|
shape(torso, new THREE.CylinderGeometry(0.255 * scale, 0.22 * scale, 0.62 * scale, 16), cloth, [
|
||||||
const seam = shape(torso, new THREE.BoxGeometry(.014 * scale, .5 * scale, .014 * scale), mat('#bda77f'), [0, 1.2 * scale, .19 * scale]);
|
0,
|
||||||
for (const side of [-1, 1]) {
|
1.2 * scale,
|
||||||
const collar = shape(torso, new THREE.BoxGeometry(.12 * scale, .09 * scale, .025 * scale), cloth, [side * .07 * scale, 1.49 * scale, .15 * scale]);
|
0,
|
||||||
collar.rotation.z = side * .4;
|
]).scale.z = 0.72;
|
||||||
}
|
sphere(torso, 0.23, pants, [0, 0.88 * scale, 0], [scale, 0.55 * scale, 0.72 * scale]);
|
||||||
const head = new THREE.Group();
|
shape(torso, new THREE.CylinderGeometry(0.075 * scale, 0.09 * scale, 0.18 * scale, 12), skin, [
|
||||||
head.position.y = 1.77 * scale;
|
0,
|
||||||
torso.add(head);
|
1.59 * scale,
|
||||||
head.scale.setScalar(isChild ? .9 : .77);
|
0,
|
||||||
const eyes = [];
|
]);
|
||||||
sphere(head, .27 * scale, skin, [0, 0, 0]);
|
const seam = shape(
|
||||||
sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]);
|
torso,
|
||||||
sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]);
|
new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale),
|
||||||
for (const side of [-1, 1]) {
|
mat('#bda77f'),
|
||||||
sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]);
|
[0, 1.2 * scale, 0.19 * scale],
|
||||||
sphere(head, .037 * scale, mat('#f6f1e7'), [side * .09 * scale, .03 * scale, .241 * scale], [1, .65, .5]);
|
);
|
||||||
const eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]);
|
for (const side of [-1, 1]) {
|
||||||
eyes.push(eye);
|
const collar = shape(
|
||||||
const brow = shape(head, new THREE.BoxGeometry(.075 * scale, .015 * scale, .015 * scale), hair, [side * .09 * scale, .087 * scale, .237 * scale]);
|
torso,
|
||||||
brow.rotation.z = side * -.08;
|
new THREE.BoxGeometry(0.12 * scale, 0.09 * scale, 0.025 * scale),
|
||||||
sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]);
|
cloth,
|
||||||
}
|
[side * 0.07 * scale, 1.49 * scale, 0.15 * scale],
|
||||||
const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]);
|
);
|
||||||
smile.rotation.z = Math.PI;
|
collar.rotation.z = side * 0.4;
|
||||||
if (isChild) {
|
|
||||||
sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]);
|
|
||||||
shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]);
|
|
||||||
} else {
|
|
||||||
sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]);
|
|
||||||
}
|
|
||||||
const legs = [-1, 1].map(side => {
|
|
||||||
const hip = new THREE.Group();
|
|
||||||
hip.position.set(side * .15 * scale, .83 * scale, 0);
|
|
||||||
root.add(hip);
|
|
||||||
shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
|
|
||||||
const knee = new THREE.Group();
|
|
||||||
knee.position.y = -.34 * scale;
|
|
||||||
hip.add(knee);
|
|
||||||
shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
|
|
||||||
const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]);
|
|
||||||
foot.scale.set(.85, .65, 1.5);
|
|
||||||
return {hip, knee, foot};
|
|
||||||
});
|
|
||||||
const arms = [-1, 1].map(() => ({
|
|
||||||
upper: segment(group, null, null, .072 * scale, cloth),
|
|
||||||
lower: segment(group, null, null, .061 * scale, skin),
|
|
||||||
hand: sphere(group, .075 * scale, skin, [0, 0, 0])
|
|
||||||
}));
|
|
||||||
return {root, torso, head, legs, arms, scale, z, eyes};
|
|
||||||
}
|
}
|
||||||
|
const head = new THREE.Group();
|
||||||
const father = robotOnly ? null : makePerson(false, -.45), child = robotOnly ? null : makePerson(true, .45);
|
head.position.y = 1.77 * scale;
|
||||||
const robot = robotOnly ? makeRobot(0) : null;
|
torso.add(head);
|
||||||
const actors = robotOnly ? [robot] : [father, child];
|
head.scale.setScalar(isChild ? 0.9 : 0.77);
|
||||||
|
const eyes = [];
|
||||||
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
|
sphere(head, 0.27 * scale, skin, [0, 0, 0]);
|
||||||
// `wave` raises the robot's right hand, `point` stretches it forward.
|
sphere(head, 0.275 * scale, hair, [0, 0.11 * scale, -0.035 * scale], [1, 0.65, 1]);
|
||||||
function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, wave = false, point = false, dt = 1 / 60}) {
|
sphere(head, 0.058 * scale, skin, [0, -0.02 * scale, 0.25 * scale]);
|
||||||
actors.forEach((actor, index) => {
|
for (const side of [-1, 1]) {
|
||||||
const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
|
sphere(head, 0.06 * scale, skin, [side * 0.26 * scale, 0, 0]);
|
||||||
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend);
|
sphere(
|
||||||
// The robot hovers; it bobs faster while it is speaking.
|
head,
|
||||||
const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0;
|
0.037 * scale,
|
||||||
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : hover, isRobot ? 1 : blend);
|
mat('#f6f1e7'),
|
||||||
actor.root.position.z = finished ? 0 : actor.z;
|
[side * 0.09 * scale, 0.03 * scale, 0.241 * scale],
|
||||||
actor.root.rotation.y = facing;
|
[1, 0.65, 0.5],
|
||||||
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
|
);
|
||||||
actor.torso.position.y = bob;
|
const eye = sphere(
|
||||||
actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0;
|
head,
|
||||||
const blinking = Math.sin(time * 1.3 + index) > .993;
|
0.022 * scale,
|
||||||
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
|
shoes,
|
||||||
actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06;
|
[side * 0.09 * scale, 0.03 * scale, 0.258 * scale],
|
||||||
actor.torso.rotation.x = delivery && actor === child ? .22 : 0;
|
[0.8, 1, 0.45],
|
||||||
actor.legs.forEach((leg, i) => {
|
);
|
||||||
const phase = gait + i * Math.PI;
|
eyes.push(eye);
|
||||||
const stride = isRobot ? .08 : .34;
|
const brow = shape(
|
||||||
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
|
head,
|
||||||
leg.foot.rotation.x = moving ? Math.sin(phase + .5) * .12 : 0;
|
new THREE.BoxGeometry(0.075 * scale, 0.015 * scale, 0.015 * scale),
|
||||||
leg.knee.rotation.x = finished ? 2.5 : isRobot ? .25 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
|
hair,
|
||||||
});
|
[side * 0.09 * scale, 0.087 * scale, 0.237 * scale],
|
||||||
});
|
);
|
||||||
group.updateMatrixWorld(true);
|
brow.rotation.z = side * -0.08;
|
||||||
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
|
sphere(head, 0.04 * scale, mat('#d79379'), [
|
||||||
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, (actor === robot ? 1.12 : 1.43) * actor.scale, 0));
|
side * 0.15 * scale,
|
||||||
// With characters facing +X, father's left and child's right hands meet.
|
-0.055 * scale,
|
||||||
const contact = new THREE.Vector3(0, 1.06, 0);
|
0.215 * scale,
|
||||||
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => {
|
]);
|
||||||
const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1;
|
|
||||||
const a = shoulder(actor, side);
|
|
||||||
let target;
|
|
||||||
if (inside && !shopping && !finished) target = contact.clone();
|
|
||||||
else if (actor === child && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
|
|
||||||
else if (actor === robot && side === 1 && wave) target = toFamily(actor, new THREE.Vector3(.42, 1.62 + Math.sin(time * 9) * .07, .12));
|
|
||||||
else if (actor === robot && side === 1 && point) target = toFamily(actor, new THREE.Vector3(.3, 1.25, .62));
|
|
||||||
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
|
|
||||||
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
|
|
||||||
updateSegment(arm.upper, a, elbow);
|
|
||||||
updateSegment(arm.lower, elbow, target);
|
|
||||||
arm.hand.position.copy(target);
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
const smile = shape(
|
||||||
|
head,
|
||||||
|
new THREE.TorusGeometry(0.068 * scale, 0.012 * scale, 6, 14, Math.PI),
|
||||||
|
shoes,
|
||||||
|
[0, -0.075 * scale, 0.252 * scale],
|
||||||
|
);
|
||||||
|
smile.rotation.z = Math.PI;
|
||||||
|
if (isChild) {
|
||||||
|
sphere(head, 0.29 * scale, mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]);
|
||||||
|
shape(head, new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale), mat('#e8b857'), [
|
||||||
|
0,
|
||||||
|
0.13 * scale,
|
||||||
|
0.12 * scale,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
sphere(torso, 0.22, mat('#a77950'), [0, 1.2 * scale, -0.24 * scale], [1, 1.2, 0.5]);
|
||||||
|
}
|
||||||
|
const legs = [-1, 1].map((side) => {
|
||||||
|
const hip = new THREE.Group();
|
||||||
|
hip.position.set(side * 0.15 * scale, 0.83 * scale, 0);
|
||||||
|
root.add(hip);
|
||||||
|
shape(hip, new THREE.CapsuleGeometry(0.09 * scale, 0.23 * scale, 4, 10), pants, [
|
||||||
|
0,
|
||||||
|
-0.16 * scale,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
const knee = new THREE.Group();
|
||||||
|
knee.position.y = -0.34 * scale;
|
||||||
|
hip.add(knee);
|
||||||
|
shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [
|
||||||
|
0,
|
||||||
|
-0.16 * scale,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
const foot = shape(knee, new THREE.SphereGeometry(0.12 * scale, 12, 8), shoes, [
|
||||||
|
0,
|
||||||
|
-0.32 * scale,
|
||||||
|
0.065 * scale,
|
||||||
|
]);
|
||||||
|
foot.scale.set(0.85, 0.65, 1.5);
|
||||||
|
return { hip, knee, foot };
|
||||||
|
});
|
||||||
|
const arms = [-1, 1].map(() => ({
|
||||||
|
upper: segment(group, null, null, 0.072 * scale, cloth),
|
||||||
|
lower: segment(group, null, null, 0.061 * scale, skin),
|
||||||
|
hand: sphere(group, 0.075 * scale, skin, [0, 0, 0]),
|
||||||
|
}));
|
||||||
|
return { root, torso, head, legs, arms, scale, z, eyes };
|
||||||
|
}
|
||||||
|
|
||||||
update({time: 0, gait: 0, moving: false});
|
const father = robotOnly ? null : makePerson(false, -0.45),
|
||||||
return {group, update};
|
child = robotOnly ? null : makePerson(true, 0.45);
|
||||||
|
const robot = robotOnly ? makeRobot(0) : null;
|
||||||
|
const actors = robotOnly ? [robot] : [father, child];
|
||||||
|
|
||||||
|
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
|
||||||
|
// `wave` raises the robot's right hand, `point` stretches it forward.
|
||||||
|
function update({
|
||||||
|
time,
|
||||||
|
gait,
|
||||||
|
moving,
|
||||||
|
facing = Math.PI / 2,
|
||||||
|
shopping,
|
||||||
|
delivery,
|
||||||
|
finished,
|
||||||
|
talking = false,
|
||||||
|
wave = false,
|
||||||
|
point = false,
|
||||||
|
dt = 1 / 60,
|
||||||
|
}) {
|
||||||
|
actors.forEach((actor, index) => {
|
||||||
|
const blend = 1 - Math.exp(-dt * 7),
|
||||||
|
isRobot = actor === robot;
|
||||||
|
actor.root.position.x = THREE.MathUtils.lerp(
|
||||||
|
actor.root.position.x,
|
||||||
|
finished ? (index === 0 ? -0.55 : 0.55) : shopping && !isRobot ? [-0.55, 0.25][index] : 0,
|
||||||
|
blend,
|
||||||
|
);
|
||||||
|
// The robot hovers; it bobs faster while it is speaking.
|
||||||
|
const hover = isRobot ? 0.35 + Math.sin(time * (talking ? 6 : 2.2)) * 0.07 : 0;
|
||||||
|
actor.root.position.y = THREE.MathUtils.lerp(
|
||||||
|
actor.root.position.y,
|
||||||
|
finished ? -0.6 * actor.scale : hover,
|
||||||
|
isRobot ? 1 : blend,
|
||||||
|
);
|
||||||
|
actor.root.position.z = finished ? 0 : actor.z;
|
||||||
|
actor.root.rotation.y = facing;
|
||||||
|
const bob = moving ? Math.abs(Math.sin(gait)) * 0.022 : Math.sin(time * 2) * 0.006;
|
||||||
|
actor.torso.position.y = bob;
|
||||||
|
actor.torso.rotation.z = moving ? Math.sin(gait) * 0.018 : 0;
|
||||||
|
const blinking = Math.sin(time * 1.3 + index) > 0.993;
|
||||||
|
actor.eyes.forEach((eye) => (eye.scale.y = blinking ? 0.12 : 1));
|
||||||
|
actor.head.rotation.y = shopping
|
||||||
|
? -0.65
|
||||||
|
: isRobot
|
||||||
|
? Math.sin(time * 0.9) * 0.35
|
||||||
|
: Math.sin(time * 0.7) * 0.06;
|
||||||
|
actor.torso.rotation.x = delivery && actor === child ? 0.22 : 0;
|
||||||
|
actor.legs.forEach((leg, i) => {
|
||||||
|
const phase = gait + i * Math.PI;
|
||||||
|
const stride = isRobot ? 0.08 : 0.34;
|
||||||
|
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
|
||||||
|
leg.foot.rotation.x = moving ? Math.sin(phase + 0.5) * 0.12 : 0;
|
||||||
|
leg.knee.rotation.x = finished
|
||||||
|
? 2.5
|
||||||
|
: isRobot
|
||||||
|
? 0.25
|
||||||
|
: moving
|
||||||
|
? Math.max(0, -Math.sin(phase)) * 0.5
|
||||||
|
: 0.05;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
group.updateMatrixWorld(true);
|
||||||
|
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
|
||||||
|
const shoulder = (actor, side) =>
|
||||||
|
toFamily(
|
||||||
|
actor,
|
||||||
|
new THREE.Vector3(
|
||||||
|
side * 0.29 * actor.scale,
|
||||||
|
(actor === robot ? 1.12 : 1.43) * actor.scale,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// With characters facing +X, father's left and child's right hands meet.
|
||||||
|
const contact = new THREE.Vector3(0, 1.06, 0);
|
||||||
|
actors.forEach((actor, index) =>
|
||||||
|
actor.arms.forEach((arm, i) => {
|
||||||
|
const side = i === 0 ? -1 : 1,
|
||||||
|
inside = actor === father ? side === -1 : actor === child && side === 1;
|
||||||
|
const a = shoulder(actor, side);
|
||||||
|
let target;
|
||||||
|
if (inside && !shopping && !finished) target = contact.clone();
|
||||||
|
else if (actor === child && shopping && side === -1)
|
||||||
|
target = new THREE.Vector3(0.5, delivery ? 0.65 : 1.1, -0.15);
|
||||||
|
else if (actor === robot && side === 1 && wave)
|
||||||
|
target = toFamily(actor, new THREE.Vector3(0.42, 1.62 + Math.sin(time * 9) * 0.07, 0.12));
|
||||||
|
else if (actor === robot && side === 1 && point)
|
||||||
|
target = toFamily(actor, new THREE.Vector3(0.3, 1.25, 0.62));
|
||||||
|
else
|
||||||
|
target = toFamily(
|
||||||
|
actor,
|
||||||
|
new THREE.Vector3(
|
||||||
|
side * 0.34 * actor.scale,
|
||||||
|
0.88 * actor.scale,
|
||||||
|
moving ? Math.sin(gait + (i ? Math.PI : 0)) * 0.2 : 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const elbow = a
|
||||||
|
.clone()
|
||||||
|
.lerp(target, 0.5)
|
||||||
|
.add(new THREE.Vector3(0.07, 0, inside ? 0 : 0.07));
|
||||||
|
updateSegment(arm.upper, a, elbow);
|
||||||
|
updateSegment(arm.lower, elbow, target);
|
||||||
|
arm.hand.position.copy(target);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ time: 0, gait: 0, moving: false });
|
||||||
|
return { group, update };
|
||||||
}
|
}
|
||||||
|
|||||||
+627
-408
File diff suppressed because it is too large
Load Diff
+72
-55
@@ -3,82 +3,99 @@ const CELL = 24;
|
|||||||
const key = (cx, cz) => cx * 100003 + cz;
|
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,
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
minZ = Infinity,
|
||||||
minX = Math.min(minX, pts[i]);
|
maxX = -Infinity,
|
||||||
maxX = Math.max(maxX, pts[i]);
|
maxZ = -Infinity;
|
||||||
minZ = Math.min(minZ, pts[i + 1]);
|
for (let i = 0; i < pts.length; i += 2) {
|
||||||
maxZ = Math.max(maxZ, pts[i + 1]);
|
minX = Math.min(minX, pts[i]);
|
||||||
|
maxX = Math.max(maxX, pts[i]);
|
||||||
|
minZ = Math.min(minZ, pts[i + 1]);
|
||||||
|
maxZ = Math.max(maxZ, pts[i + 1]);
|
||||||
|
}
|
||||||
|
const index = world.polygons.push({ pts, minX, minZ, maxX, maxZ }) - 1;
|
||||||
|
for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
|
||||||
|
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
|
||||||
|
const k = key(cx, cz);
|
||||||
|
if (!world.grid.has(k)) world.grid.set(k, []);
|
||||||
|
world.grid.get(k).push(index);
|
||||||
}
|
}
|
||||||
const index = world.polygons.push({pts, minX, minZ, maxX, maxZ}) - 1;
|
|
||||||
for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
|
|
||||||
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
|
|
||||||
const k = key(cx, cz);
|
|
||||||
if (!world.grid.has(k)) world.grid.set(k, []);
|
|
||||||
world.grid.get(k).push(index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nearby(world, x, z, r) {
|
function nearby(world, x, z, r) {
|
||||||
const found = new Set();
|
const found = new Set();
|
||||||
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
|
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
|
||||||
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],
|
||||||
return inside;
|
zj = pts[j + 1];
|
||||||
|
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closestOnEdges(pts, x, z) {
|
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],
|
||||||
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
|
az = pts[j + 1],
|
||||||
const px = ax + dx * t, pz = az + dz * t, d = Math.hypot(x - px, z - pz);
|
dx = pts[i] - ax,
|
||||||
if (d < best.d) best = {d, x: px, z: pz};
|
dz = pts[i + 1] - az;
|
||||||
}
|
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
|
||||||
return best;
|
const px = ax + dx * t,
|
||||||
|
pz = az + dz * t,
|
||||||
|
d = Math.hypot(x - px, z - pz);
|
||||||
|
if (d < best.d) best = { d, x: px, z: pz };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
|
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
|
||||||
export function resolveCircle(world, x, z, r) {
|
export function resolveCircle(world, x, z, r) {
|
||||||
let hit = false;
|
let hit = false;
|
||||||
for (let pass = 0; pass < 3; pass++) {
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
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),
|
||||||
if (!inside && c.d >= r) continue;
|
c = closestOnEdges(p.pts, x, z);
|
||||||
const len = c.d || 1e-6, nx = (x - c.x) / len * (inside ? -1 : 1), nz = (z - c.z) / len * (inside ? -1 : 1);
|
if (!inside && c.d >= r) continue;
|
||||||
x = c.x + nx * r;
|
const len = c.d || 1e-6,
|
||||||
z = c.z + nz * r;
|
nx = ((x - c.x) / len) * (inside ? -1 : 1),
|
||||||
moved = hit = true;
|
nz = ((z - c.z) / len) * (inside ? -1 : 1);
|
||||||
}
|
x = c.x + nx * r;
|
||||||
if (!moved) break;
|
z = c.z + nz * r;
|
||||||
|
moved = hit = true;
|
||||||
}
|
}
|
||||||
return {x, z, hit};
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
return { x, z, hit };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const blocked = (world, x, z) => nearby(world, x, z, 0).some(p => insidePolygon(p.pts, x, z));
|
export const blocked = (world, x, z) =>
|
||||||
|
nearby(world, x, z, 0).some((p) => insidePolygon(p.pts, x, z));
|
||||||
|
|
||||||
// Fraction (0–1) of the segment a→b that is free before it enters a footprint.
|
// 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) {
|
||||||
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
|
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
|
||||||
for (let i = 1; i <= steps; i++) {
|
for (let i = 1; i <= steps; i++) {
|
||||||
const t = i / steps;
|
const t = i / steps;
|
||||||
if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
|
if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+1063
-644
File diff suppressed because it is too large
Load Diff
+50
-31
@@ -1,56 +1,75 @@
|
|||||||
// 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, '۰')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const catalog = {
|
export const catalog = {
|
||||||
juice: {id: 'juice', name: 'آبمیوه', icon: '🧃', cost: 35, type: 'drink'},
|
juice: { id: 'juice', name: 'آبمیوه', icon: '🧃', cost: 35, type: 'drink' },
|
||||||
water: {id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink'},
|
water: { id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink' },
|
||||||
milk: {id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink'},
|
milk: { id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink' },
|
||||||
tea: {id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink'},
|
tea: { id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink' },
|
||||||
lemonade: {id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink'},
|
lemonade: { id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink' },
|
||||||
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);
|
||||||
return m >= machine.hours[0] && m < machine.hours[1];
|
return m >= machine.hours[0] && m < machine.hours[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
export function purchaseBlocker(state, machine, productId) {
|
export function purchaseBlocker(state, machine, productId) {
|
||||||
if (!machine || !machine.products.includes(productId)) return 'unknown';
|
if (!machine || !machine.products.includes(productId)) return 'unknown';
|
||||||
if (state.busy) return 'busy';
|
if (state.busy) return 'busy';
|
||||||
if (!isOpen(machine, state.minutes)) return 'closed';
|
if (!isOpen(machine, state.minutes)) return 'closed';
|
||||||
if (state.balance < priceAt(machine, productId)) return 'funds';
|
if (state.balance < priceAt(machine, productId)) return 'funds';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
state.balance -= price;
|
const record = {
|
||||||
state.purchases.push(record);
|
machineId: machine.id,
|
||||||
return record;
|
category: machine.category,
|
||||||
|
productId,
|
||||||
|
type: product.type,
|
||||||
|
price,
|
||||||
|
minutes: state.minutes,
|
||||||
|
};
|
||||||
|
state.balance -= price;
|
||||||
|
state.purchases.push(record);
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The run ends once no open or later-opening machine sells anything the player can afford.
|
// 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],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-6
@@ -1,22 +1,24 @@
|
|||||||
// Local tangent-plane projection around the playable Park Mellat area.
|
// Local tangent-plane projection around the playable Park Mellat area.
|
||||||
// World axes: +x east, -z north, 1 unit = 1 metre.
|
// World axes: +x east, -z north, 1 unit = 1 metre.
|
||||||
export const ORIGIN = {lat: 36.315, lon: 59.539};
|
export const ORIGIN = { lat: 36.315, lon: 59.539 };
|
||||||
const METRES_PER_DEG_LAT = 110574;
|
const METRES_PER_DEG_LAT = 110574;
|
||||||
const METRES_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * Math.PI / 180);
|
const METRES_PER_DEG_LON = 111320 * Math.cos((ORIGIN.lat * Math.PI) / 180);
|
||||||
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toLatLon(x, z) {
|
export function toLatLon(x, z) {
|
||||||
return {lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON};
|
return { lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compass bearing in degrees (0 = north, 90 = east) of a world-space direction.
|
// Compass bearing in degrees (0 = north, 90 = east) of a world-space direction.
|
||||||
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')} کیلومتر`;
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-73
@@ -1,89 +1,117 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import {toLocal} from './geo.js';
|
import { toLocal } from './geo.js';
|
||||||
|
|
||||||
// Satellite ground: Esri World Imagery tiles (public/imagery, see scripts/fetch-imagery.mjs) laid on
|
// Satellite ground: Esri World Imagery tiles (public/imagery, see scripts/fetch-imagery.mjs) laid on
|
||||||
// the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further
|
// 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),
|
||||||
return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
|
s = tileLat(y + 1, z),
|
||||||
|
w = tileLon(x, z),
|
||||||
|
e = tileLon(x + 1, z);
|
||||||
|
return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality = false, anisotropy = 4} = {}) {
|
export function createImagery(
|
||||||
const group = new THREE.Group();
|
scene,
|
||||||
scene.add(group);
|
index,
|
||||||
const loader = new THREE.TextureLoader();
|
{ baseUrl = './imagery/', lowQuality = false, anisotropy = 4 } = {},
|
||||||
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
|
) {
|
||||||
const levels = [
|
const group = new THREE.Group();
|
||||||
{z: 17, range: lowQuality ? 450 : 750, y: .012, offset: -3},
|
scene.add(group);
|
||||||
{z: 15, range: lowQuality ? 1800 : 2800, y: .004, offset: -1}
|
const loader = new THREE.TextureLoader();
|
||||||
].map(level => ({
|
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
|
||||||
...level,
|
const levels = [
|
||||||
tiles: (index[level.z] ?? []).map(key => {
|
{ z: 17, range: lowQuality ? 450 : 750, y: 0.012, offset: -3 },
|
||||||
const [x, y] = key.split('/').map(Number), corners = tileCorners(x, y, level.z);
|
{ z: 15, range: lowQuality ? 1800 : 2800, y: 0.004, offset: -1 },
|
||||||
const cx = corners.reduce((s, c) => s + c.x, 0) / 4, cz = corners.reduce((s, c) => s + c.z, 0) / 4;
|
].map((level) => ({
|
||||||
return {key, x, y, corners, cx, cz, mesh: null, loading: false};
|
...level,
|
||||||
})
|
tiles: (index[level.z] ?? []).map((key) => {
|
||||||
}));
|
const [x, y] = key.split('/').map(Number),
|
||||||
let loads = 0;
|
corners = tileCorners(x, y, level.z);
|
||||||
|
const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
|
||||||
|
cz = corners.reduce((s, c) => s + c.z, 0) / 4;
|
||||||
|
return { key, x, y, corners, cx, cz, mesh: null, loading: false };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
let loads = 0;
|
||||||
|
|
||||||
function show(level, tile) {
|
function show(level, tile) {
|
||||||
if (tile.mesh || tile.loading || loads >= 6) return;
|
if (tile.mesh || tile.loading || loads >= 6) return;
|
||||||
tile.loading = true;
|
tile.loading = true;
|
||||||
loads++;
|
loads++;
|
||||||
loader.load(`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`, texture => {
|
loader.load(
|
||||||
loads--;
|
`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`,
|
||||||
tile.loading = false;
|
(texture) => {
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
loads--;
|
||||||
// Sharp at the low angles a driver sees the road from.
|
tile.loading = false;
|
||||||
texture.anisotropy = anisotropy;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
const [nw, ne, sw, se] = tile.corners, geometry = new THREE.BufferGeometry();
|
// Sharp at the low angles a driver sees the road from.
|
||||||
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));
|
texture.anisotropy = anisotropy;
|
||||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
|
const [nw, ne, sw, se] = tile.corners,
|
||||||
geometry.setIndex([0, 2, 1, 1, 2, 3]);
|
geometry = new THREE.BufferGeometry();
|
||||||
tile.mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
|
geometry.setAttribute(
|
||||||
map: texture, toneMapped: false, polygonOffset: true, polygonOffsetFactor: level.offset, polygonOffsetUnits: level.offset * 2
|
'position',
|
||||||
}));
|
new THREE.Float32BufferAttribute(
|
||||||
group.add(tile.mesh);
|
[nw.x, level.y, nw.z, ne.x, level.y, ne.z, sw.x, level.y, sw.z, se.x, level.y, se.z],
|
||||||
}, undefined, () => {
|
3,
|
||||||
loads--;
|
),
|
||||||
tile.loading = false;
|
);
|
||||||
tile.failed = true;
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
|
||||||
});
|
geometry.setIndex([0, 2, 1, 1, 2, 3]);
|
||||||
}
|
tile.mesh = new THREE.Mesh(
|
||||||
|
geometry,
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
toneMapped: false,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: level.offset,
|
||||||
|
polygonOffsetUnits: level.offset * 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
group.add(tile.mesh);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => {
|
||||||
|
loads--;
|
||||||
|
tile.loading = false;
|
||||||
|
tile.failed = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hide(tile) {
|
function hide(tile) {
|
||||||
if (!tile.mesh) return;
|
if (!tile.mesh) return;
|
||||||
group.remove(tile.mesh);
|
group.remove(tile.mesh);
|
||||||
tile.mesh.geometry.dispose();
|
tile.mesh.geometry.dispose();
|
||||||
tile.mesh.material.map.dispose();
|
tile.mesh.material.map.dispose();
|
||||||
tile.mesh.material.dispose();
|
tile.mesh.material.dispose();
|
||||||
tile.mesh = null;
|
tile.mesh = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
if (!moved && loads >= 6) return;
|
if (!moved && loads >= 6) return;
|
||||||
last = {x, z};
|
last = { x, z };
|
||||||
for (const level of levels) {
|
for (const level of levels) {
|
||||||
const wanted = [];
|
const wanted = [];
|
||||||
for (const tile of level.tiles) {
|
for (const tile of level.tiles) {
|
||||||
const d = Math.hypot(tile.cx - x, tile.cz - z);
|
const d = Math.hypot(tile.cx - x, tile.cz - z);
|
||||||
if (d > level.range * 1.2) hide(tile);
|
if (d > level.range * 1.2) hide(tile);
|
||||||
else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
|
else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
|
||||||
}
|
|
||||||
wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-79
@@ -1,103 +1,145 @@
|
|||||||
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
|
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
|
||||||
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
|
||||||
export const CATEGORIES = {
|
export const CATEGORIES = {
|
||||||
factory: {label: 'کارخانه', icon: '🏭'},
|
factory: { label: 'کارخانه', icon: '🏭' },
|
||||||
park: {label: 'پارک', icon: '🌳'},
|
park: { label: 'پارک', icon: '🌳' },
|
||||||
university: {label: 'دانشگاه', icon: '🎓'},
|
university: { label: 'دانشگاه', icon: '🎓' },
|
||||||
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];
|
||||||
|
|
||||||
export const FACTORY = {
|
export const FACTORY = {
|
||||||
name: 'شرکت روبات مارکت',
|
name: 'شرکت روبات مارکت',
|
||||||
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
tagline: 'سازندهٔ دستگاههای وندینگ هوشمند',
|
||||||
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
|
||||||
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
|
||||||
center: {x: -10308.4, z: -14964.2},
|
center: { 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',
|
||||||
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
|
name: 'دستگاه دفتر فنی مهندسی',
|
||||||
anchor: null, hours: [0, 24 * 60], priceFactor: .5,
|
category: 'factory',
|
||||||
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
|
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
|
||||||
note: 'دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه'
|
anchor: null,
|
||||||
},
|
hours: [0, 24 * 60],
|
||||||
{
|
priceFactor: 0.5,
|
||||||
id: 'mellat-park', name: 'بوستان ملت', category: 'park',
|
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
|
||||||
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
note: 'دستگاه دفتر برنامهنویسها؛ نصف قیمت برای مهمانهای کارخانه',
|
||||||
anchor: {x: -487.6, z: -326.4}, hours: ALL_DAY, priceFactor: 1,
|
},
|
||||||
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
{
|
||||||
note: 'نوشیدنیهای خنک برای پیادهروی در پارک'
|
id: 'mellat-park',
|
||||||
},
|
name: 'بوستان ملت',
|
||||||
{
|
category: 'park',
|
||||||
id: 'school-rahil', name: 'مجموعه آموزشی راحیل', category: 'school',
|
address: 'بزرگراه وکیلآباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
|
||||||
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
anchor: { x: -487.6, z: -326.4 },
|
||||||
anchor: {x: -554.6, z: -569.7}, hours: [7 * 60 + 30, 13 * 60 + 30], priceFactor: .9,
|
hours: ALL_DAY,
|
||||||
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
|
priceFactor: 1,
|
||||||
note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است'
|
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
|
||||||
},
|
note: 'نوشیدنیهای خنک برای پیادهروی در پارک',
|
||||||
{
|
},
|
||||||
id: 'ferdowsi-university', name: 'دانشگاه فردوسی مشهد', category: 'university',
|
{
|
||||||
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
id: 'school-rahil',
|
||||||
anchor: {x: -936, z: 645}, hours: ALL_DAY, priceFactor: .8,
|
name: 'مجموعه آموزشی راحیل',
|
||||||
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
|
category: 'school',
|
||||||
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر'
|
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
|
||||||
},
|
anchor: { x: -554.6, z: -569.7 },
|
||||||
{
|
hours: [7 * 60 + 30, 13 * 60 + 30],
|
||||||
id: 'literature-faculty', name: 'دانشکدهٔ ادبیات دکتر شریعتی', category: 'university',
|
priceFactor: 0.9,
|
||||||
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
|
||||||
anchor: {x: -417.1, z: 966.3}, hours: ALL_DAY, priceFactor: .8,
|
note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است',
|
||||||
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
|
},
|
||||||
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر'
|
{
|
||||||
},
|
id: 'ferdowsi-university',
|
||||||
{
|
name: 'دانشگاه فردوسی مشهد',
|
||||||
id: 'medical-university', name: 'دانشگاه علوم پزشکی مشهد', category: 'university',
|
category: 'university',
|
||||||
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیادهروی مرکزی',
|
||||||
anchor: {x: -272.1, z: 150.6}, hours: ALL_DAY, priceFactor: .8,
|
anchor: { x: -936, z: 645 },
|
||||||
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
|
hours: ALL_DAY,
|
||||||
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر'
|
priceFactor: 0.8,
|
||||||
},
|
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
|
||||||
{
|
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
|
||||||
id: 'azadi-metro', name: 'ایستگاه مترو آزادی', category: 'metro',
|
},
|
||||||
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
{
|
||||||
anchor: {x: 494.1, z: 280.3}, hours: [6 * 60, 22 * 60], priceFactor: 1,
|
id: 'literature-faculty',
|
||||||
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
|
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
|
||||||
note: 'دستگاه کنار گیتهای مترو'
|
category: 'university',
|
||||||
},
|
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
|
||||||
{
|
anchor: { x: -417.1, z: 966.3 },
|
||||||
id: 'mashhad-mall', name: 'مشهد مال', category: 'mall',
|
hours: ALL_DAY,
|
||||||
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
priceFactor: 0.8,
|
||||||
anchor: {x: 1940.6, z: -367.8}, hours: [10 * 60, 23 * 60], priceFactor: 1.1,
|
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
|
||||||
products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
|
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
|
||||||
note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر'
|
},
|
||||||
}
|
{
|
||||||
|
id: 'medical-university',
|
||||||
|
name: 'دانشگاه علوم پزشکی مشهد',
|
||||||
|
category: 'university',
|
||||||
|
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
|
||||||
|
anchor: { x: -272.1, z: 150.6 },
|
||||||
|
hours: ALL_DAY,
|
||||||
|
priceFactor: 0.8,
|
||||||
|
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
|
||||||
|
note: 'تخفیف دانشجویی: ۲۰٪ ارزانتر',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'azadi-metro',
|
||||||
|
name: 'ایستگاه مترو آزادی',
|
||||||
|
category: 'metro',
|
||||||
|
address: 'بولوار ملکآباد، ورودی ایستگاه مترو آزادی',
|
||||||
|
anchor: { x: 494.1, z: 280.3 },
|
||||||
|
hours: [6 * 60, 22 * 60],
|
||||||
|
priceFactor: 1,
|
||||||
|
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
|
||||||
|
note: 'دستگاه کنار گیتهای مترو',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mashhad-mall',
|
||||||
|
name: 'مشهد مال',
|
||||||
|
category: 'mall',
|
||||||
|
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
|
||||||
|
anchor: { x: 1940.6, z: -367.8 },
|
||||||
|
hours: [10 * 60, 23 * 60],
|
||||||
|
priceFactor: 1.1,
|
||||||
|
products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
|
||||||
|
note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone.
|
// 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}`,
|
||||||
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
|
||||||
anchor: null, spot: {lat, lon}, hours: [0, 24 * 60], priceFactor: .5,
|
category: 'test',
|
||||||
products: ['water', 'biscuit'],
|
test: true,
|
||||||
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید'
|
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
||||||
|
anchor: null,
|
||||||
|
spot: { lat, lon },
|
||||||
|
hours: [0, 24 * 60],
|
||||||
|
priceFactor: 0.5,
|
||||||
|
products: ['water', 'biscuit'],
|
||||||
|
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
|
||||||
});
|
});
|
||||||
machines.push(testPoint(1, 36.450276, 59.423431), testPoint(2, 36.450172, 59.423209), testPoint(3, 36.449976, 59.422866));
|
machines.push(
|
||||||
|
testPoint(1, 36.450276, 59.423431),
|
||||||
|
testPoint(2, 36.450172, 59.423209),
|
||||||
|
testPoint(3, 36.449976, 59.422866),
|
||||||
|
);
|
||||||
|
|
||||||
// Shown on the map as the next area to unlock.
|
// 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;
|
||||||
|
|||||||
+1569
-1084
File diff suppressed because it is too large
Load Diff
+419
-324
@@ -1,367 +1,462 @@
|
|||||||
// 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) {
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.moveTo(pts[0], pts[1]);
|
c.moveTo(pts[0], pts[1]);
|
||||||
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
|
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
|
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
|
||||||
function drawFeatures(c, {areas, roads, buildings}, data, minWidth) {
|
function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
|
||||||
for (const area of areas) {
|
for (const area of areas) {
|
||||||
c.fillStyle = AREA[data.areaKinds[area[0]]];
|
c.fillStyle = AREA[data.areaKinds[area[0]]];
|
||||||
path(c, area.slice(1));
|
path(c, area.slice(1));
|
||||||
c.fill();
|
c.fill();
|
||||||
}
|
}
|
||||||
c.lineCap = c.lineJoin = 'round';
|
c.lineCap = c.lineJoin = 'round';
|
||||||
for (const road of roads) {
|
for (const road of roads) {
|
||||||
c.strokeStyle = roadColor(road[0]);
|
c.strokeStyle = roadColor(road[0]);
|
||||||
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
|
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
|
||||||
path(c, road.slice(2));
|
path(c, road.slice(2));
|
||||||
c.stroke();
|
c.stroke();
|
||||||
}
|
}
|
||||||
c.fillStyle = '#3c4943';
|
c.fillStyle = '#3c4943';
|
||||||
for (const b of buildings) {
|
for (const b of buildings) {
|
||||||
path(c, b.slice(1));
|
path(c, b.slice(1));
|
||||||
c.fill();
|
c.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMapAssets(data, extraLabels = []) {
|
export function createMapAssets(data, extraLabels = []) {
|
||||||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
// Bin every feature into the tiles its bounding box touches.
|
// Bin every feature into the tiles its bounding box touches.
|
||||||
const bins = new Map();
|
const bins = new Map();
|
||||||
const bin = (list, feature, first) => {
|
const bin = (list, feature, first) => {
|
||||||
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
|
let x0 = Infinity,
|
||||||
for (let i = first; i < feature.length; i += 2) {
|
z0 = Infinity,
|
||||||
x0 = Math.min(x0, feature[i]);
|
x1 = -Infinity,
|
||||||
x1 = Math.max(x1, feature[i]);
|
z1 = -Infinity;
|
||||||
z0 = Math.min(z0, feature[i + 1]);
|
for (let i = first; i < feature.length; i += 2) {
|
||||||
z1 = Math.max(z1, feature[i + 1]);
|
x0 = Math.min(x0, feature[i]);
|
||||||
}
|
x1 = Math.max(x1, feature[i]);
|
||||||
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
|
z0 = Math.min(z0, feature[i + 1]);
|
||||||
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
|
z1 = Math.max(z1, feature[i + 1]);
|
||||||
const k = tileKey(tx, tz);
|
}
|
||||||
if (!bins.has(k)) bins.set(k, {areas: [], roads: [], buildings: []});
|
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
|
||||||
bins.get(k)[list].push(feature);
|
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
|
||||||
}
|
|
||||||
};
|
|
||||||
data.areas.forEach(a => bin('areas', a, 1));
|
|
||||||
[...data.roads].sort((a, b) => b[0] - a[0]).forEach(r => bin('roads', r, 2));
|
|
||||||
data.buildings.forEach(b => bin('buildings', b, 1));
|
|
||||||
|
|
||||||
const cache = new Map();
|
|
||||||
function tile(tx, tz) {
|
|
||||||
const k = tileKey(tx, tz);
|
const k = tileKey(tx, tz);
|
||||||
if (cache.has(k)) {
|
if (!bins.has(k)) bins.set(k, { areas: [], roads: [], buildings: [] });
|
||||||
const canvas = cache.get(k);
|
bins.get(k)[list].push(feature);
|
||||||
cache.delete(k);
|
}
|
||||||
cache.set(k, canvas);
|
};
|
||||||
return canvas;
|
data.areas.forEach((a) => bin('areas', a, 1));
|
||||||
}
|
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
|
||||||
const canvas = document.createElement('canvas');
|
data.buildings.forEach((b) => bin('buildings', b, 1));
|
||||||
canvas.width = canvas.height = TILE_PX;
|
|
||||||
const c = canvas.getContext('2d');
|
|
||||||
c.fillStyle = '#1b231f';
|
|
||||||
c.fillRect(0, 0, TILE_PX, TILE_PX);
|
|
||||||
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
|
|
||||||
drawFeatures(c, bins.get(k) ?? {areas: [], roads: [], buildings: []}, data, 1.6);
|
|
||||||
cache.set(k, canvas);
|
|
||||||
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overview = document.createElement('canvas');
|
const cache = new Map();
|
||||||
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
|
function tile(tx, tz) {
|
||||||
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
|
const k = tileKey(tx, tz);
|
||||||
const o = overview.getContext('2d');
|
if (cache.has(k)) {
|
||||||
o.fillStyle = '#1b231f';
|
const canvas = cache.get(k);
|
||||||
o.fillRect(0, 0, overview.width, overview.height);
|
cache.delete(k);
|
||||||
o.setTransform(OVERVIEW_SCALE, 0, 0, OVERVIEW_SCALE, -minX * OVERVIEW_SCALE, -minZ * OVERVIEW_SCALE);
|
cache.set(k, canvas);
|
||||||
drawFeatures(o, {areas: data.areas, roads: data.roads.filter(r => r[0] <= 5).sort((a, b) => b[0] - a[0]), buildings: []}, data, 14);
|
return canvas;
|
||||||
|
|
||||||
// One label per named boulevard, at the middle of its longest way.
|
|
||||||
const streets = new Map();
|
|
||||||
for (const road of data.roads) {
|
|
||||||
if (road[1] < 0 || road[0] > 3) continue;
|
|
||||||
let len = 0;
|
|
||||||
for (let i = 4; i < road.length; i += 2) len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
|
|
||||||
const name = data.names[road[1]];
|
|
||||||
if ((streets.get(name)?.len ?? 0) < len) {
|
|
||||||
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
|
|
||||||
const angle = Math.atan2(road[Math.min(mid + 3, road.length - 1)] - road[mid - 1], road[Math.min(mid + 2, road.length - 2)] - road[mid - 2]);
|
|
||||||
streets.set(name, {name, len, x: road[mid], z: road[mid + 1], angle});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {tile, overview, bounds: data.bounds, labels: [...data.labels, ...extraLabels], streets: [...streets.values()].filter(s => s.len > 150)};
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = TILE_PX;
|
||||||
|
const c = canvas.getContext('2d');
|
||||||
|
c.fillStyle = '#1b231f';
|
||||||
|
c.fillRect(0, 0, TILE_PX, TILE_PX);
|
||||||
|
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
|
||||||
|
drawFeatures(c, bins.get(k) ?? { areas: [], roads: [], buildings: [] }, data, 1.6);
|
||||||
|
cache.set(k, canvas);
|
||||||
|
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = document.createElement('canvas');
|
||||||
|
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
|
||||||
|
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
|
||||||
|
const o = overview.getContext('2d');
|
||||||
|
o.fillStyle = '#1b231f';
|
||||||
|
o.fillRect(0, 0, overview.width, overview.height);
|
||||||
|
o.setTransform(
|
||||||
|
OVERVIEW_SCALE,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
OVERVIEW_SCALE,
|
||||||
|
-minX * OVERVIEW_SCALE,
|
||||||
|
-minZ * OVERVIEW_SCALE,
|
||||||
|
);
|
||||||
|
drawFeatures(
|
||||||
|
o,
|
||||||
|
{
|
||||||
|
areas: data.areas,
|
||||||
|
roads: data.roads.filter((r) => r[0] <= 5).sort((a, b) => b[0] - a[0]),
|
||||||
|
buildings: [],
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
14,
|
||||||
|
);
|
||||||
|
|
||||||
|
// One label per named boulevard, at the middle of its longest way.
|
||||||
|
const streets = new Map();
|
||||||
|
for (const road of data.roads) {
|
||||||
|
if (road[1] < 0 || road[0] > 3) continue;
|
||||||
|
let len = 0;
|
||||||
|
for (let i = 4; i < road.length; i += 2)
|
||||||
|
len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
|
||||||
|
const name = data.names[road[1]];
|
||||||
|
if ((streets.get(name)?.len ?? 0) < len) {
|
||||||
|
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
|
||||||
|
const angle = Math.atan2(
|
||||||
|
road[Math.min(mid + 3, road.length - 1)] - road[mid - 1],
|
||||||
|
road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
|
||||||
|
);
|
||||||
|
streets.set(name, { name, len, x: road[mid], z: road[mid + 1], angle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tile,
|
||||||
|
overview,
|
||||||
|
bounds: data.bounds,
|
||||||
|
labels: [...data.labels, ...extraLabels],
|
||||||
|
streets: [...streets.values()].filter((s) => s.len > 150),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
|
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
|
||||||
// metres; pxPerMetre decides between detailed tiles and the overview.
|
// metres; pxPerMetre decides between detailed tiles and the overview.
|
||||||
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
|
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
|
||||||
const {minX, minZ} = assets.bounds;
|
const { minX, minZ } = assets.bounds;
|
||||||
c.fillStyle = '#141b18';
|
c.fillStyle = '#141b18';
|
||||||
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(
|
||||||
return;
|
assets.overview,
|
||||||
}
|
minX,
|
||||||
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
|
minZ,
|
||||||
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
|
assets.overview.width / OVERVIEW_SCALE,
|
||||||
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + .5, TILE_M + .5);
|
assets.overview.height / OVERVIEW_SCALE,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
|
||||||
|
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
|
||||||
|
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + 0.5, TILE_M + 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotatePoint(x, z, cx, cz, angle, k) {
|
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,
|
||||||
return [dx * cos - dz * sin, dx * sin + dz * cos];
|
dz = (z - cz) * k,
|
||||||
|
cos = Math.cos(angle),
|
||||||
|
sin = Math.sin(angle);
|
||||||
|
return [dx * cos - dz * sin, dx * sin + dz * cos];
|
||||||
}
|
}
|
||||||
|
|
||||||
function machineIcon(c, x, y, {objective, visited, open}) {
|
function machineIcon(c, x, y, { objective, visited, open }) {
|
||||||
c.save();
|
c.save();
|
||||||
c.translate(x, y);
|
c.translate(x, y);
|
||||||
if (objective) {
|
if (objective) {
|
||||||
c.rotate(Math.PI / 4);
|
c.rotate(Math.PI / 4);
|
||||||
c.fillStyle = '#f2c94c';
|
c.fillStyle = '#f2c94c';
|
||||||
c.strokeStyle = '#1b1406';
|
c.strokeStyle = '#1b1406';
|
||||||
c.lineWidth = 2;
|
c.lineWidth = 2;
|
||||||
c.fillRect(-7, -7, 14, 14);
|
c.fillRect(-7, -7, 14, 14);
|
||||||
c.strokeRect(-7, -7, 14, 14);
|
c.strokeRect(-7, -7, 14, 14);
|
||||||
} else {
|
} else {
|
||||||
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
|
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
|
||||||
c.strokeStyle = '#08201c';
|
c.strokeStyle = '#08201c';
|
||||||
c.lineWidth = 2;
|
c.lineWidth = 2;
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.roundRect(-6, -8, 12, 16, 3);
|
c.roundRect(-6, -8, 12, 16, 3);
|
||||||
c.fill();
|
c.fill();
|
||||||
c.stroke();
|
c.stroke();
|
||||||
c.fillStyle = '#08201c';
|
c.fillStyle = '#08201c';
|
||||||
c.fillRect(-3, -5, 6, 6);
|
c.fillRect(-3, -5, 6, 6);
|
||||||
}
|
}
|
||||||
c.restore();
|
c.restore();
|
||||||
if (visited) {
|
if (visited) {
|
||||||
c.fillStyle = '#fff';
|
c.fillStyle = '#fff';
|
||||||
c.font = 'bold 11px sans-serif';
|
c.font = 'bold 11px sans-serif';
|
||||||
c.fillText('✓', x + 8, y - 6);
|
c.fillText('✓', x + 8, y - 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
ctx.clearRect(0, 0, size, size);
|
k = r / radius,
|
||||||
ctx.save();
|
angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.fillStyle = '#141b18';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.translate(r, r);
|
||||||
|
ctx.save();
|
||||||
|
ctx.rotate(angle);
|
||||||
|
ctx.scale(k, k);
|
||||||
|
ctx.translate(-x, -z);
|
||||||
|
paintWorld(
|
||||||
|
ctx,
|
||||||
|
assets,
|
||||||
|
x - radius * 1.5,
|
||||||
|
z - radius * 1.5,
|
||||||
|
x + radius * 1.5,
|
||||||
|
z + radius * 1.5,
|
||||||
|
k,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
if (route?.length > 1) {
|
||||||
|
ctx.strokeStyle = '#f2c94c';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.setLineDash([6, 4]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
route.forEach((p, i) => {
|
||||||
ctx.clip();
|
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
|
||||||
ctx.fillStyle = '#141b18';
|
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
||||||
ctx.fillRect(0, 0, size, size);
|
});
|
||||||
ctx.translate(r, r);
|
|
||||||
ctx.save();
|
|
||||||
ctx.rotate(angle);
|
|
||||||
ctx.scale(k, k);
|
|
||||||
ctx.translate(-x, -z);
|
|
||||||
paintWorld(ctx, assets, x - radius * 1.5, z - radius * 1.5, x + radius * 1.5, z + radius * 1.5, k);
|
|
||||||
ctx.restore();
|
|
||||||
if (route?.length > 1) {
|
|
||||||
ctx.strokeStyle = '#f2c94c';
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.setLineDash([6, 4]);
|
|
||||||
ctx.beginPath();
|
|
||||||
route.forEach((p, i) => {
|
|
||||||
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
|
|
||||||
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
|
||||||
});
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
for (const m of markers) {
|
|
||||||
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
|
|
||||||
const d = Math.hypot(px, py);
|
|
||||||
if (d > r - 12) {
|
|
||||||
if (!m.objective) continue;
|
|
||||||
px *= (r - 12) / d;
|
|
||||||
py *= (r - 12) / d;
|
|
||||||
}
|
|
||||||
machineIcon(ctx, px, py, m);
|
|
||||||
}
|
|
||||||
// Player arrow, then north marker on the rim.
|
|
||||||
ctx.save();
|
|
||||||
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.strokeStyle = '#0b100e';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, -10);
|
|
||||||
ctx.lineTo(7, 8);
|
|
||||||
ctx.lineTo(0, 4);
|
|
||||||
ctx.lineTo(-7, 8);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
|
|
||||||
const nl = Math.hypot(nx, ny) || 1;
|
|
||||||
ctx.fillStyle = '#f2c94c';
|
|
||||||
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText('ش', nx / nl * (r - 12), ny / nl * (r - 12));
|
|
||||||
ctx.restore();
|
|
||||||
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
for (const m of markers) {
|
||||||
|
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
|
||||||
|
const d = Math.hypot(px, py);
|
||||||
|
if (d > r - 12) {
|
||||||
|
if (!m.objective) continue;
|
||||||
|
px *= (r - 12) / d;
|
||||||
|
py *= (r - 12) / d;
|
||||||
|
}
|
||||||
|
machineIcon(ctx, px, py, m);
|
||||||
|
}
|
||||||
|
// Player arrow, then north marker on the rim.
|
||||||
|
ctx.save();
|
||||||
|
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.strokeStyle = '#0b100e';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -10);
|
||||||
|
ctx.lineTo(7, 8);
|
||||||
|
ctx.lineTo(0, 4);
|
||||||
|
ctx.lineTo(-7, 8);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
|
||||||
|
const nl = Math.hypot(nx, ny) || 1;
|
||||||
|
ctx.fillStyle = '#f2c94c';
|
||||||
|
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
||||||
|
ctx.restore();
|
||||||
|
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
|
// 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,
|
||||||
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
|
dpr = state.dpr,
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
w = width / dpr,
|
||||||
ctx.fillStyle = '#101614';
|
h = height / dpr;
|
||||||
ctx.fillRect(0, 0, w, h);
|
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
|
||||||
ctx.save();
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.translate(w / 2, h / 2);
|
ctx.fillStyle = '#101614';
|
||||||
ctx.scale(1 / view.mpp, 1 / view.mpp);
|
ctx.fillRect(0, 0, w, h);
|
||||||
ctx.translate(-view.cx, -view.cz);
|
ctx.save();
|
||||||
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.translate(w / 2, h / 2);
|
||||||
ctx.restore();
|
ctx.scale(1 / view.mpp, 1 / view.mpp);
|
||||||
// Grid lines every 500 m, like a tactical map.
|
ctx.translate(-view.cx, -view.cz);
|
||||||
ctx.strokeStyle = 'rgba(160,200,185,.08)';
|
paintWorld(
|
||||||
ctx.lineWidth = 1;
|
ctx,
|
||||||
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
|
assets,
|
||||||
const [sx] = toScreen(gx, 0);
|
view.cx - (w / 2) * view.mpp,
|
||||||
ctx.beginPath();
|
view.cz - (h / 2) * view.mpp,
|
||||||
ctx.moveTo(sx, 0);
|
view.cx + (w / 2) * view.mpp,
|
||||||
ctx.lineTo(sx, h);
|
view.cz + (h / 2) * view.mpp,
|
||||||
ctx.stroke();
|
1 / view.mpp,
|
||||||
}
|
);
|
||||||
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
|
ctx.restore();
|
||||||
const [, sy] = toScreen(0, gz);
|
// Grid lines every 500 m, like a tactical map.
|
||||||
ctx.beginPath();
|
ctx.strokeStyle = 'rgba(160,200,185,.08)';
|
||||||
ctx.moveTo(0, sy);
|
ctx.lineWidth = 1;
|
||||||
ctx.lineTo(w, sy);
|
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
|
||||||
ctx.stroke();
|
const [sx] = toScreen(gx, 0);
|
||||||
}
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
if (view.mpp < 3) {
|
|
||||||
ctx.font = '11px Vazirmatn, sans-serif';
|
|
||||||
ctx.fillStyle = 'rgba(235,240,232,.75)';
|
|
||||||
for (const s of assets.streets) {
|
|
||||||
const [sx, sy] = toScreen(s.x, s.z);
|
|
||||||
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(sx, sy);
|
|
||||||
let a = s.angle;
|
|
||||||
if (a > Math.PI / 2) a -= Math.PI;
|
|
||||||
if (a < -Math.PI / 2) a += Math.PI;
|
|
||||||
ctx.rotate(a);
|
|
||||||
ctx.fillText(s.name, 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const l of assets.labels) {
|
|
||||||
if (l.kind === 'school' && view.mpp > 1.6) continue;
|
|
||||||
const [sx, sy] = toScreen(l.x, l.z);
|
|
||||||
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
|
|
||||||
if (l.kind === 'industrial' && view.mpp > 6) continue;
|
|
||||||
ctx.fillStyle = {park: '#9fd49a', university: '#c9b6f2', school: '#e8c79a', metro: '#ff8a80', mall: '#f5b8d9', industrial: '#a9b2ad', factory: '#3ee0c9'}[l.kind];
|
|
||||||
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
|
||||||
ctx.fillText(l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name, sx, sy + 14);
|
|
||||||
}
|
|
||||||
if (state.route?.length > 1) {
|
|
||||||
ctx.strokeStyle = '#f2c94c';
|
|
||||||
ctx.lineWidth = 4;
|
|
||||||
ctx.setLineDash([8, 5]);
|
|
||||||
ctx.beginPath();
|
|
||||||
state.route.forEach((p, i) => {
|
|
||||||
const [sx, sy] = toScreen(p.x, p.z);
|
|
||||||
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
|
|
||||||
});
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
for (const m of state.markers) {
|
|
||||||
const [sx, sy] = toScreen(m.x, m.z);
|
|
||||||
if (m.id === state.selected) {
|
|
||||||
ctx.strokeStyle = '#ffffff';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
machineIcon(ctx, sx, sy, m);
|
|
||||||
}
|
|
||||||
const [px, py] = toScreen(state.player.x, state.player.z);
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(px, py);
|
|
||||||
ctx.rotate(Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2);
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, -12);
|
ctx.moveTo(sx, 0);
|
||||||
ctx.lineTo(8, 9);
|
ctx.lineTo(sx, h);
|
||||||
ctx.lineTo(0, 5);
|
|
||||||
ctx.lineTo(-8, 9);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
}
|
||||||
return toScreen;
|
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
|
||||||
|
const [, sy] = toScreen(0, gz);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, sy);
|
||||||
|
ctx.lineTo(w, sy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
if (view.mpp < 3) {
|
||||||
|
ctx.font = '11px Vazirmatn, sans-serif';
|
||||||
|
ctx.fillStyle = 'rgba(235,240,232,.75)';
|
||||||
|
for (const s of assets.streets) {
|
||||||
|
const [sx, sy] = toScreen(s.x, s.z);
|
||||||
|
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(sx, sy);
|
||||||
|
let a = s.angle;
|
||||||
|
if (a > Math.PI / 2) a -= Math.PI;
|
||||||
|
if (a < -Math.PI / 2) a += Math.PI;
|
||||||
|
ctx.rotate(a);
|
||||||
|
ctx.fillText(s.name, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const l of assets.labels) {
|
||||||
|
if (l.kind === 'school' && view.mpp > 1.6) continue;
|
||||||
|
const [sx, sy] = toScreen(l.x, l.z);
|
||||||
|
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
|
||||||
|
if (l.kind === 'industrial' && view.mpp > 6) continue;
|
||||||
|
ctx.fillStyle = {
|
||||||
|
park: '#9fd49a',
|
||||||
|
university: '#c9b6f2',
|
||||||
|
school: '#e8c79a',
|
||||||
|
metro: '#ff8a80',
|
||||||
|
mall: '#f5b8d9',
|
||||||
|
industrial: '#a9b2ad',
|
||||||
|
factory: '#3ee0c9',
|
||||||
|
}[l.kind];
|
||||||
|
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
||||||
|
ctx.fillText(
|
||||||
|
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
|
||||||
|
sx,
|
||||||
|
sy + 14,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.route?.length > 1) {
|
||||||
|
ctx.strokeStyle = '#f2c94c';
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.setLineDash([8, 5]);
|
||||||
|
ctx.beginPath();
|
||||||
|
state.route.forEach((p, i) => {
|
||||||
|
const [sx, sy] = toScreen(p.x, p.z);
|
||||||
|
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
for (const m of state.markers) {
|
||||||
|
const [sx, sy] = toScreen(m.x, m.z);
|
||||||
|
if (m.id === state.selected) {
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
machineIcon(ctx, sx, sy, m);
|
||||||
|
}
|
||||||
|
const [px, py] = toScreen(state.player.x, state.player.z);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(px, py);
|
||||||
|
ctx.rotate(
|
||||||
|
Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
|
||||||
|
);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -12);
|
||||||
|
ctx.lineTo(8, 9);
|
||||||
|
ctx.lineTo(0, 5);
|
||||||
|
ctx.lineTo(-8, 9);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
return toScreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
|
// 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 p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
|
) {
|
||||||
ctx.clearRect(0, 0, width, height);
|
const lon0 = 43.8,
|
||||||
for (const province of iran.provinces) {
|
lon1 = 63.6,
|
||||||
const active = province.id === highlight;
|
lat0 = 24.8,
|
||||||
ctx.fillStyle = active ? `rgba(62,224,201,${.35 + Math.sin(t * 3) * .12})` : 'rgba(40,56,50,.85)';
|
lat1 = 40;
|
||||||
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
|
const kx = 0.84,
|
||||||
ctx.lineWidth = active ? 2 : 1;
|
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
|
||||||
for (const ring of province.rings) {
|
const offX = (width - (lon1 - lon0) * kx * k) / 2,
|
||||||
ctx.beginPath();
|
offY = (height - (lat1 - lat0) * k) / 2;
|
||||||
for (let i = 0; i < ring.length; i += 2) {
|
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
|
||||||
const [x, y] = p(ring[i], ring[i + 1]);
|
ctx.clearRect(0, 0, width, height);
|
||||||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
for (const province of iran.provinces) {
|
||||||
}
|
const active = province.id === highlight;
|
||||||
ctx.closePath();
|
ctx.fillStyle = active
|
||||||
ctx.fill();
|
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
|
||||||
ctx.stroke();
|
: 'rgba(40,56,50,.85)';
|
||||||
}
|
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
|
||||||
|
ctx.lineWidth = active ? 2 : 1;
|
||||||
|
for (const ring of province.rings) {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < ring.length; i += 2) {
|
||||||
|
const [x, y] = p(ring[i], ring[i + 1]);
|
||||||
|
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
ctx.textAlign = 'center';
|
}
|
||||||
for (const city of cities) {
|
ctx.textAlign = 'center';
|
||||||
const [x, y] = p(city.lon, city.lat);
|
for (const city of cities) {
|
||||||
if (city.active) {
|
const [x, y] = p(city.lon, city.lat);
|
||||||
ctx.strokeStyle = '#f2c94c';
|
if (city.active) {
|
||||||
ctx.lineWidth = 2;
|
ctx.strokeStyle = '#f2c94c';
|
||||||
ctx.beginPath();
|
ctx.lineWidth = 2;
|
||||||
ctx.arc(x, y, 6 + (t * 18) % 18, 0, Math.PI * 2);
|
ctx.beginPath();
|
||||||
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
|
ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
|
||||||
ctx.globalAlpha = 1;
|
ctx.stroke();
|
||||||
}
|
ctx.globalAlpha = 1;
|
||||||
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
|
|
||||||
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
|
|
||||||
}
|
}
|
||||||
|
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
|
||||||
|
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-24
@@ -1,40 +1,40 @@
|
|||||||
import {purchase, purchaseBlocker} from './game-state.js';
|
import { purchase, purchaseBlocker } from './game-state.js';
|
||||||
|
|
||||||
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
||||||
export function createPayment() {
|
export function createPayment() {
|
||||||
return {phase: 'idle', machine: null, productId: null, record: null};
|
return { phase: 'idle', machine: null, productId: null, record: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beginPayment(state, payment, machine, productId) {
|
export function beginPayment(state, payment, machine, productId) {
|
||||||
if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
|
if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
|
||||||
Object.assign(payment, {phase: 'approaching', machine, productId, record: null});
|
Object.assign(payment, { phase: 'approaching', machine, productId, record: null });
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readyPayment(payment) {
|
export function readyPayment(payment) {
|
||||||
if (payment.phase !== 'approaching') return false;
|
if (payment.phase !== 'approaching') return false;
|
||||||
payment.phase = 'ready';
|
payment.phase = 'ready';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function swipePayment(state, payment) {
|
export function swipePayment(state, payment) {
|
||||||
if (payment.phase !== 'ready') return false;
|
if (payment.phase !== 'ready') return false;
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
const record = purchase(state, payment.machine, payment.productId);
|
const record = purchase(state, payment.machine, payment.productId);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
Object.assign(payment, createPayment());
|
Object.assign(payment, createPayment());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
payment.record = record;
|
payment.record = record;
|
||||||
payment.phase = 'approved';
|
payment.phase = 'approved';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelPayment(state, payment) {
|
export function cancelPayment(state, payment) {
|
||||||
if (!['approaching', 'ready'].includes(payment.phase)) return false;
|
if (!['approaching', 'ready'].includes(payment.phase)) return false;
|
||||||
Object.assign(payment, createPayment());
|
Object.assign(payment, createPayment());
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-41
@@ -1,49 +1,56 @@
|
|||||||
// 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 ||
|
||||||
let deferred = null;
|
navigator.standalone;
|
||||||
|
const ios =
|
||||||
|
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
let deferred = null;
|
||||||
|
|
||||||
if (!standalone && ios) installButton.hidden = false;
|
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();
|
||||||
await deferred.userChoice;
|
await deferred.userChoice;
|
||||||
deferred = null;
|
deferred = null;
|
||||||
installButton.hidden = true;
|
installButton.hidden = true;
|
||||||
} else if (ios) $('#ios-install').showModal();
|
} else if (ios) $('#ios-install').showModal();
|
||||||
};
|
};
|
||||||
$('#ios-close').onclick = () => $('#ios-install').close();
|
$('#ios-close').onclick = () => $('#ios-install').close();
|
||||||
|
|
||||||
// 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')
|
||||||
$('#update-toast').hidden = false;
|
.then((registration) => {
|
||||||
$('#update-reload').onclick = () => worker.postMessage('skip-waiting');
|
const offer = (worker) => {
|
||||||
};
|
$('#update-toast').hidden = false;
|
||||||
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
|
$('#update-reload').onclick = () => worker.postMessage('skip-waiting');
|
||||||
registration.addEventListener('updatefound', () => {
|
};
|
||||||
const worker = registration.installing;
|
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
|
||||||
worker?.addEventListener('statechange', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
|
const worker = registration.installing;
|
||||||
});
|
worker?.addEventListener('statechange', () => {
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
});
|
||||||
let reloading = false;
|
})
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
.catch(() => {});
|
||||||
if (reloading) return;
|
let reloading = false;
|
||||||
reloading = true;
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
location.reload();
|
if (reloading) return;
|
||||||
});
|
reloading = true;
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-72
@@ -1,4 +1,4 @@
|
|||||||
import {toLocal} from './geo.js';
|
import { toLocal } from './geo.js';
|
||||||
|
|
||||||
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
|
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
|
||||||
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
|
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
|
||||||
@@ -7,13 +7,24 @@ export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, s
|
|||||||
|
|
||||||
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
|
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
|
||||||
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,
|
||||||
// The blended estimate is a little better than either input, but never better than 3 m.
|
z = previous.z + (fix.z - previous.z) * weight;
|
||||||
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);
|
// The blended estimate is a little better than either input, but never better than 3 m.
|
||||||
return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt};
|
const accuracy = Math.max(
|
||||||
|
3,
|
||||||
|
Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) +
|
||||||
|
Math.hypot(x - previous.x, z - previous.z) * 0.2,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
accuracy,
|
||||||
|
time: fix.time,
|
||||||
|
speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable mode on top of smoothFix, against phone GPS jitter:
|
// Stable mode on top of smoothFix, against phone GPS jitter:
|
||||||
@@ -21,82 +32,127 @@ export function smoothFix(previous, fix) {
|
|||||||
// dropped (after three in a row it is accepted: the phone really moved, e.g. got in a car);
|
// dropped (after three in a row it is accepted: the phone really moved, e.g. got in a car);
|
||||||
// - the shown position stays put until the estimate drifts beyond about half the GPS error,
|
// - the shown position stays put until the estimate drifts beyond about half the GPS error,
|
||||||
// or the phone reports walking speed. Without stable mode every smoothed fix is shown.
|
// or the phone reports walking speed. Without stable mode every smoothed fix is shown.
|
||||||
export function createStabilizer({maxSpeed = 3} = {}) {
|
export function createStabilizer({ maxSpeed = 3 } = {}) {
|
||||||
let estimate = null, shown = null, rejected = 0;
|
let estimate = null,
|
||||||
return {
|
shown = null,
|
||||||
stable: true,
|
rejected = 0;
|
||||||
push(fix) {
|
return {
|
||||||
if (estimate && this.stable && fix.time - estimate.time < 30000) {
|
stable: true,
|
||||||
const dt = Math.max(.001, (fix.time - estimate.time) / 1000);
|
push(fix) {
|
||||||
const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
|
if (estimate && this.stable && fix.time - estimate.time < 30000) {
|
||||||
if (Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach && fix.accuracy > estimate.accuracy / 2 && rejected < 3) {
|
const dt = Math.max(0.001, (fix.time - estimate.time) / 1000);
|
||||||
rejected++;
|
const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
|
||||||
return shown && {...shown};
|
if (
|
||||||
}
|
Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach &&
|
||||||
}
|
fix.accuracy > estimate.accuracy / 2 &&
|
||||||
rejected = 0;
|
rejected < 3
|
||||||
estimate = smoothFix(estimate, fix);
|
) {
|
||||||
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
|
rejected++;
|
||||||
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > .7) shown = {...estimate};
|
return shown && { ...shown };
|
||||||
else shown = {...shown, accuracy: estimate.accuracy, time: estimate.time, speed: fix.speed ?? 0};
|
|
||||||
return {...shown};
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
rejected = 0;
|
||||||
|
estimate = smoothFix(estimate, fix);
|
||||||
|
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
|
||||||
|
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > 0.7)
|
||||||
|
shown = { ...estimate };
|
||||||
|
else
|
||||||
|
shown = {
|
||||||
|
...shown,
|
||||||
|
accuracy: estimate.accuracy,
|
||||||
|
time: estimate.time,
|
||||||
|
speed: fix.speed ?? 0,
|
||||||
|
};
|
||||||
|
return { ...shown };
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m,
|
// 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({
|
||||||
if (!('geolocation' in navigator)) {
|
onFix,
|
||||||
onError('این دستگاه موقعیتیابی (GPS) ندارد.');
|
onHeading,
|
||||||
return () => {};
|
onError,
|
||||||
}
|
onStatus = () => {},
|
||||||
if (!window.isSecureContext) {
|
stabilizer = createStabilizer(),
|
||||||
onError('موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.');
|
}) {
|
||||||
return () => {};
|
if (!('geolocation' in navigator)) {
|
||||||
}
|
onError('این دستگاه موقعیتیابی (GPS) ندارد.');
|
||||||
let fixes = 0;
|
return () => {};
|
||||||
const handle = position => {
|
}
|
||||||
const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon);
|
if (!window.isSecureContext) {
|
||||||
const shown = stabilizer.push({...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
|
onError('موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.');
|
||||||
fixes++;
|
return () => {};
|
||||||
if (shown) onFix({...shown, lat, lon});
|
}
|
||||||
};
|
let fixes = 0;
|
||||||
const fail = error => {
|
const handle = (position) => {
|
||||||
if (error.code === error.PERMISSION_DENIED) onError('اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)');
|
const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
|
||||||
else if (error.code === error.POSITION_UNAVAILABLE) onError('موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیتیابی کار نمیکند. (کد ۲)');
|
local = toLocal(lat, lon);
|
||||||
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
|
const shown = stabilizer.push({
|
||||||
else if (!fixes) onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریعتر پیدا میشود. (کد ۳)');
|
...local,
|
||||||
};
|
accuracy: Math.max(3, accuracy),
|
||||||
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
|
speed: Number.isFinite(speed) ? speed : null,
|
||||||
navigator.geolocation.getCurrentPosition(handle, () => {}, {enableHighAccuracy: false, maximumAge: 60000, timeout: 15000});
|
time: position.timestamp,
|
||||||
const watch = navigator.geolocation.watchPosition(handle, fail, {enableHighAccuracy: true, maximumAge: 3000, timeout: 30000});
|
});
|
||||||
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
|
fixes++;
|
||||||
const orientation = event => {
|
if (shown) onFix({ ...shown, lat, lon });
|
||||||
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.
|
const fail = (error) => {
|
||||||
if (compass != null) onHeading(Math.PI / 2 - compass * Math.PI / 180);
|
if (error.code === error.PERMISSION_DENIED)
|
||||||
};
|
onError(
|
||||||
const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
|
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
|
||||||
window.addEventListener(eventName, orientation);
|
);
|
||||||
return () => {
|
else if (error.code === error.POSITION_UNAVAILABLE)
|
||||||
navigator.geolocation.clearWatch(watch);
|
onError(
|
||||||
window.removeEventListener(eventName, orientation);
|
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیتیابی کار نمیکند. (کد ۲)',
|
||||||
};
|
);
|
||||||
|
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
|
||||||
|
else if (!fixes)
|
||||||
|
onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریعتر پیدا میشود. (کد ۳)');
|
||||||
|
};
|
||||||
|
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
|
||||||
|
navigator.geolocation.getCurrentPosition(handle, () => {}, {
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 60000,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
const watch = navigator.geolocation.watchPosition(handle, fail, {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
maximumAge: 3000,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
|
||||||
|
const orientation = (event) => {
|
||||||
|
const compass =
|
||||||
|
event.webkitCompassHeading ??
|
||||||
|
(event.absolute && event.alpha != null ? 360 - event.alpha : null);
|
||||||
|
// Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
|
||||||
|
if (compass != null) onHeading(Math.PI / 2 - (compass * Math.PI) / 180);
|
||||||
|
};
|
||||||
|
const eventName =
|
||||||
|
'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
|
||||||
|
window.addEventListener(eventName, orientation);
|
||||||
|
return () => {
|
||||||
|
navigator.geolocation.clearWatch(watch);
|
||||||
|
window.removeEventListener(eventName, orientation);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS needs an explicit permission prompt for the compass, and it must come from a tap.
|
// 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)
|
||||||
} catch {
|
await DeviceOrientationEvent.requestPermission();
|
||||||
// Without the compass the family faces the direction of travel instead.
|
} catch {
|
||||||
}
|
// Without the compass the family faces the direction of travel instead.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-140
@@ -2,175 +2,222 @@
|
|||||||
// A road is [typeIndex, nameIndex, x0, z0, x1, z1, …]; ways that share an OSM node share coordinates.
|
// A road is [typeIndex, nameIndex, x0, z0, x1, z1, …]; ways that share an OSM node share coordinates.
|
||||||
export const DRIVABLE_MAX_TYPE = 7; // trunk … service in data.roadTypes
|
export const DRIVABLE_MAX_TYPE = 7; // trunk … service in data.roadTypes
|
||||||
|
|
||||||
export function buildGraph(roads, {drivable = false} = {}) {
|
export function buildGraph(roads, { drivable = false } = {}) {
|
||||||
const ids = new Map(), xs = [], zs = [], adj = [];
|
const ids = new Map(),
|
||||||
const node = (x, z) => {
|
xs = [],
|
||||||
const k = `${x},${z}`;
|
zs = [],
|
||||||
if (!ids.has(k)) {
|
adj = [];
|
||||||
ids.set(k, xs.length);
|
const node = (x, z) => {
|
||||||
xs.push(x);
|
const k = `${x},${z}`;
|
||||||
zs.push(z);
|
if (!ids.has(k)) {
|
||||||
adj.push([]);
|
ids.set(k, xs.length);
|
||||||
}
|
xs.push(x);
|
||||||
return ids.get(k);
|
zs.push(z);
|
||||||
};
|
adj.push([]);
|
||||||
for (const road of roads) {
|
|
||||||
if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
|
|
||||||
let prev = node(road[2], road[3]);
|
|
||||||
for (let i = 4; i < road.length; i += 2) {
|
|
||||||
const next = node(road[i], road[i + 1]), cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
|
|
||||||
if (next !== prev) {
|
|
||||||
adj[prev].push(next, cost);
|
|
||||||
adj[next].push(prev, cost);
|
|
||||||
}
|
|
||||||
prev = next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const graph = {xs, zs, adj, main: largestComponent(adj), grid: new Map()};
|
return ids.get(k);
|
||||||
graph.main.forEach((inMain, i) => {
|
};
|
||||||
if (!inMain) return;
|
for (const road of roads) {
|
||||||
const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
|
if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
|
||||||
if (!graph.grid.has(k)) graph.grid.set(k, []);
|
let prev = node(road[2], road[3]);
|
||||||
graph.grid.get(k).push(i);
|
for (let i = 4; i < road.length; i += 2) {
|
||||||
});
|
const next = node(road[i], road[i + 1]),
|
||||||
return graph;
|
cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
|
||||||
|
if (next !== prev) {
|
||||||
|
adj[prev].push(next, cost);
|
||||||
|
adj[next].push(prev, cost);
|
||||||
|
}
|
||||||
|
prev = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graph = { xs, zs, adj, main: largestComponent(adj), grid: new Map() };
|
||||||
|
graph.main.forEach((inMain, i) => {
|
||||||
|
if (!inMain) return;
|
||||||
|
const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
|
||||||
|
if (!graph.grid.has(k)) graph.grid.set(k, []);
|
||||||
|
graph.grid.get(k).push(i);
|
||||||
|
});
|
||||||
|
return graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellKey = (cx, cz) => cx * 100003 + cz;
|
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),
|
||||||
for (let s = 0; s < adj.length; s++) {
|
sizes = [];
|
||||||
if (label[s] >= 0) continue;
|
for (let s = 0; s < adj.length; s++) {
|
||||||
const id = sizes.length, stack = [s];
|
if (label[s] >= 0) continue;
|
||||||
let size = 0;
|
const id = sizes.length,
|
||||||
label[s] = id;
|
stack = [s];
|
||||||
while (stack.length) {
|
let size = 0;
|
||||||
const n = stack.pop();
|
label[s] = id;
|
||||||
size++;
|
while (stack.length) {
|
||||||
for (let i = 0; i < adj[n].length; i += 2) if (label[adj[n][i]] < 0) {
|
const n = stack.pop();
|
||||||
label[adj[n][i]] = id;
|
size++;
|
||||||
stack.push(adj[n][i]);
|
for (let i = 0; i < adj[n].length; i += 2)
|
||||||
}
|
if (label[adj[n][i]] < 0) {
|
||||||
|
label[adj[n][i]] = id;
|
||||||
|
stack.push(adj[n][i]);
|
||||||
}
|
}
|
||||||
sizes.push(size);
|
|
||||||
}
|
}
|
||||||
const best = sizes.indexOf(Math.max(...sizes));
|
sizes.push(size);
|
||||||
return Array.from(label, l => l === best);
|
}
|
||||||
|
const best = sizes.indexOf(Math.max(...sizes));
|
||||||
|
return Array.from(label, (l) => l === best);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nearest node of the main connected component, searching outward ring by ring.
|
// 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);
|
||||||
for (let ring = 0; ring < 40; ring++) {
|
let best = -1,
|
||||||
for (let i = cx - ring; i <= cx + ring; i++) for (let j = cz - ring; j <= cz + ring; j++) {
|
bestD = Infinity;
|
||||||
if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
|
for (let ring = 0; ring < 40; ring++) {
|
||||||
for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
|
for (let i = cx - ring; i <= cx + ring; i++)
|
||||||
const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
|
for (let j = cz - ring; j <= cz + ring; j++) {
|
||||||
if (d < bestD) {
|
if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
|
||||||
bestD = d;
|
for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
|
||||||
best = n;
|
const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
|
||||||
}
|
if (d < bestD) {
|
||||||
}
|
bestD = d;
|
||||||
|
best = n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (best >= 0 && bestD < ring * 50) break;
|
}
|
||||||
}
|
if (best >= 0 && bestD < ring * 50) break;
|
||||||
return best;
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
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]]),
|
||||||
const heap = [[h(start), start]];
|
from = new Map(),
|
||||||
const push = item => {
|
closed = new Set();
|
||||||
heap.push(item);
|
const h = (n) => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
|
||||||
for (let i = heap.length - 1; i > 0;) {
|
const heap = [[h(start), start]];
|
||||||
const p = (i - 1) >> 1;
|
const push = (item) => {
|
||||||
if (heap[p][0] <= heap[i][0]) break;
|
heap.push(item);
|
||||||
[heap[p], heap[i]] = [heap[i], heap[p]];
|
for (let i = heap.length - 1; i > 0;) {
|
||||||
i = p;
|
const p = (i - 1) >> 1;
|
||||||
}
|
if (heap[p][0] <= heap[i][0]) break;
|
||||||
};
|
[heap[p], heap[i]] = [heap[i], heap[p]];
|
||||||
const pop = () => {
|
i = p;
|
||||||
const top = heap[0], last = heap.pop();
|
|
||||||
if (heap.length) {
|
|
||||||
heap[0] = last;
|
|
||||||
for (let i = 0; ;) {
|
|
||||||
const l = i * 2 + 1, r = l + 1;
|
|
||||||
let m = i;
|
|
||||||
if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
|
|
||||||
if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
|
|
||||||
if (m === i) break;
|
|
||||||
[heap[m], heap[i]] = [heap[i], heap[m]];
|
|
||||||
i = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return top;
|
|
||||||
};
|
|
||||||
while (heap.length) {
|
|
||||||
const [, n] = pop();
|
|
||||||
if (n === goal) {
|
|
||||||
const path = [n];
|
|
||||||
while (from.has(path[0])) path.unshift(from.get(path[0]));
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
if (closed.has(n)) continue;
|
|
||||||
closed.add(n);
|
|
||||||
for (let i = 0; i < adj[n].length; i += 2) {
|
|
||||||
const m = adj[n][i], cost = g.get(n) + adj[n][i + 1];
|
|
||||||
if (cost < (g.get(m) ?? Infinity)) {
|
|
||||||
g.set(m, cost);
|
|
||||||
from.set(m, n);
|
|
||||||
push([cost + h(m), m]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
};
|
||||||
|
const pop = () => {
|
||||||
|
const top = heap[0],
|
||||||
|
last = heap.pop();
|
||||||
|
if (heap.length) {
|
||||||
|
heap[0] = last;
|
||||||
|
for (let i = 0; ;) {
|
||||||
|
const l = i * 2 + 1,
|
||||||
|
r = l + 1;
|
||||||
|
let m = i;
|
||||||
|
if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
|
||||||
|
if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
|
||||||
|
if (m === i) break;
|
||||||
|
[heap[m], heap[i]] = [heap[i], heap[m]];
|
||||||
|
i = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
};
|
||||||
|
while (heap.length) {
|
||||||
|
const [, n] = pop();
|
||||||
|
if (n === goal) {
|
||||||
|
const path = [n];
|
||||||
|
while (from.has(path[0])) path.unshift(from.get(path[0]));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (closed.has(n)) continue;
|
||||||
|
closed.add(n);
|
||||||
|
for (let i = 0; i < adj[n].length; i += 2) {
|
||||||
|
const m = adj[n][i],
|
||||||
|
cost = g.get(n) + adj[n][i + 1];
|
||||||
|
if (cost < (g.get(m) ?? Infinity)) {
|
||||||
|
g.set(m, cost);
|
||||||
|
from.set(m, n);
|
||||||
|
push([cost + h(m), m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeBetween(graph, ax, az, bx, bz) {
|
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 = [
|
||||||
return {points, length: polylineLength(points)};
|
{ x: ax, z: az },
|
||||||
|
...path.map((n) => ({ x: graph.xs[n], z: graph.zs[n] })),
|
||||||
|
{ x: bx, z: bz },
|
||||||
|
];
|
||||||
|
return { points, length: polylineLength(points) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function polylineLength(points) {
|
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++)
|
||||||
return total;
|
total += Math.hypot(points[i].x - points[i - 1].x, points[i].z - points[i - 1].z);
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spatial index of road segments, used for street names and keeping trees off paths.
|
// 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)
|
||||||
const seg = [road[i - 2], road[i - 1], road[i], road[i + 1], road[0], road[1]];
|
for (let i = 4; i < road.length; i += 2) {
|
||||||
for (let cx = Math.floor(Math.min(seg[0], seg[2]) / 40); cx <= Math.floor(Math.max(seg[0], seg[2]) / 40); cx++)
|
const seg = [road[i - 2], road[i - 1], road[i], road[i + 1], road[0], road[1]];
|
||||||
for (let cz = Math.floor(Math.min(seg[1], seg[3]) / 40); cz <= Math.floor(Math.max(seg[1], seg[3]) / 40); cz++) {
|
for (
|
||||||
const k = cellKey(cx, cz);
|
let cx = Math.floor(Math.min(seg[0], seg[2]) / 40);
|
||||||
if (!grid.has(k)) grid.set(k, []);
|
cx <= Math.floor(Math.max(seg[0], seg[2]) / 40);
|
||||||
grid.get(k).push(seg);
|
cx++
|
||||||
}
|
)
|
||||||
|
for (
|
||||||
|
let cz = Math.floor(Math.min(seg[1], seg[3]) / 40);
|
||||||
|
cz <= Math.floor(Math.max(seg[1], seg[3]) / 40);
|
||||||
|
cz++
|
||||||
|
) {
|
||||||
|
const k = cellKey(cx, cz);
|
||||||
|
if (!grid.has(k)) grid.set(k, []);
|
||||||
|
grid.get(k).push(seg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
|
export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
|
||||||
let best = null, bestD = maxDistance;
|
let best = null,
|
||||||
for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++)
|
bestD = maxDistance;
|
||||||
for (let cz = Math.floor((z - maxDistance) / 40); cz <= Math.floor((z + maxDistance) / 40); cz++)
|
for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++)
|
||||||
for (const s of index.get(cellKey(cx, cz)) ?? []) {
|
for (
|
||||||
if (!filter(s)) continue;
|
let cz = Math.floor((z - maxDistance) / 40);
|
||||||
const dx = s[2] - s[0], dz = s[3] - s[1];
|
cz <= Math.floor((z + maxDistance) / 40);
|
||||||
const t = Math.max(0, Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1)));
|
cz++
|
||||||
const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
|
)
|
||||||
if (d < bestD) {
|
for (const s of index.get(cellKey(cx, cz)) ?? []) {
|
||||||
bestD = d;
|
if (!filter(s)) continue;
|
||||||
best = {type: s[4], name: s[5], distance: d, x: s[0] + dx * t, z: s[1] + dz * t, dx, dz};
|
const dx = s[2] - s[0],
|
||||||
}
|
dz = s[3] - s[1];
|
||||||
}
|
const t = Math.max(
|
||||||
return best;
|
0,
|
||||||
|
Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1)),
|
||||||
|
);
|
||||||
|
const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = {
|
||||||
|
type: s[4],
|
||||||
|
name: s[5],
|
||||||
|
distance: d,
|
||||||
|
x: s[0] + dx * t,
|
||||||
|
z: s[1] + dz * t,
|
||||||
|
dx,
|
||||||
|
dz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-60
@@ -1,82 +1,126 @@
|
|||||||
import {CATEGORIES} from './locations.js';
|
import { CATEGORIES } from './locations.js';
|
||||||
|
|
||||||
// The bonus for visiting every kind of place counts city locations, not the factory.
|
// The bonus for visiting every kind of place counts city locations, not the factory.
|
||||||
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter(c => c !== 'factory' && c !== 'test');
|
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
||||||
|
|
||||||
export const POINTS = {purchase: 100, discovery: 250, combo: 150, allCategories: 500};
|
export const POINTS = { purchase: 100, discovery: 250, combo: 150, allCategories: 500 };
|
||||||
|
|
||||||
export const RANKS = [
|
export const RANKS = [
|
||||||
{name: 'تازهوارد', xp: 0},
|
{ name: 'تازهوارد', xp: 0 },
|
||||||
{name: 'کاوشگر', xp: 600},
|
{ name: 'کاوشگر', xp: 600 },
|
||||||
{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(
|
||||||
awards.push({label: 'همهٔ انواع مکان', points: POINTS.allCategories});
|
previous.map((p) => p.category).filter((c) => CITY_CATEGORIES.includes(c)),
|
||||||
return awards;
|
);
|
||||||
|
if (
|
||||||
|
CITY_CATEGORIES.includes(record.category) &&
|
||||||
|
!categories.has(record.category) &&
|
||||||
|
categories.size === CITY_CATEGORIES.length - 1
|
||||||
|
)
|
||||||
|
awards.push({ label: 'همهٔ انواع مکان', points: POINTS.allCategories });
|
||||||
|
return awards;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rankFor(xp) {
|
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,
|
||||||
id: 'first', title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده', target: 'mellat-park', xp: 150, hetoman: 30,
|
hetoman: 20,
|
||||||
done: ps => ps.some(p => p.machineId === 'mellat-park')
|
done: (ps) => ps.some((p) => p.machineId === 'factory'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'school', title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن', target: 'school-rahil', xp: 200, hetoman: 40,
|
id: 'first',
|
||||||
done: ps => ps.some(p => p.machineId === 'school-rahil')
|
title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
|
||||||
},
|
target: 'mellat-park',
|
||||||
{
|
xp: 150,
|
||||||
id: 'campus', title: 'به دانشگاه فردوسی برو و یک خوراکی بخر', target: 'ferdowsi-university', xp: 200, hetoman: 40,
|
hetoman: 30,
|
||||||
done: ps => ps.some(p => p.machineId === 'ferdowsi-university' && p.type === 'snack')
|
done: (ps) => ps.some((p) => p.machineId === 'mellat-park'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'metro', title: 'از دستگاه ایستگاه مترو آزادی خرید کن', target: 'azadi-metro', xp: 150, hetoman: 30,
|
id: 'school',
|
||||||
done: ps => ps.some(p => p.machineId === 'azadi-metro')
|
title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
|
||||||
},
|
target: 'school-rahil',
|
||||||
{
|
xp: 200,
|
||||||
id: 'mall', title: 'با ماشین تا مشهد مال برو و خرید کن', target: 'mashhad-mall', xp: 250, hetoman: 50,
|
hetoman: 40,
|
||||||
done: ps => ps.some(p => p.machineId === 'mashhad-mall')
|
done: (ps) => ps.some((p) => p.machineId === 'school-rahil'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'explorer', title: 'از ۵ مکان متفاوت شهر خرید کن', target: null, xp: 400, hetoman: 80,
|
id: 'campus',
|
||||||
done: ps => placesVisited(ps.filter(p => CITY_CATEGORIES.includes(p.category))) >= 5
|
title: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
|
||||||
}
|
target: 'ferdowsi-university',
|
||||||
|
xp: 200,
|
||||||
|
hetoman: 40,
|
||||||
|
done: (ps) => ps.some((p) => p.machineId === 'ferdowsi-university' && p.type === 'snack'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metro',
|
||||||
|
title: 'از دستگاه ایستگاه مترو آزادی خرید کن',
|
||||||
|
target: 'azadi-metro',
|
||||||
|
xp: 150,
|
||||||
|
hetoman: 30,
|
||||||
|
done: (ps) => ps.some((p) => p.machineId === 'azadi-metro'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mall',
|
||||||
|
title: 'با ماشین تا مشهد مال برو و خرید کن',
|
||||||
|
target: 'mashhad-mall',
|
||||||
|
xp: 250,
|
||||||
|
hetoman: 50,
|
||||||
|
done: (ps) => ps.some((p) => p.machineId === 'mashhad-mall'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'explorer',
|
||||||
|
title: 'از ۵ مکان متفاوت شهر خرید کن',
|
||||||
|
target: null,
|
||||||
|
xp: 400,
|
||||||
|
hetoman: 80,
|
||||||
|
done: (ps) => placesVisited(ps.filter((p) => CITY_CATEGORIES.includes(p.category))) >= 5,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Marks and returns missions that the current purchases complete for the first time.
|
// 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(
|
||||||
for (const m of fresh) {
|
(m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
|
||||||
state.missionsDone.push(m.id);
|
);
|
||||||
state.xp += m.xp;
|
for (const m of fresh) {
|
||||||
state.balance += m.hetoman;
|
state.missionsDone.push(m.id);
|
||||||
}
|
state.xp += m.xp;
|
||||||
return fresh;
|
state.balance += m.hetoman;
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const activeMission = state => MISSIONS.find(m => !state.missionsDone.includes(m.id)) ?? null;
|
export const activeMission = (state) =>
|
||||||
|
MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null;
|
||||||
|
|||||||
+332
-217
@@ -1,5 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import {textTexture} from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
|
||||||
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
|
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
|
||||||
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
|
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
|
||||||
@@ -8,159 +8,241 @@ import {textTexture} from './textures.js';
|
|||||||
|
|
||||||
const materials = new Map();
|
const 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))
|
||||||
return materials.get(key);
|
materials.set(key, new THREE.MeshStandardMaterial({ color, roughness: 0.8, ...extra }));
|
||||||
|
return materials.get(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
|
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
mesh.position.set(x, y, z);
|
mesh.position.set(x, y, z);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
parent.add(mesh);
|
parent.add(mesh);
|
||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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({
|
||||||
const root = new THREE.Group();
|
outfit = 'hoodie',
|
||||||
const pelvis = new THREE.Group();
|
top = '#222',
|
||||||
pelvis.position.y = .94;
|
bottom = '#2c3e5a',
|
||||||
root.add(pelvis);
|
skin = '#e2b18e',
|
||||||
const topMat = mat(top), bottomMat = mat(bottom), skinMat = mat(skin), hairMat = mat(hair);
|
hair = '#2a211c',
|
||||||
const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
|
scarf = '#3b3f6b',
|
||||||
|
tie = null,
|
||||||
|
glasses = false,
|
||||||
|
shoes = '#f1f1f1',
|
||||||
|
} = {}) {
|
||||||
|
const root = new THREE.Group();
|
||||||
|
const pelvis = new THREE.Group();
|
||||||
|
pelvis.position.y = 0.94;
|
||||||
|
root.add(pelvis);
|
||||||
|
const topMat = mat(top),
|
||||||
|
bottomMat = mat(bottom),
|
||||||
|
skinMat = mat(skin),
|
||||||
|
hairMat = mat(hair);
|
||||||
|
const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
|
||||||
|
|
||||||
// Torso, neck and head.
|
// 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;
|
||||||
if (outfit === 'hoodie') {
|
part(torso, new THREE.CylinderGeometry(0.05, 0.055, 0.1, 10), skinMat, 0, 0.64, 0);
|
||||||
// Hood folded behind the neck, front pocket and drawstrings.
|
if (outfit === 'hoodie') {
|
||||||
part(torso, new THREE.SphereGeometry(.13, 12, 8), topMat, 0, .58, -.1).scale.set(1.2, .6, .7);
|
// Hood folded behind the neck, front pocket and drawstrings.
|
||||||
part(torso, new THREE.BoxGeometry(.24, .12, .02), topMat, 0, .12, .125);
|
part(torso, new THREE.SphereGeometry(0.13, 12, 8), topMat, 0, 0.58, -0.1).scale.set(
|
||||||
for (const side of [-1, 1]) part(torso, new THREE.CylinderGeometry(.006, .006, .16, 4), mat('#f2f2f2'), side * .04, .48, .13);
|
1.2,
|
||||||
} else if (outfit === 'suit') {
|
0.6,
|
||||||
// White shirt opening, optional tie, lapels.
|
0.7,
|
||||||
const shirt = part(torso, new THREE.PlaneGeometry(.12, .2), mat('#f4f4f2', {side: THREE.DoubleSide}), 0, .49, .133);
|
);
|
||||||
shirt.rotation.x = -.12;
|
part(torso, new THREE.BoxGeometry(0.24, 0.12, 0.02), topMat, 0, 0.12, 0.125);
|
||||||
if (tie) part(torso, new THREE.BoxGeometry(.035, .22, .01), mat(tie), 0, .45, .138);
|
for (const side of [-1, 1])
|
||||||
for (const side of [-1, 1]) {
|
part(
|
||||||
const lapel = part(torso, new THREE.BoxGeometry(.05, .22, .01), mat(top, {roughness: .5}), side * .07, .47, .132);
|
torso,
|
||||||
lapel.rotation.z = side * .35;
|
new THREE.CylinderGeometry(0.006, 0.006, 0.16, 4),
|
||||||
|
mat('#f2f2f2'),
|
||||||
|
side * 0.04,
|
||||||
|
0.48,
|
||||||
|
0.13,
|
||||||
|
);
|
||||||
|
} else if (outfit === 'suit') {
|
||||||
|
// White shirt opening, optional tie, lapels.
|
||||||
|
const shirt = part(
|
||||||
|
torso,
|
||||||
|
new THREE.PlaneGeometry(0.12, 0.2),
|
||||||
|
mat('#f4f4f2', { side: THREE.DoubleSide }),
|
||||||
|
0,
|
||||||
|
0.49,
|
||||||
|
0.133,
|
||||||
|
);
|
||||||
|
shirt.rotation.x = -0.12;
|
||||||
|
if (tie) part(torso, new THREE.BoxGeometry(0.035, 0.22, 0.01), mat(tie), 0, 0.45, 0.138);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const lapel = part(
|
||||||
|
torso,
|
||||||
|
new THREE.BoxGeometry(0.05, 0.22, 0.01),
|
||||||
|
mat(top, { roughness: 0.5 }),
|
||||||
|
side * 0.07,
|
||||||
|
0.47,
|
||||||
|
0.132,
|
||||||
|
);
|
||||||
|
lapel.rotation.z = side * 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const head = new THREE.Group();
|
||||||
|
head.position.y = 0.8;
|
||||||
|
torso.add(head);
|
||||||
|
part(head, new THREE.SphereGeometry(0.11, 16, 12), skinMat).scale.set(0.92, 1.05, 1);
|
||||||
|
part(head, new THREE.SphereGeometry(0.02, 6, 4), skinMat, 0, -0.01, 0.105);
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
part(head, new THREE.SphereGeometry(0.012, 6, 4), mat('#1b1512'), side * 0.038, 0.02, 0.098);
|
||||||
|
if (long) {
|
||||||
|
// Headscarf: covers the hair and the neck, leaving the face open.
|
||||||
|
part(head, new THREE.SphereGeometry(0.128, 16, 12), mat(scarf), 0, 0.012, -0.022).scale.set(
|
||||||
|
1,
|
||||||
|
1.05,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
part(
|
||||||
|
head,
|
||||||
|
new THREE.CylinderGeometry(0.09, 0.17, 0.2, 14),
|
||||||
|
mat(scarf),
|
||||||
|
0,
|
||||||
|
-0.13,
|
||||||
|
-0.01,
|
||||||
|
).scale.z = 0.75;
|
||||||
|
part(
|
||||||
|
head,
|
||||||
|
new THREE.TorusGeometry(0.105, 0.018, 6, 20, Math.PI * 1.2),
|
||||||
|
mat(scarf),
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0.04,
|
||||||
|
).rotation.set(0, 0, -0.1 * Math.PI);
|
||||||
|
} else {
|
||||||
|
part(head, new THREE.SphereGeometry(0.115, 14, 10), hairMat, 0, 0.035, -0.015).scale.set(
|
||||||
|
1,
|
||||||
|
0.75,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (glasses) {
|
||||||
|
const frame = mat('#222', { metalness: 0.6, roughness: 0.3 });
|
||||||
|
for (const side of [-1, 1])
|
||||||
|
part(head, new THREE.TorusGeometry(0.024, 0.004, 4, 14), frame, side * 0.04, 0.02, 0.11);
|
||||||
|
part(head, new THREE.BoxGeometry(0.03, 0.004, 0.004), frame, 0, 0.022, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
|
||||||
|
const arms = [-1, 1].map((side) => {
|
||||||
|
const shoulder = new THREE.Group();
|
||||||
|
shoulder.position.set(side * 0.23, 0.55, 0);
|
||||||
|
torso.add(shoulder);
|
||||||
|
part(shoulder, new THREE.CapsuleGeometry(0.05, 0.2, 4, 8), topMat, 0, -0.14, 0);
|
||||||
|
const elbow = new THREE.Group();
|
||||||
|
elbow.position.y = -0.29;
|
||||||
|
shoulder.add(elbow);
|
||||||
|
part(elbow, new THREE.CapsuleGeometry(0.043, 0.18, 4, 8), topMat, 0, -0.12, 0);
|
||||||
|
part(elbow, new THREE.SphereGeometry(0.045, 8, 6), skinMat, 0, -0.27, 0);
|
||||||
|
return { shoulder, elbow };
|
||||||
|
});
|
||||||
|
// Legs: hip → knee → foot.
|
||||||
|
const legs = [-1, 1].map((side) => {
|
||||||
|
const hip = new THREE.Group();
|
||||||
|
hip.position.set(side * 0.095, 0, 0);
|
||||||
|
pelvis.add(hip);
|
||||||
|
part(hip, new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), long ? topMat : bottomMat, 0, -0.22, 0);
|
||||||
|
const knee = new THREE.Group();
|
||||||
|
knee.position.y = -0.45;
|
||||||
|
hip.add(knee);
|
||||||
|
part(knee, new THREE.CapsuleGeometry(0.058, 0.32, 4, 8), bottomMat, 0, -0.22, 0);
|
||||||
|
part(knee, new THREE.BoxGeometry(0.1, 0.07, 0.24), shoeMat, 0, -0.45, 0.05);
|
||||||
|
return { hip, knee };
|
||||||
|
});
|
||||||
|
|
||||||
|
const person = {
|
||||||
|
root,
|
||||||
|
head,
|
||||||
|
// pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
|
||||||
|
pose(kind, t = 0, phase = 0) {
|
||||||
|
const sitting = kind === 'sit' || kind === 'type';
|
||||||
|
pelvis.position.y = sitting
|
||||||
|
? 0.5
|
||||||
|
: 0.94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * 0.02 : 0);
|
||||||
|
torso.rotation.x = kind === 'type' ? 0.18 : sitting ? -0.05 : 0;
|
||||||
|
head.rotation.y =
|
||||||
|
kind === 'type' ? Math.sin(t * 0.4 + phase) * 0.15 : Math.sin(t * 0.6 + phase) * 0.2;
|
||||||
|
head.rotation.x = kind === 'type' ? 0.12 : 0;
|
||||||
|
legs.forEach(({ hip, knee }, i) => {
|
||||||
|
const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * 0.45 : 0;
|
||||||
|
hip.rotation.x = sitting ? -Math.PI / 2 : swing;
|
||||||
|
knee.rotation.x = sitting
|
||||||
|
? Math.PI / 2
|
||||||
|
: kind === 'walk'
|
||||||
|
? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * 0.5
|
||||||
|
: 0;
|
||||||
|
});
|
||||||
|
arms.forEach(({ shoulder, elbow }, i) => {
|
||||||
|
if (kind === 'type') {
|
||||||
|
// Hands on the keyboard, fingers busy.
|
||||||
|
shoulder.rotation.set(
|
||||||
|
-0.7 + Math.sin(t * 13 + phase + i * 2) * 0.025,
|
||||||
|
0,
|
||||||
|
(i ? 1 : -1) * -0.12,
|
||||||
|
);
|
||||||
|
elbow.rotation.x = -1.15;
|
||||||
|
} else if (kind === 'sit') {
|
||||||
|
shoulder.rotation.set(-0.3, 0, 0);
|
||||||
|
elbow.rotation.x = -1.0;
|
||||||
|
} else {
|
||||||
|
const swing =
|
||||||
|
kind === 'walk'
|
||||||
|
? -Math.sin(t * 6 + phase + i * Math.PI) * 0.4
|
||||||
|
: Math.sin(t * 1.2 + phase + i) * 0.03;
|
||||||
|
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * 0.06);
|
||||||
|
elbow.rotation.x = kind === 'walk' ? -0.25 : -0.1;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
const head = new THREE.Group();
|
},
|
||||||
head.position.y = .8;
|
};
|
||||||
torso.add(head);
|
person.pose('stand');
|
||||||
part(head, new THREE.SphereGeometry(.11, 16, 12), skinMat).scale.set(.92, 1.05, 1);
|
return person;
|
||||||
part(head, new THREE.SphereGeometry(.02, 6, 4), skinMat, 0, -.01, .105);
|
|
||||||
for (const side of [-1, 1]) part(head, new THREE.SphereGeometry(.012, 6, 4), mat('#1b1512'), side * .038, .02, .098);
|
|
||||||
if (long) {
|
|
||||||
// Headscarf: covers the hair and the neck, leaving the face open.
|
|
||||||
part(head, new THREE.SphereGeometry(.128, 16, 12), mat(scarf), 0, .012, -.022).scale.set(1, 1.05, 1);
|
|
||||||
part(head, new THREE.CylinderGeometry(.09, .17, .2, 14), mat(scarf), 0, -.13, -.01).scale.z = .75;
|
|
||||||
part(head, new THREE.TorusGeometry(.105, .018, 6, 20, Math.PI * 1.2), mat(scarf), 0, .01, .04).rotation.set(0, 0, -.1 * Math.PI);
|
|
||||||
} else {
|
|
||||||
part(head, new THREE.SphereGeometry(.115, 14, 10), hairMat, 0, .035, -.015).scale.set(1, .75, 1);
|
|
||||||
}
|
|
||||||
if (glasses) {
|
|
||||||
const frame = mat('#222', {metalness: .6, roughness: .3});
|
|
||||||
for (const side of [-1, 1]) part(head, new THREE.TorusGeometry(.024, .004, 4, 14), frame, side * .04, .02, .11);
|
|
||||||
part(head, new THREE.BoxGeometry(.03, .004, .004), frame, 0, .022, .11);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
|
|
||||||
const arms = [-1, 1].map(side => {
|
|
||||||
const shoulder = new THREE.Group();
|
|
||||||
shoulder.position.set(side * .23, .55, 0);
|
|
||||||
torso.add(shoulder);
|
|
||||||
part(shoulder, new THREE.CapsuleGeometry(.05, .2, 4, 8), topMat, 0, -.14, 0);
|
|
||||||
const elbow = new THREE.Group();
|
|
||||||
elbow.position.y = -.29;
|
|
||||||
shoulder.add(elbow);
|
|
||||||
part(elbow, new THREE.CapsuleGeometry(.043, .18, 4, 8), topMat, 0, -.12, 0);
|
|
||||||
part(elbow, new THREE.SphereGeometry(.045, 8, 6), skinMat, 0, -.27, 0);
|
|
||||||
return {shoulder, elbow};
|
|
||||||
});
|
|
||||||
// Legs: hip → knee → foot.
|
|
||||||
const legs = [-1, 1].map(side => {
|
|
||||||
const hip = new THREE.Group();
|
|
||||||
hip.position.set(side * .095, 0, 0);
|
|
||||||
pelvis.add(hip);
|
|
||||||
part(hip, new THREE.CapsuleGeometry(.07, .3, 4, 8), long ? topMat : bottomMat, 0, -.22, 0);
|
|
||||||
const knee = new THREE.Group();
|
|
||||||
knee.position.y = -.45;
|
|
||||||
hip.add(knee);
|
|
||||||
part(knee, new THREE.CapsuleGeometry(.058, .32, 4, 8), bottomMat, 0, -.22, 0);
|
|
||||||
part(knee, new THREE.BoxGeometry(.1, .07, .24), shoeMat, 0, -.45, .05);
|
|
||||||
return {hip, knee};
|
|
||||||
});
|
|
||||||
|
|
||||||
const person = {
|
|
||||||
root, head,
|
|
||||||
// pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
|
|
||||||
pose(kind, t = 0, phase = 0) {
|
|
||||||
const sitting = kind === 'sit' || kind === 'type';
|
|
||||||
pelvis.position.y = sitting ? .5 : .94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * .02 : 0);
|
|
||||||
torso.rotation.x = kind === 'type' ? .18 : sitting ? -.05 : 0;
|
|
||||||
head.rotation.y = kind === 'type' ? Math.sin(t * .4 + phase) * .15 : Math.sin(t * .6 + phase) * .2;
|
|
||||||
head.rotation.x = kind === 'type' ? .12 : 0;
|
|
||||||
legs.forEach(({hip, knee}, i) => {
|
|
||||||
const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * .45 : 0;
|
|
||||||
hip.rotation.x = sitting ? -Math.PI / 2 : swing;
|
|
||||||
knee.rotation.x = sitting ? Math.PI / 2 : kind === 'walk' ? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * .5 : 0;
|
|
||||||
});
|
|
||||||
arms.forEach(({shoulder, elbow}, i) => {
|
|
||||||
if (kind === 'type') {
|
|
||||||
// Hands on the keyboard, fingers busy.
|
|
||||||
shoulder.rotation.set(-.7 + Math.sin(t * 13 + phase + i * 2) * .025, 0, (i ? 1 : -1) * -.12);
|
|
||||||
elbow.rotation.x = -1.15;
|
|
||||||
} else if (kind === 'sit') {
|
|
||||||
shoulder.rotation.set(-.3, 0, 0);
|
|
||||||
elbow.rotation.x = -1.0;
|
|
||||||
} else {
|
|
||||||
const swing = kind === 'walk' ? -Math.sin(t * 6 + phase + i * Math.PI) * .4 : Math.sin(t * 1.2 + phase + i) * .03;
|
|
||||||
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * .06);
|
|
||||||
elbow.rotation.x = kind === 'walk' ? -.25 : -.1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
person.pose('stand');
|
|
||||||
return person;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating name badge above a person's head.
|
// Floating name badge above a person's head.
|
||||||
export function nameTag(name, role = '') {
|
export function nameTag(name, role = '') {
|
||||||
const texture = textTexture(512, 160, (c, w, h) => {
|
const texture = textTexture(512, 160, (c, w, h) => {
|
||||||
c.clearRect(0, 0, w, h);
|
c.clearRect(0, 0, w, h);
|
||||||
c.fillStyle = 'rgba(8, 24, 28, .82)';
|
c.fillStyle = 'rgba(8, 24, 28, .82)';
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.roundRect(6, 6, w - 12, h - 12, 26);
|
c.roundRect(6, 6, w - 12, h - 12, 26);
|
||||||
c.fill();
|
c.fill();
|
||||||
c.fillStyle = '#3ee0c9';
|
c.fillStyle = '#3ee0c9';
|
||||||
c.fillRect(6, h - 18, w - 12, 8);
|
c.fillRect(6, h - 18, w - 12, 8);
|
||||||
c.textAlign = 'center';
|
c.textAlign = 'center';
|
||||||
c.direction = 'rtl';
|
c.direction = 'rtl';
|
||||||
c.fillStyle = '#ffffff';
|
c.fillStyle = '#ffffff';
|
||||||
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
|
||||||
c.fillText(name, w / 2, role ? 72 : 100);
|
c.fillText(name, w / 2, role ? 72 : 100);
|
||||||
if (role) {
|
if (role) {
|
||||||
c.fillStyle = '#9fe9da';
|
c.fillStyle = '#9fe9da';
|
||||||
c.font = '40px Vazirmatn, Tahoma, sans-serif';
|
c.font = '40px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.direction = 'ltr';
|
c.direction = 'ltr';
|
||||||
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 }),
|
||||||
return sprite;
|
);
|
||||||
|
sprite.scale.set(1.3, 0.41, 1);
|
||||||
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,83 +250,116 @@ export function nameTag(name, role = '') {
|
|||||||
* {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}.
|
* {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,
|
||||||
parent.add(group);
|
group = new THREE.Group();
|
||||||
const instanced = (geometry, material, count = n) => {
|
parent.add(group);
|
||||||
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
const instanced = (geometry, material, count = n) => {
|
||||||
mesh.castShadow = true;
|
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
||||||
mesh.frustumCulled = false;
|
mesh.castShadow = true;
|
||||||
group.add(mesh);
|
mesh.frustumCulled = false;
|
||||||
return mesh;
|
group.add(mesh);
|
||||||
};
|
return mesh;
|
||||||
const white = new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8});
|
};
|
||||||
const torso = instanced(new THREE.BoxGeometry(.42, .6, .26), white);
|
const white = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 });
|
||||||
const legs = instanced(new THREE.BoxGeometry(.15, .9, .17).translate(0, -.45, 0), white, n * 2);
|
const torso = instanced(new THREE.BoxGeometry(0.42, 0.6, 0.26), white);
|
||||||
const arms = instanced(new THREE.BoxGeometry(.11, .62, .11).translate(0, -.31, 0), white, n * 2);
|
const legs = instanced(
|
||||||
const heads = instanced(new THREE.SphereGeometry(.12, 10, 8), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8}));
|
new THREE.BoxGeometry(0.15, 0.9, 0.17).translate(0, -0.45, 0),
|
||||||
const hats = instanced(new THREE.SphereGeometry(.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .4}));
|
white,
|
||||||
const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map(c => new THREE.Color(c));
|
n * 2,
|
||||||
const color = new THREE.Color();
|
);
|
||||||
workers.forEach((w, i) => {
|
const arms = instanced(
|
||||||
color.set(w.suit);
|
new THREE.BoxGeometry(0.11, 0.62, 0.11).translate(0, -0.31, 0),
|
||||||
torso.setColorAt(i, color);
|
white,
|
||||||
color.set(w.pants ?? '#2d3a4d');
|
n * 2,
|
||||||
legs.setColorAt(i * 2, color);
|
);
|
||||||
legs.setColorAt(i * 2 + 1, color);
|
const heads = instanced(
|
||||||
color.set(w.suit);
|
new THREE.SphereGeometry(0.12, 10, 8),
|
||||||
arms.setColorAt(i * 2, color);
|
new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 }),
|
||||||
arms.setColorAt(i * 2 + 1, color);
|
);
|
||||||
heads.setColorAt(i, skins[i % skins.length]);
|
const hats = instanced(
|
||||||
color.set(w.hat ?? '#f2c94c');
|
new THREE.SphereGeometry(0.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2),
|
||||||
hats.setColorAt(i, color);
|
new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.4 }),
|
||||||
w.phase = i * 1.37;
|
);
|
||||||
w.from = {x: w.x, z: w.z};
|
const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map((c) => new THREE.Color(c));
|
||||||
});
|
const color = new THREE.Color();
|
||||||
const base = new THREE.Matrix4(), m = new THREE.Matrix4(), local = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0);
|
workers.forEach((w, i) => {
|
||||||
const setLimb = (mesh, index, x, y, angle) => {
|
color.set(w.suit);
|
||||||
local.makeRotationX(angle).setPosition(x, y, 0);
|
torso.setColorAt(i, color);
|
||||||
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
|
color.set(w.pants ?? '#2d3a4d');
|
||||||
};
|
legs.setColorAt(i * 2, color);
|
||||||
|
legs.setColorAt(i * 2 + 1, color);
|
||||||
|
color.set(w.suit);
|
||||||
|
arms.setColorAt(i * 2, color);
|
||||||
|
arms.setColorAt(i * 2 + 1, color);
|
||||||
|
heads.setColorAt(i, skins[i % skins.length]);
|
||||||
|
color.set(w.hat ?? '#f2c94c');
|
||||||
|
hats.setColorAt(i, color);
|
||||||
|
w.phase = i * 1.37;
|
||||||
|
w.from = { x: w.x, z: w.z };
|
||||||
|
});
|
||||||
|
const base = new THREE.Matrix4(),
|
||||||
|
m = new THREE.Matrix4(),
|
||||||
|
local = new THREE.Matrix4(),
|
||||||
|
q = new THREE.Quaternion(),
|
||||||
|
up = new THREE.Vector3(0, 1, 0);
|
||||||
|
const setLimb = (mesh, index, x, y, angle) => {
|
||||||
|
local.makeRotationX(angle).setPosition(x, y, 0);
|
||||||
|
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
|
||||||
|
};
|
||||||
|
|
||||||
function update(time) {
|
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,
|
||||||
if (w.kind === 'walk' || w.kind === 'carry') {
|
z = w.z,
|
||||||
// Walk between two points, pausing at each end.
|
facing = w.facing,
|
||||||
const dx = w.to.x - w.from.x, dz = w.to.z - w.from.z, len = Math.hypot(dx, dz);
|
walking = false;
|
||||||
const cycle = len / 1.2 * 2 + 6, c = (time + w.phase * 3) % cycle, leg = len / 1.2;
|
if (w.kind === 'walk' || w.kind === 'carry') {
|
||||||
let s;
|
// Walk between two points, pausing at each end.
|
||||||
if (c < leg) s = c / leg, walking = true, facing = Math.atan2(dx, dz);
|
const dx = w.to.x - w.from.x,
|
||||||
else if (c < leg + 3) s = 1, facing = Math.atan2(dx, dz);
|
dz = w.to.z - w.from.z,
|
||||||
else if (c < leg * 2 + 3) s = 1 - (c - leg - 3) / leg, walking = true, facing = Math.atan2(-dx, -dz);
|
len = Math.hypot(dx, dz);
|
||||||
else s = 0, facing = Math.atan2(-dx, -dz);
|
const cycle = (len / 1.2) * 2 + 6,
|
||||||
x = w.from.x + dx * s;
|
c = (time + w.phase * 3) % cycle,
|
||||||
z = w.from.z + dz * s;
|
leg = len / 1.2;
|
||||||
}
|
let s;
|
||||||
const t = time + w.phase;
|
if (c < leg) ((s = c / leg), (walking = true), (facing = Math.atan2(dx, dz)));
|
||||||
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));
|
else if (c < leg + 3) ((s = 1), (facing = Math.atan2(dx, dz)));
|
||||||
m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
|
else if (c < leg * 2 + 3)
|
||||||
torso.setMatrixAt(i, m);
|
((s = 1 - (c - leg - 3) / leg), (walking = true), (facing = Math.atan2(-dx, -dz)));
|
||||||
m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
|
else ((s = 0), (facing = Math.atan2(-dx, -dz)));
|
||||||
heads.setMatrixAt(i, m);
|
x = w.from.x + dx * s;
|
||||||
m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
|
z = w.from.z + dz * s;
|
||||||
hats.setMatrixAt(i, m);
|
}
|
||||||
for (const side of [0, 1]) {
|
const t = time + w.phase;
|
||||||
const sign = side ? 1 : -1;
|
base.compose(
|
||||||
const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * .45 : 0;
|
new THREE.Vector3(x, walking ? Math.abs(Math.sin(t * 6)) * 0.03 : 0, z),
|
||||||
setLimb(legs, i * 2 + side, sign * .1, .9, legSwing);
|
q.setFromAxisAngle(up, facing),
|
||||||
let arm;
|
new THREE.Vector3(1, 1, 1),
|
||||||
if (w.kind === 'carry' && walking) arm = -1.1;
|
);
|
||||||
else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * .4;
|
m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
|
||||||
// Working at a bench: both hands forward, one hammering or turning a tool.
|
torso.setMatrixAt(i, m);
|
||||||
else if (w.kind === 'work') arm = -1.0 + (side ? Math.sin(t * 4) * .35 : Math.sin(t * 1.3) * .08);
|
m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
|
||||||
else arm = -.9;
|
heads.setMatrixAt(i, m);
|
||||||
setLimb(arms, i * 2 + side, sign * .27, 1.46, arm);
|
m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
|
||||||
}
|
hats.setMatrixAt(i, m);
|
||||||
});
|
for (const side of [0, 1]) {
|
||||||
for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
|
const sign = side ? 1 : -1;
|
||||||
}
|
const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * 0.45 : 0;
|
||||||
update(0);
|
setLimb(legs, i * 2 + side, sign * 0.1, 0.9, legSwing);
|
||||||
for (const mesh of [torso, legs, arms, heads, hats]) if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
let arm;
|
||||||
return {group, update};
|
if (w.kind === 'carry' && walking) arm = -1.1;
|
||||||
|
else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * 0.4;
|
||||||
|
// Working at a bench: both hands forward, one hammering or turning a tool.
|
||||||
|
else if (w.kind === 'work')
|
||||||
|
arm = -1.0 + (side ? Math.sin(t * 4) * 0.35 : Math.sin(t * 1.3) * 0.08);
|
||||||
|
else arm = -0.9;
|
||||||
|
setLimb(arms, i * 2 + side, sign * 0.27, 1.46, arm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
update(0);
|
||||||
|
for (const mesh of [torso, legs, arms, heads, hats])
|
||||||
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
return { group, update };
|
||||||
}
|
}
|
||||||
|
|||||||
+787
-765
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -2,16 +2,16 @@ import * as THREE from 'three';
|
|||||||
|
|
||||||
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
|
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
|
||||||
export function textTexture(width, height, draw) {
|
export function textTexture(width, height, draw) {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
const paint = () => {
|
const paint = () => {
|
||||||
draw(canvas.getContext('2d'), width, height);
|
draw(canvas.getContext('2d'), width, height);
|
||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
};
|
};
|
||||||
paint();
|
paint();
|
||||||
document.fonts?.ready.then(paint);
|
document.fonts?.ready.then(paint);
|
||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-129
@@ -2,146 +2,213 @@ 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 }),
|
||||||
const box = (w, h, d, m, x, y, z) => {
|
glass = mat('#1e2c33', { metalness: 0.6, roughness: 0.15 });
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
const dark = mat('#141618'),
|
||||||
mesh.position.set(x, y, z);
|
chrome = mat('#c8ccce', { metalness: 0.9, roughness: 0.2 });
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
const box = (w, h, d, m, x, y, z) => {
|
||||||
group.add(mesh);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||||
return mesh;
|
mesh.position.set(x, y, z);
|
||||||
};
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
box(4.4, .62, 1.78, paint, 0, .68, 0);
|
group.add(mesh);
|
||||||
box(4.42, .16, 1.8, dark, 0, .38, 0);
|
return mesh;
|
||||||
const cabin = box(2.3, .62, 1.6, glass, -.25, 1.28, 0);
|
};
|
||||||
cabin.scale.set(1, 1, .98);
|
box(4.4, 0.62, 1.78, paint, 0, 0.68, 0);
|
||||||
box(2.1, .08, 1.62, paint, -.25, 1.62, 0);
|
box(4.42, 0.16, 1.8, dark, 0, 0.38, 0);
|
||||||
box(.9, .06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -.12;
|
const cabin = box(2.3, 0.62, 1.6, glass, -0.25, 1.28, 0);
|
||||||
for (const z of [-.62, .62]) {
|
cabin.scale.set(1, 1, 0.98);
|
||||||
box(.06, .18, .34, mat('#fff4cc', {emissive: '#fff1b0', emissiveIntensity: .6}), 2.21, .78, z);
|
box(2.1, 0.08, 1.62, paint, -0.25, 1.62, 0);
|
||||||
box(.06, .16, .34, mat('#b3261e', {emissive: '#7a0f0b', emissiveIntensity: .5}), -2.21, .8, z);
|
box(0.9, 0.06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -0.12;
|
||||||
box(.18, .08, .1, chrome, .6, 1.02, z * 1.47);
|
for (const z of [-0.62, 0.62]) {
|
||||||
|
box(
|
||||||
|
0.06,
|
||||||
|
0.18,
|
||||||
|
0.34,
|
||||||
|
mat('#fff4cc', { emissive: '#fff1b0', emissiveIntensity: 0.6 }),
|
||||||
|
2.21,
|
||||||
|
0.78,
|
||||||
|
z,
|
||||||
|
);
|
||||||
|
box(
|
||||||
|
0.06,
|
||||||
|
0.16,
|
||||||
|
0.34,
|
||||||
|
mat('#b3261e', { emissive: '#7a0f0b', emissiveIntensity: 0.5 }),
|
||||||
|
-2.21,
|
||||||
|
0.8,
|
||||||
|
z,
|
||||||
|
);
|
||||||
|
box(0.18, 0.08, 0.1, chrome, 0.6, 1.02, z * 1.47);
|
||||||
|
}
|
||||||
|
box(0.04, 0.12, 1.0, chrome, 2.22, 0.6, 0);
|
||||||
|
const wheels = [];
|
||||||
|
for (const x of [-1.38, 1.38])
|
||||||
|
for (const z of [-0.86, 0.86]) {
|
||||||
|
const wheel = new THREE.Group();
|
||||||
|
wheel.position.set(x, 0.36, z);
|
||||||
|
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.26, 18), dark);
|
||||||
|
tyre.rotation.x = Math.PI / 2;
|
||||||
|
tyre.castShadow = true;
|
||||||
|
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.27, 10), chrome);
|
||||||
|
hub.rotation.x = Math.PI / 2;
|
||||||
|
wheel.add(tyre, hub);
|
||||||
|
group.add(wheel);
|
||||||
|
wheels.push(wheel);
|
||||||
}
|
}
|
||||||
box(.04, .12, 1.0, chrome, 2.22, .6, 0);
|
return { group, wheels };
|
||||||
const wheels = [];
|
|
||||||
for (const x of [-1.38, 1.38]) for (const z of [-.86, .86]) {
|
|
||||||
const wheel = new THREE.Group();
|
|
||||||
wheel.position.set(x, .36, z);
|
|
||||||
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(.36, .36, .26, 18), dark);
|
|
||||||
tyre.rotation.x = Math.PI / 2;
|
|
||||||
tyre.castShadow = true;
|
|
||||||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(.2, .2, .27, 10), chrome);
|
|
||||||
hub.rotation.x = Math.PI / 2;
|
|
||||||
wheel.add(tyre, hub);
|
|
||||||
group.add(wheel);
|
|
||||||
wheels.push(wheel);
|
|
||||||
}
|
|
||||||
return {group, wheels};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
|
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
|
||||||
|
|
||||||
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
|
||||||
if (r[0] > 3) return [];
|
.flatMap((r, index) => {
|
||||||
const pts = [];
|
if (r[0] > 3) return [];
|
||||||
for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
|
const pts = [];
|
||||||
const lengths = [0];
|
for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
|
||||||
for (let i = 1; i < pts.length; i++) lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
|
const lengths = [0];
|
||||||
const xs = pts.map(p => p.x), zs = pts.map(p => p.y);
|
for (let i = 1; i < pts.length; i++)
|
||||||
return [{pts, lengths, total: lengths.at(-1), width: WIDTH[r[0]], oneway: oneway.has(index),
|
lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
|
||||||
box: {x0: Math.min(...xs), x1: Math.max(...xs), z0: Math.min(...zs), z1: Math.max(...zs)}}];
|
const xs = pts.map((p) => p.x),
|
||||||
}).filter(r => r.total > 80);
|
zs = pts.map((p) => p.y);
|
||||||
const sample = (road, d) => {
|
return [
|
||||||
let i = 1;
|
{
|
||||||
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
|
pts,
|
||||||
const a = road.pts[i - 1], b = road.pts[i], t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
|
lengths,
|
||||||
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};
|
total: lengths.at(-1),
|
||||||
};
|
width: WIDTH[r[0]],
|
||||||
let seed = 7;
|
oneway: oneway.has(index),
|
||||||
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
box: {
|
||||||
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
|
x0: Math.min(...xs),
|
||||||
let nearby = [], nearbyAt = null;
|
x1: Math.max(...xs),
|
||||||
const roadsNear = (x, z) => {
|
z0: Math.min(...zs),
|
||||||
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
|
z1: Math.max(...zs),
|
||||||
nearbyAt = {x, z};
|
},
|
||||||
nearby = roads.filter(({box}) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600);
|
},
|
||||||
}
|
];
|
||||||
return nearby;
|
})
|
||||||
};
|
.filter((r) => r.total > 80);
|
||||||
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
|
const sample = (road, d) => {
|
||||||
function respawn(car, x, z) {
|
let i = 1;
|
||||||
const candidates = roadsNear(x, z);
|
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
|
||||||
for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
|
const a = road.pts[i - 1],
|
||||||
const road = candidates[Math.floor(random() * candidates.length)], d = random() * road.total, p = sample(road, d);
|
b = road.pts[i],
|
||||||
const dist = Math.hypot(p.x - x, p.z - z);
|
t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
|
||||||
if (dist < 120 || dist > 600) continue;
|
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 };
|
||||||
// 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;
|
let seed = 7;
|
||||||
Object.assign(car, {road, d, dir: road.oneway || random() < .5 ? 1 : -1, lane, speed: 9 + random() * 7});
|
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
||||||
car.model.group.visible = true;
|
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
|
||||||
return true;
|
let nearby = [],
|
||||||
}
|
nearbyAt = null;
|
||||||
car.model.group.visible = false;
|
const roadsNear = (x, z) => {
|
||||||
return false;
|
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
|
||||||
|
nearbyAt = { x, z };
|
||||||
|
nearby = roads.filter(
|
||||||
|
({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// All traffic sits in one group so it can be hidden as a whole (camera AR view).
|
return nearby;
|
||||||
const group = new THREE.Group(), cars = [];
|
};
|
||||||
scene.add(group);
|
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
|
||||||
for (let i = 0; i < count; i++) {
|
function respawn(car, x, z) {
|
||||||
const model = createCarModel(COLORS[i % COLORS.length]);
|
const candidates = roadsNear(x, z);
|
||||||
group.add(model.group);
|
for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
|
||||||
model.group.visible = false;
|
const road = candidates[Math.floor(random() * candidates.length)],
|
||||||
cars.push({model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9});
|
d = random() * road.total,
|
||||||
}
|
p = sample(road, d);
|
||||||
// Takes a car off the road; it respawns out of sight on a later frame.
|
const dist = Math.hypot(p.x - x, p.z - z);
|
||||||
function remove(car) {
|
if (dist < 120 || dist > 600) continue;
|
||||||
car.road = null;
|
// One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half.
|
||||||
car.x = car.z = 1e9;
|
const lane = road.oneway ? ((random() < 0.5 ? -1 : 1) * road.width) / 4 : road.width / 4;
|
||||||
car.model.group.visible = false;
|
Object.assign(car, {
|
||||||
|
road,
|
||||||
|
d,
|
||||||
|
dir: road.oneway || random() < 0.5 ? 1 : -1,
|
||||||
|
lane,
|
||||||
|
speed: 9 + random() * 7,
|
||||||
|
});
|
||||||
|
car.model.group.visible = true;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
car.model.group.visible = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// All traffic sits in one group so it can be hidden as a whole (camera AR view).
|
||||||
|
const group = new THREE.Group(),
|
||||||
|
cars = [];
|
||||||
|
scene.add(group);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const model = createCarModel(COLORS[i % COLORS.length]);
|
||||||
|
group.add(model.group);
|
||||||
|
model.group.visible = false;
|
||||||
|
cars.push({ model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9 });
|
||||||
|
}
|
||||||
|
// Takes a car off the road; it respawns out of sight on a later frame.
|
||||||
|
function remove(car) {
|
||||||
|
car.road = null;
|
||||||
|
car.x = car.z = 1e9;
|
||||||
|
car.model.group.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
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) {
|
|
||||||
if (!car.road || !car.model.group.visible) continue;
|
|
||||||
const here = sample(car.road, car.d), len = Math.hypot(here.dx, here.dz) || 1;
|
|
||||||
const fx = here.dx / len * car.dir, fz = here.dz / len * car.dir;
|
|
||||||
const ahead = obstacles.concat(cars.filter(c => c !== car && c.model.group.visible)).some(o => {
|
|
||||||
const ox = o.x - car.x, oz = o.z - car.z, along = ox * fx + oz * fz;
|
|
||||||
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
|
|
||||||
});
|
|
||||||
const speed = ahead ? 0 : car.speed;
|
|
||||||
car.d += speed * car.dir * dt;
|
|
||||||
if (car.d > car.road.total || car.d < 0) {
|
|
||||||
// At the end of a one-way road the car leaves; on a two-way road it turns back.
|
|
||||||
if (car.road.oneway) {
|
|
||||||
remove(car);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
car.dir *= -1;
|
|
||||||
car.d = Math.max(0, Math.min(car.road.total, car.d));
|
|
||||||
}
|
|
||||||
// Keep to the right-hand lane relative to the direction of travel.
|
|
||||||
const p = sample(car.road, car.d);
|
|
||||||
car.x = p.x - fz * car.lane;
|
|
||||||
car.z = p.z + fx * car.lane;
|
|
||||||
car.model.group.position.set(car.x, 0, car.z);
|
|
||||||
car.model.group.rotation.y = Math.atan2(-fz, fx);
|
|
||||||
for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {group, cars, update, remove};
|
for (const car of cars) {
|
||||||
|
if (!car.road || !car.model.group.visible) continue;
|
||||||
|
const here = sample(car.road, car.d),
|
||||||
|
len = Math.hypot(here.dx, here.dz) || 1;
|
||||||
|
const fx = (here.dx / len) * car.dir,
|
||||||
|
fz = (here.dz / len) * car.dir;
|
||||||
|
const ahead = obstacles
|
||||||
|
.concat(cars.filter((c) => c !== car && c.model.group.visible))
|
||||||
|
.some((o) => {
|
||||||
|
const ox = o.x - car.x,
|
||||||
|
oz = o.z - car.z,
|
||||||
|
along = ox * fx + oz * fz;
|
||||||
|
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
|
||||||
|
});
|
||||||
|
const speed = ahead ? 0 : car.speed;
|
||||||
|
car.d += speed * car.dir * dt;
|
||||||
|
if (car.d > car.road.total || car.d < 0) {
|
||||||
|
// At the end of a one-way road the car leaves; on a two-way road it turns back.
|
||||||
|
if (car.road.oneway) {
|
||||||
|
remove(car);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
car.dir *= -1;
|
||||||
|
car.d = Math.max(0, Math.min(car.road.total, car.d));
|
||||||
|
}
|
||||||
|
// Keep to the right-hand lane relative to the direction of travel.
|
||||||
|
const p = sample(car.road, car.d);
|
||||||
|
car.x = p.x - fz * car.lane;
|
||||||
|
car.z = p.z + fx * car.lane;
|
||||||
|
car.model.group.position.set(car.x, 0, car.z);
|
||||||
|
car.model.group.rotation.y = Math.atan2(-fz, fx);
|
||||||
|
for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { group, cars, update, remove };
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-16
@@ -1,24 +1,35 @@
|
|||||||
// 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt);
|
car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * 0.6) * -throttle * dt;
|
||||||
if (handbrake) car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt);
|
else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt);
|
||||||
car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
|
if (handbrake)
|
||||||
// High speed narrows the usable steering angle so the car stays controllable.
|
car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt);
|
||||||
const steerAngle = car.steer / (1 + Math.abs(car.speed) * .045);
|
car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
|
||||||
car.heading -= car.speed * Math.tan(steerAngle) / CAR.wheelBase * dt;
|
// High speed narrows the usable steering angle so the car stays controllable.
|
||||||
car.x += Math.cos(car.heading) * car.speed * dt;
|
const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
|
||||||
car.z -= Math.sin(car.heading) * car.speed * dt;
|
car.heading -= ((car.speed * Math.tan(steerAngle)) / CAR.wheelBase) * dt;
|
||||||
return car;
|
car.x += Math.cos(car.heading) * car.speed * dt;
|
||||||
|
car.z -= Math.sin(car.heading) * car.speed * dt;
|
||||||
|
return car;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kmh = car => Math.round(Math.abs(car.speed) * 3.6);
|
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
|
||||||
|
|||||||
+153
-75
@@ -1,11 +1,22 @@
|
|||||||
import * as THREE from 'three';
|
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';
|
||||||
@@ -18,81 +29,148 @@ const label = (text, sub) => textTexture(512, 160, (c, width, height) => {
|
|||||||
c.fillStyle = '#9fe9da';
|
c.fillStyle = '#9fe9da';
|
||||||
c.font = '34px Vazirmatn, Tahoma, sans-serif';
|
c.font = '34px Vazirmatn, Tahoma, sans-serif';
|
||||||
c.fillText(sub, width / 2, 126);
|
c.fillText(sub, width / 2, 126);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 = {}) =>
|
||||||
const box = (w, h, d, material, x, y, z, parent = group) => {
|
new THREE.MeshStandardMaterial({ color, roughness: 0.5, ...extra });
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
|
const box = (w, h, d, material, x, y, z, parent = group) => {
|
||||||
mesh.position.set(x, y, z);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
|
||||||
mesh.castShadow = mesh.receiveShadow = true;
|
mesh.position.set(x, y, z);
|
||||||
parent.add(mesh);
|
mesh.castShadow = mesh.receiveShadow = true;
|
||||||
return mesh;
|
parent.add(mesh);
|
||||||
};
|
return mesh;
|
||||||
const shell = mat('#0b8f88', {metalness: .45, roughness: .3, emissive: '#0b8f88', emissiveIntensity: .15}), white = mat('#eef5f3', {metalness: .2, roughness: .35});
|
};
|
||||||
const dark = mat('#13292b'), steel = mat('#b7c1c0', {metalness: .8, roughness: .3});
|
const shell = mat('#0b8f88', {
|
||||||
box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
|
metalness: 0.45,
|
||||||
box(1.94, .32, 1.14, white, 0, 2.62, 0);
|
roughness: 0.3,
|
||||||
box(1.94, .12, 1.14, dark, 0, .06, 0);
|
emissive: '#0b8f88',
|
||||||
// The lit interior glows instead of using a point light, which would cost every pixel in the scene.
|
emissiveIntensity: 0.15,
|
||||||
box(1.3, 1.8, .06, mat('#13292b', {emissive: '#2a7f73', emissiveIntensity: .5}), -.2, 1.45, .56);
|
}),
|
||||||
// Six shelves of products behind the glass, coloured by what this machine sells.
|
white = mat('#eef5f3', { metalness: 0.2, roughness: 0.35 });
|
||||||
for (let row = 0; row < 6; row++) {
|
const dark = mat('#13292b'),
|
||||||
const y = .78 + row * .28;
|
steel = mat('#b7c1c0', { metalness: 0.8, roughness: 0.3 });
|
||||||
box(1.2, .02, .25, steel, -.2, y, .45);
|
box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
|
||||||
for (let col = 0; col < 8; col++) {
|
box(1.94, 0.32, 1.14, white, 0, 2.62, 0);
|
||||||
const id = def.products[(row * 8 + col) % def.products.length];
|
box(1.94, 0.12, 1.14, dark, 0, 0.06, 0);
|
||||||
const color = mat(PRODUCT_COLORS[id]);
|
// The lit interior glows instead of using a point light, which would cost every pixel in the scene.
|
||||||
if (catalog[id].type === 'drink') {
|
box(
|
||||||
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(.035, .04, .17, 8), color);
|
1.3,
|
||||||
bottle.position.set(-.73 + col * .15, y + .1, .47);
|
1.8,
|
||||||
group.add(bottle);
|
0.06,
|
||||||
} else box(.09, .14, .06, color, -.73 + col * .15, y + .08, .47);
|
mat('#13292b', { emissive: '#2a7f73', emissiveIntensity: 0.5 }),
|
||||||
}
|
-0.2,
|
||||||
|
1.45,
|
||||||
|
0.56,
|
||||||
|
);
|
||||||
|
// Six shelves of products behind the glass, coloured by what this machine sells.
|
||||||
|
for (let row = 0; row < 6; row++) {
|
||||||
|
const y = 0.78 + row * 0.28;
|
||||||
|
box(1.2, 0.02, 0.25, steel, -0.2, y, 0.45);
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const id = def.products[(row * 8 + col) % def.products.length];
|
||||||
|
const color = mat(PRODUCT_COLORS[id]);
|
||||||
|
if (catalog[id].type === 'drink') {
|
||||||
|
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.17, 8), color);
|
||||||
|
bottle.position.set(-0.73 + col * 0.15, y + 0.1, 0.47);
|
||||||
|
group.add(bottle);
|
||||||
|
} else box(0.09, 0.14, 0.06, color, -0.73 + col * 0.15, y + 0.08, 0.47);
|
||||||
}
|
}
|
||||||
const glass = new THREE.Mesh(new THREE.PlaneGeometry(1.32, 1.82), new THREE.MeshStandardMaterial({color: '#cdeeea', transparent: true, opacity: .16, roughness: .05, metalness: .3, depthWrite: false}));
|
}
|
||||||
glass.position.set(-.2, 1.45, .6);
|
const glass = new THREE.Mesh(
|
||||||
group.add(glass);
|
new THREE.PlaneGeometry(1.32, 1.82),
|
||||||
// Card reader panel on the right of the front.
|
new THREE.MeshStandardMaterial({
|
||||||
box(.42, 1.8, .05, white, .7, 1.45, .56);
|
color: '#cdeeea',
|
||||||
const screen = box(.3, .22, .02, new THREE.MeshStandardMaterial({color: '#7ef0d6', emissive: '#2bbfa0', emissiveIntensity: .8}), .7, 1.92, .6);
|
transparent: true,
|
||||||
box(.3, .2, .03, dark, .7, 1.55, .6);
|
opacity: 0.16,
|
||||||
box(.22, .03, .04, steel, .7, 1.62, .62);
|
roughness: 0.05,
|
||||||
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);
|
metalness: 0.3,
|
||||||
box(.9, .3, .08, mat('#050f10'), -.2, .38, .56);
|
depthWrite: false,
|
||||||
const flap = new THREE.Group();
|
}),
|
||||||
flap.position.set(-.2, .53, .61);
|
);
|
||||||
group.add(flap);
|
glass.position.set(-0.2, 1.45, 0.6);
|
||||||
box(.88, .28, .03, mat('#4f6f68', {metalness: .4}), 0, -.14, 0, flap);
|
group.add(glass);
|
||||||
const sign = new THREE.Mesh(new THREE.PlaneGeometry(1.86, .3), new THREE.MeshBasicMaterial({map: label('روبات مارکت', def.name)}));
|
// Card reader panel on the right of the front.
|
||||||
sign.position.set(0, 2.62, .575);
|
box(0.42, 1.8, 0.05, white, 0.7, 1.45, 0.56);
|
||||||
group.add(sign);
|
const screen = box(
|
||||||
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(.06, .06, .2, 10), mat('#e5ae47'));
|
0.3,
|
||||||
delivery.visible = false;
|
0.22,
|
||||||
group.add(delivery);
|
0.02,
|
||||||
const card = box(.2, .13, .01, mat('#e2d6a8', {metalness: .3}), .95, 1.7, .66);
|
new THREE.MeshStandardMaterial({
|
||||||
card.visible = false;
|
color: '#7ef0d6',
|
||||||
|
emissive: '#2bbfa0',
|
||||||
|
emissiveIntensity: 0.8,
|
||||||
|
}),
|
||||||
|
0.7,
|
||||||
|
1.92,
|
||||||
|
0.6,
|
||||||
|
);
|
||||||
|
box(0.3, 0.2, 0.03, dark, 0.7, 1.55, 0.6);
|
||||||
|
box(0.22, 0.03, 0.04, steel, 0.7, 1.62, 0.62);
|
||||||
|
for (let r = 0; r < 3; r++)
|
||||||
|
for (let c = 0; c < 3; c++) box(0.07, 0.05, 0.02, steel, 0.62 + c * 0.08, 1.32 - r * 0.07, 0.6);
|
||||||
|
box(0.9, 0.3, 0.08, mat('#050f10'), -0.2, 0.38, 0.56);
|
||||||
|
const flap = new THREE.Group();
|
||||||
|
flap.position.set(-0.2, 0.53, 0.61);
|
||||||
|
group.add(flap);
|
||||||
|
box(0.88, 0.28, 0.03, mat('#4f6f68', { metalness: 0.4 }), 0, -0.14, 0, flap);
|
||||||
|
const sign = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(1.86, 0.3),
|
||||||
|
new THREE.MeshBasicMaterial({ map: label('روبات مارکت', def.name) }),
|
||||||
|
);
|
||||||
|
sign.position.set(0, 2.62, 0.575);
|
||||||
|
group.add(sign);
|
||||||
|
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
|
||||||
|
delivery.visible = false;
|
||||||
|
group.add(delivery);
|
||||||
|
const card = box(0.2, 0.13, 0.01, mat('#e2d6a8', { metalness: 0.3 }), 0.95, 1.7, 0.66);
|
||||||
|
card.visible = false;
|
||||||
|
|
||||||
// Objective beacon: a tall column of light visible across the city, like a mission marker.
|
// 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',
|
||||||
beacon.position.y = 45;
|
transparent: true,
|
||||||
group.add(beacon);
|
opacity: 0.22,
|
||||||
const ring = new THREE.Mesh(new THREE.RingGeometry(1.6, 1.9, 40), new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .6, depthWrite: false}));
|
depthWrite: false,
|
||||||
ring.rotation.x = -Math.PI / 2;
|
fog: false,
|
||||||
ring.position.set(0, .09, 1.6);
|
blending: THREE.AdditiveBlending,
|
||||||
group.add(ring);
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const beacon = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.7, 0.7, 90, 16, 1, true),
|
||||||
|
beaconMaterial,
|
||||||
|
);
|
||||||
|
beacon.position.y = 45;
|
||||||
|
group.add(beacon);
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(1.6, 1.9, 40),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: '#3ee0c9',
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.6,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.set(0, 0.09, 1.6);
|
||||||
|
group.add(ring);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group, flap, delivery, card, screen, beacon, ring,
|
group,
|
||||||
setDelivery(productId) {
|
flap,
|
||||||
delivery.material = mat(PRODUCT_COLORS[productId]);
|
delivery,
|
||||||
delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
|
card,
|
||||||
},
|
screen,
|
||||||
// Points in machine space, converted to world space by the caller.
|
beacon,
|
||||||
readerPoint: new THREE.Vector3(.7, 1.6, .62),
|
ring,
|
||||||
slotPoint: new THREE.Vector3(-.2, .38, .66)
|
setDelivery(productId) {
|
||||||
};
|
delivery.material = mat(PRODUCT_COLORS[productId]);
|
||||||
|
delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
|
||||||
|
},
|
||||||
|
// Points in machine space, converted to world space by the caller.
|
||||||
|
readerPoint: new THREE.Vector3(0.7, 1.6, 0.62),
|
||||||
|
slotPoint: new THREE.Vector3(-0.2, 0.38, 0.66),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-35
@@ -1,47 +1,67 @@
|
|||||||
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,
|
||||||
import {machineById, machines} from '../src/locations.js';
|
purchaseBlocker,
|
||||||
|
priceAt,
|
||||||
|
canAffordAnything,
|
||||||
|
formatClock,
|
||||||
|
} from '../src/game-state.js';
|
||||||
|
import {
|
||||||
|
createPayment,
|
||||||
|
beginPayment,
|
||||||
|
readyPayment,
|
||||||
|
swipePayment,
|
||||||
|
cancelPayment,
|
||||||
|
} from '../src/payment.js';
|
||||||
|
import { machineById, machines } from '../src/locations.js';
|
||||||
|
|
||||||
const park = machineById('mellat-park'), school = machineById('school-rahil'), uni = machineById('ferdowsi-university');
|
const park = machineById('mellat-park'),
|
||||||
|
school = machineById('school-rahil'),
|
||||||
|
uni = machineById('ferdowsi-university');
|
||||||
|
|
||||||
test('Payment charges once, only after ready and an explicit swipe', () => {
|
test('Payment charges once, only after ready and an explicit swipe', () => {
|
||||||
const state = createState(), payment = createPayment();
|
const state = createState(),
|
||||||
assert.ok(beginPayment(state, payment, park, 'juice'));
|
payment = createPayment();
|
||||||
assert.equal(state.balance, 500);
|
assert.ok(beginPayment(state, payment, park, 'juice'));
|
||||||
assert.equal(swipePayment(state, payment), false, 'not ready yet');
|
assert.equal(state.balance, 500);
|
||||||
assert.ok(readyPayment(payment));
|
assert.equal(swipePayment(state, payment), false, 'not ready yet');
|
||||||
assert.ok(swipePayment(state, payment));
|
assert.ok(readyPayment(payment));
|
||||||
assert.equal(state.balance, 465);
|
assert.ok(swipePayment(state, payment));
|
||||||
assert.equal(swipePayment(state, payment), false, 'no double charge');
|
assert.equal(state.balance, 465);
|
||||||
assert.equal(state.purchases.length, 1);
|
assert.equal(swipePayment(state, payment), false, 'no double charge');
|
||||||
assert.equal(beginPayment(state, createPayment(), park, 'water'), false, 'busy until delivery ends');
|
assert.equal(state.purchases.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
beginPayment(state, createPayment(), park, 'water'),
|
||||||
|
false,
|
||||||
|
'busy until delivery ends',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cancel, closed machines, unknown products and low funds never charge', () => {
|
test('Cancel, closed machines, unknown products and low funds never charge', () => {
|
||||||
const state = createState(), payment = createPayment();
|
const state = createState(),
|
||||||
assert.ok(beginPayment(state, payment, park, 'cake'));
|
payment = createPayment();
|
||||||
assert.ok(cancelPayment(state, payment));
|
assert.ok(beginPayment(state, payment, park, 'cake'));
|
||||||
assert.equal(state.balance, 500);
|
assert.ok(cancelPayment(state, payment));
|
||||||
assert.equal(state.busy, false);
|
assert.equal(state.balance, 500);
|
||||||
assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
|
assert.equal(state.busy, false);
|
||||||
assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
|
assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
|
||||||
state.minutes = 14 * 60;
|
assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
|
||||||
assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
|
state.minutes = 14 * 60;
|
||||||
state.minutes = 9 * 60;
|
assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
|
||||||
state.balance = 10;
|
state.minutes = 9 * 60;
|
||||||
assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
|
state.balance = 10;
|
||||||
assert.equal(beginPayment(state, payment, park, 'water'), false);
|
assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
|
||||||
|
assert.equal(beginPayment(state, payment, park, 'water'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Prices follow machine discounts and the clock is Persian', () => {
|
test('Prices follow machine discounts and the clock is Persian', () => {
|
||||||
assert.equal(priceAt(uni, 'nuts'), 52);
|
assert.equal(priceAt(uni, 'nuts'), 52);
|
||||||
assert.equal(priceAt(park, 'nuts'), 65);
|
assert.equal(priceAt(park, 'nuts'), 65);
|
||||||
assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
|
assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
|
||||||
const state = createState();
|
const state = createState();
|
||||||
state.balance = 5;
|
state.balance = 5;
|
||||||
assert.equal(canAffordAnything(state, machines), false);
|
assert.equal(canAffordAnything(state, machines), false);
|
||||||
state.balance = 15;
|
state.balance = 15;
|
||||||
assert.ok(canAffordAnything(state, machines));
|
assert.ok(canAffordAnything(state, machines));
|
||||||
});
|
});
|
||||||
|
|||||||
+49
-45
@@ -1,59 +1,63 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {smoothFix, arrivalRadius, insideWorld, createStabilizer} from '../src/realworld.js';
|
import { smoothFix, arrivalRadius, insideWorld, createStabilizer } from '../src/realworld.js';
|
||||||
|
|
||||||
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
|
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
|
||||||
const first = smoothFix(null, {x: 0, z: 0, accuracy: 10, time: 0});
|
const first = smoothFix(null, { x: 0, z: 0, accuracy: 10, time: 0 });
|
||||||
assert.deepEqual([first.x, first.z], [0, 0]);
|
assert.deepEqual([first.x, first.z], [0, 0]);
|
||||||
const equal = smoothFix(first, {x: 10, z: 0, accuracy: 10, time: 1000});
|
const equal = smoothFix(first, { x: 10, z: 0, accuracy: 10, time: 1000 });
|
||||||
assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
|
assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
|
||||||
assert.ok(equal.accuracy < 10, 'two fixes are better than one');
|
assert.ok(equal.accuracy < 10, 'two fixes are better than one');
|
||||||
const poor = smoothFix(first, {x: 50, z: 0, accuracy: 60, time: 1000});
|
const poor = smoothFix(first, { x: 50, z: 0, accuracy: 60, time: 1000 });
|
||||||
assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
|
assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
|
||||||
const stale = smoothFix(first, {x: 99, z: 0, accuracy: 30, time: 60000});
|
const stale = smoothFix(first, { x: 99, z: 0, accuracy: 30, time: 60000 });
|
||||||
assert.equal(stale.x, 99);
|
assert.equal(stale.x, 99);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Arrival radius widens with poor GPS and bounds check the world', () => {
|
test('Arrival radius widens with poor GPS and bounds check the world', () => {
|
||||||
assert.equal(arrivalRadius(4), 20);
|
assert.equal(arrivalRadius(4), 20);
|
||||||
assert.equal(arrivalRadius(12), 30);
|
assert.equal(arrivalRadius(12), 30);
|
||||||
assert.equal(arrivalRadius(100), 50);
|
assert.equal(arrivalRadius(100), 50);
|
||||||
const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
|
const bounds = { minX: -10, minZ: -10, maxX: 10, maxZ: 10 };
|
||||||
assert.ok(insideWorld(bounds, 0, 0));
|
assert.ok(insideWorld(bounds, 0, 0));
|
||||||
assert.equal(insideWorld(bounds, 11, 0), false);
|
assert.equal(insideWorld(bounds, 11, 0), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Stable mode holds still through jitter, drops jumps and follows real walking', () => {
|
test('Stable mode holds still through jitter, drops jumps and follows real walking', () => {
|
||||||
const s = createStabilizer();
|
const s = createStabilizer();
|
||||||
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++)
|
||||||
assert.ok(p.x > 15, `followed the walk (${p.x})`);
|
p = s.push({ x: 1.4 * i, z: 0, accuracy: 6, time: 11000 + i * 1000, speed: 1.4 });
|
||||||
// With stable mode off every smoothed fix is shown.
|
assert.ok(p.x > 15, `followed the walk (${p.x})`);
|
||||||
const raw = createStabilizer();
|
// With stable mode off every smoothed fix is shown.
|
||||||
raw.stable = false;
|
const raw = createStabilizer();
|
||||||
raw.push({x: 0, z: 0, accuracy: 8, time: 0});
|
raw.stable = false;
|
||||||
assert.ok(raw.push({x: 3, z: 0, accuracy: 8, time: 1000}).x > 1);
|
raw.push({ x: 0, z: 0, accuracy: 8, time: 0 });
|
||||||
|
assert.ok(raw.push({ x: 3, z: 0, accuracy: 8, time: 1000 }).x > 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AR camera faces north/west with the phone held upright, and the FOV fits the screen', async () => {
|
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) =>
|
||||||
const north = look(0);
|
new Vector3(0, 0, -1).applyQuaternion(
|
||||||
assert.ok(north.z < -.99, 'upright phone with alpha 0 looks north (−z)');
|
orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle),
|
||||||
assert.ok(look(90).x < -.99, 'alpha 90 (turned left) looks west (−x)');
|
);
|
||||||
assert.ok(look(0, 0).y < -.99, 'phone lying flat looks at the ground');
|
const north = look(0);
|
||||||
// Portrait 9:19.5 screen over a 3:4 portrait video: the full long-side 65° shows vertically.
|
assert.ok(north.z < -0.99, 'upright phone with alpha 0 looks north (−z)');
|
||||||
assert.ok(Math.abs(coverFov(3 / 4, 9 / 19.5) - 65) < .5);
|
assert.ok(look(90).x < -0.99, 'alpha 90 (turned left) looks west (−x)');
|
||||||
// Landscape screen wider than the 4:3 video: the width shows 65°, so the vertical view is narrower.
|
assert.ok(look(0, 0).y < -0.99, 'phone lying flat looks at the ground');
|
||||||
assert.ok(coverFov(4 / 3, 19.5 / 9) < 35);
|
// Portrait 9:19.5 screen over a 3:4 portrait video: the full long-side 65° shows vertically.
|
||||||
|
assert.ok(Math.abs(coverFov(3 / 4, 9 / 19.5) - 65) < 0.5);
|
||||||
|
// Landscape screen wider than the 4:3 video: the width shows 65°, so the vertical view is narrower.
|
||||||
|
assert.ok(coverFov(4 / 3, 19.5 / 9) < 35);
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-32
@@ -1,41 +1,55 @@
|
|||||||
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 {
|
||||||
import {createState, purchase} from '../src/game-state.js';
|
awardsFor,
|
||||||
import {machineById} from '../src/locations.js';
|
rankFor,
|
||||||
|
completeMissions,
|
||||||
|
activeMission,
|
||||||
|
POINTS,
|
||||||
|
RANKS,
|
||||||
|
} from '../src/scoring.js';
|
||||||
|
import { createState, purchase } from '../src/game-state.js';
|
||||||
|
import { machineById } from '../src/locations.js';
|
||||||
|
|
||||||
const total = awards => awards.reduce((s, a) => s + a.points, 0);
|
const total = (awards) => awards.reduce((s, a) => s + a.points, 0);
|
||||||
|
|
||||||
test('Discovery, combo and all-category bonuses are paid once', () => {
|
test('Discovery, combo and all-category bonuses are paid once', () => {
|
||||||
const state = createState(), park = machineById('mellat-park');
|
const state = createState(),
|
||||||
const buy = (machine, product) => {
|
park = machineById('mellat-park');
|
||||||
const before = state.purchases.slice();
|
const buy = (machine, product) => {
|
||||||
return awardsFor(before, purchase(state, machine, product));
|
const before = state.purchases.slice();
|
||||||
};
|
return awardsFor(before, purchase(state, machine, product));
|
||||||
assert.equal(total(buy(park, 'water')), POINTS.purchase + POINTS.discovery);
|
};
|
||||||
assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
|
assert.equal(total(buy(park, 'water')), POINTS.purchase + POINTS.discovery);
|
||||||
assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
|
assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
|
||||||
assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
|
assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
|
||||||
buy(machineById('factory'), 'water');
|
assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
|
||||||
buy(machineById('school-rahil'), 'water');
|
buy(machineById('factory'), 'water');
|
||||||
buy(machineById('ferdowsi-university'), 'water');
|
buy(machineById('school-rahil'), 'water');
|
||||||
buy(machineById('azadi-metro'), 'water');
|
buy(machineById('ferdowsi-university'), 'water');
|
||||||
state.minutes = 11 * 60;
|
buy(machineById('azadi-metro'), 'water');
|
||||||
const last = buy(machineById('mashhad-mall'), 'water');
|
state.minutes = 11 * 60;
|
||||||
assert.ok(last.some(a => a.points === POINTS.allCategories));
|
const last = buy(machineById('mashhad-mall'), 'water');
|
||||||
|
assert.ok(last.some((a) => a.points === POINTS.allCategories));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ranks and missions advance with progress', () => {
|
test('Ranks and missions advance with progress', () => {
|
||||||
assert.equal(rankFor(0).name, RANKS[0].name);
|
assert.equal(rankFor(0).name, RANKS[0].name);
|
||||||
assert.equal(rankFor(RANKS[1].xp).index, 1);
|
assert.equal(rankFor(RANKS[1].xp).index, 1);
|
||||||
assert.equal(rankFor(1e6).progress, 1);
|
assert.equal(rankFor(1e6).progress, 1);
|
||||||
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(
|
||||||
assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
|
completeMissions(state).map((m) => m.id),
|
||||||
purchase(state, machineById('mellat-park'), 'water');
|
['factory'],
|
||||||
assert.deepEqual(completeMissions(state).map(m => m.id), ['first']);
|
);
|
||||||
assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
|
assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
|
||||||
assert.equal(activeMission(state).id, 'school');
|
purchase(state, machineById('mellat-park'), 'water');
|
||||||
|
assert.deepEqual(
|
||||||
|
completeMissions(state).map((m) => m.id),
|
||||||
|
['first'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
|
||||||
|
assert.equal(activeMission(state).id, 'school');
|
||||||
});
|
});
|
||||||
|
|||||||
+102
-72
@@ -1,97 +1,127 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {readFileSync} from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import {toLocal, toLatLon, bearing, ORIGIN} from '../src/geo.js';
|
import { toLocal, toLatLon, bearing, ORIGIN } from '../src/geo.js';
|
||||||
import {createCollisionWorld, resolveCircle, blocked, clearFraction} from '../src/collision.js';
|
import { createCollisionWorld, resolveCircle, blocked, clearFraction } from '../src/collision.js';
|
||||||
import {buildGraph, routeBetween, nearestSegment, buildSegmentIndex} from '../src/routing.js';
|
import { buildGraph, routeBetween, nearestSegment, buildSegmentIndex } from '../src/routing.js';
|
||||||
import {createCar, stepCar} from '../src/vehicle.js';
|
import { createCar, stepCar } from '../src/vehicle.js';
|
||||||
import {createTraffic} from '../src/traffic.js';
|
import { createTraffic } from '../src/traffic.js';
|
||||||
import {Scene} from 'three';
|
import { Scene } from 'three';
|
||||||
import {machines, machineById, FACTORY} from '../src/locations.js';
|
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),
|
||||||
assert.ok(Math.abs(back.lat - 36.32092) < 1e-9 && Math.abs(back.lon - 59.53744) < 1e-9);
|
back = toLatLon(p.x, p.z);
|
||||||
assert.ok(p.z < 0, 'north of the origin is -z');
|
assert.ok(Math.abs(back.lat - 36.32092) < 1e-9 && Math.abs(back.lon - 59.53744) < 1e-9);
|
||||||
assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), {x: 0, z: 0});
|
assert.ok(p.z < 0, 'north of the origin is -z');
|
||||||
assert.equal(bearing(0, -1), 0);
|
assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), { x: 0, z: 0 });
|
||||||
assert.equal(bearing(1, 0), 90);
|
assert.equal(bearing(0, -1), 0);
|
||||||
|
assert.equal(bearing(1, 0), 90);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Circles are pushed out of footprints and segments stop at walls', () => {
|
test('Circles are pushed out of footprints and segments stop at walls', () => {
|
||||||
const world = createCollisionWorld([[0, 0, 10, 0, 10, 10, 0, 10]]);
|
const world = createCollisionWorld([[0, 0, 10, 0, 10, 10, 0, 10]]);
|
||||||
assert.ok(blocked(world, 5, 5));
|
assert.ok(blocked(world, 5, 5));
|
||||||
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),
|
||||||
const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
|
drive = buildGraph(data.roads, { drivable: true });
|
||||||
assert.ok(toCity && toCity.length > 18000 && toCity.length < 23000, `factory → Park Mellat is ~20 km (${toCity?.length})`);
|
const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
|
||||||
const start = machineById('mellat-park').anchor;
|
assert.ok(
|
||||||
for (const m of machines.filter(m => m.anchor)) {
|
toCity && toCity.length > 18000 && toCity.length < 23000,
|
||||||
const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
|
`factory → Park Mellat is ~20 km (${toCity?.length})`,
|
||||||
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);
|
const start = machineById('mellat-park').anchor;
|
||||||
assert.ok(routeBetween(drive, start.x, start.z, m.anchor.x, m.anchor.z), `drive route to ${m.id}`);
|
for (const m of machines.filter((m) => m.anchor)) {
|
||||||
}
|
const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
|
||||||
const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, s => s[5] >= 0);
|
assert.ok(onFoot, `walk route to ${m.id}`);
|
||||||
assert.equal(data.names[street.name], 'بولوار ملک آباد');
|
assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1);
|
||||||
|
assert.ok(
|
||||||
|
routeBetween(drive, start.x, start.z, m.anchor.x, m.anchor.z),
|
||||||
|
`drive route to ${m.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, (s) => s[5] >= 0);
|
||||||
|
assert.equal(data.names[street.name], 'بولوار ملک آباد');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Car accelerates, steers right with positive input and brakes', () => {
|
test('Car accelerates, steers right with positive input and brakes', () => {
|
||||||
const car = createCar(0, 0, 0);
|
const car = createCar(0, 0, 0);
|
||||||
for (let i = 0; i < 60; i++) stepCar(car, {throttle: 1}, 1 / 30);
|
for (let i = 0; i < 60; i++) stepCar(car, { throttle: 1 }, 1 / 30);
|
||||||
assert.ok(car.speed > 10 && car.x > 10 && Math.abs(car.z) < 1e-9);
|
assert.ok(car.speed > 10 && car.x > 10 && Math.abs(car.z) < 1e-9);
|
||||||
for (let i = 0; i < 30; i++) stepCar(car, {throttle: 1, steer: 1}, 1 / 30);
|
for (let i = 0; i < 30; i++) stepCar(car, { throttle: 1, steer: 1 }, 1 / 30);
|
||||||
assert.ok(car.z > 0, 'turning right while heading east moves south (+z)');
|
assert.ok(car.z > 0, 'turning right while heading east moves south (+z)');
|
||||||
for (let i = 0; i < 90; i++) stepCar(car, {throttle: -1}, 1 / 30);
|
for (let i = 0; i < 90; i++) stepCar(car, { throttle: -1 }, 1 / 30);
|
||||||
assert.ok(car.speed <= 0);
|
assert.ok(car.speed <= 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
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)),
|
||||||
for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
|
'Chenaran highway is one-way per carriageway',
|
||||||
const active = traffic.cars.filter(c => c.road);
|
);
|
||||||
assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
|
const traffic = createTraffic(new Scene(), data, 28),
|
||||||
for (const car of active.filter(c => c.road.oneway)) {
|
around = { x: -10008, z: -15049 };
|
||||||
assert.equal(car.dir, 1);
|
for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
|
||||||
assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
|
const active = traffic.cars.filter((c) => c.road);
|
||||||
}
|
assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
|
||||||
|
for (const car of active.filter((c) => c.road.oneway)) {
|
||||||
|
assert.equal(car.dir, 1);
|
||||||
|
assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Microsoft buildings decode into the world, stay clear of the factory and leave machines reachable', async () => {
|
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(
|
||||||
assert.ok(buildings.length > 100000, `${buildings.length} buildings`);
|
file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength),
|
||||||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
);
|
||||||
for (const b of buildings) {
|
assert.ok(buildings.length > 100000, `${buildings.length} buildings`);
|
||||||
assert.ok(b[0] >= 3 && b[0] <= 30, `height ${b[0]}`);
|
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||||
assert.ok(b[1] >= minX - 150 && b[1] <= maxX + 150 && b[2] >= minZ - 150 && b[2] <= maxZ + 150, 'near the region');
|
for (const b of buildings) {
|
||||||
}
|
assert.ok(b[0] >= 3 && b[0] <= 30, `height ${b[0]}`);
|
||||||
const world = createCollisionWorld([...data.buildings, ...buildings].map(b => b.slice(1)));
|
assert.ok(
|
||||||
assert.equal(blocked(world, FACTORY.center.x, FACTORY.center.z), false, 'factory compound is clear');
|
b[1] >= minX - 150 && b[1] <= maxX + 150 && b[2] >= minZ - 150 && b[2] <= maxZ + 150,
|
||||||
// Every city machine's path point is still outside buildings, so the family can stand there.
|
'near the region',
|
||||||
for (const m of machines.filter(m => m.anchor)) assert.equal(blocked(world, m.anchor.x, m.anchor.z), false, m.id);
|
);
|
||||||
|
}
|
||||||
|
const world = createCollisionWorld([...data.buildings, ...buildings].map((b) => b.slice(1)));
|
||||||
|
assert.equal(
|
||||||
|
blocked(world, FACTORY.center.x, FACTORY.center.z),
|
||||||
|
false,
|
||||||
|
'factory compound is clear',
|
||||||
|
);
|
||||||
|
// Every city machine's path point is still outside buildings, so the family can stand there.
|
||||||
|
for (const m of machines.filter((m) => m.anchor))
|
||||||
|
assert.equal(blocked(world, m.anchor.x, m.anchor.z), false, m.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Satellite tiles land where the map is: a tile over Park Mellat covers the park', async () => {
|
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;
|
||||||
assert.ok(index[17].includes(`${x}/${y}`), 'the Park Mellat tile was downloaded');
|
const x = Math.floor(((59.53744 + 180) / 360) * 2 ** z),
|
||||||
const [nw, ne, sw, se] = tileCorners(x, y, z);
|
y = Math.floor(((1 - Math.asinh(Math.tan((36.32092 * Math.PI) / 180)) / Math.PI) / 2) * 2 ** 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(index[17].includes(`${x}/${y}`), 'the Park Mellat tile was downloaded');
|
||||||
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');
|
const [nw, ne, sw, se] = tileCorners(x, y, z);
|
||||||
assert.ok(Math.abs((ne.x - nw.x) - 247) < 5, 'a zoom-17 tile is ~247 m wide at Mashhad');
|
assert.ok(
|
||||||
|
nw.x < ne.x && nw.z < sw.z && se.x > sw.x,
|
||||||
|
'north-west corner is up-left in world space',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
park.x >= nw.x && park.x <= se.x && park.z >= nw.z && park.z <= se.z,
|
||||||
|
'the park centre lies inside its tile',
|
||||||
|
);
|
||||||
|
assert.ok(Math.abs(ne.x - nw.x - 247) < 5, 'a zoom-17 tile is ~247 m wide at Mashhad');
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-32
@@ -1,29 +1,37 @@
|
|||||||
import {defineConfig} from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import {readdirSync, statSync} from 'node:fs';
|
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];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emits sw.js listing every built file, so the installed game works offline. The cache name is a
|
// Emits sw.js listing every built file, so the installed game works offline. The cache name is a
|
||||||
// hash of that list: a new build gets a new cache and the page offers to reload into it.
|
// hash of that list: a new build gets a new cache and the page offers to reload into it.
|
||||||
// Satellite tiles (16 MB) are not precached; they are kept as the player passes them.
|
// Satellite tiles (16 MB) are not precached; they are kept as the player passes them.
|
||||||
function offlineServiceWorker() {
|
function offlineServiceWorker() {
|
||||||
return {
|
return {
|
||||||
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),
|
||||||
const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
|
...listFiles('public').map((f) => relative('public', f).replaceAll('\\', '/')),
|
||||||
this.emitFile({
|
].filter(
|
||||||
type: 'asset', fileName: 'sw.js', source: `// Generated by vite.config.js — do not edit.
|
(f) =>
|
||||||
|
f !== 'sw.js' && !f.endsWith('.map') && !(f.startsWith('imagery/') && f.endsWith('.jpg')),
|
||||||
|
);
|
||||||
|
const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: 'sw.js',
|
||||||
|
source: `// Generated by vite.config.js — do not edit.
|
||||||
const CACHE = 'robot-market-${version}';
|
const 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,23 +54,20 @@ 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)));
|
||||||
});
|
});
|
||||||
`
|
`,
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Relative assets also load under a GitHub Pages repository path.
|
// Relative assets also load under a GitHub Pages repository path.
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [offlineServiceWorker()],
|
plugins: [offlineServiceWorker()],
|
||||||
// Top-level await loads the building and imagery files before the world is built.
|
// Top-level await loads the building and imagery files before the world is built.
|
||||||
build: {target: 'es2022'},
|
build: { target: 'es2022' },
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
allowedHosts: [
|
allowedHosts: ['robotmarkets.ir', 'www.robotmarkets.ir'],
|
||||||
'robotmarkets.ir',
|
},
|
||||||
'www.robotmarkets.ir',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user