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:
2026-10-31 08:27:52 +03:30
co-authored by Claude Opus 5.5
parent 8dbaf3d97e
commit 611960511e
14 changed files with 44 additions and 259 deletions
+3 -3
View File
@@ -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 پس از تماس طولانی حذف/بازپدید می‌شود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
## پول و محصول ## پول و محصول
+1 -2
View File
@@ -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 داده |
-3
View File
@@ -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
View File
@@ -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 ----------
-155
View File
@@ -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
View File
@@ -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);
} }
/** /**
+4 -5
View File
@@ -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);
} }
/** /**
-1
View File
@@ -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);
} }
/** /**
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
-19
View File
@@ -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 -1
View File
@@ -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.
-3
View File
@@ -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
View File
@@ -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);
});