Wire up real Quake 3 menu system with PK3 assets

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 22:40:07 +01:00
parent a12c575a7e
commit 8abe642baf
44 changed files with 2240 additions and 243 deletions
+1
View File
@@ -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
@@ -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"
}
}
}
+2 -1
View File
@@ -17,7 +17,8 @@
],
"config": [
"config/q3_rendering.json"
"config/q3_rendering.json",
"config/menu.json"
],
"scene": [
@@ -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 }] } },
@@ -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 }] } }
}
}
@@ -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 }] } }
}
}
@@ -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]
}
}
]
}
@@ -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"
}
}
}
@@ -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)"
]
}
@@ -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"
}
}
@@ -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"
},
{
}
]
}
@@ -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)"
}
}
@@ -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"]
}
}
@@ -0,0 +1,73 @@
#include <metal_stdlib>
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<float> albedoTex [[texture(0)]],
sampler albedoSampler [[sampler(0)]],
texture2d<float> shadowMap [[texture(1)]],
sampler shadowSampler [[sampler(1)]],
texture2d<float> lightmapTex [[texture(2)]],
sampler lightmapSampler [[sampler(2)]],
texture2d<float> 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);
}
@@ -0,0 +1,43 @@
#include <metal_stdlib>
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;
}
@@ -0,0 +1,13 @@
#include <metal_stdlib>
using namespace metal;
struct FragmentInput {
float4 position [[position]];
float2 uv;
};
fragment float4 main0(FragmentInput in [[stage_in]],
texture2d<float> overlayTex [[texture(0)]],
sampler overlaySampler [[sampler(0)]]) {
return overlayTex.sample(overlaySampler, in.uv);
}
@@ -0,0 +1,19 @@
#include <metal_stdlib>
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;
}
@@ -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);
}
@@ -0,0 +1,40 @@
#version 450
// Quake 3 BSP vertex shader (minimal).
// Vertex format `position_uv_lmuv_normal` (BspRenderVertex):
// loc 0: vec3 position
// loc 1: vec2 diffuse uv
// loc 2: vec2 lightmap uv (already atlas-remapped by bsp.lightmap_atlas step)
// loc 3: vec3 world-space normal
// Uniform layout matches sdl3cpp::services::rendering::VertexUniformData
// so the same C++ uniform push call used by the seed pipeline works here.
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec2 a_uv;
layout(location = 2) in vec2 a_lmuv;
layout(location = 3) in vec3 a_normal;
layout(set = 1, binding = 0) uniform VertexUniforms {
mat4 u_modelViewProj;
mat4 u_model;
vec4 u_surfaceNormal; // unused for BSP — per-vertex normal wins
vec4 u_uvScale;
vec4 u_cameraPos;
mat4 u_shadowVP; // unused for BSP — lightmap already encodes shadowing
};
layout(location = 0) out vec2 v_uv;
layout(location = 1) out vec2 v_lmuv;
layout(location = 2) out vec3 v_worldNormal;
layout(location = 3) out vec3 v_worldPos;
layout(location = 4) out vec3 v_cameraPos;
void main() {
gl_Position = u_modelViewProj * vec4(a_position, 1.0);
v_uv = a_uv * u_uvScale.xy;
v_lmuv = a_lmuv;
vec4 wp = u_model * vec4(a_position, 1.0);
v_worldPos = wp.xyz;
v_worldNormal = mat3(u_model) * a_normal;
v_cameraPos = u_cameraPos.xyz;
}
@@ -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);
}
@@ -0,0 +1,11 @@
#version 450
layout(location = 0) in vec3 in_pos;
layout(location = 1) in vec2 in_uv;
layout(location = 0) out vec2 v_uv;
void main() {
gl_Position = vec4(in_pos, 1.0);
v_uv = in_uv;
}
@@ -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
}
]
}
}
}
}
@@ -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 }] } }
}
}
@@ -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 }] } }
}
}
@@ -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 }] } }
}
}
@@ -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}"
}
@@ -31,7 +31,9 @@ void WorkflowGraphicsScreenshotRequestStep::Execute(
const auto* output_path = context.TryGet<std::string>(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
@@ -2,10 +2,38 @@
#include <nlohmann/json.hpp>
#include <algorithm>
#include <fstream>
#include <string>
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<std::string>() == "maps") {
nlohmann::json out = nlohmann::json::array();
for (const auto& m : maps) {
std::string name = m.get<std::string>();
out.push_back({ {"label", name}, {"action", "map:" + name} });
}
return out;
}
return *itemsField;
}
} // namespace
WorkflowQ3MenuUpdateStep::WorkflowQ3MenuUpdateStep(std::shared_ptr<ILogger> 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<std::string>("q3.menu_screen", defaultScreen);
auto screenIt = screens.find(curScreen);
if (screenIt != screens.end() && screenIt->contains("back")) {
const std::string back = (*screenIt)["back"].get<std::string>();
context.Set<std::string>("q3.menu_screen", back);
context.Set<int>("q3.menu_selected_item", 0);
} else {
open = false;
}
} else {
open = true;
context.Set<std::string>("q3.menu_screen", defaultScreen);
context.Set<int>("q3.menu_selected_item", 0);
}
}
context.Set<bool>("q3.menu_open", open);
auto maps = context.Get<nlohmann::json>("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<std::string>("q3.menu_screen", defaultScreen);
const auto maps = context.Get<nlohmann::json>("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<int>("q3.menu_selected_map", 0);
selected = std::clamp(selected, 0, static_cast<int>(maps.size()) - 1);
context.Set("q3.menu_items", items);
context.Set<std::string>("q3.menu_title", title);
// --- navigate ---
bool mapSelected = false;
bool quitPressed = false;
int selected = context.Get<int>("q3.menu_selected_item", 0);
const int numItems = static_cast<int>(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<int>(maps.size()) - 1) % static_cast<int>(maps.size());
}
if (context.GetBool("input_key_down_pressed", false)) {
selected = (selected + 1) % static_cast<int>(maps.size());
}
if (context.GetBool("input_key_enter_pressed", false)) {
const std::string map = maps[selected].get<std::string>();
context.Set<std::string>("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<bool>("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<std::string>() : defaultScreen;
context.Set<std::string>("q3.menu_screen", back);
context.Set<int>("q3.menu_selected_item", 0);
} else if (action.rfind("screen:", 0) == 0) {
context.Set<std::string>("q3.menu_screen", action.substr(7));
context.Set<int>("q3.menu_selected_item", 0);
} else if (action.rfind("map:", 0) == 0) {
const std::string map = action.substr(4);
context.Set<std::string>("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<int>("q3.menu_selected_map", selected);
context.Set<int>("q3.menu_selected_item", selected);
context.Set<bool>("q3.menu_map_selected", mapSelected);
context.Set<bool>("q3.menu_quit_pressed", quitPressed);
}
} // namespace sdl3cpp::services::impl
@@ -2,6 +2,8 @@
#include <SDL3/SDL.h>
#include <nlohmann/json.hpp>
#include <stb_image.h>
#include <zip.h>
#include <cstring>
#include <fstream>
@@ -22,25 +24,32 @@ std::vector<uint8_t> 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<ILogger> 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<uint8_t> vert;
std::vector<uint8_t> frag;
std::vector<uint8_t> 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<uint32_t>(sizeof(float));
bci.size = 6u * 5u * static_cast<uint32_t>(sizeof(float));
vtx_buf_ = SDL_CreateGPUBuffer(device, &bci);
SDL_GPUSamplerCreateInfo sci = {};
sci.min_filter = SDL_GPU_FILTER_NEAREST;
sci.mag_filter = SDL_GPU_FILTER_NEAREST;
sci.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_NEAREST;
sci.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<uint8_t> 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<int>(buf.size()), &w, &h, &ch, 4);
if (!px) return nullptr;
SDL_Surface* surf = SDL_CreateSurfaceFrom(
w, h, SDL_PIXELFORMAT_RGBA32, px, w * 4);
SDL_Texture* tex = surf ? SDL_CreateTextureFromSurface(renderer_, surf) : nullptr;
if (surf) SDL_DestroySurface(surf);
stbi_image_free(px);
return tex;
}
// ---------------------------------------------------------------------------
// 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<unsigned char>(*p);
SDL_FRect src = { static_cast<float>((code % 16) * kGlyphSrc),
static_cast<float>((code / 16) * kGlyphSrc),
static_cast<float>(kGlyphSrc),
static_cast<float>(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<unsigned char>(*p) & 127;
const int cw = kPropMap[ch][2];
if (cw == -1) continue;
w += static_cast<float>(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<unsigned char>(*p) & 127;
const int cw = kPropMap[ch][2];
if (cw == -1) continue;
if (cw == kPropSpace) { cx += kPropSpace * scale; continue; }
SDL_FRect src = { static_cast<float>(kPropMap[ch][0]),
static_cast<float>(kPropMap[ch][1]),
static_cast<float>(cw),
static_cast<float>(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<std::string>("q3.current_weapon", "weapon_machinegun");
const int shots = context.Get<int>("q3.shots_fired", 0);
const int damage = context.Get<int>("q3.damage_done", 0);
SDL_FRect hudBg{12, static_cast<float>(kH - 44), 230, 28};
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 150);
SDL_RenderFillRect(renderer_, &hudBg);
const std::string weapon = context.Get<std::string>("q3.current_weapon", "weapon_machinegun");
const int shots = context.Get<int>("q3.shots_fired", 0);
const int damage = context.Get<int>("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<float>(kH - 36), hud.c_str(), SDL_Color{255, 216, 64, 255});
Text(renderer_, static_cast<float>(kW / 2 - 4), static_cast<float>(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<float>(kH - 36), hud.c_str(), {255, 216, 64, 255}, 0.5f);
// Crosshair
DrawQ3Text(static_cast<float>(kW / 2 - 4), static_cast<float>(kH / 2 - 4),
"+", {255, 255, 255, 220}, 0.5f);
const uint32_t frame = static_cast<uint32_t>(context.GetDouble("loop.iteration", 0.0));
const bool flashing = frame < context.Get<uint32_t>("q3.weapon_flash_until_frame", 0u);
const bool flashing = frame < context.Get<uint32_t>("q3.weapon_flash_until_frame", 0u);
const bool hitMarker = frame < context.Get<uint32_t>("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<nlohmann::json>("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<nlohmann::json>("q3.maps", nlohmann::json::array());
int selected = context.Get<int>("q3.menu_selected_map", 0);
for (int i = 0; i < 8 && i < static_cast<int>(maps.size()); ++i) {
int idx = (selected / 8) * 8 + i;
if (idx >= static_cast<int>(maps.size())) break;
std::string line = (idx == selected ? "> " : " ") + maps[idx].get<std::string>();
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<float>(kW), static_cast<float>(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<std::string>("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<std::string>("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<int>(PX + 12), static_cast<int>(sepY),
static_cast<int>(PX + PW - 12), static_cast<int>(sepY));
// ---- Menu items --------------------------------------------------
const auto items = context.Get<nlohmann::json>("q3.menu_items", nlohmann::json::array());
const int sel = context.Get<int>("q3.menu_selected_item", 0);
const int numItems = static_cast<int>(items.size());
constexpr float kItemScale = 0.75f; // Q3 PROP_SMALL_SIZE_SCALE
constexpr float kItemStep = static_cast<float>(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<int>(PX + 12), static_cast<int>(botY),
static_cast<int>(PX + PW - 12), static_cast<int>(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<std::string>("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<bool>("screenshot_saved", true);
} else {
if (logger_) logger_->Warn("q3.overlay: SDL_SaveBMP failed for " + *ssPath);
context.Set<bool>("screenshot_saved", false);
}
// Clear the path so we only save once
context.Set<std::string>("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<SDL_GPUCommandBuffer*>("gpu_command_buffer", nullptr);
auto* cmd = context.Get<SDL_GPUCommandBuffer*>("gpu_command_buffer", nullptr);
auto* swapchain = context.Get<SDL_GPUTexture*>("gpu_swapchain_texture", nullptr);
auto* device = context.Get<SDL_GPUDevice*>("gpu_device", nullptr);
auto* device = context.Get<SDL_GPUDevice*>("gpu_device", nullptr);
if (!cmd || !swapchain || !device) return;
if (!ready_) TryInit(device, context.Get<SDL_Window*>("sdl_window", nullptr));
if (!ready_) return;
const auto fw = context.Get<uint32_t>("frame_width", 1280u);
const auto fw = context.Get<uint32_t>("frame_width", 1280u);
const auto fh = context.Get<uint32_t>("frame_height", 960u);
DrawSurface(context, fw, fh);
Render(cmd, swapchain, device, fw, fh);
@@ -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<uint32_t>("q3.weapon_last_fire_frame", lastFire);
context.Set<uint32_t>("q3.weapon_flash_until_frame", lastFire + 4u);
@@ -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<btRigidBody*>("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<btCompoundShape*>(shape);
if (compound) {
for (int i = compound->getNumChildShapes() - 1; i >= 0; --i) {
delete compound->getChildShape(i);
}
}
delete shape;
}
context.Set<btRigidBody*>("bsp_collision_body", nullptr);
}
const auto& bspData = *bspDataPtr;
auto* lumps = reinterpret_cast<const BspLump*>(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<btRigidBody*>("physics_body_bsp_" + map_name, body);
context.Set<btRigidBody*>("bsp_collision_body", body);
}
if (logger_) {
@@ -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<std::string>("q3.pending_map");
if (pendingMap && !pendingMap->empty()) {
map_name = *pendingMap;
context.Set<std::string>("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);
@@ -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<float>(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<bool>(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<bool>("mouse_grabbed", grab);
context.Set<bool>("game_running", grab);
if (logger_) {
logger_->Info("input.mouse.grab: " + std::string(grab ? "enabled" : "disabled"));
@@ -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<bool>("game_running", false);
case SDL_EVENT_WINDOW_CLOSE_REQUESTED:
context.Set<bool>("game_running", false);
context.Set<bool>("outer_running", false);
context.Set<bool>("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<bool>("game_running", false);
keyQPressed = true;
}
break;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
@@ -61,6 +65,7 @@ void WorkflowInputPollStep::Execute(
context.Set<bool>("input_key_enter_pressed", keyEnterPressed);
context.Set<bool>("input_key_up_pressed", keyUpPressed);
context.Set<bool>("input_key_down_pressed", keyDownPressed);
context.Set<bool>("input_key_q_pressed", keyQPressed);
context.Set<bool>("input_mouse_left_pressed", mouseLeftPressed);
// Read keyboard state (snapshot, not event-based)
@@ -26,7 +26,7 @@ void WorkflowPhysicsFpsMoveStep::Execute(
auto* body = context.Get<btRigidBody*>("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;
@@ -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 <stdexcept>
#include <utility>
namespace sdl3cpp::services::impl {
WorkflowValueSetIfStep::WorkflowValueSetIfStep(std::shared_ptr<ILogger> 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<bool>(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
@@ -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<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowValueCopyStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowValueDefaultStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowValueLiteralStep>(logger_));
count += 6;
registry->RegisterStep(std::make_shared<WorkflowValueSetIfStep>(logger_));
count += 7;
// ── VFX ────────────────────────────────────────────────────
registry->RegisterStep(std::make_shared<WorkflowVfxSpawnStep>(logger_));
@@ -3,6 +3,7 @@
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <nlohmann/json.hpp>
#include <memory>
namespace sdl3cpp::services::impl {
@@ -15,6 +16,8 @@ public:
private:
std::shared_ptr<ILogger> logger_;
nlohmann::json config_;
bool config_loaded_ = false;
};
} // namespace sdl3cpp::services::impl
@@ -9,6 +9,7 @@
#include <SDL3/SDL_surface.h>
#include <cstdint>
#include <memory>
#include <string>
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<ILogger> 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
@@ -0,0 +1,24 @@
#pragma once
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/i_workflow_step.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl