diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 7f2b1427c..5c673b33d 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -288,6 +288,11 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp src/services/impl/workflow/quake3/workflow_q3_pickups_draw_step.cpp src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp + src/services/impl/workflow/quake3/workflow_q3_md3_load_step.cpp + src/services/impl/workflow/quake3/workflow_q3_md3_draw_step.cpp + src/services/impl/workflow/quake3/workflow_q3_bots_spawn_step.cpp + src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp + src/services/impl/workflow/quake3/workflow_q3_bots_draw_step.cpp src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp src/services/impl/workflow/rendering/workflow_bsp_build_geometry_step.cpp src/services/impl/workflow/rendering/workflow_bsp_entity_update_step.cpp diff --git a/gameengine/packages/quake3/workflows/q3_frame.json b/gameengine/packages/quake3/workflows/q3_frame.json index e8104d055..35469f7a2 100644 --- a/gameengine/packages/quake3/workflows/q3_frame.json +++ b/gameengine/packages/quake3/workflows/q3_frame.json @@ -1,6 +1,6 @@ { "name": "Q3 Frame Tick", - "description": "Per-frame: poll input, FPS move, render BSP map with post-FX.", + "description": "Per-frame: poll input, FPS move, render BSP map with player models, bots, and weapon.", "nodes": [ { "id": "input_poll", @@ -111,6 +111,18 @@ "typeVersion": 1, "position": [690, 0] }, + { + "id": "q3_bots_update", + "type": "q3.bots.update", + "typeVersion": 1, + "position": [695, 0], + "parameters": { + "chase_range": 20.0, + "shoot_range": 6.0, + "move_speed": 3.5, + "shoot_interval": 30 + } + }, { "id": "render_prepare", "type": "render.prepare", @@ -152,6 +164,32 @@ "typeVersion": 1, "position": [1100, 0] }, + { + "id": "draw_bots", + "type": "q3.bots.draw", + "typeVersion": 1, + "position": [1150, 0], + "parameters": { + "lower_prefix": "lower", + "upper_prefix": "upper", + "head_prefix": "head", + "weapon_prefix": "weapon_mg" + } + }, + { + "id": "draw_weapon_viewmodel", + "type": "q3.md3.draw", + "typeVersion": 1, + "position": [1160, 0], + "parameters": { + "prefix": "weapon_mg", + "viewmodel": true, + "vm_right": 0.25, + "vm_down": -0.22, + "vm_fwd": 0.45, + "fps": 15.0 + } + }, { "id": "end_scene", "type": "frame.gpu.end_scene", @@ -203,30 +241,33 @@ } ], "connections": { - "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, - "q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } }, - "should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } }, - "stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested","type": "main", "index": 0 }] } }, - "set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } }, - "compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } }, - "update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, - "physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } }, - "physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } }, - "sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } }, - "bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } }, - "camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } }, - "q3_weapon": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } }, - "render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } }, - "portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } }, - "frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } }, - "draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } }, - "draw_pickups": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }, - "end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } }, - "overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } }, - "q3_overlay": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } }, - "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } }, - "postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } }, - "bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } }, - "bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } } + "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, + "q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } }, + "should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } }, + "stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested", "type": "main", "index": 0 }] } }, + "set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } }, + "compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } }, + "update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, + "physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } }, + "physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } }, + "sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } }, + "bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } }, + "camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } }, + "q3_weapon": { "main": { "0": [{ "node": "q3_bots_update", "type": "main", "index": 0 }] } }, + "q3_bots_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } }, + "render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } }, + "portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } }, + "frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } }, + "draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } }, + "draw_pickups": { "main": { "0": [{ "node": "draw_bots", "type": "main", "index": 0 }] } }, + "draw_bots": { "main": { "0": [{ "node": "draw_weapon_viewmodel", "type": "main", "index": 0 }] } }, + "draw_weapon_viewmodel": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }, + "end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } }, + "overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } }, + "q3_overlay": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } }, + "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } }, + "postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } }, + "bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } }, + "bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } } } } diff --git a/gameengine/packages/quake3/workflows/q3_map_session.json b/gameengine/packages/quake3/workflows/q3_map_session.json index f7ae58e3c..b3a3466a7 100644 --- a/gameengine/packages/quake3/workflows/q3_map_session.json +++ b/gameengine/packages/quake3/workflows/q3_map_session.json @@ -1,6 +1,6 @@ { "name": "Q3 Map Session", - "description": "Loads a BSP map and runs the frame loop. Repeated by the outer loop for each map change.", + "description": "Loads a BSP map, player/weapon MD3 models, spawns bots, then runs the frame loop.", "nodes": [ { "id": "load_bsp", @@ -12,14 +12,12 @@ }, { "id": "bsp_lightmap", - "name": "BSP Lightmap Atlas", "type": "bsp.lightmap_atlas", "typeVersion": 1, "position": [100, 0] }, { "id": "bsp_geometry", - "name": "BSP Build Geometry", "type": "bsp.build_geometry", "typeVersion": 1, "position": [200, 0], @@ -27,28 +25,24 @@ }, { "id": "bsp_textures", - "name": "BSP Extract Textures", "type": "bsp.extract_textures", "typeVersion": 1, "position": [300, 0] }, { "id": "bsp_upload", - "name": "BSP Upload Geometry", "type": "bsp.upload_geometry", "typeVersion": 1, "position": [400, 0] }, { "id": "bsp_collision", - "name": "BSP Build Collision", "type": "bsp.build_collision", "typeVersion": 1, "position": [500, 0] }, { "id": "bsp_spawn", - "name": "BSP Parse Spawn", "type": "bsp.parse_spawn", "typeVersion": 1, "position": [600, 0] @@ -59,6 +53,54 @@ "typeVersion": 1, "position": [700, 0] }, + { + "id": "load_lower", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [720, 0], + "parameters": { + "prefix": "lower", + "path": "models/players/keel/lower.md3", + "anim": "models/players/keel/animation.cfg" + } + }, + { + "id": "load_upper", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [730, 0], + "parameters": { + "prefix": "upper", + "path": "models/players/keel/upper.md3" + } + }, + { + "id": "load_head", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [740, 0], + "parameters": { + "prefix": "head", + "path": "models/players/keel/head.md3" + } + }, + { + "id": "load_weapon_mg", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [750, 0], + "parameters": { + "prefix": "weapon_mg", + "path": "models/weapons2/machinegun/machinegun.md3" + } + }, + { + "id": "bots_spawn", + "type": "q3.bots.spawn", + "typeVersion": 1, + "position": [760, 0], + "parameters": { "count": 4 } + }, { "id": "set_running", "type": "value.literal", @@ -83,15 +125,20 @@ } ], "connections": { - "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, - "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, - "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, - "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, - "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, - "bsp_collision":{ "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, - "bsp_spawn": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, - "spawn_apply": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, - "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, - "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } + "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, + "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, + "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, + "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, + "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, + "bsp_collision": { "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, + "bsp_spawn": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, + "spawn_apply": { "main": { "0": [{ "node": "load_lower", "type": "main", "index": 0 }] } }, + "load_lower": { "main": { "0": [{ "node": "load_upper", "type": "main", "index": 0 }] } }, + "load_upper": { "main": { "0": [{ "node": "load_head", "type": "main", "index": 0 }] } }, + "load_head": { "main": { "0": [{ "node": "load_weapon_mg","type": "main", "index": 0 }] } }, + "load_weapon_mg":{ "main": { "0": [{ "node": "bots_spawn", "type": "main", "index": 0 }] } }, + "bots_spawn": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, + "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, + "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } } } diff --git a/gameengine/packages/quake3_screenshot/workflows/q3_frame.json b/gameengine/packages/quake3_screenshot/workflows/q3_frame.json index 789ae5dd9..d67bc6e7f 100644 --- a/gameengine/packages/quake3_screenshot/workflows/q3_frame.json +++ b/gameengine/packages/quake3_screenshot/workflows/q3_frame.json @@ -1,6 +1,6 @@ { "name": "Q3 Frame Tick", - "description": "Per-frame: poll input, FPS move, render BSP map with post-FX.", + "description": "Per-frame: poll input, FPS move, render BSP map with bots and weapon. Screenshots after 240 frames then exits.", "nodes": [ { "id": "input_poll", @@ -111,6 +111,18 @@ "typeVersion": 1, "position": [690, 0] }, + { + "id": "q3_bots_update", + "type": "q3.bots.update", + "typeVersion": 1, + "position": [695, 0], + "parameters": { + "chase_range": 20.0, + "shoot_range": 6.0, + "move_speed": 3.5, + "shoot_interval": 30 + } + }, { "id": "render_prepare", "type": "render.prepare", @@ -152,6 +164,32 @@ "typeVersion": 1, "position": [1100, 0] }, + { + "id": "draw_bots", + "type": "q3.bots.draw", + "typeVersion": 1, + "position": [1150, 0], + "parameters": { + "lower_prefix": "lower", + "upper_prefix": "upper", + "head_prefix": "head", + "weapon_prefix": "weapon_mg" + } + }, + { + "id": "draw_weapon_viewmodel", + "type": "q3.md3.draw", + "typeVersion": 1, + "position": [1160, 0], + "parameters": { + "prefix": "weapon_mg", + "viewmodel": true, + "vm_right": 0.25, + "vm_down": -0.22, + "vm_fwd": 0.45, + "fps": 15.0 + } + }, { "id": "end_scene", "type": "frame.gpu.end_scene", @@ -237,34 +275,37 @@ } ], "connections": { - "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, - "q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } }, - "should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } }, - "stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested","type": "main", "index": 0 }] } }, - "set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } }, - "compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } }, - "update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, - "physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } }, - "physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } }, - "sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } }, - "bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } }, - "camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } }, - "q3_weapon": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } }, - "render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } }, - "portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } }, - "frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } }, - "draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } }, - "draw_pickups": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }, - "end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } }, - "overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } }, - "q3_overlay": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } }, - "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } }, - "postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } }, - "bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } }, - "bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } }, - "postfx_composite": { "main": { "0": [{ "node": "set_threshold", "type": "main", "index": 0 }] } }, - "set_threshold": { "main": { "0": [{ "node": "check_frame_240", "type": "main", "index": 0 }] } }, - "check_frame_240": { "main": { "0": [{ "node": "set_screenshot_path", "type": "main", "index": 0 }] } }, - "set_screenshot_path": { "main": { "0": [{ "node": "stop_after_screenshot", "type": "main", "index": 0 }] } } + "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, + "q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } }, + "should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } }, + "stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested", "type": "main", "index": 0 }] } }, + "set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } }, + "compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } }, + "update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, + "physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } }, + "physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } }, + "sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } }, + "bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } }, + "camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } }, + "q3_weapon": { "main": { "0": [{ "node": "q3_bots_update", "type": "main", "index": 0 }] } }, + "q3_bots_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } }, + "render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } }, + "portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } }, + "frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } }, + "draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } }, + "draw_pickups": { "main": { "0": [{ "node": "draw_bots", "type": "main", "index": 0 }] } }, + "draw_bots": { "main": { "0": [{ "node": "draw_weapon_viewmodel", "type": "main", "index": 0 }] } }, + "draw_weapon_viewmodel": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }, + "end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } }, + "overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } }, + "q3_overlay": { "main": { "0": [{ "node": "set_threshold", "type": "main", "index": 0 }] } }, + "set_threshold": { "main": { "0": [{ "node": "check_frame_240", "type": "main", "index": 0 }] } }, + "check_frame_240": { "main": { "0": [{ "node": "set_screenshot_path", "type": "main", "index": 0 }] } }, + "set_screenshot_path": { "main": { "0": [{ "node": "stop_after_screenshot", "type": "main", "index": 0 }] } }, + "stop_after_screenshot": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } }, + "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } }, + "postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } }, + "bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } }, + "bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } } } } diff --git a/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json b/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json index 4fcac0fac..843b36ec7 100644 --- a/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json +++ b/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json @@ -1,6 +1,6 @@ { "name": "Q3 Map Session", - "description": "Loads a BSP map and runs the frame loop. Repeated by the outer loop for each map change.", + "description": "Loads a BSP map, player/weapon MD3 models, spawns bots, then runs the frame loop.", "nodes": [ { "id": "load_bsp", @@ -12,14 +12,12 @@ }, { "id": "bsp_lightmap", - "name": "BSP Lightmap Atlas", "type": "bsp.lightmap_atlas", "typeVersion": 1, "position": [100, 0] }, { "id": "bsp_geometry", - "name": "BSP Build Geometry", "type": "bsp.build_geometry", "typeVersion": 1, "position": [200, 0], @@ -27,28 +25,24 @@ }, { "id": "bsp_textures", - "name": "BSP Extract Textures", "type": "bsp.extract_textures", "typeVersion": 1, "position": [300, 0] }, { "id": "bsp_upload", - "name": "BSP Upload Geometry", "type": "bsp.upload_geometry", "typeVersion": 1, "position": [400, 0] }, { "id": "bsp_collision", - "name": "BSP Build Collision", "type": "bsp.build_collision", "typeVersion": 1, "position": [500, 0] }, { "id": "bsp_spawn", - "name": "BSP Parse Spawn", "type": "bsp.parse_spawn", "typeVersion": 1, "position": [600, 0] @@ -59,6 +53,54 @@ "typeVersion": 1, "position": [700, 0] }, + { + "id": "load_lower", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [720, 0], + "parameters": { + "prefix": "lower", + "path": "models/players/keel/lower.md3", + "anim": "models/players/keel/animation.cfg" + } + }, + { + "id": "load_upper", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [730, 0], + "parameters": { + "prefix": "upper", + "path": "models/players/keel/upper.md3" + } + }, + { + "id": "load_head", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [740, 0], + "parameters": { + "prefix": "head", + "path": "models/players/keel/head.md3" + } + }, + { + "id": "load_weapon_mg", + "type": "q3.md3.load", + "typeVersion": 1, + "position": [750, 0], + "parameters": { + "prefix": "weapon_mg", + "path": "models/weapons2/machinegun/machinegun.md3" + } + }, + { + "id": "bots_spawn", + "type": "q3.bots.spawn", + "typeVersion": 1, + "position": [760, 0], + "parameters": { "count": 4 } + }, { "id": "set_running", "type": "value.literal", @@ -83,15 +125,20 @@ } ], "connections": { - "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, - "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, - "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, - "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, - "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, - "bsp_collision":{ "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, - "bsp_spawn": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, - "spawn_apply": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, - "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, - "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } + "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, + "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, + "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, + "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, + "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, + "bsp_collision": { "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, + "bsp_spawn": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, + "spawn_apply": { "main": { "0": [{ "node": "load_lower", "type": "main", "index": 0 }] } }, + "load_lower": { "main": { "0": [{ "node": "load_upper", "type": "main", "index": 0 }] } }, + "load_upper": { "main": { "0": [{ "node": "load_head", "type": "main", "index": 0 }] } }, + "load_head": { "main": { "0": [{ "node": "load_weapon_mg", "type": "main", "index": 0 }] } }, + "load_weapon_mg": { "main": { "0": [{ "node": "bots_spawn", "type": "main", "index": 0 }] } }, + "bots_spawn": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, + "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, + "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } } } diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_draw_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_draw_step.cpp new file mode 100644 index 000000000..2fdf0ffcd --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_draw_step.cpp @@ -0,0 +1,192 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_bots_draw_step.hpp" +#include "services/interfaces/workflow/rendering/rendering_types.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +// Build a column-major glm matrix from a tag stored in engine Y-up coords. +// Tags are stored with axis[i] as the i-th row of the Q3 rotation matrix. +// In column-major (glm), we must transpose: column j = (axis[0][j], axis[1][j], axis[2][j]). +glm::mat4 TagMatrix(const nlohmann::json& tag) { + const auto& ax = tag["axis"]; + const auto& o = tag["origin"]; + return glm::mat4( + glm::vec4(ax[0][0].get(), ax[1][0].get(), ax[2][0].get(), 0.0f), + glm::vec4(ax[0][1].get(), ax[1][1].get(), ax[2][1].get(), 0.0f), + glm::vec4(ax[0][2].get(), ax[1][2].get(), ax[2][2].get(), 0.0f), + glm::vec4(o[0].get(), o[1].get(), o[2].get(), 1.0f) + ); +} + +// Draw all surfaces of a named MD3 model at the given world transform. +void DrawMd3(const std::string& prefix, int frame, const glm::mat4& modelMat, + const glm::mat4& view, const glm::mat4& proj, const glm::vec3& camPos, + const glm::mat4& shadowVP, const rendering::FragmentUniformData& fu, + SDL_GPURenderPass* pass, SDL_GPUCommandBuffer* cmd, + SDL_GPUTexture* shadowTex, SDL_GPUSampler* shadowSamp, + WorkflowContext& context) { + const int nSurfs = context.Get("q3.md3." + prefix + "_num_surfs", 0); + if (nSurfs <= 0) return; + const int nFrames = context.Get("q3.md3." + prefix + "_num_frames", 1); + const int clampedFrame = std::max(0, std::min(frame, nFrames - 1)); + + const glm::mat4 mvp = proj * view * modelMat; + rendering::VertexUniformData vu = {}; + std::memcpy(vu.mvp, glm::value_ptr(mvp), sizeof(float) * 16); + std::memcpy(vu.model_mat, glm::value_ptr(modelMat), sizeof(float) * 16); + vu.normal[1] = 1.0f; + vu.uv_scale[0] = 1.0f; vu.uv_scale[1] = 1.0f; + vu.camera_pos[0] = camPos.x; vu.camera_pos[1] = camPos.y; vu.camera_pos[2] = camPos.z; + std::memcpy(vu.shadow_vp, glm::value_ptr(shadowVP), sizeof(float) * 16); + + for (int s = 0; s < nSurfs; ++s) { + const std::string sk = "q3.md3." + prefix + "_surf" + std::to_string(s); + auto* vb = context.Get( + sk + "_f" + std::to_string(clampedFrame) + "_vb", nullptr); + auto* ib = context.Get(sk + "_ib", nullptr); + const int numIdx = context.Get(sk + "_num_idx", 0); + if (!vb || !ib || numIdx <= 0) continue; + auto* tex = context.Get(sk + "_tex", nullptr); + auto* samp = context.Get(sk + "_samp", nullptr); + if (!tex || !samp) continue; + + // Always bind 2 samplers — shader slot 1 is the shadow map. + // Reuse albedo when no shadow texture to avoid Metal null-slot validation errors. + { + auto* stex = shadowTex ? shadowTex : tex; + auto* ssamp = shadowSamp ? shadowSamp : samp; + SDL_GPUTextureSamplerBinding b[2] = {{tex, samp}, {stex, ssamp}}; + SDL_BindGPUFragmentSamplers(pass, 0, b, 2); + } + SDL_GPUBufferBinding vbb = {vb, 0}; + SDL_BindGPUVertexBuffers(pass, 0, &vbb, 1); + SDL_GPUBufferBinding ibb = {ib, 0}; + SDL_BindGPUIndexBuffer(pass, &ibb, SDL_GPU_INDEXELEMENTSIZE_16BIT); + SDL_PushGPUVertexUniformData(cmd, 0, &vu, sizeof(vu)); + SDL_PushGPUFragmentUniformData(cmd, 0, &fu, sizeof(fu)); + SDL_DrawGPUIndexedPrimitives(pass, (uint32_t)numIdx, 1, 0, 0, 0); + } +} + +// Get a tag matrix from the context for a given MD3 prefix + frame + tag name. +// Returns identity if the tag is not found. +glm::mat4 GetTagMatrix(const std::string& prefix, int frame, const std::string& tagName, + WorkflowContext& context) { + const auto* tagsJson = context.TryGet("q3.md3." + prefix + "_tags"); + if (!tagsJson || !tagsJson->is_array() || (int)tagsJson->size() <= frame) return glm::mat4(1.0f); + const auto& frameObj = (*tagsJson)[(size_t)frame]; + if (!frameObj.contains(tagName)) return glm::mat4(1.0f); + return TagMatrix(frameObj[tagName]); +} + +} // namespace + +WorkflowQ3BotsDrawStep::WorkflowQ3BotsDrawStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3BotsDrawStep::GetPluginId() const { return "q3.bots.draw"; } + +void WorkflowQ3BotsDrawStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (context.GetBool("frame_skip", false)) return; + + auto* pass = context.Get("gpu_render_pass", nullptr); + auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* pipeline = context.Get("gpu_pipeline_textured", nullptr); + const auto* botsPtr = context.TryGet("q3.bots"); + if (!pass || !cmd || !pipeline || !botsPtr || !botsPtr->is_array()) return; + + WorkflowStepParameterResolver params; + auto getStr = [&](const char* k, const std::string& def) -> std::string { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::String) ? p->stringValue : def; + }; + + const std::string lowerPfx = getStr("lower_prefix", "lower"); + const std::string upperPfx = getStr("upper_prefix", "upper"); + const std::string headPfx = getStr("head_prefix", "head"); + const std::string weaponPfx = getStr("weapon_prefix", "weapon_mg"); + + const bool hasLower = context.Get("q3.md3." + lowerPfx + "_num_frames", 0) > 0; + const bool hasUpper = context.Get("q3.md3." + upperPfx + "_num_frames", 0) > 0; + const bool hasHead = context.Get("q3.md3." + headPfx + "_num_frames", 0) > 0; + const bool hasWeapon = context.Get("q3.md3." + weaponPfx + "_num_frames", 0) > 0; + if (!hasLower) return; // nothing to draw without lower + + auto view = context.Get("render.view_matrix", glm::mat4(1.0f)); + auto proj = context.Get("render.proj_matrix", glm::mat4(1.0f)); + auto camPos = context.Get("render.camera_pos", glm::vec3(0.0f)); + auto shadowVP = context.Get("render.shadow_vp", glm::mat4(1.0f)); + auto fu = context.Get("render.frag_uniforms", + rendering::FragmentUniformData{}); + fu.material[0] = 0.6f; fu.material[1] = 0.1f; // roughness/metallic + + auto* shadowTex = context.Get("shadow_depth_texture", nullptr); + auto* shadowSamp = context.Get("shadow_depth_sampler", nullptr); + + SDL_BindGPUGraphicsPipeline(pass, pipeline); + + for (const auto& bot : *botsPtr) { + if (bot.value("state", std::string{}) == "dead") continue; + + const auto& posJ = bot["pos"]; + const glm::vec3 bpos(posJ[0].get(), posJ[1].get(), posJ[2].get()); + const float yaw = bot.value("yaw", 0.0f); + const int legFrame = bot.value("leg_frame", 0); + const int torsoFrame = bot.value("torso_frame", 0); + + // ── lower.md3: root transform ──────────────────────────────────────── + const glm::mat4 lowerMat = + glm::translate(glm::mat4(1.0f), bpos) + * glm::rotate(glm::mat4(1.0f), yaw, glm::vec3(0.0f, 1.0f, 0.0f)); + + DrawMd3(lowerPfx, legFrame, lowerMat, + view, proj, camPos, shadowVP, fu, + pass, cmd, shadowTex, shadowSamp, context); + + if (!hasUpper) continue; + + // ── upper.md3: attached at tag_torso from lower ─────────────────────── + const glm::mat4 tagTorso = GetTagMatrix(lowerPfx, legFrame, "tag_torso", context); + const glm::mat4 upperMat = lowerMat * tagTorso; + + DrawMd3(upperPfx, torsoFrame, upperMat, + view, proj, camPos, shadowVP, fu, + pass, cmd, shadowTex, shadowSamp, context); + + if (!hasHead) continue; + + // ── head.md3: attached at tag_head from upper ───────────────────────── + const glm::mat4 tagHead = GetTagMatrix(upperPfx, torsoFrame, "tag_head", context); + const glm::mat4 headMat = upperMat * tagHead; + + DrawMd3(headPfx, 0, headMat, + view, proj, camPos, shadowVP, fu, + pass, cmd, shadowTex, shadowSamp, context); + + if (!hasWeapon) continue; + + // ── weapon.md3: attached at tag_weapon from upper ───────────────────── + const glm::mat4 tagWeapon = GetTagMatrix(upperPfx, torsoFrame, "tag_weapon", context); + const glm::mat4 weaponMat = upperMat * tagWeapon; + + DrawMd3(weaponPfx, 0, weaponMat, + view, proj, camPos, shadowVP, fu, + pass, cmd, shadowTex, shadowSamp, context); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_spawn_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_spawn_step.cpp new file mode 100644 index 000000000..51ec54ca9 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_spawn_step.cpp @@ -0,0 +1,74 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_bots_spawn_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3BotsSpawnStep::WorkflowQ3BotsSpawnStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3BotsSpawnStep::GetPluginId() const { return "q3.bots.spawn"; } + +void WorkflowQ3BotsSpawnStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + // Idempotent: only spawn once + const auto* existing = context.TryGet("q3.bots"); + if (existing && existing->is_array() && !existing->empty()) return; + + WorkflowStepParameterResolver params; + const auto* cp = params.FindParameter(step, "count"); + const int maxBots = (cp && cp->type == WorkflowParameterValue::Type::Number) + ? (int)cp->numberValue : 4; + + // Collect all deathmatch spawn points from BSP entities + const auto* entities = context.TryGet("bsp.entities"); + std::vector spawnPts; + if (entities && entities->is_array()) { + for (const auto& ent : *entities) { + const std::string cls = ent.value("classname", std::string{}); + if ((cls == "info_player_deathmatch" || cls == "info_player_start") + && ent.contains("position")) { + spawnPts.push_back(ent["position"]); + } + } + } + + if (spawnPts.empty()) { + // Fallback: scatter bots near origin + for (int i = 0; i < maxBots; ++i) { + const float a = (float)i * 1.57f; + spawnPts.push_back(nlohmann::json::array( + {std::cos(a) * 3.0f, 0.5f, std::sin(a) * 3.0f})); + } + } + + nlohmann::json bots = nlohmann::json::array(); + // Skip index 0 — that spawn point is reserved for the player (same one + // bsp.parse_spawn selects). If there are 2+ spawn points start bots from + // index 1 so they never overlap the player. Fall back to index 0 only + // when the map has a single spawn point (no better option). + const int startIdx = (spawnPts.size() > 1) ? 1 : 0; + const int available = (int)spawnPts.size() - startIdx; + const int count = std::min(maxBots, available > 0 ? available : maxBots); + for (int i = 0; i < count; ++i) { + const auto& sp = spawnPts[(size_t)(startIdx + i) % spawnPts.size()]; + nlohmann::json bot; + bot["id"] = i; + bot["pos"] = sp; + bot["yaw"] = 0.0f; + bot["health"] = 100; + bot["state"] = "idle"; // idle | chase | shoot | dead + bot["leg_frame"] = 0; + bot["torso_frame"] = 0; + bot["weapon"] = "weapon_machinegun"; + bot["last_shot"] = 0; + bots.push_back(bot); + } + context.Set("q3.bots", bots); + if (logger_) logger_->Info("q3.bots.spawn: spawned " + std::to_string(count) + " bots"); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp new file mode 100644 index 000000000..67c18501b --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp @@ -0,0 +1,119 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3BotsUpdateStep::WorkflowQ3BotsUpdateStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3BotsUpdateStep::GetPluginId() const { return "q3.bots.update"; } + +void WorkflowQ3BotsUpdateStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + auto* botsPtr = context.TryGet("q3.bots"); + if (!botsPtr || !botsPtr->is_array() || botsPtr->empty()) return; + + WorkflowStepParameterResolver params; + auto getNum = [&](const char* k, float def) -> float { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::Number) ? (float)p->numberValue : def; + }; + + const float chaseRange = getNum("chase_range", 20.0f); + const float shootRange = getNum("shoot_range", 6.0f); + const float moveSpeed = getNum("move_speed", 3.0f); + const int legIdle = (int)getNum("leg_idle", 162); // Q3 keel defaults + const int legRun = (int)getNum("leg_run", 167); + const int legRunCnt = (int)getNum("leg_run_cnt", 8); + const int torsoStand = (int)getNum("torso_stand", 101); + const int torsoAtk = (int)getNum("torso_attack", 107); + const int torsoAtkCnt = (int)getNum("torso_atk_cnt", 6); + const int shootInterv = (int)getNum("shoot_interval", 30); // frames + + // Player position from camera state + const auto camState = context.Get("camera.state", nlohmann::json::object()); + glm::vec3 playerPos(0.0f); + if (camState.contains("position") && camState["position"].is_array()) { + const auto& cp = camState["position"]; + if (cp.size() >= 3) + playerPos = {cp[0].get(), cp[1].get(), cp[2].get()}; + } + + const double dt = context.GetDouble("frame.delta_time", 0.016); + const double elapsed = context.GetDouble("frame.elapsed", 0.0); + const int globalFrame = (int)(elapsed * 60.0); // ~60fps frame counter + + nlohmann::json bots = *botsPtr; + nlohmann::json shots = nlohmann::json::array(); + + for (auto& bot : bots) { + if (bot.value("state", std::string{}) == "dead") continue; + + const auto& posJ = bot["pos"]; + glm::vec3 bpos(posJ[0].get(), posJ[1].get(), posJ[2].get()); + + const glm::vec3 toPlayer = playerPos - bpos; + const float dist = std::sqrt(toPlayer.x * toPlayer.x + + toPlayer.y * toPlayer.y + + toPlayer.z * toPlayer.z); + + // Face player: yaw = atan2 of horizontal direction (XZ plane in Y-up) + const float targetYaw = std::atan2(toPlayer.x, toPlayer.z); + bot["yaw"] = targetYaw; + + // State transitions + if (dist < shootRange) { + bot["state"] = "shoot"; + } else if (dist < chaseRange) { + bot["state"] = "chase"; + } else { + bot["state"] = "idle"; + } + + const std::string state = bot["state"].get(); + + // Movement + if (state == "chase" && dist > 0.5f) { + const glm::vec3 dir = toPlayer / dist; + bpos.x += dir.x * moveSpeed * (float)dt; + bpos.z += dir.z * moveSpeed * (float)dt; + // Keep Y from spawn (basic ground-snapping; proper would do physics) + bot["pos"] = nlohmann::json::array({bpos.x, bpos.y, bpos.z}); + } + + // Animation frame selection + const double fps = 15.0; + const int baseFrame = (int)(elapsed * fps); + if (state == "chase") { + bot["leg_frame"] = legRun + (legRunCnt > 0 ? (baseFrame % legRunCnt) : 0); + bot["torso_frame"] = torsoStand; + } else if (state == "shoot") { + bot["leg_frame"] = legIdle; + bot["torso_frame"] = torsoAtk + (torsoAtkCnt > 0 ? (baseFrame % torsoAtkCnt) : 0); + + // Fire shot + const int lastShot = bot.value("last_shot", 0); + if (globalFrame >= lastShot + shootInterv) { + bot["last_shot"] = globalFrame; + shots.push_back({ + {"bot_id", bot["id"]}, + {"from", bot["pos"]}, + {"to", nlohmann::json::array({playerPos.x, playerPos.y, playerPos.z})} + }); + } + } else { + bot["leg_frame"] = legIdle; + bot["torso_frame"] = torsoStand; + } + } + + context.Set("q3.bots", bots); + if (!shots.empty()) context.Set("q3.bot_shots", shots); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_md3_draw_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_md3_draw_step.cpp new file mode 100644 index 000000000..0adb5e8fa --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_md3_draw_step.cpp @@ -0,0 +1,186 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_md3_draw_step.hpp" +#include "services/interfaces/workflow/rendering/rendering_types.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +// Build a glm column-major matrix from a Q3/MD3 tag stored in engine Y-up coords. +// The tag axis[i] rows (already coord-converted) must be transposed to form +// the columns of a column-major matrix. +glm::mat4 TagMatrix(const nlohmann::json& tag) { + const auto& ax = tag["axis"]; // 3 rows in engine space + const auto& o = tag["origin"]; + // Transpose: row i of Q3 axis → column i of glm matrix + return glm::mat4( + glm::vec4(ax[0][0].get(), ax[1][0].get(), ax[2][0].get(), 0.0f), + glm::vec4(ax[0][1].get(), ax[1][1].get(), ax[2][1].get(), 0.0f), + glm::vec4(ax[0][2].get(), ax[1][2].get(), ax[2][2].get(), 0.0f), + glm::vec4(o[0].get(), o[1].get(), o[2].get(), 1.0f) + ); +} + +// Draw all surfaces of a named MD3 at the given model matrix. +void DrawMd3Surfaces(const std::string& prefix, int frame, + const glm::mat4& model, const glm::mat4& view, const glm::mat4& proj, + const glm::vec3& camPos, const glm::mat4& shadowVP, + const rendering::FragmentUniformData& fu, + SDL_GPURenderPass* pass, SDL_GPUCommandBuffer* cmd, + SDL_GPUTexture* shadowTex, SDL_GPUSampler* shadowSamp, + WorkflowContext& context) { + const int nSurfs = context.Get("q3.md3." + prefix + "_num_surfs", 0); + if (nSurfs <= 0) return; + + const glm::mat4 mvp = proj * view * model; + rendering::VertexUniformData vu = {}; + std::memcpy(vu.mvp, glm::value_ptr(mvp), sizeof(float) * 16); + std::memcpy(vu.model_mat, glm::value_ptr(model), sizeof(float) * 16); + vu.normal[1] = 1.0f; + vu.uv_scale[0] = 1.0f; vu.uv_scale[1] = 1.0f; + vu.camera_pos[0] = camPos.x; vu.camera_pos[1] = camPos.y; vu.camera_pos[2] = camPos.z; + std::memcpy(vu.shadow_vp, glm::value_ptr(shadowVP), sizeof(float) * 16); + + for (int s = 0; s < nSurfs; ++s) { + const std::string sk = "q3.md3." + prefix + "_surf" + std::to_string(s); + auto* vb = context.Get(sk + "_f" + std::to_string(frame) + "_vb", nullptr); + auto* ib = context.Get(sk + "_ib", nullptr); + const int numIdx = context.Get(sk + "_num_idx", 0); + if (!vb || !ib || numIdx <= 0) continue; + auto* tex = context.Get(sk + "_tex", nullptr); + auto* samp = context.Get(sk + "_samp", nullptr); + if (!tex || !samp) continue; + + // Always bind 2 samplers — shader slot 1 is the shadow map. + // When no shadow texture is available, reuse the albedo texture so slot 1 + // is never null (Metal validation error). The shadow UV will be out of bounds + // (shadow_vp is identity) so ComputeShadowPCF returns 1.0 (no shadow). + { + auto* stex = shadowTex ? shadowTex : tex; + auto* ssamp = shadowSamp ? shadowSamp : samp; + SDL_GPUTextureSamplerBinding b[2] = {{tex, samp}, {stex, ssamp}}; + SDL_BindGPUFragmentSamplers(pass, 0, b, 2); + } + SDL_GPUBufferBinding vbBind = {vb, 0}; + SDL_BindGPUVertexBuffers(pass, 0, &vbBind, 1); + SDL_GPUBufferBinding ibBind = {ib, 0}; + SDL_BindGPUIndexBuffer(pass, &ibBind, SDL_GPU_INDEXELEMENTSIZE_16BIT); + SDL_PushGPUVertexUniformData(cmd, 0, &vu, sizeof(vu)); + SDL_PushGPUFragmentUniformData(cmd, 0, &fu, sizeof(fu)); + SDL_DrawGPUIndexedPrimitives(pass, (uint32_t)numIdx, 1, 0, 0, 0); + } +} + +} // namespace + +WorkflowQ3Md3DrawStep::WorkflowQ3Md3DrawStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3Md3DrawStep::GetPluginId() const { return "q3.md3.draw"; } + +void WorkflowQ3Md3DrawStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (context.GetBool("frame_skip", false)) return; + + WorkflowStepParameterResolver params; + auto getStr = [&](const char* k, const std::string& def) -> std::string { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::String) ? p->stringValue : def; + }; + auto getNum = [&](const char* k, float def) -> float { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::Number) ? (float)p->numberValue : def; + }; + auto getBool = [&](const char* k, bool def) -> bool { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::Bool) ? p->boolValue : def; + }; + + const std::string prefix = getStr("prefix", "model"); + const std::string pos_key = getStr("pos_key", ""); + const std::string yaw_key = getStr("yaw_key", ""); + const std::string frame_key = getStr("frame_key", ""); + const float fps = getNum("fps", 15.0f); + const int animFirst = (int)getNum("anim_first", 0.0f); + const int animCount = (int)getNum("anim_count", 0.0f); + const bool viewmodel = getBool("viewmodel", false); + const float vmRight = getNum("vm_right", 0.35f); + const float vmDown = getNum("vm_down", -0.3f); + const float vmFwd = getNum("vm_fwd", 0.5f); + + auto* pass = context.Get("gpu_render_pass", nullptr); + auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* pipeline = context.Get("gpu_pipeline_textured", nullptr); + if (!pass || !cmd || !pipeline) return; + + const int nFrames = context.Get("q3.md3." + prefix + "_num_frames", 0); + if (nFrames <= 0) return; + + // ── Determine animation frame ───────────────────────────────────────────── + int frame = 0; + if (!frame_key.empty()) { + frame = context.Get(frame_key, 0); + } else { + const double elapsed = context.GetDouble("frame.elapsed", 0.0); + const int totalFrame = (int)(elapsed * fps); + frame = (animCount > 0) + ? animFirst + (totalFrame % animCount) + : (totalFrame % nFrames); + } + frame = std::max(0, std::min(frame, nFrames - 1)); + + // ── Build world model matrix ────────────────────────────────────────────── + auto view = context.Get("render.view_matrix", glm::mat4(1.0f)); + auto proj = context.Get("render.proj_matrix", glm::mat4(1.0f)); + auto camPos = context.Get("render.camera_pos", glm::vec3(0.0f)); + auto shadowVP = context.Get("render.shadow_vp", glm::mat4(1.0f)); + auto fu = context.Get("render.frag_uniforms", + rendering::FragmentUniformData{}); + + glm::mat4 model(1.0f); + if (viewmodel) { + glm::vec3 right (view[0][0], view[1][0], view[2][0]); + glm::vec3 up (view[0][1], view[1][1], view[2][1]); + glm::vec3 forward(-view[0][2],-view[1][2],-view[2][2]); + glm::vec3 pos = camPos + right * vmRight + up * vmDown + forward * vmFwd; + // MD3 weapon models have barrel along local +X. + // Map: model X → world forward (barrel points into screen) + // model Y → world up + // model Z → world right (right-handed: right × forward = up ✓) + glm::mat4 orient(1.0f); + orient[0] = glm::vec4(forward, 0.0f); + orient[1] = glm::vec4(up, 0.0f); + orient[2] = glm::vec4(right, 0.0f); + model = glm::translate(glm::mat4(1.0f), pos) * orient; + } else { + glm::vec3 pos(0.0f); + if (!pos_key.empty()) { + const auto* pv = context.TryGet(pos_key); + if (pv && pv->is_array() && pv->size() >= 3) + pos = glm::vec3((*pv)[0].get(), (*pv)[1].get(), (*pv)[2].get()); + } + float yaw = 0.0f; + if (!yaw_key.empty()) yaw = context.Get(yaw_key, 0.0f); + model = glm::translate(glm::mat4(1.0f), pos) + * glm::rotate(glm::mat4(1.0f), yaw, glm::vec3(0.0f, 1.0f, 0.0f)); + } + + auto* shadowTex = context.Get("shadow_depth_texture", nullptr); + auto* shadowSamp = context.Get("shadow_depth_sampler", nullptr); + + SDL_BindGPUGraphicsPipeline(pass, pipeline); + DrawMd3Surfaces(prefix, frame, model, view, proj, camPos, shadowVP, fu, + pass, cmd, shadowTex, shadowSamp, context); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_md3_load_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_md3_load_step.cpp new file mode 100644 index 000000000..f945f9b0f --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_md3_load_step.cpp @@ -0,0 +1,353 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_md3_load_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +// MD3 XYZ scale: Q3 stores vertices as int16 * (1/64) = Q3 world units +constexpr float kMd3XyzScale = 1.0f / 64.0f; + +// BSP world scale: 1 Q3 world unit → 0.03125 engine units (matches bsp.load scale=0.03125) +// Combined scale: raw_xyz * kMd3XyzScale * kMd3WorldScale → engine units +constexpr float kMd3WorldScale = 0.03125f; + +#pragma pack(push, 1) +struct Md3Header { + int32_t ident, version; + char name[64]; + int32_t flags, numFrames, numTags, numSurfaces, numSkins; + int32_t ofsFrames, ofsTags, ofsSurfaces, ofsEnd; +}; +struct Md3Tag { + char name[64]; + float origin[3]; + float axis[3][3]; // row-major: axis[0]=right, axis[1]=fwd, axis[2]=up in Q3 space +}; +struct Md3Surface { + int32_t ident; + char name[64]; + int32_t flags, numFrames, numShaders, numVerts, numTriangles; + int32_t ofsTriangles, ofsShaders, ofsSt, ofsXyzNormals, ofsEnd; +}; +struct Md3Triangle { int32_t indexes[3]; }; +struct Md3Shader { char name[64]; int32_t shaderIndex; }; +struct Md3St { float st[2]; }; +struct Md3XyzNormal { int16_t xyz[3]; int16_t normal; }; +#pragma pack(pop) + +// ── PK3 helpers ────────────────────────────────────────────────────────────── + +static std::vector ReadFromPk3(const std::string& pk3, const std::string& entry) { + int err = 0; + zip_t* arc = zip_open(pk3.c_str(), ZIP_RDONLY, &err); + if (!arc) return {}; + zip_stat_t st; + if (zip_stat(arc, entry.c_str(), 0, &st) != 0) { zip_close(arc); return {}; } + std::vector buf(st.size); + zip_file_t* zf = zip_fopen(arc, entry.c_str(), 0); + if (!zf) { zip_close(arc); return {}; } + zip_fread(zf, buf.data(), st.size); + zip_fclose(zf); + zip_close(arc); + return buf; +} + +// ── GPU upload helpers ─────────────────────────────────────────────────────── + +static SDL_GPUBuffer* UploadBuffer(SDL_GPUDevice* dev, SDL_GPUBufferUsageFlags usage, + const void* data, uint32_t size) { + SDL_GPUBufferCreateInfo bi = {}; bi.usage = usage; bi.size = size; + auto* buf = SDL_CreateGPUBuffer(dev, &bi); + if (!buf) return nullptr; + SDL_GPUTransferBufferCreateInfo tbi = {}; + tbi.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; tbi.size = size; + auto* tb = SDL_CreateGPUTransferBuffer(dev, &tbi); + std::memcpy(SDL_MapGPUTransferBuffer(dev, tb, false), data, size); + SDL_UnmapGPUTransferBuffer(dev, tb); + auto* cmd = SDL_AcquireGPUCommandBuffer(dev); + auto* cp = SDL_BeginGPUCopyPass(cmd); + SDL_GPUTransferBufferLocation src = {tb, 0}; + SDL_GPUBufferRegion dst = {buf, 0, size}; + SDL_UploadToGPUBuffer(cp, &src, &dst, false); + SDL_EndGPUCopyPass(cp); SDL_SubmitGPUCommandBuffer(cmd); + SDL_ReleaseGPUTransferBuffer(dev, tb); + return buf; +} + +static SDL_GPUTexture* UploadTexture(SDL_GPUDevice* dev, const uint8_t* px, int w, int h) { + SDL_GPUTextureCreateInfo ti = {}; + ti.type = SDL_GPU_TEXTURETYPE_2D; + ti.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + ti.width = (uint32_t)w; ti.height = (uint32_t)h; + ti.layer_count_or_depth = 1; ti.num_levels = 1; + ti.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER; + auto* tex = SDL_CreateGPUTexture(dev, &ti); + if (!tex) return nullptr; + uint32_t bytes = (uint32_t)(w * h * 4); + SDL_GPUTransferBufferCreateInfo tbi = {}; + tbi.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; tbi.size = bytes; + auto* tb = SDL_CreateGPUTransferBuffer(dev, &tbi); + std::memcpy(SDL_MapGPUTransferBuffer(dev, tb, false), px, bytes); + SDL_UnmapGPUTransferBuffer(dev, tb); + auto* cmd = SDL_AcquireGPUCommandBuffer(dev); + auto* cp = SDL_BeginGPUCopyPass(cmd); + SDL_GPUTextureTransferInfo src = {}; src.transfer_buffer = tb; + src.pixels_per_row = (uint32_t)w; src.rows_per_layer = (uint32_t)h; + SDL_GPUTextureRegion dst = {}; + dst.texture = tex; dst.w = (uint32_t)w; dst.h = (uint32_t)h; dst.d = 1; + SDL_UploadToGPUTexture(cp, &src, &dst, false); + SDL_EndGPUCopyPass(cp); SDL_SubmitGPUCommandBuffer(cmd); + SDL_ReleaseGPUTransferBuffer(dev, tb); + return tex; +} + +static SDL_GPUSampler* MakeLinearSampler(SDL_GPUDevice* dev) { + SDL_GPUSamplerCreateInfo si = {}; + si.min_filter = SDL_GPU_FILTER_LINEAR; si.mag_filter = SDL_GPU_FILTER_LINEAR; + si.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_LINEAR; + si.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_REPEAT; + si.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_REPEAT; + si.address_mode_w = SDL_GPU_SAMPLERADDRESSMODE_REPEAT; + return SDL_CreateGPUSampler(dev, &si); +} + +static SDL_GPUTexture* TryLoadTexture(SDL_GPUDevice* dev, const std::string& pk3, + const std::vector& candidates) { + for (const auto& entry : candidates) { + auto raw = ReadFromPk3(pk3, entry); + if (raw.empty()) continue; + int w, h, ch; + unsigned char* px = stbi_load_from_memory(raw.data(), (int)raw.size(), &w, &h, &ch, 4); + if (!px) continue; + auto* tex = UploadTexture(dev, px, w, h); + stbi_image_free(px); + if (tex) return tex; + } + return nullptr; +} + +// ── animation.cfg parser ───────────────────────────────────────────────────── + +static nlohmann::json ParseAnimCfg(const std::vector& data) { + nlohmann::json result = nlohmann::json::array(); + std::string txt(data.begin(), data.end()); + std::istringstream ss(txt); + std::string line; + while (std::getline(ss, line)) { + auto cp = line.find("//"); + if (cp != std::string::npos) line = line.substr(0, cp); + std::istringstream ls(line); + int first, num, loop, fps; + if (ls >> first >> num >> loop >> fps) { + result.push_back({{"first", first}, {"num", num}, + {"loop", loop}, {"fps", fps}}); + } + } + return result; +} + +} // namespace + +// ── Step implementation ────────────────────────────────────────────────────── + +WorkflowQ3Md3LoadStep::WorkflowQ3Md3LoadStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3Md3LoadStep::GetPluginId() const { return "q3.md3.load"; } + +void WorkflowQ3Md3LoadStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + WorkflowStepParameterResolver params; + auto getStr = [&](const char* k, const std::string& def) -> std::string { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::String) ? p->stringValue : def; + }; + + const std::string prefix = getStr("prefix", "model"); + const std::string path = getStr("path", ""); + const std::string skin = getStr("skin", ""); + const std::string anim = getStr("anim", ""); + + // Idempotent: skip if already loaded + if (context.Get("q3.md3." + prefix + "_num_frames", -1) >= 0) return; + + // Read pk3 path from bsp_config (set by bsp.load step) + const auto bspCfg = context.Get("bsp_config", nlohmann::json{}); + const std::string pk3 = bspCfg.value("pk3_path", std::string("")); + auto* device = context.Get("gpu_device", nullptr); + if (pk3.empty() || !device || path.empty()) { + if (logger_) logger_->Warn("q3.md3.load[" + prefix + "]: pk3/device/path missing"); + return; + } + + auto md3raw = ReadFromPk3(pk3, path); + if (md3raw.size() < sizeof(Md3Header)) { + if (logger_) logger_->Warn("q3.md3.load[" + prefix + "]: cannot read " + path); + return; + } + + const uint8_t* base = md3raw.data(); + const auto& hdr = *reinterpret_cast(base); + const int nFrames = hdr.numFrames, nTags = hdr.numTags, nSurfs = hdr.numSurfaces; + if (nFrames <= 0 || nSurfs <= 0) return; + + // ── Tags: convert from Q3 Z-up → engine Y-up ───────────────────────────── + // Q3 tag axis[i] are rows of the rotation matrix (right, forward, up in Q3 space). + // In engine space: x'=x, y'=z, z'=-y (for both origin and each basis vector). + nlohmann::json tagsJson = nlohmann::json::array(); + if (nTags > 0 && hdr.ofsTags > 0) { + const auto* rawTags = reinterpret_cast(base + hdr.ofsTags); + for (int f = 0; f < nFrames; ++f) { + nlohmann::json frameObj = nlohmann::json::object(); + for (int t = 0; t < nTags; ++t) { + const auto& tag = rawTags[f * nTags + t]; + std::string tname(tag.name, strnlen(tag.name, 64)); + + // Origin in engine coords (scaled to match BSP world) + nlohmann::json tagObj; + tagObj["origin"] = nlohmann::json::array({ + tag.origin[0] * kMd3XyzScale * kMd3WorldScale, + tag.origin[2] * kMd3XyzScale * kMd3WorldScale, // Q3 Z → eng Y + -tag.origin[1] * kMd3XyzScale * kMd3WorldScale // -Q3 Y → eng Z + }); + + // Axis rows converted to engine space + nlohmann::json axisArr = nlohmann::json::array(); + for (int i = 0; i < 3; ++i) { + axisArr.push_back(nlohmann::json::array({ + tag.axis[i][0], + tag.axis[i][2], // Q3 Z → eng Y + -tag.axis[i][1] // -Q3 Y → eng Z + })); + } + tagObj["axis"] = axisArr; + frameObj[tname] = tagObj; + } + tagsJson.push_back(frameObj); + } + } + context.Set("q3.md3." + prefix + "_tags", tagsJson); + context.Set("q3.md3." + prefix + "_num_frames", nFrames); + context.Set("q3.md3." + prefix + "_num_surfs", nSurfs); + context.Set("q3.md3." + prefix + "_num_tags", nTags); + + // ── animation.cfg ───────────────────────────────────────────────────────── + if (!anim.empty()) { + auto animData = ReadFromPk3(pk3, anim); + if (!animData.empty()) + context.Set("q3.md3." + prefix + "_anim", ParseAnimCfg(animData)); + } + + // Derive the directory from path (for relative skin lookups) + std::string pathDir = path; + auto lastSlash = pathDir.rfind('/'); + if (lastSlash != std::string::npos) pathDir = pathDir.substr(0, lastSlash + 1); + + // ── Surfaces ────────────────────────────────────────────────────────────── + const uint8_t* surfPtr = base + hdr.ofsSurfaces; + + struct PosUv { float x, y, z, u, v; }; // matches pipeline "position_uv" + + for (int s = 0; s < nSurfs; ++s) { + const auto& surf = *reinterpret_cast(surfPtr); + const std::string sk = "q3.md3." + prefix + "_surf" + std::to_string(s); + + if (surf.numVerts <= 0 || surf.numTriangles <= 0) { + surfPtr += surf.ofsEnd; continue; + } + + // Index buffer (uint16) — MD3 triangles are already CCW (OpenGL convention), no swap needed + const auto* tris = reinterpret_cast(surfPtr + surf.ofsTriangles); + std::vector indices; + indices.reserve(surf.numTriangles * 3); + for (int i = 0; i < surf.numTriangles; ++i) { + indices.push_back((uint16_t)tris[i].indexes[0]); + indices.push_back((uint16_t)tris[i].indexes[1]); + indices.push_back((uint16_t)tris[i].indexes[2]); + } + auto* ib = UploadBuffer(device, SDL_GPU_BUFFERUSAGE_INDEX, + indices.data(), (uint32_t)(indices.size() * 2)); + context.Set(sk + "_ib", ib); + context.Set(sk + "_num_idx", (int)indices.size()); + + // UV coordinates (shared across all frames) + const auto* uvs = reinterpret_cast(surfPtr + surf.ofsSt); + + // Per-frame vertex buffers: decode int16 xyz → float, convert coords, bake scale + const auto* xyzn = reinterpret_cast(surfPtr + surf.ofsXyzNormals); + const int nv = surf.numVerts; + std::vector verts((size_t)nv); + + for (int f = 0; f < nFrames; ++f) { + const Md3XyzNormal* fxyz = xyzn + f * nv; + for (int vi = 0; vi < nv; ++vi) { + // Q3 Z-up → engine Y-up, scaled to BSP world + verts[vi].x = fxyz[vi].xyz[0] * kMd3XyzScale * kMd3WorldScale; + verts[vi].y = fxyz[vi].xyz[2] * kMd3XyzScale * kMd3WorldScale; + verts[vi].z = -fxyz[vi].xyz[1] * kMd3XyzScale * kMd3WorldScale; + verts[vi].u = uvs[vi].st[0]; + verts[vi].v = uvs[vi].st[1]; + } + auto* vb = UploadBuffer(device, SDL_GPU_BUFFERUSAGE_VERTEX, + verts.data(), (uint32_t)(nv * sizeof(PosUv))); + context.Set(sk + "_f" + std::to_string(f) + "_vb", vb); + } + + // ── Texture: prefer shader name from surface, then path-derived candidates ── + std::string shaderName; + if (surf.numShaders > 0) { + const auto* sh = reinterpret_cast(surfPtr + surf.ofsShaders); + shaderName = std::string(sh[0].name, strnlen(sh[0].name, 64)); + } + std::vector texCandidates; + if (!shaderName.empty()) { + texCandidates.push_back(shaderName + ".tga"); + texCandidates.push_back(shaderName + ".jpg"); + texCandidates.push_back(shaderName); + } + if (!skin.empty()) { + texCandidates.push_back(skin); + auto dot = skin.rfind('.'); + if (dot != std::string::npos) + texCandidates.push_back(skin.substr(0, dot) + ".jpg"); + } + // Fallback: same name as MD3 but with texture extensions + { + std::string noext = path; + auto dot = noext.rfind('.'); + if (dot != std::string::npos) noext = noext.substr(0, dot); + texCandidates.push_back(noext + ".tga"); + texCandidates.push_back(noext + ".jpg"); + } + + auto* tex = TryLoadTexture(device, pk3, texCandidates); + if (!tex) { + // 1×1 grey fallback + const uint8_t grey[4] = {128, 128, 128, 255}; + tex = UploadTexture(device, grey, 1, 1); + } + auto* samp = MakeLinearSampler(device); + context.Set(sk + "_tex", tex); + context.Set(sk + "_samp", samp); + + surfPtr += surf.ofsEnd; + } + + if (logger_) logger_->Info("q3.md3.load[" + prefix + "]: loaded " + + path + " (" + std::to_string(nSurfs) + " surfs, " + + std::to_string(nFrames) + " frames)"); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp index 86ee5df81..4cd2172ac 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp @@ -53,7 +53,7 @@ void WorkflowQ3MenuUpdateStep::Execute(const WorkflowStepDefinition& step, Workf const std::string defaultScreen = config_.value("default_screen", std::string("main")); // --- toggle open/close --- - bool open = context.GetBool("q3.menu_open", true); + bool open = context.GetBool("q3.menu_open", false); const bool escPressed = context.GetBool("input_key_escape_pressed", false); if (escPressed) { if (open) { diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp index e91305551..6a0aa98a6 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_postfx_composite_step.cpp @@ -329,6 +329,50 @@ void WorkflowPostfxCompositeStep::Execute( if (!cmd || !pipeline || !hdr_texture || !sampler || !swapchain_tex) { if (logger_) logger_->Warn("postfx.composite: Missing required resources"); if (cmd) { + // GPU screenshot: before submitting, blit swapchain to a download buffer + // so the screenshot captures the full 3D scene (not just the CPU overlay). + auto* gswap = context.Get("gpu_swapchain_texture", nullptr); + const auto* ssPath = context.TryGet("screenshot_output_path"); + if (device && gswap && ssPath && !ssPath->empty()) { + const uint32_t sw = context.Get("frame_width", 1); + const uint32_t sh = context.Get("frame_height", 1); + SDL_GPUTransferBufferCreateInfo tbci = {}; + tbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD; + tbci.size = sw * sh * 4; + auto* tbuf = SDL_CreateGPUTransferBuffer(device, &tbci); + if (tbuf) { + auto* copy = SDL_BeginGPUCopyPass(cmd); + if (copy) { + SDL_GPUTextureRegion src = {}; + src.texture = gswap; + src.w = sw; src.h = sh; src.d = 1; + SDL_GPUTextureTransferInfo dst = {}; + dst.transfer_buffer = tbuf; + dst.pixels_per_row = sw; + dst.rows_per_layer = sh; + SDL_DownloadFromGPUTexture(copy, &src, &dst); + SDL_EndGPUCopyPass(copy); + } + SDL_SubmitGPUCommandBuffer(cmd); + SDL_WaitForGPUIdle(device); + void* mapped = SDL_MapGPUTransferBuffer(device, tbuf, false); + if (mapped) { + SDL_Surface* surf = SDL_CreateSurfaceFrom( + (int)sw, (int)sh, SDL_PIXELFORMAT_ABGR8888, + mapped, (int)(sw * 4)); + if (surf) { + SDL_SaveBMP(surf, ssPath->c_str()); + SDL_DestroySurface(surf); + if (logger_) logger_->Info("postfx.composite: GPU screenshot saved to " + *ssPath); + } + SDL_UnmapGPUTransferBuffer(device, tbuf); + } + SDL_ReleaseGPUTransferBuffer(device, tbuf); + context.Set("screenshot_output_path", std::string("")); + context.Remove("gpu_command_buffer"); + return; + } + } SDL_SubmitGPUCommandBuffer(cmd); context.Remove("gpu_command_buffer"); } diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index 7a110774c..b39609788 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -118,6 +118,11 @@ #include "services/interfaces/workflow/quake3/workflow_q3_weapon_update_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_overlay_draw_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_pickups_draw_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_md3_load_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_md3_draw_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_bots_spawn_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_bots_draw_step.hpp" // Audio (service-dependent, registered with nullptr) #include "services/interfaces/workflow/workflow_generic_steps/workflow_audio_pause_step.hpp" @@ -327,7 +332,12 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr reg registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); - count += 18; + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + count += 23; // ── Texture ─────────────────────────────────────────────── registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_draw_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_draw_step.hpp new file mode 100644 index 000000000..a66bc9340 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_draw_step.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.bots.draw + * + * Renders each bot as a three-part player model chain: + * lower.md3 (legs) at bot world position + * → upper.md3 (torso) attached via lower's tag_torso + * → head.md3 attached via upper's tag_head + * → weapon.md3 attached via upper's tag_weapon (optional) + * + * Parameters: + * lower_prefix (string) – MD3 prefix for lower.md3 (default "lower") + * upper_prefix (string) – MD3 prefix for upper.md3 (default "upper") + * head_prefix (string) – MD3 prefix for head.md3 (default "head") + * weapon_prefix (string) – MD3 prefix for weapon (default "weapon_mg") + * + * Reads: q3.bots, gpu_render_pass, gpu_command_buffer, gpu_pipeline_textured, + * render.* matrices, q3.md3.{prefix}_* from q3.md3.load + */ +class WorkflowQ3BotsDrawStep final : public IWorkflowStep { +public: + explicit WorkflowQ3BotsDrawStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_spawn_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_spawn_step.hpp new file mode 100644 index 000000000..f6f8dcbae --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_spawn_step.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.bots.spawn + * + * One-time step: reads BSP spawn points (info_player_deathmatch entities) + * and creates bot entities spaced around the map. + * + * Parameters: + * count (int) – number of bots to spawn (default 4) + * + * Context writes: + * q3.bots nlohmann::json array of bot objects: + * { id, pos:[x,y,z], yaw, health, state, leg_frame, torso_frame, weapon } + */ +class WorkflowQ3BotsSpawnStep final : public IWorkflowStep { +public: + explicit WorkflowQ3BotsSpawnStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp new file mode 100644 index 000000000..2b140d725 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.bots.update + * + * Per-frame bot AI: chase player, face player, animate legs/torso. + * Bots transition: idle ↔ chase ↔ shoot. + * + * Parameters: + * chase_range (float) – distance to start chasing (default 20.0) + * shoot_range (float) – distance to start shooting (default 6.0) + * move_speed (float) – bot movement speed per second (default 3.0) + * leg_idle (int) – first frame of legs idle anim + * leg_run (int) – first frame of legs run anim + * leg_run_cnt (int) – frame count for run anim + * torso_stand (int) – first frame of torso stand + * torso_attack (int) – first frame of torso attack + * torso_atk_cnt(int) – frame count for attack anim + * + * Reads: camera.state (player position), q3.bots, frame.delta_time + * Writes: q3.bots (updated), q3.bot_shots (list of shot events) + */ +class WorkflowQ3BotsUpdateStep final : public IWorkflowStep { +public: + explicit WorkflowQ3BotsUpdateStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_md3_draw_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_md3_draw_step.hpp new file mode 100644 index 000000000..bf0872707 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_md3_draw_step.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.md3.draw + * + * Renders all surfaces of a named MD3 model at a world transform. + * Supports both free-standing (world-space position + yaw) and + * viewmodel (camera-relative) modes. + * + * Parameters: + * prefix (string) – context namespace from q3.md3.load ("lower", "upper", etc.) + * pos_key (string) – context key holding position as JSON [x,y,z] (world mode) + * yaw_key (string) – context key holding yaw float in radians (world mode) + * frame_key (string) – override frame index from context (optional) + * fps (float) – animation playback FPS (default 15) + * anim_first (int) – first frame of animation clip + * anim_count (int) – frame count (0 = loop all frames) + * viewmodel (bool) – if true, render camera-relative (weapon viewmodel) + * vm_right (float) – viewmodel right offset (default 0.35) + * vm_down (float) – viewmodel down offset (default -0.3) + * vm_fwd (float) – viewmodel forward offset (default 0.5) + * + * Reads from context: + * gpu_render_pass, gpu_command_buffer, gpu_pipeline_textured + * render.view_matrix, render.proj_matrix, render.camera_pos + * render.shadow_vp, render.frag_uniforms, frame.elapsed + */ +class WorkflowQ3Md3DrawStep final : public IWorkflowStep { +public: + explicit WorkflowQ3Md3DrawStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_md3_load_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_md3_load_step.hpp new file mode 100644 index 000000000..830b5e035 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_md3_load_step.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.md3.load + * + * Reads an MD3 model from the Q3 PK3 archive, decodes all animation frames, + * and uploads per-frame vertex buffers + one index buffer per surface to the GPU. + * Coordinates are converted from Q3 Z-up to the engine's Y-up system. + * + * Parameters: + * prefix (string) – context key namespace for this model, e.g. "lower" + * path (string) – PK3 entry path, e.g. "models/players/keel/lower.md3" + * skin (string) – optional explicit skin texture PK3 path + * anim (string) – optional animation.cfg PK3 path + * + * Context outputs (prefix = e.g. "lower"): + * q3.md3.{prefix}_num_frames int + * q3.md3.{prefix}_num_surfs int + * q3.md3.{prefix}_num_tags int + * q3.md3.{prefix}_tags nlohmann::json (array[frame] → map tag_name → {origin,axis}) + * q3.md3.{prefix}_anim nlohmann::json (parsed animation.cfg, optional) + * q3.md3.{prefix}_surf{s}_ib SDL_GPUBuffer* + * q3.md3.{prefix}_surf{s}_num_idx int + * q3.md3.{prefix}_surf{s}_f{f}_vb SDL_GPUBuffer* + * q3.md3.{prefix}_surf{s}_tex SDL_GPUTexture* + * q3.md3.{prefix}_surf{s}_samp SDL_GPUSampler* + * + * Reads from context: + * q3.pk3_path string – path to pak0.pk3 (set by bsp.load) + * gpu_device SDL_GPUDevice* + */ +class WorkflowQ3Md3LoadStep final : public IWorkflowStep { +public: + explicit WorkflowQ3Md3LoadStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl