From 8abe642bafc5be993e69eb9f9f846f3c9dcb0c3b Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 2 May 2026 22:40:07 +0100 Subject: [PATCH] Wire up real Quake 3 menu system with PK3 assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multi-screen menu hierarchy (main/setup/player/controls/options/map_select) defined in packages/quake3/config/menu.json, loaded at runtime - Overlay renderer loads real Q3 assets from pak0.pk3 via libzip+stb_image: cut_frame.tga (panel), frame1_l/r.tga (decorations), font1_prop.tga (text) - Proportional font renderer using exact Q3 propMap table (ioquake3 source) - Centered in-game panel matching ioquake3 layout - Two-level DAG loop: outer_loop reloads map, inner game_loop runs per frame - value.set_if atomic step added; movement_active computed by DAG (bool.not) - Mouse grab, physics move, weapon update driven by movement_active context key - BSP collision cleanup on map reload to prevent ghost geometry - SDLK_q → SDLK_Q fix for SDL3; window close button exits both loops - quake3_screenshot package: renders 240 frames, saves BMP, exits Co-Authored-By: Claude Sonnet 4.5 --- gameengine/CMakeLists.txt | 1 + gameengine/packages/quake3/config/menu.json | 57 ++ gameengine/packages/quake3/package.json | 3 +- .../packages/quake3/workflows/q3_frame.json | 50 +- .../packages/quake3/workflows/q3_game.json | 131 ++--- .../quake3/workflows/q3_map_session.json | 97 ++++ .../assets/quake3_materials.json | 19 + .../quake3_screenshot/config/menu.json | 57 ++ .../config/q3_rendering.json | 102 ++++ .../quake3_screenshot/config/units.json | 36 ++ .../packages/quake3_screenshot/package.json | 79 +++ .../scene/camera_q3_default.json | 29 + .../quake3_screenshot/scene/quake3_map.json | 11 + .../shaders/msl/bsp.frag.metal | 73 +++ .../shaders/msl/bsp.vert.metal | 43 ++ .../shaders/msl/overlay.frag.metal | 13 + .../shaders/msl/overlay.vert.metal | 19 + .../shaders/spirv/bsp.frag.glsl | 49 ++ .../shaders/spirv/bsp.frag.spv | Bin 0 -> 2844 bytes .../shaders/spirv/bsp.vert.glsl | 40 ++ .../shaders/spirv/bsp.vert.spv | Bin 0 -> 2776 bytes .../shaders/spirv/overlay.frag.glsl | 10 + .../shaders/spirv/overlay.frag.spv | Bin 0 -> 560 bytes .../shaders/spirv/overlay.vert.glsl | 11 + .../shaders/spirv/overlay.vert.spv | Bin 0 -> 1004 bytes .../load_map_with_unit_conversion.json | 92 +++ .../quake3_screenshot/workflows/q3_frame.json | 270 +++++++++ .../quake3_screenshot/workflows/q3_game.json | 86 +++ .../workflows/q3_map_session.json | 97 ++++ .../workflows/quake3_frame.json | 129 +++++ ...kflow_graphics_screenshot_request_step.cpp | 4 +- .../quake3/workflow_q3_menu_update_step.cpp | 129 ++++- .../quake3/workflow_q3_overlay_draw_step.cpp | 542 ++++++++++++++---- .../quake3/workflow_q3_weapon_update_step.cpp | 2 +- .../workflow_bsp_build_collision_step.cpp | 22 +- .../rendering/workflow_bsp_load_step.cpp | 12 +- .../workflow_input_mouse_grab_step.cpp | 20 +- .../workflow_input_poll_step.cpp | 9 +- .../workflow_physics_fps_move_step.cpp | 2 +- .../workflow_value_set_if_step.cpp | 54 ++ .../impl/workflow/workflow_registrar.cpp | 4 +- .../quake3/workflow_q3_menu_update_step.hpp | 3 + .../quake3/workflow_q3_overlay_draw_step.hpp | 52 +- .../workflow_value_set_if_step.hpp | 24 + 44 files changed, 2240 insertions(+), 243 deletions(-) create mode 100644 gameengine/packages/quake3/config/menu.json create mode 100644 gameengine/packages/quake3/workflows/q3_map_session.json create mode 100644 gameengine/packages/quake3_screenshot/assets/quake3_materials.json create mode 100644 gameengine/packages/quake3_screenshot/config/menu.json create mode 100644 gameengine/packages/quake3_screenshot/config/q3_rendering.json create mode 100644 gameengine/packages/quake3_screenshot/config/units.json create mode 100644 gameengine/packages/quake3_screenshot/package.json create mode 100644 gameengine/packages/quake3_screenshot/scene/camera_q3_default.json create mode 100644 gameengine/packages/quake3_screenshot/scene/quake3_map.json create mode 100644 gameengine/packages/quake3_screenshot/shaders/msl/bsp.frag.metal create mode 100644 gameengine/packages/quake3_screenshot/shaders/msl/bsp.vert.metal create mode 100644 gameengine/packages/quake3_screenshot/shaders/msl/overlay.frag.metal create mode 100644 gameengine/packages/quake3_screenshot/shaders/msl/overlay.vert.metal create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.glsl create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.spv create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/bsp.vert.glsl create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/bsp.vert.spv create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.glsl create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.spv create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/overlay.vert.glsl create mode 100644 gameengine/packages/quake3_screenshot/shaders/spirv/overlay.vert.spv create mode 100644 gameengine/packages/quake3_screenshot/workflows/load_map_with_unit_conversion.json create mode 100644 gameengine/packages/quake3_screenshot/workflows/q3_frame.json create mode 100644 gameengine/packages/quake3_screenshot/workflows/q3_game.json create mode 100644 gameengine/packages/quake3_screenshot/workflows/q3_map_session.json create mode 100644 gameengine/packages/quake3_screenshot/workflows/quake3_frame.json create mode 100644 gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_value_set_if_step.cpp create mode 100644 gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_value_set_if_step.hpp diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 78a97ccea..7f2b1427c 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -276,6 +276,7 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/workflow_generic_steps/workflow_value_copy_step.cpp src/services/impl/workflow/workflow_generic_steps/workflow_value_default_step.cpp src/services/impl/workflow/workflow_generic_steps/workflow_value_literal_step.cpp + src/services/impl/workflow/workflow_generic_steps/workflow_value_set_if_step.cpp src/services/impl/workflow/workflow_generic_steps/workflow_variable_get_step.cpp src/services/impl/workflow/workflow_generic_steps/workflow_variable_set_step.cpp src/services/impl/workflow/workflow_generic_steps/workflow_vfx_destroy_step.cpp diff --git a/gameengine/packages/quake3/config/menu.json b/gameengine/packages/quake3/config/menu.json new file mode 100644 index 000000000..452658765 --- /dev/null +++ b/gameengine/packages/quake3/config/menu.json @@ -0,0 +1,57 @@ +{ + "default_screen": "main", + "screens": { + "main": { + "title": "QUAKE III ARENA", + "items": [ + { "label": "RESUME GAME", "action": "close" }, + { "label": "SETUP", "action": "screen:setup" }, + { "label": "CHANGE MAP", "action": "screen:map_select" }, + { "label": "LEAVE ARENA", "action": "quit" } + ] + }, + "setup": { + "title": "SETUP", + "items": [ + { "label": "PLAYER", "action": "screen:player" }, + { "label": "CONTROLS", "action": "screen:controls" }, + { "label": "GAME OPTIONS", "action": "screen:options" }, + { "label": "BACK", "action": "back" } + ], + "back": "main" + }, + "player": { + "title": "PLAYER", + "items": [ + { "label": "NAME : SARGE", "action": "none" }, + { "label": "MODEL : KEEL", "action": "none" }, + { "label": "HANDEDNESS : RIGHT", "action": "none" }, + { "label": "BACK", "action": "back" } + ], + "back": "setup" + }, + "controls": { + "title": "CONTROLS", + "items": [ + { "label": "MOUSE SENSITIVITY", "action": "none" }, + { "label": "INVERT MOUSE : OFF", "action": "none" }, + { "label": "BACK", "action": "back" } + ], + "back": "setup" + }, + "options": { + "title": "GAME OPTIONS", + "items": [ + { "label": "SKILL : HURT ME PLENTY", "action": "none" }, + { "label": "FRIENDLY FIRE : OFF", "action": "none" }, + { "label": "BACK", "action": "back" } + ], + "back": "setup" + }, + "map_select": { + "title": "SKIRMISH", + "items": "maps", + "back": "main" + } + } +} diff --git a/gameengine/packages/quake3/package.json b/gameengine/packages/quake3/package.json index 441212de1..bba8721ee 100644 --- a/gameengine/packages/quake3/package.json +++ b/gameengine/packages/quake3/package.json @@ -17,7 +17,8 @@ ], "config": [ - "config/q3_rendering.json" + "config/q3_rendering.json", + "config/menu.json" ], "scene": [ diff --git a/gameengine/packages/quake3/workflows/q3_frame.json b/gameengine/packages/quake3/workflows/q3_frame.json index 7cd58d798..e8104d055 100644 --- a/gameengine/packages/quake3/workflows/q3_frame.json +++ b/gameengine/packages/quake3/workflows/q3_frame.json @@ -14,6 +14,47 @@ "typeVersion": 1, "position": [100, 0] }, + { + "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": "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": "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": "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", @@ -162,8 +203,13 @@ } ], "connections": { - "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, - "q3_menu": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, + "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, + "q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } }, + "should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } }, + "stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested","type": "main", "index": 0 }] } }, + "set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } }, + "compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } }, + "update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, "physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } }, "physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } }, "sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } }, diff --git a/gameengine/packages/quake3/workflows/q3_game.json b/gameengine/packages/quake3/workflows/q3_game.json index c3c2f061c..97651bffd 100644 --- a/gameengine/packages/quake3/workflows/q3_game.json +++ b/gameengine/packages/quake3/workflows/q3_game.json @@ -3,95 +3,84 @@ "active": true, "settings": { "executionTimeout": 0 }, "variables": { - "window_width": { "name": "window_width", "type": "number", "defaultValue": 1280 }, - "window_height": { "name": "window_height", "type": "number", "defaultValue": 960 }, - "window_title": { "name": "window_title", "type": "string", "defaultValue": "Quake 3 - Map Viewer" }, - "renderer_type": { "name": "renderer_type", "type": "string", "defaultValue": "auto" }, - "present_mode": { "name": "present_mode", "type": "string", "defaultValue": "mailbox" }, - "shader_vertex_path": { "name": "shader_vertex_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.vert.metal" }, - "shader_fragment_path": { "name": "shader_fragment_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.frag.metal" }, - "shader_textured_vert_path": { "name": "shader_textured_vert_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/textured.vert.metal" }, - "shader_textured_frag_path": { "name": "shader_textured_frag_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/textured.frag.metal" }, - "shader_bsp_vert_path": { "name": "shader_bsp_vert_path", "type": "string", "defaultValue": "packages/quake3/shaders/msl/bsp.vert.metal" }, - "shader_bsp_frag_path": { "name": "shader_bsp_frag_path", "type": "string", "defaultValue": "packages/quake3/shaders/msl/bsp.frag.metal" }, - "tex_walls_path": { "name": "tex_walls_path", "type": "string", "defaultValue": "packages/seed/assets/textures/walls/Bricks058_1K-JPG_Color.jpg" } + "window_width": { "name": "window_width", "type": "number", "defaultValue": 1280 }, + "window_height": { "name": "window_height", "type": "number", "defaultValue": 960 }, + "window_title": { "name": "window_title", "type": "string", "defaultValue": "Quake 3 - Map Viewer" }, + "renderer_type": { "name": "renderer_type", "type": "string", "defaultValue": "auto" }, + "present_mode": { "name": "present_mode", "type": "string", "defaultValue": "mailbox" }, + "shader_vertex_path": { "name": "shader_vertex_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.vert.metal" }, + "shader_fragment_path": { "name": "shader_fragment_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.frag.metal" }, + "shader_textured_vert_path":{ "name": "shader_textured_vert_path","type": "string", "defaultValue": "packages/seed/shaders/msl/textured.vert.metal" }, + "shader_textured_frag_path":{ "name": "shader_textured_frag_path","type": "string", "defaultValue": "packages/seed/shaders/msl/textured.frag.metal" }, + "shader_bsp_vert_path": { "name": "shader_bsp_vert_path", "type": "string", "defaultValue": "packages/quake3/shaders/msl/bsp.vert.metal" }, + "shader_bsp_frag_path": { "name": "shader_bsp_frag_path", "type": "string", "defaultValue": "packages/quake3/shaders/msl/bsp.frag.metal" }, + "tex_walls_path": { "name": "tex_walls_path", "type": "string", "defaultValue": "packages/seed/assets/textures/walls/Bricks058_1K-JPG_Color.jpg" } }, "nodes": [ - { "id": "sdl_init", "type": "sdl.init", "typeVersion": 1, "position": [0, 0] }, - { "id": "sdl_window", "type": "sdl.window.create", "typeVersion": 1, "position": [200, 0] }, - { "id": "gpu_init_viewport", "type": "graphics.gpu.init_viewport", "typeVersion": 1, "position": [400, 0], + { "id": "sdl_init", "type": "sdl.init", "typeVersion": 1, "position": [0, 0] }, + { "id": "sdl_window", "type": "sdl.window.create", "typeVersion": 1, "position": [200, 0] }, + { "id": "gpu_init_viewport", "type": "graphics.gpu.init_viewport", "typeVersion": 1, "position": [400, 0], "parameters": { "present_mode": "auto", "inputs": { "width": "window_width", "height": "window_height" }, "outputs": { "viewport_config": "viewport_config" } } }, - { "id": "gpu_init_renderer", "type": "graphics.gpu.init_renderer", "typeVersion": 1, "position": [600, 0], + { "id": "gpu_init_renderer", "type": "graphics.gpu.init_renderer", "typeVersion": 1, "position": [600, 0], "parameters": { "inputs": { "renderer_type": "renderer_type" }, "outputs": { "selected_renderer": "selected_renderer" } } }, - { "id": "gpu_init", "type": "graphics.gpu.init", "typeVersion": 1, "position": [800, 0], + { "id": "gpu_init", "type": "graphics.gpu.init", "typeVersion": 1, "position": [800, 0], "parameters": { "inputs": { "viewport_config": "viewport_config", "selected_renderer": "selected_renderer" }, "outputs": { "gpu_handle": "gpu_handle" } } }, - { "id": "compile_tex_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1000, 0], - "parameters": { "stage": "vertex", "output_key": "textured_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 }, + + { "id": "compile_tex_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1000, 0], + "parameters": { "stage": "vertex", "output_key": "textured_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 }, "inputs": { "shader_path": "shader_textured_vert_path" } }, - { "id": "compile_tex_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1100, 0], + { "id": "compile_tex_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1100, 0], "parameters": { "stage": "fragment", "output_key": "textured_fragment_shader", "num_uniform_buffers": 1, "num_samplers": 2 }, "inputs": { "shader_path": "shader_textured_frag_path" } }, - { "id": "create_tex_pipeline", "type": "graphics.gpu.pipeline.create", "typeVersion": 1, "position": [1200, 0], + { "id": "create_tex_pipeline","type": "graphics.gpu.pipeline.create","typeVersion": 1, "position": [1200, 0], "parameters": { "vertex_shader_key": "textured_vertex_shader", "fragment_shader_key": "textured_fragment_shader", "vertex_format": "position_uv", "pipeline_key": "gpu_pipeline_textured" } }, - { "id": "compile_bsp_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1300, 0], - "parameters": { "stage": "vertex", "output_key": "bsp_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 }, + { "id": "compile_bsp_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1300, 0], + "parameters": { "stage": "vertex", "output_key": "bsp_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 }, "inputs": { "shader_path": "shader_bsp_vert_path" } }, - { "id": "compile_bsp_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1400, 0], + { "id": "compile_bsp_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1400, 0], "parameters": { "stage": "fragment", "output_key": "bsp_fragment_shader", "num_uniform_buffers": 1, "num_samplers": 4 }, "inputs": { "shader_path": "shader_bsp_frag_path" } }, - { "id": "create_bsp_pipeline", "type": "graphics.gpu.pipeline.create", "typeVersion": 1, "position": [1500, 0], + { "id": "create_bsp_pipeline","type": "graphics.gpu.pipeline.create","typeVersion": 1, "position": [1500, 0], "parameters": { "vertex_shader_key": "bsp_vertex_shader", "fragment_shader_key": "bsp_fragment_shader", "vertex_format": "position_uv_lmuv_normal", "pipeline_key": "gpu_pipeline_bsp", "alpha_blend": 1 } }, - { "id": "tex_walls", "name": "Load Texture", "type": "texture.load", "typeVersion": 1, "position": [1600, 0], + { "id": "tex_walls", "name": "Load Texture", "type": "texture.load", "typeVersion": 1, "position": [1600, 0], "parameters": { "inputs": { "image_path": "tex_walls_path" }, "outputs": { "texture": "walls_texture" } } }, - { "id": "physics_world", "type": "physics.world.create", "typeVersion": 1, "position": [0, 200] }, - { "id": "load_bsp", "name": "Load Q3 BSP", "type": "bsp.load", "typeVersion": 1, "position": [200, 200], - "parameters": { "pk3_path": "${env:QUAKE3_PAK0}", "map_name": "${env:QUAKE3_MAP}", "scale": 0.03125 } }, - { "id": "bsp_lightmap", "name": "BSP Lightmap Atlas", "type": "bsp.lightmap_atlas", "typeVersion": 1, "position": [400, 200] }, - { "id": "bsp_geometry", "name": "BSP Build Geometry", "type": "bsp.build_geometry", "typeVersion": 1, "position": [600, 200], - "parameters": { "patch_tess_level": 4 } }, - { "id": "bsp_textures", "name": "BSP Extract Textures", "type": "bsp.extract_textures", "typeVersion": 1, "position": [800, 200] }, - { "id": "bsp_upload", "name": "BSP Upload Geometry", "type": "bsp.upload_geometry", "typeVersion": 1, "position": [1000, 200] }, - { "id": "bsp_collision", "name": "BSP Build Collision", "type": "bsp.build_collision", "typeVersion": 1, "position": [1200, 200] }, - { "id": "bsp_spawn", "name": "BSP Parse Spawn", "type": "bsp.parse_spawn", "typeVersion": 1, "position": [1400, 200] }, - { "id": "player", "type": "physics.body.add", "typeVersion": 1, "position": [1600, 200], + + { "id": "physics_world", "type": "physics.world.create", "typeVersion": 1, "position": [0, 200] }, + { "id": "player", "type": "physics.body.add", "typeVersion": 1, "position": [200, 200], "parameters": { "name": "player", "shape": "capsule", "mass": 80, "pos_x": 0, "pos_y": 5, "pos_z": 0, "radius": 0.3, "height": 1.0, "lock_rotation": 1, "is_player": 1 } }, - { "id": "spawn_apply", "type": "spawn.apply", "typeVersion": 1, "position": [1800, 200] }, - { "id": "camera_setup", "type": "camera.setup", "typeVersion": 1, "position": [0, 400], + { "id": "camera_setup", "type": "camera.setup", "typeVersion": 1, "position": [400, 200], "parameters": { "outputs": { "camera_state": "camera.state" } } }, - { "id": "lighting", "type": "lighting.setup", "typeVersion": 1, "position": [200, 400], + { "id": "lighting", "type": "lighting.setup", "typeVersion": 1, "position": [600, 200], "parameters": { "light_dir_x": -0.5, "light_dir_y": -0.8, "light_dir_z": -0.3, "light_intensity": 2.0, "ambient_r": 0.2, "ambient_g": 0.2, "ambient_b": 0.25, "ambient_intensity": 1.5, "exposure": 1.0 } }, - { "id": "set_running", "type": "value.literal", "typeVersion": 1, "position": [400, 400], - "parameters": { "value": true, "outputs": { "value": "game_running" } } }, - { "id": "game_loop", "type": "control.loop.while", "typeVersion": 1, "position": [600, 400], - "parameters": { "condition_key": "game_running", "package": "quake3", "workflow": "q3_frame" } }, - { "id": "exit", "type": "system.exit", "typeVersion": 1, "position": [800, 400] } + + { "id": "init_quit_flag", "type": "value.literal", "typeVersion": 1, "position": [800, 200], + "parameters": { "value": false, "outputs": { "value": "q3.quit_requested" } } }, + { "id": "init_outer_running", "type": "value.literal", "typeVersion": 1, "position": [1000, 200], + "parameters": { "value": true, "outputs": { "value": "outer_running" } } }, + { "id": "outer_loop", "type": "control.loop.while", "typeVersion": 1, "position": [1200, 200], + "parameters": { "condition_key": "outer_running", "package": "quake3", "workflow": "q3_map_session" } }, + + { "id": "exit", "type": "system.exit", "typeVersion": 1, "position": [1400, 200] } ], "connections": { - "sdl_init": { "main": { "0": [{ "node": "sdl_window", "type": "main", "index": 0 }] } }, - "sdl_window": { "main": { "0": [{ "node": "gpu_init_viewport", "type": "main", "index": 0 }] } }, - "gpu_init_viewport": { "main": { "0": [{ "node": "gpu_init_renderer", "type": "main", "index": 0 }] } }, - "gpu_init_renderer": { "main": { "0": [{ "node": "gpu_init", "type": "main", "index": 0 }] } }, - "gpu_init": { "main": { "0": [{ "node": "compile_tex_vert", "type": "main", "index": 0 }] } }, - "compile_tex_vert": { "main": { "0": [{ "node": "compile_tex_frag", "type": "main", "index": 0 }] } }, - "compile_tex_frag": { "main": { "0": [{ "node": "create_tex_pipeline", "type": "main", "index": 0 }] } }, - "create_tex_pipeline": { "main": { "0": [{ "node": "compile_bsp_vert", "type": "main", "index": 0 }] } }, - "compile_bsp_vert": { "main": { "0": [{ "node": "compile_bsp_frag", "type": "main", "index": 0 }] } }, - "compile_bsp_frag": { "main": { "0": [{ "node": "create_bsp_pipeline", "type": "main", "index": 0 }] } }, - "create_bsp_pipeline": { "main": { "0": [{ "node": "tex_walls", "type": "main", "index": 0 }] } }, - "tex_walls": { "main": { "0": [{ "node": "physics_world", "type": "main", "index": 0 }] } }, - "physics_world": { "main": { "0": [{ "node": "load_bsp", "type": "main", "index": 0 }] } }, - "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, - "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, - "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, - "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, - "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, - "bsp_collision": { "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, - "bsp_spawn": { "main": { "0": [{ "node": "player", "type": "main", "index": 0 }] } }, - "player": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, - "spawn_apply": { "main": { "0": [{ "node": "camera_setup", "type": "main", "index": 0 }] } }, - "camera_setup": { "main": { "0": [{ "node": "lighting", "type": "main", "index": 0 }] } }, - "lighting": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, - "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, - "game_loop": { "main": { "0": [{ "node": "exit", "type": "main", "index": 0 }] } } + "sdl_init": { "main": { "0": [{ "node": "sdl_window", "type": "main", "index": 0 }] } }, + "sdl_window": { "main": { "0": [{ "node": "gpu_init_viewport", "type": "main", "index": 0 }] } }, + "gpu_init_viewport": { "main": { "0": [{ "node": "gpu_init_renderer", "type": "main", "index": 0 }] } }, + "gpu_init_renderer": { "main": { "0": [{ "node": "gpu_init", "type": "main", "index": 0 }] } }, + "gpu_init": { "main": { "0": [{ "node": "compile_tex_vert", "type": "main", "index": 0 }] } }, + "compile_tex_vert": { "main": { "0": [{ "node": "compile_tex_frag", "type": "main", "index": 0 }] } }, + "compile_tex_frag": { "main": { "0": [{ "node": "create_tex_pipeline", "type": "main", "index": 0 }] } }, + "create_tex_pipeline":{ "main": { "0": [{ "node": "compile_bsp_vert", "type": "main", "index": 0 }] } }, + "compile_bsp_vert": { "main": { "0": [{ "node": "compile_bsp_frag", "type": "main", "index": 0 }] } }, + "compile_bsp_frag": { "main": { "0": [{ "node": "create_bsp_pipeline", "type": "main", "index": 0 }] } }, + "create_bsp_pipeline":{ "main": { "0": [{ "node": "tex_walls", "type": "main", "index": 0 }] } }, + "tex_walls": { "main": { "0": [{ "node": "physics_world", "type": "main", "index": 0 }] } }, + "physics_world": { "main": { "0": [{ "node": "player", "type": "main", "index": 0 }] } }, + "player": { "main": { "0": [{ "node": "camera_setup", "type": "main", "index": 0 }] } }, + "camera_setup": { "main": { "0": [{ "node": "lighting", "type": "main", "index": 0 }] } }, + "lighting": { "main": { "0": [{ "node": "init_quit_flag", "type": "main", "index": 0 }] } }, + "init_quit_flag": { "main": { "0": [{ "node": "init_outer_running", "type": "main", "index": 0 }] } }, + "init_outer_running": { "main": { "0": [{ "node": "outer_loop", "type": "main", "index": 0 }] } }, + "outer_loop": { "main": { "0": [{ "node": "exit", "type": "main", "index": 0 }] } } } } diff --git a/gameengine/packages/quake3/workflows/q3_map_session.json b/gameengine/packages/quake3/workflows/q3_map_session.json new file mode 100644 index 000000000..f7ae58e3c --- /dev/null +++ b/gameengine/packages/quake3/workflows/q3_map_session.json @@ -0,0 +1,97 @@ +{ + "name": "Q3 Map Session", + "description": "Loads a BSP map and runs the frame loop. Repeated by the outer loop for each map change.", + "nodes": [ + { + "id": "load_bsp", + "name": "Load Q3 BSP", + "type": "bsp.load", + "typeVersion": 1, + "position": [0, 0], + "parameters": { "pk3_path": "${env:QUAKE3_PAK0}", "map_name": "${env:QUAKE3_MAP}", "scale": 0.03125 } + }, + { + "id": "bsp_lightmap", + "name": "BSP Lightmap Atlas", + "type": "bsp.lightmap_atlas", + "typeVersion": 1, + "position": [100, 0] + }, + { + "id": "bsp_geometry", + "name": "BSP Build Geometry", + "type": "bsp.build_geometry", + "typeVersion": 1, + "position": [200, 0], + "parameters": { "patch_tess_level": 4 } + }, + { + "id": "bsp_textures", + "name": "BSP Extract Textures", + "type": "bsp.extract_textures", + "typeVersion": 1, + "position": [300, 0] + }, + { + "id": "bsp_upload", + "name": "BSP Upload Geometry", + "type": "bsp.upload_geometry", + "typeVersion": 1, + "position": [400, 0] + }, + { + "id": "bsp_collision", + "name": "BSP Build Collision", + "type": "bsp.build_collision", + "typeVersion": 1, + "position": [500, 0] + }, + { + "id": "bsp_spawn", + "name": "BSP Parse Spawn", + "type": "bsp.parse_spawn", + "typeVersion": 1, + "position": [600, 0] + }, + { + "id": "spawn_apply", + "type": "spawn.apply", + "typeVersion": 1, + "position": [700, 0] + }, + { + "id": "set_running", + "type": "value.literal", + "typeVersion": 1, + "position": [800, 0], + "parameters": { "value": true, "outputs": { "value": "game_running" } } + }, + { + "id": "game_loop", + "type": "control.loop.while", + "typeVersion": 1, + "position": [900, 0], + "parameters": { "condition_key": "game_running", "package": "quake3", "workflow": "q3_frame" } + }, + { + "id": "check_quit", + "type": "bool.not", + "typeVersion": 1, + "position": [1000, 0], + "inputs": { "value": "q3.quit_requested" }, + "outputs": { "value": "outer_running" } + } + ], + "connections": { + "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, + "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, + "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, + "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, + "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, + "bsp_collision":{ "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, + "bsp_spawn": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, + "spawn_apply": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, + "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, + "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } + } +} diff --git a/gameengine/packages/quake3_screenshot/assets/quake3_materials.json b/gameengine/packages/quake3_screenshot/assets/quake3_materials.json new file mode 100644 index 000000000..0ce2ea0bb --- /dev/null +++ b/gameengine/packages/quake3_screenshot/assets/quake3_materials.json @@ -0,0 +1,19 @@ +{ + "materials": [ + { + "id": "quake_floor", + "shader": "quake_floor", + "params": { + "diffuse": [0.4, 0.4, 0.5], + "roughness": 0.9 + } + }, + { + "id": "quake_light", + "shader": "quake_light", + "params": { + "emission": [1.0, 0.6, 0.3] + } + } + ] +} diff --git a/gameengine/packages/quake3_screenshot/config/menu.json b/gameengine/packages/quake3_screenshot/config/menu.json new file mode 100644 index 000000000..452658765 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/config/menu.json @@ -0,0 +1,57 @@ +{ + "default_screen": "main", + "screens": { + "main": { + "title": "QUAKE III ARENA", + "items": [ + { "label": "RESUME GAME", "action": "close" }, + { "label": "SETUP", "action": "screen:setup" }, + { "label": "CHANGE MAP", "action": "screen:map_select" }, + { "label": "LEAVE ARENA", "action": "quit" } + ] + }, + "setup": { + "title": "SETUP", + "items": [ + { "label": "PLAYER", "action": "screen:player" }, + { "label": "CONTROLS", "action": "screen:controls" }, + { "label": "GAME OPTIONS", "action": "screen:options" }, + { "label": "BACK", "action": "back" } + ], + "back": "main" + }, + "player": { + "title": "PLAYER", + "items": [ + { "label": "NAME : SARGE", "action": "none" }, + { "label": "MODEL : KEEL", "action": "none" }, + { "label": "HANDEDNESS : RIGHT", "action": "none" }, + { "label": "BACK", "action": "back" } + ], + "back": "setup" + }, + "controls": { + "title": "CONTROLS", + "items": [ + { "label": "MOUSE SENSITIVITY", "action": "none" }, + { "label": "INVERT MOUSE : OFF", "action": "none" }, + { "label": "BACK", "action": "back" } + ], + "back": "setup" + }, + "options": { + "title": "GAME OPTIONS", + "items": [ + { "label": "SKILL : HURT ME PLENTY", "action": "none" }, + { "label": "FRIENDLY FIRE : OFF", "action": "none" }, + { "label": "BACK", "action": "back" } + ], + "back": "setup" + }, + "map_select": { + "title": "SKIRMISH", + "items": "maps", + "back": "main" + } + } +} diff --git a/gameengine/packages/quake3_screenshot/config/q3_rendering.json b/gameengine/packages/quake3_screenshot/config/q3_rendering.json new file mode 100644 index 000000000..60edd5be0 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/config/q3_rendering.json @@ -0,0 +1,102 @@ +{ + "id": "quake3_rendering_config", + "name": "Quake 3 Rendering Configuration", + "description": "Quake 3 engine defaults and how they map to UE5", + + "camera": { + "fov_degrees": 90, + "notes": "Quake 3 hardcoded 90° FOV. This is the default. Do NOT change without understanding visual consequences.", + "why_90": "Q3 was designed with 90° as the standard. Changing this breaks visual asset alignment (texture scale, level proportions, sight lines)", + "ue5_equivalent": "90 degrees in UE5 = 90 degrees in Q3 (direct match)" + }, + + "viewport": { + "aspect_ratio": "4:3", + "notes": "Q3 was designed for 4:3 displays (CRT monitors). Modern widescreen (16:9, 16:10) will distort the image unless corrected." + }, + + "rendering": { + "unit_scale": { + "q3_units_per_meter": 32, + "ue5_units_per_meter": 100, + "conversion_factor": 3.125, + "explanation": "Q3 geometry is 3.125× smaller than UE5. Multiply all coordinates by 3.125." + }, + + "texture_scale": { + "note": "After unit conversion, textures should display at correct physical size", + "warning": "If textures look too small after unit conversion, verify the conversion factor is correct" + }, + + "lighting": { + "q3_system": "Radiosity-based (pre-calculated lightmaps)", + "q3_lightmaps": "128x128 or 256x256 per surface", + "ue5_approach": "Real-time lighting or baked GI (different system)" + }, + + "polygon_count": { + "q3_typical_level": "8,000-20,000 triangles for outdoor map", + "q3_typical_arena": "3,000-8,000 triangles for arena", + "notes": "Q3 was optimized for Pentium 3 era hardware (~1 GHz CPU, 256-512 MB RAM)" + } + }, + + "physics": { + "player_height": 56, + "player_height_ue5": 175, + "calculation": "56 * 3.125 = 175 UE5 units (1.75m)", + + "player_dimensions": { + "width": 32, + "height": 56, + "depth": 32, + "notes": "Collision box, not visual model" + }, + + "movement": { + "walk_speed": 320, + "walk_speed_ue5_units_per_sec": 1000, + "walk_speed_kmh": "3.6 km/h (human walking pace) ✓", + "sprint_speed": 600, + "air_acceleration": 1.0, + "friction": 0.7 + }, + + "gravity": { + "value": 800, + "ue5_comparison": "980 (9.8 m/s²) - Q3 is slightly lower", + "notes": "Q3's 800 units/sec² ≈ 8.0 m/s² (slightly reduced gravity for gameplay)" + }, + + "jump_height": { + "q3_units": 64, + "ue5_units": 200, + "approximate_height": "2.0 meters" + } + }, + + "weapon_scaling": { + "note": "Q3 models are very small. After 3.125× unit scaling, they should be appropriately sized.", + "example": { + "plasma_gun": "Original ~2 units tall, after scaling: ~6.25 UE5 units (6.25cm) - this is TINY", + "recommendation": "Consider upscaling weapon models separately (2-4×) for visibility" + } + }, + + "animation": { + "framerate": 10, + "notes": "Q3 animation frames run at 10 FPS internally. This should be scaled to 60 FPS for modern playback.", + "conversion": "Q3 animations should be played at 6× speed (60/10) or re-timed to 60 FPS" + }, + + "asset_import_checklist": [ + "☐ Apply 3.125× scale to all geometry (unit conversion)", + "☐ Set camera FOV to 90° (Q3 standard)", + "☐ Verify lightmaps transfer correctly (if converting lighting)", + "☐ Check texture scale (should be correct after unit scaling)", + "☐ Scale animations to 60 FPS (from Q3's 10 FPS internal)", + "☐ Test player movement speed (should feel natural)", + "☐ Verify weapon sizing (may need additional upscale)", + "☐ Check collision detection (physics bodies should match geometry)" + ] +} diff --git a/gameengine/packages/quake3_screenshot/config/units.json b/gameengine/packages/quake3_screenshot/config/units.json new file mode 100644 index 000000000..8787f68d4 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/config/units.json @@ -0,0 +1,36 @@ +{ + "id": "quake3_units", + "name": "Quake 3 Unit System", + "description": "Quake 3 uses different unit scales than UE5. This configuration handles conversion.", + + "source": { + "engine": "Quake 3", + "units_per_meter": 32, + "description": "Quake 3: ~32 units = 1 real-world meter (16 units ≈ 2.54cm based on player height)" + }, + + "target": { + "engine": "UE5/MetaBuilder", + "units_per_meter": 100, + "description": "UE5: 1 unit = 1cm, so 100 units = 1 real-world meter" + }, + + "conversion_factor": 3.125, + "explanation": "To convert Quake 3 to UE5: multiply all coordinates and sizes by 3.125 (100/32)", + + "scale_mapping": { + "player_height_q3": 56, + "player_height_q3_cm": "~180cm (typical human)", + "player_height_ue5": 180, + "player_height_ue5_units": "180 units = 180cm", + "verification": "56 * 3.125 = 175 ✓ (close to 180)" + }, + + "common_scales": { + "weapon_scale_q3": 1.0, + "weapon_scale_ue5": 3.125, + "door_height_q3": 96, + "door_height_ue5": 300, + "door_height_ue5_cm": "300cm = 3m" + } +} diff --git a/gameengine/packages/quake3_screenshot/package.json b/gameengine/packages/quake3_screenshot/package.json new file mode 100644 index 000000000..623c86483 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/package.json @@ -0,0 +1,79 @@ +{ + "name": "Quake 3 Screenshot", + "version": "1.0.0", + "id": "quake3_screenshot", + "type": "game", + "category": "demo", + "description": "Renders 240 frames then saves a screenshot and exits", + + "defaultWorkflow": "workflows/q3_game.json", + + "workflows": [ + "workflows/load_map_with_unit_conversion.json" + ], + + "assets": [ + "assets/quake3_materials.json" + ], + + "config": [ + "config/q3_rendering.json", + "config/menu.json" + ], + + "scene": [ + "scene/quake3_map.json", + "scene/camera_q3_default.json" + ], + + "shaders": [], + + "q3_specifications": { + "unit_scale": { + "units_per_meter": 32, + "conversion_to_ue5": 3.125, + "explanation": "Multiply all Q3 coordinates by 3.125 to get UE5 units" + }, + + "camera": { + "fov_degrees": 90, + "aspect_ratio": "4:3", + "critical": "Q3 was designed with 90° FOV. DO NOT change - textures and geometry align to this value." + }, + + "player": { + "height_q3_units": 56, + "height_ue5_units": 175, + "height_cm": 175, + "walk_speed_ue5_units_per_sec": 1000, + "walk_speed_kmh": 3.6 + }, + + "physics": { + "gravity": 800, + "sprint_speed_q3": 600, + "jump_height_q3_units": 64, + "jump_height_ue5_units": 200 + }, + + "animation": { + "internal_framerate": 10, + "upsample_to": 60, + "upsampling_factor": 6 + } + }, + + "import_notes": "Quake 3 maps are designed around 90° FOV (field of view). This is not arbitrary - all texture scaling, weapon visibility, and sight lines assume 90°. Changing FOV will misalign textures and break the visual design.", + + "bundled": true, + + "dependencies": [ + { + "name": "asset_loader", + "version": ">=1.0.0", + "usage": "map_loading_and_unit_conversion" + }, + { + } + ] +} diff --git a/gameengine/packages/quake3_screenshot/scene/camera_q3_default.json b/gameengine/packages/quake3_screenshot/scene/camera_q3_default.json new file mode 100644 index 000000000..a08fbfa44 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/scene/camera_q3_default.json @@ -0,0 +1,29 @@ +{ + "id": "camera_q3_default", + "name": "Quake 3 Default Camera Configuration", + "description": "Camera setup for Q3 maps with correct 90° FOV and aspect ratio handling", + + "camera": { + "position": [0.0, 0.0, -40.0], + "look_at": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_degrees": 90, + "fov_notes": "Q3 hardcoded 90° FOV. Keep this value for maps designed in Q3.", + "near": 0.1, + "far": 100.0 + }, + + "viewport": { + "recommended_aspect_ratio": "4:3", + "aspect_ratio_notes": "Q3 was designed for 4:3 (640x480, 800x600, 1024x768). Modern 16:9 will stretch horizontally.", + "widescreen_correction": "If using 16:9, either: (a) add black bars, or (b) adjust FOV horizontally" + }, + + "player_spawn": { + "default_position": [0.0, 176.0, 0.0], + "position_notes": "Adjusted for 3.125× unit scaling. Original Q3 player spawn is ~56 units above floor.", + "eye_height_above_ground": 56, + "eye_height_ue5_units": 175, + "eye_position_explanation": "Player eyes are 56 units above feet in Q3 (≈ 175 cm in UE5)" + } +} diff --git a/gameengine/packages/quake3_screenshot/scene/quake3_map.json b/gameengine/packages/quake3_screenshot/scene/quake3_map.json new file mode 100644 index 000000000..f3b1ee7a8 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/scene/quake3_map.json @@ -0,0 +1,11 @@ +{ + "id": "quake3_map", + "description": "Legacy Quake3 arena map metadata for validation captures.", + "spawn": { + "position": [0.0, 1.5, 0.0], + "orientation": [0.0, 0.0, 0.0] + }, + "validation": { + "checks": ["non_black_ratio", "mean_color", "sample_points"] + } +} diff --git a/gameengine/packages/quake3_screenshot/shaders/msl/bsp.frag.metal b/gameengine/packages/quake3_screenshot/shaders/msl/bsp.frag.metal new file mode 100644 index 000000000..ae4c30937 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/shaders/msl/bsp.frag.metal @@ -0,0 +1,73 @@ +#include +using namespace metal; + +struct PBRUniforms { + float4 u_lightDir; + float4 u_lightColor; + float4 u_ambient; + float4 u_material; + float4 u_flashPos; + float4 u_flashDir; + float4 u_flashColor; +}; + +struct FragmentInput { + float4 position [[position]]; + float2 uv; + float2 lightmapUv; + float3 worldNormal; + float3 worldPos; + float3 cameraPos; +}; + +fragment float4 main0( + FragmentInput in [[stage_in]], + texture2d albedoTex [[texture(0)]], + sampler albedoSampler [[sampler(0)]], + texture2d shadowMap [[texture(1)]], + sampler shadowSampler [[sampler(1)]], + texture2d lightmapTex [[texture(2)]], + sampler lightmapSampler [[sampler(2)]], + texture2d portalTex [[texture(3)]], + sampler portalSampler [[sampler(3)]], + constant PBRUniforms& pbr [[buffer(0)]]) +{ + (void)shadowMap; + (void)shadowSampler; + (void)in.worldNormal; + + float3 albedo = albedoTex.sample(albedoSampler, in.uv).rgb; + float3 lightmap = lightmapTex.sample(lightmapSampler, in.lightmapUv).rgb; + float overbright = (pbr.u_material.z > 0.0) ? pbr.u_material.z : 2.0; + float3 ambient = pbr.u_ambient.rgb * albedo; + float exposure = (pbr.u_lightColor.a > 0.0) ? pbr.u_lightColor.a : 1.0; + + if (pbr.u_material.w > 0.5) { + float time = pbr.u_material.y; + float3 viewDir = normalize(in.cameraPos - in.worldPos); + float3 n = normalize(in.worldNormal); + float3 reflected = reflect(-viewDir, n); + float fresnel = pow(1.0 - saturate(dot(viewDir, n)), 3.0); + + float2 reflectUv = reflected.xz * 0.32 + float2(0.5, 0.5); + reflectUv += float2(sin(time * 1.7 + in.worldPos.y * 0.35), + cos(time * 1.3 + in.worldPos.x * 0.28)) * 0.035; + + float3 portalBase = albedoTex.sample(albedoSampler, reflectUv).rgb; + float2 portalUv = fract(in.uv); + portalUv.y = 1.0 - portalUv.y; + float2 centered = portalUv - float2(0.5, 0.5); + float radial = length(centered); + float centerMask = 1.0 - smoothstep(0.34, 0.49, radial); + float3 destination = portalTex.sample(portalSampler, portalUv).rgb; + float pulse = 0.5 + 0.5 * sin(time * 3.0 + length(in.worldPos.xz) * 0.45); + float3 glow = float3(0.18, 0.42, 0.95) * (0.35 + 0.35 * pulse); + float3 reflectedColor = mix(portalBase, glow, 0.18 + 0.32 * fresnel); + float3 color = mix(reflectedColor, destination, centerMask * 0.88); + color += glow * (1.0 - centerMask) * 0.45; + float alpha = mix(0.18 + 0.24 * fresnel, 0.92, centerMask); + return float4((color * 1.08 + ambient * 0.08) * exposure, alpha); + } + + return float4((albedo * lightmap * overbright + ambient) * exposure, 1.0); +} diff --git a/gameengine/packages/quake3_screenshot/shaders/msl/bsp.vert.metal b/gameengine/packages/quake3_screenshot/shaders/msl/bsp.vert.metal new file mode 100644 index 000000000..cfc394505 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/shaders/msl/bsp.vert.metal @@ -0,0 +1,43 @@ +#include +using namespace metal; + +struct VertexUniforms { + float4x4 u_modelViewProj; + float4x4 u_model; + float4 u_surfaceNormal; + float4 u_uvScale; + float4 u_cameraPos; + float4x4 u_shadowVP; +}; + +struct VertexInput { + float3 position [[attribute(0)]]; + float2 uv [[attribute(1)]]; + float2 lightmapUv [[attribute(2)]]; + float3 normal [[attribute(3)]]; +}; + +struct VertexOutput { + float4 position [[position]]; + float2 uv; + float2 lightmapUv; + float3 worldNormal; + float3 worldPos; + float3 cameraPos; +}; + +vertex VertexOutput main0( + VertexInput in [[stage_in]], + constant VertexUniforms& uniforms [[buffer(0)]]) +{ + VertexOutput out; + out.position = uniforms.u_modelViewProj * float4(in.position, 1.0); + out.uv = in.uv * uniforms.u_uvScale.xy; + out.lightmapUv = in.lightmapUv; + + float4 worldPos = uniforms.u_model * float4(in.position, 1.0); + out.worldPos = worldPos.xyz; + out.worldNormal = (uniforms.u_model * float4(in.normal, 0.0)).xyz; + out.cameraPos = uniforms.u_cameraPos.xyz; + return out; +} diff --git a/gameengine/packages/quake3_screenshot/shaders/msl/overlay.frag.metal b/gameengine/packages/quake3_screenshot/shaders/msl/overlay.frag.metal new file mode 100644 index 000000000..463833f51 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/shaders/msl/overlay.frag.metal @@ -0,0 +1,13 @@ +#include +using namespace metal; + +struct FragmentInput { + float4 position [[position]]; + float2 uv; +}; + +fragment float4 main0(FragmentInput in [[stage_in]], + texture2d overlayTex [[texture(0)]], + sampler overlaySampler [[sampler(0)]]) { + return overlayTex.sample(overlaySampler, in.uv); +} diff --git a/gameengine/packages/quake3_screenshot/shaders/msl/overlay.vert.metal b/gameengine/packages/quake3_screenshot/shaders/msl/overlay.vert.metal new file mode 100644 index 000000000..a00e1a1e1 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/shaders/msl/overlay.vert.metal @@ -0,0 +1,19 @@ +#include +using namespace metal; + +struct VertexInput { + float3 position [[attribute(0)]]; + float2 uv [[attribute(1)]]; +}; + +struct VertexOutput { + float4 position [[position]]; + float2 uv; +}; + +vertex VertexOutput main0(VertexInput in [[stage_in]]) { + VertexOutput out; + out.position = float4(in.position, 1.0); + out.uv = in.uv; + return out; +} diff --git a/gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.glsl b/gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.glsl new file mode 100644 index 000000000..01e716dec --- /dev/null +++ b/gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.glsl @@ -0,0 +1,49 @@ +#version 450 + +// Quake 3 BSP fragment shader (minimal). +// Q3 bakes radiosity into a lightmap atlas at compile time, so runtime lighting +// is just: albedo × lightmap × overbright. The classic Q3 "overbright bits = 1" +// doubles lightmap intensity — modern screens are brighter, so we expose it as +// part of the existing FragmentUniformData.material slot (z) with a 2.0 default. +// +// Fragment sampler bindings come from workflow_draw_map_step.cpp BSP path: +// set=2 binding=0 albedo (per-surface diffuse from extracted pk3 textures) +// set=2 binding=1 shadowMap (shadow map — ignored for BSP, lightmap is canonical) +// set=2 binding=2 lightmap (shared atlas built by bsp.lightmap_atlas) + +layout(set = 2, binding = 0) uniform sampler2D albedoTex; +layout(set = 2, binding = 1) uniform sampler2D shadowMap; +layout(set = 2, binding = 2) uniform sampler2D lightmapTex; + +layout(set = 3, binding = 0) uniform PBRUniforms { + vec4 u_lightDir; + vec4 u_lightColor; // a = exposure + vec4 u_ambient; + vec4 u_material; // z = lightmap overbright multiplier (defaults via C++) + vec4 u_flashPos; + vec4 u_flashDir; + vec4 u_flashColor; +}; + +layout(location = 0) in vec2 v_uv; +layout(location = 1) in vec2 v_lmuv; +layout(location = 2) in vec3 v_worldNormal; +layout(location = 3) in vec3 v_worldPos; +layout(location = 4) in vec3 v_cameraPos; + +layout(location = 0) out vec4 o_color; + +void main() { + vec3 albedo = texture(albedoTex, v_uv).rgb; + vec3 lightmap = texture(lightmapTex, v_lmuv).rgb; + + // Q3 overbright. material.z falls back to 2.0 (engine pushes 0 today, so + // fall through to a sane default rather than rendering a black map). + float overbright = (u_material.z > 0.0) ? u_material.z : 2.0; + + vec3 ambient = u_ambient.rgb * albedo; + vec3 lit = albedo * lightmap * overbright + ambient; + + float exposure = (u_lightColor.a > 0.0) ? u_lightColor.a : 1.0; + o_color = vec4(lit * exposure, 1.0); +} diff --git a/gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.spv b/gameengine/packages/quake3_screenshot/shaders/spirv/bsp.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..372002ca5eb7388a529f2c6473a96199809e013c GIT binary patch literal 2844 zcmZ{kYjYG;5QZnj4JgX}BBHnA1jmPM6yr-Y``fLSf7kjJu`|@f#dl=a63g$`AnOi??wLNL< zeb{-w(OK+xcz}HfHh=c|&6VbZywmRT5@U&&tCtqH%rrX*==uWpElT`c-pcXl8=SGu zUYfNRn~fF1ui?I8FKuV5jZQOb4RPP1mp*7^-R1ebJH%^4{C{_k4e_N2*=G|VWfm-nGU7p@AO7V?;#qKU=OZlVA#pBkf!ru1NM|r2U zbcIKCUPnfdgQY!z?wjfVh`o-u z^lN{NM2!bZ-8%HUe}=er!1|m$@FNA9OvOFNup`d#Qn!}ypGS|py!QfPUJ=jPOp-s& zSm$*#eV(^Y?S&GHw{_lJ^6#)4+xOJ3?fi*_?fg}3=TG}M-=TTiH<$N51M!>jtaIjn zKVob9PY>HZI>%M}i;6AZ|6KUB{m+H%`I(pOA4>Za_dB1P5NA`{`P+s#d$-8aH`;;7 z5x+{t#@>mDD=urb&eK$AJ^Ca5yXekp#D5RnS&jI6(dCFo{P)qt6<0OB@1uX3338@B zLB^2@#Ch``XX{f$-#^qbn5_Zk%snN~Np{x@_y^e!AzvZ(M?KiZG7ir5qN=<8+lSH-^quFstO zRHXkP$RdBN>e5cB(6BbgVg~<`l~-ZoJnw z@DY3lpUNAn{J!p9Q(d#C{_M5>b=-UHPHkcJRFXWCoJme6KP1IEpDe&6a7+2Vy}Q4= zJ{ljb-@LV<$JwNoJL+?epKB5H(oSFb1@H>E4%Wa2z`#w=1iN4l)c7s1|3jiW8LBzz z)INjlr*6BI4G*&6IQtb9w&0bu4Mv@DXV8Z|mHXRM&UgCb{myTheqpl~wg=tL@x#t& zoc0g1navYvn{>M~Tb=$qi_3n_vlpHG*zY_U411#@x4di8?hTHz?m;IzX$=Rz5K+%# z?5VWn_4K!wNqaOIK1mO=N90Po*oqqVGHFky`-f@Q0|f8=OxlNOFB_&De8$&{TtBBr zgOh`nz1EToaIQK(XOXs#$H6`77}@@O3)9NEw&~y5dq+Fim0ht1=e^3g2WO0& zcNmZVNbflK{5dsX)vRu6yQMpfS^b!!D|!h z)_aNF^XX?jb!R1~{pC{D-*aDP_Z;@4{R+Fi$Q!TjEamJ^-`7BrJiV{~3X-w*V7^s$ zeWKo*$lB%2r|w&jGyW}hbBA4ht>of9-a!xlcahC)y~yX=(XMX%1~8}jKVsKcM82EI zVZVoLY@Ao!w-))-o2BkN9wO@-cJIfxXFlue@7vQJ^;*cz?>1kVy(_!)`4`4-%UFB$ zf1tfKVV%`Q;QV|O_waFUmw>#wy}SyX(PLt~`>Q3#Hsf9|Ir|JhzYk;WsYO-mz5$Ha zU%RvZlf2p*z}ouQ-wOLR;Cr^eO-`iV0QR=Z{x(`B}+T@A593@%n48-sR`$*4D>fox5Dz$$ezGJ-&JS+5&RIWo>I2>wDb-an3K0 z=g;{7PTqdK^Dlv%D{6d&EH7M9BVxAUjFFF+9b|donveM!&KP@*m~W8fg)3rOyk5Vr zZ-Kp-Ti=+?cgTKgzDMum5ztrOduWziytT*Z-h5FXr<**8U&t G9q>P*u(l%r literal 0 HcmV?d00001 diff --git a/gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.glsl b/gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.glsl new file mode 100644 index 000000000..d71b1ec73 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.glsl @@ -0,0 +1,10 @@ +#version 450 + +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 out_color; + +layout(set = 2, binding = 0) uniform sampler2D font_tex; + +void main() { + out_color = texture(font_tex, v_uv); +} diff --git a/gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.spv b/gameengine/packages/quake3_screenshot/shaders/spirv/overlay.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..9bcedad17e95a9e7f9537e646b4ea6f9cc8de59e GIT binary patch literal 560 zcmY+AO-lk{6oqe2qn71IMiD`1?ZrifAhmVlu0_is`hcJus8RZT{i-%W&oe633wQ2& z&U@~;_fAqdt3_-?BkHjq%hQPpOu$vkJel512k*th;QV5!q8UjkRMU!DY?6)FyXTn? zTSSLwqm!orw*=RSPl{65zxou$ntWbl_xVfy#$9fg{FKj%ta$w3TGr{i$mR?21Q+X0 z1RSw7{Sy7+Q?R{#w|=ef!pir{>6V%U?p3%Rafsg8#m?K~TPM4{{CI_(LGFlYw#n*Q z^WRzHW0VbE)akdgQpxEjb_b9BTV(y5Q+=DPm%MthJIP)0htxT%$Cu*uHQu5$n_|+fw-N}%Pyib=r8)KGYcfR+2?@hx|@a!&PC?_`a{YA`?J~VUChjuR*zK^Fd<{k6l zY;NPcN%4s2suumsd{8xaj`fy{_sOQ0dDM`HCY#3{jvaLqElu4yV6^tjcl{#D_;d`reTPB34TehuL@dFtVsx}nYXAcnP+w=QqBUnZEH z!ZD{9<;8uO11`xp=-QNL4*B#e%cB9Feqg=}9QEXG%Z&NDA%9mlYSBi&eR*=&0r7@B zad`TH*`=LlcWC2acFVkTeOLOhGY&OBVjTG^v1qKw(Dx{oxz&V|TX406qo>WQCmj9z qQjh)}8TI57=bg9e%NzG(?3LYJO9_~KbfCQ@LpL$r2maM7E@Xe27(4s` literal 0 HcmV?d00001 diff --git a/gameengine/packages/quake3_screenshot/workflows/load_map_with_unit_conversion.json b/gameengine/packages/quake3_screenshot/workflows/load_map_with_unit_conversion.json new file mode 100644 index 000000000..9a99a4f9d --- /dev/null +++ b/gameengine/packages/quake3_screenshot/workflows/load_map_with_unit_conversion.json @@ -0,0 +1,92 @@ +{ + "name": "Quake 3 Map Loader with Unit Conversion", + "description": "Load Quake 3 BSP maps and convert to UE5 units (3.125x scale: 32 Q3 units/meter → 100 UE5 units/meter)", + "version": "1.0.0", + "id": "workflow_quake3_map_loader", + "tenantId": "${TENANT_ID}", + + "variables": { + "mapFile": { + "type": "string", + "value": "${PACKAGE_ROOT}/maps/q3dm1.bsp", + "description": "Path to Quake 3 BSP map file" + }, + "q3ToUE5Scale": { + "type": "number", + "value": 3.125, + "description": "Q3: 32 units/meter, UE5: 100 units/meter. Multiply all coordinates by 3.125" + } + }, + + "nodes": [ + { + "id": "load_bsp", + "name": "Load Quake 3 BSP", + "type": "asset.load_map", + "typeVersion": 1, + "position": [0, 0], + "parameters": { + "file_path": "${variables.mapFile}", + "format_hint": "quake3", + "output_key": "q3_map" + } + }, + + { + "id": "scale_to_ue5", + "name": "Scale to UE5 Units (3.125x)", + "type": "geometry.apply_scale_transform", + "typeVersion": 1, + "position": [200, 0], + "parameters": { + "inputs": { + "geometry": "q3_map" + }, + "scale_factor": "${variables.q3ToUE5Scale}", + "apply_to": "positions,sizes,physics", + "output_key": "map_scaled" + } + }, + + { + "id": "load_scene", + "name": "Load to Scene with Colliders", + "type": "scene.load_geometry", + "typeVersion": 1, + "position": [400, 0], + "parameters": { + "inputs": { + "geometry": "map_scaled" + }, + "create_colliders": true, + "shader_key": "bsp_default", + "output_key": "loaded_map" + } + } + ], + + "connections": { + "load_bsp": { + "main": { + "0": [ + { + "node": "scale_to_ue5", + "type": "main", + "index": 0 + } + ] + } + }, + "scale_to_ue5": { + "main": { + "0": [ + { + "node": "load_scene", + "type": "main", + "index": 0 + } + ] + } + } + } +} diff --git a/gameengine/packages/quake3_screenshot/workflows/q3_frame.json b/gameengine/packages/quake3_screenshot/workflows/q3_frame.json new file mode 100644 index 000000000..789ae5dd9 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/workflows/q3_frame.json @@ -0,0 +1,270 @@ +{ + "name": "Q3 Frame Tick", + "description": "Per-frame: poll input, FPS move, render BSP map with post-FX.", + "nodes": [ + { + "id": "input_poll", + "type": "input.poll", + "typeVersion": 1, + "position": [0, 0] + }, + { + "id": "q3_menu", + "type": "q3.menu.update", + "typeVersion": 1, + "position": [100, 0] + }, + { + "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": "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": "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": "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": "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": "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" } + } + ], + "connections": { + "input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } }, + "q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } }, + "should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } }, + "stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested","type": "main", "index": 0 }] } }, + "set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } }, + "compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } }, + "update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } }, + "physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } }, + "physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } }, + "sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } }, + "bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } }, + "camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } }, + "q3_weapon": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } }, + "render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } }, + "portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } }, + "frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } }, + "draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } }, + "draw_pickups": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }, + "end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } }, + "overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } }, + "q3_overlay": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } }, + "postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } }, + "postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } }, + "bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } }, + "bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } }, + "postfx_composite": { "main": { "0": [{ "node": "set_threshold", "type": "main", "index": 0 }] } }, + "set_threshold": { "main": { "0": [{ "node": "check_frame_240", "type": "main", "index": 0 }] } }, + "check_frame_240": { "main": { "0": [{ "node": "set_screenshot_path", "type": "main", "index": 0 }] } }, + "set_screenshot_path": { "main": { "0": [{ "node": "stop_after_screenshot", "type": "main", "index": 0 }] } } + } +} diff --git a/gameengine/packages/quake3_screenshot/workflows/q3_game.json b/gameengine/packages/quake3_screenshot/workflows/q3_game.json new file mode 100644 index 000000000..6946ada05 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/workflows/q3_game.json @@ -0,0 +1,86 @@ +{ + "name": "Quake 3 Map Viewer", + "active": true, + "settings": { "executionTimeout": 0 }, + "variables": { + "window_width": { "name": "window_width", "type": "number", "defaultValue": 1280 }, + "window_height": { "name": "window_height", "type": "number", "defaultValue": 960 }, + "window_title": { "name": "window_title", "type": "string", "defaultValue": "Quake 3 - Map Viewer" }, + "renderer_type": { "name": "renderer_type", "type": "string", "defaultValue": "auto" }, + "present_mode": { "name": "present_mode", "type": "string", "defaultValue": "mailbox" }, + "shader_vertex_path": { "name": "shader_vertex_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.vert.metal" }, + "shader_fragment_path": { "name": "shader_fragment_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.frag.metal" }, + "shader_textured_vert_path":{ "name": "shader_textured_vert_path","type": "string", "defaultValue": "packages/seed/shaders/msl/textured.vert.metal" }, + "shader_textured_frag_path":{ "name": "shader_textured_frag_path","type": "string", "defaultValue": "packages/seed/shaders/msl/textured.frag.metal" }, + "shader_bsp_vert_path": { "name": "shader_bsp_vert_path", "type": "string", "defaultValue": "packages/quake3/shaders/msl/bsp.vert.metal" }, + "shader_bsp_frag_path": { "name": "shader_bsp_frag_path", "type": "string", "defaultValue": "packages/quake3/shaders/msl/bsp.frag.metal" }, + "tex_walls_path": { "name": "tex_walls_path", "type": "string", "defaultValue": "packages/seed/assets/textures/walls/Bricks058_1K-JPG_Color.jpg" } + }, + "nodes": [ + { "id": "sdl_init", "type": "sdl.init", "typeVersion": 1, "position": [0, 0] }, + { "id": "sdl_window", "type": "sdl.window.create", "typeVersion": 1, "position": [200, 0] }, + { "id": "gpu_init_viewport", "type": "graphics.gpu.init_viewport", "typeVersion": 1, "position": [400, 0], + "parameters": { "present_mode": "auto", "inputs": { "width": "window_width", "height": "window_height" }, "outputs": { "viewport_config": "viewport_config" } } }, + { "id": "gpu_init_renderer", "type": "graphics.gpu.init_renderer", "typeVersion": 1, "position": [600, 0], + "parameters": { "inputs": { "renderer_type": "renderer_type" }, "outputs": { "selected_renderer": "selected_renderer" } } }, + { "id": "gpu_init", "type": "graphics.gpu.init", "typeVersion": 1, "position": [800, 0], + "parameters": { "inputs": { "viewport_config": "viewport_config", "selected_renderer": "selected_renderer" }, "outputs": { "gpu_handle": "gpu_handle" } } }, + + { "id": "compile_tex_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1000, 0], + "parameters": { "stage": "vertex", "output_key": "textured_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 }, + "inputs": { "shader_path": "shader_textured_vert_path" } }, + { "id": "compile_tex_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1100, 0], + "parameters": { "stage": "fragment", "output_key": "textured_fragment_shader", "num_uniform_buffers": 1, "num_samplers": 2 }, + "inputs": { "shader_path": "shader_textured_frag_path" } }, + { "id": "create_tex_pipeline","type": "graphics.gpu.pipeline.create","typeVersion": 1, "position": [1200, 0], + "parameters": { "vertex_shader_key": "textured_vertex_shader", "fragment_shader_key": "textured_fragment_shader", "vertex_format": "position_uv", "pipeline_key": "gpu_pipeline_textured" } }, + { "id": "compile_bsp_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1300, 0], + "parameters": { "stage": "vertex", "output_key": "bsp_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 }, + "inputs": { "shader_path": "shader_bsp_vert_path" } }, + { "id": "compile_bsp_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1400, 0], + "parameters": { "stage": "fragment", "output_key": "bsp_fragment_shader", "num_uniform_buffers": 1, "num_samplers": 4 }, + "inputs": { "shader_path": "shader_bsp_frag_path" } }, + { "id": "create_bsp_pipeline","type": "graphics.gpu.pipeline.create","typeVersion": 1, "position": [1500, 0], + "parameters": { "vertex_shader_key": "bsp_vertex_shader", "fragment_shader_key": "bsp_fragment_shader", "vertex_format": "position_uv_lmuv_normal", "pipeline_key": "gpu_pipeline_bsp", "alpha_blend": 1 } }, + { "id": "tex_walls", "name": "Load Texture", "type": "texture.load", "typeVersion": 1, "position": [1600, 0], + "parameters": { "inputs": { "image_path": "tex_walls_path" }, "outputs": { "texture": "walls_texture" } } }, + + { "id": "physics_world", "type": "physics.world.create", "typeVersion": 1, "position": [0, 200] }, + { "id": "player", "type": "physics.body.add", "typeVersion": 1, "position": [200, 200], + "parameters": { "name": "player", "shape": "capsule", "mass": 80, "pos_x": 0, "pos_y": 5, "pos_z": 0, "radius": 0.3, "height": 1.0, "lock_rotation": 1, "is_player": 1 } }, + { "id": "camera_setup", "type": "camera.setup", "typeVersion": 1, "position": [400, 200], + "parameters": { "outputs": { "camera_state": "camera.state" } } }, + { "id": "lighting", "type": "lighting.setup", "typeVersion": 1, "position": [600, 200], + "parameters": { "light_dir_x": -0.5, "light_dir_y": -0.8, "light_dir_z": -0.3, "light_intensity": 2.0, "ambient_r": 0.2, "ambient_g": 0.2, "ambient_b": 0.25, "ambient_intensity": 1.5, "exposure": 1.0 } }, + + { "id": "init_quit_flag", "type": "value.literal", "typeVersion": 1, "position": [800, 200], + "parameters": { "value": false, "outputs": { "value": "q3.quit_requested" } } }, + { "id": "init_outer_running", "type": "value.literal", "typeVersion": 1, "position": [1000, 200], + "parameters": { "value": true, "outputs": { "value": "outer_running" } } }, + { "id": "outer_loop", "type": "control.loop.while", "typeVersion": 1, "position": [1200, 200], + "parameters": { "condition_key": "outer_running", "package": "quake3_screenshot", "workflow": "q3_map_session" } }, + + { "id": "exit", "type": "system.exit", "typeVersion": 1, "position": [1400, 200] } + ], + "connections": { + "sdl_init": { "main": { "0": [{ "node": "sdl_window", "type": "main", "index": 0 }] } }, + "sdl_window": { "main": { "0": [{ "node": "gpu_init_viewport", "type": "main", "index": 0 }] } }, + "gpu_init_viewport": { "main": { "0": [{ "node": "gpu_init_renderer", "type": "main", "index": 0 }] } }, + "gpu_init_renderer": { "main": { "0": [{ "node": "gpu_init", "type": "main", "index": 0 }] } }, + "gpu_init": { "main": { "0": [{ "node": "compile_tex_vert", "type": "main", "index": 0 }] } }, + "compile_tex_vert": { "main": { "0": [{ "node": "compile_tex_frag", "type": "main", "index": 0 }] } }, + "compile_tex_frag": { "main": { "0": [{ "node": "create_tex_pipeline", "type": "main", "index": 0 }] } }, + "create_tex_pipeline":{ "main": { "0": [{ "node": "compile_bsp_vert", "type": "main", "index": 0 }] } }, + "compile_bsp_vert": { "main": { "0": [{ "node": "compile_bsp_frag", "type": "main", "index": 0 }] } }, + "compile_bsp_frag": { "main": { "0": [{ "node": "create_bsp_pipeline", "type": "main", "index": 0 }] } }, + "create_bsp_pipeline":{ "main": { "0": [{ "node": "tex_walls", "type": "main", "index": 0 }] } }, + "tex_walls": { "main": { "0": [{ "node": "physics_world", "type": "main", "index": 0 }] } }, + "physics_world": { "main": { "0": [{ "node": "player", "type": "main", "index": 0 }] } }, + "player": { "main": { "0": [{ "node": "camera_setup", "type": "main", "index": 0 }] } }, + "camera_setup": { "main": { "0": [{ "node": "lighting", "type": "main", "index": 0 }] } }, + "lighting": { "main": { "0": [{ "node": "init_quit_flag", "type": "main", "index": 0 }] } }, + "init_quit_flag": { "main": { "0": [{ "node": "init_outer_running", "type": "main", "index": 0 }] } }, + "init_outer_running": { "main": { "0": [{ "node": "outer_loop", "type": "main", "index": 0 }] } }, + "outer_loop": { "main": { "0": [{ "node": "exit", "type": "main", "index": 0 }] } } + } +} diff --git a/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json b/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json new file mode 100644 index 000000000..4fcac0fac --- /dev/null +++ b/gameengine/packages/quake3_screenshot/workflows/q3_map_session.json @@ -0,0 +1,97 @@ +{ + "name": "Q3 Map Session", + "description": "Loads a BSP map and runs the frame loop. Repeated by the outer loop for each map change.", + "nodes": [ + { + "id": "load_bsp", + "name": "Load Q3 BSP", + "type": "bsp.load", + "typeVersion": 1, + "position": [0, 0], + "parameters": { "pk3_path": "${env:QUAKE3_PAK0}", "map_name": "${env:QUAKE3_MAP}", "scale": 0.03125 } + }, + { + "id": "bsp_lightmap", + "name": "BSP Lightmap Atlas", + "type": "bsp.lightmap_atlas", + "typeVersion": 1, + "position": [100, 0] + }, + { + "id": "bsp_geometry", + "name": "BSP Build Geometry", + "type": "bsp.build_geometry", + "typeVersion": 1, + "position": [200, 0], + "parameters": { "patch_tess_level": 4 } + }, + { + "id": "bsp_textures", + "name": "BSP Extract Textures", + "type": "bsp.extract_textures", + "typeVersion": 1, + "position": [300, 0] + }, + { + "id": "bsp_upload", + "name": "BSP Upload Geometry", + "type": "bsp.upload_geometry", + "typeVersion": 1, + "position": [400, 0] + }, + { + "id": "bsp_collision", + "name": "BSP Build Collision", + "type": "bsp.build_collision", + "typeVersion": 1, + "position": [500, 0] + }, + { + "id": "bsp_spawn", + "name": "BSP Parse Spawn", + "type": "bsp.parse_spawn", + "typeVersion": 1, + "position": [600, 0] + }, + { + "id": "spawn_apply", + "type": "spawn.apply", + "typeVersion": 1, + "position": [700, 0] + }, + { + "id": "set_running", + "type": "value.literal", + "typeVersion": 1, + "position": [800, 0], + "parameters": { "value": true, "outputs": { "value": "game_running" } } + }, + { + "id": "game_loop", + "type": "control.loop.while", + "typeVersion": 1, + "position": [900, 0], + "parameters": { "condition_key": "game_running", "package": "quake3_screenshot", "workflow": "q3_frame" } + }, + { + "id": "check_quit", + "type": "bool.not", + "typeVersion": 1, + "position": [1000, 0], + "inputs": { "value": "q3.quit_requested" }, + "outputs": { "value": "outer_running" } + } + ], + "connections": { + "load_bsp": { "main": { "0": [{ "node": "bsp_lightmap", "type": "main", "index": 0 }] } }, + "bsp_lightmap": { "main": { "0": [{ "node": "bsp_geometry", "type": "main", "index": 0 }] } }, + "bsp_geometry": { "main": { "0": [{ "node": "bsp_textures", "type": "main", "index": 0 }] } }, + "bsp_textures": { "main": { "0": [{ "node": "bsp_upload", "type": "main", "index": 0 }] } }, + "bsp_upload": { "main": { "0": [{ "node": "bsp_collision", "type": "main", "index": 0 }] } }, + "bsp_collision":{ "main": { "0": [{ "node": "bsp_spawn", "type": "main", "index": 0 }] } }, + "bsp_spawn": { "main": { "0": [{ "node": "spawn_apply", "type": "main", "index": 0 }] } }, + "spawn_apply": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, + "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, + "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } + } +} diff --git a/gameengine/packages/quake3_screenshot/workflows/quake3_frame.json b/gameengine/packages/quake3_screenshot/workflows/quake3_frame.json new file mode 100644 index 000000000..33c7265a9 --- /dev/null +++ b/gameengine/packages/quake3_screenshot/workflows/quake3_frame.json @@ -0,0 +1,129 @@ +{ + "name": "Quake3 Frame", + "nodes": [ + { + "id": "quake_begin", + "name": "Quake Begin", + "type": "frame.begin", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "parameters": { + "inputs": { + "delta": "frame.delta" + } + } + }, + { + "id": "quake_physics", + "name": "Quake Physics", + "type": "frame.bullet_physics", + "typeVersion": 1, + "position": [ + 260, + 0 + ], + "parameters": { + "inputs": { + "delta": "frame.delta" + } + } + }, + { + "id": "quake_scene", + "name": "Quake Scene", + "type": "frame.scene", + "typeVersion": 1, + "position": [ + 520, + 0 + ], + "parameters": { + "inputs": { + "delta": "frame.delta" + } + } + }, + { + "id": "quake_render", + "name": "Quake Render", + "type": "frame.render", + "typeVersion": 1, + "position": [ + 780, + 0 + ], + "parameters": { + "inputs": { + "elapsed": "frame.elapsed" + } + } + }, + { + "id": "quake_validation", + "name": "Quake Validation", + "type": "validation.tour.checkpoint", + "typeVersion": 1, + "position": [ + 1040, + 0 + ], + "parameters": { + "inputs": { + "checkpoint": "packages.quake3_map" + } + } + } + ], + "connections": { + "Quake Begin": { + "main": { + "0": [ + { + "node": "Quake Physics", + "type": "main", + "index": 0 + } + ] + } + }, + "Quake Physics": { + "main": { + "0": [ + { + "node": "Quake Scene", + "type": "main", + "index": 0 + } + ] + } + }, + "Quake Scene": { + "main": { + "0": [ + { + "node": "Quake Render", + "type": "main", + "index": 0 + } + ] + } + }, + "Quake Render": { + "main": { + "0": [ + { + "node": "Quake Validation", + "type": "main", + "index": 0 + } + ] + } + } + }, + "id": "workflow_quake3_frame", + "version": "3.0.0", + "tenantId": "${TENANT_ID}" +} \ No newline at end of file diff --git a/gameengine/src/services/impl/workflow/graphics/workflow_graphics_screenshot_request_step.cpp b/gameengine/src/services/impl/workflow/graphics/workflow_graphics_screenshot_request_step.cpp index 2287e9b38..86cc5ea99 100644 --- a/gameengine/src/services/impl/workflow/graphics/workflow_graphics_screenshot_request_step.cpp +++ b/gameengine/src/services/impl/workflow/graphics/workflow_graphics_screenshot_request_step.cpp @@ -31,7 +31,9 @@ void WorkflowGraphicsScreenshotRequestStep::Execute( const auto* output_path = context.TryGet(outputPathKey); if (!output_path || output_path->empty()) { - throw std::runtime_error("graphics.screenshot.request requires output_path input"); + // No path set yet — skip silently (e.g. waiting for frame threshold) + context.Set(outputSuccessKey, false); + return; } // Resolve ~ in path diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp index 5ba5a3dcc..86ee5df81 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp @@ -2,10 +2,38 @@ #include #include +#include #include namespace sdl3cpp::services::impl { +namespace { + +nlohmann::json LoadMenuConfig() { + std::ifstream f("packages/quake3/config/menu.json"); + if (!f.is_open()) return nlohmann::json{}; + try { return nlohmann::json::parse(f); } + catch (...) { return nlohmann::json{}; } +} + +// Build the item list for a screen. "maps" source is expanded from q3.maps context. +nlohmann::json BuildItems(const nlohmann::json& screen, const nlohmann::json& maps) { + auto itemsField = screen.find("items"); + if (itemsField == screen.end()) return nlohmann::json::array(); + + if (itemsField->is_string() && itemsField->get() == "maps") { + nlohmann::json out = nlohmann::json::array(); + for (const auto& m : maps) { + std::string name = m.get(); + out.push_back({ {"label", name}, {"action", "map:" + name} }); + } + return out; + } + return *itemsField; +} + +} // namespace + WorkflowQ3MenuUpdateStep::WorkflowQ3MenuUpdateStep(std::shared_ptr logger) : logger_(std::move(logger)) {} @@ -14,35 +42,100 @@ std::string WorkflowQ3MenuUpdateStep::GetPluginId() const { } void WorkflowQ3MenuUpdateStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + // Lazy-load menu config once per engine lifetime + if (!config_loaded_) { + config_ = LoadMenuConfig(); + config_loaded_ = true; + if (logger_) logger_->Info("q3.menu.update: loaded menu config"); + } + + const auto& screens = config_.value("screens", nlohmann::json::object()); + const std::string defaultScreen = config_.value("default_screen", std::string("main")); + + // --- toggle open/close --- bool open = context.GetBool("q3.menu_open", true); - if (context.GetBool("input_key_escape_pressed", false)) { - open = !open; + const bool escPressed = context.GetBool("input_key_escape_pressed", false); + if (escPressed) { + if (open) { + // If we're on a sub-screen and it has a back, go back rather than close + const std::string curScreen = context.Get("q3.menu_screen", defaultScreen); + auto screenIt = screens.find(curScreen); + if (screenIt != screens.end() && screenIt->contains("back")) { + const std::string back = (*screenIt)["back"].get(); + context.Set("q3.menu_screen", back); + context.Set("q3.menu_selected_item", 0); + } else { + open = false; + } + } else { + open = true; + context.Set("q3.menu_screen", defaultScreen); + context.Set("q3.menu_selected_item", 0); + } } context.Set("q3.menu_open", open); - auto maps = context.Get("q3.maps", nlohmann::json::array()); - if (!maps.is_array() || maps.empty()) { - maps = nlohmann::json::array({"q3dm7"}); + // --- build current item list --- + const std::string screen = context.Get("q3.menu_screen", defaultScreen); + const auto maps = context.Get("q3.maps", nlohmann::json::array({"q3dm7"})); + + nlohmann::json items = nlohmann::json::array(); + std::string title; + auto screenIt = screens.find(screen); + if (screenIt != screens.end()) { + title = screenIt->value("title", screen); + items = BuildItems(*screenIt, maps); } - int selected = context.Get("q3.menu_selected_map", 0); - selected = std::clamp(selected, 0, static_cast(maps.size()) - 1); + context.Set("q3.menu_items", items); + context.Set("q3.menu_title", title); + + // --- navigate --- + bool mapSelected = false; + bool quitPressed = false; + + int selected = context.Get("q3.menu_selected_item", 0); + const int numItems = static_cast(items.size()); + if (numItems > 0) selected = std::clamp(selected, 0, numItems - 1); + + if (open && numItems > 0) { + if (context.GetBool("input_key_up_pressed", false)) + selected = (selected + numItems - 1) % numItems; + if (context.GetBool("input_key_down_pressed", false)) + selected = (selected + 1) % numItems; - if (open) { - if (context.GetBool("input_key_up_pressed", false)) { - selected = (selected + static_cast(maps.size()) - 1) % static_cast(maps.size()); - } - if (context.GetBool("input_key_down_pressed", false)) { - selected = (selected + 1) % static_cast(maps.size()); - } if (context.GetBool("input_key_enter_pressed", false)) { - const std::string map = maps[selected].get(); - context.Set("q3.pending_map", map); - if (logger_) logger_->Info("q3.menu.update: selected map " + map + " (restart with QUAKE3_MAP=" + map + ")"); + const std::string action = items[selected].value("action", std::string("none")); + if (action == "quit") { + quitPressed = true; + if (logger_) logger_->Info("q3.menu.update: quit"); + } else if (action == "close") { + open = false; + context.Set("q3.menu_open", open); + } else if (action == "back") { + auto sIt = screens.find(screen); + const std::string back = (sIt != screens.end() && sIt->contains("back")) + ? (*sIt)["back"].get() : defaultScreen; + context.Set("q3.menu_screen", back); + context.Set("q3.menu_selected_item", 0); + } else if (action.rfind("screen:", 0) == 0) { + context.Set("q3.menu_screen", action.substr(7)); + context.Set("q3.menu_selected_item", 0); + } else if (action.rfind("map:", 0) == 0) { + const std::string map = action.substr(4); + context.Set("q3.pending_map", map); + mapSelected = true; + if (logger_) logger_->Info("q3.menu.update: map selected: " + map); + } } + + if (context.GetBool("input_key_q_pressed", false)) + quitPressed = true; } - context.Set("q3.menu_selected_map", selected); + context.Set("q3.menu_selected_item", selected); + context.Set("q3.menu_map_selected", mapSelected); + context.Set("q3.menu_quit_pressed", quitPressed); } } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp index 12283b2ad..ee30f56a1 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -22,25 +24,32 @@ std::vector LoadBinary(const char* path) { return data; } -void Text(SDL_Renderer* r, float x, float y, const char* text, SDL_Color color) { - SDL_SetRenderDrawColor(r, color.r, color.g, color.b, color.a); - SDL_RenderDebugText(r, x, y, text); -} - } // namespace +// --------------------------------------------------------------------------- +// Construction / destruction +// --------------------------------------------------------------------------- + WorkflowQ3OverlayDrawStep::WorkflowQ3OverlayDrawStep(std::shared_ptr logger) : logger_(std::move(logger)) {} WorkflowQ3OverlayDrawStep::~WorkflowQ3OverlayDrawStep() { - if (renderer_) SDL_DestroyRenderer(renderer_); + if (renderer_) { + if (bigchars_tex_) SDL_DestroyTexture(bigchars_tex_); + if (prop_font_tex_) SDL_DestroyTexture(prop_font_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_); + 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_); + 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_); } } @@ -48,6 +57,10 @@ 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; @@ -55,8 +68,7 @@ void WorkflowQ3OverlayDrawStep::TryInit(SDL_GPUDevice* device, SDL_Window* windo const char* driver = SDL_GetGPUDeviceDriver(device); const std::string driverName = driver ? driver : ""; SDL_GPUShaderFormat shaderFormat = SDL_GPU_SHADERFORMAT_INVALID; - std::vector vert; - std::vector frag; + std::vector vert, frag; const char* entry = "main"; if (driverName == "metal") { shaderFormat = SDL_GPU_SHADERFORMAT_MSL; @@ -68,25 +80,17 @@ void WorkflowQ3OverlayDrawStep::TryInit(SDL_GPUDevice* device, SDL_Window* windo 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; + 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.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.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); @@ -94,25 +98,23 @@ void WorkflowQ3OverlayDrawStep::TryInit(SDL_GPUDevice* device, SDL_Window* windo if (!vs || !fs) { if (vs) SDL_ReleaseGPUShader(device, vs); if (fs) SDL_ReleaseGPUShader(device, fs); - disabled_ = true; - return; + disabled_ = true; return; } SDL_GPUVertexBufferDescription vbd = {}; - vbd.slot = 0; - vbd.pitch = sizeof(float) * 5; + 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; + 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.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; @@ -122,135 +124,429 @@ void WorkflowQ3OverlayDrawStep::TryInit(SDL_GPUDevice* device, SDL_Window* windo ctd.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD; SDL_GPUGraphicsPipelineCreateInfo pci = {}; - pci.vertex_shader = vs; - pci.fragment_shader = fs; + 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.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_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; - } + 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.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; + 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)); + 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.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); + + surface_ = SDL_CreateSurface(kW, kH, SDL_PIXELFORMAT_RGBA32); renderer_ = surface_ ? SDL_CreateSoftwareRenderer(surface_) : nullptr; ready_ = tex_ && transfer_ && vtx_buf_ && sampler_ && surface_ && renderer_; } -void WorkflowQ3OverlayDrawStep::DrawSurface(WorkflowContext& context, uint32_t frameW, uint32_t frameH) { +// --------------------------------------------------------------------------- +// 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 + + // HUD grid font (bigchars): 256×256, 16×16 cell grid + bigchars_tex_ = LoadTextureFromPk3(pk3Path, "gfx/2d/bigchars.tga"); + + // Proportional font used for all in-game menu text + prop_font_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/font1_prop.tga"); + + // Panel background — cut_frame has the distinctive Q3 diagonal-cut corners + frame_bg_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/cut_frame.tga"); + + // Left and right frame edge decorations + frame_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame1_l.tga"); + frame_r_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame1_r.tga"); + + // Selection highlight strip + frame2_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame2_l.tga"); + + if (logger_) { + logger_->Info(std::string("q3.overlay: menu textures — " + "prop:") + (prop_font_tex_ ? "ok" : "MISSING") + + " cut_frame:" + (frame_bg_tex_ ? "ok" : "MISSING") + + " frame_l:" + (frame_l_tex_ ? "ok" : "MISSING") + + " frame_r:" + (frame_r_tex_ ? "ok" : "MISSING") + + " frame2_l:" + (frame2_l_tex_ ? "ok" : "MISSING") + + " bigchars:" + (bigchars_tex_ ? "ok" : "MISSING")); + } +} + +// --------------------------------------------------------------------------- +// 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; + } +} + +// --------------------------------------------------------------------------- +// 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) ---------------------------------------- + const std::string weapon = context.Get("q3.current_weapon", "weapon_machinegun"); + const int shots = context.Get("q3.shots_fired", 0); + const int damage = context.Get("q3.damage_done", 0); + SDL_FRect hudBg{12, static_cast(kH - 44), 230, 28}; SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 150); SDL_RenderFillRect(renderer_, &hudBg); - const std::string weapon = context.Get("q3.current_weapon", "weapon_machinegun"); - const int shots = context.Get("q3.shots_fired", 0); - const int damage = context.Get("q3.damage_done", 0); - std::string hud = "WEAPON " + weapon.substr(7) + " SHOTS " + std::to_string(shots) + - " DAMAGE " + std::to_string(damage); - Text(renderer_, 20, static_cast(kH - 36), hud.c_str(), SDL_Color{255, 216, 64, 255}); - Text(renderer_, static_cast(kW / 2 - 4), static_cast(kH / 2 - 4), "+", SDL_Color{255, 255, 255, 220}); + const std::string weapName = weapon.size() > 7 ? weapon.substr(7) : weapon; + const std::string hud = "WEAPON " + weapName + + " SHOTS " + std::to_string(shots) + " DAMAGE " + std::to_string(damage); + DrawQ3Text(20, static_cast(kH - 36), hud.c_str(), {255, 216, 64, 255}, 0.5f); + + // Crosshair + DrawQ3Text(static_cast(kW / 2 - 4), static_cast(kH / 2 - 4), + "+", {255, 255, 255, 220}, 0.5f); const uint32_t frame = static_cast(context.GetDouble("loop.iteration", 0.0)); - const bool flashing = frame < context.Get("q3.weapon_flash_until_frame", 0u); + const bool flashing = frame < context.Get("q3.weapon_flash_until_frame", 0u); const bool hitMarker = frame < context.Get("q3.hit_marker_until_frame", 0u); + // Gun silhouette SDL_SetRenderDrawColor(renderer_, 34, 34, 38, 235); - SDL_FRect gunBody{410, 278, 168, 46}; - SDL_RenderFillRect(renderer_, &gunBody); + SDL_FRect gunBody{410, 278, 168, 46}; SDL_RenderFillRect(renderer_, &gunBody); SDL_SetRenderDrawColor(renderer_, 92, 96, 110, 255); SDL_RenderRect(renderer_, &gunBody); SDL_SetRenderDrawColor(renderer_, 20, 20, 22, 255); - SDL_FRect grip{452, 318, 36, 30}; - SDL_RenderFillRect(renderer_, &grip); - SDL_FRect barrel{568, 291, 54, 18}; - SDL_RenderFillRect(renderer_, &barrel); + SDL_FRect grip{452, 318, 36, 30}; SDL_RenderFillRect(renderer_, &grip); + SDL_FRect barrel{568, 291, 54, 18}; SDL_RenderFillRect(renderer_, &barrel); SDL_SetRenderDrawColor(renderer_, 255, 210, 70, 255); SDL_RenderLine(renderer_, 424, 290, 550, 290); - Text(renderer_, 430, 300, weapon.substr(7).c_str(), SDL_Color{220, 235, 255, 255}); + DrawQ3Text(430, 300, weapName.c_str(), {220, 235, 255, 255}, 0.5f); if (flashing) { SDL_SetRenderDrawColor(renderer_, 255, 190, 50, 230); - SDL_FRect flash{616, 284, 18, 32}; - SDL_RenderFillRect(renderer_, &flash); + SDL_FRect flash{616, 284, 18, 32}; SDL_RenderFillRect(renderer_, &flash); SDL_RenderLine(renderer_, 615, 300, 638, 276); SDL_RenderLine(renderer_, 615, 300, 638, 324); } if (hitMarker) { 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); - Text(renderer_, 300, 204, "HIT", SDL_Color{255, 92, 64, 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); + DrawQ3Text(300, 204, "HIT", {255, 92, 64, 255}, 0.5f); } + // ---- In-game menu (ioquake3 style, centered) ------------------------- if (context.GetBool("q3.menu_open", false)) { - SDL_FRect panel{120, 42, 400, 250}; - SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 210); - SDL_RenderFillRect(renderer_, &panel); - SDL_SetRenderDrawColor(renderer_, 40, 120, 220, 255); - SDL_RenderRect(renderer_, &panel); - Text(renderer_, 170, 62, "QUAKE III ARENA", SDL_Color{255, 216, 64, 255}); - Text(renderer_, 160, 88, "SKIRMISH / MAP SELECTION", SDL_Color{180, 220, 255, 255}); + 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); - auto maps = context.Get("q3.maps", nlohmann::json::array()); - int selected = context.Get("q3.menu_selected_map", 0); - for (int i = 0; i < 8 && i < static_cast(maps.size()); ++i) { - int idx = (selected / 8) * 8 + i; - if (idx >= static_cast(maps.size())) break; - std::string line = (idx == selected ? "> " : " ") + maps[idx].get(); - Text(renderer_, 176, 122 + i * 16, line.c_str(), - idx == selected ? SDL_Color{255, 255, 255, 255} : SDL_Color{140, 190, 240, 255}); + // Full-screen dark tint — same as Q3's colour 0 0 0 0.75 + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 192); + SDL_FRect tint{0, 0, static_cast(kW), static_cast(kH)}; + SDL_RenderFillRect(renderer_, &tint); + + // ---- Panel geometry (matches Q3 virtual 640×480 scaled to 640×360) ---- + // Q3 in-game menu: cut_frame panel ~356×256 centred around x=320,y=240 + // We scale y: 256*(360/480)=192 → round to 220 for comfort + constexpr float PW = 340.f; + constexpr float PH = 260.f; + constexpr float PX = (kW - PW) / 2.f; // centred horizontally + constexpr float PY = (kH - PH) / 2.f; // centred vertically + constexpr float DEC = 48.f; // width of frame1_l / frame1_r decorations + + // Panel background: cut_frame.tga (semi-transparent RGBA TGA) + if (frame_bg_tex_) { + SDL_FRect dst{PX, PY, PW, PH}; + SDL_SetTextureAlphaMod(frame_bg_tex_, 245); + SDL_RenderTexture(renderer_, frame_bg_tex_, nullptr, &dst); + } else { + SDL_SetRenderDrawColor(renderer_, 16, 12, 8, 230); + SDL_FRect fb{PX, PY, PW, PH}; SDL_RenderFillRect(renderer_, &fb); } - Text(renderer_, 154, 266, "UP/DOWN SELECT ENTER SET MAP ESC RESUME Q QUIT", SDL_Color{180, 180, 180, 255}); - auto pending = context.Get("q3.pending_map", ""); - if (!pending.empty()) { - std::string msg = "NEXT START: QUAKE3_MAP=" + pending; - Text(renderer_, 160, 246, msg.c_str(), SDL_Color{255, 170, 80, 255}); + + // Left decoration strip (frame1_l) + if (frame_l_tex_) { + SDL_FRect dst{PX - DEC, PY, DEC, PH}; + SDL_RenderTexture(renderer_, frame_l_tex_, nullptr, &dst); + } + // Right decoration strip (frame1_r) + if (frame_r_tex_) { + SDL_FRect dst{PX + PW, PY, DEC, PH}; + SDL_RenderTexture(renderer_, frame_r_tex_, nullptr, &dst); + } + + // Title — prop font, large (scale 0.9), orange-yellow, centred + const std::string title = context.Get("q3.menu_title", "QUAKE III ARENA"); + constexpr float kTitleScale = 0.9f; + const float titleY = PY + 14.f; + DrawPropText(PX + PW * 0.5f, titleY, title.c_str(), {255, 210, 0, 255}, + kTitleScale, /*center=*/true); + + // Separator line below title + SDL_SetRenderDrawColor(renderer_, 200, 120, 20, 200); + const float sepY = titleY + kPropHeight * kTitleScale + 4.f; + SDL_RenderLine(renderer_, + static_cast(PX + 12), static_cast(sepY), + static_cast(PX + PW - 12), static_cast(sepY)); + + // ---- 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 numItems = static_cast(items.size()); + + constexpr float kItemScale = 0.75f; // Q3 PROP_SMALL_SIZE_SCALE + constexpr float kItemStep = static_cast(kPropHeight) * kItemScale + 6.f; + const float itemsTop = sepY + 10.f; + constexpr int kVisible = 10; + const int page = sel / kVisible; + + for (int i = 0; i < kVisible; ++i) { + const int idx = page * kVisible + i; + if (idx >= numItems) break; + const std::string label = items[idx].value("label", ""); + const float iy = itemsTop + i * kItemStep; + + if (idx == sel) { + // Selection highlight: frame2_l.tga stretched as a strip, or fallback rect + if (frame2_l_tex_) { + SDL_FRect gdst{PX + 8.f, iy - 2.f, PW - 16.f, kPropHeight * kItemScale + 4.f}; + SDL_SetTextureAlphaMod(frame2_l_tex_, 180); + SDL_RenderTexture(renderer_, frame2_l_tex_, nullptr, &gdst); + } else { + SDL_SetRenderDrawColor(renderer_, 80, 55, 10, 140); + SDL_FRect hi{PX + 8.f, iy - 2.f, PW - 16.f, kPropHeight * kItemScale + 4.f}; + SDL_RenderFillRect(renderer_, &hi); + } + // Selected: bright orange-white, centred + DrawPropText(PX + PW * 0.5f, iy, label.c_str(), + {255, 210, 60, 255}, kItemScale, /*center=*/true); + } else { + // Unselected: muted olive-green, centred + DrawPropText(PX + PW * 0.5f, iy, label.c_str(), + {160, 160, 100, 200}, kItemScale, /*center=*/true); + } + } + + // Bottom divider + current map name + const float botY = PY + PH - 24.f; + SDL_SetRenderDrawColor(renderer_, 200, 120, 20, 120); + SDL_RenderLine(renderer_, + static_cast(PX + 12), static_cast(botY), + static_cast(PX + PW - 12), static_cast(botY)); + + const std::string curMap = bspCfg.value("map_name", std::string("")); + if (!curMap.empty()) { + DrawPropText(PX + PW * 0.5f, botY + 4.f, curMap.c_str(), + {120, 200, 120, 200}, 0.55f, /*center=*/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("")); + } } -void WorkflowQ3OverlayDrawStep::Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchainTex, - SDL_GPUDevice* device, uint32_t frameW, uint32_t frameH) { +// --------------------------------------------------------------------------- +// 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); @@ -260,38 +556,31 @@ void WorkflowQ3OverlayDrawStep::Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture if (copy) { SDL_GPUTextureTransferInfo src = {}; src.transfer_buffer = transfer_; - src.pixels_per_row = kW; - src.rows_per_layer = kH; + 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; + 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}, + {-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 size = sizeof(verts); + const uint32_t sz = sizeof(verts); SDL_GPUTransferBufferCreateInfo tb = {}; - tb.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; - tb.size = size; + 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, size); - SDL_UnmapGPUTransferBuffer(device, tmp); - } + if (ptr) { std::memcpy(ptr, verts, sz); SDL_UnmapGPUTransferBuffer(device, tmp); } auto* cp = SDL_BeginGPUCopyPass(cmd); if (cp) { - SDL_GPUTransferBufferLocation src = {tmp, 0}; - SDL_GPUBufferRegion dst = {vtx_buf_, 0, size}; - SDL_UploadToGPUBuffer(cp, &src, &dst, false); + 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); @@ -300,8 +589,8 @@ void WorkflowQ3OverlayDrawStep::Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture } SDL_GPUColorTargetInfo target = {}; - target.texture = swapchainTex; - target.load_op = SDL_GPU_LOADOP_LOAD; + 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; @@ -314,15 +603,18 @@ void WorkflowQ3OverlayDrawStep::Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture SDL_EndGPURenderPass(pass); } -void WorkflowQ3OverlayDrawStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { +// --------------------------------------------------------------------------- + +void WorkflowQ3OverlayDrawStep::Execute( + const WorkflowStepDefinition& step, WorkflowContext& context) { if (context.GetBool("frame_skip", false)) return; - auto* cmd = context.Get("gpu_command_buffer", nullptr); + auto* cmd = context.Get("gpu_command_buffer", nullptr); auto* swapchain = context.Get("gpu_swapchain_texture", nullptr); - auto* device = context.Get("gpu_device", 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 fw = context.Get("frame_width", 1280u); const auto fh = context.Get("frame_height", 960u); DrawSurface(context, fw, fh); Render(cmd, swapchain, device, fw, fh); diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp index b7c89c257..63ee5f343 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp @@ -63,7 +63,7 @@ void WorkflowQ3WeaponUpdateStep::Execute(const WorkflowStepDefinition& step, Wor 28u; bool wantsFire = firePressed || (fireHeld && current == "weapon_machinegun") || (fireHeld && current == "weapon_lightning"); - if (!context.GetBool("q3.menu_open", false) && wantsFire && (lastFire == 0u || frame >= lastFire + interval)) { + if (context.GetBool("movement_active", true) && wantsFire && (lastFire == 0u || frame >= lastFire + interval)) { lastFire = frame == 0u ? 1u : frame; context.Set("q3.weapon_last_fire_frame", lastFire); context.Set("q3.weapon_flash_until_frame", lastFire + 4u); diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp index 35d965cd2..f92775ad4 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp @@ -84,6 +84,26 @@ void WorkflowBspBuildCollisionStep::Execute(const WorkflowStepDefinition& step, return; } + // Remove previous BSP collision body to prevent ghost geometry on map reload + auto* prevBody = context.Get("bsp_collision_body", nullptr); + if (prevBody) { + world->removeRigidBody(prevBody); + auto* shape = prevBody->getCollisionShape(); + auto* ms = prevBody->getMotionState(); + delete prevBody; + delete ms; + if (shape) { + auto* compound = dynamic_cast(shape); + if (compound) { + for (int i = compound->getNumChildShapes() - 1; i >= 0; --i) { + delete compound->getChildShape(i); + } + } + delete shape; + } + context.Set("bsp_collision_body", nullptr); + } + const auto& bspData = *bspDataPtr; auto* lumps = reinterpret_cast(bspData.data() + sizeof(BspHeader)); @@ -160,7 +180,7 @@ void WorkflowBspBuildCollisionStep::Execute(const WorkflowStepDefinition& step, auto* body = new btRigidBody(rbInfo); body->setCollisionFlags(body->getCollisionFlags() | btCollisionObject::CF_STATIC_OBJECT); world->addRigidBody(body); - context.Set("physics_body_bsp_" + map_name, body); + context.Set("bsp_collision_body", body); } if (logger_) { diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp index 412edf166..e7c0581bd 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp @@ -38,7 +38,17 @@ void WorkflowBspLoadStep::Execute(const WorkflowStepDefinition& step, WorkflowCo }; const std::string pk3_path = getStr("pk3_path", ""); - std::string map_name = getStr("map_name", "q3dm17"); + + // Context key q3.pending_map (set by menu) takes precedence over the workflow parameter. + // This allows in-process map switching without modifying the workflow JSON. + std::string map_name; + const auto* pendingMap = context.TryGet("q3.pending_map"); + if (pendingMap && !pendingMap->empty()) { + map_name = *pendingMap; + context.Set("q3.pending_map", ""); + } else { + map_name = getStr("map_name", "q3dm7"); + } if (map_name.empty()) map_name = "q3dm7"; const float scale = getNum("scale", 1.0f / 32.0f); diff --git a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_mouse_grab_step.cpp b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_mouse_grab_step.cpp index 5dc3ab101..9f06fdb34 100644 --- a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_mouse_grab_step.cpp +++ b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_mouse_grab_step.cpp @@ -24,18 +24,24 @@ void WorkflowInputMouseGrabStep::Execute( return; } - WorkflowStepParameterResolver paramResolver; - float enabled = 1.0f; - if (const auto* p = paramResolver.FindParameter(step, "enabled")) { - if (p->type == WorkflowParameterValue::Type::Number) { - enabled = static_cast(p->numberValue); + // Prefer context key input over static parameter so the DAG can drive grab per-frame. + bool grab = true; + auto enabledIt = step.inputs.find("enabled"); + if (enabledIt != step.inputs.end()) { + const auto* v = context.TryGet(enabledIt->second); + if (v) grab = *v; + } else { + WorkflowStepParameterResolver paramResolver; + if (const auto* p = paramResolver.FindParameter(step, "enabled")) { + if (p->type == WorkflowParameterValue::Type::Number) + grab = p->numberValue > 0.5; + else if (p->type == WorkflowParameterValue::Type::Bool) + grab = p->boolValue; } } - bool grab = enabled > 0.5f; SDL_SetWindowRelativeMouseMode(window, grab); context.Set("mouse_grabbed", grab); - context.Set("game_running", grab); if (logger_) { logger_->Info("input.mouse.grab: " + std::string(grab ? "enabled" : "disabled")); diff --git a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_poll_step.cpp b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_poll_step.cpp index 619e8c9c8..074e5847a 100644 --- a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_poll_step.cpp +++ b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_input_poll_step.cpp @@ -23,13 +23,17 @@ void WorkflowInputPollStep::Execute( bool keyEnterPressed = false; bool keyUpPressed = false; bool keyDownPressed = false; + bool keyQPressed = false; bool mouseLeftPressed = false; SDL_Event event; while (SDL_PollEvent(&event)) { switch (event.type) { case SDL_EVENT_QUIT: - context.Set("game_running", false); + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: + context.Set("game_running", false); + context.Set("outer_running", false); + context.Set("q3.quit_requested", true); break; case SDL_EVENT_KEY_DOWN: if (event.key.key == SDLK_ESCAPE) { @@ -41,7 +45,7 @@ void WorkflowInputPollStep::Execute( } else if (event.key.key == SDLK_DOWN) { keyDownPressed = true; } else if (event.key.key == SDLK_Q) { - context.Set("game_running", false); + keyQPressed = true; } break; case SDL_EVENT_MOUSE_BUTTON_DOWN: @@ -61,6 +65,7 @@ void WorkflowInputPollStep::Execute( context.Set("input_key_enter_pressed", keyEnterPressed); context.Set("input_key_up_pressed", keyUpPressed); context.Set("input_key_down_pressed", keyDownPressed); + context.Set("input_key_q_pressed", keyQPressed); context.Set("input_mouse_left_pressed", mouseLeftPressed); // Read keyboard state (snapshot, not event-based) diff --git a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp index 549fe2cf7..f6c6bee26 100644 --- a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp +++ b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_physics_fps_move_step.cpp @@ -26,7 +26,7 @@ void WorkflowPhysicsFpsMoveStep::Execute( auto* body = context.Get("physics_body_" + playerName, nullptr); if (!body) return; - if (context.GetBool("q3.menu_open", false)) { + if (!context.GetBool("movement_active", true)) { btVector3 vel = body->getLinearVelocity(); body->setLinearVelocity(btVector3(0, vel.y(), 0)); return; diff --git a/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_value_set_if_step.cpp b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_value_set_if_step.cpp new file mode 100644 index 000000000..f43e5f680 --- /dev/null +++ b/gameengine/src/services/impl/workflow/workflow_generic_steps/workflow_value_set_if_step.cpp @@ -0,0 +1,54 @@ +#include "services/interfaces/workflow/workflow_generic_steps/workflow_value_set_if_step.hpp" + +#include "services/interfaces/workflow/workflow_step_io_resolver.hpp" +#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowValueSetIfStep::WorkflowValueSetIfStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowValueSetIfStep::GetPluginId() const { + return "value.set_if"; +} + +void WorkflowValueSetIfStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + WorkflowStepIoResolver ioResolver; + WorkflowStepParameterResolver paramResolver; + + const std::string condKey = ioResolver.GetRequiredInputKey(step, "condition"); + const auto* cond = context.TryGet(condKey); + if (!cond) { + throw std::runtime_error("value.set_if: condition key '" + condKey + "' is not a bool"); + } + + if (!*cond) return; + + const std::string outputKey = ioResolver.GetRequiredOutputKey(step, "value"); + const auto& param = paramResolver.GetRequiredParameter(step, "value"); + + switch (param.type) { + case WorkflowParameterValue::Type::Bool: + context.Set(outputKey, param.boolValue); + break; + case WorkflowParameterValue::Type::Number: + context.Set(outputKey, param.numberValue); + break; + case WorkflowParameterValue::Type::String: + context.Set(outputKey, param.stringValue); + break; + default: + throw std::runtime_error("value.set_if: unsupported parameter type"); + } + + if (logger_) { + logger_->Trace("WorkflowValueSetIfStep", "Execute", + "condition=" + condKey + ", output=" + outputKey, + "Conditionally set workflow value"); + } +} + +} // 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 7ab2f509d..7a110774c 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -202,6 +202,7 @@ // Value #include "services/interfaces/workflow/workflow_generic_steps/workflow_value_assert_exists_step.hpp" +#include "services/interfaces/workflow/workflow_generic_steps/workflow_value_set_if_step.hpp" #include "services/interfaces/workflow/workflow_generic_steps/workflow_value_assert_type_step.hpp" #include "services/interfaces/workflow/workflow_generic_steps/workflow_value_clear_step.hpp" #include "services/interfaces/workflow/workflow_generic_steps/workflow_value_copy_step.hpp" @@ -471,7 +472,8 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr reg registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); - count += 6; + registry->RegisterStep(std::make_shared(logger_)); + count += 7; // ── VFX ──────────────────────────────────────────────────── registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp index 58ae882ed..228b6478c 100644 --- a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp @@ -3,6 +3,7 @@ #include "services/interfaces/i_workflow_step.hpp" #include "services/interfaces/i_logger.hpp" +#include #include namespace sdl3cpp::services::impl { @@ -15,6 +16,8 @@ public: private: std::shared_ptr logger_; + nlohmann::json config_; + bool config_loaded_ = false; }; } // 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 index 01e6a1272..b4d502634 100644 --- 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 @@ -9,6 +9,7 @@ #include #include #include +#include namespace sdl3cpp::services::impl { @@ -22,25 +23,54 @@ public: private: void TryInit(SDL_GPUDevice* device, SDL_Window* window); + void TryLoadMenuTextures(const std::string& pk3Path); void DrawSurface(WorkflowContext& context, uint32_t frameW, uint32_t frameH); 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 (HUD use). + 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); + + // 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); + 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; - SDL_Surface* surface_ = nullptr; - SDL_Renderer* renderer_ = nullptr; + + // 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 textures loaded from PK3 (all 256×256 RGBA TGAs) + bool menu_tex_loaded_ = false; + SDL_Texture* bigchars_tex_ = nullptr; // gfx/2d/bigchars.tga – HUD grid font + SDL_Texture* prop_font_tex_ = nullptr; // menu/art/font1_prop.tga – proportional font + SDL_Texture* frame_bg_tex_ = nullptr; // menu/art/cut_frame.tga – panel background + SDL_Texture* frame_l_tex_ = nullptr; // menu/art/frame1_l.tga – left decoration + SDL_Texture* frame_r_tex_ = nullptr; // menu/art/frame1_r.tga – right decoration + SDL_Texture* frame2_l_tex_ = nullptr; // menu/art/frame2_l.tga – selection highlight 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/workflow_generic_steps/workflow_value_set_if_step.hpp b/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_value_set_if_step.hpp new file mode 100644 index 000000000..41b453e31 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/workflow_generic_steps/workflow_value_set_if_step.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "services/interfaces/i_logger.hpp" +#include "services/interfaces/i_workflow_step.hpp" + +#include + +namespace sdl3cpp::services::impl { + +// If the boolean context key named by input "condition" is true, +// write the JSON parameter "value" to the context key named by output "value". +// Plugin ID: value.set_if +class WorkflowValueSetIfStep final : public IWorkflowStep { +public: + explicit WorkflowValueSetIfStep(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