Taxi demo update with sedan
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ¶ms)
|
||||
{
|
||||
(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 ¶ms);
|
||||
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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user