feat: add drivable black luxury cars, Apache motorcycles and lane splitting
CI/CD / test (push) Successful in 57s
CI/CD / publish (push) Successful in 59s
CI/CD / deploy (push) Successful in 6s

- Park black BMW, Lamborghini, Mazda and Nissan style cars in the factory
  yard; Mr Mohammadi now arrives in a Bugatti that becomes drivable once
  parked; the delivery truck is black.
- Add six Apache style motorcycles with a rider, lean and their own
  handling (BIKE spec, 130 km/h).
- Let the player take any traffic car; a garage module owns every
  drivable vehicle and resets them.
- Replace the 3.4 m circle test between vehicles with oriented boxes
  sized from each model, so contact only happens when bodies touch and
  bikes can filter between cars; traffic only brakes for its own lane.
- Add tests for box collisions, bike handling, traffic lane checks and
  taking traffic cars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 09:33:19 +03:30
co-authored by Claude Opus 5.5
parent 526796126d
commit e02347142b
18 changed files with 1352 additions and 336 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
پنج بخش تولید: خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاه‌های آماده، بسته‌بندی و بارگیری. کامیون هشت بسته را بار می‌زند، خارج می‌شود، مدتی غایب می‌ماند، بازمی‌گردد و دنده‌عقب به سکو می‌رسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمی‌دهد. پنج بخش تولید: خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاه‌های آماده، بسته‌بندی و بارگیری. کامیون هشت بسته را بار می‌زند، خارج می‌شود، مدتی غایب می‌ماند، بازمی‌گردد و دنده‌عقب به سکو می‌رسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمی‌دهد.
مدیر با وانت سفید به سبک KMC T8 وارد می‌شود، پارک می‌کند، تا دفتر راه می‌رود و می‌نشیند؛ گفت‌وگوی او بعد از نشستن فعال می‌شود. گفت‌وگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیک‌شدن فعال می‌شود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجع‌اند. مدیر با بوگاتی مشکی وارد می‌شود، پارک می‌کند (از آن به بعد ماشینش سوارشدنی است)، تا دفتر راه می‌رود و می‌نشیند؛ گفت‌وگوی او بعد از نشستن فعال می‌شود. گفت‌وگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیک‌شدن فعال می‌شود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجع‌اند.
## خارج از دامنهٔ فعلی ## خارج از دامنهٔ فعلی
+4 -2
View File
@@ -11,11 +11,13 @@
- موبایل joystick و دکمه‌های F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت می‌کشد. - موبایل joystick و دکمه‌های F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت می‌کشد.
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرم‌سازی است. برخورد دایره‌ای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود می‌شود. - سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرم‌سازی است. برخورد دایره‌ای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود می‌شود.
## ماشین ## ماشین و موتور
همهٔ وسیله‌های حیاط کارخانه مشکی‌اند و سوارشدنی: بی‌ام‌وی خانواده کنار نقطهٔ شروع، لامبورگینی، مزدا و نیسان در پارکینگ کارکنان، شش موتور آپاچی کنار دفتر، و بوگاتی آقای محمدی پس از اینکه پارک کرد. هر ماشین ترافیک شهر هم با F گرفته می‌شود و تا reset همان‌جا که رها شده می‌ماند. کامیون بارگیری تزئینی است و سوار نمی‌شود. همه در `runtime/garage.js` نگه‌داری می‌شوند.
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیاده‌شدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیاده‌شدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان می‌دهد. سمت خروج راننده بررسی برخورد می‌شود. سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیاده‌شدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیاده‌شدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان می‌دهد. سمت خروج راننده بررسی برخورد می‌شود.
ثابت‌های `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر می‌شود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. تصادف ترافیک سرعت را کم می‌کند و NPC پس از تماس طولانی حذف/بازپدید می‌شود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد. ثابت‌های `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر می‌شود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. ثابت‌های `BIKE`: سقف ۳۶٫۱ (حدود ۱۳۰ km/h)، شتاب ۱۱، wheelBase برابر ۱٫۳۵؛ موتور در پیچ خم می‌شود. برخورد بین وسیله‌ها با جعبهٔ جهت‌دار به اندازهٔ واقعی مدل (SAT در `world/vehicleCollision.js`) است، پس فقط وقتی بدنه‌ها واقعاً به هم برسند سرعت کم می‌شود و موتور می‌تواند لایی بکشد. کاهش سرعت به زاویهٔ برخورد بستگی دارد: شاخ‌به‌شاخ ۶۰٪، مالیدن کناری تقریباً هیچ. ترافیک فقط برای چیزی که واقعاً در عرض خطش باشد ترمز می‌کند و NPC پس از تماس طولانی حذف/بازپدید می‌شود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
## پول و محصول ## پول و محصول
+6 -2
View File
@@ -16,8 +16,12 @@ JavaScript ES Modules، Three.js/WebGL، Vite، HTML و CSS فارسی RTL و Ca
| characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن | | characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن |
| factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون | | factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون |
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh | | staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
| car-models.js | سدان خانواده، pickup مدیر و کامیون | | car-models.js | سدان خانواده، کامیون و helperهای مشترک بدنه |
| vehicle.js | state و فیزیک آرکید ماشین | | luxuryCars.js | ماشین‌های لوکس مشکی به سبک بوگاتی، لامبورگینی، مزدا و نیسان |
| motorcycle.js | موتور آپاچی با راکب و بدنهٔ خم‌شونده |
| vehicle.js | state و فیزیک آرکید ماشین (`CAR`) و موتور (`BIKE`) |
| vehicleCollision.js | برخورد جعبهٔ جهت‌دار وسیله‌ها و هل‌دادن پیاده از وسیلهٔ پارک‌شده |
| garage.js | فهرست وسیله‌های سوارشدنی، سوار/پیاده، برخورد رانندگی و reset |
| traffic.js | ماشین‌های NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده | | traffic.js | ماشین‌های NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
| city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility | | city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility |
| collision.js | spatial grid چندضلعی‌ها، resolveCircle و clearFraction دوربین | | collision.js | spatial grid چندضلعی‌ها، resolveCircle و clearFraction دوربین |
+26
View File
@@ -0,0 +1,26 @@
// Every vehicle in the factory yard is painted this gloss black.
export const VEHICLE_BLACK = '#0c0d0f';
// How close (metres, centre to centre) the player must stand to get on or in a vehicle.
export const ENTER_DISTANCE = 4.5;
// Above this speed (m/s) the player has to stop before getting off.
export const EXIT_MAX_SPEED = 3;
// Persian names shown in prompts. `bmw` is the family car parked beside the start point.
export const VEHICLE_LABELS = {
bmw: 'بی‌ام‌و',
bugatti: 'بوگاتی',
lamborghini: 'لامبورگینی',
mazda: 'مزدا',
nissan: 'نیسان',
apache: 'موتور آپاچی',
traffic: 'ماشین',
};
// Brands parked in the staff bays, in bay order. Mr Mohammadi arrives in the Bugatti.
export const STAFF_CAR_BRANDS = ['lamborghini', 'mazda', 'nissan'];
export const MANAGER_CAR_BRAND = 'bugatti';
// Apache motorcycles lined up in the yard.
export const MOTORCYCLE_COUNT = 6;
+15 -141
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three'; 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, plus the shared helpers luxuryCars.js builds
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones. // on. 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.
/** /**
* Runs the mat operation for this subsystem. * Runs the mat operation for this subsystem.
@@ -9,7 +9,7 @@ import * as THREE from 'three';
* @param {*} extra - extra. * @param {*} extra - extra.
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
const mat = (color, extra = {}) => export const mat = (color, extra = {}) =>
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color, color,
roughness: 0.45, roughness: 0.45,
@@ -25,7 +25,7 @@ const mat = (color, extra = {}) =>
* @param {*} bevel - bevel. * @param {*} bevel - bevel.
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
function extrudeProfile(points, width, material, bevel = 0.1) { export 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, { const geometry = new THREE.ExtrudeGeometry(shape, {
@@ -52,7 +52,7 @@ function extrudeProfile(points, width, material, bevel = 0.1) {
* @param {*} top - top. * @param {*} top - top.
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
function bodyOutline(front, rear, arches, radius, top) { export 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 = 0.3; const bottom = 0.3;
@@ -66,6 +66,15 @@ function bodyOutline(front, rear, arches, radius, top) {
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;
} }
/**
* Measures a freshly built model (not yet placed or parented) on the ground plane.
* @param {THREE.Group} group - Model group facing local +X.
* @returns {{halfLength: number, halfWidth: number}} Half extents along and across the body.
*/
export function measureFootprint(group) {
const size = new THREE.Box3().setFromObject(group).getSize(new THREE.Vector3());
return { halfLength: size.x / 2, halfWidth: size.z / 2 };
}
/** /**
* Runs the add wheels operation for this subsystem. * Runs the add wheels operation for this subsystem.
* @param {*} group - group. * @param {*} group - group.
@@ -76,7 +85,7 @@ function bodyOutline(front, rear, arches, radius, top) {
* @param {*} spokes - spokes. * @param {*} spokes - spokes.
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) { export function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
const tyre = mat('#151719', { const tyre = mat('#151719', {
roughness: 0.9, roughness: 0.9,
}), }),
@@ -311,141 +320,6 @@ export function createSportSedan(color = '#1d3f78') {
}; };
} }
/**
* 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: 0.5,
roughness: 0.32,
}),
glass = mat('#13202a', {
metalness: 0.6,
roughness: 0.1,
});
const black = mat('#1a1c1e', {
roughness: 0.7,
}),
chrome = mat('#d0d4d7', {
metalness: 0.9,
roughness: 0.2,
});
/**
* Runs the box operation for this subsystem.
* @param {*} w - w.
* @param {*} h - h.
* @param {*} d - d.
* @param {*} m - m.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
group.add(
extrudeProfile(
[
[2.62, 0.5],
[2.66, 0.95],
[2.5, 1.18],
[1.3, 1.24],
[1.3, 0.5],
],
1.9,
paint,
0.1,
),
);
group.add(
extrudeProfile(
[
[1.3, 0.5],
[1.3, 1.24],
[0.7, 1.86],
[-0.95, 1.88],
[-1.0, 0.5],
],
1.88,
paint,
0.1,
),
);
group.add(
extrudeProfile(
[
[1.24, 1.26],
[0.68, 1.82],
[-0.9, 1.84],
[-0.94, 1.26],
],
1.92,
glass,
0.02,
),
);
// Cargo bed: floor, sides and tailgate.
box(1.62, 0.1, 1.8, black, -1.85, 0.72, 0);
for (const side of [-1, 1]) box(1.66, 0.5, 0.07, paint, -1.85, 0.95, side * 0.9);
box(0.07, 0.5, 1.86, paint, -2.66, 0.95, 0);
box(1.66, 0.46, 1.9, paint, -1.85, 0.5, 0);
box(0.3, 0.06, 1.86, black, -1.02, 1.26, 0);
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0);
for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0);
const lamp = mat('#eaf6ff', {
emissive: '#cfe9ff',
emissiveIntensity: 1.2,
});
for (const side of [-1, 1]) {
box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
box(
0.06,
0.26,
0.1,
mat('#9e1410', {
emissive: '#c4241b',
emissiveIntensity: 0.7,
}),
-2.7,
1.0,
side * 0.86,
);
box(0.2, 0.14, 0.2, black, 1.2, 1.36, side * 1.02);
box(1.0, 0.05, 0.06, chrome, -0.1, 1.25, side * 0.97);
for (const x of [0.6, -0.4]) box(0.16, 0.04, 0.03, chrome, x, 1.12, side * 0.97);
}
box(0.16, 0.2, 2.0, black, 2.7, 0.55, 0);
box(0.16, 0.2, 2.0, black, -2.72, 0.55, 0);
box(0.04, 0.12, 0.5, mat('#f4f4f0'), 2.79, 0.6, 0);
const wheels = addWheels(
group,
[
[-1.7, -0.86],
[-1.7, 0.86],
[1.7, -0.86],
[1.7, 0.86],
],
0.4,
0.3,
'#a9adb1',
6,
);
for (const x of [-1.7, 1.7])
for (const side of [-1, 1]) box(1.05, 0.1, 0.2, black, x, 0.86, side * 0.96);
return {
group,
wheels,
};
}
/** /**
* Flatbed delivery lorry with side rails. `bed` is a group on the load floor (local +X towards the * 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. * cab, origin at the bed's centre) where cargo is placed. 8 m long.
+29 -37
View File
@@ -1,7 +1,8 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { createVendingMachine } from './vending.js'; import { createVendingMachine } from './vending.js';
import { createCarModel } from '../systems/traffic.js'; import { createTruck } from './carModels.js';
import { createPickup, createTruck } from './carModels.js'; import { createLuxuryCar } from './luxuryCars.js';
import { MANAGER_CAR_BRAND, MOTORCYCLE_COUNT, VEHICLE_BLACK } from '../../constants/vehicles.js';
import { createPerson, createCrew, nameTag } from './staff.js'; import { createPerson, createCrew, nameTag } from './staff.js';
import { FACTORY } from '../../constants/locations.js'; import { FACTORY } from '../../constants/locations.js';
import { textTexture } from './textures.js'; import { textTexture } from './textures.js';
@@ -139,7 +140,7 @@ const CODE = {
/** /**
* The Robot Market factory compound: production hall with five work areas and forty workers, an * 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 * 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 * arrival in his car, 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 * towards the gate, v across it. Returns world-space collision footprints, key positions and the
* people the family can talk to. * people the family can talk to.
*/ */
@@ -270,8 +271,8 @@ function factoryUpdateArrival(build, dt, time) {
if (build.arrival.stage === 'drive') { if (build.arrival.stage === 'drive') {
build.arrival.s += dt * 7; build.arrival.s += dt * 7;
const p = build.along(build.DRIVE_IN, build.arrival.s); const p = build.along(build.DRIVE_IN, build.arrival.s);
build.placeVehicle(build.pickup, build.arrival, p, dt); build.placeVehicle(build.managerCar, build.arrival, p, dt);
build.pickup.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4)); build.managerCar.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4));
build.manager.root.visible = false; build.manager.root.visible = false;
if (p.done) if (p.done)
Object.assign(build.arrival, { Object.assign(build.arrival, {
@@ -304,7 +305,7 @@ function factoryResetArrival(build) {
s: 0, s: 0,
sat: false, sat: false,
}); });
build.placeVehicle(build.pickup, build.arrival, build.along(build.DRIVE_IN, 0), 0); build.placeVehicle(build.managerCar, build.arrival, build.along(build.DRIVE_IN, 0), 0);
build.manager.root.visible = false; build.manager.root.visible = false;
} }
/** /**
@@ -1586,20 +1587,22 @@ function factoryBuildGeometry12(build) {
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
function factoryBuildGeometry13(build) { function factoryBuildGeometry13(build) {
for (let i = 0; i < 4; i++) { // Bay centres for the staff cars and the motorcycle row; the garage puts drivable vehicles there.
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]); const heading = build.worldHeading(0, -1);
const u = 56 + i * 4; build.parking = {
parked.group.position.set(u, 0, -19); cars: [58, 62, 66].map((u) => ({ ...build.toWorld(u, -19), heading })),
parked.group.rotation.y = Math.PI / 2; // Motorcycles barely reverse, so they stand facing out towards the driveway.
build.group.add(parked.group); bikes: Array.from({ length: MOTORCYCLE_COUNT }, (_, i) => ({
build.solid(u - 1.1, u + 1.1, -21.3, -16.7); ...build.toWorld(36.6 + i * 1.1, -19.5),
} heading: build.worldHeading(0, 1),
})),
};
// ---------- The manager arrives in his pickup ---------- // ---------- The manager arrives in his Bugatti ----------
// Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to // Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
// his room and sits at his desk. // his room and sits at his desk. Once parked the car can be taken like any other.
build.pickup = createPickup('#f1f1ee'); build.managerCar = createLuxuryCar(MANAGER_CAR_BRAND);
build.group.add(build.pickup.group); build.group.add(build.managerCar.group);
} }
/** /**
* Runs the factory build manager operation for this subsystem. * Runs the factory build manager operation for this subsystem.
@@ -1634,24 +1637,6 @@ function factoryBuildManager(build) {
[48.5, -40.7], [48.5, -40.7],
[45, -40.7], [45, -40.7],
]; ];
{
// The parked pickup blocks its bay.
const [ax, az] = build.DRIVE_IN.at(-2),
[bx, bz] = build.DRIVE_IN.at(-1),
len = Math.hypot(bx - ax, bz - az);
const du = (bx - ax) / len,
dv = (bz - az) / len;
build.footprints.push(
[
[2.7, 1],
[2.7, -1],
[-2.7, -1],
[-2.7, 1],
].flatMap(([a, b]) =>
Object.values(build.toWorld(bx + du * a - dv * b, bz + dv * a + du * b)),
),
);
}
} }
/** /**
* Runs the factory build arrival operation for this subsystem. * Runs the factory build arrival operation for this subsystem.
@@ -1723,7 +1708,7 @@ function factoryBuildPlaceVehicle(build) {
// ---------- Delivery truck ---------- // ---------- Delivery truck ----------
// Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out // 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. // of the gate to the city, and comes back empty a while later, reversing up to the dock.
build.truck = createTruck(); build.truck = createTruck(VEHICLE_BLACK);
build.group.add(build.truck.group); build.group.add(build.truck.group);
build.DOCK = [5.5, 31]; build.DOCK = [5.5, 31];
build.DEPART = [build.DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]]; build.DEPART = [build.DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
@@ -1836,6 +1821,13 @@ function factoryBuildResult(build) {
...build.toWorld(-20, 0), ...build.toWorld(-20, 0),
}, },
talks: build.talks, talks: build.talks,
parking: build.parking,
// The garage takes the manager's car over once it has parked, and hands it back on reset.
managerCar: {
model: build.managerCar,
home: build.group,
parked: () => build.arrival.stage !== 'drive',
},
reset: build.resetArrival, reset: build.resetArrival,
// `dt` is 0 while the game is paused; distant viewers skip the people and machines. // `dt` is 0 while the game is paused; distant viewers skip the people and machines.
update(time, dt, viewer) { update(time, dt, viewer) {
+367
View File
@@ -0,0 +1,367 @@
import * as THREE from 'three';
import { VEHICLE_BLACK } from '../../constants/vehicles.js';
import { addWheels, bodyOutline, extrudeProfile, mat, measureFootprint } from './carModels.js';
// Low-poly luxury cars for the factory yard, each in the style of a well-known brand but without
// badges or logos. Like the other models they face local +X, sit on y = 0 and list their wheels
// rear pair first, then the two front (steering) ones.
/**
* Creates the materials every luxury car shares.
* @param {string} color - Body paint colour.
* @returns {object} Paint, glass, trim and lamp materials.
*/
function createKit(color) {
return {
paint: mat(color, { metalness: 0.75, roughness: 0.22 }),
glass: mat('#0f1a22', { metalness: 0.7, roughness: 0.08 }),
black: mat('#050506', { roughness: 0.6 }),
chrome: mat('#d8dcdf', { metalness: 0.95, roughness: 0.15 }),
head: mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 }),
tail: mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 }),
};
}
/**
* Adds a box part, optionally mirrored to both sides of the car.
* @param {THREE.Group} group - Car group.
* @param {THREE.Material} material - Part material.
* @param {{size: number[], at: number[], mirror?: boolean}} placement - Box size and centre; with
* `mirror` a copy is placed at -z too.
* @returns {THREE.Mesh[]} The added meshes.
*/
function part(group, material, { size, at, mirror = false }) {
return (mirror ? [1, -1] : [1]).map((side) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material);
mesh.position.set(at[0], at[1], at[2] * side);
mesh.castShadow = true;
group.add(mesh);
return mesh;
});
}
/**
* Adds a round lamp ring facing along the car, mirrored to both sides.
* @param {THREE.Group} group - Car group.
* @param {THREE.Material} material - Lamp material.
* @param {{radius: number, at: number[]}} placement - Ring radius and centre.
*/
function lampRing(group, material, { radius, at }) {
for (const side of [1, -1]) {
const ring = new THREE.Mesh(new THREE.TorusGeometry(radius, radius * 0.3, 8, 20), material);
ring.rotation.y = Math.PI / 2;
ring.position.set(at[0], at[1], at[2] * side);
group.add(ring);
}
}
/**
* Extrudes the lower body outline across the car's width.
* @param {THREE.Shape} outline - Side outline from bodyOutline.
* @param {number} width - Overall body width.
* @param {THREE.Material} paint - Body paint.
* @returns {THREE.Mesh} The body mesh.
*/
function extrudeBody(outline, width, paint) {
const geometry = new THREE.ExtrudeGeometry(outline, {
depth: width - 0.2,
bevelEnabled: true,
bevelThickness: 0.1,
bevelSize: 0.08,
bevelSegments: 3,
curveSegments: 10,
});
geometry.translate(0, 0, -(width - 0.2) / 2);
const body = new THREE.Mesh(geometry, paint);
body.castShadow = body.receiveShadow = true;
return body;
}
/**
* Adds the painted roof panel, mirrors and side skirts shared by every model.
* @param {THREE.Group} group - Car group.
* @param {object} spec - Brand spec.
* @param {object} kit - Materials from createKit.
*/
function addCommonParts(group, spec, kit) {
const [, roofFront, roofRear] = spec.cabin;
const roofTop = Math.max(roofFront[1], roofRear[1]) + 0.02;
part(group, kit.paint, {
size: [roofFront[0] - roofRear[0], 0.05, spec.cabinWidth - 0.1],
at: [(roofFront[0] + roofRear[0]) / 2, roofTop, 0],
});
const [windscreenX, windscreenY] = spec.cabin[0];
part(group, kit.paint, {
size: [0.18, 0.1, 0.14],
at: [windscreenX - 0.12, windscreenY + 0.08, spec.width / 2 + 0.02],
mirror: true,
});
part(group, kit.black, {
size: [spec.arches[1] - spec.arches[0] - 0.9, 0.08, 0.04],
at: [(spec.arches[0] + spec.arches[1]) / 2, 0.34, spec.width / 2 + 0.01],
mirror: true,
});
}
/**
* Wheel centres for a spec: rear pair first, then the front pair.
* @param {object} spec - Brand spec.
* @returns {number[][]} [x, z] pairs.
*/
function wheelPositions(spec) {
const track = spec.width / 2 - 0.14;
return spec.arches.flatMap((x) => [
[x, -track],
[x, track],
]);
}
/**
* Hypercar details: horseshoe grille, C-shaped side sweep, roof fin and a full-width tail bar.
* @param {THREE.Group} group - Car group.
* @param {object} kit - Materials.
* @param {object} spec - Brand spec.
*/
function bugattiDetails(group, kit, spec) {
part(group, kit.black, { size: [0.05, 0.3, 0.3], at: [2.33, 0.46, 0] });
const horseshoe = new THREE.Mesh(
new THREE.TorusGeometry(0.16, 0.025, 8, 20, Math.PI),
kit.chrome,
);
// Euler XYZ applies z first: flip the arch into a U, then turn it to face forward.
horseshoe.rotation.set(0, Math.PI / 2, Math.PI);
horseshoe.position.set(2.35, 0.56, 0);
group.add(horseshoe);
for (const side of [1, -1]) {
const sweep = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.022, 6, 24, Math.PI), kit.chrome);
// The C bulges towards the nose and opens towards the rear.
sweep.rotation.z = -Math.PI / 2;
sweep.position.set(-0.1, 0.6, (side * spec.width) / 2);
group.add(sweep);
}
part(group, kit.paint, { size: [1.2, 0.07, 0.03], at: [-0.4, 1.27, 0] });
for (const y of [0.6, 0.53])
part(group, kit.head, { size: [0.05, 0.035, 0.26], at: [2.3, y, 0.62], mirror: true });
part(group, kit.tail, { size: [0.05, 0.06, 1.7], at: [-2.28, 0.8, 0] });
part(group, kit.chrome, { size: [0.06, 0.12, 0.5], at: [-2.3, 0.38, 0] });
}
/**
* Wedge supercar details: Y-shaped lamps, side intakes, rear wing and a hexagonal exhaust.
* @param {THREE.Group} group - Car group.
* @param {object} kit - Materials.
* @param {object} spec - Brand spec.
*/
function lamborghiniDetails(group, kit, spec) {
for (const side of [1, -1]) {
for (const tilt of [0.45, -0.45]) {
const lamp = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.3), kit.head);
lamp.position.set(2.3, 0.52, side * 0.64);
lamp.rotation.x = tilt * side;
group.add(lamp);
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.03, 0.34), kit.tail);
tail.position.set(-2.42, 0.74, side * 0.66);
tail.rotation.x = tilt * side;
group.add(tail);
}
}
part(group, kit.black, {
size: [0.7, 0.25, 0.04],
at: [-0.75, 0.62, spec.width / 2 + 0.01],
mirror: true,
});
part(group, kit.black, { size: [0.05, 0.14, 1.5], at: [2.4, 0.36, 0] });
part(group, kit.paint, { size: [0.34, 0.04, 1.7], at: [-2.1, 1.1, 0] });
part(group, kit.black, { size: [0.06, 0.16, 0.05], at: [-2.1, 1.0, 0.6], mirror: true });
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.12, 6), kit.chrome);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.set(-2.46, 0.42, 0);
group.add(exhaust);
}
/**
* Grand-tourer details: large five-point grille with a chrome wing and twin round tail lamps.
* @param {THREE.Group} group - Car group.
* @param {object} kit - Materials.
*/
function mazdaDetails(group, kit) {
part(group, kit.black, { size: [0.05, 0.28, 0.9], at: [2.3, 0.5, 0] });
part(group, kit.chrome, { size: [0.04, 0.03, 1.1], at: [2.31, 0.37, 0] });
part(group, kit.head, { size: [0.06, 0.05, 0.38], at: [2.24, 0.66, 0.6], mirror: true });
for (const z of [0.5, 0.72]) lampRing(group, kit.tail, { radius: 0.07, at: [-2.26, 0.76, z] });
part(group, kit.chrome, { size: [0.06, 0.1, 0.16], at: [-2.26, 0.38, 0.5], mirror: true });
}
/**
* Grand-touring coupe details: four round tail lamps, a rear wing on struts and a V grille.
* @param {THREE.Group} group - Car group.
* @param {object} kit - Materials.
*/
function nissanDetails(group, kit) {
part(group, kit.black, { size: [0.05, 0.22, 0.8], at: [2.4, 0.52, 0] });
part(group, kit.chrome, { size: [0.04, 0.03, 0.82], at: [2.41, 0.64, 0] });
part(group, kit.head, { size: [0.06, 0.08, 0.42], at: [2.34, 0.72, 0.6], mirror: true });
for (const z of [0.45, 0.72]) lampRing(group, kit.tail, { radius: 0.08, at: [-2.4, 0.86, z] });
part(group, kit.paint, { size: [0.3, 0.04, 1.6], at: [-2.02, 1.24, 0] });
part(group, kit.black, { size: [0.06, 0.22, 0.05], at: [-2.02, 1.12, 0.55], mirror: true });
part(group, kit.chrome, { size: [0.06, 0.12, 0.12], at: [-2.4, 0.38, 0.55], mirror: true });
}
// Side profiles (x forward, y up). `body` feeds bodyOutline; `cabin` is the glasshouse: windscreen
// base, roof front, roof rear, rear-window base, closing point.
const BRANDS = {
bugatti: {
width: 2.0,
arches: [-1.4, 1.35],
archRadius: 0.43,
front: [
[2.12, 0.3],
[2.26, 0.4],
[2.28, 0.58],
[2.1, 0.72],
],
rear: [
[-2.2, 0.3],
[-2.25, 0.55],
[-2.2, 0.82],
[-2.05, 0.9],
],
top: [
[1.3, 0.82],
[0.6, 0.9],
[-1.0, 0.95],
[-1.9, 0.9],
],
cabin: [
[0.6, 0.88],
[-0.15, 1.2],
[-0.75, 1.21],
[-1.5, 0.94],
[-1.4, 0.9],
],
cabinWidth: 1.4,
rim: '#2a2d30',
spokes: 6,
details: bugattiDetails,
},
lamborghini: {
width: 2.03,
arches: [-1.45, 1.4],
archRadius: 0.43,
front: [
[2.3, 0.3],
[2.42, 0.38],
[2.35, 0.55],
],
rear: [
[-2.35, 0.3],
[-2.4, 0.6],
[-2.3, 0.92],
],
top: [
[1.6, 0.7],
[0.7, 0.85],
[-1.2, 0.95],
[-2.1, 0.92],
],
cabin: [
[0.75, 0.83],
[-0.1, 1.13],
[-0.7, 1.14],
[-1.6, 0.95],
[-1.5, 0.92],
],
cabinWidth: 1.3,
rim: '#16181a',
spokes: 5,
details: lamborghiniDetails,
},
mazda: {
width: 1.9,
arches: [-1.38, 1.4],
archRadius: 0.42,
front: [
[2.1, 0.3],
[2.25, 0.42],
[2.24, 0.62],
[2.05, 0.76],
],
rear: [
[-2.15, 0.3],
[-2.2, 0.6],
[-2.1, 0.85],
[-1.95, 0.9],
],
top: [
[1.5, 0.82],
[0.5, 0.9],
[-1.2, 0.92],
[-1.8, 0.88],
],
cabin: [
[0.4, 0.88],
[-0.35, 1.18],
[-0.95, 1.18],
[-1.75, 0.92],
[-1.6, 0.88],
],
cabinWidth: 1.35,
rim: '#9aa0a5',
spokes: 10,
details: mazdaDetails,
},
nissan: {
width: 1.9,
arches: [-1.39, 1.39],
archRadius: 0.42,
front: [
[2.2, 0.3],
[2.34, 0.44],
[2.34, 0.66],
[2.18, 0.8],
],
rear: [
[-2.3, 0.3],
[-2.35, 0.6],
[-2.3, 0.95],
[-2.15, 1.0],
],
top: [
[1.6, 0.86],
[0.7, 0.96],
[-1.5, 1.0],
[-2.0, 0.98],
],
cabin: [
[0.7, 0.95],
[-0.1, 1.36],
[-0.95, 1.37],
[-1.85, 1.0],
[-1.7, 0.97],
],
cabinWidth: 1.5,
rim: '#3a3d40',
spokes: 6,
details: nissanDetails,
},
};
/**
* Builds a black luxury car in the style of one brand.
* @param {'bugatti' | 'lamborghini' | 'mazda' | 'nissan'} brand - Which style to build.
* @param {string} color - Body paint colour.
* @returns {{group: THREE.Group, wheels: THREE.Group[], size: object}} The car model and its
* footprint half extents.
*/
export function createLuxuryCar(brand, color = VEHICLE_BLACK) {
const spec = BRANDS[brand];
const kit = createKit(color);
const group = new THREE.Group();
const outline = bodyOutline(spec.front, spec.rear, spec.arches, spec.archRadius, spec.top);
group.add(extrudeBody(outline, spec.width, kit.paint));
group.add(extrudeProfile(spec.cabin, spec.cabinWidth, kit.glass, 0.08));
addCommonParts(group, spec, kit);
spec.details(group, kit, spec);
const wheels = addWheels(group, wheelPositions(spec), 0.36, 0.28, spec.rim, spec.spokes);
return { group, wheels, size: measureFootprint(group) };
}
+155
View File
@@ -0,0 +1,155 @@
import * as THREE from 'three';
import { VEHICLE_BLACK } from '../../constants/vehicles.js';
import { extrudeProfile, mat, measureFootprint } from './carModels.js';
import { createPerson } from './staff.js';
// Naked street bike in the style of an Apache RTR: sculpted tank, split seat, angular tail and a
// short underbelly exhaust. 2 m long, faces local +X. `wheels` is [rear, front]; `body` is the
// group that leans into corners; `rider` sits on it and is shown only while the bike is ridden.
const WHEEL_RADIUS = 0.31;
const WHEEL_X = [-0.67, 0.67];
/**
* Builds one spoked wheel that spins about local z.
* @param {THREE.Material} rubber - Tyre material.
* @param {THREE.Material} rim - Rim material.
* @returns {THREE.Group} The wheel.
*/
function createWheel(rubber, rim) {
const wheel = new THREE.Group();
const tyre = new THREE.Mesh(new THREE.TorusGeometry(WHEEL_RADIUS - 0.05, 0.05, 8, 24), rubber);
tyre.castShadow = true;
wheel.add(tyre);
for (let i = 0; i < 3; i++) {
const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.035, 0.03), rim);
spoke.rotation.z = (i * Math.PI) / 3;
wheel.add(spoke);
}
return wheel;
}
/**
* Adds a box to a group.
* @param {THREE.Group} group - Parent group.
* @param {THREE.Material} material - Material.
* @param {{size: number[], at: number[], tilt?: number}} placement - Size, centre and z rotation.
*/
function box(group, material, { size, at, tilt = 0 }) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material);
mesh.position.set(...at);
mesh.rotation.z = tilt;
mesh.castShadow = true;
group.add(mesh);
}
/**
* Adds the tank, seat, tail, engine and exhaust.
* @param {THREE.Group} body - Leaning body group.
* @param {object} kit - Materials.
*/
function addBodywork(body, kit) {
const tank = [
[0.55, 0.82],
[0.45, 1.03],
[0.05, 1.0],
[-0.12, 0.86],
];
body.add(extrudeProfile(tank, 0.38, kit.paint, 0.05));
const tail = [
[-0.55, 0.86],
[-0.98, 1.04],
[-1.0, 0.98],
[-0.6, 0.82],
];
body.add(extrudeProfile(tail, 0.22, kit.paint, 0.03));
box(body, kit.seat, { size: [0.5, 0.08, 0.26], at: [-0.35, 0.93, 0] });
box(body, kit.accent, { size: [0.32, 0.03, 0.39], at: [0.3, 0.9, 0], tilt: 0.2 });
box(body, kit.engine, { size: [0.46, 0.34, 0.3], at: [0.05, 0.52, 0] });
box(body, kit.engine, { size: [0.7, 0.05, 0.08], at: [-0.33, 0.38, 0], tilt: 0.12 });
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.07, 0.38, 10), kit.chrome);
exhaust.rotation.z = Math.PI / 2 - 0.15;
exhaust.position.set(-0.05, 0.3, 0.12);
body.add(exhaust);
}
/**
* Adds the fork, handlebar, headlight and tail light.
* @param {THREE.Group} body - Leaning body group.
* @param {object} kit - Materials.
*/
function addFrontAndLights(body, kit) {
for (const z of [0.08, -0.08])
box(body, kit.chrome, { size: [0.04, 0.72, 0.04], at: [0.58, 0.65, z], tilt: 0.42 });
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.7, 8), kit.engine);
bar.rotation.x = Math.PI / 2;
bar.position.set(0.44, 1.07, 0);
body.add(bar);
box(body, kit.paint, { size: [0.14, 0.2, 0.24], at: [0.62, 0.98, 0], tilt: -0.3 });
box(body, kit.head, { size: [0.04, 0.08, 0.16], at: [0.7, 0.96, 0], tilt: -0.3 });
box(body, kit.tail, { size: [0.03, 0.05, 0.12], at: [-1.0, 0.98, 0] });
box(body, kit.paint, { size: [0.3, 0.03, 0.14], at: [0.68, 0.62, 0], tilt: -0.5 });
}
/**
* Creates the seated rider in a black jacket and helmet, holding the handlebar.
* @returns {object} Person from createPerson.
*/
function createRider() {
const rider = createPerson({ top: '#151515', bottom: '#28303a', shoes: '#1a1a1a' });
const helmet = new THREE.Mesh(
new THREE.SphereGeometry(0.15, 16, 12),
mat('#0d0d0f', { metalness: 0.6, roughness: 0.25 }),
);
helmet.position.set(0, 0.01, -0.01);
rider.head.add(helmet);
// Seated pose puts the pelvis 0.5 m up; lift it onto the 0.94 m seat, facing the bars (+x).
rider.pose('type');
rider.root.position.set(-0.32, 0.42, 0);
rider.root.rotation.y = Math.PI / 2;
rider.root.visible = false;
return rider;
}
/**
* Creates the motorcycle's materials.
* @param {string} color - Body paint colour.
* @returns {object} Paint, trim and lamp materials.
*/
function createBikeKit(color) {
return {
paint: mat(color, { metalness: 0.7, roughness: 0.25 }),
accent: mat('#c8171e', { metalness: 0.4, roughness: 0.35 }),
seat: mat('#151617', { roughness: 0.85 }),
engine: mat('#2b2e31', { metalness: 0.6, roughness: 0.45 }),
chrome: mat('#c9cdd1', { metalness: 0.9, roughness: 0.2 }),
head: mat('#eaf6ff', { emissive: '#cfe9ff', emissiveIntensity: 1.6 }),
tail: mat('#9e1410', { emissive: '#e0261c', emissiveIntensity: 0.9 }),
};
}
/**
* Builds a black Apache-style motorcycle.
* @param {string} color - Body paint colour.
* @returns {{group: THREE.Group, body: THREE.Group, wheels: THREE.Group[], rider: object,
* size: object}} The model and its footprint half extents (without the rider).
*/
export function createMotorcycle(color = VEHICLE_BLACK) {
const kit = createBikeKit(color);
const group = new THREE.Group(),
body = new THREE.Group();
group.add(body);
addBodywork(body, kit);
addFrontAndLights(body, kit);
const rubber = mat('#151719', { roughness: 0.9 });
const wheels = WHEEL_X.map((x) => {
const wheel = createWheel(rubber, kit.engine);
wheel.position.set(x, WHEEL_RADIUS, 0);
body.add(wheel);
return wheel;
});
const size = measureFootprint(group);
const rider = createRider();
body.add(rider.root);
return { group, body, wheels, rider, size };
}
+319
View File
@@ -0,0 +1,319 @@
import * as THREE from 'three';
import {
ENTER_DISTANCE,
MANAGER_CAR_BRAND,
STAFF_CAR_BRANDS,
VEHICLE_BLACK,
VEHICLE_LABELS,
} from '../../constants/vehicles.js';
import { createSportSedan, measureFootprint } from '../models/carModels.js';
import { createLuxuryCar } from '../models/luxuryCars.js';
import { createMotorcycle } from '../models/motorcycle.js';
import { TRAFFIC_CAR } from '../systems/traffic.js';
import { BIKE, CAR, createCar } from '../systems/vehicle.js';
import { boxOverlap, circleOutOfBox, vehicleBox } from '../world/vehicleCollision.js';
// Every vehicle the player can drive: the family BMW, the staff cars, the Apache motorcycles,
// Mr Mohammadi's Bugatti once he has parked it, and any traffic car the player has taken over.
// `context.vehicle` is the one in use (or last used); `context.car` is its moving state.
// Distance beyond which two vehicles can't touch, to skip the box test.
const CONTACT_RANGE = 8;
// Radius of the walking player against parked vehicles.
const WALKER_RADIUS = 0.45;
// Speed kept after a head-on hit; a glancing scrape keeps nearly all of it.
const HEAD_ON_SPEED_KEPT = 0.4;
// A traffic car the player keeps pushing against pulls away (respawns) after this many seconds.
const TRAFFIC_GIVE_WAY = 0.8;
// How far a motorcycle leans per radian of steering at speed, and the speed of full lean.
const LEAN_PER_STEER = 0.8;
const FULL_LEAN_SPEED = 12;
/**
* Adds a vehicle to the garage and the scene.
* @param {object} context - Game context.
* @param {{brand: string, kind: 'car' | 'bike', model: object, slot: object}} vehicle - Vehicle
* fields; `slot` is where it is parked on reset (null for vehicles that are not reset).
* @returns {object} The garage record.
*/
function addVehicle(context, { brand, kind, model, slot, ...extra }) {
const record = {
brand,
kind,
model,
slot,
label: VEHICLE_LABELS[brand],
// Handling from the kind, collision box from the model itself, so contact is exact.
spec: { ...(kind === 'bike' ? BIKE : CAR), ...model.size },
state: createCar(slot.x, slot.z, slot.heading),
...extra,
};
context.garage.group.add(model.group);
context.garage.vehicles.push(record);
placeModel(record);
return record;
}
/**
* Adds footprint half extents to a freshly built model that doesn't report its own.
* @param {object} model - Model with a group.
* @returns {object} The same model with `size`.
*/
function withSize(model) {
model.size = measureFootprint(model.group);
return model;
}
/**
* Moves a vehicle's model to its state's position and heading.
* @param {object} record - Garage record.
*/
function placeModel({ model, state }) {
model.group.position.set(state.x, 0, state.z);
model.group.rotation.set(0, state.heading, 0);
}
/**
* Makes a garage vehicle the one the player uses.
* @param {object} context - Game context.
* @param {object} record - Garage record.
*/
function setActive(context, record) {
context.vehicle = record;
context.car = record.state;
}
/**
* Creates the garage with the factory fleet parked in the yard.
* @param {object} context - Game context with scene and factory.
*/
export function createGarage(context) {
context.garage = { group: new THREE.Group(), vehicles: [], managerCar: null };
context.scene.add(context.garage.group);
const { car: familySlot, parking } = context.factory;
addVehicle(context, {
brand: 'bmw',
kind: 'car',
model: withSize(createSportSedan(VEHICLE_BLACK)),
slot: familySlot,
});
STAFF_CAR_BRANDS.forEach((brand, i) =>
addVehicle(context, {
brand,
kind: 'car',
model: createLuxuryCar(brand),
slot: parking.cars[i],
}),
);
for (const slot of parking.bikes)
addVehicle(context, { brand: 'apache', kind: 'bike', model: createMotorcycle(), slot });
setActive(context, context.garage.vehicles[0]);
}
/**
* Parks the fleet back in its bays, drops taken traffic cars and returns the manager's car to the
* factory so his arrival can play again. Call before factory.reset().
* @param {object} context - Game context.
*/
export function resetGarage(context) {
const { garage } = context;
for (const record of garage.vehicles.filter((v) => !v.slot))
garage.group.remove(record.model.group);
garage.vehicles = garage.vehicles.filter((v) => v.slot);
if (garage.managerCar) context.factory.managerCar.home.add(garage.managerCar.model.group);
garage.managerCar = null;
for (const record of garage.vehicles) {
record.state = createCar(record.slot.x, record.slot.z, record.slot.heading);
placeModel(record);
if (record.model.rider) record.model.rider.root.visible = false;
}
setActive(context, garage.vehicles[0]);
}
/**
* Takes over the manager's car once he has parked it, so it can be driven like the others.
* @param {object} context - Game context.
*/
export function updateGarage(context) {
const manager = context.factory.managerCar;
if (context.garage.managerCar || !manager.parked()) return;
const group = manager.model.group;
group.updateWorldMatrix(true, false);
const at = group.getWorldPosition(new THREE.Vector3());
const nose = group.localToWorld(new THREE.Vector3(1, 0, 0));
// The factory frame is rotated; recover the world heading from where the nose points.
const heading = Math.atan2(-(nose.z - at.z), nose.x - at.x);
const record = addVehicle(context, {
brand: MANAGER_CAR_BRAND,
kind: 'car',
model: manager.model,
slot: { x: at.x, z: at.z, heading },
});
// Not reset with the fleet: resetGarage hands it back to the factory instead.
record.slot = null;
context.garage.managerCar = record;
}
/**
* Finds the closest vehicle the walking player can get on or in.
* @param {object} context - Game context.
* @returns {{label: string, record?: object, npc?: object} | null} A garage vehicle or a traffic
* car within reach, or null.
*/
export function nearestRide(context) {
const { x, z } = context.player;
let best = null,
bestDistance = ENTER_DISTANCE;
for (const record of context.garage.vehicles) {
const distance = Math.hypot(record.state.x - x, record.state.z - z);
if (distance < bestDistance) [best, bestDistance] = [{ record, label: record.label }, distance];
}
for (const npc of context.traffic.cars) {
if (!npc.model.group.visible) continue;
const distance = Math.hypot(npc.x - x, npc.z - z);
if (distance < bestDistance)
[best, bestDistance] = [{ npc, label: VEHICLE_LABELS.traffic }, distance];
}
return best;
}
/**
* Gets the player into a ride found by nearestRide; a traffic car becomes a garage vehicle.
* @param {object} context - Game context.
* @param {{record?: object, npc?: object}} ride - The ride to take.
* @returns {object} The garage record now in use.
*/
export function takeRide(context, { record, npc }) {
if (npc) {
const slot = { x: npc.x, z: npc.z, heading: npc.model.group.rotation.y };
const model = { ...context.traffic.adopt(npc), size: TRAFFIC_CAR };
record = addVehicle(context, { brand: 'traffic', kind: 'car', model, slot });
// Taken traffic cars stay where they are left until the next reset, which removes them.
record.slot = null;
}
setActive(context, record);
return record;
}
/**
* Updates the active vehicle's model: position, wheel spin and steering, motorcycle lean and rider.
* @param {object} context - Game context.
* @param {number} dt - Frame time in seconds.
*/
export function syncActiveVehicle(context, dt) {
const { model, state, spec, kind } = context.vehicle;
placeModel(context.vehicle);
const firstFront = kind === 'bike' ? 1 : 2;
for (const [i, wheel] of model.wheels.entries()) {
wheel.rotation.z -= (state.speed * dt) / spec.wheelRadius;
if (i >= firstFront) wheel.rotation.y = -state.steer;
}
if (kind !== 'bike') return;
const riding = context.mode === 'drive';
model.rider.root.visible = riding;
// Lean into the turn (towards local +z, the right-hand side, when steering right).
const speedShare = Math.min(1, Math.abs(state.speed) / FULL_LEAN_SPEED);
const lean = riding ? state.steer * LEAN_PER_STEER * speedShare * Math.sign(state.speed) : 0;
model.body.rotation.x += (lean - model.body.rotation.x) * Math.min(1, dt * 8);
}
/**
* Footprint box of a garage vehicle.
* @param {object} record - Garage record.
* @returns {object} Box from vehicleBox.
*/
const recordBox = (record) => vehicleBox(record.state, record.spec);
/**
* Footprint box of a traffic car.
* @param {object} npc - Traffic car.
* @returns {object} Box from vehicleBox.
*/
const trafficBox = (npc) =>
vehicleBox({ x: npc.x, z: npc.z, heading: npc.model.group.rotation.y }, TRAFFIC_CAR);
/**
* Pushes the driven vehicle out of another body and slows it by how head-on the hit was.
* @param {object} context - Game context.
* @param {object} box - The other body's box.
* @returns {boolean} Whether the bodies touched.
*/
function bump(context, box) {
const { state, spec } = context.vehicle;
const push = boxOverlap(vehicleBox(state, spec), box);
if (!push) return false;
state.x += push.x;
state.z += push.z;
// 1 when the push points straight against the direction of travel, 0 for a side scrape.
const forwardX = Math.cos(state.heading) * Math.sign(state.speed),
forwardZ = -Math.sin(state.heading) * Math.sign(state.speed);
const headOn = Math.max(0, -(push.x * forwardX + push.z * forwardZ) / push.depth);
if (headOn > 0.5 && Math.abs(state.speed) > 4) context.tone(110, 0.25, 0.08, 'square');
state.speed *= 1 - (1 - HEAD_ON_SPEED_KEPT) * headOn;
context.debugHits.push({ kind: 'vehicle', x: Math.round(state.x), z: Math.round(state.z) });
return true;
}
/**
* Whether a point is close enough to the driven vehicle to bother with a box test.
* @param {object} state - Driven vehicle state.
* @param {{x: number, z: number}} other - Other body's centre.
* @returns {boolean} True when within CONTACT_RANGE.
*/
const inRange = (state, other) => Math.hypot(other.x - state.x, other.z - state.z) < CONTACT_RANGE;
/**
* Resolves the driven vehicle against parked garage vehicles and traffic. Bodies only meet when
* their outlines overlap, so the player keeps full speed through any gap they fit.
* @param {object} context - Game context.
* @param {number} dt - Step length in seconds.
*/
export function collideDriving(context, dt) {
const active = context.vehicle;
for (const record of context.garage.vehicles) {
if (record !== active && inRange(active.state, record.state)) bump(context, recordBox(record));
}
for (const npc of context.traffic.cars) {
if (!npc.model.group.visible || !inRange(active.state, npc)) continue;
if (!bump(context, trafficBox(npc))) {
npc.contact = 0;
continue;
}
// Staying pressed against a traffic car makes it pull away, so the player never gets stuck.
npc.contact = (npc.contact ?? 0) + dt;
if (npc.contact > TRAFFIC_GIVE_WAY) context.traffic.remove(npc);
}
}
/**
* Keeps the walking player out of parked vehicles.
* @param {object} context - Game context.
* @param {{x: number, z: number}} point - Proposed player position.
* @returns {{x: number, z: number}} The position outside every vehicle.
*/
export function walkAroundVehicles(context, point) {
let at = point;
for (const record of context.garage.vehicles) {
if (!inRange(at, record.state)) continue;
at = circleOutOfBox(recordBox(record), at, WALKER_RADIUS) ?? at;
}
return at;
}
/**
* Everything traffic should not drive into: the player and every garage vehicle, with widths so
* traffic only stops for what really blocks its lane.
* @param {object} context - Game context.
* @returns {{x: number, z: number, halfWidth: number}[]} Obstacles.
*/
export function trafficObstacles(context) {
const walker = { x: context.player.x, z: context.player.z, halfWidth: WALKER_RADIUS };
return [
...(context.mode === 'walk' ? [walker] : []),
...context.garage.vehicles.map(({ state, spec }) => ({
x: state.x,
z: state.z,
halfWidth: spec.halfWidth,
})),
];
}
+72 -82
View File
@@ -2,6 +2,8 @@ import * as THREE from 'three';
import data from '../../data/mellat.json'; import data from '../../data/mellat.json';
import { resolveCircle } from '../world/collision.js'; import { resolveCircle } from '../world/collision.js';
import { stepCar } from '../systems/vehicle.js'; import { stepCar } from '../systems/vehicle.js';
import { EXIT_MAX_SPEED } from '../../constants/vehicles.js';
import { collideDriving, nearestRide, takeRide, walkAroundVehicles } from './garage.js';
/** /**
* Runs the look operation for this subsystem. * Runs the look operation for this subsystem.
* @param {*} context - context. * @param {*} context - context.
@@ -30,9 +32,8 @@ export function look(context, dx, dy, k) {
// Touch joystick: x steers / strafes, y walks / throttles. // Touch joystick: x steers / strafes, y walks / throttles.
/** /**
* Runs the toggle car operation for this subsystem. * Gets the player into the nearest vehicle, or out of the current one.
* @param {*} context - context. * @param {object} context - Game context.
* @returns {*} Operation result, when applicable.
*/ */
export // ---------- Car ---------- export // ---------- Car ----------
function toggleCar(context) { function toggleCar(context) {
@@ -41,40 +42,52 @@ function toggleCar(context) {
context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.'); context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.');
return; return;
} }
if (context.mode === 'walk') { if (context.mode === 'walk') getIn(context);
if (Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) > 4.5) else getOut(context);
return; context.refreshHUD();
}
/**
* Gets on or in the closest vehicle within reach, if there is one.
* @param {object} context - Game context.
*/
function getIn(context) {
const ride = nearestRide(context);
if (!ride) return;
const { kind } = takeRide(context, ride);
context.mode = 'drive'; context.mode = 'drive';
context.cam.distance = Math.max(context.cam.distance, 8); context.cam.distance = Math.max(context.cam.distance, kind === 'bike' ? 6 : 8);
context.tone(320, 0.2, 0.05, 'square'); context.tone(320, 0.2, 0.05, 'square');
context.say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.'); const line =
} else { kind === 'bike'
if (Math.abs(context.car.speed) > 3) { ? 'کلاه ایمنی سرت باشه! با موتور می‌شه از لای ماشین‌ها رد شد.'
context.say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.'); : 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.';
context.say('father', line);
}
/**
* Steps out of the current vehicle on its left side, or wherever is free.
* @param {object} context - Game context.
*/
function getOut(context) {
const { state, spec } = context.vehicle;
if (Math.abs(state.speed) > EXIT_MAX_SPEED) {
context.say('father', 'اول نگه دار، بعد پیاده می‌شویم.');
return; return;
} }
// Step out on the driver's (left) side, or wherever is free. const left = { x: -Math.sin(state.heading), z: -Math.cos(state.heading) };
const left = { const step = spec.halfWidth + 1.8;
x: -Math.sin(context.car.heading),
z: -Math.cos(context.car.heading),
};
const spot = resolveCircle( const spot = resolveCircle(
context.collision, context.collision,
context.car.x + left.x * 2.8, state.x + left.x * step,
context.car.z + left.z * 2.8, state.z + left.z * step,
1, 1,
); );
Object.assign(context.player, { Object.assign(context.player, { x: spot.x, z: spot.z, heading: state.heading });
x: spot.x, state.speed = 0;
z: spot.z,
heading: context.car.heading,
});
context.car.speed = 0;
context.mode = 'walk'; context.mode = 'walk';
context.cam.distance = 5.5; context.cam.distance = 5.5;
context.tone(260, 0.2, 0.05, 'square'); context.tone(260, 0.2, 0.05, 'square');
}
context.refreshHUD();
} }
// ---------- Machines, shop and payment ---------- // ---------- Machines, shop and payment ----------
@@ -121,13 +134,40 @@ export function updateWalk(context, dt) {
} }
const mx = Math.cos(context.player.heading) * context.player.speed * dt, const mx = Math.cos(context.player.heading) * context.player.speed * dt,
mz = -Math.sin(context.player.heading) * context.player.speed * dt; mz = -Math.sin(context.player.heading) * context.player.speed * dt;
const p = resolveCircle(context.collision, context.player.x + mx, context.player.z + mz, 1); const p = walkAroundVehicles(
context,
resolveCircle(context.collision, context.player.x + mx, context.player.z + mz, 1),
);
const { minX, minZ, maxX, maxZ } = data.bounds; const { minX, minZ, maxX, maxZ } = data.bounds;
context.player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15); context.player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
context.player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15); context.player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
context.player.gait += dt * context.player.speed * 1.9; context.player.gait += dt * context.player.speed * 1.9;
} }
/**
* Pushes the driven vehicle out of buildings using the circles along its body.
* @param {object} context - Game context.
*/
function keepOutOfBuildings(context) {
const car = context.car;
for (const { along, radius } of context.vehicle.spec.probes) {
const cx = car.x + Math.cos(car.heading) * along,
cz = car.z - Math.sin(car.heading) * along;
const p = resolveCircle(context.collision, cx, cz, radius);
if (!p.hit) continue;
context.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) context.tone(90, 0.2, 0.08, 'square');
car.speed *= 0.35;
}
}
// Collision log for ?debug runs; trimmed so it never grows without bound. // Collision log for ?debug runs; trimmed so it never grows without bound.
/** /**
* Updates drive from the active simulation. * Updates drive from the active simulation.
@@ -148,67 +188,17 @@ export function updateDrive(context, dt) {
1, 1,
); );
// At top speed the car covers almost 2 m per step, so move it in short slices that can't // 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. // tunnel through a thin wall, a fence or another vehicle.
const slices = Math.max(1, Math.ceil((Math.abs(context.car.speed) * dt) / 0.6)); const slices = Math.max(1, Math.ceil((Math.abs(context.car.speed) * dt) / 0.6));
for (let i = 0; i < slices; i++) { for (let i = 0; i < slices; i++) {
stepCar( stepCar(
context.car, context.car,
{ { throttle, steer, handbrake: context.keys.has(' ') },
throttle,
steer,
handbrake: context.keys.has(' '),
},
dt / slices, dt / slices,
context.vehicle.spec,
); );
// Two collision circles along the body keep the nose and tail out of buildings. keepOutOfBuildings(context);
for (const along of [1.3, -1.3]) { collideDriving(context, dt / slices);
const cx = context.car.x + Math.cos(context.car.heading) * along,
cz = context.car.z - Math.sin(context.car.heading) * along;
const p = resolveCircle(context.collision, cx, cz, 1.05);
if (p.hit) {
context.debugHits.push({
kind: 'building',
x: Math.round(cx),
z: Math.round(cz),
v: +context.car.speed.toFixed(1),
});
context.car.x += p.x - cx;
context.car.z += p.z - cz;
if (Math.abs(context.car.speed) > 4) context.tone(90, 0.2, 0.08, 'square');
context.car.speed *= 0.35;
}
}
}
// Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
// against one, it pulls away (respawns out of sight) so you can never get stuck behind it.
for (const npc of context.traffic.cars) {
const dx = context.car.x - npc.x,
dz = context.car.z - npc.z,
d = Math.hypot(dx, dz);
if (d >= 3.4 || d === 0) {
if (npc.contact) npc.contact = 0;
continue;
}
context.debugHits.push({
kind: 'traffic',
x: Math.round(context.car.x),
z: Math.round(context.car.z),
v: +context.car.speed.toFixed(1),
npcDir: npc.dir,
});
context.car.x = npc.x + (dx / d) * 3.4;
context.car.z = npc.z + (dz / d) * 3.4;
const towards =
(Math.cos(context.car.heading) * -dx - Math.sin(context.car.heading) * -dz) *
Math.sign(context.car.speed) >
0;
if (towards && Math.abs(context.car.speed) > 4) context.tone(110, 0.25, 0.08, 'square');
if (towards) context.car.speed *= 0.4;
npc.contact = (npc.contact ?? 0) + dt;
if (npc.contact > 0.8) {
npc.contact = 0;
context.traffic.remove(npc);
}
} }
const { minX, minZ, maxX, maxZ } = data.bounds; const { minX, minZ, maxX, maxZ } = data.bounds;
context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15); context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15);
+14 -6
View File
@@ -5,6 +5,7 @@ import { FACTORY } from '../../constants/locations.js';
import { isOpen, formatClock, fa } from '../economy/gameState.js'; import { isOpen, formatClock, fa } from '../economy/gameState.js';
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js'; import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
import { kmh } from '../systems/vehicle.js'; import { kmh } from '../systems/vehicle.js';
import { nearestRide } from './garage.js';
import { drawMinimap } from '../world/mapRender.js'; import { drawMinimap } from '../world/mapRender.js';
import { bearing, formatDistance } from '../../utils/geo.js'; import { bearing, formatDistance } from '../../utils/geo.js';
/** /**
@@ -191,12 +192,22 @@ export function refreshHUD(context) {
}); });
context.ui.publishGame(context.state); context.ui.publishGame(context.state);
context.ui.patch('mode-label', { context.ui.patch('mode-label', {
text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده', text: modeLabel(context),
}); });
context.ui.patch('speed-stat', { context.ui.patch('speed-stat', {
hidden: context.mode !== 'drive', hidden: context.mode !== 'drive',
}); });
} }
/**
* Describes how the player is travelling, for the HUD.
* @param {object} context - Game context.
* @returns {string} Persian label.
*/
function modeLabel(context) {
if (context.mode !== 'drive') return 'پیاده';
if (context.vehicle.kind === 'bike') return 'روی موتور · F برای پیاده شدن';
return `در ${context.vehicle.label} · F برای پیاده شدن`;
}
/** /**
* Updates prompt from the active simulation. * Updates prompt from the active simulation.
* @param {*} context - context. * @param {*} context - context.
@@ -213,16 +224,13 @@ export function updatePrompt(context) {
} }
let text = ''; let text = '';
const station = context.nearStation(); const station = context.nearStation();
const ride = context.mode === 'walk' ? nearestRide(context) : null;
if (!context.state.busy && context.payment.phase === 'idle') { if (!context.state.busy && context.payment.phase === 'idle') {
if (station) if (station)
text = isOpen(station.def, context.state.minutes) text = isOpen(station.def, context.state.minutes)
? `E · خرید از روبات مارکت · ${station.def.name}` ? `E · خرید از روبات مارکت · ${station.def.name}`
: `دستگاه بسته است · ${context.hoursText(station.def)}`; : `دستگاه بسته است · ${context.hoursText(station.def)}`;
else if ( else if (ride) text = `F · سوار ${ride.label} شو`;
context.mode === 'walk' &&
Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5
)
text = 'F · سوار ماشین شو';
else if ( else if (
context.mode === 'drive' && context.mode === 'drive' &&
Math.abs(context.car.speed) < 1 && Math.abs(context.car.speed) < 1 &&
+4 -20
View File
@@ -2,6 +2,7 @@ import * as THREE from 'three';
import iran from '../../data/iran.json'; import iran from '../../data/iran.json';
import { clearFraction } from '../world/collision.js'; import { clearFraction } from '../world/collision.js';
import { drawIran } from '../world/mapRender.js'; import { drawIran } from '../world/mapRender.js';
import { syncActiveVehicle, trafficObstacles, updateGarage } from './garage.js';
/** /**
* Runs the resize operation for this subsystem. * Runs the resize operation for this subsystem.
* @param {*} context - context. * @param {*} context - context.
@@ -109,20 +110,7 @@ export function frame(context) {
else context.updateWalk(step); else context.updateWalk(step);
context.updatePayment(step); context.updatePayment(step);
context.updateDelivery(step); context.updateDelivery(step);
context.traffic.update( context.traffic.update(step, trafficObstacles(context), context.player);
step,
[
{
x: context.player.x,
z: context.player.z,
},
{
x: context.car.x,
z: context.car.z,
},
],
context.player,
);
} }
context.updateRoute(dt); context.updateRoute(dt);
context.updateHints(); context.updateHints();
@@ -147,6 +135,7 @@ export function frame(context) {
delivery: context.deliveryTimer > 0, delivery: context.deliveryTimer > 0,
}); });
context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position); context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position);
updateGarage(context);
context.showroom.update({ context.showroom.update({
player: context.player, player: context.player,
time: context.elapsed, time: context.elapsed,
@@ -154,12 +143,7 @@ export function frame(context) {
enabled: context.mode === 'walk' && !context.real.on, enabled: context.mode === 'walk' && !context.real.on,
talking: context.ui.read('showroom').open, talking: context.ui.read('showroom').open,
}); });
context.carModel.group.position.set(context.car.x, 0, context.car.z); syncActiveVehicle(context, dt);
context.carModel.group.rotation.y = context.car.heading;
for (const [i, wheel] of context.carModel.wheels.entries()) {
wheel.rotation.z -= (context.car.speed * dt) / 0.36;
if (i >= 2) wheel.rotation.y = -context.car.steer;
}
for (const s of context.stations) for (const s of context.stations)
s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25; s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25;
context.updateReachCircles(); context.updateReachCircles();
+2 -1
View File
@@ -2,6 +2,7 @@ import { addPolygon, resolveCircle } from '../world/collision.js';
import { createState } from '../economy/gameState.js'; import { createState } from '../economy/gameState.js';
import { createPayment } from '../economy/payment.js'; import { createPayment } from '../economy/payment.js';
import { createVendingMachine } from '../models/vending.js'; import { createVendingMachine } from '../models/vending.js';
import { resetGarage } from './garage.js';
/** /**
* Runs the place station operation for this subsystem. * Runs the place station operation for this subsystem.
* @param {*} context - context. * @param {*} context - context.
@@ -78,9 +79,9 @@ export function reset(context) {
speed: 0, speed: 0,
gait: 0, gait: 0,
}; };
resetGarage(context);
context.factory.reset(); context.factory.reset();
for (const t of context.factory.talks) t.done = false; for (const t of context.factory.talks) t.done = false;
context.car = context.spawnCar();
context.mode = 'walk'; context.mode = 'walk';
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos). // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
context.cam = { context.cam = {
+10 -8
View File
@@ -20,9 +20,8 @@ import { isOpen, formatClock } from '../economy/gameState.js';
import { activeMission } from '../economy/scoring.js'; import { activeMission } from '../economy/scoring.js';
import { createFamily } from '../models/characters.js'; import { createFamily } from '../models/characters.js';
import { createFactory } from '../models/factory.js'; import { createFactory } from '../models/factory.js';
import { createCar } from '../systems/vehicle.js';
import { createTraffic } from '../systems/traffic.js'; import { createTraffic } from '../systems/traffic.js';
import { createSportSedan } from '../models/carModels.js'; import { createGarage } from './garage.js';
import { createMapAssets } from '../world/mapRender.js'; import { createMapAssets } from '../world/mapRender.js';
import { formatDistance, toLocal } from '../../utils/geo.js'; import { formatDistance, toLocal } from '../../utils/geo.js';
@@ -402,12 +401,11 @@ export async function initializeFamily(context) {
}); });
context.guide.group.visible = false; context.guide.group.visible = false;
context.scene.add(context.guide.group); context.scene.add(context.guide.group);
context.carModel = createSportSedan('#1d3f78');
context.scene.add(context.carModel.group);
context.state = undefined; context.state = undefined;
context.payment = undefined; context.payment = undefined;
context.player = undefined; context.player = undefined;
context.car = undefined; context.car = undefined;
context.vehicle = undefined;
context.mode = undefined; context.mode = undefined;
context.cam = undefined; context.cam = undefined;
context.waypoint = undefined; context.waypoint = undefined;
@@ -423,9 +421,9 @@ export async function initializeFamily(context) {
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
export async function initializeSpawnCar(context) { export async function initializeSpawnCar(context) {
// The family car waits in the factory yard right beside them, pointing at the gate. // The family car waits in the factory yard right beside them, pointing at the gate; the staff
context.spawnCar = () => // cars and motorcycles are parked in front of the office.
createCar(context.factory.car.x, context.factory.car.z, context.factory.car.heading); createGarage(context);
// ---------- Input ---------- // ---------- Input ----------
context.isTouch = matchMedia('(pointer: coarse)').matches; context.isTouch = matchMedia('(pointer: coarse)').matches;
context.anyDialogOpen = () => context.anyDialogOpen = () =>
@@ -1091,7 +1089,7 @@ export async function initializeArMode(context) {
context.imagery.group, context.imagery.group,
context.factory.group, context.factory.group,
context.traffic.group, context.traffic.group,
context.carModel.group, context.garage.group,
context.parkGate, context.parkGate,
...context.showroom.samples.map((sample) => sample.machine.group), ...context.showroom.samples.map((sample) => sample.machine.group),
]; ];
@@ -1197,6 +1195,10 @@ export async function exposeDebugTools(context) {
real: context.real, real: context.real,
keys: context.keys, keys: context.keys,
traffic: context.traffic, traffic: context.traffic,
garage: context.garage,
get vehicle() {
return context.vehicle;
},
}; };
} }
/** /**
+31 -2
View File
@@ -119,6 +119,13 @@ const COLORS = [
// 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
// Body size of a traffic car (createCarModel), for collisions and lane checks.
export const TRAFFIC_CAR = { halfLength: 2.2, halfWidth: 0.88 };
// Extra side clearance a traffic car wants before it drives past something.
const PASSING_MARGIN = 0.25;
// Half width assumed for an obstacle that doesn't give one, such as a pedestrian.
const DEFAULT_OBSTACLE_HALF_WIDTH = 0.4;
/** /**
* Creates traffic resources for the game. * Creates traffic resources for the game.
* @param {*} scene - scene. * @param {*} scene - scene.
@@ -238,11 +245,14 @@ export function createTraffic(scene, data, count = 28) {
cars = []; cars = [];
scene.add(group); scene.add(group);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const model = createCarModel(COLORS[i % COLORS.length]); const color = COLORS[i % COLORS.length];
const model = createCarModel(color);
group.add(model.group); group.add(model.group);
model.group.visible = false; model.group.visible = false;
cars.push({ cars.push({
model, model,
color,
halfWidth: TRAFFIC_CAR.halfWidth,
road: null, road: null,
d: 0, d: 0,
dir: 1, dir: 1,
@@ -263,6 +273,20 @@ export function createTraffic(scene, data, count = 28) {
car.x = car.z = 1e9; car.x = car.z = 1e9;
car.model.group.visible = false; car.model.group.visible = false;
} }
/**
* Hands a traffic car's model over to the player, who takes it as their own vehicle. The traffic
* slot gets a fresh model of the same colour and respawns out of sight later.
* @param {object} car - The traffic car being taken.
* @returns {{group: THREE.Group, wheels: THREE.Group[]}} The model that now belongs to the player.
*/
function adopt(car) {
const { model } = car;
group.remove(model.group);
car.model = createCarModel(car.color);
group.add(car.model.group);
remove(car);
return model;
}
let cursor = 0; let cursor = 0;
/** /**
* Updates update from the active simulation. * Updates update from the active simulation.
@@ -290,7 +314,11 @@ export function createTraffic(scene, data, count = 28) {
const ox = o.x - car.x, const ox = o.x - car.x,
oz = o.z - car.z, oz = o.z - car.z,
along = ox * fx + oz * fz; along = ox * fx + oz * fz;
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2; // Only something that really overlaps the lane stops the car, so a motorcycle
// filtering between two lanes doesn't bring traffic to a halt.
const reach =
TRAFFIC_CAR.halfWidth + (o.halfWidth ?? DEFAULT_OBSTACLE_HALF_WIDTH) + PASSING_MARGIN;
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < reach;
}); });
const speed = ahead ? 0 : car.speed; const speed = ahead ? 0 : car.speed;
car.d += speed * car.dir * dt; car.d += speed * car.dir * dt;
@@ -317,5 +345,6 @@ export function createTraffic(scene, data, count = 28) {
cars, cars,
update, update,
remove, remove,
adopt,
}; };
} }
+68 -24
View File
@@ -1,5 +1,9 @@
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y. // Arcade vehicle physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's
// maxSpeed 55.6 m/s is 200 km/h. // rotation.y. A spec holds the handling and the body size used for collisions:
// halfLength/halfWidth is the footprint box, `probes` are circles along the body that keep it out
// of buildings, `wheelRadius` turns speed into wheel spin.
// Cars: maxSpeed 55.6 m/s is 200 km/h.
export const CAR = { export const CAR = {
maxSpeed: 55.6, maxSpeed: 55.6,
maxReverse: 6, maxReverse: 6,
@@ -9,13 +13,40 @@ export const CAR = {
maxSteer: 0.55, maxSteer: 0.55,
wheelBase: 2.7, wheelBase: 2.7,
radius: 1.6, radius: 1.6,
halfLength: 2.35,
halfWidth: 0.98,
wheelRadius: 0.36,
probes: [
{ along: 1.3, radius: 1.05 },
{ along: -1.3, radius: 1.05 },
],
}; };
// Apache motorcycle: 130 km/h top speed, quick off the line, narrow enough to filter between cars.
export const BIKE = {
maxSpeed: 36.1,
maxReverse: 1.5,
accel: 11,
brake: 13,
drag: 1.4,
maxSteer: 0.6,
wheelBase: 1.35,
radius: 0.6,
halfLength: 1.0,
halfWidth: 0.4,
wheelRadius: 0.31,
probes: [
{ along: 0.55, radius: 0.42 },
{ along: -0.55, radius: 0.42 },
],
};
/** /**
* Creates car resources for the game. * Creates the moving state of a vehicle.
* @param {*} x - x. * @param {number} x - World x.
* @param {*} z - z. * @param {number} z - World z.
* @param {*} heading - heading. * @param {number} heading - Heading in radians.
* @returns {*} Operation result, when applicable. * @returns {{x: number, z: number, heading: number, speed: number, steer: number}} The state.
*/ */
export function createCar(x, z, heading = 0) { export function createCar(x, z, heading = 0) {
return { return {
@@ -26,32 +57,45 @@ export function createCar(x, z, heading = 0) {
steer: 0, steer: 0,
}; };
} }
/** /**
* Runs the step car operation for this subsystem. * Applies throttle and brakes to the speed for one step.
* @param {*} car - car. * @param {object} car - Vehicle state, changed in place.
* @param {*} options1 - Operation options. * @param {{throttle: number, handbrake: boolean}} input - Driver input.
* @param {*} dt - dt. * @param {{dt: number, spec: object}} step - Time step and vehicle spec.
* @returns {*} Operation result, when applicable.
*/ */
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) { function applyThrottle(car, { throttle, handbrake }, { dt, spec }) {
car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6); if (throttle > 0) car.speed += (car.speed < 0 ? spec.brake : spec.accel) * throttle * dt;
if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt;
else if (throttle < 0) else if (throttle < 0)
car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * 0.6) * -throttle * dt; car.speed += (car.speed > 0 ? -spec.brake : -spec.accel * 0.6) * -throttle * dt;
else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt); else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), spec.drag * dt);
if (handbrake) if (handbrake)
car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt); car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), spec.brake * 1.4 * dt);
car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed)); car.speed = Math.max(-spec.maxReverse, Math.min(spec.maxSpeed, car.speed));
// High speed narrows the usable steering angle so the car stays controllable. }
/**
* Advances a vehicle by one physics step.
* @param {object} car - Vehicle state, changed in place.
* @param {{throttle?: number, steer?: number, handbrake?: boolean}} input - Driver input.
* @param {number} dt - Step length in seconds.
* @param {object} spec - Handling spec, CAR by default.
* @returns {object} The same vehicle state.
*/
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt, spec = CAR) {
car.steer += (steer * spec.maxSteer - car.steer) * Math.min(1, dt * 6);
applyThrottle(car, { throttle, handbrake }, { dt, spec });
// High speed narrows the usable steering angle so the vehicle stays controllable.
const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045); const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
car.heading -= ((car.speed * Math.tan(steerAngle)) / CAR.wheelBase) * dt; car.heading -= ((car.speed * Math.tan(steerAngle)) / spec.wheelBase) * dt;
car.x += Math.cos(car.heading) * car.speed * dt; car.x += Math.cos(car.heading) * car.speed * dt;
car.z -= Math.sin(car.heading) * car.speed * dt; car.z -= Math.sin(car.heading) * car.speed * dt;
return car; return car;
} }
/** /**
* Runs the kmh operation for this subsystem. * Converts a vehicle's speed to whole km/h for the HUD.
* @param {*} car - car. * @param {{speed: number}} car - Vehicle state.
* @returns {*} Operation result, when applicable. * @returns {number} Speed in km/h.
*/ */
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6); export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
+98
View File
@@ -0,0 +1,98 @@
// Oriented-box collisions between vehicles, so two bodies only touch when their outlines really
// meet. A narrow motorcycle can therefore pass between two cars with a hand's width to spare.
/**
* Builds the footprint box of a vehicle on the ground plane.
* @param {{x: number, z: number, heading: number}} pose - Centre and heading (Three rotation.y).
* @param {{halfLength: number, halfWidth: number}} size - Half extents along and across the body.
* @returns {object} Box with centre, forward/side unit axes and half extents.
*/
export function vehicleBox({ x, z, heading }, { halfLength, halfWidth }) {
return {
x,
z,
// Heading h faces (cos h, -sin h); the side axis is that rotated by 90°.
forward: { x: Math.cos(heading), z: -Math.sin(heading) },
side: { x: Math.sin(heading), z: Math.cos(heading) },
halfLength,
halfWidth,
};
}
/**
* Half the length of a box's shadow on an axis.
* @param {object} box - Box from vehicleBox.
* @param {{x: number, z: number}} axis - Unit axis.
* @returns {number} Projected radius.
*/
function projectedRadius(box, axis) {
const along = Math.abs(box.forward.x * axis.x + box.forward.z * axis.z),
across = Math.abs(box.side.x * axis.x + box.side.z * axis.z);
return box.halfLength * along + box.halfWidth * across;
}
/**
* Tests two boxes with the separating-axis theorem.
* @param {object} a - The box to move.
* @param {object} b - The box it hits.
* @returns {{x: number, z: number, depth: number} | null} The shortest push that moves `a` out of
* `b`, or null when they don't touch.
*/
export function boxOverlap(a, b) {
const dx = b.x - a.x,
dz = b.z - a.z;
let best = null;
for (const axis of [a.forward, a.side, b.forward, b.side]) {
const distance = dx * axis.x + dz * axis.z;
const depth = projectedRadius(a, axis) + projectedRadius(b, axis) - Math.abs(distance);
// One gap on any axis means the boxes are apart.
if (depth <= 0) return null;
if (best && depth >= best.depth) continue;
const away = distance > 0 ? -1 : 1;
best = { x: axis.x * away * depth, z: axis.z * away * depth, depth };
}
return best;
}
/**
* Pushes a walking circle (the player) out of a box.
* @param {object} box - Box from vehicleBox.
* @param {{x: number, z: number}} point - Circle centre.
* @param {number} radius - Circle radius.
* @returns {{x: number, z: number} | null} The corrected centre, or null when there is no contact.
*/
export function circleOutOfBox(box, point, radius) {
const dx = point.x - box.x,
dz = point.z - box.z;
const along = dx * box.forward.x + dz * box.forward.z,
across = dx * box.side.x + dz * box.side.z;
const nearAlong = Math.max(-box.halfLength, Math.min(box.halfLength, along)),
nearAcross = Math.max(-box.halfWidth, Math.min(box.halfWidth, across));
const gapAlong = along - nearAlong,
gapAcross = across - nearAcross,
gap = Math.hypot(gapAlong, gapAcross);
if (gap >= radius) return null;
if (gap > 0) {
const push = (radius - gap) / gap;
return toWorld(box, along + gapAlong * push, across + gapAcross * push);
}
// The centre is inside the box: leave through the nearest side.
const outAlong = box.halfLength + radius - Math.abs(along),
outAcross = box.halfWidth + radius - Math.abs(across);
if (outAlong < outAcross) return toWorld(box, along + Math.sign(along || 1) * outAlong, across);
return toWorld(box, along, across + Math.sign(across || 1) * outAcross);
}
/**
* Converts box-local coordinates back to world space.
* @param {object} box - Box from vehicleBox.
* @param {number} along - Distance along the forward axis.
* @param {number} across - Distance along the side axis.
* @returns {{x: number, z: number}} World position.
*/
function toWorld(box, along, across) {
return {
x: box.x + box.forward.x * along + box.side.x * across,
z: box.z + box.forward.z * along + box.side.z * across,
};
}
+121
View File
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Scene } from 'three';
import { boxOverlap, circleOutOfBox, vehicleBox } from '../src/game/world/vehicleCollision.js';
import { BIKE, CAR, createCar, kmh, stepCar } from '../src/game/systems/vehicle.js';
import { createTraffic, TRAFFIC_CAR } from '../src/game/systems/traffic.js';
import { createLuxuryCar } from '../src/game/models/luxuryCars.js';
import { createMotorcycle } from '../src/game/models/motorcycle.js';
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
// Two traffic cars side by side on a 9 m road, lanes 4.5 m apart (centres at ±2.25).
const leftCar = vehicleBox({ x: 0, z: -2.25, heading: 0 }, TRAFFIC_CAR);
const rightCar = vehicleBox({ x: 0, z: 2.25, heading: Math.PI }, TRAFFIC_CAR);
test('A motorcycle filters between two cars without touching them', () => {
const bike = createMotorcycle();
const between = vehicleBox({ x: 0, z: 0, heading: 0 }, bike.size);
assert.equal(boxOverlap(between, leftCar), null);
assert.equal(boxOverlap(between, rightCar), null);
// Regression: the old 3.4 m circle test counted this as a crash and stopped the rider.
assert.ok(Math.hypot(0, 2.25) < 3.4);
});
test('Vehicles only collide when their bodies really overlap', () => {
const car = (z) => vehicleBox({ x: 0, z, heading: 0 }, { halfLength: 2.3, halfWidth: 0.95 });
assert.equal(boxOverlap(car(0), leftCar), null, '0.4 m gap: no contact');
const hit = boxOverlap(car(-0.5), leftCar);
assert.ok(hit, 'bodies overlap by 0.08 m');
assert.ok(Math.abs(hit.depth - 0.08) < 1e-9);
assert.ok(hit.z > 0 && Math.abs(hit.x) < 1e-9, 'pushed sideways, away from the other car');
const nose = boxOverlap(vehicleBox({ x: -4.3, z: -2.25, heading: 0 }, TRAFFIC_CAR), leftCar);
assert.ok(nose && nose.x < 0, 'rear-ending pushes back along the road');
const crossing = vehicleBox({ x: 0, z: -5.4, heading: Math.PI / 2 }, TRAFFIC_CAR);
assert.equal(boxOverlap(crossing, leftCar), null, 'a car crossing 7 cm away does not clip it');
});
test('A walker is pushed out of a parked vehicle, even from inside it', () => {
const parked = vehicleBox({ x: 10, z: 0, heading: 0.7 }, { halfLength: 2.3, halfWidth: 1 });
assert.equal(circleOutOfBox(parked, { x: 20, z: 0 }, 0.45), null);
for (const point of [
{ x: 10, z: 0.2 },
{ x: 11.5, z: -1.4 },
]) {
const out = circleOutOfBox(parked, point, 0.45);
assert.ok(out, 'contact');
const again = circleOutOfBox(parked, out, 0.449);
assert.equal(again, null, 'the corrected point is clear of the box');
}
});
test('Motorcycles top out at 130 km/h and turn tighter than cars', () => {
const bike = createCar(0, 0, 0),
car = createCar(0, 0, 0);
for (let i = 0; i < 900; i++) stepCar(bike, { throttle: 1 }, 1 / 30, BIKE);
assert.equal(kmh(bike), 130);
bike.speed = car.speed = 8;
for (let i = 0; i < 15; i++) {
stepCar(bike, { steer: 1 }, 1 / 30, BIKE);
stepCar(car, { steer: 1 }, 1 / 30, CAR);
}
assert.ok(bike.heading < car.heading, 'the shorter wheelbase turns faster');
});
test('Luxury cars and the motorcycle report footprints that match their size', () => {
for (const brand of ['bugatti', 'lamborghini', 'mazda', 'nissan']) {
const model = createLuxuryCar(brand);
assert.equal(model.wheels.length, 4, brand);
assert.ok(model.size.halfLength > 2.1 && model.size.halfLength < 2.6, brand);
assert.ok(model.size.halfWidth > 0.9 && model.size.halfWidth < 1.15, brand);
}
const bike = createMotorcycle();
assert.equal(bike.wheels.length, 2);
assert.ok(bike.size.halfWidth < 0.45 && bike.size.halfLength < 1.1);
});
/**
* Finds an active traffic car with no other traffic car within 20 m.
* @param {object} traffic - Traffic from createTraffic.
* @returns {object | undefined} The car.
*/
function loneCar(traffic) {
return traffic.cars.find(
(car) =>
car.road && traffic.cars.every((o) => o === car || Math.hypot(o.x - car.x, o.z - car.z) > 20),
);
}
test('Traffic stops for what blocks its lane but keeps going past a filtering motorcycle', () => {
const traffic = createTraffic(new Scene(), data, 28),
around = { x: -10008, z: -15049 };
for (let i = 0; i < 200; i++) traffic.update(1 / 30, [], around);
const car = loneCar(traffic);
assert.ok(car, 'a traffic car on its own');
const heading = car.model.group.rotation.y;
const forward = { x: Math.cos(heading), z: -Math.sin(heading) },
side = { x: Math.sin(heading), z: Math.cos(heading) };
// An obstacle 5 m ahead, offset sideways by `offset`.
const at = (offset, halfWidth) => ({
x: car.x + forward.x * 5 + side.x * offset,
z: car.z + forward.z * 5 + side.z * offset,
halfWidth,
});
const before = car.d;
traffic.update(1 / 30, [at(0.5, BIKE.halfWidth)], around);
assert.equal(car.d, before, 'a bike in the lane stops the car');
traffic.update(1 / 30, [at(1.8, BIKE.halfWidth)], around);
assert.notEqual(car.d, before, 'a bike on the lane edge does not');
});
test('Taking a traffic car hands its model to the player and refills the slot', () => {
const traffic = createTraffic(new Scene(), data, 4);
for (let i = 0; i < 60; i++) traffic.update(1 / 30, [], { x: -10008, z: -15049 });
const car = traffic.cars.find((c) => c.road);
const model = traffic.adopt(car);
assert.equal(model.group.parent, null, 'no longer part of traffic');
assert.notEqual(car.model, model);
assert.equal(car.model.group.parent, traffic.group);
assert.equal(car.road, null, 'the slot respawns elsewhere later');
});