diff --git a/docs/01-product-and-scenario.md b/docs/01-product-and-scenario.md index 25222b4..cdbd7cf 100644 --- a/docs/01-product-and-scenario.md +++ b/docs/01-product-and-scenario.md @@ -28,7 +28,7 @@ پنج بخش تولید: خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاه‌های آماده، بسته‌بندی و بارگیری. کامیون هشت بسته را بار می‌زند، خارج می‌شود، مدتی غایب می‌ماند، بازمی‌گردد و دنده‌عقب به سکو می‌رسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمی‌دهد. -مدیر با وانت سفید به سبک KMC T8 وارد می‌شود، پارک می‌کند، تا دفتر راه می‌رود و می‌نشیند؛ گفت‌وگوی او بعد از نشستن فعال می‌شود. گفت‌وگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیک‌شدن فعال می‌شود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجع‌اند. +مدیر با بوگاتی مشکی وارد می‌شود، پارک می‌کند (از آن به بعد ماشینش سوارشدنی است)، تا دفتر راه می‌رود و می‌نشیند؛ گفت‌وگوی او بعد از نشستن فعال می‌شود. گفت‌وگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیک‌شدن فعال می‌شود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجع‌اند. ## خارج از دامنهٔ فعلی diff --git a/docs/02-game-rules.md b/docs/02-game-rules.md index 813e785..a5130e0 100644 --- a/docs/02-game-rules.md +++ b/docs/02-game-rules.md @@ -11,11 +11,13 @@ - موبایل joystick و دکمه‌های F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت می‌کشد. - سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرم‌سازی است. برخورد دایره‌ای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود می‌شود. -## ماشین +## ماشین و موتور + +همهٔ وسیله‌های حیاط کارخانه مشکی‌اند و سوارشدنی: بی‌ام‌وی خانواده کنار نقطهٔ شروع، لامبورگینی، مزدا و نیسان در پارکینگ کارکنان، شش موتور آپاچی کنار دفتر، و بوگاتی آقای محمدی پس از اینکه پارک کرد. هر ماشین ترافیک شهر هم با F گرفته می‌شود و تا reset همان‌جا که رها شده می‌ماند. کامیون بارگیری تزئینی است و سوار نمی‌شود. همه در `runtime/garage.js` نگه‌داری می‌شوند. سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیاده‌شدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیاده‌شدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان می‌دهد. سمت خروج راننده بررسی برخورد می‌شود. -ثابت‌های `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر می‌شود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. تصادف ترافیک سرعت را کم می‌کند و NPC پس از تماس طولانی حذف/بازپدید می‌شود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد. +ثابت‌های `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر می‌شود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. ثابت‌های `BIKE`: سقف ۳۶٫۱ (حدود ۱۳۰ km/h)، شتاب ۱۱، wheelBase برابر ۱٫۳۵؛ موتور در پیچ خم می‌شود. برخورد بین وسیله‌ها با جعبهٔ جهت‌دار به اندازهٔ واقعی مدل (SAT در `world/vehicleCollision.js`) است، پس فقط وقتی بدنه‌ها واقعاً به هم برسند سرعت کم می‌شود و موتور می‌تواند لایی بکشد. کاهش سرعت به زاویهٔ برخورد بستگی دارد: شاخ‌به‌شاخ ۶۰٪، مالیدن کناری تقریباً هیچ. ترافیک فقط برای چیزی که واقعاً در عرض خطش باشد ترمز می‌کند و NPC پس از تماس طولانی حذف/بازپدید می‌شود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد. ## پول و محصول diff --git a/docs/03-architecture.md b/docs/03-architecture.md index aeb93b8..255912b 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -16,8 +16,12 @@ JavaScript ES Modules، Three.js/WebGL، Vite، HTML و CSS فارسی RTL و Ca | characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن | | factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون | | staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh | -| car-models.js | سدان خانواده، pickup مدیر و کامیون | -| vehicle.js | state و فیزیک آرکید ماشین | +| car-models.js | سدان خانواده، کامیون و helperهای مشترک بدنه | +| luxuryCars.js | ماشین‌های لوکس مشکی به سبک بوگاتی، لامبورگینی، مزدا و نیسان | +| motorcycle.js | موتور آپاچی با راکب و بدنهٔ خم‌شونده | +| vehicle.js | state و فیزیک آرکید ماشین (`CAR`) و موتور (`BIKE`) | +| vehicleCollision.js | برخورد جعبهٔ جهت‌دار وسیله‌ها و هل‌دادن پیاده از وسیلهٔ پارک‌شده | +| garage.js | فهرست وسیله‌های سوارشدنی، سوار/پیاده، برخورد رانندگی و reset | | traffic.js | ماشین‌های NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده | | city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility | | collision.js | spatial grid چندضلعی‌ها، resolveCircle و clearFraction دوربین | diff --git a/src/constants/vehicles.js b/src/constants/vehicles.js new file mode 100644 index 0000000..aec7d34 --- /dev/null +++ b/src/constants/vehicles.js @@ -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; diff --git a/src/game/models/carModels.js b/src/game/models/carModels.js index fb0663d..de09c4d 100644 --- a/src/game/models/carModels.js +++ b/src/game/models/carModels.js @@ -1,7 +1,7 @@ import * as THREE from 'three'; -// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like -// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones. +// Detailed vehicles for the family and the factory, plus the shared helpers luxuryCars.js builds +// 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. @@ -9,7 +9,7 @@ import * as THREE from 'three'; * @param {*} extra - extra. * @returns {*} Operation result, when applicable. */ -const mat = (color, extra = {}) => +export const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({ color, roughness: 0.45, @@ -25,7 +25,7 @@ const mat = (color, extra = {}) => * @param {*} bevel - bevel. * @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(); points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y))); const geometry = new THREE.ExtrudeGeometry(shape, { @@ -52,7 +52,7 @@ function extrudeProfile(points, width, material, bevel = 0.1) { * @param {*} top - top. * @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(); shape.moveTo(rear[0][0], rear[0][1]); 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); 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. * @param {*} group - group. @@ -76,7 +85,7 @@ function bodyOutline(front, rear, arches, radius, top) { * @param {*} spokes - spokes. * @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', { 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 * cab, origin at the bed's centre) where cargo is placed. 8 m long. diff --git a/src/game/models/factory.js b/src/game/models/factory.js index 6e06817..2d90b26 100644 --- a/src/game/models/factory.js +++ b/src/game/models/factory.js @@ -1,7 +1,8 @@ import * as THREE from 'three'; import { createVendingMachine } from './vending.js'; -import { createCarModel } from '../systems/traffic.js'; -import { createPickup, createTruck } from './carModels.js'; +import { 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 { FACTORY } from '../../constants/locations.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 * 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 * people the family can talk to. */ @@ -270,8 +271,8 @@ function factoryUpdateArrival(build, dt, time) { if (build.arrival.stage === 'drive') { build.arrival.s += dt * 7; const p = build.along(build.DRIVE_IN, build.arrival.s); - build.placeVehicle(build.pickup, build.arrival, p, dt); - build.pickup.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4)); + build.placeVehicle(build.managerCar, build.arrival, p, dt); + build.managerCar.wheels.forEach((w) => (w.rotation.z -= (dt * 7) / 0.4)); build.manager.root.visible = false; if (p.done) Object.assign(build.arrival, { @@ -304,7 +305,7 @@ function factoryResetArrival(build) { s: 0, 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; } /** @@ -1586,20 +1587,22 @@ function factoryBuildGeometry12(build) { * @returns {*} Operation result, when applicable. */ function factoryBuildGeometry13(build) { - for (let i = 0; i < 4; i++) { - const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]); - const u = 56 + i * 4; - parked.group.position.set(u, 0, -19); - parked.group.rotation.y = Math.PI / 2; - build.group.add(parked.group); - build.solid(u - 1.1, u + 1.1, -21.3, -16.7); - } + // Bay centres for the staff cars and the motorcycle row; the garage puts drivable vehicles there. + const heading = build.worldHeading(0, -1); + build.parking = { + cars: [58, 62, 66].map((u) => ({ ...build.toWorld(u, -19), heading })), + // Motorcycles barely reverse, so they stand facing out towards the driveway. + bikes: Array.from({ length: MOTORCYCLE_COUNT }, (_, i) => ({ + ...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 - // his room and sits at his desk. - build.pickup = createPickup('#f1f1ee'); - build.group.add(build.pickup.group); + // his room and sits at his desk. Once parked the car can be taken like any other. + build.managerCar = createLuxuryCar(MANAGER_CAR_BRAND); + build.group.add(build.managerCar.group); } /** * Runs the factory build manager operation for this subsystem. @@ -1634,24 +1637,6 @@ function factoryBuildManager(build) { [48.5, -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. @@ -1723,7 +1708,7 @@ function factoryBuildPlaceVehicle(build) { // ---------- Delivery truck ---------- // Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out // of the gate to the city, and comes back empty a while later, reversing up to the dock. - build.truck = createTruck(); + build.truck = createTruck(VEHICLE_BLACK); build.group.add(build.truck.group); build.DOCK = [5.5, 31]; 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), }, 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, // `dt` is 0 while the game is paused; distant viewers skip the people and machines. update(time, dt, viewer) { diff --git a/src/game/models/luxuryCars.js b/src/game/models/luxuryCars.js new file mode 100644 index 0000000..0377aec --- /dev/null +++ b/src/game/models/luxuryCars.js @@ -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) }; +} diff --git a/src/game/models/motorcycle.js b/src/game/models/motorcycle.js new file mode 100644 index 0000000..4effd46 --- /dev/null +++ b/src/game/models/motorcycle.js @@ -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 }; +} diff --git a/src/game/runtime/garage.js b/src/game/runtime/garage.js new file mode 100644 index 0000000..351eaa5 --- /dev/null +++ b/src/game/runtime/garage.js @@ -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, + })), + ]; +} diff --git a/src/game/runtime/movement.js b/src/game/runtime/movement.js index 99d6d5c..2243be3 100644 --- a/src/game/runtime/movement.js +++ b/src/game/runtime/movement.js @@ -2,6 +2,8 @@ import * as THREE from 'three'; import data from '../../data/mellat.json'; import { resolveCircle } from '../world/collision.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. * @param {*} context - context. @@ -30,9 +32,8 @@ export function look(context, dx, dy, k) { // Touch joystick: x steers / strafes, y walks / throttles. /** - * Runs the toggle car operation for this subsystem. - * @param {*} context - context. - * @returns {*} Operation result, when applicable. + * Gets the player into the nearest vehicle, or out of the current one. + * @param {object} context - Game context. */ export // ---------- Car ---------- function toggleCar(context) { @@ -41,42 +42,54 @@ function toggleCar(context) { context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.'); return; } - if (context.mode === 'walk') { - if (Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) > 4.5) - 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'); - } + if (context.mode === 'walk') getIn(context); + else getOut(context); 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 ---------- // 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, 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; 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.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. /** * Updates drive from the active simulation. @@ -148,67 +188,17 @@ export function updateDrive(context, dt) { 1, ); // 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)); for (let i = 0; i < slices; i++) { stepCar( context.car, - { - throttle, - steer, - handbrake: context.keys.has(' '), - }, + { throttle, steer, handbrake: context.keys.has(' ') }, dt / slices, + context.vehicle.spec, ); - // Two collision circles along the body keep the nose and tail out of buildings. - for (const along of [1.3, -1.3]) { - 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); - } + keepOutOfBuildings(context); + collideDriving(context, dt / slices); } const { minX, minZ, maxX, maxZ } = data.bounds; context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15); diff --git a/src/game/runtime/navigationHud.js b/src/game/runtime/navigationHud.js index bc869da..50d0664 100644 --- a/src/game/runtime/navigationHud.js +++ b/src/game/runtime/navigationHud.js @@ -5,6 +5,7 @@ import { FACTORY } from '../../constants/locations.js'; import { isOpen, formatClock, fa } from '../economy/gameState.js'; import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js'; import { kmh } from '../systems/vehicle.js'; +import { nearestRide } from './garage.js'; import { drawMinimap } from '../world/mapRender.js'; import { bearing, formatDistance } from '../../utils/geo.js'; /** @@ -191,12 +192,22 @@ export function refreshHUD(context) { }); context.ui.publishGame(context.state); context.ui.patch('mode-label', { - text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده', + text: modeLabel(context), }); context.ui.patch('speed-stat', { 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. * @param {*} context - context. @@ -213,16 +224,13 @@ export function updatePrompt(context) { } let text = ''; const station = context.nearStation(); + const ride = context.mode === 'walk' ? nearestRide(context) : null; if (!context.state.busy && context.payment.phase === 'idle') { if (station) text = isOpen(station.def, context.state.minutes) ? `E · خرید از روبات مارکت · ${station.def.name}` : `دستگاه بسته است · ${context.hoursText(station.def)}`; - else if ( - context.mode === 'walk' && - Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5 - ) - text = 'F · سوار ماشین شو'; + else if (ride) text = `F · سوار ${ride.label} شو`; else if ( context.mode === 'drive' && Math.abs(context.car.speed) < 1 && diff --git a/src/game/runtime/renderLoop.js b/src/game/runtime/renderLoop.js index 13a606c..7ef46ec 100644 --- a/src/game/runtime/renderLoop.js +++ b/src/game/runtime/renderLoop.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import iran from '../../data/iran.json'; import { clearFraction } from '../world/collision.js'; import { drawIran } from '../world/mapRender.js'; +import { syncActiveVehicle, trafficObstacles, updateGarage } from './garage.js'; /** * Runs the resize operation for this subsystem. * @param {*} context - context. @@ -109,20 +110,7 @@ export function frame(context) { else context.updateWalk(step); context.updatePayment(step); context.updateDelivery(step); - context.traffic.update( - step, - [ - { - x: context.player.x, - z: context.player.z, - }, - { - x: context.car.x, - z: context.car.z, - }, - ], - context.player, - ); + context.traffic.update(step, trafficObstacles(context), context.player); } context.updateRoute(dt); context.updateHints(); @@ -147,6 +135,7 @@ export function frame(context) { delivery: context.deliveryTimer > 0, }); context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position); + updateGarage(context); context.showroom.update({ player: context.player, time: context.elapsed, @@ -154,12 +143,7 @@ export function frame(context) { enabled: context.mode === 'walk' && !context.real.on, talking: context.ui.read('showroom').open, }); - context.carModel.group.position.set(context.car.x, 0, context.car.z); - 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; - } + syncActiveVehicle(context, dt); for (const s of context.stations) s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25; context.updateReachCircles(); diff --git a/src/game/runtime/runtimeLifecycle.js b/src/game/runtime/runtimeLifecycle.js index 96713d4..6d97ba2 100644 --- a/src/game/runtime/runtimeLifecycle.js +++ b/src/game/runtime/runtimeLifecycle.js @@ -2,6 +2,7 @@ import { addPolygon, resolveCircle } from '../world/collision.js'; import { createState } from '../economy/gameState.js'; import { createPayment } from '../economy/payment.js'; import { createVendingMachine } from '../models/vending.js'; +import { resetGarage } from './garage.js'; /** * Runs the place station operation for this subsystem. * @param {*} context - context. @@ -78,9 +79,9 @@ export function reset(context) { speed: 0, gait: 0, }; + resetGarage(context); context.factory.reset(); for (const t of context.factory.talks) t.done = false; - context.car = context.spawnCar(); context.mode = 'walk'; // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos). context.cam = { diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js index be86b9b..319dd08 100644 --- a/src/game/runtime/runtimeSetup.js +++ b/src/game/runtime/runtimeSetup.js @@ -20,9 +20,8 @@ import { isOpen, formatClock } from '../economy/gameState.js'; import { activeMission } from '../economy/scoring.js'; import { createFamily } from '../models/characters.js'; import { createFactory } from '../models/factory.js'; -import { createCar } from '../systems/vehicle.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 { formatDistance, toLocal } from '../../utils/geo.js'; @@ -402,12 +401,11 @@ export async function initializeFamily(context) { }); context.guide.group.visible = false; context.scene.add(context.guide.group); - context.carModel = createSportSedan('#1d3f78'); - context.scene.add(context.carModel.group); context.state = undefined; context.payment = undefined; context.player = undefined; context.car = undefined; + context.vehicle = undefined; context.mode = undefined; context.cam = undefined; context.waypoint = undefined; @@ -423,9 +421,9 @@ export async function initializeFamily(context) { * @returns {*} Operation result, when applicable. */ export async function initializeSpawnCar(context) { - // The family car waits in the factory yard right beside them, pointing at the gate. - context.spawnCar = () => - createCar(context.factory.car.x, context.factory.car.z, context.factory.car.heading); + // The family car waits in the factory yard right beside them, pointing at the gate; the staff + // cars and motorcycles are parked in front of the office. + createGarage(context); // ---------- Input ---------- context.isTouch = matchMedia('(pointer: coarse)').matches; context.anyDialogOpen = () => @@ -1091,7 +1089,7 @@ export async function initializeArMode(context) { context.imagery.group, context.factory.group, context.traffic.group, - context.carModel.group, + context.garage.group, context.parkGate, ...context.showroom.samples.map((sample) => sample.machine.group), ]; @@ -1197,6 +1195,10 @@ export async function exposeDebugTools(context) { real: context.real, keys: context.keys, traffic: context.traffic, + garage: context.garage, + get vehicle() { + return context.vehicle; + }, }; } /** diff --git a/src/game/systems/traffic.js b/src/game/systems/traffic.js index 35eb95e..e9276f1 100644 --- a/src/game/systems/traffic.js +++ b/src/game/systems/traffic.js @@ -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. 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. * @param {*} scene - scene. @@ -238,11 +245,14 @@ export function createTraffic(scene, data, count = 28) { cars = []; scene.add(group); 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); model.group.visible = false; cars.push({ model, + color, + halfWidth: TRAFFIC_CAR.halfWidth, road: null, d: 0, dir: 1, @@ -263,6 +273,20 @@ export function createTraffic(scene, data, count = 28) { car.x = car.z = 1e9; 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; /** * Updates update from the active simulation. @@ -290,7 +314,11 @@ export function createTraffic(scene, data, count = 28) { const ox = o.x - car.x, oz = o.z - car.z, along = ox * fx + oz * fz; - return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2; + // 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; car.d += speed * car.dir * dt; @@ -317,5 +345,6 @@ export function createTraffic(scene, data, count = 28) { cars, update, remove, + adopt, }; } diff --git a/src/game/systems/vehicle.js b/src/game/systems/vehicle.js index 2578606..3c91f88 100644 --- a/src/game/systems/vehicle.js +++ b/src/game/systems/vehicle.js @@ -1,5 +1,9 @@ -// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y. -// maxSpeed 55.6 m/s is 200 km/h. +// Arcade vehicle physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's +// 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 = { maxSpeed: 55.6, maxReverse: 6, @@ -9,13 +13,40 @@ export const CAR = { maxSteer: 0.55, wheelBase: 2.7, 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. - * @param {*} x - x. - * @param {*} z - z. - * @param {*} heading - heading. - * @returns {*} Operation result, when applicable. + * Creates the moving state of a vehicle. + * @param {number} x - World x. + * @param {number} z - World z. + * @param {number} heading - Heading in radians. + * @returns {{x: number, z: number, heading: number, speed: number, steer: number}} The state. */ export function createCar(x, z, heading = 0) { return { @@ -26,32 +57,45 @@ export function createCar(x, z, heading = 0) { steer: 0, }; } + /** - * Runs the step car operation for this subsystem. - * @param {*} car - car. - * @param {*} options1 - Operation options. - * @param {*} dt - dt. - * @returns {*} Operation result, when applicable. + * Applies throttle and brakes to the speed for one step. + * @param {object} car - Vehicle state, changed in place. + * @param {{throttle: number, handbrake: boolean}} input - Driver input. + * @param {{dt: number, spec: object}} step - Time step and vehicle spec. */ -export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) { - car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6); - if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt; +function applyThrottle(car, { throttle, handbrake }, { dt, spec }) { + if (throttle > 0) car.speed += (car.speed < 0 ? spec.brake : spec.accel) * throttle * dt; else if (throttle < 0) - car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * 0.6) * -throttle * dt; - else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt); + 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), spec.drag * dt); if (handbrake) - car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt); - car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed)); - // High speed narrows the usable steering angle so the car stays controllable. + car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), spec.brake * 1.4 * dt); + car.speed = Math.max(-spec.maxReverse, Math.min(spec.maxSpeed, car.speed)); +} + +/** + * 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); - 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.z -= Math.sin(car.heading) * car.speed * dt; return car; } + /** - * Runs the kmh operation for this subsystem. - * @param {*} car - car. - * @returns {*} Operation result, when applicable. + * Converts a vehicle's speed to whole km/h for the HUD. + * @param {{speed: number}} car - Vehicle state. + * @returns {number} Speed in km/h. */ export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6); diff --git a/src/game/world/vehicleCollision.js b/src/game/world/vehicleCollision.js new file mode 100644 index 0000000..83ed189 --- /dev/null +++ b/src/game/world/vehicleCollision.js @@ -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, + }; +} diff --git a/tests/vehicles.test.js b/tests/vehicles.test.js new file mode 100644 index 0000000..1bee800 --- /dev/null +++ b/tests/vehicles.test.js @@ -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'); +});