mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-05-04 02:34:52 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -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}"
|
||||
}
|
||||
+3
-1
@@ -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);
|
||||
|
||||
+21
-1
@@ -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);
|
||||
|
||||
|
||||
+13
-7
@@ -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"));
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+54
@@ -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
|
||||
|
||||
+41
-11
@@ -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
|
||||
|
||||
+24
@@ -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
|
||||
Reference in New Issue
Block a user