diff --git a/assets/blender/scripts/gen_vehicle.py b/assets/blender/scripts/gen_vehicle.py index 063f575..e996d76 100644 --- a/assets/blender/scripts/gen_vehicle.py +++ b/assets/blender/scripts/gen_vehicle.py @@ -382,7 +382,11 @@ if trunk: vehicle = { "cameraDistance": 6.0, "cameraHeight": 1.6, - "engineBrake": 0.5, + # Cars coast when the accelerator is released (braking is the + # driver's job: S while rolling forward, Space = handbrake). + # Slow yard vehicles like the forklift use ~0.5 instead so they + # do not roll forever. + "engineBrake": 0.0, "maxPitchRollAngleDeg": 60.0, "maxTorque": 300.0, "rearSteer": False, diff --git a/assets/blender/vehicles/CMakeLists.txt b/assets/blender/vehicles/CMakeLists.txt index 9321ab1..79dba90 100644 --- a/assets/blender/vehicles/CMakeLists.txt +++ b/assets/blender/vehicles/CMakeLists.txt @@ -27,7 +27,7 @@ endforeach() # Convention-built drivable vehicles: same mesh/material export plus a # generated scene-entity JSON fragment (.vehicle.json, see # assets/blender/scripts/gen_vehicle.py and vehicles/CAR_MODELING.md). -set(VEHICLES_GEN_SRC sedan-v2.blend) +set(VEHICLES_GEN_SRC sedan-v2.blend sedan-v2-taxi.blend) foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC}) get_filename_component(FILE_NAME ${VEHICLE_FILE} NAME_WE) @@ -65,5 +65,7 @@ add_custom_target(update_vehicles ${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2.vehicle.json sedan1 COMMAND python3 merge_vehicle.py demo_scene_exterior.json ${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2.vehicle.json sedan_plain + COMMAND python3 merge_vehicle.py demo_scene_exterior.json + ${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2-taxi.vehicle.json sedan_taxi WORKING_DIRECTORY ${DEMO_TAXI_DIR} DEPENDS import_vehicles) diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 6e796e5..63b2ba1 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -275,6 +275,10 @@ cd demos/demo-taxi # for 150 frames; PASS needs >= 45 deg of yaw - catches the # "drives like it's on ice" regression): ./demoTaxi --headless --test-vehicle-turn +# ...or the coast check (W for 200 frames, release, 240 coast frames; +# PASS needs > 5 m/s at the end - cars have engineBrake 0 and must not +# brake by themselves when the accelerator is released): +./demoTaxi --headless --test-vehicle-coast # NOTE: always pass --headless to the tests — without it the frame # delta is wall time (a few FPS under xvfb), so the fixed-length # W-hold drives the sedan clear off the test floor and the exit-spot @@ -285,18 +289,22 @@ cd demos/demo-taxi # handbrake while driving, Escape = pause menu (frees the cursor). # # Phase 1 sedans: sedan1/sedan_plain are the hand-modeled -# sedan-v2.blend via the convention-based generator +# sedan-v2.blend and sedan_taxi is sedan-v2-taxi.blend (same base car +# with taxi livery + roof sign), all via the convention-based generator # assets/blender/scripts/gen_vehicle.py (VEHICLES_GEN_SRC in # assets/blender/vehicles/CMakeLists.txt) - seat_* empties mark the # character root pins, the driving state plays the sitting-vehicle # animation (no offsets, seatCharacterScale 1.0); see -# assets/blender/vehicles/CAR_MODELING.md. sedan_taxi is still the -# older procedural gen_taxi_assets.py car (sitting pose at -# seatCharacterScale 0.85) pending a v2-based taxi model. The exterior +# assets/blender/vehicles/CAR_MODELING.md. After editing a vehicle +# blend run `cmake --build build-vscode --target update_vehicles` (one +# step: re-export, re-stage resources, re-merge both demo scenes). +# The exterior # scene parks sedan_taxi and sedan_plain near the spawn point; each # door child has an actuator with a vehicle_seat_* action (driver, # passenger, rl, rr) that seats the player at that door's seat via the -# vehicle_enter event (vehicle resolved by proximity). +# vehicle_enter event (vehicle resolved by proximity). Stepping out of +# a stopped/almost-stopped vehicle parks it (handbrake stays engaged +# until a driver seats again); bailing out at speed lets the car coast. # Visual tuning aids (work in any scene): ./demoTaxi demo_scene_exterior.json --force-pos 4006 11 3972 90 -10 \ --debug-enter-vehicle sedan_taxi --screenshot 300 /tmp/seat.png @@ -1064,7 +1072,10 @@ driving), `rearSteer` (flips the steer sign for rear-steered vehicles like the forklift, so D still turns the nose right), `engineBrake` (brake factor applied automatically when coasting above 0.5 m/s with no throttle — -keeps the forklift from rolling forever), `wheelMeshName`, the steering +0 for cars so they coast when the accelerator is released (braking is +the driver's job: S while rolling forward brakes via the +brake-before-reverse rule, Space is the handbrake), ~0.5 for slow yard +vehicles like the forklift so they do not roll forever), `wheelMeshName`, the steering wheel visual (`steeringWheelMeshName` — mesh modelled at the origin with its spin axis on local Y —, `steeringWheelOffset` in chassis space, `steeringWheelTiltDeg`, `steeringRatio` steering-wheel degrees @@ -1255,19 +1266,28 @@ makes vehicles drivable by the player: - **Driving**: `update()` (called right before `PlayerControllerSystem` in Game+Playing) feeds `GameInputState` into the vehicle inputs — W/S `inputForward`, A/D `inputRight`, Space `inputHandBrake` — only when - the player sits on a driver seat; passengers zero the inputs. + the player sits on a driver seat; passengers zero the inputs except + `inputHandBrake` (it is part of the parked state, see below). - **Exit**: holding E while driving (a cooldown of `EXIT_MIN_SEAT_TIME` 0.75 s after seating must pass first, so the entering tap never counts, then E must be held for `EXIT_HOLD_TIME` 0.5 s) picks the first raycast-clear spot beside the door of the seat actually occupied - (sideways from the seat's own chassis-space row, own side first, then + (sideways from the seat's own chassis-space row with the direction + vectors flattened to horizontal — a tilted car on rough terrain must + not swing the spots off the ground —, own side first, then the opposite side of the same row, then behind/ahead (±Z) of the - chassis as fallbacks; horizontal clearance ray + downward ground ray; - `raycastQuery` sees NON_MOVING only, so the vehicle never blocks its - own exit), moves the + chassis as fallbacks; the ground ray runs first and the horizontal + clearance ray goes from above the vehicle to just above the ground at + the spot, so a gentle slope does not swallow it; `raycastQuery` sees + NON_MOVING only, so the vehicle never blocks its own exit), moves the character node there, re-enables physics (the CharacterSystem diff check teleports the capsule to the node) and restores the on-foot camera - settings. After an exit the E key must be released once before a + settings. Exiting a stopped or nearly stopped vehicle (|speed| <= + `EXIT_PARK_MAX_SPEED` 1.0 m/s) parks it: `inputHandBrake` is left + engaged (VehicleSystem keeps feeding it every physics frame) until a + driver seats again and `update()` overwrites the inputs; bailing out + at speed lets the car coast. After an exit the E key must be released + once before a `vehicle_enter` event is accepted again (`m_eReleaseRequired`), so the still-held exit key cannot immediately re-seat the player; the E/F/I/Space pressed edges also ignore SDL key repeats diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index f7d8179..eecbd3e 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -343,7 +343,30 @@ visuals: Jolt reports every wheel with the same axle direction, so a single shared wheel mesh had its inner face outward on the right side — `VehicleSystem::postPhysicsUpdate` now flips right-side (chassis -X) wheel visuals 180 deg about the local forward axis; no mirrored mesh -needed. +needed. Finally, cars no longer brake by themselves when the +accelerator is released: gen_vehicle.py now emits `engineBrake` 0 +(the forklift keeps 0.5 so it does not roll forever), so a sedan +coasts until the driver brakes (S while rolling forward brakes via the +existing brake-before-reverse rule, Space is the handbrake) — a parked +car on a slope legitimately rolls. `--test-vehicle-coast` drives +sedan1 for 200 frames, releases W and requires the car to still roll +above 5 m/s after 240 coast frames; it then jumps off at speed and +requires the car to keep coasting, while the drive test's driver-exit +leg stops the car first and requires the exit to park it (handbrake +stays engaged, car stays put even on the exterior slope — +`VehicleControllerSystem` parks on exit below 1 m/s and passengers no +longer release the handbrake). The exit-spot probe directions are +flattened to horizontal (a car resting tilted on rough terrain used to +swing the side spots meters up/down so the ground ray missed real +ground) and the clearance ray targets the ground height at the spot. + +**sedan-v2-taxi (the taxi).** `sedan_taxi` in the exterior scene is +now `assets/blender/vehicles/sedan-v2-taxi.blend` — same base car as +sedan-v2 (identical part/seat naming, shared wheel mesh) with taxi +livery and roof sign — exported by the same gen_vehicle.py pass and +merged by `update_vehicles`; `sedan-v2-taxi-glass` joined +`kGlassMaterialNames` for the near/far glass swap. The old procedural +gen_taxi_assets.py taxi is retired. Note the scene JSONs hold a merged COPY of the generated entity — after editing the blend, run the single `update_vehicles` build target (assets/blender/vehicles/CMakeLists.txt): @@ -355,6 +378,7 @@ Verify: ```bash ./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json ./demoTaxi --headless --test-vehicle-drive # exterior streaming terrain +./demoTaxi --headless --test-vehicle-coast # coasting + park/jump-off ``` ### Phase 2 — Road geometry & compliance improvements diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 542342e..b776dc5 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -281,7 +281,8 @@ static void createFloorMesh(const Ogre::String &meshName, * extend it when adding car families. */ static const char *kGlassMaterialNames[] = {"sedan-glass", - "sedan-v2-glass"}; + "sedan-v2-glass", + "sedan-v2-taxi-glass"}; static void fixupSedanGlassMaterial() { @@ -965,6 +966,7 @@ struct VehicleDriveTestListener : public Ogre::FrameListener { bool failed = false; Ogre::String failReason; Ogre::Vector3 startPos = Ogre::Vector3::ZERO; + Ogre::Vector3 parkPos = Ogre::Vector3::ZERO; Ogre::Vector3 authoredPos = Ogre::Vector3::ZERO; bool authoredValid = false; float lastY = 0.0f; @@ -1241,14 +1243,26 @@ struct VehicleDriveTestListener : public Ogre::FrameListener { break; } case 6: { - /* Let the steering settle back, then exit. */ + /* Let the steering settle back, then stop the car + * (as a driver braking to a stop would) and exit — + * a stopped car must get parked by the exit. */ if (frame - driveStart < 60) break; + { + const RigidBodyComponent &rb = + sedan.get(); + app->getEditorPhysicsSystem() + ->getPhysicsWrapper() + ->setVelocities(rb.bodyID, + Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + } const TransformComponent &ft = sedan.get(); Ogre::Vector3 pos = ft.node ? ft.node->_getDerivedPosition() : ft.position; + parkPos = pos; vcs->exitVehicle(); if (vcs->isDriving()) { failed = true; @@ -1307,6 +1321,33 @@ struct VehicleDriveTestListener : public Ogre::FrameListener { * NOT become player-controlled. */ if (frame - driveStart < 30) break; + /* The driver exited a stopped car: it must be + * parked (handbrake) and stay put even on the + * exterior scene's slope. */ + { + const TransformComponent &t = + sedan.get(); + Ogre::Vector3 pos = + t.node ? t.node->_getDerivedPosition() : + t.position; + float drift = (pos - parkPos).length(); + const VehicleComponent &vc = + sedan.get(); + if (vc.inputHandBrake < 1.0f || drift > 0.5f) { + failed = true; + failReason = + vehicleName + + " not parked after the driver " + "exited a stopped car (handbrake " + + Ogre::StringConverter::toString( + vc.inputHandBrake) + + ", drift " + + Ogre::StringConverter::toString( + drift) + + ")"; + break; + } + } editScene::EventParams params; params.setString("vehicle", vehicleName); params.setString("seat", "passenger"); @@ -1378,13 +1419,24 @@ struct VehicleDriveTestListener : public Ogre::FrameListener { t.node->_getDerivedPosition() : t.position; float moved = (pos - startPos).length(); - if (moved > 1.0f) { + /* The contract is that no driving inputs reach the + * vehicle from a passenger seat; a positional check is + * meaningless now that cars coast (a parked car on a + * slope legitimately rolls). */ + const VehicleComponent &vic = + sedan.get(); + if (vic.inputForward != 0.0f || + vic.inputRight != 0.0f) { failed = true; failReason = vehicleName + - " moved with passenger-seat W input " - "(moved " + - Ogre::StringConverter::toString(moved) + + " got driving inputs from the " + "passenger seat (forward " + + Ogre::StringConverter::toString( + vic.inputForward) + + ", right " + + Ogre::StringConverter::toString( + vic.inputRight) + ")"; break; } @@ -1421,9 +1473,22 @@ struct VehicleDriveTestListener : public Ogre::FrameListener { Ogre::Vector3 right = vt.node->_getDerivedOrientation() * Ogre::Vector3::UNIT_X; - float lat = (cpos - vt.node->_getDerivedPosition()) - .dotProduct(right); - if (lat * seatX <= 0.0f) { + /* Flatten both vectors: the exit + * spots are computed horizontally, + * and a car resting tilted on rough + * terrain must not skew the metric + * (the character drops to the + * ground below a tilted chassis). */ + right.y = 0.0f; + if (right.squaredLength() < 0.01f) + right = Ogre::Vector3::UNIT_X; + right.normalise(); + Ogre::Vector3 delta = + cpos - vt.node->_getDerivedPosition(); + delta.y = 0.0f; + float lat = delta.dotProduct(right); + if (lat * seatX <= 0.0f || + delta.length() < 1.0f) { failed = true; failReason = "exited on the wrong side " @@ -1768,6 +1833,162 @@ struct VehicleRightingTestListener : public Ogre::FrameListener { } }; +/* + * --test-vehicle-coast: cars must NOT brake by themselves when the + * accelerator is released (engineBrake 0; braking is the driver's job + * via S-while-rolling / Space). Enters sedan1 (vehicletest scene), + * holds W for 200 frames, releases all inputs and coasts for 240 + * frames, requiring the car to still be rolling above 5 m/s + * (engineBrake 0.5 would have stopped it); then jumps off at speed and + * requires the car to keep coasting (parking the car only happens when + * the driver steps out of a nearly stopped vehicle). + */ +struct VehicleCoastTestListener : public Ogre::FrameListener { + EditorApp *app; + Ogre::String vehicleName = "sedan1"; + int frame = 0; + /* 0: wait ready, 1: wait settle, 2: W drive, 3: coast, + * 4: exit at speed + keep-coasting check, 5: done */ + int phase = 0; + bool failed = false; + Ogre::String failReason; + int driveStart = 0; + float speedAtRelease = 0.0f; + + VehicleCoastTestListener(EditorApp *a) + : app(a) + { + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 5) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > 20000) { + failed = true; + failReason = "timeout in vehicle coast test"; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity sedan = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == vehicleName) + sedan = e; + }); + if (!sedan.is_alive()) { + if (frame > 600) { + failed = true; + failReason = vehicleName + " entity not found"; + } + return true; + } + if (!sedan.has() || + !sedan.has()) + return true; + const RigidBodyComponent &rb = sedan.get(); + const VehicleComponent &v = sedan.get(); + if (!rb.bodyCreated || !v.constraintCreated) + return true; + if (!app->getPlayerCharacterEntity().is_alive()) + return true; + + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + + switch (phase) { + case 0: + phase = 1; + break; + case 1: { + if (phys->getLinearVelocity(rb.bodyID).length() > + 0.05f) + break; + EventBus::getInstance().send("vehicle_enter", "vehicle", + vehicleName); + VehicleControllerSystem *vcs = + app->getVehicleControllerSystem(); + if (!vcs || !vcs->isDriving()) { + failed = true; + failReason = "vehicle_enter did not start " + "driving"; + break; + } + app->getGameInputState().w = true; + driveStart = frame; + phase = 2; + break; + } + case 2: { + if (frame - driveStart < 200) + break; + app->getGameInputState().w = false; + speedAtRelease = + phys->getLinearVelocity(rb.bodyID).length(); + driveStart = frame; + phase = 3; + break; + } + case 3: { + if (frame - driveStart < 240) + break; + float speed = + phys->getLinearVelocity(rb.bodyID).length(); + std::cout << "[test] coast-test: speed at release " + << speedAtRelease << " m/s, after 240 " + "coast frames " + << speed << " m/s" << std::endl; + if (speedAtRelease > 5.0f && speed < 5.0f) { + failed = true; + failReason = "car braked by itself while " + "coasting (engineBrake regression)"; + break; + } + /* Jump off at speed: the car must keep coasting + * (no parking brake). */ + VehicleControllerSystem *vcs = + app->getVehicleControllerSystem(); + vcs->exitVehicle(); + if (vcs->isDriving()) { + failed = true; + failReason = "exitVehicle at speed did not " + "end driving"; + break; + } + driveStart = frame; + phase = 4; + break; + } + case 4: { + if (frame - driveStart < 60) + break; + float speed = + phys->getLinearVelocity(rb.bodyID).length(); + const VehicleComponent &vc = + sedan.get(); + std::cout << "[test] coast-test: after jumping off " + "at speed, 60 frames later " + << speed << " m/s (handbrake " + << vc.inputHandBrake << ")" << std::endl; + if (vc.inputHandBrake != 0.0f || speed < 3.0f) { + failed = true; + failReason = "car parked after the driver " + "jumped off at speed"; + break; + } + std::cout << "[test] PASS" << std::endl; + phase = 5; + break; + } + } + return true; + } +}; + /* * demo-taxi: taxi driver demo. Starts as an exact copy of * demos/demo-interior-exterior-dynamics (same scene-switch-door setup, @@ -1937,6 +2158,7 @@ int main(int argc, char *argv[]) bool testVehicleDrive = false; bool testVehicleTurn = false; bool testVehicleRighting = false; + bool testVehicleCoast = false; bool sceneArgGiven = false; Ogre::String shotFile; int shotFrame = -1; @@ -1965,6 +2187,8 @@ int main(int argc, char *argv[]) testVehicleTurn = true; } else if (arg == "--test-vehicle-righting") { testVehicleRighting = true; + } else if (arg == "--test-vehicle-coast") { + testVehicleCoast = true; } else if (arg == "--debug-buoyancy") { app.setDebugBuoyancy(true); } else if (arg == "--debug-enter-vehicle" && @@ -2017,6 +2241,9 @@ int main(int argc, char *argv[]) * floor. */ if (testVehicleRighting && !sceneArgGiven) sceneFile = "demo_scene_vehicletest.json"; + /* The coast test too. */ + if (testVehicleCoast && !sceneArgGiven) + sceneFile = "demo_scene_vehicletest.json"; app.setHeadless(headless); if (headless) { /* Headless frames take ~1ms of wall time; pin a 60Hz @@ -2082,6 +2309,10 @@ int main(int argc, char *argv[]) app.getRoot()->addFrameListener( &vehicleRightingListener); + VehicleCoastTestListener vehicleCoastListener(&app); + if (testVehicleCoast) + app.getRoot()->addFrameListener(&vehicleCoastListener); + /* Window glass near/far swap (always on). */ SedanGlassLodListener glassListener(&app); app.getRoot()->addFrameListener(&glassListener); diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json index 6887d92..d249708 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json @@ -606,27 +606,19 @@ { "children": [ { - "actuator": { - "actionNames": [ - "vehicle_seat_driver" - ], - "height": 2.0, - "radius": 1.2 - }, "children": [], - "id": 610, "name": { - "name": "sedan_taxi_door_fl" + "name": "sedan_taxi_door_rl" }, "renderable": { - "meshName": "sedan-taxi-door-fl.mesh", + "meshName": "sedan-v2-taxi-door-rl.mesh", "visible": true }, "transform": { "position": { - "x": 0.855, - "y": -0.1, - "z": 0.55 + "x": 0.9192, + "y": -0.2869, + "z": -0.132 }, "rotation": { "w": 1.0, @@ -639,66 +631,29 @@ "y": 1.0, "z": 1.0 } - } - }, - { - "actuator": { - "actionNames": [ - "vehicle_seat_passenger" - ], - "height": 2.0, - "radius": 1.2 }, - "children": [], - "id": 611, - "name": { - "name": "sedan_taxi_door_fr" - }, - "renderable": { - "meshName": "sedan-taxi-door-fr.mesh", - "visible": true - }, - "transform": { - "position": { - "x": -0.855, - "y": -0.1, - "z": 0.55 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, - { "actuator": { "actionNames": [ "vehicle_seat_rl" ], "height": 2.0, "radius": 1.2 - }, + } + }, + { "children": [], - "id": 612, "name": { - "name": "sedan_taxi_door_rl" + "name": "sedan_taxi_door_rr" }, "renderable": { - "meshName": "sedan-taxi-door-rl.mesh", + "meshName": "sedan-v2-taxi-door-rr.mesh", "visible": true }, "transform": { "position": { - "x": 0.855, - "y": -0.1, - "z": -0.46 + "x": -0.9192, + "y": -0.2869, + "z": -0.132 }, "rotation": { "w": 1.0, @@ -711,30 +666,29 @@ "y": 1.0, "z": 1.0 } - } - }, - { + }, "actuator": { "actionNames": [ "vehicle_seat_rr" ], "height": 2.0, "radius": 1.2 - }, + } + }, + { "children": [], - "id": 613, "name": { - "name": "sedan_taxi_door_rr" + "name": "sedan_taxi_door_fl" }, "renderable": { - "meshName": "sedan-taxi-door-rr.mesh", + "meshName": "sedan-v2-taxi-door-fl.mesh", "visible": true }, "transform": { "position": { - "x": -0.855, - "y": -0.1, - "z": -0.46 + "x": 0.9192, + "y": -0.2868, + "z": 0.801 }, "rotation": { "w": 1.0, @@ -747,23 +701,64 @@ "y": 1.0, "z": 1.0 } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_driver" + ], + "height": 2.0, + "radius": 1.2 + } + }, + { + "children": [], + "name": { + "name": "sedan_taxi_door_fr" + }, + "renderable": { + "meshName": "sedan-v2-taxi-door-fr.mesh", + "visible": true + }, + "transform": { + "position": { + "x": -0.9192, + "y": -0.2868, + "z": 0.801 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "actuator": { + "actionNames": [ + "vehicle_seat_passenger" + ], + "height": 2.0, + "radius": 1.2 } }, { "children": [], - "id": 614, "name": { "name": "sedan_taxi_hood" }, "renderable": { - "meshName": "sedan-taxi-hood.mesh", + "meshName": "sedan-v2-taxi-hood.mesh", "visible": true }, "transform": { "position": { - "x": 0.0, - "y": 0.17, - "z": 0.75 + "x": -0.0, + "y": 0.266, + "z": 0.9992 }, "rotation": { "w": 1.0, @@ -780,19 +775,18 @@ }, { "children": [], - "id": 615, "name": { "name": "sedan_taxi_trunk" }, "renderable": { - "meshName": "sedan-taxi-trunk.mesh", + "meshName": "sedan-v2-taxi-trunk.mesh", "visible": true }, "transform": { "position": { "x": 0.0, - "y": 0.27, - "z": -1.35 + "y": 0.2744, + "z": -1.446 }, "rotation": { "w": 1.0, @@ -813,13 +807,13 @@ "meshName": "", "offset": { "x": 0.0, - "y": 0.0, - "z": 0.0 + "y": 0.3821, + "z": -0.0629 }, "parameters": { - "x": 0.9, - "y": 0.35, - "z": 2.4 + "x": 1.0107, + "y": 0.8412, + "z": 2.4834 }, "radius": 0.5, "rotationOffset": { @@ -830,12 +824,11 @@ }, "shapeType": "box" }, - "id": 600, "name": { "name": "sedan_taxi" }, "renderable": { - "meshName": "sedan-taxi.mesh", + "meshName": "sedan-v2-taxi.mesh", "visible": true }, "rigidBody": { @@ -867,130 +860,134 @@ "vehicle": { "cameraDistance": 6.0, "cameraHeight": 1.6, - "engineBrake": 0.5, + "engineBrake": 0.0, "maxPitchRollAngleDeg": 60.0, "maxTorque": 300.0, "rearSteer": false, "seatOffset": { - "x": -0.35, - "y": 0.15, - "z": -0.1 + "x": 0.48, + "y": -0.28, + "z": 0.2012 }, "steeringRatio": 4.0, - "steeringWheelMeshName": "sedan-steering-wheel.mesh", + "steeringWheelMeshName": "sedan-v2-taxi-steering-wheel.mesh", "steeringWheelOffset": { - "x": 0.35, - "y": 0.45, - "z": 0.2 + "x": 0.4915, + "y": 0.0807, + "z": 0.601 }, "steeringWheelTiltDeg": 25.0, - "wheelMeshName": "sedan-wheel.mesh", + "wheelMeshName": "sedan-v2-taxi-wheel.mesh", "wheels": [ { "driven": false, "maxHandBrakeTorque": 0.0, "maxSteerAngleDeg": 35.0, "position": { - "x": -0.8, - "y": -0.25, - "z": 1.5 + "x": 0.7805, + "y": -0.2052, + "z": 1.4806 }, - "radius": 0.33, - "suspensionDamping": 0.7, - "suspensionFrequency": 1.8, + "radius": 0.3918, + "suspensionDamping": 0.6, + "suspensionFrequency": 1.3, "suspensionMaxLength": 0.35, "suspensionMinLength": 0.1, - "width": 0.22 + "width": 0.3145 }, { "driven": false, "maxHandBrakeTorque": 0.0, "maxSteerAngleDeg": 35.0, "position": { - "x": 0.8, - "y": -0.25, - "z": 1.5 + "x": -0.7805, + "y": -0.2052, + "z": 1.4806 }, - "radius": 0.33, - "suspensionDamping": 0.7, - "suspensionFrequency": 1.8, + "radius": 0.3918, + "suspensionDamping": 0.6, + "suspensionFrequency": 1.3, "suspensionMaxLength": 0.35, "suspensionMinLength": 0.1, - "width": 0.22 + "width": 0.3145 }, { "driven": true, "maxHandBrakeTorque": 300.0, "maxSteerAngleDeg": 0.0, "position": { - "x": -0.8, - "y": -0.25, - "z": -1.5 + "x": 0.7805, + "y": -0.2052, + "z": -1.2806 }, - "radius": 0.33, - "suspensionDamping": 0.7, - "suspensionFrequency": 1.8, + "radius": 0.3918, + "suspensionDamping": 0.6, + "suspensionFrequency": 1.3, "suspensionMaxLength": 0.35, "suspensionMinLength": 0.1, - "width": 0.22 + "width": 0.3145 }, { "driven": true, "maxHandBrakeTorque": 300.0, "maxSteerAngleDeg": 0.0, "position": { - "x": 0.8, - "y": -0.25, - "z": -1.5 + "x": -0.7805, + "y": -0.2052, + "z": -1.2806 }, - "radius": 0.33, - "suspensionDamping": 0.7, - "suspensionFrequency": 1.8, + "radius": 0.3918, + "suspensionDamping": 0.6, + "suspensionFrequency": 1.3, "suspensionMaxLength": 0.35, "suspensionMinLength": 0.1, - "width": 0.22 + "width": 0.3145 } ], "seats": [ { "name": "driver", "offset": { - "x": 0.35, - "y": -0.22, - "z": 0.25 + "x": 0.48, + "y": -0.28, + "z": 0.2012 }, "driver": true }, { "name": "passenger", "offset": { - "x": -0.35, - "y": -0.22, - "z": 0.25 + "x": -0.48, + "y": -0.28, + "z": 0.2012 }, "driver": false }, { "name": "rl", "offset": { - "x": 0.35, - "y": -0.22, - "z": -0.35 + "x": 0.48, + "y": -0.28, + "z": -0.6032 }, "driver": false }, { "name": "rr", "offset": { - "x": -0.35, - "y": -0.22, - "z": -0.35 + "x": -0.48, + "y": -0.28, + "z": -0.6032 }, "driver": false } ], - "seatCharacterScale": 0.85 - } + "seatCharacterScale": 1.0, + "tireFrictionLongitudinal": 1.3, + "tireFrictionLateral": 3.0, + "antiRollBarStiffness": 2000.0 + }, + "id": 600 }, { "children": [ @@ -1249,7 +1246,7 @@ "vehicle": { "cameraDistance": 6.0, "cameraHeight": 1.6, - "engineBrake": 0.5, + "engineBrake": 0.0, "maxPitchRollAngleDeg": 60.0, "maxTorque": 300.0, "rearSteer": false, diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json b/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json index 2328f0f..e69247c 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_vehicletest.json @@ -554,7 +554,7 @@ "vehicle": { "cameraDistance": 6.0, "cameraHeight": 1.6, - "engineBrake": 0.5, + "engineBrake": 0.0, "maxPitchRollAngleDeg": 60.0, "maxTorque": 300.0, "rearSteer": false, diff --git a/src/features/editScene/systems/VehicleControllerSystem.cpp b/src/features/editScene/systems/VehicleControllerSystem.cpp index 5b0a387..3e10fd9 100644 --- a/src/features/editScene/systems/VehicleControllerSystem.cpp +++ b/src/features/editScene/systems/VehicleControllerSystem.cpp @@ -26,6 +26,9 @@ const float EXIT_GROUND_PROBE_DOWN = 2.0f; * exiting needs a deliberately longer hold than entering. */ const float EXIT_MIN_SEAT_TIME = 0.75f; const float EXIT_HOLD_TIME = 0.5f; +/* Stepping out of a stopped or nearly stopped vehicle parks it + * (handbrake); bailing out at speed lets the car coast on. */ +const float EXIT_PARK_MAX_SPEED = 1.0f; } VehicleControllerSystem::VehicleControllerSystem(flecs::world &world, @@ -372,11 +375,12 @@ void VehicleControllerSystem::update(float deltaTime) /* Fork lift: R raises, F lowers (hold). */ vc.inputFork = input.r ? 1.0f : (input.f ? -1.0f : 0.0f); } else { - /* Passenger seat: no driving inputs reach the vehicle. */ + /* Passenger seat: no driving inputs reach the vehicle. + * The handbrake is left alone — it is part of the + * parked state set when the driver got out. */ vc.inputForward = 0.0f; vc.inputRight = 0.0f; vc.inputBrake = 0.0f; - vc.inputHandBrake = 0.0f; vc.inputFork = 0.0f; } @@ -427,14 +431,26 @@ bool VehicleControllerSystem::findExitSpot(flecs::entity vehicle, /* Exit through the door of the seat actually occupied: sideways * from the seat's own row (chassis +X is the left/driver side), * then the opposite side of the same row, then behind/ahead of - * the vehicle as fallbacks. */ + * the vehicle as fallbacks. The direction vectors are flattened: + * a tilted car (slope, curb) would otherwise swing the spots + * meters up/down and the ground probe would miss real ground. */ float side = seatOffset.x >= 0.0f ? 1.0f : -1.0f; Ogre::Vector3 seatWorld = vor * seatOffset; + Ogre::Vector3 rightFlat = right; + rightFlat.y = 0.0f; + if (rightFlat.squaredLength() < 0.01f) + rightFlat = Ogre::Vector3::UNIT_X; + rightFlat.normalise(); + Ogre::Vector3 fwdFlat = fwd; + fwdFlat.y = 0.0f; + if (fwdFlat.squaredLength() < 0.01f) + fwdFlat = Ogre::Vector3::UNIT_Z; + fwdFlat.normalise(); const Ogre::Vector3 candidates[4] = { - seatWorld + right * (side * EXIT_SIDE_OFFSET), - seatWorld - right * (side * EXIT_SIDE_OFFSET), - vpos - fwd * EXIT_END_OFFSET, - vpos + fwd * EXIT_END_OFFSET, + seatWorld + rightFlat * (side * EXIT_SIDE_OFFSET), + seatWorld - rightFlat * (side * EXIT_SIDE_OFFSET), + fwdFlat * -EXIT_END_OFFSET, + fwdFlat * EXIT_END_OFFSET, }; JoltPhysicsWrapper *physics = JoltPhysicsWrapper::getSingletonPtr(); @@ -448,18 +464,25 @@ bool VehicleControllerSystem::findExitSpot(flecs::entity vehicle, * vehicle chassis (MOVING) never blocks its own exit. */ Ogre::Vector3 hitPos; JPH::BodyID hitBody; - Ogre::Vector3 eye = vpos + - Ogre::Vector3(0, EXIT_PROBE_HEIGHT, 0); - Ogre::Vector3 probe = spot + - Ogre::Vector3(0, EXIT_PROBE_HEIGHT, 0); - if (physics->raycastQuery(eye, probe, hitPos, hitBody)) - continue; /* wall/terrain between vehicle and spot */ Ogre::Vector3 top = spot + Ogre::Vector3(0, EXIT_GROUND_PROBE_UP, 0); Ogre::Vector3 bottom = spot - Ogre::Vector3(0, EXIT_GROUND_PROBE_DOWN, 0); if (!physics->raycastQuery(top, bottom, hitPos, hitBody)) continue; /* no ground under the spot */ + /* Clearance ray from above the vehicle to just above the + * GROUND at the spot — using the spot's ground height keeps + * a gentle slope from swallowing the ray (a straight + * chassis-height ray dips into an uphill slope and would + * reject a perfectly good door-side spot). Walls still + * block it. */ + Ogre::Vector3 eye = vpos + + Ogre::Vector3(0, EXIT_PROBE_HEIGHT, 0); + Ogre::Vector3 probe = hitPos + + Ogre::Vector3(0, EXIT_PROBE_HEIGHT, 0); + Ogre::Vector3 wallHit; + if (physics->raycastQuery(eye, probe, wallHit, hitBody)) + continue; /* wall between vehicle and spot */ out = hitPos + Ogre::Vector3(0, 0.05f, 0); return true; } @@ -544,6 +567,18 @@ void VehicleControllerSystem::exitVehicle() vc.inputBrake = 0.0f; vc.inputHandBrake = 0.0f; vc.inputFork = 0.0f; + /* Park a stopped or nearly stopped vehicle (handbrake) so it does + * not roll away on a slope; bailing out at speed lets the car + * coast on. The handbrake input persists until someone seats a + * driver again (update() overwrites the inputs every frame). */ + if (vc.constraintCreated) { + JoltPhysicsWrapper *physics = + JoltPhysicsWrapper::getSingletonPtr(); + if (physics && + fabsf(physics->getVehicleForwardSpeed( + vc.constraint)) <= EXIT_PARK_MAX_SPEED) + vc.inputHandBrake = 1.0f; + } if (!vc.seats.empty() && m_seatIndex >= 0 && m_seatIndex < (int)vc.seats.size()) vc.seats[m_seatIndex].occupant = 0; diff --git a/src/features/editScene/systems/VehicleControllerSystem.hpp b/src/features/editScene/systems/VehicleControllerSystem.hpp index cdda808..5bd8486 100644 --- a/src/features/editScene/systems/VehicleControllerSystem.hpp +++ b/src/features/editScene/systems/VehicleControllerSystem.hpp @@ -40,7 +40,9 @@ class EditorApp; * actually occupied (then the opposite side of the same row, then * behind/ahead of the vehicle), re-enables its physics and restores * the on-foot camera. When no spot is clear the player stays seated and - * a warning is logged. + * a warning is logged. Exiting a stopped or nearly stopped vehicle + * parks it (the handbrake input is left engaged until a driver seats + * again); bailing out at speed lets the car coast on. * * Driving state is runtime-only: it does not survive save/load, and * resetDriving() (called from EditorApp::destroySceneEntities) drops it