diff --git a/AGENTS.md b/AGENTS.md index fb59eae..ac43d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//roads/_.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//`: painted blend maps, aux maps, fixup chunks, plus the streamed content stores `spawners/_.json` (prefab spawner defs, see below) and `roads/_.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//spawners/_.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 + `.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 diff --git a/assets/blender/characters/edited-normal-female.blend b/assets/blender/characters/edited-normal-female.blend index 0646652..6b0b141 100644 --- a/assets/blender/characters/edited-normal-female.blend +++ b/assets/blender/characters/edited-normal-female.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:530457a55e3a1be1100e7ad2349c6c7268356074371b05b7a428d413e61ce849 -size 19308281 +oid sha256:b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51 +size 19341498 diff --git a/assets/blender/characters/edited-normal-male.blend b/assets/blender/characters/edited-normal-male.blend index adc543d..ae29366 100644 --- a/assets/blender/characters/edited-normal-male.blend +++ b/assets/blender/characters/edited-normal-male.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e555a3e665b49e908aa6eaf3df01dcd3f89798f9d8a11ec0498614266bd0e04 -size 14749883 +oid sha256:4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff +size 14884512 diff --git a/assets/blender/scripts/gen_sokoban_assets.py b/assets/blender/scripts/gen_sokoban_assets.py new file mode 100644 index 0000000..db06aec --- /dev/null +++ b/assets/blender/scripts/gen_sokoban_assets.py @@ -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() diff --git a/assets/blender/scripts/gen_taxi_assets.py b/assets/blender/scripts/gen_taxi_assets.py new file mode 100644 index 0000000..981078f --- /dev/null +++ b/assets/blender/scripts/gen_taxi_assets.py @@ -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: + -door-fl/-fr origin at the front-bottom hinge edge, + panel spans backward (+Y) and up (+Z) + -door-rl/-rr same, hinge at the door's front-bottom edge + -hood origin at the cowl (rear edge of the hood) + -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 + + -door-fl/-fr/-rl/-rr, -hood, + -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() diff --git a/assets/blender/scripts/gen_vehicle.py b/assets/blender/scripts/gen_vehicle.py new file mode 100644 index 0000000..063f575 --- /dev/null +++ b/assets/blender/scripts/gen_vehicle.py @@ -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 (.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 .blend -b -Y -P gen_vehicle.py -- .glb .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 - 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 (-) 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() diff --git a/assets/blender/vehicles/CMakeLists.txt b/assets/blender/vehicles/CMakeLists.txt index de11c1e..9321ab1 100644 --- a/assets/blender/vehicles/CMakeLists.txt +++ b/assets/blender/vehicles/CMakeLists.txt @@ -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 (.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 --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) diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index c10d003..6e796e5 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -264,8 +264,49 @@ cd demos/demo-taxi ./demoTaxi --headless --exit-after-first-frame # ...or headless end-to-end check of the inherited scene contract: ./demoTaxi --headless --test-switch -# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door, -# Escape = pause menu (frees the cursor). +# ...or headless sedan drive test (spawn, enter, drive, steer, exit — +# plus a passenger-seat leg: no PlayerControlledComponent, W does not +# drive, and the exit must land on the occupied seat's side of the car) +# on the flat vehicle test scene: +./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json +# ...same test on the exterior scene (streaming terrain, sedan_taxi): +./demoTaxi --headless --test-vehicle-drive +# ...or the quantitative handling check (W to ~15 m/s, then full lock +# for 150 frames; PASS needs >= 45 deg of yaw - catches the +# "drives like it's on ice" regression): +./demoTaxi --headless --test-vehicle-turn +# NOTE: always pass --headless to the tests — without it the frame +# delta is wall time (a few FPS under xvfb), so the fixed-length +# W-hold drives the sedan clear off the test floor and the exit-spot +# raycast legitimately fails. +# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door / +# sit in the seat of the sedan door you used (driver = front-left, +# only the driver seat drives), hold E = exit the vehicle, Space = +# handbrake while driving, Escape = pause menu (frees the cursor). +# +# Phase 1 sedans: sedan1/sedan_plain are the hand-modeled +# sedan-v2.blend via the convention-based generator +# assets/blender/scripts/gen_vehicle.py (VEHICLES_GEN_SRC in +# assets/blender/vehicles/CMakeLists.txt) - seat_* empties mark the +# character root pins, the driving state plays the sitting-vehicle +# animation (no offsets, seatCharacterScale 1.0); see +# assets/blender/vehicles/CAR_MODELING.md. sedan_taxi is still the +# older procedural gen_taxi_assets.py car (sitting pose at +# seatCharacterScale 0.85) pending a v2-based taxi model. The exterior +# scene parks sedan_taxi and sedan_plain near the spawn point; each +# door child has an actuator with a vehicle_seat_* action (driver, +# passenger, rl, rr) that seats the player at that door's seat via the +# vehicle_enter event (vehicle resolved by proximity). +# Visual tuning aids (work in any scene): +./demoTaxi demo_scene_exterior.json --force-pos 4006 11 3972 90 -10 \ + --debug-enter-vehicle sedan_taxi --screenshot 300 /tmp/seat.png +# --force-pos x y z yawDeg [pitchDeg] teleport the player once, +# re-apply the TPS yaw/pitch +# --debug-enter-vehicle name seat the player in the named +# vehicle (default driver seat) +# --debug-enter-seat seat pick the seat (driver, +# passenger, rl, rr) +# --screenshot N path dump frame N and quit # Road wedge/segment self-intersection regression test (no scene needed, @@ -988,13 +1029,38 @@ collider) gets a vehicle constraint on its rigid body; / `inputBrake` / `inputHandBrake` into Jolt (with the Jolt sample's brake-before-reverse rule) and `postPhysicsUpdate()` moves wheel visual child nodes (created from `wheelMeshName`) to the constraint's wheel -transforms. Both hooks are called from `EditorApp` around the physics -step. Any number of vehicles per scene; removal of the component or +transforms — right-side wheels (chassis -X) get an extra 180 deg flip +about the local forward axis, because Jolt reports every wheel with the +same axle direction and a single shared wheel mesh would otherwise show +its inner face outward on the right (no mirrored mesh needed). Both +hooks are called from `EditorApp` around the physics step. Any number of vehicles per scene; removal of the component or the body tears the constraint down safely. +`prePhysicsUpdate()` also applies anti-two-wheel righting: when the +chassis tilt passes ~35 deg (up to ~80 deg — a car on its side or +roof is left alone) the wrapper's `addTorque` applies a torque about +the chassis-up x world-up axis proportional to sin(tilt), and above +~5 deg of tilt the angular velocity along that axis is damped so the +car does not rock on its suspension for seconds. A high-grip car +still trips past 45 deg transiently in a full-lock turn, but it can +no longer get stuck driving on two wheels; regression check: +`demoTaxi --headless --test-vehicle-righting`. + `VehicleComponent` fields (serialized as the `vehicle` scene key, editable via `ui/VehicleEditor`): `maxTorque`, `maxPitchRollAngleDeg`, -`seatOffset`, `cameraDistance` / `cameraHeight` (TPS boom while driving), +`tireFrictionLongitudinal` / `tireFrictionLateral` (multipliers over +the Jolt default tire friction curves, peak 1.2, before Jolt combines +them with ground friction via sqrt(tire * ground) — 1.0 drives like +wet ice at speed, sedans use 1.3 / 3.0), `antiRollBarStiffness` +(N/m per axle pair of Jolt VehicleAntiRollBar, 0 = none; sedans use +2000), +`seats` (a list of named chassis-space seats, each with `name`, +`offset` and a `driver` flag — only driver seats get driving controls; +when empty the legacy `seatOffset` acts as the single driver seat), +`seatCharacterScale` (uniform scale applied to the character node +while seated, restored on exit — < 1 shrinks the stock chair-sit pose +to fit a car cabin), `cameraDistance` / `cameraHeight` (TPS boom while +driving), `rearSteer` (flips the steer sign for rear-steered vehicles like the forklift, so D still turns the nose right), `engineBrake` (brake factor applied automatically when coasting above 0.5 m/s with no throttle — @@ -1160,37 +1226,54 @@ without one). Game-mode-only system (`systems/VehicleControllerSystem.{hpp,cpp}`) that makes vehicles drivable by the player: -- **Enter**: an actuator action (e.g. `vehicle_drive` in the sokoban demo) - runs a `luaTask` node `vehicleEnter` (registered in - `lua-scripts/data2.lua`) which sends the `vehicle_enter` event with a - `vehicle=` 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=` param (empty = nearest vehicle + within 5 m of the player) and an optional `seat=` 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`. diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 314e89a..6d932b2 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -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; diff --git a/src/features/editScene/components/Vehicle.hpp b/src/features/editScene/components/Vehicle.hpp index 57e1eae..092499c 100644 --- a/src/features/editScene/components/Vehicle.hpp +++ b/src/features/editScene/components/Vehicle.hpp @@ -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 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. */ diff --git a/src/features/editScene/lua-scripts/data2.lua b/src/features/editScene/lua-scripts/data2.lua index 1ff28be..9ff5b94 100644 --- a/src/features/editScene/lua-scripts/data2.lua +++ b/src/features/editScene/lua-scripts/data2.lua @@ -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) diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index 3bcdff9..49e0bc4 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -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) diff --git a/src/features/editScene/physics/physics.h b/src/features/editScene/physics/physics.h index b764f04..b1f1930 100644 --- a/src/features/editScene/physics/physics.h +++ b/src/features/editScene/physics/physics.h @@ -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 diff --git a/src/features/editScene/systems/AnimationTreeSystem.cpp b/src/features/editScene/systems/AnimationTreeSystem.cpp index 238663d..5447c01 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.cpp +++ b/src/features/editScene/systems/AnimationTreeSystem.cpp @@ -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 diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 9151a19..5c65fb2 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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", ""); diff --git a/src/features/editScene/systems/VehicleControllerSystem.cpp b/src/features/editScene/systems/VehicleControllerSystem.cpp index e283a8a..5b0a387 100644 --- a/src/features/editScene/systems/VehicleControllerSystem.cpp +++ b/src/features/editScene/systems/VehicleControllerSystem.cpp @@ -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().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().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() && + character.get().node) { + vehicle = findNearestVehicle( + character.get() + .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().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(); + /* Pick the seat: the requested one when free, else the driver + * seat, else any free seat. */ + const VehicleComponent &vcc = vehicle.get(); + 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().cameraDistance; - pc.tpsHeight = vehicle.get().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(); + m_seatIndex = seatIndex; + m_seatIsDriver = seatIsDriverAt(vcc, seatIndex); + if (!vcc.seats.empty()) { + auto &vcm = vehicle.get_mut(); + vcm.seats[seatIndex].occupant = character.id(); + } + + /* Fit the character into the cabin (seatCharacterScale; restored + * by restoreOnFoot). */ + { + auto &ctm = character.get_mut(); + 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(); 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().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(); - 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(); @@ -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()) { + const TransformComponent &ct = + character.get(); + 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(), 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(); 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(); 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(); } @@ -410,4 +590,6 @@ void VehicleControllerSystem::resetDriving() m_vehicleId = 0; m_characterId = 0; m_controllerId = 0; + m_seatIndex = -1; + m_seatIsDriver = false; } diff --git a/src/features/editScene/systems/VehicleControllerSystem.hpp b/src/features/editScene/systems/VehicleControllerSystem.hpp index f25700c..cdda808 100644 --- a/src/features/editScene/systems/VehicleControllerSystem.hpp +++ b/src/features/editScene/systems/VehicleControllerSystem.hpp @@ -3,6 +3,7 @@ #pragma once #include "EventBus.hpp" +#include "../components/Vehicle.hpp" #include #include @@ -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 diff --git a/src/features/editScene/systems/VehicleSystem.cpp b/src/features/editScene/systems/VehicleSystem.cpp index 05f84b0..2c14f6c 100644 --- a/src/features/editScene/systems/VehicleSystem.cpp +++ b/src/features/editScene/systems/VehicleSystem.cpp @@ -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. */ diff --git a/src/features/editScene/ui/VehicleEditor.cpp b/src/features/editScene/ui/VehicleEditor.cpp index 1618891..e7f36ce 100644 --- a/src/features/editScene/ui/VehicleEditor.cpp +++ b/src/features/editScene/ui/VehicleEditor.cpp @@ -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)) diff --git a/src/gamedata/GUIModule.cpp b/src/gamedata/GUIModule.cpp index 3e85ceb..00b2fc7 100644 --- a/src/gamedata/GUIModule.cpp +++ b/src/gamedata/GUIModule.cpp @@ -288,7 +288,7 @@ struct GUIListener : public Ogre::RenderTargetListener { ImGuiWindowFlags_NoInputs); // if (ECS::get().get().enabled) // ECS::get().get().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().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; diff --git a/src/gamedata/items/items.cpp b/src/gamedata/items/items.cpp index 21fdcca..7a22d26 100644 --- a/src/gamedata/items/items.cpp +++ b/src/gamedata/items/items.cpp @@ -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 }