feat: add drivable black luxury cars, Apache motorcycles and lane splitting
- 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:
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
پنج بخش تولید: خمکاری، برش لیزر، رنگکاری، یخچالسازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاههای آماده، بستهبندی و بارگیری. کامیون هشت بسته را بار میزند، خارج میشود، مدتی غایب میماند، بازمیگردد و دندهعقب به سکو میرسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمیدهد.
|
پنج بخش تولید: خمکاری، برش لیزر، رنگکاری، یخچالسازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاههای آماده، بستهبندی و بارگیری. کامیون هشت بسته را بار میزند، خارج میشود، مدتی غایب میماند، بازمیگردد و دندهعقب به سکو میرسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمیدهد.
|
||||||
|
|
||||||
مدیر با وانت سفید به سبک KMC T8 وارد میشود، پارک میکند، تا دفتر راه میرود و مینشیند؛ گفتوگوی او بعد از نشستن فعال میشود. گفتوگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیکشدن فعال میشود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجعاند.
|
مدیر با بوگاتی مشکی وارد میشود، پارک میکند (از آن به بعد ماشینش سوارشدنی است)، تا دفتر راه میرود و مینشیند؛ گفتوگوی او بعد از نشستن فعال میشود. گفتوگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیکشدن فعال میشود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجعاند.
|
||||||
|
|
||||||
## خارج از دامنهٔ فعلی
|
## خارج از دامنهٔ فعلی
|
||||||
|
|
||||||
|
|||||||
@@ -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 پس از تماس طولانی حذف/بازپدید میشود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
|
||||||
|
|
||||||
## پول و محصول
|
## پول و محصول
|
||||||
|
|
||||||
|
|||||||
@@ -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 دوربین |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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,42 +42,54 @@ 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.mode = 'drive';
|
|
||||||
context.cam.distance = Math.max(context.cam.distance, 8);
|
|
||||||
context.tone(320, 0.2, 0.05, 'square');
|
|
||||||
context.say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.');
|
|
||||||
} else {
|
|
||||||
if (Math.abs(context.car.speed) > 3) {
|
|
||||||
context.say('father', 'اول ماشین را نگه دار، بعد پیاده میشویم.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Step out on the driver's (left) side, or wherever is free.
|
|
||||||
const left = {
|
|
||||||
x: -Math.sin(context.car.heading),
|
|
||||||
z: -Math.cos(context.car.heading),
|
|
||||||
};
|
|
||||||
const spot = resolveCircle(
|
|
||||||
context.collision,
|
|
||||||
context.car.x + left.x * 2.8,
|
|
||||||
context.car.z + left.z * 2.8,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
Object.assign(context.player, {
|
|
||||||
x: spot.x,
|
|
||||||
z: spot.z,
|
|
||||||
heading: context.car.heading,
|
|
||||||
});
|
|
||||||
context.car.speed = 0;
|
|
||||||
context.mode = 'walk';
|
|
||||||
context.cam.distance = 5.5;
|
|
||||||
context.tone(260, 0.2, 0.05, 'square');
|
|
||||||
}
|
|
||||||
context.refreshHUD();
|
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.cam.distance = Math.max(context.cam.distance, kind === 'bike' ? 6 : 8);
|
||||||
|
context.tone(320, 0.2, 0.05, 'square');
|
||||||
|
const line =
|
||||||
|
kind === 'bike'
|
||||||
|
? 'کلاه ایمنی سرت باشه! با موتور میشه از لای ماشینها رد شد.'
|
||||||
|
: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const left = { x: -Math.sin(state.heading), z: -Math.cos(state.heading) };
|
||||||
|
const step = spec.halfWidth + 1.8;
|
||||||
|
const spot = resolveCircle(
|
||||||
|
context.collision,
|
||||||
|
state.x + left.x * step,
|
||||||
|
state.z + left.z * step,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
Object.assign(context.player, { x: spot.x, z: spot.z, heading: state.heading });
|
||||||
|
state.speed = 0;
|
||||||
|
context.mode = 'walk';
|
||||||
|
context.cam.distance = 5.5;
|
||||||
|
context.tone(260, 0.2, 0.05, 'square');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Machines, shop and payment ----------
|
// ---------- Machines, shop and payment ----------
|
||||||
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
|
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
|
||||||
/**
|
/**
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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,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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user