refactor: remove the motorcycle
Phase 2 of turning the game into the company landing page. - Delete the Apache motorcycle model, its BIKE handling spec, the motorcycle row in the factory yard, rider and lean animation, and its prompt and dialogue strings. - The garage now holds cars only; the oriented-box collision, traffic lane checks and other vehicles are unchanged. - Rework the vehicle tests to cover gaps with a car instead of a bike, and add a check that no motorcycle code or text remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,13 @@
|
|||||||
- موبایل joystick و دکمههای F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت میکشد.
|
- موبایل joystick و دکمههای F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت میکشد.
|
||||||
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرمسازی است. برخورد دایرهای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود میشود.
|
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرمسازی است. برخورد دایرهای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود میشود.
|
||||||
|
|
||||||
## ماشین و موتور
|
## ماشینها
|
||||||
|
|
||||||
همهٔ وسیلههای حیاط کارخانه مشکیاند و سوارشدنی: بیاموی خانواده کنار نقطهٔ شروع، لامبورگینی، مزدا و نیسان در پارکینگ کارکنان، شش موتور آپاچی کنار دفتر، و بوگاتی آقای محمدی پس از اینکه پارک کرد. هر ماشین ترافیک شهر هم با F گرفته میشود و تا reset همانجا که رها شده میماند. کامیون بارگیری تزئینی است و سوار نمیشود. همه در `runtime/garage.js` نگهداری میشوند.
|
همهٔ وسیلههای حیاط کارخانه مشکیاند و سوارشدنی: بیاموی خانواده کنار نقطهٔ شروع، لامبورگینی، مزدا و نیسان در پارکینگ کارکنان، و بوگاتی آقای محمدی پس از اینکه پارک کرد. هر ماشین ترافیک شهر هم با F گرفته میشود و تا reset همانجا که رها شده میماند. کامیون بارگیری تزئینی است و سوار نمیشود. همه در `runtime/garage.js` نگهداری میشوند.
|
||||||
|
|
||||||
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیادهشدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیادهشدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان میدهد. سمت خروج راننده بررسی برخورد میشود.
|
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیادهشدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیادهشدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان میدهد. سمت خروج راننده بررسی برخورد میشود.
|
||||||
|
|
||||||
ثابتهای `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر میشود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. ثابتهای `BIKE`: سقف ۳۶٫۱ (حدود ۱۳۰ km/h)، شتاب ۱۱، wheelBase برابر ۱٫۳۵؛ موتور در پیچ خم میشود. برخورد بین وسیلهها با جعبهٔ جهتدار به اندازهٔ واقعی مدل (SAT در `world/vehicleCollision.js`) است، پس فقط وقتی بدنهها واقعاً به هم برسند سرعت کم میشود و موتور میتواند لایی بکشد. کاهش سرعت به زاویهٔ برخورد بستگی دارد: شاخبهشاخ ۶۰٪، مالیدن کناری تقریباً هیچ. ترافیک فقط برای چیزی که واقعاً در عرض خطش باشد ترمز میکند و NPC پس از تماس طولانی حذف/بازپدید میشود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
|
ثابتهای `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر میشود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. برخورد بین وسیلهها با جعبهٔ جهتدار به اندازهٔ واقعی مدل (SAT در `world/vehicleCollision.js`) است، پس فقط وقتی بدنهها واقعاً به هم برسند سرعت کم میشود و ماشین از هر فاصلهای که واقعاً جا شود رد میشود. کاهش سرعت به زاویهٔ برخورد بستگی دارد: شاخبهشاخ ۶۰٪، مالیدن کناری تقریباً هیچ. ترافیک فقط برای چیزی که واقعاً در عرض خطش باشد ترمز میکند و NPC پس از تماس طولانی حذف/بازپدید میشود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
|
||||||
|
|
||||||
## پول و محصول
|
## پول و محصول
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ JavaScript ES Modules، Three.js/WebGL، Vite، HTML و CSS فارسی RTL و Ca
|
|||||||
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
|
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
|
||||||
| car-models.js | سدان خانواده، کامیون و helperهای مشترک بدنه |
|
| car-models.js | سدان خانواده، کامیون و helperهای مشترک بدنه |
|
||||||
| luxuryCars.js | ماشینهای لوکس مشکی به سبک بوگاتی، لامبورگینی، مزدا و نیسان |
|
| luxuryCars.js | ماشینهای لوکس مشکی به سبک بوگاتی، لامبورگینی، مزدا و نیسان |
|
||||||
| motorcycle.js | موتور آپاچی با راکب و بدنهٔ خمشونده |
|
| vehicle.js | state و فیزیک آرکید ماشین (`CAR`) |
|
||||||
| vehicle.js | state و فیزیک آرکید ماشین (`CAR`) و موتور (`BIKE`) |
|
|
||||||
| vehicleCollision.js | برخورد جعبهٔ جهتدار وسیلهها و هلدادن پیاده از وسیلهٔ پارکشده |
|
| vehicleCollision.js | برخورد جعبهٔ جهتدار وسیلهها و هلدادن پیاده از وسیلهٔ پارکشده |
|
||||||
| garage.js | فهرست وسیلههای سوارشدنی، سوار/پیاده، برخورد رانندگی و reset |
|
| garage.js | فهرست وسیلههای سوارشدنی، سوار/پیاده، برخورد رانندگی و reset |
|
||||||
| traffic.js | ماشینهای NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
|
| traffic.js | ماشینهای NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
|
||||||
|
|||||||
@@ -15,6 +15,3 @@ export const VEHICLE_LABELS = strings.vehicles;
|
|||||||
// Brands parked in the staff bays, in bay order. Mr Mohammadi arrives in the Bugatti.
|
// 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 STAFF_CAR_BRANDS = ['lamborghini', 'mazda', 'nissan'];
|
||||||
export const MANAGER_CAR_BRAND = 'bugatti';
|
export const MANAGER_CAR_BRAND = 'bugatti';
|
||||||
|
|
||||||
// Apache motorcycles lined up in the yard.
|
|
||||||
export const MOTORCYCLE_COUNT = 6;
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as THREE from 'three';
|
|||||||
import { createVendingMachine } from './vending.js';
|
import { createVendingMachine } from './vending.js';
|
||||||
import { createTruck } from './carModels.js';
|
import { createTruck } from './carModels.js';
|
||||||
import { createLuxuryCar } from './luxuryCars.js';
|
import { createLuxuryCar } from './luxuryCars.js';
|
||||||
import { MANAGER_CAR_BRAND, MOTORCYCLE_COUNT, VEHICLE_BLACK } from '../../constants/vehicles.js';
|
import { MANAGER_CAR_BRAND, 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';
|
||||||
@@ -1581,15 +1581,10 @@ function factoryBuildGeometry12(build) {
|
|||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
function factoryBuildGeometry13(build) {
|
function factoryBuildGeometry13(build) {
|
||||||
// Bay centres for the staff cars and the motorcycle row; the garage puts drivable vehicles there.
|
// Bay centres for the staff cars; the garage parks drivable cars there.
|
||||||
const heading = build.worldHeading(0, -1);
|
const heading = build.worldHeading(0, -1);
|
||||||
build.parking = {
|
build.parking = {
|
||||||
cars: [58, 62, 66].map((u) => ({ ...build.toWorld(u, -19), heading })),
|
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 Bugatti ----------
|
// ---------- The manager arrives in his Bugatti ----------
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
+11
-29
@@ -8,12 +8,11 @@ import {
|
|||||||
} from '../../constants/vehicles.js';
|
} from '../../constants/vehicles.js';
|
||||||
import { createSportSedan, measureFootprint } from '../models/carModels.js';
|
import { createSportSedan, measureFootprint } from '../models/carModels.js';
|
||||||
import { createLuxuryCar } from '../models/luxuryCars.js';
|
import { createLuxuryCar } from '../models/luxuryCars.js';
|
||||||
import { createMotorcycle } from '../models/motorcycle.js';
|
|
||||||
import { TRAFFIC_CAR } from '../systems/traffic.js';
|
import { TRAFFIC_CAR } from '../systems/traffic.js';
|
||||||
import { BIKE, CAR, createCar } from '../systems/vehicle.js';
|
import { CAR, createCar } from '../systems/vehicle.js';
|
||||||
import { boxOverlap, circleOutOfBox, vehicleBox } from '../world/vehicleCollision.js';
|
import { boxOverlap, circleOutOfBox, vehicleBox } from '../world/vehicleCollision.js';
|
||||||
|
|
||||||
// Every vehicle the player can drive: the family BMW, the staff cars, the Apache motorcycles,
|
// Every vehicle the player can drive: the family BMW, the staff cars,
|
||||||
// Mr Mohammadi's Bugatti once he has parked it, and any traffic car the player has taken over.
|
// 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.
|
// `context.vehicle` is the one in use (or last used); `context.car` is its moving state.
|
||||||
|
|
||||||
@@ -25,26 +24,22 @@ const WALKER_RADIUS = 0.45;
|
|||||||
const HEAD_ON_SPEED_KEPT = 0.4;
|
const HEAD_ON_SPEED_KEPT = 0.4;
|
||||||
// A traffic car the player keeps pushing against pulls away (respawns) after this many seconds.
|
// A traffic car the player keeps pushing against pulls away (respawns) after this many seconds.
|
||||||
const TRAFFIC_GIVE_WAY = 0.8;
|
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.
|
* Adds a vehicle to the garage and the scene.
|
||||||
* @param {object} context - Game context.
|
* @param {object} context - Game context.
|
||||||
* @param {{brand: string, kind: 'car' | 'bike', model: object, slot: object}} vehicle - Vehicle
|
* @param {{brand: string, model: object, slot: object}} vehicle - Vehicle
|
||||||
* fields; `slot` is where it is parked on reset (null for vehicles that are not reset).
|
* fields; `slot` is where it is parked on reset (null for vehicles that are not reset).
|
||||||
* @returns {object} The garage record.
|
* @returns {object} The garage record.
|
||||||
*/
|
*/
|
||||||
function addVehicle(context, { brand, kind, model, slot, ...extra }) {
|
function addVehicle(context, { brand, model, slot, ...extra }) {
|
||||||
const record = {
|
const record = {
|
||||||
brand,
|
brand,
|
||||||
kind,
|
|
||||||
model,
|
model,
|
||||||
slot,
|
slot,
|
||||||
label: VEHICLE_LABELS[brand],
|
label: VEHICLE_LABELS[brand],
|
||||||
// Handling from the kind, collision box from the model itself, so contact is exact.
|
// Car handling, with the collision box measured from the model itself so contact is exact.
|
||||||
spec: { ...(kind === 'bike' ? BIKE : CAR), ...model.size },
|
spec: { ...CAR, ...model.size },
|
||||||
state: createCar(slot.x, slot.z, slot.heading),
|
state: createCar(slot.x, slot.z, slot.heading),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@@ -93,20 +88,16 @@ export function createGarage(context) {
|
|||||||
const { car: familySlot, parking } = context.factory;
|
const { car: familySlot, parking } = context.factory;
|
||||||
addVehicle(context, {
|
addVehicle(context, {
|
||||||
brand: 'bmw',
|
brand: 'bmw',
|
||||||
kind: 'car',
|
|
||||||
model: withSize(createSportSedan(VEHICLE_BLACK)),
|
model: withSize(createSportSedan(VEHICLE_BLACK)),
|
||||||
slot: familySlot,
|
slot: familySlot,
|
||||||
});
|
});
|
||||||
STAFF_CAR_BRANDS.forEach((brand, i) =>
|
STAFF_CAR_BRANDS.forEach((brand, i) =>
|
||||||
addVehicle(context, {
|
addVehicle(context, {
|
||||||
brand,
|
brand,
|
||||||
kind: 'car',
|
|
||||||
model: createLuxuryCar(brand),
|
model: createLuxuryCar(brand),
|
||||||
slot: parking.cars[i],
|
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]);
|
setActive(context, context.garage.vehicles[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +116,6 @@ export function resetGarage(context) {
|
|||||||
for (const record of garage.vehicles) {
|
for (const record of garage.vehicles) {
|
||||||
record.state = createCar(record.slot.x, record.slot.z, record.slot.heading);
|
record.state = createCar(record.slot.x, record.slot.z, record.slot.heading);
|
||||||
placeModel(record);
|
placeModel(record);
|
||||||
if (record.model.rider) record.model.rider.root.visible = false;
|
|
||||||
}
|
}
|
||||||
setActive(context, garage.vehicles[0]);
|
setActive(context, garage.vehicles[0]);
|
||||||
}
|
}
|
||||||
@@ -145,7 +135,6 @@ export function updateGarage(context) {
|
|||||||
const heading = Math.atan2(-(nose.z - at.z), nose.x - at.x);
|
const heading = Math.atan2(-(nose.z - at.z), nose.x - at.x);
|
||||||
const record = addVehicle(context, {
|
const record = addVehicle(context, {
|
||||||
brand: MANAGER_CAR_BRAND,
|
brand: MANAGER_CAR_BRAND,
|
||||||
kind: 'car',
|
|
||||||
model: manager.model,
|
model: manager.model,
|
||||||
slot: { x: at.x, z: at.z, heading },
|
slot: { x: at.x, z: at.z, heading },
|
||||||
});
|
});
|
||||||
@@ -187,7 +176,7 @@ export function takeRide(context, { record, npc }) {
|
|||||||
if (npc) {
|
if (npc) {
|
||||||
const slot = { x: npc.x, z: npc.z, heading: npc.model.group.rotation.y };
|
const slot = { x: npc.x, z: npc.z, heading: npc.model.group.rotation.y };
|
||||||
const model = { ...context.traffic.adopt(npc), size: TRAFFIC_CAR };
|
const model = { ...context.traffic.adopt(npc), size: TRAFFIC_CAR };
|
||||||
record = addVehicle(context, { brand: 'traffic', kind: 'car', model, slot });
|
record = addVehicle(context, { brand: 'traffic', model, slot });
|
||||||
// Taken traffic cars stay where they are left until the next reset, which removes them.
|
// Taken traffic cars stay where they are left until the next reset, which removes them.
|
||||||
record.slot = null;
|
record.slot = null;
|
||||||
}
|
}
|
||||||
@@ -196,25 +185,18 @@ export function takeRide(context, { record, npc }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the active vehicle's model: position, wheel spin and steering, motorcycle lean and rider.
|
* Updates the active vehicle's model: position, wheel spin and front-wheel steering.
|
||||||
* @param {object} context - Game context.
|
* @param {object} context - Game context.
|
||||||
* @param {number} dt - Frame time in seconds.
|
* @param {number} dt - Frame time in seconds.
|
||||||
*/
|
*/
|
||||||
export function syncActiveVehicle(context, dt) {
|
export function syncActiveVehicle(context, dt) {
|
||||||
const { model, state, spec, kind } = context.vehicle;
|
const { model, state, spec } = context.vehicle;
|
||||||
placeModel(context.vehicle);
|
placeModel(context.vehicle);
|
||||||
const firstFront = kind === 'bike' ? 1 : 2;
|
// Wheels list the rear pair first, then the two front (steering) wheels.
|
||||||
for (const [i, wheel] of model.wheels.entries()) {
|
for (const [i, wheel] of model.wheels.entries()) {
|
||||||
wheel.rotation.z -= (state.speed * dt) / spec.wheelRadius;
|
wheel.rotation.z -= (state.speed * dt) / spec.wheelRadius;
|
||||||
if (i >= firstFront) wheel.rotation.y = -state.steer;
|
if (i >= 2) 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -49,18 +49,17 @@ function toggleCar(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets on or in the closest vehicle within reach, if there is one.
|
* Gets into the closest vehicle within reach, if there is one.
|
||||||
* @param {object} context - Game context.
|
* @param {object} context - Game context.
|
||||||
*/
|
*/
|
||||||
function getIn(context) {
|
function getIn(context) {
|
||||||
const ride = nearestRide(context);
|
const ride = nearestRide(context);
|
||||||
if (!ride) return;
|
if (!ride) return;
|
||||||
const { kind } = takeRide(context, ride);
|
takeRide(context, ride);
|
||||||
context.mode = 'drive';
|
context.mode = 'drive';
|
||||||
context.cam.distance = Math.max(context.cam.distance, kind === 'bike' ? 6 : 8);
|
context.cam.distance = Math.max(context.cam.distance, 8);
|
||||||
context.tone(320, 0.2, 0.05, 'square');
|
context.tone(320, 0.2, 0.05, 'square');
|
||||||
const line = kind === 'bike' ? strings.dialogue.bikeOn : strings.dialogue.carOn;
|
context.say('father', strings.dialogue.carOn);
|
||||||
context.say('father', line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ export function refreshHUD(context) {
|
|||||||
*/
|
*/
|
||||||
function modeLabel(context) {
|
function modeLabel(context) {
|
||||||
if (context.mode !== 'drive') return strings.common.walking;
|
if (context.mode !== 'drive') return strings.common.walking;
|
||||||
if (context.vehicle.kind === 'bike') return strings.travel.onBike;
|
|
||||||
return strings.travel.inVehicle(context.vehicle.label);
|
return strings.travel.inVehicle(context.vehicle.label);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ export async function initializeFamily(context) {
|
|||||||
*/
|
*/
|
||||||
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 staff
|
// 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.
|
// cars are parked in front of the office.
|
||||||
createGarage(context);
|
createGarage(context);
|
||||||
// ---------- Input ----------
|
// ---------- Input ----------
|
||||||
context.isTouch = matchMedia('(pointer: coarse)').matches;
|
context.isTouch = matchMedia('(pointer: coarse)').matches;
|
||||||
|
|||||||
@@ -314,8 +314,8 @@ 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;
|
||||||
// Only something that really overlaps the lane stops the car, so a motorcycle
|
// Only something that really overlaps the lane stops the car, so a car or walker
|
||||||
// filtering between two lanes doesn't bring traffic to a halt.
|
// beside the lane doesn't bring traffic to a halt.
|
||||||
const reach =
|
const reach =
|
||||||
TRAFFIC_CAR.halfWidth + (o.halfWidth ?? DEFAULT_OBSTACLE_HALF_WIDTH) + PASSING_MARGIN;
|
TRAFFIC_CAR.halfWidth + (o.halfWidth ?? DEFAULT_OBSTACLE_HALF_WIDTH) + PASSING_MARGIN;
|
||||||
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < reach;
|
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < reach;
|
||||||
|
|||||||
@@ -22,25 +22,6 @@ export const CAR = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 the moving state of a vehicle.
|
* Creates the moving state of a vehicle.
|
||||||
* @param {number} x - World x.
|
* @param {number} x - World x.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Oriented-box collisions between vehicles, so two bodies only touch when their outlines really
|
// 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.
|
// meet. A car can therefore pass through any gap it actually fits, without slowing down.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the footprint box of a vehicle on the ground plane.
|
* Builds the footprint box of a vehicle on the ground plane.
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export const strings = {
|
|||||||
metroPrefix: 'ایستگاه مترو ',
|
metroPrefix: 'ایستگاه مترو ',
|
||||||
},
|
},
|
||||||
travel: {
|
travel: {
|
||||||
onBike: 'روی موتور · F برای پیاده شدن',
|
|
||||||
/** @type {(vehicle: string) => string} */
|
/** @type {(vehicle: string) => string} */
|
||||||
inVehicle: (vehicle) => `در ${vehicle} · F برای پیاده شدن`,
|
inVehicle: (vehicle) => `در ${vehicle} · F برای پیاده شدن`,
|
||||||
},
|
},
|
||||||
@@ -125,7 +124,6 @@ export const strings = {
|
|||||||
lamborghini: 'لامبورگینی',
|
lamborghini: 'لامبورگینی',
|
||||||
mazda: 'مزدا',
|
mazda: 'مزدا',
|
||||||
nissan: 'نیسان',
|
nissan: 'نیسان',
|
||||||
apache: 'موتور آپاچی',
|
|
||||||
traffic: 'ماشین',
|
traffic: 'ماشین',
|
||||||
},
|
},
|
||||||
map: {
|
map: {
|
||||||
@@ -287,7 +285,6 @@ export const strings = {
|
|||||||
targetClosed: (machine, hours) =>
|
targetClosed: (machine, hours) =>
|
||||||
`${machine} الان بسته است (${hours}). از نقشه مقصد دیگری انتخاب کن.`,
|
`${machine} الان بسته است (${hours}). از نقشه مقصد دیگری انتخاب کن.`,
|
||||||
longWay: 'راه طولانیه؛ بهتره با ماشین بریم.',
|
longWay: 'راه طولانیه؛ بهتره با ماشین بریم.',
|
||||||
bikeOn: 'کلاه ایمنی سرت باشه! با موتور میشه از لای ماشینها رد شد.',
|
|
||||||
carOn: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.',
|
carOn: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.',
|
||||||
stopFirst: 'اول نگه دار، بعد پیاده میشویم.',
|
stopFirst: 'اول نگه دار، بعد پیاده میشویم.',
|
||||||
tooFastToBuy: 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.',
|
tooFastToBuy: 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.',
|
||||||
|
|||||||
+19
-28
@@ -3,10 +3,8 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { Scene } from 'three';
|
import { Scene } from 'three';
|
||||||
import { boxOverlap, circleOutOfBox, vehicleBox } from '../src/game/world/vehicleCollision.js';
|
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 { createTraffic, TRAFFIC_CAR } from '../src/game/systems/traffic.js';
|
||||||
import { createLuxuryCar } from '../src/game/models/luxuryCars.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)));
|
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
|
||||||
|
|
||||||
@@ -14,12 +12,12 @@ const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.m
|
|||||||
const leftCar = vehicleBox({ x: 0, z: -2.25, heading: 0 }, TRAFFIC_CAR);
|
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);
|
const rightCar = vehicleBox({ x: 0, z: 2.25, heading: Math.PI }, TRAFFIC_CAR);
|
||||||
|
|
||||||
test('A motorcycle filters between two cars without touching them', () => {
|
test('A car drives between two traffic cars without touching them', () => {
|
||||||
const bike = createMotorcycle();
|
const car = createLuxuryCar('mazda');
|
||||||
const between = vehicleBox({ x: 0, z: 0, heading: 0 }, bike.size);
|
const between = vehicleBox({ x: 0, z: 0, heading: 0 }, car.size);
|
||||||
assert.equal(boxOverlap(between, leftCar), null);
|
assert.equal(boxOverlap(between, leftCar), null);
|
||||||
assert.equal(boxOverlap(between, rightCar), null);
|
assert.equal(boxOverlap(between, rightCar), null);
|
||||||
// Regression: the old 3.4 m circle test counted this as a crash and stopped the rider.
|
// Regression: the old 3.4 m circle test counted this as a crash and stopped the driver.
|
||||||
assert.ok(Math.hypot(0, 2.25) < 3.4);
|
assert.ok(Math.hypot(0, 2.25) < 3.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,29 +48,13 @@ test('A walker is pushed out of a parked vehicle, even from inside it', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Motorcycles top out at 130 km/h and turn tighter than cars', () => {
|
test('Luxury cars report footprints that match their size', () => {
|
||||||
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']) {
|
for (const brand of ['bugatti', 'lamborghini', 'mazda', 'nissan']) {
|
||||||
const model = createLuxuryCar(brand);
|
const model = createLuxuryCar(brand);
|
||||||
assert.equal(model.wheels.length, 4, 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.halfLength > 2.1 && model.size.halfLength < 2.6, brand);
|
||||||
assert.ok(model.size.halfWidth > 0.9 && model.size.halfWidth < 1.15, 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +69,7 @@ function loneCar(traffic) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Traffic stops for what blocks its lane but keeps going past a filtering motorcycle', () => {
|
test('Traffic stops for what blocks its lane but keeps going past what is beside it', () => {
|
||||||
const traffic = createTraffic(new Scene(), data, 28),
|
const traffic = createTraffic(new Scene(), data, 28),
|
||||||
around = { x: -10008, z: -15049 };
|
around = { x: -10008, z: -15049 };
|
||||||
for (let i = 0; i < 200; i++) traffic.update(1 / 30, [], around);
|
for (let i = 0; i < 200; i++) traffic.update(1 / 30, [], around);
|
||||||
@@ -103,10 +85,11 @@ test('Traffic stops for what blocks its lane but keeps going past a filtering mo
|
|||||||
halfWidth,
|
halfWidth,
|
||||||
});
|
});
|
||||||
const before = car.d;
|
const before = car.d;
|
||||||
traffic.update(1 / 30, [at(0.5, BIKE.halfWidth)], around);
|
// A walker (0.45 m half width) in the lane stops the car; one at the lane edge does not.
|
||||||
assert.equal(car.d, before, 'a bike in the lane stops the car');
|
traffic.update(1 / 30, [at(0.5, 0.45)], around);
|
||||||
traffic.update(1 / 30, [at(1.8, BIKE.halfWidth)], around);
|
assert.equal(car.d, before, 'a walker in the lane stops the car');
|
||||||
assert.notEqual(car.d, before, 'a bike on the lane edge does not');
|
traffic.update(1 / 30, [at(1.8, 0.45)], around);
|
||||||
|
assert.notEqual(car.d, before, 'a walker on the lane edge does not');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Taking a traffic car hands its model to the player and refills the slot', () => {
|
test('Taking a traffic car hands its model to the player and refills the slot', () => {
|
||||||
@@ -119,3 +102,11 @@ test('Taking a traffic car hands its model to the player and refills the slot',
|
|||||||
assert.equal(car.model.group.parent, traffic.group);
|
assert.equal(car.model.group.parent, traffic.group);
|
||||||
assert.equal(car.road, null, 'the slot respawns elsewhere later');
|
assert.equal(car.road, null, 'the slot respawns elsewhere later');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('The motorcycle is gone from the game', () => {
|
||||||
|
const src = new URL('../src/', import.meta.url);
|
||||||
|
const vehicleSource = ['game/runtime/garage.js', 'game/systems/vehicle.js', 'i18n/fa.js']
|
||||||
|
.map((file) => readFileSync(new URL(file, src), 'utf8'))
|
||||||
|
.join('\n');
|
||||||
|
assert.doesNotMatch(vehicleSource, /motorcycle|BIKE|apache/i);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user