Taxi demo update with sedan

This commit is contained in:
2026-09-17 02:55:53 +03:00
parent c748a7dd1e
commit 3c7f50a4ef
21 changed files with 2007 additions and 105 deletions
+61 -2
View File
@@ -127,6 +127,10 @@ cmake --build build --target Procedural # Procedural test (legacy)
cmake --build build --target TerrainTest # Terrain test (legacy)
cmake --build build --target TerrainTest # Terrain test (legacy)
cmake --build build --target editSceneEditor # Current executable for scene editor and game
cmake --build build --target demoLuaSceneScript # Demo: game-mode inline scene/prefab Lua scripts
cmake --build build --target demoSceneSwitchingExtra # Demo: scene switching with CellGrid interior
cmake --build build --target demoSokoban # Demo: forklift sokoban activity (vehicle + crates + quest HUD)
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
```
### Build Outputs
@@ -135,6 +139,11 @@ cmake --build build --target editSceneEditor # Current executable for scene e
- `build/Procedural` - Procedural generation test (legacy)
- `build/TerrainTest` - Terrain system test (legacy)
- `build//src/features/editScene/editSceneEditor` - current executable
- `build//src/features/editScene/demos/demo-lua-scene-script/demoLuaSceneScript` - SceneScriptComponent demo (game mode, inline Lua); self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-scene-switching-extra/demoSceneSwitchingExtra` - Scene-switching demo with a CellGrid interior (own-entity ProceduralMaterial/ProceduralTexture); self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-interior-exterior-dynamics/demoInteriorExteriorDynamics` - Same scene-switching demo, but the exterior scene has a streaming terrain (baseNoise archipelago - a large island at the content site amid an ocean with more islands) + sky + water instead of the flat floor; self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-sokoban/demoSokoban` - Forklift sokoban activity demo (drivable Jolt VehicleConstraint forklift, pushable crates, target pads, completion HUD); based on demo-interior-exterior-dynamics, see demos/demo-sokoban/PLAN.md; self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-taxi/demoTaxi` - Taxi driver demo (procedurally generated town, drivable sedans, passenger/foot-traffic/vehicle-traffic NPCs, chained timed fares with doubling payout); based on demo-interior-exterior-dynamics with vehicle ideas from demo-sokoban, see demos/demo-taxi/PLAN.md; self-contained runtime staged in the same directory
- `build/resources/` - Staged game resources
- `build/characters/` - Processed character models
- `build/water/` - Water assets (legacy)
@@ -224,13 +233,17 @@ The editor/game mode executable adds its own ECS modules:
|--------|---------|
| CharacterSlotSystem | Catalog-driven multi-slot character mesh builder |
| CharacterSpawnerSystem | Distance-based spawn/despawn of registry characters |
| TerrainPrefabSpawnerSystem | Distance-based terrain prefab spawn/despawn, terrain snap and compliance flattening |
| PlayerControllerSystem | Player input, camera, locomotion and animation state |
| ActuatorSystem | Player interaction prompts and smart-object/item actions |
| DoorSystem | Swing animation and collider toggle for CellGrid door entities |
| EditorUISystem | ImGui property panels and scene management |
| ProceduralTextureSystem | Runtime procedural texture generation |
| ProceduralMaterialSystem | Runtime procedural material creation |
| AnimationTreeRegistry | Global named animation tree definitions |
| AnimationTreeSystem | Runtime evaluation of registry-referenced animation trees |
| SceneScriptSystem | Execute-once scene/prefab Lua scripts (SceneScriptComponent) and scene_loaded/prefab_loaded events |
| VehicleSystem | Jolt VehicleConstraint vehicles (VehicleComponent): chassis constraint lifetime, driver input, wheel visual nodes |
#### PlayerControllerSystem
@@ -245,6 +258,11 @@ When a controller's `targetCharacterName` points at an entity with
immediate spawn; the actual controlled entity is the spawned character
instance, not the spawner.
The TPS camera boom is collision-clamped via a physics raycast from the
pivot to the desired camera position (instant pull-in, eased recovery), and
the character geometry is hidden when the clamped camera gets too close to
the body — see `src/features/editScene/AGENTS.md` for details.
#### Player Character Resolution
Systems that need the live player character (inventory UI, character sheet,
@@ -268,7 +286,9 @@ inside `keyPressed`/`keyReleased` by `keysym.sym`. Repeated `KEYDOWN` events
(`evt.repeat != 0`) are ignored for movement keys, because releasing `Shift`
while `W`/`A`/`S`/`D` is held can generate a repeated keydown with a different
symbol after the physical release, which would otherwise leave the key stuck.
One-shot actions (`E`, `F`, `I`, `Escape`) are still matched by `keysym.sym`.
One-shot actions (`E`, `F`, `I`, `Space`) are also matched by `keysym.sym`,
and their `*Pressed` edges ignore repeats too — otherwise holding `E` (e.g.
the hold-E-to-exit a vehicle) re-fires the door actuators and re-enters.
#### CharacterSpawnerComponent
@@ -286,6 +306,30 @@ The spawner uses the entity's `TransformComponent` for spawn position/rotation.
The actual tree definition (node hierarchy, state machines, animations) lives in the `AnimationTreeRegistry`, persisted to `animation_tree.json`. Trees are authored in the registry editor (**Tools -> Animation Tree Registry**), where each entry can also select a skeleton source from the `Characters` resource group (one representative mesh per skeleton) to populate animation-name dropdowns. Old scenes and prefabs that stored the tree inline or referenced an `AnimationTreeTemplate` are migrated automatically on load.
#### Road Region Streaming (M5)
When the terrain has `streamingEnabled`, `RoadSystem` persists the road graph per terrain page in `heightmaps/<terrainId>/roads/<px>_<py>.json` (managed by `RoadRegionStore`) instead of inline in the scene JSON — `SceneSerializer::serializeTerrain` then skips `roadNodes`/`roadEdges` and keeps only `roadConfig`. Region files store absolute world-space doubles; a cross-page edge is written to the region file of each endpoint page with the foreign endpoint repeated inline. On the first sync a non-empty inline graph is migrated to region files automatically. Merges match nodes by world XZ (0.01 eps) so reloaded pages never duplicate nodes; `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` before writing the scene. Page mesh rebuilds are gated by a rebase-invariant FNV-1a content signature (world positions quantized to cm), so a render-origin rebase shifts node render positions without re-dirtying pages; navmesh dirtying is debounced (~15 quiet frames) while streaming.
#### Terrain Streaming Architecture
The terrain can span up to 40,000,000 x 40,000,000 units (`TerrainComponent::worldSizeUnits`, default; 20000 x 20000 pages at the default 2000-unit page size, clamped to 32768 per axis for TerrainGroup's signed 16-bit slot packing). Streaming is opt-in per terrain via `TerrainComponent::streamingEnabled`.
**Coordinate spaces.** Three spaces coexist (see `TerrainSystem.hpp` "Coordinate spaces"):
- **WORLD** space — absolute double-precision coordinates; authoritative. `TransformComponent::worldX/Y/Z`, region files, bookmarks and the Navigation panel all use it.
- **RENDER** space — floats relative to the floating render origin: `render = world - renderOrigin`. Scene nodes, mouse rays and the editor camera live here. `RenderOriginSystem` (singleton, `RenderOriginSystem::getInstance()`) owns the origin and rebases when the camera moves further than `REBASE_THRESHOLD` (8192 render units) from it: the origin snaps to integer world coordinates near the camera and every render-space position shifts by the exact negated delta. Convert with `RenderOriginSystem::worldToRender(x,y,z)` / `renderToWorld(Ogre::Vector3)`.
- **PHYSICS** space — Jolt `DVec3`/`RVec3` in absolute world coordinates via `physics/physics.h`; terrain colliders live here, so `TerrainSystem::raycastTerrain` converts its render-space ray to world space for the collider query.
`TerrainSystem` additionally exposes `renderToWorldX/Y/Z`, `worldToRender`, and `visualToPhysicalX/Z` / `physicalToVisualX/Z` for the physical heightmap space (physical X shifted by half a page, physical Z mirrored within each page).
**Page streaming window.** With `streamingEnabled`, pages are not defined up front; `TerrainSystem::updateStreamingWindow()` loads a square window of `pageLoadRadius` pages around the camera page each frame (2 loads/frame cap) and unloads pages beyond `pageHoldRadius` (4 unloads/frame cap), all synchronously to avoid WorkQueue shutdown hangs. Base heights come from procedural `TerrainComponent::baseNoise` (FastNoiseLite, double precision) instead of the legacy heightmap buffer; `setHeightAt` writes through the LRU-capped fixup chunk layer. Pages are indexed in PHYSICAL page coords `[0, N-1]`; the Ogre TerrainGroup slot for physical page `(x, y)` is `(x, -y)` because `ALIGN_X_Z` negates Z. The world is bounded — page indices and teleports clamp to `[0, N-1]`.
**Region storage.** Per-page data lives under `heightmaps/<terrainId>/`: painted blend maps, aux maps, fixup chunks, plus the streamed content stores `spawners/<px>_<py>.json` (prefab spawner defs, see below) and `roads/<px>_<py>.json` (road graph, see "Road Region Streaming (M5)" above).
**Editor navigation tools (M3).** `EditorCamera` supports mouse-wheel zoom (`handleMouseWheel`), Shift boost (x10) and a configurable fly speed (`setFlySpeed`, 1..100000 units/s). **Tools -> Navigation** opens `NavigationPanel` (teleport by world X/Y/Z or by page index, clamped to world bounds; named world bookmarks stored in the scene JSON top-level `bookmarks` array, tolerated when missing on load). **Tools -> World Map** opens `WorldMapPanel` (ImGui canvas over `WorldMapData`: coarse heights, loaded pages, roads, spawners, bookmarks and camera overlays; drag pan, wheel zoom, double-click teleport). `TerrainEditor` also has a compact "Navigation" section sharing the same static teleport backend.
**Streamed vs always-loaded spawners (M4).** Scene-embedded `TerrainPrefabSpawnerComponent` entities behave as always-loaded spawners for small/non-streaming scenes. When the terrain streams, spawner definitions persist per page in `heightmaps/<terrainId>/spawners/<px>_<py>.json` (via `SpawnerRegionStore`); `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` polls `TerrainGroup::getTerrainSlots()` and creates a region spawner entity (tagged `StreamedSpawnerTag{pageX,pageY,defId}`, excluded from scene JSON) when a page loads, destroying it and writing edits back (including page-boundary crossings) when it unloads. The editor "prefab spawn mode" click-to-place writes to the region store when streaming.
### Physics System
Uses Jolt Physics with custom wrapper (legacy - `src/physics/physics.h`, current - `src/features/editScene/physics/physics.h`):
@@ -311,7 +355,22 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
2. **Export**: Python scripts in `assets/blender/scripts/`
- `export_buildings.py` - Building export to glTF
- `export_characters_ogre.py` - Character export to OGRE format
- `export_vehicles.py` - Vehicle export
- `export_vehicles.py` - Vehicle export (car modeling contract:
`assets/blender/vehicles/CAR_MODELING.md`)
- `gen_sokoban_assets.py` - One-shot generator for the demo-sokoban
`forklift`/`crate`/`pad` .blend files (blender2ogre names .mesh
files after mesh *data* names — set both object and data names)
- `gen_taxi_assets.py` - Generator for the demo-taxi `sedan-a` /
`sedan-taxi` .blend files (body + hinge-local door/hood/trunk
parts, shared wheel meshes; body colors baked as material
variants)
- `gen_vehicle.py` - Convention-based vehicle pipeline for
hand-modeled cars (`sedan-v2.blend` is the reference): classifies
parts by name (`* Tire`, `* Door`, `* hood`/`* trunk`,
`* Steering`, `seat_*` empties), normalizes wheel/steering axes,
unpacks texture atlases, and emits a ready-to-merge
`<name>.vehicle.json` scene entity (contract:
`assets/blender/vehicles/CAR_MODELING.md`)
- `import_vrm.py` - VRM character import
3. **Build**: CMake processes assets during build
4. **Runtime**: Assets loaded from `resources.cfg` paths
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,286 @@
#!/usr/bin/env python
"""Generate the demo-sokoban .blend assets (forklift, crate, pad, wall).
Run once with the project Blender:
blender -b -Y -P assets/blender/scripts/gen_sokoban_assets.py
Writes forklift.blend, crate.blend, pad.blend and wall.blend into
assets/blender/vehicles/, from where the import_vehicles CMake target
exports them through export_vehicles.py like the other vehicles.
Axis convention: blender2ogre EX_SWAP_AXIS='xz-y' maps Blender
(x, y, z) -> Ogre (x, z, -y), so Ogre +Z (vehicle forward, Jolt
chassis +Z) is Blender -Y and Ogre +Y (up) is Blender +Z. All meshes
are modelled around the origin: the forklift body sits in the chassis
collider space (collider half extents 0.7 x 0.3 x 1.2, wheels at
(+/-0.65, -0.2, +/-0.85) r=0.3), the crate fills the 1 m box collider,
the pad is a flat 1.2 x 1.2 marker slab.
NOTE: join_into() makes the first part the join target, whose object
location stays baked into the exported mesh as an origin offset (the
exporter does not apply it). For the forklift that is body-front at
blender (0, -0.45, 0.55), so a blender point (X, Y, Z) renders at
Ogre (X, Z - 0.55, -(Y + 0.45)). Keep that offset in mind when
matching mesh geometry against VehicleComponent offsets.
"""
import os
import bpy
from math import radians
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vehicles")
def reset_scene():
bpy.ops.wm.read_homefile(use_empty=True)
def make_material(name, color, roughness=0.8, metallic=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return mat
def add_box(name, dims, loc, mat=None):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.active_object
obj.name = name
obj.dimensions = dims
bpy.ops.object.transform_apply(location=False, rotation=False,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def add_cylinder(name, radius, depth, loc, mat=None, rotation=None,
vertices=24):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=depth, location=loc)
obj = bpy.context.active_object
obj.name = name
if rotation:
obj.rotation_euler = rotation
bpy.ops.object.transform_apply(location=False, rotation=True,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def join_into(name, objects):
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = name
obj.data.name = name # the exporter names .mesh files after mesh data
return obj
def save_blend(filename):
path = os.path.join(OUT_DIR, filename)
bpy.ops.wm.save_as_mainfile(filepath=path)
print("wrote " + path)
def gen_forklift():
"""forklift.mesh (body+mast), forklift-forks.mesh (carriage+tines,
lifted by VehicleSystem) and forklift-wheel.mesh.
Chassis origin is the Jolt body origin; the visible body sits on
top of the collider box (which spans y -0.3..0.3 in Ogre space).
Front (mast, forks) is Blender -Y == Ogre +Z.
"""
reset_scene()
yellow = make_material("forklift", (0.85, 0.6, 0.05))
steel = make_material("forklift-steel", (0.35, 0.35, 0.38),
roughness=0.5, metallic=0.6)
dark = make_material("wheel", (0.08, 0.08, 0.08), roughness=0.95)
seat_mat = make_material("forklift-seat", (0.1, 0.1, 0.12))
parts = []
# Main chassis / counterweight body, split for a driver footwell:
# the front block (hood/dash) stops at blender y 0.0 so the seated
# driver's legs dangle into the recess in front of the seat.
parts.append(add_box("body-front", (1.3, 0.9, 0.5), (0, -0.45, 0.55),
yellow))
# Nose extension: continues the hood forward over the front wheels
# (blender y -1.35..-0.9 -> Ogre z 0.45..0.90); the wheels tuck
# under it with only their fronts/sides poking out. A separate
# part so body-front keeps its location, which join_into() bakes
# in as the mesh origin offset (see the file docstring).
parts.append(add_box("body-nose", (1.3, 0.45, 0.5),
(0, -1.125, 0.55), yellow))
# Under-seat block (the seat itself sits on top, see below).
parts.append(add_box("body-seat", (0.7, 0.5, 0.42), (0, 0.45, 0.59),
yellow))
# Footwell floor.
parts.append(add_box("footwell", (0.7, 0.5, 0.06), (0, 0.05, 0.36),
steel))
# Rear counterweight bulge (back is blender +Y). Slid forward to
# touch/embed 1 cm into the under-seat block (back face y 0.70) so
# there is no gap; its rear is covered by the chassis collider
# (half z 1.3 with offset z -0.3 -> Ogre z -1.6..1.0).
parts.append(add_box("counterweight", (1.2, 0.45, 0.62),
(0, 0.915, 0.6), yellow))
# Steering column: runs from the hood top up to just under the
# wheel hub. NOTE the mesh coordinates: join_into() bakes the
# first part's (body-front) location (0, -0.45, 0.55) in as an
# origin offset, so a blender point (X, Y, Z) lands at Ogre
# (X, Z - 0.55, -(Y + 0.45)). VehicleComponent::steeringWheelOffset
# (0, 0.75, -0.3) is therefore blender (0, -0.15, 1.30); the column
# leans 25 deg toward the driver (+Y), matching the wheel tilt.
parts.append(add_cylinder("steering-column", 0.03, 0.65,
(0, -0.30, 0.98), steel,
rotation=(radians(-25.0), 0, 0)))
# Driver seat (shifted 0.1 back from the driver pin so the legs
# dangle into the footwell opening and the gap behind the seat
# closes).
parts.append(add_box("seat-base", (0.55, 0.5, 0.12),
(0, 0.45, 0.86), seat_mat))
parts.append(add_box("seat-back", (0.55, 0.12, 0.5),
(0, 0.68, 1.1), seat_mat))
# Overhead guard: 4 posts + roof (raised so the seated driver's
# head stays below the roof).
for sx in (-0.58, 0.58):
for sy in (-0.15, 0.95):
parts.append(add_box("post", (0.08, 0.08, 1.20),
(sx, sy, 1.395), steel))
parts.append(add_box("roof", (1.28, 1.3, 0.06), (0, 0.4, 2.02),
steel))
# Mast rails at the very front, ahead of the front wheels (wheel
# front edge is Ogre z 1.15; the mast front face lands at Ogre z
# 1.14) so the fork carriage rides in front of them.
for sx in (-0.45, 0.45):
parts.append(add_box("mast", (0.14, 0.12, 1.5),
(sx, -1.53, 0.95), steel))
# Low cross beam at axle height linking the nose to the mast;
# passes between the front wheels (beam x half 0.5 clears the
# wheel inner edges at x +/-0.55).
parts.append(add_box("mast-cross", (1.0, 0.75, 0.12),
(0, -1.275, 0.35), steel))
join_into("forklift", parts)
# Fork carriage + tines as a separate mesh: VehicleSystem attaches
# it as a child node at VehicleComponent::forkOffset (chassis
# space, (0,0,1.17) for this model — that shifts the assembly in
# front of the front wheels) and raises it by the fork height, so
# the geometry is modelled at the fork-down position relative to
# the carriage. join_into() bakes the carriage location
# (0, -1.12, 0.15) in as the origin offset, so a blender point
# (X, Y, Z) of this mesh renders at Ogre (X, Z - 0.15, -(Y + 1.12))
# plus forkOffset.
fork_parts = []
# Carriage plate sliding on the mast.
fork_parts.append(add_box("carriage", (0.85, 0.06, 0.55),
(0, -1.12, 0.15), steel))
# Fork tines reaching forward at floor level: the chassis rides
# ~0.73 above the floor, so the tines are dropped to Ogre y -0.70
# (blender z -0.55) — just above the floor and the resting crate's
# bottom edge. They have no collider; the chassis front face stops
# at the crate.
for sx in (-0.35, 0.35):
fork_parts.append(add_box("fork", (0.16, 0.95, 0.05),
(sx, -1.55, -0.55), steel))
fork_parts.append(add_box("fork-heel", (0.16, 0.06, 0.45),
(sx, -1.12, -0.325), steel))
join_into("forklift-forks", fork_parts)
# Wheel: the wrapper's getWheelWorldTransform assumes a Y-axis
# cylinder (Jolt GetWheelWorldTransform(inWheelRight=Y,
# inWheelUp=X)), so keep the default Blender cylinder axis (Z),
# which exports to Ogre Y.
wheel = add_cylinder("forklift-wheel", 0.3, 0.2, (0, 0, 0), dark)
wheel.data.name = "forklift-wheel"
# Steering wheel: modelled at the origin with the spin axis on
# Blender Z (exports to Ogre Y); VehicleSystem places it at
# VehicleComponent::steeringWheelOffset with a tilt and spins it
# around that local axis with the steering. Rim + hub + 3 spokes.
sw_parts = []
bpy.ops.mesh.primitive_torus_add(major_segments=24,
minor_segments=8,
major_radius=0.19,
minor_radius=0.028,
location=(0, 0, 0))
rim = bpy.context.active_object
rim.name = "sw-rim"
rim.data.materials.append(dark)
sw_parts.append(rim)
sw_parts.append(add_cylinder("sw-hub", 0.04, 0.06, (0, 0, 0), dark))
for ang in (0, 60, 120):
spoke = add_box("sw-spoke", (0.34, 0.03, 0.02), (0, 0, 0), dark)
spoke.rotation_euler = (0, 0, radians(ang))
bpy.ops.object.transform_apply(location=False, rotation=True,
scale=True)
sw_parts.append(spoke)
join_into("forklift-steering-wheel", sw_parts)
save_blend("forklift.blend")
def gen_crate():
"""crate.mesh: 1 m box centred at the origin (matches a 0.5 half
extents box collider), with proud edge slats for a wooden-crate
look."""
reset_scene()
wood = make_material("crate", (0.45, 0.3, 0.12))
wood_dark = make_material("crate-frame", (0.32, 0.2, 0.08))
parts = []
parts.append(add_box("core", (0.96, 0.96, 0.96), (0, 0, 0), wood))
# 12 edge beams.
e = 0.5
b = 0.07
for sx in (-e, e):
for sy in (-e, e):
parts.append(add_box("edge", (b, b, 1.0), (sx, sy, 0),
wood_dark))
for sx in (-e, e):
for sz in (-e, e):
parts.append(add_box("edge", (b, 1.0, b), (sx, 0, sz),
wood_dark))
for sy in (-e, e):
for sz in (-e, e):
parts.append(add_box("edge", (1.0, b, b), (0, sy, sz),
wood_dark))
join_into("crate", parts)
save_blend("crate.blend")
def gen_pad():
"""pad.mesh: flat 1.2 x 1.2 marker slab, 4 cm thick, centred at the
origin. The zone system swaps its material for covered/uncovered
feedback, so keep a single named material."""
reset_scene()
pad_mat = make_material("pad", (0.9, 0.75, 0.1))
parts = []
parts.append(add_box("slab", (1.2, 1.2, 0.04), (0, 0, 0), pad_mat))
join_into("pad", parts)
save_blend("pad.blend")
def gen_wall():
"""wall.mesh: unit 1 x 1 x 1 cube centred at the origin, scaled
per-instance in the scene for yard boundary walls (the buildings
Cube.mesh is not 1-unit, so the demo gets its own)."""
reset_scene()
concrete = make_material("sokoban-wall", (0.55, 0.55, 0.52))
parts = []
parts.append(add_box("wall", (1.0, 1.0, 1.0), (0, 0, 0), concrete))
join_into("wall", parts)
save_blend("wall.blend")
gen_forklift()
gen_crate()
gen_pad()
gen_wall()
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env python
"""Generate the demo-taxi .blend assets (sedan-a, sedan-taxi).
Run once with the project Blender:
blender -b -Y -P assets/blender/scripts/gen_taxi_assets.py
Writes sedan-a.blend and sedan-taxi.blend into assets/blender/vehicles/,
from where the import_vehicles CMake target exports them through
export_vehicles.py like the other vehicles. After editing this script,
re-run the command above and then:
cmake --build build-vscode --target import_vehicles -j4
Axis convention: blender2ogre EX_SWAP_AXIS='xz-y' maps Blender
(x, y, z) -> Ogre (x, z, -y), so Ogre +Z (vehicle forward, Jolt
chassis +Z) is Blender -Y and Ogre +Y (up) is Blender +Z. The cars are
modelled with the chassis origin at the axle-box centre, 0.58 m above
the ground contact plane (Blender z -0.58), matching a physics box
collider of half extents (0.9, 0.35, 2.4) centred at the origin.
Dimensions (Blender coords; front = -Y):
overall length y -2.45..+2.45 (Ogre z +/-2.45, incl. bumpers)
overall width x +/-0.95 (fender flares; doors/sills at +/-0.88)
body bottom z -0.30, except the side skirts (sills and the
front/rear side panels) which reach z -0.49 (9 cm
ground clearance) so the seated character's feet --
the sit pose dangles them below the floor pan --
stay hidden behind the skirt from side views
beltline z +0.32 (0.90 m above ground)
roof z +1.02 (1.60 m above ground -- a chair-sitting
character with feet on the floor pan (butt ~0.45 m
above the feet, head ~+0.85 above the butt) clears
the headliner)
wheels (x +/-0.80, y -/+1.50, z -0.25), r 0.33, w 0.22
Origin baking: unlike gen_sokoban_assets.py (which lets join_into()
bake the first part's location in as an origin offset), here every
primitive gets its location applied to the mesh data right away, so
joined meshes start out in chassis space with a zero origin -- the
body shells (sedan-a-blue/-red/-grey, sedan-taxi) therefore have NO
baked origin offset: mesh vertex (x, y, z) renders at Ogre
(x, z, -y) relative to the chassis origin.
Doors/hood/trunk are modelled at the CLOSED position and then
re-origined with set_origin() onto their hinge line (the object moves
to the hinge, the mesh data becomes hinge-local), so a later phase can
rotate them around the hinge:
<body>-door-fl/-fr origin at the front-bottom hinge edge,
panel spans backward (+Y) and up (+Z)
<body>-door-rl/-rr same, hinge at the door's front-bottom edge
<body>-hood origin at the cowl (rear edge of the hood)
<body>-trunk origin at the cabin-side edge of the trunk lid
The shell meshes carry the door/hood/trunk openings (sills, cowl,
bulkhead, fenders), so shell + parts fit together flush (parts embed
~2 cm into the shell to avoid coincident-face z-fighting).
Part list per body variant (exported mesh data names, referenced by
the scene JSON):
sedan-a.blend: sedan-a-blue, sedan-a-red, sedan-a-grey
+ <name>-door-fl/-fr/-rl/-rr, <name>-hood,
<name>-trunk for each
+ sedan-wheel, sedan-steering-wheel (shared)
sedan-taxi.blend: sedan-taxi
+ sedan-taxi-door-fl/-fr/-rl/-rr,
sedan-taxi-hood, sedan-taxi-trunk
(reuses sedan-wheel/-steering-wheel from
sedan-a.blend -- do NOT duplicate them here)
The sedan-wheel cylinder axis is Blender Z (exports to Ogre Y), like
the forklift's: the wrapper's getWheelWorldTransform assumes a Y-axis
cylinder. The steering wheel (rim + hub + 3 spokes at the origin)
also spins around Blender Z. Doors on Blender +X are the Ogre-left
(-fl/-rl) side (facing Ogre +Z, +X is left).
Materials are plain named Principled-BSDF materials; blender2ogre
writes one .material file per material (EX_SEPARATE_MATERIALS).
sedan-taxi.blend re-defines the shared ones (sedan-glass, sedan-trim,
...) with identical values, which simply re-writes the same files.
"""
import os
import bpy
from math import radians
from mathutils import Vector, Matrix
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vehicles")
# Shared hardpoints (see the file docstring).
Z_BOT = -0.30 # body bottom edge (ground contact at -0.58)
BELT = 0.32 # beltline: solid body below, glasshouse above
ROOF = 1.02 # roof exterior (1.60 m above ground -- a chair-sitting
# character (feet on the floor pan, head ~0.85 above
# the cushion) fits under the headliner)
WHEEL_X = 0.80
WHEEL_Y = 1.50 # front axle at Blender y -1.50 (Ogre z +1.50)
WHEEL_Z = -0.25
WHEEL_R = 0.33
WHEEL_W = 0.22
SIDE_X = 0.88 # door/sill outer face
FENDER_X = 0.95 # fender flare outer face
# Door openings (y extents) and hinge positions.
DOOR_F = (-0.55, 0.44) # front door, hinge at y -0.55 (front-bottom)
DOOR_R = (0.46, 1.12) # rear door, hinge at y 0.46 (front-bottom)
DOOR_Z = (-0.10, BELT) # panel z extent (bottom embeds into the sill)
# Silhouette parameters per body type.
TYPE_A = dict(hood_top=0.20, nose_top=0.14, tail_top=0.30, trunk_top=0.30,
glass=(0.78, -0.70, 1.30, 0.60, -0.42, 1.02),
stripe=False, sign=False)
TYPE_B = dict(hood_top=0.22, nose_top=0.16, tail_top=0.34, trunk_top=0.34,
glass=(0.82, -0.78, 1.32, 0.70, -0.55, 1.10),
stripe=True, sign=True)
def reset_scene():
bpy.ops.wm.read_homefile(use_empty=True)
def make_material(name, color, roughness=0.8, metallic=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return mat
def add_box(name, dims, loc, mat=None):
"""Add a box at a world-space location; the location is applied to
the mesh data immediately so the object location stays zero (no
hidden origin offset for join_into()/the exporter)."""
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.active_object
obj.name = name
obj.dimensions = dims
bpy.ops.object.transform_apply(location=True, rotation=False,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def add_cylinder(name, radius, depth, loc, mat=None, rotation=None,
vertices=24):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=depth, location=loc)
obj = bpy.context.active_object
obj.name = name
if rotation:
obj.rotation_euler = rotation
bpy.ops.object.transform_apply(location=True, rotation=True,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def add_frustum(name, bx, by0, by1, tx, ty0, ty1, z0, z1, mat=None):
"""Tapered box (truncated pyramid) between z0 (bottom rect half
width bx, y by0..by1) and z1 (top rect tx, ty0..ty1) -- used for
the glasshouse. Mesh data is in world space, object at origin."""
verts = [(-bx, by0, z0), (bx, by0, z0), (bx, by1, z0), (-bx, by1, z0),
(-tx, ty0, z1), (tx, ty0, z1), (tx, ty1, z1), (-tx, ty1, z1)]
# Winding gives outward normals (bottom down, top up, sides out).
faces = [(0, 3, 2, 1), (4, 5, 6, 7),
(0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)]
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(verts, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
if mat:
mesh.materials.append(mat)
return obj
def join_into(name, objects):
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = name
obj.data.name = name # the exporter names .mesh files after mesh data
return obj
def set_origin(obj, point):
"""Move the mesh data origin to a world-space hinge point: verts
become hinge-local, the object moves to the hinge (closed pose
unchanged). Exported .mesh vertices are hinge-local because the
exporter does not apply object locations."""
obj.data.transform(Matrix.Translation(Vector((-point[0], -point[1],
-point[2]))))
obj.location = point
def save_blend(filename):
path = os.path.join(OUT_DIR, filename)
bpy.ops.wm.save_as_mainfile(filepath=path)
print("wrote " + path)
def make_shared_materials():
"""Materials shared by both .blends (identical definitions)."""
return {
'glass': make_material("sedan-glass", (0.04, 0.06, 0.08),
roughness=0.15, metallic=0.3),
'trim': make_material("sedan-trim", (0.10, 0.10, 0.11)),
'interior': make_material("sedan-interior", (0.16, 0.14, 0.12)),
'light': make_material("sedan-light", (0.95, 0.92, 0.75),
roughness=0.3),
'taillight': make_material("sedan-taillight", (0.55, 0.05, 0.05),
roughness=0.3),
}
def build_shell(prefix, paint, mats, cfg):
"""Body shell with the door/hood/trunk openings: floor pan, sills,
cowl, engine bay, nose, fenders, bulkhead, trunk well, tail,
bumpers, lights, glasshouse + roof cap (plus taxi sign/stripes for
type B). Exported with a zero origin (chassis space)."""
glass, trim, interior = mats['glass'], mats['trim'], mats['interior']
ht, nt = cfg['hood_top'], cfg['nose_top']
tt, kt = cfg['tail_top'], cfg['trunk_top']
parts = []
# Floor pan and rockers (sills carry the door bottoms). The sill
# reaches down to z -0.49 (side skirt, see the file docstring).
parts.append(add_box("floor", (1.30, 2.10, 0.18), (0, 0.30, -0.21),
trim))
for sx in (-0.765, 0.765):
parts.append(add_box("sill", (0.23, 1.80, 0.41),
(sx, 0.35, -0.285), paint))
# Cowl/dash panel between hood and glasshouse (front door hinges
# sit on its lower corners).
parts.append(add_box("cowl", (1.76, 0.22, 0.62), (0, -0.64, 0.01),
paint))
# Engine bay block under the hood (dark, seen through panel gaps).
parts.append(add_box("bay", (1.30, 1.42, 0.40), (0, -1.44, -0.10),
trim))
# Nose: grille block + bumper + headlights.
parts.append(add_box("nose", (1.70, 0.19, nt + 0.30),
(0, -2.225, nt / 2 - 0.15), paint))
parts.append(add_box("bumper-f", (1.80, 0.13, 0.24),
(0, -2.385, -0.17), trim))
parts.append(add_box("grille", (0.90, 0.06, 0.14),
(0, -2.30, nt - 0.16), trim))
for sx in (-0.58, 0.58):
parts.append(add_box("headlight", (0.35, 0.06, 0.12),
(sx, -2.30, nt - 0.10), mats['light']))
# Front side panels ahead of and behind the front wheel arch
# (arch opening y -1.88..-1.10, fender over the top); both reach
# down to z -0.49 (side skirt, see the file docstring).
for sx in (-0.765, 0.765):
parts.append(add_box("fside-f", (0.23, 0.27, nt + 0.49),
(sx, -2.015, nt / 2 - 0.245), paint))
parts.append(add_box("fside-r", (0.23, 0.37, 0.81),
(sx, -0.915, -0.085), paint))
for sx in (-0.80, 0.80):
parts.append(add_box("fender-f", (0.30, 0.78, ht - 0.05),
(sx, -1.49, ht / 2 + 0.035), paint))
# Interior: dashboard, front seats, rear bench (seen through the
# glasshouse).
parts.append(add_box("dash", (1.30, 0.20, 0.18), (0, -0.45, 0.20),
interior))
for sx in (-0.35, 0.35):
parts.append(add_box("seat-f", (0.50, 0.45, 0.30),
(sx, 0.0, 0.03), interior))
parts.append(add_box("seat-f-back", (0.50, 0.14, 0.55),
(sx, 0.26, 0.20), interior))
parts.append(add_box("bench", (1.30, 0.45, 0.28), (0, 0.85, 0.02),
interior))
parts.append(add_box("bench-back", (1.30, 0.16, 0.50),
(0, 1.10, 0.20), interior))
# Rear bulkhead (trunk hinges on its top), trunk well, tail,
# rear fenders, bumper, taillights.
parts.append(add_box("bulkhead", (1.76, 0.22, 0.62), (0, 1.25, 0.01),
paint))
parts.append(add_box("well", (1.30, 0.80, 0.40), (0, 1.75, -0.10),
trim))
for sx in (-0.80, 0.80):
parts.append(add_box("fender-r", (0.30, 0.78, tt - 0.05),
(sx, 1.49, tt / 2 + 0.035), paint))
for sx in (-0.765, 0.765):
parts.append(add_box("rside", (0.23, 0.27, tt + 0.51),
(sx, 2.015, tt / 2 - 0.235), paint))
parts.append(add_box("tail", (1.70, 0.19, tt + 0.30),
(0, 2.205, tt / 2 - 0.15), paint))
parts.append(add_box("bumper-r", (1.80, 0.15, 0.24),
(0, 2.375, -0.17), trim))
for sx in (-0.58, 0.58):
parts.append(add_box("taillight", (0.35, 0.06, 0.12),
(sx, 2.30, tt - 0.18), mats['taillight']))
# Glasshouse: tapered glass box from the beltline to just under
# the roof, body-colour roof cap on top.
bx, by0, by1, tx, ty0, ty1 = cfg['glass']
parts.append(add_frustum("glasshouse", bx, by0, by1, tx, ty0, ty1,
BELT, ROOF - 0.05, glass))
parts.append(add_box("roof", (2 * tx + 0.08, ty1 - ty0 + 0.08, 0.05),
(0, (ty0 + ty1) / 2, ROOF - 0.025), paint))
if cfg['stripe']:
stripe = mats['stripe']
# Colour-blocked dark stripe band (z 0.02..0.14) along the
# shell sides; the door segments live in the door meshes and
# the band breaks over the wheel arches.
for sx in (-0.895, 0.895):
parts.append(add_box("stripe-f", (0.03, 0.25, 0.12),
(sx, -2.015, 0.08), stripe))
parts.append(add_box("stripe-b", (0.03, 0.20, 0.12),
(sx, 1.25, 0.08), stripe))
parts.append(add_box("stripe-r", (0.03, 0.25, 0.12),
(sx, 2.015, 0.08), stripe))
if cfg['sign']:
parts.append(add_box("sign-base", (0.60, 0.34, 0.04),
(0, 0.30, ROOF + 0.02), trim))
parts.append(add_box("sign", (0.55, 0.28, 0.16),
(0, 0.30, ROOF + 0.11), mats['sign']))
return join_into(prefix, parts)
def build_door(name, paint, mats, cfg, front, left):
"""One door: panel + handle (+ taxi stripe), painted to match the
body, origin on the front-bottom hinge edge."""
(y0, y1) = DOOR_F if front else DOOR_R
(z0, z1) = DOOR_Z
sx = SIDE_X - 0.025 if left else -(SIDE_X - 0.025)
length = y1 - y0
cy = (y0 + y1) / 2
out = SIDE_X + 0.015 if left else -(SIDE_X + 0.015)
parts = [add_box("panel", (0.05, length, z1 - z0),
(sx, cy, (z0 + z1) / 2), paint)]
parts.append(add_box("handle", (0.03, 0.15, 0.03),
(out, y1 - 0.18, BELT - 0.08), mats['trim']))
if cfg['stripe']:
parts.append(add_box("stripe", (0.03, length - 0.04, 0.12),
(out, cy, 0.08), mats['stripe']))
obj = join_into(name, parts)
set_origin(obj, (sx, y0, z0))
return obj
def build_hood(name, paint, cfg):
"""Hood slab, origin at the cowl (rear edge of the hood)."""
ht = cfg['hood_top']
parts = [add_box("hood", (1.60, 1.44, 0.06), (0, -1.44, ht - 0.03),
paint)]
obj = join_into(name, parts)
set_origin(obj, (0, -0.75, ht - 0.03))
return obj
def build_trunk(name, paint, cfg):
"""Trunk lid, origin at its cabin-side edge (near the back
glass)."""
kt = cfg['trunk_top']
parts = [add_box("trunk", (1.60, 0.84, 0.06), (0, 1.74, kt - 0.03),
paint)]
obj = join_into(name, parts)
set_origin(obj, (0, 1.35, kt - 0.03))
return obj
def build_body(prefix, paint, mats, cfg):
"""One full car: shell + 4 doors + hood + trunk (7 meshes)."""
build_shell(prefix, paint, mats, cfg)
for front, tag_f in ((True, 'f'), (False, 'r')):
for left, tag_s in ((True, 'l'), (False, 'r')):
build_door("%s-door-%s%s" % (prefix, tag_f, tag_s),
paint, mats, cfg, front, left)
build_hood(prefix + "-hood", paint, cfg)
build_trunk(prefix + "-trunk", paint, cfg)
def build_wheel():
"""sedan-wheel: dark tire + lighter hub as one mesh (two
submeshes). Cylinder axis stays on Blender Z so it exports to
Ogre Y, like the forklift wheel."""
tire = make_material("sedan-tire", (0.07, 0.07, 0.07), roughness=0.95)
hub = make_material("sedan-hub", (0.55, 0.55, 0.58), roughness=0.4,
metallic=0.6)
parts = [add_cylinder("tire", WHEEL_R, WHEEL_W, (0, 0, 0), tire,
vertices=16),
add_cylinder("hub", 0.17, WHEEL_W + 0.02, (0, 0, 0), hub,
vertices=12)]
join_into("sedan-wheel", parts)
def build_steering_wheel():
"""sedan-steering-wheel: rim + hub + 3 spokes at the origin, spin
axis Blender Z (exports to Ogre Y), like the forklift's."""
dark = make_material("sedan-steering", (0.08, 0.08, 0.09))
parts = []
bpy.ops.mesh.primitive_torus_add(major_segments=24, minor_segments=8,
major_radius=0.17,
minor_radius=0.025,
location=(0, 0, 0))
rim = bpy.context.active_object
rim.name = "sw-rim"
rim.data.materials.append(dark)
parts.append(rim)
parts.append(add_cylinder("sw-hub", 0.035, 0.06, (0, 0, 0), dark))
for ang in (0, 60, 120):
spoke = add_box("sw-spoke", (0.30, 0.025, 0.02), (0, 0, 0), dark)
spoke.rotation_euler = (0, 0, radians(ang))
bpy.ops.object.transform_apply(location=False, rotation=True,
scale=True)
parts.append(spoke)
join_into("sedan-steering-wheel", parts)
def gen_sedan_a():
"""sedan-a.blend: type-A body (three-box sedan after Car2.blend)
in three colours, plus the shared wheel and steering wheel."""
reset_scene()
mats = make_shared_materials()
blue = make_material("sedan-a-blue", (0.08, 0.18, 0.65),
roughness=0.35, metallic=0.2)
red = make_material("sedan-a-red", (0.62, 0.07, 0.07),
roughness=0.35, metallic=0.2)
grey = make_material("sedan-a-grey", (0.45, 0.46, 0.48),
roughness=0.35, metallic=0.2)
build_body("sedan-a-blue", blue, mats, TYPE_A)
build_body("sedan-a-red", red, mats, TYPE_A)
build_body("sedan-a-grey", grey, mats, TYPE_A)
build_wheel()
build_steering_wheel()
save_blend("sedan-a.blend")
def gen_sedan_taxi():
"""sedan-taxi.blend: type-B body (boxier, upright glasshouse after
Car5_Taxi.blend) in taxi yellow with a roof sign and a dark
checker-stripe band. Reuses sedan-wheel/-steering-wheel from
sedan-a.blend."""
reset_scene()
mats = make_shared_materials()
mats['stripe'] = make_material("sedan-taxi-dark", (0.08, 0.08, 0.08))
mats['sign'] = make_material("taxi-sign", (0.95, 0.85, 0.40),
roughness=0.4)
yellow = make_material("sedan-taxi-yellow", (0.85, 0.60, 0.05),
roughness=0.35, metallic=0.2)
build_body("sedan-taxi", yellow, mats, TYPE_B)
save_blend("sedan-taxi.blend")
gen_sedan_a()
gen_sedan_taxi()
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python
#
# Vehicle pipeline generator for convention-built vehicle blends.
#
# Does everything export_vehicles.py does (blender2ogre .scene/.mesh/
# .material export + a .glb preview) and additionally derives a
# ready-to-merge scene-entity JSON fragment (<name>.vehicle.json) from
# the blend by naming convention:
#
# root the single parentless MESH object; its mesh DATA frame
# is the chassis frame (object location is NOT baked into
# the .mesh, so author the mesh data relative to the
# intended chassis origin).
# * Tire wheel objects; mesh data is rotated so the axle lands
# on Blender Z (Ogre Y, the convention
# getWheelWorldTransform assumes); the objects are
# counter-rotated so the scene look is unchanged.
# * Door door child entities, hinge-local mesh data (origin on
# the hinge line); each gets a seat actuator for the
# nearest seat.
# * hood hood child entity.
# * trunk trunk child entity.
# * Steering steering wheel; mesh data is rotated so the spin axis
# lands on Blender Z (Ogre Y, the convention VehicleSystem
# assumes for steeringWheelMeshName).
# seat_* EMPTY objects marking character seat pins (character
# root with the sitting-vehicle animation; no offsets
# applied). The one with "driver" in the name becomes
# the driver seat.
#
# The emitted JSON has Ogre coordinates ((x, z, -y) of the Blender
# chassis frame). See assets/blender/vehicles/CAR_MODELING.md.
#
# Usage:
# blender <vehicle>.blend -b -Y -P gen_vehicle.py -- <out>.glb <out>.vehicle.json
import os
import sys
import json
import math
import bpy
from math import radians
from mathutils import Matrix, Vector
argv = sys.argv
argv = argv[argv.index("--") + 1:]
incpath = os.path.dirname(__file__)
sys.path.insert(0, incpath)
sys.path.insert(1, incpath + "/blender2ogre")
import io_ogre
try:
io_ogre.register()
except:
pass
glb_file = argv[0]
json_file = argv[1]
outdir = os.path.dirname(glb_file)
base = os.path.splitext(os.path.basename(bpy.data.filepath))[0]
print("gen_vehicle: %s -> %s, %s" % (bpy.data.filepath, glb_file,
json_file))
for obj in list(bpy.data.objects):
if obj.name.endswith("-col"):
bpy.data.objects.remove(obj)
# GLB preview of the untouched scene (as modeled).
bpy.ops.export_scene.gltf(filepath=glb_file,
use_selection=False,
check_existing=False,
export_format='GLB',
export_texture_dir='textures', export_texcoords=True,
export_normals=True,
export_tangents=True,
export_materials='EXPORT',
export_colors=True,
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_renderable=False,
export_yup=True,
export_apply=False,
export_animations=False,
export_morph=True,
export_morph_animation=False,
export_morph_normal=True,
export_morph_tangent=True,
export_lights=False,
export_skins=True)
def chassis_pos(obj, root_inv):
"""Object origin in the chassis frame (root mesh-data frame)."""
return root_inv @ obj.matrix_world.translation
def mesh_bbox(me):
vs = [v.co for v in me.vertices]
mn = Vector((min(v.x for v in vs), min(v.y for v in vs),
min(v.z for v in vs)))
mx = Vector((max(v.x for v in vs), max(v.y for v in vs),
max(v.z for v in vs)))
return mn, mx
def ogre(v):
"""Blender chassis frame -> Ogre (x, z, -y)."""
return (round(v.x, 4), round(v.z, 4), round(-v.y, 4))
def ogre_vec3(v):
x, y, z = ogre(v)
return {"x": x, "y": y, "z": z}
# --- find the root (the single parentless MESH object) ---
roots = [o for o in bpy.data.objects
if o.type == 'MESH' and o.parent is None]
if len(roots) != 1:
raise RuntimeError("gen_vehicle: expected exactly one parentless "
"MESH object (the body shell), found %d: %s" %
(len(roots), [o.name for o in roots]))
root = roots[0]
root_inv = root.matrix_world.inverted()
print("gen_vehicle: root '%s' (mesh data '%s')" %
(root.name, root.data.name))
# --- classify parts by naming convention ---
wheels = []
doors = []
seats = []
hood = None
trunk = None
steering = None
extras = []
for obj in bpy.data.objects:
if obj == root:
continue
lname = obj.name.lower()
if obj.type == 'EMPTY':
if lname.startswith("seat"):
seats.append(obj)
continue
if obj.type != 'MESH':
continue
if "tire" in lname or "wheel" in lname:
wheels.append(obj)
elif "door" in lname:
doors.append(obj)
elif "hood" in lname or "bonnet" in lname:
hood = obj
elif "trunk" in lname:
trunk = obj
elif "steering" in lname:
steering = obj
else:
extras.append(obj)
if len(wheels) < 2:
raise RuntimeError("gen_vehicle: no wheel objects found "
"(name them '* Tire')")
if not seats:
raise RuntimeError("gen_vehicle: no seat empties found "
"(name them 'seat_*')")
print("gen_vehicle: %d wheels, %d doors, %d seats, hood=%s, trunk=%s, "
"steering=%s, extras=%s" %
(len(wheels), len(doors), len(seats),
hood.name if hood else "-", trunk.name if trunk else "-",
steering.name if steering else "-",
[o.name for o in extras]))
# --- wheel geometry (from the shared tire mesh data, pre-rotation) ---
tyme = wheels[0].data
wmn, wmx = mesh_bbox(tyme)
wheel_radius = max(wmx.y - wmn.y, wmx.z - wmn.z) / 2.0
wheel_width = wmx.x - wmn.x
wheel_info = []
for obj in wheels:
p = chassis_pos(obj, root_inv)
wheel_info.append({"obj": obj, "pos": p})
axle_mid_y = (min(w["pos"].y for w in wheel_info) +
max(w["pos"].y for w in wheel_info)) / 2.0
# --- seats ---
seat_info = []
for obj in seats:
p = chassis_pos(obj, root_inv)
seat_info.append({"obj": obj, "pos": p,
"front": p.y < axle_mid_y,
"left": p.x > 0.0,
"driver": "driver" in obj.name.lower()})
driver_seats = [s for s in seat_info if s["driver"]]
if len(driver_seats) != 1:
raise RuntimeError("gen_vehicle: expected exactly one seat empty "
"with 'driver' in the name, found %d" %
len(driver_seats))
for s in seat_info:
if s["driver"]:
s["name"] = "driver"
elif s["front"]:
s["name"] = "passenger"
else:
s["name"] = "rl" if s["left"] else "rr"
# --- rename mesh data blocks to the <base>-<role> scheme ---
def part_side_row(p):
return ("f" if p.y < axle_mid_y else "r") + \
("l" if p.x > 0.0 else "r")
def sanitize(name):
return name.lower().replace(" ", "-").replace("_", "-")
root.data.name = base
for w in wheel_info:
w["obj"].data.name = base + "-wheel"
for d in doors:
p = chassis_pos(d, root_inv)
d.data.name = base + "-door-" + part_side_row(p)
if hood:
hood.data.name = base + "-hood"
if trunk:
trunk.data.name = base + "-trunk"
if steering:
steering.data.name = base + "-steering-wheel"
for e in extras:
e.data.name = base + "-" + sanitize(e.data.name)
# --- rename materials (<base>-<material>) and unpack textures ---
used_mats = set()
for obj in bpy.data.objects:
if obj.type == 'MESH':
for m in obj.data.materials:
if m:
used_mats.add(m)
for m in used_mats:
if not m.name.lower().startswith(base.lower()):
m.name = base + "-" + sanitize(m.name)
tex_index = 0
saved_images = set()
for m in used_mats:
if not m.node_tree:
continue
for n in m.node_tree.nodes:
if n.type != 'TEX_IMAGE' or not n.image:
continue
img = n.image
if img in saved_images or not img.packed_file:
continue
saved_images.add(img)
tex_name = base + ("-atlas.png" if tex_index == 0 else
"-tex%d.png" % tex_index)
tex_index += 1
img.filepath_raw = os.path.join(outdir, tex_name)
img.file_format = 'PNG'
img.save()
print("gen_vehicle: unpacked texture '%s' -> %s" %
(img.name, tex_name))
# --- JSON fragment (Ogre coordinates) ---
smn, smx = mesh_bbox(root.data)
collider = {
"halfHeight": 1.0,
"meshName": "",
"offset": ogre_vec3(Vector(((smx.x + smn.x) / 2.0,
(smx.y + smn.y) / 2.0,
(smx.z + smn.z) / 2.0))),
"parameters": {
"x": round((smx.x - smn.x) / 2.0, 4),
"y": round((smx.z - smn.z) / 2.0, 4),
"z": round((smx.y - smn.y) / 2.0, 4),
},
"radius": 0.5,
"rotationOffset": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"shapeType": "box",
}
json_wheels = []
# Jolt's suspension spring settles at L_rest = maxLength - g/(2*pi*f)^2
# below the attach point. The modeled wheel centers are the intended
# REST position, so place the attach points L_rest above them — the car
# then rides exactly at the modeled stance.
SUSP_MIN = 0.1
SUSP_MAX = 0.35
# Softer than the 1.8/0.7 starting point: gives the body some slack
# over bumps instead of pogo-stiff wheels (which also cost grip).
SUSP_FREQ = 1.3
SUSP_DAMP = 0.6
SUSP_REST = SUSP_MAX - 9.81 / (2.0 * math.pi * SUSP_FREQ) ** 2
for w in sorted(wheel_info, key=lambda w: w["pos"].y):
front = w["pos"].y < axle_mid_y
attach = Vector((w["pos"].x, w["pos"].y, w["pos"].z + SUSP_REST))
json_wheels.append({
"driven": not front,
"maxHandBrakeTorque": 0.0 if front else 300.0,
"maxSteerAngleDeg": 35.0 if front else 0.0,
"position": ogre_vec3(attach),
"radius": round(wheel_radius, 4),
"suspensionDamping": SUSP_DAMP,
"suspensionFrequency": SUSP_FREQ,
"suspensionMaxLength": SUSP_MAX,
"suspensionMinLength": SUSP_MIN,
"width": round(wheel_width, 4),
})
json_seats = []
for s in seat_info:
json_seats.append({
"name": s["name"],
"offset": ogre_vec3(s["pos"]),
"driver": s["driver"],
})
driver_offset = ogre_vec3(driver_seats[0]["pos"])
children = []
def make_child(name, mesh, pos, actuator_action=None):
child = {
"children": [],
"name": {"name": name},
"renderable": {"meshName": mesh + ".mesh", "visible": True},
"transform": {
"position": ogre_vec3(pos),
"rotation": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"scale": {"x": 1.0, "y": 1.0, "z": 1.0},
},
}
if actuator_action:
child["actuator"] = {
"actionNames": [actuator_action],
"height": 2.0,
"radius": 1.2,
}
return child
def nearest_seat_name(p):
best = None
best_dist = 1e9
for s in seat_info:
d = (s["pos"] - p).length
if d < best_dist:
best_dist = d
best = s["name"]
return best
def seat_for_door(p):
"""The seat behind a door: same side and row (front/rear)."""
front = p.y < axle_mid_y
left = p.x > 0.0
for s in seat_info:
if s["front"] == front and s["left"] == left:
return s["name"]
return nearest_seat_name(p)
for d in doors:
p = chassis_pos(d, root_inv)
seat_name = seat_for_door(p)
children.append(make_child(
base + "_door_" + part_side_row(p), d.data.name, p,
"vehicle_seat_" + seat_name))
if hood:
children.append(make_child(base + "_hood", hood.data.name,
chassis_pos(hood, root_inv)))
if trunk:
children.append(make_child(base + "_trunk", trunk.data.name,
chassis_pos(trunk, root_inv)))
vehicle = {
"cameraDistance": 6.0,
"cameraHeight": 1.6,
"engineBrake": 0.5,
"maxPitchRollAngleDeg": 60.0,
"maxTorque": 300.0,
"rearSteer": False,
"seatOffset": driver_offset,
"steeringRatio": 4.0,
"steeringWheelMeshName": "",
"steeringWheelOffset": {"x": 0.0, "y": 0.0, "z": 0.0},
"steeringWheelTiltDeg": 25.0,
"wheelMeshName": base + "-wheel.mesh",
"wheels": json_wheels,
"seats": json_seats,
"seatCharacterScale": 1.0,
# Arcade-friendly grip: multipliers over the Jolt default tire
# curves (peak 1.2); 1.0 feels like wet ice at speed (full lock at
# ~15 m/s turns only ~44 deg in 2.5 s; 3.0 lateral turns ~88 deg).
# Note longitudinal grip adds understeer on a RWD car (1.5 costs
# ~16 deg on the same turn), so keep it near stock.
"tireFrictionLongitudinal": 1.3,
"tireFrictionLateral": 3.0,
# Anti-roll bars per axle pair (Jolt VehicleAntiRollBar); with the
# soft 1.3 Hz springs a full-lock turn otherwise rolls the car past
# 45 deg onto two wheels.
"antiRollBarStiffness": 2000.0,
}
if steering:
vehicle["steeringWheelMeshName"] = base + "-steering-wheel.mesh"
vehicle["steeringWheelOffset"] = ogre_vec3(
chassis_pos(steering, root_inv))
entity = {
"children": children,
"collider": collider,
"name": {"name": base},
"renderable": {"meshName": base + ".mesh", "visible": True},
"rigidBody": {
"bodyType": "dynamic",
"enabled": True,
"friction": 0.8,
"isSensor": False,
"mass": 1200.0,
"restitution": 0.0,
},
"transform": {
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
"rotation": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"scale": {"x": 1.0, "y": 1.0, "z": 1.0},
},
"vehicle": vehicle,
}
with open(json_file, "w") as f:
json.dump({"entities": [entity]}, f, indent=2)
print("gen_vehicle: wrote %s" % json_file)
# --- axis normalization (mesh data rotated, objects counter-rotated,
# so the .mesh files follow the engine conventions while the
# exported scene keeps the modeled look) ---
def rotate_data_shared(data, rot):
data.transform(rot)
inv = rot.inverted()
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data == data:
obj.matrix_world = obj.matrix_world @ inv
for data in set(w["obj"].data for w in wheel_info):
# Axle from Blender X onto Blender Z (-> Ogre Y).
rotate_data_shared(data, Matrix.Rotation(radians(90.0), 4, 'Y'))
if steering:
# Disc normal from Blender Y onto Blender Z (-> Ogre Y spin axis).
rotate_data_shared(steering.data,
Matrix.Rotation(radians(90.0), 4, 'X'))
# --- OGRE export (same parameters as export_vehicles.py) ---
scene_file = glb_file.replace(".glb", "").replace(".gltf", "") + ".scene"
bpy.ops.ogre.export(filepath=scene_file,
EX_SWAP_AXIS='xz-y',
EX_V2_MESH_TOOL_VERSION='v2',
EX_EXPORT_XML_DELETE=True,
EX_SCENE=True,
EX_SELECTED_ONLY=False,
EX_EXPORT_HIDDEN=False,
EX_FORCE_CAMERA=False,
EX_FORCE_LIGHTS=False,
EX_NODE_ANIMATION=True,
EX_MATERIALS=True,
EX_SEPARATE_MATERIALS=True,
EX_COPY_SHADER_PROGRAMS=True,
EX_MESH=True,
EX_LOD_LEVELS=3,
EX_LOD_DISTANCE=100,
EX_LOD_PERCENT=40
)
bpy.ops.wm.read_homefile(use_empty=True)
import time
time.sleep(2)
bpy.ops.wm.quit_blender()
+45 -1
View File
@@ -2,7 +2,9 @@ project(vehicles)
set(VEHICLES_SRC boat-big.blend boat.blend
boat-gobbot.blend boat-sails.blend
raft.blend tiny-boat.blend)
raft.blend tiny-boat.blend
forklift.blend crate.blend pad.blend
wall.blend sedan-a.blend sedan-taxi.blend)
set(VEHICLE_OUTPUT_FILES)
foreach(VEHICLE_FILE ${VEHICLES_SRC})
@@ -22,4 +24,46 @@ foreach(VEHICLE_FILE ${VEHICLES_SRC})
list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene)
endforeach()
# Convention-built drivable vehicles: same mesh/material export plus a
# generated scene-entity JSON fragment (<name>.vehicle.json, see
# assets/blender/scripts/gen_vehicle.py and vehicles/CAR_MODELING.md).
set(VEHICLES_GEN_SRC sedan-v2.blend)
foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
get_filename_component(FILE_NAME ${VEHICLE_FILE} NAME_WE)
set(VEHICLE_OUTPUT_FILE ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb)
set(VEHICLE_JSON_FILE ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.vehicle.json)
add_custom_command(
OUTPUT ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/vehicles
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py
-- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND touch ${VEHICLE_OUTPUT_FILE}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE} ${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py)
list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE})
endforeach()
add_custom_target(import_vehicles ALL DEPENDS ${VEHICLE_OUTPUT_FILES})
# One-step vehicle refresh: re-export changed .blend files (import_vehicles),
# re-stage the resources for the editScene runtime and re-merge the generated
# vehicle entity JSONs into the demo-taxi scenes. NOTE: the merge step EDITS
# the source scene JSONs under src/features/editScene/demos/demo-taxi (that is
# the point - merge_vehicle.py preserves entity id/name/transform, so hand
# edits to placement survive). Run after editing a vehicle .blend:
# cmake --build <build-dir> --target update_vehicles
set(DEMO_TAXI_DIR ${CMAKE_SOURCE_DIR}/src/features/editScene/demos/demo-taxi)
add_custom_target(update_vehicles
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_BINARY_DIR}/resources
${CMAKE_BINARY_DIR}/src/features/editScene/resources
COMMAND python3 merge_vehicle.py demo_scene_vehicletest.json
${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
WORKING_DIRECTORY ${DEMO_TAXI_DIR}
DEPENDS import_vehicles)
+109 -26
View File
@@ -264,8 +264,49 @@ cd demos/demo-taxi
./demoTaxi --headless --exit-after-first-frame
# ...or headless end-to-end check of the inherited scene contract:
./demoTaxi --headless --test-switch
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
# ...or headless sedan drive test (spawn, enter, drive, steer, exit —
# plus a passenger-seat leg: no PlayerControlledComponent, W does not
# drive, and the exit must land on the occupied seat's side of the car)
# on the flat vehicle test scene:
./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json
# ...same test on the exterior scene (streaming terrain, sedan_taxi):
./demoTaxi --headless --test-vehicle-drive
# ...or the quantitative handling check (W to ~15 m/s, then full lock
# for 150 frames; PASS needs >= 45 deg of yaw - catches the
# "drives like it's on ice" regression):
./demoTaxi --headless --test-vehicle-turn
# 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
# raycast legitimately fails.
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door /
# sit in the seat of the sedan door you used (driver = front-left,
# only the driver seat drives), hold E = exit the vehicle, Space =
# 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
# 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
# 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).
# 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
# --force-pos x y z yawDeg [pitchDeg] teleport the player once,
# re-apply the TPS yaw/pitch
# --debug-enter-vehicle name seat the player in the named
# vehicle (default driver seat)
# --debug-enter-seat seat pick the seat (driver,
# passenger, rl, rr)
# --screenshot N path dump frame N and quit
# Road wedge/segment self-intersection regression test (no scene needed,
@@ -988,13 +1029,38 @@ collider) gets a vehicle constraint on its rigid body;
/ `inputBrake` / `inputHandBrake` into Jolt (with the Jolt sample's
brake-before-reverse rule) and `postPhysicsUpdate()` moves wheel visual
child nodes (created from `wheelMeshName`) to the constraint's wheel
transforms. Both hooks are called from `EditorApp` around the physics
step. Any number of vehicles per scene; removal of the component or
transforms — right-side wheels (chassis -X) get an extra 180 deg flip
about the local forward axis, because Jolt reports every wheel with the
same axle direction and a single shared wheel mesh would otherwise show
its inner face outward on the right (no mirrored mesh needed). Both
hooks are called from `EditorApp` around the physics step. Any number of vehicles per scene; removal of the component or
the body tears the constraint down safely.
`prePhysicsUpdate()` also applies anti-two-wheel righting: when the
chassis tilt passes ~35 deg (up to ~80 deg — a car on its side or
roof is left alone) the wrapper's `addTorque` applies a torque about
the chassis-up x world-up axis proportional to sin(tilt), and above
~5 deg of tilt the angular velocity along that axis is damped so the
car does not rock on its suspension for seconds. A high-grip car
still trips past 45 deg transiently in a full-lock turn, but it can
no longer get stuck driving on two wheels; regression check:
`demoTaxi --headless --test-vehicle-righting`.
`VehicleComponent` fields (serialized as the `vehicle` scene key,
editable via `ui/VehicleEditor`): `maxTorque`, `maxPitchRollAngleDeg`,
`seatOffset`, `cameraDistance` / `cameraHeight` (TPS boom while driving),
`tireFrictionLongitudinal` / `tireFrictionLateral` (multipliers over
the Jolt default tire friction curves, peak 1.2, before Jolt combines
them with ground friction via sqrt(tire * ground) — 1.0 drives like
wet ice at speed, sedans use 1.3 / 3.0), `antiRollBarStiffness`
(N/m per axle pair of Jolt VehicleAntiRollBar, 0 = none; sedans use
2000),
`seats` (a list of named chassis-space seats, each with `name`,
`offset` and a `driver` flag — only driver seats get driving controls;
when empty the legacy `seatOffset` acts as the single driver seat),
`seatCharacterScale` (uniform scale applied to the character node
while seated, restored on exit — < 1 shrinks the stock chair-sit pose
to fit a car cabin), `cameraDistance` / `cameraHeight` (TPS boom while
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 —
@@ -1160,37 +1226,54 @@ without one).
Game-mode-only system (`systems/VehicleControllerSystem.{hpp,cpp}`) that
makes vehicles drivable by the player:
- **Enter**: an actuator action (e.g. `vehicle_drive` in the sokoban demo)
runs a `luaTask` node `vehicleEnter` (registered in
`lua-scripts/data2.lua`) which sends the `vehicle_enter` event with a
`vehicle=<entity name>` param; the system subscribes on the `EventBus`
(`enterVehicle(entity)` is also directly callable by tests). Entering
disables the player character's physics capsule
(`CharacterSystem::disablePhysics`), pins its node to
`VehicleComponent::seatOffset` (re-pinned every frame from
- **Enter**: an actuator action (e.g. `vehicle_drive` in the sokoban demo,
`vehicle_seat_*` in the taxi demo) runs a `luaTask` node `vehicleEnter`
(registered in `lua-scripts/data2.lua`) which sends the `vehicle_enter`
event with a `vehicle=<entity name>` param (empty = nearest vehicle
within 5 m of the player) and an optional `seat=<seat name>` param
(empty = driver seat, else first free seat); the system subscribes on
the `EventBus` (`enterVehicle(entity)` is also directly callable by
tests). Entering disables the player character's physics capsule
(`CharacterSystem::disablePhysics`), pins its node to the resolved
seat's offset (`VehicleComponent::seats`, or the legacy single driver
`seatOffset` when `seats` is empty; re-pinned every frame from
`postPhysicsUpdate()`, after the physics step syncs the vehicle node —
see "Frame Update Order" 21a), parks the locomotion state
machine in idle, tags the vehicle `PlayerControlledComponent`, sets
`PlayerControllerComponent::driving` and swaps `tpsDistance`/`tpsHeight`
for the vehicle's `cameraDistance`/`cameraHeight`. The character plays
see "Frame Update Order" 21a), applies `seatCharacterScale` to the
character node (restored on exit), parks the locomotion state
machine in idle, marks the seat occupied, and — only on a driver
seat — tags the vehicle `PlayerControlledComponent`, sets
`PlayerControllerComponent::driving` and swaps
`tpsDistance`/`tpsHeight` for the vehicle's
`cameraDistance`/`cameraHeight`. The character plays
the locomotion state machine's `PlayerControllerComponent::drivingState`
state ("driving", mapped to the `sitting` animation in the demo's
animation_tree.json); `idleState` is restored on exit. Entering also
state ("driving", mapped to the `sitting` animation in the sokoban
demo's animation_tree.json, `sitting-vehicle` in the taxi demo's);
`idleState` is restored on exit. Entering also
pushes a controls hint to the GameHudSystem ("W/S move, A/D steer, hold
E to exit", plus "R/F fork up/down" when the vehicle has a fork).
E to exit", plus "R/F fork up/down" when the vehicle has a fork;
passengers get "Riding: hold E to exit").
- **Driving**: `update()` (called right before `PlayerControllerSystem` in
Game+Playing) feeds `GameInputState` into the vehicle inputs — W/S
`inputForward`, A/D `inputRight`, Space `inputHandBrake`.
`inputForward`, A/D `inputRight`, Space `inputHandBrake` — only when
the player sits on a driver seat; passengers zero the inputs.
- **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 (±X) or behind/ahead (±Z) of the chassis
(horizontal clearance ray + downward ground ray; `raycastQuery` sees
NON_MOVING only, so the vehicle never blocks its own exit), moves the
raycast-clear spot beside the door of the seat actually occupied
(sideways from the seat's own chassis-space row, 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
character node there, re-enables physics (the CharacterSystem diff check
teleports the capsule to the node) and restores the on-foot camera
settings. No clear spot → the player stays seated and a warning is
logged.
settings. 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
(EditorApp::keyPressed), which previously re-fired the door actuators
during a long hold. No clear spot → the player stays seated and a
warning is logged.
- **Teardown**: `resetDriving()` (no raycasts) runs from
`EditorApp::destroySceneEntities` before `resetControllers()`, and the
system is destroyed before `PlayerControllerSystem`/`CharacterSystem`.
+8 -4
View File
@@ -2764,17 +2764,20 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
case 'e':
case 'E':
m_gameInput.e = true;
m_gameInput.ePressed = true;
if (!isRepeat)
m_gameInput.ePressed = true;
break;
case 'f':
case 'F':
m_gameInput.f = true;
m_gameInput.fPressed = true;
if (!isRepeat)
m_gameInput.fPressed = true;
break;
case 'i':
case 'I':
m_gameInput.i = true;
m_gameInput.iPressed = true;
if (!isRepeat)
m_gameInput.iPressed = true;
break;
case 'r':
case 'R':
@@ -2785,7 +2788,8 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
break;
case OgreBites::SDLK_SPACE:
m_gameInput.space = true;
m_gameInput.spacePressed = true;
if (!isRepeat)
m_gameInput.spacePressed = true;
break;
}
return true;
+44 -1
View File
@@ -34,6 +34,24 @@ struct VehicleWheel {
float maxHandBrakeTorque = 0.0f;
};
/**
* Vehicle seat definition.
*
* Seats are named ("driver", "passenger", ...) so scene actions can ask
* for a specific one; the driver flag marks the seat that gets driving
* controls (player-controlled driving only happens there, NPC passengers
* sit in the others).
*/
struct VehicleSeat {
Ogre::String name = "driver";
/* Seat offset in chassis space (character root, sitting pose). */
Ogre::Vector3 offset = Ogre::Vector3(0.0f, 1.0f, 0.0f);
bool driver = false;
/* Runtime: entity currently sitting here (0 = free). */
uint64_t occupant = 0;
};
/**
* Vehicle component.
*
@@ -52,11 +70,36 @@ struct VehicleComponent {
/* Degrees; 0 = unlimited. */
float maxPitchRollAngleDeg = 60.0f;
/* Tire friction multipliers: scale the Jolt default tire friction
* curves (peak ~1.2) before they are combined with the ground
* friction. 1.0 = stock asphalt tire; raise lateral for less
* drift-prone arcade handling (sedans use ~1.8). */
float tireFrictionLongitudinal = 1.0f;
float tireFrictionLateral = 1.0f;
/* Anti-roll bar stiffness (N/m) per axle pair (Jolt
* VehicleAntiRollBar; 0 = none). Limits body roll in hard
* corners so a soft-sprung car does not end up on two wheels. */
float antiRollBarStiffness = 0.0f;
/* Driver seat offset in chassis space (used by the
* VehicleControllerSystem enter/exit flow to seat the player
* character). */
* character). Legacy single-seat shortcut: only used when seats
* is empty, where it acts as the single driver seat. */
Ogre::Vector3 seatOffset = Ogre::Vector3(0.0f, 1.0f, 0.0f);
/* All seats of the vehicle (chassis space). When empty, the
* vehicle has one implicit driver seat at seatOffset. */
std::vector<VehicleSeat> seats;
/* Uniform scale applied to the character node while seated
* (restored on exit). The stock sit animations are proportioned
* for a chair, not a car cabin: with a normal sedan cabin the feet
* dangle below the rocker line and the head grazes the roof, so
* shrinking the seated character a little (0.85) makes the pose
* fit without cartoonish seat geometry. 1.0 = no scaling. */
float seatCharacterScale = 1.0f;
/* Rear-wheel steering (like a real forklift): flips the steer sign
* so D still turns the nose right — with the steered axle behind
* the driver the raw wheel angle would turn the nose left. */
+4 -3
View File
@@ -7,10 +7,11 @@ ecs.behavior_tree.register_node("luaHello", function(entity_id, params)
end)
-- F10 vehicles: seat the player character on the named vehicle entity
-- (VehicleControllerSystem listens for "vehicle_enter").
-- (VehicleControllerSystem listens for "vehicle_enter"). "vehicle" is
-- optional (empty = nearest vehicle to the player); "seat" picks a seat
-- by name (empty = driver seat / first free seat).
ecs.behavior_tree.register_node("vehicleEnter", function(entity_id, params)
local vehicle = params.vehicle or ""
ecs.send_event("vehicle_enter", { vehicle = vehicle })
ecs.send_event("vehicle_enter", { vehicle = params.vehicle or "", seat = params.seat or "" })
return "success"
end)
@@ -650,9 +650,32 @@ public:
w->mMaxHandBrakeTorque = wd.maxHandBrakeTorque;
w->mRadius = wd.radius;
w->mWidth = wd.width;
/* Scale the default tire friction curves (see the
* WheelSettingsWV constructor) by the requested
* multipliers; Jolt combines them with the ground
* friction via sqrt(tire * ground). */
for (JPH::LinearCurve::Point &p :
w->mLongitudinalFriction.mPoints)
p.mY *= desc.tireFrictionLongitudinal;
for (JPH::LinearCurve::Point &p :
w->mLateralFriction.mPoints)
p.mY *= desc.tireFrictionLateral;
settings.mWheels.push_back(w);
}
/* One anti-roll bar per axle pair (wheels 0/1, 2/3, ...) to
* limit body roll in corners. */
if (desc.antiRollBarStiffness > 0.0f) {
settings.mAntiRollBars.resize(desc.wheels.size() / 2);
for (size_t i = 0; i + 1 < desc.wheels.size(); i += 2) {
JPH::VehicleAntiRollBar &bar =
settings.mAntiRollBars[i / 2];
bar.mLeftWheel = (int)i;
bar.mRightWheel = (int)i + 1;
bar.mStiffness = desc.antiRollBarStiffness;
}
}
JPH::WheeledVehicleControllerSettings *controller =
new JPH::WheeledVehicleControllerSettings;
settings.mController = controller;
@@ -1793,6 +1816,12 @@ public:
physics_system.GetBodyInterface().GetAngularVelocity(
id));
}
void addTorque(JPH::BodyID id, const Ogre::Vector3 &torque)
{
physics_system.GetBodyInterface().AddTorque(
id, JPH::Vec3(torque.x, torque.y, torque.z),
JPH::EActivation::Activate);
}
void setVelocities(JPH::BodyID id, const Ogre::Vector3 &linear,
const Ogre::Vector3 &angular)
{
@@ -2219,6 +2248,11 @@ Ogre::Vector3 JoltPhysicsWrapper::getAngularVelocity(JPH::BodyID id)
{
return phys->getAngularVelocity(id);
}
void JoltPhysicsWrapper::addTorque(JPH::BodyID id,
const Ogre::Vector3 &torque)
{
phys->addTorque(id, torque);
}
void JoltPhysicsWrapper::setVelocities(JPH::BodyID id,
const Ogre::Vector3 &linear,
const Ogre::Vector3 &angular)
+7
View File
@@ -232,6 +232,7 @@ public:
bool activate = true);
Ogre::Vector3 getLinearVelocity(JPH::BodyID id);
Ogre::Vector3 getAngularVelocity(JPH::BodyID id);
void addTorque(JPH::BodyID id, const Ogre::Vector3 &torque);
void setVelocities(JPH::BodyID id, const Ogre::Vector3 &linear,
const Ogre::Vector3 &angular);
float getFriction(JPH::BodyID id);
@@ -307,6 +308,12 @@ public:
float maxTorque = 400.0f;
/* Degrees; 0 = unlimited. */
float maxPitchRollAngleDeg = 60.0f;
/* Multipliers for the default tire friction curves
* (1.0 = stock asphalt tire). */
float tireFrictionLongitudinal = 1.0f;
float tireFrictionLateral = 1.0f;
/* Anti-roll bar stiffness (N/m) per axle pair; 0 = none. */
float antiRollBarStiffness = 0.0f;
};
/* Create/destroy a vehicle on an existing (already added) dynamic
@@ -549,6 +549,22 @@ void AnimationTreeSystem::update(float deltaTime)
}
}
/* Disable animations that were not evaluated this frame.
* When a fading-out animation's weight drops to zero in a
* single step (large deltaTime * fadeSpeed), evaluateNode
* skips it entirely and it never appears in ctx.animData;
* without this, its Ogre::AnimationState would stay enabled
* forever at its last non-zero weight with frozen time. */
for (auto &pair : state.animations) {
Ogre::AnimationState *as = pair.second.ogreAnimState;
if (!as || !as->getEnabled())
continue;
if (ctx.animData.find(pair.first) == ctx.animData.end()) {
as->setEnabled(false);
as->setWeight(0.0f);
}
}
if (at.useRootMotion && sceneNode) {
/* Smooth root-motion displacement with EMA to
* filter high-frequency hip oscillation within
@@ -1669,9 +1669,30 @@ nlohmann::json SceneSerializer::serializeVehicle(flecs::entity entity)
json["maxTorque"] = vehicle.maxTorque;
json["maxPitchRollAngleDeg"] = vehicle.maxPitchRollAngleDeg;
if (vehicle.tireFrictionLongitudinal != 1.0f)
json["tireFrictionLongitudinal"] =
vehicle.tireFrictionLongitudinal;
if (vehicle.tireFrictionLateral != 1.0f)
json["tireFrictionLateral"] = vehicle.tireFrictionLateral;
if (vehicle.antiRollBarStiffness != 0.0f)
json["antiRollBarStiffness"] = vehicle.antiRollBarStiffness;
json["seatOffset"] = { { "x", vehicle.seatOffset.x },
{ "y", vehicle.seatOffset.y },
{ "z", vehicle.seatOffset.z } };
nlohmann::json seats = nlohmann::json::array();
for (const VehicleSeat &s : vehicle.seats) {
nlohmann::json sj;
sj["name"] = s.name;
sj["offset"] = { { "x", s.offset.x },
{ "y", s.offset.y },
{ "z", s.offset.z } };
sj["driver"] = s.driver;
seats.push_back(sj);
}
if (!vehicle.seats.empty())
json["seats"] = seats;
if (vehicle.seatCharacterScale != 1.0f)
json["seatCharacterScale"] = vehicle.seatCharacterScale;
json["wheelMeshName"] = vehicle.wheelMeshName;
json["steeringWheelMeshName"] = vehicle.steeringWheelMeshName;
json["steeringWheelOffset"] = {
@@ -1721,12 +1742,33 @@ void SceneSerializer::deserializeVehicle(flecs::entity entity,
vehicle.maxTorque = json.value("maxTorque", 400.0f);
vehicle.maxPitchRollAngleDeg =
json.value("maxPitchRollAngleDeg", 60.0f);
vehicle.tireFrictionLongitudinal =
json.value("tireFrictionLongitudinal", 1.0f);
vehicle.tireFrictionLateral =
json.value("tireFrictionLateral", 1.0f);
vehicle.antiRollBarStiffness =
json.value("antiRollBarStiffness", 0.0f);
if (json.contains("seatOffset")) {
auto &so = json["seatOffset"];
vehicle.seatOffset = Ogre::Vector3(so.value("x", 0.0f),
so.value("y", 1.0f),
so.value("z", 0.0f));
}
if (json.contains("seats")) {
for (const auto &sj : json["seats"]) {
VehicleSeat s;
s.name = sj.value("name", "driver");
if (sj.contains("offset")) {
auto &o = sj["offset"];
s.offset = Ogre::Vector3(o.value("x", 0.0f),
o.value("y", 1.0f),
o.value("z", 0.0f));
}
s.driver = sj.value("driver", false);
vehicle.seats.push_back(s);
}
}
vehicle.seatCharacterScale = json.value("seatCharacterScale", 1.0f);
vehicle.wheelMeshName = json.value("wheelMeshName", "");
vehicle.steeringWheelMeshName =
json.value("steeringWheelMeshName", "");
@@ -67,40 +67,138 @@ flecs::entity VehicleControllerSystem::findPlayerController()
return result;
}
flecs::entity VehicleControllerSystem::findNearestVehicle(
const Ogre::Vector3 &pos, float maxDist) const
{
flecs::entity result = flecs::entity::null();
float best = maxDist;
m_world.query<VehicleComponent, TransformComponent>().each(
[&](flecs::entity e, VehicleComponent &, TransformComponent &t) {
if (!t.node)
return;
float d = t.node->_getDerivedPosition().distance(pos);
if (d < best) {
best = d;
result = e;
}
});
return result;
}
void VehicleControllerSystem::onVehicleEnterEvent(
const Ogre::String &eventName, const editScene::EventParams &params)
{
(void)eventName;
if (m_driving)
return;
/* After an exit, E must have been released once before it can
* seat the player again (the exit is a hold, so the key is still
* down when the exit spot lands next to a door actuator). */
if (m_eReleaseRequired)
return;
Ogre::String vehicleName = params.getString("vehicle", "");
if (vehicleName.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"[VehicleControllerSystem] vehicle_enter event without "
"a 'vehicle' param, ignored");
return;
}
Ogre::String seatName = params.getString("seat", "");
flecs::entity vehicle = flecs::entity::null();
m_world.query<EntityNameComponent>().each(
[&](flecs::entity e, EntityNameComponent &name) {
if (vehicle.is_alive())
return;
if (name.name == vehicleName)
vehicle = e;
});
if (!vehicle.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"[VehicleControllerSystem] vehicle_enter: no entity "
"named '" +
vehicleName + "'");
return;
if (vehicleName.empty()) {
/* No vehicle named: take the nearest one to the player
* character (per-door seat actuators are generic and rely
* on this). */
flecs::entity character =
m_editorApp->getPlayerCharacterEntity();
if (character.is_alive() &&
character.has<TransformComponent>() &&
character.get<TransformComponent>().node) {
vehicle = findNearestVehicle(
character.get<TransformComponent>()
.node->_getDerivedPosition(),
5.0f);
}
if (!vehicle.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"[VehicleControllerSystem] vehicle_enter: no "
"'vehicle' param and no vehicle near the player");
return;
}
} else {
m_world.query<EntityNameComponent>().each(
[&](flecs::entity e, EntityNameComponent &name) {
if (vehicle.is_alive())
return;
if (name.name == vehicleName)
vehicle = e;
});
if (!vehicle.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"[VehicleControllerSystem] vehicle_enter: no entity "
"named '" +
vehicleName + "'");
return;
}
}
enterVehicle(vehicle);
enterVehicle(vehicle, seatName);
}
int VehicleControllerSystem::seatCount(const VehicleComponent &vc) const
{
return vc.seats.empty() ? 1 : (int)vc.seats.size();
}
Ogre::Vector3 VehicleControllerSystem::seatOffsetAt(
const VehicleComponent &vc, int index) const
{
if (vc.seats.empty())
return vc.seatOffset;
return vc.seats[index].offset;
}
bool VehicleControllerSystem::seatIsDriverAt(const VehicleComponent &vc,
int index) const
{
if (vc.seats.empty())
return true; /* the implicit seatOffset seat is the driver */
return vc.seats[index].driver;
}
bool VehicleControllerSystem::seatIsFreeAt(const VehicleComponent &vc,
int index) const
{
if (vc.seats.empty())
return true; /* single player, implicit seat: untracked */
return vc.seats[index].occupant == 0;
}
int VehicleControllerSystem::resolveSeat(const VehicleComponent &vc,
const Ogre::String &requested) const
{
int count = seatCount(vc);
if (!requested.empty()) {
for (int i = 0; i < count; i++) {
Ogre::String name = vc.seats.empty() ?
"driver" :
vc.seats[i].name;
if (name == requested && seatIsFreeAt(vc, i))
return i;
}
}
/* Fall back to the driver seat, then to any free seat. */
for (int i = 0; i < count; i++)
if (seatIsDriverAt(vc, i) && seatIsFreeAt(vc, i))
return i;
for (int i = 0; i < count; i++)
if (seatIsFreeAt(vc, i))
return i;
return -1;
}
bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle)
{
return enterVehicle(vehicle, "");
}
bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle,
const Ogre::String &seatName)
{
if (m_driving)
return false;
@@ -126,24 +224,59 @@ bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle)
return false;
auto &pc = controller.get_mut<PlayerControllerComponent>();
/* Pick the seat: the requested one when free, else the driver
* seat, else any free seat. */
const VehicleComponent &vcc = vehicle.get<VehicleComponent>();
int seatIndex = resolveSeat(vcc, seatName);
if (seatIndex < 0) {
GameHudSystem *hud = m_editorApp->getGameHudSystem();
if (hud)
hud->pushMessage("No free seat in this vehicle",
4.0f);
return false;
}
m_savedTpsDistance = pc.tpsDistance;
m_savedTpsHeight = pc.tpsHeight;
pc.driving = true;
pc.tpsDistance = vehicle.get<VehicleComponent>().cameraDistance;
pc.tpsHeight = vehicle.get<VehicleComponent>().cameraHeight;
pc.tpsDistance = vcc.cameraDistance;
pc.tpsHeight = vcc.cameraHeight;
CharacterSystem *cs = m_editorApp->getCharacterSystem();
if (cs)
cs->disablePhysics(character);
/* Switch to the driving animation state (currently mapped to the
* "sitting" animation in the locomotion state machine). */
/* Switch to the driving animation state (the animation it maps
* to comes from the scene's animation tree; demo-taxi maps it to
* "sitting-vehicle"). */
AnimationTreeSystem *ats = m_editorApp->getAnimationTreeSystem();
if (ats)
ats->setState(character, pc.locomotionStateMachine,
pc.drivingState);
vehicle.add<PlayerControlledComponent>();
m_seatIndex = seatIndex;
m_seatIsDriver = seatIsDriverAt(vcc, seatIndex);
if (!vcc.seats.empty()) {
auto &vcm = vehicle.get_mut<VehicleComponent>();
vcm.seats[seatIndex].occupant = character.id();
}
/* Fit the character into the cabin (seatCharacterScale; restored
* by restoreOnFoot). */
{
auto &ctm = character.get_mut<TransformComponent>();
if (ctm.node) {
m_savedCharScale = ctm.node->getScale();
if (vcc.seatCharacterScale != 1.0f)
ctm.node->setScale(Ogre::Vector3(
vcc.seatCharacterScale));
}
}
/* Only the driver seat marks the vehicle player-controlled and
* gets the driving inputs; passengers just sit. */
if (m_seatIsDriver)
vehicle.add<PlayerControlledComponent>();
m_driving = true;
m_vehicleId = vehicle.id();
@@ -158,11 +291,13 @@ bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle)
* actually has a fork). */
GameHudSystem *hud = m_editorApp->getGameHudSystem();
if (hud) {
Ogre::String hint =
"Driving: W/S move, A/D steer, hold E to exit";
if (!vehicle.get<VehicleComponent>().forkMeshName.empty())
hint = "Driving: W/S move, A/D steer, R/F fork "
"up/down, hold E to exit";
Ogre::String hint = "Riding: hold E to exit";
if (m_seatIsDriver) {
hint = "Driving: W/S move, A/D steer, hold E to exit";
if (!vcc.forkMeshName.empty())
hint = "Driving: W/S move, A/D steer, R/F fork "
"up/down, hold E to exit";
}
hud->pushMessage(hint, 10.0f);
}
@@ -184,7 +319,7 @@ void VehicleControllerSystem::pinCharacterToSeat(flecs::entity vehicle,
* uses setPosition), so derived == world here; vehicle scale is
* 1 in practice. */
Ogre::Vector3 seat = vt.node->_getDerivedOrientation() *
vc.seatOffset +
seatOffsetAt(vc, m_seatIndex) +
vt.node->_getDerivedPosition();
ct.node->setPosition(seat);
ct.node->setOrientation(vt.node->_getDerivedOrientation());
@@ -193,6 +328,9 @@ void VehicleControllerSystem::pinCharacterToSeat(flecs::entity vehicle,
void VehicleControllerSystem::update(float deltaTime)
{
if (m_eReleaseRequired && !m_editorApp->getGameInputState().e)
m_eReleaseRequired = false;
if (!m_driving)
return;
@@ -209,6 +347,8 @@ void VehicleControllerSystem::update(float deltaTime)
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
m_seatIndex = -1;
m_seatIsDriver = false;
return;
}
if (!character.is_alive()) {
@@ -217,17 +357,28 @@ void VehicleControllerSystem::update(float deltaTime)
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
m_seatIndex = -1;
m_seatIsDriver = false;
return;
}
GameInputState &input = m_editorApp->getGameInputState();
auto &vc = vehicle.get_mut<VehicleComponent>();
vc.inputForward = input.w ? 1.0f : (input.s ? -1.0f : 0.0f);
vc.inputRight = input.d ? 1.0f : (input.a ? -1.0f : 0.0f);
vc.inputBrake = 0.0f;
vc.inputHandBrake = input.space ? 1.0f : 0.0f;
/* Fork lift: R raises, F lowers (hold). */
vc.inputFork = input.r ? 1.0f : (input.f ? -1.0f : 0.0f);
if (m_seatIsDriver) {
vc.inputForward = input.w ? 1.0f : (input.s ? -1.0f : 0.0f);
vc.inputRight = input.d ? 1.0f : (input.a ? -1.0f : 0.0f);
vc.inputBrake = 0.0f;
vc.inputHandBrake = input.space ? 1.0f : 0.0f;
/* 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. */
vc.inputForward = 0.0f;
vc.inputRight = 0.0f;
vc.inputBrake = 0.0f;
vc.inputHandBrake = 0.0f;
vc.inputFork = 0.0f;
}
/* The seat pin runs in postPhysicsUpdate() (after the physics
* step syncs the vehicle node) so the driver and the seat are
@@ -261,6 +412,7 @@ void VehicleControllerSystem::postPhysicsUpdate()
}
bool VehicleControllerSystem::findExitSpot(flecs::entity vehicle,
const Ogre::Vector3 &seatOffset,
Ogre::Vector3 &out) const
{
const TransformComponent &vt = vehicle.get<TransformComponent>();
@@ -272,16 +424,22 @@ bool VehicleControllerSystem::findExitSpot(flecs::entity vehicle,
Ogre::Vector3 right = vor * Ogre::Vector3::UNIT_X;
Ogre::Vector3 fwd = vor * Ogre::Vector3::UNIT_Z;
/* 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. */
float side = seatOffset.x >= 0.0f ? 1.0f : -1.0f;
Ogre::Vector3 seatWorld = vor * seatOffset;
const Ogre::Vector3 candidates[4] = {
vpos + right * EXIT_SIDE_OFFSET,
vpos - right * EXIT_SIDE_OFFSET,
seatWorld + right * (side * EXIT_SIDE_OFFSET),
seatWorld - right * (side * EXIT_SIDE_OFFSET),
vpos - fwd * EXIT_END_OFFSET,
vpos + fwd * EXIT_END_OFFSET,
};
JoltPhysicsWrapper *physics = JoltPhysicsWrapper::getSingletonPtr();
for (int i = 0; i < 4; i++) {
Ogre::Vector3 spot = candidates[i];
Ogre::Vector3 spot = vpos + candidates[i];
if (!physics) {
out = spot;
return true;
@@ -317,6 +475,12 @@ void VehicleControllerSystem::restoreOnFoot()
CharacterSystem *cs = m_editorApp->getCharacterSystem();
if (cs)
cs->enablePhysics(character);
if (character.has<TransformComponent>()) {
const TransformComponent &ct =
character.get<TransformComponent>();
if (ct.node)
ct.node->setScale(m_savedCharScale);
}
}
if (controller.is_alive() &&
@@ -350,8 +514,14 @@ void VehicleControllerSystem::exitVehicle()
return;
}
/* The exit spot depends on the seat actually occupied, so grab the
* seat offset before the state below is cleared. */
int seatIndex = m_seatIndex >= 0 ? m_seatIndex : 0;
Ogre::Vector3 seatOffset =
seatOffsetAt(vehicle.get<VehicleComponent>(), seatIndex);
Ogre::Vector3 spot;
if (!findExitSpot(vehicle, spot)) {
if (!findExitSpot(vehicle, seatOffset, spot)) {
Ogre::LogManager::getSingleton().logMessage(
"[VehicleControllerSystem] no clear exit spot, "
"staying seated");
@@ -366,13 +536,17 @@ void VehicleControllerSystem::exitVehicle()
ct.node->setPosition(spot);
ct.position = spot;
/* Zero the driver inputs so the vehicle rolls to a stop. */
/* Zero the driver inputs so the vehicle rolls to a stop, and free
* the seat. */
auto &vc = vehicle.get_mut<VehicleComponent>();
vc.inputForward = 0.0f;
vc.inputRight = 0.0f;
vc.inputBrake = 0.0f;
vc.inputHandBrake = 0.0f;
vc.inputFork = 0.0f;
if (!vc.seats.empty() && m_seatIndex >= 0 &&
m_seatIndex < (int)vc.seats.size())
vc.seats[m_seatIndex].occupant = 0;
vehicle.remove<PlayerControlledComponent>();
restoreOnFoot();
@@ -381,6 +555,9 @@ void VehicleControllerSystem::exitVehicle()
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
m_seatIndex = -1;
m_seatIsDriver = false;
m_eReleaseRequired = true;
Ogre::LogManager::getSingleton().logMessage(
"[VehicleControllerSystem] exited vehicle");
@@ -400,6 +577,9 @@ void VehicleControllerSystem::resetDriving()
vc.inputBrake = 0.0f;
vc.inputHandBrake = 0.0f;
vc.inputFork = 0.0f;
if (!vc.seats.empty() && m_seatIndex >= 0 &&
m_seatIndex < (int)vc.seats.size())
vc.seats[m_seatIndex].occupant = 0;
}
vehicle.remove<PlayerControlledComponent>();
}
@@ -410,4 +590,6 @@ void VehicleControllerSystem::resetDriving()
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
m_seatIndex = -1;
m_seatIsDriver = false;
}
@@ -3,6 +3,7 @@
#pragma once
#include "EventBus.hpp"
#include "../components/Vehicle.hpp"
#include <Ogre.h>
#include <flecs.h>
@@ -11,25 +12,33 @@ class EditorApp;
/**
* Vehicle enter/exit + drive input (game mode only, F10).
*
* Entering: a scene action (typically an actuator on the vehicle entity)
* runs a luaTask that sends the "vehicle_enter" event with a "vehicle"
* param naming the vehicle entity; this system listens for that event and
* seats the player character: the character's physics capsule is disabled
* Entering: a scene action (typically an actuator on the vehicle entity,
* or on one of its door children) runs a luaTask that sends the
* "vehicle_enter" event with an optional "vehicle" param naming the
* vehicle entity (empty = nearest vehicle to the player character) and
* an optional "seat" param naming the seat (empty = driver seat, or the
* first free seat); this system listens for that event and seats the
* player character: the character's physics capsule is disabled
* (CharacterSystem::disablePhysics), its scene node is pinned to the
* vehicle's VehicleComponent::seatOffset every frame (after the physics
* step, so the seat never jitters under the driver — see
* postPhysicsUpdate), and the player controller's TPS boom is swapped for
* the vehicle's cameraDistance / cameraHeight (restored on exit).
* seat offset (VehicleComponent::seats, or the legacy single driver
* seatOffset when seats is empty) every frame (after the physics step,
* so the seat never jitters under the driver — see postPhysicsUpdate),
* and the player controller's TPS boom is swapped for the vehicle's
* cameraDistance / cameraHeight (restored on exit).
* PlayerControllerComponent::driving gates off locomotion and actuator
* prompts while seated.
*
* Driving: W/S feed VehicleComponent::inputForward, A/D inputRight,
* Space inputHandBrake; VehicleSystem consumes them in the physics step.
* Only the driver seat (VehicleSeat::driver, or the implicit seatOffset
* seat) gets these inputs and marks the vehicle PlayerControlledComponent;
* passengers sit without any control over the vehicle.
*
* Exit: holding E (a longer press than entering: a short cooldown after
* seating must pass first, then E must be held for a moment) places the
* character on the first raycast-clear spot beside
* (then behind/ahead of) the vehicle, re-enables its physics and restores
* character on the first raycast-clear spot beside the door of the seat
* 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.
*
@@ -61,9 +70,14 @@ public:
}
flecs::entity getDrivenVehicle() const;
/** Seat the player character on the vehicle. Returns false when
* already driving or the entity is not a usable vehicle. */
/** Seat the player character on the vehicle (driver seat by
* default). Returns false when already driving or the entity is
* not a usable vehicle. */
bool enterVehicle(flecs::entity vehicle);
/** Same, but asks for the named seat first (falls back to the
* driver seat, then to any free seat). */
bool enterVehicle(flecs::entity vehicle,
const Ogre::String &seatName);
/** Place the character next to the vehicle and restore on-foot
* controls. No-op when not driving. */
void exitVehicle();
@@ -75,9 +89,20 @@ private:
void onVehicleEnterEvent(const Ogre::String &eventName,
const editScene::EventParams &params);
flecs::entity findPlayerController();
flecs::entity findNearestVehicle(const Ogre::Vector3 &pos,
float maxDist) const;
int seatCount(const VehicleComponent &vc) const;
Ogre::Vector3 seatOffsetAt(const VehicleComponent &vc,
int index) const;
bool seatIsDriverAt(const VehicleComponent &vc, int index) const;
bool seatIsFreeAt(const VehicleComponent &vc, int index) const;
int resolveSeat(const VehicleComponent &vc,
const Ogre::String &requested) const;
void pinCharacterToSeat(flecs::entity vehicle,
flecs::entity character);
bool findExitSpot(flecs::entity vehicle, Ogre::Vector3 &out) const;
bool findExitSpot(flecs::entity vehicle,
const Ogre::Vector3 &seatOffset,
Ogre::Vector3 &out) const;
void restoreOnFoot();
flecs::world &m_world;
@@ -88,12 +113,22 @@ private:
flecs::entity_t m_vehicleId = 0;
flecs::entity_t m_characterId = 0;
flecs::entity_t m_controllerId = 0;
/* Resolved seat: index into VehicleComponent::seats (0 for the
* implicit seatOffset seat) and whether it has driving controls. */
int m_seatIndex = -1;
bool m_seatIsDriver = false;
/* Stashed on-foot camera settings, restored on exit. */
float m_savedTpsDistance = 3.0f;
float m_savedTpsHeight = 2.0f;
/* Stashed character node scale (seatCharacterScale is applied
* while seated), restored on exit. */
Ogre::Vector3 m_savedCharScale = Ogre::Vector3::UNIT_SCALE;
/* Exit debounce: time seated and current E hold duration. */
float m_seatTime = 0.0f;
float m_exitHold = 0.0f;
/* Set on exit: the E key (still held from the exit hold) must be
* released once before a vehicle_enter event is accepted again. */
bool m_eReleaseRequired = false;
};
#endif // EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP
@@ -62,6 +62,9 @@ void VehicleSystem::createConstraint(flecs::entity entity,
JoltPhysicsWrapper::VehicleDesc desc;
desc.maxTorque = vehicle.maxTorque;
desc.maxPitchRollAngleDeg = vehicle.maxPitchRollAngleDeg;
desc.tireFrictionLongitudinal = vehicle.tireFrictionLongitudinal;
desc.tireFrictionLateral = vehicle.tireFrictionLateral;
desc.antiRollBarStiffness = vehicle.antiRollBarStiffness;
desc.wheels.reserve(vehicle.wheels.size());
for (const VehicleWheel &vw : vehicle.wheels) {
JoltPhysicsWrapper::VehicleWheelDesc wd;
@@ -317,7 +320,7 @@ void VehicleSystem::prePhysicsUpdate(float deltaTime)
m_query.each([&](flecs::entity entity, VehicleComponent &vehicle,
RigidBodyComponent &rigidBody,
TransformComponent &) {
TransformComponent &transform) {
/* The chassis body went away (disabled, rebuilt, scene
* teardown): the constraint references the old body, drop
* it. */
@@ -390,6 +393,49 @@ void VehicleSystem::prePhysicsUpdate(float deltaTime)
float blend = std::min(1.0f, deltaTime * 8.0f);
steer += (steerTarget - steer) * blend;
/* Anti-two-wheel righting: a high-grip car can end up
* balanced on two wheels at ~45 deg after a hard turn and
* just stay there. When the chassis tilt is past ~35 deg
* but short of fully rolled (lying on the side/roof is
* fine and settles on its own), apply a torque about the
* (chassis up x world up) axis to put it back on four
* wheels. Above ~5 deg of tilt, also damp the angular
* velocity along the same axis so the car does not rock
* on its soft suspension for seconds afterwards. The
* thresholds are above any sane road slope, so parked
* cars on hills are untouched. */
if (transform.node && rigidBody.bodyCreated) {
Ogre::Vector3 up =
transform.node->_getDerivedOrientation() *
Ogre::Vector3::UNIT_Y;
const float cosDamp = 0.9962f; /* 5 deg */
const float cosMin = 0.8192f; /* 35 deg */
const float cosMax = 0.1736f; /* 80 deg */
if (up.y < cosDamp && up.y > cosMax) {
Ogre::Vector3 axis = up.crossProduct(
Ogre::Vector3::UNIT_Y);
float sinTilt = axis.length();
axis /= sinTilt;
Ogre::Vector3 angVel =
m_physics->getAngularVelocity(
rigidBody.bodyID);
float mass =
std::max(1.0f, rigidBody.mass);
/* Damping along the righting axis
* (calms rocking in both directions). */
Ogre::Vector3 torque =
axis * (-1.0f * mass *
axis.dotProduct(angVel));
/* Righting torque proportional to
* sin(tilt), only past ~35 deg. */
if (up.y < cosMin)
torque += axis * (0.6f * mass *
9.81f * sinTilt);
m_physics->addTorque(rigidBody.bodyID,
torque);
}
}
updateFork(entity, vehicle, rigidBody, deltaTime);
});
}
@@ -409,6 +455,15 @@ void VehicleSystem::postPhysicsUpdate()
Ogre::Quaternion invChassis =
transform.node->_getDerivedOrientation().Inverse();
/* Jolt reports every wheel with the same axes (mirrored
* position, same axle direction), so a single wheel mesh has
* its outer face pointing inward on the right side (chassis
* -X). Flip right-side wheels 180 degrees about the local
* forward axis: the outer face points outward again and the
* spin about the axle is visually unchanged, so one shared
* wheel mesh works for all four corners. */
const Ogre::Quaternion rightSideFlip(
Ogre::Degree(180.0f), Ogre::Vector3::UNIT_Z);
for (size_t i = 0; i < it->second.nodes.size(); i++) {
Ogre::Vector3 pos;
Ogre::Quaternion ori;
@@ -417,7 +472,12 @@ void VehicleSystem::postPhysicsUpdate()
Ogre::SceneNode *node = it->second.nodes[i];
node->setPosition(transform.node->convertWorldToLocalPosition(
pos));
node->setOrientation(invChassis * ori);
bool rightSide =
i < vehicle.wheels.size() &&
vehicle.wheels[i].position.x < 0.0f;
node->setOrientation(invChassis * ori *
(rightSide ? rightSideFlip :
Ogre::Quaternion::IDENTITY));
}
/* Fork visual: follow the fork height. */
@@ -17,9 +17,79 @@ bool VehicleEditor::renderComponent(flecs::entity entity,
&vehicle.maxPitchRollAngleDeg, 0.5f,
0.0f, 90.0f))
modified = true;
if (ImGui::DragFloat("Tire Friction (long.)",
&vehicle.tireFrictionLongitudinal,
0.05f, 0.0f, 5.0f))
modified = true;
if (ImGui::DragFloat("Tire Friction (lat.)",
&vehicle.tireFrictionLateral, 0.05f,
0.0f, 5.0f))
modified = true;
if (ImGui::DragFloat("Anti-Roll Bar (N/m)",
&vehicle.antiRollBarStiffness, 50.0f,
0.0f, 20000.0f))
modified = true;
if (ImGui::DragFloat3("Seat Offset",
&vehicle.seatOffset.x, 0.01f))
modified = true;
ImGui::SameLine();
ImGui::TextDisabled("(?)");
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Legacy single driver seat; only "
"used when the Seats list below is "
"empty.");
ImGui::Text("Seats (%d)", (int)vehicle.seats.size());
ImGui::SameLine();
if (ImGui::SmallButton("Add##seat")) {
vehicle.seats.push_back(VehicleSeat());
modified = true;
}
int removeSeat = -1;
for (size_t i = 0; i < vehicle.seats.size(); i++) {
VehicleSeat &s = vehicle.seats[i];
ImGui::PushID((int)i + 1000);
Ogre::String label = s.name.empty() ?
"Seat " + std::to_string(i) :
s.name;
if (s.driver)
label += " (driver)";
if (ImGui::TreeNode(label.c_str())) {
char seatNameBuf[256];
snprintf(seatNameBuf, sizeof(seatNameBuf), "%s",
s.name.c_str());
if (ImGui::InputText("Name", seatNameBuf,
sizeof(seatNameBuf))) {
s.name = seatNameBuf;
modified = true;
}
if (ImGui::DragFloat3("Offset", &s.offset.x,
0.01f))
modified = true;
if (ImGui::Checkbox("Driver", &s.driver))
modified = true;
if (ImGui::SmallButton("Remove##seat"))
removeSeat = (int)i;
ImGui::TreePop();
}
ImGui::PopID();
}
if (removeSeat >= 0) {
vehicle.seats.erase(vehicle.seats.begin() + removeSeat);
modified = true;
}
if (ImGui::DragFloat("Seated Char Scale",
&vehicle.seatCharacterScale, 0.01f,
0.5f, 1.5f))
modified = true;
ImGui::SameLine();
ImGui::TextDisabled("(?)");
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Uniform scale applied to the "
"character while seated (restored on "
"exit); < 1 shrinks the sit pose to fit "
"the cabin.");
if (ImGui::DragFloat("Camera Distance",
&vehicle.cameraDistance, 0.05f,
1.0f, 50.0f))
+5 -5
View File
@@ -288,7 +288,7 @@ struct GUIListener : public Ogre::RenderTargetListener {
ImGuiWindowFlags_NoInputs);
// if (ECS::get().get<GUI>().enabled)
// ECS::get().get<App>().app->setWindowGrab(true);
ImGui::PushFont(bigFont);
ImGui::PushFont(bigFont, 0.0f);
ImGui::TextWrapped(
"%s",
"This game does not autosave. Please use save function to keep your state");
@@ -307,7 +307,7 @@ struct GUIListener : public Ogre::RenderTargetListener {
ImGuiCond_Always);
ImGui::Begin("Narration...", NULL,
ImGuiWindowFlags_NoTitleBar);
ImGui::PushFont(midFont);
ImGui::PushFont(midFont, 0.0f);
ImVec2 p = ImGui::GetCursorScreenPos();
GUI::NarrationHandler *narration =
ECS::get<GUI>().narrationHandlers.front();
@@ -356,7 +356,7 @@ struct GUIListener : public Ogre::RenderTargetListener {
ImGuiCond_Always);
ImGui::Begin("Narration...", NULL,
ImGuiWindowFlags_NoTitleBar);
ImGui::PushFont(midFont);
ImGui::PushFont(midFont, 0.0f);
ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::TextWrapped(
"%s",
@@ -454,8 +454,8 @@ struct GUIListener : public Ogre::RenderTargetListener {
ImVec2 size = ImGui::GetWindowSize();
float window_width = size.x;
float window_height = size.y;
ImGui::PushFont(bigFont);
ImGui::TextWrapped("%s", "Booo!!!!");
ImGui::PushFont(bigFont, 0.0f);
ImGui::TextWrapped("%s", "Booo!!!!");
float extra_pixels = 20.0f;
float button_width = 0.0f;
float buttons_height = 0.0f;
+3 -2
View File
@@ -283,8 +283,9 @@ void drawRectPreview(nlohmann::json::json_pointer ptr,
// Outline if selected
if (rectName == name)
draw_list->AddRect(p_min, p_max,
IM_COL32(255, 255, 0, 255), 0.0f, 0,
2.0f);
IM_COL32(255, 255, 0, 255), 0.0f,
2.0f,
ImDrawFlags_None);
}
ImGui::Dummy(canvas_sz); // Advance cursor
}