diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 5c673b33d..457e47c78 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -189,6 +189,7 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/workflow_definition_parser.cpp src/services/impl/workflow/workflow_definition_parser_nodes.cpp src/services/impl/workflow/workflow_definition_parser_variables.cpp + src/services/impl/workflow/workflow_definition_parser_includes.cpp src/services/impl/workflow/workflow_connection_resolver.cpp src/services/impl/workflow/workflow_execute_step.cpp src/stb_image.cpp @@ -285,7 +286,13 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/geometry/workflow_geometry_create_plane_step.cpp src/services/impl/workflow/geometry/workflow_geometry_cube_generate_step.cpp src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp - src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp + src/services/impl/workflow/rendering/workflow_overlay_sw_begin_step.cpp + src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp + src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp + src/services/impl/workflow/quake3/workflow_q3_crosshair_step.cpp + src/services/impl/workflow/quake3/workflow_q3_hitmarker_step.cpp + src/services/impl/workflow/quake3/workflow_q3_menu_frame_step.cpp + src/services/impl/workflow/quake3/workflow_q3_mapselect_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 diff --git a/gameengine/packages/quake3/workflows/q3_combat.json b/gameengine/packages/quake3/workflows/q3_combat.json new file mode 100644 index 000000000..043e76aa0 --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_combat.json @@ -0,0 +1,20 @@ +{ + "name": "Q3 Combat", + "description": "Weapon fire + bot AI update.", + "steps": [ + { + "id": "q3_weapon", + "plugin": "q3.weapon.update" + }, + { + "id": "q3_bots_update", + "plugin": "q3.bots.update", + "parameters": { + "chase_range": 20.0, + "shoot_range": 6.0, + "move_speed": 3.5, + "shoot_interval": 30 + } + } + ] +} diff --git a/gameengine/packages/quake3/workflows/q3_frame.json b/gameengine/packages/quake3/workflows/q3_frame.json index 35469f7a2..479f1502c 100644 --- a/gameengine/packages/quake3/workflows/q3_frame.json +++ b/gameengine/packages/quake3/workflows/q3_frame.json @@ -1,273 +1,36 @@ { "name": "Q3 Frame Tick", - "description": "Per-frame: poll input, FPS move, render BSP map with player models, bots, and weapon.", - "nodes": [ + "description": "Per-frame composition: each section is a reusable sub-workflow included like a React component.", + "steps": [ { - "id": "input_poll", - "type": "input.poll", - "typeVersion": 1, - "position": [0, 0] + "id": "input_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_input.json" } }, { - "id": "q3_menu", - "type": "q3.menu.update", - "typeVersion": 1, - "position": [100, 0] + "id": "physics_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_physics.json" } }, { - "id": "should_stop", - "type": "bool.or", - "typeVersion": 1, - "position": [140, 0], - "inputs": { "left": "q3.menu_quit_pressed", "right": "q3.menu_map_selected" }, - "outputs": { "value": "q3.stop_game" } + "id": "combat_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_combat.json" } }, { - "id": "stop_game_loop", - "type": "value.set_if", - "typeVersion": 1, - "position": [160, 0], - "inputs": { "condition": "q3.stop_game" }, - "parameters": { "value": false }, - "outputs": { "value": "game_running" } + "id": "render_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_render.json" } }, { - "id": "set_quit_requested", - "type": "value.set_if", - "typeVersion": 1, - "position": [170, 0], - "inputs": { "condition": "q3.menu_quit_pressed" }, - "parameters": { "value": true }, - "outputs": { "value": "q3.quit_requested" } + "id": "overlay_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_overlay.json" } }, { - "id": "compute_movement_active", - "type": "bool.not", - "typeVersion": 1, - "position": [190, 0], - "inputs": { "value": "q3.menu_open" }, - "outputs": { "value": "movement_active" } - }, - { - "id": "update_mouse_grab", - "type": "input.mouse.grab", - "typeVersion": 1, - "position": [195, 0], - "inputs": { "enabled": "movement_active" } - }, - { - "id": "physics_move", - "type": "physics.fps.move", - "typeVersion": 1, - "position": [200, 0], - "parameters": { - "move_speed": 6.5, - "sprint_multiplier": 1.5, - "crouch_multiplier": 0.45, - "jump_velocity": 5.5, - "air_control": 0.25, - "gravity_scale": 0.5, - "ground_accel": 30.0, - "ground_friction": 24.0, - "step_height": 0.6, - "crouch_height": 0.5, - "stand_height": 1.4 - } - }, - { - "id": "physics_step", - "type": "physics.step", - "typeVersion": 1, - "position": [400, 0] - }, - { - "id": "sync_transforms", - "type": "physics.sync_transforms", - "typeVersion": 1, - "position": [500, 0] - }, - { - "id": "bsp_entities_update", - "type": "bsp.entities.update", - "typeVersion": 1, - "position": [550, 0] - }, - { - "id": "camera_update", - "type": "camera.fps.update", - "typeVersion": 1, - "position": [600, 0], - "parameters": { - "sensitivity": 0.003, - "eye_height": 1.4, - "fov": 90.0, - "near": 0.1, - "far": 500.0 - } - }, - { - "id": "q3_weapon", - "type": "q3.weapon.update", - "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", - "typeVersion": 1, - "position": [750, 0] - }, - { - "id": "portal_view", - "type": "bsp.portal_view", - "typeVersion": 1, - "position": [775, 0] - }, - { - "id": "frame_begin", - "type": "frame.gpu.begin", - "typeVersion": 1, - "position": [800, 0], - "parameters": { - "clear_r": 0.3, - "clear_g": 0.5, - "clear_b": 0.8 - } - }, - { - "id": "draw_map", - "type": "draw.map", - "typeVersion": 1, - "position": [1000, 0], - "parameters": { - "default_texture": "walls_texture", - "pipeline_key": "gpu_pipeline_bsp", - "roughness": 0.7, - "metallic": 0.0 - } - }, - { - "id": "draw_pickups", - "type": "q3.pickups.draw", - "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", - "typeVersion": 1, - "position": [1200, 0] - }, - { - "id": "overlay_fps", - "type": "overlay.fps", - "typeVersion": 1, - "position": [1225, 0] - }, - { - "id": "q3_overlay", - "type": "q3.overlay.draw", - "typeVersion": 1, - "position": [1235, 0] - }, - { - "id": "postfx_taa", - "type": "postfx.taa", - "typeVersion": 1, - "position": [1250, 0], - "parameters": { "blend_factor": 0.05 } - }, - { - "id": "postfx_ssao", - "type": "postfx.ssao", - "typeVersion": 1, - "position": [1300, 0] - }, - { - "id": "bloom_extract", - "type": "postfx.bloom_extract", - "typeVersion": 1, - "position": [1400, 0] - }, - { - "id": "bloom_blur", - "type": "postfx.bloom_blur", - "typeVersion": 1, - "position": [1500, 0] - }, - { - "id": "postfx_composite", - "type": "postfx.composite", - "typeVersion": 1, - "position": [1600, 0] + "id": "postfx_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_postfx.json" } } - ], - "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": "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_input.json b/gameengine/packages/quake3/workflows/q3_input.json new file mode 100644 index 000000000..007d74151 --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_input.json @@ -0,0 +1,45 @@ +{ + "name": "Q3 Input & Menu", + "description": "Poll input, update menu state, compute movement gate.", + "steps": [ + { + "id": "input_poll", + "plugin": "input.poll" + }, + { + "id": "q3_menu", + "plugin": "q3.menu.update" + }, + { + "id": "should_stop", + "plugin": "bool.or", + "inputs": { "left": "q3.menu_quit_pressed", "right": "q3.menu_map_selected" }, + "outputs": { "value": "q3.stop_game" } + }, + { + "id": "stop_game_loop", + "plugin": "value.set_if", + "inputs": { "condition": "q3.stop_game" }, + "parameters": { "value": false }, + "outputs": { "value": "game_running" } + }, + { + "id": "set_quit_requested", + "plugin": "value.set_if", + "inputs": { "condition": "q3.menu_quit_pressed" }, + "parameters": { "value": true }, + "outputs": { "value": "q3.quit_requested" } + }, + { + "id": "compute_movement_active", + "plugin": "bool.not", + "inputs": { "value": "q3.menu_open" }, + "outputs": { "value": "movement_active" } + }, + { + "id": "update_mouse_grab", + "plugin": "input.mouse.grab", + "inputs": { "enabled": "movement_active" } + } + ] +} diff --git a/gameengine/packages/quake3/workflows/q3_overlay.json b/gameengine/packages/quake3/workflows/q3_overlay.json new file mode 100644 index 000000000..8b09b81bf --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_overlay.json @@ -0,0 +1,44 @@ +{ + "name": "Q3 Overlay", + "description": "SDL software overlay: FPS counter, HUD, crosshair, hitmarker, menu, map-select. Composable like a React component.", + "steps": [ + { + "id": "overlay_fps", + "plugin": "overlay.fps" + }, + { + "id": "overlay_begin", + "plugin": "overlay.sw.begin", + "parameters": { + "vert_shader_path_msl": "packages/quake3/shaders/msl/overlay.vert.metal", + "frag_shader_path_msl": "packages/quake3/shaders/msl/overlay.frag.metal", + "vert_shader_path_spirv": "packages/quake3/shaders/spirv/overlay.vert.spv", + "frag_shader_path_spirv": "packages/quake3/shaders/spirv/overlay.frag.spv" + } + }, + { + "id": "q3_hud", + "plugin": "q3.hud" + }, + { + "id": "q3_crosshair", + "plugin": "q3.crosshair" + }, + { + "id": "q3_hitmarker", + "plugin": "q3.hitmarker" + }, + { + "id": "q3_menu_frame", + "plugin": "q3.menu.frame" + }, + { + "id": "q3_mapselect", + "plugin": "q3.mapselect" + }, + { + "id": "overlay_end", + "plugin": "overlay.sw.end" + } + ] +} diff --git a/gameengine/packages/quake3/workflows/q3_physics.json b/gameengine/packages/quake3/workflows/q3_physics.json new file mode 100644 index 000000000..ea4038b5a --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_physics.json @@ -0,0 +1,46 @@ +{ + "name": "Q3 Physics", + "description": "FPS movement, physics step, transform sync, BSP entity update, camera.", + "steps": [ + { + "id": "physics_move", + "plugin": "physics.fps.move", + "parameters": { + "move_speed": 6.5, + "sprint_multiplier": 1.5, + "crouch_multiplier": 0.45, + "jump_velocity": 5.5, + "air_control": 0.25, + "gravity_scale": 0.5, + "ground_accel": 30.0, + "ground_friction": 24.0, + "step_height": 0.6, + "crouch_height": 0.5, + "stand_height": 1.4 + } + }, + { + "id": "physics_step", + "plugin": "physics.step" + }, + { + "id": "sync_transforms", + "plugin": "physics.sync_transforms" + }, + { + "id": "bsp_entities_update", + "plugin": "bsp.entities.update" + }, + { + "id": "camera_update", + "plugin": "camera.fps.update", + "parameters": { + "sensitivity": 0.003, + "eye_height": 1.4, + "fov": 90.0, + "near": 0.1, + "far": 500.0 + } + } + ] +} diff --git a/gameengine/packages/quake3/workflows/q3_postfx.json b/gameengine/packages/quake3/workflows/q3_postfx.json new file mode 100644 index 000000000..6a858dc5a --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_postfx.json @@ -0,0 +1,27 @@ +{ + "name": "Q3 Post FX", + "description": "TAA, SSAO, bloom extract+blur, composite.", + "steps": [ + { + "id": "postfx_taa", + "plugin": "postfx.taa", + "parameters": { "blend_factor": 0.05 } + }, + { + "id": "postfx_ssao", + "plugin": "postfx.ssao" + }, + { + "id": "bloom_extract", + "plugin": "postfx.bloom_extract" + }, + { + "id": "bloom_blur", + "plugin": "postfx.bloom_blur" + }, + { + "id": "postfx_composite", + "plugin": "postfx.composite" + } + ] +} diff --git a/gameengine/packages/quake3/workflows/q3_render.json b/gameengine/packages/quake3/workflows/q3_render.json new file mode 100644 index 000000000..f83b19a14 --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_render.json @@ -0,0 +1,63 @@ +{ + "name": "Q3 3D Render", + "description": "Prepare render state, cull with portal PVS, GPU frame, draw world + bots + viewmodel.", + "steps": [ + { + "id": "render_prepare", + "plugin": "render.prepare" + }, + { + "id": "portal_view", + "plugin": "bsp.portal_view" + }, + { + "id": "frame_begin", + "plugin": "frame.gpu.begin", + "parameters": { + "clear_r": 0.3, + "clear_g": 0.5, + "clear_b": 0.8 + } + }, + { + "id": "draw_map", + "plugin": "draw.map", + "parameters": { + "default_texture": "walls_texture", + "pipeline_key": "gpu_pipeline_bsp", + "roughness": 0.7, + "metallic": 0.0 + } + }, + { + "id": "draw_pickups", + "plugin": "q3.pickups.draw" + }, + { + "id": "draw_bots", + "plugin": "q3.bots.draw", + "parameters": { + "lower_prefix": "lower", + "upper_prefix": "upper", + "head_prefix": "head", + "weapon_prefix": "weapon_mg" + } + }, + { + "id": "draw_weapon_viewmodel", + "plugin": "q3.md3.draw", + "parameters": { + "prefix": "weapon_mg", + "viewmodel": true, + "vm_right": 0.25, + "vm_down": -0.22, + "vm_fwd": 0.45, + "fps": 15.0 + } + }, + { + "id": "end_scene", + "plugin": "frame.gpu.end_scene" + } + ] +} diff --git a/gameengine/packages/quake3_screenshot/workflows/q3_frame.json b/gameengine/packages/quake3_screenshot/workflows/q3_frame.json index d67bc6e7f..3f2dd7c2d 100644 --- a/gameengine/packages/quake3_screenshot/workflows/q3_frame.json +++ b/gameengine/packages/quake3_screenshot/workflows/q3_frame.json @@ -1,311 +1,41 @@ { - "name": "Q3 Frame Tick", - "description": "Per-frame: poll input, FPS move, render BSP map with bots and weapon. Screenshots after 240 frames then exits.", - "nodes": [ + "name": "Q3 Frame Tick (Screenshot)", + "description": "Per-frame composition: shared Q3 sub-workflows + screenshot-check component. Exits after frame 240.", + "steps": [ { - "id": "input_poll", - "type": "input.poll", - "typeVersion": 1, - "position": [0, 0] + "id": "input_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_input.json" } }, { - "id": "q3_menu", - "type": "q3.menu.update", - "typeVersion": 1, - "position": [100, 0] + "id": "physics_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_physics.json" } }, { - "id": "should_stop", - "type": "bool.or", - "typeVersion": 1, - "position": [140, 0], - "inputs": { "left": "q3.menu_quit_pressed", "right": "q3.menu_map_selected" }, - "outputs": { "value": "q3.stop_game" } + "id": "combat_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_combat.json" } }, { - "id": "stop_game_loop", - "type": "value.set_if", - "typeVersion": 1, - "position": [160, 0], - "inputs": { "condition": "q3.stop_game" }, - "parameters": { "value": false }, - "outputs": { "value": "game_running" } + "id": "render_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_render.json" } }, { - "id": "set_quit_requested", - "type": "value.set_if", - "typeVersion": 1, - "position": [170, 0], - "inputs": { "condition": "q3.menu_quit_pressed" }, - "parameters": { "value": true }, - "outputs": { "value": "q3.quit_requested" } + "id": "screenshot_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3_screenshot/workflows/q3_screenshot_check.json" } }, { - "id": "compute_movement_active", - "type": "bool.not", - "typeVersion": 1, - "position": [190, 0], - "inputs": { "value": "q3.menu_open" }, - "outputs": { "value": "movement_active" } + "id": "overlay_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_overlay.json" } }, { - "id": "update_mouse_grab", - "type": "input.mouse.grab", - "typeVersion": 1, - "position": [195, 0], - "inputs": { "enabled": "movement_active" } - }, - { - "id": "physics_move", - "type": "physics.fps.move", - "typeVersion": 1, - "position": [200, 0], - "parameters": { - "move_speed": 6.5, - "sprint_multiplier": 1.5, - "crouch_multiplier": 0.45, - "jump_velocity": 5.5, - "air_control": 0.25, - "gravity_scale": 0.5, - "ground_accel": 30.0, - "ground_friction": 24.0, - "step_height": 0.6, - "crouch_height": 0.5, - "stand_height": 1.4 - } - }, - { - "id": "physics_step", - "type": "physics.step", - "typeVersion": 1, - "position": [400, 0] - }, - { - "id": "sync_transforms", - "type": "physics.sync_transforms", - "typeVersion": 1, - "position": [500, 0] - }, - { - "id": "bsp_entities_update", - "type": "bsp.entities.update", - "typeVersion": 1, - "position": [550, 0] - }, - { - "id": "camera_update", - "type": "camera.fps.update", - "typeVersion": 1, - "position": [600, 0], - "parameters": { - "sensitivity": 0.003, - "eye_height": 1.4, - "fov": 90.0, - "near": 0.1, - "far": 500.0 - } - }, - { - "id": "q3_weapon", - "type": "q3.weapon.update", - "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", - "typeVersion": 1, - "position": [750, 0] - }, - { - "id": "portal_view", - "type": "bsp.portal_view", - "typeVersion": 1, - "position": [775, 0] - }, - { - "id": "frame_begin", - "type": "frame.gpu.begin", - "typeVersion": 1, - "position": [800, 0], - "parameters": { - "clear_r": 0.3, - "clear_g": 0.5, - "clear_b": 0.8 - } - }, - { - "id": "draw_map", - "type": "draw.map", - "typeVersion": 1, - "position": [1000, 0], - "parameters": { - "default_texture": "walls_texture", - "pipeline_key": "gpu_pipeline_bsp", - "roughness": 0.7, - "metallic": 0.0 - } - }, - { - "id": "draw_pickups", - "type": "q3.pickups.draw", - "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", - "typeVersion": 1, - "position": [1200, 0] - }, - { - "id": "overlay_fps", - "type": "overlay.fps", - "typeVersion": 1, - "position": [1225, 0] - }, - { - "id": "q3_overlay", - "type": "q3.overlay.draw", - "typeVersion": 1, - "position": [1235, 0] - }, - { - "id": "postfx_taa", - "type": "postfx.taa", - "typeVersion": 1, - "position": [1250, 0], - "parameters": { "blend_factor": 0.05 } - }, - { - "id": "postfx_ssao", - "type": "postfx.ssao", - "typeVersion": 1, - "position": [1300, 0] - }, - { - "id": "bloom_extract", - "type": "postfx.bloom_extract", - "typeVersion": 1, - "position": [1400, 0] - }, - { - "id": "bloom_blur", - "type": "postfx.bloom_blur", - "typeVersion": 1, - "position": [1500, 0] - }, - { - "id": "postfx_composite", - "type": "postfx.composite", - "typeVersion": 1, - "position": [1600, 0] - }, - { - "id": "set_threshold", - "type": "value.literal", - "typeVersion": 1, - "position": [1650, 0], - "parameters": { "value": 240.0 }, - "outputs": { "value": "screenshot_threshold" } - }, - { - "id": "check_frame_240", - "type": "compare.gte", - "typeVersion": 1, - "position": [1700, 0], - "inputs": { "left": "loop.iteration", "right": "screenshot_threshold" }, - "outputs": { "value": "screenshot_due" } - }, - { - "id": "set_screenshot_path", - "type": "value.set_if", - "typeVersion": 1, - "position": [1750, 0], - "inputs": { "condition": "screenshot_due" }, - "parameters": { "value": "/tmp/q3_screenshot.bmp" }, - "outputs": { "value": "screenshot_output_path" } - }, - { - "id": "stop_after_screenshot", - "type": "value.set_if", - "typeVersion": 1, - "position": [1800, 0], - "inputs": { "condition": "screenshot_due" }, - "parameters": { "value": false }, - "outputs": { "value": "game_running" } + "id": "postfx_group", + "plugin": "workflow.include", + "parameters": { "path": "packages/quake3/workflows/q3_postfx.json" } } - ], - "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": "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_screenshot_check.json b/gameengine/packages/quake3_screenshot/workflows/q3_screenshot_check.json new file mode 100644 index 000000000..cb8c4ca79 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/workflows/q3_screenshot_check.json @@ -0,0 +1,39 @@ +{ + "name": "Q3 Screenshot Check", + "description": "After frame 240: write screenshot path, stop the frame loop, and signal quit so the outer loop exits to desktop.", + "steps": [ + { + "id": "set_threshold", + "plugin": "value.literal", + "parameters": { "value": 240.0 }, + "outputs": { "value": "screenshot_threshold" } + }, + { + "id": "check_frame_240", + "plugin": "compare.gte", + "inputs": { "left": "loop.iteration", "right": "screenshot_threshold" }, + "outputs": { "value": "screenshot_due" } + }, + { + "id": "set_screenshot_path", + "plugin": "value.set_if", + "inputs": { "condition": "screenshot_due" }, + "parameters": { "value": "/tmp/q3_screenshot.bmp" }, + "outputs": { "value": "screenshot_output_path" } + }, + { + "id": "stop_after_screenshot", + "plugin": "value.set_if", + "inputs": { "condition": "screenshot_due" }, + "parameters": { "value": false }, + "outputs": { "value": "game_running" } + }, + { + "id": "quit_after_screenshot", + "plugin": "value.set_if", + "inputs": { "condition": "screenshot_due" }, + "parameters": { "value": true }, + "outputs": { "value": "q3.quit_requested" } + } + ] +} diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_crosshair_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_crosshair_step.cpp new file mode 100644 index 000000000..16f64b0bc --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_crosshair_step.cpp @@ -0,0 +1,26 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include +namespace sdl3cpp::services::impl { +using namespace q3overlay; +WorkflowQ3CrosshairStep::WorkflowQ3CrosshairStep(std::shared_ptr l) : logger_(std::move(l)) {} +std::string WorkflowQ3CrosshairStep::GetPluginId() const { return "q3.crosshair"; } +void WorkflowQ3CrosshairStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) { + if (!context.GetBool("overlay.ready", false)) return; + if (context.GetBool("q3.menu_open", false)) return; + auto* r = context.Get("overlay.renderer", nullptr); + if (!r) return; + auto* ch = context.Get("overlay.tex.crosshair", nullptr); + constexpr float kSize = 24.f; + const float x = (kW - kSize) * 0.5f, y = (kH - kSize) * 0.5f; + if (ch) { + SDL_SetTextureAlphaMod(ch, 200); SDL_SetTextureColorMod(ch,255,255,255); + SDL_SetTextureBlendMode(ch, SDL_BLENDMODE_BLEND); + SDL_FRect dst{x, y, kSize, kSize}; SDL_RenderTexture(r, ch, nullptr, &dst); + } else { + SDL_SetRenderDrawColor(r, 255, 255, 255, 200); + SDL_FRect dot{(kW-4.f)*0.5f,(kH-4.f)*0.5f,4.f,4.f}; + SDL_RenderFillRect(r, &dot); + } +} +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hitmarker_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hitmarker_step.cpp new file mode 100644 index 000000000..883e3413b --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hitmarker_step.cpp @@ -0,0 +1,26 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include +#include +namespace sdl3cpp::services::impl { +using namespace q3overlay; +WorkflowQ3HitmarkerStep::WorkflowQ3HitmarkerStep(std::shared_ptr l) : logger_(std::move(l)) {} +std::string WorkflowQ3HitmarkerStep::GetPluginId() const { return "q3.hitmarker"; } +void WorkflowQ3HitmarkerStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) { + if (!context.GetBool("overlay.ready", false)) return; + if (context.GetBool("q3.menu_open", false)) return; + auto* r = context.Get("overlay.renderer", nullptr); + if (!r) return; + const auto frame = (uint32_t)context.GetDouble("loop.iteration", 0.0); + if (frame < context.Get("q3.hit_marker_until_frame", 0u)) { + SDL_SetRenderDrawColor(r, 255, 80, 55, 255); + SDL_RenderLine(r, 308,172,320,160); SDL_RenderLine(r,332,172,320,160); + SDL_RenderLine(r, 308,188,320,200); SDL_RenderLine(r,332,188,320,200); + } + if (frame < context.Get("q3.weapon_flash_until_frame", 0u)) { + SDL_SetRenderDrawColor(r, 255, 200, 60, 180); + SDL_FRect flash{kW - 40.f, kH * 0.58f, 24.f, 40.f}; + SDL_RenderFillRect(r, &flash); + } +} +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp new file mode 100644 index 000000000..995535f99 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp @@ -0,0 +1,71 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include +namespace sdl3cpp::services::impl { +using namespace q3overlay; + +WorkflowQ3HudStep::WorkflowQ3HudStep(std::shared_ptr l) : logger_(std::move(l)) {} +std::string WorkflowQ3HudStep::GetPluginId() const { return "q3.hud"; } + +void WorkflowQ3HudStep::Execute( + const WorkflowStepDefinition&, WorkflowContext& context) { + if (!context.GetBool("overlay.ready", false)) return; + if (context.GetBool("q3.menu_open", false)) return; + + auto* r = context.Get("overlay.renderer", nullptr); + if (!r) return; + + SDL_Texture* digits[11] = {}; + for (int i = 0; i < 11; ++i) + digits[i] = context.Get("overlay.tex.num." + std::to_string(i), nullptr); + + auto* iArmor = context.Get("overlay.tex.icon_armor", nullptr); + auto* iFace = context.Get("overlay.tex.icon_face", nullptr); + auto* iWeapon = context.Get("overlay.tex.icon_weapon", nullptr); + + const int health = context.Get("q3.player_health", 100); + const int armor = context.Get("q3.player_armor", 0); + const int ammo = context.Get("q3.player_ammo", 50); + + constexpr float kNS = 1.0f; // digit sprite scale → 32×32 px (Q3A native) + constexpr float kNH = 32.f * kNS; // digit height = 32 px + constexpr float kHudY = kH - kNH - 6.f; + + auto drawIcon = [&](SDL_Texture* t, float x, float y, float w, float h) { + if (!t) return; + SDL_SetTextureAlphaMod(t, 255); SDL_SetTextureColorMod(t, 255, 255, 255); + SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND); + SDL_FRect dst{x, y, w, h}; SDL_RenderTexture(r, t, nullptr, &dst); + }; + + auto digitWidth = [&](int val) -> float { + return (val >= 100 ? 3 : val >= 10 ? 2 : 1) * 32.f * kNS; + }; + + // ── Left cluster: [armor#] [armor icon] ───────────────────────────────── + // Real Q3A: armor sits at far left, icon to its right. + float cx = 10.f; + cx = DrawHudNumber(r, digits, cx, kHudY, armor, kNS); + drawIcon(iArmor, cx + 4.f, kHudY, kNH, kNH); + + // ── Center cluster: [health#] [face icon] ─────────────────────────────── + // Real Q3A: health number + mugshot centered on screen. + { + const float hNumW = digitWidth(health); + const float cluster = hNumW + 4.f + kNH; + const float hx = kW * 0.5f - cluster * 0.5f; + DrawHudNumber(r, digits, hx, kHudY, health, kNS); + drawIcon(iFace, hx + hNumW + 4.f, kHudY, kNH, kNH); + } + + // ── Right cluster: [ammo#] [weapon icon] ───────────────────────────────── + // Real Q3A: ammo number then weapon icon at far-right edge. + { + const float wIconW = iWeapon ? kNH : 0.f; + const float aNumW = digitWidth(ammo); + const float rx = kW - 10.f - wIconW - (wIconW > 0 ? 6.f : 0.f) - aNumW; + DrawHudNumber(r, digits, rx, kHudY, ammo, kNS); + drawIcon(iWeapon, rx + aNumW + 6.f, kHudY, wIconW, wIconW); + } +} +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_mapselect_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_mapselect_step.cpp new file mode 100644 index 000000000..168938931 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_mapselect_step.cpp @@ -0,0 +1,117 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_mapselect_step.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { +using namespace q3overlay; +WorkflowQ3MapSelectStep::WorkflowQ3MapSelectStep(std::shared_ptr l) : logger_(std::move(l)) {} +std::string WorkflowQ3MapSelectStep::GetPluginId() const { return "q3.mapselect"; } +void WorkflowQ3MapSelectStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) { + if (!context.GetBool("overlay.ready", false)) return; + if (!context.GetBool("q3.menu_open", false)) return; + if (context.Get("q3.menu_screen","main") != "map_select") return; + + auto* r = context.Get("overlay.renderer", nullptr); + auto* prop = context.Get("overlay.tex.prop", nullptr); + auto* bchars = context.Get("overlay.tex.bigchars", nullptr); + if (!r) return; + + auto arenas = context.Get>("overlay.arena_data", nullptr); + auto shots = context.Get>("overlay.levelshot_cache", nullptr); + const auto bspCfg = context.Get("bsp_config", nlohmann::json{}); + const std::string pk3 = bspCfg.value("pk3_path", std::string("")); + + // Black background + SDL_SetRenderDrawColor(r, 0, 0, 0, 255); + SDL_FRect bg{0,0,(float)kW,(float)kH}; SDL_RenderFillRect(r, &bg); + + constexpr float kCX = kW * 0.5f; + DrawQ3Text(r, bchars, kCX-96.f, 12.f, "CHOOSE LEVEL", {200,50,20,255}, 1.0f); + + const auto maps = context.Get("q3.maps", nlohmann::json::array({"q3dm7"})); + const int sel = context.Get("q3.menu_selected_item", 0); + const int nMaps = (int)maps.size(); + const int idx = (nMaps>0) ? std::max(0,std::min(sel,nMaps-1)) : 0; + std::string mapName = (nMaps>0) ? maps[(size_t)idx].get() : "q3dm7"; + + // Levelshot + constexpr float kLW=162.f, kLH=162.f, kLX=(kW-kLW)*0.5f, kLY=58.f; + SDL_Texture* shot = nullptr; + if (shots && !mapName.empty() && !pk3.empty()) { + std::string key = mapName; + std::transform(key.begin(),key.end(),key.begin(),::toupper); + auto it = shots->find(key); + if (it != shots->end()) { shot = it->second; } + else { + shot = LoadTextureFromPk3(r, pk3, ("levelshots/"+key+".jpg").c_str()); + if (!shot) shot = LoadTextureFromPk3(r, pk3, ("levelshots/"+mapName+".jpg").c_str()); + (*shots)[key] = shot; + } + } + if (shot) { + SDL_FRect dst{kLX,kLY,kLW,kLH}; SDL_SetTextureAlphaMod(shot,255); + SDL_SetTextureBlendMode(shot,SDL_BLENDMODE_NONE); + SDL_RenderTexture(r,shot,nullptr,&dst); + } else { + SDL_SetRenderDrawColor(r,30,20,15,255); SDL_FRect fb{kLX,kLY,kLW,kLH}; SDL_RenderFillRect(r,&fb); + } + SDL_SetRenderDrawColor(r,180,50,20,255); + SDL_FRect border{kLX-2.f,kLY-2.f,kLW+4.f,kLH+4.f}; SDL_RenderRect(r,&border); + + std::string mU=mapName; std::transform(mU.begin(),mU.end(),mU.begin(),::toupper); + DrawPropText(r,prop,kCX,kLY+kLH+6.f,mU.c_str(),{255,200,50,255},0.7f,true); + + std::string mKey=mapName; std::transform(mKey.begin(),mKey.end(),mKey.begin(),::tolower); + if (arenas) { + auto aIt = arenas->find(mKey); + if (aIt != arenas->end() && !aIt->second.longname.empty()) { + std::string ln = mU+": "+aIt->second.longname; + std::transform(ln.begin(),ln.end(),ln.begin(),::toupper); + DrawPropText(r,prop,kCX,kLY+kLH+34.f,ln.c_str(),{200,130,40,255},0.75f,true); + if (!aIt->second.bot.empty() && shots && !pk3.empty()) { + const std::string botKey=aIt->second.bot; + std::string bk=botKey; std::transform(bk.begin(),bk.end(),bk.begin(),::tolower); + SDL_Texture* botIcon = nullptr; + auto bIt = shots->find("bot_"+bk); + if (bIt!=shots->end()) { botIcon=bIt->second; } + else { + botIcon=LoadTextureFromPk3(r,pk3,("models/players/"+bk+"/icon_default.tga").c_str()); + (*shots)["bot_"+bk]=botIcon; + } + if (botIcon) { + SDL_FRect dst{kCX-22.f,kLY+kLH+76.f,44.f,44.f}; + SDL_SetTextureAlphaMod(botIcon,255); SDL_SetTextureBlendMode(botIcon,SDL_BLENDMODE_BLEND); + SDL_RenderTexture(r,botIcon,nullptr,&dst); + } + std::string bU=botKey; std::transform(bU.begin(),bU.end(),bU.begin(),::toupper); + DrawPropText(r,prop,kCX,kLY+kLH+124.f,bU.c_str(),{180,130,50,220},0.65f,true); + } + } + } + + // Nav arrows + auto* al = context.Get("overlay.tex.arrow_l", nullptr); + auto* ar = context.Get("overlay.tex.arrow_r", nullptr); + constexpr float kAY = kLY+kLH*0.5f-16.f; + if (al && idx>0) { SDL_FRect d{8.f,kAY,64.f,32.f}; SDL_RenderTexture(r,al,nullptr,&d); } + if (ar && idx("overlay.tex.btn_back", nullptr); + auto* bf = context.Get("overlay.tex.btn_fight", nullptr); + auto* bs = context.Get("overlay.tex.btn_skirmish", nullptr); + constexpr float kBY=(float)kH-52.f, kBW=128.f, kBH=46.f; + auto dbtn=[&](SDL_Texture* t,float x){ + if(!t) return; SDL_SetTextureAlphaMod(t,255); SDL_SetTextureBlendMode(t,SDL_BLENDMODE_BLEND); + SDL_FRect d{x,kBY,kBW,kBH}; SDL_RenderTexture(r,t,nullptr,&d); + }; + dbtn(bb, kW*0.12f-kBW*0.5f); + dbtn(bs, kW*0.50f-kBW*0.5f); + dbtn(bf, kW*0.88f-kBW*0.5f); +} +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_frame_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_frame_step.cpp new file mode 100644 index 000000000..cc132174e --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_frame_step.cpp @@ -0,0 +1,60 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include +#include +namespace sdl3cpp::services::impl { +using namespace q3overlay; +WorkflowQ3MenuFrameStep::WorkflowQ3MenuFrameStep(std::shared_ptr l) : logger_(std::move(l)) {} +std::string WorkflowQ3MenuFrameStep::GetPluginId() const { return "q3.menu.frame"; } +void WorkflowQ3MenuFrameStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) { + if (!context.GetBool("overlay.ready", false)) return; + if (!context.GetBool("q3.menu_open", false)) return; + const std::string screen = context.Get("q3.menu_screen", "main"); + if (screen == "map_select") return; // handled by q3.mapselect + + auto* r = context.Get("overlay.renderer", nullptr); + auto* prop = context.Get("overlay.tex.prop", nullptr); + auto* glo = context.Get("overlay.tex.prop_glo", nullptr); + auto* fl = context.Get("overlay.tex.frame_l", nullptr); + auto* fr = context.Get("overlay.tex.frame_r", nullptr); + if (!r) return; + + // Black background + SDL_SetRenderDrawColor(r, 0, 0, 0, 255); + SDL_FRect bg{0,0,(float)kW,(float)kH}; SDL_RenderFillRect(r, &bg); + + // Ring emblem + constexpr float kRY=120.f, kRH=185.f, kHalf=kW*0.5f; + if (fl) { SDL_FRect d{0.f,kRY,kHalf,kRH}; SDL_SetTextureAlphaMod(fl,255); SDL_RenderTexture(r,fl,nullptr,&d); } + if (fr) { SDL_FRect d{kHalf,kRY,kHalf,kRH}; SDL_SetTextureAlphaMod(fr,255); SDL_RenderTexture(r,fr,nullptr,&d); } + + // Title with glow + constexpr float kCX=kW*0.5f, kTY=20.f, kTS=1.5f, kSS=1.1f; + const float kSY = kTY + kPropHeight*kTS + 2.f; + if (glo) { + DrawPropText(r,glo,kCX+2.f,kTY+2.f,"QUAKE III",{255,80,0,120},kTS,true); + DrawPropText(r,glo,kCX+2.f,kSY+2.f,"ARENA", {255,80,0,120},kSS,true); + } + DrawPropText(r,prop,kCX,kTY,"QUAKE III",{255,200,50,255},kTS,true); + DrawPropText(r,prop,kCX,kSY,"ARENA", {220,100,10,255},kSS,true); + + // Menu items + const auto items = context.Get("q3.menu_items", nlohmann::json::array()); + const int sel = context.Get("q3.menu_selected_item", 0); + const int nItems = (int)items.size(); + constexpr float kIS=1.0f, kIStep=kPropHeight*kIS+8.f, kITop=185.f; + for (int i=0; i -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace sdl3cpp::services::impl { - -namespace { - -std::vector LoadBinary(const char* path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f.is_open()) return {}; - auto size = f.tellg(); - std::vector data(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(data.data()), size); - return data; -} - -} // namespace - -// --------------------------------------------------------------------------- -// Construction / destruction -// --------------------------------------------------------------------------- - -WorkflowQ3OverlayDrawStep::WorkflowQ3OverlayDrawStep(std::shared_ptr logger) - : logger_(std::move(logger)) {} - -WorkflowQ3OverlayDrawStep::~WorkflowQ3OverlayDrawStep() { - if (renderer_) { - if (bigchars_tex_) SDL_DestroyTexture(bigchars_tex_); - if (prop_font_tex_) SDL_DestroyTexture(prop_font_tex_); - if (prop_glo_tex_) SDL_DestroyTexture(prop_glo_tex_); - if (frame_bg_tex_) SDL_DestroyTexture(frame_bg_tex_); - if (frame_l_tex_) SDL_DestroyTexture(frame_l_tex_); - if (frame_r_tex_) SDL_DestroyTexture(frame_r_tex_); - if (frame2_l_tex_) SDL_DestroyTexture(frame2_l_tex_); - for (auto* t : num_digits_) if (t) SDL_DestroyTexture(t); - if (icon_armor_tex_) SDL_DestroyTexture(icon_armor_tex_); - if (icon_health_tex_) SDL_DestroyTexture(icon_health_tex_); - if (icon_face_tex_) SDL_DestroyTexture(icon_face_tex_); - if (icon_ammo_tex_) SDL_DestroyTexture(icon_ammo_tex_); - if (icon_crosshair_) SDL_DestroyTexture(icon_crosshair_); - if (btn_back_tex_) SDL_DestroyTexture(btn_back_tex_); - if (btn_fight_tex_) SDL_DestroyTexture(btn_fight_tex_); - if (btn_skirmish_tex_)SDL_DestroyTexture(btn_skirmish_tex_); - if (btn_arrow_l_tex_) SDL_DestroyTexture(btn_arrow_l_tex_); - if (btn_arrow_r_tex_) SDL_DestroyTexture(btn_arrow_r_tex_); - for (auto& [k, v] : levelshot_cache_) if (v) SDL_DestroyTexture(v); - SDL_DestroyRenderer(renderer_); - } - if (surface_) SDL_DestroySurface(surface_); - if (device_) { - if (sampler_) SDL_ReleaseGPUSampler(device_, sampler_); - if (vtx_buf_) SDL_ReleaseGPUBuffer(device_, vtx_buf_); - if (transfer_) SDL_ReleaseGPUTransferBuffer(device_, transfer_); - if (tex_) SDL_ReleaseGPUTexture(device_, tex_); - if (pipeline_) SDL_ReleaseGPUGraphicsPipeline(device_, pipeline_); - } -} - -std::string WorkflowQ3OverlayDrawStep::GetPluginId() const { - return "q3.overlay.draw"; -} - -// --------------------------------------------------------------------------- -// GPU pipeline init (unchanged from before) -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::TryInit(SDL_GPUDevice* device, SDL_Window* window) { - if (disabled_ || ready_) return; - device_ = device; - - const char* driver = SDL_GetGPUDeviceDriver(device); - const std::string driverName = driver ? driver : ""; - SDL_GPUShaderFormat shaderFormat = SDL_GPU_SHADERFORMAT_INVALID; - std::vector vert, frag; - const char* entry = "main"; - if (driverName == "metal") { - shaderFormat = SDL_GPU_SHADERFORMAT_MSL; - vert = LoadBinary("packages/quake3/shaders/msl/overlay.vert.metal"); - frag = LoadBinary("packages/quake3/shaders/msl/overlay.frag.metal"); - entry = "main0"; - } else if (driverName == "vulkan") { - shaderFormat = SDL_GPU_SHADERFORMAT_SPIRV; - vert = LoadBinary("packages/quake3/shaders/spirv/overlay.vert.spv"); - frag = LoadBinary("packages/quake3/shaders/spirv/overlay.frag.spv"); - } else { - disabled_ = true; return; - } - if (vert.empty() || frag.empty()) { disabled_ = true; return; } - - SDL_GPUShaderCreateInfo vsi = {}; - vsi.code = vert.data(); vsi.code_size = vert.size(); - vsi.entrypoint = entry; vsi.format = shaderFormat; - vsi.stage = SDL_GPU_SHADERSTAGE_VERTEX; - SDL_GPUShaderCreateInfo fsi = {}; - fsi.code = frag.data(); fsi.code_size = frag.size(); - fsi.entrypoint = entry; fsi.format = shaderFormat; - fsi.stage = SDL_GPU_SHADERSTAGE_FRAGMENT; - fsi.num_samplers = 1; - auto* vs = SDL_CreateGPUShader(device, &vsi); - auto* fs = SDL_CreateGPUShader(device, &fsi); - if (!vs || !fs) { - if (vs) SDL_ReleaseGPUShader(device, vs); - if (fs) SDL_ReleaseGPUShader(device, fs); - disabled_ = true; return; - } - - SDL_GPUVertexBufferDescription vbd = {}; - vbd.slot = 0; vbd.pitch = sizeof(float) * 5; - vbd.input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX; - SDL_GPUVertexAttribute attrs[2] = {}; - attrs[0] = {0, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, 0}; - attrs[1] = {1, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, sizeof(float) * 3}; - SDL_GPUVertexInputState vis = {}; - vis.vertex_buffer_descriptions = &vbd; vis.num_vertex_buffers = 1; - vis.vertex_attributes = attrs; vis.num_vertex_attributes = 2; - - SDL_GPUColorTargetDescription ctd = {}; - ctd.format = window - ? SDL_GetGPUSwapchainTextureFormat(device, window) - : SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; - ctd.blend_state.enable_blend = true; - ctd.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA; - ctd.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; - ctd.blend_state.color_blend_op = SDL_GPU_BLENDOP_ADD; - ctd.blend_state.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE; - ctd.blend_state.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ZERO; - ctd.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD; - - SDL_GPUGraphicsPipelineCreateInfo pci = {}; - pci.vertex_shader = vs; pci.fragment_shader = fs; - pci.vertex_input_state = vis; - pci.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - pci.rasterizer_state.fill_mode = SDL_GPU_FILLMODE_FILL; - pci.rasterizer_state.cull_mode = SDL_GPU_CULLMODE_NONE; - pci.rasterizer_state.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE; - pci.depth_stencil_state.enable_depth_test = false; - pci.depth_stencil_state.enable_depth_write = false; - pci.target_info.num_color_targets = 1; - pci.target_info.color_target_descriptions = &ctd; - pipeline_ = SDL_CreateGPUGraphicsPipeline(device, &pci); - SDL_ReleaseGPUShader(device, vs); - SDL_ReleaseGPUShader(device, fs); - if (!pipeline_) { disabled_ = true; return; } - - SDL_GPUTextureCreateInfo tci = {}; - tci.type = SDL_GPU_TEXTURETYPE_2D; - tci.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; - tci.width = kW; tci.height = kH; - tci.layer_count_or_depth = 1; tci.num_levels = 1; - tci.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER; - tex_ = SDL_CreateGPUTexture(device, &tci); - - SDL_GPUTransferBufferCreateInfo tbci = {}; - tbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; - tbci.size = kW * kH * 4; - transfer_ = SDL_CreateGPUTransferBuffer(device, &tbci); - - SDL_GPUBufferCreateInfo bci = {}; - bci.usage = SDL_GPU_BUFFERUSAGE_VERTEX; - bci.size = 6u * 5u * static_cast(sizeof(float)); - vtx_buf_ = SDL_CreateGPUBuffer(device, &bci); - - SDL_GPUSamplerCreateInfo sci = {}; - sci.min_filter = SDL_GPU_FILTER_NEAREST; - sci.mag_filter = SDL_GPU_FILTER_NEAREST; - sci.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_NEAREST; - sci.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - sci.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - sampler_ = SDL_CreateGPUSampler(device, &sci); - - surface_ = SDL_CreateSurface(kW, kH, SDL_PIXELFORMAT_RGBA32); - renderer_ = surface_ ? SDL_CreateSoftwareRenderer(surface_) : nullptr; - ready_ = tex_ && transfer_ && vtx_buf_ && sampler_ && surface_ && renderer_; -} - -// --------------------------------------------------------------------------- -// Load a texture from inside a PK3 (zip) archive using stb_image -// --------------------------------------------------------------------------- - -SDL_Texture* WorkflowQ3OverlayDrawStep::LoadTextureFromPk3( - const std::string& pk3Path, const char* entry) { - if (pk3Path.empty() || !renderer_) return nullptr; - - int zip_err = 0; - zip_t* arc = zip_open(pk3Path.c_str(), ZIP_RDONLY, &zip_err); - if (!arc) return nullptr; - - zip_stat_t st; - if (zip_stat(arc, entry, 0, &st) != 0) { zip_close(arc); return nullptr; } - - std::vector buf(st.size); - zip_file_t* zf = zip_fopen(arc, entry, 0); - if (!zf) { zip_close(arc); return nullptr; } - zip_fread(zf, buf.data(), st.size); - zip_fclose(zf); - zip_close(arc); - - int w = 0, h = 0, ch = 0; - unsigned char* px = stbi_load_from_memory(buf.data(), - static_cast(buf.size()), &w, &h, &ch, 4); - if (!px) return nullptr; - - SDL_Surface* surf = SDL_CreateSurfaceFrom( - w, h, SDL_PIXELFORMAT_RGBA32, px, w * 4); - SDL_Texture* tex = surf ? SDL_CreateTextureFromSurface(renderer_, surf) : nullptr; - if (surf) SDL_DestroySurface(surf); - stbi_image_free(px); - return tex; -} - -// --------------------------------------------------------------------------- -// Lazy-load all menu textures from the same PK3 as the current BSP -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::TryLoadMenuTextures(const std::string& pk3Path) { - if (menu_tex_loaded_) return; - menu_tex_loaded_ = true; // set early so we don't retry on failure - - bigchars_tex_ = LoadTextureFromPk3(pk3Path, "gfx/2d/bigchars.tga"); - prop_font_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/font1_prop.tga"); - prop_glo_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/font1_prop_glo.tga"); - frame_bg_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/cut_frame.tga"); - frame_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame1_l.tga"); - frame_r_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame1_r.tga"); - frame2_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame2_l.tga"); - - // HUD large-number sprites (gfx/2d/numbers/*_32b.tga, each 32x32) - static const char* kDigitNames[11] = { - "gfx/2d/numbers/zero_32b.tga", "gfx/2d/numbers/one_32b.tga", - "gfx/2d/numbers/two_32b.tga", "gfx/2d/numbers/three_32b.tga", - "gfx/2d/numbers/four_32b.tga", "gfx/2d/numbers/five_32b.tga", - "gfx/2d/numbers/six_32b.tga", "gfx/2d/numbers/seven_32b.tga", - "gfx/2d/numbers/eight_32b.tga", "gfx/2d/numbers/nine_32b.tga", - "gfx/2d/numbers/minus_32b.tga" - }; - for (int i = 0; i < 11; ++i) - num_digits_[i] = LoadTextureFromPk3(pk3Path, kDigitNames[i]); - - // HUD icon sprites - icon_armor_tex_ = LoadTextureFromPk3(pk3Path, "icons/iconr_yellow.tga"); - icon_health_tex_ = LoadTextureFromPk3(pk3Path, "icons/iconh_red.tga"); - icon_face_tex_ = LoadTextureFromPk3(pk3Path, "models/players/keel/icon_default.tga"); - icon_ammo_tex_ = LoadTextureFromPk3(pk3Path, "icons/icona_machinegun.tga"); - icon_crosshair_ = LoadTextureFromPk3(pk3Path, "gfx/2d/crosshaira.tga"); - - // Map-select buttons and nav arrows - btn_back_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/back_0.tga"); - btn_fight_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/fight_0.tga"); - btn_skirmish_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/skirmish_0.tga"); - btn_arrow_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/gs_arrows_l.tga"); - btn_arrow_r_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/gs_arrows_r.tga"); - - ParseArenas(pk3Path); - - if (logger_) { - logger_->Info(std::string("q3.overlay: textures loaded — " - "nums:") + (num_digits_[0] ? "ok" : "MISS") + - " armor:" + (icon_armor_tex_ ? "ok" : "MISS") + - " face:" + (icon_face_tex_ ? "ok" : "MISS") + - " fight:" + (btn_fight_tex_ ? "ok" : "MISS") + - " arenas:" + std::to_string(arena_data_.size())); - } -} - -// --------------------------------------------------------------------------- -// Parse scripts/arenas.txt from the PK3 for map long-names and bot opponents -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::ParseArenas(const std::string& pk3Path) { - if (arena_data_loaded_) return; - arena_data_loaded_ = true; - - int err = 0; - zip_t* arc = zip_open(pk3Path.c_str(), ZIP_RDONLY, &err); - if (!arc) return; - - zip_stat_t st; - if (zip_stat(arc, "scripts/arenas.txt", 0, &st) != 0) { zip_close(arc); return; } - std::vector buf(st.size + 1, '\0'); - zip_file_t* zf = zip_fopen(arc, "scripts/arenas.txt", 0); - if (zf) { zip_fread(zf, buf.data(), st.size); zip_fclose(zf); } - zip_close(arc); - - // Simple tokenizer: split on '{' '}' and parse key/value pairs - std::string text(buf.data()); - size_t pos = 0; - while ((pos = text.find('{', pos)) != std::string::npos) { - size_t end = text.find('}', pos); - if (end == std::string::npos) break; - std::string block = text.substr(pos + 1, end - pos - 1); - pos = end + 1; - - // Extract key-value pairs (Q3 config format: key "value") - std::string mapName, longName, bots; - std::istringstream ss(block); - std::string tok; - while (ss >> tok) { - auto readVal = [&]() -> std::string { - std::string val; - ss >> std::ws; - if (ss.peek() == '"') { - ss.get(); - std::getline(ss, val, '"'); - } else { ss >> val; } - return val; - }; - if (tok == "map") mapName = readVal(); - else if (tok == "longname") longName = readVal(); - else if (tok == "bots") bots = readVal(); - } - if (!mapName.empty()) { - // Store under lowercase key - std::string key = mapName; - std::transform(key.begin(), key.end(), key.begin(), ::tolower); - // First bot only - std::string firstBot = bots.substr(0, bots.find(' ')); - arena_data_[key] = { longName, firstBot }; - } - } -} - -// --------------------------------------------------------------------------- -// Load or retrieve a cached levelshot texture for a map name -// --------------------------------------------------------------------------- - -SDL_Texture* WorkflowQ3OverlayDrawStep::LoadOrGetLevelshot( - const std::string& mapName, const std::string& pk3Path) { - // Levelshot paths use uppercase names (e.g. levelshots/Q3DM7.jpg) - std::string key = mapName; - std::transform(key.begin(), key.end(), key.begin(), ::toupper); - - auto it = levelshot_cache_.find(key); - if (it != levelshot_cache_.end()) return it->second; - - // Try uppercase then lowercase - std::string upperPath = "levelshots/" + key + ".jpg"; - std::string lowerPath = "levelshots/" + mapName + ".jpg"; - SDL_Texture* tex = LoadTextureFromPk3(pk3Path, upperPath.c_str()); - if (!tex) tex = LoadTextureFromPk3(pk3Path, lowerPath.c_str()); - levelshot_cache_[key] = tex; - return tex; -} - -// --------------------------------------------------------------------------- -// Draw an integer using the number sprites; returns x position after last digit -// --------------------------------------------------------------------------- - -float WorkflowQ3OverlayDrawStep::DrawHudNumber( - float x, float y, int value, float scale) { - if (!renderer_) return x; - // Convert to string; use minus sprite for negative values - std::string s; - if (value < 0) { s = "-"; s += std::to_string(-value); } - else { s = std::to_string(value); } - - const float dw = 32.f * scale; - float cx = x; - for (char c : s) { - int idx = (c == '-') ? 10 : (c - '0'); - if (idx < 0 || idx > 10) { cx += dw; continue; } - if (SDL_Texture* t = num_digits_[idx]) { - SDL_SetTextureColorMod(t, 255, 220, 60); // Q3A gold tint - SDL_SetTextureAlphaMod(t, 255); - SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND); - SDL_FRect dst{cx, y, dw, dw}; - SDL_RenderTexture(renderer_, t, nullptr, &dst); - } - cx += dw; - } - return cx; -} - -// --------------------------------------------------------------------------- -// Q3 bitmap-font text renderer -// In bigchars.tga each ASCII character N occupies the cell: -// col = N % 16, row = N / 16 (16 columns × 16 rows, each cell 16×16 px) -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::DrawQ3Text( - float x, float y, const char* text, SDL_Color color, float scale) { - if (!text || !renderer_) return; - - if (bigchars_tex_) { - SDL_SetTextureColorMod(bigchars_tex_, color.r, color.g, color.b); - SDL_SetTextureAlphaMod(bigchars_tex_, color.a); - SDL_SetTextureBlendMode(bigchars_tex_, SDL_BLENDMODE_BLEND); - - const float cw = kGlyphSrc * scale; - const float ch = kGlyphSrc * scale; - float cx = x; - for (const char* p = text; *p; ++p) { - const int code = static_cast(*p); - SDL_FRect src = { static_cast((code % 16) * kGlyphSrc), - static_cast((code / 16) * kGlyphSrc), - static_cast(kGlyphSrc), - static_cast(kGlyphSrc) }; - SDL_FRect dst = { cx, y, cw, ch }; - SDL_RenderTexture(renderer_, bigchars_tex_, &src, &dst); - cx += cw; - } - } else { - // Fallback: built-in debug text - SDL_SetRenderDrawColor(renderer_, color.r, color.g, color.b, color.a); - SDL_RenderDebugText(renderer_, x, y, text); - } -} - -// --------------------------------------------------------------------------- -// Q3 proportional font renderer (font1_prop.tga) -// Source: ioquake3 code/q3_ui/ui_atoms.c propMap[128][3] = {src_x, src_y, width} -// PROP_HEIGHT=27, PROP_GAP_WIDTH=3, PROP_SPACE_WIDTH=8 -// --------------------------------------------------------------------------- - -static const int kPropMap[128][3] = { - {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, - {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, - {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, - {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, - // 32 space - {0, 0, 8}, - // 33 ! 34 " 35 # 36 $ 37 % 38 & 39 ' - {11,122,7},{154,181,14},{55,122,17},{79,122,18},{101,122,23},{153,122,18},{9,93,7}, - // 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 / - {207,122,8},{230,122,9},{177,122,18},{30,152,18},{85,181,7},{34,93,11},{110,181,6},{130,152,14}, - // 48-57: 0-9 - {22,64,17},{41,64,12},{58,64,17},{78,64,18},{98,64,19},{120,64,18},{141,64,18},{204,64,16},{162,64,17},{182,64,18}, - // 58 : 59 ; 60 < 61 = 62 > 63 ? 64 @ - {59,181,7},{35,181,7},{203,152,14},{56,93,14},{228,152,14},{177,181,18},{28,122,22}, - // 65-90: A-Z - {5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17}, - {164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18}, - {68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29}, - {215,34,18},{234,34,18},{5,64,14}, - // 91 [ 92 \ 93 ] 94 ^ 95 _ 96 ` - {60,152,7},{106,151,13},{83,152,7},{128,122,17},{4,152,21},{134,181,5}, - // 97-122: a-z (map to uppercase in prop font) - {5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17}, - {164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18}, - {68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29}, - {215,34,18},{234,34,18},{5,64,14}, - // 123 { 124 | 125 } 126 ~ 127 DEL - {153,152,13},{11,181,5},{180,152,13},{79,93,17},{0,0,-1} -}; - -float WorkflowQ3OverlayDrawStep::PropStringWidth(const char* text) const { - if (!text) return 0.f; - float w = 0.f; - for (const char* p = text; *p; ++p) { - const int ch = static_cast(*p) & 127; - const int cw = kPropMap[ch][2]; - if (cw == -1) continue; - w += static_cast(cw == 8 ? 8 : cw + kPropGap); - } - return w - kPropGap; // no trailing gap -} - -void WorkflowQ3OverlayDrawStep::DrawPropText( - float x, float y, const char* text, SDL_Color color, float scale, bool center) { - if (!text || !renderer_) return; - - SDL_Texture* fnt = prop_font_tex_ ? prop_font_tex_ : nullptr; - if (!fnt) { - // Fallback to debug text if prop font failed to load - SDL_SetRenderDrawColor(renderer_, color.r, color.g, color.b, color.a); - SDL_RenderDebugText(renderer_, x, y, text); - return; - } - - if (center) - x -= PropStringWidth(text) * scale * 0.5f; - - SDL_SetTextureColorMod(fnt, color.r, color.g, color.b); - SDL_SetTextureAlphaMod(fnt, color.a); - SDL_SetTextureBlendMode(fnt, SDL_BLENDMODE_BLEND); - - float cx = x; - for (const char* p = text; *p; ++p) { - const int ch = static_cast(*p) & 127; - const int cw = kPropMap[ch][2]; - if (cw == -1) continue; - if (cw == kPropSpace) { cx += kPropSpace * scale; continue; } - SDL_FRect src = { static_cast(kPropMap[ch][0]), - static_cast(kPropMap[ch][1]), - static_cast(cw), - static_cast(kPropHeight) }; - SDL_FRect dst = { cx, y, cw * scale, kPropHeight * scale }; - SDL_RenderTexture(renderer_, fnt, &src, &dst); - cx += (cw + kPropGap) * scale; - } -} - -// --------------------------------------------------------------------------- -// Q3A "Choose Level" / map-select screen -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::DrawMapSelectScreen( - WorkflowContext& context, const std::string& pk3Path) { - constexpr float kCX = kW * 0.5f; - - // ── "CHOOSE LEVEL" title ───────────────────────────────────────────── - // Q3A uses bigchars font at large scale for this heading. - DrawQ3Text(kCX - 96.f, 12.f, "CHOOSE LEVEL", {200, 50, 20, 255}, 1.0f); - - // ── Determine which map is selected and load its levelshot ─────────── - const auto maps = context.Get("q3.maps", nlohmann::json::array({"q3dm7"})); - const int sel = context.Get("q3.menu_selected_item", 0); - const int nMaps = static_cast(maps.size()); - const int idx = (nMaps > 0) ? std::max(0, std::min(sel, nMaps-1)) : 0; - - std::string mapName; - if (nMaps > 0) mapName = maps[(size_t)idx].get(); - - // Levelshot image — centred, bordered in Q3A orange-red - constexpr float kLW = 162.f, kLH = 162.f; - constexpr float kLX = (kW - kLW) * 0.5f; - constexpr float kLY = 58.f; - - SDL_Texture* shot = (!mapName.empty() && !pk3Path.empty()) - ? LoadOrGetLevelshot(mapName, pk3Path) : nullptr; - - if (shot) { - SDL_FRect dst{kLX, kLY, kLW, kLH}; - SDL_SetTextureAlphaMod(shot, 255); - SDL_SetTextureColorMod(shot, 255, 255, 255); - SDL_SetTextureBlendMode(shot, SDL_BLENDMODE_NONE); - SDL_RenderTexture(renderer_, shot, nullptr, &dst); - } else { - SDL_SetRenderDrawColor(renderer_, 30, 20, 15, 255); - SDL_FRect fbk{kLX, kLY, kLW, kLH}; - SDL_RenderFillRect(renderer_, &fbk); - } - // Red border - SDL_SetRenderDrawColor(renderer_, 180, 50, 20, 255); - SDL_FRect border{kLX - 2.f, kLY - 2.f, kLW + 4.f, kLH + 4.f}; - SDL_RenderRect(renderer_, &border); - - // Map name label under image (e.g. "Q3DM7") - std::string mapUpper = mapName; - std::transform(mapUpper.begin(), mapUpper.end(), mapUpper.begin(), ::toupper); - DrawPropText(kCX, kLY + kLH + 6.f, mapUpper.c_str(), {255, 200, 50, 255}, 0.7f, true); - - // Long name from arenas.txt (e.g. "TEMPLE OF RETRIBUTION") - std::string mapKey = mapName; - std::transform(mapKey.begin(), mapKey.end(), mapKey.begin(), ::tolower); - auto aIt = arena_data_.find(mapKey); - if (aIt != arena_data_.end() && !aIt->second.longname.empty()) { - std::string longUpper = mapUpper + ": " + aIt->second.longname; - std::transform(longUpper.begin(), longUpper.end(), longUpper.begin(), ::toupper); - DrawPropText(kCX, kLY + kLH + 34.f, longUpper.c_str(), {200, 130, 40, 255}, 0.75f, true); - } - - // ── Opponent bot icon + name ───────────────────────────────────────── - float botY = kLY + kLH + 76.f; - if (aIt != arena_data_.end() && !aIt->second.bot.empty()) { - const std::string botName = aIt->second.bot; - std::string botKey = botName; - std::transform(botKey.begin(), botKey.end(), botKey.begin(), ::tolower); - const std::string iconPath = "models/players/" + botKey + "/icon_default.tga"; - SDL_Texture* botIcon = LoadOrGetLevelshot("bot_" + botKey, pk3Path); - if (!botIcon) { - // Try loading from pk3 directly (not a levelshot — use raw loader) - botIcon = LoadTextureFromPk3(pk3Path, iconPath.c_str()); - if (botIcon) levelshot_cache_["bot_" + botKey] = botIcon; - } - if (botIcon) { - SDL_FRect dst{kCX - 22.f, botY, 44.f, 44.f}; - SDL_SetTextureAlphaMod(botIcon, 255); - SDL_SetTextureColorMod(botIcon, 255, 255, 255); - SDL_SetTextureBlendMode(botIcon, SDL_BLENDMODE_BLEND); - SDL_RenderTexture(renderer_, botIcon, nullptr, &dst); - } - std::string botUpper = botName; - std::transform(botUpper.begin(), botUpper.end(), botUpper.begin(), ::toupper); - DrawPropText(kCX, botY + 48.f, botUpper.c_str(), {180, 130, 50, 220}, 0.65f, true); - botY += 80.f; - } - - // ── Navigation arrows (left / right) ───────────────────────────────── - constexpr float kArrowY = kLY + kLH * 0.5f - 16.f; - if (btn_arrow_l_tex_ && idx > 0) { - SDL_FRect dst{8.f, kArrowY, 64.f, 32.f}; - SDL_RenderTexture(renderer_, btn_arrow_l_tex_, nullptr, &dst); - } - if (btn_arrow_r_tex_ && idx < nMaps - 1) { - SDL_FRect dst{kW - 72.f, kArrowY, 64.f, 32.f}; - SDL_RenderTexture(renderer_, btn_arrow_r_tex_, nullptr, &dst); - } - - // ── Bottom buttons: BACK SKIRMISH FIGHT ──────────────────────────── - constexpr float kBtnY = static_cast(kH) - 52.f; - constexpr float kBtnW = 128.f, kBtnH = 46.f; - auto drawBtn = [&](SDL_Texture* t, float x) { - if (!t) return; - SDL_SetTextureAlphaMod(t, 255); - SDL_SetTextureColorMod(t, 255, 255, 255); - SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND); - SDL_FRect dst{x, kBtnY, kBtnW, kBtnH}; - SDL_RenderTexture(renderer_, t, nullptr, &dst); - }; - drawBtn(btn_back_tex_, kW * 0.12f - kBtnW * 0.5f); - drawBtn(btn_skirmish_tex_, kW * 0.50f - kBtnW * 0.5f); - drawBtn(btn_fight_tex_, kW * 0.88f - kBtnW * 0.5f); -} - -// --------------------------------------------------------------------------- -// Software-render the overlay surface each frame -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::DrawSurface( - WorkflowContext& context, uint32_t /*frameW*/, uint32_t /*frameH*/) { - SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0); - SDL_RenderClear(renderer_); - SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND); - - // ---- HUD (always visible unless menu is open) ----------------------- - const bool menuOpen = context.GetBool("q3.menu_open", false); - - if (!menuOpen) { - const auto bspCfg2 = context.Get("bsp_config", nlohmann::json{}); - const std::string pk3hud = bspCfg2.value("pk3_path", std::string("")); - if (!menu_tex_loaded_ && !pk3hud.empty()) TryLoadMenuTextures(pk3hud); - - const int health = context.Get("q3.player_health", 100); - const int armor = context.Get("q3.player_armor", 0); - const int ammo = context.Get("q3.player_ammo", 50); - - // ── Large number HUD — bottom-left (matches Q3A layout) ──────────── - // Scale 1.75 → each digit 56×56 px. Bottom edge at kH-6. - constexpr float kNS = 1.75f; // number sprite scale - constexpr float kNH = 32.f * kNS; // digit height = 56 px - constexpr float kHudY = kH - kNH - 6.f; - constexpr float kIS = 1.25f; // icon scale: 32×1.25 = 40 px - - // Armor value (left) - float cx = 10.f; - cx = DrawHudNumber(cx, kHudY, armor, kNS); - // Armor icon immediately after digits - if (icon_armor_tex_) { - SDL_SetTextureAlphaMod(icon_armor_tex_, 255); - SDL_SetTextureColorMod(icon_armor_tex_, 255, 255, 255); - SDL_SetTextureBlendMode(icon_armor_tex_, SDL_BLENDMODE_BLEND); - SDL_FRect dst{cx + 4.f, kHudY + (kNH - 32.f*kIS)*0.5f, 32.f*kIS, 32.f*kIS}; - SDL_RenderTexture(renderer_, icon_armor_tex_, nullptr, &dst); - } - cx += 4.f + 32.f*kIS + 14.f; - - // Health value - cx = DrawHudNumber(cx, kHudY, health, kNS); - // Face icon after health digits - if (icon_face_tex_) { - SDL_SetTextureAlphaMod(icon_face_tex_, 255); - SDL_SetTextureColorMod(icon_face_tex_, 255, 255, 255); - SDL_SetTextureBlendMode(icon_face_tex_, SDL_BLENDMODE_BLEND); - SDL_FRect dst{cx + 4.f, kHudY, kNH, kNH}; // face is 64×64 → scaled to digit height - SDL_RenderTexture(renderer_, icon_face_tex_, nullptr, &dst); - } - - // ── Ammo / weapon — bottom-right ──────────────────────────────────── - // Show ammo icon + ammo count right-aligned at kW-10 - const float ammoNumW = (ammo >= 100 ? 3 : ammo >= 10 ? 2 : 1) * 32.f * kNS; - const float ammoIconW = icon_ammo_tex_ ? 32.f * kIS : 0.f; - const float ammoX = kW - 10.f - ammoNumW - ammoIconW - 8.f; - if (icon_ammo_tex_) { - SDL_SetTextureAlphaMod(icon_ammo_tex_, 255); - SDL_SetTextureColorMod(icon_ammo_tex_, 255, 255, 255); - SDL_SetTextureBlendMode(icon_ammo_tex_, SDL_BLENDMODE_BLEND); - SDL_FRect dst{ammoX, kHudY + (kNH - 32.f*kIS)*0.5f, 32.f*kIS, 32.f*kIS}; - SDL_RenderTexture(renderer_, icon_ammo_tex_, nullptr, &dst); - } - DrawHudNumber(ammoX + ammoIconW + 6.f, kHudY, ammo, kNS); - - // ── Crosshair ──────────────────────────────────────────────────────── - constexpr float kCHSize = 24.f; - const float chx = (kW - kCHSize) * 0.5f; - const float chy = (kH - kCHSize) * 0.5f; - if (icon_crosshair_) { - SDL_SetTextureAlphaMod(icon_crosshair_, 200); - SDL_SetTextureColorMod(icon_crosshair_, 255, 255, 255); - SDL_SetTextureBlendMode(icon_crosshair_, SDL_BLENDMODE_BLEND); - SDL_FRect dst{chx, chy, kCHSize, kCHSize}; - SDL_RenderTexture(renderer_, icon_crosshair_, nullptr, &dst); - } else { - // Fallback dot crosshair - SDL_SetRenderDrawColor(renderer_, 255, 255, 255, 200); - SDL_FRect dot{(kW - 4.f) * 0.5f, (kH - 4.f) * 0.5f, 4.f, 4.f}; - SDL_RenderFillRect(renderer_, &dot); - } - - // ── Hit marker ─────────────────────────────────────────────────────── - const uint32_t frame2 = static_cast(context.GetDouble("loop.iteration", 0.0)); - if (frame2 < context.Get("q3.hit_marker_until_frame", 0u)) { - SDL_SetRenderDrawColor(renderer_, 255, 80, 55, 255); - SDL_RenderLine(renderer_, 308, 172, 320, 160); - SDL_RenderLine(renderer_, 332, 172, 320, 160); - SDL_RenderLine(renderer_, 308, 188, 320, 200); - SDL_RenderLine(renderer_, 332, 188, 320, 200); - } - - // ── Muzzle flash ───────────────────────────────────────────────────── - const uint32_t frame3 = static_cast(context.GetDouble("loop.iteration", 0.0)); - if (frame3 < context.Get("q3.weapon_flash_until_frame", 0u)) { - SDL_SetRenderDrawColor(renderer_, 255, 200, 60, 180); - SDL_FRect flash{kW - 40.f, kH * 0.58f, 24.f, 40.f}; - SDL_RenderFillRect(renderer_, &flash); - } - } - - // ---- Menu screens (full-screen overlays) -------------------------------- - if (menuOpen) { - const auto bspCfg = context.Get("bsp_config", nlohmann::json{}); - const std::string pk3 = bspCfg.value("pk3_path", std::string("")); - if (!menu_tex_loaded_ && !pk3.empty()) TryLoadMenuTextures(pk3); - - const std::string screen = context.Get("q3.menu_screen", "main"); - - // Black background for all menu screens - SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255); - SDL_FRect bg{0, 0, static_cast(kW), static_cast(kH)}; - SDL_RenderFillRect(renderer_, &bg); - - if (screen == "map_select") { - DrawMapSelectScreen(context, pk3); - } else { - // ── Q3A main menu (ring + title + item list) ────────────────── - constexpr float kRingY = 120.f, kRingH = 185.f, kHalf = kW * 0.5f; - if (frame_l_tex_) { - SDL_SetTextureAlphaMod(frame_l_tex_, 255); - SDL_FRect d{0.f, kRingY, kHalf, kRingH}; - SDL_RenderTexture(renderer_, frame_l_tex_, nullptr, &d); - } - if (frame_r_tex_) { - SDL_SetTextureAlphaMod(frame_r_tex_, 255); - SDL_FRect d{kHalf, kRingY, kHalf, kRingH}; - SDL_RenderTexture(renderer_, frame_r_tex_, nullptr, &d); - } - - constexpr float kCX = kW * 0.5f; - constexpr float kTY = 20.f, kTS = 1.5f, kSS = 1.1f; - const float kSY = kTY + kPropHeight * kTS + 2.f; - if (prop_glo_tex_) { - SDL_Texture* sv = prop_font_tex_; prop_font_tex_ = prop_glo_tex_; - DrawPropText(kCX+2.f, kTY+2.f, "QUAKE III", {255,80,0,120}, kTS, true); - DrawPropText(kCX+2.f, kSY+2.f, "ARENA", {255,80,0,120}, kSS, true); - prop_font_tex_ = sv; - } - DrawPropText(kCX, kTY, "QUAKE III", {255,200,50,255}, kTS, true); - DrawPropText(kCX, kSY, "ARENA", {220,100,10,255}, kSS, true); - - const auto items = context.Get("q3.menu_items", nlohmann::json::array()); - const int sel = context.Get("q3.menu_selected_item", 0); - const int nItems = static_cast(items.size()); - constexpr float kIS = 1.0f, kIStep = kPropHeight * kIS + 8.f, kITop = 185.f; - - for (int i = 0; i < nItems; ++i) { - const std::string lbl = items[i].value("label", ""); - const float iy = kITop + i * kIStep; - if (i == sel) { - if (prop_glo_tex_) { - SDL_Texture* sv = prop_font_tex_; prop_font_tex_ = prop_glo_tex_; - for (float ox : {-1.f, 1.f}) - DrawPropText(kCX+ox, iy, lbl.c_str(), {255,180,0,100}, kIS, true); - prop_font_tex_ = sv; - } - DrawPropText(kCX, iy, lbl.c_str(), {255,220,80,255}, kIS, true); - } else { - DrawPropText(kCX, iy, lbl.c_str(), {200,55,35,210}, kIS, true); - } - } - DrawPropText(kCX, static_cast(kH-18), - "Quake III Arena(c) 1999-2000, Id Software, Inc.", - {180,50,30,200}, 0.42f, true); - } - } - - SDL_RenderPresent(renderer_); - - // ---- Screenshot (CPU path: surface is already in RAM) -------------------- - const auto* ssPath = context.TryGet("screenshot_output_path"); - if (ssPath && !ssPath->empty() && surface_) { - if (SDL_SaveBMP(surface_, ssPath->c_str()) == true) { - if (logger_) logger_->Info("q3.overlay: screenshot saved to " + *ssPath); - context.Set("screenshot_saved", true); - } else { - if (logger_) logger_->Warn("q3.overlay: SDL_SaveBMP failed for " + *ssPath); - context.Set("screenshot_saved", false); - } - // Clear the path so we only save once - context.Set("screenshot_output_path", std::string("")); - } -} - -// --------------------------------------------------------------------------- -// Upload surface → GPU texture → full-screen quad -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::Render( - SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchainTex, - SDL_GPUDevice* device, uint32_t /*frameW*/, uint32_t /*frameH*/) { - void* mapped = SDL_MapGPUTransferBuffer(device, transfer_, false); - if (!mapped) return; - std::memcpy(mapped, surface_->pixels, kW * kH * 4); - SDL_UnmapGPUTransferBuffer(device, transfer_); - - auto* copy = SDL_BeginGPUCopyPass(cmd); - if (copy) { - SDL_GPUTextureTransferInfo src = {}; - src.transfer_buffer = transfer_; - src.pixels_per_row = kW; - src.rows_per_layer = kH; - SDL_GPUTextureRegion dst = {}; - dst.texture = tex_; dst.w = kW; dst.h = kH; dst.d = 1; - SDL_UploadToGPUTexture(copy, &src, &dst, false); - SDL_EndGPUCopyPass(copy); - } - - if (!vbuf_uploaded_) { - const float verts[6][5] = { - {-1, 1,0,0,0},{1,1,0,1,0},{1,-1,0,1,1}, - {-1,1,0,0,0},{1,-1,0,1,1},{-1,-1,0,0,1}, - }; - const uint32_t sz = sizeof(verts); - SDL_GPUTransferBufferCreateInfo tb = {}; - tb.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; tb.size = sz; - auto* tmp = SDL_CreateGPUTransferBuffer(device, &tb); - if (tmp) { - void* ptr = SDL_MapGPUTransferBuffer(device, tmp, false); - if (ptr) { std::memcpy(ptr, verts, sz); SDL_UnmapGPUTransferBuffer(device, tmp); } - auto* cp = SDL_BeginGPUCopyPass(cmd); - if (cp) { - SDL_GPUTransferBufferLocation s = {tmp, 0}; - SDL_GPUBufferRegion d = {vtx_buf_, 0, sz}; - SDL_UploadToGPUBuffer(cp, &s, &d, false); - SDL_EndGPUCopyPass(cp); - } - SDL_ReleaseGPUTransferBuffer(device, tmp); - } - vbuf_uploaded_ = true; - } - - SDL_GPUColorTargetInfo target = {}; - target.texture = swapchainTex; - target.load_op = SDL_GPU_LOADOP_LOAD; - target.store_op = SDL_GPU_STOREOP_STORE; - auto* pass = SDL_BeginGPURenderPass(cmd, &target, 1, nullptr); - if (!pass) return; - SDL_BindGPUGraphicsPipeline(pass, pipeline_); - SDL_GPUBufferBinding vb = {vtx_buf_, 0}; - SDL_BindGPUVertexBuffers(pass, 0, &vb, 1); - SDL_GPUTextureSamplerBinding ts = {tex_, sampler_}; - SDL_BindGPUFragmentSamplers(pass, 0, &ts, 1); - SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0); - SDL_EndGPURenderPass(pass); -} - -// --------------------------------------------------------------------------- - -void WorkflowQ3OverlayDrawStep::Execute( - const WorkflowStepDefinition& step, WorkflowContext& context) { - if (context.GetBool("frame_skip", false)) return; - auto* cmd = context.Get("gpu_command_buffer", nullptr); - auto* swapchain = context.Get("gpu_swapchain_texture", nullptr); - auto* device = context.Get("gpu_device", nullptr); - if (!cmd || !swapchain || !device) return; - if (!ready_) TryInit(device, context.Get("sdl_window", nullptr)); - if (!ready_) return; - const auto fw = context.Get("frame_width", 1280u); - const auto fh = context.Get("frame_height", 960u); - DrawSurface(context, fw, fh); - Render(cmd, swapchain, device, fw, fh); -} - -} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_begin_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_begin_step.cpp new file mode 100644 index 000000000..0671fa142 --- /dev/null +++ b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_begin_step.cpp @@ -0,0 +1,166 @@ +#include "services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { +using namespace q3overlay; + +WorkflowOverlaySwBeginStep::WorkflowOverlaySwBeginStep(std::shared_ptr l) + : logger_(std::move(l)) {} + +WorkflowOverlaySwBeginStep::~WorkflowOverlaySwBeginStep() { + if (renderer_) { + auto destroy = [&](SDL_Texture*& t) { if (t) { SDL_DestroyTexture(t); t=nullptr; } }; + destroy(bigchars_); destroy(prop_); destroy(prop_glo_); + destroy(frame_l_); destroy(frame_r_); + for (auto& d : digits_) destroy(d); + destroy(icon_armor_); destroy(icon_health_); destroy(icon_face_); destroy(icon_weapon_); + destroy(crosshair_); destroy(btn_back_); destroy(btn_fight_); destroy(btn_skirmish_); + destroy(arrow_l_); destroy(arrow_r_); + // Levelshot textures owned by the cache + if (levelshot_cache_) { + for (auto& [k,v] : *levelshot_cache_) if (v) SDL_DestroyTexture(v); + } + SDL_DestroyRenderer(renderer_); + } + if (surface_) SDL_DestroySurface(surface_); +} + +std::string WorkflowOverlaySwBeginStep::GetPluginId() const { return "overlay.sw.begin"; } + +void WorkflowOverlaySwBeginStep::TryLoadTextures(const std::string& pk3) { + if (tex_loaded_) return; + tex_loaded_ = true; + + auto load = [&](const char* e) { return LoadTextureFromPk3(renderer_, pk3, e); }; + + bigchars_ = load("gfx/2d/bigchars.tga"); + prop_ = load("menu/art/font1_prop.tga"); + prop_glo_ = load("menu/art/font1_prop_glo.tga"); + frame_l_ = load("menu/art/frame1_l.tga"); + frame_r_ = load("menu/art/frame1_r.tga"); + + static const char* kDigits[11] = { + "gfx/2d/numbers/zero_32b.tga", "gfx/2d/numbers/one_32b.tga", + "gfx/2d/numbers/two_32b.tga", "gfx/2d/numbers/three_32b.tga", + "gfx/2d/numbers/four_32b.tga", "gfx/2d/numbers/five_32b.tga", + "gfx/2d/numbers/six_32b.tga", "gfx/2d/numbers/seven_32b.tga", + "gfx/2d/numbers/eight_32b.tga", "gfx/2d/numbers/nine_32b.tga", + "gfx/2d/numbers/minus_32b.tga" + }; + for (int i = 0; i < 11; ++i) digits_[i] = load(kDigits[i]); + + icon_armor_ = load("icons/iconr_yellow.tga"); + icon_health_ = load("icons/iconh_red.tga"); + icon_face_ = load("models/players/keel/icon_default.tga"); + icon_weapon_ = load("icons/iconw_machinegun.tga"); // weapon icon (right HUD), not ammo pickup + crosshair_ = load("gfx/2d/crosshaira.tga"); + btn_back_ = load("menu/art/back_0.tga"); + btn_fight_ = load("menu/art/fight_0.tga"); + btn_skirmish_ = load("menu/art/skirmish_0.tga"); + arrow_l_ = load("menu/art/gs_arrows_l.tga"); + arrow_r_ = load("menu/art/gs_arrows_r.tga"); + + // Parse arenas.txt + arena_data_ = std::make_shared(); + levelshot_cache_ = std::make_shared(); + int ze = 0; + zip_t* arc = zip_open(pk3.c_str(), ZIP_RDONLY, &ze); + if (arc) { + zip_stat_t st; + if (zip_stat(arc, "scripts/arenas.txt", 0, &st) == 0) { + std::vector buf(st.size + 1, '\0'); + zip_file_t* zf = zip_fopen(arc, "scripts/arenas.txt", 0); + if (zf) { zip_fread(zf, buf.data(), st.size); zip_fclose(zf); } + std::string text(buf.data()); + size_t pos = 0; + while ((pos = text.find('{', pos)) != std::string::npos) { + size_t end = text.find('}', pos); + if (end == std::string::npos) break; + std::string block = text.substr(pos+1, end-pos-1); + pos = end + 1; + std::string mapName, longName, bots; + std::istringstream ss(block); + std::string tok; + while (ss >> tok) { + auto readVal = [&]() -> std::string { + std::string v; ss >> std::ws; + if (ss.peek()=='"') { ss.get(); std::getline(ss,v,'"'); } + else { ss >> v; } + return v; + }; + if (tok=="map") mapName = readVal(); + else if (tok=="longname") longName = readVal(); + else if (tok=="bots") bots = readVal(); + } + if (!mapName.empty()) { + std::string key = mapName; + std::transform(key.begin(), key.end(), key.begin(), ::tolower); + std::string firstBot = bots.substr(0, bots.find(' ')); + (*arena_data_)[key] = {longName, firstBot}; + } + } + } + zip_close(arc); + } + + if (logger_) logger_->Info("overlay.sw.begin: textures loaded from " + pk3); +} + +void WorkflowOverlaySwBeginStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + if (context.GetBool("frame_skip", false)) return; + + if (!ready_) { + surface_ = SDL_CreateSurface(kW, kH, SDL_PIXELFORMAT_RGBA32); + renderer_ = surface_ ? SDL_CreateSoftwareRenderer(surface_) : nullptr; + ready_ = surface_ && renderer_; + } + if (!ready_) return; + + const auto bspCfg = context.Get("bsp_config", nlohmann::json{}); + const std::string pk3 = bspCfg.value("pk3_path", std::string("")); + if (!tex_loaded_ && !pk3.empty()) TryLoadTextures(pk3); + + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0); + SDL_RenderClear(renderer_); + SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND); + + // Expose surface/renderer so drawing steps can use them + context.Set("overlay.renderer", renderer_); + context.Set ("overlay.surface", surface_); + context.Set ("overlay.ready", ready_); + + // Font textures + context.Set("overlay.tex.bigchars", bigchars_); + context.Set("overlay.tex.prop", prop_); + context.Set("overlay.tex.prop_glo", prop_glo_); + context.Set("overlay.tex.frame_l", frame_l_); + context.Set("overlay.tex.frame_r", frame_r_); + for (int i = 0; i < 11; ++i) + context.Set("overlay.tex.num." + std::to_string(i), digits_[i]); + + // HUD icons + context.Set("overlay.tex.icon_armor", icon_armor_); + context.Set("overlay.tex.icon_health", icon_health_); + context.Set("overlay.tex.icon_face", icon_face_); + context.Set("overlay.tex.icon_weapon", icon_weapon_); + context.Set("overlay.tex.crosshair", crosshair_); + + // Map-select UI + context.Set("overlay.tex.btn_back", btn_back_); + context.Set("overlay.tex.btn_fight", btn_fight_); + context.Set("overlay.tex.btn_skirmish", btn_skirmish_); + context.Set("overlay.tex.arrow_l", arrow_l_); + context.Set("overlay.tex.arrow_r", arrow_r_); + + context.Set> ("overlay.arena_data", arena_data_); + context.Set>("overlay.levelshot_cache", levelshot_cache_); +} +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp new file mode 100644 index 000000000..751c4223f --- /dev/null +++ b/gameengine/src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp @@ -0,0 +1,202 @@ +#include "services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { +std::vector LoadBin(const char* path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f.is_open()) return {}; + auto sz = f.tellg(); std::vector d(sz); + f.seekg(0); f.read((char*)d.data(), sz); return d; +} +} // namespace + +WorkflowOverlaySwEndStep::WorkflowOverlaySwEndStep(std::shared_ptr l) + : logger_(std::move(l)) {} + +WorkflowOverlaySwEndStep::~WorkflowOverlaySwEndStep() { + if (device_) { + if (sampler_) SDL_ReleaseGPUSampler(device_, sampler_); + if (vtx_buf_) SDL_ReleaseGPUBuffer(device_, vtx_buf_); + if (transfer_) SDL_ReleaseGPUTransferBuffer(device_, transfer_); + if (tex_) SDL_ReleaseGPUTexture(device_, tex_); + if (pipeline_) SDL_ReleaseGPUGraphicsPipeline(device_, pipeline_); + } +} + +std::string WorkflowOverlaySwEndStep::GetPluginId() const { return "overlay.sw.end"; } + +void WorkflowOverlaySwEndStep::TryInit( + SDL_GPUDevice* device, SDL_Window* window, + const std::string& vertPath, const std::string& fragPath) { + device_ = device; + const char* driver = SDL_GetGPUDeviceDriver(device); + const std::string drv = driver ? driver : ""; + SDL_GPUShaderFormat fmt = SDL_GPU_SHADERFORMAT_INVALID; + auto vert = LoadBin(vertPath.c_str()); + auto frag = LoadBin(fragPath.c_str()); + const char* entry = (drv == "metal") ? "main0" : "main"; + if (drv == "metal") fmt = SDL_GPU_SHADERFORMAT_MSL; + else if (drv == "vulkan") fmt = SDL_GPU_SHADERFORMAT_SPIRV; + else { disabled_ = true; return; } + if (vert.empty() || frag.empty()) { disabled_ = true; return; } + + SDL_GPUShaderCreateInfo vsi{}, fsi{}; + vsi.code=vert.data(); vsi.code_size=vert.size(); vsi.entrypoint=entry; + vsi.format=fmt; vsi.stage=SDL_GPU_SHADERSTAGE_VERTEX; + fsi.code=frag.data(); fsi.code_size=frag.size(); fsi.entrypoint=entry; + fsi.format=fmt; fsi.stage=SDL_GPU_SHADERSTAGE_FRAGMENT; fsi.num_samplers=1; + auto* vs = SDL_CreateGPUShader(device, &vsi); + auto* fs = SDL_CreateGPUShader(device, &fsi); + if (!vs || !fs) { + if (vs) SDL_ReleaseGPUShader(device, vs); + if (fs) SDL_ReleaseGPUShader(device, fs); + disabled_ = true; return; + } + SDL_GPUVertexBufferDescription vbd{}; vbd.slot=0; vbd.pitch=sizeof(float)*5; + vbd.input_rate=SDL_GPU_VERTEXINPUTRATE_VERTEX; + SDL_GPUVertexAttribute attrs[2]{}; + attrs[0]={0,0,SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3,0}; + attrs[1]={1,0,SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2,sizeof(float)*3}; + SDL_GPUVertexInputState vis{}; vis.vertex_buffer_descriptions=&vbd; vis.num_vertex_buffers=1; + vis.vertex_attributes=attrs; vis.num_vertex_attributes=2; + SDL_GPUColorTargetDescription ctd{}; + ctd.format = window ? SDL_GetGPUSwapchainTextureFormat(device,window) + : SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; + ctd.blend_state.enable_blend=true; + ctd.blend_state.src_color_blendfactor=SDL_GPU_BLENDFACTOR_SRC_ALPHA; + ctd.blend_state.dst_color_blendfactor=SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + ctd.blend_state.color_blend_op=SDL_GPU_BLENDOP_ADD; + ctd.blend_state.src_alpha_blendfactor=SDL_GPU_BLENDFACTOR_ONE; + ctd.blend_state.dst_alpha_blendfactor=SDL_GPU_BLENDFACTOR_ZERO; + ctd.blend_state.alpha_blend_op=SDL_GPU_BLENDOP_ADD; + SDL_GPUGraphicsPipelineCreateInfo pci{}; + pci.vertex_shader=vs; pci.fragment_shader=fs; pci.vertex_input_state=vis; + pci.primitive_type=SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + pci.rasterizer_state.fill_mode=SDL_GPU_FILLMODE_FILL; + pci.rasterizer_state.cull_mode=SDL_GPU_CULLMODE_NONE; + pci.rasterizer_state.front_face=SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE; + pci.depth_stencil_state.enable_depth_test=false; + pci.depth_stencil_state.enable_depth_write=false; + pci.target_info.num_color_targets=1; + pci.target_info.color_target_descriptions=&ctd; + pipeline_=SDL_CreateGPUGraphicsPipeline(device,&pci); + SDL_ReleaseGPUShader(device,vs); SDL_ReleaseGPUShader(device,fs); + if (!pipeline_) { disabled_=true; return; } + + SDL_GPUTextureCreateInfo tci{}; tci.type=SDL_GPU_TEXTURETYPE_2D; + tci.format=SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + tci.width=kW; tci.height=kH; tci.layer_count_or_depth=1; tci.num_levels=1; + tci.usage=SDL_GPU_TEXTUREUSAGE_SAMPLER; + tex_=SDL_CreateGPUTexture(device,&tci); + SDL_GPUTransferBufferCreateInfo tbci{}; tbci.usage=SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + tbci.size=kW*kH*4; transfer_=SDL_CreateGPUTransferBuffer(device,&tbci); + SDL_GPUBufferCreateInfo bci{}; bci.usage=SDL_GPU_BUFFERUSAGE_VERTEX; + bci.size=6u*5u*(uint32_t)sizeof(float); vtx_buf_=SDL_CreateGPUBuffer(device,&bci); + SDL_GPUSamplerCreateInfo sci{}; sci.min_filter=SDL_GPU_FILTER_NEAREST; + sci.mag_filter=SDL_GPU_FILTER_NEAREST; sci.mipmap_mode=SDL_GPU_SAMPLERMIPMAPMODE_NEAREST; + sci.address_mode_u=SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + sci.address_mode_v=SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + sampler_=SDL_CreateGPUSampler(device,&sci); + ready_ = tex_ && transfer_ && vtx_buf_ && sampler_ && pipeline_; +} + +void WorkflowOverlaySwEndStep::Execute( + const WorkflowStepDefinition& step, WorkflowContext& context) { + if (context.GetBool("frame_skip", false)) return; + if (!context.GetBool("overlay.ready", false)) return; + + auto* renderer = context.Get("overlay.renderer", nullptr); + auto* surface = context.Get ("overlay.surface", nullptr); + auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* swapchain = context.Get("gpu_swapchain_texture", nullptr); + auto* device = context.Get("gpu_device", nullptr); + if (!surface || !cmd || !swapchain || !device) return; + + if (renderer) SDL_RenderPresent(renderer); + + if (!ready_ && !disabled_) { + WorkflowStepParameterResolver params; + auto getStr = [&](const char* k, const char* def) -> std::string { + const auto* p = params.FindParameter(step, k); + return (p && p->type == WorkflowParameterValue::Type::String) + ? p->stringValue : def; + }; + const char* driver = SDL_GetGPUDeviceDriver(device); + const std::string drv = driver ? driver : ""; + std::string vp, fp; + if (drv == "metal") { + vp = getStr("vert_shader_path_msl", + "packages/quake3/shaders/msl/overlay.vert.metal"); + fp = getStr("frag_shader_path_msl", + "packages/quake3/shaders/msl/overlay.frag.metal"); + } else { + vp = getStr("vert_shader_path_spirv", + "packages/quake3/shaders/spirv/overlay.vert.spv"); + fp = getStr("frag_shader_path_spirv", + "packages/quake3/shaders/spirv/overlay.frag.spv"); + } + TryInit(device, context.Get("sdl_window", nullptr), vp, fp); + } + if (!ready_) return; + + // Upload surface pixels to GPU texture + void* mapped = SDL_MapGPUTransferBuffer(device, transfer_, false); + if (!mapped) return; + std::memcpy(mapped, surface->pixels, kW * kH * 4); + SDL_UnmapGPUTransferBuffer(device, transfer_); + auto* copy = SDL_BeginGPUCopyPass(cmd); + if (copy) { + SDL_GPUTextureTransferInfo src{}; src.transfer_buffer=transfer_; + src.pixels_per_row=kW; src.rows_per_layer=kH; + SDL_GPUTextureRegion dst{}; dst.texture=tex_; dst.w=kW; dst.h=kH; dst.d=1; + SDL_UploadToGPUTexture(copy, &src, &dst, false); + SDL_EndGPUCopyPass(copy); + } + // Upload vertex buffer once + if (!vbuf_uploaded_) { + const float verts[6][5]={{-1,1,0,0,0},{1,1,0,1,0},{1,-1,0,1,1}, + {-1,1,0,0,0},{1,-1,0,1,1},{-1,-1,0,0,1}}; + const uint32_t sz=sizeof(verts); + SDL_GPUTransferBufferCreateInfo tb{}; tb.usage=SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; tb.size=sz; + auto* tmp=SDL_CreateGPUTransferBuffer(device,&tb); + if (tmp) { + void* ptr=SDL_MapGPUTransferBuffer(device,tmp,false); + if (ptr) { std::memcpy(ptr,verts,sz); SDL_UnmapGPUTransferBuffer(device,tmp); } + auto* cp=SDL_BeginGPUCopyPass(cmd); + if (cp) { + SDL_GPUTransferBufferLocation s{tmp,0}; + SDL_GPUBufferRegion d{vtx_buf_,0,sz}; + SDL_UploadToGPUBuffer(cp,&s,&d,false); + SDL_EndGPUCopyPass(cp); + } + SDL_ReleaseGPUTransferBuffer(device,tmp); + } + vbuf_uploaded_=true; + } + // Render full-screen quad + SDL_GPUColorTargetInfo target{}; target.texture=swapchain; + target.load_op=SDL_GPU_LOADOP_LOAD; target.store_op=SDL_GPU_STOREOP_STORE; + auto* pass=SDL_BeginGPURenderPass(cmd,&target,1,nullptr); + if (!pass) return; + SDL_BindGPUGraphicsPipeline(pass,pipeline_); + SDL_GPUBufferBinding vb{vtx_buf_,0}; SDL_BindGPUVertexBuffers(pass,0,&vb,1); + SDL_GPUTextureSamplerBinding ts{tex_,sampler_}; SDL_BindGPUFragmentSamplers(pass,0,&ts,1); + SDL_DrawGPUPrimitives(pass,6,1,0,0); + SDL_EndGPURenderPass(pass); + + // Screenshot + const auto* ssPath = context.TryGet("screenshot_output_path"); + if (ssPath && !ssPath->empty()) { + if (SDL_SaveBMP(surface, ssPath->c_str())) + context.Set("screenshot_saved", true); + context.Set("screenshot_output_path", std::string("")); + } +} +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/workflow_definition_parser.cpp b/gameengine/src/services/impl/workflow/workflow_definition_parser.cpp index 8d91fead0..6ea9969b2 100644 --- a/gameengine/src/services/impl/workflow/workflow_definition_parser.cpp +++ b/gameengine/src/services/impl/workflow/workflow_definition_parser.cpp @@ -4,7 +4,9 @@ #include +#include #include +#include #include namespace sdl3cpp::services::impl { @@ -45,6 +47,15 @@ WorkflowDefinition WorkflowDefinitionParser::ParseFile(const std::filesystem::pa // Read workflow variables (n8n-style) ParseVariables(document, workflow); + // Build include-cycle detection set (canonical path of THIS file) + std::unordered_set visited; + try { + visited.insert(std::filesystem::canonical(path).string()); + } catch (...) { + visited.insert(path.string()); + } + const std::filesystem::path baseDir = path.parent_path(); + // Handle "steps" format (simple sequential) if (hasSteps) { if (!document["steps"].IsArray()) { @@ -62,12 +73,17 @@ WorkflowDefinition WorkflowDefinitionParser::ParseFile(const std::filesystem::pa step.parameters = paramReader.ReadParameterMap(entry, "parameters"); workflow.steps.push_back(std::move(step)); } + // Expand workflow.include nodes (parse-time composition, like React imports) + ResolveIncludes(workflow.steps, baseDir, visited); return workflow; } // Handle "nodes" format (n8n with connections) workflow.steps = ParseNodes(document); + // Expand workflow.include nodes (parse-time composition, like React imports) + ResolveIncludes(workflow.steps, baseDir, visited); + return workflow; } diff --git a/gameengine/src/services/impl/workflow/workflow_definition_parser_includes.cpp b/gameengine/src/services/impl/workflow/workflow_definition_parser_includes.cpp new file mode 100644 index 000000000..7a323e2c0 --- /dev/null +++ b/gameengine/src/services/impl/workflow/workflow_definition_parser_includes.cpp @@ -0,0 +1,106 @@ +#include "services/interfaces/workflow/workflow_definition_parser.hpp" +#include "services/interfaces/workflow_step_definition.hpp" +#include "services/interfaces/workflow_parameter_value.hpp" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace sdl3cpp::services::impl { + +// Resolve the include path: +// 1. Absolute path → use directly. +// 2. Relative path → try CWD first (game assets live under CWD/packages/…), +// then relative to the including file's directory. +static fs::path ResolvePath(const std::string& rawPath, const fs::path& baseDir) { + fs::path p(rawPath); + if (p.is_absolute()) return p; + + // CWD-relative first (matches how shader/asset paths work in workflows) + const fs::path cwdCandidate = fs::current_path() / p; + if (fs::exists(cwdCandidate)) return cwdCandidate; + + // Fallback: relative to the including file + const fs::path relCandidate = baseDir / p; + if (fs::exists(relCandidate)) return relCandidate; + + // Return CWD-relative even if it doesn't exist yet — ParseFile will throw a + // better "file not found" message than we would. + return cwdCandidate; +} + +void WorkflowDefinitionParser::ResolveIncludes( + std::vector& steps, + const fs::path& baseDir, + std::unordered_set& visited) const { + + std::vector expanded; + expanded.reserve(steps.size()); + + for (auto& step : steps) { + if (step.plugin != "workflow.include") { + expanded.push_back(std::move(step)); + continue; + } + + // Read the required "path" parameter + auto pathIt = step.parameters.find("path"); + if (pathIt == step.parameters.end() || + pathIt->second.type != WorkflowParameterValue::Type::String || + pathIt->second.stringValue.empty()) { + if (logger_) { + logger_->Warn("workflow.include step '" + step.id + + "' is missing a 'path' parameter — skipping"); + } + continue; + } + + const fs::path includePath = ResolvePath(pathIt->second.stringValue, baseDir); + + // Cycle detection + std::string canonicalKey; + try { + canonicalKey = fs::canonical(includePath).string(); + } catch (...) { + canonicalKey = includePath.string(); + } + if (visited.count(canonicalKey)) { + throw std::runtime_error( + "workflow.include cycle detected: '" + canonicalKey + + "' is already on the include stack"); + } + + if (logger_) { + logger_->Info("workflow.include: expanding '" + step.id + + "' → " + includePath.string()); + } + + // Parse the included file (its own ResolveIncludes runs inside ParseFile, + // so nested includes are handled automatically). + visited.insert(canonicalKey); + WorkflowDefinition sub = ParseFile(includePath); + visited.erase(canonicalKey); + + // Namespace included node IDs to prevent collisions when the same + // sub-workflow is included more than once. + // e.g. include id "overlay_group" + sub id "q3_hud" → "overlay_group/q3_hud" + const std::string ns = step.id + "/"; + for (auto& subStep : sub.steps) { + subStep.id = ns + subStep.id; + expanded.push_back(std::move(subStep)); + } + + if (logger_) { + logger_->Info("workflow.include: '" + step.id + "' expanded to " + + std::to_string(sub.steps.size()) + " steps"); + } + } + + steps = std::move(expanded); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index b39609788..4ee2f14d9 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -116,7 +116,13 @@ // Quake 3 #include "services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp" #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_hud_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_mapselect_step.hpp" +#include "services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp" +#include "services/interfaces/workflow/rendering/workflow_overlay_sw_end_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" @@ -331,13 +337,19 @@ 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_)); - 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_)); + 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_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); - count += 23; + count += 29; // ── Texture ─────────────────────────────────────────────── registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/quake3/q3_overlay_utils.hpp b/gameengine/src/services/interfaces/workflow/quake3/q3_overlay_utils.hpp new file mode 100644 index 000000000..1507ec106 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/q3_overlay_utils.hpp @@ -0,0 +1,142 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::q3overlay { + +struct ArenaInfo { std::string longname; std::string bot; }; +using ArenaMap = std::unordered_map; +using LevelshotCache = std::unordered_map; + +static constexpr int kW = 640, kH = 360; +static constexpr int kGlyphSrc = 16; +static constexpr int kPropHeight = 27; +static constexpr int kPropGap = 3; +static constexpr int kPropSpace = 8; + +// Q3 proportional font character map [128][3] = {src_x, src_y, width} +// From ioquake3 code/q3_ui/ui_atoms.c propMap +inline constexpr int kPropMap[128][3] = { + {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, + {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, + {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, + {0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1}, + {0,0,8}, + {11,122,7},{154,181,14},{55,122,17},{79,122,18},{101,122,23},{153,122,18},{9,93,7}, + {207,122,8},{230,122,9},{177,122,18},{30,152,18},{85,181,7},{34,93,11},{110,181,6},{130,152,14}, + {22,64,17},{41,64,12},{58,64,17},{78,64,18},{98,64,19},{120,64,18},{141,64,18},{204,64,16},{162,64,17},{182,64,18}, + {59,181,7},{35,181,7},{203,152,14},{56,93,14},{228,152,14},{177,181,18},{28,122,22}, + {5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17}, + {164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18}, + {68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29}, + {215,34,18},{234,34,18},{5,64,14}, + {60,152,7},{106,151,13},{83,152,7},{128,122,17},{4,152,21},{134,181,5}, + {5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17}, + {164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18}, + {68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29}, + {215,34,18},{234,34,18},{5,64,14}, + {153,152,13},{11,181,5},{180,152,13},{79,93,17},{0,0,-1} +}; + +inline SDL_Texture* LoadTextureFromPk3( + SDL_Renderer* renderer, const std::string& pk3Path, const char* entry) { + if (pk3Path.empty() || !renderer || !entry) return nullptr; + int ze = 0; + zip_t* arc = zip_open(pk3Path.c_str(), ZIP_RDONLY, &ze); + if (!arc) return nullptr; + zip_stat_t st; + if (zip_stat(arc, entry, 0, &st) != 0) { zip_close(arc); return nullptr; } + std::vector buf(st.size); + zip_file_t* zf = zip_fopen(arc, entry, 0); + if (!zf) { zip_close(arc); return nullptr; } + zip_fread(zf, buf.data(), st.size); + zip_fclose(zf); zip_close(arc); + int w = 0, h = 0, ch = 0; + unsigned char* px = stbi_load_from_memory(buf.data(), (int)buf.size(), &w, &h, &ch, 4); + if (!px) return nullptr; + SDL_Surface* surf = SDL_CreateSurfaceFrom(w, h, SDL_PIXELFORMAT_RGBA32, px, w * 4); + SDL_Texture* tex = surf ? SDL_CreateTextureFromSurface(renderer, surf) : nullptr; + if (surf) SDL_DestroySurface(surf); + stbi_image_free(px); + return tex; +} + +inline float PropStringWidth(const char* text) { + if (!text) return 0.f; + float w = 0.f; + for (const char* p = text; *p; ++p) { + int ch = (unsigned char)*p & 127; + int cw = kPropMap[ch][2]; + if (cw < 0) continue; + w += (float)(cw == kPropSpace ? kPropSpace : cw + kPropGap); + } + return w > 0.f ? w - kPropGap : 0.f; +} + +inline void DrawPropText(SDL_Renderer* r, SDL_Texture* font, + float x, float y, const char* text, SDL_Color col, float scale, bool center) { + if (!text || !r || !font) return; + if (center) x -= PropStringWidth(text) * scale * 0.5f; + SDL_SetTextureColorMod(font, col.r, col.g, col.b); + SDL_SetTextureAlphaMod(font, col.a); + SDL_SetTextureBlendMode(font, SDL_BLENDMODE_BLEND); + float cx = x; + for (const char* p = text; *p; ++p) { + int ch = (unsigned char)*p & 127; + int cw = kPropMap[ch][2]; + if (cw < 0) continue; + if (cw == kPropSpace) { cx += kPropSpace * scale; continue; } + SDL_FRect src{(float)kPropMap[ch][0], (float)kPropMap[ch][1], (float)cw, (float)kPropHeight}; + SDL_FRect dst{cx, y, cw * scale, kPropHeight * scale}; + SDL_RenderTexture(r, font, &src, &dst); + cx += (cw + kPropGap) * scale; + } +} + +inline void DrawQ3Text(SDL_Renderer* r, SDL_Texture* bigchars, + float x, float y, const char* text, SDL_Color col, float scale) { + if (!text || !r) return; + if (bigchars) { + SDL_SetTextureColorMod(bigchars, col.r, col.g, col.b); + SDL_SetTextureAlphaMod(bigchars, col.a); + SDL_SetTextureBlendMode(bigchars, SDL_BLENDMODE_BLEND); + float cx = x; + for (const char* p = text; *p; ++p) { + int code = (unsigned char)*p; + SDL_FRect src{(float)((code%16)*kGlyphSrc), (float)((code/16)*kGlyphSrc), + (float)kGlyphSrc, (float)kGlyphSrc}; + SDL_FRect dst{cx, y, kGlyphSrc * scale, kGlyphSrc * scale}; + SDL_RenderTexture(r, bigchars, &src, &dst); + cx += kGlyphSrc * scale; + } + } else { + SDL_SetRenderDrawColor(r, col.r, col.g, col.b, col.a); + SDL_RenderDebugText(r, x, y, text); + } +} + +inline float DrawHudNumber(SDL_Renderer* r, SDL_Texture* digits[11], + float x, float y, int value, float scale) { + if (!r) return x; + std::string s = (value < 0) ? ("-" + std::to_string(-value)) : std::to_string(value); + const float dw = 32.f * scale; + for (char c : s) { + int idx = (c == '-') ? 10 : (c - '0'); + if (idx >= 0 && idx <= 10 && digits[idx]) { + SDL_SetTextureColorMod(digits[idx], 255, 220, 60); + SDL_SetTextureAlphaMod(digits[idx], 255); + SDL_SetTextureBlendMode(digits[idx], SDL_BLENDMODE_BLEND); + SDL_FRect dst{x, y, dw, dw}; + SDL_RenderTexture(r, digits[idx], nullptr, &dst); + } + x += dw; + } + return x; +} + +} // namespace sdl3cpp::q3overlay diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp new file mode 100644 index 000000000..d6ffaca4d --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +namespace sdl3cpp::services::impl { +class WorkflowQ3CrosshairStep final : public IWorkflowStep { +public: + explicit WorkflowQ3CrosshairStep(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_hitmarker_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp new file mode 100644 index 000000000..e7c2dcba7 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +namespace sdl3cpp::services::impl { +class WorkflowQ3HitmarkerStep final : public IWorkflowStep { +public: + explicit WorkflowQ3HitmarkerStep(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_hud_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp new file mode 100644 index 000000000..03c9926a7 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +namespace sdl3cpp::services::impl { +class WorkflowQ3HudStep final : public IWorkflowStep { +public: + explicit WorkflowQ3HudStep(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_mapselect_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_mapselect_step.hpp new file mode 100644 index 000000000..84407fa3d --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_mapselect_step.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +namespace sdl3cpp::services::impl { +class WorkflowQ3MapSelectStep final : public IWorkflowStep { +public: + explicit WorkflowQ3MapSelectStep(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_menu_frame_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp new file mode 100644 index 000000000..39ef09934 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +namespace sdl3cpp::services::impl { +class WorkflowQ3MenuFrameStep final : public IWorkflowStep { +public: + explicit WorkflowQ3MenuFrameStep(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_overlay_draw_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_overlay_draw_step.hpp deleted file mode 100644 index 470302491..000000000 --- a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_overlay_draw_step.hpp +++ /dev/null @@ -1,110 +0,0 @@ -#pragma once - -#include "services/interfaces/i_workflow_step.hpp" -#include "services/interfaces/i_logger.hpp" -#include "services/interfaces/workflow_context.hpp" - -#include -#include -#include -#include -#include -#include -#include - -namespace sdl3cpp::services::impl { - -class WorkflowQ3OverlayDrawStep final : public IWorkflowStep { -public: - explicit WorkflowQ3OverlayDrawStep(std::shared_ptr logger); - ~WorkflowQ3OverlayDrawStep(); - - std::string GetPluginId() const override; - void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; - -private: - void TryInit(SDL_GPUDevice* device, SDL_Window* window); - void TryLoadMenuTextures(const std::string& pk3Path); - void ParseArenas(const std::string& pk3Path); - void DrawSurface(WorkflowContext& context, uint32_t frameW, uint32_t frameH); - void DrawMapSelectScreen(WorkflowContext& context, const std::string& pk3Path); - void Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchainTex, - SDL_GPUDevice* device, uint32_t frameW, uint32_t frameH); - - // Render a string using the Q3 bigchars grid font. - void DrawQ3Text(float x, float y, const char* text, SDL_Color color, float scale = 1.0f); - - // Render a string using Q3's proportional font (font1_prop.tga). - // center=true → x is the horizontal centre of the string. - void DrawPropText(float x, float y, const char* text, SDL_Color color, - float scale = 1.0f, bool center = false); - - // Draw an integer value using the gfx/2d/numbers/* sprites. - // Returns the x position just past the last digit. - float DrawHudNumber(float x, float y, int value, float scale); - - // Compute pixel width of a proportional string at scale=1. - float PropStringWidth(const char* text) const; - - SDL_Texture* LoadTextureFromPk3(const std::string& pk3Path, const char* entry); - SDL_Texture* LoadOrGetLevelshot(const std::string& mapName, const std::string& pk3Path); - - std::shared_ptr logger_; - - // GPU overlay pipeline state - bool ready_ = false; - bool disabled_ = false; - bool vbuf_uploaded_= false; - SDL_GPUDevice* device_ = nullptr; - SDL_GPUGraphicsPipeline* pipeline_ = nullptr; - SDL_GPUTexture* tex_ = nullptr; - SDL_GPUTransferBuffer* transfer_ = nullptr; - SDL_GPUBuffer* vtx_buf_ = nullptr; - SDL_GPUSampler* sampler_ = nullptr; - SDL_Surface* surface_ = nullptr; - SDL_Renderer* renderer_ = nullptr; - - // Menu / font textures loaded from PK3 - bool menu_tex_loaded_ = false; - SDL_Texture* bigchars_tex_ = nullptr; // gfx/2d/bigchars.tga - SDL_Texture* prop_font_tex_ = nullptr; // menu/art/font1_prop.tga - SDL_Texture* prop_glo_tex_ = nullptr; // menu/art/font1_prop_glo.tga - SDL_Texture* frame_bg_tex_ = nullptr; // menu/art/cut_frame.tga - SDL_Texture* frame_l_tex_ = nullptr; // menu/art/frame1_l.tga (left ring half) - SDL_Texture* frame_r_tex_ = nullptr; // menu/art/frame1_r.tga (right ring half) - SDL_Texture* frame2_l_tex_ = nullptr; // menu/art/frame2_l.tga - - // HUD number sprites: indices 0-9 = digits, index 10 = minus sign - SDL_Texture* num_digits_[11] = {}; - - // HUD icon textures - SDL_Texture* icon_armor_tex_ = nullptr; // icons/iconr_yellow.tga - SDL_Texture* icon_health_tex_ = nullptr; // icons/iconh_red.tga - SDL_Texture* icon_face_tex_ = nullptr; // models/players/keel/icon_default.tga - SDL_Texture* icon_ammo_tex_ = nullptr; // icons/icona_machinegun.tga - SDL_Texture* icon_crosshair_ = nullptr; // gfx/2d/crosshaira.tga - - // Map-select UI textures - SDL_Texture* btn_back_tex_ = nullptr; // menu/art/back_0.tga - SDL_Texture* btn_fight_tex_ = nullptr; // menu/art/fight_0.tga - SDL_Texture* btn_skirmish_tex_ = nullptr; // menu/art/skirmish_0.tga - SDL_Texture* btn_arrow_l_tex_ = nullptr; // menu/art/gs_arrows_l.tga - SDL_Texture* btn_arrow_r_tex_ = nullptr; // menu/art/gs_arrows_r.tga - - // Levelshot cache: uppercase map name → software SDL_Texture - std::unordered_map levelshot_cache_; - - // Arena metadata parsed from scripts/arenas.txt - struct ArenaInfo { std::string longname; std::string bot; }; - std::unordered_map arena_data_; // key = lowercase map name - bool arena_data_loaded_ = false; - - static constexpr int kW = 640; - static constexpr int kH = 360; - static constexpr int kGlyphSrc = 16; // bigchars cell size in source texture - static constexpr int kPropHeight = 27; // Q3 PROP_HEIGHT - static constexpr int kPropGap = 3; // Q3 PROP_GAP_WIDTH - static constexpr int kPropSpace = 8; // Q3 PROP_SPACE_WIDTH -}; - -} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp new file mode 100644 index 000000000..5c5e8d3c2 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp @@ -0,0 +1,44 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { +class WorkflowOverlaySwBeginStep final : public IWorkflowStep { +public: + explicit WorkflowOverlaySwBeginStep(std::shared_ptr logger); + ~WorkflowOverlaySwBeginStep(); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + void TryLoadTextures(const std::string& pk3Path); + std::shared_ptr logger_; + SDL_Surface* surface_ = nullptr; + SDL_Renderer* renderer_ = nullptr; + bool ready_ = false; + bool tex_loaded_ = false; + SDL_Texture* bigchars_ = nullptr; + SDL_Texture* prop_ = nullptr; + SDL_Texture* prop_glo_ = nullptr; + SDL_Texture* frame_l_ = nullptr; + SDL_Texture* frame_r_ = nullptr; + SDL_Texture* digits_[11]= {}; + SDL_Texture* icon_armor_ = nullptr; + SDL_Texture* icon_health_ = nullptr; + SDL_Texture* icon_face_ = nullptr; + SDL_Texture* icon_weapon_ = nullptr; // iconw_machinegun — weapon icon on HUD right side + SDL_Texture* crosshair_ = nullptr; + SDL_Texture* btn_back_ = nullptr; + SDL_Texture* btn_fight_ = nullptr; + SDL_Texture* btn_skirmish_ = nullptr; + SDL_Texture* arrow_l_ = nullptr; + SDL_Texture* arrow_r_ = nullptr; + std::shared_ptr arena_data_; + std::shared_ptr levelshot_cache_; +}; +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp new file mode 100644 index 000000000..46eb67e0f --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp @@ -0,0 +1,31 @@ +#pragma once +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/workflow_context.hpp" +#include +#include +#include + +namespace sdl3cpp::services::impl { +class WorkflowOverlaySwEndStep final : public IWorkflowStep { +public: + explicit WorkflowOverlaySwEndStep(std::shared_ptr logger); + ~WorkflowOverlaySwEndStep(); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + void TryInit(SDL_GPUDevice* device, SDL_Window* window, + const std::string& vertPath, const std::string& fragPath); + std::shared_ptr logger_; + bool ready_ = false; + bool disabled_ = false; + bool vbuf_uploaded_ = false; + SDL_GPUDevice* device_ = nullptr; + SDL_GPUGraphicsPipeline* pipeline_ = nullptr; + SDL_GPUTexture* tex_ = nullptr; + SDL_GPUTransferBuffer* transfer_ = nullptr; + SDL_GPUBuffer* vtx_buf_ = nullptr; + SDL_GPUSampler* sampler_ = nullptr; + static constexpr int kW = 640, kH = 360; +}; +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/workflow_definition_parser.hpp b/gameengine/src/services/interfaces/workflow/workflow_definition_parser.hpp index 317e43eb6..1830ffecc 100644 --- a/gameengine/src/services/interfaces/workflow/workflow_definition_parser.hpp +++ b/gameengine/src/services/interfaces/workflow/workflow_definition_parser.hpp @@ -7,6 +7,8 @@ #include #include +#include +#include namespace sdl3cpp::services::impl { @@ -23,6 +25,13 @@ private: std::vector ParseNodes( const rapidjson::Document& document) const; + // Expand any workflow.include steps in-place (parse-time composition). + // baseDir — directory of the including file, for relative path resolution. + // visited — canonical paths already on the include stack (cycle detection). + void ResolveIncludes(std::vector& steps, + const std::filesystem::path& baseDir, + std::unordered_set& visited) const; + std::shared_ptr logger_; };