Compare commits
5 Commits
d300a16333
...
3c7f50a4ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c7f50a4ef | |||
| c748a7dd1e | |||
| e2e4a398a9 | |||
| 55cd5f3c52 | |||
| 6d10fec2a0 |
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
@@ -251,6 +251,63 @@ xvfb-run -a ./demoSokoban demo_scene_exterior.json \
|
||||
# while driving, R/F = raise/lower the fork while driving, Escape =
|
||||
# pause menu (frees the cursor).
|
||||
|
||||
# Demo: taxi driver demo (demos/demo-taxi, target demoTaxi). Starts as
|
||||
# an exact copy of demo-interior-exterior-dynamics (same
|
||||
# interior/exterior scenes, streaming terrain, scene-switch doors,
|
||||
# --test-switch contract); the taxi game (procedural town generation,
|
||||
# drivable sedans, passenger/foot-traffic/vehicle-traffic NPCs, chained
|
||||
# timed fares with doubling payout) is added step by step - see
|
||||
# demos/demo-taxi/PLAN.md. Vehicle ideas come from demo-sokoban.
|
||||
cd demos/demo-taxi
|
||||
./demoTaxi
|
||||
# ...or headless smoke run (one frame, then exit):
|
||||
./demoTaxi --headless --exit-after-first-frame
|
||||
# ...or headless end-to-end check of the inherited scene contract:
|
||||
./demoTaxi --headless --test-switch
|
||||
# ...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,
|
||||
# also registered as CTest roadGeometryOverlapTest)
|
||||
@@ -972,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 —
|
||||
@@ -1144,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`.
|
||||
|
||||
@@ -1064,3 +1064,4 @@ add_subdirectory(demos/demo-scene-switching)
|
||||
add_subdirectory(demos/demo-scene-switching-extra)
|
||||
add_subdirectory(demos/demo-interior-exterior-dynamics)
|
||||
add_subdirectory(demos/demo-sokoban)
|
||||
add_subdirectory(demos/demo-taxi)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# demo-taxi — taxi driver demo: procedurally generated town, drivable sedans,
|
||||
# passenger/foot-traffic/vehicle-traffic NPCs, chained taxi fares
|
||||
# ---------------------------------------------------------------------------
|
||||
# Starts as an exact copy of demos/demo-interior-exterior-dynamics (same
|
||||
# scene-switch-door setup, same streaming-terrain/sky/water exterior at
|
||||
# world center ~(4000, 4000)); the taxi game (town generation, sedans,
|
||||
# NPC traffic, fare chain) is built step by step on top of it - see
|
||||
# PLAN.md in this directory. Vehicle ideas come from demos/demo-sokoban
|
||||
# (Jolt VehicleConstraint, VehicleControllerSystem enter/exit flow).
|
||||
#
|
||||
# The executable is self-contained in this build directory: a POST_BUILD
|
||||
# step (stage_runtime.cmake) copies resources.cfg, the
|
||||
# character prefab (prefabs/char_2.json, written by a previous editor/game
|
||||
# run) and any runtime config JSONs here, and symlinks both demo scenes
|
||||
# (demo_scene_interior.json / demo_scene_exterior.json, so scene edits in
|
||||
# the source tree are visible without a rebuild) plus the big pre-staged
|
||||
# runtime directories (resources/, characters/, lua-scripts/) from the
|
||||
# editScene binary directory; the terrain heightmap is staged separately
|
||||
# by configure_file (see below) so source changes re-copy it on the next
|
||||
# build. Run it from here:
|
||||
# cd <build>/src/features/editScene/demos/demo-taxi
|
||||
# ./demoTaxi
|
||||
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
|
||||
# Escape = pause menu (frees the cursor).
|
||||
|
||||
get_filename_component(EDITSCENE_SOURCE_DIR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
get_filename_component(EDITSCENE_BINARY_DIR
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
|
||||
|
||||
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
|
||||
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
|
||||
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
|
||||
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
|
||||
|
||||
# --- Embedded project configuration (F8 release binary) ---------------
|
||||
# Read project.json at configure time and embed its parameters into the
|
||||
# binary via a generated project.h, so the release binary is attached to
|
||||
# its project directory without needing --project. Editing project.json
|
||||
# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS).
|
||||
set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json")
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${PROJECT_JSON_PATH}")
|
||||
file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT)
|
||||
string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName)
|
||||
string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene)
|
||||
string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode)
|
||||
if(PROJECT_GAME_MODE)
|
||||
set(PROJECT_GAME_MODE_VALUE 1)
|
||||
else()
|
||||
set(PROJECT_GAME_MODE_VALUE 0)
|
||||
endif()
|
||||
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY)
|
||||
|
||||
# Terrain heightmap for the exterior scene's terrain entity (terrainId
|
||||
# 4242424300000001, heightmapFile heightmap.bin — TerrainSystem resolves it
|
||||
# as heightmaps/<terrainId>/<heightmapFile> relative to the CWD). Generated
|
||||
# in the source tree by gen_heightmap.py. The terrain runs in streaming
|
||||
# mode (base heights come from baseNoise, not the heightmap), but the file
|
||||
# stays staged so flipping streamingEnabled off keeps working. A
|
||||
# configure_file COPYONLY copy is used on purpose: it registers a configure
|
||||
# dependency on the source file, so regenerating heightmap.bin re-copies it
|
||||
# into the staging directory on the next build without manual intervention.
|
||||
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
|
||||
COPYONLY)
|
||||
|
||||
add_executable(demoTaxi
|
||||
demo_main.cpp
|
||||
${DEMO_SOURCES}
|
||||
)
|
||||
|
||||
target_compile_definitions(demoTaxi
|
||||
PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT)
|
||||
|
||||
add_dependencies(demoTaxi morph)
|
||||
|
||||
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
|
||||
target_compile_definitions(demoTaxi PRIVATE JPH_DEBUG_RENDERER)
|
||||
|
||||
target_link_libraries(demoTaxi
|
||||
OgreMain
|
||||
OgreBites
|
||||
OgreOverlay
|
||||
OgreMeshLodGenerator
|
||||
OgrePaging
|
||||
OgreTerrain
|
||||
flecs::flecs_static
|
||||
nlohmann_json::nlohmann_json
|
||||
Jolt::Jolt
|
||||
OgreProcedural::OgreProcedural
|
||||
RecastNavigation::Recast
|
||||
RecastNavigation::Detour
|
||||
RecastNavigation::DetourTileCache
|
||||
RecastNavigation::DetourCrowd
|
||||
RecastNavigation::DebugUtils
|
||||
PackageArchive
|
||||
RoadGeometryLib
|
||||
lua
|
||||
SDL2::SDL2
|
||||
)
|
||||
|
||||
target_include_directories(demoTaxi PRIVATE
|
||||
${CMAKE_CURRENT_BINARY_DIR}/generated
|
||||
${EDITSCENE_SOURCE_DIR}
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
|
||||
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
|
||||
)
|
||||
|
||||
# Stage the standalone runtime next to the executable (see header comment).
|
||||
add_custom_command(TARGET demoTaxi POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
|
||||
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
|
||||
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
|
||||
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
|
||||
COMMENT "Staging demo-taxi standalone runtime"
|
||||
)
|
||||
@@ -0,0 +1,566 @@
|
||||
# demo-taxi — Implementation Plan
|
||||
|
||||
**Status: Phase 0 done (2026-09-15).** Demo skeleton only; all game
|
||||
phases pending. This plan is a working draft — phases get refined,
|
||||
split or reordered as we work through them.
|
||||
|
||||
Taxi driver demo in the open world of `demo_scene_exterior.json`: a
|
||||
procedurally generated (but tightly controlled) settlement on the
|
||||
streaming terrain, two drivable sedan-type cars for the player, taxi
|
||||
passenger NPCs, foot-traffic NPCs on sidewalks, vehicle traffic with
|
||||
NPC drivers, and a fare chain — the player must complete 10 taxi fares,
|
||||
each fare paying double the previous one, delivering each passenger
|
||||
within a time limit derived from the trip distance. Missing the timer
|
||||
fails the whole mission and resets the chain.
|
||||
|
||||
This demo starts as an exact copy of `demo-interior-exterior-dynamics`
|
||||
(scene-switch doors interior <-> exterior, streaming terrain
|
||||
archipelago, sky, water). Vehicle ideas come from `demo-sokoban`
|
||||
(Jolt `VehicleConstraint`, `VehicleControllerSystem` enter/exit flow,
|
||||
`--test-vehicle*` headless test patterns).
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
Goals:
|
||||
|
||||
- **Easy procedural creation of settlements.** Today building a town
|
||||
by hand (roads node-by-node, lots/buildings one by one) takes
|
||||
endless amounts of time. The headline improvement of this demo is
|
||||
generator tooling: a road-network generator and a town generator
|
||||
with explicit, controllable parameters and deterministic seeds, so a
|
||||
village/town/city is a parameter choice, not a week of clicking.
|
||||
- **Multiple streamed settlements.** One settlement occupies a square
|
||||
of at most 1000 x 1000 m; the world can hold several of them.
|
||||
Everything streams (terrain pages, road/spawner region stores,
|
||||
navmesh tiles, NPC and traffic spawn radii) so FPS stays good.
|
||||
- **Enterable buildings.** Every generated building can be entered
|
||||
through an F1 scene-switch door; the matching interior is generated
|
||||
on the fly (deterministic from the building's exterior parameters).
|
||||
Until that phase lands, exterior doors stay disabled.
|
||||
- **NPC and vehicle control infrastructure.** AI-driven vehicles on
|
||||
the road graph, pedestrians on sidewalks, passengers entering and
|
||||
leaving the player's taxi — all reusable engine systems, not demo
|
||||
hacks.
|
||||
- **Realistic foot traffic.** People leave their houses, walk along
|
||||
the sidewalks and join other pedestrians — or walk to their own
|
||||
vehicle, get in and join the vehicle traffic.
|
||||
- **The taxi game loop** on top: pickup -> timed drive -> dropoff ->
|
||||
next fare, doubling payout, 10 fares to win.
|
||||
|
||||
Non-goals (for now):
|
||||
|
||||
- No multiplayer, no traffic-code simulation beyond what gameplay needs
|
||||
(speed limits, intersection priority, basic collision avoidance).
|
||||
- No full economy/quest system — the fare chain is a self-contained
|
||||
activity like the sokoban yard (Lua rules + GlobalStateStore + HUD).
|
||||
|
||||
## Design decisions (agreed with the user)
|
||||
|
||||
- **Engine-level, not demo-level** (same rule as demo-sokoban): all new
|
||||
components/systems/Lua APIs live in `src/features/editScene`
|
||||
(`components/`, `systems/`, `lua/`, `ui/`, road generators next to
|
||||
`roadlib/`) so any demo or scene can use them. `demo-taxi` itself
|
||||
contributes scene JSON data, assets, demo-owned config and headless
|
||||
test listeners in `demo_main.cpp` — no game logic in the demo binary.
|
||||
- **Lua runs the game rules** (same pattern as `sokoban.lua`): C++
|
||||
provides per-frame physics-adjacent detection and emits `EventBus`
|
||||
events; fare rules (price doubling, timers, completion) live in a
|
||||
shared Lua module (e.g. `lua-scripts/taxi.lua`). No per-frame Lua
|
||||
polling beyond the existing throttled `frame` event pattern.
|
||||
- **Deterministic generation**: every procedural step (road network,
|
||||
town layout, interiors, NPC placement) takes an explicit seed; same
|
||||
seed + same parameters = same town. Generated output is *data in
|
||||
the existing formats* (RoadGraph nodes/edges in road region files,
|
||||
scene entities, spawner defs), so it remains hand-editable
|
||||
afterwards — generation is a starting point, not a black box.
|
||||
- **Streaming-compatible**: the exterior terrain keeps
|
||||
`streamingEnabled`; generated content must land in the region stores
|
||||
(`heightmaps/<terrainId>/roads/`, `.../spawners/`) when streaming,
|
||||
inline otherwise. A settlement fits in a 1000 x 1000 m square so a
|
||||
settlement's content covers at most one terrain page (2000-unit
|
||||
pages) plus margin; multiple settlements sit in different regions of
|
||||
the same world.
|
||||
- **Vehicles**: several sedan types, each in multiple colors, for both
|
||||
the player cars and traffic. Many more vehicle types come later
|
||||
(Phase 11); the vehicle pipeline must not hardcode "sedan".
|
||||
- **Fare failure**: missing the delivery timer fails the whole
|
||||
mission — the chain resets to fare 1 and the payout multiplier is
|
||||
lost.
|
||||
- **Feature numbering**: `GameFeatures202609.md` has F0–F12 used; new
|
||||
engine features from this plan get F13+ entries there as they land,
|
||||
and AGENTS.md files are updated per the project rules.
|
||||
|
||||
## Existing pieces we build on
|
||||
|
||||
- `VehicleComponent` / `VehicleSystem` (F10) — Jolt `VehicleConstraint`
|
||||
+ `WheeledVehicleController`, per-wheel suspension/steering params,
|
||||
wheel/steering-wheel visuals, camera boom fields. The sedan is a
|
||||
configuration of this (front steer, no fork, higher speed).
|
||||
- `VehicleControllerSystem` (F10) — player enter/exit (`vehicle_enter`
|
||||
event, seat pinning, driving camera, controls hint). Passenger
|
||||
seating will extend this.
|
||||
- `RoadSystem` + `roadlib/RoadGeometryLib` — per-terrain `RoadGraph`,
|
||||
wedge/segment geometry with colliders, sidewalks, roadside prefab
|
||||
slots, terrain compliance both directions, M5 region streaming.
|
||||
Intersections already work at the visual level (wedge fans join
|
||||
edges at multi-neighbor nodes). `ProceduralRoadGeometry.md`
|
||||
documents the geometry; `ProceduralRoadGeometryImprovement.md`
|
||||
tracks known improvements.
|
||||
- `TownComponent` / `DistrictComponent` / `LotComponent` +
|
||||
`CellGridSystem` — procedural building generation with a
|
||||
Town -> District -> Lot material/template hierarchy (currently
|
||||
circle-arranged districts; the generators below replace the tedious
|
||||
manual arrangement, not the building generator). CellGrid supports
|
||||
multi-storey buildings (`height` = floors) but generates no stairs,
|
||||
so upper floors are unreachable.
|
||||
- Scene switching (F1 scene-switch doors, `EditorApp::switchScene`,
|
||||
per-scene arrival targets) — the mechanism enterable buildings build
|
||||
on; `demo_scene_interior.json`/`demo_scene_exterior.json` in this
|
||||
demo are the working example.
|
||||
- `NavMeshSystem` (tile cache) + `PathFollowingSystem` — pedestrian
|
||||
locomotion on navmesh, door-aware; Recast climbs geometry within the
|
||||
configured step height, so stair geometry is navmesh-compatible.
|
||||
- `CharacterSpawnerSystem` + `CharacterRegistry` + animation trees —
|
||||
distance-based NPC spawn/despawn; streamed spawner region store (M4).
|
||||
- GOAP / BehaviorTree / SmartObject / Actuator systems — NPC behaviour
|
||||
glue and player interaction prompts.
|
||||
- `ZoneSystem` / `TargetZoneComponent` (F11) — generic zone detection,
|
||||
reusable for pickup/dropoff curbs.
|
||||
- `GameHudSystem` + `ecs.hud.*` (F12) — status lines and banners.
|
||||
- `GlobalStateStore` (F9) — persistent fare-chain state
|
||||
(`taxi.fare.index`, `taxi.money`, ...).
|
||||
- `EventBus`, `SceneScriptComponent`, `lua-examples/` — event/Lua glue.
|
||||
- Demo scaffolding in this directory (CMake target, embedded
|
||||
`project.json`, symlinked scenes, `--headless` smoke and
|
||||
`--test-switch` listener patterns in `demo_main.cpp`).
|
||||
- demo-sokoban reference material: sedan-relevant tuning gotchas in
|
||||
`AGENTS.md` ("VehicleSystem (F10)"), its `animation_tree.json`
|
||||
"driving" state, `--test-vehicle-drive` headless test flow,
|
||||
`assets/blender/scripts/gen_sokoban_assets.py` as the asset-generator
|
||||
script pattern.
|
||||
|
||||
## Pieces that do not exist yet (to build)
|
||||
|
||||
1. Sedan vehicles: no car meshes, no car-tuned `VehicleComponent`
|
||||
preset. Reference models exist
|
||||
(`assets/blender/vehicles/Car5_Taxi.blend`,
|
||||
`assets/blender/vehicles/Car2.blend`) but cannot be used directly —
|
||||
they lack separate doors, wheels, trunk and engine cover.
|
||||
2. Road geometry improvements: intersection wedges are not smoothed at
|
||||
the edge and often overlap because too little space is reserved for
|
||||
lanes and sidewalks; the procedural lane template emits front/back
|
||||
geometry even where the intersection should be open; terrain
|
||||
compliance is manual and tedious.
|
||||
3. Road-network generation: roads are authored node-by-node with the
|
||||
Connect tool today; no layout generator.
|
||||
4. Town generation: District/Lot exist but arrange lots in a circle
|
||||
and require manual setup; no road-following lot placement, no
|
||||
village/town/city presets. We need road-specific lot components
|
||||
that place buildings along road frontages — from CellGrid templates
|
||||
or plain prefabs — with easy-to-set parameters.
|
||||
5. Stair generation: CellGrid multi-storey buildings (`height` > 1)
|
||||
have stacked floors with no connection between them — no stair
|
||||
cells, no floor openings, nothing for the character or the navmesh
|
||||
to climb.
|
||||
6. On-the-fly interiors: no way to generate an interior scene matching
|
||||
a generated building's exterior at scene-switch time.
|
||||
7. AI vehicle driving: vehicles are player-only; no road-following AI
|
||||
driver, no speed/intersection logic, no NPC seated in a vehicle.
|
||||
Intersections have no traffic controls (priority rules) at all.
|
||||
8. Foot traffic: no pedestrian wandering/scheduling on sidewalks, no
|
||||
house -> sidewalk -> own-car flows.
|
||||
9. Passenger mechanics: no NPC vehicle boarding/alighting, no
|
||||
passenger seat, no hail/pickup flow.
|
||||
10. Fare chain: no fare/timer/money rules (Lua module + HUD).
|
||||
11. Assets: sedans (several types x several colors, one taxi-marked),
|
||||
possibly street props (lamps, signs).
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase ends in a buildable, runnable demo with headless
|
||||
verification; verification commands assume the build tree (e.g.
|
||||
`build-vscode`) is configured. Phase scopes below are initial
|
||||
estimates — expect splits as we learn.
|
||||
|
||||
### Phase 0 — Demo skeleton — DONE (this step)
|
||||
|
||||
Copy of `demo-interior-exterior-dynamics` with target `demoTaxi`,
|
||||
`project.json` appName `demo-taxi`, registered via
|
||||
`add_subdirectory(demos/demo-taxi)` in
|
||||
`src/features/editScene/CMakeLists.txt`.
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
cmake --build build-vscode --target demoTaxi -j4
|
||||
cd build-vscode/src/features/editScene/demos/demo-taxi
|
||||
./demoTaxi --headless --exit-after-first-frame
|
||||
./demoTaxi --headless --test-switch # inherited scene contract
|
||||
```
|
||||
|
||||
### Phase 1 — Sedan vehicles — DONE
|
||||
|
||||
- New sedan meshes through the Blender asset pipeline (pattern:
|
||||
`gen_sokoban_assets.py`), using the reference models
|
||||
`assets/blender/vehicles/Car5_Taxi.blend` (taxi) and
|
||||
`assets/blender/vehicles/Car2.blend` (plain sedan) as shape
|
||||
references. They cannot be used directly — they lack separate
|
||||
doors, wheels, trunk and engine cover — so the export splits those
|
||||
parts. Scale to real car dimensions: length ~5 m, width ~1.9 m,
|
||||
height ~1.2 m (wheels modelled at the origin like the forklift's).
|
||||
- Several sedan types (silhouette variants) x several colors/materials
|
||||
so traffic is distinguishable; one type is taxi-marked (sign/roof
|
||||
marking). Keep the pipeline generic — many more vehicle types come
|
||||
in Phase 11.
|
||||
- Car-tuned `VehicleComponent`: front-wheel steering (no `rearSteer`),
|
||||
no fork, higher `maxTorque`/gearing than the forklift, sedan
|
||||
`cameraDistance`/`cameraHeight`, multi-seat `seats` list,
|
||||
`seatCharacterScale`.
|
||||
- Demo-owned `animation_tree.json` (copy demo-sokoban's — it carries
|
||||
the "driving" locomotion state, here mapped to the `sitting-chair`
|
||||
animation which fits a car cabin much better than the stool-sit
|
||||
`sitting`).
|
||||
- Two player sedans parked in the exterior scene near the content
|
||||
site. Each has 4 seats (`VehicleComponent::seats`: driver
|
||||
front-left, passenger front-right, rl/rr); each door child entity
|
||||
carries an actuator running a generic `vehicle_seat_*` action
|
||||
(vehicleEnter Lua node -> "vehicle_enter" event with a `seat` param,
|
||||
the vehicle resolved by proximity), so the player sits at the seat
|
||||
whose door they used — driving only works from the driver seat
|
||||
(multi-seat support in `VehicleComponent`/`VehicleControllerSystem`:
|
||||
seat occupancy, passengers ride without controls).
|
||||
- Headless: `--test-vehicle-drive`-equivalent for the sedan (spawn,
|
||||
enter, drive, steer, brake, exit — plus a passenger-seat leg: enter
|
||||
passenger seat, no `PlayerControlledComponent`, W does not drive) on
|
||||
a flat test scene.
|
||||
|
||||
**As built.** Generator `assets/blender/scripts/gen_taxi_assets.py`
|
||||
(registered in `assets/blender/vehicles/CMakeLists.txt`) produces
|
||||
`assets/blender/vehicles/sedan-a.blend` + `sedan-taxi.blend`; the
|
||||
vehicle export stages into `resources/vehicles/`. When hand-modeling
|
||||
replacement sedans (or any new car), follow
|
||||
`assets/blender/vehicles/CAR_MODELING.md` — it documents the full
|
||||
contract (axis/origin rules, required parts and hinge pivots, cabin
|
||||
dimensions matching the seated character, scene JSON wiring, glass
|
||||
material naming, multi-look strategy):
|
||||
|
||||
- Bodies: `sedan-a-blue/red/grey.mesh`, `sedan-taxi.mesh` (yellow,
|
||||
roof sign). Roof ended up 1.60 m above ground, not 1.2 m — a seated
|
||||
character's head clips through a 1.2 m roof. The side skirts
|
||||
(sills + front/rear side panels) reach 9 cm off the ground so the
|
||||
sit pose's feet (which dangle below the floor pan) stay hidden from
|
||||
side views.
|
||||
- Hinge-local part meshes per body: `-door-fl/-fr/-rl/-rr`, `-hood`,
|
||||
`-trunk`; shared `sedan-wheel.mesh` + `sedan-steering-wheel.mesh`.
|
||||
Hinge pivots in chassis space: doors FL/RL (left = +X) at
|
||||
(0.855, -0.10, 0.55) / (0.855, -0.10, -0.46), FR/RR mirrored at
|
||||
x = -0.855; hood (0, 0.17, 0.75); trunk (0, 0.27, -1.35). Scene
|
||||
entities hang the parts as children at those pivots.
|
||||
- Wheels at (±0.8, -0.25, ±1.5), r = 0.33, w = 0.22.
|
||||
|
||||
Demo side: `demo_scene_vehicletest.json` (flat 200 x 200 floor —
|
||||
sedans outrun the 60 x 60 interior floor; mesh
|
||||
`DemoFloorPlaneVehicleTest` created in `createDemoResources()`) with
|
||||
`sedan1` (blue), and `sedan_taxi` (id 600, (4006, 10.8, 3965)) +
|
||||
`sedan_plain` (id 601, (4018, 10.8, 3965)) in
|
||||
`demo_scene_exterior.json`. Each sedan has a 4-seat
|
||||
`VehicleComponent::seats` list (driver/passenger at
|
||||
(±0.35, -0.22, 0.25), rl/rr at (±0.35, -0.22, -0.35), driver =
|
||||
front-left) and `seatCharacterScale` 0.85 — the stock chair-sit pose
|
||||
is proportioned for a chair (butt-to-feet 0.76 m), so the seated
|
||||
character is scaled down a little to fit the cabin (head 4 cm under
|
||||
the headliner, feet above the skirts); the scale is applied on enter
|
||||
and restored on exit by `VehicleControllerSystem`. Each of the 4
|
||||
door children carries an actuator with a `vehicle_seat_driver` /
|
||||
`vehicle_seat_passenger` / `vehicle_seat_rl` / `vehicle_seat_rr`
|
||||
action (generic actions in `lua-scripts/data2.lua` sending
|
||||
"vehicle_enter" with a `seat` param; empty `vehicle` param = nearest
|
||||
vehicle to the player).
|
||||
`VehicleComponent`: maxTorque 300, engineBrake 0.5, front steering
|
||||
(35 deg), rear driven, handbrake 300, suspension 0.1/0.35/1.8/0.7,
|
||||
cameraDistance 6.0, cameraHeight 1.6, steering wheel at
|
||||
(0.35, 0.45, 0.2) tilt 25 deg ratio 4.
|
||||
|
||||
Window glass: the exported `sedan-glass` is plain opaque (hides the
|
||||
cabin completely). `fixupSedanGlassMaterial()` (demo_main.cpp) makes
|
||||
`sedan-glass` alpha-blended and clones an opaque
|
||||
`sedan-glass-opaque`; `SedanGlassLodListener` swaps every vehicle's
|
||||
glass submeshes between the two by camera distance (10 m threshold) —
|
||||
Ogre 14 no longer applies `Material::setLodLevels` to entities, so a
|
||||
real LOD material never switches.
|
||||
|
||||
**sedan-v2 (hand-modeled base sedan, current reference).** The
|
||||
hand-modeled `assets/blender/vehicles/sedan-v2.blend` replaced the
|
||||
procedural sedan as the base plain sedan (`sedan1` in
|
||||
`demo_scene_vehicletest.json`, `sedan_plain` in the exterior scene).
|
||||
It goes through the new convention-based generator
|
||||
`assets/blender/scripts/gen_vehicle.py` (`VEHICLES_GEN_SRC` in
|
||||
`assets/blender/vehicles/CMakeLists.txt`): names parts by convention
|
||||
(`* Tire`, `* Door`, `* hood`, `* trunk`, `* Steering`, `seat_*`
|
||||
empties — exactly one with "driver" in the name), normalizes
|
||||
wheel/steering mesh axes, renames mesh data/materials to
|
||||
`sedan-v2-*`, unpacks the color atlas to `sedan-v2-atlas.png`, and
|
||||
emits `sedan-v2.vehicle.json` — a complete scene entity (box collider
|
||||
from the shell bounds, wheels with suspension attach lifted by
|
||||
L_rest = maxLength - g/(2*pi*freq)^2 so the car rides at the modeled
|
||||
stance, seats from the empties, door/hood/trunk children with
|
||||
`vehicle_seat_*` actuators). Seat pins are the character ROOT
|
||||
position — the `driving` state now plays the new `sitting-vehicle`
|
||||
animation (authored so the base position is the seat point; no
|
||||
offsets, `seatCharacterScale` 1.0). `sedan-v2-glass` was added to
|
||||
`kGlassMaterialNames` for the near/far glass swap. The full contract
|
||||
is `assets/blender/vehicles/CAR_MODELING.md`; the taxi variant of
|
||||
sedan-v2 is next. One engine fix this exposed:
|
||||
`AnimationTreeSystem` now disables animations that fall out of the
|
||||
evaluated set in a single frame (a large-deltaTime fade-out step used
|
||||
to leave the old animation enabled at its last weight forever — the
|
||||
stuck `idle` lifted the seated pose through the roof).
|
||||
|
||||
Tuning after the first drives: the stock suspension (1.8 Hz / 0.7
|
||||
damping) was pogo-stiff, so gen_vehicle.py now emits 1.3 Hz / 0.6.
|
||||
And stock Jolt tire friction (peak 1.2 combined with ground via
|
||||
sqrt) drove like wet ice at speed, so `VehicleComponent` grew
|
||||
`tireFrictionLongitudinal` / `tireFrictionLateral` curve multipliers
|
||||
(1.0 = stock; sedans use 1.3 / 3.0 — measured with
|
||||
`--test-vehicle-turn`: full lock at 15.5 m/s turns 44 deg at stock
|
||||
grip, 88 deg at 3.0; editable in the Vehicle editor, serialized).
|
||||
The high grip made hard turns trip the car toward two wheels, so
|
||||
`antiRollBarStiffness` (Jolt VehicleAntiRollBar per axle pair, sedans
|
||||
use 2000) was added the same way, and VehicleSystem now applies a
|
||||
righting torque past ~35 deg of tilt (plus roll rocking damping past
|
||||
~5 deg) so a car stuck on two wheels recovers on its own —
|
||||
`--test-vehicle-righting` rolls sedan1 50 deg and requires it back
|
||||
near-upright. Exiting also got two fixes: the E/F/I/Space pressed
|
||||
edges now ignore SDL key repeats (holding E no longer re-fires the
|
||||
door actuators), and after an exit the E key must be released once
|
||||
before `vehicle_enter` is accepted again
|
||||
(VehicleControllerSystem::m_eReleaseRequired), and the exit spot is
|
||||
seat-aware: the character steps out beside the door of the seat it
|
||||
actually occupied (own side at the seat's chassis-space row first,
|
||||
then the opposite side of the same row, then behind/ahead of the car)
|
||||
instead of always beside the chassis center. The drive test's
|
||||
passenger leg asserts the exit lands on the seat's side. Wheel
|
||||
visuals: Jolt reports every wheel with the same axle direction, so a
|
||||
single shared wheel mesh had its inner face outward on the right side —
|
||||
`VehicleSystem::postPhysicsUpdate` now flips right-side (chassis -X)
|
||||
wheel visuals 180 deg about the local forward axis; no mirrored mesh
|
||||
needed.
|
||||
Note the scene JSONs hold a merged COPY of the
|
||||
generated entity — after editing the blend, run the single
|
||||
`update_vehicles` build target (assets/blender/vehicles/CMakeLists.txt):
|
||||
it re-exports changed blends, re-stages the editScene resources and
|
||||
re-merges both demo-taxi scenes via
|
||||
`demos/demo-taxi/merge_vehicle.py` (preserves id + transform).
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json
|
||||
./demoTaxi --headless --test-vehicle-drive # exterior streaming terrain
|
||||
```
|
||||
|
||||
### Phase 2 — Road geometry & compliance improvements
|
||||
|
||||
Geometry-level fixes that both hand editing and the Phase 3 generator
|
||||
need (intersections already work visually; this makes them correct and
|
||||
terrain-friendly):
|
||||
|
||||
- Intersection inner geometry: the procedural lane template must not
|
||||
emit front/back geometry at multi-neighbor nodes (open junction
|
||||
area); front/back caps only for single-neighbor (dead-end) nodes.
|
||||
- Intersection overlap: smooth the wedge edges at intersections and
|
||||
reserve enough space for lanes + sidewalks so adjacent wedges stop
|
||||
overlapping.
|
||||
- Compliance workflow: make terrain compliance automatic instead of
|
||||
tedious — a single action (and an automatic pass after generation)
|
||||
that chains roads-to-terrain and terrain-to-roads until stable.
|
||||
- Headless: extend/reuse the `road_geometry_overlap_test` machinery —
|
||||
no self-intersections at 3-/4-way nodes, dead-end caps present,
|
||||
compliance pass converges and is idempotent.
|
||||
|
||||
### Phase 3 — Road-network generator
|
||||
|
||||
The tedious part today: every node/edge is hand-placed. Build a
|
||||
generator that emits a `RoadGraph` (region files when streaming):
|
||||
|
||||
- Grid layout first (block size, road width, jitter), explicit seed,
|
||||
fully deterministic. (Organic/radial layouts are deferred to
|
||||
Phase 11.) A network is bounded to the settlement square
|
||||
(max 1000 x 1000 m); generating another settlement is just another
|
||||
generator invocation at a different origin.
|
||||
- Terrain-aware placement: sample `getHeightAt`, reject or relocate
|
||||
segments over water or too-steep slopes, then run the Phase 2
|
||||
automatic compliance pass.
|
||||
- Editor entry point: a "Generate Road Network" panel on the terrain
|
||||
(parameters, seed, generate/regenerate, clear).
|
||||
- Headless: generate a network, assert node/edge counts, intersection
|
||||
valences, no self-intersections, region files written and reload
|
||||
identically; two settlements in one world do not interfere.
|
||||
|
||||
### Phase 4 — Procedural town generation
|
||||
|
||||
On top of the generated road network:
|
||||
|
||||
- Road-side lot components (new): lots derived from road-edge
|
||||
frontages (frontage/depth parameters), each placing a building from
|
||||
a CellGrid template or a plain prefab, with easy-to-set parameters.
|
||||
- District zoning presets (village / town / city-residential /
|
||||
city-center) driving building templates, heights, densities.
|
||||
- Building placement: orient to the street, snap to terrain, run
|
||||
terrain compliance per lot. Every building gets its (disabled for
|
||||
now) F1 scene-switch exterior door config, so Phase 8 only has to
|
||||
enable and wire them.
|
||||
- Street props: sidewalks everywhere (RoadConfig), roadside prefab
|
||||
slots for lamps/signs/trees where appropriate.
|
||||
- Spawnable points of interest: named curbs/stops that later phases
|
||||
use as passenger pickup/dropoff locations (and as pedestrian
|
||||
origins/destinations).
|
||||
- Streaming budget: all settlement content goes through the region
|
||||
stores (roads, spawners) or distance-based spawn/despawn so an
|
||||
unloaded settlement costs nothing; two settlements in one world
|
||||
stream independently.
|
||||
- Deterministic seed; generated entities stay editable scene/spawner
|
||||
data (region stores when streaming).
|
||||
- Headless: generate the demo town, assert building count per zone,
|
||||
all buildings on dry land above water level, every building carries
|
||||
its (disabled) scene-switch door config, navmesh rebuilds.
|
||||
|
||||
### Phase 5 — NPC vehicle traffic
|
||||
|
||||
- AI driver system (new, engine-level): a vehicle follows the road
|
||||
graph (route = path over edges), respects per-edge speed limits,
|
||||
basic collision avoidance (raycast/whisker braking for the vehicle
|
||||
ahead and for the player).
|
||||
- Intersection traffic controls: priority rules on intersection nodes
|
||||
(the visual intersections from Phase 2 get behaviour), AI drivers
|
||||
slow/stop/yield accordingly.
|
||||
- NPC driver characters seated in the vehicles (visual occupancy;
|
||||
reuse the seat-pinning approach of `VehicleControllerSystem`).
|
||||
- Traffic vehicles drawn from the Phase 1 sedan pool (type + color
|
||||
variants).
|
||||
- Traffic density control: spawn/despawn traffic vehicles by distance
|
||||
to the camera (spawner pattern), cap active count.
|
||||
- Headless: two AI cars on a generated loop complete N laps without
|
||||
collision/assert; intersection yield check; despawn/respawn
|
||||
stability.
|
||||
|
||||
### Phase 6 — Foot traffic NPCs
|
||||
|
||||
- Pedestrian system: spawners at houses/POIs, pedestrians walk navmesh
|
||||
routes along sidewalks between points of interest, avoid standing on
|
||||
the road, despawn by distance.
|
||||
- Realistic flows: a pedestrian leaves a house (door/curb POI), joins
|
||||
the sidewalk traffic, and either walks to a destination or enters
|
||||
their own parked vehicle and drives off as traffic (hand-off to the
|
||||
Phase 5 AI driver); the reverse flow parks and walks home. (Real
|
||||
walk-through-interior exits arrive with Phase 8; until then "leaving
|
||||
a house" starts at its door POI.)
|
||||
- Uses existing `NavMeshSystem` + `PathFollowingSystem` +
|
||||
`CharacterSpawnerSystem`; the new work is orchestration data
|
||||
(sidewalk waypoint extraction from the road graph) plus the flow
|
||||
scheduler.
|
||||
- Headless: pedestrians spawn, reach waypoints, never path through
|
||||
building interiors; house -> car hand-off completes; counts stay
|
||||
within caps while the camera moves.
|
||||
|
||||
### Phase 7 — CellGrid stair generation
|
||||
|
||||
Multi-storey CellGrid buildings (`height` > 1) generate stacked floors
|
||||
with no connection between them — interiors above ground level are
|
||||
unreachable. Add stairs to the CellGrid generator (engine-level, not
|
||||
taxi-specific):
|
||||
|
||||
- Stair cell: a cell flag (like the existing door/window flags) marking
|
||||
a stairwell; the generator cuts the matching opening in the
|
||||
floor/ceiling above and emits a stair mesh (steps sized within the
|
||||
Jolt character's and Recast's climb limits) plus walkable colliders.
|
||||
- Placement rules: stairwell position comes from the room layout
|
||||
(RoomLayoutSystem integration) or an explicit cell flag in
|
||||
hand-authored grids; a stairwell column stacks through all floors.
|
||||
- Navmesh: stairs rasterize as walkable (Recast step/slope limits), so
|
||||
NPCs can path between floors; verify doorway-area costs and door
|
||||
obstacles still behave.
|
||||
- Editor + serialization + Lua binding, per the component rules
|
||||
(serialized flag, Cell Grid editor toggle, `cellgrid_door_test`-style
|
||||
headless coverage).
|
||||
- Headless: multi-floor grid with a stairwell — character walks from
|
||||
ground floor to the top floor (physics), navmesh query finds a
|
||||
ground->top path; grids without stair flags are unchanged.
|
||||
|
||||
### Phase 8 — Enterable buildings (on-the-fly interiors)
|
||||
|
||||
- Interior scene generator (new, engine-level): given a building's
|
||||
exterior parameters (footprint, floors, template, seed), generate
|
||||
the matching interior scene deterministically — same building, same
|
||||
interior, every visit. Generated as a scene at switch time (or
|
||||
cached on first visit), not pre-authored per building. Multi-storey
|
||||
buildings get Phase 7 stairwells so every floor is traversable.
|
||||
- Enable the F1 scene-switch exterior doors prepared in Phase 4
|
||||
(doorConfigs `disabled` -> wired with `sceneSwitchPath`/target);
|
||||
entering switches to the generated interior, the interior's exit
|
||||
door switches back to the player's exterior position.
|
||||
- Interior content level for v1: walls/floor/ceiling/stairs/windows
|
||||
matching the exterior shell, no furniture gameplay.
|
||||
- Headless: for N generated buildings, enter -> interior loads and
|
||||
matches exterior footprint -> exit returns to the curb; two visits
|
||||
produce identical interiors (determinism check); a multi-storey
|
||||
building's upper floor is reachable by stairs.
|
||||
|
||||
### Phase 9 — Taxi passengers
|
||||
|
||||
- Passenger NPCs waiting at curbs/POIs; hail interaction (E prompt via
|
||||
ActuatorSystem or proximity hail while driving slowly).
|
||||
- NPC boarding/alighting: walk to the car door, play sit state, ride
|
||||
as passenger (the Phase 1 `VehicleComponent::seats` list tracks
|
||||
occupancy for the player; extend it to NPC occupants), walk away
|
||||
at dropoff.
|
||||
- Destination assignment: passenger names a POI/curb from the
|
||||
generated town (any settlement in the world); route distance
|
||||
computed over the road graph (drives the fare timer).
|
||||
- Headless: scripted passenger boards, rides, alights at destination;
|
||||
vehicle carries passenger through a scene-physics run.
|
||||
|
||||
### Phase 10 — Fare chain gameplay (the taxi game)
|
||||
|
||||
- `lua-scripts/taxi.lua` (pattern of `sokoban.lua`): fare state
|
||||
machine (awaiting fare -> passenger aboard -> delivered/failed),
|
||||
price doubling per completed fare (fare 1 = base price, fare 10 =
|
||||
base * 512), timer from road-graph route distance (e.g. distance /
|
||||
expected speed * slack factor), money counter.
|
||||
- Failure rule: missing the timer fails the whole mission — chain
|
||||
resets to fare 1, multiplier lost, HUD banner explains.
|
||||
- `GameHudSystem` display: current fare index n/10, fare price, time
|
||||
remaining, total money; banners on pickup/delivery/fail; win banner
|
||||
after fare 10.
|
||||
- Persistence in `GlobalStateStore` under `taxi.*` so the chain
|
||||
survives scene switches and save/load.
|
||||
- Headless: scripted 3-fare run asserting prices double, timer scales
|
||||
with distance, a timeout resets the chain to fare 1, completion
|
||||
events fire, HUD state updates.
|
||||
|
||||
### Phase 11 — Generator extras & vehicle variety (as needed)
|
||||
|
||||
Less critical items deferred from earlier phases; pull in when the
|
||||
demo needs them:
|
||||
|
||||
- Organic layout (looped/curved arterial + side streets) and radial
|
||||
village layout for the road generator.
|
||||
- Bulk-edit ops for road graphs (delete region, translate/rotate
|
||||
selection) — generation makes these necessary sooner or later.
|
||||
- Asymmetric roads (different lane/sidewalk setup per side) and road
|
||||
narrowing/widening along an edge.
|
||||
- Generation preview in the editor (ghost overlay before committing).
|
||||
- More vehicle types beyond sedans (the Phase 1 pipeline is already
|
||||
generic): vans, trucks, buses; service vehicles.
|
||||
|
||||
### Phase 12 — Demo assembly, polish, docs
|
||||
|
||||
- Assemble the full demo scene: at least one full settlement on the
|
||||
exterior terrain (optionally a second one to prove
|
||||
multi-settlement streaming), two player sedans, traffic +
|
||||
pedestrians + passengers live, interior scene kept as the
|
||||
"home"/menu-adjacent space.
|
||||
- Balance pass: speeds, timers, fare slack, traffic density.
|
||||
- Performance pass: streaming radii, navmesh tile budget, active NPC
|
||||
caps — measure FPS with both settlements placed.
|
||||
- Docs: update `AGENTS.md` (both levels), `GameFeatures202609.md`
|
||||
(F13+ entries), demo README section; full headless test battery
|
||||
wired into the demo like demo-sokoban's.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
{
|
||||
"actionDatabase": {
|
||||
"actions": [
|
||||
{
|
||||
"behaviorTree": {
|
||||
"children": [
|
||||
{
|
||||
"name": "vehicleEnter",
|
||||
"params": "seat=driver",
|
||||
"type": "luaTask"
|
||||
}
|
||||
],
|
||||
"type": "sequence"
|
||||
},
|
||||
"cost": 1,
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"name": "vehicle_seat_driver",
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"behaviorTree": {
|
||||
"children": [
|
||||
{
|
||||
"name": "vehicleEnter",
|
||||
"params": "seat=passenger",
|
||||
"type": "luaTask"
|
||||
}
|
||||
],
|
||||
"type": "sequence"
|
||||
},
|
||||
"cost": 1,
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"name": "vehicle_seat_passenger",
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"behaviorTree": {
|
||||
"children": [
|
||||
{
|
||||
"name": "vehicleEnter",
|
||||
"params": "seat=rl",
|
||||
"type": "luaTask"
|
||||
}
|
||||
],
|
||||
"type": "sequence"
|
||||
},
|
||||
"cost": 1,
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"name": "vehicle_seat_rl",
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"behaviorTree": {
|
||||
"children": [
|
||||
{
|
||||
"name": "vehicleEnter",
|
||||
"params": "seat=rr",
|
||||
"type": "luaTask"
|
||||
}
|
||||
],
|
||||
"type": "sequence"
|
||||
},
|
||||
"cost": 1,
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"name": "vehicle_seat_rr",
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"bitNames": [
|
||||
{
|
||||
"index": 1,
|
||||
"name": "hungry"
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"name": "thirsty"
|
||||
}
|
||||
],
|
||||
"goals": []
|
||||
},
|
||||
"bookmarks": [],
|
||||
"entities": [
|
||||
{
|
||||
"children": [],
|
||||
"id": 4294967794,
|
||||
"light": {
|
||||
"castShadows": false,
|
||||
"constantAttenuation": 1.0,
|
||||
"diffuseColor": {
|
||||
"a": 1.0,
|
||||
"b": 1.0,
|
||||
"g": 1.0,
|
||||
"r": 1.0
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.30000001192092896,
|
||||
"y": -1.0,
|
||||
"z": 0.20000000298023224
|
||||
},
|
||||
"intensity": 1.0,
|
||||
"lightType": "directional",
|
||||
"linearAttenuation": 0.0,
|
||||
"quadraticAttenuation": 0.0,
|
||||
"range": 100.0,
|
||||
"specularColor": {
|
||||
"a": 1.0,
|
||||
"b": 0.5,
|
||||
"g": 0.5,
|
||||
"r": 0.5
|
||||
},
|
||||
"spotlightFalloff": 1.0,
|
||||
"spotlightInnerAngle": 30.0,
|
||||
"spotlightOuterAngle": 45.0
|
||||
},
|
||||
"name": {
|
||||
"name": "demo_light"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 10.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
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"collider": {
|
||||
"halfHeight": 1.0,
|
||||
"meshName": "",
|
||||
"offset": {
|
||||
"x": 0.0,
|
||||
"y": -0.10000000149011612,
|
||||
"z": 0.0
|
||||
},
|
||||
"parameters": {
|
||||
"x": 100.0,
|
||||
"y": 0.10000000149011612,
|
||||
"z": 100.0
|
||||
},
|
||||
"radius": 0.5,
|
||||
"rotationOffset": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"shapeType": "box"
|
||||
},
|
||||
"id": 4294967792,
|
||||
"name": {
|
||||
"name": "demo_floor"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoFloorPlaneVehicleTest",
|
||||
"visible": true
|
||||
},
|
||||
"rigidBody": {
|
||||
"bodyType": "static",
|
||||
"enabled": true,
|
||||
"friction": 0.800000011920929,
|
||||
"isSensor": false,
|
||||
"mass": 1.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
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterSpawner": {
|
||||
"despawnDistance": 200.0,
|
||||
"registryId": 2,
|
||||
"spawnDistance": 100.0
|
||||
},
|
||||
"children": [],
|
||||
"id": 4294967790,
|
||||
"name": {
|
||||
"name": "s1"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.10288965702056885,
|
||||
"z": -6.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"id": 4294967789,
|
||||
"name": {
|
||||
"name": "player"
|
||||
},
|
||||
"playerController": {
|
||||
"actuatorColor": [
|
||||
0.0,
|
||||
0.4000000059604645,
|
||||
1.0
|
||||
],
|
||||
"actuatorCooldown": 1.5,
|
||||
"actuatorDistance": 25.0,
|
||||
"actuatorLabelFontSize": 12.0,
|
||||
"cameraMode": 0,
|
||||
"distantCircleRadius": 8.0,
|
||||
"fpsBoneName": "Head",
|
||||
"idleState": "idle",
|
||||
"locomotionStateMachine": "locomotion",
|
||||
"mouseSensitivity": 0.20000000298023224,
|
||||
"nearCircleRadius": 14.0,
|
||||
"runState": "running",
|
||||
"swimFastState": "swimming-fast",
|
||||
"swimIdleState": "swim-idle",
|
||||
"swimState": "swimming",
|
||||
"targetCharacterName": "s1",
|
||||
"tpsDistance": 3.0,
|
||||
"tpsHeight": 2.0,
|
||||
"walkState": "walking"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"name": {
|
||||
"name": "sedan1_door_rl"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2-door-rl.mesh",
|
||||
"visible": true
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.9192,
|
||||
"y": -0.2869,
|
||||
"z": -0.132
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"actuator": {
|
||||
"actionNames": [
|
||||
"vehicle_seat_rl"
|
||||
],
|
||||
"height": 2.0,
|
||||
"radius": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"name": {
|
||||
"name": "sedan1_door_rr"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2-door-rr.mesh",
|
||||
"visible": true
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": -0.9192,
|
||||
"y": -0.2869,
|
||||
"z": -0.132
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"actuator": {
|
||||
"actionNames": [
|
||||
"vehicle_seat_rr"
|
||||
],
|
||||
"height": 2.0,
|
||||
"radius": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"name": {
|
||||
"name": "sedan1_door_fl"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2-door-fl.mesh",
|
||||
"visible": true
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.9192,
|
||||
"y": -0.2868,
|
||||
"z": 0.801
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"actuator": {
|
||||
"actionNames": [
|
||||
"vehicle_seat_driver"
|
||||
],
|
||||
"height": 2.0,
|
||||
"radius": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"name": {
|
||||
"name": "sedan1_door_fr"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2-door-fr.mesh",
|
||||
"visible": true
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": -0.9192,
|
||||
"y": -0.2868,
|
||||
"z": 0.801
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"actuator": {
|
||||
"actionNames": [
|
||||
"vehicle_seat_passenger"
|
||||
],
|
||||
"height": 2.0,
|
||||
"radius": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"name": {
|
||||
"name": "sedan1_hood"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2-hood.mesh",
|
||||
"visible": true
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": -0.0,
|
||||
"y": 0.266,
|
||||
"z": 0.9992
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"name": {
|
||||
"name": "sedan1_trunk"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2-trunk.mesh",
|
||||
"visible": true
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.2744,
|
||||
"z": -1.446
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"collider": {
|
||||
"halfHeight": 1.0,
|
||||
"meshName": "",
|
||||
"offset": {
|
||||
"x": 0.0,
|
||||
"y": 0.1535,
|
||||
"z": -0.0629
|
||||
},
|
||||
"parameters": {
|
||||
"x": 1.0107,
|
||||
"y": 0.6126,
|
||||
"z": 2.4834
|
||||
},
|
||||
"radius": 0.5,
|
||||
"rotationOffset": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"shapeType": "box"
|
||||
},
|
||||
"name": {
|
||||
"name": "sedan1"
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "sedan-v2.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": 1.5,
|
||||
"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": {
|
||||
"cameraDistance": 6.0,
|
||||
"cameraHeight": 1.6,
|
||||
"engineBrake": 0.5,
|
||||
"maxPitchRollAngleDeg": 60.0,
|
||||
"maxTorque": 300.0,
|
||||
"rearSteer": false,
|
||||
"seatOffset": {
|
||||
"x": 0.48,
|
||||
"y": -0.28,
|
||||
"z": 0.2012
|
||||
},
|
||||
"steeringRatio": 4.0,
|
||||
"steeringWheelMeshName": "sedan-v2-steering-wheel.mesh",
|
||||
"steeringWheelOffset": {
|
||||
"x": 0.4915,
|
||||
"y": 0.0807,
|
||||
"z": 0.601
|
||||
},
|
||||
"steeringWheelTiltDeg": 25.0,
|
||||
"wheelMeshName": "sedan-v2-wheel.mesh",
|
||||
"wheels": [
|
||||
{
|
||||
"driven": false,
|
||||
"maxHandBrakeTorque": 0.0,
|
||||
"maxSteerAngleDeg": 35.0,
|
||||
"position": {
|
||||
"x": 0.7805,
|
||||
"y": -0.2052,
|
||||
"z": 1.4806
|
||||
},
|
||||
"radius": 0.3918,
|
||||
"suspensionDamping": 0.6,
|
||||
"suspensionFrequency": 1.3,
|
||||
"suspensionMaxLength": 0.35,
|
||||
"suspensionMinLength": 0.1,
|
||||
"width": 0.3145
|
||||
},
|
||||
{
|
||||
"driven": false,
|
||||
"maxHandBrakeTorque": 0.0,
|
||||
"maxSteerAngleDeg": 35.0,
|
||||
"position": {
|
||||
"x": -0.7805,
|
||||
"y": -0.2052,
|
||||
"z": 1.4806
|
||||
},
|
||||
"radius": 0.3918,
|
||||
"suspensionDamping": 0.6,
|
||||
"suspensionFrequency": 1.3,
|
||||
"suspensionMaxLength": 0.35,
|
||||
"suspensionMinLength": 0.1,
|
||||
"width": 0.3145
|
||||
},
|
||||
{
|
||||
"driven": true,
|
||||
"maxHandBrakeTorque": 300.0,
|
||||
"maxSteerAngleDeg": 0.0,
|
||||
"position": {
|
||||
"x": 0.7805,
|
||||
"y": -0.2052,
|
||||
"z": -1.2806
|
||||
},
|
||||
"radius": 0.3918,
|
||||
"suspensionDamping": 0.6,
|
||||
"suspensionFrequency": 1.3,
|
||||
"suspensionMaxLength": 0.35,
|
||||
"suspensionMinLength": 0.1,
|
||||
"width": 0.3145
|
||||
},
|
||||
{
|
||||
"driven": true,
|
||||
"maxHandBrakeTorque": 300.0,
|
||||
"maxSteerAngleDeg": 0.0,
|
||||
"position": {
|
||||
"x": -0.7805,
|
||||
"y": -0.2052,
|
||||
"z": -1.2806
|
||||
},
|
||||
"radius": 0.3918,
|
||||
"suspensionDamping": 0.6,
|
||||
"suspensionFrequency": 1.3,
|
||||
"suspensionMaxLength": 0.35,
|
||||
"suspensionMinLength": 0.1,
|
||||
"width": 0.3145
|
||||
}
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "driver",
|
||||
"offset": {
|
||||
"x": 0.48,
|
||||
"y": -0.28,
|
||||
"z": 0.2012
|
||||
},
|
||||
"driver": true
|
||||
},
|
||||
{
|
||||
"name": "passenger",
|
||||
"offset": {
|
||||
"x": -0.48,
|
||||
"y": -0.28,
|
||||
"z": 0.2012
|
||||
},
|
||||
"driver": false
|
||||
},
|
||||
{
|
||||
"name": "rl",
|
||||
"offset": {
|
||||
"x": 0.48,
|
||||
"y": -0.28,
|
||||
"z": -0.6032
|
||||
},
|
||||
"driver": false
|
||||
},
|
||||
{
|
||||
"name": "rr",
|
||||
"offset": {
|
||||
"x": -0.48,
|
||||
"y": -0.28,
|
||||
"z": -0.6032
|
||||
},
|
||||
"driver": false
|
||||
}
|
||||
],
|
||||
"seatCharacterScale": 1.0,
|
||||
"tireFrictionLongitudinal": 1.3,
|
||||
"tireFrictionLateral": 3.0,
|
||||
"antiRollBarStiffness": 2000.0
|
||||
},
|
||||
"id": 500
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate heightmap.bin for the demo-interior-exterior-dynamics terrain.
|
||||
|
||||
NOTE: the exterior scene's terrain (terrainId 4242424300000001) runs in
|
||||
streaming mode, where base heights come from the terrain's baseNoise
|
||||
(FastNoiseLite) and this file is NOT sampled — it is only staged next to
|
||||
the binary (heightmaps/4242424300000001/heightmap.bin) so the loader
|
||||
finds it. This script documents the historical non-streaming layout and
|
||||
is kept as the generator of record for that staged file.
|
||||
|
||||
Legacy description (non-streaming mode):
|
||||
|
||||
The exterior scene's terrain entity (terrainId 4242424300000001,
|
||||
non-streaming, worldSize 500 per page) loads its base heights from
|
||||
heightmaps/4242424300000001/heightmap.bin via
|
||||
TerrainSystem::loadSceneHeightmap. File format: uint32 resolution,
|
||||
then res*res little-endian float32 heights.
|
||||
|
||||
Height profile: a flat disc at y=0 around the origin (the playable area
|
||||
with the building shell and the scene-switch arrival point), sloping
|
||||
down to -5 in a ring so the water plane (waterSurfaceY -0.5) is visible
|
||||
as a lake around the centre.
|
||||
|
||||
Sampling math (see TerrainSystem::sampleBaseLocked / fillPageHeightData
|
||||
and the visualToPhysicalX/Z helpers): in non-streaming mode the system
|
||||
loads a fixed 3x3 page grid, so the heightmap covers the physical world
|
||||
rect [-worldSize, 2*worldSize] on both axes and texel (ix, iz) holds
|
||||
the height at physical coordinate (-ws + i * 3*ws / res). Physical
|
||||
space maps to visual (scene) space by a half-page shift on X and a
|
||||
per-page Z mirror; this script inverts that mapping so every texel gets
|
||||
the height of the *visual* position it will be rendered at, then
|
||||
verifies the round trip by re-sampling the generated grid the same
|
||||
bilinear way the engine does.
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
|
||||
RES = 256 # must match "heightmapSize" in the scene JSON
|
||||
WORLD_SIZE = 500.0 # per-page world size ("worldSize" in the scene JSON)
|
||||
FLAT_RADIUS = 60.0 # flat-0 disc radius around the visual origin
|
||||
EDGE_RADIUS = 160.0 # slope reaches full depth here
|
||||
DEPTH = -5.0 # lake floor height (below waterSurfaceY -0.5)
|
||||
|
||||
|
||||
def profile(r):
|
||||
"""Visual height at distance r from the origin."""
|
||||
if r <= FLAT_RADIUS:
|
||||
return 0.0
|
||||
if r >= EDGE_RADIUS:
|
||||
return DEPTH
|
||||
t = (r - FLAT_RADIUS) / (EDGE_RADIUS - FLAT_RADIUS)
|
||||
t = t * t * (3.0 - 2.0 * t) # smoothstep
|
||||
return DEPTH * t
|
||||
|
||||
|
||||
def physical_to_visual(px, pz):
|
||||
"""Invert TerrainSystem::visualToPhysicalX/Z (terrain origin 0,0,0)."""
|
||||
vx = px - WORLD_SIZE * 0.5
|
||||
page = math.floor(pz / WORLD_SIZE)
|
||||
vz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - pz
|
||||
return vx, vz
|
||||
|
||||
|
||||
def visual_to_physical(vx, vz):
|
||||
"""TerrainSystem::visualToPhysicalX/Z with a zero terrain origin."""
|
||||
px = vx + WORLD_SIZE * 0.5
|
||||
page = math.floor((vz + WORLD_SIZE * 0.5) / WORLD_SIZE)
|
||||
pz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - vz
|
||||
return px, pz
|
||||
|
||||
|
||||
def main():
|
||||
out_path = sys.argv[1] if len(sys.argv) > 1 else "heightmap.bin"
|
||||
span = 3.0 * WORLD_SIZE
|
||||
origin = -WORLD_SIZE
|
||||
|
||||
grid = []
|
||||
for iz in range(RES):
|
||||
row = []
|
||||
pz = origin + iz * span / RES
|
||||
for ix in range(RES):
|
||||
px = origin + ix * span / RES
|
||||
vx, vz = physical_to_visual(px, pz)
|
||||
row.append(profile(math.hypot(vx, vz)))
|
||||
grid.append(row)
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(struct.pack("<I", RES))
|
||||
for row in grid:
|
||||
f.write(struct.pack("<%df" % RES, *row))
|
||||
|
||||
# Verify: re-sample the grid exactly like TerrainSystem::sampleBaseLocked
|
||||
# (bilinear, physical coords) at a few visual positions.
|
||||
def sample(vx, vz):
|
||||
px, pz = visual_to_physical(vx, vz)
|
||||
fx = (px - origin) / span * RES
|
||||
fz = (pz - origin) / span * RES
|
||||
x0 = max(0, min(int(math.floor(fx)), RES - 1))
|
||||
z0 = max(0, min(int(math.floor(fz)), RES - 1))
|
||||
x1 = max(0, min(x0 + 1, RES - 1))
|
||||
z1 = max(0, min(z0 + 1, RES - 1))
|
||||
tx = fx - int(math.floor(fx))
|
||||
tz = fz - int(math.floor(fz))
|
||||
h00 = grid[z0][x0]
|
||||
h10 = grid[z0][x1]
|
||||
h01 = grid[z1][x0]
|
||||
h11 = grid[z1][x1]
|
||||
return ((1 - tx) * (1 - tz) * h00 + tx * (1 - tz) * h10 +
|
||||
(1 - tx) * tz * h01 + tx * tz * h11)
|
||||
|
||||
checks = [
|
||||
("arrival (0,24)", 0.0, 24.0),
|
||||
("origin (0,0)", 0.0, 0.0),
|
||||
("building corner (4,30)", 4.0, 30.0),
|
||||
("flat edge (55,0)", 55.0, 0.0),
|
||||
("mid slope (110,0)", 110.0, 0.0),
|
||||
("lake (200,0)", 200.0, 0.0),
|
||||
("lake (-200,200)", -200.0, 200.0),
|
||||
]
|
||||
ok = True
|
||||
for label, vx, vz in checks:
|
||||
h = sample(vx, vz)
|
||||
print("%-24s visual (%7.1f,%7.1f) -> height %8.3f"
|
||||
% (label, vx, vz, h))
|
||||
for vx, vz in [(0.0, 24.0), (0.0, 0.0), (4.0, 30.0), (55.0, 0.0)]:
|
||||
if abs(sample(vx, vz)) > 1e-4:
|
||||
print("ERROR: playable area not flat at", vx, vz)
|
||||
ok = False
|
||||
for vx, vz in [(200.0, 0.0), (-200.0, 200.0)]:
|
||||
if sample(vx, vz) > -4.9:
|
||||
print("ERROR: lake area not deep enough at", vx, vz)
|
||||
ok = False
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
print("heightmap written to %s (%dx%d), all checks passed"
|
||||
% (out_path, RES, RES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,131 @@
|
||||
[Window][Debug##Default]
|
||||
Pos=60,60
|
||||
Size=400,400
|
||||
LastUsed=20260908
|
||||
|
||||
[Window][Entity Hierarchy]
|
||||
Pos=0,0
|
||||
Size=300,1043
|
||||
LastUsed=20260908
|
||||
|
||||
[Window][Property Editor]
|
||||
Pos=1570,0
|
||||
Size=350,1043
|
||||
LastUsed=20260908
|
||||
|
||||
[Window][Load Scene]
|
||||
Pos=710,321
|
||||
Size=500,400
|
||||
LastUsed=20260908
|
||||
|
||||
[Window][Save Scene]
|
||||
Pos=710,321
|
||||
Size=500,400
|
||||
LastUsed=20260908
|
||||
|
||||
[Window][StartupMenu]
|
||||
Pos=0,0
|
||||
Size=1920,1043
|
||||
|
||||
[Window][Prefab Browser]
|
||||
Pos=300,300
|
||||
Size=250,400
|
||||
|
||||
[Window][Create Prefab]
|
||||
Pos=655,364
|
||||
Size=305,77
|
||||
|
||||
[Window][3D Cursor]
|
||||
Pos=300,100
|
||||
Size=280,350
|
||||
|
||||
[Window][Mesh Browser]
|
||||
Pos=533,240
|
||||
Size=416,406
|
||||
|
||||
[Window][Action Database (Singleton)]
|
||||
Pos=326,33
|
||||
Size=404,694
|
||||
|
||||
[Window][Dialogue Settings]
|
||||
Pos=300,100
|
||||
Size=400,500
|
||||
|
||||
[Window][DialogueBox]
|
||||
Pos=0,681
|
||||
Size=1682,227
|
||||
|
||||
[Window][Character Class Database]
|
||||
Pos=303,109
|
||||
Size=600,600
|
||||
|
||||
[Window][Character Registry]
|
||||
Pos=476,258
|
||||
Size=903,429
|
||||
|
||||
[Window][Delete Prefab]
|
||||
Pos=797,467
|
||||
Size=325,109
|
||||
|
||||
[Window][PauseMenu]
|
||||
Pos=0,0
|
||||
Size=2490,1536
|
||||
LastUsed=20260905
|
||||
|
||||
[Window][Item Registry]
|
||||
Pos=60,60
|
||||
Size=600,500
|
||||
|
||||
[Window][Inventory Dialog Config]
|
||||
Pos=300,100
|
||||
Size=350,200
|
||||
|
||||
[Window][Character Sheet]
|
||||
Pos=0,0
|
||||
Size=1920,1043
|
||||
|
||||
[Window][Load Game]
|
||||
Pos=710,321
|
||||
Size=500,400
|
||||
|
||||
[Window][Save Game]
|
||||
Pos=710,321
|
||||
Size=500,400
|
||||
|
||||
[Window][Animation Tree Registry]
|
||||
Pos=60,60
|
||||
Size=900,600
|
||||
|
||||
[Window][Confirm Blend Map Resolution Change]
|
||||
Pos=815,477
|
||||
Size=290,105
|
||||
|
||||
[Window][Confirm Heightmap Resolution Change]
|
||||
Pos=815,477
|
||||
Size=290,105
|
||||
|
||||
[Window][Road Graph Invalid]
|
||||
Pos=892,217
|
||||
Size=136,127
|
||||
LastUsed=20260731
|
||||
|
||||
[Window][Wedge Geometry Debug]
|
||||
Pos=10,10
|
||||
Size=420,520
|
||||
LastUsed=20260814
|
||||
|
||||
[Window][Road Graph Valid]
|
||||
Pos=882,486
|
||||
Size=156,71
|
||||
LastUsed=20260821
|
||||
|
||||
[Window][Switch Scene]
|
||||
Pos=710,321
|
||||
Size=500,400
|
||||
LastUsed=20260906
|
||||
|
||||
[Window][Open Project]
|
||||
Pos=300,100
|
||||
Size=639,377
|
||||
LastUsed=20260908
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"fontPath": "Jupiteroid-Bold.ttf",
|
||||
"fontSize": 16.0
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Merge a generated vehicle entity (<name>.vehicle.json, produced by
|
||||
# assets/blender/scripts/gen_vehicle.py) into a demo scene JSON,
|
||||
# replacing the entity with the given name. The scene entity keeps
|
||||
# its id, name and transform (position/rotation/scale); everything
|
||||
# else (collider, vehicle, renderable, children) is replaced by the
|
||||
# generated fragment, with child entity names prefixed accordingly.
|
||||
#
|
||||
# This is how blend tweaks (seat empties, suspension, wheels, ...)
|
||||
# propagate into the demo scenes after re-running import_vehicles:
|
||||
#
|
||||
# cmake --build build-vscode --target import_vehicles -j4
|
||||
# python3 merge_vehicle.py demo_scene_vehicletest.json \
|
||||
# ../../../../../../resources/vehicles/sedan-v2.vehicle.json sedan1
|
||||
#
|
||||
# (paths relative to the build demo dir; the build scene JSONs are
|
||||
# symlinks to the source dir, so merging there updates the source).
|
||||
#
|
||||
# Usage:
|
||||
# merge_vehicle.py <scene.json> <vehicle.json> <entityName>
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit("usage: merge_vehicle.py <scene.json> <vehicle.json> "
|
||||
"<entityName>")
|
||||
scene_path, vehicle_path, entity_name = sys.argv[1:4]
|
||||
|
||||
with open(scene_path) as f:
|
||||
scene = json.load(f)
|
||||
with open(vehicle_path) as f:
|
||||
generated = json.load(f)["entities"][0]
|
||||
gen_base = generated["name"]["name"]
|
||||
|
||||
target = None
|
||||
for e in scene["entities"]:
|
||||
if e.get("name", {}).get("name") == entity_name:
|
||||
target = e
|
||||
break
|
||||
if target is None:
|
||||
sys.exit("merge_vehicle: entity '%s' not found in %s" %
|
||||
(entity_name, scene_path))
|
||||
|
||||
# Rename the generated entity and its children to the target name.
|
||||
def rename(node):
|
||||
if "name" in node:
|
||||
node["name"]["name"] = node["name"]["name"].replace(
|
||||
gen_base, entity_name, 1)
|
||||
for c in node.get("children", []):
|
||||
rename(c)
|
||||
|
||||
rename(generated)
|
||||
|
||||
generated["id"] = target["id"]
|
||||
generated["transform"] = target["transform"]
|
||||
target.clear()
|
||||
target.update(generated)
|
||||
|
||||
with open(scene_path, "w") as f:
|
||||
json.dump(scene, f, indent=2)
|
||||
f.write("\n")
|
||||
print("merge_vehicle: replaced '%s' in %s from %s" %
|
||||
(entity_name, scene_path, vehicle_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
/* Generated by CMake from project.json - do not edit.
|
||||
* Embeds the project parameters into the release binary so it runs its
|
||||
* project with no --project flag (see GameFeatures202609.md, F8). */
|
||||
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
|
||||
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
|
||||
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appName": "demo-taxi",
|
||||
"startScene": "demo_scene_interior.json",
|
||||
"gameMode": true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Stage everything demoTaxi needs into its own directory so it
|
||||
# runs standalone from
|
||||
# <build>/src/features/editScene/demos/demo-taxi.
|
||||
#
|
||||
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
|
||||
#
|
||||
# Small demo-owned files are COPIED, except the demo scenes, which are
|
||||
# SYMLINKED to the source tree so scene edits are visible to the demo
|
||||
# without a rebuild (the demo never saves scenes, so nothing writes through
|
||||
# the links); the big pre-staged runtime directories
|
||||
# are SYMLINKED from the editScene binary directory (Ogre FileSystem
|
||||
# locations follow symlinks, and copying would duplicate hundreds of MB on
|
||||
# every build). The symlink targets are populated by the editSceneEditor
|
||||
# staging, so editSceneEditor must have been built (and run its POST_BUILD
|
||||
# staging) at least once.
|
||||
|
||||
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
|
||||
file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}")
|
||||
|
||||
foreach(f demo_scene_interior.json demo_scene_exterior.json
|
||||
demo_scene_vehicletest.json)
|
||||
set(link "${DEMO_DIR}/${f}")
|
||||
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
|
||||
file(REMOVE "${link}")
|
||||
endif()
|
||||
file(CREATE_LINK "${SRC_DIR}/${f}" "${link}" SYMBOLIC)
|
||||
endforeach()
|
||||
|
||||
# Terrain heightmap: staged at configure time by a configure_file COPYONLY
|
||||
# in CMakeLists.txt (to heightmaps/4242424300000001/), which re-copies it
|
||||
# automatically when the source file changes. Not symlinked: the fixup
|
||||
# layer and save path write next to it.
|
||||
|
||||
# Character prefab for the spawner's registry entry (registryId 2). It is
|
||||
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
|
||||
# not part of the source tree, so copy it from the editScene binary
|
||||
# directory when present.
|
||||
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
|
||||
foreach(f char_2.json)
|
||||
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
|
||||
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
|
||||
DESTINATION "${DEMO_DIR}/prefabs")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
foreach(dir resources characters lua-scripts)
|
||||
set(link "${DEMO_DIR}/${dir}")
|
||||
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
|
||||
file(REMOVE_RECURSE "${link}")
|
||||
endif()
|
||||
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
|
||||
endforeach()
|
||||
|
||||
# Runtime config JSONs loaded at startup relative to the CWD (game mode
|
||||
# reads startup_menu.json, the registries read the rest — the demo needs
|
||||
# character_registry.json for the spawner's registryId 2). They
|
||||
# only exist after the editor/game has run once; copy them when present so
|
||||
# the demo behaves the same as when run from the editor binary directory.
|
||||
foreach(f startup_menu.json character_registry.json character_class.json
|
||||
items.json item_state.json inventory_config.json)
|
||||
if(EXISTS "${EDITSCENE_BIN}/${f}")
|
||||
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
# animation_tree.json is demo-owned (source tree, versioned with the demo;
|
||||
# it carries the "driving" locomotion state used by the sedan seats).
|
||||
# COPIED, not symlinked: AnimationTreeRegistry auto-saves on runtime tree
|
||||
# migrations and must not write through into the source tree.
|
||||
file(COPY "${SRC_DIR}/animation_tree.json" DESTINATION "${DEMO_DIR}")
|
||||
@@ -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