Factory departments, staff and a faster family car
CI/CD / test (push) Successful in 23s
CI/CD / publish (push) Successful in 24s
CI/CD / deploy (push) Successful in 5s

The factory now has an engineering office with five named developers and
the first-mission vending machine, an accounting office, and the manager's
room. Mr. Mohammadi drives in with his KMC T8 pickup and walks to his desk.
The production hall has five work areas staffed by 40 instanced workers, and
a truck loads packed machines at the dock and drives out of the gate.

The family car is a BMW-style sports sedan (no badges) parked beside the
start point, with a top speed of 200 km/h. Driving is now sliced into
sub-steps so the car can't tunnel through walls. Microsoft footprints that
reach into the compound are dropped, and the gate sign reads correctly
from inside the yard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 11:27:46 +03:30
co-authored by Claude Opus 5.5
parent 9b289f76b0
commit 751af768ce
9 changed files with 2342 additions and 336 deletions
+12 -3
View File
@@ -8,14 +8,21 @@
## داستان و هدف
- **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است.
- **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه این بخش‌ها را دارد:
- **سالن تولید** با پنج بخش خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی و مونتاژ، و ۴۰ کارگر مشغول کار؛
- **کامیون ارسال**: دستگاه‌های بسته‌بندی‌شده از سکوی بارگیری روی کامیون می‌روند. کامیون پر که شد از دروازه بیرون می‌رود و بعد خالی برمی‌گردد؛
- **دفتر فنی مهندسی** با پنج برنامه‌نویس که اسمشان بالای سرشان دیده می‌شود: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کت‌وشلوار) و محمد. دستگاه وندینگ اول بازی هم همین‌جاست؛
- **واحد حسابداری** با چهار همکار خانم؛
- **اتاق مدیریت**: آقای محمدی، مدیر شرکت، اول بازی با وانت KMC T8 وارد حیاط می‌شود و به اتاقش می‌رود.
با نزدیک شدن به هر کدام از این آدم‌ها، با خانواده حرف می‌زنند. ماشین خانواده، یک سدان اسپرت به سبک BMW با سرعت تا ۲۰۰ کیلومتر بر ساعت، درست کنار نقطهٔ شروع پارک است.
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد.
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند.
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap:
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
- همهٔ خیابان‌ها و ساختمان‌ها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
- شبکهٔ خیابان‌های اصلی در کل منطقه
- **دستگاه‌ها:** دستگاه نمونهٔ کارخانه (نصف قیمت)، بوستان ملت، مجموعه آموزشی راحیل (فقط ۷:۳۰ تا ۱۳:۳۰)، دانشگاه فردوسی و دانشکدهٔ ادبیات (۲۰٪ تخفیف دانشجویی)، دانشگاه علوم پزشکی، ایستگاه مترو آزادی، مشهد مال (از ساعت ۱۰). همهٔ دستگاه‌ها و محصولاتشان فرضی‌اند.
- **دستگاه‌ها:** دستگاه دفتر فنی مهندسی کارخانه (نصف قیمت)، بوستان ملت، مجموعه آموزشی راحیل (فقط ۷:۳۰ تا ۱۳:۳۰)، دانشگاه فردوسی و دانشکدهٔ ادبیات (۲۰٪ تخفیف دانشجویی)، دانشگاه علوم پزشکی، ایستگاه مترو آزادی، مشهد مال (از ساعت ۱۰). همهٔ دستگاه‌ها و محصولاتشان فرضی‌اند.
## کنترل
@@ -207,7 +214,9 @@ node scripts/fetch-imagery.mjs
- `src/locations.js`: دستگاه‌ها، آدرس‌ها، ساعت کاری و محصولات
- `src/game-state.js`، `src/payment.js`، `src/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریت‌ها
- `src/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاه‌ها بیرون می‌آید
- `src/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها
- `src/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، به‌همراه دفترها، کامیون و ورود مدیر
- `src/staff.js`: آدم‌های کارخانه، یعنی کارمندهای مفصل (هودی، کت‌وشلوار، مانتو و روسری) با برچسب اسم، و ۴۰ کارگر به‌صورت instanced
- `src/car-models.js`: ماشین خانواده (سدان اسپرت)، وانت مدیر و کامیون
- `src/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام به‌روزرسانی
- `src/realworld.js`: GPS، قطب‌نما، نرم‌سازی و حالت پایدار موقعیت، و شعاع رسیدن
- `src/imagery.js`: زمین ماهواره‌ای که کاشی‌هایش با نزدیک شدن بارگذاری و با دور شدن آزاد می‌شوند
+250
View File
@@ -0,0 +1,250 @@
import * as THREE from 'three';
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .45, ...extra});
// A side profile (x along the car, y up) extruded across its width, with rounded edges.
function extrudeProfile(points, width, material, bevel = .1) {
const shape = new THREE.Shape();
points.forEach(([x, y], i) => i ? shape.lineTo(x, y) : shape.moveTo(x, y));
const geometry = new THREE.ExtrudeGeometry(shape, {depth: width - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel * .8, bevelSegments: 3, curveSegments: 6});
geometry.translate(0, 0, -(width - bevel * 2) / 2);
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = mesh.receiveShadow = true;
return mesh;
}
// Lower-body outline with wheel arches cut around the wheel centres.
function bodyOutline(front, rear, arches, radius, top) {
const shape = new THREE.Shape();
shape.moveTo(rear[0][0], rear[0][1]);
const bottom = .3;
// Underside from the rear towards the front, arching over each wheel.
for (const ax of [...arches].sort((a, b) => a - b)) {
shape.lineTo(ax - radius, bottom);
shape.absarc(ax, .36, radius, Math.PI + .14, -.14, true);
}
for (const [x, y] of front) shape.lineTo(x, y);
for (const [x, y] of top) shape.lineTo(x, y);
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
return shape;
}
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
const tyre = mat('#151719', {roughness: .9}), rim = mat(rimColor, {metalness: .85, roughness: .25}), brake = mat('#3a3d40', {metalness: .6});
const wheels = [];
for (const [x, z] of positions) {
const wheel = new THREE.Group();
wheel.position.set(x, radius, z);
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
t.rotation.x = Math.PI / 2;
t.castShadow = true;
wheel.add(t);
const side = Math.sign(z);
const face = new THREE.Mesh(new THREE.CylinderGeometry(radius * .68, radius * .68, .02, 24), brake);
face.rotation.x = Math.PI / 2;
face.position.z = side * tyreWidth * .44;
wheel.add(face);
for (let i = 0; i < spokes; i++) {
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * .16, .04), rim);
spoke.position.z = side * tyreWidth * .5;
spoke.rotation.z = i * Math.PI / spokes;
wheel.add(spoke);
}
const hub = new THREE.Mesh(new THREE.CylinderGeometry(radius * .2, radius * .2, .05, 12), rim);
hub.rotation.x = Math.PI / 2;
hub.position.z = side * tyreWidth * .52;
wheel.add(hub);
group.add(wheel);
wheels.push(wheel);
}
return wheels;
}
/**
* Sports saloon in the style of a German executive car: long bonnet, twin kidney grille,
* ring-shaped "angel eye" headlights, kinked rear side window and five-spoke rims. 4.7 m long.
* No manufacturer badges.
*/
export function createSportSedan(color = '#1d3f78') {
const group = new THREE.Group();
const paint = mat(color, {metalness: .7, roughness: .28}), glass = mat('#0f1a22', {metalness: .7, roughness: .08});
const black = mat('#0b0c0d', {roughness: .6}), chrome = mat('#d8dcdf', {metalness: .95, roughness: .15});
const W = 1.86;
const outline = bodyOutline(
[[2.24, .3], [2.36, .42], [2.38, .62], [2.28, .78]],
[[-2.3, .3], [-2.36, .55], [-2.3, .86], [-2.12, .97]],
[-1.42, 1.43], .41,
[[1.9, .86], [.95, 1.0], [-1.2, 1.0], [-1.6, .99]]
);
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {depth: W - .2, bevelEnabled: true, bevelThickness: .1, bevelSize: .08, bevelSegments: 3, curveSegments: 10});
bodyGeometry.translate(0, 0, -(W - .2) / 2);
const body = new THREE.Mesh(bodyGeometry, paint);
body.castShadow = body.receiveShadow = true;
group.add(body);
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
const cabin = extrudeProfile([[.95, .98], [.05, 1.39], [-.9, 1.41], [-1.62, 1.0], [-1.5, .98]], 1.5, glass, .08);
group.add(cabin);
const roof = extrudeProfile([[.0, 1.37], [-.88, 1.39], [-1.0, 1.32], [.12, 1.32]], 1.36, paint, .06);
roof.position.y = .045;
group.add(roof);
const pillar = new THREE.ShapeGeometry(new THREE.Shape([new THREE.Vector2(-1.05, 1.0), new THREE.Vector2(-1.55, 1.0), new THREE.Vector2(-1.08, 1.33), new THREE.Vector2(-.96, 1.33), new THREE.Vector2(-1.18, 1.12)]));
const pillarPaint = paint.clone();
pillarPaint.side = THREE.DoubleSide;
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
for (const side of [-1, 1]) {
const z = side * .72;
const b = new THREE.Mesh(new THREE.BoxGeometry(.09, .4, .04), black);
b.position.set(-.35, 1.18, z);
group.add(b);
const c = new THREE.Mesh(pillar, pillarPaint);
c.position.z = z + side * .035;
group.add(c);
// Door handles, mirror and the chrome window line.
for (const x of [.15, -.75]) {
const handle = new THREE.Mesh(new THREE.BoxGeometry(.18, .04, .03), chrome);
handle.position.set(x, .9, side * .94);
group.add(handle);
}
const mirror = new THREE.Mesh(new THREE.BoxGeometry(.18, .12, .2), paint);
mirror.position.set(.78, 1.05, side * 1.0);
group.add(mirror);
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, .025, .02), chrome);
trim.position.set(-.3, .985, side * .76);
group.add(trim);
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, .08, .04), black);
skirt.position.set(0, .34, side * .93);
group.add(skirt);
}
// Twin kidney grille with chrome surrounds.
for (const side of [-1, 1]) {
const surround = new THREE.Mesh(new THREE.BoxGeometry(.06, .2, .3), chrome);
surround.position.set(2.46, .66, side * .18);
group.add(surround);
const grille = new THREE.Mesh(new THREE.BoxGeometry(.07, .16, .25), black);
grille.position.set(2.47, .66, side * .18);
group.add(grille);
for (let i = 0; i < 5; i++) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(.075, .15, .012), chrome);
bar.position.set(2.47, .66, side * .18 + (i - 2) * .05);
group.add(bar);
}
}
// Headlights with two light rings each, and slim L-shaped tail lights.
const ring = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.6});
const tail = mat('#9e1410', {emissive: '#e0261c', emissiveIntensity: .9});
for (const side of [-1, 1]) {
const housing = new THREE.Mesh(new THREE.BoxGeometry(.12, .14, .44), black);
housing.position.set(2.42, .72, side * .6);
group.add(housing);
for (const dz of [-.1, .1]) {
const eye = new THREE.Mesh(new THREE.TorusGeometry(.05, .012, 6, 18), ring);
eye.rotation.y = Math.PI / 2;
eye.position.set(2.48, .72, side * .6 + dz);
group.add(eye);
}
const tl = new THREE.Mesh(new THREE.BoxGeometry(.06, .1, .42), tail);
tl.position.set(-2.45, .82, side * .62);
group.add(tl);
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(.3, .1, .05), tail);
tlSide.position.set(-2.2, .82, side * .9);
group.add(tlSide);
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .12, 12), chrome);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.set(-2.44, .36, side * .45);
group.add(exhaust);
}
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(.05, .12, 1.2), black);
lowerGrille.position.set(2.45, .42, 0);
group.add(lowerGrille);
const plate = new THREE.Mesh(new THREE.BoxGeometry(.03, .11, .5), mat('#f4f4f0'));
plate.position.set(2.47, .5, 0);
group.add(plate);
const wheels = addWheels(group, [[-1.42, -.82], [-1.42, .82], [1.43, -.82], [1.43, .82]], .36, .26, '#b8bcc0', 5);
return {group, wheels};
}
/**
* Double-cab pickup in the style of the KMC T8: tall square bonnet, wide chrome grille, a cargo
* bed behind the cab. 5.3 m long.
*/
export function createPickup(color = '#e8e8e4') {
const group = new THREE.Group();
const paint = mat(color, {metalness: .5, roughness: .32}), glass = mat('#13202a', {metalness: .6, roughness: .1});
const black = mat('#1a1c1e', {roughness: .7}), chrome = mat('#d0d4d7', {metalness: .9, roughness: .2});
const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
group.add(extrudeProfile([[2.62, .5], [2.66, .95], [2.5, 1.18], [1.3, 1.24], [1.3, .5]], 1.9, paint, .1));
group.add(extrudeProfile([[1.3, .5], [1.3, 1.24], [.7, 1.86], [-.95, 1.88], [-1.0, .5]], 1.88, paint, .1));
group.add(extrudeProfile([[1.24, 1.26], [.68, 1.82], [-.9, 1.84], [-.94, 1.26]], 1.92, glass, .02));
// Cargo bed: floor, sides and tailgate.
box(1.62, .1, 1.8, black, -1.85, .72, 0);
for (const side of [-1, 1]) box(1.66, .5, .07, paint, -1.85, .95, side * .9);
box(.07, .5, 1.86, paint, -2.66, .95, 0);
box(1.66, .46, 1.9, paint, -1.85, .5, 0);
box(.3, .06, 1.86, black, -1.02, 1.26, 0);
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
box(.06, .34, 1.1, black, 2.67, .92, 0);
for (let i = 0; i < 3; i++) box(.07, .04, 1.12, chrome, 2.68, .82 + i * .1, 0);
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.2});
for (const side of [-1, 1]) {
box(.08, .14, .32, lamp, 2.62, 1.02, side * .74);
box(.06, .26, .1, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .7}), -2.7, 1.0, side * .86);
box(.2, .14, .2, black, 1.2, 1.36, side * 1.02);
box(1.0, .05, .06, chrome, -.1, 1.25, side * .97);
for (const x of [.6, -.4]) box(.16, .04, .03, chrome, x, 1.12, side * .97);
}
box(.16, .2, 2.0, black, 2.7, .55, 0);
box(.16, .2, 2.0, black, -2.72, .55, 0);
box(.04, .12, .5, mat('#f4f4f0'), 2.79, .6, 0);
const wheels = addWheels(group, [[-1.7, -.86], [-1.7, .86], [1.7, -.86], [1.7, .86]], .4, .3, '#a9adb1', 6);
for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, .1, .2, black, x, .86, side * .96);
return {group, wheels};
}
/**
* Flatbed delivery lorry with side rails. `bed` is a group on the load floor (local +X towards the
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
*/
export function createTruck(color = '#e9eef0') {
const group = new THREE.Group();
const paint = mat(color, {metalness: .35, roughness: .4}), glass = mat('#14212b', {metalness: .6, roughness: .1});
const black = mat('#1b1d1f', {roughness: .7}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
const box = (w, h, d, m, x, y, z, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
};
// Cab over the front axle.
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
box(.05, .9, 2.0, glass, 3.91, 2.35, 0);
for (const side of [-1, 1]) box(1.0, .7, .04, glass, 3.1, 2.4, side * 1.16);
box(.08, .3, 2.2, teal, 3.92, 1.35, 0);
box(.25, .35, 2.36, black, 3.95, .85, 0);
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1});
for (const side of [-1, 1]) {
box(.06, .16, .3, lamp, 3.98, 1.1, side * .9);
box(.3, .3, .08, black, 3.4, 2.7, side * 1.25);
}
// Chassis and the flatbed with low side rails.
box(7.6, .3, 1.0, black, 0, .75, 0);
box(5.9, .14, 2.4, mat('#6b5a45', {roughness: .9}), -1.05, 1.0, 0);
for (const side of [-1, 1]) box(5.9, .5, .06, teal, -1.05, 1.32, side * 1.19);
box(.06, .5, 2.4, teal, -4.0, 1.32, 0);
box(.1, 1.6, 2.4, black, 1.9, 1.85, 0);
for (const side of [-1, 1]) box(.1, .22, .26, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .6}), -4.02, .82, side * .95);
const bed = new THREE.Group();
bed.position.set(-1.05, 1.07, 0);
group.add(bed);
const wheels = addWheels(group, [[-2.9, -1.0], [-2.9, 1.0], [2.9, -1.0], [2.9, 1.0]], .5, .38, '#c2c6c9', 8);
return {group, wheels, bed};
}
+579 -76
View File
@@ -1,6 +1,8 @@
import * as THREE from 'three';
import {createVendingMachine} from './vending.js';
import {createCarModel} from './traffic.js';
import {createPickup, createTruck} from './car-models.js';
import {createPerson, createCrew, nameTag} from './staff.js';
import {FACTORY} from './locations.js';
import {textTexture} from './textures.js';
@@ -11,47 +13,120 @@ function stripes(c, w, h, base, line, every) {
for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * .35, h);
}
// Persian sign: white text on the company's dark teal.
const sign = (text, sub = '') => textTexture(512, 160, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillRect(0, h - 12, w, 12);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(text, w / 2, sub ? 72 : 100);
if (sub) {
c.fillStyle = '#9fe9da';
c.font = '36px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, w / 2, 128);
}
});
// Monitor contents: a few lines of code (or rows) on a dark editor background.
const screen = (lines, background = '#14181f') => textTexture(512, 300, (c, w, h) => {
c.fillStyle = background;
c.fillRect(0, 0, w, h);
c.fillStyle = '#232a35';
c.fillRect(0, 0, w, 26);
c.font = '21px monospace';
lines.forEach(([text, color], i) => {
c.fillStyle = color;
c.fillText(text, 16, 58 + i * 29);
});
});
const CODE = {
react: screen([['export function Shelf({items}) {', '#c792ea'], [' const [slot, setSlot] = useState();', '#82aaff'], [' return (', '#eeffff'], [' <Grid cols={4}>', '#f07178'], [' {items.map(i => <Item {...i} />)}', '#c3e88d'], [' </Grid>', '#f07178'], [' );', '#eeffff'], ['}', '#c792ea']]),
go: screen([['func (s *Server) Pay(w http.Res', '#82aaff'], [' tx, err := s.bank.Charge(ctx, req)', '#eeffff'], [' if err != nil {', '#c792ea'], [' http.Error(w, err.Error(), 402)', '#f07178'], [' return', '#c792ea'], [' }', '#c792ea'], [' s.events <- Sale{tx.ID, req.Slot}', '#c3e88d'], ['}', '#82aaff']]),
cpp: screen([['void Motor::dispense(int slot) {', '#82aaff'], [' auto& coil = coils_[slot];', '#eeffff'], [' coil.rotate(360_deg);', '#c3e88d'], [' while (!sensor_.dropped())', '#c792ea'], [' std::this_thread::yield();', '#eeffff'], [' fridge_.hold(4.0_C);', '#f78c6c'], [' log("slot", slot, "ok");', '#c3e88d'], ['}', '#82aaff']]),
terminal: screen([['$ go test ./...', '#c3e88d'], ['ok payments 0.42s', '#eeffff'], ['$ cmake --build build', '#c3e88d'], ['[100%] Built target firmware', '#eeffff'], ['$ npm run build', '#c3e88d'], ['✓ built in 2.1s', '#eeffff'], ['$ git push origin main', '#c3e88d'], ['', '#fff']], '#0c0f12'),
sheet: screen([['فاکتور دستگاه مبلغ', '#1d3b2f'], ['۱۰۴۲ بوستان ملت ۴۸۰', '#333'], ['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'], ['۱۰۴۴ مترو آزادی ۲۹۰', '#333'], ['۱۰۴۵ مشهد مال ۶۱۰', '#333'], ['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'], ['جمع ۱۸۸۰', '#0b8f88']], '#f4f7f2'),
dashboard: screen([['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'], ['تولید هفته: ۴۶ دستگاه', '#f2c94c'], ['ارسال شده: ۳۸', '#eeffff'], ['', '#fff'], ['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'], ['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'], ['▇▇▇▇▇▇▇ رنگ', '#82aaff']], '#101820')
};
/**
* The Robot Market factory compound: production hall (where the game starts),
* office, fenced yard and gate. Layout uses a local frame: u runs along the driveway towards the
* gate, v across it. Returns world-space collision footprints and key positions.
* The Robot Market factory compound: production hall with five work areas and forty workers, an
* office building (engineering, accounting and the manager's room), a loading truck, the manager's
* arrival in his pickup, fenced yard and gate. Layout uses a local frame: u runs along the driveway
* towards the gate, v across it. Returns world-space collision footprints, key positions and the
* people the family can talk to.
*/
export function createFactory(scene) {
const {center, axis} = FACTORY, normal = {x: -axis.z, z: axis.x};
const rot = Math.atan2(-axis.z, axis.x);
const toWorld = (u, v) => ({x: center.x + axis.x * u + normal.x * v, z: center.z + axis.z * u + normal.z * v});
// A heading in the local frame (u, v direction) as a world-space car heading.
const worldHeading = (du, dv) => {
const x = axis.x * du + normal.x * dv, z = axis.z * du + normal.z * dv;
return Math.atan2(-z, x);
};
const group = new THREE.Group();
group.position.set(center.x, 0, center.z);
group.rotation.y = rot;
scene.add(group);
const footprints = [];
const solid = (u0, u1, v0, v1) => footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
// Axis-aligned box in the local frame; solid ones also block movement.
const box = (u0, u1, v0, v1, y0, y1, material, solid = true, parent = group) => {
const box = (a0, a1, b0, b1, y0, y1, material, isSolid = true, parent = group) => {
const u0 = Math.min(a0, a1), u1 = Math.max(a0, a1), v0 = Math.min(b0, b1), v1 = Math.max(b0, b1);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
if (solid) footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
if (isSolid) solid(u0, u1, v0, v1);
return mesh;
};
const cylinder = (r, h, material, u, y, v, parent = group) => {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 16), material);
mesh.position.set(u, y, v);
mesh.castShadow = true;
parent.add(mesh);
return mesh;
};
// A sign plane; `facing` is the local direction its front faces: 1 = +v, -1 = −v.
const panel = (texture, w, h, u, y, v, facing = 1) => {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({map: texture, side: THREE.DoubleSide}));
mesh.position.set(u, y, v);
if (facing < 0) mesh.rotation.y = Math.PI;
group.add(mesh);
return mesh;
};
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
const floorMat = (color, extra = {}) => mat(color, {polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32, ...extra});
const flat = (u0, u1, v0, v1, y, material) => {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, v1 - v0), material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set((u0 + u1) / 2, y, (v0 + v1) / 2);
mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
const steel = mat('#59646b', {metalness: .5}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
const yellow = mat('#f2c94c', {roughness: .5}), darkSteel = mat('#3d4549', {metalness: .5}), silver = mat('#c4ccd0', {metalness: .8, roughness: .3});
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
const animated = [];
// Yard: asphalt, painted driveway, and the concrete fence with a gate onto Kaleh Street.
const yard = new THREE.Mesh(new THREE.PlaneGeometry(190, 100), mat('#8f8c86', {roughness: .95, polygonOffset: true, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
yard.rotation.x = -Math.PI / 2;
yard.position.set(-15, .08, 0);
yard.receiveShadow = true;
group.add(yard);
// ---------- Yard, fence, gate ----------
flat(-110, 80, -50, 50, .08, floorMat('#8f8c86', {roughness: .95, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
const fence = mat('#d9d3c4');
box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
box(79.7, 80.3, -50, -9, 0, 2.4, fence);
box(79.7, 80.3, 9, 50, 0, 2.4, fence);
const teal = mat('#0b8f88', {metalness: .3, roughness: .4}), white = mat('#eef4f2', {roughness: .5});
for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
// Painted parking bays in front of the office.
const paint = floorMat('#f1efe6', {roughness: .9, polygonOffsetFactor: -18, polygonOffsetUnits: -36});
for (let u = 38; u <= 70; u += 4) flat(u - .06, u + .06, -21.5, -16.5, .09, paint);
// Gate sign with the company name.
const nameSign = textTexture(1024, 320, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
@@ -70,21 +145,28 @@ export function createFactory(scene) {
c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
});
const signMaterial = new THREE.MeshBasicMaterial({map: nameSign, side: THREE.DoubleSide});
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), signMaterial);
gateSign.position.set(82, 6.6, -20);
gateSign.rotation.y = Math.PI / 2;
group.add(gateSign);
for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, mat('#59646b', {metalness: .5}), false);
// Two faces, so the name also reads correctly from inside the yard.
const frontSign = new THREE.MeshBasicMaterial({map: nameSign});
for (const side of [1, -1]) {
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), frontSign);
gateSign.position.set(82 + side * .02, 6.6, -20);
gateSign.rotation.y = side * Math.PI / 2;
group.add(gateSign);
}
for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, steel, false);
// Production hall: corrugated walls, teal band, a wide door facing the driveway.
// ---------- Production hall ----------
// Corrugated walls, a wide door onto the yard (v = V0) and a loading dock door at the east end.
const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
corrugated.repeat.set(12, 2);
corrugated.repeat.set(18, 2);
const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
const H = 10, U0 = -62, U1 = 2, V0 = 15, V1 = 45, D0 = -20, D1 = -4;
const H = 10, U0 = -104, U1 = 0, V0 = 10, V1 = 42, D0 = -36, D1 = -20, DOCK0 = 27, DOCK1 = 35;
box(U0, U1, V1, V1 + .4, 0, H, wall);
box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
box(U1, U1 + .4, V0, V1 + .4, 0, H, wall);
box(U1, U1 + .4, V0 - .4, DOCK0, 0, H, wall);
box(U1, U1 + .4, DOCK1, V1 + .4, 0, H, wall);
box(U1, U1 + .4, DOCK0, DOCK1, 5, H, wall, false);
box(U0 - .4, D0, V0 - .4, V0, 0, H, wall);
box(D1, U1 + .4, V0 - .4, V0, 0, H, wall);
box(D0, D1, V0 - .4, V0, 7, H, wall, false);
@@ -92,43 +174,108 @@ export function createFactory(scene) {
const roof = box(U0 - 1, U1 + 1, V0 - 1, V1 + 1, H, H + .4, mat('#9aa6aa', {metalness: .4}), false);
// The roof stays out of the shadow map, so daylight still reaches the hall floor.
roof.castShadow = false;
const floor = new THREE.Mesh(new THREE.PlaneGeometry(U1 - U0, V1 - V0), mat('#b9c4bd', {roughness: .35, polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32}));
floor.rotation.x = -Math.PI / 2;
floor.position.set((U0 + U1) / 2, .1, (V0 + V1) / 2);
floor.receiveShadow = true;
group.add(floor);
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
for (let u = U0 + 8; u < U1; u += 12) box(u - 3, u + 3, 29, 31, H - .3, H - .1, lamp, false);
const hallSign = new THREE.Mesh(new THREE.PlaneGeometry(16, 5), signMaterial);
hallSign.position.set((D0 + D1) / 2, H + 3, V0 - .6);
hallSign.rotation.y = Math.PI;
group.add(hallSign);
for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, mat('#59646b', {metalness: .5}), false);
flat(U0, U1, V0, V1, .1, floorMat('#b9c4bd', {roughness: .35}));
// Safety walkway along the door side.
flat(U0 + 1, U1 - 1, 18.6, 19, .11, floorMat('#f2c94c', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
for (let u = U0 + 8; u < U1; u += 12) for (const v of [18, 32]) box(u - 3, u + 3, v - 1, v + 1, H - .3, H - .1, lamp, false).castShadow = false;
panel(nameSign, 16, 5, (D0 + D1) / 2, H + 3, V0 - .6, -1);
for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, steel, false);
// Work-area signs hang above the walkway, readable from the door.
const zone = (text, sub, u) => {
panel(sign(text, sub), 5.4, 1.7, u, 6.6, 21, -1);
for (const du of [-2.2, 2.2]) box(u + du - .03, u + du + .03, 20.97, 21.03, 7.45, H, steel, false);
};
zone('خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه', -93);
zone('برش لیزر', 'برش ورق و قطعات', -71);
zone('رنگ‌کاری', 'کابین رنگ پودری', -52);
zone('یخچال‌سازی', 'کمپرسور و عایق‌کاری', -31);
zone('مونتاژ', 'خط مونتاژ نهایی', -13);
zone('بسته‌بندی', 'ارسال به شهر', -3);
// Assembly line: a conveyor carrying machine frames, robot arms, and finished machines in a row.
box(-60, -36, 33, 37, 0, .9, mat('#3d4549', {metalness: .5}));
// Bending: two press brakes with a moving ram, sheet stacks behind.
const sheet = mat('#d4dadd', {metalness: .85, roughness: .25});
for (const u of [-98, -88]) {
box(u - 1.8, u + 1.8, 29.4, 30.6, 0, 1.0, mat('#4a6b80', {metalness: .4}));
for (const du of [-1.8, 1.5]) box(u + du, u + du + .3, 29.2, 30.8, 0, 2.9, mat('#4a6b80', {metalness: .4}), false);
const ram = box(u - 1.5, u + 1.5, 29.6, 30.4, 1.9, 2.5, yellow, false);
animated.push(t => ram.position.y = 2.2 + Math.max(0, Math.sin(t * 1.4 + u)) * -.6);
box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, sheet, false);
}
for (const u of [-101, -95, -90, -85]) {
box(u - 1, u + 1, 38.4, 39.6, 0, .15, mat('#8a6a45'));
for (let i = 0; i < 6; i++) box(u - .95, u + .95, 38.45, 39.55, .15 + i * .05, .19 + i * .05, sheet, false);
}
// Laser cutting: a bed with a moving gantry and glowing cutting head, plus a shear.
box(-78, -66, 26, 32, 0, .9, darkSteel);
const slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12));
flat(-77.8, -66.2, 26.2, 31.8, .91, new THREE.MeshStandardMaterial({map: slats, metalness: .6, roughness: .4}));
const gantry = new THREE.Group();
group.add(gantry);
box(-.3, .3, 25.4, 32.6, 1.6, 1.9, yellow, false, gantry);
for (const v of [25.5, 32.5]) box(-.25, .25, v - .2, v + .2, .9, 1.9, yellow, false, gantry);
const head = box(-.2, .2, -.2, .2, 1.1, 1.6, darkSteel, false, gantry);
const spark = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({color: '#ff5a3c'}));
gantry.add(spark);
animated.push(t => {
gantry.position.x = -72 + Math.sin(t * .45) * 5;
head.position.z = spark.position.z = 29 + Math.sin(t * 1.3) * 2.5;
spark.position.y = .95;
spark.scale.setScalar(.7 + Math.random() * .6);
});
box(-65, -64, 33.2, 33.8, 0, 1.4, mat('#2e3a40'));
box(-73.5, -70.5, 37.9, 39.1, 0, 1.6, mat('#4a6b80', {metalness: .4}));
// Powder painting booth: see-through panels, an overhead rail carrying panels through it.
const glass = mat('#9fd3e6', {transparent: true, opacity: .28, depthWrite: false, roughness: .1});
box(-58, -46, 36.8, 37, 0, 4.2, glass);
box(-58, -57.8, 25, 37, 0, 4.2, glass);
box(-46.2, -46, 25, 37, 0, 4.2, glass);
box(-58, -46, 25, 37, 4.2, 4.3, glass, false);
flat(-57.8, -46.2, 25.2, 36.8, .12, floorMat('#5d7f86', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
box(-61, -43, 30.9, 31.1, 3.7, 3.8, steel, false);
const hung = [];
for (let i = 0; i < 5; i++) {
const piece = box(-.6, .6, 30.97, 31.03, 1.4, 3.2, i % 2 ? teal : mat('#eef4f2'), false);
hung.push(piece);
}
animated.push(t => hung.forEach((piece, i) => piece.position.x = -60 + ((t * .5 + i * 3.4) % 17)));
// Refrigeration: work tables with insulated cabinets, compressors and copper coils.
const copper = mat('#c27a45', {metalness: .9, roughness: .3}), insulated = mat('#f2f4f3', {roughness: .5});
for (const u of [-37, -31, -25]) {
box(u - 2, u + 2, 29.2, 30.8, 0, .9, steel);
box(u - 1.6, u - .2, 29.4, 30.6, .9, 2.2, insulated, false);
cylinder(.28, .55, mat('#16191c', {metalness: .5}), u + .7, 1.18, 30);
const coil = new THREE.Mesh(new THREE.TorusGeometry(.35, .04, 6, 20), copper);
coil.position.set(u + 1.4, 1.3, 30);
coil.rotation.y = Math.PI / 2;
group.add(coil);
}
for (let i = 0; i < 4; i++) box(-35 + i * 2.1, -33.2 + i * 2.1, 38.6, 40.4, 0, 1.9, insulated);
// Assembly: conveyor carrying machine frames between robot arms, finished machines at the end.
box(-20, -6, 32.4, 34.6, 0, .9, darkSteel);
const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
for (let u = -59; u < -36; u += 1.2) {
const roller = new THREE.Mesh(new THREE.CylinderGeometry(.12, .12, 3.8, 8), rollers);
for (let u = -19.4; u < -6; u += 1.2) {
const roller = cylinder(.12, 2.2, rollers, u, .95, 33.5);
roller.rotation.x = Math.PI / 2;
roller.position.set(u, .95, 35);
group.add(roller);
}
const frames = [], frameMaterial = mat('#0b8f88', {wireframe: true});
for (let i = 0; i < 4; i++) {
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.1, 2.4, 1.8), i % 2 ? frameMaterial : teal);
frame.position.set(-58 + i * 6, 2.2, 35);
frame.position.set(-19 + i * 3.5, 2.2, 33.5);
frame.castShadow = true;
group.add(frame);
frames.push(frame);
}
const arms = [];
for (const u of [-52, -42]) {
box(u - .6, u + .6, 39, 40.2, 0, 1.2, mat('#f2c94c'));
for (const u of [-16, -10]) {
box(u - .6, u + .6, 36, 37.2, 0, 1.2, yellow);
const arm = new THREE.Group();
arm.position.set(u, 1.2, 39.6);
arm.position.set(u, 1.2, 36.6);
group.add(arm);
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), mat('#f2c94c'));
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), yellow);
upper.position.y = 1.1;
upper.castShadow = true;
const fore = new THREE.Mesh(new THREE.BoxGeometry(.28, .28, 2.4), mat('#e0b23a'));
@@ -137,59 +284,415 @@ export function createFactory(scene) {
arm.add(upper, fore);
arms.push(arm);
}
animated.push(t => {
arms.forEach((arm, i) => arm.rotation.y = Math.sin(t * .8 + i * 2) * .9);
frames.forEach((frame, i) => frame.position.x = -19.5 + ((t * .6 + i * 3.5) % 14));
});
for (let i = 0; i < 4; i++) {
const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
finished.beacon.visible = finished.ring.visible = false;
finished.group.position.set(-32 + i * 4.2, 0, 43.4);
const u = -19.2 + i * 4.2;
finished.group.position.set(u, 0, 40.6);
finished.group.rotation.y = Math.PI;
group.add(finished.group);
footprints.push([[-33 + i * 4.2, 42.6], [-31 + i * 4.2, 42.6], [-31 + i * 4.2, 44.6], [-33 + i * 4.2, 44.6]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
solid(u - 1, u + 1, 40, 41.2);
}
// Crates of Robot Market stock along the end wall.
const crate = mat('#b98b55');
for (let i = 0; i < 6; i++) box(-61, -58.6, 17 + i * 2.6, 19.2 + i * 2.6, 0, 1.2 + (i % 3) * 1.1, crate);
// Office block with the company name on its roof.
// Packing: wrapped machines and a roller conveyor out through the dock door to the truck.
const carton = textTexture(256, 512, (c, w, h) => {
c.fillStyle = '#c49a64';
c.fillRect(0, 0, w, h);
c.fillStyle = 'rgba(255,255,255,.18)';
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
c.fillStyle = '#0b8f88';
c.fillRect(0, h * .42, w, 34);
c.fillStyle = '#1d2a2a';
c.textAlign = 'center';
c.font = 'bold 34px sans-serif';
c.fillText('ROBOT', w / 2, h * .3);
c.fillText('MARKET', w / 2, h * .3 + 40);
c.font = '60px sans-serif';
c.fillText('⇧', w / 2, h * .75);
});
const cartonMat = mat('#ffffff', {map: carton, roughness: .85});
const cartonGeometry = new THREE.BoxGeometry(.95, 1.9, 1.05);
const packaged = () => {
const mesh = new THREE.Mesh(cartonGeometry, cartonMat);
mesh.castShadow = mesh.receiveShadow = true;
return mesh;
};
for (const v of [37, 38.4, 39.8]) {
const p = packaged();
p.position.set(-3.2, .95, v);
group.add(p);
solid(-3.7, -2.7, v - .55, v + .55);
}
box(-6, 1.6, 30.4, 31.6, 0, 1.0, darkSteel);
for (let u = -5.6; u < 1.6; u += .8) cylinder(.08, 1.1, rollers, u, 1.04, 31).rotation.x = Math.PI / 2;
// ---------- Production crew: 40 workers ----------
const overall = '#2f4e7a', vest = '#e8742c', paintSuit = '#eef1f2';
const W = (x, z, facing, kind = 'work', extra = {}) => ({x, z, facing, kind, suit: overall, ...extra});
const walk = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'walk', {to: {x: tx, z: tz}, ...extra});
const carry = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'carry', {to: {x: tx, z: tz}, ...extra});
const crew = [
// Bending: two at each press, four carrying sheets.
W(-98.8, 32.3, Math.PI), W(-97.2, 32.3, Math.PI), W(-88.8, 32.3, Math.PI), W(-87.2, 32.3, Math.PI, 'work', {suit: vest}),
carry(-101, 37.3, -99.5, 33.6), carry(-95, 37.3, -96.2, 33.6, {suit: vest}), carry(-90, 37.3, -90.5, 33.6), carry(-85, 37.3, -86.2, 33.6),
// Laser cutting: operators at the bed and control panel, two at the shear, three moving parts.
W(-64.5, 34.6, Math.PI), W(-68, 33.4, Math.PI), W(-75, 33.4, Math.PI, 'work', {suit: vest}),
W(-73, 37.1, 0), W(-71, 37.1, 0),
walk(-79, 35.3, -62, 35.3), carry(-68, 40.4, -62, 40.4), walk(-80, 37, -76, 40.4, {suit: vest}),
// Painting: four spraying inside the booth in white suits, three outside.
W(-55, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-51.5, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}),
W(-53, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-49, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}),
walk(-60, 23, -44, 23), carry(-59, 39.4, -45, 39.4), walk(-44.5, 26, -44.5, 37, {suit: vest}),
// Refrigeration: two at each table, three moving cabinets and coils.
W(-38, 28.4, 0), W(-36, 28.4, 0), W(-32, 28.4, 0, 'work', {suit: vest}), W(-30, 28.4, 0), W(-26, 28.4, 0), W(-24, 28.4, 0),
walk(-39, 34.4, -22, 34.4), carry(-24, 37, -38, 37), walk(-31, 32.4, -31, 36),
// Assembly and packing.
W(-18, 31, 0), W(-14.4, 31, 0), W(-11, 31, 0, 'work', {suit: vest}), W(-7.6, 31, 0),
W(-17.1, 38.4, 0), W(-12.9, 38.4, 0),
carry(-20, 24, -4, 24), carry(-3, 22.5, -3, 29)
];
const workers = createCrew(group, crew);
for (const w of crew) if (w.kind === 'work') solid(w.x - .3, w.x + .3, w.z - .3, w.z + .3);
// ---------- Office building: engineering, accounting, management ----------
// One storey, rooms side by side with their own doors onto the yard (front at v = -24).
const windows = textTexture(128, 128, (c, w, h) => {
c.fillStyle = '#e9e4d8';
c.fillRect(0, 0, w, h);
c.fillStyle = '#4c6672';
c.fillRect(18, 22, 92, 70);
c.fillRect(14, 26, 100, 64);
c.fillStyle = 'rgba(255,255,255,.25)';
c.fillRect(22, 26, 26, 62);
c.fillRect(18, 30, 28, 56);
});
windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
const officeMaterial = mat('#ffffff', {map: windows});
const office = box(8, 32, -34, -22, 0, 9.6, officeMaterial);
office.material = [officeMaterial, officeMaterial, mat('#bdb5a5'), mat('#bdb5a5'), officeMaterial, officeMaterial];
windows.repeat.set(6, 3);
const roofSign = new THREE.Mesh(new THREE.PlaneGeometry(22, 3.4), signMaterial);
roofSign.position.set(20, 11.6, -21.9);
roofSign.rotation.y = Math.PI;
group.add(roofSign);
for (const u of [11, 29]) box(u - .15, u + .15, -22.2, -21.9, 9.6, 13.2, mat('#59646b'), false);
const facade = mat('#ffffff', {map: windows}), plaster = mat('#ece8df', {roughness: .9});
const OH = 3.8, O0 = 6, O1 = 50, F = -24, B = -46;
const doors = [[14, 17], [31, 34], [43.5, 46.5]];
let from = O0;
for (const [d0, d1] of doors) {
box(from, d0, F - .3, F, 0, OH, plaster);
box(d0, d1, F - .3, F, 2.7, OH, plaster, false);
from = d1;
}
box(from, O1, F - .3, F, 0, OH, plaster);
box(O0, O1, B - .3, B, 0, OH, plaster);
box(O0 - .3, O0, B - .3, F, 0, OH, plaster);
box(O1, O1 + .3, B - .3, F, 0, OH, plaster);
box(25.85, 26.15, B, F - .3, 0, OH, plaster);
box(39.85, 40.15, B, F - .3, 0, OH, plaster);
const officeRoof = box(O0 - .5, O1 + .5, B - .8, F + .5, OH, OH + .25, mat('#bdb5a5'), false);
officeRoof.castShadow = false;
box(O0 - .5, O1 + .5, F + .4, F + .5, OH - .5, OH, teal, false);
// Windows on the front wall between the doors, and on the end walls.
for (const [u0, u1] of [[7, 13.2], [18, 30.2], [35, 42.6], [47.4, 49.4]]) {
const pane = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, 1.4), facade);
pane.position.set((u0 + u1) / 2, 1.9, F + .02);
facade.map.repeat.set(1, 1);
group.add(pane);
}
panel(nameSign, 22, 3.4, 28, OH + 2, F + .1);
for (const u of [18, 38]) box(u - .15, u + .15, F - .1, F + .05, OH, OH + 3.7, steel, false);
flat(O0, 26, B, F, .1, floorMat('#a9b4bb'));
flat(26, 40, B, F, .1, floorMat('#c8bba4'));
flat(40, O1, B, F, .1, floorMat('#7b5a44', {roughness: .5}));
for (const [u0, u1] of [[8, 24], [28, 38], [42, 48]]) box(u0, u1, -36, -34, OH - .08, OH - .02, lamp, false).castShadow = false;
panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, F + .05);
panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, F + .05);
panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, F + .05);
// Staff cars in the car park.
for (let i = 0; i < 4; i++) {
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
parked.group.position.set(36 + i * 4, 0, -18);
parked.group.rotation.y = Math.PI / 2;
group.add(parked.group);
footprints.push([[34.9 + i * 4, -20.3], [37.1 + i * 4, -20.3], [37.1 + i * 4, -15.7], [34.9 + i * 4, -15.7]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
const wood = mat('#c8a477', {roughness: .6}), black = mat('#1b1d20', {roughness: .5}), fabric = mat('#2c3238');
// Desk facing `facing` (the side the person sits on) with one or two monitors.
function desk(u, v, sitSide, screens, width = 1.8) {
box(u - width / 2, u + width / 2, v - .45, v + .45, .72, .77, wood);
for (const du of [-1, 1]) box(u + du * (width / 2 - .05) - .03, u + du * (width / 2 - .05) + .03, v - .4, v + .4, 0, .72, black, false);
screens.forEach((texture, i) => {
const du = screens.length > 1 ? (i - .5) * .78 : 0;
const mats = [black, black, black, black, black, black];
mats[sitSide > 0 ? 4 : 5] = new THREE.MeshBasicMaterial({map: texture});
const monitor = new THREE.Mesh(new THREE.BoxGeometry(.72, .44, .04), mats);
monitor.position.set(u + du, 1.12, v - sitSide * .2);
monitor.rotation.y = (i - .5) * -.25 * sitSide * (screens.length > 1 ? 1 : 0);
group.add(monitor);
box(u + du - .03, u + du + .03, v - sitSide * .22 - .03, v - sitSide * .22 + .03, .77, .9, black, false);
});
box(u - .25, u + .25, v + sitSide * .12, v + sitSide * .28, .77, .79, mat('#30353b'), false);
}
// Office chair behind a seated person; the back is on the far side from the desk.
function chair(u, v, sitSide) {
box(u - .26, u + .26, v - .26, v + .26, .4, .47, fabric, false);
box(u - .25, u + .25, v + sitSide * .26, v + sitSide * .32, .47, 1.1, fabric, false);
cylinder(.035, .4, black, u, .2, v);
box(u - .3, u + .3, v - .03, v + .03, .02, .06, black, false);
box(u - .03, u + .03, v - .3, v + .3, .02, .06, black, false);
solid(u - .35, u + .35, v - .35, v + .35);
}
const people = [], talks = [];
// A seated person at a desk; `sitSide` is +1 when they sit on the +v side facing −v.
function seat(person, u, v, sitSide, tag) {
person.root.position.set(u, 0, v);
person.root.rotation.y = sitSide > 0 ? Math.PI : 0;
group.add(person.root);
if (tag) {
tag.position.set(0, 1.78, 0);
person.root.add(tag);
}
chair(u, v, sitSide);
return person;
}
// The demo machine stands inside the hall facing the door; the family starts in front of it.
const demo = toWorld(-8, 41.5), facing = {x: -normal.x, z: -normal.z};
const car = toWorld(52, -6);
// Engineering: five developers, a whiteboard, and the office's vending machine by the door.
const developers = [
{name: 'حامد', role: 'React', u: 11, v: -31.6, look: {outfit: 'hoodie', top: '#151517', bottom: '#2d3d57'}, code: CODE.react, speaker: 'hamed',
lines: ['سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاه‌ها رو با React می‌سازم.', 'هر چی روی صفحهٔ دستگاه می‌بینین، از همین مانیتور اومده!']},
{name: 'رایان', role: 'Go', u: 16, v: -31.6, look: {outfit: 'hoodie', top: '#2f6fd6', bottom: '#33363b', skin: '#e9bf9c'}, code: CODE.go, speaker: 'ryan',
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاه‌ها رو با Go می‌نویسم.']},
{name: 'علیرضا', role: 'C++', u: 21, v: -31.6, look: {outfit: 'hoodie', top: '#1a1a1c', bottom: '#3b4a5e', hair: '#1a1410'}, code: CODE.cpp, speaker: 'alireza',
lines: ['سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم می‌کنم.']},
{name: 'رضا', role: 'C++', u: 13.5, v: -38.6, look: {outfit: 'suit', top: '#2b2f3a', bottom: '#2b2f3a', tie: '#7a1f2b', shoes: '#111'}, code: CODE.cpp, speaker: 'reza',
lines: ['سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.', 'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!']},
{name: 'محمد', role: '', u: 18.5, v: -38.6, look: {outfit: 'hoodie', top: '#6f767d', bottom: '#2a2f36', skin: '#d9a985'}, code: CODE.terminal, speaker: 'mohammad',
lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!']}
];
for (const dev of developers) {
desk(dev.u, dev.v - 1.0, 1, [dev.code, CODE.terminal]);
const person = seat(createPerson(dev.look), dev.u, dev.v, 1, nameTag(dev.name, dev.role));
people.push({person, pose: 'type'});
talks.push({speaker: dev.speaker, label: dev.name, lines: dev.lines, radius: 2.6, pos: () => toWorld(dev.u, dev.v)});
}
const board = textTexture(512, 256, (c, w, h) => {
c.fillStyle = '#fbfbf8';
c.fillRect(0, 0, w, h);
c.strokeStyle = '#9aa4a8';
c.lineWidth = 8;
c.strokeRect(0, 0, w, h);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#1d4f91';
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
c.font = '28px Vazirmatn, Tahoma, sans-serif';
c.fillStyle = '#333';
['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
});
panel(board, 4, 2, 16, 1.9, B + .05);
const officeMachine = {u: 6.8, v: -28.6};
// Accounting: four colleagues at desks, filing cabinets along the wall.
const accountants = [
{u: 29.5, v: -31.6, look: {top: '#2d3550', scarf: '#c25b7a'}},
{u: 36.5, v: -31.6, look: {top: '#1f1f22', scarf: '#3e6e8e', skin: '#edc2a0'}},
{u: 29.5, v: -38.6, look: {top: '#8a6e55', scarf: '#e3d3b9'}},
{u: 36.5, v: -38.6, look: {top: '#4a5a48', scarf: '#7a3b52', skin: '#d9a985'}}
];
for (const a of accountants) {
desk(a.u, a.v - 1.0, 1, [CODE.sheet]);
people.push({person: seat(createPerson({outfit: 'manteau', bottom: '#24262b', shoes: '#111', ...a.look}), a.u, a.v, 1), pose: 'type'});
}
for (let i = 0; i < 4; i++) box(26.2, 26.9, -44.5 + i * 1, -43.6 + i * 1, 0, 1.4, mat('#9aa3a8', {metalness: .5}));
talks.push({speaker: 'accountant', label: 'واحد حسابداری', radius: 4, pos: () => toWorld(33, -29),
lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!']});
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
desk(45, -39.7, -1, [CODE.dashboard], 2.4);
chair(45, -40.7, -1);
for (const u of [43.8, 46.2]) {
box(u - .28, u + .28, -37.1, -36.5, .42, .5, mat('#5a3a2a'), false);
box(u - .28, u + .28, -36.5, -36.4, .5, 1.0, mat('#5a3a2a'), false);
solid(u - .3, u + .3, -37.1, -36.4);
}
box(41, 49, -45.9, -45.4, 0, 2.6, mat('#6b4a33'));
for (let i = 0; i < 4; i++) for (let j = 0; j < 9; j++) box(41.3 + j * .85, 41.3 + j * .85 + .5, -45.4, -45.2, .3 + i * .6, .78 + i * .6, mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]), false);
box(49, 49.5, -27, -26.5, 0, .5, mat('#7a5a3c'), true);
const leaves = new THREE.Mesh(new THREE.SphereGeometry(.45, 10, 8), mat('#3e7a3e'));
leaves.position.set(49.25, 1.0, -26.75);
group.add(leaves);
const nameplate = panel(sign('محمدی', 'مدیر شرکت'), .6, .19, 45, .87, -39.2, 1);
nameplate.rotation.x = -.3;
// ---------- Staff car park ----------
for (let i = 0; i < 4; i++) {
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
const u = 56 + i * 4;
parked.group.position.set(u, 0, -19);
parked.group.rotation.y = Math.PI / 2;
group.add(parked.group);
solid(u - 1.1, u + 1.1, -21.3, -16.7);
}
// ---------- The manager arrives in his pickup ----------
// Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
// his room and sits at his desk.
const pickup = createPickup('#f1f1ee');
group.add(pickup.group);
const manager = createPerson({outfit: 'suit', top: '#3a3f47', bottom: '#3a3f47', hair: '#8d8a86', glasses: true, shoes: '#111'});
const managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
managerTag.position.y = 2.15;
manager.root.add(managerTag);
group.add(manager.root);
const DRIVE_IN = [[98, -3], [80, -3], [62, -5], [51, -9], [46, -16]];
const WALK_IN = [[47.4, -16.9], [45, -21], [45, -25.6], [48.5, -29], [48.5, -40.7], [45, -40.7]];
{
// The parked pickup blocks its bay.
const [ax, az] = DRIVE_IN.at(-2), [bx, bz] = DRIVE_IN.at(-1), len = Math.hypot(bx - ax, bz - az);
const du = (bx - ax) / len, dv = (bz - az) / len;
footprints.push([[2.7, 1], [2.7, -1], [-2.7, -1], [-2.7, 1]].flatMap(([a, b]) => Object.values(toWorld(bx + du * a - dv * b, bz + dv * a + du * b))));
}
const arrival = {stage: 'drive', s: 0, heading: Math.PI, sat: false};
talks.push({speaker: 'mohammadi', label: 'آقای محمدی', radius: 3.4, pos: () => toWorld(manager.root.position.x, manager.root.position.z), ready: () => arrival.stage === 'seated',
lines: ['سلام، خوش اومدین! من محمدی‌ام، مدیر روبات مارکت.', 'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.', 'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!']});
// Position and direction a distance `s` along a polyline of [u, v] points.
const along = (path, s) => {
for (let i = 1; i < path.length; i++) {
const [ax, az] = path[i - 1], [bx, bz] = path[i], len = Math.hypot(bx - ax, bz - az);
if (s <= len || i === path.length - 1) {
const t = Math.min(1, s / len);
return {u: ax + (bx - ax) * t, v: az + (bz - az) * t, du: (bx - ax) / len, dv: (bz - az) / len, done: s >= len && i === path.length - 1};
}
s -= len;
}
};
const turn = (from, to, k) => from + Math.atan2(Math.sin(to - from), Math.cos(to - from)) * k;
const placeVehicle = (vehicle, state, p, dt, reverse = false) => {
const want = reverse ? Math.atan2(p.dv, -p.du) : Math.atan2(-p.dv, p.du);
state.heading = dt ? turn(state.heading, want, Math.min(1, dt * 3)) : want;
vehicle.group.position.set(p.u, 0, p.v);
vehicle.group.rotation.y = state.heading;
};
function updateArrival(dt, time) {
if (arrival.stage === 'drive') {
arrival.s += dt * 7;
const p = along(DRIVE_IN, arrival.s);
placeVehicle(pickup, arrival, p, dt);
pickup.wheels.forEach(w => w.rotation.z -= dt * 7 / .4);
manager.root.visible = false;
if (p.done) Object.assign(arrival, {stage: 'walk', s: 0});
} else if (arrival.stage === 'walk') {
arrival.s += dt * 1.3;
const p = along(WALK_IN, arrival.s);
manager.root.visible = true;
manager.root.position.set(p.u, 0, p.v);
manager.root.rotation.y = Math.atan2(p.du, p.dv);
manager.pose('walk', time);
if (p.done) arrival.stage = 'seated';
} else if (!arrival.sat) {
arrival.sat = true;
manager.root.position.set(45, 0, -40.7);
manager.root.rotation.y = 0;
manager.pose('sit');
}
}
function resetArrival() {
Object.assign(arrival, {stage: 'drive', s: 0, sat: false});
placeVehicle(pickup, arrival, along(DRIVE_IN, 0), 0);
manager.root.visible = false;
}
resetArrival();
// ---------- Delivery truck ----------
// Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out
// of the gate to the city, and comes back empty a while later, reversing up to the dock.
const truck = createTruck();
group.add(truck.group);
const DOCK = [5.5, 31];
const DEPART = [DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
const RETURN = [[100, 0], [80, 0], [60, 6], [40, 14], [27, 18]];
const BACK_IN = [[27, 18], [16, 28.5], DOCK];
const SLOTS = [2.1, .95, -.2, -1.35].flatMap(x => [[x, -.6], [x, .6]]);
const loaded = [];
const moving = packaged();
group.add(moving);
const delivery = {stage: 'load', t: 0, s: 0, heading: 0};
const truckAtDock = () => {
truck.group.visible = true;
truck.group.position.set(DOCK[0], 0, DOCK[1]);
truck.group.rotation.y = delivery.heading = 0;
};
truckAtDock();
function updateTruck(dt) {
const d = delivery;
d.t += dt;
moving.visible = d.stage === 'load';
if (d.stage === 'load') {
// Each box: two seconds along the conveyor, one second lifted onto its slot.
const [sx, sz] = SLOTS[loaded.length], bed = truck.bed.position;
const slot = {u: DOCK[0] + bed.x + sx, y: bed.y + .95, v: DOCK[1] + sz};
if (d.t < 2) moving.position.set(-6 + (1.6 + 6) * d.t / 2, 1.05 + .95, 31);
else {
const k = Math.min(1, d.t - 2);
moving.position.set(1.6 + (slot.u - 1.6) * k, 2.0 + (slot.y - 2.0) * k + Math.sin(k * Math.PI) * .5, 31 + (slot.v - 31) * k);
}
if (d.t >= 3) {
const box = packaged();
box.position.set(sx, .95, sz);
truck.bed.add(box);
loaded.push(box);
d.t = 0;
if (loaded.length === SLOTS.length) d.stage = 'full';
}
} else if (d.stage === 'full' && d.t > 2) Object.assign(d, {stage: 'depart', s: 0});
else if (d.stage === 'depart' || d.stage === 'return' || d.stage === 'back') {
const speed = d.stage === 'back' ? 2.5 : 6;
d.s += dt * speed;
const path = {depart: DEPART, return: RETURN, back: BACK_IN}[d.stage];
const p = along(path, d.s);
placeVehicle(truck, d, p, dt, d.stage === 'back');
truck.wheels.forEach(w => w.rotation.z -= dt * speed / .5 * (d.stage === 'back' ? -1 : 1));
if (p.done) {
if (d.stage === 'depart') {
Object.assign(d, {stage: 'away', t: 0});
truck.group.visible = false;
loaded.splice(0).forEach(box => truck.bed.remove(box));
} else if (d.stage === 'return') Object.assign(d, {stage: 'back', s: 0});
else {
truckAtDock();
Object.assign(d, {stage: 'load', t: 0});
}
}
} else if (d.stage === 'away' && d.t > 18) {
truck.group.visible = true;
Object.assign(d, {stage: 'return', s: 0, heading: Math.PI});
}
}
talks.push({speaker: 'child', radius: 9, pos: () => toWorld(4, 31), once: true,
lines: ['بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!']});
// The family comments on each work area the first time they walk into it.
for (const [u, text, speaker] of [
[-93, 'اینجا خم‌کاریه؛ ورق‌های فلزی بدنهٔ دستگاه رو با پرس خم می‌کنن.', 'father'],
[-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ می‌بُره.', 'child'],
[-52, 'این کابین رنگه؛ بدنه‌ها اینجا رنگ فیروزه‌ای روبات مارکت می‌گیرن.', 'father'],
[-31, 'یخچال‌سازی! برای همینه که نوشیدنی‌های دستگاه همیشه خنکن.', 'child'],
[-13, 'خط مونتاژ: روبات‌ها و کارگرها قطعه‌ها رو کنار هم می‌ذارن و دستگاه کامل می‌شه.', 'father']
]) talks.push({speaker, radius: 8, pos: () => toWorld(u, 25), once: true, lines: [text]});
// ---------- Key positions ----------
// The family starts in the yard between the hall and the office, the car parked right beside them.
const demo = toWorld(officeMachine.u, officeMachine.v), facing = {x: axis.x, z: axis.z};
const car = toWorld(1.5, -7);
const start = toWorld(-1, -3.6), door = toWorld(15.5, -24);
return {
group,
footprints,
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
car: {x: car.x, z: car.z, heading: Math.atan2(-axis.z, axis.x)},
car: {x: car.x, z: car.z, heading: worldHeading(1, 0)},
start: {x: start.x, z: start.z, heading: Math.atan2(-(door.z - start.z), door.x - start.x)},
label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
update(time) {
arms.forEach((arm, i) => arm.rotation.y = Math.sin(time * .8 + i * 2) * .9);
frames.forEach((frame, i) => frame.position.x = -58 + ((time * .6 + i * 6) % 24));
talks,
reset: resetArrival,
// `dt` is 0 while the game is paused; distant viewers skip the people and machines.
update(time, dt, viewer) {
const near = !viewer || Math.hypot(viewer.x - center.x, viewer.z - center.z) < 260;
updateArrival(dt, time);
updateTruck(dt);
workers.group.visible = near;
if (!near) return;
for (const step of animated) step(time);
workers.update(time);
people.forEach(({person, pose}, i) => person.pose(pose, time, i * 1.7));
}
};
}
+3 -3
View File
@@ -23,11 +23,11 @@ export const FACTORY = {
export const machines = [
{
id: 'factory', name: 'دستگاه نمونهٔ کارخانه', category: 'factory',
address: `${FACTORY.address}، سالن تولید`,
id: 'factory', name: 'دستگاه دفتر فنی مهندسی', category: 'factory',
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
anchor: null, hours: [0, 24 * 60], priceFactor: .5,
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
note: 'دستگاه نمونهٔ خط تولید؛ نصف قیمت برای مهمان‌های کارخانه'
note: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه'
},
{
id: 'mellat-park', name: 'بوستان ملت', category: 'park',
+65 -28
View File
@@ -13,7 +13,7 @@ import {createImagery} from './imagery.js';
import {decodeBuildings} from './buildings-data.js';
import {createCollisionWorld, addPolygon, resolveCircle, clearFraction} from './collision.js';
import {buildGraph, routeBetween, buildSegmentIndex, nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
import {machines, CATEGORIES, START, CITY_START, FACTORY, lockedPlaces} from './locations.js';
import {machines, CATEGORIES, CITY_START, FACTORY, lockedPlaces} from './locations.js';
import {createState, catalog, priceAt, isOpen, purchaseBlocker, formatHetoman, formatClock, fa, canAffordAnything} from './game-state.js';
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from './payment.js';
import {awardsFor, rankFor, completeMissions, activeMission, MISSIONS} from './scoring.js';
@@ -21,7 +21,8 @@ import {createFamily} from './characters.js';
import {createVendingMachine} from './vending.js';
import {createFactory} from './factory.js';
import {createCar, stepCar, kmh} from './vehicle.js';
import {createCarModel, createTraffic} from './traffic.js';
import {createTraffic} from './traffic.js';
import {createSportSedan} from './car-models.js';
import {createMapAssets, drawMinimap, drawFullMap, drawIran} from './map-render.js';
import {bearing, formatDistance, toLocal} from './geo.js';
@@ -77,7 +78,16 @@ scene.add(sun, sun.target);
// The game still runs without them (OSM buildings and drawn roads).
const loadFile = (path, read) => fetch(path).then(r => r.ok ? r[read]() : null).catch(() => null);
const [buildingsFile, imageryIndex] = await Promise.all([loadFile('./data/buildings.bin', 'arrayBuffer'), loadFile('./imagery/index.json', 'json')]);
const extraBuildings = buildingsFile ? decodeBuildings(buildingsFile) : [];
// Footprints reaching into the factory compound are left out: the game's own buildings stand there.
const inFactory = (x, z) => {
const dx = x - FACTORY.center.x, dz = z - FACTORY.center.z;
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x;
return u > -112 && u < 82 && v > -52 && v < 52;
};
const extraBuildings = (buildingsFile ? decodeBuildings(buildingsFile) : []).filter(b => {
for (let i = 1; i < b.length; i += 2) if (inFactory(b[i], b[i + 1])) return false;
return true;
});
const collision = createCollisionWorld([...data.buildings, ...extraBuildings].map(b => b.slice(1)));
const segments = buildSegmentIndex(data.roads);
const walkGraph = buildGraph(data.roads), driveGraph = buildGraph(data.roads, {drivable: true});
@@ -199,21 +209,23 @@ scene.add(family.group);
const guide = createFamily({robot: true});
guide.group.visible = false;
scene.add(guide.group);
const carModel = createCarModel('#f2f2ee');
const carModel = createSportSedan('#1d3f78');
scene.add(carModel.group);
let state, payment, player, car, mode, cam, waypoint, route, routeTimer, deliveryTimer, ended;
const keys = new Set();
// The family car waits in the factory yard, pointing at the gate.
// The family car waits in the factory yard right beside them, pointing at the gate.
const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading);
function reset() {
state = createState();
payment = createPayment();
const start = stationById(START.machineId);
const spot = resolveCircle(collision, start.front.x + (start.front.x - start.x) * 3, start.front.z + (start.front.z - start.z) * 3, 1);
player = {x: spot.x, z: spot.z, heading: Math.atan2(-(start.z - spot.z), start.x - spot.x), speed: 0, gait: 0};
// The family starts in the factory yard, facing the engineering office.
const spot = resolveCircle(collision, factory.start.x, factory.start.z, 1);
player = {x: spot.x, z: spot.z, heading: factory.start.heading, speed: 0, gait: 0};
factory.reset();
for (const t of factory.talks) t.done = false;
car = spawnCar();
mode = 'walk';
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
@@ -337,6 +349,8 @@ const axis = (pos, neg) => Number(pos.some(k => keys.has(k))) - Number(neg.some(
// ---------- Subtitles ----------
const SPEAKERS = {robot: 'روبات', father: 'پدر', child: 'بچه'};
// Factory staff speak under their own names.
for (const t of factory.talks) if (t.label) SPEAKERS[t.speaker] = t.label;
let lines = [], lineTimer = 0, talking = false;
// `face` picks the robot's expression portrait (public/robot/<face>.webp).
@@ -353,7 +367,7 @@ function updateSubtitles(dt) {
talking = next?.speaker === 'robot';
if (!next) return;
$('#subtitle-speaker').textContent = `${SPEAKERS[next.speaker]}:`;
$('#subtitle-speaker').className = next.speaker;
$('#subtitle-speaker').className = ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff';
$('#subtitle-text').textContent = next.text;
$('#subtitle-face').hidden = next.speaker !== 'robot';
if (next.speaker === 'robot') $('#subtitle-face').src = `./robot/${next.face}.webp`;
@@ -903,7 +917,9 @@ function startMission() {
$('#briefing').hidden = true;
$('#hud').hidden = false;
say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟');
say('father', 'آره! بیا جلوی دستگاه نمونه وایسیم ببینیم چی می‌شه.');
say('father', 'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!');
say('child', 'اون وانت سفیده کیه داره میاد تو؟');
say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {});
}
@@ -1088,17 +1104,22 @@ function updateDrive(dt) {
if (debugHits.length > 200) debugHits.splice(0, 100);
const throttle = THREE.MathUtils.clamp(axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, -1, 1);
const steer = THREE.MathUtils.clamp(axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, -1, 1);
stepCar(car, {throttle, steer, handbrake: keys.has(' ')}, dt);
// Two collision circles along the body keep the nose and tail out of buildings.
for (const along of [1.3, -1.3]) {
const cx = car.x + Math.cos(car.heading) * along, cz = car.z - Math.sin(car.heading) * along;
const p = resolveCircle(collision, cx, cz, 1.05);
if (p.hit) {
debugHits.push({kind: 'building', x: Math.round(cx), z: Math.round(cz), v: +car.speed.toFixed(1)});
car.x += p.x - cx;
car.z += p.z - cz;
if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square');
car.speed *= .35;
// At top speed the car covers almost 2 m per step, so move it in short slices that can't
// tunnel through a thin wall or fence.
const slices = Math.max(1, Math.ceil(Math.abs(car.speed) * dt / .6));
for (let i = 0; i < slices; i++) {
stepCar(car, {throttle, steer, handbrake: keys.has(' ')}, dt / slices);
// Two collision circles along the body keep the nose and tail out of buildings.
for (const along of [1.3, -1.3]) {
const cx = car.x + Math.cos(car.heading) * along, cz = car.z - Math.sin(car.heading) * along;
const p = resolveCircle(collision, cx, cz, 1.05);
if (p.hit) {
debugHits.push({kind: 'building', x: Math.round(cx), z: Math.round(cz), v: +car.speed.toFixed(1)});
car.x += p.x - cx;
car.z += p.z - cz;
if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square');
car.speed *= .35;
}
}
}
// Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
@@ -1156,7 +1177,7 @@ function updateCamera(dt) {
let distance = cam.distance;
const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch), oz = Math.cos(cam.yaw) * Math.cos(cam.pitch);
const free = clearFraction(collision, focus.x, focus.z, focus.x + ox * distance, focus.z + oz * distance, .8);
if (cam.pitch < .9) distance = Math.max(1.6, distance * free - .4);
if (cam.pitch < .9) distance = Math.max(1.6, distance * free - .7);
camTarget.set(focus.x + ox * distance, Math.max(.6, focus.y + Math.sin(cam.pitch) * distance), focus.z + oz * distance);
lookAt.lerp(focus, 1 - Math.exp(-dt * 14));
}
@@ -1212,6 +1233,19 @@ function updateHUD(dt) {
let hints = {closed: false, car: false};
// People in the factory say their lines once, when the family walks up to them.
function updateTalks() {
if (mode !== 'walk' || real.on || lines.length > 2) return;
for (const t of factory.talks) {
if (t.done || (t.ready && !t.ready())) continue;
const p = t.pos();
if (Math.hypot(p.x - player.x, p.z - player.z) > t.radius) continue;
t.done = true;
for (const text of t.lines) say(t.speaker, text);
return;
}
}
// ---------- Robot guide ----------
// When the family walks up to a machine the robot pops out of it, greets them and explains what to
// do; when they leave it goes back inside. It is never a walking companion.
@@ -1223,10 +1257,10 @@ function greet(station) {
const def = station.def, first = !guideState.greeted.has(def.id);
guideState.greeted.add(def.id);
if (def.id === 'factory' && first) {
say('robot', 'سلام! من روبات راهنمای روبات مارکتم؛ همین الان از خط تولید اومدم بیرون!', 'excited');
say('robot', `به ${FACTORY.name} خوش اومدین. همهٔ دستگاه‌های شهر همین‌جا ساخته می‌شن.`, 'happy');
say('robot', 'اول از همین دستگاه نمونه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
say('robot', 'بعد ماشینتون تو حیاطه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', 'love');
say('robot', 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!', 'excited');
say('robot', `به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, 'happy');
say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
say('robot', 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', 'love');
return;
}
if (!isOpen(def, state.minutes)) {
@@ -1313,6 +1347,7 @@ function frame() {
}
updateRoute(dt);
updateHints();
updateTalks();
updateGuide(dt);
if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd();
} else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .1);
@@ -1323,7 +1358,7 @@ function frame() {
family.group.position.set(player.x, 0, player.z);
family.group.rotation.y = player.heading;
family.update({dt, time: elapsed, gait: player.gait, moving: player.speed > .4, shopping: state.busy || payment.phase !== 'idle', delivery: deliveryTimer > 0});
factory.update(elapsed);
factory.update(elapsed, paused ? 0 : dt, camera.position);
carModel.group.position.set(car.x, 0, car.z);
carModel.group.rotation.y = car.heading;
for (const [i, wheel] of carModel.wheels.entries()) {
@@ -1381,7 +1416,9 @@ if (new URLSearchParams(location.search).has('debug')) window.game = {
return state;
}, get player() {
return player;
}, get car() {
}, get cam() {
return cam;
}, factory, get car() {
return car;
}, get mode() {
return mode;
+1 -1
View File
@@ -39,7 +39,7 @@ const placesVisited = purchases => new Set(purchases.map(p => p.machineId)).size
export const MISSIONS = [
{
id: 'factory', title: 'در سالن تولید از دستگاه نمونهٔ کارخانه خرید کن', target: 'factory', xp: 100, hetoman: 20,
id: 'factory', title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن', target: 'factory', xp: 100, hetoman: 20,
done: ps => ps.some(p => p.machineId === 'factory')
},
{
+250
View File
@@ -0,0 +1,250 @@
import * as THREE from 'three';
import {textTexture} from './textures.js';
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
// as a handful of instanced meshes, so forty of them cost only a few draw calls.
// People face their local +Z; `root.rotation.y` turns them.
const materials = new Map();
const mat = (color, extra = {}) => {
const key = color + JSON.stringify(extra);
if (!materials.has(key)) materials.set(key, new THREE.MeshStandardMaterial({color, roughness: .8, ...extra}));
return materials.get(key);
};
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z);
mesh.castShadow = true;
parent.add(mesh);
return mesh;
}
/**
* outfit: 'hoodie' | 'suit' | 'manteau'. Colours: top (hoodie, jacket or manteau), bottom, skin,
* hair, scarf (manteau only), tie (suit only, optional), glasses.
*/
export function createPerson({outfit = 'hoodie', top = '#222', bottom = '#2c3e5a', skin = '#e2b18e', hair = '#2a211c', scarf = '#3b3f6b', tie = null, glasses = false, shoes = '#f1f1f1'} = {}) {
const root = new THREE.Group();
const pelvis = new THREE.Group();
pelvis.position.y = .94;
root.add(pelvis);
const topMat = mat(top), bottomMat = mat(bottom), skinMat = mat(skin), hairMat = mat(hair);
const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
// Torso, neck and head.
const torso = new THREE.Group();
pelvis.add(torso);
const long = outfit === 'manteau';
part(torso, new THREE.CylinderGeometry(.2, .17, .6, 12), topMat, 0, .3, 0).scale.z = .65;
if (long) part(torso, new THREE.CylinderGeometry(.18, .2, .3, 12), topMat, 0, -.08, 0).scale.z = .7;
part(torso, new THREE.CylinderGeometry(.05, .055, .1, 10), skinMat, 0, .64, 0);
if (outfit === 'hoodie') {
// Hood folded behind the neck, front pocket and drawstrings.
part(torso, new THREE.SphereGeometry(.13, 12, 8), topMat, 0, .58, -.1).scale.set(1.2, .6, .7);
part(torso, new THREE.BoxGeometry(.24, .12, .02), topMat, 0, .12, .125);
for (const side of [-1, 1]) part(torso, new THREE.CylinderGeometry(.006, .006, .16, 4), mat('#f2f2f2'), side * .04, .48, .13);
} else if (outfit === 'suit') {
// White shirt opening, optional tie, lapels.
const shirt = part(torso, new THREE.PlaneGeometry(.12, .2), mat('#f4f4f2', {side: THREE.DoubleSide}), 0, .49, .133);
shirt.rotation.x = -.12;
if (tie) part(torso, new THREE.BoxGeometry(.035, .22, .01), mat(tie), 0, .45, .138);
for (const side of [-1, 1]) {
const lapel = part(torso, new THREE.BoxGeometry(.05, .22, .01), mat(top, {roughness: .5}), side * .07, .47, .132);
lapel.rotation.z = side * .35;
}
}
const head = new THREE.Group();
head.position.y = .8;
torso.add(head);
part(head, new THREE.SphereGeometry(.11, 16, 12), skinMat).scale.set(.92, 1.05, 1);
part(head, new THREE.SphereGeometry(.02, 6, 4), skinMat, 0, -.01, .105);
for (const side of [-1, 1]) part(head, new THREE.SphereGeometry(.012, 6, 4), mat('#1b1512'), side * .038, .02, .098);
if (long) {
// Headscarf: covers the hair and the neck, leaving the face open.
part(head, new THREE.SphereGeometry(.128, 16, 12), mat(scarf), 0, .012, -.022).scale.set(1, 1.05, 1);
part(head, new THREE.CylinderGeometry(.09, .17, .2, 14), mat(scarf), 0, -.13, -.01).scale.z = .75;
part(head, new THREE.TorusGeometry(.105, .018, 6, 20, Math.PI * 1.2), mat(scarf), 0, .01, .04).rotation.set(0, 0, -.1 * Math.PI);
} else {
part(head, new THREE.SphereGeometry(.115, 14, 10), hairMat, 0, .035, -.015).scale.set(1, .75, 1);
}
if (glasses) {
const frame = mat('#222', {metalness: .6, roughness: .3});
for (const side of [-1, 1]) part(head, new THREE.TorusGeometry(.024, .004, 4, 14), frame, side * .04, .02, .11);
part(head, new THREE.BoxGeometry(.03, .004, .004), frame, 0, .022, .11);
}
// Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
const arms = [-1, 1].map(side => {
const shoulder = new THREE.Group();
shoulder.position.set(side * .23, .55, 0);
torso.add(shoulder);
part(shoulder, new THREE.CapsuleGeometry(.05, .2, 4, 8), topMat, 0, -.14, 0);
const elbow = new THREE.Group();
elbow.position.y = -.29;
shoulder.add(elbow);
part(elbow, new THREE.CapsuleGeometry(.043, .18, 4, 8), topMat, 0, -.12, 0);
part(elbow, new THREE.SphereGeometry(.045, 8, 6), skinMat, 0, -.27, 0);
return {shoulder, elbow};
});
// Legs: hip → knee → foot.
const legs = [-1, 1].map(side => {
const hip = new THREE.Group();
hip.position.set(side * .095, 0, 0);
pelvis.add(hip);
part(hip, new THREE.CapsuleGeometry(.07, .3, 4, 8), long ? topMat : bottomMat, 0, -.22, 0);
const knee = new THREE.Group();
knee.position.y = -.45;
hip.add(knee);
part(knee, new THREE.CapsuleGeometry(.058, .32, 4, 8), bottomMat, 0, -.22, 0);
part(knee, new THREE.BoxGeometry(.1, .07, .24), shoeMat, 0, -.45, .05);
return {hip, knee};
});
const person = {
root, head,
// pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
pose(kind, t = 0, phase = 0) {
const sitting = kind === 'sit' || kind === 'type';
pelvis.position.y = sitting ? .5 : .94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * .02 : 0);
torso.rotation.x = kind === 'type' ? .18 : sitting ? -.05 : 0;
head.rotation.y = kind === 'type' ? Math.sin(t * .4 + phase) * .15 : Math.sin(t * .6 + phase) * .2;
head.rotation.x = kind === 'type' ? .12 : 0;
legs.forEach(({hip, knee}, i) => {
const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * .45 : 0;
hip.rotation.x = sitting ? -Math.PI / 2 : swing;
knee.rotation.x = sitting ? Math.PI / 2 : kind === 'walk' ? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * .5 : 0;
});
arms.forEach(({shoulder, elbow}, i) => {
if (kind === 'type') {
// Hands on the keyboard, fingers busy.
shoulder.rotation.set(-.7 + Math.sin(t * 13 + phase + i * 2) * .025, 0, (i ? 1 : -1) * -.12);
elbow.rotation.x = -1.15;
} else if (kind === 'sit') {
shoulder.rotation.set(-.3, 0, 0);
elbow.rotation.x = -1.0;
} else {
const swing = kind === 'walk' ? -Math.sin(t * 6 + phase + i * Math.PI) * .4 : Math.sin(t * 1.2 + phase + i) * .03;
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * .06);
elbow.rotation.x = kind === 'walk' ? -.25 : -.1;
}
});
}
};
person.pose('stand');
return person;
}
// Floating name badge above a person's head.
export function nameTag(name, role = '') {
const texture = textTexture(512, 160, (c, w, h) => {
c.clearRect(0, 0, w, h);
c.fillStyle = 'rgba(8, 24, 28, .82)';
c.beginPath();
c.roundRect(6, 6, w - 12, h - 12, 26);
c.fill();
c.fillStyle = '#3ee0c9';
c.fillRect(6, h - 18, w - 12, 8);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(name, w / 2, role ? 72 : 100);
if (role) {
c.fillStyle = '#9fe9da';
c.font = '40px Vazirmatn, Tahoma, sans-serif';
c.direction = 'ltr';
c.fillText(role, w / 2, 126);
}
});
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({map: texture, transparent: true, depthWrite: false}));
sprite.scale.set(1.3, .41, 1);
return sprite;
}
/**
* Production workers as instanced meshes, in the parent's local frame. Each worker:
* {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}.
*/
export function createCrew(parent, workers) {
const n = workers.length, group = new THREE.Group();
parent.add(group);
const instanced = (geometry, material, count = n) => {
const mesh = new THREE.InstancedMesh(geometry, material, count);
mesh.castShadow = true;
mesh.frustumCulled = false;
group.add(mesh);
return mesh;
};
const white = new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8});
const torso = instanced(new THREE.BoxGeometry(.42, .6, .26), white);
const legs = instanced(new THREE.BoxGeometry(.15, .9, .17).translate(0, -.45, 0), white, n * 2);
const arms = instanced(new THREE.BoxGeometry(.11, .62, .11).translate(0, -.31, 0), white, n * 2);
const heads = instanced(new THREE.SphereGeometry(.12, 10, 8), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .8}));
const hats = instanced(new THREE.SphereGeometry(.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .4}));
const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map(c => new THREE.Color(c));
const color = new THREE.Color();
workers.forEach((w, i) => {
color.set(w.suit);
torso.setColorAt(i, color);
color.set(w.pants ?? '#2d3a4d');
legs.setColorAt(i * 2, color);
legs.setColorAt(i * 2 + 1, color);
color.set(w.suit);
arms.setColorAt(i * 2, color);
arms.setColorAt(i * 2 + 1, color);
heads.setColorAt(i, skins[i % skins.length]);
color.set(w.hat ?? '#f2c94c');
hats.setColorAt(i, color);
w.phase = i * 1.37;
w.from = {x: w.x, z: w.z};
});
const base = new THREE.Matrix4(), m = new THREE.Matrix4(), local = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0);
const setLimb = (mesh, index, x, y, angle) => {
local.makeRotationX(angle).setPosition(x, y, 0);
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
};
function update(time) {
workers.forEach((w, i) => {
let x = w.x, z = w.z, facing = w.facing, walking = false;
if (w.kind === 'walk' || w.kind === 'carry') {
// Walk between two points, pausing at each end.
const dx = w.to.x - w.from.x, dz = w.to.z - w.from.z, len = Math.hypot(dx, dz);
const cycle = len / 1.2 * 2 + 6, c = (time + w.phase * 3) % cycle, leg = len / 1.2;
let s;
if (c < leg) s = c / leg, walking = true, facing = Math.atan2(dx, dz);
else if (c < leg + 3) s = 1, facing = Math.atan2(dx, dz);
else if (c < leg * 2 + 3) s = 1 - (c - leg - 3) / leg, walking = true, facing = Math.atan2(-dx, -dz);
else s = 0, facing = Math.atan2(-dx, -dz);
x = w.from.x + dx * s;
z = w.from.z + dz * s;
}
const t = time + w.phase;
base.compose(new THREE.Vector3(x, walking ? Math.abs(Math.sin(t * 6)) * .03 : 0, z), q.setFromAxisAngle(up, facing), new THREE.Vector3(1, 1, 1));
m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
torso.setMatrixAt(i, m);
m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
heads.setMatrixAt(i, m);
m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
hats.setMatrixAt(i, m);
for (const side of [0, 1]) {
const sign = side ? 1 : -1;
const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * .45 : 0;
setLimb(legs, i * 2 + side, sign * .1, .9, legSwing);
let arm;
if (w.kind === 'carry' && walking) arm = -1.1;
else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * .4;
// Working at a bench: both hands forward, one hammering or turning a tool.
else if (w.kind === 'work') arm = -1.0 + (side ? Math.sin(t * 4) * .35 : Math.sin(t * 1.3) * .08);
else arm = -.9;
setLimb(arms, i * 2 + side, sign * .27, 1.46, arm);
}
});
for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
}
update(0);
for (const mesh of [torso, legs, arms, heads, hats]) if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
return {group, update};
}
+1180 -224
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y.
export const CAR = {maxSpeed: 33, maxReverse: 6, accel: 7, brake: 14, drag: 1.6, maxSteer: .55, wheelBase: 2.7, radius: 1.6};
// 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 function createCar(x, z, heading = 0) {
return {x, z, heading, speed: 0, steer: 0};