From 7bcc0026e0750f7db8a01165b3576844df637dc9 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 20 Jul 2026 10:56:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 4 + .gitignore | 4 + .mcp.json | 11 + Tools/godot-mcp-pro-v1.15.1/AGENTS.md | 146 + Tools/godot-mcp-pro-v1.15.1/CHANGELOG.md | 699 ++++ Tools/godot-mcp-pro-v1.15.1/INSTALL.md | 121 + Tools/godot-mcp-pro-v1.15.1/LICENSE | 42 + Tools/godot-mcp-pro-v1.15.1/README.md | 496 +++ .../docs/architecture.md | 70 + .../docs/installation.md | 73 + .../docs/tools-reference.md | 406 ++ .../instructions/.clinerules | 146 + .../instructions/.windsurfrules | 146 + .../instructions/AGENTS.md | 146 + .../instructions/CLAUDE.md | 146 + .../instructions/GEMINI.md | 146 + .../instructions/README.md | 19 + .../instructions/amazonq-godot-mcp-pro.md | 146 + .../instructions/augment-godot-mcp-pro.md | 146 + .../instructions/continue-godot-mcp-pro.md | 146 + .../instructions/godot-mcp-pro.mdc | 151 + .../instructions/junie-guidelines.md | 146 + .../instructions/roo-godot-mcp-pro.md | 146 + .../server/build/cli.d.ts | 10 + .../server/build/cli.d.ts.map | 1 + .../godot-mcp-pro-v1.15.1/server/build/cli.js | 705 ++++ .../server/build/cli.js.map | 1 + .../server/build/godot-connection.d.ts | 29 + .../server/build/godot-connection.d.ts.map | 1 + .../server/build/godot-connection.js | 226 ++ .../server/build/godot-connection.js.map | 1 + .../server/build/index.d.ts | 3 + .../server/build/index.d.ts.map | 1 + .../server/build/index.js | 138 + .../server/build/index.js.map | 1 + .../server/build/setup.d.ts | 12 + .../server/build/setup.d.ts.map | 1 + .../server/build/setup.js | 273 ++ .../server/build/setup.js.map | 1 + .../server/build/tools/analysis-tools.d.ts | 4 + .../build/tools/analysis-tools.d.ts.map | 1 + .../server/build/tools/analysis-tools.js | 74 + .../server/build/tools/analysis-tools.js.map | 1 + .../server/build/tools/android-tools.d.ts | 4 + .../server/build/tools/android-tools.d.ts.map | 1 + .../server/build/tools/android-tools.js | 42 + .../server/build/tools/android-tools.js.map | 1 + .../server/build/tools/animation-tools.d.ts | 4 + .../build/tools/animation-tools.d.ts.map | 1 + .../server/build/tools/animation-tools.js | 85 + .../server/build/tools/animation-tools.js.map | 1 + .../build/tools/animation-tree-tools.d.ts | 4 + .../build/tools/animation-tree-tools.d.ts.map | 1 + .../build/tools/animation-tree-tools.js | 124 + .../build/tools/animation-tree-tools.js.map | 1 + .../server/build/tools/audio-tools.d.ts | 4 + .../server/build/tools/audio-tools.d.ts.map | 1 + .../server/build/tools/audio-tools.js | 93 + .../server/build/tools/audio-tools.js.map | 1 + .../server/build/tools/batch-tools.d.ts | 4 + .../server/build/tools/batch-tools.d.ts.map | 1 + .../server/build/tools/batch-tools.js | 97 + .../server/build/tools/batch-tools.js.map | 1 + .../server/build/tools/editor-tools.d.ts | 4 + .../server/build/tools/editor-tools.d.ts.map | 1 + .../server/build/tools/editor-tools.js | 231 ++ .../server/build/tools/editor-tools.js.map | 1 + .../server/build/tools/export-tools.d.ts | 4 + .../server/build/tools/export-tools.d.ts.map | 1 + .../server/build/tools/export-tools.js | 36 + .../server/build/tools/export-tools.js.map | 1 + .../server/build/tools/input-map-tools.d.ts | 4 + .../build/tools/input-map-tools.d.ts.map | 1 + .../server/build/tools/input-map-tools.js | 41 + .../server/build/tools/input-map-tools.js.map | 1 + .../server/build/tools/input-tools.d.ts | 4 + .../server/build/tools/input-tools.d.ts.map | 1 + .../server/build/tools/input-tools.js | 109 + .../server/build/tools/input-tools.js.map | 1 + .../server/build/tools/navigation-tools.d.ts | 4 + .../build/tools/navigation-tools.d.ts.map | 1 + .../server/build/tools/navigation-tools.js | 87 + .../build/tools/navigation-tools.js.map | 1 + .../server/build/tools/node-tools.d.ts | 4 + .../server/build/tools/node-tools.d.ts.map | 1 + .../server/build/tools/node-tools.js | 219 ++ .../server/build/tools/node-tools.js.map | 1 + .../server/build/tools/particle-tools.d.ts | 4 + .../build/tools/particle-tools.d.ts.map | 1 + .../server/build/tools/particle-tools.js | 106 + .../server/build/tools/particle-tools.js.map | 1 + .../server/build/tools/physics-tools.d.ts | 4 + .../server/build/tools/physics-tools.d.ts.map | 1 + .../server/build/tools/physics-tools.js | 115 + .../server/build/tools/physics-tools.js.map | 1 + .../server/build/tools/profiling-tools.d.ts | 4 + .../build/tools/profiling-tools.d.ts.map | 1 + .../server/build/tools/profiling-tools.js | 25 + .../server/build/tools/profiling-tools.js.map | 1 + .../server/build/tools/project-tools.d.ts | 4 + .../server/build/tools/project-tools.d.ts.map | 1 + .../server/build/tools/project-tools.js | 125 + .../server/build/tools/project-tools.js.map | 1 + .../server/build/tools/resource-tools.d.ts | 4 + .../build/tools/resource-tools.d.ts.map | 1 + .../server/build/tools/resource-tools.js | 69 + .../server/build/tools/resource-tools.js.map | 1 + .../server/build/tools/runtime-tools.d.ts | 4 + .../server/build/tools/runtime-tools.d.ts.map | 1 + .../server/build/tools/runtime-tools.js | 552 +++ .../server/build/tools/runtime-tools.js.map | 1 + .../server/build/tools/scene-3d-tools.d.ts | 4 + .../build/tools/scene-3d-tools.d.ts.map | 1 + .../server/build/tools/scene-3d-tools.js | 405 ++ .../server/build/tools/scene-3d-tools.js.map | 1 + .../server/build/tools/scene-tools.d.ts | 4 + .../server/build/tools/scene-tools.d.ts.map | 1 + .../server/build/tools/scene-tools.js | 117 + .../server/build/tools/scene-tools.js.map | 1 + .../server/build/tools/script-tools.d.ts | 4 + .../server/build/tools/script-tools.d.ts.map | 1 + .../server/build/tools/script-tools.js | 100 + .../server/build/tools/script-tools.js.map | 1 + .../server/build/tools/shader-tools.d.ts | 4 + .../server/build/tools/shader-tools.d.ts.map | 1 + .../server/build/tools/shader-tools.js | 83 + .../server/build/tools/shader-tools.js.map | 1 + .../server/build/tools/test-tools.d.ts | 4 + .../server/build/tools/test-tools.d.ts.map | 1 + .../server/build/tools/test-tools.js | 146 + .../server/build/tools/test-tools.js.map | 1 + .../server/build/tools/theme-tools.d.ts | 4 + .../server/build/tools/theme-tools.d.ts.map | 1 + .../server/build/tools/theme-tools.js | 109 + .../server/build/tools/theme-tools.js.map | 1 + .../server/build/tools/tilemap-tools.d.ts | 4 + .../server/build/tools/tilemap-tools.d.ts.map | 1 + .../server/build/tools/tilemap-tools.js | 93 + .../server/build/tools/tilemap-tools.js.map | 1 + .../server/build/utils/errors.d.ts | 13 + .../server/build/utils/errors.d.ts.map | 1 + .../server/build/utils/errors.js | 51 + .../server/build/utils/errors.js.map | 1 + .../server/build/utils/load-instructions.d.ts | 7 + .../build/utils/load-instructions.d.ts.map | 1 + .../server/build/utils/load-instructions.js | 43 + .../build/utils/load-instructions.js.map | 1 + .../server/build/utils/tool-filter.d.ts | 12 + .../server/build/utils/tool-filter.d.ts.map | 1 + .../server/build/utils/tool-filter.js | 69 + .../server/build/utils/tool-filter.js.map | 1 + .../server/build/utils/types.d.ts | 32 + .../server/build/utils/types.d.ts.map | 1 + .../server/build/utils/types.js | 2 + .../server/build/utils/types.js.map | 1 + .../server/build/utils/zod-coerce.d.ts | 12 + .../server/build/utils/zod-coerce.d.ts.map | 1 + .../server/build/utils/zod-coerce.js | 35 + .../server/build/utils/zod-coerce.js.map | 1 + .../server/package-lock.json | 2451 ++++++++++++ .../godot-mcp-pro-v1.15.1/server/package.json | 36 + Tools/godot-mcp-pro-v1.15.1/server/src/cli.ts | 733 ++++ .../server/src/godot-connection.ts | 291 ++ .../godot-mcp-pro-v1.15.1/server/src/index.ts | 161 + .../godot-mcp-pro-v1.15.1/server/src/setup.ts | 332 ++ .../server/src/tools/analysis-tools.ts | 108 + .../server/src/tools/android-tools.ts | 61 + .../server/src/tools/animation-tools.ts | 119 + .../server/src/tools/animation-tree-tools.ts | 168 + .../server/src/tools/audio-tools.ts | 127 + .../server/src/tools/batch-tools.ts | 136 + .../server/src/tools/editor-tools.ts | 308 ++ .../server/src/tools/export-tools.ts | 55 + .../server/src/tools/input-map-tools.ts | 55 + .../server/src/tools/input-tools.ts | 138 + .../server/src/tools/navigation-tools.ts | 116 + .../server/src/tools/node-tools.ts | 308 ++ .../server/src/tools/particle-tools.ts | 135 + .../server/src/tools/physics-tools.ts | 149 + .../server/src/tools/profiling-tools.ts | 39 + .../server/src/tools/project-tools.ts | 179 + .../server/src/tools/resource-tools.ts | 93 + .../server/src/tools/runtime-tools.ts | 775 ++++ .../server/src/tools/scene-3d-tools.ts | 485 +++ .../server/src/tools/scene-tools.ts | 171 + .../server/src/tools/script-tools.ts | 141 + .../server/src/tools/shader-tools.ts | 117 + .../server/src/tools/test-tools.ts | 195 + .../server/src/tools/theme-tools.ts | 148 + .../server/src/tools/tilemap-tools.ts | 127 + .../server/src/utils/errors.ts | 54 + .../server/src/utils/load-instructions.ts | 45 + .../server/src/utils/tool-filter.ts | 73 + .../server/src/utils/types.ts | 35 + .../server/src/utils/zod-coerce.ts | 33 + .../server/tsconfig.json | 19 + .../godot-mcp-pro-v1.15.1/settings.local.json | 176 + .../settings.local.permissive.json | 38 + addons/game_designer/balance_tab.gd | 67 + addons/game_designer/balance_tab.gd.uid | 1 + addons/game_designer/core_tab.gd | 125 + addons/game_designer/core_tab.gd.uid | 1 + addons/game_designer/designer_panel.gd | 26 + addons/game_designer/designer_panel.gd.uid | 1 + addons/game_designer/designer_ui.gd | 68 + addons/game_designer/designer_ui.gd.uid | 1 + addons/game_designer/enemy_tab.gd | 60 + addons/game_designer/enemy_tab.gd.uid | 1 + addons/game_designer/game_designer.gd | 17 + addons/game_designer/game_designer.gd.uid | 1 + addons/game_designer/plugin.cfg | 6 + addons/game_designer/resonance_tab.gd | 100 + addons/game_designer/resonance_tab.gd.uid | 1 + addons/game_designer/status_tab.gd | 110 + addons/game_designer/status_tab.gd.uid | 1 + addons/game_designer/wave_tab.gd | 86 + addons/game_designer/wave_tab.gd.uid | 1 + addons/godot_mcp/command_router.gd | 120 + addons/godot_mcp/command_router.gd.uid | 1 + .../godot_mcp/commands/analysis_commands.gd | 547 +++ .../commands/analysis_commands.gd.uid | 1 + addons/godot_mcp/commands/android_commands.gd | 192 + .../commands/android_commands.gd.uid | 1 + .../godot_mcp/commands/animation_commands.gd | 298 ++ .../commands/animation_commands.gd.uid | 1 + .../commands/animation_tree_commands.gd | 601 +++ .../commands/animation_tree_commands.gd.uid | 1 + addons/godot_mcp/commands/audio_commands.gd | 431 +++ .../godot_mcp/commands/audio_commands.gd.uid | 1 + addons/godot_mcp/commands/base_command.gd | 485 +++ addons/godot_mcp/commands/base_command.gd.uid | 1 + addons/godot_mcp/commands/batch_commands.gd | 436 +++ .../godot_mcp/commands/batch_commands.gd.uid | 1 + addons/godot_mcp/commands/editor_commands.gd | 682 ++++ .../godot_mcp/commands/editor_commands.gd.uid | 1 + addons/godot_mcp/commands/export_commands.gd | 117 + .../godot_mcp/commands/export_commands.gd.uid | 1 + addons/godot_mcp/commands/input_commands.gd | 162 + .../godot_mcp/commands/input_commands.gd.uid | 1 + .../godot_mcp/commands/input_map_commands.gd | 157 + .../commands/input_map_commands.gd.uid | 1 + .../godot_mcp/commands/navigation_commands.gd | 472 +++ .../commands/navigation_commands.gd.uid | 1 + addons/godot_mcp/commands/node_commands.gd | 845 ++++ .../godot_mcp/commands/node_commands.gd.uid | 1 + .../godot_mcp/commands/particle_commands.gd | 626 +++ .../commands/particle_commands.gd.uid | 1 + addons/godot_mcp/commands/physics_commands.gd | 757 ++++ .../commands/physics_commands.gd.uid | 1 + .../godot_mcp/commands/profiling_commands.gd | 47 + .../commands/profiling_commands.gd.uid | 1 + addons/godot_mcp/commands/project_commands.gd | 390 ++ .../commands/project_commands.gd.uid | 1 + .../godot_mcp/commands/resource_commands.gd | 205 + .../commands/resource_commands.gd.uid | 1 + addons/godot_mcp/commands/runtime_commands.gd | 373 ++ .../commands/runtime_commands.gd.uid | 1 + .../godot_mcp/commands/scene_3d_commands.gd | 680 ++++ .../commands/scene_3d_commands.gd.uid | 1 + addons/godot_mcp/commands/scene_commands.gd | 331 ++ .../godot_mcp/commands/scene_commands.gd.uid | 1 + addons/godot_mcp/commands/script_commands.gd | 369 ++ .../godot_mcp/commands/script_commands.gd.uid | 1 + addons/godot_mcp/commands/shader_commands.gd | 239 ++ .../godot_mcp/commands/shader_commands.gd.uid | 1 + addons/godot_mcp/commands/test_commands.gd | 580 +++ .../godot_mcp/commands/test_commands.gd.uid | 1 + addons/godot_mcp/commands/theme_commands.gd | 428 +++ .../godot_mcp/commands/theme_commands.gd.uid | 1 + addons/godot_mcp/commands/tilemap_commands.gd | 381 ++ .../commands/tilemap_commands.gd.uid | 1 + .../godot_mcp/mcp_game_inspector_service.gd | 1761 +++++++++ .../mcp_game_inspector_service.gd.uid | 1 + addons/godot_mcp/mcp_input_service.gd | 198 + addons/godot_mcp/mcp_input_service.gd.uid | 1 + addons/godot_mcp/mcp_screenshot_service.gd | 34 + .../godot_mcp/mcp_screenshot_service.gd.uid | 1 + addons/godot_mcp/plugin.cfg | 7 + addons/godot_mcp/plugin.gd | 196 + addons/godot_mcp/plugin.gd.uid | 1 + addons/godot_mcp/skills.es.md | 271 ++ addons/godot_mcp/skills.hi.md | 271 ++ addons/godot_mcp/skills.ja.md | 276 ++ addons/godot_mcp/skills.md | 295 ++ addons/godot_mcp/skills.pt-br.md | 271 ++ addons/godot_mcp/skills.ru.md | 271 ++ addons/godot_mcp/skills.zh.md | 271 ++ addons/godot_mcp/ui/status_panel.gd | 382 ++ addons/godot_mcp/ui/status_panel.gd.uid | 1 + addons/godot_mcp/ui/status_panel.tscn | 8 + addons/godot_mcp/utils/node_utils.gd | 80 + addons/godot_mcp/utils/node_utils.gd.uid | 1 + addons/godot_mcp/utils/property_parser.gd | 218 ++ addons/godot_mcp/utils/property_parser.gd.uid | 1 + addons/godot_mcp/websocket_server.gd | 254 ++ addons/godot_mcp/websocket_server.gd.uid | 1 + addons/spell_editor/plugin.cfg | 6 + addons/spell_editor/spell_dock.gd | 214 ++ addons/spell_editor/spell_dock.gd.uid | 1 + addons/spell_editor/spell_editor.gd | 17 + addons/spell_editor/spell_editor.gd.uid | 1 + assets/generated/game_start_icon.png | Bin 0 -> 253803 bytes assets/generated/game_start_icon.png.import | 40 + assets/ui/ui_theme.tres | 84 + csharp/autoloads/BulletManagerCs.cs | 41 + csharp/autoloads/BulletManagerCs.cs.uid | 1 + csharp/autoloads/EnemyManagerCs.cs | 36 + csharp/autoloads/EnemyManagerCs.cs.uid | 1 + csharp/systems/SpatialGridCs.cs | 98 + csharp/systems/SpatialGridCs.cs.uid | 1 + data/balance.json | 12 + data/cores.json | 7 + data/enemies.json | 8 + data/resonance.json | 9 + data/spells.json | 22 + data/status_effects.json | 5 + data/waves.json | 22 + docs/README.md | 141 + docs/architecture_design.md | 214 ++ docs/design/core_wand_design.md | 252 ++ docs/design/game_design.md | 387 ++ docs/design/numerical_design.md | 248 ++ docs/design/tutorial_design.md | 284 ++ docs/handbook/01_quickstart.md | 121 + docs/handbook/02_content_authoring.md | 248 ++ docs/handbook/03_asset_replacement.md | 281 ++ docs/handbook/04_localization.md | 197 + docs/handbook/05_release_guide.md | 170 + docs/handbook/06_spell_editor.md | 134 + docs/handbook/07_game_designer.md | 158 + docs/handbook/README.md | 65 + ...anced_mechanics_summons_and_environment.md | 98 + docs/mechanics/combat_mechanics_depth.md | 176 + .../combat_mechanics_extensions_v2.md | 102 + docs/mechanics/consecutive_hits_stacking.md | 92 + docs/mechanics/weapon_system_expansion.md | 112 + docs/plan/certification_checklist.md | 184 + docs/plan/development_plan.md | 592 +++ docs/technical/architecture_design.md | 3421 +++++++++++++++++ docs/technical/boss_design.md | 215 ++ docs/technical/implementation_plan.md | 815 ++++ icon.svg | 1 + icon.svg.import | 43 + package-lock.json | 13 + package.json | 15 + project.godot | 79 + scenes/main/combat_s2.gd | 791 ++++ scenes/main/combat_s2.gd.uid | 1 + scenes/main/combat_s2.tscn | 6 + scenes/main/combat_test.gd | 62 + scenes/main/combat_test.gd.uid | 1 + scenes/main/combat_test.tscn | 6 + scenes/ui/main_menu.gd | 353 ++ scenes/ui/main_menu.gd.uid | 1 + scenes/ui/main_menu.tscn | 10 + scenes/ui/splash.gd | 91 + scenes/ui/splash.gd.uid | 1 + scenes/ui/splash.tscn | 8 + scripts/autoloads/audio_bus_id.gd | 15 + scripts/autoloads/audio_bus_id.gd.uid | 1 + scripts/autoloads/audio_manager.gd | 110 + scripts/autoloads/audio_manager.gd.uid | 1 + scripts/autoloads/bullet_manager.gd | 178 + scripts/autoloads/bullet_manager.gd.uid | 1 + scripts/autoloads/config_mgr.gd | 39 + scripts/autoloads/config_mgr.gd.uid | 1 + scripts/autoloads/core_feature_tag.gd | 9 + scripts/autoloads/core_feature_tag.gd.uid | 1 + scripts/autoloads/crash_reporter.gd | 108 + scripts/autoloads/crash_reporter.gd.uid | 1 + scripts/autoloads/damage_context.gd | 28 + scripts/autoloads/damage_context.gd.uid | 1 + scripts/autoloads/damage_context_pool.gd | 69 + scripts/autoloads/damage_context_pool.gd.uid | 1 + scripts/autoloads/damage_type.gd | 11 + scripts/autoloads/damage_type.gd.uid | 1 + scripts/autoloads/dps_tracker.gd | 50 + scripts/autoloads/dps_tracker.gd.uid | 1 + scripts/autoloads/drop_manager.gd | 18 + scripts/autoloads/drop_manager.gd.uid | 1 + scripts/autoloads/endless_records.gd | 76 + scripts/autoloads/endless_records.gd.uid | 1 + scripts/autoloads/enemy_manager.gd | 329 ++ scripts/autoloads/enemy_manager.gd.uid | 1 + scripts/autoloads/event_bus.gd | 42 + scripts/autoloads/event_bus.gd.uid | 1 + scripts/autoloads/event_ids.gd | 49 + scripts/autoloads/event_ids.gd.uid | 1 + scripts/autoloads/locale_manager.gd | 33 + scripts/autoloads/locale_manager.gd.uid | 1 + scripts/autoloads/minion_manager.gd | 103 + scripts/autoloads/minion_manager.gd.uid | 1 + scripts/autoloads/object_pool.gd | 40 + scripts/autoloads/object_pool.gd.uid | 1 + scripts/autoloads/player_stats.gd | 89 + scripts/autoloads/player_stats.gd.uid | 1 + scripts/autoloads/profile_manager.gd | 118 + scripts/autoloads/profile_manager.gd.uid | 1 + scripts/autoloads/scene_manager.gd | 93 + scripts/autoloads/scene_manager.gd.uid | 1 + scripts/autoloads/settings_manager.gd | 149 + scripts/autoloads/settings_manager.gd.uid | 1 + scripts/autoloads/shop_manager.gd | 86 + scripts/autoloads/shop_manager.gd.uid | 1 + scripts/autoloads/spatial_grid.gd | 75 + scripts/autoloads/spatial_grid.gd.uid | 1 + scripts/autoloads/spell_context_pool.gd | 54 + scripts/autoloads/spell_context_pool.gd.uid | 1 + scripts/autoloads/spell_registry.gd | 46 + scripts/autoloads/spell_registry.gd.uid | 1 + scripts/autoloads/status_id.gd | 14 + scripts/autoloads/status_id.gd.uid | 1 + scripts/autoloads/status_manager.gd | 122 + scripts/autoloads/status_manager.gd.uid | 1 + scripts/autoloads/status_registry.gd | 40 + scripts/autoloads/status_registry.gd.uid | 1 + scripts/autoloads/time_manager.gd | 34 + scripts/autoloads/time_manager.gd.uid | 1 + scripts/autoloads/vfx_manager.gd | 125 + scripts/autoloads/vfx_manager.gd.uid | 1 + scripts/autoloads/wand_preset.gd | 46 + scripts/autoloads/wand_preset.gd.uid | 1 + scripts/autoloads/wave_manager.gd | 174 + scripts/autoloads/wave_manager.gd.uid | 1 + scripts/autoloads/zone_manager.gd | 76 + scripts/autoloads/zone_manager.gd.uid | 1 + scripts/domain/combat/combat_manager.gd | 288 ++ scripts/domain/combat/combat_manager.gd.uid | 1 + scripts/domain/player_manager.gd | 119 + scripts/domain/player_manager.gd.uid | 1 + scripts/domain/spell_system/cast_stats.gd | 23 + scripts/domain/spell_system/cast_stats.gd.uid | 1 + scripts/domain/spell_system/compiled_deck.gd | 21 + .../domain/spell_system/compiled_deck.gd.uid | 1 + .../domain/spell_system/core_definition.gd | 28 + .../spell_system/core_definition.gd.uid | 1 + scripts/domain/spell_system/projectile_def.gd | 39 + .../domain/spell_system/projectile_def.gd.uid | 1 + scripts/domain/spell_system/spell_context.gd | 44 + .../domain/spell_system/spell_context.gd.uid | 1 + scripts/domain/spell_system/spell_deck.gd | 74 + scripts/domain/spell_system/spell_deck.gd.uid | 1 + .../domain/spell_system/spell_evaluator.gd | 619 +++ .../spell_system/spell_evaluator.gd.uid | 1 + scripts/domain/spell_system/spell_node.gd | 27 + scripts/domain/spell_system/spell_node.gd.uid | 1 + .../spell_system/sub_payload_registry.gd | 47 + .../spell_system/sub_payload_registry.gd.uid | 1 + scripts/domain/status_instance.gd | 21 + scripts/domain/status_instance.gd.uid | 1 + scripts/domain/status_type_def.gd | 23 + scripts/domain/status_type_def.gd.uid | 1 + shaders/arena_grid.gdshader | 26 + shaders/arena_grid.gdshader.uid | 1 + shaders/bullet_glow.gdshader | 12 + shaders/bullet_glow.gdshader.uid | 1 + shaders/enemy_circle.gdshader | 16 + shaders/enemy_circle.gdshader.uid | 1 + translations/en.po | 193 + translations/ja.po | 193 + translations/zh_CN.po | 193 + translations/zh_TW.po | 193 + 462 files changed, 50191 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 Tools/godot-mcp-pro-v1.15.1/AGENTS.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/CHANGELOG.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/INSTALL.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/LICENSE create mode 100644 Tools/godot-mcp-pro-v1.15.1/README.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/docs/architecture.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/docs/installation.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/docs/tools-reference.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/.clinerules create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/.windsurfrules create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/AGENTS.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/CLAUDE.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/GEMINI.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/README.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/amazonq-godot-mcp-pro.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/augment-godot-mcp-pro.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/continue-godot-mcp-pro.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/godot-mcp-pro.mdc create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/junie-guidelines.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/instructions/roo-godot-mcp-pro.md create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/cli.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/cli.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/index.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/index.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/setup.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/setup.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js.map create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/package-lock.json create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/package.json create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/cli.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/godot-connection.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/index.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/setup.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/analysis-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/android-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tree-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/audio-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/batch-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/editor-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/export-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-map-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/navigation-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/node-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/particle-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/physics-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/profiling-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/project-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/resource-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/runtime-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-3d-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/script-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/shader-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/test-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/theme-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/tools/tilemap-tools.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/utils/errors.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/utils/load-instructions.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/utils/tool-filter.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/utils/types.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/src/utils/zod-coerce.ts create mode 100644 Tools/godot-mcp-pro-v1.15.1/server/tsconfig.json create mode 100644 Tools/godot-mcp-pro-v1.15.1/settings.local.json create mode 100644 Tools/godot-mcp-pro-v1.15.1/settings.local.permissive.json create mode 100644 addons/game_designer/balance_tab.gd create mode 100644 addons/game_designer/balance_tab.gd.uid create mode 100644 addons/game_designer/core_tab.gd create mode 100644 addons/game_designer/core_tab.gd.uid create mode 100644 addons/game_designer/designer_panel.gd create mode 100644 addons/game_designer/designer_panel.gd.uid create mode 100644 addons/game_designer/designer_ui.gd create mode 100644 addons/game_designer/designer_ui.gd.uid create mode 100644 addons/game_designer/enemy_tab.gd create mode 100644 addons/game_designer/enemy_tab.gd.uid create mode 100644 addons/game_designer/game_designer.gd create mode 100644 addons/game_designer/game_designer.gd.uid create mode 100644 addons/game_designer/plugin.cfg create mode 100644 addons/game_designer/resonance_tab.gd create mode 100644 addons/game_designer/resonance_tab.gd.uid create mode 100644 addons/game_designer/status_tab.gd create mode 100644 addons/game_designer/status_tab.gd.uid create mode 100644 addons/game_designer/wave_tab.gd create mode 100644 addons/game_designer/wave_tab.gd.uid create mode 100644 addons/godot_mcp/command_router.gd create mode 100644 addons/godot_mcp/command_router.gd.uid create mode 100644 addons/godot_mcp/commands/analysis_commands.gd create mode 100644 addons/godot_mcp/commands/analysis_commands.gd.uid create mode 100644 addons/godot_mcp/commands/android_commands.gd create mode 100644 addons/godot_mcp/commands/android_commands.gd.uid create mode 100644 addons/godot_mcp/commands/animation_commands.gd create mode 100644 addons/godot_mcp/commands/animation_commands.gd.uid create mode 100644 addons/godot_mcp/commands/animation_tree_commands.gd create mode 100644 addons/godot_mcp/commands/animation_tree_commands.gd.uid create mode 100644 addons/godot_mcp/commands/audio_commands.gd create mode 100644 addons/godot_mcp/commands/audio_commands.gd.uid create mode 100644 addons/godot_mcp/commands/base_command.gd create mode 100644 addons/godot_mcp/commands/base_command.gd.uid create mode 100644 addons/godot_mcp/commands/batch_commands.gd create mode 100644 addons/godot_mcp/commands/batch_commands.gd.uid create mode 100644 addons/godot_mcp/commands/editor_commands.gd create mode 100644 addons/godot_mcp/commands/editor_commands.gd.uid create mode 100644 addons/godot_mcp/commands/export_commands.gd create mode 100644 addons/godot_mcp/commands/export_commands.gd.uid create mode 100644 addons/godot_mcp/commands/input_commands.gd create mode 100644 addons/godot_mcp/commands/input_commands.gd.uid create mode 100644 addons/godot_mcp/commands/input_map_commands.gd create mode 100644 addons/godot_mcp/commands/input_map_commands.gd.uid create mode 100644 addons/godot_mcp/commands/navigation_commands.gd create mode 100644 addons/godot_mcp/commands/navigation_commands.gd.uid create mode 100644 addons/godot_mcp/commands/node_commands.gd create mode 100644 addons/godot_mcp/commands/node_commands.gd.uid create mode 100644 addons/godot_mcp/commands/particle_commands.gd create mode 100644 addons/godot_mcp/commands/particle_commands.gd.uid create mode 100644 addons/godot_mcp/commands/physics_commands.gd create mode 100644 addons/godot_mcp/commands/physics_commands.gd.uid create mode 100644 addons/godot_mcp/commands/profiling_commands.gd create mode 100644 addons/godot_mcp/commands/profiling_commands.gd.uid create mode 100644 addons/godot_mcp/commands/project_commands.gd create mode 100644 addons/godot_mcp/commands/project_commands.gd.uid create mode 100644 addons/godot_mcp/commands/resource_commands.gd create mode 100644 addons/godot_mcp/commands/resource_commands.gd.uid create mode 100644 addons/godot_mcp/commands/runtime_commands.gd create mode 100644 addons/godot_mcp/commands/runtime_commands.gd.uid create mode 100644 addons/godot_mcp/commands/scene_3d_commands.gd create mode 100644 addons/godot_mcp/commands/scene_3d_commands.gd.uid create mode 100644 addons/godot_mcp/commands/scene_commands.gd create mode 100644 addons/godot_mcp/commands/scene_commands.gd.uid create mode 100644 addons/godot_mcp/commands/script_commands.gd create mode 100644 addons/godot_mcp/commands/script_commands.gd.uid create mode 100644 addons/godot_mcp/commands/shader_commands.gd create mode 100644 addons/godot_mcp/commands/shader_commands.gd.uid create mode 100644 addons/godot_mcp/commands/test_commands.gd create mode 100644 addons/godot_mcp/commands/test_commands.gd.uid create mode 100644 addons/godot_mcp/commands/theme_commands.gd create mode 100644 addons/godot_mcp/commands/theme_commands.gd.uid create mode 100644 addons/godot_mcp/commands/tilemap_commands.gd create mode 100644 addons/godot_mcp/commands/tilemap_commands.gd.uid create mode 100644 addons/godot_mcp/mcp_game_inspector_service.gd create mode 100644 addons/godot_mcp/mcp_game_inspector_service.gd.uid create mode 100644 addons/godot_mcp/mcp_input_service.gd create mode 100644 addons/godot_mcp/mcp_input_service.gd.uid create mode 100644 addons/godot_mcp/mcp_screenshot_service.gd create mode 100644 addons/godot_mcp/mcp_screenshot_service.gd.uid create mode 100644 addons/godot_mcp/plugin.cfg create mode 100644 addons/godot_mcp/plugin.gd create mode 100644 addons/godot_mcp/plugin.gd.uid create mode 100644 addons/godot_mcp/skills.es.md create mode 100644 addons/godot_mcp/skills.hi.md create mode 100644 addons/godot_mcp/skills.ja.md create mode 100644 addons/godot_mcp/skills.md create mode 100644 addons/godot_mcp/skills.pt-br.md create mode 100644 addons/godot_mcp/skills.ru.md create mode 100644 addons/godot_mcp/skills.zh.md create mode 100644 addons/godot_mcp/ui/status_panel.gd create mode 100644 addons/godot_mcp/ui/status_panel.gd.uid create mode 100644 addons/godot_mcp/ui/status_panel.tscn create mode 100644 addons/godot_mcp/utils/node_utils.gd create mode 100644 addons/godot_mcp/utils/node_utils.gd.uid create mode 100644 addons/godot_mcp/utils/property_parser.gd create mode 100644 addons/godot_mcp/utils/property_parser.gd.uid create mode 100644 addons/godot_mcp/websocket_server.gd create mode 100644 addons/godot_mcp/websocket_server.gd.uid create mode 100644 addons/spell_editor/plugin.cfg create mode 100644 addons/spell_editor/spell_dock.gd create mode 100644 addons/spell_editor/spell_dock.gd.uid create mode 100644 addons/spell_editor/spell_editor.gd create mode 100644 addons/spell_editor/spell_editor.gd.uid create mode 100644 assets/generated/game_start_icon.png create mode 100644 assets/generated/game_start_icon.png.import create mode 100644 assets/ui/ui_theme.tres create mode 100644 csharp/autoloads/BulletManagerCs.cs create mode 100644 csharp/autoloads/BulletManagerCs.cs.uid create mode 100644 csharp/autoloads/EnemyManagerCs.cs create mode 100644 csharp/autoloads/EnemyManagerCs.cs.uid create mode 100644 csharp/systems/SpatialGridCs.cs create mode 100644 csharp/systems/SpatialGridCs.cs.uid create mode 100644 data/balance.json create mode 100644 data/cores.json create mode 100644 data/enemies.json create mode 100644 data/resonance.json create mode 100644 data/spells.json create mode 100644 data/status_effects.json create mode 100644 data/waves.json create mode 100644 docs/README.md create mode 100644 docs/architecture_design.md create mode 100644 docs/design/core_wand_design.md create mode 100644 docs/design/game_design.md create mode 100644 docs/design/numerical_design.md create mode 100644 docs/design/tutorial_design.md create mode 100644 docs/handbook/01_quickstart.md create mode 100644 docs/handbook/02_content_authoring.md create mode 100644 docs/handbook/03_asset_replacement.md create mode 100644 docs/handbook/04_localization.md create mode 100644 docs/handbook/05_release_guide.md create mode 100644 docs/handbook/06_spell_editor.md create mode 100644 docs/handbook/07_game_designer.md create mode 100644 docs/handbook/README.md create mode 100644 docs/mechanics/advanced_mechanics_summons_and_environment.md create mode 100644 docs/mechanics/combat_mechanics_depth.md create mode 100644 docs/mechanics/combat_mechanics_extensions_v2.md create mode 100644 docs/mechanics/consecutive_hits_stacking.md create mode 100644 docs/mechanics/weapon_system_expansion.md create mode 100644 docs/plan/certification_checklist.md create mode 100644 docs/plan/development_plan.md create mode 100644 docs/technical/architecture_design.md create mode 100644 docs/technical/boss_design.md create mode 100644 docs/technical/implementation_plan.md create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 project.godot create mode 100644 scenes/main/combat_s2.gd create mode 100644 scenes/main/combat_s2.gd.uid create mode 100644 scenes/main/combat_s2.tscn create mode 100644 scenes/main/combat_test.gd create mode 100644 scenes/main/combat_test.gd.uid create mode 100644 scenes/main/combat_test.tscn create mode 100644 scenes/ui/main_menu.gd create mode 100644 scenes/ui/main_menu.gd.uid create mode 100644 scenes/ui/main_menu.tscn create mode 100644 scenes/ui/splash.gd create mode 100644 scenes/ui/splash.gd.uid create mode 100644 scenes/ui/splash.tscn create mode 100644 scripts/autoloads/audio_bus_id.gd create mode 100644 scripts/autoloads/audio_bus_id.gd.uid create mode 100644 scripts/autoloads/audio_manager.gd create mode 100644 scripts/autoloads/audio_manager.gd.uid create mode 100644 scripts/autoloads/bullet_manager.gd create mode 100644 scripts/autoloads/bullet_manager.gd.uid create mode 100644 scripts/autoloads/config_mgr.gd create mode 100644 scripts/autoloads/config_mgr.gd.uid create mode 100644 scripts/autoloads/core_feature_tag.gd create mode 100644 scripts/autoloads/core_feature_tag.gd.uid create mode 100644 scripts/autoloads/crash_reporter.gd create mode 100644 scripts/autoloads/crash_reporter.gd.uid create mode 100644 scripts/autoloads/damage_context.gd create mode 100644 scripts/autoloads/damage_context.gd.uid create mode 100644 scripts/autoloads/damage_context_pool.gd create mode 100644 scripts/autoloads/damage_context_pool.gd.uid create mode 100644 scripts/autoloads/damage_type.gd create mode 100644 scripts/autoloads/damage_type.gd.uid create mode 100644 scripts/autoloads/dps_tracker.gd create mode 100644 scripts/autoloads/dps_tracker.gd.uid create mode 100644 scripts/autoloads/drop_manager.gd create mode 100644 scripts/autoloads/drop_manager.gd.uid create mode 100644 scripts/autoloads/endless_records.gd create mode 100644 scripts/autoloads/endless_records.gd.uid create mode 100644 scripts/autoloads/enemy_manager.gd create mode 100644 scripts/autoloads/enemy_manager.gd.uid create mode 100644 scripts/autoloads/event_bus.gd create mode 100644 scripts/autoloads/event_bus.gd.uid create mode 100644 scripts/autoloads/event_ids.gd create mode 100644 scripts/autoloads/event_ids.gd.uid create mode 100644 scripts/autoloads/locale_manager.gd create mode 100644 scripts/autoloads/locale_manager.gd.uid create mode 100644 scripts/autoloads/minion_manager.gd create mode 100644 scripts/autoloads/minion_manager.gd.uid create mode 100644 scripts/autoloads/object_pool.gd create mode 100644 scripts/autoloads/object_pool.gd.uid create mode 100644 scripts/autoloads/player_stats.gd create mode 100644 scripts/autoloads/player_stats.gd.uid create mode 100644 scripts/autoloads/profile_manager.gd create mode 100644 scripts/autoloads/profile_manager.gd.uid create mode 100644 scripts/autoloads/scene_manager.gd create mode 100644 scripts/autoloads/scene_manager.gd.uid create mode 100644 scripts/autoloads/settings_manager.gd create mode 100644 scripts/autoloads/settings_manager.gd.uid create mode 100644 scripts/autoloads/shop_manager.gd create mode 100644 scripts/autoloads/shop_manager.gd.uid create mode 100644 scripts/autoloads/spatial_grid.gd create mode 100644 scripts/autoloads/spatial_grid.gd.uid create mode 100644 scripts/autoloads/spell_context_pool.gd create mode 100644 scripts/autoloads/spell_context_pool.gd.uid create mode 100644 scripts/autoloads/spell_registry.gd create mode 100644 scripts/autoloads/spell_registry.gd.uid create mode 100644 scripts/autoloads/status_id.gd create mode 100644 scripts/autoloads/status_id.gd.uid create mode 100644 scripts/autoloads/status_manager.gd create mode 100644 scripts/autoloads/status_manager.gd.uid create mode 100644 scripts/autoloads/status_registry.gd create mode 100644 scripts/autoloads/status_registry.gd.uid create mode 100644 scripts/autoloads/time_manager.gd create mode 100644 scripts/autoloads/time_manager.gd.uid create mode 100644 scripts/autoloads/vfx_manager.gd create mode 100644 scripts/autoloads/vfx_manager.gd.uid create mode 100644 scripts/autoloads/wand_preset.gd create mode 100644 scripts/autoloads/wand_preset.gd.uid create mode 100644 scripts/autoloads/wave_manager.gd create mode 100644 scripts/autoloads/wave_manager.gd.uid create mode 100644 scripts/autoloads/zone_manager.gd create mode 100644 scripts/autoloads/zone_manager.gd.uid create mode 100644 scripts/domain/combat/combat_manager.gd create mode 100644 scripts/domain/combat/combat_manager.gd.uid create mode 100644 scripts/domain/player_manager.gd create mode 100644 scripts/domain/player_manager.gd.uid create mode 100644 scripts/domain/spell_system/cast_stats.gd create mode 100644 scripts/domain/spell_system/cast_stats.gd.uid create mode 100644 scripts/domain/spell_system/compiled_deck.gd create mode 100644 scripts/domain/spell_system/compiled_deck.gd.uid create mode 100644 scripts/domain/spell_system/core_definition.gd create mode 100644 scripts/domain/spell_system/core_definition.gd.uid create mode 100644 scripts/domain/spell_system/projectile_def.gd create mode 100644 scripts/domain/spell_system/projectile_def.gd.uid create mode 100644 scripts/domain/spell_system/spell_context.gd create mode 100644 scripts/domain/spell_system/spell_context.gd.uid create mode 100644 scripts/domain/spell_system/spell_deck.gd create mode 100644 scripts/domain/spell_system/spell_deck.gd.uid create mode 100644 scripts/domain/spell_system/spell_evaluator.gd create mode 100644 scripts/domain/spell_system/spell_evaluator.gd.uid create mode 100644 scripts/domain/spell_system/spell_node.gd create mode 100644 scripts/domain/spell_system/spell_node.gd.uid create mode 100644 scripts/domain/spell_system/sub_payload_registry.gd create mode 100644 scripts/domain/spell_system/sub_payload_registry.gd.uid create mode 100644 scripts/domain/status_instance.gd create mode 100644 scripts/domain/status_instance.gd.uid create mode 100644 scripts/domain/status_type_def.gd create mode 100644 scripts/domain/status_type_def.gd.uid create mode 100644 shaders/arena_grid.gdshader create mode 100644 shaders/arena_grid.gdshader.uid create mode 100644 shaders/bullet_glow.gdshader create mode 100644 shaders/bullet_glow.gdshader.uid create mode 100644 shaders/enemy_circle.gdshader create mode 100644 shaders/enemy_circle.gdshader.uid create mode 100644 translations/en.po create mode 100644 translations/ja.po create mode 100644 translations/zh_CN.po create mode 100644 translations/zh_TW.po diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbb2f91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Godot 4+ specific ignores +.godot/ +/android/ +node_modules/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1a3b468 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR:-.}/Tools/godot-mcp-pro-v1.15.1/server/build/index.js"], + "env": { + "GODOT_MCP_PORT": "6505" + } + } + } +} diff --git a/Tools/godot-mcp-pro-v1.15.1/AGENTS.md b/Tools/godot-mcp-pro-v1.15.1/AGENTS.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/AGENTS.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/CHANGELOG.md b/Tools/godot-mcp-pro-v1.15.1/CHANGELOG.md new file mode 100644 index 0000000..c03a9ea --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/CHANGELOG.md @@ -0,0 +1,699 @@ +# Changelog + +All notable changes to Godot MCP Pro will be documented in this file. + +--- + +## v1.15.1 — 2026-07-19 + +**Patch** — 15 fixes from an external user's full-toolset audit (all 174 tools tested against a live editor). Huge thanks to the reporter. + +### Fixed — Critical +- **`set_particle_color_gradient` infinite loop / editor hang**: clearing a fresh `Gradient` point-by-point never terminates because `Gradient.remove_point` refuses to drop below 2 points. Gradients are now built by assigning `offsets` / `colors` wholesale. The same root cause also produced a spurious opaque-black stop at offset 0 in every `apply_particle_preset` color ramp — both fixed via the same change. +- **`connect_signal` connections were never saved to the `.tscn`**: `Object.connect()` was called without `CONNECT_PERSIST`, so `PackedScene.pack()` dropped the connection on save. Connections are now persistent; new optional `deferred` / `one_shot` parameters, and the response echoes `flags` / `persistent`. +- **`update_property` destroyed Resource-typed properties**: assigning e.g. `texture` by `"res://..."` path passed the raw String through, which the engine coerced to `null` — silently wiping the existing value. `PropertyParser` now loads `res://` / `uid://` paths into Resources (also fixes `batch_add_nodes` properties), and `update_property` fails loudly when a string cannot be resolved instead of committing a `null`. + +### Fixed — High +- **`create_theme` returned `{}` and never wrote the file**: a `!= null` check on a `Dictionary` guard made the guard branch unconditional. Now uses `is_empty()` like every other call site. Also creates parent directories. +- **`get_performance_monitors` reported the editor process's metrics as the game's**: `Performance` is per-process. The tool now routes through the game IPC channel (requires a playing scene) and returns `"process": "game"`; use `get_editor_performance` for editor metrics. +- **`get_test_report` counted passing assertions as failures**: non-assertion steps (input/wait/screenshot) were scored as failed, and game replies were stored still double-wrapped so their `passed` key was never found. Only assertion results are stored now, and the envelope is unwrapped defensively. Empty reports return `no_results: true` instead of a misleading `all_passed: false`. +- **`run_test_scenario` reported `all_passed: false` on green runs**: same double-wrapped envelope, unwrapped one level too few in `_execute_assert_step`. Assertion results now surface `passed` at the top level with a consistent shape. + +### Fixed — Medium +- **`get_scene_tree` returned editor-internal absolute paths** (`/root/@EditorNode@.../...`): paths are now scene-relative (root = `"."`), directly usable as `node_path` input for other tools, and ~10× smaller. +- **`analyze_signal_flow` dumped editor-internal connections** (~8k tokens of dock bookkeeping): now filters to persistent connections targeting nodes inside the edited scene. +- **`find_unused_resources` ignored `uid://` references**: `preload("uid://…")` and `uid=` references are now resolved back to their `res://` paths (self-referencing file-header uids excluded). References held via `ProjectSettings` defaults (main scene, audio bus layout, icon, autoloads) are also seeded, so `default_bus_layout.tres` and friends are no longer reported deletable. +- **`get_input_actions(include_builtin: false)` leaked 16 editor actions** (`spatial_editor/*`): actions not declared in the project's `ProjectSettings` are now excluded. +- **`create_resource` failed on missing parent directories**: directories are created recursively; the error message now includes the path. +- **`run_test_scenario` `keycode` input steps never released the key**, corrupting later assertions: keycode steps now auto-release like `action` steps (disable with `auto_release: false`). + +### Fixed — Minor +- **`set_particle_material`**: emission sub-parameters (`emission_sphere_radius`, box extents, ring radii/height) are now listed in `changes[]`. +- `run_test_scenario` screen-text assertions no longer lose their result type to a key collision (`assert_type` field). + +--- + +## v1.14.1 — 2026-05-24 + +**Patch** — `assert_node_state` regression fix + +### Fixed +- **`assert_node_state` "Unknown command" regression**: The game-side handler in `mcp_game_inspector_service.gd` had been removed during the v1.7.0 refactor (`4ea3989`, 2026-03-29) that introduced `batch_add_nodes` / `watch_signals` / `setup_control`. The TypeScript server and editor-side GDScript still routed the call, but the runtime match statement no longer recognized it — so `assert_node_state` (and any `type:"assert"` step inside `run_test_scenario`) returned `Unknown command: assert_node_state` on every call from v1.7.0 through v1.14.0. Restored the handler with all 8 operators (`eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `contains`, `type_is`) and sub-property access via `get_indexed()` (e.g. `position:y`). Reported by **Z_runner [CRWN]** on Discord. + +--- + +## v1.14.0 — 2026-05-18 + +**Feature / Safety** — File-conflict safety overhaul (community contribution) + +This release is a coordinated overhaul of how the addon interacts with editor-owned resources. It prevents the "external change" dialog and silent in-memory/disk divergence that could occur when MCP commands wrote scenes, scripts, shaders, or resources while Godot still had them open. Contributed by **[@aallnneess](https://github.com/aallnneess)** (PR #31), with the design and patch reviewed and tested locally with GitHub Copilot using GPT-5.5 xhigh. Reported and validated against the previous v1.13.x behavior. + +### Added — safety primitives in `base_command.gd` +- `guard_offline_scene_save(path)`: blocks `ResourceSaver.save(...)` to a `.tscn`/`.scn` path when that scene is currently open in the editor. Returns a structured conflict error (JSON-RPC code `-32009`) including the path, open-scenes list, and a recovery suggestion. +- `guard_text_resource_write(path, force)`: blocks writes to a script/shader file that is currently open in Godot's script editor (or, for shaders, currently loaded/cached in `ResourceLoader`). Override with `force=true`. +- `add_child_with_undo(...)` / `set_property_with_undo(...)`: register live scene mutations through `EditorUndoRedoManager` with correct `add_do_reference` / `add_undo_reference` retention for `Resource` values so they survive GC across the undo history. +- `get_open_scene_paths()` / `is_scene_path_open()` / `is_active_scene_path()` / `is_text_resource_open_in_script_editor()`: shared checks so per-command code never re-implements the same logic. +- `normalize_project_path()`: consistent comparison key for `res://`, project-relative, and absolute paths. + +### Changed — scene saves go through `EditorInterface` +- `save_scene`: when the target path matches the active edited scene, calls `EditorInterface.save_scene()`; when the target differs or the active scene has no path yet, calls `EditorInterface.save_scene_as(path)`. Refuses to save an inactive open scene tab. No more silent `ResourceSaver.save` to an open path. +- `create_scene` / `create_theme` / `edit_resource`: all guarded against accidentally targeting an open scene path. + +### Changed — broad cross-scene edits are opt-in +- **`cross_scene_set_property` defaults to `dry_run=true`** (breaking change). Real writes now require `dry_run=false` **and** `force=true`. The response includes a per-scene `mode` field (`dry_run` / `offline_saved` / `live_open_scene`) and a `skipped_open_scenes` list so callers can see exactly what happened. +- The active open scene is live-edited via `EditorUndoRedoManager` instead of being offline-overwritten, so changes are visible in the editor and undoable. +- Inactive open scenes are skipped and reported rather than silently overwritten. + +### Changed — live scene mutations participate in UndoRedo +- `batch_add_nodes`, `batch_set_property`: routed through the shared UndoRedo helpers. +- `node_commands`: node creation, `set_anchor_preset` (computed on a duplicate Control before applying), signal connect/disconnect, group add/remove — all undoable. +- `animation_commands`: animation create/remove, track add, and keyframe edits. `_upsert_animation_key` / `_restore_animation_key` give round-trip undo for keyframe edits (including the previously-present-key replacement case) using `is_equal_approx` time matching. +- `animation_tree_commands`: AnimationTree create, state machine state/transition edits, blend tree node changes, tree parameter edits. +- `tilemap_commands`: single-cell set, rect fill, and clear capture the affected cells' old state and apply via UndoRedo. Round-trip undoable. +- `theme_commands`: color, constant, font-size, and stylebox theme overrides; `setup_control` computes target state on a duplicate Control before applying. +- `audio_commands`, `navigation_commands`, `particle_commands`: node creation and resource assignment go through UndoRedo. Navigation bake also marks the active scene unsaved. +- `shader_commands`: shader material assignment via `set_property_with_undo`. + +### Fixed — open script/shader writes +- `create_script` / `edit_script`: refuse to write when the target is open in the script editor, unless `force=true` is explicitly passed. Also restricted to `.gd` / `.cs` extensions — scene and shader paths are rejected with a clear suggestion (added in `6d8d650`). +- `edit_script` now actually implements the 1-based inclusive `start_line` / `end_line` range replacement that the CLI had been advertising. The TypeScript `edit_script` schema and CLI `script edit` both expose the new parameters. +- `create_shader` / `edit_shader`: same open-file guard, with `force=true` to override. +- Shader cache refresh fixed: `_refresh_loaded_shader` uses `take_over_path()` + `emit_changed()` to update any cached/live `Shader` resource, replacing the unreliable `Shader.reload_from_file()` call. Live materials referencing the shader now pick up edits immediately. + +### Fixed — `execute_editor_script` escape hatch +- Submitted code is scanned for direct file/resource write APIs (`ResourceSaver.save`, `FileAccess WRITE`, `ProjectSettings.save`, `ConfigFile.save`, `DirAccess` filesystem mutations). If present, the call is refused with a structured conflict error unless `allow_unsafe_editor_io=true` is explicitly passed. Closes the obvious workaround where an AI client could route around the per-command guards by submitting raw script. + +### Changed — server schemas +- `create_script` / `edit_script` / `create_shader` / `edit_shader`: added optional `force` parameter and updated tool descriptions. +- `cross_scene_set_property`: added optional `dry_run` / `force` parameters and a description that reflects the new dry-run-by-default semantics. +- `execute_editor_script`: added optional `allow_unsafe_editor_io` parameter. +- `cli.ts`: `script create` and `script edit` accept `--force` flag. + +### Migration notes +- **Breaking**: scripts/agents that previously relied on `cross_scene_set_property` writing on first call now need to add `dry_run=false force=true` to perform writes. Without those, the call returns a dry-run preview. This is intentional — silent project-wide writes were a footgun. +- **Soft-breaking**: scripts/agents that previously overwrote open files (scenes, scripts, shaders) without checking now hit a `-32009` conflict error. The fix is to either close the file in the editor first, save through the editor (for scenes), or pass `force=true` (for scripts/shaders, when you've verified no buffer holds unsaved changes). +- Existing wire-compatible calls that did *not* target open resources continue to work unchanged. + +--- + +## v1.13.2 — 2026-05-13 + +**Bug Fix** — Port allocation race when multiple Claude Code sessions start at the same time + +### Fixed +- **Parallel-session port collision** (Discord report by CrusherEAGLE): Two Claude Code sessions starting nearly simultaneously could both pre-check port 6505 as free, both attempt to bind, the loser would get `EADDRINUSE` and give up without retrying the next port. The session that lost the race had a server that never started, so every tool call failed for the remainder of that session with no way to recover short of restart. The fallback path in `index.ts` claimed it would "retry on first command" but no such retry existed. +- The fix replaces the racy pre-check + single bind with a proper bind-retry loop: each port in `6505–6509` is tried in turn, and `EADDRINUSE` triggers a fall-through to the next port. Only when the entire range is exhausted does `connect()` reject, with a clear error message and remediation hint. +- Cleaned up the misleading "will retry on first command" log line in `index.ts`. + +### Tests +- New `tests/godot-connection.test.ts` covers: first-port allocation, sequential fall-through, **simultaneous parallel connects** (the exact regression scenario), range exhaustion, and `fixedPort=true` fail-fast behavior. 5 new tests, 62 total. + +--- + +## v1.13.1 — 2026-05-12 + +**Bug Fix** — Silent disconnect / dead-connection recovery + +### Fixed +- **Heartbeat now actually detects dead connections** (Discord report by CrusherEAGLE): The `ping`/`pong` heartbeat was being sent every 10s but neither side tracked whether responses were arriving, so a half-open TCP connection (common on Windows after sleep/wake, VPN toggle, or a brief editor hang) left both sides holding a dead socket. `isConnected()` continued to return `true`, every command timed out at 30s, and the only way back was to restart Claude Code **and** the Godot editor. Fixed on both sides: + - **Server**: tracks the last `pong` timestamp; if 30s passes with no pong, forcibly destroys the socket (`terminate()`, vs `close()` which waits for a FIN ack that never comes on a dead link). Pending requests are rejected immediately rather than hanging for 30s. + - **Editor**: sends its own `ping` every 5s, tracks per-port inactivity, and after 30s of inbound silence force-closes the peer so the existing 3s reconnect cycle takes over. + - **OS-level TCP keepalive** enabled on the server socket (5s initial delay), surfacing half-open links faster than Windows' ~2-hour default. +- **Status panel surfaces stale state**: New yellow ⚠ indicator when a port is reconnecting from a stale state, plus per-port idle time (seconds since last received message) in the Clients tab. No more "looks fine while everything is broken" UI. + +### Notes +- Recovery is automatic within ~30s after the connection dies. Watch the Output panel for `[MCP] Port NNNN silent for X.Xs — forcing reconnect` if you want to see it happen. +- No API or tool changes — same 172 tools, same behavior in the healthy path. + +--- + +## v1.13.0 — 2026-05-05 + +**Bug Fixes & Polish** — Mouse motion dispatch, setup config, site pricing + +### Fixed +- **`simulate_mouse_move` honors explicit `unhandled: false`** (#24, #25): Drag motions (`button_mask > 0`) auto-promote to `push_input` so camera-pan use cases bypass GUI consumption. But callers writing UI drag-and-drop tests need events to reach the GUI dispatcher (so `_get_drag_data` / `_drop_data` fire). Now: if the caller explicitly passes `unhandled: false`, that wins; auto-promotion only happens when `unhandled` was omitted. Default behavior preserved. +- **v1.12.0 build blocker**: Restored missing `mcp/server/src/utils/load-instructions.ts` referenced by `index.ts` since v1.12.0. Fresh clones of v1.12.0 failed `npm run build` with `Cannot find module './utils/load-instructions.js'`. v1.13.0 now builds clean. + +### Changed +- **`setup` no longer pins `GODOT_MCP_PORT`** (#27): Generated MCP client config (Claude Desktop, Cursor, etc.) omits the `GODOT_MCP_PORT` env var so the server can auto-scan ports `6505–6509`. Pinning a fixed port caused silent failures when a stale process held the port. Users who need a fixed port can still set it manually. +- **Site JSON-LD price → $15** (#26): Structured data on the landing page now reflects the current price. + +### Improved +- **README clarity** (issue #7 follow-up): More prominent note that the public repo ships the addon only — the MCP server is distributed via Buy Me a Coffee / itch.io. +- **`build-release.sh` portability**: Falls back to system `zip` and `python3` when 7-Zip is not on PATH. + +--- + +## v1.12.0 — 2026-04-19 + +**Feature** — Android Remote Deploy · **Bug Fix** — Runtime IPC on custom user dirs + +### Added +- **Android Remote Deploy** (3 tools, Full mode only — #20): + - `list_android_devices` — wraps `adb devices -l`, returns serial/state/model/product. Resolves adb from Editor Settings > Export > Android > Adb, falling back to `adb` on PATH. + - `get_android_preset_info` — reads metadata (package name, export path, runnable) from an Android preset in `export_presets.cfg`. + - `deploy_to_android` — one-shot pipeline: Godot CLI export → `adb install -r` → `adb shell monkey` launch. Options: `preset_name`, `device_serial`, `debug`, `launch`, `skip_export`. Synchronous; export step can take tens of seconds. +- Full mode tool count: **169 → 172**. LITE / 3D / MINIMAL modes are unchanged. + +### Fixed +- **`get_game_user_dir()`** (`commands/base_command.gd`) — #21: Runtime IPC commands (`get_game_scene_tree`, `get_game_node_properties`, `simulate_*`, etc.) failed with `Could not create game request file` when the project used `application/config/use_custom_user_dir=true`, or when `application/config/name` contained characters illegal on the host OS (e.g. `:` on Windows). Editor and game now resolve to the same dir: early-return `OS.get_user_data_dir()` for custom user dirs, and sanitize `config/name` via `xml_unescape().validate_filename().replace(".", "_")` — matching Godot's own logic in `ProjectSettings::_init`. Thanks @asim9834 for the detailed repro + patch. + +--- + +## v1.11.0 — 2026-04-15 + +**Feature** — New `--3d` mode for 100-tool-limit clients + +### Added +- **`--3d` mode**: Registers exactly 100 tools — the 81 core LITE tools plus Physics (6), AnimationTree (8), and Navigation (5). Designed for clients with a 100-tool cap (e.g. Google Antigravity with Claude Code proxy) that need full 3D game development capabilities. Usage: `node build/index.js --3d` + +### Improved +- **Troubleshooting docs**: Clarified port conflict advice in INSTALL.md — recommends letting the server auto-scan ports 6505–6509 instead of setting a fixed `GODOT_MCP_PORT`, which can cause silent failures with stale processes + +### Mode comparison + +| Flag | Tools | Use case | +|------|-------|----------| +| *(none)* | 169 | Full mode — all tools | +| `--3d` | 100 | 3D game dev under 100-tool limit | +| `--lite` | 81 | Tight tool limits (Cursor, etc.) | +| `--minimal` | 35 | Ultra-tight limits (local LLMs) | + +--- + +## v1.10.3 — 2026-04-11 + +**Bug Fixes** — Autoload preservation, Windows build, port conflict warning + +### Fixed +- **Autoload deletion on `--import` / shutdown**: Plugin no longer removes pre-existing MCP autoloads from `project.godot`. Previously, `_remove_autoloads()` deleted all managed autoload keys unconditionally — even if they were project-owned. Now only autoloads injected by the current plugin session are removed. (#17) +- **Windows build failure**: Removed Unix-only `chmod -R a+x build || true` from the `build` script in `package.json`. The `chmod` and `true` commands don't exist on Windows cmd/PowerShell, causing `node build/setup.js install` to fail with "Build failed" even though TypeScript compilation succeeded. The fix is simply `"build": "tsc"` — execute permissions are not needed since the server runs via `node`. (#Discord) + +### Improved +- **Port conflict warning for explicit port**: When `GODOT_MCP_PORT` is set and the port is already occupied (e.g. by a stale process), the server now logs a clear warning with remediation steps instead of silently failing to bind. (#15) + +--- + +## v1.10.2 — 2026-04-11 + +**Fix** — Linux permission issue for build files + +### Fixed +- **Linux permission denied**: Added `chmod -R a+x` to build process so that `build/index.js` and other compiled files have execute permission out of the box on Linux/macOS. Previously, users had to manually run `chmod -R a+x build` after installation. (Thanks to kflamsted for reporting!) + +--- + +## v1.10.1 — 2026-04-08 + +**UX Improvement** — Bottom panel renamed, INSTALL.md rewritten + +### Improved +- **Bottom panel renamed**: "MCP Server" → "MCP Pro" for consistency with the product name. Status label also updated. +- **INSTALL.md rewritten**: Added zip structure diagram, clear separation of addon vs server, Claude Desktop config paths, and better troubleshooting. Clarified that `configure` must be run from the Godot project directory. + +--- + +## v1.10.0 — 2026-04-07 + +**New Tools & Quality Sweep** — Editor camera control, 169 tools, comprehensive audit fixes + +### New Tools +- **`get_editor_camera`**: Get the 3D editor viewport camera position, rotation, and FOV. Useful for understanding the current view before taking screenshots. +- **`set_editor_camera`**: Move the 3D editor viewport camera to a specific position and orientation. Supports position, rotation, look_at target, and FOV. Use this to frame a view before screenshots to validate changes visually. + +### Fixed +- **`plugin.gd` version display**: Was hardcoded to "v1.6.0" since initial release. Now dynamically reads from `plugin.cfg` — always shows the correct version. +- **Tool count inconsistency**: Was showing 162/163/167 across different files. All references now correctly say 169. +- **`node setup.js` path**: All docs and help text now correctly say `node build/setup.js`. +- **`configure` cwd issue**: INSTALL.md now clearly separates `install` (run from server/) and `configure` (run from Godot project root) to avoid `.mcp.json` being placed in the wrong directory. +- **INSTALL.md**: Fixed step numbering skip, stale tool count (49→169), port range (6505-6514). +- **README.md**: Replaced hardcoded dev paths with `/path/to/` placeholders. + +### Improved +- **"v1.x" wording removed**: All pricing and marketing text now says "lifetime updates" without version scope. +- **Plugin port range**: WebSocket comment and connection range expanded to 6505-6514 (6510-6514 reserved for CLI). +- **Pre-built JS in release zip**: `build/setup.js` and `build/cli.js` work immediately after extract + `npm install`. +- **Claude Desktop support**: Confirmed working, added to configure auto-detection. + +--- + +## v1.9.4 — 2026-04-06 + +**Bug Fixes** — State enum type regression, zip plugin version + +### Fixed +- **`mcp_game_inspector_service.gd` State enum type error (regression)**: `var _state: State = State.IDLE` caused "Cannot assign a value of type mcp_game_inspector_service.gd.State to variable with specified type State" in some Godot versions. Changed back to `var _state := State.IDLE` (type inference). This was originally fixed in v1.6.4 but regressed. (Thanks @kalish) +- **Release zip contained wrong plugin version**: v1.9.3 zip shipped with plugin.cfg showing v1.9.2 due to a build order issue. Fixed build pipeline to ensure public repo is synced before zip creation. + +--- + +## v1.9.3 — 2026-04-06 + +**Improvement** — Pre-built JS in release zip, docs cleanup + +### Improved +- **Pre-built JS files included in release zip**: `build/setup.js`, `build/cli.js`, and all other compiled files are now included. Users can run `node build/setup.js install` immediately after extracting — no need to manually run `npm run build` first. +- **CLI naming unified in docs**: Removed `godot-cli` shorthand. All docs consistently use `node build/cli.js`. Added note that server must be built before CLI use. +- **CLI help port range fixed**: `--help` output now correctly shows 6510-6514 (CLI range), not 6505-6509 (MCP server range). + +--- + +## v1.9.2 — 2026-04-06 + +**New Features** — Setup CLI, code-to-inspector workflow, CLI click fix + +### New Features +- **Setup CLI (`setup.js`)**: One-command server setup and management. Commands: `install` (npm install + build), `check-update` (GitHub release check with semver comparison), `configure` (auto-detect AI client and generate .mcp.json), `doctor` (environment diagnostics). +- **Code-to-inspector migration workflow**: New guideline in AGENTS.md and skills.md instructing AI to prefer `update_property` over hardcoded GDScript for visual properties (colors, sizes, theme overrides). Includes step-by-step migration pattern. + +### Fixed +- **CLI `input click --button` mapping**: The CLI sent string values ("left", "right", "middle") but the plugin expects numeric indices (1, 2, 3). Now correctly maps `left`→1, `right`→2, `middle`→3. (Thanks @Gogomy) + +### Improved +- **INSTALL.md**: Added quick setup flow using `setup.js` for both fresh install and updates. +- **Instruction files**: All 12 client instruction files updated with new workflow patterns. + +--- + +## v1.9.1 — 2026-04-05 + +**Bug Fix** — GODOT_MCP_PORT env var now respected + Cursor Full mode + +### Fixed +- **`GODOT_MCP_PORT` env var ignored**: The server always scanned ports 6505-6509 for the first free port, ignoring the explicitly configured port. Now when `GODOT_MCP_PORT` is set, the server uses that port directly without scanning. (Fixes #13) + +### Changed +- **Cursor moved to Full mode**: Cursor removed its 40-tool limit with Dynamic Context Discovery — all 167 tools now work in Full mode. (Thanks to @CrossBread for PR #14) + +--- + +## v1.9.0 — 2026-04-05 + +**Universal Compatibility** — Minimal mode, CLI tool, and test suite + +### New Features +- **Minimal mode (`--minimal`)**: Registers only 35 essential tools for clients with tight tool limits (Cursor ~40, OpenCode, local LLMs with small context windows). Covers project info, scene management, node CRUD, script editing, editor errors, input simulation, and runtime inspection. +- **CLI tool (`godot-cli`)**: Command-line interface for controlling Godot directly from a terminal. LLMs discover capabilities progressively via `--help` instead of loading all tool definitions upfront — zero context overhead, works with any client that has bash/terminal access. 7 command groups: project, scene, node, script, editor, input, runtime. +- **Test suite**: Added vitest with 47 unit tests covering tool-filter, error utilities, zod coercion, and CLI help/error handling. + +### Improved +- **Client compatibility guide**: README and landing page now include a compatibility matrix for 12+ MCP clients with recommended mode for each (Full/Lite/Minimal/CLI). +- **Landing page**: Added "Choose Your Mode" setup step, CLI documentation, and new FAQ entry for tool count limits. +- **`print_verbose` for connect/disconnect**: WebSocket connect/disconnect messages in the Godot plugin now use `print_verbose()` instead of `print()`, eliminating terminal spam during normal operation. +- **Per-client instruction files**: `instructions/` folder with ready-to-copy instruction files for 12 AI clients (Claude Code, Cursor, Cline, Windsurf, Gemini CLI, Codex CLI, OpenCode, Roo Code, JetBrains/Junie, Amazon Q, Continue, Augment). Includes CLI usage documentation. + +--- + +## v1.8.1 — 2026-04-04 + +**Bug Fix** — @export node reference support in update_property + +### Fixed +- **`update_property` @export node references**: Setting `@export var` node references (e.g. `@export var hud: HUD`) via `update_property` now correctly resolves string paths to actual node references. Previously, `typeof(old_value)` returned `TYPE_NIL` for unset exports and `TYPE_OBJECT` for set ones, neither of which resolved the path string to a node. The fix checks `PROPERTY_HINT_NODE_TYPE` from the property's metadata to detect node reference exports and resolve accordingly. (Fixes #12) + +--- + +## v1.8.0 — 2026-04-02 + +**New Features** — HTTP transport, screenshot file saving, custom class support + +### New Features +- **Streamable HTTP transport**: New `--http` and `--http-port` flags for MCP clients that need HTTP instead of stdio. Starts an HTTP server at `http://127.0.0.1:8001/mcp` (default port). +- **Screenshot `save_path` option**: `get_editor_screenshot` and `get_game_screenshot` now accept an optional `save_path` parameter (e.g. `res://screenshot.png`) to save directly to disk instead of returning base64, avoiding MCP response cache bloat. +- **`add_node` custom class support**: `add_node` now resolves script-defined classes (`class_name`) in addition to built-in ClassDB types via `ProjectSettings.get_global_class_list()`. + +### Improved +- **INSTALL.md**: Added "Updating to a New Version" section with step-by-step upgrade instructions. + +--- + +## v1.7.2 — 2026-03-31 + +**Bug Fixes & Improvements** — execute_game_script robustness + auto-dismiss control + +### Fixed +- **`execute_game_script` mixed indentation error**: User code with space indentation was prepended with tabs, causing "Mixed use of tabs and spaces" parse errors. Now auto-detects indent width and normalizes all leading spaces to tabs before wrapping. +- **`execute_game_script` standalone lambda error**: Top-level `func` definitions in user code were nested inside the wrapper's `run()` function, triggering "Standalone lambdas cannot be accessed" parse errors. Now extracts top-level functions to class level. +- **`command_router` crash on missing config section**: `_load_tool_config()` called `get_section_keys("disabled_tools")` without checking if the section exists, causing "Cannot get keys from nonexistent section" errors on fresh installs. + +### Changed +- **Auto-dismiss dialogs now opt-in**: Previously auto-dismissed blocking editor dialogs whenever an MCP client was connected. Now disabled by default — AI must explicitly enable via the new `set_auto_dismiss` tool before operations that trigger reload/save dialogs. + +### New Tools +- **`set_auto_dismiss`**: Enable or disable automatic dismissal of blocking editor dialogs (e.g., "Reload from disk?", "Save changes?"). Use before external file modifications, disable when done. + +--- + +## v1.7.1 — 2026-03-30 + +**Bug Fixes** — Scene transition crash fix and deprecated API cleanup + +### Fixed +- **`click_button_by_text` crash on scene transition**: Clicking a button that triggers a scene change (e.g., navigating from main menu to options) caused "Cannot get path of node as it is not in a scene tree" errors. Now caches button info before emitting the pressed signal and guards with `is_instance_valid()` / `is_inside_tree()` after the click. +- **Deprecated `push_unhandled_input()` warning**: Replaced with `push_input()` in `mcp_input_service.gd` per Godot 4.x API updates. + +--- + +## v1.7.0 — 2026-03-29 + +**New Tools & Multi-Client Support** — 3 new tools for faster scene building, runtime signal debugging, and UI layout + instructions for non-Claude AI clients + +### New Tools +- **`batch_add_nodes`**: Add multiple nodes in a single call. Nodes are processed in order so earlier nodes can be referenced as parents — build entire node trees in one shot instead of calling `add_node` repeatedly. +- **`watch_signals`**: Monitor signal emissions on specified nodes in the running game for a set duration. Returns a timestamped log of every signal fired with arguments — great for debugging event flow and verifying signal connections. +- **`setup_control`**: Configure a Control/Container node's layout in one call: anchor preset, min size, size flags, margins (MarginContainer), separation (VBox/HBoxContainer), and grow direction. Replaces 5+ `update_property` calls. + +### New +- **`AGENTS.md` template**: Custom instructions for non-Claude AI clients (OpenAI Codex, opencode/ollama, Cursor, etc.). Includes editor vs runtime tool categorization, workflow patterns, formatting rules, and common pitfalls. Included in release zip. + +--- + +## v1.6.5 — 2026-03-27 + +**assert_node_state Fix** — Game-side handler was missing, causing "Unknown command" error + +### Fixed +- **`assert_node_state` missing game-side handler**: The command was registered in the TypeScript server and editor-side GDScript, but `mcp_game_inspector_service.gd` had no handler — returning "Unknown command" at runtime. This also broke node assertions within `run_test_scenario`. All 8 operators (eq, neq, gt, lt, gte, lte, contains, type_is) now work correctly. +- **Sub-property access in assertions**: Properties like `position:y` now use `get_indexed()` instead of `get()`, enabling assertions on vector components and nested properties. + +--- + +## v1.6.4 — 2026-03-25 + +**Enum Type Fix** — Fixes script error on play in certain Godot versions + +### Fixed +- **`mcp_game_inspector_service.gd` State enum type error**: Explicit `State` type annotation on `_state` variable caused "Cannot assign a value of type mcp_game_inspector_service.gd.State to variable with specified type State" errors in some Godot versions. Changed to type inference (`:=`) which resolves the mismatch. + +--- + +## v1.6.3 — 2026-03-24 + +**Camera Pan Fix** — Mouse drag events now bypass GUI layer to reach `_unhandled_input()` + +### Fixed +- **Mouse drag not reaching `_unhandled_input()`**: `simulate_mouse_move` with `button_mask` (drag simulation) was consumed by GUI Controls (`mouse_filter=STOP`) before reaching `_unhandled_input()`. Camera pan, drag-to-select, and other drag-based mechanics that rely on `_unhandled_input()` now work correctly. Events with `button_mask > 0` automatically use `push_unhandled_input()` to bypass the GUI layer. + +### New +- **`simulate_mouse_move` `unhandled` parameter**: Optional `unhandled` flag to force any mouse motion event to bypass GUI and go directly to `_unhandled_input()`. Auto-enabled when `button_mask > 0`. +- **`simulate_sequence` `unhandled` support**: Sequence `mouse_motion` events also support the `unhandled` flag. + +--- + +## v1.6.2 — 2026-03-24 + +**Animation Easing & Mouse Drag Simulation** — Community-requested fixes + +### New +- **`set_animation_keyframe` easing parameter**: Optional `easing` param (default 1.0) to control keyframe transition curves. Values: 1.0=linear, <1.0=ease-in, >1.0=ease-out, negative=in-out variants. +- **`get_animation_info` easing field**: Each keyframe now returns its `easing` value. +- **`simulate_mouse_move` button_mask**: New `button_mask` parameter (1=left, 2=right, 4=middle) enables drag simulation. Required for games that check `InputEventMouseMotion.button_mask` (e.g. camera pan with mouse drag). +- **`simulate_sequence` button_mask**: Sequence events also support `button_mask` for drag operations. + +### Fixed +- **Mouse sequence events**: `simulate_sequence` now correctly handles flat key format (`relative_x`, `relative_y`, `x`, `y`) in addition to nested format. Previously, mouse motion events in sequences had `relative=(0,0)` because the flat-to-nested conversion was missing. + +--- + +## v1.6.1 — 2026-03-21 + +**Permission Presets** — Auto-approve tool permissions for Claude Code + +### New +- **`settings.local.json`** (conservative): Pre-configured permission file that auto-approves 152 of 163 tools. Destructive tools (`delete_node`, `delete_scene`, `execute_editor_script`, etc.) still require manual approval. +- **`settings.local.permissive.json`**: Allows all 163 tools and all Bash commands, with an explicit deny list for dangerous shell commands (`rm -rf`, `git push --force`, `git reset --hard`, etc.) and destructive MCP tools. +- Copy either file to `~/.claude/settings.local.json` to skip per-tool permission prompts. + +--- + +## v1.6.0 — 2026-03-21 + +**Enhanced Editor Panel** — Activity log with response details, client monitor, and tool management + +### New +- **Activity tab**: Full command log showing method name, status, port, and timestamp. Toggle "Show Response Details" to inspect the JSON responses sent back to AI clients. Clear button to reset the log. +- **Clients tab**: Real-time view of all 5 WebSocket ports (6505-6509) with connection status and elapsed time since connection. +- **Tools tab**: Searchable list of all 163 tools with individual enable/disable checkboxes. Bulk "Enable All" / "Disable All" buttons. Disabled tools are persisted across sessions (`user://mcp_tool_config.cfg`) and return a clear error message to AI clients. + +### Changed +- Status panel rebuilt with TabContainer (Activity / Clients / Tools) +- WebSocket server now emits `command_completed` signal with full response data and source port +- Connection time tracking per port for uptime display + +--- + +## v1.5.3 — 2026-03-15 + +**New tool** — `record_frames` for long-running debug observation + +### New +- **`record_frames`**: Capture up to 600 screenshots saved as PNG files to `user://mcp_recorded_frames/`. Unlike `capture_frames` (which returns base64 images directly, max 30), this tool saves to disk and returns file paths — ideal for long-running debug sessions without flooding the AI context with image data. Supports optional `node_data` tracking for per-frame property snapshots (position, velocity, etc.). + +--- + +## v1.5.2 — 2026-03-13 + +**Bugfix** — Screenshot capture now works when the SceneTree is paused + +### Fixed +- **`mcp_screenshot_service.gd`**: Added `process_mode = Node.PROCESS_MODE_ALWAYS` so the file-polling loop in `_process()` keeps running during pause. The other two autoloads (`mcp_input_service.gd`, `mcp_game_inspector_service.gd`) already had this — screenshot service was the only one missing it. +- **`mcp_screenshot_service.gd`**: Replaced `await get_tree().process_frame` with `await get_tree().create_timer(0.05).timeout` — `process_frame` never fires when the tree is paused, but `create_timer()` with default `process_always=true` does. + +Thanks to **mrkielbasa** for reporting this bug! + +--- + +## v1.5.1 — 2026-03-08 + +**Patch release** — AI Skills file for better out-of-the-box experience + +### New +- **`skills.md`**: Added `addons/godot_mcp/skills.md` — a comprehensive guide for AI assistants covering all 162 tools, 10 practical workflows, best practices, and common pitfalls. Users can copy this to `.claude/skills.md` in their project root so Claude Code knows how to use the MCP tools effectively from the start. +- **README**: Added setup step for copying `skills.md` to `.claude/skills.md`. + +--- + +## v1.5.0 — 2026-03-04 + +**Feature** — Lite mode for MCP clients with tool count limits + +### New Features +- **Lite mode (`--lite`)**: Launch with `--lite` flag to register only 76 core tools instead of 162. Designed for MCP clients with tool count limits (Windsurf: 100, Cursor: ~40, Antigravity: 100). + - Core categories (always loaded): project, scene, node, script, editor, input, runtime, input_map + - Extended categories (Full mode only): animation, animation_tree, audio, batch, export, navigation, particle, physics, profiling, resource, scene_3d, shader, test, theme, tilemap, analysis + - Usage: Add `"--lite"` to args in your MCP config + +--- + +## v1.4.5 — 2026-03-04 + +**Patch release** — Godot 4.3 compatibility fix + +### Bug Fixes +- **Godot 4.3 compatibility**: Fixed `scene_3d_commands.gd` parse error caused by `Environment.TONE_MAPPER_AGX` enum (added in Godot 4.4). Now uses integer value for backward compatibility. This was a blocking error that prevented the entire plugin from loading on Godot 4.3. + +--- + +## v1.4.4 — 2026-03-04 + +**Patch release** — Revert Output panel filter expansion + +### Bug Fixes +- **`get_editor_errors`**: Removed `W `, `WARN`, `GDScript` Output panel filters added in v1.4.3 — these patterns don't actually appear in Godot's Output panel (`push_warning` uses `WARNING:` prefix) and caused false positives on normal text. + +--- + +## v1.4.3 — 2026-03-04 + +**Patch release** — Comprehensive error/warning detection + +### Improvements +- **`get_editor_errors`**: Now reads runtime errors from the debugger Errors tab (ScriptEditorDebugger), returned with `DEBUGGER:` prefix including stack traces. Previously only static analysis and Output panel errors were captured. + +### Bug Fixes +- **`get_editor_errors`**: Fixed debugger Errors tab not being found because the tab name includes a count suffix (e.g. "Errors (1)") — now uses prefix matching. + +--- + +## v1.4.2 — 2026-03-04 + +**Patch release** — Improved error detection in script editor + +### Improvements +- **`get_editor_errors`**: Now reads GDScript analyzer errors and warnings from the script editor's error/warning panels (VSplitContainer RichTextLabels), in addition to the Output panel and CodeEdit line highlights. Catches static analysis messages like type mismatches and autoload name conflicts that were previously missed. + +--- + +## v1.4.1 — 2026-03-02 + +**Patch release** — Bug fixes found during comprehensive tool audit + +### Bug Fixes +- **`replay_recording`**: Fixed false crash recovery error (`_pending_command` flag not cleared for async replay loop) +- **`wait_for_node`**: Fixed false crash recovery error when polling for node appearance +- **`apply_particle_preset`**: Fixed editor crash in `gl_compatibility` renderer caused by immediate `GradientTexture1D` assignment — now uses `set_deferred` and reduced texture width + +--- + +## v1.4.0 — 2026-03-01 + +**162 tools** across 23 categories (+15 new tools) + +### New Tools +- **`move_to`** — Autopilot: automatically walk a character to target coordinates using pathfinding +- **`navigate_to`** — High-level navigation command for AI-driven movement +- **`find_nearby_nodes`** — Find nodes within a radius of a given position +- **`get_node_groups`** / **`set_node_groups`** — Read and write node group memberships +- **`find_nodes_in_group`** — Query all nodes belonging to a specific group +- **`get_output_log`** — Retrieve Godot's Output panel contents +- **`get_input_actions`** / **`set_input_action`** — Read and configure Input Map actions +- **`search_in_files`** — Full-text search across project files +- **`validate_script`** — Check GDScript for errors without running +- **`get_resource_preview`** — Get thumbnail previews of resources +- **`get_scene_exports`** — List exported variables in a scene's root script +- **`add_autoload`** / **`remove_autoload`** — Manage autoload singletons + +### Bug Fixes & Improvements +- **Crash recovery**: `capture_frames` no longer triggers false crash recovery (`_pending_command` flag fix) +- **`capture_frames` node_data**: Optional per-frame property snapshots via `node_data` parameter +- **Debugger auto-continue**: Automatically presses Continue when runtime errors pause the debugger +- **`simulate_key` duration**: Now accepts fractional seconds (e.g., 0.3s) for precise movement +- **Command router fix**: All 8 command classes now properly registered (~47 tools were previously unreachable) + +--- + +## v1.3.1 — 2026-02-27 + +**Patch release** + +### Bug Fixes +- **`get_editor_errors`**: Now reads from Output panel and CodeEdit error gutter (previously returned empty results) +- **Tonemap enum**: Fixed environment tonemap mode enum name mapping + +--- + +## v1.3.0 — 2026-02-26 + +**147 tools** across 23 categories (+63 new tools) + +### New Tool Categories + +#### AnimationTree & State Machine (8 tools) +- `create_animation_tree`, `get_animation_tree_structure`, `set_tree_parameter` +- `add_state_machine_state`, `remove_state_machine_state` +- `add_state_machine_transition`, `remove_state_machine_transition` +- `set_blend_tree_node` + +#### Physics & Collision (6 tools) +- `setup_collision`, `setup_physics_body`, `get_collision_info` +- `get_physics_layers`, `set_physics_layers`, `add_raycast` + +#### 3D Scene (6 tools) +- `add_mesh_instance`, `setup_lighting`, `set_material_3d` +- `setup_environment`, `setup_camera_3d`, `add_gridmap` + +#### Particles (5 tools) +- `create_particles`, `set_particle_material`, `set_particle_color_gradient` +- `get_particle_info`, `apply_particle_preset` (8 built-in presets: fire, smoke, sparks, rain, snow, explosion, magic, dust) + +#### Navigation (5 tools) +- `setup_navigation_region`, `bake_navigation_mesh`, `setup_navigation_agent` +- `set_navigation_layers`, `get_navigation_info` + +#### Audio (6 tools) +- `get_audio_bus_layout`, `add_audio_bus`, `set_audio_bus` +- `add_audio_bus_effect`, `add_audio_player`, `get_audio_info` + +#### Testing & QA (5 tools) +- `run_test_scenario`, `assert_node_state`, `assert_screen_text` +- `run_stress_test`, `get_test_report` + +#### Project Analysis (6 tools) +- `find_unused_resources`, `analyze_signal_flow`, `analyze_scene_complexity` +- `detect_circular_dependencies`, `get_scene_dependencies`, `get_project_statistics` + +### Expanded: Runtime Analysis +- `find_ui_elements`, `click_button_by_text`, `wait_for_node` +- Runtime tools expanded from 4 to 15 tools + +### Other Additions +- `add_resource`, `create_resource`, `edit_resource`, `read_resource` — Resource management tools + +--- + +## v1.2.0 — 2026-02-24 + +**84 tools** across 14 categories (+34 new tools) + +### New Tool Categories + +#### Animation (6 tools) +- `list_animations`, `create_animation`, `add_animation_track` +- `set_animation_keyframe`, `get_animation_info`, `remove_animation` + +#### TileMap (6 tools) +- `tilemap_set_cell`, `tilemap_fill_rect`, `tilemap_get_cell` +- `tilemap_clear`, `tilemap_get_info`, `tilemap_get_used_cells` + +#### Theme & UI (6 tools) +- `create_theme`, `set_theme_color`, `set_theme_constant` +- `set_theme_font_size`, `set_theme_stylebox`, `get_theme_info` + +#### Profiling (2 tools) +- `get_performance_monitors`, `get_editor_performance` + +#### Batch Operations & Refactoring (5 tools) +- `find_nodes_by_type`, `find_signal_connections`, `batch_set_property` +- `find_node_references`, `get_scene_dependencies` + +#### Shader (6 tools) +- `create_shader`, `read_shader`, `edit_shader` +- `assign_shader_material`, `set_shader_param`, `get_shader_params` + +#### Export (3 tools) +- `list_export_presets`, `export_project`, `get_export_info` + +### Bug Fixes +- Fixed game IPC connection when project name changes +- Added `set_project_setting` tool for safe project.godot modifications via EditorSettings API +- Fixed script reload behavior + +--- + +## v1.1.0 — 2026-02-23 + +**49 tools** across 8 categories (+16 new tools) + +### New Tool Categories + +#### Input Simulation (4 tools) +- `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_sequence` + +#### Runtime Analysis (4 tools) +- `play_scene`, `stop_scene`, `get_game_scene_tree`, `get_game_screenshot` +- `execute_game_script`, `get_game_node_properties`, `set_game_node_property` +- `monitor_properties`, `capture_frames` + +### Other +- Added `build-release.sh` for reproducible release packaging +- `start_recording` / `stop_recording` / `replay_recording` for input recording + +--- + +## v1.0.0 — 2026-02-22 + +**~33 tools** across 6 categories — Initial release + +### Tool Categories +- **Scene Management**: `create_scene`, `open_scene`, `save_scene`, `get_scene_tree`, `delete_scene`, `get_scene_file_content`, `add_scene_instance` +- **Node Operations**: `add_node`, `delete_node`, `rename_node`, `move_node`, `duplicate_node`, `update_property`, `get_node_properties`, `batch_get_properties`, `connect_signal`, `disconnect_signal`, `get_signals` +- **Script**: `create_script`, `read_script`, `edit_script`, `attach_script`, `list_scripts`, `find_nodes_by_script`, `find_script_references`, `get_open_scripts` +- **Editor**: `get_editor_screenshot`, `get_editor_errors`, `clear_output`, `execute_editor_script`, `reload_plugin` +- **Project**: `get_project_info`, `get_project_settings`, `get_filesystem_tree`, `search_files` +- **UI**: Anchor presets (`set_anchor_preset`) + +### Architecture +- WebSocket-based communication between Godot editor plugin and MCP TypeScript server +- Supports Claude Code, Cursor, Windsurf, and any MCP-compatible AI coding tool +- Screenshot capture from both editor and game viewports diff --git a/Tools/godot-mcp-pro-v1.15.1/INSTALL.md b/Tools/godot-mcp-pro-v1.15.1/INSTALL.md new file mode 100644 index 0000000..714f817 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/INSTALL.md @@ -0,0 +1,121 @@ +# Godot MCP Pro - Installation Guide + +## What's in the zip + +``` +godot-mcp-pro/ +├── addons/godot_mcp/ ← Godot plugin (copy into your Godot project) +├── server/ ← MCP server (keep anywhere, runs alongside Godot) +├── instructions/ ← AI client instruction files (optional) +├── INSTALL.md ← This file +├── README.md +├── CHANGELOG.md +└── ... +``` + +The **addon** and **server** are two separate pieces: +- **Addon** → goes inside your Godot project +- **Server** → stays wherever you extracted it (does NOT go inside Godot) + +## Step 1: Install the Godot Plugin + +Copy the `addons/godot_mcp/` folder from the zip into your Godot project's `addons/` directory. + +Enable the plugin in Godot: +**Project → Project Settings → Plugins → Godot MCP Pro → Enable** + +You should see "MCP Pro" in the bottom panel with a green connection dot. + +> **Note**: You do NOT need to download anything from the Godot Asset Library. The paid zip includes everything. + +## Step 2: Build the MCP Server + +The server requires **Node.js 18+**. Check with `node --version`. + +Open a terminal and run from the `server/` directory inside the extracted zip: + +```bash +cd /path/to/extracted/server +node build/setup.js install +``` + +This runs `npm install` (downloads dependencies) and `npm run build` (compiles TypeScript). + +You can verify everything is working with: +```bash +node build/setup.js doctor +``` + +## Step 3: Configure Your AI Client + +Run this from your **Godot project** directory (not the server directory): + +```bash +cd /path/to/your/godot-project +node /path/to/extracted/server/build/setup.js configure +``` + +This auto-detects your AI client and creates a `.mcp.json` file in your project. + +### Manual Configuration + +If you prefer to configure manually, add this to your project's `.mcp.json`: + +```json +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": ["/path/to/extracted/server/build/index.js"] + } + } +} +``` + +Replace `/path/to/extracted/` with the actual path where you extracted the zip. + +### Claude Desktop + +For Claude Desktop, add the same config to: +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` + +## Step 4: Use It + +1. Open your Godot project with the plugin enabled +2. Start your AI client (Claude Code, Cursor, Cline, etc.) in your project directory +3. Ask the AI to interact with your Godot editor + +The MCP Pro bottom panel in Godot shows connection status. A green dot means connected. + +## Updating to a New Version + +Check for updates: +```bash +node /path/to/server/build/setup.js check-update +``` + +To update: +1. Close Godot +2. Replace `addons/godot_mcp/` in your Godot project with the new version from the zip +3. Replace the `server/` folder with the new one, then rebuild: + ```bash + cd /path/to/new/server + node build/setup.js install + ``` +4. Reopen Godot + +Your `.mcp.json` configuration stays the same — no need to reconfigure. + +## Troubleshooting + +- **Plugin not connecting**: Make sure the MCP server is running (your AI client starts it automatically via `.mcp.json`) +- **"Godot editor is not connected" error**: This is usually caused by a stale `node.exe` process from a previous session holding the port. Open Task Manager, kill all `node.exe` processes, then restart your AI client. +- **Port conflict / `GODOT_MCP_PORT`**: Avoid setting a fixed `GODOT_MCP_PORT` in your config — the server auto-scans ports 6505–6509 and Godot connects to all of them automatically. A fixed port can cause silent failures if a stale process is already using it. +- **Bottom panel shows "Waiting for connection"**: Start your AI client — it launches the MCP server which connects to Godot +- **Need help?**: Contact abyo.software@gmail.com or join [Discord](https://discord.gg/zJ2u5zNUBZ) + +## Documentation + +- Landing page & tool reference: https://godot-mcp.abyo.net +- Full tool list: `README.md` diff --git a/Tools/godot-mcp-pro-v1.15.1/LICENSE b/Tools/godot-mcp-pro-v1.15.1/LICENSE new file mode 100644 index 0000000..780e0bb --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/LICENSE @@ -0,0 +1,42 @@ +Godot MCP Pro - Proprietary License + +Copyright (c) 2026 Godot MCP Pro. All rights reserved. + +This software and associated documentation files (the "Software") are the +proprietary property of the copyright holder. + +1. GRANT OF LICENSE + Upon purchase, you are granted a non-exclusive, non-transferable, + perpetual license to: + - Use the Software for personal and commercial game development projects + - Install the Software on any number of your own machines + - Use the Software in any number of your own projects + +2. RESTRICTIONS + You may NOT: + - Redistribute, sublicense, sell, or share the Software or any portion + of its source code + - Modify and redistribute the Software as a competing product + - Remove or alter any copyright notices or license information + - Share your license key or purchased files with others + +3. UPDATES + Your purchase includes lifetime access to updates for the version you + purchased (1.x). Major version upgrades (2.x, 3.x) may require a + separate purchase or upgrade fee. + +4. NO WARRANTY + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +5. TERMINATION + This license is automatically terminated if you violate any of these + restrictions. Upon termination, you must destroy all copies of the + Software in your possession. + +For questions about licensing, contact: abyo.software@gmail.com diff --git a/Tools/godot-mcp-pro-v1.15.1/README.md b/Tools/godot-mcp-pro-v1.15.1/README.md new file mode 100644 index 0000000..a0eb5bf --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/README.md @@ -0,0 +1,496 @@ +# Godot MCP Pro + +Premium MCP (Model Context Protocol) server for AI-powered Godot game development. Connects AI assistants like Claude directly to your Godot editor with **172 powerful tools**. + +## Architecture + +``` +AI Assistant ←—stdio/MCP—→ Node.js Server ←—WebSocket:6505—→ Godot Editor Plugin +``` + +- **Real-time**: WebSocket connection means instant feedback, no file polling +- **Editor Integration**: Full access to Godot's editor API, UndoRedo system, and scene tree +- **JSON-RPC 2.0**: Standard protocol with proper error codes and suggestions + +## What's in this repo + +> ⚠️ **This public repo only contains the free Godot addon/plugin.** The MCP server (Node.js, required to connect AI assistants) is distributed as part of the paid package — **one-time purchase**, lifetime updates: +> +> - **Buy Me a Coffee**: +> - **itch.io**: +> +> The paid zip includes the addon, the `server/` directory with pre-built JavaScript, `INSTALL.md`, and AI-client instructions. If you cloned this repo and don't see a `server/` folder, **that's expected** — grab the full package from one of the links above. + +## Quick Start + +### 1. Install the Godot Plugin + +Copy the `addons/godot_mcp/` folder into your Godot project's `addons/` directory. + +Enable the plugin: **Project → Project Settings → Plugins → Godot MCP Pro → Enable** + +### 2. Install the MCP Server + +> The `server/` directory is only included in the **full paid package** (see above). After downloading and extracting the zip, run: + +```bash +cd server +npm install +npm run build +``` + +### 3. Configure Claude Code + +Add to your `.mcp.json`: + +```json +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": ["/path/to/server/build/index.js"], + "env": { + "GODOT_MCP_PORT": "6505" + } + } + } +} +``` + +### 4. Choose Your Mode + +Godot MCP Pro offers four modes to fit any client's tool limit: + +| Mode | Tools | Best For | +|------|-------|----------| +| **Full** (default) | 172 | Claude Code, Cline, VS Code Copilot, Cursor | +| **3D** (`--3d`) | 100 | Antigravity and other 100-tool-limit clients needing 3D | +| **Lite** (`--lite`) | 81 | Windsurf, JetBrains Junie, Gemini CLI | +| **Minimal** (`--minimal`) | 35 | OpenCode, local LLMs with small context | + +```json +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": ["/path/to/server/build/index.js", "--lite"] + } + } +} +``` + +Replace `--lite` with `--minimal` for the smallest footprint. + +- **Lite** includes: project, scene, node, script, editor, input, runtime, and input_map tools. +- **Minimal** includes: 35 essential tools — project info, scene management, node CRUD, script editing, editor errors, input simulation, and runtime inspection. + +### 5. CLI Mode (Alternative to MCP) + +For clients without MCP support, or when you want zero context overhead, use the CLI directly from a terminal/bash tool. The CLI requires the server to be built first (Step 2). + +```bash +# Top-level help — shows all command groups +node /path/to/server/build/cli.js --help + +# Group help — shows commands in a group +node /path/to/server/build/cli.js node --help + +# Command help — shows options for a command +node /path/to/server/build/cli.js node add --help + +# Execute +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player +``` + +Replace `/path/to/` with the actual path where you extracted the files. + +The CLI connects directly to the Godot editor plugin via WebSocket. It requires: +- Godot editor running with the MCP plugin enabled +- Server built (`node build/setup.js install`) +- An available port in the 6510-6514 range + +**Advantage**: LLMs discover capabilities progressively via `--help` instead of loading all tool definitions upfront. This works with any LLM client that has terminal access, regardless of tool count limits. + +### 6. Client Compatibility + +| Client | Recommended Mode | Notes | +|--------|-----------------|-------| +| Claude Code | Full (default) | Deferred tool loading — minimal context cost | +| VS Code Copilot | Full | Virtual Tools auto-group tools | +| OpenAI Codex CLI | Full | MCPSearch defers overflow | +| Cline | Full | No hard limit; use `enabledTools` to whitelist | +| Roo Code | Full | No hard limit | +| Windsurf | Lite | 100 tool limit | +| JetBrains Junie | Lite | 100 tool limit | +| Gemini CLI | Lite | ~100 client limit; use `excludeTools` for finer control | +| Cursor | Full | Tool limit removed (Dynamic Context Discovery) | +| OpenCode | Minimal or CLI | Models degrade past ~40 tools | +| Local LLMs (LM Studio, etc.) | Minimal or CLI | Context window is the bottleneck | + +### 7. Use It + +Open your Godot project with the plugin enabled, then use Claude Code to interact with the editor. + +## All 172 Tools + +### Project Tools (7) +| Tool | Description | +|------|-------------| +| `get_project_info` | Project metadata, version, viewport, autoloads | +| `get_filesystem_tree` | Recursive file tree with filtering | +| `search_files` | Fuzzy/glob file search | +| `get_project_settings` | Read project.godot settings | +| `set_project_setting` | Set project settings via editor API | +| `uid_to_project_path` | UID → res:// conversion | +| `project_path_to_uid` | res:// → UID conversion | + +### Scene Tools (9) +| Tool | Description | +|------|-------------| +| `get_scene_tree` | Live scene tree with hierarchy | +| `get_scene_file_content` | Raw .tscn file content | +| `create_scene` | Create new scene files | +| `open_scene` | Open scene in editor | +| `delete_scene` | Delete scene file | +| `add_scene_instance` | Instance scene as child node | +| `play_scene` | Run scene (main/current/custom) | +| `stop_scene` | Stop running scene | +| `save_scene` | Save current scene to disk | + +### Node Tools (14) +| Tool | Description | +|------|-------------| +| `add_node` | Add node with type and properties | +| `delete_node` | Delete node (with undo support) | +| `duplicate_node` | Duplicate node and children | +| `move_node` | Move/reparent node | +| `update_property` | Set any property (auto type parsing) | +| `get_node_properties` | Get all node properties | +| `add_resource` | Add Shape/Material/etc to node | +| `set_anchor_preset` | Set Control anchor preset | +| `rename_node` | Rename a node in the scene | +| `connect_signal` | Connect signal between nodes | +| `disconnect_signal` | Disconnect signal connection | +| `get_node_groups` | Get groups a node belongs to | +| `set_node_groups` | Set node group membership | +| `find_nodes_in_group` | Find all nodes in a group | + +### Script Tools (8) +| Tool | Description | +|------|-------------| +| `list_scripts` | List all scripts with class info | +| `read_script` | Read script content | +| `create_script` | Create new script with template | +| `edit_script` | Search/replace or full edit | +| `attach_script` | Attach script to node | +| `get_open_scripts` | List scripts open in editor | +| `validate_script` | Validate GDScript syntax | +| `search_in_files` | Search content in project files | + +### Editor Tools (9) +| Tool | Description | +|------|-------------| +| `get_editor_errors` | Get errors and stack traces | +| `get_editor_screenshot` | Capture editor viewport | +| `get_game_screenshot` | Capture running game | +| `execute_editor_script` | Run arbitrary GDScript in editor | +| `clear_output` | Clear output panel | +| `get_signals` | Get all signals of a node with connections | +| `reload_plugin` | Reload the MCP plugin (auto-reconnect) | +| `reload_project` | Rescan filesystem and reload scripts | +| `get_output_log` | Get output panel content | + +### Input Tools (7) +| Tool | Description | +|------|-------------| +| `simulate_key` | Simulate keyboard key press/release | +| `simulate_mouse_click` | Simulate mouse click at position | +| `simulate_mouse_move` | Simulate mouse movement | +| `simulate_action` | Simulate Godot Input Action | +| `simulate_sequence` | Sequence of input events with frame delays | +| `get_input_actions` | List all input actions | +| `set_input_action` | Create/modify input action | + +### Runtime Tools (19) +| Tool | Description | +|------|-------------| +| `get_game_scene_tree` | Scene tree of running game | +| `get_game_node_properties` | Node properties in running game | +| `set_game_node_property` | Set node property in running game | +| `execute_game_script` | Run GDScript in game context | +| `capture_frames` | Multi-frame screenshot capture | +| `monitor_properties` | Record property values over time | +| `start_recording` | Start input recording | +| `stop_recording` | Stop input recording | +| `replay_recording` | Replay recorded input | +| `find_nodes_by_script` | Find game nodes by script | +| `get_autoload` | Get autoload node properties | +| `batch_get_properties` | Batch get multiple node properties | +| `find_ui_elements` | Find UI elements in game | +| `click_button_by_text` | Click button by text content | +| `wait_for_node` | Wait for node to appear | +| `find_nearby_nodes` | Find nodes near position | +| `navigate_to` | Navigate to target position | +| `move_to` | Walk character to target | + +### Animation Tools (6) +| Tool | Description | +|------|-------------| +| `list_animations` | List all animations in AnimationPlayer | +| `create_animation` | Create new animation | +| `add_animation_track` | Add track (value/position/rotation/method/bezier) | +| `set_animation_keyframe` | Insert keyframe into track | +| `get_animation_info` | Detailed animation info with all tracks/keys | +| `remove_animation` | Remove an animation | + +### TileMap Tools (6) +| Tool | Description | +|------|-------------| +| `tilemap_set_cell` | Set a single tile cell | +| `tilemap_fill_rect` | Fill rectangular region with tiles | +| `tilemap_get_cell` | Get tile data at cell | +| `tilemap_clear` | Clear all cells | +| `tilemap_get_info` | TileMapLayer info and tile set sources | +| `tilemap_get_used_cells` | List of used cells | + +### Theme & UI Tools (6) +| Tool | Description | +|------|-------------| +| `create_theme` | Create Theme resource file | +| `set_theme_color` | Set theme color override | +| `set_theme_constant` | Set theme constant override | +| `set_theme_font_size` | Set theme font size override | +| `set_theme_stylebox` | Set StyleBoxFlat override | +| `get_theme_info` | Get theme overrides info | + +### Profiling Tools (2) +| Tool | Description | +|------|-------------| +| `get_performance_monitors` | All performance monitors (FPS, memory, physics, etc.) | +| `get_editor_performance` | Quick performance summary | + +### Batch & Refactoring Tools (8) +| Tool | Description | +|------|-------------| +| `find_nodes_by_type` | Find all nodes of a type | +| `find_signal_connections` | Find all signal connections in scene | +| `batch_set_property` | Set property on all nodes of a type | +| `find_node_references` | Search project files for pattern | +| `get_scene_dependencies` | Get resource dependencies | +| `cross_scene_set_property` | Set property across all scenes | +| `find_script_references` | Find where script/resource is used | +| `detect_circular_dependencies` | Find circular scene dependencies | + +### Shader Tools (6) +| Tool | Description | +|------|-------------| +| `create_shader` | Create shader with template | +| `read_shader` | Read shader file | +| `edit_shader` | Edit shader (replace/search-replace) | +| `assign_shader_material` | Assign ShaderMaterial to node | +| `set_shader_param` | Set shader parameter | +| `get_shader_params` | Get all shader parameters | + +### Export Tools (3) +| Tool | Description | +|------|-------------| +| `list_export_presets` | List export presets | +| `export_project` | Get export command for preset | +| `get_export_info` | Export-related project info | + +### Resource Tools (6) +| Tool | Description | +|------|-------------| +| `read_resource` | Read .tres resource properties | +| `edit_resource` | Edit resource properties | +| `create_resource` | Create new .tres resource | +| `get_resource_preview` | Get resource thumbnail | +| `add_autoload` | Register autoload singleton | +| `remove_autoload` | Remove autoload singleton | + +### Physics Tools (6) +| Tool | Description | +|------|-------------| +| `setup_physics_body` | Configure physics body properties | +| `setup_collision` | Add collision shapes to nodes | +| `set_physics_layers` | Set collision layer/mask | +| `get_physics_layers` | Get collision layer/mask info | +| `get_collision_info` | Get collision shape details | +| `add_raycast` | Add RayCast2D/3D node | + +### 3D Scene Tools (6) +| Tool | Description | +|------|-------------| +| `add_mesh_instance` | Add MeshInstance3D with primitive mesh | +| `setup_camera_3d` | Configure Camera3D properties | +| `setup_lighting` | Add/configure light nodes | +| `setup_environment` | Configure WorldEnvironment | +| `add_gridmap` | Set up GridMap node | +| `set_material_3d` | Set StandardMaterial3D properties | + +### Particle Tools (5) +| Tool | Description | +|------|-------------| +| `create_particles` | Create GPUParticles2D/3D | +| `set_particle_material` | Configure ParticleProcessMaterial | +| `set_particle_color_gradient` | Set color gradient for particles | +| `apply_particle_preset` | Apply preset (fire, smoke, sparks, etc.) | +| `get_particle_info` | Get particle system details | + +### Navigation Tools (6) +| Tool | Description | +|------|-------------| +| `setup_navigation_region` | Configure NavigationRegion | +| `setup_navigation_agent` | Configure NavigationAgent | +| `bake_navigation_mesh` | Bake navigation mesh | +| `set_navigation_layers` | Set navigation layers | +| `get_navigation_info` | Get navigation setup info | + +### Audio Tools (6) +| Tool | Description | +|------|-------------| +| `add_audio_player` | Add AudioStreamPlayer node | +| `add_audio_bus` | Add audio bus | +| `add_audio_bus_effect` | Add effect to audio bus | +| `set_audio_bus` | Configure audio bus properties | +| `get_audio_bus_layout` | Get audio bus layout info | +| `get_audio_info` | Get audio-related node info | + +### AnimationTree Tools (4) +| Tool | Description | +|------|-------------| +| `create_animation_tree` | Create AnimationTree | +| `get_animation_tree_structure` | Get tree structure | +| `set_tree_parameter` | Set AnimationTree parameter | +| `add_state_machine_state` | Add state to state machine | + +### State Machine Tools (3) +| Tool | Description | +|------|-------------| +| `remove_state_machine_state` | Remove state from state machine | +| `add_state_machine_transition` | Add transition between states | +| `remove_state_machine_transition` | Remove state transition | + +### Blend Tree Tools (1) +| Tool | Description | +|------|-------------| +| `set_blend_tree_node` | Configure blend tree nodes | + +### Analysis & Search Tools (4) +| Tool | Description | +|------|-------------| +| `analyze_scene_complexity` | Analyze scene performance | +| `analyze_signal_flow` | Map signal connections | +| `find_unused_resources` | Find unreferenced resources | +| `get_project_statistics` | Get project-wide statistics | + +### Testing & QA Tools (6) +| Tool | Description | +|------|-------------| +| `run_test_scenario` | Run automated test scenario | +| `assert_node_state` | Assert node property values | +| `assert_screen_text` | Check for text on screen | +| `compare_screenshots` | Compare two screenshots | +| `run_stress_test` | Run performance stress test | +| `get_test_report` | Get test results report | + +## Key Features + +- **UndoRedo Integration**: All node/property operations support Ctrl+Z +- **Smart Type Parsing**: `"Vector2(100, 200)"`, `"#ff0000"`, `"Color(1,0,0)"` auto-converted +- **Auto-Reconnect**: Exponential backoff reconnection (1s → 2s → 4s ... → 60s max) +- **Heartbeat**: 10s ping/pong keeps connection alive +- **Helpful Errors**: Error responses include suggestions for next steps + +## Competitive Comparison + +### Tool Count + +| Category | Godot MCP Pro | GDAI MCP ($19) | tomyud1 (free) | Dokujaa (free) | Coding-Solo (free) | ee0pdt (free) | bradypp (free) | +|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Project | 7 | 5 | 4 | 0 | 2 | 2 | 2 | +| Scene | 9 | 8 | 11 | 9 | 3 | 4 | 5 | +| Node | **14** | 8 | 0 | 8 | 2 | 3 | 0 | +| Script | **8** | 5 | 6 | 4 | 0 | 5 | 0 | +| Editor | **9** | 5 | 1 | 5 | 1 | 3 | 2 | +| Input | **7** | 2 | 0 | 0 | 0 | 0 | 0 | +| Runtime | **19** | 0 | 0 | 0 | 0 | 0 | 0 | +| Animation | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| TileMap | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Theme/UI | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Profiling | **2** | 0 | 0 | 0 | 0 | 0 | 0 | +| Batch/Refactor | **8** | 0 | 0 | 0 | 0 | 0 | 0 | +| Shader | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Export | **3** | 0 | 0 | 0 | 0 | 0 | 0 | +| Resource | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Physics | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| 3D Scene | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Particle | **5** | 0 | 0 | 0 | 0 | 0 | 0 | +| Navigation | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Audio | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| AnimationTree | **4** | 0 | 0 | 0 | 0 | 0 | 0 | +| State Machine | **3** | 0 | 0 | 0 | 0 | 0 | 0 | +| Blend Tree | **1** | 0 | 0 | 0 | 0 | 0 | 0 | +| Analysis | **4** | 0 | 0 | 0 | 0 | 0 | 0 | +| Testing/QA | **6** | 0 | 0 | 0 | 0 | 0 | 0 | +| Asset/AI | 0 | 0 | 1 | 6 | 0 | 0 | 0 | +| Material | 0 | 0 | 0 | 2 | 0 | 0 | 0 | +| Other | 0 | 0 | 9 | 5 | 5 | 2 | 1 | +| Android Deploy | **3** | 0 | 0 | 0 | 0 | 0 | 0 | +| **Total** | **172** | ~30 | **32** | **39** | **13** | **19** | **10** | + +### Feature Matrix + +| Feature | Godot MCP Pro | GDAI MCP ($19) | tomyud1 (free) | Dokujaa (free) | Coding-Solo (free) | +|---------|:---:|:---:|:---:|:---:|:---:| +| **Connection** | WebSocket (real-time) | stdio (Python) | WebSocket | TCP Socket | Headless CLI | +| **Undo/Redo** | Yes | Yes | No | No | No | +| **JSON-RPC 2.0** | Yes | Custom | Custom | Custom | N/A | +| **Auto-reconnect** | Yes (exponential backoff) | N/A | No | No | N/A | +| **Heartbeat** | Yes (10s ping/pong) | No | No | No | No | +| **Error suggestions** | Yes (contextual hints) | No | No | No | No | +| **Screenshot capture** | Yes (editor + game) | Yes | No | No | No | +| **Game input simulation** | Yes (key/mouse/action/sequence) | Yes (basic) | No | No | No | +| **Runtime inspection** | Yes (scene tree + properties + monitor) | No | No | No | No | +| **Signal management** | Yes (connect/disconnect/inspect) | No | No | No | No | +| **Browser visualizer** | No | No | Yes | No | No | +| **AI 3D mesh generation** | No | No | No | Yes (Meshy API) | No | + +### Exclusive Categories (No Competitor Has These) + +| Category | Tools | Why It Matters | +|----------|-------|----------------| +| **Animation** | 6 tools | Create animations, add tracks, set keyframes — all programmatically | +| **TileMap** | 6 tools | Set cells, fill rects, query tile data — essential for 2D level design | +| **Theme/UI** | 6 tools | StyleBox, colors, fonts — build UI themes without manual editor work | +| **Profiling** | 2 tools | FPS, memory, draw calls, physics — performance monitoring | +| **Batch/Refactor** | 8 tools | Find by type, batch property changes, cross-scene updates, dependency analysis | +| **Shader** | 6 tools | Create/edit shaders, assign materials, set parameters | +| **Export** | 3 tools | List presets, get export commands, check templates | +| **Physics** | 6 tools | Set up collision shapes, bodies, raycasts, and layer management | +| **3D Scene** | 6 tools | Add meshes, cameras, lights, environment, GridMap support | +| **Particle** | 5 tools | Create particles with custom materials, presets, and gradients | +| **Navigation** | 6 tools | Configure navigation regions, agents, pathfinding, baking | +| **Audio** | 6 tools | Complete audio bus system, effects, players, live management | +| **AnimationTree** | 4 tools | Build state machines with transitions and blend trees | +| **State Machine** | 3 tools | Advanced state machine management for complex animations | +| **Testing/QA** | 6 tools | Automated testing, assertions, stress testing, screenshot comparison | +| **Runtime** | 19 tools | Inspect and control game at runtime: inspect, record, replay, navigate | + +### Architecture Advantages + +| Aspect | Godot MCP Pro | Typical Competitor | +|--------|--------------|-------------------| +| **Protocol** | JSON-RPC 2.0 (standard, extensible) | Custom JSON or CLI-based | +| **Connection** | Persistent WebSocket with heartbeat | Per-command subprocess or raw TCP | +| **Reliability** | Auto-reconnect with exponential backoff (1s→60s) | Manual reconnection required | +| **Type Safety** | Smart type parsing (Vector2, Color, Rect2, hex colors) | String-only or limited types | +| **Error Handling** | Structured errors with codes + suggestions | Generic error messages | +| **Undo Support** | All mutations go through UndoRedo system | Direct modifications (no undo) | +| **Port Management** | Auto-scan ports 6505-6509 | Fixed port, conflicts possible | + +## License + +Proprietary — see [LICENSE](LICENSE) for details. Purchase includes lifetime updates. diff --git a/Tools/godot-mcp-pro-v1.15.1/docs/architecture.md b/Tools/godot-mcp-pro-v1.15.1/docs/architecture.md new file mode 100644 index 0000000..23634ea --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/docs/architecture.md @@ -0,0 +1,70 @@ +# Architecture + +## Overview + +``` +┌─────────────┐ stdio/MCP ┌──────────────┐ WebSocket:6505 ┌──────────────────┐ +│ AI Client │ ←────────────────→ │ Node.js MCP │ ←──────────────────→ │ Godot Plugin │ +│ (Claude Code)│ │ Server │ JSON-RPC 2.0 │ (Editor Plugin) │ +└─────────────┘ └──────────────┘ └──────────────────┘ +``` + +## Communication Flow + +1. AI client sends MCP tool call (e.g. `add_node`) +2. Node.js server translates to JSON-RPC 2.0 request +3. WebSocket sends to Godot plugin +4. Plugin's command router dispatches to handler +5. Handler executes via Godot Editor API (with UndoRedo) +6. Result sent back as JSON-RPC 2.0 response +7. Node.js formats as MCP tool result +8. AI receives structured response + +## Godot Plugin Structure + +``` +plugin.gd (EditorPlugin) +├── websocket_server.gd (TCP+WebSocket server) +├── command_router.gd (dispatch hub) +│ ├── project_commands.gd (6 commands) +│ ├── scene_commands.gd (8 commands) +│ ├── node_commands.gd (8 commands) +│ ├── script_commands.gd (6 commands) +│ └── editor_commands.gd (5 commands) +└── ui/status_panel (connection monitor) +``` + +## Key Design Decisions + +### WebSocket over HTTP +- Real-time bidirectional communication +- Natural for editor integration (persistent connection) +- Heartbeat keeps connection alive + +### JSON-RPC 2.0 +- Standard protocol with well-defined error codes +- Each request has unique ID for tracking +- Easy to debug and extend + +### UndoRedo Integration +- All scene modifications go through `EditorUndoRedoManager` +- Users can Ctrl+Z any AI-made change +- Prevents accidental data loss + +### Type Parsing +- `PropertyParser` handles string → Godot type conversion +- Supports Vector2/3, Color, Rect2, NodePath, etc. +- AI can send simple strings, plugin handles the rest + +## Error Codes + +| Code | Meaning | +|------|---------| +| -32700 | Parse error (invalid JSON) | +| -32600 | Invalid request | +| -32601 | Method not found | +| -32602 | Invalid params | +| -32603 | Internal error | +| -32000 | No scene open | +| -32001 | Node/resource not found | +| -32002 | Script compilation failed | diff --git a/Tools/godot-mcp-pro-v1.15.1/docs/installation.md b/Tools/godot-mcp-pro-v1.15.1/docs/installation.md new file mode 100644 index 0000000..3df15b5 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/docs/installation.md @@ -0,0 +1,73 @@ +# Installation Guide + +## Prerequisites + +- Godot 4.3+ (tested with 4.6) +- Node.js 18+ +- An MCP-compatible AI client (Claude Code, Claude Desktop, etc.) + +## Step 1: Godot Plugin + +1. Copy the `addons/godot_mcp/` folder into your Godot project +2. Open Project → Project Settings → Plugins +3. Find "Godot MCP Pro" and click Enable +4. You should see "MCP Server" appear in the bottom panel +5. The status should show "Waiting for connection..." + +## Step 2: Node.js Server + +```bash +cd server +npm install +npm run build +``` + +This creates the compiled server in `server/build/`. + +## Step 3: MCP Client Configuration + +### Claude Code (.mcp.json) + +```json +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": ["/absolute/path/to/godot-mcp-pro/server/build/index.js"] + } + } +} +``` + +### Custom Port + +Set the `GODOT_MCP_PORT` environment variable (default: 6505): + +```json +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": ["/absolute/path/to/server/build/index.js"], + "env": { "GODOT_MCP_PORT": "6510" } + } + } +} +``` + +Also update the port in `plugin.gd` (line 3: `const PORT := 6505`). + +## Troubleshooting + +### Plugin doesn't appear +- Make sure the `addons/godot_mcp/` directory is inside your Godot project +- Check that `plugin.cfg` exists in the directory + +### Connection fails +- Verify the Godot editor is running with the plugin enabled +- Check the bottom panel "MCP Server" tab for status +- Ensure no firewall is blocking localhost port 6505 + +### Tools timeout +- Commands have a 30-second timeout +- Large operations (full filesystem scan) may need the `max_depth` parameter diff --git a/Tools/godot-mcp-pro-v1.15.1/docs/tools-reference.md b/Tools/godot-mcp-pro-v1.15.1/docs/tools-reference.md new file mode 100644 index 0000000..6191d29 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/docs/tools-reference.md @@ -0,0 +1,406 @@ +# Tools Reference + +## Project Tools + +### get_project_info +Returns project metadata including name, Godot version, viewport settings, renderer, and autoloads. + +### get_filesystem_tree +Scans the project directory and returns a tree structure. +- `path` (optional): Root path (default: `res://`) +- `filter` (optional): Glob pattern like `*.gd`, `*.tscn` +- `max_depth` (optional): Maximum recursion depth (default: 10) + +### search_files +Fuzzy search for files by name. +- `query` (required): Search string or glob pattern +- `path` (optional): Root path +- `file_type` (optional): Extension filter (`gd`, `tscn`, etc.) +- `max_results` (optional): Limit results (default: 50) + +### get_project_settings +Read settings from project.godot. +- `section` (optional): Filter by section prefix (e.g. `display/window`) +- `key` (optional): Get a specific setting + +### uid_to_project_path / project_path_to_uid +Convert between UIDs (`uid://...`) and resource paths (`res://...`). + +## Scene Tools + +### get_scene_tree +Returns the live node hierarchy of the currently edited scene. +- `max_depth` (optional): Limit tree depth + +### get_scene_file_content +Reads the raw .tscn file. +- `path` (required): Scene file path + +### create_scene +Creates a new scene file. +- `path` (required): Where to save +- `root_type` (optional): Root node type (default: `Node2D`) +- `root_name` (optional): Root node name + +### open_scene / delete_scene +Open or delete a scene file by path. + +### add_scene_instance +Instance a scene as a child node. +- `scene_path` (required): Scene to instance +- `parent_path` (optional): Parent node (default: root) +- `name` (optional): Instance name + +### play_scene / stop_scene +Run or stop scenes. `play_scene` accepts `mode`: `main`, `current`, or a file path. + +## Node Tools + +### add_node +Add a node to the scene. +- `type` (required): Node class name +- `parent_path` (optional): Parent node path +- `name` (optional): Node name +- `properties` (optional): Dict of property values + +### delete_node / duplicate_node / move_node +Modify scene tree structure. All support undo. + +### update_property +Set any node property. Values are auto-parsed: +- `"Vector2(100, 200)"` → Vector2 +- `"#ff0000"` or `"Color(1, 0, 0)"` → Color +- `"true"` / `"false"` → bool +- Numbers → int/float + +### get_node_properties +Get all editor-visible properties of a node. +- `category` (optional): Filter prefix + +### add_resource +Create and assign a resource to a node property. +- `resource_type`: Class name (e.g. `RectangleShape2D`) +- `resource_properties` (optional): Properties for the resource + +### set_anchor_preset +Set anchor preset on Control nodes. Available presets: `top_left`, `center`, `full_rect`, etc. + +### rename_node +Rename a node in the current scene. +- `node_path` (required): Path to the node +- `new_name` (required): New name for the node + +### connect_signal +Connect a signal from one node to a method on another node. +- `source_path` (required): Path to the source node (emitter) +- `signal_name` (required): Signal name to connect +- `target_path` (required): Path to the target node (receiver) +- `method_name` (required): Method name on target to call + +### disconnect_signal +Disconnect a signal connection between two nodes. +- `source_path` (required): Path to the source node (emitter) +- `signal_name` (required): Signal name to disconnect +- `target_path` (required): Path to the target node (receiver) +- `method_name` (required): Method name on target + +## Script Tools + +### list_scripts +Find all scripts with class/extends info. + +### read_script / create_script +Read or create script files. + +### edit_script +Edit scripts via: +1. `replacements`: Array of `{search, replace, regex?}` operations +2. `content`: Full file replacement +3. `insert_at_line` + `text`: Insert at specific line + +### attach_script +Attach a script to a node in the current scene. + +### get_open_scripts +List scripts currently open in the script editor. + +## Editor Tools + +### get_editor_errors +Get recent errors from the Godot log. + +### get_editor_screenshot / get_game_screenshot +Capture viewport as PNG (returned as base64 image). + +### execute_editor_script +Run arbitrary GDScript in the editor context. Use `_mcp_print(value)` to capture output. + +### clear_output +Clear the editor output panel. + +### get_signals +Get all signals of a node, including current connections. +- `node_path` (required): Path to the node to inspect + +### reload_plugin +Reload the Godot MCP Pro plugin (disable/re-enable). Connection will briefly drop and auto-reconnect. + +### reload_project +Rescan the Godot project filesystem and reload changed scripts. No reconnection needed. + +### save_scene +Save the currently edited scene to disk. +- `path` (optional): Path to save to (defaults to current scene path) + +### set_project_setting +Set a project setting value via the editor API. +- `key` (required): Setting key (e.g. `display/window/size/viewport_width`) +- `value` (required): Value to set (auto-parsed for Vector2, bool, int, float) + +## Input Tools + +### simulate_key +Simulate a keyboard key press/release in the running game. +- `keycode` (required): Key constant (e.g. `KEY_SPACE`, `KEY_W`) +- `pressed` (optional): true for press, false for release +- `ctrl`, `shift`, `alt` (optional): Modifier keys + +### simulate_mouse_click +Simulate a mouse button click at a position in the running game. +- `x`, `y` (optional): Viewport position +- `button` (optional): 1=left, 2=right, 3=middle +- `pressed` (optional): true for press, false for release + +### simulate_mouse_move +Simulate mouse movement in the running game. +- `x`, `y` (optional): Target position +- `relative_x`, `relative_y` (optional): Relative movement + +### simulate_action +Simulate a Godot Input Action in the running game. +- `action` (required): Action name from Input Map +- `pressed` (optional): true for press, false for release +- `strength` (optional): 0.0–1.0 + +### simulate_sequence +Simulate a sequence of input events with frame delays. +- `events` (required): Array of input events +- `frame_delay` (optional): Frames between events + +## Runtime Tools + +### get_game_scene_tree +Get the scene tree of the currently running game. +- `max_depth` (optional): Maximum tree depth + +### get_game_node_properties +Get properties of a node in the running game. +- `node_path` (required): Absolute node path +- `properties` (optional): Specific property names to read + +### capture_frames +Capture multiple screenshots at regular frame intervals from the running game. +- `count` (optional): Number of frames (1–30) +- `frame_interval` (optional): Frames between captures +- `half_resolution` (optional): Halve resolution to reduce data size + +### monitor_properties +Record property values over multiple frames from the running game. +- `node_path` (required): Absolute node path +- `properties` (required): Property names to monitor +- `frame_count` (optional): Number of samples (1–600) +- `frame_interval` (optional): Frames between samples + +## Animation Tools + +### list_animations +List all animations in an AnimationPlayer node. +- `node_path` (required): Path to the AnimationPlayer + +### create_animation +Create a new animation in an AnimationPlayer. +- `node_path` (required): Path to the AnimationPlayer +- `name` (required): Animation name +- `length` (optional): Length in seconds (default: 1.0) +- `loop_mode` (optional): 0=none, 1=linear, 2=pingpong + +### add_animation_track +Add a track to an animation. +- `node_path` (required): Path to the AnimationPlayer +- `animation` (required): Animation name +- `track_path` (required): Node path and property (e.g. `Sprite2D:position`) +- `track_type` (optional): value, position_2d, rotation_2d, scale_2d, method, bezier, blend_shape +- `update_mode` (optional): continuous, discrete, capture + +### set_animation_keyframe +Insert a keyframe into an animation track. +- `node_path` (required): Path to the AnimationPlayer +- `animation` (required): Animation name +- `track_index` (required): Track index +- `time` (required): Time position in seconds +- `value` (required): Keyframe value (auto-parsed) + +### get_animation_info +Get detailed info about an animation including all tracks and keyframes. +- `node_path` (required): Path to the AnimationPlayer +- `animation` (required): Animation name + +### remove_animation +Remove an animation from an AnimationPlayer. +- `node_path` (required): Path to the AnimationPlayer +- `name` (required): Animation name + +## TileMap Tools + +### tilemap_set_cell +Set a single cell in a TileMapLayer. +- `node_path` (required): Path to the TileMapLayer +- `x`, `y` (required): Cell coordinates +- `source_id` (optional): Tile source ID +- `atlas_x`, `atlas_y` (optional): Atlas coordinates +- `alternative` (optional): Alternative tile ID + +### tilemap_fill_rect +Fill a rectangular region with tiles. +- `node_path` (required): Path to the TileMapLayer +- `x1`, `y1`, `x2`, `y2` (required): Rectangle bounds +- `source_id`, `atlas_x`, `atlas_y`, `alternative` (optional): Tile data + +### tilemap_get_cell +Get tile data at a specific cell. +- `node_path` (required): Path to the TileMapLayer +- `x`, `y` (required): Cell coordinates + +### tilemap_clear +Clear all cells in a TileMapLayer. +- `node_path` (required): Path to the TileMapLayer + +### tilemap_get_info +Get TileMapLayer info including tile set sources and cell count. +- `node_path` (required): Path to the TileMapLayer + +### tilemap_get_used_cells +Get a list of used (non-empty) cells. +- `node_path` (required): Path to the TileMapLayer +- `max_count` (optional): Maximum cells to return (default: 500) + +## Theme Tools + +### create_theme +Create a new Theme resource file. +- `path` (required): Save path (e.g. `res://themes/main.tres`) +- `default_font_size` (optional): Default font size + +### set_theme_color +Set a theme color override on a Control node. +- `node_path` (required): Path to the Control node +- `name` (required): Color name (e.g. `font_color`) +- `color` (required): Hex color string + +### set_theme_constant +Set a theme constant override on a Control node. +- `node_path` (required): Path to the Control node +- `name` (required): Constant name +- `value` (required): Integer value + +### set_theme_font_size +Set a theme font size override on a Control node. +- `node_path` (required): Path to the Control node +- `name` (required): Font size name (e.g. `font_size`) +- `size` (required): Font size in pixels + +### set_theme_stylebox +Set a StyleBoxFlat override on a Control node. +- `node_path` (required): Path to the Control node +- `name` (required): Style name (e.g. `panel`, `normal`) +- `bg_color` (optional): Background color +- `border_color` (optional): Border color +- `border_width` (optional): Border width +- `corner_radius` (optional): Corner radius +- `padding` (optional): Content padding + +### get_theme_info +Get theme information and overrides for a Control node. +- `node_path` (required): Path to the Control node + +## Profiling Tools + +### get_performance_monitors +Get all Godot performance monitors (FPS, memory, draw calls, physics, navigation). +- `category` (optional): Filter by prefix (e.g. `render`, `physics_2d`) + +### get_editor_performance +Get a quick performance summary (FPS, frame time, draw calls, memory). + +## Batch & Refactoring Tools + +### find_nodes_by_type +Find all nodes of a specific type in the current scene. +- `type` (required): Node class name +- `recursive` (optional): Search recursively (default: true) + +### find_signal_connections +Find all signal connections in the current scene. +- `signal_name` (optional): Filter by signal name +- `node_path` (optional): Filter by node path + +### batch_set_property +Set a property on all nodes of a given type. +- `type` (required): Node type to target +- `property` (required): Property name +- `value` (required): Value to set (auto-parsed) + +### find_node_references +Search through project files for a text pattern. +- `pattern` (required): Text pattern to search for + +### get_scene_dependencies +Get all resource dependencies of a scene or resource file. +- `path` (required): Path to the file + +## Shader Tools + +### create_shader +Create a new shader file with template or custom content. +- `path` (required): Shader file path +- `shader_type` (optional): spatial, canvas_item, particles, sky +- `content` (optional): Full shader code + +### read_shader +Read the content of a shader file. +- `path` (required): Path to the shader file + +### edit_shader +Edit a shader file using full replacement or search-and-replace. +- `path` (required): Path to the shader file +- `content` (optional): Full replacement content +- `replacements` (optional): Array of `{search, replace}` operations + +### assign_shader_material +Create a ShaderMaterial from a shader and assign to a node. +- `node_path` (required): Target node path +- `shader_path` (required): Path to the shader file + +### set_shader_param +Set a shader parameter on a node's ShaderMaterial. +- `node_path` (required): Node with ShaderMaterial +- `param` (required): Parameter name +- `value` (required): Parameter value (auto-parsed) + +### get_shader_params +Get all shader parameters from a node's ShaderMaterial. +- `node_path` (required): Node with ShaderMaterial + +## Export Tools + +### list_export_presets +List all export presets configured in export_presets.cfg. + +### export_project +Get the export command for a preset. +- `preset_name` (optional): Preset name +- `preset_index` (optional): Preset index +- `debug` (optional): Debug export (default: true) + +### get_export_info +Get export-related project info (executable path, templates directory, project path). diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/.clinerules b/Tools/godot-mcp-pro-v1.15.1/instructions/.clinerules new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/.clinerules @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/.windsurfrules b/Tools/godot-mcp-pro-v1.15.1/instructions/.windsurfrules new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/.windsurfrules @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/AGENTS.md b/Tools/godot-mcp-pro-v1.15.1/instructions/AGENTS.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/AGENTS.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/CLAUDE.md b/Tools/godot-mcp-pro-v1.15.1/instructions/CLAUDE.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/CLAUDE.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/GEMINI.md b/Tools/godot-mcp-pro-v1.15.1/instructions/GEMINI.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/GEMINI.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/README.md b/Tools/godot-mcp-pro-v1.15.1/instructions/README.md new file mode 100644 index 0000000..4dc45c4 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/README.md @@ -0,0 +1,19 @@ +# Godot MCP Pro — AI Client Instructions + +Copy the appropriate file to your project root so your AI assistant knows how to use Godot MCP Pro. + +| Client | File to copy | Destination | +|--------|-------------|-------------| +| Claude Code | `CLAUDE.md` | Project root | +| Codex CLI / OpenCode | `AGENTS.md` | Project root | +| Gemini CLI | `GEMINI.md` | Project root | +| Cursor | `godot-mcp-pro.mdc` | `.cursor/rules/godot-mcp-pro.mdc` | +| Cline | `.clinerules` | Project root | +| Windsurf | `.windsurfrules` | Project root | +| Roo Code | `roo-godot-mcp-pro.md` | `.roo/rules/roo-godot-mcp-pro.md` | +| JetBrains / Junie | `junie-guidelines.md` | `.junie/guidelines.md` | +| Amazon Q | `amazonq-godot-mcp-pro.md` | `.amazonq/rules/amazonq-godot-mcp-pro.md` | +| Continue | `continue-godot-mcp-pro.md` | `.continue/rules/godot-mcp-pro.md` | +| Augment Code | `augment-godot-mcp-pro.md` | `.augment/instructions/godot-mcp-pro.md` | + +All files contain the same instructions adapted for each client's format. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/amazonq-godot-mcp-pro.md b/Tools/godot-mcp-pro-v1.15.1/instructions/amazonq-godot-mcp-pro.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/amazonq-godot-mcp-pro.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/augment-godot-mcp-pro.md b/Tools/godot-mcp-pro-v1.15.1/instructions/augment-godot-mcp-pro.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/augment-godot-mcp-pro.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/continue-godot-mcp-pro.md b/Tools/godot-mcp-pro-v1.15.1/instructions/continue-godot-mcp-pro.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/continue-godot-mcp-pro.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/godot-mcp-pro.mdc b/Tools/godot-mcp-pro-v1.15.1/instructions/godot-mcp-pro.mdc new file mode 100644 index 0000000..aacfa35 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/godot-mcp-pro.mdc @@ -0,0 +1,151 @@ +--- +description: Godot MCP Pro instructions for controlling Godot editor via MCP tools and CLI +alwaysApply: true +--- + +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/junie-guidelines.md b/Tools/godot-mcp-pro-v1.15.1/instructions/junie-guidelines.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/junie-guidelines.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/instructions/roo-godot-mcp-pro.md b/Tools/godot-mcp-pro-v1.15.1/instructions/roo-godot-mcp-pro.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/instructions/roo-godot-mcp-pro.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts new file mode 100644 index 0000000..55a947c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +/** + * godot-cli — Command-line interface for Godot MCP Pro + * + * Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0). + * Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits. + * Progressive disclosure via --help at each command level. + */ +export {}; +//# sourceMappingURL=cli.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts.map new file mode 100644 index 0000000..8911a06 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;GAMG"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/cli.js b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.js new file mode 100644 index 0000000..dbebebf --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.js @@ -0,0 +1,705 @@ +#!/usr/bin/env node +/** + * godot-cli — Command-line interface for Godot MCP Pro + * + * Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0). + * Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits. + * Progressive disclosure via --help at each command level. + */ +import { WebSocketServer } from "ws"; +import { randomUUID } from "crypto"; +import { createServer } from "net"; +const BASE_PORT = 6510; +const MAX_PORT = 6514; +const CONNECT_TIMEOUT_MS = 10000; +const COMMAND_TIMEOUT_MS = 30000; +const COMMANDS = { + project: { + description: "Project info, files, and settings", + commands: { + info: { + description: "Get project metadata (name, version, viewport, renderer, autoloads)", + method: "get_project_info", + }, + files: { + description: "List project file/directory tree", + method: "get_filesystem_tree", + args: { + path: { description: "Root path (default: res://)" }, + filter: { description: "Glob filter (e.g. '*.gd', '*.tscn')" }, + }, + }, + search: { + description: "Search for files by name pattern", + method: "search_files", + args: { + query: { description: "Search query (fuzzy match or glob)", required: true }, + path: { description: "Directory to search in" }, + file_type: { description: "Filter by extension (e.g. 'gd', 'tscn')" }, + }, + }, + grep: { + description: "Search inside file contents", + method: "search_in_files", + args: { + query: { description: "Text/regex pattern", required: true }, + path: { description: "Directory to search in" }, + file_type: { description: "File extension filter (e.g. 'gd', 'tscn')" }, + }, + }, + "get-setting": { + description: "Get project settings", + method: "get_project_settings", + args: { + category: { description: "Settings category filter" }, + }, + }, + "set-setting": { + description: "Set a project setting", + method: "set_project_setting", + args: { + setting: { description: "Setting path (e.g. display/window/size/viewport_width)", required: true }, + value: { description: "Value to set", required: true }, + }, + mapArgs: (p) => ({ setting: p.setting, value: autoType(p.value) }), + }, + }, + }, + scene: { + description: "Scene tree and scene management", + commands: { + tree: { + description: "Get the current scene tree", + method: "get_scene_tree", + args: { + max_depth: { description: "Maximum depth to display", type: "number" }, + }, + mapArgs: (p) => (p.max_depth ? { max_depth: parseInt(p.max_depth) } : {}), + }, + create: { + description: "Create a new scene with a root node", + method: "create_scene", + args: { + path: { description: "Scene path (e.g. res://scenes/player.tscn)", required: true }, + root_type: { description: "Root node type (default: Node2D)" }, + root_name: { description: "Root node name" }, + }, + }, + open: { + description: "Open a scene in the editor", + method: "open_scene", + args: { + path: { description: "Scene path to open", required: true }, + }, + }, + save: { + description: "Save the current scene", + method: "save_scene", + args: { + path: { description: "Optional path to save as" }, + }, + }, + play: { + description: "Run the current/specified scene", + method: "play_scene", + args: { + mode: { description: "'main' (default), 'current', or a scene path" }, + }, + }, + stop: { + description: "Stop the running scene", + method: "stop_scene", + }, + content: { + description: "Get scene file content (tscn format parsed)", + method: "get_scene_file_content", + args: { + path: { description: "Scene file path", required: true }, + }, + }, + exports: { + description: "Get exported variables of a scene", + method: "get_scene_exports", + args: { + path: { description: "Scene file path", required: true }, + }, + }, + delete: { + description: "Delete a scene file", + method: "delete_scene", + args: { + path: { description: "Scene file path to delete", required: true }, + }, + }, + instance: { + description: "Add a scene instance as child node", + method: "add_scene_instance", + args: { + scene_path: { description: "Path to .tscn file to instance", required: true }, + parent_path: { description: "Parent node path (default: selected/root)" }, + name: { description: "Instance name" }, + }, + }, + }, + }, + node: { + description: "Add, modify, and delete scene nodes", + commands: { + add: { + description: "Add a new node to the scene", + method: "add_node", + args: { + type: { description: "Node type (e.g. CharacterBody3D, Sprite2D)", required: true }, + name: { description: "Node name" }, + parent_path: { description: "Parent node path (default: root '.')" }, + }, + }, + delete: { + description: "Delete a node from the scene", + method: "delete_node", + args: { + node_path: { description: "Node path to delete", required: true }, + }, + }, + get: { + description: "Get all properties of a node", + method: "get_node_properties", + args: { + node_path: { description: "Node path", required: true }, + }, + }, + set: { + description: "Set a property on a node", + method: "update_property", + args: { + node_path: { description: "Node path", required: true }, + property: { description: "Property name", required: true }, + value: { description: "Value to set", required: true }, + }, + mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }), + }, + duplicate: { + description: "Duplicate a node", + method: "duplicate_node", + args: { + node_path: { description: "Node path to duplicate", required: true }, + name: { description: "Name for the duplicate" }, + }, + }, + move: { + description: "Move/reparent a node", + method: "move_node", + args: { + node_path: { description: "Node path to move", required: true }, + new_parent_path: { description: "New parent path", required: true }, + }, + }, + rename: { + description: "Rename a node", + method: "rename_node", + args: { + node_path: { description: "Node path", required: true }, + new_name: { description: "New name", required: true }, + }, + }, + connect: { + description: "Connect a signal between nodes", + method: "connect_signal", + args: { + source_path: { description: "Source node path", required: true }, + signal_name: { description: "Signal name", required: true }, + target_path: { description: "Target node path", required: true }, + method_name: { description: "Target method name", required: true }, + }, + }, + groups: { + description: "Get groups a node belongs to", + method: "get_node_groups", + args: { + node_path: { description: "Node path", required: true }, + }, + }, + }, + }, + script: { + description: "Read, create, and edit GDScript/C# files", + commands: { + read: { + description: "Read a script file", + method: "read_script", + args: { + path: { description: "Script path (e.g. res://player.gd)", required: true }, + }, + }, + create: { + description: "Create a new script file (.gd or .cs only)", + method: "create_script", + args: { + path: { description: "Script path", required: true }, + content: { description: "Script content", required: true }, + base_type: { description: "Base class (default: Node)" }, + force: { description: "Override open-script-editor guard" }, + }, + mapArgs: (p) => { + const r = { path: p.path, content: p.content }; + if (p.base_type) + r.base_type = p.base_type; + if (p.force !== undefined) + r.force = p.force === "true"; + return r; + }, + }, + edit: { + description: "Edit an existing script (full replace or 1-based inclusive line range)", + method: "edit_script", + args: { + path: { description: "Script path", required: true }, + content: { description: "New content", required: true }, + start_line: { description: "Start line for partial edit (1-based inclusive)", type: "number" }, + end_line: { description: "End line for partial edit (1-based inclusive)", type: "number" }, + force: { description: "Override open-script-editor guard" }, + }, + mapArgs: (p) => { + const r = { path: p.path, content: p.content }; + if (p.start_line) + r.start_line = parseInt(p.start_line); + if (p.end_line) + r.end_line = parseInt(p.end_line); + if (p.force !== undefined) + r.force = p.force === "true"; + return r; + }, + }, + attach: { + description: "Attach a script to a node", + method: "attach_script", + args: { + node_path: { description: "Node path", required: true }, + script_path: { description: "Script path", required: true }, + }, + }, + validate: { + description: "Validate a GDScript for errors", + method: "validate_script", + args: { + path: { description: "Script path to validate", required: true }, + }, + }, + list: { + description: "List all scripts in the project", + method: "list_scripts", + }, + }, + }, + editor: { + description: "Editor state, errors, screenshots, and utilities", + commands: { + errors: { + description: "Get current editor errors/warnings", + method: "get_editor_errors", + }, + log: { + description: "Get editor output log", + method: "get_output_log", + args: { + lines: { description: "Number of lines (default: 50)", type: "number" }, + }, + mapArgs: (p) => (p.lines ? { lines: parseInt(p.lines) } : {}), + }, + screenshot: { + description: "Take a screenshot of the running game", + method: "get_game_screenshot", + }, + "editor-screenshot": { + description: "Take a screenshot of the editor", + method: "get_editor_screenshot", + }, + exec: { + description: "Execute an editor script (GDScript in editor context)", + method: "execute_editor_script", + args: { + code: { description: "GDScript code to execute", required: true }, + }, + }, + signals: { + description: "Get signals of a node type", + method: "get_signals", + args: { + node_path: { description: "Node path", required: true }, + }, + }, + reload: { + description: "Reload the project", + method: "reload_project", + }, + }, + }, + input: { + description: "Simulate keyboard, mouse, and input actions", + commands: { + key: { + description: "Simulate a key press in the running game", + method: "simulate_key", + args: { + key: { description: "Key name (e.g. W, A, S, D, Space)", required: true }, + duration: { description: "Hold duration in seconds", type: "number" }, + pressed: { description: "true=press, false=release" }, + }, + mapArgs: (p) => { + const r = { key: p.key }; + if (p.duration) + r.duration = parseFloat(p.duration); + if (p.pressed !== undefined) + r.pressed = p.pressed === "true"; + return r; + }, + }, + click: { + description: "Simulate a mouse click in the running game", + method: "simulate_mouse_click", + args: { + x: { description: "X coordinate", required: true, type: "number" }, + y: { description: "Y coordinate", required: true, type: "number" }, + button: { description: "Mouse button: left, right, or middle (default: left)" }, + }, + mapArgs: (p) => { + const buttonMap = { left: 1, right: 2, middle: 3 }; + const r = { x: parseInt(p.x), y: parseInt(p.y) }; + if (p.button) + r.button = buttonMap[p.button.toLowerCase()] ?? (parseInt(p.button) || 1); + return r; + }, + }, + action: { + description: "Simulate an input action (as defined in Input Map)", + method: "simulate_action", + args: { + action: { description: "Action name (e.g. ui_accept, move_left)", required: true }, + pressed: { description: "true=press, false=release" }, + duration: { description: "Hold duration in seconds", type: "number" }, + }, + mapArgs: (p) => { + const r = { action: p.action }; + if (p.pressed !== undefined) + r.pressed = p.pressed === "true"; + if (p.duration) + r.duration = parseFloat(p.duration); + return r; + }, + }, + actions: { + description: "List all configured input actions", + method: "get_input_actions", + }, + }, + }, + runtime: { + description: "Inspect and control the running game", + commands: { + tree: { + description: "Get the running game's scene tree", + method: "get_game_scene_tree", + args: { + max_depth: { description: "Max depth (-1 for unlimited)", type: "number" }, + }, + mapArgs: (p) => { + const r = {}; + if (p.max_depth) + r.max_depth = parseInt(p.max_depth); + return r; + }, + }, + get: { + description: "Get properties of a node in the running game", + method: "get_game_node_properties", + args: { + node_path: { description: "Node path", required: true }, + properties: { description: "Comma-separated property names (default: all)" }, + }, + mapArgs: (p) => { + const r = { node_path: p.node_path }; + if (p.properties) + r.properties = p.properties.split(",").map(s => s.trim()); + return r; + }, + }, + set: { + description: "Set a property on a running game node", + method: "set_game_node_property", + args: { + node_path: { description: "Node path", required: true }, + property: { description: "Property name", required: true }, + value: { description: "Value to set", required: true }, + }, + mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }), + }, + exec: { + description: "Execute GDScript in the running game", + method: "execute_game_script", + args: { + code: { description: "GDScript code", required: true }, + node_path: { description: "Node context (default: /root)" }, + }, + }, + ui: { + description: "Find UI elements (buttons, labels, etc.) in the running game", + method: "find_ui_elements", + args: { + type_filter: { description: "Filter by type (Button, Label, etc.)" }, + }, + }, + }, + }, +}; +// ─── Argument parsing ───────────────────────────────────────────────── +function autoType(value) { + if (value === "true") + return true; + if (value === "false") + return false; + if (value === "null") + return null; + const num = Number(value); + if (!isNaN(num) && value.trim() !== "") + return num; + // Try JSON for arrays/objects + if ((value.startsWith("[") || value.startsWith("{")) && (value.endsWith("]") || value.endsWith("}"))) { + try { + return JSON.parse(value); + } + catch { /* fall through */ } + } + return value; +} +function parseArgs(argv) { + const positional = []; + const flags = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + flags[key] = next; + i++; + } + else { + flags[key] = "true"; + } + } + else { + positional.push(arg); + } + } + return { positional, flags }; +} +// ─── Help formatting ────────────────────────────────────────────────── +function showMainHelp() { + console.log(`godot-cli — Control Godot editor from the command line + +Usage: godot-cli [options] + +Groups:`); + for (const [name, group] of Object.entries(COMMANDS)) { + console.log(` ${name.padEnd(12)} ${group.description}`); + } + console.log(` +Options: + --port Godot WebSocket port (default: auto-detect 6510-6514) + --help Show help for a group or command + +Examples: + godot-cli project info + godot-cli scene tree + godot-cli node add --type CharacterBody3D --name Player + godot-cli script read --path res://player.gd + godot-cli scene play + godot-cli input key --key W --duration 0.5`); +} +function showGroupHelp(groupName, group) { + console.log(`godot-cli ${groupName} — ${group.description} + +Commands:`); + for (const [name, cmd] of Object.entries(group.commands)) { + console.log(` ${name.padEnd(18)} ${cmd.description}`); + } + console.log(`\nUse: godot-cli ${groupName} --help for details`); +} +function showCommandHelp(groupName, cmdName, cmd) { + console.log(`godot-cli ${groupName} ${cmdName} — ${cmd.description}`); + if (cmd.args && Object.keys(cmd.args).length > 0) { + console.log(`\nOptions:`); + for (const [name, arg] of Object.entries(cmd.args)) { + const req = arg.required ? " (required)" : ""; + console.log(` --${name.padEnd(16)} ${arg.description}${req}`); + } + } +} +// ─── WebSocket connection ───────────────────────────────────────────── +// The Godot plugin is a WebSocket CLIENT that connects to servers on ports 6505-6514. +// The CLI starts a temporary WebSocket SERVER on an available port and waits for +// the Godot plugin to connect (it polls every 3 seconds). +function isPortFree(port) { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} +async function findFreePort(preferredPort) { + if (preferredPort) { + if (await isPortFree(preferredPort)) + return preferredPort; + return null; + } + for (let p = BASE_PORT; p <= MAX_PORT; p++) { + if (await isPortFree(p)) + return p; + } + return null; +} +/** + * Start a WebSocket server and wait for the Godot plugin to connect. + * Returns the connected client WebSocket and the server (for cleanup). + */ +function waitForGodot(port) { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ port, host: "127.0.0.1" }); + const timeout = setTimeout(() => { + wss.close(); + reject(new Error(`Godot plugin did not connect within ${CONNECT_TIMEOUT_MS / 1000}s.\n` + + "Make sure the Godot editor is running with the MCP plugin enabled.")); + }, CONNECT_TIMEOUT_MS); + wss.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + wss.on("connection", (ws) => { + clearTimeout(timeout); + resolve({ client: ws, wss }); + }); + }); +} +function sendCommand(ws, method, params) { + return new Promise((resolve, reject) => { + const id = randomUUID(); + const timeout = setTimeout(() => { + reject(new Error(`Command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms`)); + }, COMMAND_TIMEOUT_MS); + const handler = (data) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } + catch { + return; + } + // Ignore ping/pong + if (msg.method === "pong" || msg.method === "ping") + return; + if (msg.id !== id) + return; + clearTimeout(timeout); + ws.off("message", handler); + if (msg.error) { + reject(new Error(`Godot error: ${msg.error.message || JSON.stringify(msg.error)}`)); + } + else { + resolve(msg.result); + } + }; + ws.on("message", handler); + ws.send(JSON.stringify({ jsonrpc: "2.0", method, params, id })); + }); +} +// ─── Main ───────────────────────────────────────────────────────────── +async function main() { + const userArgs = process.argv.slice(2); + const { positional, flags } = parseArgs(userArgs); + // Global --help + if (positional.length === 0 || flags.help === "true" && positional.length === 0) { + showMainHelp(); + process.exit(0); + } + const groupName = positional[0]; + const group = COMMANDS[groupName]; + if (!group) { + console.error(`Unknown group: ${groupName}`); + showMainHelp(); + process.exit(1); + } + // Group-level --help + if (positional.length === 1 || (flags.help === "true" && positional.length === 1)) { + showGroupHelp(groupName, group); + process.exit(0); + } + const cmdName = positional[1]; + const cmd = group.commands[cmdName]; + if (!cmd) { + console.error(`Unknown command: ${groupName} ${cmdName}`); + showGroupHelp(groupName, group); + process.exit(1); + } + // Command-level --help + if (flags.help === "true") { + showCommandHelp(groupName, cmdName, cmd); + process.exit(0); + } + // Validate required args + if (cmd.args) { + for (const [name, arg] of Object.entries(cmd.args)) { + if (arg.required && !flags[name]) { + console.error(`Missing required option: --${name}`); + showCommandHelp(groupName, cmdName, cmd); + process.exit(1); + } + } + } + // Build params + const params = cmd.mapArgs ? cmd.mapArgs(flags) : { ...flags }; + // Remove internal flags + delete params.port; + delete params.help; + // Connect and execute + const preferredPort = flags.port ? parseInt(flags.port) : undefined; + const port = await findFreePort(preferredPort); + if (!port) { + console.error(`No free ports in range ${BASE_PORT}-${MAX_PORT}.\n` + + "All ports are occupied by MCP server instances."); + process.exit(1); + } + let client; + let wss; + try { + process.stderr.write(`Waiting for Godot on port ${port}...`); + ({ client, wss } = await waitForGodot(port)); + process.stderr.write(" connected!\n"); + } + catch (err) { + process.stderr.write("\n"); + console.error(err.message); + process.exit(1); + } + try { + const result = await sendCommand(client, cmd.method, params); + if (result !== undefined && result !== null) { + console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)); + } + } + catch (err) { + console.error(err.message); + process.exit(1); + } + finally { + client.close(); + wss.close(); + } +} +main().catch((err) => { + console.error("Fatal:", err.message); + process.exit(1); +}); +//# sourceMappingURL=cli.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/cli.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.js.map new file mode 100644 index 0000000..eea7ee9 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/cli.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;GAMG;AAEH,OAAO,EAAE,eAAe,EAAa,MAAM,IAAI,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,KAAK,CAAC;AAEnC,MAAM,SAAS,GAAG,IAAI,CAAC;AACvB,MAAM,QAAQ,GAAG,IAAI,CAAC;AACtB,MAAM,kBAAkB,GAAG,KAAK,CAAC;AACjC,MAAM,kBAAkB,GAAG,KAAK,CAAC;AAgBjC,MAAM,QAAQ,GAA6B;IACzC,OAAO,EAAE;QACP,WAAW,EAAE,mCAAmC;QAChD,QAAQ,EAAE;YACR,IAAI,EAAE;gBACJ,WAAW,EAAE,qEAAqE;gBAClF,MAAM,EAAE,kBAAkB;aAC3B;YACD,KAAK,EAAE;gBACL,WAAW,EAAE,kCAAkC;gBAC/C,MAAM,EAAE,qBAAqB;gBAC7B,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,6BAA6B,EAAE;oBACpD,MAAM,EAAE,EAAE,WAAW,EAAE,qCAAqC,EAAE;iBAC/D;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,kCAAkC;gBAC/C,MAAM,EAAE,cAAc;gBACtB,IAAI,EAAE;oBACJ,KAAK,EAAE,EAAE,WAAW,EAAE,oCAAoC,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC5E,IAAI,EAAE,EAAE,WAAW,EAAE,wBAAwB,EAAE;oBAC/C,SAAS,EAAE,EAAE,WAAW,EAAE,yCAAyC,EAAE;iBACtE;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,6BAA6B;gBAC1C,MAAM,EAAE,iBAAiB;gBACzB,IAAI,EAAE;oBACJ,KAAK,EAAE,EAAE,WAAW,EAAE,oBAAoB,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC5D,IAAI,EAAE,EAAE,WAAW,EAAE,wBAAwB,EAAE;oBAC/C,SAAS,EAAE,EAAE,WAAW,EAAE,2CAA2C,EAAE;iBACxE;aACF;YACD,aAAa,EAAE;gBACb,WAAW,EAAE,sBAAsB;gBACnC,MAAM,EAAE,sBAAsB;gBAC9B,IAAI,EAAE;oBACJ,QAAQ,EAAE,EAAE,WAAW,EAAE,0BAA0B,EAAE;iBACtD;aACF;YACD,aAAa,EAAE;gBACb,WAAW,EAAE,uBAAuB;gBACpC,MAAM,EAAE,qBAAqB;gBAC7B,IAAI,EAAE;oBACJ,OAAO,EAAE,EAAE,WAAW,EAAE,wDAAwD,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAClG,KAAK,EAAE,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACvD;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;aACnE;SACF;KACF;IACD,KAAK,EAAE;QACL,WAAW,EAAE,iCAAiC;QAC9C,QAAQ,EAAE;YACR,IAAI,EAAE;gBACJ,WAAW,EAAE,4BAA4B;gBACzC,MAAM,EAAE,gBAAgB;gBACxB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,0BAA0B,EAAE,IAAI,EAAE,QAAQ,EAAE;iBACvE;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAC1E;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,qCAAqC;gBAClD,MAAM,EAAE,cAAc;gBACtB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,4CAA4C,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACnF,SAAS,EAAE,EAAE,WAAW,EAAE,kCAAkC,EAAE;oBAC9D,SAAS,EAAE,EAAE,WAAW,EAAE,gBAAgB,EAAE;iBAC7C;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,4BAA4B;gBACzC,MAAM,EAAE,YAAY;gBACpB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,oBAAoB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBAC5D;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,wBAAwB;gBACrC,MAAM,EAAE,YAAY;gBACpB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,0BAA0B,EAAE;iBAClD;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,iCAAiC;gBAC9C,MAAM,EAAE,YAAY;gBACpB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,8CAA8C,EAAE;iBACtE;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,wBAAwB;gBACrC,MAAM,EAAE,YAAY;aACrB;YACD,OAAO,EAAE;gBACP,WAAW,EAAE,6CAA6C;gBAC1D,MAAM,EAAE,wBAAwB;gBAChC,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,iBAAiB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACzD;aACF;YACD,OAAO,EAAE;gBACP,WAAW,EAAE,mCAAmC;gBAChD,MAAM,EAAE,mBAAmB;gBAC3B,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,iBAAiB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACzD;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,qBAAqB;gBAClC,MAAM,EAAE,cAAc;gBACtB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,2BAA2B,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACnE;aACF;YACD,QAAQ,EAAE;gBACR,WAAW,EAAE,oCAAoC;gBACjD,MAAM,EAAE,oBAAoB;gBAC5B,IAAI,EAAE;oBACJ,UAAU,EAAE,EAAE,WAAW,EAAE,gCAAgC,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC7E,WAAW,EAAE,EAAE,WAAW,EAAE,2CAA2C,EAAE;oBACzE,IAAI,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE;iBACvC;aACF;SACF;KACF;IACD,IAAI,EAAE;QACJ,WAAW,EAAE,qCAAqC;QAClD,QAAQ,EAAE;YACR,GAAG,EAAE;gBACH,WAAW,EAAE,6BAA6B;gBAC1C,MAAM,EAAE,UAAU;gBAClB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,4CAA4C,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACnF,IAAI,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE;oBAClC,WAAW,EAAE,EAAE,WAAW,EAAE,sCAAsC,EAAE;iBACrE;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,8BAA8B;gBAC3C,MAAM,EAAE,aAAa;gBACrB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,qBAAqB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBAClE;aACF;YACD,GAAG,EAAE;gBACH,WAAW,EAAE,8BAA8B;gBAC3C,MAAM,EAAE,qBAAqB;gBAC7B,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACxD;aACF;YACD,GAAG,EAAE;gBACH,WAAW,EAAE,0BAA0B;gBACvC,MAAM,EAAE,iBAAiB;gBACzB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvD,QAAQ,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC1D,KAAK,EAAE,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACvD;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;aAC7F;YACD,SAAS,EAAE;gBACT,WAAW,EAAE,kBAAkB;gBAC/B,MAAM,EAAE,gBAAgB;gBACxB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,wBAAwB,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACpE,IAAI,EAAE,EAAE,WAAW,EAAE,wBAAwB,EAAE;iBAChD;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,sBAAsB;gBACnC,MAAM,EAAE,WAAW;gBACnB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,mBAAmB,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC/D,eAAe,EAAE,EAAE,WAAW,EAAE,iBAAiB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACpE;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,eAAe;gBAC5B,MAAM,EAAE,aAAa;gBACrB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvD,QAAQ,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACtD;aACF;YACD,OAAO,EAAE;gBACP,WAAW,EAAE,gCAAgC;gBAC7C,MAAM,EAAE,gBAAgB;gBACxB,IAAI,EAAE;oBACJ,WAAW,EAAE,EAAE,WAAW,EAAE,kBAAkB,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAChE,WAAW,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC3D,WAAW,EAAE,EAAE,WAAW,EAAE,kBAAkB,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAChE,WAAW,EAAE,EAAE,WAAW,EAAE,oBAAoB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACnE;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,8BAA8B;gBAC3C,MAAM,EAAE,iBAAiB;gBACzB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACxD;aACF;SACF;KACF;IACD,MAAM,EAAE;QACN,WAAW,EAAE,0CAA0C;QACvD,QAAQ,EAAE;YACR,IAAI,EAAE;gBACJ,WAAW,EAAE,oBAAoB;gBACjC,MAAM,EAAE,aAAa;gBACrB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,oCAAoC,EAAE,QAAQ,EAAE,IAAI,EAAE;iBAC5E;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,4CAA4C;gBACzD,MAAM,EAAE,eAAe;gBACvB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACpD,OAAO,EAAE,EAAE,WAAW,EAAE,gBAAgB,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC1D,SAAS,EAAE,EAAE,WAAW,EAAE,4BAA4B,EAAE;oBACxD,KAAK,EAAE,EAAE,WAAW,EAAE,mCAAmC,EAAE;iBAC5D;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,CAAC,GAA4B,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;oBACxE,IAAI,CAAC,CAAC,SAAS;wBAAE,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC;oBAC3C,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS;wBAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC;oBACxD,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,wEAAwE;gBACrF,MAAM,EAAE,aAAa;gBACrB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACpD,OAAO,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvD,UAAU,EAAE,EAAE,WAAW,EAAE,iDAAiD,EAAE,IAAI,EAAE,QAAQ,EAAE;oBAC9F,QAAQ,EAAE,EAAE,WAAW,EAAE,+CAA+C,EAAE,IAAI,EAAE,QAAQ,EAAE;oBAC1F,KAAK,EAAE,EAAE,WAAW,EAAE,mCAAmC,EAAE;iBAC5D;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,CAAC,GAA4B,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;oBACxE,IAAI,CAAC,CAAC,UAAU;wBAAE,CAAC,CAAC,UAAU,GAAG,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;oBACxD,IAAI,CAAC,CAAC,QAAQ;wBAAE,CAAC,CAAC,QAAQ,GAAG,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;oBAClD,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS;wBAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC;oBACxD,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,2BAA2B;gBACxC,MAAM,EAAE,eAAe;gBACvB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvD,WAAW,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,IAAI,EAAE;iBAC5D;aACF;YACD,QAAQ,EAAE;gBACR,WAAW,EAAE,gCAAgC;gBAC7C,MAAM,EAAE,iBAAiB;gBACzB,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,yBAAyB,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACjE;aACF;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,iCAAiC;gBAC9C,MAAM,EAAE,cAAc;aACvB;SACF;KACF;IACD,MAAM,EAAE;QACN,WAAW,EAAE,kDAAkD;QAC/D,QAAQ,EAAE;YACR,MAAM,EAAE;gBACN,WAAW,EAAE,oCAAoC;gBACjD,MAAM,EAAE,mBAAmB;aAC5B;YACD,GAAG,EAAE;gBACH,WAAW,EAAE,uBAAuB;gBACpC,MAAM,EAAE,gBAAgB;gBACxB,IAAI,EAAE;oBACJ,KAAK,EAAE,EAAE,WAAW,EAAE,+BAA+B,EAAE,IAAI,EAAE,QAAQ,EAAE;iBACxE;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAC9D;YACD,UAAU,EAAE;gBACV,WAAW,EAAE,uCAAuC;gBACpD,MAAM,EAAE,qBAAqB;aAC9B;YACD,mBAAmB,EAAE;gBACnB,WAAW,EAAE,iCAAiC;gBAC9C,MAAM,EAAE,uBAAuB;aAChC;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,uDAAuD;gBACpE,MAAM,EAAE,uBAAuB;gBAC/B,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,0BAA0B,EAAE,QAAQ,EAAE,IAAI,EAAE;iBAClE;aACF;YACD,OAAO,EAAE;gBACP,WAAW,EAAE,4BAA4B;gBACzC,MAAM,EAAE,aAAa;gBACrB,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACxD;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,oBAAoB;gBACjC,MAAM,EAAE,gBAAgB;aACzB;SACF;KACF;IACD,KAAK,EAAE;QACL,WAAW,EAAE,6CAA6C;QAC1D,QAAQ,EAAE;YACR,GAAG,EAAE;gBACH,WAAW,EAAE,0CAA0C;gBACvD,MAAM,EAAE,cAAc;gBACtB,IAAI,EAAE;oBACJ,GAAG,EAAE,EAAE,WAAW,EAAE,mCAAmC,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACzE,QAAQ,EAAE,EAAE,WAAW,EAAE,0BAA0B,EAAE,IAAI,EAAE,QAAQ,EAAE;oBACrE,OAAO,EAAE,EAAE,WAAW,EAAE,2BAA2B,EAAE;iBACtD;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,CAAC,GAA4B,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC;oBAClD,IAAI,CAAC,CAAC,QAAQ;wBAAE,CAAC,CAAC,QAAQ,GAAG,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;oBACpD,IAAI,CAAC,CAAC,OAAO,KAAK,SAAS;wBAAE,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,KAAK,MAAM,CAAC;oBAC9D,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,KAAK,EAAE;gBACL,WAAW,EAAE,4CAA4C;gBACzD,MAAM,EAAE,sBAAsB;gBAC9B,IAAI,EAAE;oBACJ,CAAC,EAAE,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE;oBAClE,CAAC,EAAE,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE;oBAClE,MAAM,EAAE,EAAE,WAAW,EAAE,sDAAsD,EAAE;iBAChF;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,SAAS,GAA2B,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;oBAC3E,MAAM,CAAC,GAA4B,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;oBAC1E,IAAI,CAAC,CAAC,MAAM;wBAAE,CAAC,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;oBACxF,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,MAAM,EAAE;gBACN,WAAW,EAAE,oDAAoD;gBACjE,MAAM,EAAE,iBAAiB;gBACzB,IAAI,EAAE;oBACJ,MAAM,EAAE,EAAE,WAAW,EAAE,yCAAyC,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAClF,OAAO,EAAE,EAAE,WAAW,EAAE,2BAA2B,EAAE;oBACrD,QAAQ,EAAE,EAAE,WAAW,EAAE,0BAA0B,EAAE,IAAI,EAAE,QAAQ,EAAE;iBACtE;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,CAAC,GAA4B,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;oBACxD,IAAI,CAAC,CAAC,OAAO,KAAK,SAAS;wBAAE,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,KAAK,MAAM,CAAC;oBAC9D,IAAI,CAAC,CAAC,QAAQ;wBAAE,CAAC,CAAC,QAAQ,GAAG,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;oBACpD,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,OAAO,EAAE;gBACP,WAAW,EAAE,mCAAmC;gBAChD,MAAM,EAAE,mBAAmB;aAC5B;SACF;KACF;IACD,OAAO,EAAE;QACP,WAAW,EAAE,sCAAsC;QACnD,QAAQ,EAAE;YACR,IAAI,EAAE;gBACJ,WAAW,EAAE,mCAAmC;gBAChD,MAAM,EAAE,qBAAqB;gBAC7B,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,8BAA8B,EAAE,IAAI,EAAE,QAAQ,EAAE;iBAC3E;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,CAAC,GAA4B,EAAE,CAAC;oBACtC,IAAI,CAAC,CAAC,SAAS;wBAAE,CAAC,CAAC,SAAS,GAAG,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;oBACrD,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,GAAG,EAAE;gBACH,WAAW,EAAE,8CAA8C;gBAC3D,MAAM,EAAE,0BAA0B;gBAClC,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvD,UAAU,EAAE,EAAE,WAAW,EAAE,+CAA+C,EAAE;iBAC7E;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;oBACb,MAAM,CAAC,GAA4B,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC;oBAC9D,IAAI,CAAC,CAAC,UAAU;wBAAE,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;oBAC5E,OAAO,CAAC,CAAC;gBACX,CAAC;aACF;YACD,GAAG,EAAE;gBACH,WAAW,EAAE,uCAAuC;gBACpD,MAAM,EAAE,wBAAwB;gBAChC,IAAI,EAAE;oBACJ,SAAS,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvD,QAAQ,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE,QAAQ,EAAE,IAAI,EAAE;oBAC1D,KAAK,EAAE,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,IAAI,EAAE;iBACvD;gBACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;aAC7F;YACD,IAAI,EAAE;gBACJ,WAAW,EAAE,sCAAsC;gBACnD,MAAM,EAAE,qBAAqB;gBAC7B,IAAI,EAAE;oBACJ,IAAI,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACtD,SAAS,EAAE,EAAE,WAAW,EAAE,+BAA+B,EAAE;iBAC5D;aACF;YACD,EAAE,EAAE;gBACF,WAAW,EAAE,8DAA8D;gBAC3E,MAAM,EAAE,kBAAkB;gBAC1B,IAAI,EAAE;oBACJ,WAAW,EAAE,EAAE,WAAW,EAAE,sCAAsC,EAAE;iBACrE;aACF;SACF;KACF;CACF,CAAC;AAEF,yEAAyE;AAEzE,SAAS,QAAQ,CAAC,KAAa;IAC7B,IAAI,KAAK,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IAClC,IAAI,KAAK,KAAK,OAAO;QAAE,OAAO,KAAK,CAAC;IACpC,IAAI,KAAK,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,GAAG,CAAC;IACnD,8BAA8B;IAC9B,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;QACrG,IAAI,CAAC;YAAC,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,kBAAkB,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,SAAS,CAAC,IAAc;IAC/B,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,MAAM,KAAK,GAA2B,EAAE,CAAC;IACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACpB,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YACzB,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACzB,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBACnC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;gBAClB,CAAC,EAAE,CAAC;YACN,CAAC;iBAAM,CAAC;gBACN,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;YACtB,CAAC;QACH,CAAC;aAAM,CAAC;YACN,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACvB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;AAC/B,CAAC;AAED,yEAAyE;AAEzE,SAAS,YAAY;IACnB,OAAO,CAAC,GAAG,CAAC;;;;QAIN,CAAC,CAAC;IACR,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QACrD,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC;IAC3D,CAAC;IACD,OAAO,CAAC,GAAG,CAAC;;;;;;;;;;;6CAW+B,CAAC,CAAC;AAC/C,CAAC;AAED,SAAS,aAAa,CAAC,SAAiB,EAAE,KAAe;IACvD,OAAO,CAAC,GAAG,CAAC,aAAa,SAAS,MAAM,KAAK,CAAC,WAAW;;UAEjD,CAAC,CAAC;IACV,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzD,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;IACzD,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,oBAAoB,SAAS,+BAA+B,CAAC,CAAC;AAC5E,CAAC;AAED,SAAS,eAAe,CAAC,SAAiB,EAAE,OAAe,EAAE,GAAe;IAC1E,OAAO,CAAC,GAAG,CAAC,aAAa,SAAS,IAAI,OAAO,MAAM,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;IACtE,IAAI,GAAG,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACjD,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;QAC1B,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YACnD,MAAM,GAAG,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9C,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,GAAG,CAAC,WAAW,GAAG,GAAG,EAAE,CAAC,CAAC;QACjE,CAAC;IACH,CAAC;AACH,CAAC;AAED,yEAAyE;AACzE,sFAAsF;AACtF,iFAAiF;AACjF,0DAA0D;AAE1D,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;QAC9B,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;QAC3C,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE;YAC5B,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QACpC,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,YAAY,CAAC,aAAsB;IAChD,IAAI,aAAa,EAAE,CAAC;QAClB,IAAI,MAAM,UAAU,CAAC,aAAa,CAAC;YAAE,OAAO,aAAa,CAAC;QAC1D,OAAO,IAAI,CAAC;IACd,CAAC;IACD,KAAK,IAAI,CAAC,GAAG,SAAS,EAAE,CAAC,IAAI,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3C,IAAI,MAAM,UAAU,CAAC,CAAC,CAAC;YAAE,OAAO,CAAC,CAAC;IACpC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,SAAS,YAAY,CAAC,IAAY;IAChC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,GAAG,GAAG,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7D,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE;YAC9B,GAAG,CAAC,KAAK,EAAE,CAAC;YACZ,MAAM,CAAC,IAAI,KAAK,CACd,uCAAuC,kBAAkB,GAAG,IAAI,MAAM;gBACtE,oEAAoE,CACrE,CAAC,CAAC;QACL,CAAC,EAAE,kBAAkB,CAAC,CAAC;QAEvB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YACtB,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,MAAM,CAAC,GAAG,CAAC,CAAC;QACd,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAE,EAAE,EAAE;YAC1B,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAC/B,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,WAAW,CAClB,EAAa,EACb,MAAc,EACd,MAA+B;IAE/B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,EAAE,GAAG,UAAU,EAAE,CAAC;QACxB,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE;YAC9B,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY,MAAM,qBAAqB,kBAAkB,IAAI,CAAC,CAAC,CAAC;QACnF,CAAC,EAAE,kBAAkB,CAAC,CAAC;QAEvB,MAAM,OAAO,GAAG,CAAC,IAAY,EAAE,EAAE;YAC/B,IAAI,GAAG,CAAC;YACR,IAAI,CAAC;gBACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;YACpC,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO;YACT,CAAC;YACD,mBAAmB;YACnB,IAAK,GAA2B,CAAC,MAAM,KAAK,MAAM,IAAK,GAA2B,CAAC,MAAM,KAAK,MAAM;gBAAE,OAAO;YAC7G,IAAI,GAAG,CAAC,EAAE,KAAK,EAAE;gBAAE,OAAO;YAE1B,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,EAAE,CAAC,GAAG,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;YAE3B,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;gBACd,MAAM,CAAC,IAAI,KAAK,CAAC,gBAAgB,GAAG,CAAC,KAAK,CAAC,OAAO,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;YACtF,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YACtB,CAAC;QACH,CAAC,CAAC;QAEF,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;QAC1B,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;AACL,CAAC;AAED,yEAAyE;AAEzE,KAAK,UAAU,IAAI;IACjB,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACvC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;IAElD,gBAAgB;IAChB,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChF,YAAY,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,SAAS,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;IAChC,MAAM,KAAK,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC;IAElC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,kBAAkB,SAAS,EAAE,CAAC,CAAC;QAC7C,YAAY,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,qBAAqB;IACrB,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,MAAM,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;QAClF,aAAa,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAChC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,OAAO,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;IAC9B,MAAM,GAAG,GAAG,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAEpC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,CAAC,KAAK,CAAC,oBAAoB,SAAS,IAAI,OAAO,EAAE,CAAC,CAAC;QAC1D,aAAa,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAChC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,uBAAuB;IACvB,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC1B,eAAe,CAAC,SAAS,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC;QACzC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,yBAAyB;IACzB,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC;QACb,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YACnD,IAAI,GAAG,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;gBACjC,OAAO,CAAC,KAAK,CAAC,8BAA8B,IAAI,EAAE,CAAC,CAAC;gBACpD,eAAe,CAAC,SAAS,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC;gBACzC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAClB,CAAC;QACH,CAAC;IACH,CAAC;IAED,eAAe;IACf,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC;IAC/D,wBAAwB;IACxB,OAAO,MAAM,CAAC,IAAI,CAAC;IACnB,OAAO,MAAM,CAAC,IAAI,CAAC;IAEnB,sBAAsB;IACtB,MAAM,aAAa,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACpE,MAAM,IAAI,GAAG,MAAM,YAAY,CAAC,aAAa,CAAC,CAAC;IAC/C,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CACX,0BAA0B,SAAS,IAAI,QAAQ,KAAK;YACpD,iDAAiD,CAClD,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,IAAI,MAAiB,CAAC;IACtB,IAAI,GAAoB,CAAC;IACzB,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,6BAA6B,IAAI,KAAK,CAAC,CAAC;QAC7D,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;QAC7C,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IACxC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC3B,OAAO,CAAC,KAAK,CAAE,GAAa,CAAC,OAAO,CAAC,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC7D,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAC5C,OAAO,CAAC,GAAG,CAAC,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;QACrF,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAE,GAAa,CAAC,OAAO,CAAC,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,KAAK,EAAE,CAAC;QACf,GAAG,CAAC,KAAK,EAAE,CAAC;IACd,CAAC;AACH,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IACrC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts new file mode 100644 index 0000000..c221db2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts @@ -0,0 +1,29 @@ +export declare class GodotConnection { + private wss; + private client; + private port; + private fixedPort; + private basePort; + private maxPort; + private pendingRequests; + private heartbeatTimer; + private lastPongAt; + constructor(port?: number, fixedPort?: boolean, options?: { + basePort?: number; + maxPort?: number; + }); + /** Start WebSocket server, retrying on the next port if the first bind races. */ + connect(): Promise; + /** Try to bind a single WebSocketServer. Resolves once 'listening' fires, rejects on bind error. */ + private bindWebSocketServer; + private attachConnectionHandler; + disconnect(): void; + isConnected(): boolean; + getPort(): number; + sendCommand(method: string, params?: Record): Promise; + private handleMessage; + private rejectAllPending; + private startHeartbeat; + private stopHeartbeat; +} +//# sourceMappingURL=godot-connection.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts.map new file mode 100644 index 0000000..6b347a1 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"godot-connection.d.ts","sourceRoot":"","sources":["../src/godot-connection.ts"],"names":[],"mappings":"AAoBA,qBAAa,eAAe;IAC1B,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,SAAS,CAAU;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,eAAe,CAA0C;IACjE,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,UAAU,CAAa;gBAG7B,IAAI,GAAE,MAAkB,EACxB,SAAS,GAAE,OAAe,EAC1B,OAAO,GAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO;IAQvD,iFAAiF;IAC3E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C9B,oGAAoG;IACpG,OAAO,CAAC,mBAAmB;IAwB3B,OAAO,CAAC,uBAAuB;IAsC/B,UAAU,IAAI,IAAI;IAalB,WAAW,IAAI,OAAO;IAItB,OAAO,IAAI,MAAM;IAIX,WAAW,CACf,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACnC,OAAO,CAAC,OAAO,CAAC;IA8BnB,OAAO,CAAC,aAAa;IA6CrB,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,cAAc;IA4BtB,OAAO,CAAC,aAAa;CAMtB"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js new file mode 100644 index 0000000..df5ae56 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js @@ -0,0 +1,226 @@ +import { WebSocketServer, WebSocket } from "ws"; +import { randomUUID } from "crypto"; +import { GodotConnectionError, GodotCommandError, TimeoutError, } from "./utils/errors.js"; +const BASE_PORT = 6505; +const MAX_PORT = 6509; +const COMMAND_TIMEOUT_MS = 30000; +const HEARTBEAT_INTERVAL_MS = 10000; +const HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 3; +const TCP_KEEPALIVE_DELAY_MS = 5000; +export class GodotConnection { + wss = null; + client = null; + port; + fixedPort; + basePort; + maxPort; + pendingRequests = new Map(); + heartbeatTimer = null; + lastPongAt = 0; + constructor(port = BASE_PORT, fixedPort = false, options = {}) { + this.port = port; + this.fixedPort = fixedPort; + this.basePort = options.basePort ?? BASE_PORT; + this.maxPort = options.maxPort ?? MAX_PORT; + } + /** Start WebSocket server, retrying on the next port if the first bind races. */ + async connect() { + if (this.wss) + return; + const candidates = this.fixedPort + ? [this.port] + : Array.from({ length: this.maxPort - this.basePort + 1 }, (_, i) => this.basePort + i); + let lastError = null; + for (const port of candidates) { + try { + const wss = await this.bindWebSocketServer(port); + this.wss = wss; + this.port = port; + this.attachConnectionHandler(wss); + console.error(`[MCP] WebSocket server listening on ws://127.0.0.1:${port}`); + return; + } + catch (err) { + lastError = err; + // EADDRINUSE means another MCP server (likely a parallel Claude session) + // won the bind race. Silently try the next port. Other errors are + // logged so we don't swallow real config problems. + if (err.code !== "EADDRINUSE") { + console.error(`[MCP] Bind failed on port ${port}: ${err.message}`); + } + } + } + const range = this.fixedPort + ? String(this.port) + : `${this.basePort}-${this.maxPort}`; + const hint = this.fixedPort + ? "Try removing GODOT_MCP_PORT from your client config to enable auto-scanning, or kill the process holding the port." + : "All ports are occupied — likely too many parallel Claude Code sessions or stale node MCP processes."; + throw new GodotConnectionError(`Failed to bind WebSocket server on port range ${range}. ` + + `Last error: ${lastError?.message ?? "unknown"}. ${hint}`); + } + /** Try to bind a single WebSocketServer. Resolves once 'listening' fires, rejects on bind error. */ + bindWebSocketServer(port) { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ port, host: "127.0.0.1" }); + const onError = (err) => { + wss.off("listening", onListening); + wss.close(); + reject(err); + }; + const onListening = () => { + wss.off("error", onError); + // Re-attach a runtime error handler now that the server is live. + // Pre-bind errors fail the connect attempt; post-bind errors are logged. + wss.on("error", (err) => { + console.error("[MCP] WebSocket server error:", err.message); + }); + resolve(wss); + }; + wss.once("error", onError); + wss.once("listening", onListening); + }); + } + attachConnectionHandler(wss) { + wss.on("connection", (ws) => { + console.error("[MCP] Godot editor connected"); + // Enable OS-level TCP keepalive so half-open sockets surface faster + // than the Windows default (~2 hours). Application-level heartbeat + // below is still the primary detection mechanism. + const sock = ws._socket; + sock?.setKeepAlive?.(true, TCP_KEEPALIVE_DELAY_MS); + if (this.client) { + this.client.close(1000, "Replaced by new connection"); + } + this.client = ws; + this.lastPongAt = Date.now(); + this.startHeartbeat(); + ws.on("message", (data) => { + this.handleMessage(data.toString()); + }); + ws.on("close", () => { + console.error("[MCP] Godot editor disconnected"); + if (this.client === ws) { + this.client = null; + this.stopHeartbeat(); + this.rejectAllPending(new GodotConnectionError("Godot disconnected")); + } + }); + ws.on("error", (err) => { + console.error("[MCP] WebSocket error:", err.message); + }); + }); + } + disconnect() { + this.stopHeartbeat(); + if (this.client) { + this.client.close(1000, "Server shutting down"); + this.client = null; + } + if (this.wss) { + this.wss.close(); + this.wss = null; + } + this.rejectAllPending(new GodotConnectionError("Server shut down")); + } + isConnected() { + return this.client?.readyState === WebSocket.OPEN; + } + getPort() { + return this.port; + } + async sendCommand(method, params = {}) { + if (!this.isConnected()) { + throw new GodotConnectionError("Godot editor is not connected. Make sure the Godot MCP Pro plugin is enabled and the editor is running."); + } + const id = randomUUID(); + const request = { + jsonrpc: "2.0", + method, + params, + id, + }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new TimeoutError(method, COMMAND_TIMEOUT_MS)); + }, COMMAND_TIMEOUT_MS); + this.pendingRequests.set(id, { + resolve: resolve, + reject, + timer, + }); + this.client.send(JSON.stringify(request)); + }); + } + handleMessage(data) { + let msg; + try { + msg = JSON.parse(data); + } + catch { + console.error("[MCP] Failed to parse message from Godot:", data); + return; + } + const method = msg.method; + if (method === "pong") { + this.lastPongAt = Date.now(); + return; + } + // Godot may also send unsolicited pings — reply so its inactivity timer resets + if (method === "ping") { + this.lastPongAt = Date.now(); + if (this.isConnected()) { + this.client.send(JSON.stringify({ jsonrpc: "2.0", method: "pong", params: {} })); + } + return; + } + if (!msg.id) + return; + const pending = this.pendingRequests.get(msg.id); + if (!pending) + return; + clearTimeout(pending.timer); + this.pendingRequests.delete(msg.id); + if (msg.error) { + pending.reject(new GodotCommandError(msg.error.code, msg.error.message, msg.error.data)); + } + else { + pending.resolve(msg.result); + } + } + rejectAllPending(error) { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + } + startHeartbeat() { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (!this.isConnected()) + return; + // If Godot has been silent for too long, the socket is likely half-open. + // terminate() forcibly destroys the TCP socket (vs close() which waits + // for a FIN ack that will never arrive on a dead link). + if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) { + console.error(`[MCP] Heartbeat timeout (no pong for ${HEARTBEAT_TIMEOUT_MS}ms) — terminating dead connection`); + const dead = this.client; + this.client = null; + this.stopHeartbeat(); + this.rejectAllPending(new GodotConnectionError("Heartbeat timeout — Godot connection lost")); + dead?.terminate(); + return; + } + this.client.send(JSON.stringify({ jsonrpc: "2.0", method: "ping", params: {} })); + }, HEARTBEAT_INTERVAL_MS); + } + stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } +} +//# sourceMappingURL=godot-connection.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js.map new file mode 100644 index 0000000..cf81ab6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/godot-connection.js.map @@ -0,0 +1 @@ +{"version":3,"file":"godot-connection.js","sourceRoot":"","sources":["../src/godot-connection.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts new file mode 100644 index 0000000..dc1ec89 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts.map new file mode 100644 index 0000000..535b86d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/index.js b/Tools/godot-mcp-pro-v1.15.1/server/build/index.js new file mode 100644 index 0000000..2a34b43 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/index.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { GodotConnection } from "./godot-connection.js"; +import { registerProjectTools } from "./tools/project-tools.js"; +import { registerSceneTools } from "./tools/scene-tools.js"; +import { registerNodeTools } from "./tools/node-tools.js"; +import { registerScriptTools } from "./tools/script-tools.js"; +import { registerEditorTools } from "./tools/editor-tools.js"; +import { registerInputTools } from "./tools/input-tools.js"; +import { registerRuntimeTools } from "./tools/runtime-tools.js"; +import { registerAnimationTools } from "./tools/animation-tools.js"; +import { registerTilemapTools } from "./tools/tilemap-tools.js"; +import { registerThemeTools } from "./tools/theme-tools.js"; +import { registerProfilingTools } from "./tools/profiling-tools.js"; +import { registerBatchTools } from "./tools/batch-tools.js"; +import { registerShaderTools } from "./tools/shader-tools.js"; +import { registerExportTools } from "./tools/export-tools.js"; +import { registerResourceTools } from "./tools/resource-tools.js"; +import { registerAnimationTreeTools } from "./tools/animation-tree-tools.js"; +import { registerPhysicsTools } from "./tools/physics-tools.js"; +import { registerScene3DTools } from "./tools/scene-3d-tools.js"; +import { registerParticleTools } from "./tools/particle-tools.js"; +import { registerNavigationTools } from "./tools/navigation-tools.js"; +import { registerAudioTools } from "./tools/audio-tools.js"; +import { registerTestTools } from "./tools/test-tools.js"; +import { registerAnalysisTools } from "./tools/analysis-tools.js"; +import { registerInputMapTools } from "./tools/input-map-tools.js"; +import { registerAndroidTools } from "./tools/android-tools.js"; +import { MINIMAL_TOOLS, createFilteredServer } from "./utils/tool-filter.js"; +import { loadInstructions } from "./utils/load-instructions.js"; +const MINIMAL_MODE = process.argv.includes("--minimal"); +const THREED_MODE = process.argv.includes("--3d"); +const LITE_MODE = process.argv.includes("--lite") || MINIMAL_MODE || THREED_MODE; +const HTTP_MODE = process.argv.includes("--http"); +const HTTP_PORT = parseInt(process.argv.find((_, i, a) => a[i - 1] === "--http-port") || + process.env.GODOT_MCP_HTTP_PORT || + "8001"); +const explicitPort = process.env.GODOT_MCP_PORT; +const godot = new GodotConnection(parseInt(explicitPort || "6505"), !!explicitPort); +const serverName = MINIMAL_MODE + ? "godot-mcp-pro-minimal" + : THREED_MODE + ? "godot-mcp-pro-3d" + : LITE_MODE + ? "godot-mcp-pro-lite" + : "godot-mcp-pro"; +const server = new McpServer({ + name: serverName, + version: "1.15.1", +}, { + instructions: loadInstructions(), +}); +// In minimal mode, wrap the server to filter tool registrations +const toolServer = MINIMAL_MODE ? createFilteredServer(server, MINIMAL_TOOLS) : server; +// Core tools (always registered) +registerProjectTools(toolServer, godot); +registerSceneTools(toolServer, godot); +registerNodeTools(toolServer, godot); +registerScriptTools(toolServer, godot); +registerEditorTools(toolServer, godot); +registerInputTools(toolServer, godot); +registerRuntimeTools(toolServer, godot); +registerInputMapTools(toolServer, godot); +// 3D-critical tools (registered in FULL and --3d modes) +// Core (81) + Physics (6) + AnimationTree (8) + Navigation (5) = exactly 100 tools +if (!LITE_MODE || THREED_MODE) { + registerPhysicsTools(server, godot); + registerAnimationTreeTools(server, godot); + registerNavigationTools(server, godot); +} +// Extended tools (Full mode only) +if (!LITE_MODE) { + registerAnimationTools(server, godot); + registerAudioTools(server, godot); + registerBatchTools(server, godot); + registerExportTools(server, godot); + registerParticleTools(server, godot); + registerProfilingTools(server, godot); + registerResourceTools(server, godot); + registerScene3DTools(server, godot); + registerShaderTools(server, godot); + registerTestTools(server, godot); + registerThemeTools(server, godot); + registerTilemapTools(server, godot); + registerAnalysisTools(server, godot); + registerAndroidTools(server, godot); +} +// Start server +async function main() { + // Attempt initial connection to Godot (non-blocking). + // If this fails (all ports occupied, etc.), tool calls will fail with a + // clear error message from sendCommand until the user restarts the server. + godot.connect().catch((err) => { + console.error(`[MCP] Failed to start WebSocket server: ${err.message}`); + }); + if (HTTP_MODE) { + // Streamable HTTP transport — clients connect via http://host:port/mcp + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + await server.connect(transport); + const httpServer = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + if (url.pathname === "/mcp") { + await transport.handleRequest(req, res); + } + else { + res.writeHead(404).end("Not Found"); + } + }); + httpServer.listen(HTTP_PORT, () => { + const mode = MINIMAL_MODE ? "MINIMAL " : THREED_MODE ? "3D " : LITE_MODE ? "LITE " : ""; + console.error(`[MCP] Godot MCP Pro ${mode}started (HTTP transport on http://127.0.0.1:${HTTP_PORT}/mcp)`); + }); + } + else { + // Default stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + const modeLabel = MINIMAL_MODE + ? "[MCP] Godot MCP Pro MINIMAL started (35 tools, stdio transport)" + : THREED_MODE + ? "[MCP] Godot MCP Pro 3D started (103 tools, stdio transport)" + : LITE_MODE + ? "[MCP] Godot MCP Pro LITE started (84 tools, stdio transport)" + : "[MCP] Godot MCP Pro started (stdio transport)"; + console.error(modeLabel); + } +} +main().catch((err) => { + console.error("[MCP] Fatal error:", err); + process.exit(1); +}); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/index.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/index.js.map new file mode 100644 index 0000000..f31d634 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts new file mode 100644 index 0000000..1576628 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +/** + * godot-mcp-setup — Setup and management CLI for Godot MCP Pro + * + * Commands: + * install Install dependencies and build the server + * check-update Check if a newer version is available on GitHub + * configure Auto-detect AI client and generate MCP config + * doctor Diagnose environment (Node.js, npm, build status) + */ +export {}; +//# sourceMappingURL=setup.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts.map new file mode 100644 index 0000000..8f45ce2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../src/setup.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/setup.js b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.js new file mode 100644 index 0000000..1c8cb6c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * godot-mcp-setup — Setup and management CLI for Godot MCP Pro + * + * Commands: + * install Install dependencies and build the server + * check-update Check if a newer version is available on GitHub + * configure Auto-detect AI client and generate MCP config + * doctor Diagnose environment (Node.js, npm, build status) + */ +import { execSync } from "child_process"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { resolve, dirname, join } from "path"; +import { fileURLToPath } from "url"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +// Server root is one level up from build/ +const SERVER_DIR = resolve(__dirname, ".."); +const PACKAGE_JSON = join(SERVER_DIR, "package.json"); +const BUILD_INDEX = join(SERVER_DIR, "build", "index.js"); +const GITHUB_REPO = "youichi-uda/godot-mcp-pro"; +// ─── Utilities ──────────────────────────────────────────────── +function getVersion() { + try { + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")); + return pkg.version || "unknown"; + } + catch { + return "unknown"; + } +} +function run(cmd, cwd) { + try { + return execSync(cmd, { + cwd: cwd || SERVER_DIR, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } + catch (err) { + return err.stderr?.trim() || err.message || "command failed"; + } +} +function check(label, ok, detail) { + const icon = ok ? "✓" : "✗"; + const line = detail ? `${label}: ${detail}` : label; + console.log(` ${icon} ${line}`); +} +/** Compare semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal. */ +function compareSemver(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) + return diff; + } + return 0; +} +// ─── Commands ───────────────────────────────────────────────── +async function cmdInstall() { + console.log("Installing Godot MCP Pro server...\n"); + console.log("[1/2] Installing dependencies..."); + try { + execSync("npm install", { cwd: SERVER_DIR, stdio: "inherit" }); + } + catch { + console.error("\nFailed to install dependencies. Make sure npm is available."); + process.exit(1); + } + console.log("\n[2/2] Building server..."); + try { + execSync("npm run build", { cwd: SERVER_DIR, stdio: "inherit" }); + } + catch { + console.error("\nBuild failed. Check for TypeScript errors above."); + process.exit(1); + } + console.log(`\nDone! Server built at: ${BUILD_INDEX}`); + console.log(`Version: ${getVersion()}`); + console.log("\nNext step: Run 'node build/setup.js configure' to set up your AI client."); +} +async function cmdCheckUpdate() { + const current = getVersion(); + console.log(`Current version: ${current}\n`); + console.log(`Checking GitHub releases for ${GITHUB_REPO}...`); + try { + const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { headers: { "User-Agent": "godot-mcp-pro-setup" } }); + if (!res.ok) { + if (res.status === 404) { + console.log("No releases found on GitHub."); + return; + } + console.error(`GitHub API error: ${res.status} ${res.statusText}`); + return; + } + const data = (await res.json()); + const latest = data.tag_name.replace(/^v/, ""); + if (compareSemver(latest, current) > 0) { + console.log(`\nUpdate available: v${latest} (current: v${current})`); + console.log(`Download: ${data.html_url}`); + console.log("\nTo update: download the new version, replace server/src/, and run 'node build/setup.js install'"); + } + else { + console.log(`\nUp to date! (${current})`); + } + } + catch (err) { + console.error(`Failed to check for updates: ${err.message}`); + } +} +async function cmdConfigure() { + const serverPath = resolve(BUILD_INDEX).replace(/\\/g, "/"); + if (!existsSync(BUILD_INDEX)) { + console.error("Server not built yet. Run 'node build/setup.js install' first."); + process.exit(1); + } + console.log("Detecting AI clients...\n"); + // Detect available clients by checking config file locations + const home = process.env.HOME || process.env.USERPROFILE || ""; + const cwd = process.cwd(); + const candidates = [ + { + name: "Claude Code (project)", + configPath: join(cwd, ".mcp.json"), + configKey: "godot-mcp-pro", + }, + { + name: "Cursor (project)", + configPath: join(cwd, ".cursor", "mcp.json"), + configKey: "godot-mcp-pro", + }, + { + name: "Windsurf (project)", + configPath: join(cwd, ".windsurf", "mcp.json"), + configKey: "godot-mcp-pro", + }, + { + name: "Claude Desktop", + configPath: join(home, process.platform === "win32" + ? "AppData/Roaming/Claude/claude_desktop_config.json" + : process.platform === "darwin" + ? "Library/Application Support/Claude/claude_desktop_config.json" + : ".config/claude/claude_desktop_config.json"), + configKey: "godot-mcp-pro", + }, + ]; + // Find existing configs + const existing = candidates.filter((c) => existsSync(c.configPath)); + const missing = candidates.filter((c) => !existsSync(c.configPath)); + if (existing.length > 0) { + console.log("Found existing configs:"); + for (const c of existing) { + console.log(` ✓ ${c.name}: ${c.configPath}`); + } + } + // Default: create .mcp.json in cwd (Claude Code) + const target = candidates[0]; // Claude Code project-level + // No GODOT_MCP_PORT env: lets the server auto-scan 6505-6509 so multiple + // Claude Code sessions can each grab a free port. Pinning a single port + // here would force every session to collide on 6505. + const entry = { + command: "node", + args: [serverPath], + }; + let config; + if (existsSync(target.configPath)) { + try { + config = JSON.parse(readFileSync(target.configPath, "utf-8")); + if (!config.mcpServers) + config.mcpServers = {}; + } + catch { + config = { mcpServers: {} }; + } + } + else { + config = { mcpServers: {} }; + } + if (config.mcpServers[target.configKey]) { + console.log(`\n${target.name} already configured in ${target.configPath}`); + console.log("Updating server path..."); + } + config.mcpServers[target.configKey] = entry; + const dir = dirname(target.configPath); + if (!existsSync(dir)) + mkdirSync(dir, { recursive: true }); + writeFileSync(target.configPath, JSON.stringify(config, null, 2) + "\n"); + console.log(`\nWrote config to: ${target.configPath}`); + console.log(`Server path: ${serverPath}`); + console.log("\nYou're all set! Start your AI assistant to begin using Godot MCP Pro."); +} +function cmdDoctor() { + console.log("Godot MCP Pro — Environment Check\n"); + // Node.js + const nodeVer = run("node --version"); + const nodeOk = nodeVer.startsWith("v") && parseInt(nodeVer.slice(1)) >= 18; + check("Node.js", nodeOk, nodeVer); + // npm + const npmVer = run("npm --version"); + const npmOk = !npmVer.includes("not found") && !npmVer.includes("failed"); + check("npm", npmOk, npmVer); + // Dependencies installed + const nodeModules = existsSync(join(SERVER_DIR, "node_modules")); + check("Dependencies installed", nodeModules); + // Server built + const built = existsSync(BUILD_INDEX); + check("Server built", built, built ? BUILD_INDEX : "run 'node build/setup.js install'"); + // Version + console.log(`\n Version: ${getVersion()}`); + // Overall + const allOk = nodeOk && npmOk && nodeModules && built; + console.log(allOk ? "\nAll good!" : "\nSome issues found. Fix them above."); + if (!allOk) + process.exit(1); +} +// ─── Main ───────────────────────────────────────────────────── +function showHelp() { + console.log(`godot-mcp-setup — Setup and management for Godot MCP Pro + +Usage: node build/setup.js + +Commands: + install Install dependencies and build the server + check-update Check if a newer version is available on GitHub + configure Auto-detect AI client and generate .mcp.json config + doctor Check Node.js, npm, and build status + +Options: + --help Show this help + --version Show current version + +Examples: + node build/setup.js install + node build/setup.js doctor + node build/setup.js configure + node build/setup.js check-update`); +} +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + if (!cmd || cmd === "--help" || cmd === "-h") { + showHelp(); + process.exit(0); + } + if (cmd === "--version" || cmd === "-v") { + console.log(getVersion()); + process.exit(0); + } + switch (cmd) { + case "install": + await cmdInstall(); + break; + case "check-update": + await cmdCheckUpdate(); + break; + case "configure": + await cmdConfigure(); + break; + case "doctor": + cmdDoctor(); + break; + default: + console.error(`Unknown command: ${cmd}`); + showHelp(); + process.exit(1); + } +} +main().catch((err) => { + console.error("Fatal:", err.message); + process.exit(1); +}); +//# sourceMappingURL=setup.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/setup.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.js.map new file mode 100644 index 0000000..ea9ef8c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/setup.js.map @@ -0,0 +1 @@ +{"version":3,"file":"setup.js","sourceRoot":"","sources":["../src/setup.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts new file mode 100644 index 0000000..6f1ce88 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerAnalysisTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=analysis-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts.map new file mode 100644 index 0000000..fb1f903 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"analysis-tools.d.ts","sourceRoot":"","sources":["../../src/tools/analysis-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAmGN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js new file mode 100644 index 0000000..7db32cb --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerAnalysisTools(server, godot) { + server.tool("find_unused_resources", "Scan the project for resource files (.tres, .tscn, .png, .wav, .ogg, .ttf, .gdshader, etc.) that are not referenced by any .tscn, .gd, or .tres file. Useful for cleaning up unused assets.", { + path: z.string().optional().describe("Root path to scan (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in scan (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_unused_resources", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("analyze_signal_flow", "Map all signal connections in the currently edited scene. Returns a graph-like structure showing which nodes emit which signals and which nodes receive them.", {}, async () => { + try { + const result = await godot.sendCommand("analyze_signal_flow"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("analyze_scene_complexity", "Analyze a scene's complexity: total node count, max nesting depth, nodes grouped by type, attached scripts, and potential issues (too many nodes, deep nesting).", { + path: z.string().optional().describe("Scene path to analyze (default: currently edited scene)"), + }, async (params) => { + try { + const result = await godot.sendCommand("analyze_scene_complexity", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("find_script_references", "Find all places where a given script path, class_name, or resource path is referenced across the project. Searches .tscn, .gd, and .tres files.", { + query: z.string().describe("The script path, class_name, or resource path to search for (e.g. 'res://scripts/player.gd', 'PlayerController', 'res://assets/icon.png')"), + path: z.string().optional().describe("Root path to search (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in search (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_script_references", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("detect_circular_dependencies", "Check for circular scene dependencies where Scene A instances Scene B which instances Scene A (directly or indirectly). Walks all .tscn files and builds a dependency graph.", { + path: z.string().optional().describe("Root path to scan (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in scan (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("detect_circular_dependencies", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_project_statistics", "Get overall project statistics: file counts by extension, total script lines, scene count, resource count, autoload list, and enabled plugins.", { + path: z.string().optional().describe("Root path to scan (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in statistics (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_project_statistics", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=analysis-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js.map new file mode 100644 index 0000000..ba3856d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/analysis-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"analysis-tools.js","sourceRoot":"","sources":["../../src/tools/analysis-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts new file mode 100644 index 0000000..94e50c6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerAndroidTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=android-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts.map new file mode 100644 index 0000000..2f2ca7b --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"android-tools.d.ts","sourceRoot":"","sources":["../../src/tools/android-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAoDN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js new file mode 100644 index 0000000..8cf7d23 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerAndroidTools(server, godot) { + server.tool("list_android_devices", "List Android devices visible to adb (parses 'adb devices -l'). Uses the path configured in Editor Settings > Export > Android > Adb, falls back to 'adb' on PATH.", {}, async () => { + try { + const result = await godot.sendCommand("list_android_devices"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_android_preset_info", "Read metadata (package name, export path, runnable flag) from an Android export preset in export_presets.cfg. If no preset is specified, returns the first Android preset.", { + preset_name: z.string().optional().describe("Preset name as shown in Project > Export"), + preset_index: z.number().optional().describe("Preset index (alternative to name)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_android_preset_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("deploy_to_android", "Export APK via Godot CLI, install it on a connected Android device via adb, and optionally launch the main activity. Equivalent to Godot's Remote Deploy button. Requires a configured Android export preset and adb on PATH (or set in Editor Settings). This call is synchronous and may take tens of seconds to complete.", { + preset_name: z.string().optional().describe("Android export preset name (defaults to first Android preset)"), + preset_index: z.number().optional().describe("Preset index (alternative to name)"), + device_serial: z.string().optional().describe("adb device serial (omit to use default device)"), + debug: z.boolean().optional().describe("Debug export (default: true)"), + launch: z.boolean().optional().describe("Launch the app after install (default: true)"), + skip_export: z.boolean().optional().describe("Skip the export step and install the existing APK at the preset's export_path (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("deploy_to_android", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=android-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js.map new file mode 100644 index 0000000..3ee0135 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/android-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"android-tools.js","sourceRoot":"","sources":["../../src/tools/android-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts new file mode 100644 index 0000000..9ce073e --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerAnimationTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=animation-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts.map new file mode 100644 index 0000000..eeda473 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"animation-tools.d.ts","sourceRoot":"","sources":["../../src/tools/animation-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA8GN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js new file mode 100644 index 0000000..c8aa7fe --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerAnimationTools(server, godot) { + server.tool("list_animations", "List all animations in an AnimationPlayer node", { + node_path: z.string().describe("Path to the AnimationPlayer node"), + }, async (params) => { + try { + const result = await godot.sendCommand("list_animations", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("create_animation", "Create a new animation in an AnimationPlayer", { + node_path: z.string().describe("Path to the AnimationPlayer node"), + name: z.string().describe("Name for the new animation"), + length: z.number().optional().describe("Animation length in seconds (default: 1.0)"), + loop_mode: z.number().optional().describe("Loop mode: 0=none, 1=linear, 2=pingpong (default: 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("create_animation", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_animation_track", "Add a track to an animation (value, position, rotation, scale, method, bezier)", { + node_path: z.string().describe("Path to the AnimationPlayer node"), + animation: z.string().describe("Animation name"), + track_path: z.string().describe("Node path and property for the track (e.g. 'Sprite2D:position')"), + track_type: z.string().optional().describe("Track type: value, position_2d, rotation_2d, scale_2d, method, bezier, blend_shape (default: value)"), + update_mode: z.string().optional().describe("Update mode for value tracks: continuous, discrete, capture"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_animation_track", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_animation_keyframe", "Insert a keyframe into an animation track", { + node_path: z.string().describe("Path to the AnimationPlayer node"), + animation: z.string().describe("Animation name"), + track_index: z.number().describe("Track index"), + time: z.number().describe("Time position in seconds"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Keyframe value. Strings auto-parsed for Vector2, Color, etc."), + easing: z.number().optional().describe("Easing/transition value. 1.0=linear, <1.0=ease-in, >1.0=ease-out. Use negative for in-out variants. (default: 1.0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_animation_keyframe", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_animation_info", "Get detailed info about an animation including all tracks and keyframes", { + node_path: z.string().describe("Path to the AnimationPlayer node"), + animation: z.string().describe("Animation name"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_animation_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("remove_animation", "Remove an animation from an AnimationPlayer", { + node_path: z.string().describe("Path to the AnimationPlayer node"), + name: z.string().describe("Name of the animation to remove"), + }, async (params) => { + try { + const result = await godot.sendCommand("remove_animation", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=animation-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js.map new file mode 100644 index 0000000..2e2e435 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"animation-tools.js","sourceRoot":"","sources":["../../src/tools/animation-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,sBAAsB,CACpC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,gDAAgD,EAChD;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;KACnE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;YAClE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,kBAAkB,EAClB,8CAA8C,EAC9C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,4BAA4B,CAAC;QACvD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4CAA4C,CAAC;QACpF,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;KAClG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;YACnE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,qBAAqB,EACrB,gFAAgF,EAChF;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;QAChD,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,iEAAiE,CAAC;QAClG,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qGAAqG,CAAC;QACjJ,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,6DAA6D,CAAC;KAC3G,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;YACtE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,wBAAwB,EACxB,2CAA2C,EAC3C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;QAChD,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,aAAa,CAAC;QAC/C,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QACrD,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,8DAA8D,CAAC;QAC9H,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oHAAoH,CAAC;KAC7J,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAAC;YACzE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,yEAAyE,EACzE;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;KACjD,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,kBAAkB,EAClB,6CAA6C,EAC7C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;KAC7D,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;YACnE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts new file mode 100644 index 0000000..c1e54b4 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerAnimationTreeTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=animation-tree-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts.map new file mode 100644 index 0000000..45b844c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"animation-tree-tools.d.ts","sourceRoot":"","sources":["../../src/tools/animation-tree-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA+JN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js new file mode 100644 index 0000000..d1f43b7 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js @@ -0,0 +1,124 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerAnimationTreeTools(server, godot) { + server.tool("create_animation_tree", "Create an AnimationTree node with an AnimationNodeStateMachine as root, optionally linked to an AnimationPlayer", { + node_path: z.string().describe("Path to the parent node where the AnimationTree will be added"), + anim_player: z.string().optional().describe("Relative path from the AnimationTree to the AnimationPlayer (e.g. '../AnimationPlayer')"), + name: z.string().optional().describe("Name for the AnimationTree node (default: 'AnimationTree')"), + }, async (params) => { + try { + const result = await godot.sendCommand("create_animation_tree", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_animation_tree_structure", "Read the full structure of an AnimationTree including all states, transitions, and blend tree nodes", { + node_path: z.string().describe("Path to the AnimationTree node"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_animation_tree_structure", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_state_machine_state", "Add a state to an AnimationNodeStateMachine (animation clip, blend tree, or nested state machine)", { + node_path: z.string().describe("Path to the AnimationTree node"), + state_name: z.string().describe("Name for the new state"), + state_type: z.enum(["animation", "blend_tree", "state_machine"]).optional().describe("Type of state: 'animation' (default), 'blend_tree', or 'state_machine'"), + animation: z.string().optional().describe("Animation name to play (only for state_type='animation')"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine (e.g. 'Run/SubState'). Empty or omit for root."), + position_x: z.number().optional().describe("X position in the graph editor (default: 0)"), + position_y: z.number().optional().describe("Y position in the graph editor (default: 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_state_machine_state", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("remove_state_machine_state", "Remove a state from an AnimationNodeStateMachine (also removes connected transitions)", { + node_path: z.string().describe("Path to the AnimationTree node"), + state_name: z.string().describe("Name of the state to remove"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + }, async (params) => { + try { + const result = await godot.sendCommand("remove_state_machine_state", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_state_machine_transition", "Add a transition between two states in an AnimationNodeStateMachine with configurable switch mode, advance mode, and expression conditions", { + node_path: z.string().describe("Path to the AnimationTree node"), + from_state: z.string().describe("Source state name (use 'Start' for the entry point)"), + to_state: z.string().describe("Destination state name (use 'End' for the exit point)"), + switch_mode: z.enum(["at_end", "immediate", "sync"]).optional().describe("When to switch: 'at_end' (wait for animation), 'immediate' (default), 'sync'"), + advance_mode: z.enum(["disabled", "enabled", "auto"]).optional().describe("How to advance: 'disabled', 'enabled' (default, uses travel), 'auto' (automatic)"), + advance_expression: z.string().optional().describe("GDScript expression that triggers this transition (e.g. 'is_running')"), + xfade_time: z.number().optional().describe("Cross-fade time in seconds"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + }, async (params) => { + try { + const result = await godot.sendCommand("add_state_machine_transition", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("remove_state_machine_transition", "Remove a transition between two states in an AnimationNodeStateMachine", { + node_path: z.string().describe("Path to the AnimationTree node"), + from_state: z.string().describe("Source state name"), + to_state: z.string().describe("Destination state name"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + }, async (params) => { + try { + const result = await godot.sendCommand("remove_state_machine_transition", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_blend_tree_node", "Add or replace a node inside an AnimationNodeBlendTree state (Add2, Blend2, TimeScale, Animation, etc.) with optional connection", { + node_path: z.string().describe("Path to the AnimationTree node"), + blend_tree_state: z.string().describe("Name of the BlendTree state in the state machine"), + bt_node_name: z.string().describe("Name for the node inside the BlendTree"), + bt_node_type: z.enum(["Animation", "Add2", "Blend2", "Add3", "Blend3", "TimeScale", "TimeSeek", "Transition", "OneShot", "Sub2"]).describe("Type of BlendTree node to create"), + animation: z.string().optional().describe("Animation name (only for bt_node_type='Animation')"), + connect_to: z.string().optional().describe("Name of another BlendTree node to connect this node's output to"), + connect_port: z.number().optional().describe("Input port index on the target node (default: 0)"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + position_x: z.number().optional().describe("X position in the graph editor (default: 0)"), + position_y: z.number().optional().describe("Y position in the graph editor (default: 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_blend_tree_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_tree_parameter", "Set an AnimationTree parameter value (conditions, blend amounts, time scale, etc.)", { + node_path: z.string().describe("Path to the AnimationTree node"), + parameter: z.string().describe("Parameter path (e.g. 'conditions/is_running', 'Blend2/blend_amount'). 'parameters/' prefix is auto-added if missing."), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Parameter value. Strings are auto-parsed for Vector2, Color, etc."), + }, async (params) => { + try { + const result = await godot.sendCommand("set_tree_parameter", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=animation-tree-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js.map new file mode 100644 index 0000000..e6e08e7 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/animation-tree-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"animation-tree-tools.js","sourceRoot":"","sources":["../../src/tools/animation-tree-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts new file mode 100644 index 0000000..73a0b87 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerAudioTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=audio-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts.map new file mode 100644 index 0000000..c95e492 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"audio-tools.d.ts","sourceRoot":"","sources":["../../src/tools/audio-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAsHN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js new file mode 100644 index 0000000..ad5852d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerAudioTools(server, godot) { + server.tool("get_audio_bus_layout", "Get the entire audio bus layout: all buses with volumes, effects, send targets, solo/mute states", {}, async (params) => { + try { + const result = await godot.sendCommand("get_audio_bus_layout", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_audio_bus", "Add a new audio bus with name, volume, send target, solo, and mute settings", { + name: z.string().describe("Name for the new audio bus"), + volume_db: z.number().optional().describe("Volume in dB (default: 0)"), + send: z.string().optional().describe("Name of the bus to send output to (e.g. 'Master')"), + solo: z.boolean().optional().describe("Solo this bus (default: false)"), + mute: z.boolean().optional().describe("Mute this bus (default: false)"), + at_position: z.number().optional().describe("Bus index position to insert at (-1 = end)"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_audio_bus", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_audio_bus", "Modify an existing audio bus: volume, solo, mute, bypass_effects, send, or rename", { + name: z.string().describe("Name of the audio bus to modify"), + volume_db: z.number().optional().describe("Volume in dB"), + solo: z.boolean().optional().describe("Solo state"), + mute: z.boolean().optional().describe("Mute state"), + bypass_effects: z.boolean().optional().describe("Bypass all effects on this bus"), + send: z.string().optional().describe("Name of the bus to send output to"), + rename: z.string().optional().describe("New name for the bus"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_audio_bus", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_audio_bus_effect", "Add an audio effect to a bus. Types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq", { + bus: z.string().describe("Name of the audio bus"), + effect_type: z.string().describe("Effect type: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter (or lowpass), highpassfilter (or highpass), bandpassfilter (or bandpass), amplify, eq"), + params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("Effect-specific parameters. E.g. for reverb: {room_size, damping, wet, dry, spread}; for compressor: {threshold, ratio, attack_us, release_ms}; for filters: {cutoff_hz, resonance}"), + at_position: z.number().optional().describe("Effect index position (-1 = end)"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_audio_bus_effect", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_audio_player", "Add an AudioStreamPlayer, AudioStreamPlayer2D, or AudioStreamPlayer3D node to a parent node", { + node_path: z.string().describe("Path to the parent node"), + name: z.string().describe("Name for the new audio player node"), + type: z.string().optional().describe("Player type: AudioStreamPlayer (default), AudioStreamPlayer2D, AudioStreamPlayer3D"), + stream: z.string().optional().describe("Path to audio resource (e.g. 'res://audio/music.ogg')"), + volume_db: z.number().optional().describe("Volume in dB (default: 0)"), + bus: z.string().optional().describe("Audio bus name (default: 'Master')"), + autoplay: z.boolean().optional().describe("Auto-play when scene starts (default: false)"), + max_distance: z.number().optional().describe("Maximum hearing distance (for 2D/3D players)"), + attenuation: z.number().optional().describe("Distance attenuation factor (for 2D players)"), + attenuation_model: z.number().optional().describe("Attenuation model for 3D: 0=inverse_distance, 1=inverse_square, 2=logarithmic"), + unit_size: z.number().optional().describe("Unit size for 3D player volume reference"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_audio_player", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_audio_info", "Get audio setup for a node subtree: finds all AudioStreamPlayer nodes with their settings, streams, and bus assignments", { + node_path: z.string().describe("Path to the root node to search within"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_audio_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=audio-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js.map new file mode 100644 index 0000000..c49d420 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/audio-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"audio-tools.js","sourceRoot":"","sources":["../../src/tools/audio-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts new file mode 100644 index 0000000..6de8831 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerBatchTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=batch-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts.map new file mode 100644 index 0000000..df669e1 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"batch-tools.d.ts","sourceRoot":"","sources":["../../src/tools/batch-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA+HN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js new file mode 100644 index 0000000..74a87de --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerBatchTools(server, godot) { + server.tool("find_nodes_by_type", "Find all nodes of a specific type in the current scene", { + type: z.string().describe("Node type/class to search for (e.g. 'Sprite2D', 'Label', 'CollisionShape2D')"), + recursive: z.boolean().optional().describe("Search recursively through children (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_nodes_by_type", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("find_signal_connections", "Find all signal connections in the current scene, optionally filtered by signal name or node", { + signal_name: z.string().optional().describe("Filter by signal name (partial match)"), + node_path: z.string().optional().describe("Filter by node path (partial match)"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_signal_connections", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("batch_set_property", "Set a property on all nodes of a given type in the current scene", { + type: z.string().describe("Node type to target (e.g. 'Label', 'Sprite2D')"), + property: z.string().describe("Property name to set (e.g. 'visible', 'modulate')"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to set. Strings auto-parsed for Vector2, Color, etc."), + }, async (params) => { + try { + const result = await godot.sendCommand("batch_set_property", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("batch_add_nodes", "Add multiple nodes in a single call. Supports building entire node trees at once — nodes added earlier can be referenced as parents by later entries. Much faster than calling add_node repeatedly.", { + nodes: z.array(z.object({ + type: z.string().describe("Node type (e.g. 'Sprite2D', 'CharacterBody2D', 'Label')"), + parent_path: z.string().optional().describe("Parent node path (default: root '.'). Can reference nodes created earlier in this batch."), + name: z.string().optional().describe("Node name"), + properties: z.record(z.string(), z.any()).optional().describe("Properties to set (e.g. {\"position\": \"Vector2(100, 200)\"})"), + })).describe("Array of node definitions to add, processed in order"), + }, async (params) => { + try { + const result = await godot.sendCommand("batch_add_nodes", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("find_node_references", "Search through project files (.tscn, .gd, .tres, .gdshader) for a text pattern", { + pattern: z.string().describe("Text pattern to search for"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_node_references", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_scene_dependencies", "Get all resource dependencies of a scene or resource file", { + path: z.string().describe("Path to the scene or resource file (e.g. 'res://scenes/player.tscn')"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_scene_dependencies", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("cross_scene_set_property", "Preview or apply a property change on all nodes of a given type across scene files in the project. Defaults to dry_run=true (returns the matching scenes and node paths without writing). To actually apply: pass force=true AND dry_run=false. Inactive open scenes are skipped and reported in skipped_open_scenes — open them as the active tab first to live-edit. The active open scene is live-edited via UndoRedo so changes are visible in the editor and undoable. Closed scenes are offline-saved. The response includes a per-scene `mode` field: dry_run / offline_saved / live_open_scene.", { + type: z.string().describe("Node type to target (e.g. 'Label', 'Sprite2D')"), + property: z.string().describe("Property name to set"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to set. Strings auto-parsed for Vector2, Color, etc."), + path_filter: z.string().optional().describe("Directory to search in (default: 'res://')"), + exclude_addons: z.boolean().optional().describe("Exclude addons/ directory (default: true)"), + dry_run: z.boolean().optional().describe("Preview only — list affected scenes and nodes without writing. Defaults to true unless force=true is set."), + force: z.boolean().optional().describe("Required (alongside dry_run=false) to actually write. Acknowledges that this can modify many scene files at once."), + }, async (params) => { + try { + const result = await godot.sendCommand("cross_scene_set_property", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=batch-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js.map new file mode 100644 index 0000000..51a9f20 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/batch-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"batch-tools.js","sourceRoot":"","sources":["../../src/tools/batch-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts new file mode 100644 index 0000000..ce4dd04 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerEditorTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=editor-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts.map new file mode 100644 index 0000000..6bff75e --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"editor-tools.d.ts","sourceRoot":"","sources":["../../src/tools/editor-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA2SN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js new file mode 100644 index 0000000..e7e5236 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js @@ -0,0 +1,231 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerEditorTools(server, godot) { + server.tool("get_editor_errors", "Get recent errors and stack traces from the Godot editor log", { + max_lines: z.number().optional().describe("Maximum log lines to scan for errors (default: 50)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_editor_errors", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_output_log", "Read the full Godot editor Output panel content. Unlike get_editor_errors which filters for errors only, this returns all output including print() statements and warnings.", { + max_lines: z.number().optional().describe("Maximum number of lines to return from the end (default: 100)"), + filter: z.string().optional().describe("Filter lines containing this substring (case-sensitive)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_output_log", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_editor_screenshot", "Capture a screenshot of the Godot editor's 2D/3D viewport", { + save_path: z.string().optional().describe("Optional res:// or user:// path to save the screenshot as PNG file (e.g. 'res://screenshot.png'). When provided, the image is saved to disk and the file path is returned instead of base64 data."), + }, async (params) => { + try { + const result = await godot.sendCommand("get_editor_screenshot", params); + if (result && typeof result === "object" && "saved_path" in result) { + return { + content: [ + { + type: "text", + text: `Screenshot saved: ${result.saved_path} (${result.width}x${result.height})`, + }, + ], + }; + } + if (result && typeof result === "object" && "image_base64" in result) { + return { + content: [ + { + type: "image", + data: result.image_base64, + mimeType: "image/png", + }, + { + type: "text", + text: `Screenshot captured: ${result.width}x${result.height}`, + }, + ], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_game_screenshot", "Capture a single screenshot of the running game (requires a scene to be playing). Good for checking static visual state (UI layout, scene composition, colors). For verifying animations or movement, use capture_frames instead — a single screenshot cannot confirm whether an animation is playing.", { + save_path: z.string().optional().describe("Optional res:// or user:// path to save the screenshot as PNG file (e.g. 'res://screenshot.png'). When provided, the image is saved to disk and the file path is returned instead of base64 data."), + }, async (params) => { + try { + const result = await godot.sendCommand("get_game_screenshot", params); + if (result && typeof result === "object" && "saved_path" in result) { + return { + content: [ + { + type: "text", + text: `Game screenshot saved: ${result.saved_path} (${result.width}x${result.height})${result.note ? ` (${result.note})` : ""}`, + }, + ], + }; + } + if (result && typeof result === "object" && "image_base64" in result) { + return { + content: [ + { + type: "image", + data: result.image_base64, + mimeType: "image/png", + }, + { + type: "text", + text: `Game screenshot: ${result.width}x${result.height}${result.note ? ` (${result.note})` : ""}`, + }, + ], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("execute_editor_script", "Execute arbitrary GDScript code inside the Godot editor. Use _mcp_print() to output values. By default refuses to execute code that contains direct file/resource write APIs (ResourceSaver.save, FileAccess WRITE, ProjectSettings.save, ConfigFile.save, DirAccess filesystem mutations) because those bypass the per-command open-resource guards. Use the dedicated MCP tools (save_scene, create_script, etc.) for those operations, or pass allow_unsafe_editor_io=true ONLY when you have verified no open editor resource will be overwritten.", { + code: z.string().describe("GDScript code to execute. Use _mcp_print(value) to capture output. " + + "The code runs inside a run() function with access to the full editor API."), + allow_unsafe_editor_io: z.boolean().optional().describe("Override the file-write safety guard. Only set this when you are certain no open scene/script/shader will be overwritten by the script. Prefer the dedicated MCP tools for ordinary save flows."), + }, async (params) => { + try { + const result = await godot.sendCommand("execute_editor_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("clear_output", "Clear the Godot editor output panel", {}, async () => { + try { + const result = await godot.sendCommand("clear_output"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_signals", "Get all signals of a node, including current connections", { + node_path: z.string().describe("Path to the node to inspect"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_signals", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("reload_plugin", "Reload the Godot MCP Pro plugin (disable/re-enable). Connection will briefly drop and auto-reconnect. NOTE: This does NOT reload GDScript preload() caches. If you changed GDScript command files, use execute_editor_script with 'EditorInterface.restart_editor(true)' instead for a full editor restart.", {}, async () => { + try { + const result = await godot.sendCommand("reload_plugin"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("reload_project", "Rescan the Godot project filesystem and reload changed scripts (no reconnection needed)", {}, async () => { + try { + const result = await godot.sendCommand("reload_project"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("compare_screenshots", "Compare two screenshots pixel-by-pixel and return a diff analysis. Returns changed pixel count, diff percentage, and a highlighted diff image. Useful for visual regression testing. Accepts file paths (res://, user://) or base64 PNG strings.", { + image_a: z.string().describe("First image: file path (e.g. 'user://screenshot_a.png') or base64 PNG string"), + image_b: z.string().describe("Second image: file path (e.g. 'user://screenshot_b.png') or base64 PNG string"), + threshold: z.number().optional().describe("Color difference threshold (0-255, default: 10). Pixels with max channel difference below this are considered identical."), + }, async (params) => { + try { + const result = await godot.sendCommand("compare_screenshots", params); + const content = []; + // Add summary text + content.push({ + type: "text", + text: JSON.stringify({ + identical: result.identical, + changed_pixels: result.changed_pixels, + total_pixels: result.total_pixels, + diff_percentage: result.diff_percentage, + threshold: result.threshold, + size: `${result.width}x${result.height}`, + }, null, 2), + }); + // Add diff image if there are differences + if (result.diff_image_base64 && !result.identical) { + content.push({ + type: "image", + data: result.diff_image_base64, + mimeType: "image/png", + }); + } + return { content }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_auto_dismiss", "Enable or disable automatic dismissal of blocking editor dialogs (e.g. 'Reload from disk?', 'Save changes?'). Enable this before operations that modify files externally, and disable when done. Disabled by default.", { + enabled: z.boolean().describe("true to enable auto-dismiss, false to disable"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_auto_dismiss", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_editor_camera", "Get the current 3D editor viewport camera position, rotation, and FOV. Use this to understand the current view before taking editor screenshots.", {}, async () => { + try { + const result = await godot.sendCommand("get_editor_camera"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_editor_camera", "Move the 3D editor viewport camera to a specific position and orientation. Use this to frame a view before taking editor screenshots to validate changes visually.", { + position: z.object({ + x: z.number().describe("X position"), + y: z.number().describe("Y position"), + z: z.number().describe("Z position"), + }).optional().describe("Camera world position"), + rotation_degrees: z.object({ + x: z.number().describe("Pitch (degrees)"), + y: z.number().describe("Yaw (degrees)"), + z: z.number().describe("Roll (degrees)"), + }).optional().describe("Camera rotation in degrees"), + look_at: z.object({ + x: z.number().describe("Target X"), + y: z.number().describe("Target Y"), + z: z.number().describe("Target Z"), + }).optional().describe("Point to look at (overrides rotation_degrees if both set)"), + fov: z.number().optional().describe("Field of view in degrees (default: 75)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_editor_camera", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=editor-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js.map new file mode 100644 index 0000000..d0fd8fa --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/editor-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"editor-tools.js","sourceRoot":"","sources":["../../src/tools/editor-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts new file mode 100644 index 0000000..f142bdc --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerExportTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=export-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts.map new file mode 100644 index 0000000..46f036f --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"export-tools.d.ts","sourceRoot":"","sources":["../../src/tools/export-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA8CN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js new file mode 100644 index 0000000..0104c18 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerExportTools(server, godot) { + server.tool("list_export_presets", "List all export presets configured in export_presets.cfg", {}, async () => { + try { + const result = await godot.sendCommand("list_export_presets"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("export_project", "Get the export command for a preset (direct export from editor is not supported in Godot 4)", { + preset_name: z.string().optional().describe("Export preset name"), + preset_index: z.number().optional().describe("Export preset index (alternative to name)"), + debug: z.boolean().optional().describe("Debug export (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("export_project", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_export_info", "Get export-related project info (executable path, templates, project path)", {}, async () => { + try { + const result = await godot.sendCommand("get_export_info"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=export-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js.map new file mode 100644 index 0000000..3d110f6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/export-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"export-tools.js","sourceRoot":"","sources":["../../src/tools/export-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,mBAAmB,CACjC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,qBAAqB,EACrB,0DAA0D,EAC1D,EAAE,EACF,KAAK,IAAI,EAAE;QACT,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,qBAAqB,CAAC,CAAC;YAC9D,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,gBAAgB,EAChB,6FAA6F,EAC7F;QACE,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oBAAoB,CAAC;QACjE,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,2CAA2C,CAAC;QACzF,KAAK,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8BAA8B,CAAC;KACvE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;YACjE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,4EAA4E,EAC5E,EAAE,EACF,KAAK,IAAI,EAAE;QACT,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,CAAC,CAAC;YAC1D,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts new file mode 100644 index 0000000..2a81a51 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerInputMapTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=input-map-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts.map new file mode 100644 index 0000000..a08b6be --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"input-map-tools.d.ts","sourceRoot":"","sources":["../../src/tools/input-map-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA8CN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js new file mode 100644 index 0000000..3b58251 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerInputMapTools(server, godot) { + server.tool("get_input_actions", "Get all input actions defined in the project's Input Map with their key/button bindings", { + filter: z.string().optional().describe("Filter action names containing this substring"), + include_builtin: z.boolean().optional().describe("Include built-in ui_* actions (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_input_actions", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_input_action", "Create or update an input action with key/mouse/joypad bindings. Saves to project.godot and updates the runtime InputMap.", { + action: z.string().describe("Action name (e.g. 'move_left', 'jump', 'attack')"), + events: z.array(z.object({ + type: z.enum(["key", "mouse_button", "joypad_button", "joypad_motion"]).describe("Event type"), + keycode: z.string().optional().describe("Key name for 'key' type (e.g. 'W', 'Space', 'Escape', 'Shift')"), + physical_keycode: z.string().optional().describe("Physical key name for 'key' type"), + ctrl: z.boolean().optional().describe("Ctrl modifier for 'key' type"), + shift: z.boolean().optional().describe("Shift modifier for 'key' type"), + alt: z.boolean().optional().describe("Alt modifier for 'key' type"), + meta: z.boolean().optional().describe("Meta/Cmd modifier for 'key' type"), + button_index: z.number().optional().describe("Button index for mouse/joypad button types"), + axis: z.number().optional().describe("Axis index for joypad_motion type"), + axis_value: z.number().optional().describe("Axis value (-1.0 or 1.0) for joypad_motion type"), + })).describe("Array of input event bindings"), + deadzone: z.number().optional().describe("Deadzone for analog inputs (default: 0.5)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_input_action", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=input-map-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js.map new file mode 100644 index 0000000..36bfc6c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-map-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"input-map-tools.js","sourceRoot":"","sources":["../../src/tools/input-map-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts new file mode 100644 index 0000000..a2106b4 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerInputTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=input-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts.map new file mode 100644 index 0000000..1b52681 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"input-tools.d.ts","sourceRoot":"","sources":["../../src/tools/input-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAIzD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAgIN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js new file mode 100644 index 0000000..3786dbc --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js @@ -0,0 +1,109 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +import { coerceNumber } from "../utils/zod-coerce.js"; +export function registerInputTools(server, godot) { + server.tool("simulate_key", "Simulate a keyboard key press or release in the running game. Use `duration` to hold a key for a set time (auto-releases after). Without duration: keys are NOT auto-released — you must explicitly call with pressed=false to release them.", { + keycode: z.string().describe("Key constant (e.g. 'KEY_SPACE', 'KEY_W', 'KEY_ESCAPE')"), + pressed: z.boolean().optional().describe("true for press, false for release (default: true)"), + duration: coerceNumber().optional().describe("Hold duration in seconds (e.g. 1.5). Key is pressed, held for this duration, then auto-released. Cannot be used with pressed=false."), + shift: z.boolean().optional().describe("Shift modifier (default: false)"), + ctrl: z.boolean().optional().describe("Ctrl modifier (default: false)"), + alt: z.boolean().optional().describe("Alt modifier (default: false)"), + }, async (params) => { + try { + if (params.duration !== undefined && params.duration > 0) { + const { duration, ...keyParams } = params; + // Press + await godot.sendCommand("simulate_key", { ...keyParams, pressed: true }); + // Hold + await new Promise(resolve => setTimeout(resolve, duration * 1000)); + // Release + await godot.sendCommand("simulate_key", { ...keyParams, pressed: false }); + return { content: [{ type: "text", text: JSON.stringify({ + event: { keycode: params.keycode, duration, shift: params.shift, ctrl: params.ctrl, alt: params.alt, auto_released: true }, + sent: true + }, null, 2) }] }; + } + const result = await godot.sendCommand("simulate_key", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("simulate_mouse_click", "Simulate a mouse button click at a position in the running game. By default sends both press and release (auto_release) so UI buttons work correctly.", { + x: z.number().optional().describe("X position in viewport (default: 0)"), + y: z.number().optional().describe("Y position in viewport (default: 0)"), + button: z.number().optional().describe("Mouse button index: 1=left, 2=right, 3=middle (default: 1)"), + pressed: z.boolean().optional().describe("true for press, false for release (default: true)"), + double_click: z.boolean().optional().describe("Double click (default: false)"), + auto_release: z.boolean().optional().describe("Auto-send release after press so buttons fire (default: true). Set false for drag operations."), + }, async (params) => { + try { + const result = await godot.sendCommand("simulate_mouse_click", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("simulate_mouse_move", "Simulate mouse movement in the running game. Use x/y for absolute viewport positioning (UI interaction), or relative_x/relative_y for relative motion (camera rotation in 3D games, FPS-style look). For 3D camera rotation: relative_x rotates yaw (negative = look left, positive = look right), relative_y rotates pitch (negative = look up, positive = look down). Typical values: 200-400px for a ~90° turn. Use navigate_to tool to calculate exact relative_x needed to face a target.", { + x: z.number().optional().describe("Absolute X position in viewport (for UI interaction)"), + y: z.number().optional().describe("Absolute Y position in viewport (for UI interaction)"), + relative_x: z.number().optional().describe("Relative X movement in pixels. For 3D camera: negative = look left, positive = look right. ~400px ≈ 180° turn"), + relative_y: z.number().optional().describe("Relative Y movement in pixels. For 3D camera: negative = look up, positive = look down"), + button_mask: z.number().optional().describe("Mouse button mask to simulate drag. 1=left button held, 2=right button held, 4=middle button held. Required for drag operations like camera pan. (default: 0)"), + unhandled: z.boolean().optional().describe("Force event to bypass GUI layer and go directly to _unhandled_input(). Auto-enabled when button_mask > 0. Use for camera pan/drag when UI overlays consume mouse events. (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("simulate_mouse_move", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("simulate_action", "Simulate a Godot Input Action (e.g. 'jump', 'move_left') in the running game", { + action: z.string().describe("Action name as defined in Input Map (e.g. 'jump', 'move_left')"), + pressed: z.boolean().optional().describe("true for press, false for release (default: true)"), + strength: z.number().optional().describe("Action strength 0.0-1.0 (default: 1.0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("simulate_action", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("simulate_sequence", "Simulate a sequence of input events with optional frame delays between them. Useful for complex input patterns like press W → wait 30 frames → press Space → wait → release all. After the sequence, use capture_frames to verify the visual result.", { + events: z.array(z.object({ + type: z.string().describe("Event type: 'key', 'mouse_button', 'mouse_motion', or 'action'"), + keycode: z.string().optional().describe("For 'key': key constant (e.g. 'KEY_SPACE')"), + action: z.string().optional().describe("For 'action': action name"), + button: z.number().optional().describe("For 'mouse_button': button index"), + pressed: z.boolean().optional().describe("Press state (default: true)"), + x: z.number().optional().describe("X position for mouse events"), + y: z.number().optional().describe("Y position for mouse events"), + relative_x: z.number().optional().describe("Relative X for mouse_motion"), + relative_y: z.number().optional().describe("Relative Y for mouse_motion"), + button_mask: z.number().optional().describe("Mouse button mask for mouse_motion drag: 1=left, 2=right, 4=middle"), + unhandled: z.boolean().optional().describe("Bypass GUI, send directly to _unhandled_input. Auto-enabled for mouse_motion with button_mask > 0"), + shift: z.boolean().optional(), + ctrl: z.boolean().optional(), + alt: z.boolean().optional(), + strength: z.number().optional(), + double_click: z.boolean().optional(), + })).describe("Array of input events to send"), + frame_delay: z.number().optional().describe("Frames to wait between events (default: 1, 0 = all in one frame)"), + }, async (params) => { + try { + const result = await godot.sendCommand("simulate_sequence", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=input-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js.map new file mode 100644 index 0000000..c1877a1 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/input-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"input-tools.js","sourceRoot":"","sources":["../../src/tools/input-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts new file mode 100644 index 0000000..cf298ef --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerNavigationTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=navigation-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts.map new file mode 100644 index 0000000..e08df36 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"navigation-tools.d.ts","sourceRoot":"","sources":["../../src/tools/navigation-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA2GN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js new file mode 100644 index 0000000..bd3f40a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerNavigationTools(server, godot) { + server.tool("setup_navigation_region", "Add a NavigationRegion2D/3D child to a node with auto-created NavigationPolygon or NavigationMesh. Auto-detects 2D/3D from parent context.", { + node_path: z.string().describe("Path to the parent node to add the region to"), + mode: z.string().optional().describe("Force '2d' or '3d' mode, or 'auto' to detect from parent (default: auto)"), + name: z.string().optional().describe("Name for the NavigationRegion node"), + navigation_layers: z.number().optional().describe("Navigation layers bitmask"), + agent_radius: z.number().optional().describe("Agent radius for mesh generation (3D default: 0.5, 2D: from NavigationPolygon)"), + agent_height: z.number().optional().describe("Agent height (3D only, default: 1.5)"), + agent_max_climb: z.number().optional().describe("Max climb height (3D only, default: 0.25)"), + agent_max_slope: z.number().optional().describe("Max slope angle in degrees (3D only, default: 45.0)"), + cell_size: z.number().optional().describe("Cell size for navigation mesh (default: 0.25 for 3D)"), + cell_height: z.number().optional().describe("Cell height (3D only, default: 0.25)"), + source_geometry_mode: z.string().optional().describe("2D only: root_node, groups_with_children, or groups_explicit"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_navigation_region", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("bake_navigation_mesh", "Bake navigation mesh for a NavigationRegion3D, or set outline vertices and generate polygons for a NavigationRegion2D.", { + node_path: z.string().describe("Path to the NavigationRegion2D or NavigationRegion3D node"), + outline: z.array(z.union([ + z.array(z.number()).describe("[x, y] coordinate pair"), + z.object({ x: z.number(), y: z.number() }), + ])).optional().describe("2D only: Array of outline vertices as [x,y] pairs or {x,y} objects. At least 3 vertices required."), + }, async (params) => { + try { + const result = await godot.sendCommand("bake_navigation_mesh", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("setup_navigation_agent", "Add a NavigationAgent2D/3D child to a node and configure pathfinding and avoidance properties. Auto-detects 2D/3D from parent context.", { + node_path: z.string().describe("Path to the parent node to add the agent to"), + mode: z.string().optional().describe("Force '2d' or '3d' mode, or 'auto' to detect from parent (default: auto)"), + name: z.string().optional().describe("Name for the NavigationAgent node"), + path_desired_distance: z.number().optional().describe("Distance threshold to advance to next path point"), + target_desired_distance: z.number().optional().describe("Distance threshold to consider target reached"), + radius: z.number().optional().describe("Agent radius for avoidance"), + neighbor_distance: z.number().optional().describe("Max distance to consider other agents as neighbors"), + max_neighbors: z.number().optional().describe("Max number of neighbors for avoidance"), + max_speed: z.number().optional().describe("Maximum movement speed for avoidance"), + avoidance_enabled: z.boolean().optional().describe("Enable avoidance behavior"), + navigation_layers: z.number().optional().describe("Navigation layers bitmask for pathfinding queries"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_navigation_agent", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_navigation_layers", "Set navigation layers for a NavigationRegion or NavigationAgent. Supports bitmask value, layer bit numbers, or named layers from ProjectSettings.", { + node_path: z.string().describe("Path to a NavigationRegion2D/3D or NavigationAgent2D/3D node"), + layers: z.number().optional().describe("Navigation layers as a bitmask value (e.g. 5 = layers 1 and 3)"), + layer_bits: z.array(z.number()).optional().describe("Array of 1-based layer numbers to enable (e.g. [1, 3] = bitmask 5)"), + layer_names: z.array(z.string()).optional().describe("Array of named layer names from ProjectSettings (layer_names/2d_navigation/layer_N or 3d)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_navigation_layers", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_navigation_info", "Get navigation setup info for a node and its subtree: all NavigationRegions, NavigationAgents, their layers, and mesh/polygon data.", { + node_path: z.string().describe("Path to the root node to inspect"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_navigation_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=navigation-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js.map new file mode 100644 index 0000000..06e761e --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/navigation-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"navigation-tools.js","sourceRoot":"","sources":["../../src/tools/navigation-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts new file mode 100644 index 0000000..d5cc115 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerNodeTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=node-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts.map new file mode 100644 index 0000000..5a0cca7 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"node-tools.d.ts","sourceRoot":"","sources":["../../src/tools/node-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA2SN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js new file mode 100644 index 0000000..caf72ea --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js @@ -0,0 +1,219 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerNodeTools(server, godot) { + server.tool("add_node", "Add a new node to the current scene. Supports built-in Godot types and script-defined classes (class_name).", { + type: z.string().describe("Node type — built-in (e.g. 'Sprite2D', 'Camera2D') or script class_name (e.g. 'HoverDetector', 'StationBuilder')"), + parent_path: z.string().optional().describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Node name"), + properties: z.record(z.string(), z.any()).optional().describe("Properties to set (e.g. {\"position\": \"Vector2(100, 200)\"})"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("delete_node", "Delete a node from the current scene (supports undo)", { + node_path: z.string().describe("Path to the node to delete"), + }, async (params) => { + try { + const result = await godot.sendCommand("delete_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("duplicate_node", "Duplicate a node and all its children in the current scene", { + node_path: z.string().describe("Path to the node to duplicate"), + name: z.string().optional().describe("Name for the duplicate (default: original_copy)"), + }, async (params) => { + try { + const result = await godot.sendCommand("duplicate_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("move_node", "Move/reparent a node to a new parent in the scene tree", { + node_path: z.string().describe("Path to the node to move"), + new_parent_path: z.string().describe("Path to the new parent node"), + }, async (params) => { + try { + const result = await godot.sendCommand("move_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("update_property", "Change a property on any node. Supports Vector2, Color, and other Godot types via string parsing.", { + node_path: z.string().describe("Path to the target node"), + property: z.string().describe("Property name (e.g. 'position', 'modulate', 'visible')"), + value: z.any().describe("New value. Strings are auto-parsed: 'Vector2(10,20)', 'Color(1,0,0)', '#ff0000', etc."), + }, async (params) => { + try { + const result = await godot.sendCommand("update_property", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_node_properties", "Get all editor-visible properties of a node with their current values", { + node_path: z.string().describe("Path to the node"), + category: z.string().optional().describe("Filter by property category prefix (e.g. 'transform', 'texture')"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_node_properties", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_resource", "Add a resource (Shape2D, Material, Texture, etc.) to a node's property", { + node_path: z.string().describe("Path to the target node"), + property: z.string().describe("Property to set the resource on (e.g. 'shape', 'material', 'texture')"), + resource_type: z.string().describe("Resource class name (e.g. 'RectangleShape2D', 'CircleShape2D', 'StandardMaterial3D')"), + resource_properties: z.record(z.string(), z.any()).optional().describe("Properties to set on the created resource"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_anchor_preset", "Set a Control node's anchor preset (e.g. full_rect, center, top_left)", { + node_path: z.string().describe("Path to the Control node"), + preset: z.string().describe("Anchor preset name: top_left, top_right, bottom_left, bottom_right, center_left, center_top, center_right, center_bottom, center, left_wide, top_wide, right_wide, bottom_wide, vcenter_wide, hcenter_wide, full_rect"), + keep_offsets: z.boolean().optional().describe("Keep current offsets (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_anchor_preset", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("rename_node", "Rename a node in the current scene", { + node_path: z.string().describe("Path to the node to rename"), + new_name: z.string().describe("New name for the node"), + }, async (params) => { + try { + const result = await godot.sendCommand("rename_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("connect_signal", "Connect a signal from one node to a method on another node. The connection is persistent (saved into the .tscn on save_scene).", { + source_path: z.string().describe("Path to the source node (emitter)"), + signal_name: z.string().describe("Signal name to connect"), + target_path: z.string().describe("Path to the target node (receiver)"), + method_name: z.string().describe("Method name on target to call"), + deferred: z.boolean().optional().describe("Use a deferred connection (CONNECT_DEFERRED)"), + one_shot: z.boolean().optional().describe("Disconnect automatically after the first emission (CONNECT_ONE_SHOT)"), + }, async (params) => { + try { + const result = await godot.sendCommand("connect_signal", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("disconnect_signal", "Disconnect a signal connection between two nodes", { + source_path: z.string().describe("Path to the source node (emitter)"), + signal_name: z.string().describe("Signal name to disconnect"), + target_path: z.string().describe("Path to the target node (receiver)"), + method_name: z.string().describe("Method name on target"), + }, async (params) => { + try { + const result = await godot.sendCommand("disconnect_signal", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_node_groups", "Get all groups a node belongs to (excludes internal groups starting with '_')", { + node_path: z.string().describe("Path to the node"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_node_groups", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_node_groups", "Set the groups a node belongs to. Computes diff with current groups and adds/removes as needed.", { + node_path: z.string().describe("Path to the node"), + groups: z.array(z.string()).describe("Desired list of group names (replaces current groups)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_node_groups", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("find_nodes_in_group", "Find all nodes in the current scene that belong to a specific group", { + group: z.string().describe("Group name to search for"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_nodes_in_group", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_editor_selection", "Get the nodes currently selected in the editor Scene dock", { + top_only: z.boolean().optional().describe("Return only the topmost selected nodes, excluding a node whose parent is already selected (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_editor_selection", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("select_nodes", "Select one or more nodes in the editor Scene dock, optionally focusing and inspecting them", { + node_path: z.string().optional().describe("Path to a single node to select"), + node_paths: z.array(z.string()).optional().describe("Paths to multiple nodes to select (use instead of node_path)"), + mode: z.enum(["replace", "add", "remove"]).optional().describe("How to apply the selection (default: replace)"), + inspect: z.boolean().optional().describe("Show the node in the Inspector (only applied when a single node is selected; default: true)"), + focus: z.boolean().optional().describe("Focus the node in the Scene dock (only applied when a single node is selected; default: follows inspect)"), + for_property: z.string().optional().describe("Inspector property to focus on"), + inspector_only: z.boolean().optional().describe("Show in Inspector without changing the edited node (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("select_nodes", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("clear_editor_selection", "Clear the current editor Scene-dock selection", {}, async (params) => { + try { + const result = await godot.sendCommand("clear_editor_selection", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=node-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js.map new file mode 100644 index 0000000..c9361eb --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/node-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"node-tools.js","sourceRoot":"","sources":["../../src/tools/node-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts new file mode 100644 index 0000000..bfd8ea9 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerParticleTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=particle-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts.map new file mode 100644 index 0000000..77a8b1b --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"particle-tools.d.ts","sourceRoot":"","sources":["../../src/tools/particle-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA8HN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js new file mode 100644 index 0000000..ab61963 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerParticleTools(server, godot) { + server.tool("create_particles", "Add a GPUParticles2D or GPUParticles3D node with a ParticleProcessMaterial. Configure amount, lifetime, one_shot, explosiveness, and randomness.", { + parent_path: z.string().describe("Path to the parent node to add particles to"), + name: z.string().optional().describe("Name for the particles node (default: 'Particles')"), + is_3d: z.boolean().optional().describe("Create GPUParticles3D instead of GPUParticles2D (default: false)"), + amount: z.number().optional().describe("Number of particles (default: 16)"), + lifetime: z.number().optional().describe("Particle lifetime in seconds (default: 1.0)"), + one_shot: z.boolean().optional().describe("Emit only once (default: false)"), + explosiveness: z.number().optional().describe("Explosiveness ratio 0-1 (default: 0.0)"), + randomness: z.number().optional().describe("Randomness ratio 0-1 (default: 0.0)"), + emitting: z.boolean().optional().describe("Start emitting immediately (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("create_particles", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_particle_material", "Configure ParticleProcessMaterial properties: direction, spread, velocity, gravity, scale, color, emission shape (point/sphere/box/ring), angular/orbit velocity, damping, and attractor interaction.", { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + direction: z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).optional().describe("Emission direction vector"), + spread: z.number().optional().describe("Spread angle in degrees (0-180)"), + initial_velocity_min: z.number().optional().describe("Minimum initial velocity"), + initial_velocity_max: z.number().optional().describe("Maximum initial velocity"), + gravity: z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).optional().describe("Gravity vector"), + scale_min: z.number().optional().describe("Minimum particle scale"), + scale_max: z.number().optional().describe("Maximum particle scale"), + color: z.string().optional().describe("Particle color (hex '#RRGGBB' or named color)"), + emission_shape: z.string().optional().describe("Emission shape: point, sphere, sphere_surface, box, ring"), + emission_sphere_radius: z.number().optional().describe("Sphere emission radius"), + emission_box_extents: z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).optional().describe("Box emission extents"), + emission_ring_radius: z.number().optional().describe("Ring outer radius"), + emission_ring_inner_radius: z.number().optional().describe("Ring inner radius"), + emission_ring_height: z.number().optional().describe("Ring height"), + angular_velocity_min: z.number().optional().describe("Minimum angular velocity (degrees/sec)"), + angular_velocity_max: z.number().optional().describe("Maximum angular velocity (degrees/sec)"), + orbit_velocity_min: z.number().optional().describe("Minimum orbit velocity"), + orbit_velocity_max: z.number().optional().describe("Maximum orbit velocity"), + damping_min: z.number().optional().describe("Minimum damping"), + damping_max: z.number().optional().describe("Maximum damping"), + attractor_interaction_enabled: z.boolean().optional().describe("Enable attractor interaction"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_particle_material", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_particle_color_gradient", "Set a color ramp (gradient) on a particle system's material. Provide an array of color stops with offset (0-1) and color.", { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + stops: z.array(z.object({ + offset: z.number().describe("Gradient position (0.0 to 1.0)"), + color: z.string().describe("Color at this stop (hex '#RRGGBB' or named color)"), + })).describe("Array of gradient color stops"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_particle_color_gradient", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("apply_particle_preset", "Apply a named particle preset. Available presets: explosion (burst, short life), fire (upward, orange gradient), smoke (slow upward, gray), sparks (burst, high velocity), rain (downward, blue), snow (slow downward, drift), magic (orbit, colorful), dust (ambient, subtle).", { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + preset: z.string().describe("Preset name: explosion, fire, smoke, sparks, rain, snow, magic, dust"), + }, async (params) => { + try { + const result = await godot.sendCommand("apply_particle_preset", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_particle_info", "Get the full configuration of a particle system: node properties, material settings, emission shape, color gradient stops.", { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_particle_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=particle-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js.map new file mode 100644 index 0000000..df51bfe --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/particle-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"particle-tools.js","sourceRoot":"","sources":["../../src/tools/particle-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts new file mode 100644 index 0000000..23ef068 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerPhysicsTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=physics-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts.map new file mode 100644 index 0000000..ad7fb68 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"physics-tools.d.ts","sourceRoot":"","sources":["../../src/tools/physics-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA4IN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js new file mode 100644 index 0000000..0398ce3 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js @@ -0,0 +1,115 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerPhysicsTools(server, godot) { + server.tool("setup_collision", "Add a CollisionShape2D/3D child to a physics body or area node with a specified shape. Auto-detects 2D/3D from the parent node type.", { + node_path: z.string().describe("Path to the parent physics body or area node (e.g. CharacterBody2D, StaticBody3D, Area2D)"), + shape: z.string().describe("Shape type: 'rectangle'/'rect', 'circle', 'capsule', 'segment' (2D only), 'cylinder' (3D only), 'custom'/'convex'. For 3D: 'box'/'sphere' also work."), + width: z.number().optional().describe("Width for rectangle/box shape (default: 32 for 2D, 1 for 3D)"), + height: z.number().optional().describe("Height for rectangle/box/capsule/cylinder shape"), + depth: z.number().optional().describe("Depth for 3D box shape (default: 1)"), + radius: z.number().optional().describe("Radius for circle/sphere/capsule/cylinder shape"), + ax: z.number().optional().describe("Segment start X (2D segment only)"), + ay: z.number().optional().describe("Segment start Y (2D segment only)"), + bx: z.number().optional().describe("Segment end X (2D segment only)"), + by: z.number().optional().describe("Segment end Y (2D segment only)"), + points: z.array(z.array(z.number())).optional().describe("Convex polygon points as [[x,y],...] for 2D or [[x,y,z],...] for 3D"), + disabled: z.boolean().optional().describe("Create the collision shape disabled (default: false)"), + one_way_collision: z.boolean().optional().describe("Enable one-way collision (2D only, default: false)"), + dimension: z.string().optional().describe("Force '2d' or '3d' if auto-detection fails"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_collision", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_physics_layers", "Set collision layer and/or mask on a physics body or area node. Supports bitmask integers or arrays of layer numbers.", { + node_path: z.string().describe("Path to the node with collision layers"), + collision_layer: z.union([z.number(), z.array(z.number())]).optional().describe("Collision layer: bitmask integer or array of layer numbers [1,3,5]"), + collision_mask: z.union([z.number(), z.array(z.number())]).optional().describe("Collision mask: bitmask integer or array of layer numbers [1,2,4]"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_physics_layers", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_physics_layers", "Get the current collision layer and mask for a node, including named layer info from ProjectSettings.", { + node_path: z.string().describe("Path to the node with collision layers"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_physics_layers", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_raycast", "Add a RayCast2D/3D child node for collision detection. Auto-detects 2D/3D from the parent node type.", { + node_path: z.string().describe("Path to the parent node"), + name: z.string().optional().describe("Name for the raycast node (default: 'RayCast')"), + target_x: z.number().optional().describe("Target position X (default: 0)"), + target_y: z.number().optional().describe("Target position Y (default: 50 for 2D, -1 for 3D)"), + target_z: z.number().optional().describe("Target position Z (3D only, default: 0)"), + collision_mask: z.number().optional().describe("Collision mask bitmask (default: 1)"), + enabled: z.boolean().optional().describe("Enable the raycast (default: true)"), + collide_with_areas: z.boolean().optional().describe("Collide with Area nodes (default: false)"), + collide_with_bodies: z.boolean().optional().describe("Collide with physics bodies (default: true)"), + hit_from_inside: z.boolean().optional().describe("Detect hits from inside shapes (default: false)"), + dimension: z.string().optional().describe("Force '2d' or '3d' if auto-detection fails"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_raycast", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("setup_physics_body", "Configure physics body properties. For CharacterBody2D/3D: floor settings, motion mode, etc. For RigidBody2D/3D: mass, gravity, damping, etc.", { + node_path: z.string().describe("Path to the physics body node"), + // CharacterBody properties + floor_stop_on_slope: z.boolean().optional().describe("CharacterBody: stop on slopes when not moving"), + floor_max_angle: z.number().optional().describe("CharacterBody: maximum floor angle in radians (default ~0.785 = 45 degrees)"), + floor_snap_length: z.number().optional().describe("CharacterBody: floor snap distance for sticking to the ground"), + wall_min_slide_angle: z.number().optional().describe("CharacterBody: minimum angle for wall sliding in radians"), + motion_mode: z.string().optional().describe("CharacterBody: 'grounded' or 'floating'"), + max_slides: z.number().optional().describe("CharacterBody: maximum slide iterations (default: 6)"), + slide_on_ceiling: z.boolean().optional().describe("CharacterBody: allow sliding on ceiling"), + // RigidBody properties + mass: z.number().optional().describe("RigidBody: mass in kg (default: 1)"), + gravity_scale: z.number().optional().describe("RigidBody: gravity multiplier (default: 1, 0 = no gravity)"), + linear_damp: z.number().optional().describe("RigidBody: linear velocity damping"), + angular_damp: z.number().optional().describe("RigidBody: angular velocity damping"), + freeze: z.boolean().optional().describe("RigidBody: freeze the body (stop physics simulation)"), + freeze_mode: z.string().optional().describe("RigidBody: 'static' or 'kinematic' freeze behavior"), + continuous_cd: z.union([z.string(), z.boolean()]).optional().describe("RigidBody: continuous collision detection. 2D: 'disabled'/'cast_ray'/'cast_shape'. 3D: true/false"), + contact_monitor: z.boolean().optional().describe("RigidBody: enable contact monitoring for body_entered/body_exited signals"), + max_contacts_reported: z.number().optional().describe("RigidBody: max contacts to report (requires contact_monitor)"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_physics_body", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_collision_info", "Get detailed collision information for a node: all collision shapes, layers/masks, raycasts, and physics body settings. Scans children by default.", { + node_path: z.string().describe("Path to the node to inspect"), + include_children: z.boolean().optional().describe("Include children in the scan (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_collision_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=physics-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js.map new file mode 100644 index 0000000..2698919 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/physics-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"physics-tools.js","sourceRoot":"","sources":["../../src/tools/physics-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,oBAAoB,CAClC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,sIAAsI,EACtI;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,2FAA2F,CAAC;QAC3H,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,sJAAsJ,CAAC;QAClL,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8DAA8D,CAAC;QACrG,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iDAAiD,CAAC;QACzF,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QAC5E,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iDAAiD,CAAC;QACzF,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mCAAmC,CAAC;QACvE,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mCAAmC,CAAC;QACvE,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;QACrE,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;QACrE,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qEAAqE,CAAC;QAC/H,QAAQ,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;QACjG,iBAAiB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oDAAoD,CAAC;QACxG,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4CAA4C,CAAC;KACxF,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;YAClE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,uHAAuH,EACvH;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,wCAAwC,CAAC;QACxE,eAAe,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oEAAoE,CAAC;QACrJ,cAAc,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mEAAmE,CAAC;KACpJ,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,uGAAuG,EACvG;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,wCAAwC,CAAC;KACzE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,aAAa,EACb,sGAAsG,EACtG;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,yBAAyB,CAAC;QACzD,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gDAAgD,CAAC;QACtF,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gCAAgC,CAAC;QAC1E,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mDAAmD,CAAC;QAC7F,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yCAAyC,CAAC;QACnF,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QACrF,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;QAC9E,kBAAkB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,0CAA0C,CAAC;QAC/F,mBAAmB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,6CAA6C,CAAC;QACnG,eAAe,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iDAAiD,CAAC;QACnG,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4CAA4C,CAAC;KACxF,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;YAC9D,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,+IAA+I,EAC/I;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,+BAA+B,CAAC;QAC/D,2BAA2B;QAC3B,mBAAmB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,+CAA+C,CAAC;QACrG,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,6EAA6E,CAAC;QAC9H,iBAAiB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,+DAA+D,CAAC;QAClH,oBAAoB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,0DAA0D,CAAC;QAChH,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yCAAyC,CAAC;QACtF,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;QAClG,gBAAgB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yCAAyC,CAAC;QAC5F,uBAAuB;QACvB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;QAC1E,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4DAA4D,CAAC;QAC3G,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;QACjF,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QACnF,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;QAC/F,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oDAAoD,CAAC;QACjG,aAAa,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mGAAmG,CAAC;QAC1K,eAAe,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,2EAA2E,CAAC;QAC7H,qBAAqB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8DAA8D,CAAC;KACtH,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,oJAAoJ,EACpJ;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,6BAA6B,CAAC;QAC7D,gBAAgB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8CAA8C,CAAC;KAClG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts new file mode 100644 index 0000000..5234521 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerProfilingTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=profiling-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts.map new file mode 100644 index 0000000..ca25ecd --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"profiling-tools.d.ts","sourceRoot":"","sources":["../../src/tools/profiling-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA8BN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js new file mode 100644 index 0000000..f96c24b --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerProfilingTools(server, godot) { + server.tool("get_performance_monitors", "Get the RUNNING GAME's performance monitors (FPS, memory, draw calls, physics, navigation, etc.). Requires a scene to be playing (play_scene). For editor-process metrics use get_editor_performance.", { + category: z.string().optional().describe("Filter by category prefix: 'fps', 'memory', 'render', 'physics_2d', 'physics_3d', 'navigation'"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_performance_monitors", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_editor_performance", "Get a quick performance summary (FPS, frame time, draw calls, memory usage)", {}, async () => { + try { + const result = await godot.sendCommand("get_editor_performance"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=profiling-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js.map new file mode 100644 index 0000000..a77d6cd --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/profiling-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"profiling-tools.js","sourceRoot":"","sources":["../../src/tools/profiling-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,sBAAsB,CACpC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,0BAA0B,EAC1B,uMAAuM,EACvM;QACE,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gGAAgG,CAAC;KAC3I,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,0BAA0B,EAAE,MAAM,CAAC,CAAC;YAC3E,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,wBAAwB,EACxB,6EAA6E,EAC7E,EAAE,EACF,KAAK,IAAI,EAAE;QACT,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,wBAAwB,CAAC,CAAC;YACjE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts new file mode 100644 index 0000000..2c1aea1 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerProjectTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=project-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts.map new file mode 100644 index 0000000..89c5e93 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"project-tools.d.ts","sourceRoot":"","sources":["../../src/tools/project-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA0KN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js new file mode 100644 index 0000000..8217163 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js @@ -0,0 +1,125 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerProjectTools(server, godot) { + server.tool("get_project_info", "Get Godot project metadata including name, version, viewport settings, renderer, and autoloads", {}, async () => { + try { + const result = await godot.sendCommand("get_project_info"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_filesystem_tree", "Get the project's file/directory tree with optional filtering by extension (e.g. *.gd, *.tscn)", { + path: z.string().optional().describe("Root path to scan (default: res://)"), + filter: z.string().optional().describe("Glob filter pattern (e.g. '*.gd', '*.tscn')"), + max_depth: z.number().optional().describe("Maximum directory depth to scan (default: 10)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_filesystem_tree", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("search_files", "Search for files by name using fuzzy matching or glob patterns", { + query: z.string().describe("Search query (fuzzy match or glob pattern)"), + path: z.string().optional().describe("Root path to search (default: res://)"), + file_type: z.string().optional().describe("Filter by file extension (e.g. 'gd', 'tscn')"), + max_results: z.number().optional().describe("Maximum results to return (default: 50)"), + }, async (params) => { + try { + const result = await godot.sendCommand("search_files", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("search_in_files", "Search for text content inside project files (grep-like). Searches through GDScript, scenes, resources, shaders, and other text files. Skips addons/ and .godot/ directories.", { + query: z.string().describe("Text to search for (plain text or regex pattern)"), + path: z.string().optional().describe("Root path to search (default: res://)"), + regex: z.boolean().optional().describe("Use regex matching (default: false)"), + file_type: z.string().optional().describe("Filter by file extension (e.g. 'gd', 'tscn'). Default: all text files"), + max_results: z.number().optional().describe("Maximum results to return (default: 50)"), + }, async (params) => { + try { + const result = await godot.sendCommand("search_in_files", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_project_settings", "Read project.godot settings by section or specific key", { + section: z.string().optional().describe("Settings section prefix (e.g. 'display/window')"), + key: z.string().optional().describe("Specific setting key (e.g. 'display/window/size/viewport_width')"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_project_settings", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_project_setting", "Set a project setting value (e.g. viewport size, main scene). Saves to project.godot via the editor API.", { + key: z.string().describe("Setting key (e.g. 'display/window/size/viewport_width', 'application/run/main_scene')"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to set. Strings are auto-parsed for Vector2, bool, int, float."), + }, async (params) => { + try { + const result = await godot.sendCommand("set_project_setting", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("uid_to_project_path", "Convert a Godot UID (uid://...) to a project resource path (res://...)", { + uid: z.string().describe("The UID string (e.g. 'uid://abc123')"), + }, async (params) => { + try { + const result = await godot.sendCommand("uid_to_project_path", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("project_path_to_uid", "Convert a project resource path (res://...) to its UID (uid://...)", { + path: z.string().describe("The resource path (e.g. 'res://scenes/player.tscn')"), + }, async (params) => { + try { + const result = await godot.sendCommand("project_path_to_uid", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_autoload", "Add an autoload (singleton) to the project. The script/scene will be auto-loaded when the project starts.", { + name: z.string().describe("Autoload name (e.g. 'GameManager', 'AudioManager')"), + path: z.string().describe("Path to the script or scene file (e.g. 'res://scripts/autoload/game_manager.gd')"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_autoload", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("remove_autoload", "Remove an autoload (singleton) from the project settings", { + name: z.string().describe("Autoload name to remove (e.g. 'GameManager')"), + }, async (params) => { + try { + const result = await godot.sendCommand("remove_autoload", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=project-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js.map new file mode 100644 index 0000000..ecf952c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/project-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"project-tools.js","sourceRoot":"","sources":["../../src/tools/project-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts new file mode 100644 index 0000000..41ef008 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerResourceTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=resource-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts.map new file mode 100644 index 0000000..6f3ed2a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"resource-tools.d.ts","sourceRoot":"","sources":["../../src/tools/resource-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAoFN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js new file mode 100644 index 0000000..cd4de25 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerResourceTools(server, godot) { + server.tool("read_resource", "Read a .tres resource file and return its properties. Works with any Godot Resource type (StyleBox, Font, Theme, Material, etc.)", { + path: z.string().describe("Path to the resource file (e.g. 'res://themes/main_theme.tres')"), + }, async (params) => { + try { + const result = await godot.sendCommand("read_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("edit_resource", "Edit properties of an existing .tres resource file. Changes are saved to disk immediately.", { + path: z.string().describe("Path to the resource file (e.g. 'res://themes/main_theme.tres')"), + properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe("Properties to set as key-value pairs. Values auto-parsed for Vector2, Color, etc."), + }, async (params) => { + try { + const result = await godot.sendCommand("edit_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("create_resource", "Create a new .tres resource file of a given type with optional initial properties", { + path: z.string().describe("Path to save the resource (e.g. 'res://resources/player_stats.tres')"), + type: z.string().describe("Resource type to create (e.g. 'StyleBoxFlat', 'LabelSettings', 'Environment')"), + properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("Initial properties to set"), + overwrite: z.boolean().optional().describe("Overwrite if file exists (default: false)"), + }, async (params) => { + try { + const result = await godot.sendCommand("create_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_resource_preview", "Get a visual preview of an image or texture resource as a PNG. Works with .png, .jpg, .webp, .svg image files and Texture2D resources.", { + path: z.string().describe("Path to the resource (e.g. 'res://assets/player.png', 'res://icon.svg')"), + max_size: z.number().optional().describe("Maximum width/height in pixels, preserving aspect ratio (default: 256)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_resource_preview", params); + if (result && typeof result === "object" && "image_base64" in result) { + return { + content: [ + { + type: "image", + data: result.image_base64, + mimeType: "image/png", + }, + { + type: "text", + text: `Preview of ${result.path}: ${result.width}x${result.height}`, + }, + ], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=resource-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js.map new file mode 100644 index 0000000..20396ad --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/resource-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resource-tools.js","sourceRoot":"","sources":["../../src/tools/resource-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts new file mode 100644 index 0000000..8cc81ba --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerRuntimeTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=runtime-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts.map new file mode 100644 index 0000000..200392f --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"runtime-tools.d.ts","sourceRoot":"","sources":["../../src/tools/runtime-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAIzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA6vBN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js new file mode 100644 index 0000000..8ee2e55 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js @@ -0,0 +1,552 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +import { coerceStringArray, coerceNumber } from "../utils/zod-coerce.js"; +export function registerRuntimeTools(server, godot) { + server.tool("get_game_scene_tree", "Get the scene tree of the currently running game (requires a scene to be playing). Supports filtering by script path, node type, or name.", { + max_depth: z + .number() + .optional() + .describe("Maximum tree depth (-1 for unlimited, default: -1)"), + script_filter: z + .string() + .optional() + .describe("Only include nodes whose script path contains this string (e.g. 'enemy' matches 'enemy.gd', 'enemy_drone.gd')"), + type_filter: z + .string() + .optional() + .describe("Only include nodes of this Godot class (e.g. 'CharacterBody2D', 'Area2D')"), + named_only: z + .boolean() + .optional() + .describe("If true, exclude nodes with auto-generated names (names starting with '@'). Default: false"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_game_scene_tree", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("get_game_node_properties", "Get properties of a node in the running game (requires a scene to be playing)", { + node_path: z + .string() + .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), + properties: coerceStringArray() + .optional() + .describe("Specific property names to read (default: all editor-visible properties)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_game_node_properties", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("set_game_node_property", "Set a property on a node in the running game (requires a scene to be playing). Useful for live-tweaking values like position, speed, health, etc.", { + node_path: z + .string() + .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), + property: z + .string() + .describe("Property name to set (e.g. 'position', 'speed', 'health')"), + value: z + .union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number())]) + .describe("Value to set. Accepts: strings with auto-parsing ('Vector2(100,200)', '#ff0000'), numbers, booleans, or JSON objects for vectors/colors ({\"x\":5,\"y\":3,\"z\":10} for Vector3, {\"x\":100,\"y\":200} for Vector2, {\"r\":1,\"g\":0,\"b\":0,\"a\":1} for Color)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_game_node_property", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("execute_game_script", "Execute arbitrary GDScript code inside the running game process. Use _mcp_print() to output values. Has access to the live scene tree and all game nodes.", { + code: z + .string() + .describe("GDScript code to execute in the running game. Use _mcp_print(value) to capture output. Code runs inside a run() function with access to the live game scene tree."), + }, async (params) => { + try { + const result = await godot.sendCommand("execute_game_script", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("capture_frames", "Capture multiple screenshots at regular frame intervals from the running game. Returns base64 PNG images. Use this to verify animations are playing correctly — if character poses differ across frames, the animation is working; if all frames show the same pose (e.g. T-pose), animation loading failed. Also useful for verifying movement, physics, and any time-based behavior. Prefer this over get_game_screenshot when you need to confirm something is changing over time.", { + count: coerceNumber() + .optional() + .describe("Number of frames to capture (1-30, default: 5)"), + frame_interval: coerceNumber() + .optional() + .describe("Frames to wait between captures (default: 10, i.e. ~6 captures/sec at 60fps)"), + half_resolution: z + .boolean() + .optional() + .describe("Halve resolution to reduce data size (default: true)"), + node_data: z + .object({ + node_path: z.string().describe("Path to a node to track (e.g. '/root/Main/Player')"), + properties: coerceStringArray().describe("Property names to capture per frame (e.g. ['global_position', 'velocity'])"), + }) + .optional() + .describe("Optional: capture node property data alongside each frame for debugging (position, velocity, etc.)"), + }, async (params) => { + try { + const result = (await godot.sendCommand("capture_frames", params)); + if (result && + typeof result === "object" && + "frames" in result && + Array.isArray(result.frames)) { + const content = []; + const frameData = Array.isArray(result.frame_data) ? result.frame_data : null; + for (let i = 0; i < result.frames.length; i++) { + content.push({ + type: "image", + data: result.frames[i], + mimeType: "image/png", + }); + if (frameData && frameData[i]) { + content.push({ + type: "text", + text: `Frame ${i + 1}: ${JSON.stringify(frameData[i])}`, + }); + } + } + content.push({ + type: "text", + text: `Captured ${result.count} frames (${result.width}x${result.height}${result.half_resolution ? ", half-res" : ""})`, + }); + return { content }; + } + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("record_frames", "Record many screenshots to files on disk for long-running debug observation. Unlike capture_frames (which returns base64 images directly), this saves PNG files to user://mcp_recorded_frames/ and returns file paths. Use this when you need more than 30 frames or want to observe behavior over a longer period without flooding the context with image data.", { + count: coerceNumber() + .optional() + .describe("Number of frames to capture (1-600, default: 30)"), + frame_interval: coerceNumber() + .optional() + .describe("Frames to wait between captures (default: 10, i.e. ~6 captures/sec at 60fps)"), + half_resolution: z + .boolean() + .optional() + .describe("Halve resolution to reduce file size (default: true)"), + node_data: z + .object({ + node_path: z.string().describe("Path to a node to track (e.g. '/root/Main/Player')"), + properties: coerceStringArray().describe("Property names to capture per frame (e.g. ['global_position', 'velocity'])"), + }) + .optional() + .describe("Optional: capture node property data alongside each frame for debugging"), + }, async (params) => { + try { + const result = (await godot.sendCommand("record_frames", params)); + const content = []; + content.push({ + type: "text", + text: `Recorded ${result.count} frames to ${result.directory}/ (${result.width}x${result.height}${result.half_resolution ? ", half-res" : ""})`, + }); + if (Array.isArray(result.files)) { + content.push({ + type: "text", + text: `Files:\n${result.files.join("\n")}`, + }); + } + if (Array.isArray(result.frame_data) && result.frame_data.length > 0) { + content.push({ + type: "text", + text: `Node data:\n${JSON.stringify(result.frame_data, null, 2)}`, + }); + } + return { content }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("monitor_properties", "Record property values over multiple frames from the running game. Returns a timeline of samples. Great for verifying movement (position changing), animation state (current_animation property), physics behavior (velocity), and debugging time-dependent issues.", { + node_path: z + .string() + .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), + properties: coerceStringArray() + .describe("Property names to monitor (e.g. ['position', 'velocity'])"), + frame_count: coerceNumber() + .optional() + .describe("Number of samples to collect (1-600, default: 60)"), + frame_interval: coerceNumber() + .optional() + .describe("Frames to wait between samples (default: 1, every frame)"), + }, async (params) => { + try { + const result = await godot.sendCommand("monitor_properties", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("watch_signals", "Monitor signal emissions on specified nodes in the running game for a duration. Returns a timestamped log of every signal fired — great for debugging event flow, verifying signal connections, and understanding runtime behavior.", { + node_paths: z + .array(z.string()) + .describe("Absolute node paths to watch (e.g. ['/root/Main/Player', '/root/Main/Enemy'])"), + signal_filter: z + .array(z.string()) + .optional() + .describe("Only watch signals containing these substrings (e.g. ['health', 'died']). Omit to watch all signals."), + duration_ms: coerceNumber() + .optional() + .describe("How long to watch in milliseconds (500-30000, default: 5000)"), + }, async (params) => { + try { + const result = await godot.sendCommand("watch_signals", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("start_recording", "Start recording all input events (keyboard, mouse, actions) in the running game. Use stop_recording to get the recorded events.", {}, async () => { + try { + const result = await godot.sendCommand("start_recording"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("stop_recording", "Stop recording input events and return the recorded event timeline. Events include timestamps for replay.", {}, async () => { + try { + const result = await godot.sendCommand("stop_recording"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("replay_recording", "Replay a previously recorded input event sequence in the running game. Useful for regression testing — record a test once, replay it after code changes.", { + events: z.array(z.record(z.string(), z.unknown())).describe("Array of recorded event objects (from stop_recording output)"), + speed: z.number().optional().describe("Playback speed multiplier (default: 1.0, 2.0 = double speed)"), + }, async (params) => { + try { + const result = await godot.sendCommand("replay_recording", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("find_nodes_by_script", "Find all nodes in the running game whose script path contains a given string. Returns matching nodes with their properties.", { + script: z + .string() + .describe("Script path substring to search for (e.g. 'enemy', 'player.gd')"), + properties: coerceStringArray() + .optional() + .describe("Specific property names to include for each match (default: all editor-visible properties)"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_nodes_by_script", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("get_autoload", "Get properties of an autoload/singleton node in the running game. Quick access to global game state like GameManager, EventBus, etc.", { + name: z + .string() + .describe("Autoload name (e.g. 'GameManager', 'EventBus', 'SaveManager')"), + properties: coerceStringArray() + .optional() + .describe("Specific property names to read (default: all editor-visible properties)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_autoload", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("find_ui_elements", "Find all visible UI elements (Button, Label, LineEdit, CheckBox, Slider, etc.) in the running game. Returns each element's text, type, position, and center point for clicking.", { + type_filter: z + .string() + .optional() + .describe("Only return elements of this type (e.g. 'Button', 'Label', 'CheckBox'). Default: all types"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_ui_elements", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("click_button_by_text", "Click a button in the running game by its text label. Finds the button, calculates its center, and simulates a full click (press + release). Much easier than manual coordinate-based clicking.", { + text: z + .string() + .describe("Button text to search for (e.g. 'New Game', 'Start', 'OK')"), + partial: z + .boolean() + .optional() + .describe("Allow partial text matching (default: true). If false, requires exact match."), + }, async (params) => { + try { + const result = await godot.sendCommand("click_button_by_text", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("wait_for_node", "Wait until a node exists at the given path in the running game scene tree. Useful for waiting after scene transitions, node spawning, or UI state changes.", { + node_path: z + .string() + .describe("Absolute node path to wait for (e.g. '/root/Main/Player', '/root/Dungeon')"), + timeout: z + .number() + .optional() + .describe("Maximum seconds to wait (default: 5.0)"), + poll_frames: z + .number() + .optional() + .describe("Frames between each check (default: 5, i.e. ~12 checks/sec at 60fps)"), + }, async (params) => { + try { + const result = await godot.sendCommand("wait_for_node", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("find_nearby_nodes", "Find all nodes within a radius of a position in the running game, sorted by distance. Useful for finding what's near the player (collectibles, enemies, interactables) without manually querying each node's position.", { + position: z + .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number().optional() })]) + .describe("Origin position: either a node_path string (e.g. '/root/Main/Player') to use that node's global_position, or an {x, y, z} coordinate object"), + radius: coerceNumber() + .optional() + .describe("Search radius in world units (default: 20.0)"), + type_filter: z + .string() + .optional() + .describe("Only include nodes of this Godot class (e.g. 'Area3D', 'CharacterBody3D')"), + group_filter: z + .string() + .optional() + .describe("Only include nodes in this group (e.g. 'enemies', 'collectibles')"), + max_results: coerceNumber() + .optional() + .describe("Maximum number of results to return (default: 10)"), + }, async (params) => { + try { + const result = await godot.sendCommand("find_nearby_nodes", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("navigate_to", "Calculate navigation info from the player to a target in the running 3D game. Returns the world direction, camera-relative suggested WASD keys to press, camera yaw rotation needed (as mouse relative_x pixels for simulate_mouse_move), and estimated walk duration. Use this to plan movement instead of manually calculating directions.", { + target: z + .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number() })]) + .describe("Target: either a node_path string (e.g. '/root/Main/Crystal') or an {x, y, z} coordinate object"), + player_path: z + .string() + .optional() + .describe("Player node path (default: '/root/Main/Player')"), + camera_path: z + .string() + .optional() + .describe("Camera node path (default: auto-detect active Camera3D)"), + move_speed: coerceNumber() + .optional() + .describe("Player movement speed in units/sec for duration estimation (default: 5.0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("navigate_to", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("move_to", "Autopilot the player character to walk to a target position in the running 3D game. Handles camera rotation and forward movement internally at 60fps — completes in a single call with no manual simulate_key/simulate_mouse_move loops needed. The player's camera pivot is directly rotated toward the target, and W key is injected to walk. Much more reliable and efficient than navigate_to + manual input simulation.", { + target: z + .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number() })]) + .describe("Target: either a node_path string (e.g. '/root/Main/Crystal') or an {x, y, z} coordinate object"), + player_path: z + .string() + .optional() + .describe("Player node path (default: '/root/Main/Player')"), + camera_path: z + .string() + .optional() + .describe("Camera pivot node path (default: auto-detect SpringArm3D child of player, or active Camera3D parent)"), + arrival_radius: coerceNumber() + .optional() + .describe("Stop when this close to target in world units (default: 1.5)"), + timeout: coerceNumber() + .optional() + .describe("Maximum seconds before giving up (default: 15.0)"), + run: z + .boolean() + .optional() + .describe("Hold Shift for running speed (default: false)"), + look_at_target: z + .boolean() + .optional() + .describe("Rotate camera toward target while moving (default: true). Set false to walk forward without turning."), + }, async (params) => { + try { + const result = await godot.sendCommand("move_to", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("batch_get_properties", "Get properties of multiple nodes at once in the running game. More efficient than calling get_game_node_properties multiple times.", { + nodes: z + .array(z.object({ + path: z + .string() + .describe("Absolute node path (e.g. '/root/Main/Player')"), + properties: coerceStringArray() + .optional() + .describe("Specific properties to read (default: all editor-visible)"), + })) + .describe("Array of nodes to query"), + }, async (params) => { + try { + const result = await godot.sendCommand("batch_get_properties", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); +} +//# sourceMappingURL=runtime-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js.map new file mode 100644 index 0000000..9c75d8f --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/runtime-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"runtime-tools.js","sourceRoot":"","sources":["../../src/tools/runtime-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts new file mode 100644 index 0000000..c32e0ef --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerScene3DTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=scene-3d-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts.map new file mode 100644 index 0000000..897a2be --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"scene-3d-tools.d.ts","sourceRoot":"","sources":["../../src/tools/scene-3d-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA4dN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js new file mode 100644 index 0000000..1506003 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js @@ -0,0 +1,405 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerScene3DTools(server, godot) { + // ─── 1. add_mesh_instance ────────────────────────────────────────────── + server.tool("add_mesh_instance", "Add a MeshInstance3D node with a primitive mesh (Box, Sphere, Cylinder, Capsule, Plane, Prism, Torus, Quad) or load a 3D model file (.glb/.gltf/.obj). Set position, rotation, scale, and mesh-specific properties.", { + mesh_type: z + .string() + .optional() + .describe("Primitive mesh type: BoxMesh, SphereMesh, CylinderMesh, CapsuleMesh, PlaneMesh, PrismMesh, TorusMesh, QuadMesh"), + mesh_file: z + .string() + .optional() + .describe("Path to a 3D model file (res://path/to/model.glb, .gltf, .obj). Use instead of mesh_type for imported models"), + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Node name (default: MeshInstance3D)"), + position: z + .any() + .optional() + .describe("Position as Vector3 string 'Vector3(x,y,z)', object {x,y,z}, or array [x,y,z]"), + rotation: z + .any() + .optional() + .describe("Rotation in degrees as Vector3 string, object {x,y,z}, or array [x,y,z]"), + scale: z + .any() + .optional() + .describe("Scale as Vector3 string, object {x,y,z}, or array [x,y,z]"), + mesh_properties: z + .record(z.string(), z.any()) + .optional() + .describe("Properties to set on the mesh resource (e.g. {\"size\": \"Vector3(2,1,2)\"} for BoxMesh)"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_mesh_instance", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + // ─── 2. setup_lighting ───────────────────────────────────────────────── + server.tool("setup_lighting", "Add a light node (DirectionalLight3D, OmniLight3D, SpotLight3D) to the scene. Supports preset configurations: 'sun' (directional with shadows), 'indoor' (warm omni), 'dramatic' (focused spot with shadows).", { + light_type: z + .string() + .optional() + .describe("Light type: DirectionalLight3D, OmniLight3D, SpotLight3D. Not needed if preset is specified"), + preset: z + .string() + .optional() + .describe("Preset configuration: 'sun' (directional, shadows, -45deg), 'indoor' (warm omni, range 8), 'dramatic' (spot, high energy, shadows)"), + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Node name"), + color: z + .any() + .optional() + .describe("Light color as Color string or hex (default: white)"), + energy: z + .number() + .optional() + .describe("Light energy/intensity (default: 1.0)"), + shadows: z + .boolean() + .optional() + .describe("Enable shadow casting (default: false, true for sun/dramatic presets)"), + range: z + .number() + .optional() + .describe("Range for OmniLight3D/SpotLight3D (default: 5.0)"), + attenuation: z + .number() + .optional() + .describe("Attenuation for OmniLight3D/SpotLight3D (default: 1.0)"), + spot_angle: z + .number() + .optional() + .describe("Spot angle in degrees for SpotLight3D (default: 45.0)"), + spot_angle_attenuation: z + .number() + .optional() + .describe("Spot angle attenuation for SpotLight3D (default: 1.0)"), + position: z + .any() + .optional() + .describe("Position as Vector3 string, object {x,y,z}, or array [x,y,z]"), + rotation: z + .any() + .optional() + .describe("Rotation in degrees as Vector3 string, object {x,y,z}, or array [x,y,z]"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_lighting", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + // ─── 3. set_material_3d ──────────────────────────────────────────────── + server.tool("set_material_3d", "Create and apply a StandardMaterial3D to a MeshInstance3D. Configure PBR properties: albedo color/texture, metallic, roughness, emission, transparency, normal maps.", { + node_path: z + .string() + .describe("Path to the MeshInstance3D node"), + surface_index: z + .number() + .optional() + .describe("Surface index to apply material to (default: 0)"), + albedo_color: z + .any() + .optional() + .describe("Albedo color as Color string 'Color(r,g,b,a)', hex '#ff0000', or object {r,g,b,a}"), + albedo_texture: z + .string() + .optional() + .describe("Path to albedo texture (res://path/to/texture.png)"), + metallic: z + .number() + .optional() + .describe("Metallic value 0.0-1.0 (default: 0.0)"), + roughness: z + .number() + .optional() + .describe("Roughness value 0.0-1.0 (default: 1.0)"), + metallic_texture: z + .string() + .optional() + .describe("Path to metallic texture"), + roughness_texture: z + .string() + .optional() + .describe("Path to roughness texture"), + normal_texture: z + .string() + .optional() + .describe("Path to normal map texture (auto-enables normal mapping)"), + emission: z + .any() + .optional() + .describe("Emission color (auto-enables emission). Color string or hex"), + emission_color: z + .any() + .optional() + .describe("Alias for emission"), + emission_energy: z + .number() + .optional() + .describe("Emission energy multiplier (default: 1.0)"), + emission_texture: z + .string() + .optional() + .describe("Path to emission texture"), + transparency: z + .string() + .optional() + .describe("Transparency mode: DISABLED, ALPHA, ALPHA_SCISSOR, ALPHA_HASH, ALPHA_DEPTH_PRE_PASS"), + cull_mode: z + .string() + .optional() + .describe("Cull mode: BACK, FRONT, DISABLED"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_material_3d", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + // ─── 4. setup_environment ────────────────────────────────────────────── + server.tool("setup_environment", "Add or configure a WorldEnvironment node with sky, ambient light, tonemap, fog, glow, SSAO, SSR, and SDFGI settings.", { + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + node_path: z + .string() + .optional() + .describe("Path to an existing WorldEnvironment to modify instead of creating a new one"), + name: z + .string() + .optional() + .describe("Node name (default: WorldEnvironment)"), + background_mode: z + .string() + .optional() + .describe("Background mode: 'sky', 'color', 'canvas', 'clear_color' (default: sky)"), + background_color: z + .any() + .optional() + .describe("Background color when mode is 'color'"), + sky: z + .object({ + sky_top_color: z.any().optional().describe("Sky top color"), + sky_horizon_color: z.any().optional().describe("Sky horizon color"), + ground_bottom_color: z.any().optional().describe("Ground bottom color"), + ground_horizon_color: z + .any() + .optional() + .describe("Ground horizon color"), + sun_angle_max: z + .number() + .optional() + .describe("Maximum sun angle in degrees"), + sky_curve: z + .number() + .optional() + .describe("Sky color curve (0.0-1.0)"), + }) + .optional() + .describe("ProceduralSkyMaterial settings"), + ambient_light_color: z.any().optional().describe("Ambient light color"), + ambient_light_energy: z + .number() + .optional() + .describe("Ambient light energy"), + ambient_light_source: z + .string() + .optional() + .describe("Ambient light source: BACKGROUND, DISABLED, COLOR, SKY"), + tonemap_mode: z + .string() + .optional() + .describe("Tonemap mode: LINEAR, REINHARDT, FILMIC, ACES"), + tonemap_exposure: z.number().optional().describe("Tonemap exposure"), + tonemap_white: z.number().optional().describe("Tonemap white point"), + fog_enabled: z.boolean().optional().describe("Enable volumetric fog"), + fog_light_color: z.any().optional().describe("Fog light color"), + fog_density: z.number().optional().describe("Fog density"), + fog_light_energy: z.number().optional().describe("Fog light energy"), + glow_enabled: z.boolean().optional().describe("Enable glow/bloom"), + glow_intensity: z.number().optional().describe("Glow intensity"), + glow_strength: z.number().optional().describe("Glow strength"), + glow_bloom: z.number().optional().describe("Glow bloom amount"), + ssao_enabled: z + .boolean() + .optional() + .describe("Enable Screen-Space Ambient Occlusion"), + ssao_radius: z.number().optional().describe("SSAO radius"), + ssao_intensity: z.number().optional().describe("SSAO intensity"), + ssr_enabled: z + .boolean() + .optional() + .describe("Enable Screen-Space Reflections"), + ssr_max_steps: z.number().optional().describe("SSR max steps"), + ssr_fade_in: z.number().optional().describe("SSR fade in"), + ssr_fade_out: z.number().optional().describe("SSR fade out"), + sdfgi_enabled: z + .boolean() + .optional() + .describe("Enable Signed Distance Field Global Illumination"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_environment", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + // ─── 5. setup_camera_3d ──────────────────────────────────────────────── + server.tool("setup_camera_3d", "Add or configure a Camera3D node. Set projection mode, FOV, near/far planes, position, rotation, look-at target, and cull mask.", { + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + node_path: z + .string() + .optional() + .describe("Path to an existing Camera3D to configure instead of creating a new one"), + name: z.string().optional().describe("Node name (default: Camera3D)"), + projection: z + .string() + .optional() + .describe("Projection mode: 'perspective', 'orthogonal'/'orthographic', 'frustum'"), + fov: z + .number() + .optional() + .describe("Field of view in degrees for perspective (default: 75)"), + size: z + .number() + .optional() + .describe("View size for orthogonal projection"), + near: z + .number() + .optional() + .describe("Near clipping plane (default: 0.05)"), + far: z + .number() + .optional() + .describe("Far clipping plane (default: 4000)"), + cull_mask: z + .number() + .optional() + .describe("Cull mask as integer bitmask"), + current: z + .boolean() + .optional() + .describe("Make this the current/active camera (default: false)"), + position: z + .any() + .optional() + .describe("Position as Vector3 (default: (0, 1, 3) for new cameras)"), + rotation: z + .any() + .optional() + .describe("Rotation in degrees as Vector3"), + look_at: z + .any() + .optional() + .describe("Target position to look at as Vector3 (overrides rotation)"), + environment_path: z + .string() + .optional() + .describe("Path to an Environment resource for camera-specific environment override"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_camera_3d", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + // ─── 6. add_gridmap ──────────────────────────────────────────────────── + server.tool("add_gridmap", "Add or configure a GridMap node with a MeshLibrary. Optionally set cells at specific grid positions with item IDs and orientations.", { + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + node_path: z + .string() + .optional() + .describe("Path to an existing GridMap to configure instead of creating a new one"), + name: z.string().optional().describe("Node name (default: GridMap)"), + mesh_library_path: z + .string() + .optional() + .describe("Path to a MeshLibrary resource (res://path/to/library.meshlib or .tres)"), + cell_size: z + .any() + .optional() + .describe("Cell size as Vector3 (default: (2, 2, 2))"), + position: z.any().optional().describe("GridMap position as Vector3"), + cells: z + .array(z.object({ + x: z.number().describe("Cell X coordinate"), + y: z.number().describe("Cell Y coordinate"), + z: z.number().describe("Cell Z coordinate"), + item: z + .number() + .optional() + .describe("MeshLibrary item index (default: 0)"), + orientation: z + .number() + .optional() + .describe("Cell orientation index (default: 0)"), + })) + .optional() + .describe("Array of cells to set with grid positions and item IDs"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_gridmap", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); +} +//# sourceMappingURL=scene-3d-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js.map new file mode 100644 index 0000000..3daf041 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-3d-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scene-3d-tools.js","sourceRoot":"","sources":["../../src/tools/scene-3d-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,oBAAoB,CAClC,MAAiB,EACjB,KAAsB;IAEtB,0EAA0E;IAE1E,MAAM,CAAC,IAAI,CACT,mBAAmB,EACnB,qNAAqN,EACrN;QACE,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,gHAAgH,CACjH;QACH,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,8GAA8G,CAC/G;QACH,WAAW,EAAE,CAAC;aACX,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,sCAAsC,CAAC;QACnD,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QAC3E,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CACP,+EAA+E,CAChF;QACH,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CACP,yEAAyE,CAC1E;QACH,KAAK,EAAE,CAAC;aACL,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,2DAA2D,CAAC;QACxE,eAAe,EAAE,CAAC;aACf,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC;aAC3B,QAAQ,EAAE;aACV,QAAQ,CACP,0FAA0F,CAC3F;KACJ,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;YACpE,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;IAEF,0EAA0E;IAE1E,MAAM,CAAC,IAAI,CACT,gBAAgB,EAChB,+MAA+M,EAC/M;QACE,UAAU,EAAE,CAAC;aACV,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,6FAA6F,CAC9F;QACH,MAAM,EAAE,CAAC;aACN,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,oIAAoI,CACrI;QACH,WAAW,EAAE,CAAC;aACX,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,sCAAsC,CAAC;QACnD,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,WAAW,CAAC;QACjD,KAAK,EAAE,CAAC;aACL,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,qDAAqD,CAAC;QAClE,MAAM,EAAE,CAAC;aACN,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,uCAAuC,CAAC;QACpD,OAAO,EAAE,CAAC;aACP,OAAO,EAAE;aACT,QAAQ,EAAE;aACV,QAAQ,CAAC,uEAAuE,CAAC;QACpF,KAAK,EAAE,CAAC;aACL,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,kDAAkD,CAAC;QAC/D,WAAW,EAAE,CAAC;aACX,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,wDAAwD,CAAC;QACrE,UAAU,EAAE,CAAC;aACV,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,uDAAuD,CAAC;QACpE,sBAAsB,EAAE,CAAC;aACtB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,uDAAuD,CAAC;QACpE,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,8DAA8D,CAAC;QAC3E,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,yEAAyE,CAAC;KACvF,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;YACjE,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;IAEF,0EAA0E;IAE1E,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,sKAAsK,EACtK;QACE,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,CAAC,iCAAiC,CAAC;QAC9C,aAAa,EAAE,CAAC;aACb,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,iDAAiD,CAAC;QAC9D,YAAY,EAAE,CAAC;aACZ,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CACP,mFAAmF,CACpF;QACH,cAAc,EAAE,CAAC;aACd,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,oDAAoD,CAAC;QACjE,QAAQ,EAAE,CAAC;aACR,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,uCAAuC,CAAC;QACpD,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,wCAAwC,CAAC;QACrD,gBAAgB,EAAE,CAAC;aAChB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,0BAA0B,CAAC;QACvC,iBAAiB,EAAE,CAAC;aACjB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,2BAA2B,CAAC;QACxC,cAAc,EAAE,CAAC;aACd,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,0DAA0D,CAAC;QACvE,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CACP,6DAA6D,CAC9D;QACH,cAAc,EAAE,CAAC;aACd,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,oBAAoB,CAAC;QACjC,eAAe,EAAE,CAAC;aACf,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,2CAA2C,CAAC;QACxD,gBAAgB,EAAE,CAAC;aAChB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,0BAA0B,CAAC;QACvC,YAAY,EAAE,CAAC;aACZ,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,qFAAqF,CACtF;QACH,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,kCAAkC,CAAC;KAChD,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;YAClE,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;IAEF,0EAA0E;IAE1E,MAAM,CAAC,IAAI,CACT,mBAAmB,EACnB,sHAAsH,EACtH;QACE,WAAW,EAAE,CAAC;aACX,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,sCAAsC,CAAC;QACnD,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,8EAA8E,CAC/E;QACH,IAAI,EAAE,CAAC;aACJ,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,uCAAuC,CAAC;QACpD,eAAe,EAAE,CAAC;aACf,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,yEAAyE,CAC1E;QACH,gBAAgB,EAAE,CAAC;aAChB,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,uCAAuC,CAAC;QACpD,GAAG,EAAE,CAAC;aACH,MAAM,CAAC;YACN,aAAa,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,eAAe,CAAC;YAC3D,iBAAiB,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;YACnE,mBAAmB,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qBAAqB,CAAC;YACvE,oBAAoB,EAAE,CAAC;iBACpB,GAAG,EAAE;iBACL,QAAQ,EAAE;iBACV,QAAQ,CAAC,sBAAsB,CAAC;YACnC,aAAa,EAAE,CAAC;iBACb,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,8BAA8B,CAAC;YAC3C,SAAS,EAAE,CAAC;iBACT,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,2BAA2B,CAAC;SACzC,CAAC;aACD,QAAQ,EAAE;aACV,QAAQ,CAAC,gCAAgC,CAAC;QAC7C,mBAAmB,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qBAAqB,CAAC;QACvE,oBAAoB,EAAE,CAAC;aACpB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,sBAAsB,CAAC;QACnC,oBAAoB,EAAE,CAAC;aACpB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,wDAAwD,CACzD;QACH,YAAY,EAAE,CAAC;aACZ,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,+CAA+C,CAAC;QAC5D,gBAAgB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,kBAAkB,CAAC;QACpE,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qBAAqB,CAAC;QACpE,WAAW,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,uBAAuB,CAAC;QACrE,eAAe,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iBAAiB,CAAC;QAC/D,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,aAAa,CAAC;QAC1D,gBAAgB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,kBAAkB,CAAC;QACpE,YAAY,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;QAClE,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;QAChE,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,eAAe,CAAC;QAC9D,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;QAC/D,YAAY,EAAE,CAAC;aACZ,OAAO,EAAE;aACT,QAAQ,EAAE;aACV,QAAQ,CAAC,uCAAuC,CAAC;QACpD,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,aAAa,CAAC;QAC1D,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;QAChE,WAAW,EAAE,CAAC;aACX,OAAO,EAAE;aACT,QAAQ,EAAE;aACV,QAAQ,CAAC,iCAAiC,CAAC;QAC9C,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,eAAe,CAAC;QAC9D,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,aAAa,CAAC;QAC1D,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,cAAc,CAAC;QAC5D,aAAa,EAAE,CAAC;aACb,OAAO,EAAE;aACT,QAAQ,EAAE;aACV,QAAQ,CAAC,kDAAkD,CAAC;KAChE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;YACpE,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;IAEF,0EAA0E;IAE1E,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,iIAAiI,EACjI;QACE,WAAW,EAAE,CAAC;aACX,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,sCAAsC,CAAC;QACnD,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,yEAAyE,CAC1E;QACH,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,+BAA+B,CAAC;QACrE,UAAU,EAAE,CAAC;aACV,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,wEAAwE,CACzE;QACH,GAAG,EAAE,CAAC;aACH,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,wDAAwD,CAAC;QACrE,IAAI,EAAE,CAAC;aACJ,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,qCAAqC,CAAC;QAClD,IAAI,EAAE,CAAC;aACJ,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,qCAAqC,CAAC;QAClD,GAAG,EAAE,CAAC;aACH,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,oCAAoC,CAAC;QACjD,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,8BAA8B,CAAC;QAC3C,OAAO,EAAE,CAAC;aACP,OAAO,EAAE;aACT,QAAQ,EAAE;aACV,QAAQ,CAAC,sDAAsD,CAAC;QACnE,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,0DAA0D,CAAC;QACvE,QAAQ,EAAE,CAAC;aACR,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,gCAAgC,CAAC;QAC7C,OAAO,EAAE,CAAC;aACP,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CACP,4DAA4D,CAC7D;QACH,gBAAgB,EAAE,CAAC;aAChB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,0EAA0E,CAC3E;KACJ,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;YAClE,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;IAEF,0EAA0E;IAE1E,MAAM,CAAC,IAAI,CACT,aAAa,EACb,qIAAqI,EACrI;QACE,WAAW,EAAE,CAAC;aACX,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CAAC,sCAAsC,CAAC;QACnD,SAAS,EAAE,CAAC;aACT,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,wEAAwE,CACzE;QACH,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8BAA8B,CAAC;QACpE,iBAAiB,EAAE,CAAC;aACjB,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,yEAAyE,CAC1E;QACH,SAAS,EAAE,CAAC;aACT,GAAG,EAAE;aACL,QAAQ,EAAE;aACV,QAAQ,CAAC,2CAA2C,CAAC;QACxD,QAAQ,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,6BAA6B,CAAC;QACpE,KAAK,EAAE,CAAC;aACL,KAAK,CACJ,CAAC,CAAC,MAAM,CAAC;YACP,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;YAC3C,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;YAC3C,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;YAC3C,IAAI,EAAE,CAAC;iBACJ,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,qCAAqC,CAAC;YAClD,WAAW,EAAE,CAAC;iBACX,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,qCAAqC,CAAC;SACnD,CAAC,CACH;aACA,QAAQ,EAAE;aACV,QAAQ,CAAC,wDAAwD,CAAC;KACtE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;YAC9D,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts new file mode 100644 index 0000000..91c9ce5 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerSceneTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=scene-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts.map new file mode 100644 index 0000000..bb7c23e --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"scene-tools.d.ts","sourceRoot":"","sources":["../../src/tools/scene-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAkKN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js new file mode 100644 index 0000000..97788a4 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js @@ -0,0 +1,117 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerSceneTools(server, godot) { + server.tool("get_scene_tree", "Get the live scene tree of the currently edited scene, showing all nodes, types, and hierarchy", { + max_depth: z.number().optional().describe("Max tree depth to return (-1 for unlimited)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_scene_tree", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_scene_file_content", "Read the raw .tscn file content of a scene", { + path: z.string().describe("Path to the scene file (e.g. 'res://scenes/main.tscn')"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_scene_file_content", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("create_scene", "Create a new scene file with a specified root node type", { + path: z.string().describe("Path for the new scene (e.g. 'res://scenes/enemy.tscn')"), + root_type: z.string().optional().describe("Root node type (default: Node2D). Examples: Node2D, Node3D, Control, CharacterBody2D"), + root_name: z.string().optional().describe("Root node name (defaults to filename)"), + }, async (params) => { + try { + const result = await godot.sendCommand("create_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("open_scene", "Open a scene file in the Godot editor", { + path: z.string().describe("Path to the scene file (e.g. 'res://scenes/main.tscn')"), + }, async (params) => { + try { + const result = await godot.sendCommand("open_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("delete_scene", "Delete a scene file from the project", { + path: z.string().describe("Path to the scene file to delete"), + }, async (params) => { + try { + const result = await godot.sendCommand("delete_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("add_scene_instance", "Add an existing scene as a child node (instancing) in the current scene", { + scene_path: z.string().describe("Path to the scene to instance (e.g. 'res://scenes/enemy.tscn')"), + parent_path: z.string().optional().describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Custom name for the instance"), + }, async (params) => { + try { + const result = await godot.sendCommand("add_scene_instance", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("play_scene", "Run a scene in the Godot editor (main scene, current scene, or specific path)", { + mode: z.string().optional().describe("'main' (default), 'current', or a scene file path"), + }, async (params) => { + try { + const result = await godot.sendCommand("play_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("stop_scene", "Stop the currently playing scene", {}, async () => { + try { + const result = await godot.sendCommand("stop_scene"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("save_scene", "Save the currently edited scene to disk", { + path: z.string().optional().describe("Optional path to save to (defaults to current scene path)"), + }, async (params) => { + try { + const result = await godot.sendCommand("save_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_scene_exports", "Get all @export variables from all scripted nodes in a scene file. Useful for inspecting configurable parameters without opening the scene.", { + path: z.string().describe("Path to the scene file (e.g. 'res://scenes/enemy.tscn')"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_scene_exports", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=scene-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js.map new file mode 100644 index 0000000..cc405c3 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/scene-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scene-tools.js","sourceRoot":"","sources":["../../src/tools/scene-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts new file mode 100644 index 0000000..db92ebb --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerScriptTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=script-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts.map new file mode 100644 index 0000000..a009cdc --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"script-tools.d.ts","sourceRoot":"","sources":["../../src/tools/script-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAoIN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js new file mode 100644 index 0000000..dc6d1b2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerScriptTools(server, godot) { + server.tool("list_scripts", "List all GDScript/C#/shader files in the project with class info", { + path: z.string().optional().describe("Root path to search (default: res://)"), + recursive: z.boolean().optional().describe("Search recursively (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("list_scripts", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("read_script", "Read the full content of a GDScript file", { + path: z.string().describe("Path to the script (e.g. 'res://scripts/player.gd')"), + }, async (params) => { + try { + const result = await godot.sendCommand("read_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("create_script", "Create a new GDScript file with optional content or auto-generated template. Restricted to .gd/.cs paths. Refuses to overwrite a script that is currently open in Godot's script editor unless force=true is set.", { + path: z.string().describe("Path for the new script (e.g. 'res://scripts/enemy_ai.gd'). Must be a .gd or .cs file."), + content: z.string().optional().describe("Full script content. If empty, generates a template."), + extends: z.string().optional().describe("Base class (default: 'Node'). Only used for template generation."), + class_name: z.string().optional().describe("Class name to add. Only used for template generation."), + force: z.boolean().optional().describe("Override the open-script-editor guard and write anyway. Use only when no editor buffer holds unsaved changes for the target path."), + }, async (params) => { + try { + const result = await godot.sendCommand("create_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("edit_script", "Edit a script using search-and-replace, full content replacement, line insertion, or 1-based inclusive line-range replacement. Restricted to .gd/.cs paths. Refuses to write a script that is currently open in Godot's script editor unless force=true is set.", { + path: z.string().describe("Path to the script to edit. Must be a .gd or .cs file."), + replacements: z + .array(z.object({ + search: z.string().describe("Text to find"), + replace: z.string().describe("Replacement text"), + regex: z.boolean().optional().describe("Use regex for search (default: false)"), + })) + .optional() + .describe("Array of search-and-replace operations"), + content: z.string().optional().describe("Full replacement content (replaces entire file), or replacement lines when combined with start_line/end_line"), + insert_at_line: z.number().optional().describe("Line number to insert text at (0-indexed)"), + text: z.string().optional().describe("Text to insert (used with insert_at_line)"), + start_line: z.number().optional().describe("1-based inclusive starting line for range replacement (used with content)"), + end_line: z.number().optional().describe("1-based inclusive ending line for range replacement (defaults to start_line)"), + force: z.boolean().optional().describe("Override the open-script-editor guard and write anyway."), + }, async (params) => { + try { + const result = await godot.sendCommand("edit_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("attach_script", "Attach a GDScript to a node in the current scene", { + node_path: z.string().describe("Path to the target node"), + script_path: z.string().describe("Path to the script file (e.g. 'res://scripts/player.gd')"), + }, async (params) => { + try { + const result = await godot.sendCommand("attach_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("validate_script", "Validate a GDScript file by attempting to compile it. Returns whether the script is valid. Use get_output_log or get_editor_errors for detailed error messages on failure.", { + path: z.string().describe("Path to the script to validate (e.g. 'res://scripts/player.gd')"), + }, async (params) => { + try { + const result = await godot.sendCommand("validate_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_open_scripts", "Get a list of scripts currently open in the Godot script editor", {}, async () => { + try { + const result = await godot.sendCommand("get_open_scripts"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=script-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js.map new file mode 100644 index 0000000..db2f707 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/script-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"script-tools.js","sourceRoot":"","sources":["../../src/tools/script-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts new file mode 100644 index 0000000..af0808a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerShaderTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=shader-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts.map new file mode 100644 index 0000000..467a38d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"shader-tools.d.ts","sourceRoot":"","sources":["../../src/tools/shader-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA4GN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js new file mode 100644 index 0000000..788299e --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerShaderTools(server, godot) { + server.tool("create_shader", "Create a new shader file with a template or custom content. Refuses to overwrite a shader that is currently loaded/open in the editor unless force=true.", { + path: z.string().describe("Path for the shader file (e.g. 'res://shaders/dissolve.gdshader')"), + shader_type: z.string().optional().describe("Shader type: spatial, canvas_item, particles, sky (default: spatial)"), + content: z.string().optional().describe("Full shader code. If empty, generates a template."), + force: z.boolean().optional().describe("Override the open/cached-shader guard and write anyway."), + }, async (params) => { + try { + const result = await godot.sendCommand("create_shader", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("read_shader", "Read the content of a shader file", { + path: z.string().describe("Path to the shader file"), + }, async (params) => { + try { + const result = await godot.sendCommand("read_shader", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("edit_shader", "Edit a shader file using full replacement or search-and-replace. Refreshes any cached/loaded copy of the shader via take_over_path + emit_changed so live materials pick up changes. Refuses to write if the shader is currently loaded/open in the editor unless force=true.", { + path: z.string().describe("Path to the shader file"), + content: z.string().optional().describe("Full replacement content"), + replacements: z.array(z.object({ + search: z.string().describe("Text to find"), + replace: z.string().describe("Text to replace with"), + })).optional().describe("Array of search-and-replace operations"), + force: z.boolean().optional().describe("Override the open/cached-shader guard and write anyway."), + }, async (params) => { + try { + const result = await godot.sendCommand("edit_shader", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("assign_shader_material", "Create a ShaderMaterial from a shader file and assign it to a node", { + node_path: z.string().describe("Path to the target node (CanvasItem or MeshInstance3D)"), + shader_path: z.string().describe("Path to the shader file"), + }, async (params) => { + try { + const result = await godot.sendCommand("assign_shader_material", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_shader_param", "Set a shader parameter on a node's ShaderMaterial", { + node_path: z.string().describe("Path to the node with a ShaderMaterial"), + param: z.string().describe("Shader parameter name"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Parameter value. Strings auto-parsed for Vector2, Color, etc."), + }, async (params) => { + try { + const result = await godot.sendCommand("set_shader_param", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_shader_params", "Get all shader parameters and their current values from a node's ShaderMaterial", { + node_path: z.string().describe("Path to the node with a ShaderMaterial"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_shader_params", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=shader-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js.map new file mode 100644 index 0000000..5176899 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/shader-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"shader-tools.js","sourceRoot":"","sources":["../../src/tools/shader-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts new file mode 100644 index 0000000..8eadd2b --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerTestTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=test-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts.map new file mode 100644 index 0000000..02b2067 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"test-tools.d.ts","sourceRoot":"","sources":["../../src/tools/test-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA0LN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js new file mode 100644 index 0000000..c4adb65 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js @@ -0,0 +1,146 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerTestTools(server, godot) { + server.tool("run_test_scenario", "Execute a test scenario in the running game. Optionally plays a scene, then runs a sequence of steps (input simulation, waits, assertions, screenshots). Returns pass/fail summary. Note: keep scenarios short (under 20 seconds total) to avoid timeout.", { + scene_path: z + .string() + .optional() + .describe("Scene to play before running steps. Use 'main' for main scene, 'current' for current scene, or a res:// path. If omitted, uses already-running scene."), + steps: z + .array(z.record(z.string(), z.unknown())) + .describe("Array of test steps. Each step has a 'type' field: " + + "'input' (action/keycode simulation), " + + "'wait' (seconds or node_path to wait for), " + + "'assert' (node_path+property+expected+operator, or text for screen text), " + + "'screenshot' (capture a frame). " + + "Examples: " + + "{type:'input', action:'ui_accept'}, " + + "{type:'wait', seconds:0.5}, " + + "{type:'wait', node_path:'/root/Main/Player'}, " + + "{type:'assert', node_path:'/root/Main/Player', property:'health', expected:100, operator:'eq'}, " + + "{type:'assert', text:'Game Over'}, " + + "{type:'screenshot'}"), + }, async (params) => { + try { + const result = await godot.sendCommand("run_test_scenario", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("assert_node_state", "Assert a node's property value in the running game. Compares the actual property value against an expected value using the specified operator. Returns pass/fail with actual value for debugging.", { + node_path: z + .string() + .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), + property: z + .string() + .describe("Property name to check (e.g. 'health', 'position:x', 'visible')"), + expected: z + .union([z.string(), z.number(), z.boolean()]) + .describe("Expected value to compare against"), + operator: z + .enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"]) + .optional() + .describe("Comparison operator (default: 'eq'). " + + "eq/neq: equality, gt/lt/gte/lte: numeric comparison, " + + "contains: string/array contains, type_is: check Godot type name"), + }, async (params) => { + try { + const result = await godot.sendCommand("assert_node_state", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("assert_screen_text", "Assert that specific text is visible on screen in the running game. Searches all visible UI elements (Button, Label, LineEdit, etc.) for matching text. Useful for verifying UI state, dialog content, or game messages.", { + text: z + .string() + .describe("Text to search for on screen (e.g. 'Game Over', 'Score:', 'New Game')"), + partial: z + .boolean() + .optional() + .describe("Allow partial text matching (default: true). If false, requires exact match."), + case_sensitive: z + .boolean() + .optional() + .describe("Case-sensitive comparison (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("assert_screen_text", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("run_stress_test", "Run rapid random input events for a specified duration and check if the game crashes. Sends random UI actions (up/down/left/right/accept/cancel) plus any custom actions. Returns whether the game survived, event count, and new errors.", { + duration: z + .number() + .optional() + .describe("Duration in seconds to run the stress test (1-60, default: 5)"), + actions: z + .array(z.string()) + .optional() + .describe("Additional input action names to include in random input pool (e.g. ['jump', 'attack', 'dash'])"), + }, async (params) => { + try { + const result = await godot.sendCommand("run_stress_test", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); + server.tool("get_test_report", "Collect and format results from all assertions run so far (via assert_node_state, assert_screen_text, and run_test_scenario) into a summary test report. Shows pass/fail counts, pass rate, and detailed results.", { + clear: z + .boolean() + .optional() + .describe("Clear accumulated results after generating report (default: true)"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_test_report", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } + catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + }); +} +//# sourceMappingURL=test-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js.map new file mode 100644 index 0000000..4d0e2b2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/test-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"test-tools.js","sourceRoot":"","sources":["../../src/tools/test-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts new file mode 100644 index 0000000..c2ea9bb --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerThemeTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=theme-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts.map new file mode 100644 index 0000000..ce58538 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"theme-tools.d.ts","sourceRoot":"","sources":["../../src/tools/theme-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA2IN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js new file mode 100644 index 0000000..70b83e2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js @@ -0,0 +1,109 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerThemeTools(server, godot) { + server.tool("create_theme", "Create a new Theme resource file", { + path: z.string().describe("Path to save the theme (e.g. 'res://themes/main.tres')"), + default_font_size: z.number().optional().describe("Default font size"), + }, async (params) => { + try { + const result = await godot.sendCommand("create_theme", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_theme_color", "Set a theme color override on a Control node", { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Color name (e.g. 'font_color', 'font_hover_color')"), + color: z.string().describe("Color as hex string (e.g. '#ff0000') or name"), + theme_type: z.string().optional().describe("Theme type (defaults to node's class)"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_theme_color", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_theme_constant", "Set a theme constant override on a Control node", { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Constant name (e.g. 'margin_left', 'separation')"), + value: z.number().describe("Integer value"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_theme_constant", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_theme_font_size", "Set a theme font size override on a Control node", { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Font size name (e.g. 'font_size')"), + size: z.number().describe("Font size in pixels"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_theme_font_size", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("set_theme_stylebox", "Set a StyleBoxFlat override on a Control node with customizable appearance", { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Style name (e.g. 'panel', 'normal', 'hover', 'pressed')"), + bg_color: z.string().optional().describe("Background color (hex)"), + border_color: z.string().optional().describe("Border color (hex)"), + border_width: z.number().optional().describe("Border width in pixels"), + corner_radius: z.number().optional().describe("Corner radius in pixels"), + padding: z.number().optional().describe("Content padding in pixels"), + }, async (params) => { + try { + const result = await godot.sendCommand("set_theme_stylebox", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("setup_control", "Configure a Control/Container node's layout properties in one call. Sets anchor preset, margins, min size, size flags, and container-specific properties like separation — much faster than multiple update_property calls.", { + node_path: z.string().describe("Path to the Control node"), + anchor_preset: z.string().optional().describe("Anchor preset: 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center_left', 'center_top', 'center_right', 'center_bottom', 'center', 'left_wide', 'top_wide', 'right_wide', 'bottom_wide', 'vcenter_wide', 'hcenter_wide', 'full_rect'"), + min_size: z.string().optional().describe("Minimum size as 'Vector2(w, h)'"), + size_flags_h: z.string().optional().describe("Horizontal size flags: 'fill', 'expand', 'fill_expand', 'shrink_center', 'shrink_end'"), + size_flags_v: z.string().optional().describe("Vertical size flags: 'fill', 'expand', 'fill_expand', 'shrink_center', 'shrink_end'"), + margins: z.object({ + left: z.number().optional(), + top: z.number().optional(), + right: z.number().optional(), + bottom: z.number().optional(), + }).optional().describe("Margin overrides for MarginContainer (sets theme constants margin_left/right/top/bottom)"), + separation: z.number().optional().describe("Separation for VBoxContainer/HBoxContainer (theme constant override)"), + grow_h: z.string().optional().describe("Horizontal grow direction: 'begin', 'end', 'both'"), + grow_v: z.string().optional().describe("Vertical grow direction: 'begin', 'end', 'both'"), + }, async (params) => { + try { + const result = await godot.sendCommand("setup_control", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("get_theme_info", "Get theme information and overrides for a Control node", { + node_path: z.string().describe("Path to the Control node"), + }, async (params) => { + try { + const result = await godot.sendCommand("get_theme_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=theme-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js.map new file mode 100644 index 0000000..4ef726d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/theme-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"theme-tools.js","sourceRoot":"","sources":["../../src/tools/theme-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,kBAAkB,CAChC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,cAAc,EACd,kCAAkC,EAClC;QACE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,wDAAwD,CAAC;QACnF,iBAAiB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC;KACvE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;YAC/D,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,8CAA8C,EAC9C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QAC1D,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,oDAAoD,CAAC;QAC/E,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,8CAA8C,CAAC;QAC1E,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,uCAAuC,CAAC;KACpF,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;YAClE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,iDAAiD,EACjD;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QAC1D,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kDAAkD,CAAC;QAC7E,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,eAAe,CAAC;KAC5C,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,qBAAqB,EACrB,kDAAkD,EAClD;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QAC1D,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,mCAAmC,CAAC;QAC9D,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,qBAAqB,CAAC;KACjD,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;YACtE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,4EAA4E,EAC5E;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QAC1D,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,yDAAyD,CAAC;QACpF,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,wBAAwB,CAAC;QAClE,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oBAAoB,CAAC;QAClE,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,wBAAwB,CAAC;QACtE,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yBAAyB,CAAC;QACxE,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,2BAA2B,CAAC;KACrE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,eAAe,EACf,6NAA6N,EAC7N;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QAC1D,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,kPAAkP,CAAC;QACjS,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;QAC3E,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,uFAAuF,CAAC;QACrI,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qFAAqF,CAAC;QACnI,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC;YAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC3B,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC1B,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC5B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SAC9B,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,0FAA0F,CAAC;QAClH,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sEAAsE,CAAC;QAClH,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mDAAmD,CAAC;QAC3F,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iDAAiD,CAAC;KAC1F,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;YAChE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,gBAAgB,EAChB,wDAAwD,EACxD;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;KAC3D,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;YACjE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts new file mode 100644 index 0000000..50e7f47 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts @@ -0,0 +1,4 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { GodotConnection } from "../godot-connection.js"; +export declare function registerTilemapTools(server: McpServer, godot: GodotConnection): void; +//# sourceMappingURL=tilemap-tools.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts.map new file mode 100644 index 0000000..3344c7d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"tilemap-tools.d.ts","sourceRoot":"","sources":["../../src/tools/tilemap-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAsHN"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js new file mode 100644 index 0000000..59de9f6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { formatErrorForMcp } from "../utils/errors.js"; +export function registerTilemapTools(server, godot) { + server.tool("tilemap_set_cell", "Set a single cell in a TileMapLayer (or a deprecated multi-layer TileMap node)", { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + x: z.number().describe("Cell X coordinate"), + y: z.number().describe("Cell Y coordinate"), + source_id: z.number().optional().describe("Tile source ID (default: 0)"), + atlas_x: z.number().optional().describe("Atlas X coordinate (default: 0)"), + atlas_y: z.number().optional().describe("Atlas Y coordinate (default: 0)"), + alternative: z.number().optional().describe("Alternative tile ID (default: 0)"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("tilemap_set_cell", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("tilemap_fill_rect", "Fill a rectangular region of a TileMapLayer (or a deprecated multi-layer TileMap node) with tiles", { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + x1: z.number().describe("Start X coordinate"), + y1: z.number().describe("Start Y coordinate"), + x2: z.number().describe("End X coordinate"), + y2: z.number().describe("End Y coordinate"), + source_id: z.number().optional().describe("Tile source ID (default: 0)"), + atlas_x: z.number().optional().describe("Atlas X coordinate (default: 0)"), + atlas_y: z.number().optional().describe("Atlas Y coordinate (default: 0)"), + alternative: z.number().optional().describe("Alternative tile ID (default: 0)"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("tilemap_fill_rect", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("tilemap_get_cell", "Get tile data at a specific cell in a TileMapLayer (or a deprecated multi-layer TileMap node)", { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + x: z.number().describe("Cell X coordinate"), + y: z.number().describe("Cell Y coordinate"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("tilemap_get_cell", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("tilemap_clear", "Clear cells in a TileMapLayer (or a deprecated multi-layer TileMap node). For a legacy TileMap, omit layer to clear all layers or pass a layer to clear just that one", { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes; omit to clear all layers (TileMapLayer only accepts 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("tilemap_clear", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("tilemap_get_info", "Get TileMapLayer (or deprecated multi-layer TileMap) info including tile set sources, per-layer breakdown, and cell count", { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + }, async (params) => { + try { + const result = await godot.sendCommand("tilemap_get_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); + server.tool("tilemap_get_used_cells", "Get a list of used (non-empty) cells in a TileMapLayer (or a deprecated multi-layer TileMap node)", { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + max_count: z.number().optional().describe("Maximum cells to return (default: 500)"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, async (params) => { + try { + const result = await godot.sendCommand("tilemap_get_used_cells", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + }); +} +//# sourceMappingURL=tilemap-tools.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js.map new file mode 100644 index 0000000..9785229 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/tools/tilemap-tools.js.map @@ -0,0 +1 @@ +{"version":3,"file":"tilemap-tools.js","sourceRoot":"","sources":["../../src/tools/tilemap-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts new file mode 100644 index 0000000..5e256ac --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts @@ -0,0 +1,13 @@ +export declare class GodotConnectionError extends Error { + constructor(message: string); +} +export declare class GodotCommandError extends Error { + code: number; + data?: Record; + constructor(code: number, message: string, data?: Record); +} +export declare class TimeoutError extends Error { + constructor(method: string, timeoutMs: number); +} +export declare function formatErrorForMcp(error: unknown): string; +//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts.map new file mode 100644 index 0000000..b9a01fc --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/utils/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAE1B,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAM1E;AAED,qBAAa,YAAa,SAAQ,KAAK;gBACzB,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;CAI9C;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CA2BxD"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js new file mode 100644 index 0000000..f889e64 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js @@ -0,0 +1,51 @@ +export class GodotConnectionError extends Error { + constructor(message) { + super(message); + this.name = "GodotConnectionError"; + } +} +export class GodotCommandError extends Error { + code; + data; + constructor(code, message, data) { + super(message); + this.name = "GodotCommandError"; + this.code = code; + this.data = data; + } +} +export class TimeoutError extends Error { + constructor(method, timeoutMs) { + super(`Command '${method}' timed out after ${timeoutMs}ms`); + this.name = "TimeoutError"; + } +} +export function formatErrorForMcp(error) { + if (error instanceof GodotCommandError) { + let msg = `Godot error (${error.code}): ${error.message}`; + // Surface runtime errors captured at timeout so the agent sees the actual + // cause (a script error that paused the scene) instead of assuming the + // game/connection is dead. + const runtimeErrors = error.data?.runtime_errors; + if (Array.isArray(runtimeErrors) && runtimeErrors.length > 0) { + msg += `\nRuntime errors:\n${runtimeErrors + .map((e) => ` - ${String(e)}`) + .join("\n")}`; + } + if (error.data?.suggestion) { + msg += `\nSuggestion: ${error.data.suggestion}`; + } + return msg; + } + if (error instanceof GodotConnectionError) { + return `Connection error: ${error.message}. Make sure the Godot MCP Pro plugin is enabled in your Godot editor.`; + } + if (error instanceof TimeoutError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} +//# sourceMappingURL=errors.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js.map new file mode 100644 index 0000000..165137c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/errors.js.map @@ -0,0 +1 @@ +{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/utils/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF;AAED,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IACnC,IAAI,CAAS;IACb,IAAI,CAA2B;IAEtC,YAAY,IAAY,EAAE,OAAe,EAAE,IAA8B;QACvE,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;QAChC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrC,YAAY,MAAc,EAAE,SAAiB;QAC3C,KAAK,CAAC,YAAY,MAAM,qBAAqB,SAAS,IAAI,CAAC,CAAC;QAC5D,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,IAAI,KAAK,YAAY,iBAAiB,EAAE,CAAC;QACvC,IAAI,GAAG,GAAG,gBAAgB,KAAK,CAAC,IAAI,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC;QAC1D,0EAA0E;QAC1E,uEAAuE;QACvE,2BAA2B;QAC3B,MAAM,aAAa,GAAG,KAAK,CAAC,IAAI,EAAE,cAAc,CAAC;QACjD,IAAI,KAAK,CAAC,OAAO,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC7D,GAAG,IAAI,sBAAsB,aAAa;iBACvC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;iBAC9B,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAClB,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,EAAE,UAAU,EAAE,CAAC;YAC3B,GAAG,IAAI,iBAAiB,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;QAClD,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,KAAK,YAAY,oBAAoB,EAAE,CAAC;QAC1C,OAAO,qBAAqB,KAAK,CAAC,OAAO,uEAAuE,CAAC;IACnH,CAAC;IACD,IAAI,KAAK,YAAY,YAAY,EAAE,CAAC;QAClC,OAAO,KAAK,CAAC,OAAO,CAAC;IACvB,CAAC;IACD,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;QAC3B,OAAO,KAAK,CAAC,OAAO,CAAC;IACvB,CAAC;IACD,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;AACvB,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts new file mode 100644 index 0000000..3bb5882 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts @@ -0,0 +1,7 @@ +/** + * Loads CLAUDE.md from the shipped `instructions/` directory so Claude has + * the essential usage guide from message #1 of every session. Falls back to + * a terse built-in string if the file can't be located (e.g. custom layouts). + */ +export declare function loadInstructions(): string; +//# sourceMappingURL=load-instructions.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts.map new file mode 100644 index 0000000..5d06cdc --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"load-instructions.d.ts","sourceRoot":"","sources":["../../src/utils/load-instructions.ts"],"names":[],"mappings":"AAoBA;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAmBzC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js new file mode 100644 index 0000000..7d7bb18 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +const FALLBACK_INSTRUCTIONS = `# Godot MCP Pro + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. + +## Critical rules +- Tools are split into **editor** (always available) and **runtime** (require \`play_scene\` first). +- Never edit \`project.godot\` directly — use \`set_project_setting\`. +- For input simulation, use short \`simulate_key\` durations (0.3–0.5s), not integer seconds. +- \`execute_editor_script\` / \`execute_game_script\` must be valid GDScript; use \`_mcp_print(value)\` to return output. + +## Getting oriented +- \`get_project_info\` — project overview +- \`get_scene_tree\` — current scene structure +- For a full usage guide, see \`instructions/CLAUDE.md\` in the installed package. +`; +/** + * Loads CLAUDE.md from the shipped `instructions/` directory so Claude has + * the essential usage guide from message #1 of every session. Falls back to + * a terse built-in string if the file can't be located (e.g. custom layouts). + */ +export function loadInstructions() { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + resolve(here, "../../instructions/CLAUDE.md"), + resolve(here, "../../../instructions/CLAUDE.md"), + resolve(here, "../../../../instructions/CLAUDE.md"), + ]; + for (const path of candidates) { + try { + const content = readFileSync(path, "utf8"); + if (content.trim().length > 0) + return content; + } + catch { + // try next + } + } + return FALLBACK_INSTRUCTIONS; +} +//# sourceMappingURL=load-instructions.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js.map new file mode 100644 index 0000000..db5c781 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/load-instructions.js.map @@ -0,0 +1 @@ +{"version":3,"file":"load-instructions.js","sourceRoot":"","sources":["../../src/utils/load-instructions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAE7C,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;CAc7B,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAErD,MAAM,UAAU,GAAG;QACjB,OAAO,CAAC,IAAI,EAAE,8BAA8B,CAAC;QAC7C,OAAO,CAAC,IAAI,EAAE,iCAAiC,CAAC;QAChD,OAAO,CAAC,IAAI,EAAE,oCAAoC,CAAC;KACpD,CAAC;IAEF,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC3C,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;gBAAE,OAAO,OAAO,CAAC;QAChD,CAAC;QAAC,MAAM,CAAC;YACP,WAAW;QACb,CAAC;IACH,CAAC;IAED,OAAO,qBAAqB,CAAC;AAC/B,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts new file mode 100644 index 0000000..92300a6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts @@ -0,0 +1,12 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +/** + * Minimal mode: only these tools are registered (~35 tools). + * Designed for clients with tight tool limits (Cursor: 40, local LLMs with small context). + */ +export declare const MINIMAL_TOOLS: Set; +/** + * Creates a proxy around McpServer that filters tool registrations. + * Only tools in the allowSet will be registered; others are silently skipped. + */ +export declare function createFilteredServer(server: McpServer, allowSet: Set): McpServer; +//# sourceMappingURL=tool-filter.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts.map new file mode 100644 index 0000000..b3041b3 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"tool-filter.d.ts","sourceRoot":"","sources":["../../src/utils/tool-filter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE;;;GAGG;AACH,eAAO,MAAM,aAAa,aA4CxB,CAAC;AAEH;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GACpB,SAAS,CAaX"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js new file mode 100644 index 0000000..07c7c15 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js @@ -0,0 +1,69 @@ +/** + * Minimal mode: only these tools are registered (~35 tools). + * Designed for clients with tight tool limits (Cursor: 40, local LLMs with small context). + */ +export const MINIMAL_TOOLS = new Set([ + // project (5) + "get_project_info", + "get_filesystem_tree", + "search_files", + "search_in_files", + "set_project_setting", + // scene (6) + "get_scene_tree", + "create_scene", + "open_scene", + "play_scene", + "stop_scene", + "save_scene", + // node (5) + "add_node", + "delete_node", + "update_property", + "get_node_properties", + "connect_signal", + // script (5) + "read_script", + "create_script", + "edit_script", + "attach_script", + "validate_script", + // editor (5) + "get_editor_errors", + "get_output_log", + "get_game_screenshot", + "execute_editor_script", + "reload_project", + // input (3) + "simulate_key", + "simulate_mouse_click", + "simulate_action", + // runtime (5) + "get_game_scene_tree", + "get_game_node_properties", + "set_game_node_property", + "execute_game_script", + "find_ui_elements", + // input-map (1) + "get_input_actions", +]); +/** + * Creates a proxy around McpServer that filters tool registrations. + * Only tools in the allowSet will be registered; others are silently skipped. + */ +export function createFilteredServer(server, allowSet) { + return new Proxy(server, { + get(target, prop, receiver) { + if (prop === "tool") { + const originalTool = target.tool.bind(target); + return function filteredTool(name, ...args) { + if (!allowSet.has(name)) + return; + return originalTool(name, ...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); +} +//# sourceMappingURL=tool-filter.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js.map new file mode 100644 index 0000000..f0678e7 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/tool-filter.js.map @@ -0,0 +1 @@ +{"version":3,"file":"tool-filter.js","sourceRoot":"","sources":["../../src/utils/tool-filter.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC;IACnC,cAAc;IACd,kBAAkB;IAClB,qBAAqB;IACrB,cAAc;IACd,iBAAiB;IACjB,qBAAqB;IACrB,YAAY;IACZ,gBAAgB;IAChB,cAAc;IACd,YAAY;IACZ,YAAY;IACZ,YAAY;IACZ,YAAY;IACZ,WAAW;IACX,UAAU;IACV,aAAa;IACb,iBAAiB;IACjB,qBAAqB;IACrB,gBAAgB;IAChB,aAAa;IACb,aAAa;IACb,eAAe;IACf,aAAa;IACb,eAAe;IACf,iBAAiB;IACjB,aAAa;IACb,mBAAmB;IACnB,gBAAgB;IAChB,qBAAqB;IACrB,uBAAuB;IACvB,gBAAgB;IAChB,YAAY;IACZ,cAAc;IACd,sBAAsB;IACtB,iBAAiB;IACjB,cAAc;IACd,qBAAqB;IACrB,0BAA0B;IAC1B,wBAAwB;IACxB,qBAAqB;IACrB,kBAAkB;IAClB,gBAAgB;IAChB,mBAAmB;CACpB,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAClC,MAAiB,EACjB,QAAqB;IAErB,OAAO,IAAI,KAAK,CAAC,MAAM,EAAE;QACvB,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ;YACxB,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;gBACpB,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBAC9C,OAAO,SAAS,YAAY,CAAC,IAAY,EAAE,GAAG,IAAe;oBAC3D,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;wBAAE,OAAO;oBAChC,OAAQ,YAAyB,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,CAAC;gBACnD,CAAC,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;QAC7C,CAAC;KACF,CAAC,CAAC;AACL,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts new file mode 100644 index 0000000..2314682 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts @@ -0,0 +1,32 @@ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params: Record; + id: string; +} +export interface JsonRpcResponse { + jsonrpc: "2.0"; + result?: unknown; + error?: JsonRpcError; + id: string | null; +} +export interface JsonRpcError { + code: number; + message: string; + data?: Record; +} +export interface ToolDefinition { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; +} +export interface PendingRequest { + resolve: (value: JsonRpcResponse) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts.map new file mode 100644 index 0000000..bb53046 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/utils/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,QAAQ,CAAC;QACf,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACpC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;CACH;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC1C,MAAM,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,CAAC;IAChC,KAAK,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;CACtC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js new file mode 100644 index 0000000..718fd38 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js.map new file mode 100644 index 0000000..b886cc7 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/utils/types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts new file mode 100644 index 0000000..7c43fc6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +/** + * Coerce a value that might be a JSON string into a string array. + * LLMs sometimes pass arrays as stringified JSON (e.g. '["a","b"]' instead of ["a","b"]). + */ +export declare function coerceStringArray(): z.ZodPipe, z.ZodArray>; +/** + * Coerce a value that might be a numeric string into a number. + * LLMs sometimes pass numbers as strings (e.g. "30" instead of 30). + */ +export declare function coerceNumber(): z.ZodPipe, z.ZodNumber>; +//# sourceMappingURL=zod-coerce.d.ts.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts.map new file mode 100644 index 0000000..3469cf1 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"zod-coerce.d.ts","sourceRoot":"","sources":["../../src/utils/zod-coerce.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,wBAAgB,iBAAiB,yEAYhC;AAED;;;GAGG;AACH,wBAAgB,YAAY,6DAQ3B"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js new file mode 100644 index 0000000..32470ce --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js @@ -0,0 +1,35 @@ +import { z } from "zod"; +/** + * Coerce a value that might be a JSON string into a string array. + * LLMs sometimes pass arrays as stringified JSON (e.g. '["a","b"]' instead of ["a","b"]). + */ +export function coerceStringArray() { + return z.preprocess((val) => { + if (typeof val === "string") { + try { + const parsed = JSON.parse(val); + if (Array.isArray(parsed)) + return parsed; + } + catch { + // not JSON, return as-is for zod to validate + } + } + return val; + }, z.array(z.string())); +} +/** + * Coerce a value that might be a numeric string into a number. + * LLMs sometimes pass numbers as strings (e.g. "30" instead of 30). + */ +export function coerceNumber() { + return z.preprocess((val) => { + if (typeof val === "string") { + const n = Number(val); + if (!isNaN(n)) + return n; + } + return val; + }, z.number()); +} +//# sourceMappingURL=zod-coerce.js.map \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js.map b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js.map new file mode 100644 index 0000000..13175c3 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/build/utils/zod-coerce.js.map @@ -0,0 +1 @@ +{"version":3,"file":"zod-coerce.js","sourceRoot":"","sources":["../../src/utils/zod-coerce.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,EAAE;QAC1B,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;YAC5B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;gBAC/B,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;oBAAE,OAAO,MAAM,CAAC;YAC3C,CAAC;YAAC,MAAM,CAAC;gBACP,6CAA6C;YAC/C,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;AAC1B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,EAAE;QAC1B,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;YAC5B,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YACtB,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;gBAAE,OAAO,CAAC,CAAC;QAC1B,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;AACjB,CAAC"} \ No newline at end of file diff --git a/Tools/godot-mcp-pro-v1.15.1/server/package-lock.json b/Tools/godot-mcp-pro-v1.15.1/server/package-lock.json new file mode 100644 index 0000000..99d48c2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/package-lock.json @@ -0,0 +1,2451 @@ +{ + "name": "godot-mcp-pro", + "version": "1.10.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "godot-mcp-pro", + "version": "1.10.3", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "ws": "^8.18.0" + }, + "bin": { + "godot-cli": "build/cli.js", + "godot-mcp-setup": "build/setup.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.13", + "typescript": "^5.7.0", + "vitest": "^4.1.2" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.1.tgz", + "integrity": "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/package.json b/Tools/godot-mcp-pro-v1.15.1/server/package.json new file mode 100644 index 0000000..2a48741 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/package.json @@ -0,0 +1,36 @@ +{ + "name": "godot-mcp-pro", + "version": "1.15.1", + "description": "Premium MCP server for AI-powered Godot development with 175 tools", + "main": "build/index.js", + "bin": { + "godot-cli": "build/cli.js", + "godot-mcp-setup": "build/setup.js" + }, + "type": "module", + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "start": "node build/index.js", + "setup": "node build/setup.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.13", + "typescript": "^5.7.0", + "vitest": "^4.1.2" + }, + "keywords": [ + "godot", + "mcp", + "ai", + "editor" + ], + "license": "MIT" +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/cli.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/cli.ts new file mode 100644 index 0000000..b8578e4 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/cli.ts @@ -0,0 +1,733 @@ +#!/usr/bin/env node + +/** + * godot-cli — Command-line interface for Godot MCP Pro + * + * Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0). + * Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits. + * Progressive disclosure via --help at each command level. + */ + +import { WebSocketServer, WebSocket } from "ws"; +import { randomUUID } from "crypto"; +import { createServer } from "net"; + +const BASE_PORT = 6510; +const MAX_PORT = 6514; +const CONNECT_TIMEOUT_MS = 10000; +const COMMAND_TIMEOUT_MS = 30000; + +// ─── Command definitions ────────────────────────────────────────────── + +interface CommandDef { + description: string; + args?: Record; + method: string; // JSON-RPC method name + mapArgs?: (parsed: Record) => Record; +} + +interface GroupDef { + description: string; + commands: Record; +} + +const COMMANDS: Record = { + project: { + description: "Project info, files, and settings", + commands: { + info: { + description: "Get project metadata (name, version, viewport, renderer, autoloads)", + method: "get_project_info", + }, + files: { + description: "List project file/directory tree", + method: "get_filesystem_tree", + args: { + path: { description: "Root path (default: res://)" }, + filter: { description: "Glob filter (e.g. '*.gd', '*.tscn')" }, + }, + }, + search: { + description: "Search for files by name pattern", + method: "search_files", + args: { + query: { description: "Search query (fuzzy match or glob)", required: true }, + path: { description: "Directory to search in" }, + file_type: { description: "Filter by extension (e.g. 'gd', 'tscn')" }, + }, + }, + grep: { + description: "Search inside file contents", + method: "search_in_files", + args: { + query: { description: "Text/regex pattern", required: true }, + path: { description: "Directory to search in" }, + file_type: { description: "File extension filter (e.g. 'gd', 'tscn')" }, + }, + }, + "get-setting": { + description: "Get project settings", + method: "get_project_settings", + args: { + category: { description: "Settings category filter" }, + }, + }, + "set-setting": { + description: "Set a project setting", + method: "set_project_setting", + args: { + setting: { description: "Setting path (e.g. display/window/size/viewport_width)", required: true }, + value: { description: "Value to set", required: true }, + }, + mapArgs: (p) => ({ setting: p.setting, value: autoType(p.value) }), + }, + }, + }, + scene: { + description: "Scene tree and scene management", + commands: { + tree: { + description: "Get the current scene tree", + method: "get_scene_tree", + args: { + max_depth: { description: "Maximum depth to display", type: "number" }, + }, + mapArgs: (p) => (p.max_depth ? { max_depth: parseInt(p.max_depth) } : {}), + }, + create: { + description: "Create a new scene with a root node", + method: "create_scene", + args: { + path: { description: "Scene path (e.g. res://scenes/player.tscn)", required: true }, + root_type: { description: "Root node type (default: Node2D)" }, + root_name: { description: "Root node name" }, + }, + }, + open: { + description: "Open a scene in the editor", + method: "open_scene", + args: { + path: { description: "Scene path to open", required: true }, + }, + }, + save: { + description: "Save the current scene", + method: "save_scene", + args: { + path: { description: "Optional path to save as" }, + }, + }, + play: { + description: "Run the current/specified scene", + method: "play_scene", + args: { + mode: { description: "'main' (default), 'current', or a scene path" }, + }, + }, + stop: { + description: "Stop the running scene", + method: "stop_scene", + }, + content: { + description: "Get scene file content (tscn format parsed)", + method: "get_scene_file_content", + args: { + path: { description: "Scene file path", required: true }, + }, + }, + exports: { + description: "Get exported variables of a scene", + method: "get_scene_exports", + args: { + path: { description: "Scene file path", required: true }, + }, + }, + delete: { + description: "Delete a scene file", + method: "delete_scene", + args: { + path: { description: "Scene file path to delete", required: true }, + }, + }, + instance: { + description: "Add a scene instance as child node", + method: "add_scene_instance", + args: { + scene_path: { description: "Path to .tscn file to instance", required: true }, + parent_path: { description: "Parent node path (default: selected/root)" }, + name: { description: "Instance name" }, + }, + }, + }, + }, + node: { + description: "Add, modify, and delete scene nodes", + commands: { + add: { + description: "Add a new node to the scene", + method: "add_node", + args: { + type: { description: "Node type (e.g. CharacterBody3D, Sprite2D)", required: true }, + name: { description: "Node name" }, + parent_path: { description: "Parent node path (default: root '.')" }, + }, + }, + delete: { + description: "Delete a node from the scene", + method: "delete_node", + args: { + node_path: { description: "Node path to delete", required: true }, + }, + }, + get: { + description: "Get all properties of a node", + method: "get_node_properties", + args: { + node_path: { description: "Node path", required: true }, + }, + }, + set: { + description: "Set a property on a node", + method: "update_property", + args: { + node_path: { description: "Node path", required: true }, + property: { description: "Property name", required: true }, + value: { description: "Value to set", required: true }, + }, + mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }), + }, + duplicate: { + description: "Duplicate a node", + method: "duplicate_node", + args: { + node_path: { description: "Node path to duplicate", required: true }, + name: { description: "Name for the duplicate" }, + }, + }, + move: { + description: "Move/reparent a node", + method: "move_node", + args: { + node_path: { description: "Node path to move", required: true }, + new_parent_path: { description: "New parent path", required: true }, + }, + }, + rename: { + description: "Rename a node", + method: "rename_node", + args: { + node_path: { description: "Node path", required: true }, + new_name: { description: "New name", required: true }, + }, + }, + connect: { + description: "Connect a signal between nodes", + method: "connect_signal", + args: { + source_path: { description: "Source node path", required: true }, + signal_name: { description: "Signal name", required: true }, + target_path: { description: "Target node path", required: true }, + method_name: { description: "Target method name", required: true }, + }, + }, + groups: { + description: "Get groups a node belongs to", + method: "get_node_groups", + args: { + node_path: { description: "Node path", required: true }, + }, + }, + }, + }, + script: { + description: "Read, create, and edit GDScript/C# files", + commands: { + read: { + description: "Read a script file", + method: "read_script", + args: { + path: { description: "Script path (e.g. res://player.gd)", required: true }, + }, + }, + create: { + description: "Create a new script file (.gd or .cs only)", + method: "create_script", + args: { + path: { description: "Script path", required: true }, + content: { description: "Script content", required: true }, + base_type: { description: "Base class (default: Node)" }, + force: { description: "Override open-script-editor guard" }, + }, + mapArgs: (p) => { + const r: Record = { path: p.path, content: p.content }; + if (p.base_type) r.base_type = p.base_type; + if (p.force !== undefined) r.force = p.force === "true"; + return r; + }, + }, + edit: { + description: "Edit an existing script (full replace or 1-based inclusive line range)", + method: "edit_script", + args: { + path: { description: "Script path", required: true }, + content: { description: "New content", required: true }, + start_line: { description: "Start line for partial edit (1-based inclusive)", type: "number" }, + end_line: { description: "End line for partial edit (1-based inclusive)", type: "number" }, + force: { description: "Override open-script-editor guard" }, + }, + mapArgs: (p) => { + const r: Record = { path: p.path, content: p.content }; + if (p.start_line) r.start_line = parseInt(p.start_line); + if (p.end_line) r.end_line = parseInt(p.end_line); + if (p.force !== undefined) r.force = p.force === "true"; + return r; + }, + }, + attach: { + description: "Attach a script to a node", + method: "attach_script", + args: { + node_path: { description: "Node path", required: true }, + script_path: { description: "Script path", required: true }, + }, + }, + validate: { + description: "Validate a GDScript for errors", + method: "validate_script", + args: { + path: { description: "Script path to validate", required: true }, + }, + }, + list: { + description: "List all scripts in the project", + method: "list_scripts", + }, + }, + }, + editor: { + description: "Editor state, errors, screenshots, and utilities", + commands: { + errors: { + description: "Get current editor errors/warnings", + method: "get_editor_errors", + }, + log: { + description: "Get editor output log", + method: "get_output_log", + args: { + lines: { description: "Number of lines (default: 50)", type: "number" }, + }, + mapArgs: (p) => (p.lines ? { lines: parseInt(p.lines) } : {}), + }, + screenshot: { + description: "Take a screenshot of the running game", + method: "get_game_screenshot", + }, + "editor-screenshot": { + description: "Take a screenshot of the editor", + method: "get_editor_screenshot", + }, + exec: { + description: "Execute an editor script (GDScript in editor context)", + method: "execute_editor_script", + args: { + code: { description: "GDScript code to execute", required: true }, + }, + }, + signals: { + description: "Get signals of a node type", + method: "get_signals", + args: { + node_path: { description: "Node path", required: true }, + }, + }, + reload: { + description: "Reload the project", + method: "reload_project", + }, + }, + }, + input: { + description: "Simulate keyboard, mouse, and input actions", + commands: { + key: { + description: "Simulate a key press in the running game", + method: "simulate_key", + args: { + key: { description: "Key name (e.g. W, A, S, D, Space)", required: true }, + duration: { description: "Hold duration in seconds", type: "number" }, + pressed: { description: "true=press, false=release" }, + }, + mapArgs: (p) => { + const r: Record = { key: p.key }; + if (p.duration) r.duration = parseFloat(p.duration); + if (p.pressed !== undefined) r.pressed = p.pressed === "true"; + return r; + }, + }, + click: { + description: "Simulate a mouse click in the running game", + method: "simulate_mouse_click", + args: { + x: { description: "X coordinate", required: true, type: "number" }, + y: { description: "Y coordinate", required: true, type: "number" }, + button: { description: "Mouse button: left, right, or middle (default: left)" }, + }, + mapArgs: (p) => { + const buttonMap: Record = { left: 1, right: 2, middle: 3 }; + const r: Record = { x: parseInt(p.x), y: parseInt(p.y) }; + if (p.button) r.button = buttonMap[p.button.toLowerCase()] ?? (parseInt(p.button) || 1); + return r; + }, + }, + action: { + description: "Simulate an input action (as defined in Input Map)", + method: "simulate_action", + args: { + action: { description: "Action name (e.g. ui_accept, move_left)", required: true }, + pressed: { description: "true=press, false=release" }, + duration: { description: "Hold duration in seconds", type: "number" }, + }, + mapArgs: (p) => { + const r: Record = { action: p.action }; + if (p.pressed !== undefined) r.pressed = p.pressed === "true"; + if (p.duration) r.duration = parseFloat(p.duration); + return r; + }, + }, + actions: { + description: "List all configured input actions", + method: "get_input_actions", + }, + }, + }, + runtime: { + description: "Inspect and control the running game", + commands: { + tree: { + description: "Get the running game's scene tree", + method: "get_game_scene_tree", + args: { + max_depth: { description: "Max depth (-1 for unlimited)", type: "number" }, + }, + mapArgs: (p) => { + const r: Record = {}; + if (p.max_depth) r.max_depth = parseInt(p.max_depth); + return r; + }, + }, + get: { + description: "Get properties of a node in the running game", + method: "get_game_node_properties", + args: { + node_path: { description: "Node path", required: true }, + properties: { description: "Comma-separated property names (default: all)" }, + }, + mapArgs: (p) => { + const r: Record = { node_path: p.node_path }; + if (p.properties) r.properties = p.properties.split(",").map(s => s.trim()); + return r; + }, + }, + set: { + description: "Set a property on a running game node", + method: "set_game_node_property", + args: { + node_path: { description: "Node path", required: true }, + property: { description: "Property name", required: true }, + value: { description: "Value to set", required: true }, + }, + mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }), + }, + exec: { + description: "Execute GDScript in the running game", + method: "execute_game_script", + args: { + code: { description: "GDScript code", required: true }, + node_path: { description: "Node context (default: /root)" }, + }, + }, + ui: { + description: "Find UI elements (buttons, labels, etc.) in the running game", + method: "find_ui_elements", + args: { + type_filter: { description: "Filter by type (Button, Label, etc.)" }, + }, + }, + }, + }, +}; + +// ─── Argument parsing ───────────────────────────────────────────────── + +function autoType(value: string): unknown { + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + const num = Number(value); + if (!isNaN(num) && value.trim() !== "") return num; + // Try JSON for arrays/objects + if ((value.startsWith("[") || value.startsWith("{")) && (value.endsWith("]") || value.endsWith("}"))) { + try { return JSON.parse(value); } catch { /* fall through */ } + } + return value; +} + +function parseArgs(argv: string[]): { positional: string[]; flags: Record } { + const positional: string[] = []; + const flags: Record = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = "true"; + } + } else { + positional.push(arg); + } + } + return { positional, flags }; +} + +// ─── Help formatting ────────────────────────────────────────────────── + +function showMainHelp(): void { + console.log(`godot-cli — Control Godot editor from the command line + +Usage: godot-cli [options] + +Groups:`); + for (const [name, group] of Object.entries(COMMANDS)) { + console.log(` ${name.padEnd(12)} ${group.description}`); + } + console.log(` +Options: + --port Godot WebSocket port (default: auto-detect 6510-6514) + --help Show help for a group or command + +Examples: + godot-cli project info + godot-cli scene tree + godot-cli node add --type CharacterBody3D --name Player + godot-cli script read --path res://player.gd + godot-cli scene play + godot-cli input key --key W --duration 0.5`); +} + +function showGroupHelp(groupName: string, group: GroupDef): void { + console.log(`godot-cli ${groupName} — ${group.description} + +Commands:`); + for (const [name, cmd] of Object.entries(group.commands)) { + console.log(` ${name.padEnd(18)} ${cmd.description}`); + } + console.log(`\nUse: godot-cli ${groupName} --help for details`); +} + +function showCommandHelp(groupName: string, cmdName: string, cmd: CommandDef): void { + console.log(`godot-cli ${groupName} ${cmdName} — ${cmd.description}`); + if (cmd.args && Object.keys(cmd.args).length > 0) { + console.log(`\nOptions:`); + for (const [name, arg] of Object.entries(cmd.args)) { + const req = arg.required ? " (required)" : ""; + console.log(` --${name.padEnd(16)} ${arg.description}${req}`); + } + } +} + +// ─── WebSocket connection ───────────────────────────────────────────── +// The Godot plugin is a WebSocket CLIENT that connects to servers on ports 6505-6514. +// The CLI starts a temporary WebSocket SERVER on an available port and waits for +// the Godot plugin to connect (it polls every 3 seconds). + +function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +async function findFreePort(preferredPort?: number): Promise { + if (preferredPort) { + if (await isPortFree(preferredPort)) return preferredPort; + return null; + } + for (let p = BASE_PORT; p <= MAX_PORT; p++) { + if (await isPortFree(p)) return p; + } + return null; +} + +/** + * Start a WebSocket server and wait for the Godot plugin to connect. + * Returns the connected client WebSocket and the server (for cleanup). + */ +function waitForGodot(port: number): Promise<{ client: WebSocket; wss: WebSocketServer }> { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ port, host: "127.0.0.1" }); + const timeout = setTimeout(() => { + wss.close(); + reject(new Error( + `Godot plugin did not connect within ${CONNECT_TIMEOUT_MS / 1000}s.\n` + + "Make sure the Godot editor is running with the MCP plugin enabled." + )); + }, CONNECT_TIMEOUT_MS); + + wss.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + wss.on("connection", (ws) => { + clearTimeout(timeout); + resolve({ client: ws, wss }); + }); + }); +} + +function sendCommand( + ws: WebSocket, + method: string, + params: Record +): Promise { + return new Promise((resolve, reject) => { + const id = randomUUID(); + const timeout = setTimeout(() => { + reject(new Error(`Command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms`)); + }, COMMAND_TIMEOUT_MS); + + const handler = (data: Buffer) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + // Ignore ping/pong + if ((msg as { method?: string }).method === "pong" || (msg as { method?: string }).method === "ping") return; + if (msg.id !== id) return; + + clearTimeout(timeout); + ws.off("message", handler); + + if (msg.error) { + reject(new Error(`Godot error: ${msg.error.message || JSON.stringify(msg.error)}`)); + } else { + resolve(msg.result); + } + }; + + ws.on("message", handler); + ws.send(JSON.stringify({ jsonrpc: "2.0", method, params, id })); + }); +} + +// ─── Main ───────────────────────────────────────────────────────────── + +async function main() { + const userArgs = process.argv.slice(2); + const { positional, flags } = parseArgs(userArgs); + + // Global --help + if (positional.length === 0 || flags.help === "true" && positional.length === 0) { + showMainHelp(); + process.exit(0); + } + + const groupName = positional[0]; + const group = COMMANDS[groupName]; + + if (!group) { + console.error(`Unknown group: ${groupName}`); + showMainHelp(); + process.exit(1); + } + + // Group-level --help + if (positional.length === 1 || (flags.help === "true" && positional.length === 1)) { + showGroupHelp(groupName, group); + process.exit(0); + } + + const cmdName = positional[1]; + const cmd = group.commands[cmdName]; + + if (!cmd) { + console.error(`Unknown command: ${groupName} ${cmdName}`); + showGroupHelp(groupName, group); + process.exit(1); + } + + // Command-level --help + if (flags.help === "true") { + showCommandHelp(groupName, cmdName, cmd); + process.exit(0); + } + + // Validate required args + if (cmd.args) { + for (const [name, arg] of Object.entries(cmd.args)) { + if (arg.required && !flags[name]) { + console.error(`Missing required option: --${name}`); + showCommandHelp(groupName, cmdName, cmd); + process.exit(1); + } + } + } + + // Build params + const params = cmd.mapArgs ? cmd.mapArgs(flags) : { ...flags }; + // Remove internal flags + delete params.port; + delete params.help; + + // Connect and execute + const preferredPort = flags.port ? parseInt(flags.port) : undefined; + const port = await findFreePort(preferredPort); + if (!port) { + console.error( + `No free ports in range ${BASE_PORT}-${MAX_PORT}.\n` + + "All ports are occupied by MCP server instances." + ); + process.exit(1); + } + + let client: WebSocket; + let wss: WebSocketServer; + try { + process.stderr.write(`Waiting for Godot on port ${port}...`); + ({ client, wss } = await waitForGodot(port)); + process.stderr.write(" connected!\n"); + } catch (err) { + process.stderr.write("\n"); + console.error((err as Error).message); + process.exit(1); + } + + try { + const result = await sendCommand(client, cmd.method, params); + if (result !== undefined && result !== null) { + console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)); + } + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } finally { + client.close(); + wss.close(); + } +} + +main().catch((err) => { + console.error("Fatal:", err.message); + process.exit(1); +}); diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/godot-connection.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/godot-connection.ts new file mode 100644 index 0000000..2aec6f7 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/godot-connection.ts @@ -0,0 +1,291 @@ +import { WebSocketServer, WebSocket } from "ws"; +import { randomUUID } from "crypto"; +import { + JsonRpcRequest, + JsonRpcResponse, + PendingRequest, +} from "./utils/types.js"; +import { + GodotConnectionError, + GodotCommandError, + TimeoutError, +} from "./utils/errors.js"; + +const BASE_PORT = 6505; +const MAX_PORT = 6509; +const COMMAND_TIMEOUT_MS = 30000; +const HEARTBEAT_INTERVAL_MS = 10000; +const HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 3; +const TCP_KEEPALIVE_DELAY_MS = 5000; + +export class GodotConnection { + private wss: WebSocketServer | null = null; + private client: WebSocket | null = null; + private port: number; + private fixedPort: boolean; + private basePort: number; + private maxPort: number; + private pendingRequests: Map = new Map(); + private heartbeatTimer: ReturnType | null = null; + private lastPongAt: number = 0; + + constructor( + port: number = BASE_PORT, + fixedPort: boolean = false, + options: { basePort?: number; maxPort?: number } = {} + ) { + this.port = port; + this.fixedPort = fixedPort; + this.basePort = options.basePort ?? BASE_PORT; + this.maxPort = options.maxPort ?? MAX_PORT; + } + + /** Start WebSocket server, retrying on the next port if the first bind races. */ + async connect(): Promise { + if (this.wss) return; + + const candidates = this.fixedPort + ? [this.port] + : Array.from({ length: this.maxPort - this.basePort + 1 }, (_, i) => this.basePort + i); + + let lastError: Error | null = null; + for (const port of candidates) { + try { + const wss = await this.bindWebSocketServer(port); + this.wss = wss; + this.port = port; + this.attachConnectionHandler(wss); + console.error( + `[MCP] WebSocket server listening on ws://127.0.0.1:${port}` + ); + return; + } catch (err) { + lastError = err as Error; + // EADDRINUSE means another MCP server (likely a parallel Claude session) + // won the bind race. Silently try the next port. Other errors are + // logged so we don't swallow real config problems. + if ((err as NodeJS.ErrnoException).code !== "EADDRINUSE") { + console.error( + `[MCP] Bind failed on port ${port}: ${(err as Error).message}` + ); + } + } + } + + const range = this.fixedPort + ? String(this.port) + : `${this.basePort}-${this.maxPort}`; + const hint = this.fixedPort + ? "Try removing GODOT_MCP_PORT from your client config to enable auto-scanning, or kill the process holding the port." + : "All ports are occupied — likely too many parallel Claude Code sessions or stale node MCP processes."; + throw new GodotConnectionError( + `Failed to bind WebSocket server on port range ${range}. ` + + `Last error: ${lastError?.message ?? "unknown"}. ${hint}` + ); + } + + /** Try to bind a single WebSocketServer. Resolves once 'listening' fires, rejects on bind error. */ + private bindWebSocketServer(port: number): Promise { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ port, host: "127.0.0.1" }); + + const onError = (err: Error) => { + wss.off("listening", onListening); + wss.close(); + reject(err); + }; + const onListening = () => { + wss.off("error", onError); + // Re-attach a runtime error handler now that the server is live. + // Pre-bind errors fail the connect attempt; post-bind errors are logged. + wss.on("error", (err: Error) => { + console.error("[MCP] WebSocket server error:", err.message); + }); + resolve(wss); + }; + + wss.once("error", onError); + wss.once("listening", onListening); + }); + } + + private attachConnectionHandler(wss: WebSocketServer): void { + wss.on("connection", (ws: WebSocket) => { + console.error("[MCP] Godot editor connected"); + + // Enable OS-level TCP keepalive so half-open sockets surface faster + // than the Windows default (~2 hours). Application-level heartbeat + // below is still the primary detection mechanism. + const sock = (ws as unknown as { _socket?: { setKeepAlive?: (enable: boolean, initialDelay: number) => void } })._socket; + sock?.setKeepAlive?.(true, TCP_KEEPALIVE_DELAY_MS); + + if (this.client) { + this.client.close(1000, "Replaced by new connection"); + } + this.client = ws; + this.lastPongAt = Date.now(); + this.startHeartbeat(); + + ws.on("message", (data: Buffer) => { + this.handleMessage(data.toString()); + }); + + ws.on("close", () => { + console.error("[MCP] Godot editor disconnected"); + if (this.client === ws) { + this.client = null; + this.stopHeartbeat(); + this.rejectAllPending( + new GodotConnectionError("Godot disconnected") + ); + } + }); + + ws.on("error", (err: Error) => { + console.error("[MCP] WebSocket error:", err.message); + }); + }); + } + + disconnect(): void { + this.stopHeartbeat(); + if (this.client) { + this.client.close(1000, "Server shutting down"); + this.client = null; + } + if (this.wss) { + this.wss.close(); + this.wss = null; + } + this.rejectAllPending(new GodotConnectionError("Server shut down")); + } + + isConnected(): boolean { + return this.client?.readyState === WebSocket.OPEN; + } + + getPort(): number { + return this.port; + } + + async sendCommand( + method: string, + params: Record = {} + ): Promise { + if (!this.isConnected()) { + throw new GodotConnectionError( + "Godot editor is not connected. Make sure the Godot MCP Pro plugin is enabled and the editor is running." + ); + } + + const id = randomUUID(); + const request: JsonRpcRequest = { + jsonrpc: "2.0", + method, + params, + id, + }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new TimeoutError(method, COMMAND_TIMEOUT_MS)); + }, COMMAND_TIMEOUT_MS); + + this.pendingRequests.set(id, { + resolve: resolve as (value: JsonRpcResponse) => void, + reject, + timer, + }); + this.client!.send(JSON.stringify(request)); + }); + } + + private handleMessage(data: string): void { + let msg: JsonRpcResponse; + try { + msg = JSON.parse(data); + } catch { + console.error("[MCP] Failed to parse message from Godot:", data); + return; + } + + const method = (msg as unknown as { method?: string }).method; + if (method === "pong") { + this.lastPongAt = Date.now(); + return; + } + + // Godot may also send unsolicited pings — reply so its inactivity timer resets + if (method === "ping") { + this.lastPongAt = Date.now(); + if (this.isConnected()) { + this.client!.send(JSON.stringify({ jsonrpc: "2.0", method: "pong", params: {} })); + } + return; + } + + if (!msg.id) return; + + const pending = this.pendingRequests.get(msg.id); + if (!pending) return; + + clearTimeout(pending.timer); + this.pendingRequests.delete(msg.id); + + if (msg.error) { + pending.reject( + new GodotCommandError( + msg.error.code, + msg.error.message, + msg.error.data + ) + ); + } else { + pending.resolve(msg.result as unknown as JsonRpcResponse); + } + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRequests.clear(); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (!this.isConnected()) return; + + // If Godot has been silent for too long, the socket is likely half-open. + // terminate() forcibly destroys the TCP socket (vs close() which waits + // for a FIN ack that will never arrive on a dead link). + if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) { + console.error( + `[MCP] Heartbeat timeout (no pong for ${HEARTBEAT_TIMEOUT_MS}ms) — terminating dead connection` + ); + const dead = this.client; + this.client = null; + this.stopHeartbeat(); + this.rejectAllPending( + new GodotConnectionError("Heartbeat timeout — Godot connection lost") + ); + dead?.terminate(); + return; + } + + this.client!.send( + JSON.stringify({ jsonrpc: "2.0", method: "ping", params: {} }) + ); + }, HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/index.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/index.ts new file mode 100644 index 0000000..50d5a02 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/index.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { GodotConnection } from "./godot-connection.js"; +import { registerProjectTools } from "./tools/project-tools.js"; +import { registerSceneTools } from "./tools/scene-tools.js"; +import { registerNodeTools } from "./tools/node-tools.js"; +import { registerScriptTools } from "./tools/script-tools.js"; +import { registerEditorTools } from "./tools/editor-tools.js"; +import { registerInputTools } from "./tools/input-tools.js"; +import { registerRuntimeTools } from "./tools/runtime-tools.js"; +import { registerAnimationTools } from "./tools/animation-tools.js"; +import { registerTilemapTools } from "./tools/tilemap-tools.js"; +import { registerThemeTools } from "./tools/theme-tools.js"; +import { registerProfilingTools } from "./tools/profiling-tools.js"; +import { registerBatchTools } from "./tools/batch-tools.js"; +import { registerShaderTools } from "./tools/shader-tools.js"; +import { registerExportTools } from "./tools/export-tools.js"; +import { registerResourceTools } from "./tools/resource-tools.js"; +import { registerAnimationTreeTools } from "./tools/animation-tree-tools.js"; +import { registerPhysicsTools } from "./tools/physics-tools.js"; +import { registerScene3DTools } from "./tools/scene-3d-tools.js"; +import { registerParticleTools } from "./tools/particle-tools.js"; +import { registerNavigationTools } from "./tools/navigation-tools.js"; +import { registerAudioTools } from "./tools/audio-tools.js"; +import { registerTestTools } from "./tools/test-tools.js"; +import { registerAnalysisTools } from "./tools/analysis-tools.js"; +import { registerInputMapTools } from "./tools/input-map-tools.js"; +import { registerAndroidTools } from "./tools/android-tools.js"; +import { MINIMAL_TOOLS, createFilteredServer } from "./utils/tool-filter.js"; +import { loadInstructions } from "./utils/load-instructions.js"; + +const MINIMAL_MODE = process.argv.includes("--minimal"); +const THREED_MODE = process.argv.includes("--3d"); +const LITE_MODE = process.argv.includes("--lite") || MINIMAL_MODE || THREED_MODE; +const HTTP_MODE = process.argv.includes("--http"); +const HTTP_PORT = parseInt( + process.argv.find((_, i, a) => a[i - 1] === "--http-port") || + process.env.GODOT_MCP_HTTP_PORT || + "8001" +); + +const explicitPort = process.env.GODOT_MCP_PORT; +const godot = new GodotConnection( + parseInt(explicitPort || "6505"), + !!explicitPort +); + +const serverName = MINIMAL_MODE + ? "godot-mcp-pro-minimal" + : THREED_MODE + ? "godot-mcp-pro-3d" + : LITE_MODE + ? "godot-mcp-pro-lite" + : "godot-mcp-pro"; + +const server = new McpServer( + { + name: serverName, + version: "1.15.1", + }, + { + instructions: loadInstructions(), + } +); + +// In minimal mode, wrap the server to filter tool registrations +const toolServer = MINIMAL_MODE ? createFilteredServer(server, MINIMAL_TOOLS) : server; + +// Core tools (always registered) +registerProjectTools(toolServer, godot); +registerSceneTools(toolServer, godot); +registerNodeTools(toolServer, godot); +registerScriptTools(toolServer, godot); +registerEditorTools(toolServer, godot); +registerInputTools(toolServer, godot); +registerRuntimeTools(toolServer, godot); +registerInputMapTools(toolServer, godot); + +// 3D-critical tools (registered in FULL and --3d modes) +// Core (81) + Physics (6) + AnimationTree (8) + Navigation (5) = exactly 100 tools +if (!LITE_MODE || THREED_MODE) { + registerPhysicsTools(server, godot); + registerAnimationTreeTools(server, godot); + registerNavigationTools(server, godot); +} + +// Extended tools (Full mode only) +if (!LITE_MODE) { + registerAnimationTools(server, godot); + registerAudioTools(server, godot); + registerBatchTools(server, godot); + registerExportTools(server, godot); + registerParticleTools(server, godot); + registerProfilingTools(server, godot); + registerResourceTools(server, godot); + registerScene3DTools(server, godot); + registerShaderTools(server, godot); + registerTestTools(server, godot); + registerThemeTools(server, godot); + registerTilemapTools(server, godot); + registerAnalysisTools(server, godot); + registerAndroidTools(server, godot); +} + +// Start server +async function main() { + // Attempt initial connection to Godot (non-blocking). + // If this fails (all ports occupied, etc.), tool calls will fail with a + // clear error message from sendCommand until the user restarts the server. + godot.connect().catch((err) => { + console.error( + `[MCP] Failed to start WebSocket server: ${err.message}` + ); + }); + + if (HTTP_MODE) { + // Streamable HTTP transport — clients connect via http://host:port/mcp + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + await server.connect(transport); + + const httpServer = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + if (url.pathname === "/mcp") { + await transport.handleRequest(req, res); + } else { + res.writeHead(404).end("Not Found"); + } + }); + + httpServer.listen(HTTP_PORT, () => { + const mode = MINIMAL_MODE ? "MINIMAL " : THREED_MODE ? "3D " : LITE_MODE ? "LITE " : ""; + console.error( + `[MCP] Godot MCP Pro ${mode}started (HTTP transport on http://127.0.0.1:${HTTP_PORT}/mcp)` + ); + }); + } else { + // Default stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + const modeLabel = MINIMAL_MODE + ? "[MCP] Godot MCP Pro MINIMAL started (35 tools, stdio transport)" + : THREED_MODE + ? "[MCP] Godot MCP Pro 3D started (103 tools, stdio transport)" + : LITE_MODE + ? "[MCP] Godot MCP Pro LITE started (84 tools, stdio transport)" + : "[MCP] Godot MCP Pro started (stdio transport)"; + console.error(modeLabel); + } +} + +main().catch((err) => { + console.error("[MCP] Fatal error:", err); + process.exit(1); +}); diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/setup.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/setup.ts new file mode 100644 index 0000000..2d38bee --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/setup.ts @@ -0,0 +1,332 @@ +#!/usr/bin/env node + +/** + * godot-mcp-setup — Setup and management CLI for Godot MCP Pro + * + * Commands: + * install Install dependencies and build the server + * check-update Check if a newer version is available on GitHub + * configure Auto-detect AI client and generate MCP config + * doctor Diagnose environment (Node.js, npm, build status) + */ + +import { execSync } from "child_process"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { resolve, dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Server root is one level up from build/ +const SERVER_DIR = resolve(__dirname, ".."); +const PACKAGE_JSON = join(SERVER_DIR, "package.json"); +const BUILD_INDEX = join(SERVER_DIR, "build", "index.js"); +const GITHUB_REPO = "youichi-uda/godot-mcp-pro"; + +// ─── Utilities ──────────────────────────────────────────────── + +function getVersion(): string { + try { + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")); + return pkg.version || "unknown"; + } catch { + return "unknown"; + } +} + +function run(cmd: string, cwd?: string): string { + try { + return execSync(cmd, { + cwd: cwd || SERVER_DIR, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch (err: any) { + return err.stderr?.trim() || err.message || "command failed"; + } +} + +function check(label: string, ok: boolean, detail?: string): void { + const icon = ok ? "✓" : "✗"; + const line = detail ? `${label}: ${detail}` : label; + console.log(` ${icon} ${line}`); +} + +/** Compare semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal. */ +function compareSemver(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +// ─── Commands ───────────────────────────────────────────────── + +async function cmdInstall(): Promise { + console.log("Installing Godot MCP Pro server...\n"); + + console.log("[1/2] Installing dependencies..."); + try { + execSync("npm install", { cwd: SERVER_DIR, stdio: "inherit" }); + } catch { + console.error("\nFailed to install dependencies. Make sure npm is available."); + process.exit(1); + } + + console.log("\n[2/2] Building server..."); + try { + execSync("npm run build", { cwd: SERVER_DIR, stdio: "inherit" }); + } catch { + console.error("\nBuild failed. Check for TypeScript errors above."); + process.exit(1); + } + + console.log(`\nDone! Server built at: ${BUILD_INDEX}`); + console.log(`Version: ${getVersion()}`); + console.log("\nNext step: Run 'node build/setup.js configure' to set up your AI client."); +} + +async function cmdCheckUpdate(): Promise { + const current = getVersion(); + console.log(`Current version: ${current}\n`); + console.log(`Checking GitHub releases for ${GITHUB_REPO}...`); + + try { + const res = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, + { headers: { "User-Agent": "godot-mcp-pro-setup" } } + ); + + if (!res.ok) { + if (res.status === 404) { + console.log("No releases found on GitHub."); + return; + } + console.error(`GitHub API error: ${res.status} ${res.statusText}`); + return; + } + + const data = (await res.json()) as { tag_name: string; html_url: string }; + const latest = data.tag_name.replace(/^v/, ""); + + if (compareSemver(latest, current) > 0) { + console.log(`\nUpdate available: v${latest} (current: v${current})`); + console.log(`Download: ${data.html_url}`); + console.log( + "\nTo update: download the new version, replace server/src/, and run 'node build/setup.js install'" + ); + } else { + console.log(`\nUp to date! (${current})`); + } + } catch (err) { + console.error(`Failed to check for updates: ${(err as Error).message}`); + } +} + +interface McpConfig { + mcpServers: Record; + }>; +} + +interface ClientInfo { + name: string; + configPath: string; + configKey: string; +} + +async function cmdConfigure(): Promise { + const serverPath = resolve(BUILD_INDEX).replace(/\\/g, "/"); + + if (!existsSync(BUILD_INDEX)) { + console.error( + "Server not built yet. Run 'node build/setup.js install' first." + ); + process.exit(1); + } + + console.log("Detecting AI clients...\n"); + + // Detect available clients by checking config file locations + const home = process.env.HOME || process.env.USERPROFILE || ""; + const cwd = process.cwd(); + + const candidates: ClientInfo[] = [ + { + name: "Claude Code (project)", + configPath: join(cwd, ".mcp.json"), + configKey: "godot-mcp-pro", + }, + { + name: "Cursor (project)", + configPath: join(cwd, ".cursor", "mcp.json"), + configKey: "godot-mcp-pro", + }, + { + name: "Windsurf (project)", + configPath: join(cwd, ".windsurf", "mcp.json"), + configKey: "godot-mcp-pro", + }, + { + name: "Claude Desktop", + configPath: join( + home, + process.platform === "win32" + ? "AppData/Roaming/Claude/claude_desktop_config.json" + : process.platform === "darwin" + ? "Library/Application Support/Claude/claude_desktop_config.json" + : ".config/claude/claude_desktop_config.json" + ), + configKey: "godot-mcp-pro", + }, + ]; + + // Find existing configs + const existing = candidates.filter((c) => existsSync(c.configPath)); + const missing = candidates.filter((c) => !existsSync(c.configPath)); + + if (existing.length > 0) { + console.log("Found existing configs:"); + for (const c of existing) { + console.log(` ✓ ${c.name}: ${c.configPath}`); + } + } + + // Default: create .mcp.json in cwd (Claude Code) + const target = candidates[0]; // Claude Code project-level + // No GODOT_MCP_PORT env: lets the server auto-scan 6505-6509 so multiple + // Claude Code sessions can each grab a free port. Pinning a single port + // here would force every session to collide on 6505. + const entry = { + command: "node", + args: [serverPath], + }; + + let config: McpConfig; + if (existsSync(target.configPath)) { + try { + config = JSON.parse(readFileSync(target.configPath, "utf-8")); + if (!config.mcpServers) config.mcpServers = {}; + } catch { + config = { mcpServers: {} }; + } + } else { + config = { mcpServers: {} }; + } + + if (config.mcpServers[target.configKey]) { + console.log( + `\n${target.name} already configured in ${target.configPath}` + ); + console.log("Updating server path..."); + } + + config.mcpServers[target.configKey] = entry; + + const dir = dirname(target.configPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + writeFileSync(target.configPath, JSON.stringify(config, null, 2) + "\n"); + console.log(`\nWrote config to: ${target.configPath}`); + console.log(`Server path: ${serverPath}`); + console.log("\nYou're all set! Start your AI assistant to begin using Godot MCP Pro."); +} + +function cmdDoctor(): void { + console.log("Godot MCP Pro — Environment Check\n"); + + // Node.js + const nodeVer = run("node --version"); + const nodeOk = nodeVer.startsWith("v") && parseInt(nodeVer.slice(1)) >= 18; + check("Node.js", nodeOk, nodeVer); + + // npm + const npmVer = run("npm --version"); + const npmOk = !npmVer.includes("not found") && !npmVer.includes("failed"); + check("npm", npmOk, npmVer); + + // Dependencies installed + const nodeModules = existsSync(join(SERVER_DIR, "node_modules")); + check("Dependencies installed", nodeModules); + + // Server built + const built = existsSync(BUILD_INDEX); + check("Server built", built, built ? BUILD_INDEX : "run 'node build/setup.js install'"); + + // Version + console.log(`\n Version: ${getVersion()}`); + + // Overall + const allOk = nodeOk && npmOk && nodeModules && built; + console.log(allOk ? "\nAll good!" : "\nSome issues found. Fix them above."); + if (!allOk) process.exit(1); +} + +// ─── Main ───────────────────────────────────────────────────── + +function showHelp(): void { + console.log(`godot-mcp-setup — Setup and management for Godot MCP Pro + +Usage: node build/setup.js + +Commands: + install Install dependencies and build the server + check-update Check if a newer version is available on GitHub + configure Auto-detect AI client and generate .mcp.json config + doctor Check Node.js, npm, and build status + +Options: + --help Show this help + --version Show current version + +Examples: + node build/setup.js install + node build/setup.js doctor + node build/setup.js configure + node build/setup.js check-update`); +} + +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + + if (!cmd || cmd === "--help" || cmd === "-h") { + showHelp(); + process.exit(0); + } + + if (cmd === "--version" || cmd === "-v") { + console.log(getVersion()); + process.exit(0); + } + + switch (cmd) { + case "install": + await cmdInstall(); + break; + case "check-update": + await cmdCheckUpdate(); + break; + case "configure": + await cmdConfigure(); + break; + case "doctor": + cmdDoctor(); + break; + default: + console.error(`Unknown command: ${cmd}`); + showHelp(); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Fatal:", err.message); + process.exit(1); +}); diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/analysis-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/analysis-tools.ts new file mode 100644 index 0000000..40c5a93 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/analysis-tools.ts @@ -0,0 +1,108 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerAnalysisTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "find_unused_resources", + "Scan the project for resource files (.tres, .tscn, .png, .wav, .ogg, .ttf, .gdshader, etc.) that are not referenced by any .tscn, .gd, or .tres file. Useful for cleaning up unused assets.", + { + path: z.string().optional().describe("Root path to scan (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in scan (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_unused_resources", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "analyze_signal_flow", + "Map all signal connections in the currently edited scene. Returns a graph-like structure showing which nodes emit which signals and which nodes receive them.", + {}, + async () => { + try { + const result = await godot.sendCommand("analyze_signal_flow"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "analyze_scene_complexity", + "Analyze a scene's complexity: total node count, max nesting depth, nodes grouped by type, attached scripts, and potential issues (too many nodes, deep nesting).", + { + path: z.string().optional().describe("Scene path to analyze (default: currently edited scene)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("analyze_scene_complexity", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "find_script_references", + "Find all places where a given script path, class_name, or resource path is referenced across the project. Searches .tscn, .gd, and .tres files.", + { + query: z.string().describe("The script path, class_name, or resource path to search for (e.g. 'res://scripts/player.gd', 'PlayerController', 'res://assets/icon.png')"), + path: z.string().optional().describe("Root path to search (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in search (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_script_references", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "detect_circular_dependencies", + "Check for circular scene dependencies where Scene A instances Scene B which instances Scene A (directly or indirectly). Walks all .tscn files and builds a dependency graph.", + { + path: z.string().optional().describe("Root path to scan (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in scan (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("detect_circular_dependencies", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_project_statistics", + "Get overall project statistics: file counts by extension, total script lines, scene count, resource count, autoload list, and enabled plugins.", + { + path: z.string().optional().describe("Root path to scan (default: res://)"), + include_addons: z.boolean().optional().describe("Include addons/ directory in statistics (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_project_statistics", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/android-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/android-tools.ts new file mode 100644 index 0000000..632968b --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/android-tools.ts @@ -0,0 +1,61 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerAndroidTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "list_android_devices", + "List Android devices visible to adb (parses 'adb devices -l'). Uses the path configured in Editor Settings > Export > Android > Adb, falls back to 'adb' on PATH.", + {}, + async () => { + try { + const result = await godot.sendCommand("list_android_devices"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_android_preset_info", + "Read metadata (package name, export path, runnable flag) from an Android export preset in export_presets.cfg. If no preset is specified, returns the first Android preset.", + { + preset_name: z.string().optional().describe("Preset name as shown in Project > Export"), + preset_index: z.number().optional().describe("Preset index (alternative to name)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_android_preset_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "deploy_to_android", + "Export APK via Godot CLI, install it on a connected Android device via adb, and optionally launch the main activity. Equivalent to Godot's Remote Deploy button. Requires a configured Android export preset and adb on PATH (or set in Editor Settings). This call is synchronous and may take tens of seconds to complete.", + { + preset_name: z.string().optional().describe("Android export preset name (defaults to first Android preset)"), + preset_index: z.number().optional().describe("Preset index (alternative to name)"), + device_serial: z.string().optional().describe("adb device serial (omit to use default device)"), + debug: z.boolean().optional().describe("Debug export (default: true)"), + launch: z.boolean().optional().describe("Launch the app after install (default: true)"), + skip_export: z.boolean().optional().describe("Skip the export step and install the existing APK at the preset's export_path (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("deploy_to_android", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tools.ts new file mode 100644 index 0000000..f374fb8 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tools.ts @@ -0,0 +1,119 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerAnimationTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "list_animations", + "List all animations in an AnimationPlayer node", + { + node_path: z.string().describe("Path to the AnimationPlayer node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("list_animations", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "create_animation", + "Create a new animation in an AnimationPlayer", + { + node_path: z.string().describe("Path to the AnimationPlayer node"), + name: z.string().describe("Name for the new animation"), + length: z.number().optional().describe("Animation length in seconds (default: 1.0)"), + loop_mode: z.number().optional().describe("Loop mode: 0=none, 1=linear, 2=pingpong (default: 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_animation", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_animation_track", + "Add a track to an animation (value, position, rotation, scale, method, bezier)", + { + node_path: z.string().describe("Path to the AnimationPlayer node"), + animation: z.string().describe("Animation name"), + track_path: z.string().describe("Node path and property for the track (e.g. 'Sprite2D:position')"), + track_type: z.string().optional().describe("Track type: value, position_2d, rotation_2d, scale_2d, method, bezier, blend_shape (default: value)"), + update_mode: z.string().optional().describe("Update mode for value tracks: continuous, discrete, capture"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_animation_track", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_animation_keyframe", + "Insert a keyframe into an animation track", + { + node_path: z.string().describe("Path to the AnimationPlayer node"), + animation: z.string().describe("Animation name"), + track_index: z.number().describe("Track index"), + time: z.number().describe("Time position in seconds"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Keyframe value. Strings auto-parsed for Vector2, Color, etc."), + easing: z.number().optional().describe("Easing/transition value. 1.0=linear, <1.0=ease-in, >1.0=ease-out. Use negative for in-out variants. (default: 1.0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_animation_keyframe", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_animation_info", + "Get detailed info about an animation including all tracks and keyframes", + { + node_path: z.string().describe("Path to the AnimationPlayer node"), + animation: z.string().describe("Animation name"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_animation_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "remove_animation", + "Remove an animation from an AnimationPlayer", + { + node_path: z.string().describe("Path to the AnimationPlayer node"), + name: z.string().describe("Name of the animation to remove"), + }, + async (params) => { + try { + const result = await godot.sendCommand("remove_animation", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tree-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tree-tools.ts new file mode 100644 index 0000000..aa61a9a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/animation-tree-tools.ts @@ -0,0 +1,168 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerAnimationTreeTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "create_animation_tree", + "Create an AnimationTree node with an AnimationNodeStateMachine as root, optionally linked to an AnimationPlayer", + { + node_path: z.string().describe("Path to the parent node where the AnimationTree will be added"), + anim_player: z.string().optional().describe("Relative path from the AnimationTree to the AnimationPlayer (e.g. '../AnimationPlayer')"), + name: z.string().optional().describe("Name for the AnimationTree node (default: 'AnimationTree')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_animation_tree", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_animation_tree_structure", + "Read the full structure of an AnimationTree including all states, transitions, and blend tree nodes", + { + node_path: z.string().describe("Path to the AnimationTree node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_animation_tree_structure", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_state_machine_state", + "Add a state to an AnimationNodeStateMachine (animation clip, blend tree, or nested state machine)", + { + node_path: z.string().describe("Path to the AnimationTree node"), + state_name: z.string().describe("Name for the new state"), + state_type: z.enum(["animation", "blend_tree", "state_machine"]).optional().describe("Type of state: 'animation' (default), 'blend_tree', or 'state_machine'"), + animation: z.string().optional().describe("Animation name to play (only for state_type='animation')"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine (e.g. 'Run/SubState'). Empty or omit for root."), + position_x: z.number().optional().describe("X position in the graph editor (default: 0)"), + position_y: z.number().optional().describe("Y position in the graph editor (default: 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_state_machine_state", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "remove_state_machine_state", + "Remove a state from an AnimationNodeStateMachine (also removes connected transitions)", + { + node_path: z.string().describe("Path to the AnimationTree node"), + state_name: z.string().describe("Name of the state to remove"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + }, + async (params) => { + try { + const result = await godot.sendCommand("remove_state_machine_state", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_state_machine_transition", + "Add a transition between two states in an AnimationNodeStateMachine with configurable switch mode, advance mode, and expression conditions", + { + node_path: z.string().describe("Path to the AnimationTree node"), + from_state: z.string().describe("Source state name (use 'Start' for the entry point)"), + to_state: z.string().describe("Destination state name (use 'End' for the exit point)"), + switch_mode: z.enum(["at_end", "immediate", "sync"]).optional().describe("When to switch: 'at_end' (wait for animation), 'immediate' (default), 'sync'"), + advance_mode: z.enum(["disabled", "enabled", "auto"]).optional().describe("How to advance: 'disabled', 'enabled' (default, uses travel), 'auto' (automatic)"), + advance_expression: z.string().optional().describe("GDScript expression that triggers this transition (e.g. 'is_running')"), + xfade_time: z.number().optional().describe("Cross-fade time in seconds"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_state_machine_transition", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "remove_state_machine_transition", + "Remove a transition between two states in an AnimationNodeStateMachine", + { + node_path: z.string().describe("Path to the AnimationTree node"), + from_state: z.string().describe("Source state name"), + to_state: z.string().describe("Destination state name"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + }, + async (params) => { + try { + const result = await godot.sendCommand("remove_state_machine_transition", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_blend_tree_node", + "Add or replace a node inside an AnimationNodeBlendTree state (Add2, Blend2, TimeScale, Animation, etc.) with optional connection", + { + node_path: z.string().describe("Path to the AnimationTree node"), + blend_tree_state: z.string().describe("Name of the BlendTree state in the state machine"), + bt_node_name: z.string().describe("Name for the node inside the BlendTree"), + bt_node_type: z.enum(["Animation", "Add2", "Blend2", "Add3", "Blend3", "TimeScale", "TimeSeek", "Transition", "OneShot", "Sub2"]).describe("Type of BlendTree node to create"), + animation: z.string().optional().describe("Animation name (only for bt_node_type='Animation')"), + connect_to: z.string().optional().describe("Name of another BlendTree node to connect this node's output to"), + connect_port: z.number().optional().describe("Input port index on the target node (default: 0)"), + state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."), + position_x: z.number().optional().describe("X position in the graph editor (default: 0)"), + position_y: z.number().optional().describe("Y position in the graph editor (default: 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_blend_tree_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_tree_parameter", + "Set an AnimationTree parameter value (conditions, blend amounts, time scale, etc.)", + { + node_path: z.string().describe("Path to the AnimationTree node"), + parameter: z.string().describe("Parameter path (e.g. 'conditions/is_running', 'Blend2/blend_amount'). 'parameters/' prefix is auto-added if missing."), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Parameter value. Strings are auto-parsed for Vector2, Color, etc."), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_tree_parameter", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/audio-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/audio-tools.ts new file mode 100644 index 0000000..841ffba --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/audio-tools.ts @@ -0,0 +1,127 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerAudioTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_audio_bus_layout", + "Get the entire audio bus layout: all buses with volumes, effects, send targets, solo/mute states", + {}, + async (params) => { + try { + const result = await godot.sendCommand("get_audio_bus_layout", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_audio_bus", + "Add a new audio bus with name, volume, send target, solo, and mute settings", + { + name: z.string().describe("Name for the new audio bus"), + volume_db: z.number().optional().describe("Volume in dB (default: 0)"), + send: z.string().optional().describe("Name of the bus to send output to (e.g. 'Master')"), + solo: z.boolean().optional().describe("Solo this bus (default: false)"), + mute: z.boolean().optional().describe("Mute this bus (default: false)"), + at_position: z.number().optional().describe("Bus index position to insert at (-1 = end)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_audio_bus", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_audio_bus", + "Modify an existing audio bus: volume, solo, mute, bypass_effects, send, or rename", + { + name: z.string().describe("Name of the audio bus to modify"), + volume_db: z.number().optional().describe("Volume in dB"), + solo: z.boolean().optional().describe("Solo state"), + mute: z.boolean().optional().describe("Mute state"), + bypass_effects: z.boolean().optional().describe("Bypass all effects on this bus"), + send: z.string().optional().describe("Name of the bus to send output to"), + rename: z.string().optional().describe("New name for the bus"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_audio_bus", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_audio_bus_effect", + "Add an audio effect to a bus. Types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq", + { + bus: z.string().describe("Name of the audio bus"), + effect_type: z.string().describe("Effect type: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter (or lowpass), highpassfilter (or highpass), bandpassfilter (or bandpass), amplify, eq"), + params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("Effect-specific parameters. E.g. for reverb: {room_size, damping, wet, dry, spread}; for compressor: {threshold, ratio, attack_us, release_ms}; for filters: {cutoff_hz, resonance}"), + at_position: z.number().optional().describe("Effect index position (-1 = end)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_audio_bus_effect", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_audio_player", + "Add an AudioStreamPlayer, AudioStreamPlayer2D, or AudioStreamPlayer3D node to a parent node", + { + node_path: z.string().describe("Path to the parent node"), + name: z.string().describe("Name for the new audio player node"), + type: z.string().optional().describe("Player type: AudioStreamPlayer (default), AudioStreamPlayer2D, AudioStreamPlayer3D"), + stream: z.string().optional().describe("Path to audio resource (e.g. 'res://audio/music.ogg')"), + volume_db: z.number().optional().describe("Volume in dB (default: 0)"), + bus: z.string().optional().describe("Audio bus name (default: 'Master')"), + autoplay: z.boolean().optional().describe("Auto-play when scene starts (default: false)"), + max_distance: z.number().optional().describe("Maximum hearing distance (for 2D/3D players)"), + attenuation: z.number().optional().describe("Distance attenuation factor (for 2D players)"), + attenuation_model: z.number().optional().describe("Attenuation model for 3D: 0=inverse_distance, 1=inverse_square, 2=logarithmic"), + unit_size: z.number().optional().describe("Unit size for 3D player volume reference"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_audio_player", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_audio_info", + "Get audio setup for a node subtree: finds all AudioStreamPlayer nodes with their settings, streams, and bus assignments", + { + node_path: z.string().describe("Path to the root node to search within"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_audio_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/batch-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/batch-tools.ts new file mode 100644 index 0000000..b982d4a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/batch-tools.ts @@ -0,0 +1,136 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerBatchTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "find_nodes_by_type", + "Find all nodes of a specific type in the current scene", + { + type: z.string().describe("Node type/class to search for (e.g. 'Sprite2D', 'Label', 'CollisionShape2D')"), + recursive: z.boolean().optional().describe("Search recursively through children (default: true)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_nodes_by_type", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "find_signal_connections", + "Find all signal connections in the current scene, optionally filtered by signal name or node", + { + signal_name: z.string().optional().describe("Filter by signal name (partial match)"), + node_path: z.string().optional().describe("Filter by node path (partial match)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_signal_connections", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "batch_set_property", + "Set a property on all nodes of a given type in the current scene", + { + type: z.string().describe("Node type to target (e.g. 'Label', 'Sprite2D')"), + property: z.string().describe("Property name to set (e.g. 'visible', 'modulate')"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to set. Strings auto-parsed for Vector2, Color, etc."), + }, + async (params) => { + try { + const result = await godot.sendCommand("batch_set_property", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "batch_add_nodes", + "Add multiple nodes in a single call. Supports building entire node trees at once — nodes added earlier can be referenced as parents by later entries. Much faster than calling add_node repeatedly.", + { + nodes: z.array(z.object({ + type: z.string().describe("Node type (e.g. 'Sprite2D', 'CharacterBody2D', 'Label')"), + parent_path: z.string().optional().describe("Parent node path (default: root '.'). Can reference nodes created earlier in this batch."), + name: z.string().optional().describe("Node name"), + properties: z.record(z.string(), z.any()).optional().describe("Properties to set (e.g. {\"position\": \"Vector2(100, 200)\"})"), + })).describe("Array of node definitions to add, processed in order"), + }, + async (params) => { + try { + const result = await godot.sendCommand("batch_add_nodes", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "find_node_references", + "Search through project files (.tscn, .gd, .tres, .gdshader) for a text pattern", + { + pattern: z.string().describe("Text pattern to search for"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_node_references", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_scene_dependencies", + "Get all resource dependencies of a scene or resource file", + { + path: z.string().describe("Path to the scene or resource file (e.g. 'res://scenes/player.tscn')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_scene_dependencies", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "cross_scene_set_property", + "Preview or apply a property change on all nodes of a given type across scene files in the project. Defaults to dry_run=true (returns the matching scenes and node paths without writing). To actually apply: pass force=true AND dry_run=false. Inactive open scenes are skipped and reported in skipped_open_scenes — open them as the active tab first to live-edit. The active open scene is live-edited via UndoRedo so changes are visible in the editor and undoable. Closed scenes are offline-saved. The response includes a per-scene `mode` field: dry_run / offline_saved / live_open_scene.", + { + type: z.string().describe("Node type to target (e.g. 'Label', 'Sprite2D')"), + property: z.string().describe("Property name to set"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to set. Strings auto-parsed for Vector2, Color, etc."), + path_filter: z.string().optional().describe("Directory to search in (default: 'res://')"), + exclude_addons: z.boolean().optional().describe("Exclude addons/ directory (default: true)"), + dry_run: z.boolean().optional().describe("Preview only — list affected scenes and nodes without writing. Defaults to true unless force=true is set."), + force: z.boolean().optional().describe("Required (alongside dry_run=false) to actually write. Acknowledges that this can modify many scene files at once."), + }, + async (params) => { + try { + const result = await godot.sendCommand("cross_scene_set_property", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/editor-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/editor-tools.ts new file mode 100644 index 0000000..849a453 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/editor-tools.ts @@ -0,0 +1,308 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerEditorTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_editor_errors", + "Get recent errors and stack traces from the Godot editor log", + { + max_lines: z.number().optional().describe("Maximum log lines to scan for errors (default: 50)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_editor_errors", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_output_log", + "Read the full Godot editor Output panel content. Unlike get_editor_errors which filters for errors only, this returns all output including print() statements and warnings.", + { + max_lines: z.number().optional().describe("Maximum number of lines to return from the end (default: 100)"), + filter: z.string().optional().describe("Filter lines containing this substring (case-sensitive)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_output_log", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_editor_screenshot", + "Capture a screenshot of the Godot editor's 2D/3D viewport", + { + save_path: z.string().optional().describe("Optional res:// or user:// path to save the screenshot as PNG file (e.g. 'res://screenshot.png'). When provided, the image is saved to disk and the file path is returned instead of base64 data."), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_editor_screenshot", params) as Record; + if (result && typeof result === "object" && "saved_path" in result) { + return { + content: [ + { + type: "text" as const, + text: `Screenshot saved: ${result.saved_path} (${result.width}x${result.height})`, + }, + ], + }; + } + if (result && typeof result === "object" && "image_base64" in result) { + return { + content: [ + { + type: "image" as const, + data: result.image_base64 as string, + mimeType: "image/png", + }, + { + type: "text" as const, + text: `Screenshot captured: ${result.width}x${result.height}`, + }, + ], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_game_screenshot", + "Capture a single screenshot of the running game (requires a scene to be playing). Good for checking static visual state (UI layout, scene composition, colors). For verifying animations or movement, use capture_frames instead — a single screenshot cannot confirm whether an animation is playing.", + { + save_path: z.string().optional().describe("Optional res:// or user:// path to save the screenshot as PNG file (e.g. 'res://screenshot.png'). When provided, the image is saved to disk and the file path is returned instead of base64 data."), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_game_screenshot", params) as Record; + if (result && typeof result === "object" && "saved_path" in result) { + return { + content: [ + { + type: "text" as const, + text: `Game screenshot saved: ${result.saved_path} (${result.width}x${result.height})${result.note ? ` (${result.note})` : ""}`, + }, + ], + }; + } + if (result && typeof result === "object" && "image_base64" in result) { + return { + content: [ + { + type: "image" as const, + data: result.image_base64 as string, + mimeType: "image/png", + }, + { + type: "text" as const, + text: `Game screenshot: ${result.width}x${result.height}${result.note ? ` (${result.note})` : ""}`, + }, + ], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "execute_editor_script", + "Execute arbitrary GDScript code inside the Godot editor. Use _mcp_print() to output values. By default refuses to execute code that contains direct file/resource write APIs (ResourceSaver.save, FileAccess WRITE, ProjectSettings.save, ConfigFile.save, DirAccess filesystem mutations) because those bypass the per-command open-resource guards. Use the dedicated MCP tools (save_scene, create_script, etc.) for those operations, or pass allow_unsafe_editor_io=true ONLY when you have verified no open editor resource will be overwritten.", + { + code: z.string().describe( + "GDScript code to execute. Use _mcp_print(value) to capture output. " + + "The code runs inside a run() function with access to the full editor API." + ), + allow_unsafe_editor_io: z.boolean().optional().describe( + "Override the file-write safety guard. Only set this when you are certain no open scene/script/shader will be overwritten by the script. Prefer the dedicated MCP tools for ordinary save flows." + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("execute_editor_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "clear_output", + "Clear the Godot editor output panel", + {}, + async () => { + try { + const result = await godot.sendCommand("clear_output"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_signals", + "Get all signals of a node, including current connections", + { + node_path: z.string().describe("Path to the node to inspect"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_signals", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "reload_plugin", + "Reload the Godot MCP Pro plugin (disable/re-enable). Connection will briefly drop and auto-reconnect. NOTE: This does NOT reload GDScript preload() caches. If you changed GDScript command files, use execute_editor_script with 'EditorInterface.restart_editor(true)' instead for a full editor restart.", + {}, + async () => { + try { + const result = await godot.sendCommand("reload_plugin"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "reload_project", + "Rescan the Godot project filesystem and reload changed scripts (no reconnection needed)", + {}, + async () => { + try { + const result = await godot.sendCommand("reload_project"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "compare_screenshots", + "Compare two screenshots pixel-by-pixel and return a diff analysis. Returns changed pixel count, diff percentage, and a highlighted diff image. Useful for visual regression testing. Accepts file paths (res://, user://) or base64 PNG strings.", + { + image_a: z.string().describe("First image: file path (e.g. 'user://screenshot_a.png') or base64 PNG string"), + image_b: z.string().describe("Second image: file path (e.g. 'user://screenshot_b.png') or base64 PNG string"), + threshold: z.number().optional().describe("Color difference threshold (0-255, default: 10). Pixels with max channel difference below this are considered identical."), + }, + async (params) => { + try { + const result = await godot.sendCommand("compare_screenshots", params) as Record; + + const content: Array<{type: "text"; text: string} | {type: "image"; data: string; mimeType: string}> = []; + + // Add summary text + content.push({ + type: "text" as const, + text: JSON.stringify({ + identical: result.identical, + changed_pixels: result.changed_pixels, + total_pixels: result.total_pixels, + diff_percentage: result.diff_percentage, + threshold: result.threshold, + size: `${result.width}x${result.height}`, + }, null, 2), + }); + + // Add diff image if there are differences + if (result.diff_image_base64 && !result.identical) { + content.push({ + type: "image" as const, + data: result.diff_image_base64 as string, + mimeType: "image/png", + }); + } + + return { content }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_auto_dismiss", + "Enable or disable automatic dismissal of blocking editor dialogs (e.g. 'Reload from disk?', 'Save changes?'). Enable this before operations that modify files externally, and disable when done. Disabled by default.", + { + enabled: z.boolean().describe("true to enable auto-dismiss, false to disable"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_auto_dismiss", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_editor_camera", + "Get the current 3D editor viewport camera position, rotation, and FOV. Use this to understand the current view before taking editor screenshots.", + {}, + async () => { + try { + const result = await godot.sendCommand("get_editor_camera"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_editor_camera", + "Move the 3D editor viewport camera to a specific position and orientation. Use this to frame a view before taking editor screenshots to validate changes visually.", + { + position: z.object({ + x: z.number().describe("X position"), + y: z.number().describe("Y position"), + z: z.number().describe("Z position"), + }).optional().describe("Camera world position"), + rotation_degrees: z.object({ + x: z.number().describe("Pitch (degrees)"), + y: z.number().describe("Yaw (degrees)"), + z: z.number().describe("Roll (degrees)"), + }).optional().describe("Camera rotation in degrees"), + look_at: z.object({ + x: z.number().describe("Target X"), + y: z.number().describe("Target Y"), + z: z.number().describe("Target Z"), + }).optional().describe("Point to look at (overrides rotation_degrees if both set)"), + fov: z.number().optional().describe("Field of view in degrees (default: 75)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_editor_camera", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/export-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/export-tools.ts new file mode 100644 index 0000000..23ace96 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/export-tools.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerExportTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "list_export_presets", + "List all export presets configured in export_presets.cfg", + {}, + async () => { + try { + const result = await godot.sendCommand("list_export_presets"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "export_project", + "Get the export command for a preset (direct export from editor is not supported in Godot 4)", + { + preset_name: z.string().optional().describe("Export preset name"), + preset_index: z.number().optional().describe("Export preset index (alternative to name)"), + debug: z.boolean().optional().describe("Debug export (default: true)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("export_project", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_export_info", + "Get export-related project info (executable path, templates, project path)", + {}, + async () => { + try { + const result = await godot.sendCommand("get_export_info"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-map-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-map-tools.ts new file mode 100644 index 0000000..f9344d8 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-map-tools.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerInputMapTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_input_actions", + "Get all input actions defined in the project's Input Map with their key/button bindings", + { + filter: z.string().optional().describe("Filter action names containing this substring"), + include_builtin: z.boolean().optional().describe("Include built-in ui_* actions (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_input_actions", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_input_action", + "Create or update an input action with key/mouse/joypad bindings. Saves to project.godot and updates the runtime InputMap.", + { + action: z.string().describe("Action name (e.g. 'move_left', 'jump', 'attack')"), + events: z.array(z.object({ + type: z.enum(["key", "mouse_button", "joypad_button", "joypad_motion"]).describe("Event type"), + keycode: z.string().optional().describe("Key name for 'key' type (e.g. 'W', 'Space', 'Escape', 'Shift')"), + physical_keycode: z.string().optional().describe("Physical key name for 'key' type"), + ctrl: z.boolean().optional().describe("Ctrl modifier for 'key' type"), + shift: z.boolean().optional().describe("Shift modifier for 'key' type"), + alt: z.boolean().optional().describe("Alt modifier for 'key' type"), + meta: z.boolean().optional().describe("Meta/Cmd modifier for 'key' type"), + button_index: z.number().optional().describe("Button index for mouse/joypad button types"), + axis: z.number().optional().describe("Axis index for joypad_motion type"), + axis_value: z.number().optional().describe("Axis value (-1.0 or 1.0) for joypad_motion type"), + })).describe("Array of input event bindings"), + deadzone: z.number().optional().describe("Deadzone for analog inputs (default: 0.5)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_input_action", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-tools.ts new file mode 100644 index 0000000..3d68645 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/input-tools.ts @@ -0,0 +1,138 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; +import { coerceNumber } from "../utils/zod-coerce.js"; + +export function registerInputTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "simulate_key", + "Simulate a keyboard key press or release in the running game. Use `duration` to hold a key for a set time (auto-releases after). Without duration: keys are NOT auto-released — you must explicitly call with pressed=false to release them.", + { + keycode: z.string().describe("Key constant (e.g. 'KEY_SPACE', 'KEY_W', 'KEY_ESCAPE')"), + pressed: z.boolean().optional().describe("true for press, false for release (default: true)"), + duration: coerceNumber().optional().describe("Hold duration in seconds (e.g. 1.5). Key is pressed, held for this duration, then auto-released. Cannot be used with pressed=false."), + shift: z.boolean().optional().describe("Shift modifier (default: false)"), + ctrl: z.boolean().optional().describe("Ctrl modifier (default: false)"), + alt: z.boolean().optional().describe("Alt modifier (default: false)"), + }, + async (params) => { + try { + if (params.duration !== undefined && params.duration > 0) { + const { duration, ...keyParams } = params; + // Press + await godot.sendCommand("simulate_key", { ...keyParams, pressed: true }); + // Hold + await new Promise(resolve => setTimeout(resolve, duration * 1000)); + // Release + await godot.sendCommand("simulate_key", { ...keyParams, pressed: false }); + return { content: [{ type: "text", text: JSON.stringify({ + event: { keycode: params.keycode, duration, shift: params.shift, ctrl: params.ctrl, alt: params.alt, auto_released: true }, + sent: true + }, null, 2) }] }; + } + const result = await godot.sendCommand("simulate_key", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "simulate_mouse_click", + "Simulate a mouse button click at a position in the running game. By default sends both press and release (auto_release) so UI buttons work correctly.", + { + x: z.number().optional().describe("X position in viewport (default: 0)"), + y: z.number().optional().describe("Y position in viewport (default: 0)"), + button: z.number().optional().describe("Mouse button index: 1=left, 2=right, 3=middle (default: 1)"), + pressed: z.boolean().optional().describe("true for press, false for release (default: true)"), + double_click: z.boolean().optional().describe("Double click (default: false)"), + auto_release: z.boolean().optional().describe("Auto-send release after press so buttons fire (default: true). Set false for drag operations."), + }, + async (params) => { + try { + const result = await godot.sendCommand("simulate_mouse_click", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "simulate_mouse_move", + "Simulate mouse movement in the running game. Use x/y for absolute viewport positioning (UI interaction), or relative_x/relative_y for relative motion (camera rotation in 3D games, FPS-style look). For 3D camera rotation: relative_x rotates yaw (negative = look left, positive = look right), relative_y rotates pitch (negative = look up, positive = look down). Typical values: 200-400px for a ~90° turn. Use navigate_to tool to calculate exact relative_x needed to face a target.", + { + x: z.number().optional().describe("Absolute X position in viewport (for UI interaction)"), + y: z.number().optional().describe("Absolute Y position in viewport (for UI interaction)"), + relative_x: z.number().optional().describe("Relative X movement in pixels. For 3D camera: negative = look left, positive = look right. ~400px ≈ 180° turn"), + relative_y: z.number().optional().describe("Relative Y movement in pixels. For 3D camera: negative = look up, positive = look down"), + button_mask: z.number().optional().describe("Mouse button mask to simulate drag. 1=left button held, 2=right button held, 4=middle button held. Required for drag operations like camera pan. (default: 0)"), + unhandled: z.boolean().optional().describe("Force event to bypass GUI layer and go directly to _unhandled_input(). Auto-enabled when button_mask > 0. Use for camera pan/drag when UI overlays consume mouse events. (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("simulate_mouse_move", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "simulate_action", + "Simulate a Godot Input Action (e.g. 'jump', 'move_left') in the running game", + { + action: z.string().describe("Action name as defined in Input Map (e.g. 'jump', 'move_left')"), + pressed: z.boolean().optional().describe("true for press, false for release (default: true)"), + strength: z.number().optional().describe("Action strength 0.0-1.0 (default: 1.0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("simulate_action", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "simulate_sequence", + "Simulate a sequence of input events with optional frame delays between them. Useful for complex input patterns like press W → wait 30 frames → press Space → wait → release all. After the sequence, use capture_frames to verify the visual result.", + { + events: z.array(z.object({ + type: z.string().describe("Event type: 'key', 'mouse_button', 'mouse_motion', or 'action'"), + keycode: z.string().optional().describe("For 'key': key constant (e.g. 'KEY_SPACE')"), + action: z.string().optional().describe("For 'action': action name"), + button: z.number().optional().describe("For 'mouse_button': button index"), + pressed: z.boolean().optional().describe("Press state (default: true)"), + x: z.number().optional().describe("X position for mouse events"), + y: z.number().optional().describe("Y position for mouse events"), + relative_x: z.number().optional().describe("Relative X for mouse_motion"), + relative_y: z.number().optional().describe("Relative Y for mouse_motion"), + button_mask: z.number().optional().describe("Mouse button mask for mouse_motion drag: 1=left, 2=right, 4=middle"), + unhandled: z.boolean().optional().describe("Bypass GUI, send directly to _unhandled_input. Auto-enabled for mouse_motion with button_mask > 0"), + shift: z.boolean().optional(), + ctrl: z.boolean().optional(), + alt: z.boolean().optional(), + strength: z.number().optional(), + double_click: z.boolean().optional(), + })).describe("Array of input events to send"), + frame_delay: z.number().optional().describe("Frames to wait between events (default: 1, 0 = all in one frame)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("simulate_sequence", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/navigation-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/navigation-tools.ts new file mode 100644 index 0000000..af13a4c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/navigation-tools.ts @@ -0,0 +1,116 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerNavigationTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "setup_navigation_region", + "Add a NavigationRegion2D/3D child to a node with auto-created NavigationPolygon or NavigationMesh. Auto-detects 2D/3D from parent context.", + { + node_path: z.string().describe("Path to the parent node to add the region to"), + mode: z.string().optional().describe("Force '2d' or '3d' mode, or 'auto' to detect from parent (default: auto)"), + name: z.string().optional().describe("Name for the NavigationRegion node"), + navigation_layers: z.number().optional().describe("Navigation layers bitmask"), + agent_radius: z.number().optional().describe("Agent radius for mesh generation (3D default: 0.5, 2D: from NavigationPolygon)"), + agent_height: z.number().optional().describe("Agent height (3D only, default: 1.5)"), + agent_max_climb: z.number().optional().describe("Max climb height (3D only, default: 0.25)"), + agent_max_slope: z.number().optional().describe("Max slope angle in degrees (3D only, default: 45.0)"), + cell_size: z.number().optional().describe("Cell size for navigation mesh (default: 0.25 for 3D)"), + cell_height: z.number().optional().describe("Cell height (3D only, default: 0.25)"), + source_geometry_mode: z.string().optional().describe("2D only: root_node, groups_with_children, or groups_explicit"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_navigation_region", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "bake_navigation_mesh", + "Bake navigation mesh for a NavigationRegion3D, or set outline vertices and generate polygons for a NavigationRegion2D.", + { + node_path: z.string().describe("Path to the NavigationRegion2D or NavigationRegion3D node"), + outline: z.array(z.union([ + z.array(z.number()).describe("[x, y] coordinate pair"), + z.object({ x: z.number(), y: z.number() }), + ])).optional().describe("2D only: Array of outline vertices as [x,y] pairs or {x,y} objects. At least 3 vertices required."), + }, + async (params) => { + try { + const result = await godot.sendCommand("bake_navigation_mesh", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "setup_navigation_agent", + "Add a NavigationAgent2D/3D child to a node and configure pathfinding and avoidance properties. Auto-detects 2D/3D from parent context.", + { + node_path: z.string().describe("Path to the parent node to add the agent to"), + mode: z.string().optional().describe("Force '2d' or '3d' mode, or 'auto' to detect from parent (default: auto)"), + name: z.string().optional().describe("Name for the NavigationAgent node"), + path_desired_distance: z.number().optional().describe("Distance threshold to advance to next path point"), + target_desired_distance: z.number().optional().describe("Distance threshold to consider target reached"), + radius: z.number().optional().describe("Agent radius for avoidance"), + neighbor_distance: z.number().optional().describe("Max distance to consider other agents as neighbors"), + max_neighbors: z.number().optional().describe("Max number of neighbors for avoidance"), + max_speed: z.number().optional().describe("Maximum movement speed for avoidance"), + avoidance_enabled: z.boolean().optional().describe("Enable avoidance behavior"), + navigation_layers: z.number().optional().describe("Navigation layers bitmask for pathfinding queries"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_navigation_agent", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_navigation_layers", + "Set navigation layers for a NavigationRegion or NavigationAgent. Supports bitmask value, layer bit numbers, or named layers from ProjectSettings.", + { + node_path: z.string().describe("Path to a NavigationRegion2D/3D or NavigationAgent2D/3D node"), + layers: z.number().optional().describe("Navigation layers as a bitmask value (e.g. 5 = layers 1 and 3)"), + layer_bits: z.array(z.number()).optional().describe("Array of 1-based layer numbers to enable (e.g. [1, 3] = bitmask 5)"), + layer_names: z.array(z.string()).optional().describe("Array of named layer names from ProjectSettings (layer_names/2d_navigation/layer_N or 3d)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_navigation_layers", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_navigation_info", + "Get navigation setup info for a node and its subtree: all NavigationRegions, NavigationAgents, their layers, and mesh/polygon data.", + { + node_path: z.string().describe("Path to the root node to inspect"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_navigation_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/node-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/node-tools.ts new file mode 100644 index 0000000..ff9cc6e --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/node-tools.ts @@ -0,0 +1,308 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerNodeTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "add_node", + "Add a new node to the current scene. Supports built-in Godot types and script-defined classes (class_name).", + { + type: z.string().describe("Node type — built-in (e.g. 'Sprite2D', 'Camera2D') or script class_name (e.g. 'HoverDetector', 'StationBuilder')"), + parent_path: z.string().optional().describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Node name"), + properties: z.record(z.string(), z.any()).optional().describe("Properties to set (e.g. {\"position\": \"Vector2(100, 200)\"})"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "delete_node", + "Delete a node from the current scene (supports undo)", + { + node_path: z.string().describe("Path to the node to delete"), + }, + async (params) => { + try { + const result = await godot.sendCommand("delete_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "duplicate_node", + "Duplicate a node and all its children in the current scene", + { + node_path: z.string().describe("Path to the node to duplicate"), + name: z.string().optional().describe("Name for the duplicate (default: original_copy)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("duplicate_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "move_node", + "Move/reparent a node to a new parent in the scene tree", + { + node_path: z.string().describe("Path to the node to move"), + new_parent_path: z.string().describe("Path to the new parent node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("move_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "update_property", + "Change a property on any node. Supports Vector2, Color, and other Godot types via string parsing.", + { + node_path: z.string().describe("Path to the target node"), + property: z.string().describe("Property name (e.g. 'position', 'modulate', 'visible')"), + value: z.any().describe("New value. Strings are auto-parsed: 'Vector2(10,20)', 'Color(1,0,0)', '#ff0000', etc."), + }, + async (params) => { + try { + const result = await godot.sendCommand("update_property", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_node_properties", + "Get all editor-visible properties of a node with their current values", + { + node_path: z.string().describe("Path to the node"), + category: z.string().optional().describe("Filter by property category prefix (e.g. 'transform', 'texture')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_node_properties", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_resource", + "Add a resource (Shape2D, Material, Texture, etc.) to a node's property", + { + node_path: z.string().describe("Path to the target node"), + property: z.string().describe("Property to set the resource on (e.g. 'shape', 'material', 'texture')"), + resource_type: z.string().describe("Resource class name (e.g. 'RectangleShape2D', 'CircleShape2D', 'StandardMaterial3D')"), + resource_properties: z.record(z.string(), z.any()).optional().describe("Properties to set on the created resource"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_anchor_preset", + "Set a Control node's anchor preset (e.g. full_rect, center, top_left)", + { + node_path: z.string().describe("Path to the Control node"), + preset: z.string().describe("Anchor preset name: top_left, top_right, bottom_left, bottom_right, center_left, center_top, center_right, center_bottom, center, left_wide, top_wide, right_wide, bottom_wide, vcenter_wide, hcenter_wide, full_rect"), + keep_offsets: z.boolean().optional().describe("Keep current offsets (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_anchor_preset", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "rename_node", + "Rename a node in the current scene", + { + node_path: z.string().describe("Path to the node to rename"), + new_name: z.string().describe("New name for the node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("rename_node", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "connect_signal", + "Connect a signal from one node to a method on another node. The connection is persistent (saved into the .tscn on save_scene).", + { + source_path: z.string().describe("Path to the source node (emitter)"), + signal_name: z.string().describe("Signal name to connect"), + target_path: z.string().describe("Path to the target node (receiver)"), + method_name: z.string().describe("Method name on target to call"), + deferred: z.boolean().optional().describe("Use a deferred connection (CONNECT_DEFERRED)"), + one_shot: z.boolean().optional().describe("Disconnect automatically after the first emission (CONNECT_ONE_SHOT)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("connect_signal", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "disconnect_signal", + "Disconnect a signal connection between two nodes", + { + source_path: z.string().describe("Path to the source node (emitter)"), + signal_name: z.string().describe("Signal name to disconnect"), + target_path: z.string().describe("Path to the target node (receiver)"), + method_name: z.string().describe("Method name on target"), + }, + async (params) => { + try { + const result = await godot.sendCommand("disconnect_signal", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_node_groups", + "Get all groups a node belongs to (excludes internal groups starting with '_')", + { + node_path: z.string().describe("Path to the node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_node_groups", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_node_groups", + "Set the groups a node belongs to. Computes diff with current groups and adds/removes as needed.", + { + node_path: z.string().describe("Path to the node"), + groups: z.array(z.string()).describe("Desired list of group names (replaces current groups)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_node_groups", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "find_nodes_in_group", + "Find all nodes in the current scene that belong to a specific group", + { + group: z.string().describe("Group name to search for"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_nodes_in_group", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_editor_selection", + "Get the nodes currently selected in the editor Scene dock", + { + top_only: z.boolean().optional().describe("Return only the topmost selected nodes, excluding a node whose parent is already selected (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_editor_selection", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "select_nodes", + "Select one or more nodes in the editor Scene dock, optionally focusing and inspecting them", + { + node_path: z.string().optional().describe("Path to a single node to select"), + node_paths: z.array(z.string()).optional().describe("Paths to multiple nodes to select (use instead of node_path)"), + mode: z.enum(["replace", "add", "remove"]).optional().describe("How to apply the selection (default: replace)"), + inspect: z.boolean().optional().describe("Show the node in the Inspector (only applied when a single node is selected; default: true)"), + focus: z.boolean().optional().describe("Focus the node in the Scene dock (only applied when a single node is selected; default: follows inspect)"), + for_property: z.string().optional().describe("Inspector property to focus on"), + inspector_only: z.boolean().optional().describe("Show in Inspector without changing the edited node (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("select_nodes", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "clear_editor_selection", + "Clear the current editor Scene-dock selection", + {}, + async (params) => { + try { + const result = await godot.sendCommand("clear_editor_selection", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/particle-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/particle-tools.ts new file mode 100644 index 0000000..54839de --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/particle-tools.ts @@ -0,0 +1,135 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerParticleTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "create_particles", + "Add a GPUParticles2D or GPUParticles3D node with a ParticleProcessMaterial. Configure amount, lifetime, one_shot, explosiveness, and randomness.", + { + parent_path: z.string().describe("Path to the parent node to add particles to"), + name: z.string().optional().describe("Name for the particles node (default: 'Particles')"), + is_3d: z.boolean().optional().describe("Create GPUParticles3D instead of GPUParticles2D (default: false)"), + amount: z.number().optional().describe("Number of particles (default: 16)"), + lifetime: z.number().optional().describe("Particle lifetime in seconds (default: 1.0)"), + one_shot: z.boolean().optional().describe("Emit only once (default: false)"), + explosiveness: z.number().optional().describe("Explosiveness ratio 0-1 (default: 0.0)"), + randomness: z.number().optional().describe("Randomness ratio 0-1 (default: 0.0)"), + emitting: z.boolean().optional().describe("Start emitting immediately (default: true)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_particles", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_particle_material", + "Configure ParticleProcessMaterial properties: direction, spread, velocity, gravity, scale, color, emission shape (point/sphere/box/ring), angular/orbit velocity, damping, and attractor interaction.", + { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + direction: z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).optional().describe("Emission direction vector"), + spread: z.number().optional().describe("Spread angle in degrees (0-180)"), + initial_velocity_min: z.number().optional().describe("Minimum initial velocity"), + initial_velocity_max: z.number().optional().describe("Maximum initial velocity"), + gravity: z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).optional().describe("Gravity vector"), + scale_min: z.number().optional().describe("Minimum particle scale"), + scale_max: z.number().optional().describe("Maximum particle scale"), + color: z.string().optional().describe("Particle color (hex '#RRGGBB' or named color)"), + emission_shape: z.string().optional().describe("Emission shape: point, sphere, sphere_surface, box, ring"), + emission_sphere_radius: z.number().optional().describe("Sphere emission radius"), + emission_box_extents: z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + }).optional().describe("Box emission extents"), + emission_ring_radius: z.number().optional().describe("Ring outer radius"), + emission_ring_inner_radius: z.number().optional().describe("Ring inner radius"), + emission_ring_height: z.number().optional().describe("Ring height"), + angular_velocity_min: z.number().optional().describe("Minimum angular velocity (degrees/sec)"), + angular_velocity_max: z.number().optional().describe("Maximum angular velocity (degrees/sec)"), + orbit_velocity_min: z.number().optional().describe("Minimum orbit velocity"), + orbit_velocity_max: z.number().optional().describe("Maximum orbit velocity"), + damping_min: z.number().optional().describe("Minimum damping"), + damping_max: z.number().optional().describe("Maximum damping"), + attractor_interaction_enabled: z.boolean().optional().describe("Enable attractor interaction"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_particle_material", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_particle_color_gradient", + "Set a color ramp (gradient) on a particle system's material. Provide an array of color stops with offset (0-1) and color.", + { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + stops: z.array(z.object({ + offset: z.number().describe("Gradient position (0.0 to 1.0)"), + color: z.string().describe("Color at this stop (hex '#RRGGBB' or named color)"), + })).describe("Array of gradient color stops"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_particle_color_gradient", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "apply_particle_preset", + "Apply a named particle preset. Available presets: explosion (burst, short life), fire (upward, orange gradient), smoke (slow upward, gray), sparks (burst, high velocity), rain (downward, blue), snow (slow downward, drift), magic (orbit, colorful), dust (ambient, subtle).", + { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + preset: z.string().describe("Preset name: explosion, fire, smoke, sparks, rain, snow, magic, dust"), + }, + async (params) => { + try { + const result = await godot.sendCommand("apply_particle_preset", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_particle_info", + "Get the full configuration of a particle system: node properties, material settings, emission shape, color gradient stops.", + { + node_path: z.string().describe("Path to the GPUParticles2D/3D node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_particle_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/physics-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/physics-tools.ts new file mode 100644 index 0000000..c07c973 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/physics-tools.ts @@ -0,0 +1,149 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerPhysicsTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "setup_collision", + "Add a CollisionShape2D/3D child to a physics body or area node with a specified shape. Auto-detects 2D/3D from the parent node type.", + { + node_path: z.string().describe("Path to the parent physics body or area node (e.g. CharacterBody2D, StaticBody3D, Area2D)"), + shape: z.string().describe("Shape type: 'rectangle'/'rect', 'circle', 'capsule', 'segment' (2D only), 'cylinder' (3D only), 'custom'/'convex'. For 3D: 'box'/'sphere' also work."), + width: z.number().optional().describe("Width for rectangle/box shape (default: 32 for 2D, 1 for 3D)"), + height: z.number().optional().describe("Height for rectangle/box/capsule/cylinder shape"), + depth: z.number().optional().describe("Depth for 3D box shape (default: 1)"), + radius: z.number().optional().describe("Radius for circle/sphere/capsule/cylinder shape"), + ax: z.number().optional().describe("Segment start X (2D segment only)"), + ay: z.number().optional().describe("Segment start Y (2D segment only)"), + bx: z.number().optional().describe("Segment end X (2D segment only)"), + by: z.number().optional().describe("Segment end Y (2D segment only)"), + points: z.array(z.array(z.number())).optional().describe("Convex polygon points as [[x,y],...] for 2D or [[x,y,z],...] for 3D"), + disabled: z.boolean().optional().describe("Create the collision shape disabled (default: false)"), + one_way_collision: z.boolean().optional().describe("Enable one-way collision (2D only, default: false)"), + dimension: z.string().optional().describe("Force '2d' or '3d' if auto-detection fails"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_collision", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_physics_layers", + "Set collision layer and/or mask on a physics body or area node. Supports bitmask integers or arrays of layer numbers.", + { + node_path: z.string().describe("Path to the node with collision layers"), + collision_layer: z.union([z.number(), z.array(z.number())]).optional().describe("Collision layer: bitmask integer or array of layer numbers [1,3,5]"), + collision_mask: z.union([z.number(), z.array(z.number())]).optional().describe("Collision mask: bitmask integer or array of layer numbers [1,2,4]"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_physics_layers", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_physics_layers", + "Get the current collision layer and mask for a node, including named layer info from ProjectSettings.", + { + node_path: z.string().describe("Path to the node with collision layers"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_physics_layers", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_raycast", + "Add a RayCast2D/3D child node for collision detection. Auto-detects 2D/3D from the parent node type.", + { + node_path: z.string().describe("Path to the parent node"), + name: z.string().optional().describe("Name for the raycast node (default: 'RayCast')"), + target_x: z.number().optional().describe("Target position X (default: 0)"), + target_y: z.number().optional().describe("Target position Y (default: 50 for 2D, -1 for 3D)"), + target_z: z.number().optional().describe("Target position Z (3D only, default: 0)"), + collision_mask: z.number().optional().describe("Collision mask bitmask (default: 1)"), + enabled: z.boolean().optional().describe("Enable the raycast (default: true)"), + collide_with_areas: z.boolean().optional().describe("Collide with Area nodes (default: false)"), + collide_with_bodies: z.boolean().optional().describe("Collide with physics bodies (default: true)"), + hit_from_inside: z.boolean().optional().describe("Detect hits from inside shapes (default: false)"), + dimension: z.string().optional().describe("Force '2d' or '3d' if auto-detection fails"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_raycast", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "setup_physics_body", + "Configure physics body properties. For CharacterBody2D/3D: floor settings, motion mode, etc. For RigidBody2D/3D: mass, gravity, damping, etc.", + { + node_path: z.string().describe("Path to the physics body node"), + // CharacterBody properties + floor_stop_on_slope: z.boolean().optional().describe("CharacterBody: stop on slopes when not moving"), + floor_max_angle: z.number().optional().describe("CharacterBody: maximum floor angle in radians (default ~0.785 = 45 degrees)"), + floor_snap_length: z.number().optional().describe("CharacterBody: floor snap distance for sticking to the ground"), + wall_min_slide_angle: z.number().optional().describe("CharacterBody: minimum angle for wall sliding in radians"), + motion_mode: z.string().optional().describe("CharacterBody: 'grounded' or 'floating'"), + max_slides: z.number().optional().describe("CharacterBody: maximum slide iterations (default: 6)"), + slide_on_ceiling: z.boolean().optional().describe("CharacterBody: allow sliding on ceiling"), + // RigidBody properties + mass: z.number().optional().describe("RigidBody: mass in kg (default: 1)"), + gravity_scale: z.number().optional().describe("RigidBody: gravity multiplier (default: 1, 0 = no gravity)"), + linear_damp: z.number().optional().describe("RigidBody: linear velocity damping"), + angular_damp: z.number().optional().describe("RigidBody: angular velocity damping"), + freeze: z.boolean().optional().describe("RigidBody: freeze the body (stop physics simulation)"), + freeze_mode: z.string().optional().describe("RigidBody: 'static' or 'kinematic' freeze behavior"), + continuous_cd: z.union([z.string(), z.boolean()]).optional().describe("RigidBody: continuous collision detection. 2D: 'disabled'/'cast_ray'/'cast_shape'. 3D: true/false"), + contact_monitor: z.boolean().optional().describe("RigidBody: enable contact monitoring for body_entered/body_exited signals"), + max_contacts_reported: z.number().optional().describe("RigidBody: max contacts to report (requires contact_monitor)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_physics_body", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_collision_info", + "Get detailed collision information for a node: all collision shapes, layers/masks, raycasts, and physics body settings. Scans children by default.", + { + node_path: z.string().describe("Path to the node to inspect"), + include_children: z.boolean().optional().describe("Include children in the scan (default: true)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_collision_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/profiling-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/profiling-tools.ts new file mode 100644 index 0000000..3321e05 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/profiling-tools.ts @@ -0,0 +1,39 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerProfilingTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_performance_monitors", + "Get the RUNNING GAME's performance monitors (FPS, memory, draw calls, physics, navigation, etc.). Requires a scene to be playing (play_scene). For editor-process metrics use get_editor_performance.", + { + category: z.string().optional().describe("Filter by category prefix: 'fps', 'memory', 'render', 'physics_2d', 'physics_3d', 'navigation'"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_performance_monitors", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_editor_performance", + "Get a quick performance summary (FPS, frame time, draw calls, memory usage)", + {}, + async () => { + try { + const result = await godot.sendCommand("get_editor_performance"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/project-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/project-tools.ts new file mode 100644 index 0000000..f9ecec3 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/project-tools.ts @@ -0,0 +1,179 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerProjectTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_project_info", + "Get Godot project metadata including name, version, viewport settings, renderer, and autoloads", + {}, + async () => { + try { + const result = await godot.sendCommand("get_project_info"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_filesystem_tree", + "Get the project's file/directory tree with optional filtering by extension (e.g. *.gd, *.tscn)", + { + path: z.string().optional().describe("Root path to scan (default: res://)"), + filter: z.string().optional().describe("Glob filter pattern (e.g. '*.gd', '*.tscn')"), + max_depth: z.number().optional().describe("Maximum directory depth to scan (default: 10)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_filesystem_tree", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "search_files", + "Search for files by name using fuzzy matching or glob patterns", + { + query: z.string().describe("Search query (fuzzy match or glob pattern)"), + path: z.string().optional().describe("Root path to search (default: res://)"), + file_type: z.string().optional().describe("Filter by file extension (e.g. 'gd', 'tscn')"), + max_results: z.number().optional().describe("Maximum results to return (default: 50)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("search_files", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "search_in_files", + "Search for text content inside project files (grep-like). Searches through GDScript, scenes, resources, shaders, and other text files. Skips addons/ and .godot/ directories.", + { + query: z.string().describe("Text to search for (plain text or regex pattern)"), + path: z.string().optional().describe("Root path to search (default: res://)"), + regex: z.boolean().optional().describe("Use regex matching (default: false)"), + file_type: z.string().optional().describe("Filter by file extension (e.g. 'gd', 'tscn'). Default: all text files"), + max_results: z.number().optional().describe("Maximum results to return (default: 50)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("search_in_files", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_project_settings", + "Read project.godot settings by section or specific key", + { + section: z.string().optional().describe("Settings section prefix (e.g. 'display/window')"), + key: z.string().optional().describe("Specific setting key (e.g. 'display/window/size/viewport_width')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_project_settings", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_project_setting", + "Set a project setting value (e.g. viewport size, main scene). Saves to project.godot via the editor API.", + { + key: z.string().describe("Setting key (e.g. 'display/window/size/viewport_width', 'application/run/main_scene')"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to set. Strings are auto-parsed for Vector2, bool, int, float."), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_project_setting", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "uid_to_project_path", + "Convert a Godot UID (uid://...) to a project resource path (res://...)", + { + uid: z.string().describe("The UID string (e.g. 'uid://abc123')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("uid_to_project_path", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "project_path_to_uid", + "Convert a project resource path (res://...) to its UID (uid://...)", + { + path: z.string().describe("The resource path (e.g. 'res://scenes/player.tscn')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("project_path_to_uid", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_autoload", + "Add an autoload (singleton) to the project. The script/scene will be auto-loaded when the project starts.", + { + name: z.string().describe("Autoload name (e.g. 'GameManager', 'AudioManager')"), + path: z.string().describe("Path to the script or scene file (e.g. 'res://scripts/autoload/game_manager.gd')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_autoload", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "remove_autoload", + "Remove an autoload (singleton) from the project settings", + { + name: z.string().describe("Autoload name to remove (e.g. 'GameManager')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("remove_autoload", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/resource-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/resource-tools.ts new file mode 100644 index 0000000..d0cc202 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/resource-tools.ts @@ -0,0 +1,93 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerResourceTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "read_resource", + "Read a .tres resource file and return its properties. Works with any Godot Resource type (StyleBox, Font, Theme, Material, etc.)", + { + path: z.string().describe("Path to the resource file (e.g. 'res://themes/main_theme.tres')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("read_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "edit_resource", + "Edit properties of an existing .tres resource file. Changes are saved to disk immediately.", + { + path: z.string().describe("Path to the resource file (e.g. 'res://themes/main_theme.tres')"), + properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe("Properties to set as key-value pairs. Values auto-parsed for Vector2, Color, etc."), + }, + async (params) => { + try { + const result = await godot.sendCommand("edit_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "create_resource", + "Create a new .tres resource file of a given type with optional initial properties", + { + path: z.string().describe("Path to save the resource (e.g. 'res://resources/player_stats.tres')"), + type: z.string().describe("Resource type to create (e.g. 'StyleBoxFlat', 'LabelSettings', 'Environment')"), + properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("Initial properties to set"), + overwrite: z.boolean().optional().describe("Overwrite if file exists (default: false)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_resource", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_resource_preview", + "Get a visual preview of an image or texture resource as a PNG. Works with .png, .jpg, .webp, .svg image files and Texture2D resources.", + { + path: z.string().describe("Path to the resource (e.g. 'res://assets/player.png', 'res://icon.svg')"), + max_size: z.number().optional().describe("Maximum width/height in pixels, preserving aspect ratio (default: 256)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_resource_preview", params) as Record; + if (result && typeof result === "object" && "image_base64" in result) { + return { + content: [ + { + type: "image" as const, + data: result.image_base64 as string, + mimeType: "image/png", + }, + { + type: "text" as const, + text: `Preview of ${result.path}: ${result.width}x${result.height}`, + }, + ], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/runtime-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/runtime-tools.ts new file mode 100644 index 0000000..60fff5d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/runtime-tools.ts @@ -0,0 +1,775 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; +import { coerceStringArray, coerceNumber } from "../utils/zod-coerce.js"; + +export function registerRuntimeTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_game_scene_tree", + "Get the scene tree of the currently running game (requires a scene to be playing). Supports filtering by script path, node type, or name.", + { + max_depth: z + .number() + .optional() + .describe("Maximum tree depth (-1 for unlimited, default: -1)"), + script_filter: z + .string() + .optional() + .describe( + "Only include nodes whose script path contains this string (e.g. 'enemy' matches 'enemy.gd', 'enemy_drone.gd')" + ), + type_filter: z + .string() + .optional() + .describe( + "Only include nodes of this Godot class (e.g. 'CharacterBody2D', 'Area2D')" + ), + named_only: z + .boolean() + .optional() + .describe( + "If true, exclude nodes with auto-generated names (names starting with '@'). Default: false" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_game_scene_tree", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "get_game_node_properties", + "Get properties of a node in the running game (requires a scene to be playing)", + { + node_path: z + .string() + .describe( + "Absolute node path in the running game (e.g. '/root/Main/Player')" + ), + properties: coerceStringArray() + .optional() + .describe( + "Specific property names to read (default: all editor-visible properties)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand( + "get_game_node_properties", + params + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "set_game_node_property", + "Set a property on a node in the running game (requires a scene to be playing). Useful for live-tweaking values like position, speed, health, etc.", + { + node_path: z + .string() + .describe( + "Absolute node path in the running game (e.g. '/root/Main/Player')" + ), + property: z + .string() + .describe("Property name to set (e.g. 'position', 'speed', 'health')"), + value: z + .union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number())]) + .describe( + "Value to set. Accepts: strings with auto-parsing ('Vector2(100,200)', '#ff0000'), numbers, booleans, or JSON objects for vectors/colors ({\"x\":5,\"y\":3,\"z\":10} for Vector3, {\"x\":100,\"y\":200} for Vector2, {\"r\":1,\"g\":0,\"b\":0,\"a\":1} for Color)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand( + "set_game_node_property", + params + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "execute_game_script", + "Execute arbitrary GDScript code inside the running game process. Use _mcp_print() to output values. Has access to the live scene tree and all game nodes.", + { + code: z + .string() + .describe( + "GDScript code to execute in the running game. Use _mcp_print(value) to capture output. Code runs inside a run() function with access to the live game scene tree." + ), + }, + async (params) => { + try { + const result = await godot.sendCommand( + "execute_game_script", + params + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "capture_frames", + "Capture multiple screenshots at regular frame intervals from the running game. Returns base64 PNG images. Use this to verify animations are playing correctly — if character poses differ across frames, the animation is working; if all frames show the same pose (e.g. T-pose), animation loading failed. Also useful for verifying movement, physics, and any time-based behavior. Prefer this over get_game_screenshot when you need to confirm something is changing over time.", + { + count: coerceNumber() + .optional() + .describe("Number of frames to capture (1-30, default: 5)"), + frame_interval: coerceNumber() + .optional() + .describe( + "Frames to wait between captures (default: 10, i.e. ~6 captures/sec at 60fps)" + ), + half_resolution: z + .boolean() + .optional() + .describe("Halve resolution to reduce data size (default: true)"), + node_data: z + .object({ + node_path: z.string().describe("Path to a node to track (e.g. '/root/Main/Player')"), + properties: coerceStringArray().describe("Property names to capture per frame (e.g. ['global_position', 'velocity'])"), + }) + .optional() + .describe("Optional: capture node property data alongside each frame for debugging (position, velocity, etc.)"), + }, + async (params) => { + try { + const result = (await godot.sendCommand( + "capture_frames", + params + )) as Record; + + if ( + result && + typeof result === "object" && + "frames" in result && + Array.isArray(result.frames) + ) { + const content: Array< + | { type: "image"; data: string; mimeType: string } + | { type: "text"; text: string } + > = []; + + const frameData = Array.isArray(result.frame_data) ? result.frame_data : null; + + for (let i = 0; i < result.frames.length; i++) { + content.push({ + type: "image" as const, + data: result.frames[i] as string, + mimeType: "image/png", + }); + if (frameData && frameData[i]) { + content.push({ + type: "text" as const, + text: `Frame ${i + 1}: ${JSON.stringify(frameData[i])}`, + }); + } + } + content.push({ + type: "text" as const, + text: `Captured ${result.count} frames (${result.width}x${result.height}${result.half_resolution ? ", half-res" : ""})`, + }); + return { content }; + } + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "record_frames", + "Record many screenshots to files on disk for long-running debug observation. Unlike capture_frames (which returns base64 images directly), this saves PNG files to user://mcp_recorded_frames/ and returns file paths. Use this when you need more than 30 frames or want to observe behavior over a longer period without flooding the context with image data.", + { + count: coerceNumber() + .optional() + .describe("Number of frames to capture (1-600, default: 30)"), + frame_interval: coerceNumber() + .optional() + .describe( + "Frames to wait between captures (default: 10, i.e. ~6 captures/sec at 60fps)" + ), + half_resolution: z + .boolean() + .optional() + .describe("Halve resolution to reduce file size (default: true)"), + node_data: z + .object({ + node_path: z.string().describe("Path to a node to track (e.g. '/root/Main/Player')"), + properties: coerceStringArray().describe("Property names to capture per frame (e.g. ['global_position', 'velocity'])"), + }) + .optional() + .describe("Optional: capture node property data alongside each frame for debugging"), + }, + async (params) => { + try { + const result = (await godot.sendCommand( + "record_frames", + params + )) as Record; + + const content: Array<{ type: "text"; text: string }> = []; + + content.push({ + type: "text" as const, + text: `Recorded ${result.count} frames to ${result.directory}/ (${result.width}x${result.height}${result.half_resolution ? ", half-res" : ""})`, + }); + + if (Array.isArray(result.files)) { + content.push({ + type: "text" as const, + text: `Files:\n${(result.files as string[]).join("\n")}`, + }); + } + + if (Array.isArray(result.frame_data) && (result.frame_data as unknown[]).length > 0) { + content.push({ + type: "text" as const, + text: `Node data:\n${JSON.stringify(result.frame_data, null, 2)}`, + }); + } + + return { content }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "monitor_properties", + "Record property values over multiple frames from the running game. Returns a timeline of samples. Great for verifying movement (position changing), animation state (current_animation property), physics behavior (velocity), and debugging time-dependent issues.", + { + node_path: z + .string() + .describe( + "Absolute node path in the running game (e.g. '/root/Main/Player')" + ), + properties: coerceStringArray() + .describe( + "Property names to monitor (e.g. ['position', 'velocity'])" + ), + frame_count: coerceNumber() + .optional() + .describe("Number of samples to collect (1-600, default: 60)"), + frame_interval: coerceNumber() + .optional() + .describe( + "Frames to wait between samples (default: 1, every frame)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("monitor_properties", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "watch_signals", + "Monitor signal emissions on specified nodes in the running game for a duration. Returns a timestamped log of every signal fired — great for debugging event flow, verifying signal connections, and understanding runtime behavior.", + { + node_paths: z + .array(z.string()) + .describe( + "Absolute node paths to watch (e.g. ['/root/Main/Player', '/root/Main/Enemy'])" + ), + signal_filter: z + .array(z.string()) + .optional() + .describe( + "Only watch signals containing these substrings (e.g. ['health', 'died']). Omit to watch all signals." + ), + duration_ms: coerceNumber() + .optional() + .describe( + "How long to watch in milliseconds (500-30000, default: 5000)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("watch_signals", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "start_recording", + "Start recording all input events (keyboard, mouse, actions) in the running game. Use stop_recording to get the recorded events.", + {}, + async () => { + try { + const result = await godot.sendCommand("start_recording"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "stop_recording", + "Stop recording input events and return the recorded event timeline. Events include timestamps for replay.", + {}, + async () => { + try { + const result = await godot.sendCommand("stop_recording"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "replay_recording", + "Replay a previously recorded input event sequence in the running game. Useful for regression testing — record a test once, replay it after code changes.", + { + events: z.array(z.record(z.string(), z.unknown())).describe("Array of recorded event objects (from stop_recording output)"), + speed: z.number().optional().describe("Playback speed multiplier (default: 1.0, 2.0 = double speed)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("replay_recording", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "find_nodes_by_script", + "Find all nodes in the running game whose script path contains a given string. Returns matching nodes with their properties.", + { + script: z + .string() + .describe( + "Script path substring to search for (e.g. 'enemy', 'player.gd')" + ), + properties: coerceStringArray() + .optional() + .describe( + "Specific property names to include for each match (default: all editor-visible properties)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand( + "find_nodes_by_script", + params + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "get_autoload", + "Get properties of an autoload/singleton node in the running game. Quick access to global game state like GameManager, EventBus, etc.", + { + name: z + .string() + .describe( + "Autoload name (e.g. 'GameManager', 'EventBus', 'SaveManager')" + ), + properties: coerceStringArray() + .optional() + .describe( + "Specific property names to read (default: all editor-visible properties)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_autoload", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "find_ui_elements", + "Find all visible UI elements (Button, Label, LineEdit, CheckBox, Slider, etc.) in the running game. Returns each element's text, type, position, and center point for clicking.", + { + type_filter: z + .string() + .optional() + .describe( + "Only return elements of this type (e.g. 'Button', 'Label', 'CheckBox'). Default: all types" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_ui_elements", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "click_button_by_text", + "Click a button in the running game by its text label. Finds the button, calculates its center, and simulates a full click (press + release). Much easier than manual coordinate-based clicking.", + { + text: z + .string() + .describe( + "Button text to search for (e.g. 'New Game', 'Start', 'OK')" + ), + partial: z + .boolean() + .optional() + .describe( + "Allow partial text matching (default: true). If false, requires exact match." + ), + }, + async (params) => { + try { + const result = await godot.sendCommand( + "click_button_by_text", + params + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "wait_for_node", + "Wait until a node exists at the given path in the running game scene tree. Useful for waiting after scene transitions, node spawning, or UI state changes.", + { + node_path: z + .string() + .describe( + "Absolute node path to wait for (e.g. '/root/Main/Player', '/root/Dungeon')" + ), + timeout: z + .number() + .optional() + .describe("Maximum seconds to wait (default: 5.0)"), + poll_frames: z + .number() + .optional() + .describe( + "Frames between each check (default: 5, i.e. ~12 checks/sec at 60fps)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("wait_for_node", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "find_nearby_nodes", + "Find all nodes within a radius of a position in the running game, sorted by distance. Useful for finding what's near the player (collectibles, enemies, interactables) without manually querying each node's position.", + { + position: z + .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number().optional() })]) + .describe( + "Origin position: either a node_path string (e.g. '/root/Main/Player') to use that node's global_position, or an {x, y, z} coordinate object" + ), + radius: coerceNumber() + .optional() + .describe("Search radius in world units (default: 20.0)"), + type_filter: z + .string() + .optional() + .describe( + "Only include nodes of this Godot class (e.g. 'Area3D', 'CharacterBody3D')" + ), + group_filter: z + .string() + .optional() + .describe( + "Only include nodes in this group (e.g. 'enemies', 'collectibles')" + ), + max_results: coerceNumber() + .optional() + .describe("Maximum number of results to return (default: 10)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("find_nearby_nodes", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "navigate_to", + "Calculate navigation info from the player to a target in the running 3D game. Returns the world direction, camera-relative suggested WASD keys to press, camera yaw rotation needed (as mouse relative_x pixels for simulate_mouse_move), and estimated walk duration. Use this to plan movement instead of manually calculating directions.", + { + target: z + .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number() })]) + .describe( + "Target: either a node_path string (e.g. '/root/Main/Crystal') or an {x, y, z} coordinate object" + ), + player_path: z + .string() + .optional() + .describe( + "Player node path (default: '/root/Main/Player')" + ), + camera_path: z + .string() + .optional() + .describe( + "Camera node path (default: auto-detect active Camera3D)" + ), + move_speed: coerceNumber() + .optional() + .describe( + "Player movement speed in units/sec for duration estimation (default: 5.0)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("navigate_to", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "move_to", + "Autopilot the player character to walk to a target position in the running 3D game. Handles camera rotation and forward movement internally at 60fps — completes in a single call with no manual simulate_key/simulate_mouse_move loops needed. The player's camera pivot is directly rotated toward the target, and W key is injected to walk. Much more reliable and efficient than navigate_to + manual input simulation.", + { + target: z + .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number() })]) + .describe( + "Target: either a node_path string (e.g. '/root/Main/Crystal') or an {x, y, z} coordinate object" + ), + player_path: z + .string() + .optional() + .describe( + "Player node path (default: '/root/Main/Player')" + ), + camera_path: z + .string() + .optional() + .describe( + "Camera pivot node path (default: auto-detect SpringArm3D child of player, or active Camera3D parent)" + ), + arrival_radius: coerceNumber() + .optional() + .describe( + "Stop when this close to target in world units (default: 1.5)" + ), + timeout: coerceNumber() + .optional() + .describe( + "Maximum seconds before giving up (default: 15.0)" + ), + run: z + .boolean() + .optional() + .describe( + "Hold Shift for running speed (default: false)" + ), + look_at_target: z + .boolean() + .optional() + .describe( + "Rotate camera toward target while moving (default: true). Set false to walk forward without turning." + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("move_to", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "batch_get_properties", + "Get properties of multiple nodes at once in the running game. More efficient than calling get_game_node_properties multiple times.", + { + nodes: z + .array( + z.object({ + path: z + .string() + .describe("Absolute node path (e.g. '/root/Main/Player')"), + properties: coerceStringArray() + .optional() + .describe( + "Specific properties to read (default: all editor-visible)" + ), + }) + ) + .describe("Array of nodes to query"), + }, + async (params) => { + try { + const result = await godot.sendCommand( + "batch_get_properties", + params + ); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-3d-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-3d-tools.ts new file mode 100644 index 0000000..3f72d8d --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-3d-tools.ts @@ -0,0 +1,485 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerScene3DTools( + server: McpServer, + godot: GodotConnection +): void { + // ─── 1. add_mesh_instance ────────────────────────────────────────────── + + server.tool( + "add_mesh_instance", + "Add a MeshInstance3D node with a primitive mesh (Box, Sphere, Cylinder, Capsule, Plane, Prism, Torus, Quad) or load a 3D model file (.glb/.gltf/.obj). Set position, rotation, scale, and mesh-specific properties.", + { + mesh_type: z + .string() + .optional() + .describe( + "Primitive mesh type: BoxMesh, SphereMesh, CylinderMesh, CapsuleMesh, PlaneMesh, PrismMesh, TorusMesh, QuadMesh" + ), + mesh_file: z + .string() + .optional() + .describe( + "Path to a 3D model file (res://path/to/model.glb, .gltf, .obj). Use instead of mesh_type for imported models" + ), + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Node name (default: MeshInstance3D)"), + position: z + .any() + .optional() + .describe( + "Position as Vector3 string 'Vector3(x,y,z)', object {x,y,z}, or array [x,y,z]" + ), + rotation: z + .any() + .optional() + .describe( + "Rotation in degrees as Vector3 string, object {x,y,z}, or array [x,y,z]" + ), + scale: z + .any() + .optional() + .describe("Scale as Vector3 string, object {x,y,z}, or array [x,y,z]"), + mesh_properties: z + .record(z.string(), z.any()) + .optional() + .describe( + "Properties to set on the mesh resource (e.g. {\"size\": \"Vector3(2,1,2)\"} for BoxMesh)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_mesh_instance", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + // ─── 2. setup_lighting ───────────────────────────────────────────────── + + server.tool( + "setup_lighting", + "Add a light node (DirectionalLight3D, OmniLight3D, SpotLight3D) to the scene. Supports preset configurations: 'sun' (directional with shadows), 'indoor' (warm omni), 'dramatic' (focused spot with shadows).", + { + light_type: z + .string() + .optional() + .describe( + "Light type: DirectionalLight3D, OmniLight3D, SpotLight3D. Not needed if preset is specified" + ), + preset: z + .string() + .optional() + .describe( + "Preset configuration: 'sun' (directional, shadows, -45deg), 'indoor' (warm omni, range 8), 'dramatic' (spot, high energy, shadows)" + ), + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Node name"), + color: z + .any() + .optional() + .describe("Light color as Color string or hex (default: white)"), + energy: z + .number() + .optional() + .describe("Light energy/intensity (default: 1.0)"), + shadows: z + .boolean() + .optional() + .describe("Enable shadow casting (default: false, true for sun/dramatic presets)"), + range: z + .number() + .optional() + .describe("Range for OmniLight3D/SpotLight3D (default: 5.0)"), + attenuation: z + .number() + .optional() + .describe("Attenuation for OmniLight3D/SpotLight3D (default: 1.0)"), + spot_angle: z + .number() + .optional() + .describe("Spot angle in degrees for SpotLight3D (default: 45.0)"), + spot_angle_attenuation: z + .number() + .optional() + .describe("Spot angle attenuation for SpotLight3D (default: 1.0)"), + position: z + .any() + .optional() + .describe("Position as Vector3 string, object {x,y,z}, or array [x,y,z]"), + rotation: z + .any() + .optional() + .describe("Rotation in degrees as Vector3 string, object {x,y,z}, or array [x,y,z]"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_lighting", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + // ─── 3. set_material_3d ──────────────────────────────────────────────── + + server.tool( + "set_material_3d", + "Create and apply a StandardMaterial3D to a MeshInstance3D. Configure PBR properties: albedo color/texture, metallic, roughness, emission, transparency, normal maps.", + { + node_path: z + .string() + .describe("Path to the MeshInstance3D node"), + surface_index: z + .number() + .optional() + .describe("Surface index to apply material to (default: 0)"), + albedo_color: z + .any() + .optional() + .describe( + "Albedo color as Color string 'Color(r,g,b,a)', hex '#ff0000', or object {r,g,b,a}" + ), + albedo_texture: z + .string() + .optional() + .describe("Path to albedo texture (res://path/to/texture.png)"), + metallic: z + .number() + .optional() + .describe("Metallic value 0.0-1.0 (default: 0.0)"), + roughness: z + .number() + .optional() + .describe("Roughness value 0.0-1.0 (default: 1.0)"), + metallic_texture: z + .string() + .optional() + .describe("Path to metallic texture"), + roughness_texture: z + .string() + .optional() + .describe("Path to roughness texture"), + normal_texture: z + .string() + .optional() + .describe("Path to normal map texture (auto-enables normal mapping)"), + emission: z + .any() + .optional() + .describe( + "Emission color (auto-enables emission). Color string or hex" + ), + emission_color: z + .any() + .optional() + .describe("Alias for emission"), + emission_energy: z + .number() + .optional() + .describe("Emission energy multiplier (default: 1.0)"), + emission_texture: z + .string() + .optional() + .describe("Path to emission texture"), + transparency: z + .string() + .optional() + .describe( + "Transparency mode: DISABLED, ALPHA, ALPHA_SCISSOR, ALPHA_HASH, ALPHA_DEPTH_PRE_PASS" + ), + cull_mode: z + .string() + .optional() + .describe("Cull mode: BACK, FRONT, DISABLED"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_material_3d", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + // ─── 4. setup_environment ────────────────────────────────────────────── + + server.tool( + "setup_environment", + "Add or configure a WorldEnvironment node with sky, ambient light, tonemap, fog, glow, SSAO, SSR, and SDFGI settings.", + { + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + node_path: z + .string() + .optional() + .describe( + "Path to an existing WorldEnvironment to modify instead of creating a new one" + ), + name: z + .string() + .optional() + .describe("Node name (default: WorldEnvironment)"), + background_mode: z + .string() + .optional() + .describe( + "Background mode: 'sky', 'color', 'canvas', 'clear_color' (default: sky)" + ), + background_color: z + .any() + .optional() + .describe("Background color when mode is 'color'"), + sky: z + .object({ + sky_top_color: z.any().optional().describe("Sky top color"), + sky_horizon_color: z.any().optional().describe("Sky horizon color"), + ground_bottom_color: z.any().optional().describe("Ground bottom color"), + ground_horizon_color: z + .any() + .optional() + .describe("Ground horizon color"), + sun_angle_max: z + .number() + .optional() + .describe("Maximum sun angle in degrees"), + sky_curve: z + .number() + .optional() + .describe("Sky color curve (0.0-1.0)"), + }) + .optional() + .describe("ProceduralSkyMaterial settings"), + ambient_light_color: z.any().optional().describe("Ambient light color"), + ambient_light_energy: z + .number() + .optional() + .describe("Ambient light energy"), + ambient_light_source: z + .string() + .optional() + .describe( + "Ambient light source: BACKGROUND, DISABLED, COLOR, SKY" + ), + tonemap_mode: z + .string() + .optional() + .describe("Tonemap mode: LINEAR, REINHARDT, FILMIC, ACES"), + tonemap_exposure: z.number().optional().describe("Tonemap exposure"), + tonemap_white: z.number().optional().describe("Tonemap white point"), + fog_enabled: z.boolean().optional().describe("Enable volumetric fog"), + fog_light_color: z.any().optional().describe("Fog light color"), + fog_density: z.number().optional().describe("Fog density"), + fog_light_energy: z.number().optional().describe("Fog light energy"), + glow_enabled: z.boolean().optional().describe("Enable glow/bloom"), + glow_intensity: z.number().optional().describe("Glow intensity"), + glow_strength: z.number().optional().describe("Glow strength"), + glow_bloom: z.number().optional().describe("Glow bloom amount"), + ssao_enabled: z + .boolean() + .optional() + .describe("Enable Screen-Space Ambient Occlusion"), + ssao_radius: z.number().optional().describe("SSAO radius"), + ssao_intensity: z.number().optional().describe("SSAO intensity"), + ssr_enabled: z + .boolean() + .optional() + .describe("Enable Screen-Space Reflections"), + ssr_max_steps: z.number().optional().describe("SSR max steps"), + ssr_fade_in: z.number().optional().describe("SSR fade in"), + ssr_fade_out: z.number().optional().describe("SSR fade out"), + sdfgi_enabled: z + .boolean() + .optional() + .describe("Enable Signed Distance Field Global Illumination"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_environment", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + // ─── 5. setup_camera_3d ──────────────────────────────────────────────── + + server.tool( + "setup_camera_3d", + "Add or configure a Camera3D node. Set projection mode, FOV, near/far planes, position, rotation, look-at target, and cull mask.", + { + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + node_path: z + .string() + .optional() + .describe( + "Path to an existing Camera3D to configure instead of creating a new one" + ), + name: z.string().optional().describe("Node name (default: Camera3D)"), + projection: z + .string() + .optional() + .describe( + "Projection mode: 'perspective', 'orthogonal'/'orthographic', 'frustum'" + ), + fov: z + .number() + .optional() + .describe("Field of view in degrees for perspective (default: 75)"), + size: z + .number() + .optional() + .describe("View size for orthogonal projection"), + near: z + .number() + .optional() + .describe("Near clipping plane (default: 0.05)"), + far: z + .number() + .optional() + .describe("Far clipping plane (default: 4000)"), + cull_mask: z + .number() + .optional() + .describe("Cull mask as integer bitmask"), + current: z + .boolean() + .optional() + .describe("Make this the current/active camera (default: false)"), + position: z + .any() + .optional() + .describe("Position as Vector3 (default: (0, 1, 3) for new cameras)"), + rotation: z + .any() + .optional() + .describe("Rotation in degrees as Vector3"), + look_at: z + .any() + .optional() + .describe( + "Target position to look at as Vector3 (overrides rotation)" + ), + environment_path: z + .string() + .optional() + .describe( + "Path to an Environment resource for camera-specific environment override" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_camera_3d", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + // ─── 6. add_gridmap ──────────────────────────────────────────────────── + + server.tool( + "add_gridmap", + "Add or configure a GridMap node with a MeshLibrary. Optionally set cells at specific grid positions with item IDs and orientations.", + { + parent_path: z + .string() + .optional() + .describe("Parent node path (default: root '.')"), + node_path: z + .string() + .optional() + .describe( + "Path to an existing GridMap to configure instead of creating a new one" + ), + name: z.string().optional().describe("Node name (default: GridMap)"), + mesh_library_path: z + .string() + .optional() + .describe( + "Path to a MeshLibrary resource (res://path/to/library.meshlib or .tres)" + ), + cell_size: z + .any() + .optional() + .describe("Cell size as Vector3 (default: (2, 2, 2))"), + position: z.any().optional().describe("GridMap position as Vector3"), + cells: z + .array( + z.object({ + x: z.number().describe("Cell X coordinate"), + y: z.number().describe("Cell Y coordinate"), + z: z.number().describe("Cell Z coordinate"), + item: z + .number() + .optional() + .describe("MeshLibrary item index (default: 0)"), + orientation: z + .number() + .optional() + .describe("Cell orientation index (default: 0)"), + }) + ) + .optional() + .describe("Array of cells to set with grid positions and item IDs"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_gridmap", params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-tools.ts new file mode 100644 index 0000000..dae695c --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/scene-tools.ts @@ -0,0 +1,171 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerSceneTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "get_scene_tree", + "Get the live scene tree of the currently edited scene, showing all nodes, types, and hierarchy", + { + max_depth: z.number().optional().describe("Max tree depth to return (-1 for unlimited)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_scene_tree", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_scene_file_content", + "Read the raw .tscn file content of a scene", + { + path: z.string().describe("Path to the scene file (e.g. 'res://scenes/main.tscn')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_scene_file_content", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "create_scene", + "Create a new scene file with a specified root node type", + { + path: z.string().describe("Path for the new scene (e.g. 'res://scenes/enemy.tscn')"), + root_type: z.string().optional().describe("Root node type (default: Node2D). Examples: Node2D, Node3D, Control, CharacterBody2D"), + root_name: z.string().optional().describe("Root node name (defaults to filename)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "open_scene", + "Open a scene file in the Godot editor", + { + path: z.string().describe("Path to the scene file (e.g. 'res://scenes/main.tscn')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("open_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "delete_scene", + "Delete a scene file from the project", + { + path: z.string().describe("Path to the scene file to delete"), + }, + async (params) => { + try { + const result = await godot.sendCommand("delete_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "add_scene_instance", + "Add an existing scene as a child node (instancing) in the current scene", + { + scene_path: z.string().describe("Path to the scene to instance (e.g. 'res://scenes/enemy.tscn')"), + parent_path: z.string().optional().describe("Parent node path (default: root '.')"), + name: z.string().optional().describe("Custom name for the instance"), + }, + async (params) => { + try { + const result = await godot.sendCommand("add_scene_instance", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "play_scene", + "Run a scene in the Godot editor (main scene, current scene, or specific path)", + { + mode: z.string().optional().describe("'main' (default), 'current', or a scene file path"), + }, + async (params) => { + try { + const result = await godot.sendCommand("play_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "stop_scene", + "Stop the currently playing scene", + {}, + async () => { + try { + const result = await godot.sendCommand("stop_scene"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "save_scene", + "Save the currently edited scene to disk", + { + path: z.string().optional().describe("Optional path to save to (defaults to current scene path)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("save_scene", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_scene_exports", + "Get all @export variables from all scripted nodes in a scene file. Useful for inspecting configurable parameters without opening the scene.", + { + path: z.string().describe("Path to the scene file (e.g. 'res://scenes/enemy.tscn')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_scene_exports", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/script-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/script-tools.ts new file mode 100644 index 0000000..2bc35d6 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/script-tools.ts @@ -0,0 +1,141 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerScriptTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "list_scripts", + "List all GDScript/C#/shader files in the project with class info", + { + path: z.string().optional().describe("Root path to search (default: res://)"), + recursive: z.boolean().optional().describe("Search recursively (default: true)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("list_scripts", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "read_script", + "Read the full content of a GDScript file", + { + path: z.string().describe("Path to the script (e.g. 'res://scripts/player.gd')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("read_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "create_script", + "Create a new GDScript file with optional content or auto-generated template. Restricted to .gd/.cs paths. Refuses to overwrite a script that is currently open in Godot's script editor unless force=true is set.", + { + path: z.string().describe("Path for the new script (e.g. 'res://scripts/enemy_ai.gd'). Must be a .gd or .cs file."), + content: z.string().optional().describe("Full script content. If empty, generates a template."), + extends: z.string().optional().describe("Base class (default: 'Node'). Only used for template generation."), + class_name: z.string().optional().describe("Class name to add. Only used for template generation."), + force: z.boolean().optional().describe("Override the open-script-editor guard and write anyway. Use only when no editor buffer holds unsaved changes for the target path."), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "edit_script", + "Edit a script using search-and-replace, full content replacement, line insertion, or 1-based inclusive line-range replacement. Restricted to .gd/.cs paths. Refuses to write a script that is currently open in Godot's script editor unless force=true is set.", + { + path: z.string().describe("Path to the script to edit. Must be a .gd or .cs file."), + replacements: z + .array( + z.object({ + search: z.string().describe("Text to find"), + replace: z.string().describe("Replacement text"), + regex: z.boolean().optional().describe("Use regex for search (default: false)"), + }) + ) + .optional() + .describe("Array of search-and-replace operations"), + content: z.string().optional().describe("Full replacement content (replaces entire file), or replacement lines when combined with start_line/end_line"), + insert_at_line: z.number().optional().describe("Line number to insert text at (0-indexed)"), + text: z.string().optional().describe("Text to insert (used with insert_at_line)"), + start_line: z.number().optional().describe("1-based inclusive starting line for range replacement (used with content)"), + end_line: z.number().optional().describe("1-based inclusive ending line for range replacement (defaults to start_line)"), + force: z.boolean().optional().describe("Override the open-script-editor guard and write anyway."), + }, + async (params) => { + try { + const result = await godot.sendCommand("edit_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "attach_script", + "Attach a GDScript to a node in the current scene", + { + node_path: z.string().describe("Path to the target node"), + script_path: z.string().describe("Path to the script file (e.g. 'res://scripts/player.gd')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("attach_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "validate_script", + "Validate a GDScript file by attempting to compile it. Returns whether the script is valid. Use get_output_log or get_editor_errors for detailed error messages on failure.", + { + path: z.string().describe("Path to the script to validate (e.g. 'res://scripts/player.gd')"), + }, + async (params) => { + try { + const result = await godot.sendCommand("validate_script", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_open_scripts", + "Get a list of scripts currently open in the Godot script editor", + {}, + async () => { + try { + const result = await godot.sendCommand("get_open_scripts"); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/shader-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/shader-tools.ts new file mode 100644 index 0000000..3abb011 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/shader-tools.ts @@ -0,0 +1,117 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerShaderTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "create_shader", + "Create a new shader file with a template or custom content. Refuses to overwrite a shader that is currently loaded/open in the editor unless force=true.", + { + path: z.string().describe("Path for the shader file (e.g. 'res://shaders/dissolve.gdshader')"), + shader_type: z.string().optional().describe("Shader type: spatial, canvas_item, particles, sky (default: spatial)"), + content: z.string().optional().describe("Full shader code. If empty, generates a template."), + force: z.boolean().optional().describe("Override the open/cached-shader guard and write anyway."), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_shader", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "read_shader", + "Read the content of a shader file", + { + path: z.string().describe("Path to the shader file"), + }, + async (params) => { + try { + const result = await godot.sendCommand("read_shader", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "edit_shader", + "Edit a shader file using full replacement or search-and-replace. Refreshes any cached/loaded copy of the shader via take_over_path + emit_changed so live materials pick up changes. Refuses to write if the shader is currently loaded/open in the editor unless force=true.", + { + path: z.string().describe("Path to the shader file"), + content: z.string().optional().describe("Full replacement content"), + replacements: z.array(z.object({ + search: z.string().describe("Text to find"), + replace: z.string().describe("Text to replace with"), + })).optional().describe("Array of search-and-replace operations"), + force: z.boolean().optional().describe("Override the open/cached-shader guard and write anyway."), + }, + async (params) => { + try { + const result = await godot.sendCommand("edit_shader", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "assign_shader_material", + "Create a ShaderMaterial from a shader file and assign it to a node", + { + node_path: z.string().describe("Path to the target node (CanvasItem or MeshInstance3D)"), + shader_path: z.string().describe("Path to the shader file"), + }, + async (params) => { + try { + const result = await godot.sendCommand("assign_shader_material", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_shader_param", + "Set a shader parameter on a node's ShaderMaterial", + { + node_path: z.string().describe("Path to the node with a ShaderMaterial"), + param: z.string().describe("Shader parameter name"), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Parameter value. Strings auto-parsed for Vector2, Color, etc."), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_shader_param", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_shader_params", + "Get all shader parameters and their current values from a node's ShaderMaterial", + { + node_path: z.string().describe("Path to the node with a ShaderMaterial"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_shader_params", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/test-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/test-tools.ts new file mode 100644 index 0000000..7a973fa --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/test-tools.ts @@ -0,0 +1,195 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerTestTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "run_test_scenario", + "Execute a test scenario in the running game. Optionally plays a scene, then runs a sequence of steps (input simulation, waits, assertions, screenshots). Returns pass/fail summary. Note: keep scenarios short (under 20 seconds total) to avoid timeout.", + { + scene_path: z + .string() + .optional() + .describe( + "Scene to play before running steps. Use 'main' for main scene, 'current' for current scene, or a res:// path. If omitted, uses already-running scene." + ), + steps: z + .array( + z.record(z.string(), z.unknown()) + ) + .describe( + "Array of test steps. Each step has a 'type' field: " + + "'input' (action/keycode simulation), " + + "'wait' (seconds or node_path to wait for), " + + "'assert' (node_path+property+expected+operator, or text for screen text), " + + "'screenshot' (capture a frame). " + + "Examples: " + + "{type:'input', action:'ui_accept'}, " + + "{type:'wait', seconds:0.5}, " + + "{type:'wait', node_path:'/root/Main/Player'}, " + + "{type:'assert', node_path:'/root/Main/Player', property:'health', expected:100, operator:'eq'}, " + + "{type:'assert', text:'Game Over'}, " + + "{type:'screenshot'}" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("run_test_scenario", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "assert_node_state", + "Assert a node's property value in the running game. Compares the actual property value against an expected value using the specified operator. Returns pass/fail with actual value for debugging.", + { + node_path: z + .string() + .describe( + "Absolute node path in the running game (e.g. '/root/Main/Player')" + ), + property: z + .string() + .describe( + "Property name to check (e.g. 'health', 'position:x', 'visible')" + ), + expected: z + .union([z.string(), z.number(), z.boolean()]) + .describe("Expected value to compare against"), + operator: z + .enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"]) + .optional() + .describe( + "Comparison operator (default: 'eq'). " + + "eq/neq: equality, gt/lt/gte/lte: numeric comparison, " + + "contains: string/array contains, type_is: check Godot type name" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("assert_node_state", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "assert_screen_text", + "Assert that specific text is visible on screen in the running game. Searches all visible UI elements (Button, Label, LineEdit, etc.) for matching text. Useful for verifying UI state, dialog content, or game messages.", + { + text: z + .string() + .describe("Text to search for on screen (e.g. 'Game Over', 'Score:', 'New Game')"), + partial: z + .boolean() + .optional() + .describe( + "Allow partial text matching (default: true). If false, requires exact match." + ), + case_sensitive: z + .boolean() + .optional() + .describe("Case-sensitive comparison (default: true)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("assert_screen_text", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "run_stress_test", + "Run rapid random input events for a specified duration and check if the game crashes. Sends random UI actions (up/down/left/right/accept/cancel) plus any custom actions. Returns whether the game survived, event count, and new errors.", + { + duration: z + .number() + .optional() + .describe( + "Duration in seconds to run the stress test (1-60, default: 5)" + ), + actions: z + .array(z.string()) + .optional() + .describe( + "Additional input action names to include in random input pool (e.g. ['jump', 'attack', 'dash'])" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("run_stress_test", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); + + server.tool( + "get_test_report", + "Collect and format results from all assertions run so far (via assert_node_state, assert_screen_text, and run_test_scenario) into a summary test report. Shows pass/fail counts, pass rate, and detailed results.", + { + clear: z + .boolean() + .optional() + .describe( + "Clear accumulated results after generating report (default: true)" + ), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_test_report", params); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (e) { + return { + content: [{ type: "text", text: formatErrorForMcp(e) }], + isError: true, + }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/theme-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/theme-tools.ts new file mode 100644 index 0000000..2dcc4e2 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/theme-tools.ts @@ -0,0 +1,148 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerThemeTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "create_theme", + "Create a new Theme resource file", + { + path: z.string().describe("Path to save the theme (e.g. 'res://themes/main.tres')"), + default_font_size: z.number().optional().describe("Default font size"), + }, + async (params) => { + try { + const result = await godot.sendCommand("create_theme", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_theme_color", + "Set a theme color override on a Control node", + { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Color name (e.g. 'font_color', 'font_hover_color')"), + color: z.string().describe("Color as hex string (e.g. '#ff0000') or name"), + theme_type: z.string().optional().describe("Theme type (defaults to node's class)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_theme_color", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_theme_constant", + "Set a theme constant override on a Control node", + { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Constant name (e.g. 'margin_left', 'separation')"), + value: z.number().describe("Integer value"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_theme_constant", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_theme_font_size", + "Set a theme font size override on a Control node", + { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Font size name (e.g. 'font_size')"), + size: z.number().describe("Font size in pixels"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_theme_font_size", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "set_theme_stylebox", + "Set a StyleBoxFlat override on a Control node with customizable appearance", + { + node_path: z.string().describe("Path to the Control node"), + name: z.string().describe("Style name (e.g. 'panel', 'normal', 'hover', 'pressed')"), + bg_color: z.string().optional().describe("Background color (hex)"), + border_color: z.string().optional().describe("Border color (hex)"), + border_width: z.number().optional().describe("Border width in pixels"), + corner_radius: z.number().optional().describe("Corner radius in pixels"), + padding: z.number().optional().describe("Content padding in pixels"), + }, + async (params) => { + try { + const result = await godot.sendCommand("set_theme_stylebox", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "setup_control", + "Configure a Control/Container node's layout properties in one call. Sets anchor preset, margins, min size, size flags, and container-specific properties like separation — much faster than multiple update_property calls.", + { + node_path: z.string().describe("Path to the Control node"), + anchor_preset: z.string().optional().describe("Anchor preset: 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center_left', 'center_top', 'center_right', 'center_bottom', 'center', 'left_wide', 'top_wide', 'right_wide', 'bottom_wide', 'vcenter_wide', 'hcenter_wide', 'full_rect'"), + min_size: z.string().optional().describe("Minimum size as 'Vector2(w, h)'"), + size_flags_h: z.string().optional().describe("Horizontal size flags: 'fill', 'expand', 'fill_expand', 'shrink_center', 'shrink_end'"), + size_flags_v: z.string().optional().describe("Vertical size flags: 'fill', 'expand', 'fill_expand', 'shrink_center', 'shrink_end'"), + margins: z.object({ + left: z.number().optional(), + top: z.number().optional(), + right: z.number().optional(), + bottom: z.number().optional(), + }).optional().describe("Margin overrides for MarginContainer (sets theme constants margin_left/right/top/bottom)"), + separation: z.number().optional().describe("Separation for VBoxContainer/HBoxContainer (theme constant override)"), + grow_h: z.string().optional().describe("Horizontal grow direction: 'begin', 'end', 'both'"), + grow_v: z.string().optional().describe("Vertical grow direction: 'begin', 'end', 'both'"), + }, + async (params) => { + try { + const result = await godot.sendCommand("setup_control", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "get_theme_info", + "Get theme information and overrides for a Control node", + { + node_path: z.string().describe("Path to the Control node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("get_theme_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/tools/tilemap-tools.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/tilemap-tools.ts new file mode 100644 index 0000000..af64f40 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/tools/tilemap-tools.ts @@ -0,0 +1,127 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { GodotConnection } from "../godot-connection.js"; +import { formatErrorForMcp } from "../utils/errors.js"; + +export function registerTilemapTools( + server: McpServer, + godot: GodotConnection +): void { + server.tool( + "tilemap_set_cell", + "Set a single cell in a TileMapLayer (or a deprecated multi-layer TileMap node)", + { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + x: z.number().describe("Cell X coordinate"), + y: z.number().describe("Cell Y coordinate"), + source_id: z.number().optional().describe("Tile source ID (default: 0)"), + atlas_x: z.number().optional().describe("Atlas X coordinate (default: 0)"), + atlas_y: z.number().optional().describe("Atlas Y coordinate (default: 0)"), + alternative: z.number().optional().describe("Alternative tile ID (default: 0)"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("tilemap_set_cell", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "tilemap_fill_rect", + "Fill a rectangular region of a TileMapLayer (or a deprecated multi-layer TileMap node) with tiles", + { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + x1: z.number().describe("Start X coordinate"), + y1: z.number().describe("Start Y coordinate"), + x2: z.number().describe("End X coordinate"), + y2: z.number().describe("End Y coordinate"), + source_id: z.number().optional().describe("Tile source ID (default: 0)"), + atlas_x: z.number().optional().describe("Atlas X coordinate (default: 0)"), + atlas_y: z.number().optional().describe("Atlas Y coordinate (default: 0)"), + alternative: z.number().optional().describe("Alternative tile ID (default: 0)"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("tilemap_fill_rect", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "tilemap_get_cell", + "Get tile data at a specific cell in a TileMapLayer (or a deprecated multi-layer TileMap node)", + { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + x: z.number().describe("Cell X coordinate"), + y: z.number().describe("Cell Y coordinate"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("tilemap_get_cell", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "tilemap_clear", + "Clear cells in a TileMapLayer (or a deprecated multi-layer TileMap node). For a legacy TileMap, omit layer to clear all layers or pass a layer to clear just that one", + { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes; omit to clear all layers (TileMapLayer only accepts 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("tilemap_clear", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "tilemap_get_info", + "Get TileMapLayer (or deprecated multi-layer TileMap) info including tile set sources, per-layer breakdown, and cell count", + { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + }, + async (params) => { + try { + const result = await godot.sendCommand("tilemap_get_info", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); + + server.tool( + "tilemap_get_used_cells", + "Get a list of used (non-empty) cells in a TileMapLayer (or a deprecated multi-layer TileMap node)", + { + node_path: z.string().describe("Path to the TileMapLayer or deprecated TileMap node"), + max_count: z.number().optional().describe("Maximum cells to return (default: 500)"), + layer: z.number().optional().describe("Layer index for deprecated multi-layer TileMap nodes (default: 0; TileMapLayer has one implicit layer and only accepts 0)"), + }, + async (params) => { + try { + const result = await godot.sendCommand("tilemap_get_used_cells", params); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; + } + } + ); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/utils/errors.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/errors.ts new file mode 100644 index 0000000..336674a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/errors.ts @@ -0,0 +1,54 @@ +export class GodotConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = "GodotConnectionError"; + } +} + +export class GodotCommandError extends Error { + public code: number; + public data?: Record; + + constructor(code: number, message: string, data?: Record) { + super(message); + this.name = "GodotCommandError"; + this.code = code; + this.data = data; + } +} + +export class TimeoutError extends Error { + constructor(method: string, timeoutMs: number) { + super(`Command '${method}' timed out after ${timeoutMs}ms`); + this.name = "TimeoutError"; + } +} + +export function formatErrorForMcp(error: unknown): string { + if (error instanceof GodotCommandError) { + let msg = `Godot error (${error.code}): ${error.message}`; + // Surface runtime errors captured at timeout so the agent sees the actual + // cause (a script error that paused the scene) instead of assuming the + // game/connection is dead. + const runtimeErrors = error.data?.runtime_errors; + if (Array.isArray(runtimeErrors) && runtimeErrors.length > 0) { + msg += `\nRuntime errors:\n${runtimeErrors + .map((e) => ` - ${String(e)}`) + .join("\n")}`; + } + if (error.data?.suggestion) { + msg += `\nSuggestion: ${error.data.suggestion}`; + } + return msg; + } + if (error instanceof GodotConnectionError) { + return `Connection error: ${error.message}. Make sure the Godot MCP Pro plugin is enabled in your Godot editor.`; + } + if (error instanceof TimeoutError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/utils/load-instructions.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/load-instructions.ts new file mode 100644 index 0000000..fc57e1b --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/load-instructions.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const FALLBACK_INSTRUCTIONS = `# Godot MCP Pro + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. + +## Critical rules +- Tools are split into **editor** (always available) and **runtime** (require \`play_scene\` first). +- Never edit \`project.godot\` directly — use \`set_project_setting\`. +- For input simulation, use short \`simulate_key\` durations (0.3–0.5s), not integer seconds. +- \`execute_editor_script\` / \`execute_game_script\` must be valid GDScript; use \`_mcp_print(value)\` to return output. + +## Getting oriented +- \`get_project_info\` — project overview +- \`get_scene_tree\` — current scene structure +- For a full usage guide, see \`instructions/CLAUDE.md\` in the installed package. +`; + +/** + * Loads CLAUDE.md from the shipped `instructions/` directory so Claude has + * the essential usage guide from message #1 of every session. Falls back to + * a terse built-in string if the file can't be located (e.g. custom layouts). + */ +export function loadInstructions(): string { + const here = dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + resolve(here, "../../instructions/CLAUDE.md"), + resolve(here, "../../../instructions/CLAUDE.md"), + resolve(here, "../../../../instructions/CLAUDE.md"), + ]; + + for (const path of candidates) { + try { + const content = readFileSync(path, "utf8"); + if (content.trim().length > 0) return content; + } catch { + // try next + } + } + + return FALLBACK_INSTRUCTIONS; +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/utils/tool-filter.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/tool-filter.ts new file mode 100644 index 0000000..e581681 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/tool-filter.ts @@ -0,0 +1,73 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Minimal mode: only these tools are registered (~35 tools). + * Designed for clients with tight tool limits (Cursor: 40, local LLMs with small context). + */ +export const MINIMAL_TOOLS = new Set([ + // project (5) + "get_project_info", + "get_filesystem_tree", + "search_files", + "search_in_files", + "set_project_setting", + // scene (6) + "get_scene_tree", + "create_scene", + "open_scene", + "play_scene", + "stop_scene", + "save_scene", + // node (5) + "add_node", + "delete_node", + "update_property", + "get_node_properties", + "connect_signal", + // script (5) + "read_script", + "create_script", + "edit_script", + "attach_script", + "validate_script", + // editor (5) + "get_editor_errors", + "get_output_log", + "get_game_screenshot", + "execute_editor_script", + "reload_project", + // input (3) + "simulate_key", + "simulate_mouse_click", + "simulate_action", + // runtime (5) + "get_game_scene_tree", + "get_game_node_properties", + "set_game_node_property", + "execute_game_script", + "find_ui_elements", + // input-map (1) + "get_input_actions", +]); + +/** + * Creates a proxy around McpServer that filters tool registrations. + * Only tools in the allowSet will be registered; others are silently skipped. + */ +export function createFilteredServer( + server: McpServer, + allowSet: Set +): McpServer { + return new Proxy(server, { + get(target, prop, receiver) { + if (prop === "tool") { + const originalTool = target.tool.bind(target); + return function filteredTool(name: string, ...args: unknown[]) { + if (!allowSet.has(name)) return; + return (originalTool as Function)(name, ...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/utils/types.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/types.ts new file mode 100644 index 0000000..94b6c8a --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/types.ts @@ -0,0 +1,35 @@ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params: Record; + id: string; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + result?: unknown; + error?: JsonRpcError; + id: string | null; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: Record; +} + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; +} + +export interface PendingRequest { + resolve: (value: JsonRpcResponse) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/src/utils/zod-coerce.ts b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/zod-coerce.ts new file mode 100644 index 0000000..bac9fcd --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/src/utils/zod-coerce.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +/** + * Coerce a value that might be a JSON string into a string array. + * LLMs sometimes pass arrays as stringified JSON (e.g. '["a","b"]' instead of ["a","b"]). + */ +export function coerceStringArray() { + return z.preprocess((val) => { + if (typeof val === "string") { + try { + const parsed = JSON.parse(val); + if (Array.isArray(parsed)) return parsed; + } catch { + // not JSON, return as-is for zod to validate + } + } + return val; + }, z.array(z.string())); +} + +/** + * Coerce a value that might be a numeric string into a number. + * LLMs sometimes pass numbers as strings (e.g. "30" instead of 30). + */ +export function coerceNumber() { + return z.preprocess((val) => { + if (typeof val === "string") { + const n = Number(val); + if (!isNaN(n)) return n; + } + return val; + }, z.number()); +} diff --git a/Tools/godot-mcp-pro-v1.15.1/server/tsconfig.json b/Tools/godot-mcp-pro-v1.15.1/server/tsconfig.json new file mode 100644 index 0000000..fb85455 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/Tools/godot-mcp-pro-v1.15.1/settings.local.json b/Tools/godot-mcp-pro-v1.15.1/settings.local.json new file mode 100644 index 0000000..61408f9 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/settings.local.json @@ -0,0 +1,176 @@ +{ + "permissions": { + "allow": [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash(node *)", + "Bash(npm *)", + "Bash(npx *)", + "Bash(ls *)", + "Bash(cd *)", + "Bash(mkdir *)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + + "mcp__godot-mcp-pro__get_project_info", + "mcp__godot-mcp-pro__get_filesystem_tree", + "mcp__godot-mcp-pro__search_files", + "mcp__godot-mcp-pro__search_in_files", + "mcp__godot-mcp-pro__get_project_settings", + "mcp__godot-mcp-pro__uid_to_project_path", + "mcp__godot-mcp-pro__project_path_to_uid", + "mcp__godot-mcp-pro__get_scene_tree", + "mcp__godot-mcp-pro__get_scene_file_content", + "mcp__godot-mcp-pro__get_scene_exports", + "mcp__godot-mcp-pro__get_node_properties", + "mcp__godot-mcp-pro__get_node_groups", + "mcp__godot-mcp-pro__get_signals", + "mcp__godot-mcp-pro__find_nodes_in_group", + "mcp__godot-mcp-pro__find_nodes_by_type", + "mcp__godot-mcp-pro__find_signal_connections", + "mcp__godot-mcp-pro__find_node_references", + "mcp__godot-mcp-pro__get_scene_dependencies", + "mcp__godot-mcp-pro__list_scripts", + "mcp__godot-mcp-pro__read_script", + "mcp__godot-mcp-pro__get_open_scripts", + "mcp__godot-mcp-pro__validate_script", + "mcp__godot-mcp-pro__get_editor_errors", + "mcp__godot-mcp-pro__get_output_log", + "mcp__godot-mcp-pro__get_editor_screenshot", + "mcp__godot-mcp-pro__get_game_scene_tree", + "mcp__godot-mcp-pro__get_game_node_properties", + "mcp__godot-mcp-pro__get_game_screenshot", + "mcp__godot-mcp-pro__find_nodes_by_script", + "mcp__godot-mcp-pro__get_autoload", + "mcp__godot-mcp-pro__find_ui_elements", + "mcp__godot-mcp-pro__batch_get_properties", + "mcp__godot-mcp-pro__get_input_actions", + "mcp__godot-mcp-pro__list_animations", + "mcp__godot-mcp-pro__get_animation_info", + "mcp__godot-mcp-pro__get_animation_tree_structure", + "mcp__godot-mcp-pro__get_audio_bus_layout", + "mcp__godot-mcp-pro__get_audio_info", + "mcp__godot-mcp-pro__get_physics_layers", + "mcp__godot-mcp-pro__get_collision_info", + "mcp__godot-mcp-pro__get_performance_monitors", + "mcp__godot-mcp-pro__get_editor_performance", + "mcp__godot-mcp-pro__read_resource", + "mcp__godot-mcp-pro__get_resource_preview", + "mcp__godot-mcp-pro__read_shader", + "mcp__godot-mcp-pro__get_shader_params", + "mcp__godot-mcp-pro__get_navigation_info", + "mcp__godot-mcp-pro__get_particle_info", + "mcp__godot-mcp-pro__get_theme_info", + "mcp__godot-mcp-pro__tilemap_get_cell", + "mcp__godot-mcp-pro__tilemap_get_info", + "mcp__godot-mcp-pro__tilemap_get_used_cells", + "mcp__godot-mcp-pro__find_unused_resources", + "mcp__godot-mcp-pro__analyze_signal_flow", + "mcp__godot-mcp-pro__analyze_scene_complexity", + "mcp__godot-mcp-pro__find_script_references", + "mcp__godot-mcp-pro__detect_circular_dependencies", + "mcp__godot-mcp-pro__get_project_statistics", + "mcp__godot-mcp-pro__get_export_info", + "mcp__godot-mcp-pro__list_export_presets", + "mcp__godot-mcp-pro__compare_screenshots", + "mcp__godot-mcp-pro__navigate_to", + "mcp__godot-mcp-pro__find_nearby_nodes", + "mcp__godot-mcp-pro__get_test_report", + "mcp__godot-mcp-pro__wait_for_node", + + "mcp__godot-mcp-pro__set_project_setting", + "mcp__godot-mcp-pro__create_scene", + "mcp__godot-mcp-pro__open_scene", + "mcp__godot-mcp-pro__save_scene", + "mcp__godot-mcp-pro__add_scene_instance", + "mcp__godot-mcp-pro__add_node", + "mcp__godot-mcp-pro__duplicate_node", + "mcp__godot-mcp-pro__move_node", + "mcp__godot-mcp-pro__rename_node", + "mcp__godot-mcp-pro__update_property", + "mcp__godot-mcp-pro__add_resource", + "mcp__godot-mcp-pro__set_anchor_preset", + "mcp__godot-mcp-pro__connect_signal", + "mcp__godot-mcp-pro__disconnect_signal", + "mcp__godot-mcp-pro__set_node_groups", + "mcp__godot-mcp-pro__create_script", + "mcp__godot-mcp-pro__edit_script", + "mcp__godot-mcp-pro__attach_script", + "mcp__godot-mcp-pro__create_animation", + "mcp__godot-mcp-pro__add_animation_track", + "mcp__godot-mcp-pro__set_animation_keyframe", + "mcp__godot-mcp-pro__create_animation_tree", + "mcp__godot-mcp-pro__add_state_machine_state", + "mcp__godot-mcp-pro__add_state_machine_transition", + "mcp__godot-mcp-pro__set_blend_tree_node", + "mcp__godot-mcp-pro__set_tree_parameter", + "mcp__godot-mcp-pro__add_audio_bus", + "mcp__godot-mcp-pro__set_audio_bus", + "mcp__godot-mcp-pro__add_audio_bus_effect", + "mcp__godot-mcp-pro__add_audio_player", + "mcp__godot-mcp-pro__set_input_action", + "mcp__godot-mcp-pro__add_mesh_instance", + "mcp__godot-mcp-pro__setup_lighting", + "mcp__godot-mcp-pro__set_material_3d", + "mcp__godot-mcp-pro__setup_environment", + "mcp__godot-mcp-pro__setup_camera_3d", + "mcp__godot-mcp-pro__add_gridmap", + "mcp__godot-mcp-pro__setup_collision", + "mcp__godot-mcp-pro__set_physics_layers", + "mcp__godot-mcp-pro__add_raycast", + "mcp__godot-mcp-pro__setup_physics_body", + "mcp__godot-mcp-pro__setup_navigation_region", + "mcp__godot-mcp-pro__bake_navigation_mesh", + "mcp__godot-mcp-pro__setup_navigation_agent", + "mcp__godot-mcp-pro__set_navigation_layers", + "mcp__godot-mcp-pro__create_particles", + "mcp__godot-mcp-pro__set_particle_material", + "mcp__godot-mcp-pro__set_particle_color_gradient", + "mcp__godot-mcp-pro__apply_particle_preset", + "mcp__godot-mcp-pro__create_resource", + "mcp__godot-mcp-pro__edit_resource", + "mcp__godot-mcp-pro__create_shader", + "mcp__godot-mcp-pro__edit_shader", + "mcp__godot-mcp-pro__assign_shader_material", + "mcp__godot-mcp-pro__set_shader_param", + "mcp__godot-mcp-pro__create_theme", + "mcp__godot-mcp-pro__set_theme_color", + "mcp__godot-mcp-pro__set_theme_constant", + "mcp__godot-mcp-pro__set_theme_font_size", + "mcp__godot-mcp-pro__set_theme_stylebox", + "mcp__godot-mcp-pro__tilemap_set_cell", + "mcp__godot-mcp-pro__tilemap_fill_rect", + "mcp__godot-mcp-pro__batch_set_property", + "mcp__godot-mcp-pro__cross_scene_set_property", + "mcp__godot-mcp-pro__add_autoload", + "mcp__godot-mcp-pro__reload_plugin", + "mcp__godot-mcp-pro__reload_project", + "mcp__godot-mcp-pro__clear_output", + + "mcp__godot-mcp-pro__play_scene", + "mcp__godot-mcp-pro__stop_scene", + "mcp__godot-mcp-pro__simulate_key", + "mcp__godot-mcp-pro__simulate_mouse_click", + "mcp__godot-mcp-pro__simulate_mouse_move", + "mcp__godot-mcp-pro__simulate_action", + "mcp__godot-mcp-pro__simulate_sequence", + "mcp__godot-mcp-pro__set_game_node_property", + "mcp__godot-mcp-pro__capture_frames", + "mcp__godot-mcp-pro__record_frames", + "mcp__godot-mcp-pro__monitor_properties", + "mcp__godot-mcp-pro__start_recording", + "mcp__godot-mcp-pro__stop_recording", + "mcp__godot-mcp-pro__replay_recording", + "mcp__godot-mcp-pro__click_button_by_text", + "mcp__godot-mcp-pro__move_to", + "mcp__godot-mcp-pro__run_test_scenario", + "mcp__godot-mcp-pro__assert_node_state", + "mcp__godot-mcp-pro__assert_screen_text", + "mcp__godot-mcp-pro__run_stress_test" + ] + } +} diff --git a/Tools/godot-mcp-pro-v1.15.1/settings.local.permissive.json b/Tools/godot-mcp-pro-v1.15.1/settings.local.permissive.json new file mode 100644 index 0000000..83bae57 --- /dev/null +++ b/Tools/godot-mcp-pro-v1.15.1/settings.local.permissive.json @@ -0,0 +1,38 @@ +{ + "permissions": { + "allow": [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash(*)", + "mcp__godot-mcp-pro__*" + ], + "deny": [ + "Bash(rm -rf *)", + "Bash(rm -r /*)", + "Bash(git push --force*)", + "Bash(git push -f*)", + "Bash(git reset --hard*)", + "Bash(git clean -f*)", + "Bash(git checkout -- .)", + "Bash(git branch -D *)", + "Bash(shutdown*)", + "Bash(reboot*)", + "Bash(format *)", + "Bash(del /s *)", + "Bash(rmdir /s *)", + "mcp__godot-mcp-pro__delete_node", + "mcp__godot-mcp-pro__delete_scene", + "mcp__godot-mcp-pro__remove_animation", + "mcp__godot-mcp-pro__remove_autoload", + "mcp__godot-mcp-pro__remove_state_machine_state", + "mcp__godot-mcp-pro__remove_state_machine_transition", + "mcp__godot-mcp-pro__execute_editor_script", + "mcp__godot-mcp-pro__execute_game_script", + "mcp__godot-mcp-pro__export_project", + "mcp__godot-mcp-pro__tilemap_clear" + ] + } +} diff --git a/addons/game_designer/balance_tab.gd b/addons/game_designer/balance_tab.gd new file mode 100644 index 0000000..138d47b --- /dev/null +++ b/addons/game_designer/balance_tab.gd @@ -0,0 +1,67 @@ +## 平衡编辑器标签 — 难度乘子(3 档)+ Boss 基础血量 +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/balance.json" +const MULT_KEYS = ["enemy_hp", "player_dmg", "wave_count", "boss_hp"] +const MULT_LABELS = ["敌人血量×", "玩家受伤×", "波次怪数×", "Boss血量×"] +const DIFF_NAMES = ["初学者", "标准", "挑战"] + +var _data: Dictionary = {} +var _mult_spins: Dictionary = {} # key → [SpinBox ×3] +var _miniboss: SpinBox +var _boss: SpinBox +var _status: Label + +func _ready() -> void: + add_child(UI.header("⚖ 平衡编辑器 — 难度乘子 & Boss 血量")) + _load() + # 难度乘子表格:4 行 × 3 难度 + var grid := GridContainer.new(); grid.columns = 4; add_child(grid) + grid.add_child(Label.new()) # 角 + for dn in DIFF_NAMES: + var l := Label.new(); l.text = dn; l.modulate = Color(0.7, 0.8, 1.0) + grid.add_child(l) + var mults: Dictionary = _data.get("difficulty_mults", {}) + for i in MULT_KEYS.size(): + var key: String = MULT_KEYS[i] + grid.add_child(UI.cell_label(MULT_LABELS[i])) + var arr: Array = mults.get(key, [1.0, 1.0, 1.0]) + var spins: Array = [] + for d in 3: + var sp := UI.spin(0.0, 10.0, 0.05, float(arr[d]) if d < arr.size() else 1.0) + grid.add_child(sp); spins.append(sp) + _mult_spins[key] = spins + add_child(HSeparator.new()) + # Boss 基础血量 + var bhp: Dictionary = _data.get("boss_hp", {}) + var h1 := HBoxContainer.new(); add_child(h1) + h1.add_child(UI.cell_label("MiniBoss(W8) 血量")) + _miniboss = UI.spin(1, 999999, 10, float(bhp.get("4", 450))) + h1.add_child(_miniboss) + var h2 := HBoxContainer.new(); add_child(h2) + h2.add_child(UI.cell_label("Final Boss(W20) 血量")) + _boss = UI.spin(1, 999999, 10, float(bhp.get("5", 1800))) + h2.add_child(_boss) + add_child(HSeparator.new()) + add_child(UI.btn("💾 保存 balance.json", _save)) + _status = UI.status_label(); add_child(_status) + +func _load() -> void: + var d = UI.load_json(PATH) + _data = d if d is Dictionary else {} + +func _save() -> void: + var mults: Dictionary = {} + for key in MULT_KEYS: + var spins: Array = _mult_spins[key] + mults[key] = [spins[0].value, spins[1].value, spins[2].value] + var out := { + "difficulty_mults": mults, + "boss_hp": {"4": _miniboss.value, "5": _boss.value}, + } + if UI.save_json(PATH, out): + UI.set_status(_status, "💾 已保存(重启 F5 生效)") + else: + UI.set_status(_status, "✗ 保存失败", true) diff --git a/addons/game_designer/balance_tab.gd.uid b/addons/game_designer/balance_tab.gd.uid new file mode 100644 index 0000000..a1083b1 --- /dev/null +++ b/addons/game_designer/balance_tab.gd.uid @@ -0,0 +1 @@ +uid://13nt7jtotgom diff --git a/addons/game_designer/core_tab.gd b/addons/game_designer/core_tab.gd new file mode 100644 index 0000000..5ce4f85 --- /dev/null +++ b/addons/game_designer/core_tab.gd @@ -0,0 +1,125 @@ +## Core(法杖)编辑器标签 — 槽位/拓扑/CPU/施法间隔/特性/矩阵·电路结构 +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/cores.json" +const TOPO = ["LINEAR 线性", "MATRIX 矩阵", "CIRCUIT 电路"] +const FEAT = ["无", "PERSISTENT_MEMORY 记忆", "DUAL_STREAM 双流", "ALWAYS_CAST_LAST", "SHUFFLE_DECK", "INFINITE_SPELLS"] + +var _data: Dictionary = {} +var _cur: String = "" +var _list: ItemList +var _id: LineEdit +var _name: LineEdit +var _slots: SpinBox +var _topo: OptionButton +var _cpu: SpinBox +var _interval: SpinBox +var _feat: OptionButton +var _rows: SpinBox +var _cols: SpinBox +var _edges: TextEdit +var _status: Label + +func _ready() -> void: + add_child(UI.header("🪄 Core 编辑器 — 法杖拓扑与特性")) + _load() + _list = ItemList.new() + _list.custom_minimum_size = Vector2(0, 90) + _list.item_selected.connect(_on_select) + add_child(_list) + var brow := HBoxContainer.new(); add_child(brow) + brow.add_child(UI.btn("➕新增", _on_new)) + brow.add_child(UI.btn("🗑删除", _on_delete)) + add_child(HSeparator.new()) + _id = _row("ID", UI.line("")) + _name = _row("显示名", UI.line("", "tr 键或文本")) + _slots = _row("槽位数", UI.spin(1, 20, 1, 5)) + _topo = _row("拓扑", UI.opt(TOPO, 0)) + _cpu = _row("CPU 上限", UI.spin(1, 40, 1, 5)) + _interval = _row("施法间隔(s)", UI.spin(0.05, 5, 0.05, 0.5)) + _feat = _row("特性", UI.opt(FEAT, 0)) + _rows = _row("矩阵行(MATRIX)", UI.spin(1, 8, 1, 1)) + _cols = _row("矩阵列(MATRIX)", UI.spin(1, 16, 1, 5)) + add_child(Label.new()) + var el := Label.new(); el.text = "电路边(CIRCUIT,JSON)"; add_child(el) + _edges = TextEdit.new(); _edges.custom_minimum_size = Vector2(0, 50) + _edges.placeholder_text = '[{"from":0,"to":1},{"from":0,"to":2}]' + add_child(_edges) + add_child(HSeparator.new()) + var srow := HBoxContainer.new(); add_child(srow) + srow.add_child(UI.btn("✓应用", _on_apply)) + srow.add_child(UI.btn("💾保存 cores.json", _save)) + _status = UI.status_label(); add_child(_status) + _refresh() + +func _row(label: String, ctrl: Control) -> Control: + var h := HBoxContainer.new() + h.add_child(UI.cell_label(label)) + h.add_child(ctrl) + add_child(h) + return ctrl + +func _load() -> void: + var d = UI.load_json(PATH) + _data = d if d is Dictionary else {} + +func _refresh() -> void: + _list.clear() + var ids: Array = _data.keys(); ids.sort() + for cid in ids: + _list.add_item("%s(%s)" % [cid, _data[cid].get("display_name", "")]) + _list.set_item_metadata(_list.item_count - 1, cid) + +func _on_select(idx: int) -> void: + _cur = String(_list.get_item_metadata(idx)) + var d: Dictionary = _data.get(_cur, {}) + _id.text = _cur + _name.text = String(d.get("display_name", "")) + _slots.value = float(d.get("slot_count", 5)) + _topo.selected = int(d.get("topology", 0)) + _cpu.value = float(d.get("cpu_limit", 5)) + _interval.value = float(d.get("cast_interval", 0.5)) + _feat.selected = int(d.get("feature_tags", 0)) + _rows.value = float(d.get("grid_rows", 1)) + _cols.value = float(d.get("grid_cols", 5)) + _edges.text = JSON.stringify(d.get("edges", [])) + +func _on_apply() -> void: + var cid: String = _id.text.strip_edges() + if cid == "": + UI.set_status(_status, "✗ ID 不能为空", true); return + var edges = JSON.parse_string(_edges.text) if _edges.text.strip_edges() != "" else [] + if not (edges is Array): + edges = [] + if _cur != "" and _cur != cid and _data.has(_cur): + _data.erase(_cur) + _data[cid] = { + "display_name": _name.text, "slot_count": int(_slots.value), + "topology": _topo.selected, "cpu_limit": int(_cpu.value), + "cast_interval": _interval.value, "feature_tags": _feat.selected, + "grid_rows": int(_rows.value), "grid_cols": int(_cols.value), "edges": edges, + } + _cur = cid + _refresh() + UI.set_status(_status, "✓ 已应用『%s』(记得保存)" % cid) + +func _on_new() -> void: + var cid := "new_core" + var i := 1 + while _data.has(cid): cid = "new_core_%d" % i; i += 1 + _data[cid] = {"display_name": cid, "slot_count": 5, "topology": 0, + "cpu_limit": 5, "cast_interval": 0.5, "feature_tags": 0, "edges": []} + _refresh() + +func _on_delete() -> void: + if _cur != "" and _data.has(_cur): + _data.erase(_cur); _cur = ""; _refresh() + UI.set_status(_status, "已删除(记得保存)") + +func _save() -> void: + if UI.save_json(PATH, _data): + UI.set_status(_status, "💾 已保存 %d 个 Core(重启 F5 生效)" % _data.size()) + else: + UI.set_status(_status, "✗ 保存失败", true) diff --git a/addons/game_designer/core_tab.gd.uid b/addons/game_designer/core_tab.gd.uid new file mode 100644 index 0000000..74e92f2 --- /dev/null +++ b/addons/game_designer/core_tab.gd.uid @@ -0,0 +1 @@ +uid://dh1wnfcmj186e diff --git a/addons/game_designer/designer_panel.gd b/addons/game_designer/designer_panel.gd new file mode 100644 index 0000000..d8d504c --- /dev/null +++ b/addons/game_designer/designer_panel.gd @@ -0,0 +1,26 @@ +## 游戏设计器主面板 — TabContainer 整合 法术/敌人/波次/Core/平衡 五个编辑器 +@tool +extends Control + +func _init() -> void: + custom_minimum_size = Vector2(0, 320) + var tabs := TabContainer.new() + tabs.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(tabs) + + # 法术(复用现有法术编辑器停靠脚本) + _add_tab(tabs, "✨ 法术", preload("res://addons/spell_editor/spell_dock.gd").new()) + _add_tab(tabs, "👾 敌人", preload("res://addons/game_designer/enemy_tab.gd").new()) + _add_tab(tabs, "🌊 波次", preload("res://addons/game_designer/wave_tab.gd").new()) + _add_tab(tabs, "🪄 Core", preload("res://addons/game_designer/core_tab.gd").new()) + _add_tab(tabs, "🔮 共鸣", preload("res://addons/game_designer/resonance_tab.gd").new()) + _add_tab(tabs, "🔥 状态", preload("res://addons/game_designer/status_tab.gd").new()) + _add_tab(tabs, "⚖ 平衡", preload("res://addons/game_designer/balance_tab.gd").new()) + +func _add_tab(tabs: TabContainer, title: String, content: Control) -> void: + var scroll := ScrollContainer.new() + scroll.name = title + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(content) + tabs.add_child(scroll) diff --git a/addons/game_designer/designer_panel.gd.uid b/addons/game_designer/designer_panel.gd.uid new file mode 100644 index 0000000..ebe4334 --- /dev/null +++ b/addons/game_designer/designer_panel.gd.uid @@ -0,0 +1 @@ +uid://bjhq6mgh2g3nq diff --git a/addons/game_designer/designer_ui.gd b/addons/game_designer/designer_ui.gd new file mode 100644 index 0000000..c2be71f --- /dev/null +++ b/addons/game_designer/designer_ui.gd @@ -0,0 +1,68 @@ +## 游戏设计器通用 UI 辅助(静态方法,各标签页复用) +@tool +extends RefCounted + +static func spin(min_v: float, max_v: float, step: float, val: float) -> SpinBox: + var s := SpinBox.new() + s.min_value = min_v; s.max_value = max_v; s.step = step + s.value = val + s.custom_minimum_size = Vector2(80, 0) + return s + +static func line(text: String, placeholder: String = "") -> LineEdit: + var le := LineEdit.new() + le.text = text + le.placeholder_text = placeholder + le.custom_minimum_size = Vector2(120, 0) + return le + +static func opt(items: Array, selected: int) -> OptionButton: + var o := OptionButton.new() + for it in items: + o.add_item(str(it)) + o.selected = clampi(selected, 0, items.size() - 1) + return o + +static func header(text: String) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", 15) + l.modulate = Color(1.0, 0.85, 0.4) + return l + +static func cell_label(text: String) -> Label: + var l := Label.new() + l.text = text + l.custom_minimum_size = Vector2(70, 0) + return l + +static func btn(text: String, cb: Callable) -> Button: + var b := Button.new() + b.text = text + b.pressed.connect(cb) + return b + +static func status_label() -> Label: + var l := Label.new() + l.add_theme_font_size_override("font_size", 11) + l.modulate = Color(0.6, 0.9, 0.6) + return l + +static func set_status(lbl: Label, msg: String, is_err: bool = false) -> void: + if lbl: + lbl.text = msg + lbl.modulate = Color(1.0, 0.5, 0.5) if is_err else Color(0.6, 0.9, 0.6) + +static func load_json(path: String): + if not FileAccess.file_exists(path): + return null + return JSON.parse_string(FileAccess.get_file_as_string(path)) + +static func save_json(path: String, data) -> bool: + DirAccess.make_dir_recursive_absolute("res://data") + var f := FileAccess.open(path, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(data, " ")) + f.close() + return true + return false diff --git a/addons/game_designer/designer_ui.gd.uid b/addons/game_designer/designer_ui.gd.uid new file mode 100644 index 0000000..6136daf --- /dev/null +++ b/addons/game_designer/designer_ui.gd.uid @@ -0,0 +1 @@ +uid://cwkd7bf4jwxi2 diff --git a/addons/game_designer/enemy_tab.gd b/addons/game_designer/enemy_tab.gd new file mode 100644 index 0000000..526a77f --- /dev/null +++ b/addons/game_designer/enemy_tab.gd @@ -0,0 +1,60 @@ +## 敌人原型编辑器标签(表格:6 原型 × 速度/护甲/尺寸/颜色) +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/enemies.json" +const NAMES = ["杂兵 Basic", "快速 Fast", "护甲 Armored", "精英 Elite", "首领 MiniBoss", "终焉 Boss"] + +var _data: Dictionary = {} +var _rows: Array = [] # [{speed:SpinBox, armor:SpinBox, size:SpinBox, color:ColorPickerButton}] +var _status: Label + +func _ready() -> void: + add_child(UI.header("👾 敌人原型 — 调速度 / 护甲 / 尺寸 / 颜色")) + _load() + var grid := GridContainer.new() + grid.columns = 5 + add_child(grid) + # 表头 + for h in ["原型", "速度", "护甲", "尺寸(px)", "颜色"]: + var hl := Label.new(); hl.text = h; hl.modulate = Color(0.7, 0.8, 1.0) + grid.add_child(hl) + # 行 + for i in 6: + var key := str(i) + var d: Dictionary = _data.get(key, {}) + grid.add_child(UI.cell_label(NAMES[i])) + var sp := UI.spin(0, 1000, 1, float(d.get("speed", 80))) + var ar := UI.spin(0, 200, 0.5, float(d.get("armor", 0))) + var sz := UI.spin(2, 200, 1, float(d.get("size", 14))) + var cp := ColorPickerButton.new() + var c: Array = d.get("color", [1, 1, 1]) + cp.color = Color(float(c[0]), float(c[1]), float(c[2])) + cp.custom_minimum_size = Vector2(60, 0) + grid.add_child(sp); grid.add_child(ar); grid.add_child(sz); grid.add_child(cp) + _rows.append({"speed": sp, "armor": ar, "size": sz, "color": cp}) + add_child(HSeparator.new()) + add_child(UI.btn("💾 保存 enemies.json", _save)) + _status = UI.status_label() + add_child(_status) + +func _load() -> void: + var d = UI.load_json(PATH) + _data = d if d is Dictionary else {} + +func _save() -> void: + var out: Dictionary = {} + for i in 6: + var r: Dictionary = _rows[i] + out[str(i)] = { + "name": NAMES[i], + "speed": r["speed"].value, + "armor": r["armor"].value, + "size": r["size"].value, + "color": [r["color"].color.r, r["color"].color.g, r["color"].color.b], + } + if UI.save_json(PATH, out): + UI.set_status(_status, "💾 已保存(重启游戏 F5 生效)") + else: + UI.set_status(_status, "✗ 保存失败", true) diff --git a/addons/game_designer/enemy_tab.gd.uid b/addons/game_designer/enemy_tab.gd.uid new file mode 100644 index 0000000..481657e --- /dev/null +++ b/addons/game_designer/enemy_tab.gd.uid @@ -0,0 +1 @@ +uid://dskauy03qt13h diff --git a/addons/game_designer/game_designer.gd b/addons/game_designer/game_designer.gd new file mode 100644 index 0000000..f26934c --- /dev/null +++ b/addons/game_designer/game_designer.gd @@ -0,0 +1,17 @@ +## 游戏设计器插件入口(EditorPlugin) +## 在编辑器底部面板添加「🎮 游戏设计器」,整合 法术/敌人/波次/Core/平衡 编辑器。 +@tool +extends EditorPlugin + +var _panel: Control = null + +func _enter_tree() -> void: + _panel = preload("res://addons/game_designer/designer_panel.gd").new() + _panel.name = "🎮 游戏设计器" + add_control_to_bottom_panel(_panel, "🎮 游戏设计器") + +func _exit_tree() -> void: + if _panel: + remove_control_from_bottom_panel(_panel) + _panel.free() + _panel = null diff --git a/addons/game_designer/game_designer.gd.uid b/addons/game_designer/game_designer.gd.uid new file mode 100644 index 0000000..9939853 --- /dev/null +++ b/addons/game_designer/game_designer.gd.uid @@ -0,0 +1 @@ +uid://drkmlrut1l4pf diff --git a/addons/game_designer/plugin.cfg b/addons/game_designer/plugin.cfg new file mode 100644 index 0000000..e704337 --- /dev/null +++ b/addons/game_designer/plugin.cfg @@ -0,0 +1,6 @@ +[plugin] +name="Game Designer (游戏设计器)" +description="一站式可视化配置面板:策划/开发无需改代码即可调整法术、敌人、波次、Core、平衡数值。所有数据存 res://data/*.json,游戏运行时自动加载。底部面板「🎮 游戏设计器」。" +author="Base Games" +version="1.0.0" +script="game_designer.gd" diff --git a/addons/game_designer/resonance_tab.gd b/addons/game_designer/resonance_tab.gd new file mode 100644 index 0000000..d280f7e --- /dev/null +++ b/addons/game_designer/resonance_tab.gd @@ -0,0 +1,100 @@ +## 共鸣配方编辑器标签 — 元素组合 → 产物法术 +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/resonance.json" +const MATCH = ["adjacent", "anywhere_in_deck"] + +var _data: Array = [] +var _cur: int = -1 +var _id: LineEdit +var _p0: LineEdit +var _p1: LineEdit +var _match: OptionButton +var _result: LineEdit +var _consume: CheckBox +var _list: ItemList +var _status: Label + +func _ready() -> void: + add_child(UI.header("🔮 共鸣配方 — 元素组合相邻 → 自动合成产物法术")) + _load() + _list = ItemList.new() + _list.custom_minimum_size = Vector2(0, 80) + _list.item_selected.connect(_on_select) + add_child(_list) + var brow := HBoxContainer.new(); add_child(brow) + brow.add_child(UI.btn("➕新增", _on_new)) + brow.add_child(UI.btn("🗑删除", _on_delete)) + add_child(HSeparator.new()) + _id = _row("配方 ID", UI.line("")) + _p0 = _row("元素 A", UI.line("", "tag:water")) + _p1 = _row("元素 B", UI.line("", "tag:lightning")) + _match = _row("匹配方式", UI.opt(MATCH, 0)) + _result = _row("产物法术 ID", UI.line("", "action_plasma_storm")) + _consume = CheckBox.new(); _consume.text = "消耗输入法术(合成后移除原法术)" + add_child(_consume) + add_child(HSeparator.new()) + var srow := HBoxContainer.new(); add_child(srow) + srow.add_child(UI.btn("✓应用", _on_apply)) + srow.add_child(UI.btn("💾保存 resonance.json", _save)) + _status = UI.status_label(); add_child(_status) + _refresh() + +func _row(label: String, ctrl: Control) -> Control: + var h := HBoxContainer.new(); h.add_child(UI.cell_label(label)); h.add_child(ctrl); add_child(h) + return ctrl + +func _load() -> void: + var d = UI.load_json(PATH) + _data = d if d is Array else [] + +func _refresh() -> void: + _list.clear() + for r in _data: + var p: Array = r.get("pattern", ["?", "?"]) + _list.add_item("%s (%s + %s)" % [r.get("id", "?"), p[0] if p.size() > 0 else "", p[1] if p.size() > 1 else ""]) + +func _on_select(idx: int) -> void: + _cur = idx + var d: Dictionary = _data[idx] + var p: Array = d.get("pattern", ["", ""]) + _id.text = String(d.get("id", "")) + _p0.text = String(p[0]) if p.size() > 0 else "" + _p1.text = String(p[1]) if p.size() > 1 else "" + _match.selected = max(0, MATCH.find(String(d.get("match", "adjacent")))) + _result.text = String(d.get("result_spell_id", "")) + _consume.button_pressed = bool(d.get("consume_inputs", false)) + +func _on_apply() -> void: + var r := { + "id": _id.text.strip_edges(), + "pattern": [_p0.text.strip_edges(), _p1.text.strip_edges()], + "match": MATCH[_match.selected], + "result_spell_id": _result.text.strip_edges(), + "consume_inputs": _consume.button_pressed, + } + if _cur >= 0 and _cur < _data.size(): + _data[_cur] = r + else: + _data.append(r); _cur = _data.size() - 1 + _refresh() + UI.set_status(_status, "✓ 已应用(记得保存)") + +func _on_new() -> void: + _data.append({"id": "new_recipe", "pattern": ["tag:a", "tag:b"], + "match": "adjacent", "result_spell_id": "", "consume_inputs": true}) + _cur = _data.size() - 1 + _refresh() + +func _on_delete() -> void: + if _cur >= 0 and _cur < _data.size(): + _data.remove_at(_cur); _cur = -1; _refresh() + UI.set_status(_status, "已删除(记得保存)") + +func _save() -> void: + if UI.save_json(PATH, _data): + UI.set_status(_status, "💾 已保存 %d 个配方(重启 F5 生效)" % _data.size()) + else: + UI.set_status(_status, "✗ 保存失败", true) diff --git a/addons/game_designer/resonance_tab.gd.uid b/addons/game_designer/resonance_tab.gd.uid new file mode 100644 index 0000000..5fbc70b --- /dev/null +++ b/addons/game_designer/resonance_tab.gd.uid @@ -0,0 +1 @@ +uid://bqukws0ume8dy diff --git a/addons/game_designer/status_tab.gd b/addons/game_designer/status_tab.gd new file mode 100644 index 0000000..71cb9ef --- /dev/null +++ b/addons/game_designer/status_tab.gd @@ -0,0 +1,110 @@ +## 状态效果编辑器标签 — DoT/层数/持续时间/VFX +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/status_effects.json" +const STACK = ["REFRESH 刷新", "INTENSITY 叠层", "INDEPENDENT 独立"] + +var _data: Dictionary = {} +var _cur: String = "" +var _id: SpinBox +var _name: LineEdit +var _dur: SpinBox +var _tick: SpinBox +var _stack: OptionButton +var _max: SpinBox +var _dot: SpinBox +var _vfx: LineEdit +var _combo: CheckBox +var _list: ItemList +var _status: Label + +func _ready() -> void: + add_child(UI.header("🔥 状态效果 — 燃烧/中毒/连击等 DoT 与叠层")) + _load() + _list = ItemList.new() + _list.custom_minimum_size = Vector2(0, 80) + _list.item_selected.connect(_on_select) + add_child(_list) + var brow := HBoxContainer.new(); add_child(brow) + brow.add_child(UI.btn("➕新增", _on_new)) + brow.add_child(UI.btn("🗑删除", _on_delete)) + add_child(HSeparator.new()) + _id = _row("状态 ID(整数,1=燃烧 3=中毒…)", UI.spin(1, 999, 1, 9)) + _name = _row("名称", UI.line("")) + _dur = _row("持续时间(s)", UI.spin(0.1, 999, 0.5, 3.0)) + _tick = _row("跳字间隔(s)", UI.spin(0.1, 999, 0.5, 1.0)) + _stack = _row("叠层模式", UI.opt(STACK, 0)) + _max = _row("最大层数(0=无限)", UI.spin(0, 999, 1, 1)) + _dot = _row("每跳伤害", UI.spin(0, 9999, 0.5, 0.0)) + _vfx = _row("VFX ID", UI.line("", "status_burn")) + _combo = CheckBox.new(); _combo.text = "连击标记追踪器" + add_child(_combo) + add_child(HSeparator.new()) + var srow := HBoxContainer.new(); add_child(srow) + srow.add_child(UI.btn("✓应用", _on_apply)) + srow.add_child(UI.btn("💾保存 status_effects.json", _save)) + _status = UI.status_label(); add_child(_status) + _refresh() + +func _row(label: String, ctrl: Control) -> Control: + var h := HBoxContainer.new(); h.add_child(UI.cell_label(label)); h.add_child(ctrl); add_child(h) + return ctrl + +func _load() -> void: + var d = UI.load_json(PATH) + _data = d if d is Dictionary else {} + +func _refresh() -> void: + _list.clear() + var ids: Array = _data.keys(); ids.sort() + for k in ids: + _list.add_item("[%s] %s" % [k, _data[k].get("name", "")]) + _list.set_item_metadata(_list.item_count - 1, k) + +func _on_select(idx: int) -> void: + _cur = String(_list.get_item_metadata(idx)) + var d: Dictionary = _data[_cur] + _id.value = float(int(_cur)) + _name.text = String(d.get("name", "")) + _dur.value = float(d.get("duration", 3.0)) + _tick.value = float(d.get("tick_interval", 1.0)) + _stack.selected = int(d.get("stack_mode", 0)) + _max.value = float(d.get("max_stacks", 1)) + _dot.value = float(d.get("dot_damage", 0.0)) + _vfx.text = String(d.get("vfx_id", "")) + _combo.button_pressed = bool(d.get("is_combo_tracker", false)) + +func _on_apply() -> void: + var sid := str(int(_id.value)) + if _cur != "" and _cur != sid and _data.has(_cur): + _data.erase(_cur) + _data[sid] = { + "name": _name.text, "duration": _dur.value, "tick_interval": _tick.value, + "stack_mode": _stack.selected, "max_stacks": int(_max.value), + "dot_damage": _dot.value, "dot_damage_type": 0, + "vfx_id": _vfx.text, "is_combo_tracker": _combo.button_pressed, + } + _cur = sid + _refresh() + UI.set_status(_status, "✓ 已应用 ID %s(记得保存)" % sid) + +func _on_new() -> void: + var sid := 9 + while _data.has(str(sid)): sid += 1 + _data[str(sid)] = {"name": "新状态", "duration": 3.0, "tick_interval": 1.0, + "stack_mode": 0, "max_stacks": 1, "dot_damage": 0.0, "dot_damage_type": 0, + "vfx_id": "", "is_combo_tracker": false} + _refresh() + +func _on_delete() -> void: + if _cur != "" and _data.has(_cur): + _data.erase(_cur); _cur = ""; _refresh() + UI.set_status(_status, "已删除(记得保存)") + +func _save() -> void: + if UI.save_json(PATH, _data): + UI.set_status(_status, "💾 已保存 %d 个状态(重启 F5 生效)" % _data.size()) + else: + UI.set_status(_status, "✗ 保存失败", true) diff --git a/addons/game_designer/status_tab.gd.uid b/addons/game_designer/status_tab.gd.uid new file mode 100644 index 0000000..2700049 --- /dev/null +++ b/addons/game_designer/status_tab.gd.uid @@ -0,0 +1 @@ +uid://esmos6ikng8c diff --git a/addons/game_designer/wave_tab.gd b/addons/game_designer/wave_tab.gd new file mode 100644 index 0000000..71c1a1a --- /dev/null +++ b/addons/game_designer/wave_tab.gd @@ -0,0 +1,86 @@ +## 波次编辑器标签(关卡难度曲线:每波 怪数/血量/类型/精英/金币/XP/Boss) +@tool +extends VBoxContainer + +const UI = preload("res://addons/game_designer/designer_ui.gd") +const PATH = "res://data/waves.json" +const TYPE_NAMES = ["Basic", "Fast", "Armored", "Elite", "MiniBoss", "Boss"] +const BOSS_NAMES = ["无", "MiniBoss(4)", "Boss(5)"] # 映射到 boss 值 -1/4/5 +const BOSS_VALS = [-1, 4, 5] + +var _waves: Array = [] +var _rows: Array = [] +var _status: Label + +func _ready() -> void: + _build() + +func _build() -> void: + add_child(UI.header("🌊 波次编辑器 — 关卡难度曲线(点行号添加/删除波次)")) + _load() + var scroll := ScrollContainer.new() + scroll.custom_minimum_size = Vector2(0, 260) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + var grid := GridContainer.new() + grid.columns = 8 + scroll.add_child(grid) + for h in ["波", "怪数", "血量", "类型", "精英", "金币", "XP", "Boss"]: + var hl := Label.new(); hl.text = h; hl.modulate = Color(0.7, 0.8, 1.0) + grid.add_child(hl) + for i in _waves.size(): + _add_row(grid, i, _waves[i]) + add_child(HSeparator.new()) + var row := HBoxContainer.new(); add_child(row) + row.add_child(UI.btn("➕ 末尾加一波", _on_add)) + row.add_child(UI.btn("💾 保存 waves.json", _save)) + _status = UI.status_label() + add_child(_status) + +func _add_row(grid: GridContainer, idx: int, w: Dictionary) -> void: + grid.add_child(UI.cell_label("W%d" % (idx + 1))) + var count := UI.spin(0, 2000, 1, float(w.get("count", 20))) + var hp := UI.spin(1, 99999, 1, float(w.get("hp", 15))) + var type_o := UI.opt(TYPE_NAMES, int(w.get("type", 0))) + var elite := UI.spin(0, 100, 1, float(w.get("elite", 0))) + var gold := UI.spin(0, 99999, 1, float(w.get("gold", 10))) + var xp := UI.spin(0, 99999, 1, float(w.get("xp", 20))) + var boss_idx := BOSS_VALS.find(int(w.get("boss", -1))) + var boss_o := UI.opt(BOSS_NAMES, max(0, boss_idx)) + grid.add_child(count); grid.add_child(hp); grid.add_child(type_o) + grid.add_child(elite); grid.add_child(gold); grid.add_child(xp); grid.add_child(boss_o) + _rows.append({"count": count, "hp": hp, "type": type_o, "elite": elite, + "gold": gold, "xp": xp, "boss": boss_o}) + +func _load() -> void: + var d = UI.load_json(PATH) + _waves = d if d is Array else [] + +func _on_add() -> void: + _waves = _read_rows() + _waves.append({"count": 30, "hp": 50, "type": 0, "elite": 0, "gold": 30, "xp": 40, "boss": -1}) + _rebuild() + +func _rebuild() -> void: + # 简易重建:清空后重新 _init 逻辑(移除所有子节点重建) + for c in get_children(): + c.queue_free() + _rows.clear() + call_deferred("_build") + +func _read_rows() -> Array: + var out: Array = [] + for r in _rows: + out.append({ + "count": int(r["count"].value), "hp": r["hp"].value, + "type": r["type"].selected, "elite": int(r["elite"].value), + "gold": int(r["gold"].value), "xp": int(r["xp"].value), + "boss": BOSS_VALS[r["boss"].selected], + }) + return out + +func _save() -> void: + if UI.save_json(PATH, _read_rows()): + UI.set_status(_status, "💾 已保存 %d 波(重启游戏 F5 生效)" % _rows.size()) + else: + UI.set_status(_status, "✗ 保存失败", true) diff --git a/addons/game_designer/wave_tab.gd.uid b/addons/game_designer/wave_tab.gd.uid new file mode 100644 index 0000000..663abac --- /dev/null +++ b/addons/game_designer/wave_tab.gd.uid @@ -0,0 +1 @@ +uid://cujsbx8n78jky diff --git a/addons/godot_mcp/command_router.gd b/addons/godot_mcp/command_router.gd new file mode 100644 index 0000000..65d6268 --- /dev/null +++ b/addons/godot_mcp/command_router.gd @@ -0,0 +1,120 @@ +@tool +extends Node + +var editor_plugin: EditorPlugin + +var _command_handlers: Dictionary = {} # method_name -> Callable +var _disabled_tools: Dictionary = {} # method_name -> true + +const TOOL_CONFIG_PATH := "user://mcp_tool_config.cfg" + + +func _ready() -> void: + _load_tool_config() + _register_commands() + + +func _register_commands() -> void: + var command_classes := [ + preload("res://addons/godot_mcp/commands/project_commands.gd"), + preload("res://addons/godot_mcp/commands/scene_commands.gd"), + preload("res://addons/godot_mcp/commands/node_commands.gd"), + preload("res://addons/godot_mcp/commands/script_commands.gd"), + preload("res://addons/godot_mcp/commands/editor_commands.gd"), + preload("res://addons/godot_mcp/commands/input_commands.gd"), + preload("res://addons/godot_mcp/commands/runtime_commands.gd"), + preload("res://addons/godot_mcp/commands/animation_commands.gd"), + preload("res://addons/godot_mcp/commands/tilemap_commands.gd"), + preload("res://addons/godot_mcp/commands/theme_commands.gd"), + preload("res://addons/godot_mcp/commands/profiling_commands.gd"), + preload("res://addons/godot_mcp/commands/batch_commands.gd"), + preload("res://addons/godot_mcp/commands/shader_commands.gd"), + preload("res://addons/godot_mcp/commands/export_commands.gd"), + preload("res://addons/godot_mcp/commands/resource_commands.gd"), + preload("res://addons/godot_mcp/commands/input_map_commands.gd"), + preload("res://addons/godot_mcp/commands/scene_3d_commands.gd"), + preload("res://addons/godot_mcp/commands/physics_commands.gd"), + preload("res://addons/godot_mcp/commands/analysis_commands.gd"), + preload("res://addons/godot_mcp/commands/animation_tree_commands.gd"), + preload("res://addons/godot_mcp/commands/audio_commands.gd"), + preload("res://addons/godot_mcp/commands/navigation_commands.gd"), + preload("res://addons/godot_mcp/commands/particle_commands.gd"), + preload("res://addons/godot_mcp/commands/test_commands.gd"), + preload("res://addons/godot_mcp/commands/android_commands.gd"), + ] + + for cmd_class in command_classes: + var cmd: Node = cmd_class.new() + cmd.editor_plugin = editor_plugin + add_child(cmd) + var methods: Dictionary = cmd.get_commands() + for method_name: String in methods: + _command_handlers[method_name] = methods[method_name] + + print("[MCP] Registered %d commands" % _command_handlers.size()) + + +func execute(method: String, params: Dictionary) -> Dictionary: + if not _command_handlers.has(method): + return { + "error": { + "code": -32601, + "message": "Method not found: %s" % method, + "data": {"available_methods": _command_handlers.keys()} + } + } + + if _disabled_tools.has(method): + return { + "error": { + "code": -32603, + "message": "Tool '%s' is disabled in MCP Server settings" % method + } + } + + var handler: Callable = _command_handlers[method] + var result: Dictionary = await handler.call(params) + return result + + +func get_available_methods() -> Array: + return _command_handlers.keys() + + +func is_tool_disabled(method: String) -> bool: + return _disabled_tools.has(method) + + +func set_tool_disabled(method: String, disabled: bool) -> void: + if disabled: + _disabled_tools[method] = true + else: + _disabled_tools.erase(method) + _save_tool_config() + + +func set_all_tools_disabled(disabled: bool) -> void: + if disabled: + for method: String in _command_handlers: + _disabled_tools[method] = true + else: + _disabled_tools.clear() + _save_tool_config() + + +func _load_tool_config() -> void: + var cfg := ConfigFile.new() + if cfg.load(TOOL_CONFIG_PATH) != OK: + return + if not cfg.has_section("disabled_tools"): + return + for method: String in cfg.get_section_keys("disabled_tools"): + if cfg.get_value("disabled_tools", method, false): + _disabled_tools[method] = true + + +func _save_tool_config() -> void: + var cfg := ConfigFile.new() + for method: String in _disabled_tools: + cfg.set_value("disabled_tools", method, true) + cfg.save(TOOL_CONFIG_PATH) diff --git a/addons/godot_mcp/command_router.gd.uid b/addons/godot_mcp/command_router.gd.uid new file mode 100644 index 0000000..ff9fd28 --- /dev/null +++ b/addons/godot_mcp/command_router.gd.uid @@ -0,0 +1 @@ +uid://cin2nsghxc2ls diff --git a/addons/godot_mcp/commands/analysis_commands.gd b/addons/godot_mcp/commands/analysis_commands.gd new file mode 100644 index 0000000..a27560b --- /dev/null +++ b/addons/godot_mcp/commands/analysis_commands.gd @@ -0,0 +1,547 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "find_unused_resources": _find_unused_resources, + "analyze_signal_flow": _analyze_signal_flow, + "analyze_scene_complexity": _analyze_scene_complexity, + "find_script_references": _find_script_references, + "detect_circular_dependencies": _detect_circular_dependencies, + "get_project_statistics": _get_project_statistics, + } + + +# ============================================================================= +# find_unused_resources +# ============================================================================= + +## Scan project for resources not referenced by any .tscn, .gd, or .tres file. +func _find_unused_resources(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + # Step 1: Collect all resource files + var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg", + "wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material", + "theme", "stylebox", "font", "anim"] + var all_resources: Array = [] + _collect_files_by_ext(path, resource_extensions, all_resources, include_addons) + + # Step 2: Collect all referencing files (.tscn, .gd, .tres) + var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"] + var ref_files: Array = [] + _collect_files_by_ext(path, ref_extensions, ref_files, include_addons) + + # Step 3: Build a set of all referenced paths (both res:// and uid:// forms) + var referenced: Dictionary = {} # path -> true + for ref_file in ref_files: + var content := _read_file_text(ref_file as String) + if content.is_empty(): + continue + for prefix: String in ["res://", "uid://"]: + var idx := 0 + while idx < content.length(): + var found := content.find(prefix, idx) + if found == -1: + break + # Extract the path (up to quote, space, or end of line) + var end := found + prefix.length() + while end < content.length(): + var c := content[end] + if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}': + break + end += 1 + var ref_path := content.substr(found, end - found) + if ref_path.begins_with("uid://"): + # Map uid:// back to its res:// path. Skip self-references: + # .tres/.tscn headers carry the file's OWN uid, which must + # not count as the file being referenced by someone else. + var uid := ResourceUID.text_to_id(ref_path) + if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid): + var uid_path := ResourceUID.get_id_path(uid) + if uid_path != str(ref_file): + referenced[uid_path] = true + else: + referenced[ref_path] = true + idx = end + + # Step 3b: Seed references held by the engine via ProjectSettings + # (main scene, audio bus layout, icon, autoloads…) — these may be default + # values that never appear as literals in project.godot + for setting in ProjectSettings.get_property_list(): + var setting_name: String = setting["name"] + var value: Variant = ProjectSettings.get_setting(setting_name) if ProjectSettings.has_setting(setting_name) else null + if value is String: + var s: String = value + s = s.trim_prefix("*") # autoload paths are prefixed with '*' + if s.begins_with("res://"): + referenced[s] = true + elif s.begins_with("uid://"): + var uid := ResourceUID.text_to_id(s) + if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid): + referenced[ResourceUID.get_id_path(uid)] = true + + # Step 4: Find unreferenced resources + var unused: Array = [] + for res_path in all_resources: + var p: String = res_path + if not referenced.has(p): + unused.append(p) + + return success({ + "unused_resources": unused, + "unused_count": unused.size(), + "total_resources_scanned": all_resources.size(), + "total_files_checked": ref_files.size(), + }) + + +# ============================================================================= +# analyze_signal_flow +# ============================================================================= + +## Map all signal connections in the currently edited scene. +func _analyze_signal_flow(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var nodes_data: Array = [] + _collect_signal_data(root, root, nodes_data) + + return success({ + "scene": root.scene_file_path, + "nodes": nodes_data, + "total_nodes": nodes_data.size(), + }) + + +func _collect_signal_data(node: Node, root: Node, out: Array) -> void: + var node_path := str(root.get_path_to(node)) + var signals_emitted: Array = [] + var signals_connected_to: Array = [] + + # Get all signals this node defines + for sig in node.get_signal_list(): + var sig_name: String = sig["name"] + var connections := node.get_signal_connection_list(sig_name) + var targets: Array = [] + for conn in connections: + # Skip editor-internal bookkeeping connections — only persistent + # (scene-serialized) connections are user-relevant + if int(conn.get("flags", 0)) & Object.CONNECT_PERSIST == 0: + continue + var callable: Callable = conn["callable"] + var target_node: Node = callable.get_object() as Node + # Skip targets outside the edited scene (editor dock nodes) + if target_node == null or (target_node != root and not root.is_ancestor_of(target_node)): + continue + targets.append({ + "target_node": str(root.get_path_to(target_node)), + "method": callable.get_method(), + }) + # Also record on the target side + signals_connected_to.append({ + "from_node": node_path, + "signal": sig_name, + "method": callable.get_method(), + }) + if targets.size() > 0: + signals_emitted.append({ + "signal": sig_name, + "targets": targets, + }) + + # Only include nodes that have signal activity + if signals_emitted.size() > 0 or signals_connected_to.size() > 0: + out.append({ + "name": node.name, + "path": node_path, + "type": node.get_class(), + "signals_emitted": signals_emitted, + "signals_connected_to": signals_connected_to, + }) + + for child in node.get_children(): + _collect_signal_data(child, root, out) + + +# ============================================================================= +# analyze_scene_complexity +# ============================================================================= + +## Analyze a scene's complexity: node count, depth, types, scripts, potential issues. +func _analyze_scene_complexity(params: Dictionary) -> Dictionary: + var scene_path: String = optional_string(params, "path", "") + + var root: Node = null + if scene_path.is_empty(): + root = get_edited_root() + if root == null: + return error_no_scene() + scene_path = root.scene_file_path + else: + if not ResourceLoader.exists(scene_path): + return error_not_found("Scene '%s'" % scene_path) + var packed := ResourceLoader.load(scene_path) as PackedScene + if packed == null: + return error_internal("Failed to load scene: %s" % scene_path) + root = packed.instantiate() + + var total_nodes := 0 + var max_depth := 0 + var types: Dictionary = {} # class_name -> count + var scripts_attached: Array = [] + var resources_used: Dictionary = {} # resource path -> count + var issues: Array = [] + + _analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used) + + # Count totals from recursive walk + total_nodes = _count_nodes_recursive(root) + max_depth = _get_max_depth(root, 0) + + # Detect potential issues + if total_nodes > 1000: + issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes}) + elif total_nodes > 500: + issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes}) + + if max_depth > 15: + issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth}) + elif max_depth > 10: + issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth}) + + # If we instantiated the scene ourselves, free it + if not scene_path.is_empty() and root != get_edited_root(): + root.queue_free() + + return success({ + "scene_path": scene_path, + "total_nodes": total_nodes, + "max_depth": max_depth, + "nodes_by_type": types, + "scripts_attached": scripts_attached, + "unique_resource_count": resources_used.size(), + "issues": issues, + }) + + +func _analyze_node(node: Node, root: Node, depth: int, + total_nodes: int, max_depth: int, + types: Dictionary, scripts: Array, resources: Dictionary) -> void: + var type_name := node.get_class() + types[type_name] = types.get(type_name, 0) + 1 + + if node.get_script() != null: + var script: Script = node.get_script() + var script_path := script.resource_path + if not script_path.is_empty(): + scripts.append({ + "node": str(root.get_path_to(node)), + "script": script_path, + }) + + for child in node.get_children(): + _analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources) + + +func _count_nodes_recursive(node: Node) -> int: + var count := 1 + for child in node.get_children(): + count += _count_nodes_recursive(child) + return count + + +func _get_max_depth(node: Node, current_depth: int) -> int: + var max_d := current_depth + for child in node.get_children(): + var child_depth := _get_max_depth(child, current_depth + 1) + if child_depth > max_d: + max_d = child_depth + return max_d + + +# ============================================================================= +# find_script_references +# ============================================================================= + +## Find all places where a given script, class_name, or resource path is used. +func _find_script_references(params: Dictionary) -> Dictionary: + var result := require_string(params, "query") + if result[1] != null: + return result[1] + var query: String = result[0] + + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"] + var search_files: Array = [] + _collect_files_by_ext(path, search_extensions, search_files, include_addons) + + var references: Array = [] + for file_path in search_files: + var fp: String = file_path + var content := _read_file_text(fp) + if content.is_empty(): + continue + + var lines := content.split("\n") + var line_num := 0 + for line in lines: + line_num += 1 + var l: String = line + if l.contains(query): + references.append({ + "file": fp, + "line": line_num, + "content": l.strip_edges(), + }) + + return success({ + "query": query, + "references": references, + "reference_count": references.size(), + "files_searched": search_files.size(), + }) + + +# ============================================================================= +# detect_circular_dependencies +# ============================================================================= + +## Check for circular scene dependencies (.tscn files referencing each other). +func _detect_circular_dependencies(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + # Step 1: Collect all .tscn files + var tscn_files: Array = [] + _collect_files_by_ext(path, ["tscn"], tscn_files, include_addons) + + # Step 2: Build dependency graph: scene_path -> [referenced_scene_paths] + var dep_graph: Dictionary = {} # String -> Array[String] + for tscn_path in tscn_files: + var tp: String = tscn_path + var content := _read_file_text(tp) + if content.is_empty(): + continue + + var deps: Array = [] + for line in content.split("\n"): + var l: String = line + # Match [ext_resource ... path="res://..." ...] lines that reference .tscn + if l.begins_with("[ext_resource") and ".tscn" in l: + var path_start := l.find('path="') + if path_start == -1: + continue + path_start += 6 # len('path="') + var path_end := l.find('"', path_start) + if path_end == -1: + continue + var ref_path := l.substr(path_start, path_end - path_start) + if ref_path.ends_with(".tscn"): + deps.append(ref_path) + dep_graph[tp] = deps + + # Step 3: Detect cycles using DFS + var cycles: Array = [] + var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited" + for scene in dep_graph: + visited[scene] = "unvisited" + + for scene in dep_graph: + if visited[scene] == "unvisited": + var path_stack: Array = [] + _dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles) + + return success({ + "scenes_checked": tscn_files.size(), + "circular_dependencies": cycles, + "has_circular": cycles.size() > 0, + "dependency_graph": dep_graph, + }) + + +func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary, + path_stack: Array, cycles: Array) -> void: + visited[node] = "visiting" + path_stack.append(node) + + if graph.has(node): + var deps: Array = graph[node] + for dep in deps: + var d: String = dep + if not visited.has(d): + # Scene referenced but not in our graph (might not exist or outside scope) + continue + if visited[d] == "visiting": + # Found a cycle — extract it from the stack + var cycle_start := path_stack.find(d) + var cycle: Array = path_stack.slice(cycle_start) + cycle.append(d) # Close the cycle + cycles.append(cycle) + elif visited[d] == "unvisited": + _dfs_detect_cycle(d, graph, visited, path_stack, cycles) + + path_stack.pop_back() + visited[node] = "visited" + + +# ============================================================================= +# get_project_statistics +# ============================================================================= + +## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins. +func _get_project_statistics(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + var file_counts: Dictionary = {} # extension -> count + var total_script_lines := 0 + var scene_count := 0 + var resource_count := 0 + var total_files := 0 + + _collect_statistics(path, include_addons, file_counts) + + # Extract internal counters and remove them from the visible dict + total_script_lines = int(file_counts.get("_total_script_lines", 0)) + scene_count = int(file_counts.get("_scene_count", 0)) + resource_count = int(file_counts.get("_resource_count", 0)) + total_files = int(file_counts.get("_total_files", 0)) + file_counts.erase("_total_script_lines") + file_counts.erase("_scene_count") + file_counts.erase("_resource_count") + file_counts.erase("_total_files") + + # Autoloads + var autoloads: Dictionary = {} + for prop in ProjectSettings.get_property_list(): + var prop_name: String = prop["name"] + if prop_name.begins_with("autoload/"): + autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name)) + + # Enabled plugins + var plugins: Array = [] + var plugin_cfg_path := "res://addons" + var enabled_plugins: PackedStringArray = ProjectSettings.get_setting( + "editor_plugins/enabled", PackedStringArray() + ) + var plugin_dir := DirAccess.open(plugin_cfg_path) + if plugin_dir != null: + plugin_dir.list_dir_begin() + var dir_name := plugin_dir.get_next() + while not dir_name.is_empty(): + if plugin_dir.current_is_dir() and not dir_name.begins_with("."): + var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg") + if FileAccess.file_exists(cfg_path): + var plugin_path := "res://addons/%s/plugin.cfg" % dir_name + plugins.append({ + "name": dir_name, + "enabled": plugin_path in enabled_plugins, + }) + dir_name = plugin_dir.get_next() + plugin_dir.list_dir_end() + + return success({ + "file_counts_by_extension": file_counts, + "total_files": total_files, + "total_script_lines": total_script_lines, + "scene_count": scene_count, + "resource_count": resource_count, + "autoloads": autoloads, + "plugins": plugins, + }) + + +func _collect_statistics(path: String, include_addons: bool, + file_counts: Dictionary) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + if file_name == "addons" and not include_addons: + file_name = dir.get_next() + continue + _collect_statistics(full_path, include_addons, file_counts) + else: + var ext := file_name.get_extension().to_lower() + file_counts[ext] = file_counts.get(ext, 0) + 1 + + if ext == "gd": + var content := _read_file_text(full_path) + var line_count := content.count("\n") + 1 if not content.is_empty() else 0 + # We can't modify int params, so we store in the dict + file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count + + if ext == "tscn": + file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1 + + if ext in ["tres", "material", "theme", "stylebox", "font"]: + file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1 + + file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1 + + file_name = dir.get_next() + + dir.list_dir_end() + + +# ============================================================================= +# Shared helpers +# ============================================================================= + +## Recursively collect files matching given extensions. +func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + if file_name == "addons" and not include_addons: + file_name = dir.get_next() + continue + _collect_files_by_ext(full_path, extensions, out, include_addons) + else: + var ext := file_name.get_extension().to_lower() + if ext in extensions: + out.append(full_path) + + file_name = dir.get_next() + + dir.list_dir_end() + + +## Read a file's text content. Returns empty string on failure. +func _read_file_text(file_path: String) -> String: + var file := FileAccess.open(file_path, FileAccess.READ) + if file == null: + return "" + var content := file.get_as_text() + file.close() + return content diff --git a/addons/godot_mcp/commands/analysis_commands.gd.uid b/addons/godot_mcp/commands/analysis_commands.gd.uid new file mode 100644 index 0000000..9481a7e --- /dev/null +++ b/addons/godot_mcp/commands/analysis_commands.gd.uid @@ -0,0 +1 @@ +uid://c8exes22tfkqj diff --git a/addons/godot_mcp/commands/android_commands.gd b/addons/godot_mcp/commands/android_commands.gd new file mode 100644 index 0000000..898d645 --- /dev/null +++ b/addons/godot_mcp/commands/android_commands.gd @@ -0,0 +1,192 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_android_devices": _list_android_devices, + "get_android_preset_info": _get_android_preset_info, + "deploy_to_android": _deploy_to_android, + } + + +## Resolve adb path from editor settings or PATH fallback. +func _resolve_adb_path() -> String: + var editor_settings := get_editor().get_editor_settings() + # Godot exposes this under export/android/adb (may be stored as an absolute path). + var configured: String = "" + if editor_settings.has_setting("export/android/adb"): + configured = str(editor_settings.get_setting("export/android/adb")) + if not configured.is_empty() and FileAccess.file_exists(configured): + return configured + # Fallback: assume adb is on PATH. OS.execute will resolve it at call time. + return "adb" + + +func _run(cmd: String, args: PackedStringArray) -> Dictionary: + var output: Array = [] + var exit_code := OS.execute(cmd, args, output, true) + var stdout := "" + if not output.is_empty(): + stdout = str(output[0]) + return {"exit_code": exit_code, "stdout": stdout} + + +## List devices visible to adb. +func _list_android_devices(_params: Dictionary) -> Dictionary: + var adb := _resolve_adb_path() + var result := _run(adb, PackedStringArray(["devices", "-l"])) + if result["exit_code"] != 0: + return error(-32000, "adb failed (exit %d). Install Android platform-tools or set Editor Settings > Export > Android > Adb." % result["exit_code"], {"adb_path": adb, "output": result["stdout"]}) + + # Parse `adb devices -l` output: + # List of devices attached + # R58M12345 device usb:3-1 product:foo model:Pixel_5 device:redfin + var devices: Array = [] + var lines: PackedStringArray = str(result["stdout"]).split("\n") + for raw_line in lines: + var line: String = raw_line.strip_edges() + if line.is_empty() or line.begins_with("List of devices") or line.begins_with("* daemon"): + continue + var parts: PackedStringArray = line.split(" ", false) + if parts.size() < 2: + continue + var dev: Dictionary = {"serial": parts[0], "state": parts[1]} + for i in range(2, parts.size()): + var kv: String = parts[i] + var eq: int = kv.find(":") + if eq > 0: + dev[kv.substr(0, eq)] = kv.substr(eq + 1) + devices.append(dev) + + return success({"devices": devices, "count": devices.size(), "adb_path": adb}) + + +## Find an Android preset in export_presets.cfg. Returns the preset dict or null. +func _find_android_preset(preset_name: String, preset_index: int) -> Dictionary: + var presets_path := "res://export_presets.cfg" + if not FileAccess.file_exists(presets_path): + return {} + var cfg := ConfigFile.new() + if cfg.load(presets_path) != OK: + return {} + + var idx := 0 + while cfg.has_section("preset.%d" % idx): + var section := "preset.%d" % idx + var platform := str(cfg.get_value(section, "platform", "")) + var name := str(cfg.get_value(section, "name", "")) + var matches := false + if not preset_name.is_empty(): + matches = (name == preset_name) + elif preset_index >= 0: + matches = (idx == preset_index) + else: + # No filter: pick the first Android preset. + matches = (platform == "Android") + if matches: + var options_section := "preset.%d.options" % idx + var package_name := "" + if cfg.has_section(options_section): + package_name = str(cfg.get_value(options_section, "package/unique_name", "")) + return { + "index": idx, + "name": name, + "platform": platform, + "runnable": bool(cfg.get_value(section, "runnable", false)), + "export_path": str(cfg.get_value(section, "export_path", "")), + "package_name": package_name, + } + idx += 1 + return {} + + +## Read Android preset metadata (package name, export path, etc.) +func _get_android_preset_info(params: Dictionary) -> Dictionary: + var preset_name: String = optional_string(params, "preset_name", "") + var preset_index: int = optional_int(params, "preset_index", -1) + var preset := _find_android_preset(preset_name, preset_index) + if preset.is_empty(): + return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.") + if preset["platform"] != "Android": + return error(-32000, "Preset '%s' is not an Android preset (platform=%s)" % [preset["name"], preset["platform"]]) + return success(preset) + + +## Export APK, install it on a device, then optionally launch the main activity. +func _deploy_to_android(params: Dictionary) -> Dictionary: + var preset_name: String = optional_string(params, "preset_name", "") + var preset_index: int = optional_int(params, "preset_index", -1) + var device_serial: String = optional_string(params, "device_serial", "") + var debug: bool = optional_bool(params, "debug", true) + var launch: bool = optional_bool(params, "launch", true) + var skip_export: bool = optional_bool(params, "skip_export", false) + + var preset := _find_android_preset(preset_name, preset_index) + if preset.is_empty(): + return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.") + if preset["platform"] != "Android": + return error(-32000, "Preset '%s' is not an Android preset" % preset["name"]) + + var export_path_res: String = preset["export_path"] + if export_path_res.is_empty(): + return error(-32000, "Export path not configured for preset '%s'" % preset["name"]) + var export_path_abs: String = ProjectSettings.globalize_path(export_path_res) if export_path_res.begins_with("res://") else export_path_res + + var steps: Array = [] + + # Step 1: Export APK via Godot CLI (unless caller already has an APK). + if not skip_export: + var godot_bin := OS.get_executable_path() + var project_dir := ProjectSettings.globalize_path("res://") + var export_flag := "--export-debug" if debug else "--export-release" + var export_args := PackedStringArray(["--headless", "--path", project_dir, export_flag, preset["name"], export_path_abs]) + var export_result := _run(godot_bin, export_args) + steps.append({"step": "export", "command": godot_bin, "args": export_args, "exit_code": export_result["exit_code"]}) + if export_result["exit_code"] != 0: + return error(-32000, "Godot export failed (exit %d). See stdout." % export_result["exit_code"], {"steps": steps, "stdout": export_result["stdout"]}) + + if not FileAccess.file_exists(export_path_abs): + return error(-32000, "APK not found at %s after export" % export_path_abs, {"steps": steps}) + + # Step 2: adb install -r + var adb := _resolve_adb_path() + var install_args := PackedStringArray() + if not device_serial.is_empty(): + install_args.append("-s") + install_args.append(device_serial) + install_args.append("install") + install_args.append("-r") + install_args.append(export_path_abs) + var install_result := _run(adb, install_args) + steps.append({"step": "install", "command": adb, "args": install_args, "exit_code": install_result["exit_code"], "stdout": install_result["stdout"]}) + if install_result["exit_code"] != 0: + return error(-32000, "adb install failed (exit %d)" % install_result["exit_code"], {"steps": steps}) + + # Step 3: adb shell am start (optional) + if launch: + var package_name: String = preset["package_name"] + if package_name.is_empty(): + steps.append({"step": "launch", "skipped": true, "reason": "package_name not found in preset"}) + else: + var launch_args := PackedStringArray() + if not device_serial.is_empty(): + launch_args.append("-s") + launch_args.append(device_serial) + launch_args.append("shell") + launch_args.append("monkey") + launch_args.append("-p") + launch_args.append(package_name) + launch_args.append("-c") + launch_args.append("android.intent.category.LAUNCHER") + launch_args.append("1") + var launch_result := _run(adb, launch_args) + steps.append({"step": "launch", "command": adb, "args": launch_args, "exit_code": launch_result["exit_code"], "stdout": launch_result["stdout"]}) + + return success({ + "preset": preset["name"], + "apk_path": export_path_abs, + "device": device_serial if not device_serial.is_empty() else "(default)", + "package_name": preset["package_name"], + "steps": steps, + }) diff --git a/addons/godot_mcp/commands/android_commands.gd.uid b/addons/godot_mcp/commands/android_commands.gd.uid new file mode 100644 index 0000000..1b71849 --- /dev/null +++ b/addons/godot_mcp/commands/android_commands.gd.uid @@ -0,0 +1 @@ +uid://bdigkr0emai80 diff --git a/addons/godot_mcp/commands/animation_commands.gd b/addons/godot_mcp/commands/animation_commands.gd new file mode 100644 index 0000000..8ebeac0 --- /dev/null +++ b/addons/godot_mcp/commands/animation_commands.gd @@ -0,0 +1,298 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_animations": _list_animations, + "create_animation": _create_animation, + "add_animation_track": _add_animation_track, + "set_animation_keyframe": _set_animation_keyframe, + "get_animation_info": _get_animation_info, + "remove_animation": _remove_animation, + } + + +func _find_animation_player(node_path: String) -> AnimationPlayer: + var node := find_node_by_path(node_path) + if node is AnimationPlayer: + return node as AnimationPlayer + return null + + +func _list_animations(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var animations: Array = [] + for anim_name in player.get_animation_list(): + var anim := player.get_animation(anim_name) + animations.append({ + "name": anim_name, + "length": anim.length, + "loop_mode": anim.loop_mode, + "track_count": anim.get_track_count(), + }) + + return success({"node_path": node_path, "animations": animations, "count": animations.size()}) + + +func _create_animation(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var length: float = float(params.get("length", 1.0)) + var loop_mode: int = int(params.get("loop_mode", 0)) # 0=none, 1=linear, 2=pingpong + + var anim := Animation.new() + anim.length = length + anim.loop_mode = loop_mode as Animation.LoopMode + + var lib := player.get_animation_library("") + var created_library := false + if lib == null: + lib = AnimationLibrary.new() + created_library = true + + if lib.has_animation(anim_name): + return error_invalid_params("Animation '%s' already exists" % anim_name) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Create animation %s" % anim_name) + if created_library: + undo_redo.add_do_method(player, "add_animation_library", "", lib) + undo_redo.add_do_reference(lib) + undo_redo.add_undo_method(player, "remove_animation_library", "") + undo_redo.add_do_method(lib, "add_animation", anim_name, anim) + undo_redo.add_do_reference(anim) + undo_redo.add_undo_method(lib, "remove_animation", anim_name) + undo_redo.commit_action() + + return success({"name": anim_name, "length": length, "created": true}) + + +func _add_animation_track(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "animation") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var result3 := require_string(params, "track_path") + if result3[1] != null: + return result3[1] + var track_path: String = result3[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var anim := player.get_animation(anim_name) + if anim == null: + return error_not_found("Animation '%s'" % anim_name) + + var track_type_str: String = optional_string(params, "track_type", "value") + var track_type: int + match track_type_str: + "value": track_type = Animation.TYPE_VALUE + "position_2d": track_type = Animation.TYPE_POSITION_3D # Godot uses 3D type for 2D too + "rotation_2d": track_type = Animation.TYPE_ROTATION_3D + "scale_2d": track_type = Animation.TYPE_SCALE_3D + "method": track_type = Animation.TYPE_METHOD + "bezier": track_type = Animation.TYPE_BEZIER + "blend_shape": track_type = Animation.TYPE_BLEND_SHAPE + _: track_type = Animation.TYPE_VALUE + + var track_idx := anim.get_track_count() + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add animation track") + undo_redo.add_do_method(anim, "add_track", track_type, track_idx) + undo_redo.add_do_method(anim, "track_set_path", track_idx, NodePath(track_path)) + + var update_mode_str: String = optional_string(params, "update_mode", "") + if not update_mode_str.is_empty() and track_type == Animation.TYPE_VALUE: + match update_mode_str: + "continuous": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CONTINUOUS) + "discrete": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_DISCRETE) + "capture": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CAPTURE) + undo_redo.add_undo_method(anim, "remove_track", track_idx) + undo_redo.commit_action() + + return success({"track_index": track_idx, "track_path": track_path, "track_type": track_type_str}) + + +func _set_animation_keyframe(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "animation") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var anim := player.get_animation(anim_name) + if anim == null: + return error_not_found("Animation '%s'" % anim_name) + + var track_index: int = int(params.get("track_index", 0)) + if track_index < 0 or track_index >= anim.get_track_count(): + return error_invalid_params("Invalid track_index: %d" % track_index) + + var time: float = float(params.get("time", 0.0)) + var value = params.get("value") + + # Parse value string for common types + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + var easing: float = float(params.get("easing", 1.0)) + var old_key_idx := _find_animation_key_at_time(anim, track_index, time) + var had_old_key := old_key_idx >= 0 + var old_value: Variant = anim.track_get_key_value(track_index, old_key_idx) if had_old_key else null + var old_easing: float = anim.track_get_key_transition(track_index, old_key_idx) if had_old_key else 1.0 + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set animation keyframe") + undo_redo.add_do_method(self, "_upsert_animation_key", anim, track_index, time, value, easing) + undo_redo.add_undo_method(self, "_restore_animation_key", anim, track_index, time, had_old_key, old_value, old_easing) + undo_redo.commit_action() + + var key_idx := _find_animation_key_at_time(anim, track_index, time) + + return success({"track_index": track_index, "time": time, "key_index": key_idx, "easing": anim.track_get_key_transition(track_index, key_idx)}) + + +func _get_animation_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "animation") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var anim := player.get_animation(anim_name) + if anim == null: + return error_not_found("Animation '%s'" % anim_name) + + var tracks: Array = [] + for i in anim.get_track_count(): + var track_info := { + "index": i, + "path": str(anim.track_get_path(i)), + "type": anim.track_get_type(i), + "key_count": anim.track_get_key_count(i), + } + var keys: Array = [] + for k in anim.track_get_key_count(i): + keys.append({ + "time": anim.track_get_key_time(i, k), + "value": str(anim.track_get_key_value(i, k)), + "easing": anim.track_get_key_transition(i, k), + }) + track_info["keys"] = keys + tracks.append(track_info) + + return success({ + "name": anim_name, + "length": anim.length, + "loop_mode": anim.loop_mode, + "step": anim.step, + "tracks": tracks, + }) + + +func _remove_animation(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var lib := player.get_animation_library("") + if lib == null or not lib.has_animation(anim_name): + return error_not_found("Animation '%s'" % anim_name) + + var anim := lib.get_animation(anim_name) + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Remove animation %s" % anim_name) + undo_redo.add_do_method(lib, "remove_animation", anim_name) + undo_redo.add_undo_method(lib, "add_animation", anim_name, anim) + undo_redo.add_undo_reference(anim) + undo_redo.commit_action() + return success({"name": anim_name, "removed": true}) + + +func _find_animation_key_at_time(anim: Animation, track_index: int, time: float) -> int: + for key_index: int in anim.track_get_key_count(track_index): + if is_equal_approx(anim.track_get_key_time(track_index, key_index), time): + return key_index + return -1 + + +func _upsert_animation_key(anim: Animation, track_index: int, time: float, value: Variant, easing: float) -> void: + var key_idx := _find_animation_key_at_time(anim, track_index, time) + if key_idx < 0: + key_idx = anim.track_insert_key(track_index, time, value) + else: + anim.track_set_key_value(track_index, key_idx, value) + if easing != 1.0: + anim.track_set_key_transition(track_index, key_idx, easing) + + +func _restore_animation_key(anim: Animation, track_index: int, time: float, had_old_key: bool, old_value: Variant, old_easing: float) -> void: + var key_idx := _find_animation_key_at_time(anim, track_index, time) + if had_old_key: + if key_idx < 0: + key_idx = anim.track_insert_key(track_index, time, old_value) + else: + anim.track_set_key_value(track_index, key_idx, old_value) + anim.track_set_key_transition(track_index, key_idx, old_easing) + elif key_idx >= 0: + anim.track_remove_key(track_index, key_idx) diff --git a/addons/godot_mcp/commands/animation_commands.gd.uid b/addons/godot_mcp/commands/animation_commands.gd.uid new file mode 100644 index 0000000..647f401 --- /dev/null +++ b/addons/godot_mcp/commands/animation_commands.gd.uid @@ -0,0 +1 @@ +uid://bonthf2yoeyhw diff --git a/addons/godot_mcp/commands/animation_tree_commands.gd b/addons/godot_mcp/commands/animation_tree_commands.gd new file mode 100644 index 0000000..52954c3 --- /dev/null +++ b/addons/godot_mcp/commands/animation_tree_commands.gd @@ -0,0 +1,601 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_animation_tree": _create_animation_tree, + "get_animation_tree_structure": _get_animation_tree_structure, + "add_state_machine_state": _add_state_machine_state, + "remove_state_machine_state": _remove_state_machine_state, + "add_state_machine_transition": _add_state_machine_transition, + "remove_state_machine_transition": _remove_state_machine_transition, + "set_blend_tree_node": _set_blend_tree_node, + "set_tree_parameter": _set_tree_parameter, + } + + +## Find AnimationTree on a node or return null +func _find_animation_tree(node_path: String) -> AnimationTree: + var node := find_node_by_path(node_path) + if node is AnimationTree: + return node as AnimationTree + return null + + +## Navigate to a nested state machine by slash-separated path (e.g. "Run/SubState") +## Returns [state_machine, error_or_null] +func _resolve_state_machine(tree: AnimationTree, sm_path: String) -> Array: + var root := tree.tree_root + if not root is AnimationNodeStateMachine: + return [null, error_invalid_params("AnimationTree root is not an AnimationNodeStateMachine")] + + if sm_path.is_empty() or sm_path == ".": + return [root as AnimationNodeStateMachine, null] + + var current: AnimationNodeStateMachine = root as AnimationNodeStateMachine + var parts := sm_path.split("/") + for part in parts: + if not current.has_node(StringName(part)): + return [null, error_not_found("State machine node '%s' in path '%s'" % [part, sm_path])] + var child := current.get_node(StringName(part)) + if not child is AnimationNodeStateMachine: + return [null, error_invalid_params("Node '%s' is not a StateMachine" % part)] + current = child as AnimationNodeStateMachine + return [current, null] + + +## Resolve a BlendTree inside the tree. bt_path can be a state name inside a state machine, +## or a slash-separated path. The last segment is the BlendTree node name. +## Returns [blend_tree, error_or_null] +func _resolve_blend_tree(tree: AnimationTree, sm_path: String, bt_name: String) -> Array: + var result := _resolve_state_machine(tree, sm_path) + if result[1] != null: + return result + + var sm: AnimationNodeStateMachine = result[0] + if not sm.has_node(StringName(bt_name)): + return [null, error_not_found("BlendTree node '%s'" % bt_name)] + + var node := sm.get_node(StringName(bt_name)) + if not node is AnimationNodeBlendTree: + return [null, error_invalid_params("Node '%s' is not an AnimationNodeBlendTree" % bt_name)] + + return [node as AnimationNodeBlendTree, null] + + +func _create_animation_tree(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent := find_node_by_path(node_path) + if parent == null: + return error_not_found("Node at '%s'" % node_path) + + var anim_player_path: String = optional_string(params, "anim_player", "") + var tree_name: String = optional_string(params, "name", "AnimationTree") + + # Create the AnimationTree + var tree := AnimationTree.new() + tree.name = tree_name + + # Set root to AnimationNodeStateMachine + var state_machine := AnimationNodeStateMachine.new() + tree.tree_root = state_machine + + # Link to AnimationPlayer if provided + if not anim_player_path.is_empty(): + tree.anim_player = NodePath(anim_player_path) + + add_child_with_undo(parent, tree, root, "MCP: Create AnimationTree") + + return success({ + "name": tree.name, + "node_path": str(root.get_path_to(tree)), + "root_type": "AnimationNodeStateMachine", + "anim_player": anim_player_path, + "created": true, + }) + + +func _get_animation_tree_structure(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var root := tree.tree_root + if root == null: + return success({"node_path": node_path, "root": null}) + + var structure := _read_node_structure(root) + structure["active"] = tree.active + structure["anim_player"] = str(tree.anim_player) + structure["node_path"] = node_path + + return success(structure) + + +func _read_node_structure(node: AnimationNode) -> Dictionary: + if node is AnimationNodeStateMachine: + return _read_state_machine_structure(node as AnimationNodeStateMachine) + elif node is AnimationNodeBlendTree: + return _read_blend_tree_structure(node as AnimationNodeBlendTree) + elif node is AnimationNodeAnimation: + var anim_node := node as AnimationNodeAnimation + return {"type": "AnimationNodeAnimation", "animation": str(anim_node.animation)} + else: + return {"type": node.get_class()} + + +func _read_state_machine_structure(sm: AnimationNodeStateMachine) -> Dictionary: + var states: Array = [] + # Iterate through graph nodes via get_node_name + # AnimationNodeStateMachine doesn't have get_node_list in 4.x, iterate using _get_child_nodes + var node_list := _get_sm_node_names(sm) + for state_name in node_list: + var child := sm.get_node(StringName(state_name)) + var state_info := { + "name": state_name, + "position": {"x": sm.get_node_position(StringName(state_name)).x, "y": sm.get_node_position(StringName(state_name)).y}, + } + state_info.merge(_read_node_structure(child)) + states.append(state_info) + + var transitions: Array = [] + for i in sm.get_transition_count(): + var from_node := sm.get_transition_from(i) + var to_node := sm.get_transition_to(i) + var trans := sm.get_transition(i) + var trans_info := { + "from": str(from_node), + "to": str(to_node), + "switch_mode": trans.switch_mode, + "advance_mode": trans.advance_mode, + } + if not trans.advance_expression.is_empty(): + trans_info["advance_expression"] = trans.advance_expression + if trans.advance_mode == AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO: + trans_info["auto"] = true + transitions.append(trans_info) + + return { + "type": "AnimationNodeStateMachine", + "states": states, + "transitions": transitions, + } + + +func _get_sm_node_names(sm: AnimationNodeStateMachine) -> Array: + # Use the internal _get_child_nodes or iterate known patterns + # AnimationNodeStateMachine doesn't expose a simple list method, + # but we can use get_graph_offset and iterate via has_node with common checks. + # Actually in Godot 4.x we can get the node list by checking property list + # or using the script resource approach. The most reliable is iterating through + # the resource properties. + var names: Array = [] + var prop_list := sm.get_property_list() + for prop in prop_list: + var pname: String = prop["name"] + # State machine stores nodes as "states//node" + if pname.begins_with("states/") and pname.ends_with("/node"): + var state_name := pname.get_slice("/", 1) + if state_name != "Start" and state_name != "End": + names.append(state_name) + return names + + +func _read_blend_tree_structure(bt: AnimationNodeBlendTree) -> Dictionary: + var nodes_info: Array = [] + var prop_list := bt.get_property_list() + var node_names: Array = [] + for prop in prop_list: + var pname: String = prop["name"] + if pname.begins_with("nodes/") and pname.ends_with("/node"): + var n := pname.get_slice("/", 1) + if n != "output": + node_names.append(n) + + for n_name in node_names: + var child: AnimationNode = bt.get_node(StringName(n_name)) + var node_info := { + "name": n_name, + "type": child.get_class(), + "position": {"x": bt.get_node_position(StringName(n_name)).x, "y": bt.get_node_position(StringName(n_name)).y}, + } + if child is AnimationNodeAnimation: + node_info["animation"] = str((child as AnimationNodeAnimation).animation) + nodes_info.append(node_info) + + # Read connections + # BlendTree connections are stored as "node_connections" in properties + # We can read them from the resource property list + for prop in prop_list: + var pname: String = prop["name"] + if pname.begins_with("nodes/") and pname.ends_with("/node"): + continue + if pname.begins_with("nodes/") and pname.ends_with("/position"): + continue + # Connection format: "node_connection///" + # Actually connections are stored differently - let's skip for now + + return { + "type": "AnimationNodeBlendTree", + "nodes": nodes_info, + } + + +func _add_state_machine_state(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "state_name") + if result2[1] != null: + return result2[1] + var state_name: String = result2[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + if sm.has_node(StringName(state_name)): + return error_invalid_params("State '%s' already exists" % state_name) + + var state_type: String = optional_string(params, "state_type", "animation") + var position_x: float = float(params.get("position_x", 0.0)) + var position_y: float = float(params.get("position_y", 0.0)) + var position := Vector2(position_x, position_y) + + var node: AnimationNode + match state_type: + "animation": + var anim_node := AnimationNodeAnimation.new() + var anim_name: String = optional_string(params, "animation", "") + if not anim_name.is_empty(): + anim_node.animation = StringName(anim_name) + node = anim_node + "blend_tree": + node = AnimationNodeBlendTree.new() + "state_machine": + node = AnimationNodeStateMachine.new() + _: + return error_invalid_params("Unknown state_type: '%s'. Use 'animation', 'blend_tree', or 'state_machine'" % state_type) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add state machine state") + undo_redo.add_do_method(sm, "add_node", StringName(state_name), node, position) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(sm, "remove_node", StringName(state_name)) + undo_redo.commit_action() + + return success({ + "state_name": state_name, + "state_type": state_type, + "position": {"x": position_x, "y": position_y}, + "added": true, + }) + + +func _remove_state_machine_state(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "state_name") + if result2[1] != null: + return result2[1] + var state_name: String = result2[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + if not sm.has_node(StringName(state_name)): + return error_not_found("State '%s'" % state_name) + + var old_node := sm.get_node(StringName(state_name)) + var old_position := sm.get_node_position(StringName(state_name)) + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Remove state machine state") + undo_redo.add_do_method(sm, "remove_node", StringName(state_name)) + undo_redo.add_undo_method(sm, "add_node", StringName(state_name), old_node, old_position) + undo_redo.add_undo_reference(old_node) + undo_redo.commit_action() + + return success({"state_name": state_name, "removed": true}) + + +func _add_state_machine_transition(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "from_state") + if result2[1] != null: + return result2[1] + var from_state: String = result2[0] + + var result3 := require_string(params, "to_state") + if result3[1] != null: + return result3[1] + var to_state: String = result3[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + # Validate states exist (Start and End are special built-in nodes) + if from_state != "Start" and from_state != "End" and not sm.has_node(StringName(from_state)): + return error_not_found("State '%s'" % from_state) + if to_state != "Start" and to_state != "End" and not sm.has_node(StringName(to_state)): + return error_not_found("State '%s'" % to_state) + + var transition := AnimationNodeStateMachineTransition.new() + + # switch_mode: AT_END=0, IMMEDIATE=1, SYNC=2 + var switch_mode_str: String = optional_string(params, "switch_mode", "immediate") + match switch_mode_str: + "at_end": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END + "immediate": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE + "sync": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END # SYNC maps similarly + _: transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE + + # advance_mode: DISABLED=0, ENABLED=1, AUTO=2 + var advance_mode_str: String = optional_string(params, "advance_mode", "enabled") + match advance_mode_str: + "disabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED + "enabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED + "auto": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO + _: transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED + + # advance_expression + var expression: String = optional_string(params, "advance_expression", "") + if not expression.is_empty(): + transition.advance_expression = expression + + # xfade_time + if params.has("xfade_time"): + transition.xfade_time = float(params["xfade_time"]) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add state machine transition") + undo_redo.add_do_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition) + undo_redo.add_do_reference(transition) + undo_redo.add_undo_method(sm, "remove_transition", StringName(from_state), StringName(to_state)) + undo_redo.commit_action() + + return success({ + "from": from_state, + "to": to_state, + "switch_mode": switch_mode_str, + "advance_mode": advance_mode_str, + "advance_expression": expression, + "added": true, + }) + + +func _remove_state_machine_transition(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "from_state") + if result2[1] != null: + return result2[1] + var from_state: String = result2[0] + + var result3 := require_string(params, "to_state") + if result3[1] != null: + return result3[1] + var to_state: String = result3[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + # Check if transition exists + var found := false + for i in sm.get_transition_count(): + if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state: + found = true + break + + if not found: + return error_not_found("Transition from '%s' to '%s'" % [from_state, to_state]) + + var transition: AnimationNodeStateMachineTransition = null + for i in sm.get_transition_count(): + if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state: + transition = sm.get_transition(i) + break + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Remove state machine transition") + undo_redo.add_do_method(sm, "remove_transition", StringName(from_state), StringName(to_state)) + undo_redo.add_undo_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition) + undo_redo.add_undo_reference(transition) + undo_redo.commit_action() + + return success({"from": from_state, "to": to_state, "removed": true}) + + +func _set_blend_tree_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "blend_tree_state") + if result2[1] != null: + return result2[1] + var bt_state: String = result2[0] + + var result3 := require_string(params, "bt_node_name") + if result3[1] != null: + return result3[1] + var bt_node_name: String = result3[0] + + var result4 := require_string(params, "bt_node_type") + if result4[1] != null: + return result4[1] + var bt_node_type: String = result4[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var bt_result := _resolve_blend_tree(tree, sm_path, bt_state) + if bt_result[1] != null: + return bt_result[1] + var bt: AnimationNodeBlendTree = bt_result[0] + + var position_x: float = float(params.get("position_x", 0.0)) + var position_y: float = float(params.get("position_y", 0.0)) + var position := Vector2(position_x, position_y) + + var had_old_node := bt.has_node(StringName(bt_node_name)) + var old_node: AnimationNode = bt.get_node(StringName(bt_node_name)) if had_old_node else null + var old_position := bt.get_node_position(StringName(bt_node_name)) if had_old_node else Vector2.ZERO + + var node: AnimationNode + match bt_node_type: + "Animation": + var anim_node := AnimationNodeAnimation.new() + var anim_name: String = optional_string(params, "animation", "") + if not anim_name.is_empty(): + anim_node.animation = StringName(anim_name) + node = anim_node + "Add2": + node = AnimationNodeAdd2.new() + "Blend2": + node = AnimationNodeBlend2.new() + "Add3": + node = AnimationNodeAdd3.new() + "Blend3": + node = AnimationNodeBlend3.new() + "TimeScale": + node = AnimationNodeTimeScale.new() + "TimeSeek": + node = AnimationNodeTimeSeek.new() + "Transition": + node = AnimationNodeTransition.new() + "OneShot": + node = AnimationNodeOneShot.new() + "Sub2": + node = AnimationNodeSub2.new() + _: + return error_invalid_params("Unknown bt_node_type: '%s'. Use: Animation, Add2, Blend2, Add3, Blend3, TimeScale, TimeSeek, Transition, OneShot, Sub2" % bt_node_type) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set blend tree node") + if had_old_node: + undo_redo.add_do_method(bt, "remove_node", StringName(bt_node_name)) + undo_redo.add_undo_method(bt, "add_node", StringName(bt_node_name), old_node, old_position) + undo_redo.add_undo_reference(old_node) + undo_redo.add_do_method(bt, "add_node", StringName(bt_node_name), node, position) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(bt, "remove_node", StringName(bt_node_name)) + + # Connect to another node if specified + var connect_to: String = optional_string(params, "connect_to", "") + var connect_port: int = optional_int(params, "connect_port", 0) + if not connect_to.is_empty(): + undo_redo.add_do_method(bt, "connect_node", StringName(connect_to), connect_port, StringName(bt_node_name)) + undo_redo.commit_action() + + var connected_to_value: Variant = null + if not connect_to.is_empty(): + connected_to_value = connect_to + return success({ + "blend_tree_state": bt_state, + "bt_node_name": bt_node_name, + "bt_node_type": bt_node_type, + "position": {"x": position_x, "y": position_y}, + "connected_to": connected_to_value, + "added": true, + }) + + +func _set_tree_parameter(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "parameter") + if result2[1] != null: + return result2[1] + var parameter: String = result2[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + + var value = params["value"] + + # Prefix with "parameters/" if not already + if not parameter.begins_with("parameters/"): + parameter = "parameters/" + parameter + + # Parse string values for common types + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + set_property_with_undo(tree, parameter, value, "MCP: Set AnimationTree parameter") + + # Read back to confirm + var actual = tree.get(parameter) + + return success({ + "parameter": parameter, + "value": str(actual), + "set": true, + }) diff --git a/addons/godot_mcp/commands/animation_tree_commands.gd.uid b/addons/godot_mcp/commands/animation_tree_commands.gd.uid new file mode 100644 index 0000000..54f210c --- /dev/null +++ b/addons/godot_mcp/commands/animation_tree_commands.gd.uid @@ -0,0 +1 @@ +uid://bcqa6ux42fs1w diff --git a/addons/godot_mcp/commands/audio_commands.gd b/addons/godot_mcp/commands/audio_commands.gd new file mode 100644 index 0000000..14359cd --- /dev/null +++ b/addons/godot_mcp/commands/audio_commands.gd @@ -0,0 +1,431 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_audio_bus_layout": _get_audio_bus_layout, + "add_audio_bus": _add_audio_bus, + "set_audio_bus": _set_audio_bus, + "add_audio_bus_effect": _add_audio_bus_effect, + "add_audio_player": _add_audio_player, + "get_audio_info": _get_audio_info, + } + + +func _get_audio_bus_layout(_params: Dictionary) -> Dictionary: + var buses: Array[Dictionary] = [] + for i in range(AudioServer.bus_count): + var bus_data := { + "index": i, + "name": AudioServer.get_bus_name(i), + "volume_db": AudioServer.get_bus_volume_db(i), + "solo": AudioServer.is_bus_solo(i), + "mute": AudioServer.is_bus_mute(i), + "bypass_effects": AudioServer.is_bus_bypassing_effects(i), + "send": AudioServer.get_bus_send(i), + "effects": [], + } + var effects: Array[Dictionary] = [] + for j in range(AudioServer.get_bus_effect_count(i)): + var effect := AudioServer.get_bus_effect(i, j) + var effect_data := { + "index": j, + "type": effect.get_class(), + "enabled": AudioServer.is_bus_effect_enabled(i, j), + } + # Include effect-specific parameters + effect_data["params"] = _get_effect_params(effect) + effects.append(effect_data) + bus_data["effects"] = effects + buses.append(bus_data) + return success({"bus_count": AudioServer.bus_count, "buses": buses}) + + +func _get_effect_params(effect: AudioEffect) -> Dictionary: + var params := {} + if effect is AudioEffectReverb: + var rev := effect as AudioEffectReverb + params = {"room_size": rev.room_size, "damping": rev.damping, "wet": rev.wet, "dry": rev.dry, "spread": rev.spread} + elif effect is AudioEffectDelay: + var d := effect as AudioEffectDelay + params = {"tap1_active": d.tap1_active, "tap1_delay_ms": d.tap1_delay_ms, "tap1_level_db": d.tap1_level_db, "tap2_active": d.tap2_active, "tap2_delay_ms": d.tap2_delay_ms, "tap2_level_db": d.tap2_level_db} + elif effect is AudioEffectCompressor: + var c := effect as AudioEffectCompressor + params = {"threshold": c.threshold, "ratio": c.ratio, "attack_us": c.attack_us, "release_ms": c.release_ms, "gain": c.gain, "mix": c.mix, "sidechain": c.sidechain} + elif effect is AudioEffectLimiter: + var l := effect as AudioEffectLimiter + params = {"ceiling_db": l.ceiling_db, "threshold_db": l.threshold_db, "soft_clip_db": l.soft_clip_db, "soft_clip_ratio": l.soft_clip_ratio} + elif effect is AudioEffectDistortion: + var dist := effect as AudioEffectDistortion + params = {"mode": dist.mode, "pre_gain": dist.pre_gain, "post_gain": dist.post_gain, "keep_hf_hz": dist.keep_hf_hz, "drive": dist.drive} + elif effect is AudioEffectChorus: + var ch := effect as AudioEffectChorus + params = {"voice_count": ch.voice_count, "dry": ch.dry, "wet": ch.wet} + elif effect is AudioEffectPhaser: + var ph := effect as AudioEffectPhaser + params = {"range_min_hz": ph.range_min_hz, "range_max_hz": ph.range_max_hz, "rate_hz": ph.rate_hz, "feedback": ph.feedback, "depth": ph.depth} + elif effect is AudioEffectFilter: + # Covers LowPassFilter, HighPassFilter, BandPassFilter, etc. + var f := effect as AudioEffectFilter + params = {"cutoff_hz": f.cutoff_hz, "resonance": f.resonance, "gain": f.gain, "db": f.db} + elif effect is AudioEffectAmplify: + var a := effect as AudioEffectAmplify + params = {"volume_db": a.volume_db} + return params + + +func _add_audio_bus(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var bus_name: String = result[0] + + # Check if bus name already exists + for i in range(AudioServer.bus_count): + if AudioServer.get_bus_name(i) == bus_name: + return error_invalid_params("Audio bus '%s' already exists at index %d" % [bus_name, i]) + + var at_position: int = optional_int(params, "at_position", -1) + AudioServer.add_bus(at_position) + + var idx: int = AudioServer.bus_count - 1 if at_position < 0 else at_position + AudioServer.set_bus_name(idx, bus_name) + + if params.has("volume_db"): + AudioServer.set_bus_volume_db(idx, float(params["volume_db"])) + + var send: String = optional_string(params, "send", "") + if not send.is_empty(): + AudioServer.set_bus_send(idx, send) + + if params.has("solo"): + AudioServer.set_bus_solo(idx, bool(params["solo"])) + + if params.has("mute"): + AudioServer.set_bus_mute(idx, bool(params["mute"])) + + return success({"name": bus_name, "index": idx, "bus_count": AudioServer.bus_count}) + + +func _set_audio_bus(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var bus_name: String = result[0] + + var idx := AudioServer.get_bus_index(bus_name) + if idx < 0: + return error_not_found("Audio bus '%s'" % bus_name) + + var changes := 0 + + if params.has("volume_db"): + AudioServer.set_bus_volume_db(idx, float(params["volume_db"])) + changes += 1 + + if params.has("solo"): + AudioServer.set_bus_solo(idx, bool(params["solo"])) + changes += 1 + + if params.has("mute"): + AudioServer.set_bus_mute(idx, bool(params["mute"])) + changes += 1 + + if params.has("bypass_effects"): + AudioServer.set_bus_bypass_effects(idx, bool(params["bypass_effects"])) + changes += 1 + + var send: String = optional_string(params, "send", "") + if not send.is_empty(): + AudioServer.set_bus_send(idx, send) + changes += 1 + + if params.has("rename"): + var new_name: String = str(params["rename"]) + AudioServer.set_bus_name(idx, new_name) + bus_name = new_name + changes += 1 + + return success({"name": bus_name, "index": idx, "changes": changes}) + + +func _add_audio_bus_effect(params: Dictionary) -> Dictionary: + var result := require_string(params, "bus") + if result[1] != null: + return result[1] + var bus_name: String = result[0] + + var result2 := require_string(params, "effect_type") + if result2[1] != null: + return result2[1] + var effect_type: String = result2[0] + + var bus_idx := AudioServer.get_bus_index(bus_name) + if bus_idx < 0: + return error_not_found("Audio bus '%s'" % bus_name) + + var effect: AudioEffect = null + var effect_params: Dictionary = params.get("params", {}) if params.has("params") else {} + + match effect_type.to_lower(): + "reverb": + var e := AudioEffectReverb.new() + if effect_params.has("room_size"): + e.room_size = float(effect_params["room_size"]) + if effect_params.has("damping"): + e.damping = float(effect_params["damping"]) + if effect_params.has("wet"): + e.wet = float(effect_params["wet"]) + if effect_params.has("dry"): + e.dry = float(effect_params["dry"]) + if effect_params.has("spread"): + e.spread = float(effect_params["spread"]) + effect = e + "chorus": + var e := AudioEffectChorus.new() + if effect_params.has("voice_count"): + e.voice_count = int(effect_params["voice_count"]) + if effect_params.has("dry"): + e.dry = float(effect_params["dry"]) + if effect_params.has("wet"): + e.wet = float(effect_params["wet"]) + effect = e + "delay": + var e := AudioEffectDelay.new() + if effect_params.has("tap1_active"): + e.tap1_active = bool(effect_params["tap1_active"]) + if effect_params.has("tap1_delay_ms"): + e.tap1_delay_ms = float(effect_params["tap1_delay_ms"]) + if effect_params.has("tap1_level_db"): + e.tap1_level_db = float(effect_params["tap1_level_db"]) + if effect_params.has("tap2_active"): + e.tap2_active = bool(effect_params["tap2_active"]) + if effect_params.has("tap2_delay_ms"): + e.tap2_delay_ms = float(effect_params["tap2_delay_ms"]) + if effect_params.has("tap2_level_db"): + e.tap2_level_db = float(effect_params["tap2_level_db"]) + effect = e + "compressor": + var e := AudioEffectCompressor.new() + if effect_params.has("threshold"): + e.threshold = float(effect_params["threshold"]) + if effect_params.has("ratio"): + e.ratio = float(effect_params["ratio"]) + if effect_params.has("attack_us"): + e.attack_us = float(effect_params["attack_us"]) + if effect_params.has("release_ms"): + e.release_ms = float(effect_params["release_ms"]) + if effect_params.has("gain"): + e.gain = float(effect_params["gain"]) + if effect_params.has("mix"): + e.mix = float(effect_params["mix"]) + effect = e + "limiter": + var e := AudioEffectLimiter.new() + if effect_params.has("ceiling_db"): + e.ceiling_db = float(effect_params["ceiling_db"]) + if effect_params.has("threshold_db"): + e.threshold_db = float(effect_params["threshold_db"]) + if effect_params.has("soft_clip_db"): + e.soft_clip_db = float(effect_params["soft_clip_db"]) + if effect_params.has("soft_clip_ratio"): + e.soft_clip_ratio = float(effect_params["soft_clip_ratio"]) + effect = e + "phaser": + var e := AudioEffectPhaser.new() + if effect_params.has("range_min_hz"): + e.range_min_hz = float(effect_params["range_min_hz"]) + if effect_params.has("range_max_hz"): + e.range_max_hz = float(effect_params["range_max_hz"]) + if effect_params.has("rate_hz"): + e.rate_hz = float(effect_params["rate_hz"]) + if effect_params.has("feedback"): + e.feedback = float(effect_params["feedback"]) + if effect_params.has("depth"): + e.depth = float(effect_params["depth"]) + effect = e + "distortion": + var e := AudioEffectDistortion.new() + if effect_params.has("mode"): + e.mode = int(effect_params["mode"]) as AudioEffectDistortion.Mode + if effect_params.has("pre_gain"): + e.pre_gain = float(effect_params["pre_gain"]) + if effect_params.has("post_gain"): + e.post_gain = float(effect_params["post_gain"]) + if effect_params.has("keep_hf_hz"): + e.keep_hf_hz = float(effect_params["keep_hf_hz"]) + if effect_params.has("drive"): + e.drive = float(effect_params["drive"]) + effect = e + "lowpassfilter", "lowpass": + var e := AudioEffectLowPassFilter.new() + if effect_params.has("cutoff_hz"): + e.cutoff_hz = float(effect_params["cutoff_hz"]) + if effect_params.has("resonance"): + e.resonance = float(effect_params["resonance"]) + effect = e + "highpassfilter", "highpass": + var e := AudioEffectHighPassFilter.new() + if effect_params.has("cutoff_hz"): + e.cutoff_hz = float(effect_params["cutoff_hz"]) + if effect_params.has("resonance"): + e.resonance = float(effect_params["resonance"]) + effect = e + "bandpassfilter", "bandpass": + var e := AudioEffectBandPassFilter.new() + if effect_params.has("cutoff_hz"): + e.cutoff_hz = float(effect_params["cutoff_hz"]) + if effect_params.has("resonance"): + e.resonance = float(effect_params["resonance"]) + effect = e + "amplify": + var e := AudioEffectAmplify.new() + if effect_params.has("volume_db"): + e.volume_db = float(effect_params["volume_db"]) + effect = e + "eq": + var e := AudioEffectEQ.new() + effect = e + _: + return error_invalid_params("Unknown effect type: '%s'. Valid types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq" % effect_type) + + var at_position: int = optional_int(params, "at_position", -1) + AudioServer.add_bus_effect(bus_idx, effect, at_position) + + var effect_idx: int = AudioServer.get_bus_effect_count(bus_idx) - 1 if at_position < 0 else at_position + return success({"bus": bus_name, "bus_index": bus_idx, "effect_type": effect.get_class(), "effect_index": effect_idx}) + + +func _add_audio_player(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var player_name: String = result2[0] + + var player_type: String = optional_string(params, "type", "AudioStreamPlayer") + var valid_types := ["AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D"] + if player_type not in valid_types: + return error_invalid_params("Invalid player type '%s'. Valid: %s" % [player_type, ", ".join(valid_types)]) + + var parent := find_node_by_path(node_path) + if parent == null: + return error_not_found("Node at '%s'" % node_path) + var root := get_edited_root() + if root == null: + return error_no_scene() + + var player: Node = null + match player_type: + "AudioStreamPlayer": + player = AudioStreamPlayer.new() + "AudioStreamPlayer2D": + player = AudioStreamPlayer2D.new() + "AudioStreamPlayer3D": + player = AudioStreamPlayer3D.new() + + player.name = player_name + + # Set stream if provided + var stream_path: String = optional_string(params, "stream", "") + if not stream_path.is_empty(): + if ResourceLoader.exists(stream_path): + var stream = ResourceLoader.load(stream_path) + if stream is AudioStream: + player.set("stream", stream) + else: + player.queue_free() + return error_invalid_params("Resource at '%s' is not an AudioStream" % stream_path) + else: + player.queue_free() + return error_not_found("Audio stream at '%s'" % stream_path) + + # Common properties + if params.has("volume_db"): + player.set("volume_db", float(params["volume_db"])) + + var bus: String = optional_string(params, "bus", "") + if not bus.is_empty(): + player.set("bus", bus) + + if params.has("autoplay"): + player.set("autoplay", bool(params["autoplay"])) + + # 2D-specific properties + if player is AudioStreamPlayer2D: + if params.has("max_distance"): + (player as AudioStreamPlayer2D).max_distance = float(params["max_distance"]) + if params.has("attenuation"): + (player as AudioStreamPlayer2D).attenuation = float(params["attenuation"]) + + # 3D-specific properties + if player is AudioStreamPlayer3D: + if params.has("max_distance"): + (player as AudioStreamPlayer3D).max_distance = float(params["max_distance"]) + if params.has("attenuation_model"): + (player as AudioStreamPlayer3D).attenuation_model = int(params["attenuation_model"]) as AudioStreamPlayer3D.AttenuationModel + if params.has("unit_size"): + (player as AudioStreamPlayer3D).unit_size = float(params["unit_size"]) + + add_child_with_undo(parent, player, root, "MCP: Add audio player") + + return success({ + "name": player_name, + "type": player_type, + "parent": node_path, + "stream": stream_path, + "bus": player.get("bus"), + "volume_db": player.get("volume_db"), + "autoplay": player.get("autoplay"), + }) + + +func _get_audio_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var players: Array[Dictionary] = [] + _collect_audio_players(node, players) + + return success({"node_path": node_path, "audio_player_count": players.size(), "players": players}) + + +func _collect_audio_players(node: Node, result: Array[Dictionary]) -> void: + if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D: + var info := { + "name": node.name, + "path": str(get_edited_root().get_path_to(node)), + "type": node.get_class(), + "volume_db": node.get("volume_db"), + "bus": node.get("bus"), + "autoplay": node.get("autoplay"), + "playing": node.get("playing"), + "stream": "", + } + var stream = node.get("stream") + if stream != null and stream is AudioStream: + info["stream"] = stream.resource_path + + if node is AudioStreamPlayer2D: + info["max_distance"] = (node as AudioStreamPlayer2D).max_distance + info["attenuation"] = (node as AudioStreamPlayer2D).attenuation + elif node is AudioStreamPlayer3D: + info["max_distance"] = (node as AudioStreamPlayer3D).max_distance + info["attenuation_model"] = (node as AudioStreamPlayer3D).attenuation_model + info["unit_size"] = (node as AudioStreamPlayer3D).unit_size + + result.append(info) + + for child in node.get_children(): + _collect_audio_players(child, result) diff --git a/addons/godot_mcp/commands/audio_commands.gd.uid b/addons/godot_mcp/commands/audio_commands.gd.uid new file mode 100644 index 0000000..14e93c9 --- /dev/null +++ b/addons/godot_mcp/commands/audio_commands.gd.uid @@ -0,0 +1 @@ +uid://bijs5xgewwx3a diff --git a/addons/godot_mcp/commands/base_command.gd b/addons/godot_mcp/commands/base_command.gd new file mode 100644 index 0000000..2c48a74 --- /dev/null +++ b/addons/godot_mcp/commands/base_command.gd @@ -0,0 +1,485 @@ +@tool +extends Node + +var editor_plugin: EditorPlugin + + +## Override in subclasses: return {"method_name": Callable} +func get_commands() -> Dictionary: + return {} + + +## Helper: return a success result +func success(data: Dictionary = {}) -> Dictionary: + return {"result": data} + + +## Helper: return an error +func error(code: int, message: String, data: Dictionary = {}) -> Dictionary: + var err := {"code": code, "message": message} + if not data.is_empty(): + err["data"] = data + return {"error": err} + + +## Error codes +func error_not_found(what: String, suggestion: String = "") -> Dictionary: + var data := {} + if suggestion: + data["suggestion"] = suggestion + return error(-32001, "%s not found" % what, data) + + +func error_invalid_params(message: String) -> Dictionary: + return error(-32602, message) + + +func error_no_scene() -> Dictionary: + return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"}) + + +func error_internal(message: String) -> Dictionary: + return error(-32603, "Internal error: %s" % message) + + +func error_conflict(message: String, data: Dictionary = {}) -> Dictionary: + return error(-32009, message, data) + + +## Get required string param +func require_string(params: Dictionary, key: String) -> Array: + if not params.has(key) or not params[key] is String or (params[key] as String).is_empty(): + return [null, error_invalid_params("Missing required parameter: %s" % key)] + return [params[key] as String, null] + + +## Get optional string param with default +func optional_string(params: Dictionary, key: String, default: String = "") -> String: + if params.has(key) and params[key] is String: + return params[key] as String + return default + + +## Get optional bool param with default +func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool: + if params.has(key) and params[key] is bool: + return params[key] as bool + return default + + +## Get optional int param with default +func optional_int(params: Dictionary, key: String, default: int = 0) -> int: + if params.has(key): + return int(params[key]) + return default + + +## Get the game process's user data directory. +## OS.get_user_data_dir() is cached at editor startup and won't reflect +## project name changes made to project.godot while the editor is running. +## The game process reads the name from disk, so we must do the same. +func get_game_user_dir() -> String: + var cached_dir := OS.get_user_data_dir() + var cfg := ConfigFile.new() + var err := cfg.load(ProjectSettings.globalize_path("res://project.godot")) + if err != OK: + return cached_dir + # When use_custom_user_dir=true, editor and game share the same dir + # (OS.get_user_data_dir() already resolves to the custom path). + if cfg.get_value("application", "config/use_custom_user_dir", false): + return cached_dir + var disk_name = cfg.get_value("application", "config/name", "") + if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty(): + return cached_dir + # Sanitize exactly like Godot does when computing the default user dir + # (core/config/project_settings.cpp ProjectSettings::_init). + var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_") + if sanitized.is_empty(): + return cached_dir + var base_dir := cached_dir.get_base_dir() + var game_dir := base_dir.path_join(sanitized) + # Ensure the directory exists (game may not have created it yet) + if not DirAccess.dir_exists_absolute(game_dir): + DirAccess.make_dir_recursive_absolute(game_dir) + return game_dir + + +## Get EditorInterface +func get_editor() -> EditorInterface: + return editor_plugin.get_editor_interface() + + +## Get the edited scene root +func get_edited_root() -> Node: + return EditorInterface.get_edited_scene_root() + + +## Get UndoRedo +func get_undo_redo() -> EditorUndoRedoManager: + return editor_plugin.get_undo_redo() + + +func normalize_project_path(path: String) -> String: + if path.is_empty(): + return "" + if path.begins_with("res://") or path.begins_with("user://"): + return path.simplify_path() + return ProjectSettings.localize_path(path).simplify_path() + + +func is_scene_resource_path(path: String) -> bool: + var ext := path.get_extension().to_lower() + return ext == "tscn" or ext == "scn" + + +func get_open_scene_paths() -> Array[String]: + var paths: Array[String] = [] + var open_scenes: PackedStringArray = EditorInterface.get_open_scenes() + for scene_path: String in open_scenes: + var normalized := normalize_project_path(scene_path) + if not normalized.is_empty() and normalized not in paths: + paths.append(normalized) + + var root := get_edited_root() + if root != null and not root.scene_file_path.is_empty(): + var active_path := normalize_project_path(root.scene_file_path) + if active_path not in paths: + paths.append(active_path) + return paths + + +func is_scene_path_open(path: String) -> bool: + var normalized := normalize_project_path(path) + if normalized.is_empty(): + return false + return normalized in get_open_scene_paths() + + +func is_active_scene_path(path: String) -> bool: + var root := get_edited_root() + if root == null: + return false + return normalize_project_path(root.scene_file_path) == normalize_project_path(path) + + +func guard_offline_scene_save(path: String) -> Dictionary: + if is_scene_resource_path(path) and is_scene_path_open(path): + return error_conflict( + "Refusing to save open scene '%s' outside the Godot editor state" % normalize_project_path(path), + { + "path": normalize_project_path(path), + "open_scenes": get_open_scene_paths(), + "suggestion": "Use live editor changes plus save_scene, or close the scene before offline edits.", + } + ) + return {} + + +## Helper: create the parent directory of a res:// path if missing. +## Returns {} on success, an error dictionary on failure. +func ensure_parent_dir(path: String) -> Dictionary: + var dir := path.get_base_dir() + if dir.is_empty() or DirAccess.dir_exists_absolute(dir): + return {} + var derr := DirAccess.make_dir_recursive_absolute(dir) + if derr != OK: + return error_internal("Cannot create directory '%s': %s" % [dir, error_string(derr)]) + return {} + + +## Helper: unwrap the (possibly multi-)wrapped {"result": ...} envelope returned +## by the game IPC channel. The game writes its own {"result": ...} envelope and +## the transport wraps it again, so consumers must unwrap defensively. +func unwrap_game_result(result: Dictionary) -> Dictionary: + var payload: Variant = result + while payload is Dictionary and payload.has("result") and payload["result"] is Dictionary: + payload = payload["result"] + return payload if payload is Dictionary else {} + + +## Shared IPC helper: send a command to the running game and await its response. +func send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + + # Clean stale response + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) + + # Write request + var request_data := JSON.stringify({"command": command, "params": params}) + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create game request file") + req.store_string(request_data) + req.close() + + # Poll for response + var attempts := int(timeout_sec / 0.1) + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + if not ei.is_playing_scene(): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game stopped during command execution") + attempts -= 1 + + if not FileAccess.file_exists(response_path): + # Try to auto-resume the debugger (runtime error may have paused the game) + if ei.is_playing_scene(): + try_debugger_continue() + for _retry in 20: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + + if not FileAccess.file_exists(response_path): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return build_timeout_error(timeout_sec) + + # Read response + var file := FileAccess.open(response_path, FileAccess.READ) + if file == null: + return error_internal("Could not read game response file") + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(response_path) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + return error_internal("Invalid response JSON from game") + + if parsed.has("error"): + return error(-32000, str(parsed["error"])) + + return success(parsed) + + +func is_shader_resource_path(path: String) -> bool: + var ext := path.get_extension().to_lower() + return ext == "gdshader" or ext == "gdshaderinc" or ext == "shader" + + +func is_text_resource_open_in_script_editor(path: String) -> bool: + var target := normalize_project_path(path) + if target.is_empty(): + return false + if is_shader_resource_path(target) and ResourceLoader.has_cached(target): + return true + var script_editor := EditorInterface.get_script_editor() + if script_editor == null: + return false + for open_resource in script_editor.get_open_scripts(): + if open_resource is Resource: + var resource_path := normalize_project_path((open_resource as Resource).resource_path) + if resource_path == target: + return true + return false + + +func guard_text_resource_write(path: String, force: bool) -> Dictionary: + if not force and is_text_resource_open_in_script_editor(path): + return error_conflict( + "Refusing to write open text resource '%s' outside the script editor state" % normalize_project_path(path), + { + "path": normalize_project_path(path), + "suggestion": "Close the file in Godot's script editor or pass force=true to overwrite it deliberately.", + } + ) + return {} + + +func mark_current_scene_unsaved() -> void: + if EditorInterface.has_method("mark_scene_as_unsaved"): + EditorInterface.mark_scene_as_unsaved() + + +func add_child_with_undo(parent: Node, child: Node, root: Node, action_name: String) -> void: + var undo_redo := get_undo_redo() + undo_redo.create_action(action_name) + undo_redo.add_do_method(parent, "add_child", child) + undo_redo.add_do_method(child, "set_owner", root) + undo_redo.add_do_reference(child) + undo_redo.add_undo_method(parent, "remove_child", child) + undo_redo.commit_action() + + +func set_property_with_undo(target: Object, property: String, new_value: Variant, action_name: String) -> void: + var old_value: Variant = target.get(property) + var undo_redo := get_undo_redo() + undo_redo.create_action(action_name) + undo_redo.add_do_property(target, property, new_value) + if new_value is Resource: + undo_redo.add_do_reference(new_value) + undo_redo.add_undo_property(target, property, old_value) + if old_value is Resource: + undo_redo.add_undo_reference(old_value) + undo_redo.commit_action() + + +## ── Game-command timeout diagnostics ────────────────────────────────────────── +## Shared by the file-IPC `_send_game_command` helpers (runtime/test commands). +## The goal is to never tell the agent "the game isn't running / autoload missing" +## when the game IS running and merely paused by a runtime error. + +## Locate the editor's ScriptEditorDebugger node (BFS from base control). +func _find_script_editor_debugger() -> Node: + var base := EditorInterface.get_base_control() + if base == null: + return null + var queue: Array[Node] = [base] + while not queue.is_empty(): + var node := queue.pop_front() + if node.get_class() == "ScriptEditorDebugger": + return node + for child in node.get_children(): + queue.append(child) + return null + + +## Look up an editor theme icon by name (locale-independent), or null. +func _get_editor_icon(icon_name: String) -> Texture2D: + var base := EditorInterface.get_base_control() + if base != null and base.has_theme_icon(icon_name, "EditorIcons"): + return base.get_theme_icon(icon_name, "EditorIcons") + return null + + +## Find the debugger "Continue" button without relying on UI text. +## The editor is translated, so matching tooltip/label text breaks for +## non-English editors (issue #34: Italian → "Continua"). Match by the editor +## theme icon "DebugContinue" first, falling back to the English text only if +## the icon can't be resolved. +func _find_debugger_continue_button() -> Button: + var dbg := _find_script_editor_debugger() + if dbg == null: + return null + var continue_icon := _get_editor_icon("DebugContinue") + var fallback: Button = null + var inner: Array[Node] = [dbg] + while not inner.is_empty(): + var n := inner.pop_front() + if n is Button: + var b := n as Button + if continue_icon != null and b.icon == continue_icon: + return b + if b.tooltip_text == "Continue": + fallback = b + for c in n.get_children(): + inner.append(c) + return fallback + + +## True when the running game is halted at a breakpoint or runtime error +## (the debugger's "Continue" button is present and enabled). +func is_debugger_paused() -> bool: + var btn := _find_debugger_continue_button() + return btn != null and not btn.disabled + + +## Read recent runtime errors from the debugger's "Errors" tab tree, so a +## timeout caused by a script error can report the actual cause inline. +func collect_debugger_errors(max_errors: int = 10) -> Array: + var out: Array = [] + var dbg := _find_script_editor_debugger() + if dbg == null: + return out + for child in dbg.get_children(): + if child is TabContainer: + var tab_container := child as TabContainer + for tab_idx in range(tab_container.get_tab_count()): + var tab_control: Control = tab_container.get_tab_control(tab_idx) + if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"): + for vchild in tab_control.get_children(): + if vchild is Tree: + var tree := vchild as Tree + var root_item: TreeItem = tree.get_root() + if root_item: + var item: TreeItem = root_item.get_first_child() + while item and out.size() < max_errors: + var col0: String = item.get_text(0).strip_edges() + var col1: String = item.get_text(1).strip_edges() + var msg: String = col0 + if not col1.is_empty(): + msg = (msg + " " + col1) if not msg.is_empty() else col1 + if not msg.is_empty(): + out.append(msg) + item = item.get_next() + break + break + return out + + +## Press the debugger "Continue" button to resume a paused game process. +func try_debugger_continue() -> void: + var btn := _find_debugger_continue_button() + if btn != null and not btn.disabled: + btn.emit_signal("pressed") + push_warning("[MCP] Auto-resumed debugger after runtime error") + + +## Build an accurate error for a file-IPC game-command timeout. +## Distinguishes "game not running" from "game running but unresponsive +## (likely paused by a runtime error / breakpoint)" so callers aren't misled +## into thinking the MCP connection is dead or the autoload is missing. +func build_timeout_error(timeout_sec: float) -> Dictionary: + # Re-check play state at the moment we give up. + if not get_editor().is_playing_scene(): + return error( + -32000, + "Game command timed out after %.1fs and the game process is no longer running." % timeout_sec, + { + "game_running": false, + "suggestion": "The scene stopped. Call play_scene to start it again before sending runtime commands.", + } + ) + + # The game IS running. Figure out *why* it didn't answer. + var paused := is_debugger_paused() + var runtime_errors := collect_debugger_errors(10) + var data := { + "game_running": true, + "debugger_paused": paused, + } + if not runtime_errors.is_empty(): + data["runtime_errors"] = runtime_errors + + var msg: String + if paused or not runtime_errors.is_empty(): + msg = ("Game command timed out after %.1fs, but the game IS running. " % timeout_sec) \ + + "A runtime/script error paused the scene, so it could not respond to the command." + data["suggestion"] = "This is NOT a connection or autoload problem. Fix the error in 'runtime_errors' " \ + + "(or call get_editor_errors for the full list), then retry. The debugger was auto-resumed; " \ + + "if errors persist, call stop_scene then play_scene to restart cleanly." + else: + msg = ("Game command timed out after %.1fs. The game is running but did not respond in time." % timeout_sec) + data["suggestion"] = "The MCP server connection is fine and the game is running. The command may be slow " \ + + "or the game may be busy/blocked. Retry with a longer timeout, and call get_editor_errors to check " \ + + "for runtime errors. In rare cases (custom projects) verify the MCPGameInspector autoload is active." + return error(-32000, msg, data) + + +## Find node by path in edited scene +func find_node_by_path(node_path: String) -> Node: + var root := get_edited_root() + if root == null: + return null + if node_path == "." or node_path == root.name: + return root + # Try relative from root + if root.has_node(node_path): + return root.get_node(node_path) + # Try with root name prefix stripped + if node_path.begins_with(root.name + "/"): + var rel := node_path.substr(root.name.length() + 1) + if root.has_node(rel): + return root.get_node(rel) + return null diff --git a/addons/godot_mcp/commands/base_command.gd.uid b/addons/godot_mcp/commands/base_command.gd.uid new file mode 100644 index 0000000..3fe1fc9 --- /dev/null +++ b/addons/godot_mcp/commands/base_command.gd.uid @@ -0,0 +1 @@ +uid://burglba33dtlu diff --git a/addons/godot_mcp/commands/batch_commands.gd b/addons/godot_mcp/commands/batch_commands.gd new file mode 100644 index 0000000..563f80d --- /dev/null +++ b/addons/godot_mcp/commands/batch_commands.gd @@ -0,0 +1,436 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "find_nodes_by_type": _find_nodes_by_type, + "find_signal_connections": _find_signal_connections, + "batch_set_property": _batch_set_property, + "batch_add_nodes": _batch_add_nodes, + "find_node_references": _find_node_references, + "get_scene_dependencies": _get_scene_dependencies, + "cross_scene_set_property": _cross_scene_set_property, + } + + +func _find_nodes_by_type(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type_name: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var recursive: bool = optional_bool(params, "recursive", true) + var matches: Array = [] + _search_by_type(root, type_name, recursive, matches) + + return success({"type": type_name, "matches": matches, "count": matches.size()}) + + +func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void: + if node.is_class(type_name) or node.get_class() == type_name: + var root := get_edited_root() + matches.append({ + "name": node.name, + "path": str(root.get_path_to(node)), + "type": node.get_class(), + }) + if recursive: + for child in node.get_children(): + _search_by_type(child, type_name, recursive, matches) + + +func _find_signal_connections(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var signal_filter: String = optional_string(params, "signal_name", "") + var node_filter: String = optional_string(params, "node_path", "") + + var connections: Array = [] + _collect_signals(root, root, signal_filter, node_filter, connections) + + return success({"connections": connections, "count": connections.size()}) + + +func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void: + var node_path := str(root.get_path_to(node)) + + if node_filter.is_empty() or node_path.contains(node_filter): + for sig_info in node.get_signal_list(): + var sig_name: String = sig_info["name"] + if not signal_filter.is_empty() and not sig_name.contains(signal_filter): + continue + for conn in node.get_signal_connection_list(sig_name): + connections.append({ + "source": node_path, + "signal": sig_name, + "target": str(root.get_path_to(conn["callable"].get_object())), + "method": conn["callable"].get_method(), + }) + + for child in node.get_children(): + _collect_signals(child, root, signal_filter, node_filter, connections) + + +func _batch_set_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type_name: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + var value = params["value"] + + # Parse value string + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var affected: Array = [] + var changes: Array = [] + _batch_collect_property_changes(root, root, type_name, property, value, affected, changes) + if not changes.is_empty(): + _apply_property_changes_with_undo(changes, property, "MCP: Batch set %s" % property) + + return success({"property": property, "affected": affected, "count": affected.size()}) + + +func _batch_collect_property_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void: + if node.is_class(type_name) or node.get_class() == type_name: + if property in node: + affected.append(str(root.get_path_to(node))) + changes.append({ + "node": node, + "old_value": node.get(property), + "new_value": value, + }) + for child in node.get_children(): + _batch_collect_property_changes(child, root, type_name, property, value, affected, changes) + + +func _batch_add_nodes(params: Dictionary) -> Dictionary: + if not params.has("nodes") or not params["nodes"] is Array: + return error_invalid_params("Missing required parameter: nodes (Array)") + + var nodes_data: Array = params["nodes"] + if nodes_data.is_empty(): + return error_invalid_params("nodes array is empty") + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var created: Array = [] + var errors: Array = [] + + for i: int in nodes_data.size(): + var entry: Dictionary = nodes_data[i] + + if not entry.has("type") or not entry["type"] is String: + errors.append({"index": i, "error": "Missing or invalid 'type'"}) + continue + + var type: String = entry["type"] + if not ClassDB.class_exists(type): + errors.append({"index": i, "error": "Unknown node type: %s" % type}) + continue + + var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "." + var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else "" + var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {} + + var parent := find_node_by_path(parent_path) + if parent == null: + errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path}) + continue + + var node: Node = ClassDB.instantiate(type) + if not node_name.is_empty(): + node.name = node_name + + for prop_name: String in properties: + var prop_exists := false + for prop in node.get_property_list(): + if prop["name"] == prop_name: + prop_exists = true + break + if prop_exists: + var current: Variant = node.get(prop_name) + var target_type := typeof(current) + node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type)) + + add_child_with_undo(parent, node, root, "MCP: Batch add %s" % type) + + created.append({ + "index": i, + "type": type, + "name": str(node.name), + "parent": parent_path, + "node_path": str(root.get_path_to(node)), + }) + + var result := {"created": created, "count": created.size()} + if not errors.is_empty(): + result["errors"] = errors + return success(result) + + +func _find_node_references(params: Dictionary) -> Dictionary: + var result := require_string(params, "pattern") + if result[1] != null: + return result[1] + var pattern: String = result[0] + + # Search through all .tscn and .gd files for references + var matches: Array = [] + _search_files_for_pattern("res://", pattern, matches, 100) + + return success({"pattern": pattern, "matches": matches, "count": matches.size()}) + + +func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void: + if matches.size() >= max_results: + return + + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty() and matches.size() < max_results: + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + _search_files_for_pattern(full_path, pattern, matches, max_results) + elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]: + var file := FileAccess.open(full_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + if content.contains(pattern): + # Find line numbers + var lines := content.split("\n") + var line_matches: Array = [] + for i in lines.size(): + if lines[i].contains(pattern): + line_matches.append(i + 1) + if line_matches.size() >= 5: + break + matches.append({ + "file": full_path, + "lines": line_matches, + }) + + file_name = dir.get_next() + dir.list_dir_end() + + +func _cross_scene_set_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type_name: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + var value = params["value"] + + # Parse value string + if value is String: + var expr := Expression.new() + if expr.parse(value) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + var path_filter: String = optional_string(params, "path_filter", "res://") + var exclude_addons: bool = optional_bool(params, "exclude_addons", true) + var force: bool = optional_bool(params, "force", false) + var dry_run: bool = optional_bool(params, "dry_run", not force) + if not dry_run and not force: + return error_invalid_params("cross_scene_set_property requires force=true when dry_run=false") + + var scenes_affected: Array = [] + var skipped_open_scenes: Array = [] + var total_nodes: int = 0 + var scene_files: Array = [] + _collect_scene_files(path_filter, scene_files, exclude_addons) + + for scene_path: String in scene_files: + var normalized_scene_path := normalize_project_path(scene_path) + + if is_scene_path_open(normalized_scene_path): + if is_active_scene_path(normalized_scene_path) and force and not dry_run: + var root := get_edited_root() + var live_changes: Array = [] + var live_affected_nodes: Array = [] + _cross_scene_collect_changes(root, root, type_name, property, value, live_affected_nodes, live_changes) + if not live_changes.is_empty(): + _apply_property_changes_with_undo(live_changes, property, "MCP: Cross-scene set %s" % property) + scenes_affected.append({ + "scene": normalized_scene_path, + "nodes": live_affected_nodes, + "count": live_affected_nodes.size(), + "mode": "live_open_scene", + }) + total_nodes += live_affected_nodes.size() + else: + var reason := "open scene skipped during dry_run" if dry_run else "open scene is not the active editor scene" + skipped_open_scenes.append({"scene": normalized_scene_path, "reason": reason}) + continue + + var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene + if packed == null: + continue + var instance: Node = packed.instantiate() + if instance == null: + continue + + var affected_nodes: Array = [] + var changes: Array = [] + _cross_scene_collect_changes(instance, instance, type_name, property, value, affected_nodes, changes) + + if not changes.is_empty(): + if not dry_run: + var guard := guard_offline_scene_save(normalized_scene_path) + if not guard.is_empty(): + instance.free() + return guard + for change: Dictionary in changes: + (change["node"] as Node).set(property, value) + # Pack and save + var new_packed := PackedScene.new() + var pack_err := new_packed.pack(instance) + if pack_err != OK: + instance.free() + return error_internal("Failed to pack scene '%s': %s" % [normalized_scene_path, error_string(pack_err)]) + var save_err := ResourceSaver.save(new_packed, normalized_scene_path) + if save_err != OK: + instance.free() + return error_internal("Failed to save scene '%s': %s" % [normalized_scene_path, error_string(save_err)]) + scenes_affected.append({ + "scene": normalized_scene_path, + "nodes": affected_nodes, + "count": affected_nodes.size(), + "mode": "dry_run" if dry_run else "offline_saved", + }) + total_nodes += affected_nodes.size() + + instance.free() + + # Rescan filesystem so editor picks up changes + if not scenes_affected.is_empty(): + EditorInterface.get_resource_filesystem().scan() + + return success({ + "type": type_name, + "property": property, + "dry_run": dry_run, + "force": force, + "scenes_affected": scenes_affected, + "skipped_open_scenes": skipped_open_scenes, + "total_scenes": scenes_affected.size(), + "total_nodes": total_nodes, + "message": "Dry run only. Re-run with force=true and dry_run=false to write closed scenes and live-edit the active open scene." if dry_run else "Changes applied.", + }) + + +func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + dir.list_dir_begin() + var file_name := dir.get_next() + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + var full_path := path.path_join(file_name) + if dir.current_is_dir(): + if exclude_addons and file_name == "addons": + file_name = dir.get_next() + continue + _collect_scene_files(full_path, files, exclude_addons) + elif file_name.get_extension() == "tscn": + files.append(full_path) + file_name = dir.get_next() + dir.list_dir_end() + + +func _cross_scene_collect_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void: + if node.is_class(type_name) or node.get_class() == type_name: + if property in node: + affected.append(str(root.get_path_to(node))) + changes.append({ + "node": node, + "old_value": node.get(property), + "new_value": value, + }) + for child in node.get_children(): + _cross_scene_collect_changes(child, root, type_name, property, value, affected, changes) + + +func _apply_property_changes_with_undo(changes: Array, property: String, action_name: String) -> void: + var undo_redo := get_undo_redo() + undo_redo.create_action(action_name) + for change: Dictionary in changes: + var node: Node = change["node"] + undo_redo.add_do_property(node, property, change["new_value"]) + undo_redo.add_undo_property(node, property, change["old_value"]) + undo_redo.commit_action() + + +func _get_scene_dependencies(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("File '%s'" % path) + + var deps := ResourceLoader.get_dependencies(path) + var dependencies: Array = [] + for dep: String in deps: + # Format: "path::type" + var parts := dep.split("::") + dependencies.append({ + "path": parts[0] if parts.size() > 0 else dep, + "type": parts[2] if parts.size() > 2 else "", + }) + + return success({"path": path, "dependencies": dependencies, "count": dependencies.size()}) diff --git a/addons/godot_mcp/commands/batch_commands.gd.uid b/addons/godot_mcp/commands/batch_commands.gd.uid new file mode 100644 index 0000000..1263562 --- /dev/null +++ b/addons/godot_mcp/commands/batch_commands.gd.uid @@ -0,0 +1 @@ +uid://bwlmjlivjq4wt diff --git a/addons/godot_mcp/commands/editor_commands.gd b/addons/godot_mcp/commands/editor_commands.gd new file mode 100644 index 0000000..6bc7708 --- /dev/null +++ b/addons/godot_mcp/commands/editor_commands.gd @@ -0,0 +1,682 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_editor_errors": _get_editor_errors, + "get_output_log": _get_output_log, + "get_editor_screenshot": _get_editor_screenshot, + "get_game_screenshot": _get_game_screenshot, + "execute_editor_script": _execute_editor_script, + "clear_output": _clear_output, + "reload_plugin": _reload_plugin, + "reload_project": _reload_project, + "get_signals": _get_signals, + "compare_screenshots": _compare_screenshots, + "set_auto_dismiss": _set_auto_dismiss, + "get_editor_camera": _get_editor_camera, + "set_editor_camera": _set_editor_camera, + } + + +func _get_editor_errors(params: Dictionary) -> Dictionary: + var errors: Array = [] + var max_lines: int = optional_int(params, "max_lines", 50) + var base: Control = get_editor().get_base_control() + + # 1. Read from the editor's Output panel (EditorLog RichTextLabel) + # This captures runtime errors, warnings, and print output + var editor_log: Node = base.find_child("Output", true, false) + if editor_log: + var rtl: RichTextLabel = _find_rtl(editor_log) + if rtl: + var content: String = rtl.get_parsed_text() + var lines: PackedStringArray = content.split("\n") + var start: int = maxi(0, lines.size() - max_lines) + for i in range(start, lines.size()): + var line: String = lines[i] + if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"): + errors.append(line.strip_edges()) + + # 2. Check the script editor for compile errors (red background lines) + # These don't appear in the Output panel + var script_errors: Array = [] + var script_editor: ScriptEditor = EditorInterface.get_script_editor() + if script_editor: + var current_script: Script = script_editor.get_current_script() + var ce: CodeEdit = _find_code_edit(script_editor) + if ce and current_script: + var script_path: String = current_script.resource_path + for i in range(ce.get_line_count()): + var bg: Color = ce.get_line_background_color(i) + if bg.r > 0.8 and bg.a > 0: # Red-ish background = error + var line_text: String = ce.get_line(i).strip_edges() + script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text]) + + # 3. Read from script editor error/warning panels (GDScript analyzer messages) + # Each open script editor has a VSplitContainer with two RichTextLabels: + # child[1] = warnings panel, child[2] = errors panel + var analyzer_errors: Array = [] + if script_editor: + var open_editors: Array = script_editor.get_open_script_editors() + var open_scripts: Array = script_editor.get_open_scripts() + for ei in range(open_editors.size()): + var editor_node: Node = open_editors[ei] + var script_path: String = "" + if ei < open_scripts.size() and open_scripts[ei] != null: + script_path = (open_scripts[ei] as Resource).resource_path + var vsplit: VSplitContainer = null + for c in editor_node.get_children(): + if c is VSplitContainer: + vsplit = c as VSplitContainer + break + if vsplit == null: + continue + var children: Array = vsplit.get_children() + # child[1] = warnings panel (RichTextLabel) + if children.size() > 1 and children[1] is RichTextLabel: + var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges() + if not text.is_empty(): + for line in text.split("\n"): + var stripped: String = line.strip_edges() + if stripped.is_empty() or stripped == "[Ignore]": + continue + # Remove leading "[Ignore]" prefix from warning lines + stripped = stripped.trim_prefix("[Ignore]") + var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: " + analyzer_errors.append(prefix + stripped) + # child[2] = errors panel (RichTextLabel) + if children.size() > 2 and children[2] is RichTextLabel: + var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges() + if not text.is_empty(): + for line in text.split("\n"): + var stripped: String = line.strip_edges() + if stripped.is_empty(): + continue + var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: " + analyzer_errors.append(prefix + stripped) + + # 4. Read from the debugger Errors tab (runtime errors/warnings) + # Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree + var debugger_errors: Array = [] + var base2: Control = get_editor().get_base_control() + if base2: + var queue: Array[Node] = [base2] + while not queue.is_empty(): + var node := queue.pop_front() + if node.get_class() == "ScriptEditorDebugger": + # Find TabContainer inside the debugger + for child in node.get_children(): + if child is TabContainer: + var tab_container := child as TabContainer + for tab_idx in range(tab_container.get_tab_count()): + var tab_control: Control = tab_container.get_tab_control(tab_idx) + if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"): + # Find Tree inside the Errors tab + for vchild in tab_control.get_children(): + if vchild is Tree: + var tree := vchild as Tree + var root_item: TreeItem = tree.get_root() + if root_item: + var item: TreeItem = root_item.get_first_child() + while item: + var col0: String = item.get_text(0).strip_edges() + var col1: String = item.get_text(1).strip_edges() + if not col0.is_empty() or not col1.is_empty(): + var msg: String = col0 + if not col1.is_empty(): + msg += " " + col1 if not msg.is_empty() else col1 + debugger_errors.append("DEBUGGER: " + msg) + # Also check child items (expanded error details) + var sub: TreeItem = item.get_first_child() + while sub: + var sub0: String = sub.get_text(0).strip_edges() + var sub1: String = sub.get_text(1).strip_edges() + if not sub0.is_empty() or not sub1.is_empty(): + var sub_msg: String = sub0 + if not sub1.is_empty(): + sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1 + debugger_errors.append("DEBUGGER: " + sub_msg) + sub = sub.get_next() + item = item.get_next() + break # Found Errors tab, stop searching tabs + break # Found TabContainer, stop searching debugger children + break # Found ScriptEditorDebugger, stop BFS + for child in node.get_children(): + queue.append(child) + + # Fallback: read from log file if Output panel not accessible + if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0: + var log_path := "user://logs/godot.log" + if FileAccess.file_exists(log_path): + var file := FileAccess.open(log_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + var start: int = maxi(0, lines.size() - max_lines) + for i in range(start, lines.size()): + var line: String = lines[i] + if line.contains("ERROR") or line.contains("SCRIPT ERROR"): + errors.append(line.strip_edges()) + + errors.append_array(script_errors) + errors.append_array(analyzer_errors) + errors.append_array(debugger_errors) + return success({"errors": errors, "count": errors.size()}) + + +func _get_output_log(params: Dictionary) -> Dictionary: + var max_lines: int = optional_int(params, "max_lines", 100) + var filter: String = optional_string(params, "filter", "") + var base: Control = get_editor().get_base_control() + + var editor_log: Node = base.find_child("Output", true, false) + if editor_log == null: + # Fallback: read from log file + var log_path := "user://logs/godot.log" + if not FileAccess.file_exists(log_path): + return error_internal("Output panel not found and no log file available") + var file := FileAccess.open(log_path, FileAccess.READ) + if file == null: + return error_internal("Cannot read log file") + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + var start: int = maxi(0, lines.size() - max_lines) + var output_lines: Array = [] + for i in range(start, lines.size()): + var line: String = lines[i] + if filter.is_empty() or line.contains(filter): + output_lines.append(line) + return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"}) + + var rtl: RichTextLabel = _find_rtl(editor_log) + if rtl == null: + return error_internal("Could not find RichTextLabel in Output panel") + + var content: String = rtl.get_parsed_text() + var all_lines: PackedStringArray = content.split("\n") + var start: int = maxi(0, all_lines.size() - max_lines) + var output_lines: Array = [] + for i in range(start, all_lines.size()): + var line: String = all_lines[i] + if filter.is_empty() or line.contains(filter): + output_lines.append(line) + + return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"}) + + +func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit: + if depth > 8: + return null + if node is CodeEdit: + return node as CodeEdit + for child in node.get_children(): + var found: CodeEdit = _find_code_edit(child, depth + 1) + if found: + return found + return null + + +func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel: + if depth > 6: + return null + if node is RichTextLabel: + return node as RichTextLabel + for child in node.get_children(): + var found: RichTextLabel = _find_rtl(child, depth + 1) + if found: + return found + return null + + +func _get_editor_screenshot(params: Dictionary) -> Dictionary: + # Capture the editor's main viewport - no await to avoid timeout + var base_control: Control = get_editor().get_base_control() + if base_control == null: + return error_internal("Could not access editor base control") + + var viewport: Viewport = base_control.get_viewport() + if viewport == null: + return error_internal("Could not access editor viewport") + + var texture: ViewportTexture = viewport.get_texture() + if texture == null: + return error_internal("Could not get viewport texture") + + var image: Image = texture.get_image() + if image == null: + return error_internal("Could not get image from viewport") + + var save_path: String = params.get("save_path", "") + if save_path != "": + var abs_path := _resolve_save_path(save_path) + var err := image.save_png(abs_path) + if err != OK: + return error_internal("Failed to save screenshot: %s" % error_string(err)) + return success({ + "saved_path": save_path, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + var png_buffer := image.save_png_to_buffer() + var base64 := Marshalls.raw_to_base64(png_buffer) + + return success({ + "image_base64": base64, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + +func _get_game_screenshot(params: Dictionary) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + # Communicate with the game process via file system + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_screenshot_request" + var screenshot_path := user_dir + "/mcp_screenshot.png" + + # Clean up any stale screenshot file + if FileAccess.file_exists(screenshot_path): + DirAccess.remove_absolute(screenshot_path) + + # Create the request file to signal the game process + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create screenshot request file") + req.close() + + # Poll for the screenshot file (max 3 seconds, 0.1s interval) + var attempts := 30 + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(screenshot_path): + break + attempts -= 1 + + if not FileAccess.file_exists(screenshot_path): + # Clean up request file if it still exists + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Screenshot timed out", { + "suggestion": "Ensure the game is running and MCPScreenshot autoload is active", + }) + + # Load the PNG file + var image := Image.new() + var err := image.load(screenshot_path) + if err != OK: + DirAccess.remove_absolute(screenshot_path) + return error_internal("Failed to load screenshot: %s" % error_string(err)) + + # Clean up temp file + DirAccess.remove_absolute(screenshot_path) + + var save_path_param: String = params.get("save_path", "") + if save_path_param != "": + var abs_path := _resolve_save_path(save_path_param) + var save_err := image.save_png(abs_path) + if save_err != OK: + return error_internal("Failed to save screenshot: %s" % error_string(save_err)) + return success({ + "saved_path": save_path_param, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + var png_buffer := image.save_png_to_buffer() + var base64 := Marshalls.raw_to_base64(png_buffer) + + return success({ + "image_base64": base64, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + +func _resolve_save_path(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path + + +func _execute_editor_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "code") + if result[1] != null: + return result[1] + var code: String = result[0] + var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false) + var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io) + if not unsafe_guard.is_empty(): + return unsafe_guard + + # Wrap user code in a @tool script + var wrapped_code := """@tool +extends Node + +var _mcp_output: Array = [] + +func _mcp_print(value: Variant) -> void: + _mcp_output.append(str(value)) + +func run() -> Variant: + # User code begins +%s + # User code ends + return _mcp_output +""" % _indent_code(code) + + # Create a temporary script + var script := GDScript.new() + script.source_code = wrapped_code + var err := script.reload() + + if err != OK: + return error(-32002, "Script compilation failed", { + "error": error_string(err), + "code": wrapped_code, + }) + + # Create temp node and execute + var temp_node := Node.new() + temp_node.set_script(script) + add_child(temp_node) + + var output: Variant = null + + # Execute with error handling + if temp_node.has_method("run"): + output = temp_node.run() + + var mcp_output: Array = [] + var raw_output: Variant = temp_node.get("_mcp_output") + if raw_output is Array: + mcp_output = raw_output + + # Cleanup + temp_node.queue_free() + + return success({ + "output": mcp_output, + "return_value": str(output) if output != null else null, + }) + + +func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary: + if allow_unsafe_editor_io: + return {} + var compact := code.replace(" ", "").replace("\t", "").replace("\n", "") + var unsafe_patterns: Array[String] = [] + if compact.contains("ResourceSaver.save("): + unsafe_patterns.append("ResourceSaver.save") + if compact.contains("ProjectSettings.save("): + unsafe_patterns.append("ProjectSettings.save") + if compact.contains("ConfigFile.save("): + unsafe_patterns.append("ConfigFile.save") + if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]): + unsafe_patterns.append("FileAccess.open WRITE") + if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]): + unsafe_patterns.append("DirAccess filesystem mutation") + if unsafe_patterns.is_empty(): + return {} + return error_conflict( + "Refusing to execute editor script with direct file/resource write APIs", + { + "unsafe_patterns": unsafe_patterns, + "open_scenes": get_open_scene_paths(), + "suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.", + } + ) + + +func _contains_any(value: String, needles: Array[String]) -> bool: + for needle: String in needles: + if value.contains(needle): + return true + return false + + +func _indent_code(code: String) -> String: + var lines := code.split("\n") + var indented: PackedStringArray = [] + for line in lines: + indented.append("\t" + line) + return "\n".join(indented) + + +func _clear_output(params: Dictionary) -> Dictionary: + print("\n".repeat(50)) + return success({"cleared": true}) + + +func _reload_plugin(params: Dictionary) -> Dictionary: + # Disable and re-enable this plugin to reload all scripts + var plugin_name := "godot_mcp" + var ei := get_editor() + + # Send success BEFORE reloading (connection will briefly drop) + # Use call_deferred so the response is sent first + _deferred_reload_plugin.call_deferred(ei, plugin_name) + return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."}) + + +func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void: + ei.set_plugin_enabled(plugin_name, false) + ei.set_plugin_enabled(plugin_name, true) + print("[MCP] Plugin reloaded") + + +func _reload_project(params: Dictionary) -> Dictionary: + # Rescan filesystem and reload changed scripts + var ei := get_editor() + ei.get_resource_filesystem().scan() + + return success({"reloaded": true, "message": "Filesystem rescanned."}) + + +func _get_signals(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path) + + var signals: Array = [] + for sig in node.get_signal_list(): + var sig_info: Dictionary = { + "name": sig["name"], + "args": [], + } + for arg in sig["args"]: + sig_info["args"].append({"name": arg["name"], "type": arg["type"]}) + + # Get connections for this signal + var connections: Array = [] + for conn in node.get_signal_connection_list(sig["name"]): + connections.append({ + "target": str(root.get_path_to(conn["callable"].get_object())), + "method": conn["callable"].get_method(), + }) + sig_info["connections"] = connections + signals.append(sig_info) + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "signals": signals, + "count": signals.size(), + }) + + +func _load_image_from_param(value: String, label: String) -> Array: + ## Returns [Image, null] on success or [null, error_dict] on failure. + ## Accepts a file path (res://, user://) or raw base64 PNG data. + var img := Image.new() + if value.begins_with("res://") or value.begins_with("user://"): + var err := img.load(value) + if err != OK: + return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])] + return [img, null] + # Treat as base64 PNG + var buf := Marshalls.base64_to_raw(value) + var err := img.load_png_from_buffer(buf) + if err != OK: + return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])] + return [img, null] + + +func _compare_screenshots(params: Dictionary) -> Dictionary: + var result := require_string(params, "image_a") + if result[1] != null: + return result[1] + var image_a_value: String = result[0] + + var result2 := require_string(params, "image_b") + if result2[1] != null: + return result2[1] + var image_b_value: String = result2[0] + + var threshold: int = optional_int(params, "threshold", 10) + + # Load images (from path or base64) + var load_a := _load_image_from_param(image_a_value, "image_a") + if load_a[1] != null: + return load_a[1] + var img_a: Image = load_a[0] + + var load_b := _load_image_from_param(image_b_value, "image_b") + if load_b[1] != null: + return load_b[1] + var img_b: Image = load_b[0] + + if img_a.get_size() != img_b.get_size(): + return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())]) + + var width := img_a.get_width() + var height := img_a.get_height() + var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8) + + var changed_pixels: int = 0 + var total_pixels: int = width * height + + for y in height: + for x in width: + var ca: Color = img_a.get_pixel(x, y) + var cb: Color = img_b.get_pixel(x, y) + var dr := absi(int(ca.r8) - int(cb.r8)) + var dg := absi(int(ca.g8) - int(cb.g8)) + var db := absi(int(ca.b8) - int(cb.b8)) + var max_diff := maxi(dr, maxi(dg, db)) + if max_diff > threshold: + changed_pixels += 1 + # Red highlight for changed pixels + diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0))) + else: + # Dim version of original + diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0)) + + var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0 + var identical: bool = changed_pixels == 0 + + # Encode diff image + var diff_png := diff_image.save_png_to_buffer() + var diff_base64 := Marshalls.raw_to_base64(diff_png) + + return success({ + "identical": identical, + "changed_pixels": changed_pixels, + "total_pixels": total_pixels, + "diff_percentage": snappedf(diff_percentage, 0.01), + "threshold": threshold, + "width": width, + "height": height, + "diff_image_base64": diff_base64, + }) + + +func _get_editor_camera(_params: Dictionary) -> Dictionary: + var vp3d := EditorInterface.get_editor_viewport_3d() + var cam := vp3d.get_camera_3d() if vp3d else null + if not cam: + return error(-32000, "No 3D editor camera found", { + "suggestion": "Make sure a 3D scene is open in the editor", + }) + var pos := cam.global_position + var rot := cam.rotation_degrees + return success({ + "position": {"x": pos.x, "y": pos.y, "z": pos.z}, + "rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z}, + "fov": cam.fov, + "near": cam.near, + "far": cam.far, + }) + + +func _set_editor_camera(params: Dictionary) -> Dictionary: + var vp3d := EditorInterface.get_editor_viewport_3d() + var cam := vp3d.get_camera_3d() if vp3d else null + if not cam: + return error(-32000, "No 3D editor camera found", { + "suggestion": "Make sure a 3D scene is open in the editor", + }) + + # Set position + if params.has("position"): + var p: Dictionary = params["position"] + cam.global_position = Vector3( + float(p.get("x", cam.global_position.x)), + float(p.get("y", cam.global_position.y)), + float(p.get("z", cam.global_position.z)), + ) + + # Set rotation + if params.has("rotation_degrees"): + var r: Dictionary = params["rotation_degrees"] + cam.rotation_degrees = Vector3( + float(r.get("x", cam.rotation_degrees.x)), + float(r.get("y", cam.rotation_degrees.y)), + float(r.get("z", cam.rotation_degrees.z)), + ) + + # Look at target (overrides rotation if set) + if params.has("look_at"): + var t: Dictionary = params["look_at"] + cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0)))) + + # Set FOV + if params.has("fov"): + cam.fov = float(params["fov"]) + + var pos := cam.global_position + var rot := cam.rotation_degrees + return success({ + "position": {"x": pos.x, "y": pos.y, "z": pos.z}, + "rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z}, + "fov": cam.fov, + }) + + +func _set_auto_dismiss(params: Dictionary) -> Dictionary: + var enabled: bool = params.get("enabled", true) + editor_plugin.auto_dismiss_dialogs = enabled + return success({ + "auto_dismiss": enabled, + "message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"), + }) diff --git a/addons/godot_mcp/commands/editor_commands.gd.uid b/addons/godot_mcp/commands/editor_commands.gd.uid new file mode 100644 index 0000000..bdf26e9 --- /dev/null +++ b/addons/godot_mcp/commands/editor_commands.gd.uid @@ -0,0 +1 @@ +uid://drnul0blh6wv0 diff --git a/addons/godot_mcp/commands/export_commands.gd b/addons/godot_mcp/commands/export_commands.gd new file mode 100644 index 0000000..64164ed --- /dev/null +++ b/addons/godot_mcp/commands/export_commands.gd @@ -0,0 +1,117 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_export_presets": _list_export_presets, + "export_project": _export_project, + "get_export_info": _get_export_info, + } + + +func _list_export_presets(params: Dictionary) -> Dictionary: + # Read export_presets.cfg + var presets_path := "res://export_presets.cfg" + if not FileAccess.file_exists(presets_path): + return success({"presets": [], "count": 0, "message": "No export_presets.cfg found"}) + + var cfg := ConfigFile.new() + var err := cfg.load(presets_path) + if err != OK: + return error_internal("Failed to read export_presets.cfg: %s" % error_string(err)) + + var presets: Array = [] + var idx := 0 + while cfg.has_section("preset.%d" % idx): + var section := "preset.%d" % idx + presets.append({ + "index": idx, + "name": cfg.get_value(section, "name", ""), + "platform": cfg.get_value(section, "platform", ""), + "runnable": cfg.get_value(section, "runnable", false), + "export_path": cfg.get_value(section, "export_path", ""), + }) + idx += 1 + + return success({"presets": presets, "count": presets.size()}) + + +func _export_project(params: Dictionary) -> Dictionary: + var preset_index: int = optional_int(params, "preset_index", -1) + var preset_name: String = optional_string(params, "preset_name", "") + var debug: bool = optional_bool(params, "debug", true) + + # Find preset + var presets_path := "res://export_presets.cfg" + if not FileAccess.file_exists(presets_path): + return error(-32000, "No export_presets.cfg found. Configure exports in Project > Export first.") + + var cfg := ConfigFile.new() + var err := cfg.load(presets_path) + if err != OK: + return error_internal("Failed to read export_presets.cfg") + + # Find by name or index + var target_section := "" + var target_name := "" + var target_path := "" + + if not preset_name.is_empty(): + var idx := 0 + while cfg.has_section("preset.%d" % idx): + var section := "preset.%d" % idx + if cfg.get_value(section, "name", "") == preset_name: + target_section = section + target_name = preset_name + target_path = cfg.get_value(section, "export_path", "") + break + idx += 1 + elif preset_index >= 0: + var section := "preset.%d" % preset_index + if cfg.has_section(section): + target_section = section + target_name = cfg.get_value(section, "name", "") + target_path = cfg.get_value(section, "export_path", "") + + if target_section.is_empty(): + return error_not_found("Export preset") + + if target_path.is_empty(): + return error(-32000, "Export path not configured for preset '%s'" % target_name) + + # Use EditorExportPlatform via command line + # We can't directly call export from the plugin, so we return the command to run + var godot_path := OS.get_executable_path() + var project_path := ProjectSettings.globalize_path("res://") + var export_path := ProjectSettings.globalize_path(target_path) if target_path.begins_with("res://") else target_path + + var flag := "--export-debug" if debug else "--export-release" + var command := '"%s" --headless --path "%s" %s "%s"' % [godot_path, project_path, flag, target_name] + + return success({ + "preset": target_name, + "export_path": export_path, + "debug": debug, + "command": command, + "message": "Run the command above to export. Direct export from editor plugin is not supported in Godot 4.", + }) + + +func _get_export_info(params: Dictionary) -> Dictionary: + # General export-related project info + var info := {} + + # Check if export_presets.cfg exists + info["has_export_presets"] = FileAccess.file_exists("res://export_presets.cfg") + + # Get Godot executable path (useful for command-line exports) + info["godot_executable"] = OS.get_executable_path() + info["project_path"] = ProjectSettings.globalize_path("res://") + + # Check for common export templates + var templates_path := OS.get_data_dir().path_join("export_templates") + info["templates_dir"] = templates_path + info["templates_installed"] = DirAccess.dir_exists_absolute(templates_path) + + return success(info) diff --git a/addons/godot_mcp/commands/export_commands.gd.uid b/addons/godot_mcp/commands/export_commands.gd.uid new file mode 100644 index 0000000..45b3c85 --- /dev/null +++ b/addons/godot_mcp/commands/export_commands.gd.uid @@ -0,0 +1 @@ +uid://da135mgraliib diff --git a/addons/godot_mcp/commands/input_commands.gd b/addons/godot_mcp/commands/input_commands.gd new file mode 100644 index 0000000..8dbcacd --- /dev/null +++ b/addons/godot_mcp/commands/input_commands.gd @@ -0,0 +1,162 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const COMMANDS_PATH := "user://mcp_input_commands" + + +func get_commands() -> Dictionary: + return { + "simulate_key": _simulate_key, + "simulate_mouse_click": _simulate_mouse_click, + "simulate_mouse_move": _simulate_mouse_move, + "simulate_action": _simulate_action, + "simulate_sequence": _simulate_sequence, + } + + +func _simulate_key(params: Dictionary) -> Dictionary: + var result := require_string(params, "keycode") + if result[1] != null: + return result[1] + var keycode: String = result[0] + + var pressed: bool = optional_bool(params, "pressed", true) + var shift: bool = optional_bool(params, "shift", false) + var ctrl: bool = optional_bool(params, "ctrl", false) + var alt: bool = optional_bool(params, "alt", false) + + var event := { + "type": "key", + "keycode": keycode, + "pressed": pressed, + "shift": shift, + "ctrl": ctrl, + "alt": alt, + } + _write_commands([event]) + return success({"sent": true, "event": event}) + + +func _simulate_mouse_click(params: Dictionary) -> Dictionary: + var button: int = optional_int(params, "button", 1) # MOUSE_BUTTON_LEFT + var pressed: bool = optional_bool(params, "pressed", true) + var double_click: bool = optional_bool(params, "double_click", false) + var auto_release: bool = optional_bool(params, "auto_release", true) + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + + var press_event := { + "type": "mouse_button", + "button": button, + "pressed": pressed, + "double_click": double_click, + "position": {"x": x, "y": y}, + } + + # Auto-release: send press + release in sequence so UI buttons actually fire + if pressed and auto_release: + var release_event := press_event.duplicate() + release_event["pressed"] = false + var sequence_data := { + "sequence_events": [press_event, release_event], + "frame_delay": 1, + } + var json := JSON.stringify(sequence_data) + var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE) + if file == null: + return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error())) + file.store_string(json) + file.close() + return success({"sent": true, "event": press_event, "auto_release": true}) + + _write_commands([press_event]) + return success({"sent": true, "event": press_event}) + + +func _simulate_mouse_move(params: Dictionary) -> Dictionary: + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + var rel_x: float = float(params.get("relative_x", 0)) + var rel_y: float = float(params.get("relative_y", 0)) + var button_mask: int = optional_int(params, "button_mask", 0) + var unhandled_explicit: bool = params.has("unhandled") + var unhandled: bool = optional_bool(params, "unhandled", false) + + var event := { + "type": "mouse_motion", + "position": {"x": x, "y": y}, + "relative": {"x": rel_x, "y": rel_y}, + "button_mask": button_mask, + } + # Auto-enable unhandled for drag motions (camera-pan use case) ONLY when + # the caller did NOT explicitly pass an "unhandled" key. If they passed + # one — true or false — honor it. This lets UI drag-and-drop tests opt + # back into normal GUI dispatch by passing unhandled: false explicitly. + if unhandled_explicit: + event["unhandled"] = unhandled + elif button_mask > 0: + event["unhandled"] = true + _write_commands([event]) + return success({"sent": true, "event": event}) + + +func _simulate_action(params: Dictionary) -> Dictionary: + var result := require_string(params, "action") + if result[1] != null: + return result[1] + var action_name: String = result[0] + + var pressed: bool = optional_bool(params, "pressed", true) + var strength: float = float(params.get("strength", 1.0)) + + var event := { + "type": "action", + "action": action_name, + "pressed": pressed, + "strength": strength, + } + _write_commands([event]) + return success({"sent": true, "event": event}) + + +func _simulate_sequence(params: Dictionary) -> Dictionary: + if not params.has("events") or not params["events"] is Array: + return error_invalid_params("Missing required parameter: events (Array)") + + var events: Array = params["events"] + if events.is_empty(): + return error_invalid_params("Events array is empty") + + var frame_delay: int = optional_int(params, "frame_delay", 1) + + for event_data: Dictionary in events: + if not event_data.has("type") or (event_data["type"] as String).is_empty(): + return error_invalid_params("Invalid event in sequence: %s" % str(event_data)) + + if frame_delay <= 0: + # All events in one frame - write as plain array + _write_commands(events) + else: + # Sequence with frame delay - game side handles timing + var sequence_data := { + "sequence_events": events, + "frame_delay": frame_delay, + } + var json := JSON.stringify(sequence_data) + var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE) + if file == null: + return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error())) + file.store_string(json) + file.close() + + return success({"sent": true, "event_count": events.size(), "frame_delay": frame_delay}) + + +func _write_commands(events: Array) -> void: + var json := JSON.stringify(events) + var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE) + if file == null: + push_error("[MCP Input] Failed to write commands: %s" % error_string(FileAccess.get_open_error())) + return + file.store_string(json) + file.close() diff --git a/addons/godot_mcp/commands/input_commands.gd.uid b/addons/godot_mcp/commands/input_commands.gd.uid new file mode 100644 index 0000000..27c5045 --- /dev/null +++ b/addons/godot_mcp/commands/input_commands.gd.uid @@ -0,0 +1 @@ +uid://bwjnrsson0em diff --git a/addons/godot_mcp/commands/input_map_commands.gd b/addons/godot_mcp/commands/input_map_commands.gd new file mode 100644 index 0000000..dec784a --- /dev/null +++ b/addons/godot_mcp/commands/input_map_commands.gd @@ -0,0 +1,157 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_input_actions": _get_input_actions, + "set_input_action": _set_input_action, + } + + +func _get_input_actions(params: Dictionary) -> Dictionary: + var filter: String = optional_string(params, "filter", "") + var include_builtin: bool = optional_bool(params, "include_builtin", false) + + var actions: Dictionary = {} + for action: StringName in InputMap.get_actions(): + var action_str := str(action) + if not include_builtin: + # Skip built-in UI actions + if action_str.begins_with("ui_"): + continue + # Skip editor-registered actions (spatial_editor/*, etc.) — the addon + # runs in the editor process, so InputMap also carries the editor's + # own actions, which are not part of the project's InputMap + if not ProjectSettings.has_setting("input/" + action_str): + continue + # Apply filter + if not filter.is_empty() and not action_str.contains(filter): + continue + + var events: Array = [] + for event: InputEvent in InputMap.action_get_events(action): + events.append(_serialize_event(event)) + + actions[action_str] = { + "deadzone": InputMap.action_get_deadzone(action), + "events": events, + } + + return success({"actions": actions, "count": actions.size()}) + + +func _set_input_action(params: Dictionary) -> Dictionary: + var result := require_string(params, "action") + if result[1] != null: + return result[1] + var action_name: String = result[0] + + if not params.has("events") or not params["events"] is Array: + return error_invalid_params("'events' array is required") + var event_defs: Array = params["events"] + + var deadzone: float = float(params.get("deadzone", 0.5)) + + # Build the events array + var events: Array[InputEvent] = [] + for event_def in event_defs: + if not event_def is Dictionary: + continue + var event := _parse_event(event_def) + if event != null: + events.append(event) + + # Save to ProjectSettings + var setting_value := { + "deadzone": deadzone, + "events": events, + } + + ProjectSettings.set_setting("input/" + action_name, setting_value) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + # Also update the runtime InputMap + if not InputMap.has_action(action_name): + InputMap.add_action(action_name, deadzone) + else: + InputMap.action_set_deadzone(action_name, deadzone) + InputMap.action_erase_events(action_name) + for event in events: + InputMap.action_add_event(action_name, event) + + return success({ + "action": action_name, + "deadzone": deadzone, + "events_count": events.size(), + "saved": true, + }) + + +func _serialize_event(event: InputEvent) -> Dictionary: + if event is InputEventKey: + var key_event: InputEventKey = event + var info := { + "type": "key", + "keycode": OS.get_keycode_string(key_event.keycode) if key_event.keycode != KEY_NONE else "", + "physical_keycode": OS.get_keycode_string(key_event.physical_keycode) if key_event.physical_keycode != KEY_NONE else "", + } + if key_event.ctrl_pressed: info["ctrl"] = true + if key_event.shift_pressed: info["shift"] = true + if key_event.alt_pressed: info["alt"] = true + if key_event.meta_pressed: info["meta"] = true + return info + elif event is InputEventMouseButton: + var mb_event: InputEventMouseButton = event + return { + "type": "mouse_button", + "button_index": mb_event.button_index, + } + elif event is InputEventJoypadButton: + var jb_event: InputEventJoypadButton = event + return { + "type": "joypad_button", + "button_index": jb_event.button_index, + } + elif event is InputEventJoypadMotion: + var jm_event: InputEventJoypadMotion = event + return { + "type": "joypad_motion", + "axis": jm_event.axis, + "axis_value": jm_event.axis_value, + } + return {"type": event.get_class()} + + +func _parse_event(def: Dictionary) -> InputEvent: + var type: String = def.get("type", "") + match type: + "key": + var event := InputEventKey.new() + var keycode_str: String = def.get("keycode", "") + if not keycode_str.is_empty(): + event.keycode = OS.find_keycode_from_string(keycode_str) + var phys_str: String = def.get("physical_keycode", "") + if not phys_str.is_empty(): + event.physical_keycode = OS.find_keycode_from_string(phys_str) + event.ctrl_pressed = def.get("ctrl", false) + event.shift_pressed = def.get("shift", false) + event.alt_pressed = def.get("alt", false) + event.meta_pressed = def.get("meta", false) + return event + "mouse_button": + var event := InputEventMouseButton.new() + event.button_index = int(def.get("button_index", 1)) + return event + "joypad_button": + var event := InputEventJoypadButton.new() + event.button_index = int(def.get("button_index", 0)) + return event + "joypad_motion": + var event := InputEventJoypadMotion.new() + event.axis = int(def.get("axis", 0)) + event.axis_value = float(def.get("axis_value", 1.0)) + return event + return null diff --git a/addons/godot_mcp/commands/input_map_commands.gd.uid b/addons/godot_mcp/commands/input_map_commands.gd.uid new file mode 100644 index 0000000..cec92d3 --- /dev/null +++ b/addons/godot_mcp/commands/input_map_commands.gd.uid @@ -0,0 +1 @@ +uid://dxrh88r7hn546 diff --git a/addons/godot_mcp/commands/navigation_commands.gd b/addons/godot_mcp/commands/navigation_commands.gd new file mode 100644 index 0000000..8115469 --- /dev/null +++ b/addons/godot_mcp/commands/navigation_commands.gd @@ -0,0 +1,472 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "setup_navigation_region": _setup_navigation_region, + "bake_navigation_mesh": _bake_navigation_mesh, + "setup_navigation_agent": _setup_navigation_agent, + "set_navigation_layers": _set_navigation_layers, + "get_navigation_info": _get_navigation_info, + } + + +func _is_3d_context(node: Node) -> bool: + if node is Node3D: + return true + if node is Node2D: + return false + # Walk up to detect context + var parent := node.get_parent() + while parent != null: + if parent is Node3D: + return true + if parent is Node2D: + return false + parent = parent.get_parent() + return false + + +func _setup_navigation_region(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + var root := get_edited_root() + if root == null: + return error_no_scene() + + var force_mode: String = optional_string(params, "mode", "auto") + var is_3d: bool + match force_mode: + "2d": is_3d = false + "3d": is_3d = true + _: is_3d = _is_3d_context(node) + + if is_3d: + var region := NavigationRegion3D.new() + region.name = optional_string(params, "name", "NavigationRegion3D") + + var nav_mesh := NavigationMesh.new() + nav_mesh.agent_radius = float(params.get("agent_radius", 0.5)) + nav_mesh.agent_height = float(params.get("agent_height", 1.5)) + nav_mesh.agent_max_climb = float(params.get("agent_max_climb", 0.25)) + nav_mesh.agent_max_slope = float(params.get("agent_max_slope", 45.0)) + nav_mesh.cell_size = float(params.get("cell_size", 0.25)) + nav_mesh.cell_height = float(params.get("cell_height", 0.25)) + region.navigation_mesh = nav_mesh + + if params.has("navigation_layers"): + region.navigation_layers = int(params["navigation_layers"]) + + add_child_with_undo(node, region, root, "MCP: Add NavigationRegion3D") + + return success({ + "node_path": str(region.get_path()), + "type": "NavigationRegion3D", + "agent_radius": nav_mesh.agent_radius, + "agent_height": nav_mesh.agent_height, + "cell_size": nav_mesh.cell_size, + "created": true, + }) + else: + var region := NavigationRegion2D.new() + region.name = optional_string(params, "name", "NavigationRegion2D") + + var nav_poly := NavigationPolygon.new() + + # Set parsed geometry source if available + if params.has("source_geometry_mode"): + var mode_str: String = str(params["source_geometry_mode"]) + match mode_str: + "root_node": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN + "groups_with_children": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN + "groups_explicit": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT + + if params.has("cell_size"): + nav_poly.cell_size = float(params["cell_size"]) + + if params.has("agent_radius"): + nav_poly.agent_radius = float(params["agent_radius"]) + + region.navigation_polygon = nav_poly + + if params.has("navigation_layers"): + region.navigation_layers = int(params["navigation_layers"]) + + add_child_with_undo(node, region, root, "MCP: Add NavigationRegion2D") + + return success({ + "node_path": str(region.get_path()), + "type": "NavigationRegion2D", + "cell_size": nav_poly.cell_size, + "created": true, + }) + + +func _bake_navigation_mesh(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + var root := get_edited_root() + if root == null: + return error_no_scene() + + if node is NavigationRegion3D: + var region: NavigationRegion3D = node as NavigationRegion3D + if region.navigation_mesh == null: + return error_invalid_params("NavigationRegion3D has no NavigationMesh resource") + region.bake_navigation_mesh() + mark_current_scene_unsaved() + return success({ + "node_path": node_path, + "type": "NavigationRegion3D", + "baked": true, + }) + + elif node is NavigationRegion2D: + var region: NavigationRegion2D = node as NavigationRegion2D + if region.navigation_polygon == null: + var nav_poly := NavigationPolygon.new() + region.navigation_polygon = nav_poly + + # Set outline vertices from params + if params.has("outline"): + var outline_data: Array = params["outline"] + var outline := PackedVector2Array() + for point in outline_data: + if point is Array and point.size() >= 2: + outline.append(Vector2(float(point[0]), float(point[1]))) + elif point is Dictionary: + outline.append(Vector2(float(point.get("x", 0)), float(point.get("y", 0)))) + + if outline.size() >= 3: + # Clear existing outlines + while region.navigation_polygon.get_outline_count() > 0: + region.navigation_polygon.remove_outline(0) + region.navigation_polygon.add_outline(outline) + region.navigation_polygon.make_polygons_from_outlines() + mark_current_scene_unsaved() + return success({ + "node_path": node_path, + "type": "NavigationRegion2D", + "outline_vertices": outline.size(), + "baked": true, + }) + else: + return error_invalid_params("Outline must have at least 3 vertices") + else: + # Try baking from source geometry + region.bake_navigation_polygon() + mark_current_scene_unsaved() + return success({ + "node_path": node_path, + "type": "NavigationRegion2D", + "baked": true, + }) + + return error_invalid_params("Node '%s' is not a NavigationRegion2D or NavigationRegion3D" % node_path) + + +func _setup_navigation_agent(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + var root := get_edited_root() + if root == null: + return error_no_scene() + + var force_mode: String = optional_string(params, "mode", "auto") + var is_3d: bool + match force_mode: + "2d": is_3d = false + "3d": is_3d = true + _: is_3d = _is_3d_context(node) + + var agent_name: String = optional_string(params, "name", "NavigationAgent3D" if is_3d else "NavigationAgent2D") + + if is_3d: + var agent := NavigationAgent3D.new() + agent.name = agent_name + + if params.has("path_desired_distance"): + agent.path_desired_distance = float(params["path_desired_distance"]) + if params.has("target_desired_distance"): + agent.target_desired_distance = float(params["target_desired_distance"]) + if params.has("radius"): + agent.radius = float(params["radius"]) + if params.has("neighbor_distance"): + agent.neighbor_distance = float(params["neighbor_distance"]) + if params.has("max_neighbors"): + agent.max_neighbors = int(params["max_neighbors"]) + if params.has("max_speed"): + agent.max_speed = float(params["max_speed"]) + if params.has("avoidance_enabled"): + agent.avoidance_enabled = bool(params["avoidance_enabled"]) + if params.has("navigation_layers"): + agent.navigation_layers = int(params["navigation_layers"]) + + add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent3D") + + return success({ + "node_path": str(agent.get_path()), + "type": "NavigationAgent3D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + "created": true, + }) + else: + var agent := NavigationAgent2D.new() + agent.name = agent_name + + if params.has("path_desired_distance"): + agent.path_desired_distance = float(params["path_desired_distance"]) + if params.has("target_desired_distance"): + agent.target_desired_distance = float(params["target_desired_distance"]) + if params.has("radius"): + agent.radius = float(params["radius"]) + if params.has("neighbor_distance"): + agent.neighbor_distance = float(params["neighbor_distance"]) + if params.has("max_neighbors"): + agent.max_neighbors = int(params["max_neighbors"]) + if params.has("max_speed"): + agent.max_speed = float(params["max_speed"]) + if params.has("avoidance_enabled"): + agent.avoidance_enabled = bool(params["avoidance_enabled"]) + if params.has("navigation_layers"): + agent.navigation_layers = int(params["navigation_layers"]) + + add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent2D") + + return success({ + "node_path": str(agent.get_path()), + "type": "NavigationAgent2D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + "created": true, + }) + + +func _set_navigation_layers(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + # Support setting by bitmask value + if params.has("layers"): + var layers_val: int = int(params["layers"]) + if node is NavigationRegion2D: + set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers") + elif node is NavigationRegion3D: + set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers") + elif node is NavigationAgent2D: + set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers") + elif node is NavigationAgent3D: + set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers") + else: + return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path) + + return success({ + "node_path": node_path, + "navigation_layers": layers_val, + "updated": true, + }) + + # Support setting individual layer bits by number + if params.has("layer_bits"): + var bits: Array = params["layer_bits"] + var current_layers: int = 0 + + # Calculate bitmask from layer numbers (1-based) + for bit in bits: + var layer_num: int = int(bit) + if layer_num >= 1 and layer_num <= 32: + current_layers |= (1 << (layer_num - 1)) + + if node is NavigationRegion2D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + elif node is NavigationRegion3D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + elif node is NavigationAgent2D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + elif node is NavigationAgent3D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + else: + return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path) + + return success({ + "node_path": node_path, + "navigation_layers": current_layers, + "layer_bits": bits, + "updated": true, + }) + + # Support named layers from ProjectSettings + if params.has("layer_names"): + var names: Array = params["layer_names"] + var current_layers: int = 0 + var is_2d: bool = node is NavigationRegion2D or node is NavigationAgent2D + var prefix: String = "layer_names/2d_navigation/layer_" if is_2d else "layer_names/3d_navigation/layer_" + + for i in range(1, 33): + var setting_key: String = prefix + str(i) + if ProjectSettings.has_setting(setting_key): + var layer_name: String = str(ProjectSettings.get_setting(setting_key)) + if layer_name in names: + current_layers |= (1 << (i - 1)) + + if node is NavigationRegion2D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + elif node is NavigationRegion3D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + elif node is NavigationAgent2D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + elif node is NavigationAgent3D: + set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers") + else: + return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path) + + return success({ + "node_path": node_path, + "navigation_layers": current_layers, + "layer_names": names, + "updated": true, + }) + + return error_invalid_params("Must provide 'layers' (bitmask), 'layer_bits' (array of layer numbers), or 'layer_names' (array of named layers)") + + +func _get_navigation_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var regions: Array = [] + var agents: Array = [] + + _collect_navigation_nodes(node, regions, agents) + + # Collect named layers from ProjectSettings + var layer_names_2d: Dictionary = {} + var layer_names_3d: Dictionary = {} + for i in range(1, 33): + var key_2d: String = "layer_names/2d_navigation/layer_" + str(i) + var key_3d: String = "layer_names/3d_navigation/layer_" + str(i) + if ProjectSettings.has_setting(key_2d): + var name_2d: String = str(ProjectSettings.get_setting(key_2d)) + if not name_2d.is_empty(): + layer_names_2d[i] = name_2d + if ProjectSettings.has_setting(key_3d): + var name_3d: String = str(ProjectSettings.get_setting(key_3d)) + if not name_3d.is_empty(): + layer_names_3d[i] = name_3d + + return success({ + "node_path": node_path, + "regions": regions, + "agents": agents, + "region_count": regions.size(), + "agent_count": agents.size(), + "layer_names_2d": layer_names_2d, + "layer_names_3d": layer_names_3d, + }) + + +func _collect_navigation_nodes(node: Node, regions: Array, agents: Array) -> void: + if node is NavigationRegion2D: + var region: NavigationRegion2D = node as NavigationRegion2D + var region_info := { + "path": str(region.get_path()), + "type": "NavigationRegion2D", + "enabled": region.enabled, + "navigation_layers": region.navigation_layers, + "has_polygon": region.navigation_polygon != null, + } + if region.navigation_polygon != null: + var nav_poly: NavigationPolygon = region.navigation_polygon + region_info["outline_count"] = nav_poly.get_outline_count() + region_info["polygon_count"] = nav_poly.get_polygon_count() + region_info["cell_size"] = nav_poly.cell_size + region_info["agent_radius"] = nav_poly.agent_radius + regions.append(region_info) + + elif node is NavigationRegion3D: + var region: NavigationRegion3D = node as NavigationRegion3D + var region_info := { + "path": str(region.get_path()), + "type": "NavigationRegion3D", + "enabled": region.enabled, + "navigation_layers": region.navigation_layers, + "has_mesh": region.navigation_mesh != null, + } + if region.navigation_mesh != null: + var nav_mesh: NavigationMesh = region.navigation_mesh + region_info["agent_radius"] = nav_mesh.agent_radius + region_info["agent_height"] = nav_mesh.agent_height + region_info["agent_max_climb"] = nav_mesh.agent_max_climb + region_info["agent_max_slope"] = nav_mesh.agent_max_slope + region_info["cell_size"] = nav_mesh.cell_size + region_info["cell_height"] = nav_mesh.cell_height + regions.append(region_info) + + if node is NavigationAgent2D: + var agent: NavigationAgent2D = node as NavigationAgent2D + agents.append({ + "path": str(agent.get_path()), + "type": "NavigationAgent2D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "path_desired_distance": agent.path_desired_distance, + "target_desired_distance": agent.target_desired_distance, + "neighbor_distance": agent.neighbor_distance, + "max_neighbors": agent.max_neighbors, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + }) + + elif node is NavigationAgent3D: + var agent: NavigationAgent3D = node as NavigationAgent3D + agents.append({ + "path": str(agent.get_path()), + "type": "NavigationAgent3D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "path_desired_distance": agent.path_desired_distance, + "target_desired_distance": agent.target_desired_distance, + "neighbor_distance": agent.neighbor_distance, + "max_neighbors": agent.max_neighbors, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + }) + + for child in node.get_children(): + _collect_navigation_nodes(child, regions, agents) diff --git a/addons/godot_mcp/commands/navigation_commands.gd.uid b/addons/godot_mcp/commands/navigation_commands.gd.uid new file mode 100644 index 0000000..41a74b3 --- /dev/null +++ b/addons/godot_mcp/commands/navigation_commands.gd.uid @@ -0,0 +1 @@ +uid://7ffhpvf8g87g diff --git a/addons/godot_mcp/commands/node_commands.gd b/addons/godot_mcp/commands/node_commands.gd new file mode 100644 index 0000000..8baa236 --- /dev/null +++ b/addons/godot_mcp/commands/node_commands.gd @@ -0,0 +1,845 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "add_node": _add_node, + "delete_node": _delete_node, + "duplicate_node": _duplicate_node, + "move_node": _move_node, + "update_property": _update_property, + "get_node_properties": _get_node_properties, + "add_resource": _add_resource, + "set_anchor_preset": _set_anchor_preset, + "rename_node": _rename_node, + "connect_signal": _connect_signal, + "disconnect_signal": _disconnect_signal, + "get_node_groups": _get_node_groups, + "set_node_groups": _set_node_groups, + "find_nodes_in_group": _find_nodes_in_group, + "get_editor_selection": _get_editor_selection, + "select_nodes": _select_nodes, + "clear_editor_selection": _clear_editor_selection, + } + + +func _find_script_by_class_name(class_name_str: String) -> Script: + # Search project files for a script with matching class_name + var global_classes: Array = ProjectSettings.get_global_class_list() + for entry: Dictionary in global_classes: + if entry.get("class", "") == class_name_str: + var path: String = entry.get("path", "") + if not path.is_empty(): + return load(path) as Script + return null + + +func _add_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type: String = result[0] + + var parent_path: String = optional_string(params, "parent_path", ".") + var node_name: String = optional_string(params, "name", "") + var properties: Dictionary = params.get("properties", {}) + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes") + + var node: Node + var custom_script: Script = null + + if ClassDB.class_exists(type): + node = ClassDB.instantiate(type) + else: + # Try to find a script with matching class_name + custom_script = _find_script_by_class_name(type) + if custom_script == null: + return error_invalid_params("Unknown node type: '%s'. Not found in ClassDB or as a script class_name. Use list_scripts to see available script classes." % type) + var base_type: String = custom_script.get_instance_base_type() + if not ClassDB.class_exists(base_type): + return error_invalid_params("Script '%s' extends '%s' which is not a valid node type" % [type, base_type]) + node = ClassDB.instantiate(base_type) + node.set_script(custom_script) + if not node_name.is_empty(): + node.name = node_name + + # Apply properties + for prop_name: String in properties: + var prop_exists := false + for prop in node.get_property_list(): + if prop["name"] == prop_name: + prop_exists = true + break + if prop_exists: + var current: Variant = node.get(prop_name) + var target_type := typeof(current) + node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type)) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add %s" % type) + undo_redo.add_do_method(parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(parent, "remove_child", node) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "type": type, + "name": str(node.name), + }) + + +func _delete_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if node == root: + return error_invalid_params("Cannot delete the root node") + + var parent := node.get_parent() + var node_name := str(node.name) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Delete %s" % node_name) + undo_redo.add_do_method(parent, "remove_child", node) + undo_redo.add_undo_method(parent, "add_child", node) + undo_redo.add_undo_method(node, "set_owner", root) + undo_redo.add_undo_reference(node) + undo_redo.commit_action() + + return success({"deleted": node_name}) + + +func _duplicate_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var new_name: String = optional_string(params, "name", "") + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if new_name.is_empty(): + new_name = str(node.name) + "_copy" + + var dup := node.duplicate() + dup.name = new_name + var parent := node.get_parent() + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Duplicate %s" % node.name) + undo_redo.add_do_method(parent, "add_child", dup) + undo_redo.add_do_method(dup, "set_owner", root) + undo_redo.add_do_reference(dup) + undo_redo.add_undo_method(parent, "remove_child", dup) + undo_redo.commit_action() + + NodeUtils.set_owner_recursive(dup, root) + + return success({ + "original": str(root.get_path_to(node)), + "duplicate": str(root.get_path_to(dup)), + "name": str(dup.name), + }) + + +func _move_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "new_parent_path") + if result2[1] != null: + return result2[1] + var new_parent_path: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if node == root: + return error_invalid_params("Cannot move the root node") + + var new_parent := find_node_by_path(new_parent_path) + if new_parent == null: + return error_not_found("Target parent '%s'" % new_parent_path, "Use get_scene_tree to see available nodes") + + # Check we're not moving a node into its own subtree + if new_parent == node or node.is_ancestor_of(new_parent): + return error_invalid_params("Cannot move a node into its own subtree") + + var old_parent := node.get_parent() + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Move %s" % node.name) + undo_redo.add_do_method(old_parent, "remove_child", node) + undo_redo.add_do_method(new_parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_undo_method(new_parent, "remove_child", node) + undo_redo.add_undo_method(old_parent, "add_child", node) + undo_redo.add_undo_method(node, "set_owner", root) + undo_redo.commit_action() + + NodeUtils.set_owner_recursive(node, root) + + return success({ + "node": str(node.name), + "old_parent": str(root.get_path_to(old_parent)), + "new_parent": str(root.get_path_to(new_parent)), + "new_path": str(root.get_path_to(node)), + }) + + +func _update_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + var value: Variant = params["value"] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + # Check property exists + if not property in node: + var available: Array = [] + for prop in node.get_property_list(): + if prop["usage"] & PROPERTY_USAGE_EDITOR: + available.append(prop["name"]) + return error_not_found("Property '%s' on %s" % [property, node.get_class()], + "Available: %s" % str(available.slice(0, 20))) + + var old_value: Variant = node.get(property) + var target_type := typeof(old_value) + var parsed_value: Variant = PropertyParser.parse_value(value, target_type) + + # Handle @export node references (e.g. @export var hud: HUD) + # typeof() returns TYPE_NIL when unset or TYPE_OBJECT when set, + # neither resolves a string path to a node — check the property hint instead + if value is String: + for prop in node.get_property_list(): + if prop["name"] == property and prop["hint"] == PROPERTY_HINT_NODE_TYPE: + var target_node: Node = node.get_node_or_null(NodePath(value)) + if target_node == null: + target_node = root.get_node_or_null(NodePath(value)) + if target_node == null: + return error_not_found("Node '%s'" % value, "Could not resolve node path for property '%s'" % property) + parsed_value = target_node + break + + # Fail loudly instead of committing a String into an Object-typed property — + # the engine would coerce it to null and silently destroy the existing value. + if value is String and typeof(old_value) == TYPE_OBJECT and parsed_value == null: + return error_invalid_params( + "Could not resolve '%s' to a Resource for property '%s'" % [str(value), property]) + if value is String and parsed_value is String and typeof(old_value) in [TYPE_NIL, TYPE_OBJECT] \ + and (str(value).begins_with("res://") or str(value).begins_with("uid://")): + return error_not_found("Resource '%s'" % str(value), + "The path could not be loaded for property '%s'" % property) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) + undo_redo.add_do_property(node, property, parsed_value) + undo_redo.add_undo_property(node, property, old_value) + undo_redo.commit_action() + + return success({ + "node": str(root.get_path_to(node)), + "property": property, + "old_value": PropertyParser.serialize_value(old_value), + "new_value": PropertyParser.serialize_value(node.get(property)), + }) + + +func _get_node_properties(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var category: String = optional_string(params, "category", "") + var props := NodeUtils.get_node_properties_dict(node) + + # Filter by category if specified + if not category.is_empty(): + var filtered: Dictionary = {} + for key: String in props: + if key.begins_with(category): + filtered[key] = props[key] + props = filtered + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "properties": props, + }) + + +func _get_editor_selection(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var selection := EditorInterface.get_selection() + var include_top_only: bool = optional_bool(params, "top_only", false) + var selected_nodes: Array = selection.get_top_selected_nodes() if include_top_only else selection.get_selected_nodes() + + return success({ + "nodes": _serialize_selection_nodes(root, selected_nodes), + "count": selected_nodes.size(), + "top_only": include_top_only, + }) + + +func _select_nodes(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node_paths_result := _get_selection_node_paths(params) + if node_paths_result[1] != null: + return node_paths_result[1] + var node_paths: Array = node_paths_result[0] + + var mode: String = optional_string(params, "mode", "replace") + if mode != "replace" and mode != "add" and mode != "remove": + return error_invalid_params("mode must be one of: replace, add, remove") + + var inspect: bool = optional_bool(params, "inspect", true) + var focus: bool = optional_bool(params, "focus", inspect) + var inspector_only: bool = optional_bool(params, "inspector_only", false) + var for_property: String = optional_string(params, "for_property", "") + + var resolved_nodes: Array[Node] = [] + for node_path_variant: Variant in node_paths: + var node_path := str(node_path_variant) + if node_path.is_empty(): + return error_invalid_params("node_paths cannot contain empty values") + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + resolved_nodes.append(node) + + var selection := EditorInterface.get_selection() + if mode == "replace": + selection.clear() + + for node: Node in resolved_nodes: + if mode == "remove": + selection.remove_node(node) + else: + selection.add_node(node) + + # edit_node() and inspect_object() both reset EditorSelection to a single + # node, which would collapse a multi-node selection down to the last node. + # Only focus/inspect when exactly one node was selected. + if mode != "remove" and resolved_nodes.size() == 1: + var edited_node: Node = resolved_nodes[0] + if focus: + EditorInterface.edit_node(edited_node) + if inspect: + EditorInterface.inspect_object(edited_node, for_property, inspector_only) + + var selected_nodes: Array = selection.get_selected_nodes() + return success({ + "mode": mode, + "selected": _serialize_selection_nodes(root, selected_nodes), + "count": selected_nodes.size(), + }) + + +func _clear_editor_selection(_params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var selection := EditorInterface.get_selection() + var before_count := selection.get_selected_nodes().size() + selection.clear() + + return success({ + "cleared": before_count, + "selected": [], + "count": 0, + }) + + +func _get_selection_node_paths(params: Dictionary) -> Array: + if params.has("node_paths"): + if not params["node_paths"] is Array: + return [[], error_invalid_params("node_paths must be an array of node paths")] + return [params["node_paths"], null] + + var result := require_string(params, "node_path") + if result[1] != null: + return [[], result[1]] + return [[result[0]], null] + + +func _serialize_selection_nodes(root: Node, nodes: Array) -> Array: + var serialized: Array = [] + for node: Node in nodes: + if node == null: + continue + if node != root and not root.is_ancestor_of(node): + continue + serialized.append({ + "name": node.name, + "path": str(root.get_path_to(node)) if node != root else ".", + "type": node.get_class(), + }) + return serialized + + +func _add_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + var result3 := require_string(params, "resource_type") + if result3[1] != null: + return result3[1] + var resource_type: String = result3[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not ClassDB.class_exists(resource_type): + return error_invalid_params("Unknown resource type: %s" % resource_type) + + if not ClassDB.is_parent_class(resource_type, "Resource"): + return error_invalid_params("'%s' is not a Resource type" % resource_type) + + var resource: Resource = ClassDB.instantiate(resource_type) + if resource == null: + return error_internal("Failed to create resource: %s" % resource_type) + + # Apply resource properties if provided + var resource_props: Dictionary = params.get("resource_properties", {}) + for prop_name: String in resource_props: + if prop_name in resource: + var current: Variant = resource.get(prop_name) + resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current))) + + var old_value: Variant = node.get(property) if property in node else null + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name]) + undo_redo.add_do_property(node, property, resource) + undo_redo.add_undo_property(node, property, old_value) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "property": property, + "resource_type": resource_type, + }) + + +func _set_anchor_preset(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "preset") + if result2[1] != null: + return result2[1] + var preset_name: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not node is Control: + return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()]) + + var control: Control = node + var presets := { + "top_left": Control.PRESET_TOP_LEFT, + "top_right": Control.PRESET_TOP_RIGHT, + "bottom_left": Control.PRESET_BOTTOM_LEFT, + "bottom_right": Control.PRESET_BOTTOM_RIGHT, + "center_left": Control.PRESET_CENTER_LEFT, + "center_top": Control.PRESET_CENTER_TOP, + "center_right": Control.PRESET_CENTER_RIGHT, + "center_bottom": Control.PRESET_CENTER_BOTTOM, + "center": Control.PRESET_CENTER, + "left_wide": Control.PRESET_LEFT_WIDE, + "top_wide": Control.PRESET_TOP_WIDE, + "right_wide": Control.PRESET_RIGHT_WIDE, + "bottom_wide": Control.PRESET_BOTTOM_WIDE, + "vcenter_wide": Control.PRESET_VCENTER_WIDE, + "hcenter_wide": Control.PRESET_HCENTER_WIDE, + "full_rect": Control.PRESET_FULL_RECT, + } + + if not presets.has(preset_name): + return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()]) + + var keep_offsets: bool = optional_bool(params, "keep_offsets", false) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set anchor preset on %s" % node.name) + + # Store old values + var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom] + var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom] + + var target: Control = control.duplicate() as Control + target.set_anchors_and_offsets_preset(presets[preset_name], + Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE) + + undo_redo.add_do_property(control, "anchor_left", target.anchor_left) + undo_redo.add_do_property(control, "anchor_top", target.anchor_top) + undo_redo.add_do_property(control, "anchor_right", target.anchor_right) + undo_redo.add_do_property(control, "anchor_bottom", target.anchor_bottom) + undo_redo.add_do_property(control, "offset_left", target.offset_left) + undo_redo.add_do_property(control, "offset_top", target.offset_top) + undo_redo.add_do_property(control, "offset_right", target.offset_right) + undo_redo.add_do_property(control, "offset_bottom", target.offset_bottom) + + undo_redo.add_undo_property(control, "anchor_left", old_anchors[0]) + undo_redo.add_undo_property(control, "anchor_top", old_anchors[1]) + undo_redo.add_undo_property(control, "anchor_right", old_anchors[2]) + undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3]) + undo_redo.add_undo_property(control, "offset_left", old_offsets[0]) + undo_redo.add_undo_property(control, "offset_top", old_offsets[1]) + undo_redo.add_undo_property(control, "offset_right", old_offsets[2]) + undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3]) + + target.free() + undo_redo.commit_action() + + return success({"node_path": str(root.get_path_to(control)), "preset": preset_name}) + + +func _rename_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "new_name") + if result2[1] != null: + return result2[1] + var new_name: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var old_name: String = node.name + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name]) + undo_redo.add_do_property(node, "name", new_name) + undo_redo.add_undo_property(node, "name", old_name) + undo_redo.commit_action() + + return success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))}) + + +func _connect_signal(params: Dictionary) -> Dictionary: + var result := require_string(params, "source_path") + if result[1] != null: + return result[1] + var source_path: String = result[0] + + var result2 := require_string(params, "signal_name") + if result2[1] != null: + return result2[1] + var signal_name: String = result2[0] + + var result3 := require_string(params, "target_path") + if result3[1] != null: + return result3[1] + var target_path: String = result3[0] + + var result4 := require_string(params, "method_name") + if result4[1] != null: + return result4[1] + var method_name: String = result4[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var source := find_node_by_path(source_path) + if source == null: + return error_not_found("Source node '%s'" % source_path) + + var target := find_node_by_path(target_path) + if target == null: + return error_not_found("Target node '%s'" % target_path) + + if not source.has_signal(signal_name): + return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()]) + + if source.is_connected(signal_name, Callable(target, method_name)): + return success({"already_connected": true, "signal": signal_name}) + + # CONNECT_PERSIST is required for PackedScene.pack() to serialize the + # connection into the .tscn — without it the connection is editor-memory only. + var flags: int = Object.CONNECT_PERSIST + if optional_bool(params, "deferred", false): + flags |= Object.CONNECT_DEFERRED + if optional_bool(params, "one_shot", false): + flags |= Object.CONNECT_ONE_SHOT + + var callable := Callable(target, method_name) + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Connect signal") + undo_redo.add_do_method(source, "connect", signal_name, callable, flags) + undo_redo.add_undo_method(source, "disconnect", signal_name, callable) + undo_redo.commit_action() + + return success({ + "source": str(root.get_path_to(source)), + "signal": signal_name, + "target": str(root.get_path_to(target)), + "method": method_name, + "connected": true, + "flags": flags, + "persistent": true, + }) + + +func _disconnect_signal(params: Dictionary) -> Dictionary: + var result := require_string(params, "source_path") + if result[1] != null: + return result[1] + var source_path: String = result[0] + + var result2 := require_string(params, "signal_name") + if result2[1] != null: + return result2[1] + var signal_name: String = result2[0] + + var result3 := require_string(params, "target_path") + if result3[1] != null: + return result3[1] + var target_path: String = result3[0] + + var result4 := require_string(params, "method_name") + if result4[1] != null: + return result4[1] + var method_name: String = result4[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var source := find_node_by_path(source_path) + if source == null: + return error_not_found("Source node '%s'" % source_path) + + var target := find_node_by_path(target_path) + if target == null: + return error_not_found("Target node '%s'" % target_path) + + if not source.is_connected(signal_name, Callable(target, method_name)): + return success({"was_connected": false}) + + var callable := Callable(target, method_name) + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Disconnect signal") + undo_redo.add_do_method(source, "disconnect", signal_name, callable) + undo_redo.add_undo_method(source, "connect", signal_name, callable) + undo_redo.commit_action() + + return success({ + "source": str(root.get_path_to(source)), + "signal": signal_name, + "target": str(root.get_path_to(target)), + "method": method_name, + "disconnected": true, + }) + + +func _get_node_groups(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var groups: Array = [] + for group: StringName in node.get_groups(): + var g := str(group) + # Filter out internal groups (start with _) + if not g.begins_with("_"): + groups.append(g) + + return success({ + "node_path": str(root.get_path_to(node)), + "groups": groups, + "count": groups.size(), + }) + + +func _set_node_groups(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + if not params.has("groups") or not params["groups"] is Array: + return error_invalid_params("'groups' array is required") + var desired_groups: Array = params["groups"] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + # Get current non-internal groups + var current_groups: Array = [] + for group: StringName in node.get_groups(): + var g := str(group) + if not g.begins_with("_"): + current_groups.append(g) + + var added: Array = [] + var removed: Array = [] + + for group: String in current_groups: + if group not in desired_groups: + removed.append(group) + + for group in desired_groups: + var g: String = str(group) + if g not in current_groups: + added.append(g) + + if not added.is_empty() or not removed.is_empty(): + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set node groups") + for group: String in removed: + undo_redo.add_do_method(node, "remove_from_group", group) + undo_redo.add_undo_method(node, "add_to_group", group, true) + for group: String in added: + undo_redo.add_do_method(node, "add_to_group", group, true) + undo_redo.add_undo_method(node, "remove_from_group", group) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "groups": desired_groups, + "added": added, + "removed": removed, + }) + + +func _find_nodes_in_group(params: Dictionary) -> Dictionary: + var result := require_string(params, "group") + if result[1] != null: + return result[1] + var group_name: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var matches: Array = [] + _find_in_group_recursive(root, root, group_name, matches) + + return success({ + "group": group_name, + "nodes": matches, + "count": matches.size(), + }) + + +func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void: + if node.is_in_group(group_name): + matches.append({ + "name": node.name, + "path": str(root.get_path_to(node)), + "type": node.get_class(), + }) + for child in node.get_children(): + _find_in_group_recursive(child, root, group_name, matches) diff --git a/addons/godot_mcp/commands/node_commands.gd.uid b/addons/godot_mcp/commands/node_commands.gd.uid new file mode 100644 index 0000000..55dfd41 --- /dev/null +++ b/addons/godot_mcp/commands/node_commands.gd.uid @@ -0,0 +1 @@ +uid://dwu2yrh3e5sxu diff --git a/addons/godot_mcp/commands/particle_commands.gd b/addons/godot_mcp/commands/particle_commands.gd new file mode 100644 index 0000000..9650e1f --- /dev/null +++ b/addons/godot_mcp/commands/particle_commands.gd @@ -0,0 +1,626 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_particles": _create_particles, + "set_particle_material": _set_particle_material, + "set_particle_color_gradient": _set_particle_color_gradient, + "apply_particle_preset": _apply_particle_preset, + "get_particle_info": _get_particle_info, + } + + +func _get_particles_node(node_path: String) -> GPUParticles2D: + # Returns any GPUParticles2D or GPUParticles3D (both share similar API) + var node := find_node_by_path(node_path) + if node is GPUParticles2D: + return node as GPUParticles2D + return null + + +func _get_particles_node_any(node_path: String) -> Node: + var node := find_node_by_path(node_path) + if node is GPUParticles2D or node is GPUParticles3D: + return node + return null + + +func _parse_color(color_str: String) -> Color: + # Support hex "#RRGGBB", "#RRGGBBAA", or named colors + if color_str.begins_with("#"): + return Color.html(color_str) + # Try named color + match color_str.to_lower(): + "red": return Color.RED + "green": return Color.GREEN + "blue": return Color.BLUE + "white": return Color.WHITE + "black": return Color.BLACK + "yellow": return Color.YELLOW + "orange": return Color(1.0, 0.5, 0.0) + "gray", "grey": return Color.GRAY + "cyan": return Color.CYAN + "magenta": return Color.MAGENTA + "transparent": return Color(0, 0, 0, 0) + # Try Expression parser for Color(r,g,b,a) + var expr := Expression.new() + if expr.parse(color_str) == OK: + var parsed = expr.execute() + if parsed is Color: + return parsed + return Color.WHITE + + +func _create_particles(params: Dictionary) -> Dictionary: + var result := require_string(params, "parent_path") + if result[1] != null: + return result[1] + var parent_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Node at '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "Particles") + var is_3d: bool = optional_bool(params, "is_3d", false) + var amount: int = optional_int(params, "amount", 16) + var lifetime: float = float(params.get("lifetime", 1.0)) + var one_shot: bool = optional_bool(params, "one_shot", false) + var explosiveness: float = float(params.get("explosiveness", 0.0)) + var randomness: float = float(params.get("randomness", 0.0)) + var emitting: bool = optional_bool(params, "emitting", true) + + var particles_node: Node + if is_3d: + var p := GPUParticles3D.new() + p.name = node_name + p.amount = amount + p.lifetime = lifetime + p.one_shot = one_shot + p.explosiveness = explosiveness + p.randomness = randomness + p.emitting = emitting + var mat := ParticleProcessMaterial.new() + p.process_material = mat + particles_node = p + else: + var p := GPUParticles2D.new() + p.name = node_name + p.amount = amount + p.lifetime = lifetime + p.one_shot = one_shot + p.explosiveness = explosiveness + p.randomness = randomness + p.emitting = emitting + var mat := ParticleProcessMaterial.new() + p.process_material = mat + particles_node = p + + add_child_with_undo(parent, particles_node, root, "MCP: Create particles") + + return success({ + "name": particles_node.name, + "parent": parent_path, + "is_3d": is_3d, + "amount": amount, + "lifetime": lifetime, + "one_shot": one_shot, + "created": true, + }) + + +func _set_particle_material(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var old_mat: ParticleProcessMaterial = node.get("process_material") + var mat: ParticleProcessMaterial + if old_mat != null: + mat = old_mat.duplicate(true) as ParticleProcessMaterial + else: + mat = ParticleProcessMaterial.new() + + var changes: Array = [] + + # Direction + if params.has("direction"): + var dir = params["direction"] + if dir is Dictionary: + mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0))) + changes.append("direction") + elif dir is String: + var expr := Expression.new() + if expr.parse(dir) == OK: + var parsed = expr.execute() + if parsed is Vector3: + mat.direction = parsed + changes.append("direction") + + # Spread + if params.has("spread"): + mat.spread = float(params["spread"]) + changes.append("spread") + + # Initial velocity + if params.has("initial_velocity_min"): + mat.initial_velocity_min = float(params["initial_velocity_min"]) + changes.append("initial_velocity_min") + if params.has("initial_velocity_max"): + mat.initial_velocity_max = float(params["initial_velocity_max"]) + changes.append("initial_velocity_max") + + # Gravity + if params.has("gravity"): + var grav = params["gravity"] + if grav is Dictionary: + mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0))) + changes.append("gravity") + elif grav is String: + var expr := Expression.new() + if expr.parse(grav) == OK: + var parsed = expr.execute() + if parsed is Vector3: + mat.gravity = parsed + changes.append("gravity") + + # Scale + if params.has("scale_min"): + mat.scale_min = float(params["scale_min"]) + changes.append("scale_min") + if params.has("scale_max"): + mat.scale_max = float(params["scale_max"]) + changes.append("scale_max") + + # Color + if params.has("color"): + mat.color = _parse_color(str(params["color"])) + changes.append("color") + + # Emission shape + if params.has("emission_shape"): + var shape_str: String = str(params["emission_shape"]).to_lower() + match shape_str: + "point": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT + "sphere": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + if params.has("emission_sphere_radius"): + mat.emission_sphere_radius = float(params["emission_sphere_radius"]) + changes.append("emission_sphere_radius") + "sphere_surface": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE + if params.has("emission_sphere_radius"): + mat.emission_sphere_radius = float(params["emission_sphere_radius"]) + changes.append("emission_sphere_radius") + "box": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + if params.has("emission_box_extents"): + var ext = params["emission_box_extents"] + if ext is Dictionary: + mat.emission_box_extents = Vector3(float(ext.get("x", 1)), float(ext.get("y", 1)), float(ext.get("z", 1))) + changes.append("emission_box_extents") + "ring": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING + if params.has("emission_ring_radius"): + mat.emission_ring_radius = float(params["emission_ring_radius"]) + changes.append("emission_ring_radius") + if params.has("emission_ring_inner_radius"): + mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"]) + changes.append("emission_ring_inner_radius") + if params.has("emission_ring_height"): + mat.emission_ring_height = float(params["emission_ring_height"]) + changes.append("emission_ring_height") + changes.append("emission_shape") + + # Angular velocity + if params.has("angular_velocity_min"): + mat.angular_velocity_min = float(params["angular_velocity_min"]) + changes.append("angular_velocity_min") + if params.has("angular_velocity_max"): + mat.angular_velocity_max = float(params["angular_velocity_max"]) + changes.append("angular_velocity_max") + + # Orbit velocity + if params.has("orbit_velocity_min"): + mat.orbit_velocity_min = float(params["orbit_velocity_min"]) + changes.append("orbit_velocity_min") + if params.has("orbit_velocity_max"): + mat.orbit_velocity_max = float(params["orbit_velocity_max"]) + changes.append("orbit_velocity_max") + + # Damping + if params.has("damping_min"): + mat.damping_min = float(params["damping_min"]) + changes.append("damping_min") + if params.has("damping_max"): + mat.damping_max = float(params["damping_max"]) + changes.append("damping_max") + + # Attractor interaction + if params.has("attractor_interaction_enabled"): + mat.attractor_interaction_enabled = bool(params["attractor_interaction_enabled"]) + changes.append("attractor_interaction_enabled") + + if not changes.is_empty(): + set_property_with_undo(node, "process_material", mat, "MCP: Set particle material") + return success({"node_path": node_path, "changes": changes}) + + +func _set_particle_color_gradient(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var old_mat: ParticleProcessMaterial = node.get("process_material") + var mat: ParticleProcessMaterial + if old_mat != null: + mat = old_mat.duplicate(true) as ParticleProcessMaterial + else: + mat = ParticleProcessMaterial.new() + + if not params.has("stops") or not params["stops"] is Array: + return error_invalid_params("Missing required parameter: stops (array of {offset, color})") + + var stops: Array = params["stops"] + if stops.is_empty(): + return error_invalid_params("stops array must not be empty") + + # Gradient.remove_point refuses to drop below 2 points, so a fresh Gradient + # cannot be cleared point by point — assign offsets/colors wholesale instead. + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for stop in stops: + if stop is Dictionary: + offsets.append(float(stop.get("offset", 0.0))) + colors.append(_parse_color(str(stop.get("color", "#ffffff")))) + if offsets.is_empty(): + return error_invalid_params("stops array must contain {offset, color} dictionaries") + + var gradient := Gradient.new() + gradient.offsets = offsets + gradient.colors = colors + + var grad_tex := GradientTexture1D.new() + grad_tex.gradient = gradient + mat.color_ramp = grad_tex + set_property_with_undo(node, "process_material", mat, "MCP: Set particle color gradient") + + return success({"node_path": node_path, "stops_count": gradient.get_point_count()}) + + +func _apply_particle_preset(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "preset") + if result2[1] != null: + return result2[1] + var preset: String = result2[0].to_lower() + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var old_state := _capture_particle_state(node) + var preset_state := {} + var mat := ParticleProcessMaterial.new() + var is_2d: bool = node is GPUParticles2D + + # Default gravity for 2D (Y-down) vs 3D (Y-down) + var gravity_down := Vector3(0, 98.0 if is_2d else 9.8, 0) + var _gravity_up := Vector3(0, -98.0 if is_2d else -9.8, 0) + var gravity_none := Vector3.ZERO + + match preset: + "explosion": + preset_state["amount"] = 32 + preset_state["lifetime"] = 0.6 + preset_state["one_shot"] = true + preset_state["explosiveness"] = 1.0 + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 100.0 if is_2d else 5.0 + mat.initial_velocity_max = 200.0 if is_2d else 10.0 + mat.gravity = gravity_down * 0.5 + mat.damping_min = 2.0 + mat.damping_max = 4.0 + mat.scale_min = 0.5 + mat.scale_max = 1.5 + mat.color = Color(1.0, 0.6, 0.1) + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color.WHITE}, + {"offset": 0.3, "color": Color(1.0, 0.8, 0.2)}, + {"offset": 0.7, "color": Color(1.0, 0.3, 0.0)}, + {"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)}, + ]) + + "fire": + preset_state["amount"] = 24 + preset_state["lifetime"] = 1.2 + preset_state["one_shot"] = false + preset_state["explosiveness"] = 0.0 + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 15.0 + mat.initial_velocity_min = 30.0 if is_2d else 1.5 + mat.initial_velocity_max = 60.0 if is_2d else 3.0 + mat.gravity = gravity_none + mat.scale_min = 0.8 + mat.scale_max = 1.5 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(1.0, 1.0, 0.5)}, + {"offset": 0.3, "color": Color(1.0, 0.6, 0.0)}, + {"offset": 0.7, "color": Color(0.8, 0.2, 0.0)}, + {"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)}, + ]) + + "smoke": + preset_state["amount"] = 16 + preset_state["lifetime"] = 3.0 + preset_state["one_shot"] = false + preset_state["explosiveness"] = 0.0 + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 25.0 + mat.initial_velocity_min = 10.0 if is_2d else 0.5 + mat.initial_velocity_max = 25.0 if is_2d else 1.2 + mat.gravity = gravity_none + mat.scale_min = 1.5 + mat.scale_max = 3.0 + mat.damping_min = 1.0 + mat.damping_max = 2.0 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)}, + {"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)}, + {"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)}, + ]) + + "sparks": + preset_state["amount"] = 48 + preset_state["lifetime"] = 0.4 + preset_state["one_shot"] = true + preset_state["explosiveness"] = 0.95 + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 200.0 if is_2d else 8.0 + mat.initial_velocity_max = 400.0 if is_2d else 16.0 + mat.gravity = gravity_down + mat.scale_min = 0.1 + mat.scale_max = 0.3 + mat.damping_min = 1.0 + mat.damping_max = 3.0 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(1.0, 1.0, 0.8)}, + {"offset": 0.5, "color": Color(1.0, 0.7, 0.2)}, + {"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)}, + ]) + + "rain": + preset_state["amount"] = 64 + preset_state["lifetime"] = 0.8 + preset_state["one_shot"] = false + preset_state["explosiveness"] = 0.0 + mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0) + mat.spread = 5.0 + mat.initial_velocity_min = 300.0 if is_2d else 12.0 + mat.initial_velocity_max = 400.0 if is_2d else 16.0 + mat.gravity = gravity_down + mat.scale_min = 0.1 + mat.scale_max = 0.2 + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5) + mat.color = Color(0.6, 0.7, 1.0, 0.7) + + "snow": + preset_state["amount"] = 48 + preset_state["lifetime"] = 4.0 + preset_state["one_shot"] = false + preset_state["explosiveness"] = 0.0 + mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0) + mat.spread = 20.0 + mat.initial_velocity_min = 20.0 if is_2d else 0.8 + mat.initial_velocity_max = 40.0 if is_2d else 1.5 + mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0) + mat.scale_min = 0.3 + mat.scale_max = 0.8 + mat.angular_velocity_min = -45.0 + mat.angular_velocity_max = 45.0 + mat.damping_min = 0.5 + mat.damping_max = 1.5 + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5) + mat.color = Color(1.0, 1.0, 1.0, 0.9) + + "magic": + preset_state["amount"] = 24 + preset_state["lifetime"] = 2.0 + preset_state["one_shot"] = false + preset_state["explosiveness"] = 0.0 + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 20.0 if is_2d else 1.0 + mat.initial_velocity_max = 50.0 if is_2d else 2.5 + mat.gravity = gravity_none + mat.orbit_velocity_min = 0.5 + mat.orbit_velocity_max = 1.5 + mat.scale_min = 0.3 + mat.scale_max = 0.8 + mat.damping_min = 1.0 + mat.damping_max = 2.0 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(0.3, 0.5, 1.0)}, + {"offset": 0.25, "color": Color(1.0, 0.3, 0.8)}, + {"offset": 0.5, "color": Color(0.3, 1.0, 0.5)}, + {"offset": 0.75, "color": Color(1.0, 0.8, 0.2)}, + {"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)}, + ]) + + "dust": + preset_state["amount"] = 12 + preset_state["lifetime"] = 5.0 + preset_state["one_shot"] = false + preset_state["explosiveness"] = 0.0 + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 3.0 if is_2d else 0.1 + mat.initial_velocity_max = 8.0 if is_2d else 0.3 + mat.gravity = gravity_none + mat.scale_min = 0.2 + mat.scale_max = 0.5 + mat.damping_min = 0.5 + mat.damping_max = 1.0 + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3) + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, + {"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)}, + {"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)}, + {"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, + ]) + + _: + return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset) + + preset_state["process_material"] = mat + _register_particle_state_undo(node, old_state, preset_state, "MCP: Apply particle preset") + + return success({"node_path": node_path, "preset": preset, "applied": true}) + + +func _capture_particle_state(node: Node) -> Dictionary: + var state := {} + for property: String in ["amount", "lifetime", "one_shot", "explosiveness", "randomness", "emitting", "process_material"]: + if property in node: + state[property] = node.get(property) + return state + + +func _register_particle_state_undo(node: Node, old_state: Dictionary, new_state: Dictionary, action_name: String) -> void: + var undo_redo := get_undo_redo() + undo_redo.create_action(action_name) + for property: String in new_state: + undo_redo.add_do_property(node, property, new_state[property]) + if new_state[property] is Resource: + undo_redo.add_do_reference(new_state[property]) + undo_redo.add_undo_property(node, property, old_state.get(property, null)) + if old_state.get(property, null) is Resource: + undo_redo.add_undo_reference(old_state[property]) + undo_redo.commit_action() + + +func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void: + # Assign offsets/colors wholesale: Gradient.remove_point cannot drop below + # 2 points, so clearing the default points one by one leaves a stray stop. + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for stop in stops: + offsets.append(stop["offset"]) + colors.append(stop["color"]) + var gradient := Gradient.new() + gradient.offsets = offsets + gradient.colors = colors + var grad_tex := GradientTexture1D.new() + grad_tex.width = 64 # Smaller texture to avoid GPU issues in compatibility mode + grad_tex.gradient = gradient + # Defer color_ramp assignment to avoid editor crash during rendering + mat.set_deferred("color_ramp", grad_tex) + + +func _get_particle_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var info: Dictionary = { + "node_path": node_path, + "type": node.get_class(), + "amount": node.get("amount"), + "lifetime": node.get("lifetime"), + "one_shot": node.get("one_shot"), + "explosiveness": node.get("explosiveness"), + "randomness": node.get("randomness"), + "emitting": node.get("emitting"), + } + + var mat: ParticleProcessMaterial = node.get("process_material") + if mat != null and mat is ParticleProcessMaterial: + var mat_info: Dictionary = { + "direction": str(mat.direction), + "spread": mat.spread, + "initial_velocity_min": mat.initial_velocity_min, + "initial_velocity_max": mat.initial_velocity_max, + "gravity": str(mat.gravity), + "scale_min": mat.scale_min, + "scale_max": mat.scale_max, + "color": str(mat.color), + "angular_velocity_min": mat.angular_velocity_min, + "angular_velocity_max": mat.angular_velocity_max, + "orbit_velocity_min": mat.orbit_velocity_min, + "orbit_velocity_max": mat.orbit_velocity_max, + "damping_min": mat.damping_min, + "damping_max": mat.damping_max, + "attractor_interaction_enabled": mat.attractor_interaction_enabled, + } + + # Emission shape + var shape_name: String + match mat.emission_shape: + ParticleProcessMaterial.EMISSION_SHAPE_POINT: shape_name = "point" + ParticleProcessMaterial.EMISSION_SHAPE_SPHERE: shape_name = "sphere" + ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: shape_name = "sphere_surface" + ParticleProcessMaterial.EMISSION_SHAPE_BOX: shape_name = "box" + ParticleProcessMaterial.EMISSION_SHAPE_RING: shape_name = "ring" + _: shape_name = "unknown(%d)" % mat.emission_shape + + mat_info["emission_shape"] = shape_name + + match mat.emission_shape: + ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: + mat_info["emission_sphere_radius"] = mat.emission_sphere_radius + ParticleProcessMaterial.EMISSION_SHAPE_BOX: + mat_info["emission_box_extents"] = str(mat.emission_box_extents) + ParticleProcessMaterial.EMISSION_SHAPE_RING: + mat_info["emission_ring_radius"] = mat.emission_ring_radius + mat_info["emission_ring_inner_radius"] = mat.emission_ring_inner_radius + mat_info["emission_ring_height"] = mat.emission_ring_height + + # Color gradient + if mat.color_ramp != null and mat.color_ramp is GradientTexture1D: + var grad_tex: GradientTexture1D = mat.color_ramp + if grad_tex.gradient != null: + var gradient_stops: Array = [] + var grad: Gradient = grad_tex.gradient + for i in grad.get_point_count(): + gradient_stops.append({ + "offset": grad.get_offset(i), + "color": str(grad.get_color(i)), + }) + mat_info["color_ramp"] = gradient_stops + + info["material"] = mat_info + else: + info["material"] = null + + return success(info) diff --git a/addons/godot_mcp/commands/particle_commands.gd.uid b/addons/godot_mcp/commands/particle_commands.gd.uid new file mode 100644 index 0000000..0a9a55f --- /dev/null +++ b/addons/godot_mcp/commands/particle_commands.gd.uid @@ -0,0 +1 @@ +uid://b58ilnv1n62ak diff --git a/addons/godot_mcp/commands/physics_commands.gd b/addons/godot_mcp/commands/physics_commands.gd new file mode 100644 index 0000000..615b1ff --- /dev/null +++ b/addons/godot_mcp/commands/physics_commands.gd @@ -0,0 +1,757 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "setup_collision": _setup_collision, + "set_physics_layers": _set_physics_layers, + "get_physics_layers": _get_physics_layers, + "add_raycast": _add_raycast, + "setup_physics_body": _setup_physics_body, + "get_collision_info": _get_collision_info, + } + + +## Determine if a node (or its ancestors) lives in a 2D or 3D context. +## Returns "2d", "3d", or "" if undetermined. +func _detect_dimension(node: Node) -> String: + if node is Node2D or node is Control: + return "2d" + if node is Node3D: + return "3d" + # Walk up the tree + var parent := node.get_parent() + while parent != null: + if parent is Node2D or parent is Control: + return "2d" + if parent is Node3D: + return "3d" + parent = parent.get_parent() + return "" + + +func _setup_collision(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "shape") + if result2[1] != null: + return result2[1] + var shape_name: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var dim := _detect_dimension(node) + if dim.is_empty(): + # Allow explicit override + dim = optional_string(params, "dimension", "2d") + + # Validate parent can have collision children + var valid_parents_2d := ["PhysicsBody2D", "Area2D", "StaticBody2D", "CharacterBody2D", "RigidBody2D", "AnimatableBody2D"] + var valid_parents_3d := ["PhysicsBody3D", "Area3D", "StaticBody3D", "CharacterBody3D", "RigidBody3D", "AnimatableBody3D"] + + var is_valid_parent := false + if dim == "2d": + for vp: String in valid_parents_2d: + if node.is_class(vp): + is_valid_parent = true + break + else: + for vp: String in valid_parents_3d: + if node.is_class(vp): + is_valid_parent = true + break + + if not is_valid_parent: + return error_invalid_params("Node '%s' (%s) is not a physics body or area. CollisionShape should be added to a PhysicsBody or Area node." % [node_path, node.get_class()]) + + # Create shape resource + var shape: Resource = null + var child_name := "CollisionShape" + + if dim == "2d": + match shape_name: + "rectangle", "rect": + shape = RectangleShape2D.new() + var w: float = float(params.get("width", 32.0)) + var h: float = float(params.get("height", 32.0)) + shape.size = Vector2(w, h) + "circle": + shape = CircleShape2D.new() + shape.radius = float(params.get("radius", 16.0)) + "capsule": + shape = CapsuleShape2D.new() + shape.radius = float(params.get("radius", 16.0)) + shape.height = float(params.get("height", 40.0)) + "segment": + shape = SegmentShape2D.new() + shape.a = Vector2(float(params.get("ax", 0.0)), float(params.get("ay", 0.0))) + shape.b = Vector2(float(params.get("bx", 32.0)), float(params.get("by", 0.0))) + "custom": + # ConvexPolygonShape2D — expects "points" as array of [x,y] pairs + shape = ConvexPolygonShape2D.new() + var points_data: Array = params.get("points", []) + var pool: PackedVector2Array = PackedVector2Array() + for p: Variant in points_data: + if p is Array and p.size() >= 2: + pool.append(Vector2(float(p[0]), float(p[1]))) + if pool.size() >= 3: + shape.points = pool + _: + return error_invalid_params("Unknown 2D shape: '%s'. Available: rectangle, circle, capsule, segment, custom" % shape_name) + + var collision_node := CollisionShape2D.new() + collision_node.shape = shape + collision_node.name = child_name + + var disabled: bool = optional_bool(params, "disabled", false) + collision_node.disabled = disabled + var one_way: bool = optional_bool(params, "one_way_collision", false) + collision_node.one_way_collision = one_way + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add CollisionShape2D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", collision_node) + undo_redo.add_do_method(collision_node, "set_owner", root) + undo_redo.add_do_reference(collision_node) + undo_redo.add_undo_method(node, "remove_child", collision_node) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(collision_node)), + "shape_type": shape.get_class(), + "dimension": "2D", + }) + + else: + # 3D shapes + match shape_name: + "box", "rectangle", "rect": + shape = BoxShape3D.new() + var sx: float = float(params.get("width", 1.0)) + var sy: float = float(params.get("height", 1.0)) + var sz: float = float(params.get("depth", 1.0)) + shape.size = Vector3(sx, sy, sz) + "sphere", "circle": + shape = SphereShape3D.new() + shape.radius = float(params.get("radius", 0.5)) + "capsule": + shape = CapsuleShape3D.new() + shape.radius = float(params.get("radius", 0.5)) + shape.height = float(params.get("height", 2.0)) + "cylinder": + shape = CylinderShape3D.new() + shape.radius = float(params.get("radius", 0.5)) + shape.height = float(params.get("height", 2.0)) + "convex", "custom": + shape = ConvexPolygonShape3D.new() + var points_data: Array = params.get("points", []) + var pool: PackedVector3Array = PackedVector3Array() + for p: Variant in points_data: + if p is Array and p.size() >= 3: + pool.append(Vector3(float(p[0]), float(p[1]), float(p[2]))) + if pool.size() >= 4: + shape.points = pool + _: + return error_invalid_params("Unknown 3D shape: '%s'. Available: box, sphere, capsule, cylinder, convex" % shape_name) + + var collision_node := CollisionShape3D.new() + collision_node.shape = shape + collision_node.name = child_name + + var disabled: bool = optional_bool(params, "disabled", false) + collision_node.disabled = disabled + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add CollisionShape3D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", collision_node) + undo_redo.add_do_method(collision_node, "set_owner", root) + undo_redo.add_do_reference(collision_node) + undo_redo.add_undo_method(node, "remove_child", collision_node) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(collision_node)), + "shape_type": shape.get_class(), + "dimension": "3D", + }) + + +func _get_layer_name(dim: String, layer_index: int) -> String: + var setting_key := "layer_names/%s_physics/layer_%d" % [dim, layer_index] + if ProjectSettings.has_setting(setting_key): + var name_val: Variant = ProjectSettings.get_setting(setting_key) + if name_val is String and not (name_val as String).is_empty(): + return name_val as String + return "" + + +func _layer_bitmask_to_info(bitmask: int, dim: String) -> Array: + var layers: Array = [] + for i in range(1, 33): + if bitmask & (1 << (i - 1)): + var layer_name := _get_layer_name(dim, i) + var entry: Dictionary = {"layer": i} + if not layer_name.is_empty(): + entry["name"] = layer_name + layers.append(entry) + return layers + + +func _set_physics_layers(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + # Check node has collision_layer/collision_mask properties + if not "collision_layer" in node: + return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()]) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set physics layers on %s" % node.name) + + var changes: Dictionary = {} + + if params.has("collision_layer"): + var old_layer: int = node.get("collision_layer") + var new_layer: int = _parse_layer_value(params["collision_layer"]) + undo_redo.add_do_property(node, "collision_layer", new_layer) + undo_redo.add_undo_property(node, "collision_layer", old_layer) + changes["collision_layer"] = new_layer + + if params.has("collision_mask"): + var old_mask: int = node.get("collision_mask") + var new_mask: int = _parse_layer_value(params["collision_mask"]) + undo_redo.add_do_property(node, "collision_mask", new_mask) + undo_redo.add_undo_property(node, "collision_mask", old_mask) + changes["collision_mask"] = new_mask + + if changes.is_empty(): + return error_invalid_params("Must provide collision_layer and/or collision_mask") + + undo_redo.commit_action() + + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = "2d" + + var result_data: Dictionary = { + "node_path": str(root.get_path_to(node)), + } + if changes.has("collision_layer"): + result_data["collision_layer"] = changes["collision_layer"] + result_data["collision_layer_info"] = _layer_bitmask_to_info(changes["collision_layer"], dim) + if changes.has("collision_mask"): + result_data["collision_mask"] = changes["collision_mask"] + result_data["collision_mask_info"] = _layer_bitmask_to_info(changes["collision_mask"], dim) + + return success(result_data) + + +## Parse layer value: can be an int bitmask, or an array of layer numbers [1, 3, 5] +func _parse_layer_value(value: Variant) -> int: + if value is int or value is float: + return int(value) + if value is Array: + var bitmask: int = 0 + for layer_num: Variant in value: + var n: int = int(layer_num) + if n >= 1 and n <= 32: + bitmask |= (1 << (n - 1)) + return bitmask + # Try parsing as int + return int(value) + + +func _get_physics_layers(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not "collision_layer" in node: + return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()]) + + var layer: int = node.get("collision_layer") + var mask: int = node.get("collision_mask") + + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = "2d" + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "collision_layer": layer, + "collision_layer_info": _layer_bitmask_to_info(layer, dim), + "collision_mask": mask, + "collision_mask_info": _layer_bitmask_to_info(mask, dim), + }) + + +func _add_raycast(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = optional_string(params, "dimension", "2d") + + var ray_name: String = optional_string(params, "name", "RayCast") + var enabled: bool = optional_bool(params, "enabled", true) + var collision_mask: int = optional_int(params, "collision_mask", 1) + var collide_with_areas: bool = optional_bool(params, "collide_with_areas", false) + var collide_with_bodies: bool = optional_bool(params, "collide_with_bodies", true) + var hit_from_inside: bool = optional_bool(params, "hit_from_inside", false) + + var undo_redo := get_undo_redo() + + if dim == "2d": + var ray := RayCast2D.new() + ray.name = ray_name + ray.enabled = enabled + ray.collision_mask = collision_mask + ray.collide_with_areas = collide_with_areas + ray.collide_with_bodies = collide_with_bodies + ray.hit_from_inside = hit_from_inside + + var tx: float = float(params.get("target_x", 0.0)) + var ty: float = float(params.get("target_y", 50.0)) + ray.target_position = Vector2(tx, ty) + + undo_redo.create_action("MCP: Add RayCast2D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", ray) + undo_redo.add_do_method(ray, "set_owner", root) + undo_redo.add_do_reference(ray) + undo_redo.add_undo_method(node, "remove_child", ray) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(ray)), + "type": "RayCast2D", + "target_position": "Vector2(%s, %s)" % [tx, ty], + "collision_mask": collision_mask, + }) + + else: + var ray := RayCast3D.new() + ray.name = ray_name + ray.enabled = enabled + ray.collision_mask = collision_mask + ray.collide_with_areas = collide_with_areas + ray.collide_with_bodies = collide_with_bodies + ray.hit_from_inside = hit_from_inside + + var tx: float = float(params.get("target_x", 0.0)) + var ty: float = float(params.get("target_y", -1.0)) + var tz: float = float(params.get("target_z", 0.0)) + ray.target_position = Vector3(tx, ty, tz) + + undo_redo.create_action("MCP: Add RayCast3D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", ray) + undo_redo.add_do_method(ray, "set_owner", root) + undo_redo.add_do_reference(ray) + undo_redo.add_undo_method(node, "remove_child", ray) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(ray)), + "type": "RayCast3D", + "target_position": "Vector3(%s, %s, %s)" % [tx, ty, tz], + "collision_mask": collision_mask, + }) + + +func _setup_physics_body(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Setup physics body %s" % node.name) + + var applied: Dictionary = {} + + if node is CharacterBody2D or node is CharacterBody3D: + # CharacterBody properties + if params.has("floor_stop_on_slope"): + var old_val: bool = node.floor_stop_on_slope + var new_val: bool = bool(params["floor_stop_on_slope"]) + undo_redo.add_do_property(node, "floor_stop_on_slope", new_val) + undo_redo.add_undo_property(node, "floor_stop_on_slope", old_val) + applied["floor_stop_on_slope"] = new_val + + if params.has("floor_max_angle"): + var old_val: float = node.floor_max_angle + var new_val: float = float(params["floor_max_angle"]) + undo_redo.add_do_property(node, "floor_max_angle", new_val) + undo_redo.add_undo_property(node, "floor_max_angle", old_val) + applied["floor_max_angle"] = new_val + + if params.has("floor_snap_length"): + var old_val: float = node.floor_snap_length + var new_val: float = float(params["floor_snap_length"]) + undo_redo.add_do_property(node, "floor_snap_length", new_val) + undo_redo.add_undo_property(node, "floor_snap_length", old_val) + applied["floor_snap_length"] = new_val + + if params.has("wall_min_slide_angle"): + var old_val: float = node.wall_min_slide_angle + var new_val: float = float(params["wall_min_slide_angle"]) + undo_redo.add_do_property(node, "wall_min_slide_angle", new_val) + undo_redo.add_undo_property(node, "wall_min_slide_angle", old_val) + applied["wall_min_slide_angle"] = new_val + + if params.has("motion_mode"): + var mode_str: String = str(params["motion_mode"]) + var mode_val: int = 0 + if node is CharacterBody2D: + match mode_str.to_lower(): + "grounded": + mode_val = CharacterBody2D.MOTION_MODE_GROUNDED + "floating": + mode_val = CharacterBody2D.MOTION_MODE_FLOATING + _: + mode_val = int(params["motion_mode"]) + else: + match mode_str.to_lower(): + "grounded": + mode_val = CharacterBody3D.MOTION_MODE_GROUNDED + "floating": + mode_val = CharacterBody3D.MOTION_MODE_FLOATING + _: + mode_val = int(params["motion_mode"]) + var old_val: int = node.motion_mode + undo_redo.add_do_property(node, "motion_mode", mode_val) + undo_redo.add_undo_property(node, "motion_mode", old_val) + applied["motion_mode"] = mode_str + + if params.has("max_slides"): + var old_val: int = node.max_slides + var new_val: int = int(params["max_slides"]) + undo_redo.add_do_property(node, "max_slides", new_val) + undo_redo.add_undo_property(node, "max_slides", old_val) + applied["max_slides"] = new_val + + if params.has("slide_on_ceiling"): + var old_val: bool = node.slide_on_ceiling + var new_val: bool = bool(params["slide_on_ceiling"]) + undo_redo.add_do_property(node, "slide_on_ceiling", new_val) + undo_redo.add_undo_property(node, "slide_on_ceiling", old_val) + applied["slide_on_ceiling"] = new_val + + elif node is RigidBody2D or node is RigidBody3D: + # RigidBody properties + if params.has("mass"): + var old_val: float = node.mass + var new_val: float = float(params["mass"]) + undo_redo.add_do_property(node, "mass", new_val) + undo_redo.add_undo_property(node, "mass", old_val) + applied["mass"] = new_val + + if params.has("gravity_scale"): + var old_val: float = node.gravity_scale + var new_val: float = float(params["gravity_scale"]) + undo_redo.add_do_property(node, "gravity_scale", new_val) + undo_redo.add_undo_property(node, "gravity_scale", old_val) + applied["gravity_scale"] = new_val + + if params.has("linear_damp"): + var old_val: float = node.linear_damp + var new_val: float = float(params["linear_damp"]) + undo_redo.add_do_property(node, "linear_damp", new_val) + undo_redo.add_undo_property(node, "linear_damp", old_val) + applied["linear_damp"] = new_val + + if params.has("angular_damp"): + var old_val: float = node.angular_damp + var new_val: float = float(params["angular_damp"]) + undo_redo.add_do_property(node, "angular_damp", new_val) + undo_redo.add_undo_property(node, "angular_damp", old_val) + applied["angular_damp"] = new_val + + if params.has("freeze"): + var old_val: bool = node.freeze + var new_val: bool = bool(params["freeze"]) + undo_redo.add_do_property(node, "freeze", new_val) + undo_redo.add_undo_property(node, "freeze", old_val) + applied["freeze"] = new_val + + if params.has("freeze_mode"): + var mode_str: String = str(params["freeze_mode"]) + var mode_val: int = 0 + if node is RigidBody2D: + match mode_str.to_lower(): + "static": + mode_val = RigidBody2D.FREEZE_MODE_STATIC + "kinematic": + mode_val = RigidBody2D.FREEZE_MODE_KINEMATIC + _: + mode_val = int(params["freeze_mode"]) + else: + match mode_str.to_lower(): + "static": + mode_val = RigidBody3D.FREEZE_MODE_STATIC + "kinematic": + mode_val = RigidBody3D.FREEZE_MODE_KINEMATIC + _: + mode_val = int(params["freeze_mode"]) + var old_val: int = node.freeze_mode + undo_redo.add_do_property(node, "freeze_mode", mode_val) + undo_redo.add_undo_property(node, "freeze_mode", old_val) + applied["freeze_mode"] = mode_str + + if params.has("continuous_cd"): + if node is RigidBody2D: + var ccd_str: String = str(params["continuous_cd"]) + var ccd_val: int = 0 + match ccd_str.to_lower(): + "disabled": + ccd_val = RigidBody2D.CCD_MODE_DISABLED + "cast_ray": + ccd_val = RigidBody2D.CCD_MODE_CAST_RAY + "cast_shape": + ccd_val = RigidBody2D.CCD_MODE_CAST_SHAPE + _: + ccd_val = int(params["continuous_cd"]) + var old_val: int = node.continuous_cd + undo_redo.add_do_property(node, "continuous_cd", ccd_val) + undo_redo.add_undo_property(node, "continuous_cd", old_val) + applied["continuous_cd"] = ccd_str + else: + var old_val: bool = node.continuous_cd + var new_val: bool = bool(params["continuous_cd"]) + undo_redo.add_do_property(node, "continuous_cd", new_val) + undo_redo.add_undo_property(node, "continuous_cd", old_val) + applied["continuous_cd"] = new_val + + if params.has("contact_monitor"): + var old_val: bool = node.contact_monitor + var new_val: bool = bool(params["contact_monitor"]) + undo_redo.add_do_property(node, "contact_monitor", new_val) + undo_redo.add_undo_property(node, "contact_monitor", old_val) + applied["contact_monitor"] = new_val + + if params.has("max_contacts_reported"): + var old_val: int = node.max_contacts_reported + var new_val: int = int(params["max_contacts_reported"]) + undo_redo.add_do_property(node, "max_contacts_reported", new_val) + undo_redo.add_undo_property(node, "max_contacts_reported", old_val) + applied["max_contacts_reported"] = new_val + + elif node is StaticBody2D or node is StaticBody3D or node is AnimatableBody2D or node is AnimatableBody3D: + # StaticBody / AnimatableBody shared properties + if params.has("physics_material_override"): + # We just note it — use add_resource for complex resource assignment + return error_invalid_params("Use add_resource to set physics_material_override") + else: + return error_invalid_params("Node '%s' (%s) is not a recognized physics body type. Supported: CharacterBody2D/3D, RigidBody2D/3D, StaticBody2D/3D, AnimatableBody2D/3D" % [node_path, node.get_class()]) + + if applied.is_empty(): + undo_redo.commit_action() + return error_invalid_params("No valid properties provided for %s" % node.get_class()) + + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "applied": applied, + }) + + +func _get_collision_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var include_children: bool = optional_bool(params, "include_children", true) + + var info: Dictionary = { + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + } + + # Collect physics body properties + if "collision_layer" in node: + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = "2d" + info["collision_layer"] = node.get("collision_layer") + info["collision_layer_info"] = _layer_bitmask_to_info(int(node.get("collision_layer")), dim) + info["collision_mask"] = node.get("collision_mask") + info["collision_mask_info"] = _layer_bitmask_to_info(int(node.get("collision_mask")), dim) + + # Collect body-specific properties + if node is CharacterBody2D or node is CharacterBody3D: + info["body_settings"] = { + "motion_mode": node.motion_mode, + "floor_stop_on_slope": node.floor_stop_on_slope, + "floor_max_angle": node.floor_max_angle, + "floor_snap_length": node.floor_snap_length, + "wall_min_slide_angle": node.wall_min_slide_angle, + "max_slides": node.max_slides, + "slide_on_ceiling": node.slide_on_ceiling, + } + elif node is RigidBody2D or node is RigidBody3D: + info["body_settings"] = { + "mass": node.mass, + "gravity_scale": node.gravity_scale, + "linear_damp": node.linear_damp, + "angular_damp": node.angular_damp, + "freeze": node.freeze, + "freeze_mode": node.freeze_mode, + "contact_monitor": node.contact_monitor, + "max_contacts_reported": node.max_contacts_reported, + } + + # Collect collision shapes + var shapes: Array = [] + var raycasts: Array = [] + var nodes_to_check: Array = [node] + + if include_children: + var queue: Array = [node] + while queue.size() > 0: + var current: Node = queue.pop_front() + for child_idx in current.get_child_count(): + var child := current.get_child(child_idx) + nodes_to_check.append(child) + queue.append(child) + + for check_node: Node in nodes_to_check: + if check_node is CollisionShape2D: + var shape_info: Dictionary = { + "node_path": str(root.get_path_to(check_node)), + "disabled": check_node.disabled, + "one_way_collision": check_node.one_way_collision, + } + if check_node.shape != null: + shape_info["shape_type"] = check_node.shape.get_class() + if check_node.shape is RectangleShape2D: + shape_info["size"] = "Vector2(%s, %s)" % [check_node.shape.size.x, check_node.shape.size.y] + elif check_node.shape is CircleShape2D: + shape_info["radius"] = check_node.shape.radius + elif check_node.shape is CapsuleShape2D: + shape_info["radius"] = check_node.shape.radius + shape_info["height"] = check_node.shape.height + shapes.append(shape_info) + + elif check_node is CollisionShape3D: + var shape_info: Dictionary = { + "node_path": str(root.get_path_to(check_node)), + "disabled": check_node.disabled, + } + if check_node.shape != null: + shape_info["shape_type"] = check_node.shape.get_class() + if check_node.shape is BoxShape3D: + shape_info["size"] = "Vector3(%s, %s, %s)" % [check_node.shape.size.x, check_node.shape.size.y, check_node.shape.size.z] + elif check_node.shape is SphereShape3D: + shape_info["radius"] = check_node.shape.radius + elif check_node.shape is CapsuleShape3D: + shape_info["radius"] = check_node.shape.radius + shape_info["height"] = check_node.shape.height + elif check_node.shape is CylinderShape3D: + shape_info["radius"] = check_node.shape.radius + shape_info["height"] = check_node.shape.height + shapes.append(shape_info) + + elif check_node is CollisionPolygon2D: + shapes.append({ + "node_path": str(root.get_path_to(check_node)), + "shape_type": "CollisionPolygon2D", + "disabled": check_node.disabled, + "one_way_collision": check_node.one_way_collision, + "polygon_points": check_node.polygon.size(), + }) + + elif check_node is CollisionPolygon3D: + shapes.append({ + "node_path": str(root.get_path_to(check_node)), + "shape_type": "CollisionPolygon3D", + "disabled": check_node.disabled, + "polygon_points": check_node.polygon.size(), + }) + + elif check_node is RayCast2D: + raycasts.append({ + "node_path": str(root.get_path_to(check_node)), + "type": "RayCast2D", + "enabled": check_node.enabled, + "target_position": "Vector2(%s, %s)" % [check_node.target_position.x, check_node.target_position.y], + "collision_mask": check_node.collision_mask, + "collide_with_areas": check_node.collide_with_areas, + "collide_with_bodies": check_node.collide_with_bodies, + }) + + elif check_node is RayCast3D: + raycasts.append({ + "node_path": str(root.get_path_to(check_node)), + "type": "RayCast3D", + "enabled": check_node.enabled, + "target_position": "Vector3(%s, %s, %s)" % [check_node.target_position.x, check_node.target_position.y, check_node.target_position.z], + "collision_mask": check_node.collision_mask, + "collide_with_areas": check_node.collide_with_areas, + "collide_with_bodies": check_node.collide_with_bodies, + }) + + info["collision_shapes"] = shapes + info["raycasts"] = raycasts + + return success(info) diff --git a/addons/godot_mcp/commands/physics_commands.gd.uid b/addons/godot_mcp/commands/physics_commands.gd.uid new file mode 100644 index 0000000..0ad086f --- /dev/null +++ b/addons/godot_mcp/commands/physics_commands.gd.uid @@ -0,0 +1 @@ +uid://d2ng2v8ayfehs diff --git a/addons/godot_mcp/commands/profiling_commands.gd b/addons/godot_mcp/commands/profiling_commands.gd new file mode 100644 index 0000000..f8f3347 --- /dev/null +++ b/addons/godot_mcp/commands/profiling_commands.gd @@ -0,0 +1,47 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_performance_monitors": _get_performance_monitors, + "get_editor_performance": _get_editor_performance, + } + + +func _get_performance_monitors(params: Dictionary) -> Dictionary: + # Performance is a per-process singleton: reading it here would report the + # EDITOR's metrics, not the game's. Route through the game IPC channel. + if get_editor().is_playing_scene(): + var game_result := await send_game_command("get_performance_monitors", {}, 5.0) + if game_result.has("error"): + return game_result + var payload := unwrap_game_result(game_result) + var game_monitors: Dictionary = payload.get("monitors", {}) + var category: String = optional_string(params, "category", "") + if not category.is_empty(): + var filtered := {} + for key: String in game_monitors: + if key.begins_with(category): + filtered[key] = game_monitors[key] + return success({"monitors": filtered, "category": category, "process": "game"}) + return success({"monitors": game_monitors, "process": "game"}) + + return error(-32000, "No scene is currently playing", { + "suggestion": "Use play_scene first. For editor-process metrics, use get_editor_performance.", + }) + + +func _get_editor_performance(params: Dictionary) -> Dictionary: + # Quick summary for common use + var summary := { + "fps": Performance.get_monitor(Performance.TIME_FPS), + "frame_time_msec": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, + "draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), + "objects_in_frame": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME), + "node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT), + "orphan_nodes": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT), + "memory_static_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / (1024.0 * 1024.0), + "video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0), + } + return success(summary) diff --git a/addons/godot_mcp/commands/profiling_commands.gd.uid b/addons/godot_mcp/commands/profiling_commands.gd.uid new file mode 100644 index 0000000..9ece5ba --- /dev/null +++ b/addons/godot_mcp/commands/profiling_commands.gd.uid @@ -0,0 +1 @@ +uid://b3ogc0lfxeve6 diff --git a/addons/godot_mcp/commands/project_commands.gd b/addons/godot_mcp/commands/project_commands.gd new file mode 100644 index 0000000..d9ce4d3 --- /dev/null +++ b/addons/godot_mcp/commands/project_commands.gd @@ -0,0 +1,390 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_project_info": _get_project_info, + "get_filesystem_tree": _get_filesystem_tree, + "search_files": _search_files, + "search_in_files": _search_in_files, + "get_project_settings": _get_project_settings, + "set_project_setting": _set_project_setting, + "uid_to_project_path": _uid_to_project_path, + "project_path_to_uid": _project_path_to_uid, + "add_autoload": _add_autoload, + "remove_autoload": _remove_autoload, + } + + +func _get_project_info(params: Dictionary) -> Dictionary: + var info := {} + info["project_name"] = ProjectSettings.get_setting("application/config/name", "") + info["godot_version"] = Engine.get_version_info() + info["project_path"] = ProjectSettings.globalize_path("res://") + info["main_scene"] = ProjectSettings.get_setting("application/run/main_scene", "") + + # Viewport settings + info["viewport_width"] = ProjectSettings.get_setting("display/window/size/viewport_width", 0) + info["viewport_height"] = ProjectSettings.get_setting("display/window/size/viewport_height", 0) + info["window_width"] = ProjectSettings.get_setting("display/window/size/window_width_override", 0) + info["window_height"] = ProjectSettings.get_setting("display/window/size/window_height_override", 0) + + # Rendering + info["renderer"] = ProjectSettings.get_setting("rendering/renderer/rendering_method", "") + + # Autoloads + var autoloads := {} + for prop in ProjectSettings.get_property_list(): + var name: String = prop["name"] + if name.begins_with("autoload/"): + autoloads[name.substr(9)] = ProjectSettings.get_setting(name) + info["autoloads"] = autoloads + + return success(info) + + +func _get_filesystem_tree(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var filter: String = optional_string(params, "filter", "") # e.g. "*.gd", "*.tscn" + var max_depth: int = optional_int(params, "max_depth", 10) + + var tree := _scan_directory(path, filter, max_depth, 0) + return success({"tree": tree}) + + +func _scan_directory(path: String, filter: String, max_depth: int, depth: int) -> Dictionary: + var result := {"name": path.get_file(), "path": path, "type": "directory"} + + if depth >= max_depth: + return result + + var dir := DirAccess.open(path) + if dir == null: + return result + + var children: Array = [] + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + children.append(_scan_directory(full_path, filter, max_depth, depth + 1)) + else: + if filter.is_empty() or file_name.match(filter): + children.append({ + "name": file_name, + "path": full_path, + "type": "file", + }) + + file_name = dir.get_next() + + dir.list_dir_end() + + if not children.is_empty(): + result["children"] = children + + return result + + +func _search_files(params: Dictionary) -> Dictionary: + var result := require_string(params, "query") + if result[1] != null: + return result[1] + var query: String = result[0] + + var path: String = optional_string(params, "path", "res://") + var file_type: String = optional_string(params, "file_type", "") # e.g. "gd", "tscn" + var max_results: int = optional_int(params, "max_results", 50) + + var matches: Array = [] + _search_recursive(path, query, file_type, matches, max_results) + + return success({"matches": matches, "count": matches.size()}) + + +func _search_recursive(path: String, query: String, file_type: String, matches: Array, max_results: int) -> void: + if matches.size() >= max_results: + return + + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty() and matches.size() < max_results: + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + _search_recursive(full_path, query, file_type, matches, max_results) + else: + # Check file type filter + if not file_type.is_empty() and file_name.get_extension() != file_type: + file_name = dir.get_next() + continue + + # Fuzzy match: check if query is contained in filename (case insensitive) + if file_name.to_lower().contains(query.to_lower()): + matches.append(full_path) + # Also check glob pattern + elif file_name.match(query): + matches.append(full_path) + + file_name = dir.get_next() + + dir.list_dir_end() + + +func _get_project_settings(params: Dictionary) -> Dictionary: + var section: String = optional_string(params, "section", "") + var key: String = optional_string(params, "key", "") + + # If specific key requested + if not key.is_empty(): + if ProjectSettings.has_setting(key): + var value = ProjectSettings.get_setting(key) + return success({"key": key, "value": str(value), "type": typeof(value)}) + else: + return error_not_found("Setting '%s'" % key) + + # If section requested, return all settings in that section + var settings := {} + for prop in ProjectSettings.get_property_list(): + var name: String = prop["name"] + if section.is_empty() or name.begins_with(section): + settings[name] = str(ProjectSettings.get_setting(name)) + + return success({"settings": settings, "count": settings.size()}) + + +func _set_project_setting(params: Dictionary) -> Dictionary: + var result := require_string(params, "key") + if result[1] != null: + return result[1] + var key: String = result[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + + var value = params["value"] + + # Type conversion for common patterns + if value is String: + var s: String = value + # Try to parse typed values from string + if s.begins_with("Vector2("): + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed is Vector2: + value = parsed + elif s == "true": + value = true + elif s == "false": + value = false + elif s.is_valid_int(): + value = s.to_int() + elif s.is_valid_float(): + value = s.to_float() + + ProjectSettings.set_setting(key, value) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + return success({ + "key": key, + "value": str(ProjectSettings.get_setting(key)), + "saved": true, + }) + + +func _uid_to_project_path(params: Dictionary) -> Dictionary: + var result := require_string(params, "uid") + if result[1] != null: + return result[1] + var uid_str: String = result[0] + + # Use ResourceUID to convert + var uid := ResourceUID.text_to_id(uid_str) + if uid == ResourceUID.INVALID_ID: + return error_invalid_params("Invalid UID format: %s" % uid_str) + + if not ResourceUID.has_id(uid): + return error_not_found("UID '%s'" % uid_str) + + var path := ResourceUID.get_id_path(uid) + return success({"uid": uid_str, "path": path}) + + +func _project_path_to_uid(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not ResourceLoader.exists(path): + return error_not_found("Resource at '%s'" % path) + + var uid := ResourceLoader.get_resource_uid(path) + if uid == ResourceUID.INVALID_ID: + return error(-32001, "No UID assigned to '%s'" % path) + + var uid_str := ResourceUID.id_to_text(uid) + return success({"path": path, "uid": uid_str}) + + +const _TEXT_EXTENSIONS: PackedStringArray = ["gd", "tscn", "tres", "cfg", "godot", "gdshader", "md", "txt", "json"] + +func _search_in_files(params: Dictionary) -> Dictionary: + var result := require_string(params, "query") + if result[1] != null: + return result[1] + var query: String = result[0] + + var path: String = optional_string(params, "path", "res://") + var max_results: int = optional_int(params, "max_results", 50) + var use_regex: bool = optional_bool(params, "regex", false) + var file_type: String = optional_string(params, "file_type", "") + + var regex: RegEx = null + if use_regex: + regex = RegEx.new() + var err := regex.compile(query) + if err != OK: + return error_invalid_params("Invalid regex pattern: %s" % error_string(err)) + + var matches: Array = [] + _search_in_files_recursive(path, query, regex, file_type, matches, max_results) + + return success({"matches": matches, "count": matches.size(), "query": query}) + + +func _search_in_files_recursive(path: String, query: String, regex: RegEx, file_type: String, matches: Array, max_results: int) -> void: + if matches.size() >= max_results: + return + + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty() and matches.size() < max_results: + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + # Skip addons and .godot directories + if file_name != "addons" and file_name != ".godot": + _search_in_files_recursive(full_path, query, regex, file_type, matches, max_results) + else: + var ext := file_name.get_extension() + # Filter by file type if specified, otherwise use text extensions + if not file_type.is_empty(): + if ext != file_type: + file_name = dir.get_next() + continue + elif ext not in _TEXT_EXTENSIONS: + file_name = dir.get_next() + continue + + var file := FileAccess.open(full_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + for i in range(lines.size()): + if matches.size() >= max_results: + break + var line: String = lines[i] + var matched := false + if regex != null: + matched = regex.search(line) != null + else: + matched = line.contains(query) + if matched: + matches.append({ + "file": full_path, + "line": i + 1, + "text": line.strip_edges(), + }) + + file_name = dir.get_next() + + dir.list_dir_end() + + +func _add_autoload(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var autoload_name: String = result[0] + + var result2 := require_string(params, "path") + if result2[1] != null: + return result2[1] + var autoload_path: String = result2[0] + + if not FileAccess.file_exists(autoload_path): + return error_not_found("File '%s'" % autoload_path) + + # Check if already exists + var setting_key := "autoload/" + autoload_name + if ProjectSettings.has_setting(setting_key): + return error(-32000, "Autoload '%s' already exists" % autoload_name, { + "current_value": str(ProjectSettings.get_setting(setting_key)), + "suggestion": "Use remove_autoload first to replace it", + }) + + # Autoload format: "*res://path.gd" (the * prefix means it's a singleton) + ProjectSettings.set_setting(setting_key, "*" + autoload_path) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + return success({ + "name": autoload_name, + "path": autoload_path, + "added": true, + }) + + +func _remove_autoload(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var autoload_name: String = result[0] + + var setting_key := "autoload/" + autoload_name + if not ProjectSettings.has_setting(setting_key): + return error_not_found("Autoload '%s'" % autoload_name) + + var old_value: String = str(ProjectSettings.get_setting(setting_key)) + ProjectSettings.clear(setting_key) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + return success({ + "name": autoload_name, + "old_path": old_value, + "removed": true, + }) diff --git a/addons/godot_mcp/commands/project_commands.gd.uid b/addons/godot_mcp/commands/project_commands.gd.uid new file mode 100644 index 0000000..96fb774 --- /dev/null +++ b/addons/godot_mcp/commands/project_commands.gd.uid @@ -0,0 +1 @@ +uid://c4loc8wwb4qe8 diff --git a/addons/godot_mcp/commands/resource_commands.gd b/addons/godot_mcp/commands/resource_commands.gd new file mode 100644 index 0000000..4e8ba6b --- /dev/null +++ b/addons/godot_mcp/commands/resource_commands.gd @@ -0,0 +1,205 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "read_resource": _read_resource, + "edit_resource": _edit_resource, + "create_resource": _create_resource, + "get_resource_preview": _get_resource_preview, + } + + +func _read_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Resource '%s'" % path) + + var guard := guard_offline_scene_save(path) + if not guard.is_empty(): + return guard + + var resource: Resource = ResourceLoader.load(path) + if resource == null: + return error_internal("Failed to load resource: %s" % path) + + var props: Dictionary = {} + for prop_info in resource.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script" or prop_name == "resource_local_to_scene" or prop_name == "resource_name" or prop_name == "resource_path": + continue + props[prop_name] = PropertyParser.serialize_value(resource.get(prop_name)) + + return success({ + "path": path, + "type": resource.get_class(), + "resource_name": resource.resource_name, + "properties": props, + }) + + +func _edit_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not params.has("properties") or not params["properties"] is Dictionary: + return error_invalid_params("'properties' dictionary is required") + var new_props: Dictionary = params["properties"] + + if not FileAccess.file_exists(path): + return error_not_found("Resource '%s'" % path) + + var guard := guard_offline_scene_save(path) + if not guard.is_empty(): + return guard + + var resource: Resource = ResourceLoader.load(path) + if resource == null: + return error_internal("Failed to load resource: %s" % path) + + var changed: Dictionary = {} + for prop_name: String in new_props: + if not prop_name in resource: + continue + var old_value: Variant = resource.get(prop_name) + var target_type := typeof(old_value) + var new_value: Variant = PropertyParser.parse_value(new_props[prop_name], target_type) + resource.set(prop_name, new_value) + changed[prop_name] = { + "old": PropertyParser.serialize_value(old_value), + "new": PropertyParser.serialize_value(resource.get(prop_name)), + } + + if changed.is_empty(): + return success({"path": path, "changed": {}, "message": "No properties were changed"}) + + var err := ResourceSaver.save(resource, path) + if err != OK: + return error_internal("Failed to save resource: %s" % error_string(err)) + + return success({ + "path": path, + "type": resource.get_class(), + "changed": changed, + }) + + +func _create_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var result2 := require_string(params, "type") + if result2[1] != null: + return result2[1] + var resource_type: String = result2[0] + + if not ClassDB.class_exists(resource_type): + return error_invalid_params("Unknown resource type: %s" % resource_type) + if not ClassDB.is_parent_class(resource_type, "Resource"): + return error_invalid_params("'%s' is not a Resource type" % resource_type) + + var overwrite: bool = optional_bool(params, "overwrite", false) + if FileAccess.file_exists(path) and not overwrite: + return error(-32000, "Resource already exists: %s" % path, {"suggestion": "Set overwrite=true to replace"}) + + var guard := guard_offline_scene_save(path) + if not guard.is_empty(): + return guard + + var resource: Resource = ClassDB.instantiate(resource_type) + if resource == null: + return error_internal("Failed to instantiate: %s" % resource_type) + + # Apply properties + var properties: Dictionary = params.get("properties", {}) + for prop_name: String in properties: + if prop_name in resource: + var current: Variant = resource.get(prop_name) + resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current))) + + var dir_guard := ensure_parent_dir(path) + if not dir_guard.is_empty(): + return dir_guard + + var err := ResourceSaver.save(resource, path) + if err != OK: + return error_internal("Failed to save resource '%s': %s" % [path, error_string(err)]) + + # Rescan filesystem + EditorInterface.get_resource_filesystem().scan() + + return success({ + "path": path, + "type": resource_type, + "properties_set": properties.keys(), + }) + + +func _get_resource_preview(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Resource '%s'" % path) + + var max_size: int = optional_int(params, "max_size", 256) + var image: Image = null + + # Try loading as image file directly + var ext := path.get_extension().to_lower() + if ext in ["png", "jpg", "jpeg", "bmp", "webp", "svg"]: + image = Image.new() + var err := image.load(path) + if err != OK: + return error_internal("Failed to load image: %s" % error_string(err)) + else: + # Try loading as resource and extracting image + var resource: Resource = ResourceLoader.load(path) + if resource == null: + return error_internal("Failed to load resource: %s" % path) + + if resource is Texture2D: + image = (resource as Texture2D).get_image() + elif resource is Image: + image = resource as Image + else: + return error_invalid_params("Resource type '%s' does not have an image preview" % resource.get_class()) + + if image == null: + return error_internal("Could not extract image from resource") + + # Resize if needed + if image.get_width() > max_size or image.get_height() > max_size: + var scale_x := float(max_size) / float(image.get_width()) + var scale_y := float(max_size) / float(image.get_height()) + var scale := minf(scale_x, scale_y) + var new_w := int(image.get_width() * scale) + var new_h := int(image.get_height() * scale) + image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS) + + var png_buffer := image.save_png_to_buffer() + var base64 := Marshalls.raw_to_base64(png_buffer) + + return success({ + "image_base64": base64, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + "path": path, + }) diff --git a/addons/godot_mcp/commands/resource_commands.gd.uid b/addons/godot_mcp/commands/resource_commands.gd.uid new file mode 100644 index 0000000..af4ddf4 --- /dev/null +++ b/addons/godot_mcp/commands/resource_commands.gd.uid @@ -0,0 +1 @@ +uid://bmyqrhfpp382w diff --git a/addons/godot_mcp/commands/runtime_commands.gd b/addons/godot_mcp/commands/runtime_commands.gd new file mode 100644 index 0000000..ab424aa --- /dev/null +++ b/addons/godot_mcp/commands/runtime_commands.gd @@ -0,0 +1,373 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +## Editor-side commands for runtime game inspection. +## Communicates with MCPGameInspector autoload via file-based IPC. + + +func get_commands() -> Dictionary: + return { + "get_game_scene_tree": _get_game_scene_tree, + "get_game_node_properties": _get_game_node_properties, + "set_game_node_property": _set_game_node_property, + "capture_frames": _capture_frames, + "monitor_properties": _monitor_properties, + "execute_game_script": _execute_game_script, + "start_recording": _start_recording, + "stop_recording": _stop_recording, + "replay_recording": _replay_recording, + "find_nodes_by_script": _find_nodes_by_script, + "get_autoload": _get_autoload, + "batch_get_properties": _batch_get_properties, + "find_ui_elements": _find_ui_elements, + "click_button_by_text": _click_button_by_text, + "wait_for_node": _wait_for_node, + "find_nearby_nodes": _find_nearby_nodes, + "navigate_to": _navigate_to, + "move_to": _move_to, + "watch_signals": _watch_signals, + } + + +func _get_game_scene_tree(params: Dictionary) -> Dictionary: + var max_depth: int = optional_int(params, "max_depth", -1) + var cmd_params := {"max_depth": max_depth} + + var script_filter: String = optional_string(params, "script_filter") + if not script_filter.is_empty(): + cmd_params["script_filter"] = script_filter + + var type_filter: String = optional_string(params, "type_filter") + if not type_filter.is_empty(): + cmd_params["type_filter"] = type_filter + + var named_only: bool = optional_bool(params, "named_only", false) + if named_only: + cmd_params["named_only"] = true + + return await _send_game_command("get_scene_tree", cmd_params) + + +func _get_game_node_properties(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + var cmd_params := {"node_path": result[0]} + # Optional property filter + if params.has("properties") and params["properties"] is Array: + cmd_params["properties"] = params["properties"] + + return await _send_game_command("get_node_properties", cmd_params) + + +func _set_game_node_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + var prop_result := require_string(params, "property") + if prop_result[1] != null: + return prop_result[1] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + + return await _send_game_command("set_node_property", { + "node_path": result[0], + "property": prop_result[0], + "value": params["value"], + }) + + +func _execute_game_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "code") + if result[1] != null: + return result[1] + + return await _send_game_command("execute_script", { + "code": result[0], + }, 10.0) + + +func _capture_frames(params: Dictionary) -> Dictionary: + var count: int = optional_int(params, "count", 5) + var frame_interval: int = optional_int(params, "frame_interval", 10) + var half_resolution: bool = optional_bool(params, "half_resolution", true) + + # Dynamic timeout: allow enough time for frame capture + # At 60fps, 30 frames * 10 interval = 300 frames = 5 seconds + overhead + var estimated_seconds: float = (count * frame_interval) / 60.0 + 2.0 + var timeout := minf(estimated_seconds, 25.0) + + return await _send_game_command("capture_frames", { + "count": count, + "frame_interval": frame_interval, + "half_resolution": half_resolution, + }, timeout) + + +func _monitor_properties(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + if not params.has("properties") or not params["properties"] is Array: + return error_invalid_params("'properties' array is required") + + var frame_count: int = optional_int(params, "frame_count", 60) + var frame_interval: int = optional_int(params, "frame_interval", 1) + + # Dynamic timeout + var estimated_seconds: float = (frame_count * frame_interval) / 60.0 + 2.0 + var timeout := minf(estimated_seconds, 25.0) + + return await _send_game_command("monitor_properties", { + "node_path": result[0], + "properties": params["properties"], + "frame_count": frame_count, + "frame_interval": frame_interval, + }, timeout) + + +func _start_recording(params: Dictionary) -> Dictionary: + return await _send_game_command("start_recording", {}) + + +func _stop_recording(params: Dictionary) -> Dictionary: + return await _send_game_command("stop_recording", {}, 5.0) + + +func _replay_recording(params: Dictionary) -> Dictionary: + if not params.has("events") or not params["events"] is Array: + return error_invalid_params("'events' array is required") + var speed: float = float(params.get("speed", 1.0)) + + # Calculate timeout based on event duration + var max_time_ms: int = 0 + for event_data: Dictionary in params["events"]: + var t: int = int(event_data.get("time_ms", 0)) + if t > max_time_ms: + max_time_ms = t + var timeout := (max_time_ms / 1000.0 / speed) + 5.0 + + return await _send_game_command("replay_recording", { + "events": params["events"], + "speed": speed, + }, minf(timeout, 120.0)) + + +func _find_nodes_by_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "script") + if result[1] != null: + return result[1] + + var cmd_params := {"script": result[0]} + if params.has("properties") and params["properties"] is Array: + cmd_params["properties"] = params["properties"] + + return await _send_game_command("find_nodes_by_script", cmd_params) + + +func _get_autoload(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + + var cmd_params := {"name": result[0]} + if params.has("properties") and params["properties"] is Array: + cmd_params["properties"] = params["properties"] + + return await _send_game_command("get_autoload", cmd_params) + + +func _batch_get_properties(params: Dictionary) -> Dictionary: + if not params.has("nodes") or not params["nodes"] is Array: + return error_invalid_params("'nodes' array is required") + + return await _send_game_command("batch_get_properties", { + "nodes": params["nodes"], + }) + + +func _find_ui_elements(params: Dictionary) -> Dictionary: + var cmd_params := {} + var type_filter: String = optional_string(params, "type_filter") + if not type_filter.is_empty(): + cmd_params["type_filter"] = type_filter + return await _send_game_command("find_ui_elements", cmd_params) + + +func _click_button_by_text(params: Dictionary) -> Dictionary: + var result := require_string(params, "text") + if result[1] != null: + return result[1] + + var cmd_params := {"text": result[0]} + var partial: bool = optional_bool(params, "partial", true) + cmd_params["partial"] = partial + + return await _send_game_command("click_button_by_text", cmd_params) + + +func _wait_for_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + var timeout: float = float(params.get("timeout", 5.0)) + var poll_frames: int = optional_int(params, "poll_frames", 5) + + return await _send_game_command("wait_for_node", { + "node_path": result[0], + "timeout": timeout, + "poll_frames": poll_frames, + }, timeout + 2.0) + + +func _find_nearby_nodes(params: Dictionary) -> Dictionary: + if not params.has("position"): + return error_invalid_params("Missing required parameter: position") + + var cmd_params: Dictionary = {"position": params["position"]} + if params.has("radius"): + cmd_params["radius"] = float(params["radius"]) + var type_filter: String = optional_string(params, "type_filter") + if not type_filter.is_empty(): + cmd_params["type_filter"] = type_filter + var group_filter: String = optional_string(params, "group_filter") + if not group_filter.is_empty(): + cmd_params["group_filter"] = group_filter + if params.has("max_results"): + cmd_params["max_results"] = int(params["max_results"]) + + return await _send_game_command("find_nearby_nodes", cmd_params) + + +func _navigate_to(params: Dictionary) -> Dictionary: + if not params.has("target"): + return error_invalid_params("Missing required parameter: target") + + var cmd_params: Dictionary = {"target": params["target"]} + var player_path: String = optional_string(params, "player_path") + if not player_path.is_empty(): + cmd_params["player_path"] = player_path + var camera_path: String = optional_string(params, "camera_path") + if not camera_path.is_empty(): + cmd_params["camera_path"] = camera_path + if params.has("move_speed"): + cmd_params["move_speed"] = float(params["move_speed"]) + + return await _send_game_command("navigate_to", cmd_params) + + +func _move_to(params: Dictionary) -> Dictionary: + if not params.has("target"): + return error_invalid_params("Missing required parameter: target") + + var cmd_params: Dictionary = {"target": params["target"]} + var player_path: String = optional_string(params, "player_path") + if not player_path.is_empty(): + cmd_params["player_path"] = player_path + var camera_path: String = optional_string(params, "camera_path") + if not camera_path.is_empty(): + cmd_params["camera_path"] = camera_path + if params.has("arrival_radius"): + cmd_params["arrival_radius"] = float(params["arrival_radius"]) + if params.has("timeout"): + cmd_params["timeout"] = float(params["timeout"]) + if params.has("run"): + cmd_params["run"] = bool(params["run"]) + if params.has("look_at_target"): + cmd_params["look_at_target"] = bool(params["look_at_target"]) + + # Dynamic timeout: game-side timeout + overhead for IPC polling + var game_timeout: float = float(params.get("timeout", 15.0)) + var ipc_timeout: float = game_timeout + 5.0 + + return await _send_game_command("move_to", cmd_params, ipc_timeout) + + +func _watch_signals(params: Dictionary) -> Dictionary: + if not params.has("node_paths") or not params["node_paths"] is Array: + return error_invalid_params("Missing required parameter: node_paths (Array)") + + var cmd_params: Dictionary = {"node_paths": params["node_paths"]} + if params.has("signal_filter") and params["signal_filter"] is Array: + cmd_params["signal_filter"] = params["signal_filter"] + var duration_ms: int = optional_int(params, "duration_ms", 5000) + cmd_params["duration_ms"] = duration_ms + + # Dynamic timeout: duration + overhead + var timeout_sec: float = (duration_ms / 1000.0) + 5.0 + + return await _send_game_command("watch_signals", cmd_params, timeout_sec) + + +# ── IPC Helper ──────────────────────────────────────────────────────────────── + +func _send_game_command(command: String, params: Dictionary, timeout_sec: float = 5.0) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + + # Clean stale response + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) + + # Write request + var request_data := JSON.stringify({"command": command, "params": params}) + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create game request file") + req.store_string(request_data) + req.close() + + # Poll for response + var attempts := int(timeout_sec / 0.1) + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + # Check if game is still running + if not ei.is_playing_scene(): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game stopped during command execution") + attempts -= 1 + + if not FileAccess.file_exists(response_path): + # Try to auto-resume the debugger (runtime error may have paused the game) + if ei.is_playing_scene(): + try_debugger_continue() + # Give the game a chance to recover and write a response + for _retry in 20: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + + if not FileAccess.file_exists(response_path): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return build_timeout_error(timeout_sec) + + # Read response + var file := FileAccess.open(response_path, FileAccess.READ) + if file == null: + return error_internal("Could not read game response file") + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(response_path) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + return error_internal("Invalid response JSON from game") + + if parsed.has("error"): + return error(-32000, str(parsed["error"])) + + return success(parsed) diff --git a/addons/godot_mcp/commands/runtime_commands.gd.uid b/addons/godot_mcp/commands/runtime_commands.gd.uid new file mode 100644 index 0000000..642a1d3 --- /dev/null +++ b/addons/godot_mcp/commands/runtime_commands.gd.uid @@ -0,0 +1 @@ +uid://diyd0jmvjhevd diff --git a/addons/godot_mcp/commands/scene_3d_commands.gd b/addons/godot_mcp/commands/scene_3d_commands.gd new file mode 100644 index 0000000..5798b86 --- /dev/null +++ b/addons/godot_mcp/commands/scene_3d_commands.gd @@ -0,0 +1,680 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") +const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") + + +func get_commands() -> Dictionary: + return { + "add_mesh_instance": _add_mesh_instance, + "setup_lighting": _setup_lighting, + "set_material_3d": _set_material_3d, + "setup_environment": _setup_environment, + "setup_camera_3d": _setup_camera_3d, + "add_gridmap": _add_gridmap, + } + + +## ─── Helpers ─────────────────────────────────────────────────────────────── + +func _optional_float(params: Dictionary, key: String, default: float) -> float: + if params.has(key): + return float(params[key]) + return default + + +func _parse_color_param(params: Dictionary, key: String, default: Color) -> Color: + if not params.has(key): + return default + var val: Variant = params[key] + if val is String: + return PropertyParser.parse_value(val, TYPE_COLOR) + if val is Dictionary: + return Color( + float(val.get("r", default.r)), + float(val.get("g", default.g)), + float(val.get("b", default.b)), + float(val.get("a", default.a)) + ) + return default + + +func _parse_vector3_param(params: Dictionary, key: String, default: Vector3) -> Vector3: + if not params.has(key): + return default + var val: Variant = params[key] + if val is String: + return PropertyParser.parse_value(val, TYPE_VECTOR3) + if val is Dictionary: + return Vector3( + float(val.get("x", default.x)), + float(val.get("y", default.y)), + float(val.get("z", default.z)) + ) + if val is Array and val.size() >= 3: + return Vector3(float(val[0]), float(val[1]), float(val[2])) + return default + + +func _add_child_with_undo(node: Node, parent: Node, root: Node, action_name: String) -> void: + var undo_redo := get_undo_redo() + undo_redo.create_action(action_name) + undo_redo.add_do_method(parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(parent, "remove_child", node) + undo_redo.commit_action() + + +## ─── 1. add_mesh_instance ────────────────────────────────────────────────── + +func _add_mesh_instance(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "MeshInstance3D") + var mesh_type: String = optional_string(params, "mesh_type", "") + var mesh_file: String = optional_string(params, "mesh_file", "") + + if mesh_type.is_empty() and mesh_file.is_empty(): + return error_invalid_params("Either 'mesh_type' or 'mesh_file' is required") + + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = node_name + + if not mesh_file.is_empty(): + # Load .glb / .gltf / .obj + if not ResourceLoader.exists(mesh_file): + mesh_instance.queue_free() + return error_not_found("Mesh file '%s'" % mesh_file, "Provide a valid res:// path to .glb, .gltf, or .obj") + var loaded: Resource = load(mesh_file) + if loaded is Mesh: + mesh_instance.mesh = loaded as Mesh + elif loaded is PackedScene: + # For .glb/.gltf we instantiate and steal the first MeshInstance3D's mesh + var scene_instance: Node = (loaded as PackedScene).instantiate() + var found_mesh: Mesh = null + var search_nodes: Array[Node] = [scene_instance] + while not search_nodes.is_empty(): + var n: Node = search_nodes.pop_front() + if n is MeshInstance3D and (n as MeshInstance3D).mesh != null: + found_mesh = (n as MeshInstance3D).mesh + break + for child in n.get_children(): + search_nodes.append(child) + scene_instance.queue_free() + if found_mesh == null: + mesh_instance.queue_free() + return error_invalid_params("No mesh found in '%s'" % mesh_file) + mesh_instance.mesh = found_mesh + else: + mesh_instance.queue_free() + return error_invalid_params("'%s' is not a Mesh or PackedScene" % mesh_file) + else: + # Primitive mesh + var mesh_classes := { + "BoxMesh": BoxMesh, + "SphereMesh": SphereMesh, + "CylinderMesh": CylinderMesh, + "CapsuleMesh": CapsuleMesh, + "PlaneMesh": PlaneMesh, + "PrismMesh": PrismMesh, + "TorusMesh": TorusMesh, + "QuadMesh": QuadMesh, + } + if not mesh_classes.has(mesh_type): + mesh_instance.queue_free() + return error_invalid_params("Unknown mesh_type '%s'. Available: %s" % [mesh_type, mesh_classes.keys()]) + var mesh_res: Mesh = mesh_classes[mesh_type].new() + # Apply mesh properties if provided + var mesh_properties: Dictionary = params.get("mesh_properties", {}) + for prop_name: String in mesh_properties: + if prop_name in mesh_res: + var current: Variant = mesh_res.get(prop_name) + mesh_res.set(prop_name, PropertyParser.parse_value(mesh_properties[prop_name], typeof(current))) + mesh_instance.mesh = mesh_res + + # Transform + var position := _parse_vector3_param(params, "position", Vector3.ZERO) + var rotation_deg := _parse_vector3_param(params, "rotation", Vector3.ZERO) + var scale_vec := _parse_vector3_param(params, "scale", Vector3.ONE) + + mesh_instance.position = position + mesh_instance.rotation_degrees = rotation_deg + mesh_instance.scale = scale_vec + + _add_child_with_undo(mesh_instance, parent, root, "MCP: Add MeshInstance3D") + + return success({ + "node_path": str(root.get_path_to(mesh_instance)), + "name": str(mesh_instance.name), + "mesh_type": mesh_type if mesh_file.is_empty() else mesh_file, + }) + + +## ─── 2. setup_lighting ──────────────────────────────────────────────────── + +func _setup_lighting(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var light_type: String = optional_string(params, "light_type", "") + var preset: String = optional_string(params, "preset", "") + var node_name: String = optional_string(params, "name", "") + + # Preset configurations + if not preset.is_empty(): + match preset: + "sun": + light_type = "DirectionalLight3D" + if node_name.is_empty(): + node_name = "SunLight" + "indoor": + light_type = "OmniLight3D" + if node_name.is_empty(): + node_name = "IndoorLight" + "dramatic": + light_type = "SpotLight3D" + if node_name.is_empty(): + node_name = "DramaticLight" + _: + return error_invalid_params("Unknown preset '%s'. Available: sun, indoor, dramatic" % preset) + + if light_type.is_empty(): + return error_invalid_params("Either 'light_type' or 'preset' is required") + + var light: Light3D + match light_type: + "DirectionalLight3D": + light = DirectionalLight3D.new() + "OmniLight3D": + light = OmniLight3D.new() + "SpotLight3D": + light = SpotLight3D.new() + _: + return error_invalid_params("Unknown light_type '%s'. Available: DirectionalLight3D, OmniLight3D, SpotLight3D" % light_type) + + if node_name.is_empty(): + node_name = light_type + light.name = node_name + + # Common properties + light.light_color = _parse_color_param(params, "color", Color.WHITE) + light.light_energy = _optional_float(params, "energy", 1.0) + light.shadow_enabled = optional_bool(params, "shadows", false) + + # Type-specific properties + if light is OmniLight3D: + var omni: OmniLight3D = light as OmniLight3D + omni.omni_range = _optional_float(params, "range", 5.0) + omni.omni_attenuation = _optional_float(params, "attenuation", 1.0) + elif light is SpotLight3D: + var spot: SpotLight3D = light as SpotLight3D + spot.spot_range = _optional_float(params, "range", 5.0) + spot.spot_attenuation = _optional_float(params, "attenuation", 1.0) + spot.spot_angle = _optional_float(params, "spot_angle", 45.0) + spot.spot_angle_attenuation = _optional_float(params, "spot_angle_attenuation", 1.0) + + # Apply preset defaults after type creation + if not preset.is_empty(): + match preset: + "sun": + light.light_energy = _optional_float(params, "energy", 1.0) + light.shadow_enabled = optional_bool(params, "shadows", true) + light.rotation_degrees = _parse_vector3_param(params, "rotation", Vector3(-45, -30, 0)) + "indoor": + light.light_energy = _optional_float(params, "energy", 0.8) + light.light_color = _parse_color_param(params, "color", Color(1.0, 0.95, 0.85)) + if light is OmniLight3D: + (light as OmniLight3D).omni_range = _optional_float(params, "range", 8.0) + "dramatic": + light.light_energy = _optional_float(params, "energy", 2.0) + light.shadow_enabled = optional_bool(params, "shadows", true) + if light is SpotLight3D: + (light as SpotLight3D).spot_angle = _optional_float(params, "spot_angle", 25.0) + (light as SpotLight3D).spot_range = _optional_float(params, "range", 10.0) + + # Position / rotation + light.position = _parse_vector3_param(params, "position", Vector3.ZERO) + if params.has("rotation"): + light.rotation_degrees = _parse_vector3_param(params, "rotation", light.rotation_degrees) + + _add_child_with_undo(light, parent, root, "MCP: Add %s" % light_type) + + return success({ + "node_path": str(root.get_path_to(light)), + "name": str(light.name), + "light_type": light_type, + "preset": preset, + }) + + +## ─── 3. set_material_3d ─────────────────────────────────────────────────── + +func _set_material_3d(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path) + + if not node is MeshInstance3D: + return error_invalid_params("Node '%s' is not a MeshInstance3D (is %s)" % [node_path, node.get_class()]) + + var mesh_inst: MeshInstance3D = node as MeshInstance3D + var surface_index: int = optional_int(params, "surface_index", 0) + + var mat := StandardMaterial3D.new() + + # Albedo + mat.albedo_color = _parse_color_param(params, "albedo_color", Color.WHITE) + if params.has("albedo_texture"): + var tex_path: String = params["albedo_texture"] + if ResourceLoader.exists(tex_path): + mat.albedo_texture = load(tex_path) as Texture2D + + # PBR + mat.metallic = _optional_float(params, "metallic", 0.0) + mat.roughness = _optional_float(params, "roughness", 1.0) + if params.has("metallic_texture"): + var tex_path: String = params["metallic_texture"] + if ResourceLoader.exists(tex_path): + mat.metallic_texture = load(tex_path) as Texture2D + if params.has("roughness_texture"): + var tex_path: String = params["roughness_texture"] + if ResourceLoader.exists(tex_path): + mat.roughness_texture = load(tex_path) as Texture2D + if params.has("normal_texture"): + mat.normal_enabled = true + var tex_path: String = params["normal_texture"] + if ResourceLoader.exists(tex_path): + mat.normal_texture = load(tex_path) as Texture2D + + # Emission + if params.has("emission") or params.has("emission_color"): + mat.emission_enabled = true + mat.emission = _parse_color_param(params, "emission", _parse_color_param(params, "emission_color", Color.BLACK)) + mat.emission_energy_multiplier = _optional_float(params, "emission_energy", 1.0) + if params.has("emission_texture"): + mat.emission_enabled = true + var tex_path: String = params["emission_texture"] + if ResourceLoader.exists(tex_path): + mat.emission_texture = load(tex_path) as Texture2D + + # Transparency + if params.has("transparency"): + var transparency_val: String = str(params["transparency"]) + match transparency_val.to_upper(): + "DISABLED", "0": + mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED + "ALPHA", "1": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + "ALPHA_SCISSOR", "2": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR + "ALPHA_HASH", "3": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_HASH + "ALPHA_DEPTH_PRE_PASS", "4": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS + + # Cull mode + if params.has("cull_mode"): + var cull_val: String = str(params["cull_mode"]) + match cull_val.to_upper(): + "BACK", "0": + mat.cull_mode = BaseMaterial3D.CULL_BACK + "FRONT", "1": + mat.cull_mode = BaseMaterial3D.CULL_FRONT + "DISABLED", "2": + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + + # Apply + var old_mat: Material = mesh_inst.get_surface_override_material(surface_index) + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set material on %s" % mesh_inst.name) + undo_redo.add_do_method(mesh_inst, "set_surface_override_material", surface_index, mat) + undo_redo.add_undo_method(mesh_inst, "set_surface_override_material", surface_index, old_mat) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(mesh_inst)), + "surface_index": surface_index, + "albedo_color": str(mat.albedo_color), + "metallic": mat.metallic, + "roughness": mat.roughness, + }) + + +## ─── 4. setup_environment ───────────────────────────────────────────────── + +func _setup_environment(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "WorldEnvironment") + + # Check if a WorldEnvironment already exists at the target + var node_path: String = optional_string(params, "node_path", "") + var world_env: WorldEnvironment = null + var is_existing := false + + if not node_path.is_empty(): + var existing := find_node_by_path(node_path) + if existing != null and existing is WorldEnvironment: + world_env = existing as WorldEnvironment + is_existing = true + + if world_env == null: + world_env = WorldEnvironment.new() + world_env.name = node_name + + var env: Environment = world_env.environment + if env == null: + env = Environment.new() + + # Background / Sky + var bg_mode: String = optional_string(params, "background_mode", "sky") + match bg_mode.to_lower(): + "sky": + env.background_mode = Environment.BG_SKY + "color": + env.background_mode = Environment.BG_COLOR + env.background_color = _parse_color_param(params, "background_color", Color(0.3, 0.3, 0.3)) + "canvas": + env.background_mode = Environment.BG_CANVAS + "clear_color": + env.background_mode = Environment.BG_CLEAR_COLOR + + # Procedural sky + if params.has("sky") and params["sky"] is Dictionary: + var sky_params: Dictionary = params["sky"] + var sky_mat := ProceduralSkyMaterial.new() + sky_mat.sky_top_color = _parse_color_param(sky_params, "sky_top_color", Color(0.385, 0.454, 0.55)) + sky_mat.sky_horizon_color = _parse_color_param(sky_params, "sky_horizon_color", Color(0.646, 0.654, 0.67)) + sky_mat.ground_bottom_color = _parse_color_param(sky_params, "ground_bottom_color", Color(0.2, 0.169, 0.133)) + sky_mat.ground_horizon_color = _parse_color_param(sky_params, "ground_horizon_color", Color(0.646, 0.654, 0.67)) + sky_mat.sun_angle_max = _optional_float(sky_params, "sun_angle_max", 30.0) if sky_params.has("sun_angle_max") else 30.0 + sky_mat.sky_curve = _optional_float(sky_params, "sky_curve", 0.15) if sky_params.has("sky_curve") else 0.15 + + var sky := Sky.new() + sky.sky_material = sky_mat + env.sky = sky + env.background_mode = Environment.BG_SKY + + # Ambient light + if params.has("ambient_light_color"): + env.ambient_light_color = _parse_color_param(params, "ambient_light_color", Color.WHITE) + env.ambient_light_energy = _optional_float(params, "ambient_light_energy", 1.0) if params.has("ambient_light_energy") else env.ambient_light_energy + if params.has("ambient_light_source"): + var src: String = str(params["ambient_light_source"]) + match src.to_upper(): + "BACKGROUND", "0": + env.ambient_light_source = Environment.AMBIENT_SOURCE_BG + "DISABLED", "1": + env.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED + "COLOR", "2": + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + "SKY", "3": + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + + # Tonemap + if params.has("tonemap_mode"): + var tm: String = str(params["tonemap_mode"]) + match tm.to_upper(): + "LINEAR", "0": + env.tonemap_mode = Environment.TONE_MAPPER_LINEAR + "REINHARDT", "1": + env.tonemap_mode = Environment.TONE_MAPPER_REINHARDT + "FILMIC", "2": + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + "ACES", "3": + env.tonemap_mode = Environment.TONE_MAPPER_ACES + "AGX", "4": + env.tonemap_mode = 4 # Environment.TONE_MAPPER_AGX (Godot 4.4+) + if params.has("tonemap_exposure"): + env.tonemap_exposure = _optional_float(params, "tonemap_exposure", 1.0) + if params.has("tonemap_white"): + env.tonemap_white = _optional_float(params, "tonemap_white", 1.0) + + # Fog + if params.has("fog_enabled"): + env.fog_enabled = optional_bool(params, "fog_enabled", false) + if env.fog_enabled or params.has("fog_light_color"): + env.fog_light_color = _parse_color_param(params, "fog_light_color", Color(0.518, 0.553, 0.608)) + env.fog_density = _optional_float(params, "fog_density", 0.01) if params.has("fog_density") else env.fog_density + env.fog_light_energy = _optional_float(params, "fog_light_energy", 1.0) if params.has("fog_light_energy") else env.fog_light_energy + + # Glow + if params.has("glow_enabled"): + env.glow_enabled = optional_bool(params, "glow_enabled", false) + if env.glow_enabled: + env.glow_intensity = _optional_float(params, "glow_intensity", 0.8) if params.has("glow_intensity") else env.glow_intensity + env.glow_strength = _optional_float(params, "glow_strength", 1.0) if params.has("glow_strength") else env.glow_strength + env.glow_bloom = _optional_float(params, "glow_bloom", 0.0) if params.has("glow_bloom") else env.glow_bloom + + # SSAO + if params.has("ssao_enabled"): + env.ssao_enabled = optional_bool(params, "ssao_enabled", false) + if env.ssao_enabled: + env.ssao_radius = _optional_float(params, "ssao_radius", 1.0) if params.has("ssao_radius") else env.ssao_radius + env.ssao_intensity = _optional_float(params, "ssao_intensity", 2.0) if params.has("ssao_intensity") else env.ssao_intensity + + # SSR + if params.has("ssr_enabled"): + env.ssr_enabled = optional_bool(params, "ssr_enabled", false) + if env.ssr_enabled: + env.ssr_max_steps = optional_int(params, "ssr_max_steps", 64) if params.has("ssr_max_steps") else env.ssr_max_steps + env.ssr_fade_in = _optional_float(params, "ssr_fade_in", 0.15) if params.has("ssr_fade_in") else env.ssr_fade_in + env.ssr_fade_out = _optional_float(params, "ssr_fade_out", 2.0) if params.has("ssr_fade_out") else env.ssr_fade_out + + # SDFGI + if params.has("sdfgi_enabled"): + env.sdfgi_enabled = optional_bool(params, "sdfgi_enabled", false) + + world_env.environment = env + + if not is_existing: + _add_child_with_undo(world_env, parent, root, "MCP: Add WorldEnvironment") + + var features: Array = [] + if env.fog_enabled: features.append("fog") + if env.glow_enabled: features.append("glow") + if env.ssao_enabled: features.append("ssao") + if env.ssr_enabled: features.append("ssr") + if env.sdfgi_enabled: features.append("sdfgi") + + return success({ + "node_path": str(root.get_path_to(world_env)), + "name": str(world_env.name), + "background_mode": bg_mode, + "features": features, + "is_existing": is_existing, + }) + + +## ─── 5. setup_camera_3d ────────────────────────────────────────────────── + +func _setup_camera_3d(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + # Check if we're configuring an existing camera + var node_path: String = optional_string(params, "node_path", "") + var camera: Camera3D = null + var is_existing := false + + if not node_path.is_empty(): + var existing := find_node_by_path(node_path) + if existing != null and existing is Camera3D: + camera = existing as Camera3D + is_existing = true + elif existing != null: + return error_invalid_params("Node '%s' is not a Camera3D (is %s)" % [node_path, existing.get_class()]) + + if camera == null: + camera = Camera3D.new() + camera.name = optional_string(params, "name", "Camera3D") + + # Projection + var projection_str: String = optional_string(params, "projection", "") + if not projection_str.is_empty(): + match projection_str.to_lower(): + "perspective", "0": + camera.projection = Camera3D.PROJECTION_PERSPECTIVE + "orthogonal", "orthographic", "1": + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + "frustum", "2": + camera.projection = Camera3D.PROJECTION_FRUSTUM + + # Properties + if params.has("fov"): + camera.fov = _optional_float(params, "fov", 75.0) + if params.has("size"): + camera.size = _optional_float(params, "size", 1.0) + if params.has("near"): + camera.near = _optional_float(params, "near", 0.05) + if params.has("far"): + camera.far = _optional_float(params, "far", 4000.0) + if params.has("cull_mask"): + camera.cull_mask = optional_int(params, "cull_mask", 1048575) + + # Make current + camera.current = optional_bool(params, "current", false) + + # Transform + camera.position = _parse_vector3_param(params, "position", camera.position if is_existing else Vector3(0, 1, 3)) + if params.has("rotation"): + camera.rotation_degrees = _parse_vector3_param(params, "rotation", camera.rotation_degrees) + if params.has("look_at"): + var target := _parse_vector3_param(params, "look_at", Vector3.ZERO) + # We need to set position first, then use look_at + camera.look_at(target) + + # Environment override + if params.has("environment_path"): + var env_path: String = params["environment_path"] + if ResourceLoader.exists(env_path): + var env_res: Resource = load(env_path) + if env_res is Environment: + camera.environment = env_res as Environment + + if not is_existing: + _add_child_with_undo(camera, parent, root, "MCP: Add Camera3D") + + return success({ + "node_path": str(root.get_path_to(camera)), + "name": str(camera.name), + "projection": "perspective" if camera.projection == Camera3D.PROJECTION_PERSPECTIVE else "orthogonal", + "fov": camera.fov, + "position": str(camera.position), + "is_existing": is_existing, + }) + + +## ─── 6. add_gridmap ────────────────────────────────────────────────────── + +func _add_gridmap(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "GridMap") + + # Check for existing GridMap to configure + var node_path: String = optional_string(params, "node_path", "") + var gridmap: GridMap = null + var is_existing := false + + if not node_path.is_empty(): + var existing := find_node_by_path(node_path) + if existing != null and existing is GridMap: + gridmap = existing as GridMap + is_existing = true + elif existing != null: + return error_invalid_params("Node '%s' is not a GridMap (is %s)" % [node_path, existing.get_class()]) + + if gridmap == null: + gridmap = GridMap.new() + gridmap.name = node_name + + # Mesh library + if params.has("mesh_library_path"): + var lib_path: String = params["mesh_library_path"] + if not ResourceLoader.exists(lib_path): + if not is_existing: + gridmap.queue_free() + return error_not_found("MeshLibrary '%s'" % lib_path, "Provide a valid res:// path to a .meshlib or .tres file") + var lib: Resource = load(lib_path) + if lib is MeshLibrary: + gridmap.mesh_library = lib as MeshLibrary + else: + if not is_existing: + gridmap.queue_free() + return error_invalid_params("'%s' is not a MeshLibrary" % lib_path) + + # Cell size + if params.has("cell_size"): + gridmap.cell_size = _parse_vector3_param(params, "cell_size", Vector3(2, 2, 2)) + + # Position + gridmap.position = _parse_vector3_param(params, "position", gridmap.position if is_existing else Vector3.ZERO) + + if not is_existing: + _add_child_with_undo(gridmap, parent, root, "MCP: Add GridMap") + + # Set cells + var cells: Array = params.get("cells", []) + var cells_set: int = 0 + for cell in cells: + if cell is Dictionary: + var x: int = int(cell.get("x", 0)) + var y: int = int(cell.get("y", 0)) + var z: int = int(cell.get("z", 0)) + var item: int = int(cell.get("item", 0)) + var orientation: int = int(cell.get("orientation", 0)) + gridmap.set_cell_item(Vector3i(x, y, z), item, orientation) + cells_set += 1 + + return success({ + "node_path": str(root.get_path_to(gridmap)), + "name": str(gridmap.name), + "cells_set": cells_set, + "is_existing": is_existing, + "has_mesh_library": gridmap.mesh_library != null, + }) diff --git a/addons/godot_mcp/commands/scene_3d_commands.gd.uid b/addons/godot_mcp/commands/scene_3d_commands.gd.uid new file mode 100644 index 0000000..922f1b7 --- /dev/null +++ b/addons/godot_mcp/commands/scene_3d_commands.gd.uid @@ -0,0 +1 @@ +uid://or4r1hf3581w diff --git a/addons/godot_mcp/commands/scene_commands.gd b/addons/godot_mcp/commands/scene_commands.gd new file mode 100644 index 0000000..bb4ccbf --- /dev/null +++ b/addons/godot_mcp/commands/scene_commands.gd @@ -0,0 +1,331 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "get_scene_tree": _get_scene_tree, + "get_scene_file_content": _get_scene_file_content, + "create_scene": _create_scene, + "open_scene": _open_scene, + "delete_scene": _delete_scene, + "add_scene_instance": _add_scene_instance, + "play_scene": _play_scene, + "stop_scene": _stop_scene, + "save_scene": _save_scene, + "get_scene_exports": _get_scene_exports, + } + + +func _get_scene_tree(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var max_depth: int = optional_int(params, "max_depth", -1) + var tree := NodeUtils.get_node_tree(root, root, max_depth) + return success({"scene_path": root.scene_file_path, "tree": tree}) + + +func _get_scene_file_content(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read file: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + file.close() + return success({"path": path, "content": content, "size": content.length()}) + + +func _create_scene(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var guard := guard_offline_scene_save(path) + if not guard.is_empty(): + return guard + + var root_type: String = optional_string(params, "root_type", "Node2D") + var root_name: String = optional_string(params, "root_name", "") + + # Validate root type exists + if not ClassDB.class_exists(root_type): + return error_invalid_params("Unknown node type: %s" % root_type) + + # Create the scene + var root: Node = ClassDB.instantiate(root_type) + if root_name.is_empty(): + root_name = path.get_file().get_basename() + root.name = root_name + + var scene := PackedScene.new() + var err := scene.pack(root) + root.queue_free() + + if err != OK: + return error_internal("Failed to pack scene: %s" % error_string(err)) + + # Ensure directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + err = ResourceSaver.save(scene, path) + if err != OK: + return error_internal("Failed to save scene: %s" % error_string(err)) + + # Refresh filesystem + EditorInterface.get_resource_filesystem().scan() + + return success({"path": path, "root_type": root_type, "root_name": root_name}) + + +func _open_scene(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + EditorInterface.open_scene_from_path(path) + return success({"path": path, "opened": true}) + + +func _delete_scene(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + var err := DirAccess.remove_absolute(path) + if err != OK: + return error_internal("Failed to delete scene: %s" % error_string(err)) + + # Also remove .import file if exists + var import_path := path + ".import" + if FileAccess.file_exists(import_path): + DirAccess.remove_absolute(import_path) + + EditorInterface.get_resource_filesystem().scan() + return success({"path": path, "deleted": true}) + + +func _add_scene_instance(params: Dictionary) -> Dictionary: + var result := require_string(params, "scene_path") + if result[1] != null: + return result[1] + var scene_path: String = result[0] + + var parent_path: String = optional_string(params, "parent_path", ".") + var instance_name: String = optional_string(params, "name", "") + + var root := get_edited_root() + if root == null: + return error_no_scene() + + if not FileAccess.file_exists(scene_path): + return error_not_found("Scene file '%s'" % scene_path) + + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes") + + var packed: PackedScene = load(scene_path) + if packed == null: + return error_internal("Failed to load scene: %s" % scene_path) + + var instance := packed.instantiate() + if not instance_name.is_empty(): + instance.name = instance_name + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add scene instance") + undo_redo.add_do_method(parent, "add_child", instance) + undo_redo.add_do_method(instance, "set_owner", root) + undo_redo.add_do_reference(instance) + undo_redo.add_undo_method(parent, "remove_child", instance) + undo_redo.commit_action() + + NodeUtils.set_owner_recursive(instance, root) + + return success({ + "node_path": str(root.get_path_to(instance)), + "scene_path": scene_path, + "name": instance.name, + }) + + +func _play_scene(params: Dictionary) -> Dictionary: + var mode: String = optional_string(params, "mode", "main") # "main", "current", or path + + match mode: + "main": + EditorInterface.play_main_scene() + "current": + EditorInterface.play_current_scene() + _: + # Treat as scene path + if not FileAccess.file_exists(mode): + return error_not_found("Scene file '%s'" % mode) + EditorInterface.play_custom_scene(mode) + + return success({"playing": true, "mode": mode}) + + +func _stop_scene(_params: Dictionary) -> Dictionary: + if not EditorInterface.is_playing_scene(): + return success({"stopped": false, "message": "No scene is currently playing"}) + + EditorInterface.stop_playing_scene() + + # Clean up temp files + _cleanup_screenshot_files() + _cleanup_input_files() + _cleanup_inspector_files() + + return success({"stopped": true}) + + +func _save_scene(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var path: String = optional_string(params, "path", "") + if path.is_empty(): + path = root.scene_file_path + + if path.is_empty(): + return error_invalid_params("No save path specified and scene has no existing path") + + var normalized_path := normalize_project_path(path) + if is_scene_path_open(normalized_path) and not is_active_scene_path(normalized_path): + return error_conflict( + "Refusing to save inactive open scene '%s' from the active editor scene" % normalized_path, + { + "path": normalized_path, + "active_scene": normalize_project_path(root.scene_file_path), + "open_scenes": get_open_scene_paths(), + "suggestion": "Open the target scene tab before saving it, or close it before offline edits.", + } + ) + + var dir_path := normalized_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var err: int + var save_method: String + if root.scene_file_path.is_empty() or normalize_project_path(root.scene_file_path) != normalized_path: + EditorInterface.save_scene_as(normalized_path) + err = OK + save_method = "EditorInterface.save_scene_as" + else: + err = EditorInterface.save_scene() + save_method = "EditorInterface.save_scene" + if err != OK: + return error_internal("Failed to save scene via %s: %s" % [save_method, error_string(err)]) + + return success({"path": normalized_path, "saved": true, "method": save_method}) + + +func _get_scene_exports(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + var packed: PackedScene = load(path) + if packed == null: + return error_internal("Failed to load scene: %s" % path) + + var instance: Node = packed.instantiate() + if instance == null: + return error_internal("Failed to instantiate scene: %s" % path) + + var nodes_data: Array = [] + _collect_exports_recursive(instance, instance, nodes_data) + + instance.queue_free() + + return success({ + "path": path, + "nodes": nodes_data, + "count": nodes_data.size(), + }) + + +func _collect_exports_recursive(node: Node, root: Node, nodes_data: Array) -> void: + var script: Script = node.get_script() + if script != null: + var exports: Dictionary = {} + for prop_info in script.get_script_property_list(): + var usage: int = prop_info["usage"] + if (usage & PROPERTY_USAGE_EDITOR) and (usage & PROPERTY_USAGE_SCRIPT_VARIABLE): + var prop_name: String = prop_info["name"] + exports[prop_name] = { + "value": PropertyParser.serialize_value(node.get(prop_name)), + "type": prop_info["type"], + "hint": prop_info.get("hint", 0), + "hint_string": prop_info.get("hint_string", ""), + } + if not exports.is_empty(): + var node_path := "." if node == root else str(root.get_path_to(node)) + nodes_data.append({ + "node_path": node_path, + "node_name": node.name, + "node_type": node.get_class(), + "script_path": script.resource_path, + "exports": exports, + }) + + for child in node.get_children(): + _collect_exports_recursive(child, root, nodes_data) + + +func _cleanup_screenshot_files() -> void: + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_screenshot_request" + var screenshot_path := user_dir + "/mcp_screenshot.png" + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + if FileAccess.file_exists(screenshot_path): + DirAccess.remove_absolute(screenshot_path) + + +func _cleanup_input_files() -> void: + var user_dir := get_game_user_dir() + var commands_path := user_dir + "/mcp_input_commands" + if FileAccess.file_exists(commands_path): + DirAccess.remove_absolute(commands_path) + + +func _cleanup_inspector_files() -> void: + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) diff --git a/addons/godot_mcp/commands/scene_commands.gd.uid b/addons/godot_mcp/commands/scene_commands.gd.uid new file mode 100644 index 0000000..8ccc3fe --- /dev/null +++ b/addons/godot_mcp/commands/scene_commands.gd.uid @@ -0,0 +1 @@ +uid://3dbek7farv50 diff --git a/addons/godot_mcp/commands/script_commands.gd b/addons/godot_mcp/commands/script_commands.gd new file mode 100644 index 0000000..4dcf53f --- /dev/null +++ b/addons/godot_mcp/commands/script_commands.gd @@ -0,0 +1,369 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_scripts": _list_scripts, + "read_script": _read_script, + "create_script": _create_script, + "edit_script": _edit_script, + "attach_script": _attach_script, + "get_open_scripts": _get_open_scripts, + "validate_script": _validate_script, + } + + +func _guard_script_file_path(path: String, operation: String) -> Dictionary: + var ext := path.get_extension().to_lower() + if ext in ["gd", "cs"]: + return {} + return error( + -32602, + "%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)], + { + "path": normalize_project_path(path), + "extension": ext, + "suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.", + } + ) + + +func _list_scripts(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var recursive: bool = optional_bool(params, "recursive", true) + + var scripts: Array = [] + _find_scripts(path, recursive, scripts) + + return success({"scripts": scripts, "count": scripts.size()}) + + +func _find_scripts(path: String, recursive: bool, scripts: Array) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + if recursive: + _find_scripts(full_path, recursive, scripts) + elif file_name.get_extension() in ["gd", "cs", "gdshader"]: + var info := {"path": full_path, "type": file_name.get_extension()} + # Get basic file info + var file := FileAccess.open(full_path, FileAccess.READ) + if file: + info["size"] = file.get_length() + # Read first line for class/extends info + var first_line := file.get_line().strip_edges() + if first_line.begins_with("class_name "): + info["class_name"] = first_line.substr(11).strip_edges() + elif first_line.begins_with("extends "): + info["extends"] = first_line.substr(8).strip_edges() + file.close() + scripts.append(info) + + file_name = dir.get_next() + + dir.list_dir_end() + + +func _read_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Script '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + var line_count := content.count("\n") + 1 + file.close() + + return success({ + "path": path, + "content": content, + "line_count": line_count, + "size": content.length(), + }) + + +func _create_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + var path_guard := _guard_script_file_path(path, "create_script") + if not path_guard.is_empty(): + return path_guard + + var content: String = optional_string(params, "content", "") + var base_class: String = optional_string(params, "extends", "Node") + var class_name_str: String = optional_string(params, "class_name", "") + var force: bool = optional_bool(params, "force", false) + + var guard := guard_text_resource_write(path, force) + if not guard.is_empty(): + return guard + + # Generate template if no content provided + if content.is_empty(): + var lines: PackedStringArray = [] + if not class_name_str.is_empty(): + lines.append("class_name %s" % class_name_str) + lines.append("extends %s" % base_class) + lines.append("") + lines.append("") + lines.append("func _ready() -> void:") + lines.append("\tpass") + lines.append("") + content = "\n".join(lines) + + # Ensure directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error())) + + file.store_string(content) + file.close() + + EditorInterface.get_resource_filesystem().scan() + + # Pre-load so the script is available immediately + if ResourceLoader.exists(path): + var script = load(path) + if script is Script: + script.reload(true) + + return success({"path": path, "created": true}) + + +func _edit_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + var path_guard := _guard_script_file_path(path, "edit_script") + if not path_guard.is_empty(): + return path_guard + + if not FileAccess.file_exists(path): + return error_not_found("Script '%s'" % path) + + var force: bool = optional_bool(params, "force", false) + var guard := guard_text_resource_write(path, force) + if not guard.is_empty(): + return guard + + # Read current content + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + file.close() + + var changes_made := 0 + + # Support search-and-replace + if params.has("replacements") and params["replacements"] is Array: + var replacements: Array = params["replacements"] + for replacement in replacements: + if replacement is Dictionary: + var search: String = replacement.get("search", "") + var replace: String = replacement.get("replace", "") + if not search.is_empty(): + var use_regex: bool = replacement.get("regex", false) + if use_regex: + var regex := RegEx.new() + var err := regex.compile(search) + if err == OK: + var new_content := regex.sub(content, replace, true) + if new_content != content: + content = new_content + changes_made += 1 + else: + if content.contains(search): + content = content.replace(search, replace) + changes_made += 1 + + # Support 1-based inclusive line range replacement + elif params.has("content") and (params.has("start_line") or params.has("end_line")): + if not params.has("start_line"): + return error_invalid_params("start_line is required when end_line is provided") + var start_line: int = int(params["start_line"]) + var end_line: int = int(params.get("end_line", start_line)) + var lines := content.split("\n") + if start_line < 1: + return error_invalid_params("start_line must be >= 1") + if end_line < start_line: + return error_invalid_params("end_line must be >= start_line") + if start_line > lines.size(): + return error_invalid_params("start_line is beyond the end of the file") + if end_line > lines.size(): + return error_invalid_params("end_line is beyond the end of the file") + + var replacement_lines := str(params["content"]).split("\n") + var start_index := start_line - 1 + var remove_count := end_line - start_line + 1 + for _i in range(remove_count): + lines.remove_at(start_index) + for i in range(replacement_lines.size()): + lines.insert(start_index + i, replacement_lines[i]) + content = "\n".join(lines) + changes_made = 1 + + # Support full content replacement + elif params.has("content"): + content = str(params["content"]) + changes_made = 1 + + # Support insert at line + elif params.has("insert_at_line") and params.has("text"): + var line_num: int = int(params["insert_at_line"]) + var text: String = str(params["text"]) + var lines := content.split("\n") + line_num = clampi(line_num, 0, lines.size()) + lines.insert(line_num, text) + content = "\n".join(lines) + changes_made = 1 + + if changes_made == 0: + return success({"path": path, "changes_made": 0, "message": "No changes applied"}) + + # Write back + file = FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error())) + + file.store_string(content) + file.close() + + # Reload the script resource so the editor picks up changes immediately + _reload_script(path) + + return success({"path": path, "changes_made": changes_made}) + + +## Force-reload a script so the editor reflects disk changes immediately. +func _reload_script(path: String) -> void: + # First, trigger a filesystem scan so Godot knows the file changed + EditorInterface.get_resource_filesystem().scan() + + # If the script is already loaded in memory, reload it + if ResourceLoader.exists(path): + var script = load(path) + if script is Script: + script.reload(true) + + # If the script is open in the script editor, the reload above updates it. + # But we also need to notify the editor to refresh its error indicators. + EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED) + + +func _attach_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "script_path") + if result2[1] != null: + return result2[1] + var script_path: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not FileAccess.file_exists(script_path): + return error_not_found("Script '%s'" % script_path) + + var script: Script = load(script_path) + if script == null: + return error_internal("Failed to load script: %s" % script_path) + + var old_script: Variant = node.get_script() + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Attach script to %s" % node.name) + undo_redo.add_do_method(node, "set_script", script) + undo_redo.add_undo_method(node, "set_script", old_script) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "script_path": script_path, + "attached": true, + }) + + +func _validate_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + var path_guard := _guard_script_file_path(path, "validate_script") + if not path_guard.is_empty(): + return path_guard + + if not FileAccess.file_exists(path): + return error_not_found("Script '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) + + var source_code := file.get_as_text() + file.close() + + var script := GDScript.new() + script.source_code = source_code + var err := script.reload() + + if err == OK: + return success({"path": path, "valid": true, "message": "Script compiles successfully"}) + + return success({ + "path": path, + "valid": false, + "error_code": err, + "error_string": error_string(err), + "message": "Compilation failed. Use get_output_log or get_editor_errors for details.", + }) + + +func _get_open_scripts(_params: Dictionary) -> Dictionary: + var script_editor := EditorInterface.get_script_editor() + var open_scripts: Array = [] + + for script_base in script_editor.get_open_scripts(): + var info := { + "path": script_base.resource_path, + "type": script_base.get_class(), + } + open_scripts.append(info) + + return success({"scripts": open_scripts, "count": open_scripts.size()}) diff --git a/addons/godot_mcp/commands/script_commands.gd.uid b/addons/godot_mcp/commands/script_commands.gd.uid new file mode 100644 index 0000000..68d4d76 --- /dev/null +++ b/addons/godot_mcp/commands/script_commands.gd.uid @@ -0,0 +1 @@ +uid://f0b3g6xkqbtd diff --git a/addons/godot_mcp/commands/shader_commands.gd b/addons/godot_mcp/commands/shader_commands.gd new file mode 100644 index 0000000..4dd4d0b --- /dev/null +++ b/addons/godot_mcp/commands/shader_commands.gd @@ -0,0 +1,239 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_shader": _create_shader, + "read_shader": _read_shader, + "edit_shader": _edit_shader, + "assign_shader_material": _assign_shader_material, + "set_shader_param": _set_shader_param, + "get_shader_params": _get_shader_params, + } + + +func _create_shader(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var content: String = optional_string(params, "content", "") + var shader_type: String = optional_string(params, "shader_type", "spatial") + var force: bool = optional_bool(params, "force", false) + + var guard := guard_text_resource_write(path, force) + if not guard.is_empty(): + return guard + + if content.is_empty(): + match shader_type: + "spatial": + content = "shader_type spatial;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tALBEDO = vec3(1.0);\n}\n" + "canvas_item": + content = "shader_type canvas_item;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tCOLOR = vec4(1.0);\n}\n" + "particles": + content = "shader_type particles;\n\nvoid start() {\n\t// Called when particle spawns\n}\n\nvoid process() {\n\t// Called every frame per particle\n}\n" + "sky": + content = "shader_type sky;\n\nvoid sky() {\n\tCOLOR = vec3(0.3, 0.5, 0.8);\n}\n" + + # Ensure directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot create shader: %s" % error_string(FileAccess.get_open_error())) + + file.store_string(content) + file.close() + + _refresh_loaded_shader(path, content) + + return success({"path": path, "shader_type": shader_type, "created": true}) + + +func _read_shader(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Shader '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read shader: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + file.close() + + return success({"path": path, "content": content, "size": content.length()}) + + +func _refresh_loaded_shader(path: String, content: String) -> void: + var normalized := normalize_project_path(path) + if normalized.is_empty(): + return + if ResourceLoader.has_cached(normalized): + var shader := Shader.new() + shader.code = content + shader.take_over_path(normalized) + shader.emit_changed() + EditorInterface.get_resource_filesystem().update_file(normalized) + + +func _edit_shader(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Shader '%s'" % path) + + var force: bool = optional_bool(params, "force", false) + var guard := guard_text_resource_write(path, force) + if not guard.is_empty(): + return guard + + var changes_made := 0 + var content := "" + + if params.has("content"): + content = str(params["content"]) + changes_made = 1 + elif params.has("replacements") and params["replacements"] is Array: + # Read current + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read shader") + content = file.get_as_text() + file.close() + + for replacement in params["replacements"]: + if replacement is Dictionary: + var search: String = replacement.get("search", "") + var replace: String = replacement.get("replace", "") + if not search.is_empty() and content.contains(search): + content = content.replace(search, replace) + changes_made += 1 + + if changes_made > 0: + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot write shader: %s" % error_string(FileAccess.get_open_error())) + file.store_string(content) + file.close() + _refresh_loaded_shader(path, content) + + return success({"path": path, "changes_made": changes_made}) + + +func _assign_shader_material(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "shader_path") + if result2[1] != null: + return result2[1] + var shader_path: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + if not ResourceLoader.exists(shader_path): + return error_not_found("Shader '%s'" % shader_path) + + var shader: Shader = load(shader_path) + if shader == null: + return error_internal("Failed to load shader") + + var material := ShaderMaterial.new() + material.shader = shader + + if node is CanvasItem: + set_property_with_undo(node, "material", material, "MCP: Assign shader material") + elif node is MeshInstance3D: + set_property_with_undo(node, "material_override", material, "MCP: Assign shader material") + else: + # Try generic material property + if "material" in node: + set_property_with_undo(node, "material", material, "MCP: Assign shader material") + else: + return error_invalid_params("Node '%s' (%s) does not support materials" % [node_path, node.get_class()]) + + return success({"node_path": node_path, "shader_path": shader_path, "assigned": true}) + + +func _set_shader_param(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "param") + if result2[1] != null: + return result2[1] + var param_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var material: ShaderMaterial = null + if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial: + material = (node as CanvasItem).material + elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial: + material = (node as MeshInstance3D).material_override + + if material == null: + return error(-32000, "Node has no ShaderMaterial") + + var value = params.get("value") + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + material.set_shader_parameter(param_name, value) + + return success({"node_path": node_path, "param": param_name, "value": str(value)}) + + +func _get_shader_params(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var material: ShaderMaterial = null + if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial: + material = (node as CanvasItem).material + elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial: + material = (node as MeshInstance3D).material_override + + if material == null: + return error(-32000, "Node has no ShaderMaterial") + + var shader_params: Dictionary = {} + for prop in material.get_property_list(): + var pname: String = prop["name"] + if pname.begins_with("shader_parameter/"): + var key := pname.substr(17) + shader_params[key] = str(material.get(pname)) + + return success({"node_path": node_path, "params": shader_params}) diff --git a/addons/godot_mcp/commands/shader_commands.gd.uid b/addons/godot_mcp/commands/shader_commands.gd.uid new file mode 100644 index 0000000..ee1a972 --- /dev/null +++ b/addons/godot_mcp/commands/shader_commands.gd.uid @@ -0,0 +1 @@ +uid://c1rd4ny2dftmj diff --git a/addons/godot_mcp/commands/test_commands.gd b/addons/godot_mcp/commands/test_commands.gd new file mode 100644 index 0000000..ea90ef6 --- /dev/null +++ b/addons/godot_mcp/commands/test_commands.gd @@ -0,0 +1,580 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +## Test automation framework tools. +## Editor-side orchestration + runtime assertions via file-based IPC. + + +func get_commands() -> Dictionary: + return { + "run_test_scenario": _run_test_scenario, + "assert_node_state": _assert_node_state, + "assert_screen_text": _assert_screen_text, + "run_stress_test": _run_stress_test, + "get_test_report": _get_test_report, + } + + +# ── Internal test result accumulator ────────────────────────────────────────── + +var _test_results: Array[Dictionary] = [] + + +# ── Commands ────────────────────────────────────────────────────────────────── + +func _run_test_scenario(params: Dictionary) -> Dictionary: + ## Execute a test scenario: optionally play a scene, run a sequence of steps + ## (input simulation, waits, assertions, screenshots), return pass/fail results. + ## + ## Steps array: [{type: "input"|"wait"|"assert"|"screenshot", ...params}] + ## - input: {type:"input", action:str, pressed:bool} or {type:"input", keycode:str} + ## - wait: {type:"wait", seconds:float} or {type:"wait", node_path:str, timeout:float} + ## - assert: {type:"assert", node_path:str, property:str, expected:val, operator:str} + ## - screenshot: {type:"screenshot"} — captures a frame for visual inspection + + if not params.has("steps") or not params["steps"] is Array: + return error_invalid_params("Missing required parameter: steps (Array)") + + var steps: Array = params["steps"] + if steps.is_empty(): + return error_invalid_params("Steps array is empty") + + var scene_path: String = optional_string(params, "scene_path") + var ei := get_editor() + + # Play scene if requested + if not scene_path.is_empty(): + if ei.is_playing_scene(): + ei.stop_playing_scene() + await get_tree().create_timer(0.5).timeout + + if scene_path == "main": + ei.play_main_scene() + elif scene_path == "current": + ei.play_current_scene() + else: + if not FileAccess.file_exists(scene_path): + return error_not_found("Scene file '%s'" % scene_path) + ei.play_custom_scene(scene_path) + + # Wait for game to start + await get_tree().create_timer(1.0).timeout + + # Verify game is running + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", { + "suggestion": "Provide scene_path or use play_scene first" + }) + + var results: Array[Dictionary] = [] + var pass_count: int = 0 + var fail_count: int = 0 + var error_count: int = 0 + + for i in steps.size(): + var step: Dictionary = steps[i] + if not step.has("type"): + results.append({"step": i, "error": "Missing 'type' field"}) + error_count += 1 + continue + + var step_type: String = str(step["type"]) + var step_result: Dictionary = {"step": i, "type": step_type} + + match step_type: + "input": + var input_result := await _execute_input_step(step) + step_result.merge(input_result) + + "wait": + var wait_result := await _execute_wait_step(step) + step_result.merge(wait_result) + + "assert": + var assert_result := await _execute_assert_step(step) + step_result.merge(assert_result) + if assert_result.get("passed", false): + pass_count += 1 + else: + fail_count += 1 + # Only assertion steps carry a verdict — store just these for + # get_test_report (input/wait/screenshot steps have no "passed"). + _test_results.append(step_result) + + "screenshot": + var screenshot_result := await _send_game_command("capture_frames", { + "count": 1, + "frame_interval": 1, + "half_resolution": optional_bool(step, "half_resolution", true), + }, 5.0) + if screenshot_result.has("result"): + step_result["captured"] = true + else: + step_result["captured"] = false + step_result["error"] = "Screenshot capture failed" + error_count += 1 + + _: + step_result["error"] = "Unknown step type: %s" % step_type + error_count += 1 + + results.append(step_result) + + # Check if game crashed between steps + if not ei.is_playing_scene(): + results.append({"step": i + 1, "error": "Game stopped unexpectedly"}) + error_count += 1 + break + + var summary := { + "total_steps": steps.size(), + "completed_steps": results.size(), + "assertions_passed": pass_count, + "assertions_failed": fail_count, + "errors": error_count, + "all_passed": fail_count == 0 and error_count == 0, + "results": results, + } + + return success(summary) + + +func _assert_node_state(params: Dictionary) -> Dictionary: + ## Assert a node's property equals expected value in the running game. + ## Supports operators: eq, neq, gt, lt, gte, lte, contains, type_is. + ## Returns pass/fail with actual value. + + var path_result := require_string(params, "node_path") + if path_result[1] != null: + return path_result[1] + + var prop_result := require_string(params, "property") + if prop_result[1] != null: + return prop_result[1] + + if not params.has("expected"): + return error_invalid_params("Missing required parameter: expected") + + var operator: String = optional_string(params, "operator", "eq") + var valid_operators := ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"] + if operator not in valid_operators: + return error_invalid_params("Invalid operator '%s'. Valid: %s" % [operator, str(valid_operators)]) + + var result := await _send_game_command("assert_node_state", { + "node_path": path_result[0], + "property": prop_result[0], + "expected": params["expected"], + "operator": operator, + }, 5.0) + + if result.has("error"): + return result + + # The game reply is wrapped twice ({"result": {"result": {...}}}) — unwrap + # defensively before storing/returning so "passed" sits at the top level. + var payload := unwrap_game_result(result) + if payload.has("passed"): + _test_results.append(payload) + return success(payload) + + +func _assert_screen_text(params: Dictionary) -> Dictionary: + ## Assert that specific text is visible on screen. + ## Uses find_ui_elements internally to check all visible UI text. + + var text_result := require_string(params, "text") + if text_result[1] != null: + return text_result[1] + + var expected_text: String = text_result[0] + var partial: bool = optional_bool(params, "partial", true) + var case_sensitive: bool = optional_bool(params, "case_sensitive", true) + + # Use find_ui_elements to get all visible UI text + var ui_result := await _send_game_command("find_ui_elements", {}) + if ui_result.has("error"): + return ui_result + + var elements: Array = [] + if ui_result.has("result") and ui_result["result"].has("elements"): + elements = ui_result["result"]["elements"] + + var found := false + var matched_element: Dictionary = {} + var all_texts: Array[String] = [] + + for element: Dictionary in elements: + var element_text: String = str(element.get("text", "")) + if element_text.is_empty(): + continue + all_texts.append(element_text) + + var search_text := expected_text + var compare_text := element_text + if not case_sensitive: + search_text = search_text.to_lower() + compare_text = compare_text.to_lower() + + if partial: + if compare_text.contains(search_text): + found = true + matched_element = element + break + else: + if compare_text == search_text: + found = true + matched_element = element + break + + var assertion := { + "passed": found, + "expected_text": expected_text, + "partial": partial, + "case_sensitive": case_sensitive, + } + + if found: + assertion["matched_element"] = { + "text": matched_element.get("text", ""), + "type": matched_element.get("type", ""), + "path": matched_element.get("path", ""), + } + else: + assertion["visible_texts"] = all_texts + + # Store for test report + _test_results.append(assertion) + + return success(assertion) + + +func _run_stress_test(params: Dictionary) -> Dictionary: + ## Run rapid random inputs for N seconds and check for crashes. + ## Returns frame count, timing, and any errors from game output. + + var duration: float = float(params.get("duration", 5.0)) + if duration <= 0 or duration > 60: + return error_invalid_params("Duration must be between 0 and 60 seconds") + + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", { + "suggestion": "Use play_scene first" + }) + + # Record initial error count from log + var initial_errors := _count_log_errors() + + # Generate random input events + var actions := ["ui_up", "ui_down", "ui_left", "ui_right", "ui_accept", "ui_cancel"] + # Add common game actions if specified + var custom_actions: Array = params.get("actions", []) + for action in custom_actions: + actions.append(str(action)) + + var events_sent: int = 0 + var start_time := Time.get_ticks_msec() + var duration_ms := int(duration * 1000.0) + + while Time.get_ticks_msec() - start_time < duration_ms: + if not ei.is_playing_scene(): + var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0 + return success({ + "completed": false, + "crashed": true, + "elapsed_seconds": elapsed, + "events_sent": events_sent, + "error": "Game stopped during stress test", + }) + + # Send a batch of random inputs + var batch: Array = [] + for j in 3: + var action_name: String = actions[randi() % actions.size()] + batch.append({ + "type": "action", + "action": action_name, + "pressed": true, + "strength": 1.0, + }) + batch.append({ + "type": "action", + "action": action_name, + "pressed": false, + "strength": 0.0, + }) + + # Write input commands directly (same as input_commands) + var json := JSON.stringify({ + "sequence_events": batch, + "frame_delay": 1, + }) + var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE) + if file: + file.store_string(json) + file.close() + events_sent += batch.size() + + await get_tree().create_timer(0.1).timeout + + var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0 + var final_errors := _count_log_errors() + var new_errors := final_errors - initial_errors + + # Check if game is still running + var still_running := ei.is_playing_scene() + + return success({ + "completed": true, + "crashed": not still_running, + "duration_seconds": elapsed, + "events_sent": events_sent, + "new_errors": new_errors, + "game_still_running": still_running, + }) + + +func _get_test_report(params: Dictionary) -> Dictionary: + ## Collect and format results from accumulated assertions into a test report. + ## Returns pass count, fail count, and detailed results. + + var clear: bool = optional_bool(params, "clear", true) + + var pass_count: int = 0 + var fail_count: int = 0 + var details: Array[Dictionary] = [] + + for result: Dictionary in _test_results: + # Unwrap defensively and skip entries that carry no verdict + # (input/wait/screenshot steps are not assertions). + var entry := unwrap_game_result(result) + if not entry.has("passed"): + continue + if entry.get("passed", false): + pass_count += 1 + else: + fail_count += 1 + details.append(entry) + + var total := pass_count + fail_count + var report := { + "total": total, + "passed": pass_count, + "failed": fail_count, + "pass_rate": ("%.1f%%" % (100.0 * pass_count / total)) if total > 0 else "N/A", + "all_passed": fail_count == 0 and total > 0, + "no_results": total == 0, + "details": details, + } + + if clear: + _test_results.clear() + + return success(report) + + +# ── Step Executors (for run_test_scenario) ──────────────────────────────────── + +func _execute_input_step(step: Dictionary) -> Dictionary: + ## Execute an input step: simulate action or key press. + var events: Array = [] + + if step.has("action"): + var pressed: bool = step.get("pressed", true) as bool + events.append({ + "type": "action", + "action": str(step["action"]), + "pressed": pressed, + "strength": float(step.get("strength", 1.0)), + }) + # Auto-release if pressed + if pressed and step.get("auto_release", true): + events.append({ + "type": "action", + "action": str(step["action"]), + "pressed": false, + "strength": 0.0, + }) + elif step.has("keycode"): + var pressed: bool = step.get("pressed", true) as bool + events.append({ + "type": "key", + "keycode": str(step["keycode"]), + "pressed": pressed, + "shift": step.get("shift", false), + "ctrl": step.get("ctrl", false), + "alt": step.get("alt", false), + }) + # Auto-release if pressed, mirroring the action branch — otherwise the + # key stays held for the rest of the session and corrupts later steps + if pressed and step.get("auto_release", true): + events.append({ + "type": "key", + "keycode": str(step["keycode"]), + "pressed": false, + "shift": step.get("shift", false), + "ctrl": step.get("ctrl", false), + "alt": step.get("alt", false), + }) + else: + return {"error": "Input step requires 'action' or 'keycode'"} + + var json := JSON.stringify({ + "sequence_events": events, + "frame_delay": int(step.get("frame_delay", 1)), + }) + var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE) + if file == null: + return {"error": "Failed to write input commands"} + file.store_string(json) + file.close() + + return {"sent": true, "event_count": events.size()} + + +func _execute_wait_step(step: Dictionary) -> Dictionary: + ## Execute a wait step: wait for seconds or wait for a node to appear. + if step.has("node_path"): + var timeout: float = float(step.get("timeout", 5.0)) + var result := await _send_game_command("wait_for_node", { + "node_path": str(step["node_path"]), + "timeout": timeout, + "poll_frames": int(step.get("poll_frames", 5)), + }, timeout + 2.0) + if result.has("error"): + return {"error": "Wait for node failed: %s" % str(result["error"])} + return {"waited_for": str(step["node_path"]), "found": true} + else: + var seconds: float = float(step.get("seconds", 1.0)) + await get_tree().create_timer(seconds).timeout + return {"waited_seconds": seconds} + + +func _execute_assert_step(step: Dictionary) -> Dictionary: + ## Execute an assertion step within a scenario. + if step.has("text"): + # Screen text assertion + var ui_result := await _send_game_command("find_ui_elements", {}) + if ui_result.has("error"): + return {"passed": false, "error": "Could not get UI elements"} + + var elements: Array = [] + if ui_result.has("result") and ui_result["result"].has("elements"): + elements = ui_result["result"]["elements"] + + var expected_text: String = str(step["text"]) + var partial: bool = step.get("partial", true) as bool + # "assert_type" (not "type") so the caller's step_result.merge() cannot + # collide with the step's own "type": "assert" key + for element: Dictionary in elements: + var element_text: String = str(element.get("text", "")) + if partial and element_text.contains(expected_text): + return {"passed": true, "assert_type": "screen_text", "expected": expected_text, "found_in": element_text} + elif not partial and element_text == expected_text: + return {"passed": true, "assert_type": "screen_text", "expected": expected_text, "found_in": element_text} + + return {"passed": false, "assert_type": "screen_text", "expected": expected_text, "error": "Text not found on screen"} + + elif step.has("node_path") and step.has("property"): + # Node state assertion + var result := await _send_game_command("assert_node_state", { + "node_path": str(step["node_path"]), + "property": str(step["property"]), + "expected": step.get("expected", null), + "operator": str(step.get("operator", "eq")), + }, 5.0) + if result.has("error"): + return {"passed": false, "error": str(result["error"])} + # Game replies are double-wrapped — unwrap until "passed" surfaces + var payload := unwrap_game_result(result) + if payload.has("passed"): + return payload + return {"passed": false, "error": "Unknown assertion error"} + + else: + return {"passed": false, "error": "Assert step requires 'text' or 'node_path'+'property'"} + + +# ── IPC Helper ──────────────────────────────────────────────────────────────── + +func _send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + + # Clean stale response + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) + + # Write request + var request_data := JSON.stringify({"command": command, "params": params}) + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create game request file") + req.store_string(request_data) + req.close() + + # Poll for response + var attempts := int(timeout_sec / 0.1) + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + # Check if game is still running + if not ei.is_playing_scene(): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game stopped during command execution") + attempts -= 1 + + if not FileAccess.file_exists(response_path): + # Try to auto-resume the debugger + if ei.is_playing_scene(): + try_debugger_continue() + for _retry in 20: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + + if not FileAccess.file_exists(response_path): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return build_timeout_error(timeout_sec) + + # Read response + var file := FileAccess.open(response_path, FileAccess.READ) + if file == null: + return error_internal("Could not read game response file") + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(response_path) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + return error_internal("Invalid response JSON from game") + + if parsed.has("error"): + return error(-32000, str(parsed["error"])) + + return success(parsed) + + +# ── Utility ─────────────────────────────────────────────────────────────────── + +func _count_log_errors() -> int: + var count: int = 0 + var log_path := "user://logs/godot.log" + if FileAccess.file_exists(log_path): + var file := FileAccess.open(log_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + for line: String in lines: + if line.contains("ERROR") or line.contains("SCRIPT ERROR"): + count += 1 + return count diff --git a/addons/godot_mcp/commands/test_commands.gd.uid b/addons/godot_mcp/commands/test_commands.gd.uid new file mode 100644 index 0000000..c0f832b --- /dev/null +++ b/addons/godot_mcp/commands/test_commands.gd.uid @@ -0,0 +1 @@ +uid://dxedxhq1sfhsq diff --git a/addons/godot_mcp/commands/theme_commands.gd b/addons/godot_mcp/commands/theme_commands.gd new file mode 100644 index 0000000..efdf989 --- /dev/null +++ b/addons/godot_mcp/commands/theme_commands.gd @@ -0,0 +1,428 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_theme": _create_theme, + "set_theme_color": _set_theme_color, + "set_theme_constant": _set_theme_constant, + "set_theme_font_size": _set_theme_font_size, + "set_theme_stylebox": _set_theme_stylebox, + "setup_control": _setup_control, + "get_theme_info": _get_theme_info, + } + + +func _create_theme(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var theme := Theme.new() + + # Optionally set default font size + var font_size: int = optional_int(params, "default_font_size", 0) + if font_size > 0: + theme.default_font_size = font_size + + var scene_guard := guard_offline_scene_save(path) + if not scene_guard.is_empty(): + return scene_guard + + var dir_guard := ensure_parent_dir(path) + if not dir_guard.is_empty(): + return dir_guard + + var err := ResourceSaver.save(theme, path) + if err != OK: + return error_internal("Failed to save theme: %s" % error_string(err)) + + EditorInterface.get_resource_filesystem().scan() + return success({"path": path, "created": true}) + + +func _set_theme_color(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var color_name: String = result2[0] + + var result3 := require_string(params, "color") + if result3[1] != null: + return result3[1] + var color_str: String = result3[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var color := Color(color_str) + + var theme_type: String = optional_string(params, "theme_type", "") + if theme_type.is_empty(): + theme_type = control.get_class() + + var had_old := control.has_theme_color_override(color_name) + var old_value: Variant = control.get("theme_override_colors/" + color_name) if had_old else null + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set theme color override") + undo_redo.add_do_method(control, "add_theme_color_override", color_name, color) + undo_redo.add_undo_method(self, "_restore_theme_override", control, "color", color_name, had_old, old_value) + undo_redo.commit_action() + + return success({"node_path": node_path, "name": color_name, "color": color_str}) + + +func _set_theme_constant(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var const_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var value: int = int(params.get("value", 0)) + + var had_old := control.has_theme_constant_override(const_name) + var old_value: Variant = control.get("theme_override_constants/" + const_name) if had_old else null + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set theme constant override") + undo_redo.add_do_method(control, "add_theme_constant_override", const_name, value) + undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", const_name, had_old, old_value) + undo_redo.commit_action() + + return success({"node_path": node_path, "name": const_name, "value": value}) + + +func _set_theme_font_size(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var font_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var size: int = int(params.get("size", 16)) + + var had_old := control.has_theme_font_size_override(font_name) + var old_value: Variant = control.get("theme_override_font_sizes/" + font_name) if had_old else null + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set theme font size override") + undo_redo.add_do_method(control, "add_theme_font_size_override", font_name, size) + undo_redo.add_undo_method(self, "_restore_theme_override", control, "font_size", font_name, had_old, old_value) + undo_redo.commit_action() + + return success({"node_path": node_path, "name": font_name, "size": size}) + + +func _set_theme_stylebox(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var style_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + + var stylebox := StyleBoxFlat.new() + + var bg_color: String = optional_string(params, "bg_color", "") + if not bg_color.is_empty(): + stylebox.bg_color = Color(bg_color) + + var border_color: String = optional_string(params, "border_color", "") + if not border_color.is_empty(): + stylebox.border_color = Color(border_color) + + var border_width: int = optional_int(params, "border_width", 0) + if border_width > 0: + stylebox.border_width_left = border_width + stylebox.border_width_top = border_width + stylebox.border_width_right = border_width + stylebox.border_width_bottom = border_width + + var corner_radius: int = optional_int(params, "corner_radius", 0) + if corner_radius > 0: + stylebox.corner_radius_top_left = corner_radius + stylebox.corner_radius_top_right = corner_radius + stylebox.corner_radius_bottom_left = corner_radius + stylebox.corner_radius_bottom_right = corner_radius + + var padding: int = optional_int(params, "padding", 0) + if padding > 0: + stylebox.content_margin_left = padding + stylebox.content_margin_top = padding + stylebox.content_margin_right = padding + stylebox.content_margin_bottom = padding + + var had_old := control.has_theme_stylebox_override(style_name) + var old_value: Variant = control.get("theme_override_styles/" + style_name) if had_old else null + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set theme stylebox override") + undo_redo.add_do_method(control, "add_theme_stylebox_override", style_name, stylebox) + undo_redo.add_do_reference(stylebox) + undo_redo.add_undo_method(self, "_restore_theme_override", control, "stylebox", style_name, had_old, old_value) + if old_value is Resource: + undo_redo.add_undo_reference(old_value) + undo_redo.commit_action() + + return success({"node_path": node_path, "name": style_name, "type": "StyleBoxFlat"}) + + +func _setup_control(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var applied: Array = [] + var old_state := _capture_control_setup_state(control) + var target: Control = control.duplicate() as Control + + # Anchor preset + var anchor_preset: String = optional_string(params, "anchor_preset", "") + if not anchor_preset.is_empty(): + var preset_map := { + "top_left": Control.PRESET_TOP_LEFT, + "top_right": Control.PRESET_TOP_RIGHT, + "bottom_left": Control.PRESET_BOTTOM_LEFT, + "bottom_right": Control.PRESET_BOTTOM_RIGHT, + "center_left": Control.PRESET_CENTER_LEFT, + "center_top": Control.PRESET_CENTER_TOP, + "center_right": Control.PRESET_CENTER_RIGHT, + "center_bottom": Control.PRESET_CENTER_BOTTOM, + "center": Control.PRESET_CENTER, + "left_wide": Control.PRESET_LEFT_WIDE, + "top_wide": Control.PRESET_TOP_WIDE, + "right_wide": Control.PRESET_RIGHT_WIDE, + "bottom_wide": Control.PRESET_BOTTOM_WIDE, + "vcenter_wide": Control.PRESET_VCENTER_WIDE, + "hcenter_wide": Control.PRESET_HCENTER_WIDE, + "full_rect": Control.PRESET_FULL_RECT, + } + if preset_map.has(anchor_preset): + target.set_anchors_and_offsets_preset(preset_map[anchor_preset]) + applied.append("anchor_preset=%s" % anchor_preset) + + # Min size + var min_size_str: String = optional_string(params, "min_size", "") + if not min_size_str.is_empty(): + var expr := Expression.new() + if expr.parse(min_size_str) == OK: + var val = expr.execute() + if val is Vector2: + target.custom_minimum_size = val + applied.append("min_size=%s" % min_size_str) + + # Size flags horizontal + var sf_h: String = optional_string(params, "size_flags_h", "") + if not sf_h.is_empty(): + var flags_map := { + "fill": Control.SIZE_FILL, + "expand": Control.SIZE_EXPAND, + "fill_expand": Control.SIZE_EXPAND_FILL, + "shrink_center": Control.SIZE_SHRINK_CENTER, + "shrink_end": Control.SIZE_SHRINK_END, + } + if flags_map.has(sf_h): + target.size_flags_horizontal = flags_map[sf_h] + applied.append("size_flags_h=%s" % sf_h) + + # Size flags vertical + var sf_v: String = optional_string(params, "size_flags_v", "") + if not sf_v.is_empty(): + var flags_map := { + "fill": Control.SIZE_FILL, + "expand": Control.SIZE_EXPAND, + "fill_expand": Control.SIZE_EXPAND_FILL, + "shrink_center": Control.SIZE_SHRINK_CENTER, + "shrink_end": Control.SIZE_SHRINK_END, + } + if flags_map.has(sf_v): + target.size_flags_vertical = flags_map[sf_v] + applied.append("size_flags_v=%s" % sf_v) + + # Margins (for MarginContainer) + if params.has("margins") and params["margins"] is Dictionary: + var margins: Dictionary = params["margins"] + if target is MarginContainer: + if margins.has("left"): + target.add_theme_constant_override("margin_left", int(margins["left"])) + if margins.has("top"): + target.add_theme_constant_override("margin_top", int(margins["top"])) + if margins.has("right"): + target.add_theme_constant_override("margin_right", int(margins["right"])) + if margins.has("bottom"): + target.add_theme_constant_override("margin_bottom", int(margins["bottom"])) + applied.append("margins=%s" % str(margins)) + + # Separation (for VBox/HBoxContainer) + if params.has("separation"): + var sep: int = int(params["separation"]) + if target is BoxContainer: + target.add_theme_constant_override("separation", sep) + applied.append("separation=%d" % sep) + + # Grow direction horizontal + var grow_h: String = optional_string(params, "grow_h", "") + if not grow_h.is_empty(): + var grow_map := { + "begin": Control.GROW_DIRECTION_BEGIN, + "end": Control.GROW_DIRECTION_END, + "both": Control.GROW_DIRECTION_BOTH, + } + if grow_map.has(grow_h): + target.grow_horizontal = grow_map[grow_h] + applied.append("grow_h=%s" % grow_h) + + # Grow direction vertical + var grow_v: String = optional_string(params, "grow_v", "") + if not grow_v.is_empty(): + var grow_map := { + "begin": Control.GROW_DIRECTION_BEGIN, + "end": Control.GROW_DIRECTION_END, + "both": Control.GROW_DIRECTION_BOTH, + } + if grow_map.has(grow_v): + target.grow_vertical = grow_map[grow_v] + applied.append("grow_v=%s" % grow_v) + + if not applied.is_empty(): + var new_state := _capture_control_setup_state(target) + _register_control_setup_undo(control, old_state, new_state) + target.free() + return success({"node_path": node_path, "applied": applied, "count": applied.size()}) + + +func _restore_theme_override(control: Control, kind: String, override_name: String, had_old: bool, old_value: Variant) -> void: + match kind: + "color": + if had_old: + control.add_theme_color_override(override_name, old_value) + else: + control.remove_theme_color_override(override_name) + "constant": + if had_old: + control.add_theme_constant_override(override_name, old_value) + else: + control.remove_theme_constant_override(override_name) + "font_size": + if had_old: + control.add_theme_font_size_override(override_name, old_value) + else: + control.remove_theme_font_size_override(override_name) + "stylebox": + if had_old: + control.add_theme_stylebox_override(override_name, old_value) + else: + control.remove_theme_stylebox_override(override_name) + + +func _capture_control_setup_state(control: Control) -> Dictionary: + var state := {"properties": {}, "theme_constants": {}} + for property: String in [ + "anchor_left", "anchor_top", "anchor_right", "anchor_bottom", + "offset_left", "offset_top", "offset_right", "offset_bottom", + "custom_minimum_size", "size_flags_horizontal", "size_flags_vertical", + "grow_horizontal", "grow_vertical", + ]: + state["properties"][property] = control.get(property) + for constant_name: String in ["margin_left", "margin_top", "margin_right", "margin_bottom", "separation"]: + var had_override := control.has_theme_constant_override(constant_name) + state["theme_constants"][constant_name] = { + "had": had_override, + "value": control.get("theme_override_constants/" + constant_name) if had_override else null, + } + return state + + +func _register_control_setup_undo(control: Control, old_state: Dictionary, new_state: Dictionary) -> void: + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Setup Control") + for property: String in new_state["properties"]: + undo_redo.add_do_property(control, property, new_state["properties"][property]) + undo_redo.add_undo_property(control, property, old_state["properties"][property]) + for constant_name: String in new_state["theme_constants"]: + var new_constant: Dictionary = new_state["theme_constants"][constant_name] + var old_constant: Dictionary = old_state["theme_constants"][constant_name] + undo_redo.add_do_method(self, "_restore_theme_override", control, "constant", constant_name, new_constant["had"], new_constant["value"]) + undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", constant_name, old_constant["had"], old_constant["value"]) + undo_redo.commit_action() + + +func _get_theme_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var info := {"node_path": node_path, "class": control.get_class()} + + # Check if node has a theme + var theme := control.theme + if theme: + info["theme_path"] = theme.resource_path + info["type_list"] = Array(theme.get_type_list()) + + # List overrides + var overrides := {"colors": {}, "constants": {}, "font_sizes": {}, "styleboxes": {}} + for prop in control.get_property_list(): + var pname: String = prop["name"] + if pname.begins_with("theme_override_colors/"): + var key := pname.substr(22) + overrides["colors"][key] = "#" + (control.get(pname) as Color).to_html() + elif pname.begins_with("theme_override_constants/"): + var key := pname.substr(25) + overrides["constants"][key] = control.get(pname) + elif pname.begins_with("theme_override_font_sizes/"): + var key := pname.substr(26) + overrides["font_sizes"][key] = control.get(pname) + elif pname.begins_with("theme_override_styles/"): + var key := pname.substr(22) + var style = control.get(pname) + overrides["styleboxes"][key] = style.get_class() if style else null + + info["overrides"] = overrides + return success(info) diff --git a/addons/godot_mcp/commands/theme_commands.gd.uid b/addons/godot_mcp/commands/theme_commands.gd.uid new file mode 100644 index 0000000..72b0a85 --- /dev/null +++ b/addons/godot_mcp/commands/theme_commands.gd.uid @@ -0,0 +1 @@ +uid://bwig0wr0jlne2 diff --git a/addons/godot_mcp/commands/tilemap_commands.gd b/addons/godot_mcp/commands/tilemap_commands.gd new file mode 100644 index 0000000..1cfdfde --- /dev/null +++ b/addons/godot_mcp/commands/tilemap_commands.gd @@ -0,0 +1,381 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "tilemap_set_cell": _tilemap_set_cell, + "tilemap_fill_rect": _tilemap_fill_rect, + "tilemap_get_cell": _tilemap_get_cell, + "tilemap_clear": _tilemap_clear, + "tilemap_get_info": _tilemap_get_info, + "tilemap_get_used_cells": _tilemap_get_used_cells, + } + + +func _find_tilemap_node(node_path: String) -> Node: + var node := find_node_by_path(node_path) + if node is TileMapLayer or _is_legacy_tilemap(node): + return node + return null + + +func _is_legacy_tilemap(node: Node) -> bool: + return node != null and node.get_class() == "TileMap" + + +func _not_found_result(node_path: String) -> Dictionary: + return error_not_found( + "TileMapLayer or TileMap at '%s'" % node_path, + "Use TileMapLayer for current Godot projects, or pass a deprecated TileMap node with optional layer." + ) + + +func _get_single_layer(tilemap: Node, params: Dictionary) -> Array: + if tilemap is TileMapLayer: + if params.has("layer") and int(params["layer"]) != 0: + return [0, error_invalid_params("layer only applies to deprecated TileMap nodes; TileMapLayer has one implicit layer")] + return [0, null] + + var layer: int = optional_int(params, "layer", 0) + var layer_error := _validate_tilemap_layer(tilemap, layer) + if not layer_error.is_empty(): + return [layer, layer_error] + return [layer, null] + + +func _get_clear_layers(tilemap: Node, params: Dictionary) -> Array: + if tilemap is TileMapLayer: + var layer_result := _get_single_layer(tilemap, params) + if layer_result[1] != null: + return [[], layer_result[1]] + return [[0], null] + + if params.has("layer"): + var layer_result := _get_single_layer(tilemap, params) + if layer_result[1] != null: + return [[], layer_result[1]] + return [[int(layer_result[0])], null] + + var layers: Array = [] + for layer in range(_get_tilemap_layer_count(tilemap)): + layers.append(layer) + return [layers, null] + + +func _validate_tilemap_layer(tilemap: Node, layer: int) -> Dictionary: + if tilemap is TileMapLayer: + return {} + + var layer_count := _get_tilemap_layer_count(tilemap) + if layer_count <= 0: + return error_invalid_params("TileMap has no layers") + if layer < -layer_count or layer >= layer_count: + return error_invalid_params("layer %d is out of range for TileMap with %d layers" % [layer, layer_count]) + return {} + + +func _get_tilemap_layer_count(tilemap: Node) -> int: + if tilemap is TileMapLayer: + return 1 + return int(tilemap.call("get_layers_count")) + + +func _get_used_cells(tilemap: Node, layer: int) -> Array: + if tilemap is TileMapLayer: + return tilemap.call("get_used_cells") + return tilemap.call("get_used_cells", layer) + + +func _get_cell_source_id(tilemap: Node, layer: int, coords: Vector2i) -> int: + if tilemap is TileMapLayer: + return int(tilemap.call("get_cell_source_id", coords)) + return int(tilemap.call("get_cell_source_id", layer, coords)) + + +func _get_cell_atlas_coords(tilemap: Node, layer: int, coords: Vector2i) -> Vector2i: + if tilemap is TileMapLayer: + return tilemap.call("get_cell_atlas_coords", coords) + return tilemap.call("get_cell_atlas_coords", layer, coords) + + +func _get_cell_alternative_tile(tilemap: Node, layer: int, coords: Vector2i) -> int: + if tilemap is TileMapLayer: + return int(tilemap.call("get_cell_alternative_tile", coords)) + return int(tilemap.call("get_cell_alternative_tile", layer, coords)) + + +func _tilemap_set_cell(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap_node(node_path) + if tilemap == null: + return _not_found_result(node_path) + + var layer_result := _get_single_layer(tilemap, params) + if layer_result[1] != null: + return layer_result[1] + var layer: int = layer_result[0] + + var x: int = int(params.get("x", 0)) + var y: int = int(params.get("y", 0)) + var source_id: int = int(params.get("source_id", 0)) + var atlas_x: int = int(params.get("atlas_x", 0)) + var atlas_y: int = int(params.get("atlas_y", 0)) + var alternative: int = int(params.get("alternative", 0)) + + var coords := Vector2i(x, y) + var old_cells := [_capture_cell(tilemap, layer, coords)] + var new_cells := [_make_cell(layer, coords, source_id, Vector2i(atlas_x, atlas_y), alternative)] + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set TileMap cell") + _add_do_set_cells(undo_redo, tilemap, new_cells) + _add_undo_set_cells(undo_redo, tilemap, old_cells) + undo_redo.commit_action() + + return success({"x": x, "y": y, "layer": layer, "node_class": tilemap.get_class(), "source_id": source_id, "atlas_coords": [atlas_x, atlas_y]}) + + +func _tilemap_fill_rect(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap_node(node_path) + if tilemap == null: + return _not_found_result(node_path) + + var layer_result := _get_single_layer(tilemap, params) + if layer_result[1] != null: + return layer_result[1] + var layer: int = layer_result[0] + + var x1: int = int(params.get("x1", 0)) + var y1: int = int(params.get("y1", 0)) + var x2: int = int(params.get("x2", 0)) + var y2: int = int(params.get("y2", 0)) + var source_id: int = int(params.get("source_id", 0)) + var atlas_x: int = int(params.get("atlas_x", 0)) + var atlas_y: int = int(params.get("atlas_y", 0)) + var alternative: int = int(params.get("alternative", 0)) + + var count := 0 + var old_cells: Array = [] + var new_cells: Array = [] + for cx in range(mini(x1, x2), maxi(x1, x2) + 1): + for cy in range(mini(y1, y2), maxi(y1, y2) + 1): + var coords := Vector2i(cx, cy) + old_cells.append(_capture_cell(tilemap, layer, coords)) + new_cells.append(_make_cell(layer, coords, source_id, Vector2i(atlas_x, atlas_y), alternative)) + count += 1 + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Fill TileMap rect") + _add_do_set_cells(undo_redo, tilemap, new_cells) + _add_undo_set_cells(undo_redo, tilemap, old_cells) + undo_redo.commit_action() + + return success({"filled": count, "rect": [x1, y1, x2, y2], "layer": layer, "node_class": tilemap.get_class()}) + + +func _tilemap_get_cell(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap_node(node_path) + if tilemap == null: + return _not_found_result(node_path) + + var layer_result := _get_single_layer(tilemap, params) + if layer_result[1] != null: + return layer_result[1] + var layer: int = layer_result[0] + + var x: int = int(params.get("x", 0)) + var y: int = int(params.get("y", 0)) + var coords := Vector2i(x, y) + + var source_id := _get_cell_source_id(tilemap, layer, coords) + var atlas_coords := _get_cell_atlas_coords(tilemap, layer, coords) + var alternative := _get_cell_alternative_tile(tilemap, layer, coords) + + return success({ + "x": x, "y": y, + "layer": layer, + "node_class": tilemap.get_class(), + "source_id": source_id, + "atlas_coords": [atlas_coords.x, atlas_coords.y], + "alternative": alternative, + "empty": source_id == -1, + }) + + +func _tilemap_clear(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap_node(node_path) + if tilemap == null: + return _not_found_result(node_path) + + var clear_layers_result := _get_clear_layers(tilemap, params) + if clear_layers_result[1] != null: + return clear_layers_result[1] + var layers: Array = clear_layers_result[0] + + var old_cells := _capture_cells(tilemap, layers) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Clear TileMap") + _add_do_clear(undo_redo, tilemap, layers) + _add_undo_set_cells(undo_redo, tilemap, old_cells) + undo_redo.commit_action() + return success({"cleared": true, "layers": layers, "node_class": tilemap.get_class()}) + + +func _make_cell(layer: int, coords: Vector2i, source_id: int, atlas_coords: Vector2i, alternative: int) -> Dictionary: + return { + "layer": layer, + "coords": coords, + "source_id": source_id, + "atlas_coords": atlas_coords, + "alternative": alternative, + } + + +func _capture_cell(tilemap: Node, layer: int, coords: Vector2i) -> Dictionary: + return _make_cell( + layer, + coords, + _get_cell_source_id(tilemap, layer, coords), + _get_cell_atlas_coords(tilemap, layer, coords), + _get_cell_alternative_tile(tilemap, layer, coords) + ) + + +func _capture_cells(tilemap: Node, layers: Array) -> Array: + var cells: Array = [] + for layer: int in layers: + for coords: Vector2i in _get_used_cells(tilemap, layer): + cells.append(_capture_cell(tilemap, layer, coords)) + return cells + + +func _add_do_set_cells(undo_redo: EditorUndoRedoManager, tilemap: Node, cells: Array) -> void: + for cell: Dictionary in cells: + if tilemap is TileMapLayer: + undo_redo.add_do_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"]) + else: + undo_redo.add_do_method(tilemap, "set_cell", cell["layer"], cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"]) + + +func _add_undo_set_cells(undo_redo: EditorUndoRedoManager, tilemap: Node, cells: Array) -> void: + for cell: Dictionary in cells: + if tilemap is TileMapLayer: + undo_redo.add_undo_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"]) + else: + undo_redo.add_undo_method(tilemap, "set_cell", cell["layer"], cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"]) + + +func _add_do_clear(undo_redo: EditorUndoRedoManager, tilemap: Node, layers: Array) -> void: + if tilemap is TileMapLayer or layers.size() == _get_tilemap_layer_count(tilemap): + undo_redo.add_do_method(tilemap, "clear") + return + + for layer: int in layers: + undo_redo.add_do_method(tilemap, "clear_layer", layer) + + +func _tilemap_get_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap_node(node_path) + if tilemap == null: + return _not_found_result(node_path) + + var tile_set: TileSet = tilemap.get("tile_set") + var sources: Array = [] + if tile_set: + for i in tile_set.get_source_count(): + var source_id := tile_set.get_source_id(i) + var source := tile_set.get_source(source_id) + var info := {"id": source_id, "type": source.get_class()} + if source is TileSetAtlasSource: + var atlas: TileSetAtlasSource = source + info["texture"] = atlas.texture.resource_path if atlas.texture else "" + info["tile_count"] = atlas.get_tiles_count() + sources.append(info) + + var layers := _get_layer_info(tilemap) + var used_cells := 0 + for layer_info: Dictionary in layers: + used_cells += int(layer_info["used_cells"]) + + return success({ + "node_path": node_path, + "node_class": tilemap.get_class(), + "layer_count": layers.size(), + "layers": layers, + "used_cells": used_cells, + "tile_set_sources": sources, + "tile_size": [tile_set.tile_size.x, tile_set.tile_size.y] if tile_set else [0, 0], + }) + + +func _get_layer_info(tilemap: Node) -> Array: + var layers: Array = [] + if tilemap is TileMapLayer: + layers.append({ + "index": 0, + "name": tilemap.name, + "enabled": true, + "used_cells": _get_used_cells(tilemap, 0).size(), + }) + return layers + + for layer in range(_get_tilemap_layer_count(tilemap)): + layers.append({ + "index": layer, + "name": String(tilemap.call("get_layer_name", layer)), + "enabled": bool(tilemap.call("is_layer_enabled", layer)), + "used_cells": _get_used_cells(tilemap, layer).size(), + }) + return layers + + +func _tilemap_get_used_cells(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap_node(node_path) + if tilemap == null: + return _not_found_result(node_path) + + var layer_result := _get_single_layer(tilemap, params) + if layer_result[1] != null: + return layer_result[1] + var layer: int = layer_result[0] + + var max_count: int = maxi(0, optional_int(params, "max_count", 500)) + var cells: Array = [] + var used := _get_used_cells(tilemap, layer) + + for i in mini(used.size(), max_count): + var pos: Vector2i = used[i] + cells.append({"x": pos.x, "y": pos.y, "layer": layer, "source_id": _get_cell_source_id(tilemap, layer, pos)}) + + return success({"cells": cells, "total": used.size(), "returned": cells.size(), "layer": layer, "node_class": tilemap.get_class()}) diff --git a/addons/godot_mcp/commands/tilemap_commands.gd.uid b/addons/godot_mcp/commands/tilemap_commands.gd.uid new file mode 100644 index 0000000..b6497a7 --- /dev/null +++ b/addons/godot_mcp/commands/tilemap_commands.gd.uid @@ -0,0 +1 @@ +uid://cmsrsh4xgw30p diff --git a/addons/godot_mcp/mcp_game_inspector_service.gd b/addons/godot_mcp/mcp_game_inspector_service.gd new file mode 100644 index 0000000..8e9acc3 --- /dev/null +++ b/addons/godot_mcp/mcp_game_inspector_service.gd @@ -0,0 +1,1761 @@ +## Autoload injected by Godot MCP Pro plugin at runtime. +## Handles runtime game inspection: scene tree, node properties, frame capture, property monitoring. +extends Node + +const REQUEST_PATH := "user://mcp_game_request" +const RESPONSE_PATH := "user://mcp_game_response" + +enum State { IDLE, CAPTURING_FRAMES, MONITORING, RECORDING, MOVING_TO, WATCHING_SIGNALS } + +var _state := State.IDLE +var _pending_command: bool = false # Crash recovery flag + +# Frame capture state +var _capture_frames_remaining: int = 0 +var _capture_frame_interval: int = 1 +var _capture_frame_counter: int = 0 +var _capture_half_res: bool = true +var _captured_images: Array = [] # Array of base64 strings +var _capture_node_path: String = "" # Optional node to track per frame +var _capture_node_props: Array = [] # Properties to snapshot per frame +var _capture_frame_data: Array = [] # Array of per-frame node snapshots + +# Recording state +var _recording_events: Array = [] +var _recording_start_msec: int = 0 + +# Monitor state +var _monitor_node_path: String = "" +var _monitor_properties: Array = [] +var _monitor_frames_remaining: int = 0 +var _monitor_frame_interval: int = 1 +var _monitor_frame_counter: int = 0 +var _monitor_timeline: Array = [] # Array of sample dicts + +# Signal watch state +var _watch_nodes: Array = [] # Array of node paths being watched +var _watch_signal_filter: Array = [] # Optional signal name filter +var _watch_log: Array = [] # Array of {time_ms, node, signal, args} +var _watch_start_msec: int = 0 +var _watch_duration_ms: int = 5000 +var _watch_connections: Array = [] # Array of {node, signal, callable} for cleanup + +# Move-to state +var _moveto_target: Vector3 = Vector3.ZERO +var _moveto_player: Node3D = null +var _moveto_camera_pivot: Node3D = null +var _moveto_arrival_radius: float = 1.5 +var _moveto_timeout: float = 15.0 +var _moveto_elapsed: float = 0.0 +var _moveto_run: bool = false +var _moveto_look_at: bool = true +var _moveto_keys_held: Array = [] # Track injected keys for guaranteed release + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + + +func _process(_delta: float) -> void: + # Crash recovery: if a command was in progress but never wrote a response + if _pending_command and not FileAccess.file_exists(REQUEST_PATH) and not FileAccess.file_exists(RESPONSE_PATH): + push_warning("[MCP] Recovered from crashed command — writing error response") + _pending_command = false + _state = State.IDLE + # Signal editor plugin to auto-press debugger Continue + var flag := FileAccess.open("user://mcp_debugger_continue", FileAccess.WRITE) + if flag: + flag.close() + _write_response({"error": "Command crashed (runtime error). Check Godot debugger."}) + return + + match _state: + State.IDLE: + if FileAccess.file_exists(REQUEST_PATH): + _handle_request() + State.CAPTURING_FRAMES: + _process_capture() + State.MONITORING: + _process_monitor() + State.RECORDING: + if FileAccess.file_exists(REQUEST_PATH): + _handle_request() + State.MOVING_TO: + _process_move_to(_delta) + State.WATCHING_SIGNALS: + _process_watch_signals() + + +# ── Request handling ────────────────────────────────────────────────────────── + +func _handle_request() -> void: + var file := FileAccess.open(REQUEST_PATH, FileAccess.READ) + if file == null: + return + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(REQUEST_PATH) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + _write_response({"error": "Invalid request JSON"}) + return + + # Abort any in-progress operation + _state = State.IDLE + _pending_command = true + + var command: String = parsed.get("command", "") + var params: Dictionary = parsed.get("params", {}) + + match command: + "get_scene_tree": + _cmd_get_scene_tree(params) + "get_node_properties": + _cmd_get_node_properties(params) + "set_node_property": + _cmd_set_node_property(params) + "capture_frames": + _cmd_capture_frames(params) + "monitor_properties": + _cmd_monitor_properties(params) + "execute_script": + _cmd_execute_script(params) + "start_recording": + _cmd_start_recording(params) + "stop_recording": + _cmd_stop_recording(params) + "replay_recording": + _cmd_replay_recording(params) + "find_nodes_by_script": + _cmd_find_nodes_by_script(params) + "get_autoload": + _cmd_get_autoload(params) + "batch_get_properties": + _cmd_batch_get_properties(params) + "find_ui_elements": + _cmd_find_ui_elements(params) + "click_button_by_text": + _cmd_click_button_by_text(params) + "wait_for_node": + _cmd_wait_for_node(params) + "find_nearby_nodes": + _cmd_find_nearby_nodes(params) + "navigate_to": + _cmd_navigate_to(params) + "move_to": + _cmd_move_to(params) + "watch_signals": + _cmd_watch_signals(params) + "assert_node_state": + _cmd_assert_node_state(params) + "get_performance_monitors": + _cmd_get_performance_monitors(params) + _: + _write_response({"error": "Unknown command: %s" % command}) + + +# ── get_scene_tree ──────────────────────────────────────────────────────────── + +func _cmd_get_scene_tree(params: Dictionary) -> void: + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var max_depth: int = params.get("max_depth", -1) + var script_filter: String = params.get("script_filter", "") + var type_filter: String = params.get("type_filter", "") + var named_only: bool = params.get("named_only", false) + + var has_filter: bool = not script_filter.is_empty() or not type_filter.is_empty() or named_only + + if has_filter: + var tree: Variant = _build_filtered_node_tree(root, max_depth, script_filter, type_filter, named_only) + if tree == null: + _write_response({"tree": null, "message": "No nodes matched the filter"}) + else: + _write_response({"tree": tree}) + else: + var tree := _build_node_tree(root, max_depth) + _write_response({"tree": tree}) + + +func _build_node_tree(node: Node, max_depth: int, current_depth: int = 0) -> Dictionary: + var result := { + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()), + } + + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + if max_depth == -1 or current_depth < max_depth: + var children: Array = [] + for child in node.get_children(): + children.append(_build_node_tree(child, max_depth, current_depth + 1)) + if not children.is_empty(): + result["children"] = children + + return result + + +## Build a filtered tree. Returns null if this subtree has no matches. +func _build_filtered_node_tree(node: Node, max_depth: int, script_filter: String, type_filter: String, named_only: bool, current_depth: int = 0) -> Variant: + var node_matches := _node_matches_filter(node, script_filter, type_filter, named_only) + + # Build children first to check if any descendant matches + var matched_children: Array = [] + if max_depth == -1 or current_depth < max_depth: + for child in node.get_children(): + var child_tree: Variant = _build_filtered_node_tree(child, max_depth, script_filter, type_filter, named_only, current_depth + 1) + if child_tree != null: + matched_children.append(child_tree) + + # Include this node if it matches or if any descendant matches + if not node_matches and matched_children.is_empty(): + return null + + var result := { + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()), + } + + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + if not matched_children.is_empty(): + result["children"] = matched_children + + return result + + +func _node_matches_filter(node: Node, script_filter: String, type_filter: String, named_only: bool) -> bool: + if named_only and (node.name as String).begins_with("@"): + return false + if not type_filter.is_empty() and not node.is_class(type_filter): + return false + if not script_filter.is_empty(): + var script: Script = node.get_script() + if script == null: + return false + if not script.resource_path.to_lower().contains(script_filter.to_lower()): + return false + return true + + +# ── get_node_properties ─────────────────────────────────────────────────────── + +func _cmd_get_node_properties(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "node_path is required"}) + return + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + _write_response({"error": "Node not found: %s" % node_path}) + return + + var filter: Array = params.get("properties", []) + var props: Dictionary = {} + + if filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in filter: + var value: Variant = node.get(prop_name) + props[prop_name] = _serialize_value(value) + + _write_response({ + "node_path": str(node.get_path()), + "type": node.get_class(), + "properties": props, + }) + + +# ── capture_frames ──────────────────────────────────────────────────────────── + +func _cmd_capture_frames(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + var count: int = clampi(params.get("count", 5), 1, 30) + var interval: int = maxi(params.get("frame_interval", 10), 1) + _capture_half_res = params.get("half_resolution", true) + + # Optional node_data tracking + _capture_node_path = "" + _capture_node_props = [] + _capture_frame_data.clear() + var node_data: Dictionary = params.get("node_data", {}) + if not node_data.is_empty(): + _capture_node_path = node_data.get("node_path", "") + _capture_node_props = node_data.get("properties", []) + + _captured_images.clear() + _capture_frames_remaining = count + _capture_frame_interval = interval + _capture_frame_counter = 0 + _state = State.CAPTURING_FRAMES + _capture_one_frame() + + +func _process_capture() -> void: + if FileAccess.file_exists(REQUEST_PATH): + _state = State.IDLE + _handle_request() + return + + _capture_frame_counter += 1 + if _capture_frame_counter >= _capture_frame_interval: + _capture_frame_counter = 0 + _capture_one_frame() + + +func _capture_one_frame() -> void: + var viewport := get_viewport() + if viewport == null: + _finish_capture() + return + + var image := viewport.get_texture().get_image() + if image == null: + _finish_capture() + return + + if _capture_half_res: + var new_size := image.get_size() / 2 + image.resize(new_size.x, new_size.y, Image.INTERPOLATE_BILINEAR) + + var png_buffer := image.save_png_to_buffer() + _captured_images.append(Marshalls.raw_to_base64(png_buffer)) + + # Snapshot node properties if tracking + if not _capture_node_path.is_empty() and not _capture_node_props.is_empty(): + var snap := {} + var node := get_tree().root.get_node_or_null(_capture_node_path) + if node: + for prop_name in _capture_node_props: + var val = node.get(prop_name) + snap[prop_name] = _serialize_value(val) + _capture_frame_data.append(snap) + + _capture_frames_remaining -= 1 + if _capture_frames_remaining <= 0: + _finish_capture() + + +func _finish_capture() -> void: + _state = State.IDLE + var viewport := get_viewport() + var w := 0 + var h := 0 + if viewport: + var size := viewport.get_visible_rect().size + if _capture_half_res: + size /= 2 + w = int(size.x) + h = int(size.y) + + var response := { + "frames": _captured_images, + "count": _captured_images.size(), + "width": w, + "height": h, + "half_resolution": _capture_half_res, + } + if not _capture_frame_data.is_empty(): + response["frame_data"] = _capture_frame_data + _write_response(response) + _captured_images.clear() + _capture_frame_data.clear() + + +# ── monitor_properties ──────────────────────────────────────────────────────── + +func _cmd_monitor_properties(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + _monitor_node_path = params.get("node_path", "") + _monitor_properties = params.get("properties", []) + if _monitor_node_path.is_empty() or _monitor_properties.is_empty(): + _write_response({"error": "node_path and properties are required"}) + return + + var frame_count: int = clampi(params.get("frame_count", 60), 1, 600) + var interval: int = maxi(params.get("frame_interval", 1), 1) + + _monitor_timeline.clear() + _monitor_frames_remaining = frame_count + _monitor_frame_interval = interval + _monitor_frame_counter = 0 + _state = State.MONITORING + _sample_one_frame() + + +func _process_monitor() -> void: + if FileAccess.file_exists(REQUEST_PATH): + _state = State.IDLE + _handle_request() + return + + _monitor_frame_counter += 1 + if _monitor_frame_counter >= _monitor_frame_interval: + _monitor_frame_counter = 0 + _sample_one_frame() + + +func _sample_one_frame() -> void: + var sample: Dictionary = {} + var node := get_node_or_null(NodePath(_monitor_node_path)) + + if node == null: + for prop_name: String in _monitor_properties: + sample[prop_name] = null + else: + for prop_name: String in _monitor_properties: + sample[prop_name] = _serialize_value(node.get(prop_name)) + + _monitor_timeline.append(sample) + + _monitor_frames_remaining -= 1 + if _monitor_frames_remaining <= 0: + _finish_monitor() + + +func _finish_monitor() -> void: + _state = State.IDLE + _write_response({ + "node_path": _monitor_node_path, + "properties": _monitor_properties, + "samples": _monitor_timeline, + "sample_count": _monitor_timeline.size(), + "frame_interval": _monitor_frame_interval, + }) + _monitor_timeline.clear() + + +# ── watch_signals ──────────────────────────────────────────────────────────── + +func _cmd_watch_signals(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + + if not params.has("node_paths") or not params["node_paths"] is Array: + _write_response({"error": "node_paths array is required"}) + return + + var node_paths: Array = params["node_paths"] + if node_paths.is_empty(): + _write_response({"error": "node_paths array is empty"}) + return + + _watch_signal_filter = params.get("signal_filter", []) if params.has("signal_filter") and params["signal_filter"] is Array else [] + _watch_duration_ms = clampi(params.get("duration_ms", 5000), 500, 30000) + _watch_log.clear() + _watch_connections.clear() + _watch_nodes = node_paths + + # Connect to signals on each node + var connected_count: int = 0 + for node_path_str: String in node_paths: + var node := get_node_or_null(NodePath(node_path_str)) + if node == null: + _watch_log.append({"warning": "Node not found: %s" % node_path_str}) + continue + + for sig_info: Dictionary in node.get_signal_list(): + var sig_name: String = sig_info["name"] + # Apply filter if specified + if not _watch_signal_filter.is_empty(): + var match_found := false + for filter_str: String in _watch_signal_filter: + if sig_name.contains(filter_str): + match_found = true + break + if not match_found: + continue + + # Create a callable matched to the signal's argument count + var arg_count: int = sig_info["args"].size() + var cb := _make_signal_callback(node_path_str, sig_name, arg_count) + if cb.is_valid() and not node.is_connected(sig_name, cb): + node.connect(sig_name, cb) + _watch_connections.append({"node": node, "signal": sig_name, "callable": cb}) + connected_count += 1 + + if connected_count == 0 and _watch_log.is_empty(): + _write_response({"error": "No signals connected. Check node_paths and signal_filter."}) + return + + _watch_start_msec = Time.get_ticks_msec() + _state = State.WATCHING_SIGNALS + + +func _on_signal_fired(node_path_str: String, sig_name: String, args: Array) -> void: + var elapsed: int = Time.get_ticks_msec() - _watch_start_msec + var entry: Dictionary = { + "time_ms": elapsed, + "node": node_path_str, + "signal": sig_name, + } + if not args.is_empty(): + var serialized: Array = [] + for a: Variant in args: + serialized.append(_serialize_value(a)) + entry["args"] = serialized + _watch_log.append(entry) + + +func _make_signal_callback(node_path_str: String, sig_name: String, arg_count: int) -> Callable: + var np := node_path_str + var sn := sig_name + match arg_count: + 0: + return func() -> void: _on_signal_fired(np, sn, []) + 1: + return func(a: Variant) -> void: _on_signal_fired(np, sn, [a]) + 2: + return func(a: Variant, b: Variant) -> void: _on_signal_fired(np, sn, [a, b]) + 3: + return func(a: Variant, b: Variant, c: Variant) -> void: _on_signal_fired(np, sn, [a, b, c]) + 4: + return func(a: Variant, b: Variant, c: Variant, d: Variant) -> void: _on_signal_fired(np, sn, [a, b, c, d]) + _: + # For 5+ args, drop extra args and log without them + var cb := func() -> void: _on_signal_fired(np, sn, []) + return cb.unbind(arg_count) + + +func _process_watch_signals() -> void: + # Check for abort (new request) + if FileAccess.file_exists(REQUEST_PATH): + _finish_watch_signals() + _state = State.IDLE + _handle_request() + return + + var elapsed: int = Time.get_ticks_msec() - _watch_start_msec + if elapsed >= _watch_duration_ms: + _finish_watch_signals() + + +func _finish_watch_signals() -> void: + # Disconnect all watchers + for conn: Dictionary in _watch_connections: + var node: Node = conn["node"] as Node + if is_instance_valid(node): + var sig_name: String = conn["signal"] + var cb: Callable = conn["callable"] + if node.is_connected(sig_name, cb): + node.disconnect(sig_name, cb) + _watch_connections.clear() + + _state = State.IDLE + _write_response({ + "node_paths": _watch_nodes, + "signal_filter": _watch_signal_filter, + "duration_ms": _watch_duration_ms, + "events": _watch_log, + "event_count": _watch_log.size(), + }) + _watch_log.clear() + + +# ── set_node_property ───────────────────────────────────────────────────────── + +func _cmd_set_node_property(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "node_path is required"}) + return + + var property: String = params.get("property", "") + if property.is_empty(): + _write_response({"error": "property is required"}) + return + + if not params.has("value"): + _write_response({"error": "value is required"}) + return + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + _write_response({"error": "Node not found: %s" % node_path}) + return + + var old_value: Variant = node.get(property) + var raw_value: Variant = params.get("value") + var parsed_value: Variant = _parse_value_for_type(raw_value, typeof(old_value)) + + node.set(property, parsed_value) + var new_value: Variant = node.get(property) + + _write_response({ + "node_path": str(node.get_path()), + "property": property, + "old_value": _serialize_value(old_value), + "new_value": _serialize_value(new_value), + }) + + +func _parse_value_for_type(raw: Variant, target_type: int) -> Variant: + if typeof(raw) == target_type: + return raw + + # Handle Dictionary → Vector2/Vector3/Color conversion + if raw is Dictionary: + var dict: Dictionary = raw + match target_type: + TYPE_VECTOR3: + return Vector3( + float(dict.get("x", 0)), + float(dict.get("y", 0)), + float(dict.get("z", 0)) + ) + TYPE_VECTOR3I: + return Vector3i( + int(dict.get("x", 0)), + int(dict.get("y", 0)), + int(dict.get("z", 0)) + ) + TYPE_VECTOR2: + return Vector2( + float(dict.get("x", 0)), + float(dict.get("y", 0)) + ) + TYPE_VECTOR2I: + return Vector2i( + int(dict.get("x", 0)), + int(dict.get("y", 0)) + ) + TYPE_COLOR: + return Color( + float(dict.get("r", 0)), + float(dict.get("g", 0)), + float(dict.get("b", 0)), + float(dict.get("a", 1)) + ) + return raw + + if raw is String: + var raw_str: String = raw + if raw_str.begins_with("#"): + return Color.html(raw_str) + + var expr := Expression.new() + var err := expr.parse(raw_str) + if err == OK: + var result: Variant = expr.execute() + if not expr.has_execute_failed(): + return result + return raw_str + + if raw is float and target_type == TYPE_INT: + return int(raw) + if raw is int and target_type == TYPE_FLOAT: + return float(raw) + + return raw + + +# ── execute_script ──────────────────────────────────────────────────────────── + +func _cmd_execute_script(params: Dictionary) -> void: + var code: String = params.get("code", "") + if code.is_empty(): + _write_response({"error": "code is required"}) + return + + # Normalize indentation: convert leading spaces to tabs + var raw_lines := code.split("\n") + var indent_size := 0 + for raw_line in raw_lines: + var spaces := 0 + while spaces < raw_line.length() and raw_line[spaces] == " ": + spaces += 1 + if spaces > 0 and (indent_size == 0 or spaces < indent_size): + indent_size = spaces + if indent_size > 0: + var space_unit := " ".repeat(indent_size) + for idx in raw_lines.size(): + var rl: String = raw_lines[idx] + var tabs := "" + while rl.begins_with(space_unit): + tabs += "\t" + rl = rl.substr(indent_size) + raw_lines[idx] = tabs + rl + + # Separate top-level func definitions (place at class level, not inside run()) + var class_funcs: PackedStringArray = [] + var body_lines: PackedStringArray = [] + var i := 0 + while i < raw_lines.size(): + var line: String = raw_lines[i] + if not line.begins_with("\t") and not line.begins_with(" ") and line.begins_with("func "): + class_funcs.append(line) + i += 1 + while i < raw_lines.size(): + var next_line: String = raw_lines[i] + if next_line.is_empty() or next_line.begins_with("\t"): + class_funcs.append(next_line) + i += 1 + else: + break + else: + body_lines.append(line) + i += 1 + + var wrapped := """extends Node + +var _mcp_output: Array = [] +var _mcp_error: String = "" + +func _mcp_print(value: Variant) -> void: + _mcp_output.append(str(value)) + +func _safe_get(node: Node, prop: String, default: Variant = null) -> Variant: + if node == null: + return default + return node.get(prop) if prop in node else default + +""" + # Add user's top-level functions at class level + for func_line in class_funcs: + wrapped += func_line + "\n" + if class_funcs.size() > 0: + wrapped += "\n" + + wrapped += "func run() -> Variant:\n" + for line in body_lines: + wrapped += "\t" + line + "\n" + wrapped += "\treturn _mcp_output\n" + + var script := GDScript.new() + script.source_code = wrapped + var err := script.reload() + if err != OK: + _write_response({"error": "Script compilation failed: %s" % error_string(err)}) + return + + var temp_node := Node.new() + temp_node.set_script(script) + get_tree().current_scene.add_child(temp_node) + + var output: Variant = null + if temp_node.has_method("run"): + output = temp_node.run() + + var mcp_output: Array = temp_node.get("_mcp_output") if temp_node.get("_mcp_output") is Array else [] + temp_node.queue_free() + + _write_response({ + "output": mcp_output, + "return_value": str(output) if output != null else null, + }) + + +# ── find_nodes_by_script ────────────────────────────────────────────────────── + +func _cmd_find_nodes_by_script(params: Dictionary) -> void: + var script_name: String = params.get("script", "") + if script_name.is_empty(): + _write_response({"error": "'script' is required"}) + return + + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var prop_filter: Array = params.get("properties", []) + var matches: Array = [] + _find_nodes_by_script_recursive(root, script_name.to_lower(), prop_filter, matches) + + _write_response({"nodes": matches, "count": matches.size()}) + + +func _find_nodes_by_script_recursive(node: Node, script_filter: String, prop_filter: Array, results: Array) -> void: + var script: Script = node.get_script() + if script and script.resource_path.to_lower().contains(script_filter): + var entry := { + "name": node.name, + "path": str(node.get_path()), + "type": node.get_class(), + "script": script.resource_path, + } + var props: Dictionary = {} + if prop_filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in prop_filter: + props[prop_name] = _serialize_value(node.get(prop_name)) + entry["properties"] = props + results.append(entry) + + for child in node.get_children(): + _find_nodes_by_script_recursive(child, script_filter, prop_filter, results) + + +# ── get_autoload ───────────────────────────────────────────────────────────── + +func _cmd_get_autoload(params: Dictionary) -> void: + var autoload_name: String = params.get("name", "") + if autoload_name.is_empty(): + _write_response({"error": "'name' is required"}) + return + + var node := get_node_or_null(NodePath("/root/" + autoload_name)) + if node == null: + _write_response({"error": "Autoload not found: %s" % autoload_name}) + return + + var prop_filter: Array = params.get("properties", []) + var props: Dictionary = {} + + if prop_filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in prop_filter: + props[prop_name] = _serialize_value(node.get(prop_name)) + + var result := { + "name": autoload_name, + "path": str(node.get_path()), + "type": node.get_class(), + "properties": props, + } + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + _write_response(result) + + +# ── batch_get_properties ───────────────────────────────────────────────────── + +func _cmd_batch_get_properties(params: Dictionary) -> void: + var nodes: Array = params.get("nodes", []) + if nodes.is_empty(): + _write_response({"error": "'nodes' array is required"}) + return + + var results: Array = [] + for entry: Dictionary in nodes: + var node_path: String = entry.get("path", "") + var prop_filter: Array = entry.get("properties", []) + + if node_path.is_empty(): + results.append({"path": "", "properties": {}, "error": "Empty path"}) + continue + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + results.append({"path": node_path, "properties": {}, "error": "Node not found"}) + continue + + var props: Dictionary = {} + if prop_filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in prop_filter: + props[prop_name] = _serialize_value(node.get(prop_name)) + + results.append({"path": node_path, "properties": props}) + + _write_response({"nodes": results, "count": results.size()}) + + +# ── find_ui_elements ───────────────────────────────────────────────────────── + +func _cmd_find_ui_elements(params: Dictionary) -> void: + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var type_filter: String = params.get("type_filter", "") + var elements: Array = [] + _find_ui_recursive(root, type_filter, elements) + _write_response({"elements": elements, "count": elements.size()}) + + +func _find_ui_recursive(node: Node, type_filter: String, results: Array) -> void: + if node is Control and node.visible: + var ctrl: Control = node + var entry: Dictionary = {} + var include := false + + if ctrl is Button: + var btn: Button = ctrl + entry["type"] = "Button" + entry["text"] = btn.text + entry["disabled"] = btn.disabled + include = true + elif ctrl is Label: + var lbl: Label = ctrl + entry["type"] = "Label" + entry["text"] = lbl.text + include = true + elif ctrl is LineEdit: + var le: LineEdit = ctrl + entry["type"] = "LineEdit" + entry["text"] = le.text + entry["placeholder"] = le.placeholder_text + include = true + elif ctrl is TextEdit: + var te: TextEdit = ctrl + entry["type"] = "TextEdit" + entry["text"] = te.text.left(200) + include = true + elif ctrl is OptionButton: + var ob: OptionButton = ctrl + entry["type"] = "OptionButton" + entry["text"] = ob.text + entry["selected"] = ob.selected + include = true + elif ctrl is CheckBox: + var cb: CheckBox = ctrl + entry["type"] = "CheckBox" + entry["text"] = cb.text + entry["checked"] = cb.button_pressed + include = true + elif ctrl is HSlider or ctrl is VSlider: + var sl: Range = ctrl + entry["type"] = "HSlider" if ctrl is HSlider else "VSlider" + entry["value"] = sl.value + entry["min"] = sl.min_value + entry["max"] = sl.max_value + include = true + + if include: + if not type_filter.is_empty() and entry.get("type", "") != type_filter: + pass # Skip non-matching types + else: + var rect := ctrl.get_global_rect() + entry["name"] = str(ctrl.name) + entry["path"] = str(ctrl.get_path()) + entry["rect"] = { + "x": rect.position.x, + "y": rect.position.y, + "width": rect.size.x, + "height": rect.size.y, + } + entry["center"] = { + "x": rect.position.x + rect.size.x / 2.0, + "y": rect.position.y + rect.size.y / 2.0, + } + results.append(entry) + + for child in node.get_children(): + _find_ui_recursive(child, type_filter, results) + + +# ── click_button_by_text ───────────────────────────────────────────────────── + +func _cmd_click_button_by_text(params: Dictionary) -> void: + var text: String = params.get("text", "") + var partial: bool = params.get("partial", true) + if text.is_empty(): + _write_response({"error": "'text' is required"}) + return + + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var btn: Button = _find_button_by_text(root, text, partial) + if btn == null: + _write_response({"error": "No visible button found with text: '%s'" % text}) + return + + var rect := btn.get_global_rect() + var center := rect.get_center() + var btn_text_value := btn.text + + # Capture button path before clicking — the click may trigger a scene + # transition that removes the node from the tree. + var btn_path := str(btn.get_path()) if btn.is_inside_tree() else "" + + # Emit the pressed signal directly — more reliable than Input.parse_input_event + # which doesn't always reach GUI Controls. + btn.emit_signal("pressed") + + # After the click, the node may have been freed due to scene transition. + # Re-check before accessing any node properties. + if not is_instance_valid(btn) or not btn.is_inside_tree(): + _write_response({ + "clicked": true, + "button_text": btn_text_value, + "button_path": btn_path, + "position": {"x": center.x, "y": center.y}, + "note": "Button was removed from scene tree after click (likely a scene transition)", + }) + return + + _write_response({ + "clicked": true, + "button_text": btn.text, + "button_path": str(btn.get_path()), + "position": {"x": center.x, "y": center.y}, + }) + + +func _find_button_by_text(node: Node, text: String, partial: bool) -> Button: + if node is Button and node.visible: + var btn: Button = node + var btn_text := btn.text.to_lower().strip_edges() + var search_text := text.to_lower().strip_edges() + if partial and btn_text.contains(search_text): + return btn + elif not partial and btn_text == search_text: + return btn + + for child in node.get_children(): + var found := _find_button_by_text(child, text, partial) + if found != null: + return found + return null + + +# ── wait_for_node ──────────────────────────────────────────────────────────── + +func _cmd_wait_for_node(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "'node_path' is required"}) + return + + var timeout_sec: float = params.get("timeout", 5.0) + var poll_interval: int = maxi(int(params.get("poll_frames", 5)), 1) + + var attempts := int(timeout_sec / (poll_interval / 60.0)) + var frame_counter := 0 + + for i in attempts: + var node := get_node_or_null(NodePath(node_path)) + if node != null: + var result := { + "found": true, + "node_path": str(node.get_path()), + "type": node.get_class(), + "name": str(node.name), + } + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + _write_response(result) + return + + # Wait poll_interval frames + for _f in poll_interval: + await get_tree().process_frame + + _write_response({ + "found": false, + "node_path": node_path, + "error": "Node not found after %.1fs" % timeout_sec, + }) + + +# ── find_nearby_nodes ───────────────────────────────────────────────────────── + +func _cmd_find_nearby_nodes(params: Dictionary) -> void: + var radius: float = float(params.get("radius", 20.0)) + var max_results: int = int(params.get("max_results", 10)) + var type_filter: String = params.get("type_filter", "") + var group_filter: String = params.get("group_filter", "") + + # Resolve origin position + var origin := Vector3.ZERO + var position_param: Variant = params.get("position", null) + if position_param is String: + # node_path — use its global_position + var origin_node := get_node_or_null(NodePath(position_param as String)) + if origin_node == null: + _write_response({"error": "Origin node not found: %s" % position_param}) + return + if origin_node is Node3D: + origin = (origin_node as Node3D).global_position + elif origin_node is Node2D: + var pos2d: Vector2 = (origin_node as Node2D).global_position + origin = Vector3(pos2d.x, pos2d.y, 0) + else: + _write_response({"error": "Origin node is not Node2D or Node3D: %s" % position_param}) + return + elif position_param is Dictionary: + var dict: Dictionary = position_param + origin = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + elif position_param == null: + _write_response({"error": "'position' is required (node_path string or {x,y,z} object)"}) + return + + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + # Collect all spatial nodes within radius + var candidates: Array = [] + _find_nearby_recursive(root, origin, radius, type_filter, group_filter, candidates) + + # Sort by distance + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a["distance"] < b["distance"] + ) + + # Limit results + if candidates.size() > max_results: + candidates.resize(max_results) + + _write_response({ + "origin": {"x": origin.x, "y": origin.y, "z": origin.z}, + "radius": radius, + "nodes": candidates, + "count": candidates.size(), + }) + + +func _find_nearby_recursive(node: Node, origin: Vector3, radius: float, type_filter: String, group_filter: String, results: Array) -> void: + var pos := Vector3.ZERO + var is_spatial := false + + if node is Node3D: + pos = (node as Node3D).global_position + is_spatial = true + elif node is Node2D: + var pos2d: Vector2 = (node as Node2D).global_position + pos = Vector3(pos2d.x, pos2d.y, 0) + is_spatial = true + + if is_spatial: + var diff := pos - origin + var dist := diff.length() + if dist <= radius: + # Apply filters + var passes := true + if not type_filter.is_empty() and not node.is_class(type_filter): + passes = false + if not group_filter.is_empty() and not node.is_in_group(group_filter): + passes = false + + if passes: + var entry: Dictionary = { + "node_path": str(node.get_path()), + "name": str(node.name), + "type": node.get_class(), + "distance": snappedf(dist, 0.01), + "global_position": {"x": snappedf(pos.x, 0.01), "y": snappedf(pos.y, 0.01), "z": snappedf(pos.z, 0.01)}, + "direction": {"x": snappedf(diff.x, 0.01), "y": snappedf(diff.y, 0.01), "z": snappedf(diff.z, 0.01)}, + } + var script: Script = node.get_script() + if script: + entry["script"] = script.resource_path + results.append(entry) + + for child in node.get_children(): + _find_nearby_recursive(child, origin, radius, type_filter, group_filter, results) + + +# ── navigate_to ────────────────────────────────────────────────────────────── + +func _cmd_navigate_to(params: Dictionary) -> void: + # Resolve player position + var player_path: String = params.get("player_path", "/root/Main/Player") + var player := get_node_or_null(NodePath(player_path)) + if player == null: + _write_response({"error": "Player not found: %s" % player_path}) + return + + var player_pos := Vector3.ZERO + if player is Node3D: + player_pos = (player as Node3D).global_position + else: + _write_response({"error": "Player is not Node3D: %s" % player_path}) + return + + # Resolve target position + var target_param: Variant = params.get("target", null) + var target_pos := Vector3.ZERO + if target_param is String: + var target_node := get_node_or_null(NodePath(target_param as String)) + if target_node == null: + _write_response({"error": "Target node not found: %s" % target_param}) + return + if target_node is Node3D: + target_pos = (target_node as Node3D).global_position + else: + _write_response({"error": "Target is not Node3D: %s" % target_param}) + return + elif target_param is Dictionary: + var dict: Dictionary = target_param + target_pos = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + else: + _write_response({"error": "'target' is required (node_path string or {x,y,z} object)"}) + return + + # Calculate world direction (XZ plane for 3D movement) + var world_dir := target_pos - player_pos + var distance := world_dir.length() + var flat_dir := Vector3(world_dir.x, 0, world_dir.z).normalized() + + # Find camera for relative direction + var camera_path: String = params.get("camera_path", "") + var camera: Camera3D = null + if not camera_path.is_empty(): + var cam_node := get_node_or_null(NodePath(camera_path)) + if cam_node is Camera3D: + camera = cam_node + else: + # Auto-detect: find Camera3D in scene + camera = get_viewport().get_camera_3d() + + var suggested_keys: Array = [] + var camera_yaw_delta: float = 0.0 + var camera_forward := Vector3.ZERO + + if camera != null: + # Camera forward (XZ plane) + camera_forward = -camera.global_basis.z + var cam_flat := Vector3(camera_forward.x, 0, camera_forward.z).normalized() + var cam_right := Vector3(camera_forward.z, 0, -camera_forward.x).normalized() + + if flat_dir.length() > 0.01: + # Project target direction onto camera axes + var forward_dot := flat_dir.dot(cam_flat) + var right_dot := flat_dir.dot(cam_right) + + # Suggest keys based on dominant direction + if forward_dot > 0.3: + suggested_keys.append("KEY_W") + elif forward_dot < -0.3: + suggested_keys.append("KEY_S") + if right_dot > 0.3: + suggested_keys.append("KEY_D") + elif right_dot < -0.3: + suggested_keys.append("KEY_A") + + # Calculate yaw rotation needed to face target directly (W only) + var angle_to_target := atan2(flat_dir.x, flat_dir.z) + var cam_yaw := atan2(cam_flat.x, cam_flat.z) + camera_yaw_delta = angle_to_target - cam_yaw + # Normalize to [-PI, PI] + while camera_yaw_delta > PI: + camera_yaw_delta -= TAU + while camera_yaw_delta < -PI: + camera_yaw_delta += TAU + + # Estimate walk duration (rough: assume ~5 units/sec movement speed) + var move_speed: float = float(params.get("move_speed", 5.0)) + var estimated_duration := distance / move_speed if move_speed > 0 else 0.0 + + # Convert yaw delta to approximate mouse relative_x pixels + # Typical: 400px mouse movement ≈ PI radians + var mouse_sensitivity_scale: float = 400.0 / PI + var suggested_mouse_x := -camera_yaw_delta * mouse_sensitivity_scale + + _write_response({ + "distance": snappedf(distance, 0.01), + "world_direction": { + "x": snappedf(world_dir.x, 0.01), + "y": snappedf(world_dir.y, 0.01), + "z": snappedf(world_dir.z, 0.01), + }, + "flat_direction": { + "x": snappedf(flat_dir.x, 0.01), + "z": snappedf(flat_dir.z, 0.01), + }, + "suggested_keys": suggested_keys, + "camera_rotation_delta": { + "yaw_radians": snappedf(camera_yaw_delta, 0.001), + "suggested_mouse_relative_x": snappedf(suggested_mouse_x, 1.0), + }, + "estimated_duration": snappedf(estimated_duration, 0.1), + "player_position": {"x": snappedf(player_pos.x, 0.01), "y": snappedf(player_pos.y, 0.01), "z": snappedf(player_pos.z, 0.01)}, + "target_position": {"x": snappedf(target_pos.x, 0.01), "y": snappedf(target_pos.y, 0.01), "z": snappedf(target_pos.z, 0.01)}, + }) + + +# ── move_to ─────────────────────────────────────────────────────────────────── + +func _cmd_move_to(params: Dictionary) -> void: + # Resolve player node + var player_path: String = params.get("player_path", "/root/Main/Player") + var player := get_node_or_null(NodePath(player_path)) + if player == null or not player is Node3D: + _write_response({"error": "Player not found or not Node3D: %s" % player_path}) + return + + _moveto_player = player as Node3D + + # Resolve target position + var target_param: Variant = params.get("target", null) + if target_param is String: + var target_node := get_node_or_null(NodePath(target_param as String)) + if target_node == null: + _write_response({"error": "Target node not found: %s" % target_param}) + return + if target_node is Node3D: + _moveto_target = (target_node as Node3D).global_position + else: + _write_response({"error": "Target is not Node3D: %s" % target_param}) + return + elif target_param is Dictionary: + var dict: Dictionary = target_param + _moveto_target = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + else: + _write_response({"error": "'target' is required (node_path string or {x,y,z} object)"}) + return + + # Resolve camera pivot + _moveto_camera_pivot = null + var camera_path: String = params.get("camera_path", "") + if not camera_path.is_empty(): + var cam_node := get_node_or_null(NodePath(camera_path)) + if cam_node is Node3D: + _moveto_camera_pivot = cam_node as Node3D + else: + # Auto-detect: look for SpringArm3D child of player + for child in _moveto_player.get_children(): + if child is SpringArm3D: + _moveto_camera_pivot = child as Node3D + break + # Fallback: active Camera3D's parent + if _moveto_camera_pivot == null: + var cam := get_viewport().get_camera_3d() + if cam != null and cam.get_parent() is Node3D and cam.get_parent() != get_tree().root: + _moveto_camera_pivot = cam.get_parent() as Node3D + + # Read params + _moveto_arrival_radius = float(params.get("arrival_radius", 1.5)) + _moveto_timeout = float(params.get("timeout", 15.0)) + _moveto_run = bool(params.get("run", false)) + _moveto_look_at = bool(params.get("look_at_target", true)) + _moveto_elapsed = 0.0 + _moveto_keys_held.clear() + + # Check if already at target + var dist := _moveto_player.global_position.distance_to(_moveto_target) + if dist <= _moveto_arrival_radius: + _write_response({ + "success": true, + "arrived": true, + "final_distance": snappedf(dist, 0.01), + "final_position": _serialize_value(_moveto_player.global_position), + "target_position": _serialize_value(_moveto_target), + "elapsed_time": 0.0, + }) + return + + # Start walking — async state, don't trigger crash recovery + _pending_command = false + _state = State.MOVING_TO + + # Inject walk key + _inject_key(KEY_W, true) + if _moveto_run: + _inject_key(KEY_SHIFT, true) + + +func _process_move_to(delta: float) -> void: + # Check for abort (new command arrived) + if FileAccess.file_exists(REQUEST_PATH): + _finish_move_to(false, "Aborted by new command") + _state = State.IDLE + _handle_request() + return + + _moveto_elapsed += delta + + # Timeout check + if _moveto_elapsed >= _moveto_timeout: + _finish_move_to(false, "Timeout after %.1fs" % _moveto_timeout) + return + + # Safety: player freed + if not is_instance_valid(_moveto_player): + _finish_move_to(false, "Player node was freed") + return + + var player_pos := _moveto_player.global_position + var flat_target := Vector3(_moveto_target.x, player_pos.y, _moveto_target.z) + var dist := player_pos.distance_to(flat_target) + + # Arrival check (XZ distance only, ignore Y) + if dist <= _moveto_arrival_radius: + _finish_move_to(true, "Arrived") + return + + # Rotate camera pivot toward target + if _moveto_look_at and _moveto_camera_pivot != null and is_instance_valid(_moveto_camera_pivot): + var dir := flat_target - player_pos + if dir.length_squared() > 0.01: + var target_yaw := atan2(-dir.x, -dir.z) + var current_yaw: float = _moveto_camera_pivot.rotation.y + # Lerp toward target yaw (~10 rad/s) + var yaw_diff := target_yaw - current_yaw + # Normalize to [-PI, PI] + while yaw_diff > PI: + yaw_diff -= TAU + while yaw_diff < -PI: + yaw_diff += TAU + var max_step := 10.0 * delta + var step := clampf(yaw_diff, -max_step, max_step) + _moveto_camera_pivot.rotation.y += step + + +func _finish_move_to(success: bool, message: String) -> void: + # Release all held keys + _release_all_keys() + _state = State.IDLE + + var final_pos := Vector3.ZERO + var final_dist := 0.0 + if is_instance_valid(_moveto_player): + final_pos = _moveto_player.global_position + final_dist = final_pos.distance_to(_moveto_target) + + _write_response({ + "success": success, + "arrived": success, + "message": message, + "final_distance": snappedf(final_dist, 0.01), + "final_position": _serialize_value(final_pos), + "target_position": _serialize_value(_moveto_target), + "elapsed_time": snappedf(_moveto_elapsed, 0.01), + }) + + +func _inject_key(keycode: int, pressed: bool) -> void: + var event := InputEventKey.new() + event.keycode = keycode + event.pressed = pressed + Input.parse_input_event(event) + if pressed: + _moveto_keys_held.append(keycode) + else: + _moveto_keys_held.erase(keycode) + + +func _release_all_keys() -> void: + for keycode: int in _moveto_keys_held.duplicate(): + var event := InputEventKey.new() + event.keycode = keycode + event.pressed = false + Input.parse_input_event(event) + _moveto_keys_held.clear() + + +# ── Recording ──────────────────────────────────────────────────────────────── + +func _cmd_start_recording(_params: Dictionary) -> void: + _recording_events.clear() + _recording_start_msec = Time.get_ticks_msec() + _state = State.RECORDING + set_process_input(true) + _write_response({"recording": true, "message": "Recording started"}) + + +func _cmd_stop_recording(_params: Dictionary) -> void: + set_process_input(false) + _state = State.IDLE + var events := _recording_events.duplicate() + var duration_ms := Time.get_ticks_msec() - _recording_start_msec + _write_response({ + "recording": false, + "events": events, + "event_count": events.size(), + "duration_ms": duration_ms, + }) + + +func _cmd_replay_recording(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + var events: Array = params.get("events", []) + if events.is_empty(): + _write_response({"error": "No events to replay"}) + return + + var speed: float = params.get("speed", 1.0) + + var start_msec := Time.get_ticks_msec() + for event_data: Dictionary in events: + var delay_ms: int = event_data.get("time_ms", 0) + var adjusted_delay := int(delay_ms / speed) + + while Time.get_ticks_msec() - start_msec < adjusted_delay: + await get_tree().process_frame + + var event := _reconstruct_event(event_data) + if event != null: + Input.parse_input_event(event) + + _write_response({ + "replayed": true, + "event_count": events.size(), + "speed": speed, + }) + + +func _input(event: InputEvent) -> void: + if _state != State.RECORDING: + return + + var time_ms := Time.get_ticks_msec() - _recording_start_msec + var data: Dictionary = {"time_ms": time_ms} + + if event is InputEventKey: + var key: InputEventKey = event + data["type"] = "key" + data["keycode"] = OS.get_keycode_string(key.keycode) if key.keycode != 0 else "" + data["physical_keycode"] = OS.get_keycode_string(key.physical_keycode) if key.physical_keycode != 0 else "" + data["pressed"] = key.pressed + data["shift"] = key.shift_pressed + data["ctrl"] = key.ctrl_pressed + data["alt"] = key.alt_pressed + elif event is InputEventMouseButton: + var mb: InputEventMouseButton = event + data["type"] = "mouse_button" + data["button"] = mb.button_index + data["pressed"] = mb.pressed + data["position"] = {"x": mb.position.x, "y": mb.position.y} + data["double_click"] = mb.double_click + elif event is InputEventMouseMotion: + var mm: InputEventMouseMotion = event + data["type"] = "mouse_motion" + data["position"] = {"x": mm.position.x, "y": mm.position.y} + data["relative"] = {"x": mm.relative.x, "y": mm.relative.y} + elif event is InputEventAction: + var act: InputEventAction = event + data["type"] = "action" + data["action"] = act.action + data["pressed"] = act.pressed + data["strength"] = act.strength + else: + return + + _recording_events.append(data) + + +func _reconstruct_event(data: Dictionary) -> InputEvent: + var type: String = data.get("type", "") + match type: + "key": + var event := InputEventKey.new() + var keycode_str: String = data.get("keycode", "") + if not keycode_str.is_empty(): + event.keycode = OS.find_keycode_from_string(keycode_str) + event.pressed = data.get("pressed", true) + event.shift_pressed = data.get("shift", false) + event.ctrl_pressed = data.get("ctrl", false) + event.alt_pressed = data.get("alt", false) + return event + "mouse_button": + var event := InputEventMouseButton.new() + event.button_index = data.get("button", MOUSE_BUTTON_LEFT) + event.pressed = data.get("pressed", true) + event.double_click = data.get("double_click", false) + var pos: Dictionary = data.get("position", {}) + event.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) + event.global_position = event.position + return event + "mouse_motion": + var event := InputEventMouseMotion.new() + var pos: Dictionary = data.get("position", {}) + event.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) + event.global_position = event.position + var rel: Dictionary = data.get("relative", {}) + event.relative = Vector2(rel.get("x", 0.0), rel.get("y", 0.0)) + return event + "action": + var event := InputEventAction.new() + event.action = data.get("action", "") + event.pressed = data.get("pressed", true) + event.strength = data.get("strength", 1.0) + return event + return null + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +func _write_response(data: Dictionary) -> void: + _pending_command = false + var json := JSON.stringify(data) + var file := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE) + if file: + file.store_string(json) + file.close() + + +# ── assert_node_state ───────────────────────────────────────────────────────── + +func _cmd_assert_node_state(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "node_path is required"}) + return + + var property: String = params.get("property", "") + if property.is_empty(): + _write_response({"error": "property is required"}) + return + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + _write_response({"error": "Node not found: %s" % node_path}) + return + + var actual: Variant + if ":" in property: + actual = node.get_indexed(NodePath(property)) + else: + actual = node.get(property) + var expected: Variant = params.get("expected", null) + var operator: String = params.get("operator", "eq") + var passed: bool = false + + match operator: + "eq": + passed = (str(actual) == str(expected)) or (actual == expected) + "neq": + passed = (actual != expected) and (str(actual) != str(expected)) + "gt": + passed = float(actual) > float(expected) + "lt": + passed = float(actual) < float(expected) + "gte": + passed = float(actual) >= float(expected) + "lte": + passed = float(actual) <= float(expected) + "contains": + passed = str(actual).contains(str(expected)) + "type_is": + passed = typeof(actual) == int(expected) or type_string(typeof(actual)) == str(expected) + _: + _write_response({"error": "Unknown operator: %s" % operator}) + return + + _write_response({ + "result": { + "assertion": "node_state", + "node_path": node_path, + "property": property, + "operator": operator, + "expected": _serialize_value(expected), + "actual": _serialize_value(actual), + "passed": passed, + } + }) + + +# ── get_performance_monitors ────────────────────────────────────────────────── + +func _cmd_get_performance_monitors(_params: Dictionary) -> void: + var monitors := {} + monitors["fps"] = Performance.get_monitor(Performance.TIME_FPS) + monitors["frame_time_msec"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0 + monitors["physics_frame_time_msec"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0 + monitors["navigation_process_msec"] = Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0 + + monitors["memory_static"] = Performance.get_monitor(Performance.MEMORY_STATIC) + monitors["memory_static_max"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX) + + monitors["object_count"] = Performance.get_monitor(Performance.OBJECT_COUNT) + monitors["object_resource_count"] = Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT) + monitors["object_node_count"] = Performance.get_monitor(Performance.OBJECT_NODE_COUNT) + monitors["object_orphan_node_count"] = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + + monitors["render_total_objects_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME) + monitors["render_total_primitives_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME) + monitors["render_total_draw_calls_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME) + monitors["render_video_mem_used"] = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) + + monitors["physics_2d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS) + monitors["physics_2d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS) + monitors["physics_2d_island_count"] = Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT) + + monitors["physics_3d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS) + monitors["physics_3d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS) + monitors["physics_3d_island_count"] = Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT) + + monitors["navigation_active_maps"] = Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS) + monitors["navigation_region_count"] = Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT) + monitors["navigation_agent_count"] = Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT) + + _write_response({"result": {"monitors": monitors, "process": "game"}}) + + +func _serialize_value(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_VECTOR2: + var v: Vector2 = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR2I: + var v: Vector2i = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR3: + var v: Vector3 = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_VECTOR3I: + var v: Vector3i = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_RECT2: + var r: Rect2 = value + return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y} + TYPE_COLOR: + var c: Color = value + return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()} + TYPE_NODE_PATH: + return str(value) + TYPE_OBJECT: + if value is Resource: + var res: Resource = value + return {"type": res.get_class(), "path": res.resource_path} + return str(value) + TYPE_ARRAY: + var arr: Array = value + var result: Array = [] + for item in arr: + result.append(_serialize_value(item)) + return result + TYPE_DICTIONARY: + var dict: Dictionary = value + var result: Dictionary = {} + for key in dict: + result[str(key)] = _serialize_value(dict[key]) + return result + _: + return value diff --git a/addons/godot_mcp/mcp_game_inspector_service.gd.uid b/addons/godot_mcp/mcp_game_inspector_service.gd.uid new file mode 100644 index 0000000..3244ab4 --- /dev/null +++ b/addons/godot_mcp/mcp_game_inspector_service.gd.uid @@ -0,0 +1 @@ +uid://b0l5v3g6gnkju diff --git a/addons/godot_mcp/mcp_input_service.gd b/addons/godot_mcp/mcp_input_service.gd new file mode 100644 index 0000000..53f4da9 --- /dev/null +++ b/addons/godot_mcp/mcp_input_service.gd @@ -0,0 +1,198 @@ +## Autoload injected by Godot MCP Pro plugin at runtime. +## Monitors for input commands from the editor and dispatches them as Input events. +extends Node + +const COMMANDS_PATH := "user://mcp_input_commands" + +var _sequence_queue: Array = [] # Array of event dicts +var _sequence_frame_delay: int = 0 +var _sequence_frames_waited: int = 0 + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + + +func _process(_delta: float) -> void: + # Process queued sequence events + if not _sequence_queue.is_empty(): + _process_sequence_tick() + + # Check for new commands from file + if FileAccess.file_exists(COMMANDS_PATH): + _process_commands() + + +func _process_commands() -> void: + var file := FileAccess.open(COMMANDS_PATH, FileAccess.READ) + if file == null: + return + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(COMMANDS_PATH) + + var parsed = JSON.parse_string(text) + if parsed == null: + push_warning("[MCP Input] Failed to parse input commands JSON") + return + + # Check if this is a sequence command (dict with "events" and "frame_delay") + if parsed is Dictionary and parsed.has("sequence_events"): + _start_sequence(parsed) + return + + # Otherwise treat as immediate event(s) + var events: Array = parsed if parsed is Array else [parsed] + for event_data: Dictionary in events: + var event := _create_event(event_data) + if event != null: + _dispatch_event(event, event_data) + + +func _start_sequence(data: Dictionary) -> void: + _sequence_queue = data.get("sequence_events", []).duplicate() + _sequence_frame_delay = data.get("frame_delay", 1) + _sequence_frames_waited = 0 + # Dispatch first event immediately + if not _sequence_queue.is_empty(): + _dispatch_next_sequence_event() + + +func _process_sequence_tick() -> void: + _sequence_frames_waited += 1 + if _sequence_frames_waited >= _sequence_frame_delay: + _sequence_frames_waited = 0 + _dispatch_next_sequence_event() + + +func _dispatch_next_sequence_event() -> void: + if _sequence_queue.is_empty(): + return + var event_data: Dictionary = _sequence_queue.pop_front() + var event := _create_event(event_data) + if event != null: + _dispatch_event(event, event_data) + + +## Dispatch an input event using the appropriate method. +## Mouse drag motions (button_mask > 0) auto-promote to push_input to bypass +## GUI consumption and reach _unhandled_input — needed for camera-pan use +## cases where UI Controls would otherwise swallow drag events. But for UI +## drag-and-drop *testing* we want events to reach the GUI dispatcher so +## hit-testing and _get_drag_data / _drop_data fire. So: respect an explicit +## "unhandled": false in the event payload — only auto-promote when the +## caller did NOT pass an "unhandled" key. Default behavior preserved. +func _dispatch_event(event: InputEvent, event_data: Dictionary = {}) -> void: + var force_unhandled: bool + if event_data.has("unhandled"): + force_unhandled = bool(event_data.get("unhandled")) + else: + force_unhandled = event is InputEventMouseMotion and event.button_mask != 0 + if force_unhandled: + var vp := get_viewport() + if vp: + vp.push_input(event, true) + else: + Input.parse_input_event(event) + else: + Input.parse_input_event(event) + + +func _create_event(data: Dictionary) -> InputEvent: + var type: String = data.get("type", "") + match type: + "key": + return _create_key_event(data) + "mouse_button": + return _create_mouse_button_event(data) + "mouse_motion": + return _create_mouse_motion_event(data) + "action": + return _create_action_event(data) + _: + push_warning("[MCP Input] Unknown event type: %s" % type) + return null + + +## Convert viewport coordinates to window coordinates for Input.parse_input_event(). +## Godot applies viewport.get_final_transform() to mouse events internally, +## so we must pass window-space coordinates (pre-transform). +func _viewport_to_window(viewport_pos: Vector2) -> Vector2: + var vp := get_viewport() + if vp == null: + return viewport_pos + var xform := vp.get_final_transform() + return xform * viewport_pos + + +func _create_key_event(data: Dictionary) -> InputEventKey: + var event := InputEventKey.new() + var keycode_str: String = data.get("keycode", "") + if keycode_str.begins_with("KEY_"): + var constant_value = ClassDB.class_get_integer_constant("@GlobalScope", keycode_str) + if constant_value != 0: + event.keycode = constant_value + else: + event.keycode = OS.find_keycode_from_string(keycode_str.substr(4)) + else: + event.keycode = OS.find_keycode_from_string(keycode_str) + event.pressed = data.get("pressed", true) + event.shift_pressed = data.get("shift", false) + event.ctrl_pressed = data.get("ctrl", false) + event.alt_pressed = data.get("alt", false) + return event + + +func _extract_position(data: Dictionary) -> Vector2: + # Support nested {"position": {"x": ..., "y": ...}} or flat {"x": ..., "y": ...} + var pos = data.get("position", null) + if pos is Dictionary: + return Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) + return Vector2(data.get("x", 0.0), data.get("y", 0.0)) + + +func _create_mouse_button_event(data: Dictionary) -> InputEventMouseButton: + var event := InputEventMouseButton.new() + event.button_index = data.get("button", MOUSE_BUTTON_LEFT) + event.pressed = data.get("pressed", true) + event.double_click = data.get("double_click", false) + var window_pos := _viewport_to_window(_extract_position(data)) + event.position = window_pos + event.global_position = window_pos + return event + + +func _create_mouse_motion_event(data: Dictionary) -> InputEventMouseMotion: + var event := InputEventMouseMotion.new() + var window_pos := _viewport_to_window(_extract_position(data)) + event.position = window_pos + event.global_position = window_pos + # Support nested {"relative": {"x": ..., "y": ...}} or flat {"relative_x": ..., "relative_y": ...} + var rel_x: float = 0.0 + var rel_y: float = 0.0 + var rel = data.get("relative", null) + if rel is Dictionary: + rel_x = float(rel.get("x", 0.0)) + rel_y = float(rel.get("y", 0.0)) + else: + rel_x = float(data.get("relative_x", 0.0)) + rel_y = float(data.get("relative_y", 0.0)) + # Scale relative movement by the same transform (scale only, no offset) + var vp := get_viewport() + if vp: + var scale := vp.get_final_transform().get_scale() + event.relative = Vector2(rel_x, rel_y) * scale + else: + event.relative = Vector2(rel_x, rel_y) + # Set button_mask so drag detection works (e.g. camera pan checks button_mask) + var button_mask: int = int(data.get("button_mask", 0)) + event.button_mask = button_mask + return event + + +func _create_action_event(data: Dictionary) -> InputEventAction: + var event := InputEventAction.new() + event.action = data.get("action", "") + event.pressed = data.get("pressed", true) + event.strength = data.get("strength", 1.0) + return event diff --git a/addons/godot_mcp/mcp_input_service.gd.uid b/addons/godot_mcp/mcp_input_service.gd.uid new file mode 100644 index 0000000..a2c3a7b --- /dev/null +++ b/addons/godot_mcp/mcp_input_service.gd.uid @@ -0,0 +1 @@ +uid://yc5bqv2o84nn diff --git a/addons/godot_mcp/mcp_screenshot_service.gd b/addons/godot_mcp/mcp_screenshot_service.gd new file mode 100644 index 0000000..2983219 --- /dev/null +++ b/addons/godot_mcp/mcp_screenshot_service.gd @@ -0,0 +1,34 @@ +## Autoload injected by Godot MCP Pro plugin at runtime. +## Monitors for screenshot requests from the editor and captures the game viewport. +extends Node + +const REQUEST_PATH := "user://mcp_screenshot_request" +const SCREENSHOT_PATH := "user://mcp_screenshot.png" + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + + +func _process(_delta: float) -> void: + if FileAccess.file_exists(REQUEST_PATH): + _take_screenshot() + + +func _take_screenshot() -> void: + # Delete request file immediately to avoid re-triggering + DirAccess.remove_absolute(REQUEST_PATH) + + # Wait one frame so the viewport has a fully rendered image + # process_always=true (default) so the timer ticks even when tree is paused + await get_tree().create_timer(0.05).timeout + + var viewport := get_viewport() + if viewport == null: + return + + var image := viewport.get_texture().get_image() + if image == null: + return + + image.save_png(SCREENSHOT_PATH) diff --git a/addons/godot_mcp/mcp_screenshot_service.gd.uid b/addons/godot_mcp/mcp_screenshot_service.gd.uid new file mode 100644 index 0000000..f60832e --- /dev/null +++ b/addons/godot_mcp/mcp_screenshot_service.gd.uid @@ -0,0 +1 @@ +uid://d123ueb43a5sc diff --git a/addons/godot_mcp/plugin.cfg b/addons/godot_mcp/plugin.cfg new file mode 100644 index 0000000..e5466d3 --- /dev/null +++ b/addons/godot_mcp/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot MCP Pro" +description="Premium MCP server for AI-powered Godot development. Connects via WebSocket to expose 175 editor tools." +author="godot-mcp-pro" +version="1.15.1" +script="plugin.gd" diff --git a/addons/godot_mcp/plugin.gd b/addons/godot_mcp/plugin.gd new file mode 100644 index 0000000..88959f3 --- /dev/null +++ b/addons/godot_mcp/plugin.gd @@ -0,0 +1,196 @@ +@tool +extends EditorPlugin + +const _MCP_AUTOLOADS: Array[Array] = [ + ["autoload/MCPScreenshot", "res://addons/godot_mcp/mcp_screenshot_service.gd"], + ["autoload/MCPInputService", "res://addons/godot_mcp/mcp_input_service.gd"], + ["autoload/MCPGameInspector", "res://addons/godot_mcp/mcp_game_inspector_service.gd"], +] + +const _MCP_TEMP_FILES: Array[String] = [ + "mcp_game_request", + "mcp_game_response", + "mcp_input_commands", + "mcp_screenshot_request", +] + +var websocket_server: Node +var command_router: Node +var status_panel: Control +var auto_dismiss_dialogs: bool = false +# Track which autoloads THIS session injected (vs project-owned) +var _session_injected_autoloads: Array[String] = [] + +func _enter_tree() -> void: + # Create command router + command_router = preload("res://addons/godot_mcp/command_router.gd").new() + command_router.name = "MCPCommandRouter" + command_router.editor_plugin = self + add_child(command_router) + + # Create WebSocket server + websocket_server = preload("res://addons/godot_mcp/websocket_server.gd").new() + websocket_server.name = "MCPWebSocketServer" + websocket_server.command_router = command_router + add_child(websocket_server) + + # Create status panel + var panel_scene: PackedScene = preload("res://addons/godot_mcp/ui/status_panel.tscn") + status_panel = panel_scene.instantiate() + add_control_to_bottom_panel(status_panel, "MCP Pro") + status_panel.call_deferred("setup", websocket_server, command_router) + + # Inject MCP autoloads into project settings + _inject_autoloads() + + websocket_server.start_server() + var cfg := ConfigFile.new() + var ver := "unknown" + if cfg.load("res://addons/godot_mcp/plugin.cfg") == OK: + ver = cfg.get_value("plugin", "version", "unknown") + print("[MCP] Godot MCP Pro v%s started (ports 6505-6514)" % ver) + + +func _exit_tree() -> void: + # Remove MCP autoloads and clean up temp files + _remove_autoloads() + _cleanup_temp_files() + + if websocket_server: + websocket_server.stop_server() + + if status_panel: + remove_control_from_bottom_panel(status_panel) + status_panel.queue_free() + + if command_router: + command_router.queue_free() + + if websocket_server: + websocket_server.queue_free() + + print("[MCP] Godot MCP Pro stopped") + + +func _inject_autoloads() -> void: + _session_injected_autoloads.clear() + var changed := false + for entry: Array in _MCP_AUTOLOADS: + var key: String = entry[0] + var script: String = entry[1] + if not ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, "*" + script) + _session_injected_autoloads.append(key) + changed = true + if changed: + ProjectSettings.save() + + +func _remove_autoloads() -> void: + # Only remove autoloads that THIS session injected. + # Pre-existing project-owned autoloads are preserved. + var changed := false + for key: String in _session_injected_autoloads: + if ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, null) + changed = true + _session_injected_autoloads.clear() + if changed: + ProjectSettings.save() + + +var _dialog_check_timer: float = 0.0 +const _DIALOG_CHECK_INTERVAL: float = 0.5 # Check every 0.5 seconds + +func _process(delta: float) -> void: + # Check if game inspector requested debugger continue + var flag_path := OS.get_user_data_dir() + "/mcp_debugger_continue" + if FileAccess.file_exists(flag_path): + DirAccess.remove_absolute(flag_path) + _try_debugger_continue() + + # Periodically check for blocking editor dialogs (only when enabled by AI) + if auto_dismiss_dialogs: + _dialog_check_timer += delta + if _dialog_check_timer >= _DIALOG_CHECK_INTERVAL: + _dialog_check_timer = 0.0 + _auto_dismiss_dialogs() + + +func _try_debugger_continue() -> void: + # Last resort: find and press the debugger Continue button to unstick the game + var base: Node = EditorInterface.get_base_control() + var continue_btn := _find_debugger_continue_button(base) + if continue_btn and continue_btn.visible and not continue_btn.disabled: + continue_btn.emit_signal("pressed") + push_warning("[MCP] Auto-pressed debugger Continue button") + else: + push_warning("[MCP] Could not find debugger Continue button") + + +func _find_debugger_continue_button(node: Node) -> Button: + # Search for the Continue button in ScriptEditorDebugger. + # The editor UI is translated, so matching tooltip/label text fails for + # non-English editors (issue #34: Italian → "Continua"). Match by the editor + # theme icon "DebugContinue" first, falling back to English text. + var continue_icon: Texture2D = null + var base: Control = EditorInterface.get_base_control() + if base != null and base.has_theme_icon("DebugContinue", "EditorIcons"): + continue_icon = base.get_theme_icon("DebugContinue", "EditorIcons") + return _find_continue_button_recursive(node, continue_icon) + + +func _find_continue_button_recursive(node: Node, continue_icon: Texture2D) -> Button: + if node is Button: + var btn: Button = node + if continue_icon != null and btn.icon == continue_icon: + return btn + if btn.tooltip_text.contains("Continue") or btn.text == "Continue": + return btn + for child in node.get_children(): + var found: Button = _find_continue_button_recursive(child, continue_icon) + if found: + return found + return null + + +func _auto_dismiss_dialogs() -> void: + var base: Node = EditorInterface.get_base_control() + if not base: + return + _find_and_dismiss_dialogs(base) + + +func _find_and_dismiss_dialogs(node: Node) -> void: + if node is AcceptDialog and node.visible: + var dialog: AcceptDialog = node + # Never dismiss file dialogs or non-modal popups + if dialog is FileDialog: + return + if not dialog.exclusive: + return + # Get dialog title/text for logging + var title := dialog.title + var text := dialog.dialog_text + # Accept the dialog (presses OK / confirms) + dialog.get_ok_button().emit_signal("pressed") + push_warning("[MCP] Auto-dismissed editor dialog: '%s' — %s" % [title, text]) + return # One dialog per check cycle to avoid side effects + + for child in node.get_children(): + # Only search visible Windows to keep the scan lightweight + if child is Window and not child.visible: + continue + _find_and_dismiss_dialogs(child) + + +func _cleanup_temp_files() -> void: + var user_dir := OS.get_user_data_dir() + for filename: String in _MCP_TEMP_FILES: + var path := user_dir + "/" + filename + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + # Also clean up screenshot image + var screenshot_path := user_dir + "/mcp_screenshot.png" + if FileAccess.file_exists(screenshot_path): + DirAccess.remove_absolute(screenshot_path) diff --git a/addons/godot_mcp/plugin.gd.uid b/addons/godot_mcp/plugin.gd.uid new file mode 100644 index 0000000..af81609 --- /dev/null +++ b/addons/godot_mcp/plugin.gd.uid @@ -0,0 +1 @@ +uid://chtol501pvkq3 diff --git a/addons/godot_mcp/skills.es.md b/addons/godot_mcp/skills.es.md new file mode 100644 index 0000000..3f9f8a6 --- /dev/null +++ b/addons/godot_mcp/skills.es.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | Español | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Skills para Asistentes de IA + +> Copia este archivo a `.claude/skills.md` en la raíz de tu proyecto Godot para darle a Claude Code el contexto completo sobre cómo usar Godot MCP Pro de forma efectiva. + +## ¿Qué es Godot MCP Pro? + +Tienes acceso a 169 herramientas MCP que se conectan directamente al editor de Godot 4. Puedes crear escenas, escribir scripts, simular entrada del jugador, inspeccionar juegos en ejecución y más — todo sin que el usuario salga de esta conversación. Cada cambio pasa por el sistema UndoRedo de Godot, así que el usuario siempre puede hacer Ctrl+Z. + +## Flujos de Trabajo Esenciales + +### 1. Explorar un Proyecto + +Siempre empieza entendiendo el proyecto antes de hacer cambios: + +``` +get_project_info → nombre del proyecto, versión de Godot, renderizador, tamaño del viewport +get_filesystem_tree → estructura de directorios (usa filter: "*.tscn" o "*.gd") +get_scene_tree → jerarquía de nodos de la escena abierta actualmente +read_script → leer cualquier archivo GDScript +get_project_settings → revisar la configuración del proyecto +``` + +### 2. Construir una Escena 2D + +``` +create_scene → crear archivo .tscn con tipo de nodo raíz +add_node → agregar nodos hijos con propiedades +create_script → escribir GDScript para lógica del juego +attach_script → adjuntar script a un nodo +update_property → establecer position, scale, modulate, etc. +save_scene → guardar en disco +``` + +**Ejemplo — creando un jugador:** +1. `create_scene` con root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` tipo `Sprite2D` con propiedad texture +3. `add_node` tipo `CollisionShape2D` +4. `add_resource` para asignar una shape (ej: `RectangleShape2D`) al CollisionShape2D +5. `create_script` con lógica de movimiento +6. `attach_script` al nodo raíz +7. `save_scene` + +### 3. Construir una Escena 3D + +``` +create_scene → root_type: Node3D +add_mesh_instance → agregar primitivas (box, sphere, cylinder, plane) o importar .glb/.gltf +setup_lighting → agregar DirectionalLight3D, OmniLight3D o SpotLight3D +setup_environment → cielo, luz ambiental, niebla, tonemap +setup_camera_3d → cámara con SpringArm3D opcional para tercera persona +set_material_3d → materiales PBR (albedo, metallic, roughness, emission) +setup_collision → agregar shapes de colisión a cuerpos físicos +setup_physics_body → configurar masa, fricción, gravedad +``` + +### 4. Escribir y Editar Scripts + +``` +create_script → crear nuevo archivo .gd (proporciona el contenido completo) +edit_script → modificar scripts existentes + - Usa `replacements: [{search: "old code", replace: "new code"}]` para ediciones específicas + - Usa `content` para reemplazo completo del archivo + - Usa `insert_at_line` + `text` para insertar código +validate_script → verificar errores de sintaxis sin ejecutar +read_script → leer contenido actual antes de editar +``` + +### 5. Probar y Depurar + +``` +play_scene → lanzar el juego (mode: "current", "main" o ruta de archivo) +get_game_screenshot → ver cómo luce el juego en este momento +capture_frames → capturar múltiples frames para observar movimiento/animación +get_game_scene_tree → inspeccionar el árbol de escena en tiempo de ejecución +get_game_node_properties → leer valores en runtime (position, health, state, etc.) +set_game_node_property → modificar valores en el juego en ejecución +simulate_key → presionar teclas (WASD, SPACE, etc.) con duración +simulate_mouse_click → hacer clic en coordenadas del viewport +simulate_action → disparar acciones del InputMap (move_left, jump, etc.) +get_editor_errors → revisar errores de ejecución +stop_scene → detener el juego +``` + +**Ciclo de playtesting:** +1. `play_scene` → iniciar el juego +2. `get_game_screenshot` → ver estado actual +3. `simulate_key` / `simulate_action` → interactuar con el juego +4. `capture_frames` → observar comportamiento a lo largo del tiempo +5. `get_game_node_properties` → verificar valores específicos +6. `stop_scene` → detener cuando termines +7. Corregir problemas en scripts → repetir + +### 6. Animaciones + +``` +# Asegúrate de que exista un nodo AnimationPlayer en la escena +create_animation → nueva animación con duración y modo de loop +add_animation_track → agregar tracks de property/transform/method +set_animation_keyframe → insertar keyframes en tiempos específicos +get_animation_info → inspeccionar animaciones existentes +``` + +**Ejemplo — sprite rebotando:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop lineal) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect, etc. +set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.) +set_theme_color → cambiar font_color, etc. +set_theme_font_size → ajustar tamaño de texto +set_theme_stylebox → fondos, bordes, esquinas redondeadas +connect_signal → conectar pressed del button, value_changed, etc. +``` + +### 8. TileMap + +``` +tilemap_get_info → revisar fuentes del tile set y disposición del atlas +tilemap_set_cell → colocar tiles individuales +tilemap_fill_rect → rellenar regiones rectangulares +tilemap_get_used_cells → ver qué ya está colocado +tilemap_clear → limpiar todas las celdas +``` + +### 9. Audio + +``` +add_audio_bus → crear buses de audio (SFX, Music, UI) +set_audio_bus → ajustar volumen, solo, mute +add_audio_bus_effect → agregar reverb, delay, compressor, etc. +add_audio_player → agregar nodos AudioStreamPlayer(2D/3D) +``` + +### 10. Configuración del Proyecto + +``` +set_project_setting → cambiar tamaño del viewport, configuraciones de física, etc. +set_input_action → definir mapeos de entrada (move_left → KEY_A, etc.) +add_autoload → registrar singletons autoload +set_physics_layers → nombrar capas de colisión (player, enemy, world, etc.) +``` + +## Reglas Importantes y Trampas + +### Valores de Propiedades +Las propiedades se parsean automáticamente desde strings. Usa estos formatos: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` o `"#ff0000"` +- Bool: `"true"` / `"false"` +- Números: `"42"`, `"3.14"` +- Enums: Usa valores enteros (ej: `0` para el primer valor del enum) + +### Nunca Edites project.godot Directamente +El editor de Godot sobrescribe `project.godot` constantemente. Siempre usa `set_project_setting` para cambiar configuraciones del proyecto. + +### Anotaciones de Tipo en GDScript +Al escribir GDScript con loops `for` sobre arrays sin tipo, usa anotaciones de tipo explícitas: +```gdscript +# MAL — causará errores +for item in some_untyped_array: + var x := item.value # la inferencia de tipos falla + +# BIEN +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Los Cambios en Scripts Necesitan Reload +Después de crear o modificar scripts significativamente, usa `reload_project` para asegurar que Godot reconozca los cambios. Esto es especialmente importante después de `create_script`. + +### Consejos para simulate_key +- Usa **duraciones cortas** (0.3–0.5 segundos) para movimiento preciso +- Duraciones largas (1+ segundo) causan overshooting +- Para pruebas de gameplay, prefiere `simulate_action` sobre `simulate_key` cuando haya acciones del InputMap definidas + +### simulate_mouse_click +- El valor por defecto `auto_release: true` envía press y release — requerido para botones de UI +- Los botones de UI se activan en release, por lo que ambos eventos son necesarios + +### Limitaciones de execute_game_script +- Sin funciones anidadas (`func` dentro de `func`) — causa error de compilación +- Usa `.get("property")` en lugar de `.property` para acceso dinámico +- Los errores de runtime pausan el debugger (se continúa automáticamente, pero evítalo si es posible) + +### Colisión y Áreas de Recolección +- Para ítems recolectables, usa Area3D/Area2D con radio >= 1.5 +- Radios más pequeños son casi imposibles de activar con entrada simulada + +### Guarda Frecuentemente +Llama a `save_scene` después de hacer cambios significativos. Los cambios no guardados pueden perderse si el editor se recarga. + +## Herramientas de Análisis y Depuración + +Cuando algo sale mal, usa estas herramientas para investigar: + +``` +get_editor_errors → revisar errores de script y excepciones de runtime +get_output_log → leer salida de print() y advertencias +analyze_scene_complexity → encontrar cuellos de botella de rendimiento +analyze_signal_flow → visualizar conexiones de signals +detect_circular_dependencies → encontrar referencias circulares de script/escena +find_unused_resources → limpiar archivos no utilizados +get_performance_monitors → FPS, memoria, draw calls, estadísticas de física +``` + +## Pruebas y QA + +``` +run_test_scenario → definir y ejecutar secuencias de prueba automatizadas +assert_node_state → verificar que las propiedades de nodos coincidan con valores esperados +assert_screen_text → verificar que el texto se muestre en pantalla +compare_screenshots → pruebas de regresión visual (usa rutas de archivo, no base64) +run_stress_test → generar muchos nodos para probar rendimiento +``` + +## Patrones Avanzados + +### Operaciones entre Escenas +``` +cross_scene_set_property → modificar nodos en escenas que no están abiertas actualmente +find_node_references → encontrar todos los archivos que referencian un patrón +batch_set_property → establecer una propiedad en todos los nodos de un tipo +``` + +### Flujo de Trabajo con Shaders +``` +create_shader → escribir código shader estilo GLSL +assign_shader_material → aplicar a un nodo +set_shader_param → ajustar uniforms en runtime +get_shader_params → inspeccionar valores actuales +``` + +### Navegación (3D) +``` +setup_navigation_region → definir área transitable +bake_navigation_mesh → generar navmesh +setup_navigation_agent → agregar pathfinding a personajes +``` + +### AnimationTree y Máquinas de Estado +``` +create_animation_tree → configurar AnimationTree con máquina de estado o blend tree +add_state_machine_state → agregar estados (idle, walk, run, jump) +add_state_machine_transition → definir transiciones entre estados +set_tree_parameter → controlar parámetros de blend +``` + +## Orden de Flujo de Trabajo Recomendado + +Al construir un juego nuevo desde cero: + +1. **Configuración del proyecto** — `get_project_info`, `set_project_setting` (viewport, física) +2. **Mapeo de entrada** — `set_input_action` para todos los controles del jugador +3. **Escena principal** — `create_scene`, establecer como escena principal +4. **Jugador** — crear escena del jugador con sprite, colisión, script +5. **Nivel/Mundo** — construir el entorno (TileMap, meshes 3D, etc.) +6. **Lógica del juego** — scripts para enemigos, ítems, UI +7. **Audio** — configurar buses, agregar audio players +8. **Playtesting** — `play_scene`, probar con entrada simulada, corregir bugs +9. **Pulido** — animaciones, partículas, shaders, temas +10. **Exportación** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.hi.md b/addons/godot_mcp/skills.hi.md new file mode 100644 index 0000000..1af0540 --- /dev/null +++ b/addons/godot_mcp/skills.hi.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | हिन्दी + +# Godot MCP Pro — AI Assistants के लिए Skills + +> इस फ़ाइल को अपने Godot प्रोजेक्ट रूट में `.claude/skills.md` पर कॉपी करें ताकि Claude Code को Godot MCP Pro को प्रभावी ढंग से उपयोग करने का पूरा context मिल सके। + +## Godot MCP Pro क्या है? + +आपके पास 169 MCP tools उपलब्ध हैं जो सीधे Godot 4 editor से कनेक्ट होते हैं। आप scenes बना सकते हैं, scripts लिख सकते हैं, player input simulate कर सकते हैं, running games को inspect कर सकते हैं, और बहुत कुछ — सब कुछ बिना user को इस conversation से बाहर जाए। हर बदलाव Godot के UndoRedo system से होता है, इसलिए user हमेशा Ctrl+Z कर सकता है। + +## ज़रूरी Workflows + +### 1. प्रोजेक्ट को Explore करें + +बदलाव करने से पहले हमेशा प्रोजेक्ट को समझें: + +``` +get_project_info → प्रोजेक्ट का नाम, Godot version, renderer, viewport size +get_filesystem_tree → directory structure (filter: "*.tscn" या "*.gd" use करें) +get_scene_tree → currently open scene की node hierarchy +read_script → कोई भी GDScript फ़ाइल पढ़ें +get_project_settings → project configuration चेक करें +``` + +### 2. 2D Scene बनाएं + +``` +create_scene → root node type के साथ .tscn फ़ाइल बनाएं +add_node → properties के साथ child nodes जोड़ें +create_script → game logic के लिए GDScript लिखें +attach_script → node पर script attach करें +update_property → position, scale, modulate आदि सेट करें +save_scene → disk पर save करें +``` + +**उदाहरण — player बनाना:** +1. `create_scene` root_type `CharacterBody2D` के साथ, path `res://scenes/player.tscn` +2. `add_node` type `Sprite2D` texture property के साथ +3. `add_node` type `CollisionShape2D` +4. `add_resource` CollisionShape2D को shape assign करने के लिए (जैसे `RectangleShape2D`) +5. `create_script` movement logic के साथ +6. `attach_script` root node पर +7. `save_scene` + +### 3. 3D Scene बनाएं + +``` +create_scene → root_type: Node3D +add_mesh_instance → primitives (box, sphere, cylinder, plane) जोड़ें या .glb/.gltf import करें +setup_lighting → DirectionalLight3D, OmniLight3D, या SpotLight3D जोड़ें +setup_environment → sky, ambient light, fog, tonemap +setup_camera_3d → camera, optional SpringArm3D के साथ third-person के लिए +set_material_3d → PBR materials (albedo, metallic, roughness, emission) +setup_collision → physics bodies में collision shapes जोड़ें +setup_physics_body → mass, friction, gravity configure करें +``` + +### 4. Scripts लिखें और Edit करें + +``` +create_script → नई .gd फ़ाइल बनाएं (पूरा content दें) +edit_script → existing scripts modify करें + - `replacements: [{search: "old code", replace: "new code"}]` targeted edits के लिए + - `content` पूरी फ़ाइल replace करने के लिए + - `insert_at_line` + `text` code insert करने के लिए +validate_script → बिना run किए syntax errors चेक करें +read_script → edit करने से पहले current content पढ़ें +``` + +### 5. Playtest और Debug करें + +``` +play_scene → game launch करें (mode: "current", "main", या file path) +get_game_screenshot → अभी game कैसा दिख रहा है देखें +capture_frames → motion/animation observe करने के लिए multiple frames capture करें +get_game_scene_tree → runtime पर live scene tree inspect करें +get_game_node_properties → runtime values पढ़ें (position, health, state आदि) +set_game_node_property → running game में values modify करें +simulate_key → keys press करें (WASD, SPACE आदि) duration के साथ +simulate_mouse_click → viewport coordinates पर click करें +simulate_action → InputMap actions trigger करें (move_left, jump आदि) +get_editor_errors → runtime errors चेक करें +stop_scene → game बंद करें +``` + +**Playtesting loop:** +1. `play_scene` → game शुरू करें +2. `get_game_screenshot` → current state देखें +3. `simulate_key` / `simulate_action` → game के साथ interact करें +4. `capture_frames` → समय के साथ behavior observe करें +5. `get_game_node_properties` → specific values चेक करें +6. `stop_scene` → काम हो जाए तो बंद करें +7. Scripts में issues fix करें → दोहराएं + +### 6. Animations + +``` +# Scene में AnimationPlayer node होना ज़रूरी है +create_animation → length और loop mode के साथ नई animation +add_animation_track → property/transform/method tracks जोड़ें +set_animation_keyframe → specific times पर keyframes insert करें +get_animation_info → existing animations inspect करें +``` + +**उदाहरण — bouncing sprite:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect आदि +set_anchor_preset → Controls position करें (full_rect, center, bottom_wide आदि) +set_theme_color → font_color आदि बदलें +set_theme_font_size → text size adjust करें +set_theme_stylebox → backgrounds, borders, rounded corners +connect_signal → button pressed, value_changed आदि wire up करें +``` + +### 8. TileMap + +``` +tilemap_get_info → tile set sources और atlas layout चेक करें +tilemap_set_cell → individual tiles place करें +tilemap_fill_rect → rectangular regions fill करें +tilemap_get_used_cells → देखें क्या पहले से placed है +tilemap_clear → सभी cells clear करें +``` + +### 9. Audio + +``` +add_audio_bus → audio buses बनाएं (SFX, Music, UI) +set_audio_bus → volume, solo, mute adjust करें +add_audio_bus_effect → reverb, delay, compressor आदि जोड़ें +add_audio_player → AudioStreamPlayer(2D/3D) nodes जोड़ें +``` + +### 10. Project Configuration + +``` +set_project_setting → viewport size, physics settings आदि बदलें +set_input_action → input mappings define करें (move_left → KEY_A आदि) +add_autoload → autoload singletons register करें +set_physics_layers → collision layers name करें (player, enemy, world आदि) +``` + +## ज़रूरी Rules और Pitfalls + +### Property Values +Properties strings से auto-parse होती हैं। ये formats use करें: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` या `"#ff0000"` +- Bool: `"true"` / `"false"` +- Numbers: `"42"`, `"3.14"` +- Enums: Integer values use करें (जैसे पहले enum value के लिए `0`) + +### project.godot को कभी सीधे Edit न करें +Godot editor लगातार `project.godot` को overwrite करता है। Project settings बदलने के लिए हमेशा `set_project_setting` use करें। + +### GDScript Type Annotations +Untyped arrays पर `for` loops लिखते समय, explicit type annotations use करें: +```gdscript +# गलत — errors आएंगे +for item in some_untyped_array: + var x := item.value # type inference fail होता है + +# सही +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Script Changes के लिए Reload ज़रूरी +Scripts create या significantly modify करने के बाद, `reload_project` use करें ताकि Godot changes को pick up करे। `create_script` के बाद ये खासकर ज़रूरी है। + +### simulate_key Tips +- Precise movement के लिए **छोटी duration** (0.3–0.5 seconds) use करें +- लंबी duration (1+ second) से overshooting होती है +- Gameplay testing के लिए, जब InputMap actions defined हों तो `simulate_key` की जगह `simulate_action` prefer करें + +### simulate_mouse_click +- Default `auto_release: true` press और release दोनों भेजता है — UI buttons के लिए ज़रूरी है +- UI buttons release पर fire होते हैं, इसलिए दोनों events चाहिए + +### execute_game_script की Limitations +- Nested functions (`func` के अंदर `func`) नहीं चलतीं — compile error आता है +- Dynamic access के लिए `.property` की जगह `.get("property")` use करें +- Runtime errors debugger को pause करती हैं (auto-continue होता है, लेकिन बचना बेहतर) + +### Collision और Pickup Areas +- Collectible items के लिए Area3D/Area2D radius >= 1.5 रखें +- छोटे radius को simulated input से trigger करना लगभग impossible है + +### बार-बार Save करें +बड़े बदलावों के बाद `save_scene` call करें। Unsaved changes editor reload होने पर खो सकते हैं। + +## Analysis और Debugging Tools + +कुछ गलत होने पर, इन tools से investigate करें: + +``` +get_editor_errors → script errors और runtime exceptions चेक करें +get_output_log → print() output और warnings पढ़ें +analyze_scene_complexity → performance bottlenecks खोजें +analyze_signal_flow → signal connections visualize करें +detect_circular_dependencies → circular script/scene references खोजें +find_unused_resources → unused files clean up करें +get_performance_monitors → FPS, memory, draw calls, physics stats +``` + +## Testing और QA + +``` +run_test_scenario → automated test sequences define और run करें +assert_node_state → verify करें कि node properties expected values से match करती हैं +assert_screen_text → verify करें कि text screen पर display हो रहा है +compare_screenshots → visual regression testing (file paths use करें, base64 नहीं) +run_stress_test → performance test के लिए बहुत सारे nodes spawn करें +``` + +## Advanced Patterns + +### Cross-Scene Operations +``` +cross_scene_set_property → उन scenes के nodes modify करें जो अभी open नहीं हैं +find_node_references → किसी pattern को reference करने वाली सभी files खोजें +batch_set_property → किसी type के सभी nodes पर property set करें +``` + +### Shader Workflow +``` +create_shader → GLSL-like shader code लिखें +assign_shader_material → node पर apply करें +set_shader_param → runtime पर uniforms adjust करें +get_shader_params → current values inspect करें +``` + +### Navigation (3D) +``` +setup_navigation_region → walkable area define करें +bake_navigation_mesh → navmesh generate करें +setup_navigation_agent → characters में pathfinding जोड़ें +``` + +### AnimationTree और State Machines +``` +create_animation_tree → state machine या blend tree के साथ AnimationTree set up करें +add_state_machine_state → states जोड़ें (idle, walk, run, jump) +add_state_machine_transition → states के बीच transitions define करें +set_tree_parameter → blend parameters control करें +``` + +## Recommended Workflow Order + +नया game scratch से बनाते समय: + +1. **Project setup** — `get_project_info`, `set_project_setting` (viewport, physics) +2. **Input mapping** — `set_input_action` सभी player controls के लिए +3. **Main scene** — `create_scene`, main scene के रूप में set करें +4. **Player** — sprite, collision, script के साथ player scene बनाएं +5. **Level/World** — environment build करें (TileMap, 3D meshes आदि) +6. **Game logic** — enemies, items, UI के लिए scripts +7. **Audio** — buses set up करें, audio players जोड़ें +8. **Playtest** — `play_scene`, simulated input से test करें, bugs fix करें +9. **Polish** — animations, particles, shaders, themes +10. **Export** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.ja.md b/addons/godot_mcp/skills.ja.md new file mode 100644 index 0000000..b3ac9b4 --- /dev/null +++ b/addons/godot_mcp/skills.ja.md @@ -0,0 +1,276 @@ +> **Language:** [English](skills.md) | 日本語 | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — AIアシスタント向けスキル + +> このファイルをGodotプロジェクトルートの `.claude/skills.md` にコピーすると、Claude CodeがGodot MCP Proを効果的に活用するためのコンテキストを得られます。 + +## Godot MCP Proとは? + +Godot 4エディタに直接接続する169のMCPツールを利用できます。シーンの作成、スクリプトの記述、プレイヤー入力のシミュレーション、実行中のゲームの検査など、ユーザーがこの会話から離れることなく、すべての操作が可能です。すべての変更はGodotのUndoRedoシステムを通じて行われるため、いつでもCtrl+Zで元に戻せます。 + +## 基本ワークフロー + +### 1. プロジェクトの調査 + +変更を加える前に、まずプロジェクトの全体像を把握しましょう: + +``` +get_project_info → プロジェクト名、Godotバージョン、レンダラー、ビューポートサイズ +get_filesystem_tree → ディレクトリ構造(filter: "*.tscn" や "*.gd" が使えます) +get_scene_tree → 現在開いているシーンのノード階層 +read_script → 任意のGDScriptファイルを読む +get_project_settings → プロジェクト設定の確認 +``` + +### 2. 2Dシーンの構築 + +``` +create_scene → .tscnファイルをルートノードタイプ指定で作成 +add_node → プロパティ付きの子ノードを追加 +create_script → ゲームロジック用のGDScriptを作成 +attach_script → ノードにスクリプトをアタッチ +update_property → position、scale、modulateなどを設定 +save_scene → ディスクに保存 +``` + +**例 — プレイヤーの作成:** +1. `create_scene` でroot_type `CharacterBody2D`、path `res://scenes/player.tscn` を指定 +2. `add_node` でtextureプロパティ付きの `Sprite2D` を追加 +3. `add_node` で `CollisionShape2D` を追加 +4. `add_resource` でCollisionShape2Dにシェイプ(例:`RectangleShape2D`)を割り当て +5. `create_script` で移動ロジックを記述 +6. `attach_script` でルートノードにアタッチ +7. `save_scene` + +### 3. 3Dシーンの構築 + +``` +create_scene → root_type: Node3D +add_mesh_instance → プリミティブ(box、sphere、cylinder、plane)の追加、または.glb/.gltfのインポート +setup_lighting → DirectionalLight3D、OmniLight3D、SpotLight3Dの追加 +setup_environment → スカイ、アンビエントライト、フォグ、トーンマップ +setup_camera_3d → カメラ(オプションでSpringArm3Dによる三人称視点) +set_material_3d → PBRマテリアル(albedo、metallic、roughness、emission) +setup_collision → 物理ボディにコリジョンシェイプを追加 +setup_physics_body → 質量、摩擦、重力の設定 +``` + +### 4. スクリプトの作成と編集 + +``` +create_script → 新規.gdファイルを作成(完全な内容を提供) +edit_script → 既存スクリプトを編集 + - `replacements: [{search: "old code", replace: "new code"}]` で部分的な編集 + - `content` でファイル全体を置換 + - `insert_at_line` + `text` でコードを挿入 +validate_script → 実行せずに構文エラーをチェック +read_script → 編集前に現在の内容を確認 +``` + +### 5. プレイテストとデバッグ + +``` +play_scene → ゲームを起動(mode: "current"、"main"、またはファイルパス) +get_game_screenshot → ゲームの現在の見た目を確認 +capture_frames → 複数フレームをキャプチャして動きやアニメーションを観察 +get_game_scene_tree → 実行時のシーンツリーを検査 +get_game_node_properties → ランタイムの値を読み取り(position、health、stateなど) +set_game_node_property → 実行中のゲームの値を変更 +simulate_key → キー入力(WASD、SPACEなど)をduration指定で実行 +simulate_mouse_click → ビューポート座標でクリック +simulate_action → InputMapアクション(move_left、jumpなど)をトリガー +get_editor_errors → ランタイムエラーの確認 +stop_scene → ゲームを停止 +``` + +**プレイテストループ:** +1. `play_scene` → ゲームを開始 +2. `get_game_screenshot` → 現在の状態を確認 +3. `simulate_key` / `simulate_action` → ゲームを操作 +4. `capture_frames` → 時間経過での挙動を観察 +5. `get_game_node_properties` → 特定の値を確認 +6. `stop_scene` → 完了したら停止 +7. スクリプトの問題を修正 → 繰り返し + +### 6. アニメーション + +``` +# シーンにAnimationPlayerノードが存在することを確認 +create_animation → 長さとループモード付きの新規アニメーション +add_animation_track → property/transform/methodトラックの追加 +set_animation_keyframe → 特定時間にキーフレームを挿入 +get_animation_info → 既存アニメーションの情報を取得 +``` + +**例 — バウンドするスプライト:** +1. `create_animation` でname `bounce`、length `1.0`、loop_mode `1`(リニアループ) +2. `add_animation_track` でtrack_path `Sprite2D:position`、track_type `value` +3. `set_animation_keyframe` でtime `0.0`、value `Vector2(0, 0)` +4. `set_animation_keyframe` でtime `0.5`、value `Vector2(0, -50)` +5. `set_animation_keyframe` でtime `1.0`、value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control、Label、Button、TextureRectなど +set_anchor_preset → Controlの配置(full_rect、center、bottom_wideなど) +set_theme_color → font_colorなどの変更 +set_theme_font_size → テキストサイズの調整 +set_theme_stylebox → 背景、ボーダー、角丸 +connect_signal → buttonのpressed、value_changedなどを接続 +``` + +### 8. TileMap + +``` +tilemap_get_info → タイルセットのソースとアトラスレイアウトを確認 +tilemap_set_cell → 個別タイルの配置 +tilemap_fill_rect → 矩形領域を塗りつぶし +tilemap_get_used_cells → 配置済みセルの確認 +tilemap_clear → 全セルをクリア +``` + +### 9. オーディオ + +``` +add_audio_bus → オーディオバスの作成(SFX、Music、UI) +set_audio_bus → ボリューム、ソロ、ミュートの調整 +add_audio_bus_effect → リバーブ、ディレイ、コンプレッサーなどの追加 +add_audio_player → AudioStreamPlayer(2D/3D)ノードの追加 +``` + +### 10. プロジェクト設定 + +``` +set_project_setting → ビューポートサイズ、物理設定などの変更 +set_input_action → 入力マッピングの定義(move_left → KEY_Aなど) +add_autoload → Autoloadシングルトンの登録 +set_physics_layers → コリジョンレイヤーの命名(player、enemy、worldなど) +``` + +## 重要なルールと注意点 + +### プロパティ値 + +プロパティは文字列から自動パースされます。以下のフォーマットを使用してください: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` または `"#ff0000"` +- Bool: `"true"` / `"false"` +- 数値: `"42"`、`"3.14"` +- Enum: 整数値を使用(例:最初のenum値は `0`) + +### project.godotを直接編集しないこと + +Godotエディタは `project.godot` を常に上書きします。プロジェクト設定の変更には必ず `set_project_setting` を使用してください。 + +### GDScriptの型アノテーション + +型なし配列に対する `for` ループでは、明示的な型アノテーションを使用してください: +```gdscript +# NG — エラーの原因になる +for item in some_untyped_array: + var x := item.value # 型推論が失敗 + +# OK +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### スクリプト変更にはリロードが必要 + +スクリプトの作成や大幅な変更の後は、`reload_project` を使用してGodotに変更を反映させましょう。特に `create_script` の後は重要です。 + +### simulate_keyのコツ +- 精密な移動には**短いduration**(0.3〜0.5秒)を使用 +- 長いduration(1秒以上)はオーバーシュートの原因に +- ゲームプレイのテストでは、InputMapアクションが定義されている場合は `simulate_key` より `simulate_action` を推奨 + +### simulate_mouse_click +- デフォルトの `auto_release: true` はpressとreleaseの両方を送信 — UIボタンに必須 +- UIボタンはreleaseで発火するため、両方のイベントが必要 + +### execute_game_scriptの制限事項 +- ネストされた関数(`func` 内の `func`)は不可 — コンパイルエラーになる +- 動的アクセスには `.property` ではなく `.get("property")` を使用 +- ランタイムエラーはデバッガーを一時停止させる(自動再開されるが、できれば避けること) + +### コリジョンとピックアップエリア +- 収集アイテムにはArea3D/Area2Dで半径1.5以上を使用 +- 小さい半径ではシミュレーション入力でのトリガーがほぼ不可能 + +### こまめに保存する + +大きな変更を行った後は `save_scene` を呼んでください。保存していない変更はエディタのリロード時に失われる可能性があります。 + +## 分析とデバッグツール + +問題が発生した場合、以下のツールで調査できます: + +``` +get_editor_errors → スクリプトエラーとランタイム例外を確認 +get_output_log → print()出力と警告を読む +analyze_scene_complexity → パフォーマンスのボトルネックを特定 +analyze_signal_flow → シグナル接続を可視化 +detect_circular_dependencies → 循環参照するスクリプト/シーンを検出 +find_unused_resources → 未使用ファイルのクリーンアップ +get_performance_monitors → FPS、メモリ、ドローコール、物理統計 +``` + +## テストとQA + +``` +run_test_scenario → 自動テストシーケンスの定義と実行 +assert_node_state → ノードプロパティが期待値と一致するか検証 +assert_screen_text → 画面にテキストが表示されているか検証 +compare_screenshots → ビジュアル回帰テスト(base64ではなくファイルパスを使用) +run_stress_test → 多数のノードを生成してパフォーマンスをテスト +``` + +## 高度なパターン + +### クロスシーン操作 +``` +cross_scene_set_property → 現在開いていないシーンのノードを変更 +find_node_references → パターンを参照しているすべてのファイルを検索 +batch_set_property → 特定タイプの全ノードにプロパティを設定 +``` + +### シェーダーワークフロー +``` +create_shader → GLSL風のシェーダーコードを記述 +assign_shader_material → ノードに適用 +set_shader_param → 実行時にuniformを調整 +get_shader_params → 現在の値を取得 +``` + +### ナビゲーション(3D) +``` +setup_navigation_region → 歩行可能エリアの定義 +bake_navigation_mesh → ナビメッシュの生成 +setup_navigation_agent → キャラクターにパスファインディングを追加 +``` + +### AnimationTreeとステートマシン +``` +create_animation_tree → ステートマシンまたはブレンドツリーでAnimationTreeをセットアップ +add_state_machine_state → ステートを追加(idle、walk、run、jump) +add_state_machine_transition → ステート間のトランジションを定義 +set_tree_parameter → ブレンドパラメータを制御 +``` + +## 推奨ワークフロー順序 + +ゲームをゼロから構築する場合の推奨順序: + +1. **プロジェクトセットアップ** — `get_project_info`、`set_project_setting`(ビューポート、物理) +2. **入力マッピング** — `set_input_action` で全プレイヤー操作を定義 +3. **メインシーン** — `create_scene` でメインシーンとして設定 +4. **プレイヤー** — スプライト、コリジョン、スクリプト付きのプレイヤーシーンを作成 +5. **レベル/ワールド** — 環境を構築(TileMap、3Dメッシュなど) +6. **ゲームロジック** — 敵、アイテム、UIのスクリプト +7. **オーディオ** — バスのセットアップ、オーディオプレイヤーの追加 +8. **プレイテスト** — `play_scene` でシミュレーション入力によるテスト、バグ修正 +9. **ポリッシュ** — アニメーション、パーティクル、シェーダー、テーマ +10. **エクスポート** — `list_export_presets`、`export_project` diff --git a/addons/godot_mcp/skills.md b/addons/godot_mcp/skills.md new file mode 100644 index 0000000..1b32f04 --- /dev/null +++ b/addons/godot_mcp/skills.md @@ -0,0 +1,295 @@ +> **Language:** English | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Skills for AI Assistants + +> Copy this file to `.claude/skills.md` in your Godot project root to give Claude Code full context on how to use Godot MCP Pro effectively. + +## What is Godot MCP Pro? + +You have access to 169 MCP tools that connect directly to the Godot 4 editor. You can create scenes, write scripts, simulate player input, inspect running games, and more — all without the user leaving this conversation. Every change goes through Godot's UndoRedo system, so the user can always Ctrl+Z. + +## Essential Workflows + +### 1. Explore a Project + +Always start by understanding the project before making changes: + +``` +get_project_info → project name, Godot version, renderer, viewport size +get_filesystem_tree → directory structure (use filter: "*.tscn" or "*.gd") +get_scene_tree → node hierarchy of the currently open scene +read_script → read any GDScript file +get_project_settings → check project configuration +``` + +### 2. Build a 2D Scene + +``` +create_scene → create .tscn file with root node type +add_node → add child nodes with properties +create_script → write GDScript for game logic +attach_script → attach script to a node +update_property → set position, scale, modulate, etc. +save_scene → save to disk +``` + +**Example — creating a player:** +1. `create_scene` with root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` type `Sprite2D` with texture property +3. `add_node` type `CollisionShape2D` +4. `add_resource` to assign a shape (e.g., `RectangleShape2D`) to the CollisionShape2D +5. `create_script` with movement logic +6. `attach_script` to the root node +7. `save_scene` + +### 3. Build a 3D Scene + +``` +create_scene → root_type: Node3D +add_mesh_instance → add primitives (box, sphere, cylinder, plane) or import .glb/.gltf +setup_lighting → add DirectionalLight3D, OmniLight3D, or SpotLight3D +setup_environment → sky, ambient light, fog, tonemap +setup_camera_3d → camera with optional SpringArm3D for third-person +set_material_3d → PBR materials (albedo, metallic, roughness, emission) +setup_collision → add collision shapes to physics bodies +setup_physics_body → configure mass, friction, gravity +``` + +### 4. Write & Edit Scripts + +``` +create_script → create new .gd file (provide full content) +edit_script → modify existing scripts + - Use `replacements: [{search: "old code", replace: "new code"}]` for targeted edits + - Use `content` for full file replacement + - Use `insert_at_line` + `text` for inserting code +validate_script → check for syntax errors without running +read_script → read current content before editing +``` + +### 5. Playtest & Debug + +``` +play_scene → launch the game (mode: "current", "main", or file path) +get_game_screenshot → see what the game looks like right now +capture_frames → capture multiple frames to observe motion/animation +get_game_scene_tree → inspect the live scene tree at runtime +get_game_node_properties → read runtime values (position, health, state, etc.) +set_game_node_property → modify values in the running game +simulate_key → press keys (WASD, SPACE, etc.) with duration +simulate_mouse_click → click at viewport coordinates +simulate_action → trigger InputMap actions (move_left, jump, etc.) +get_editor_errors → check for runtime errors +stop_scene → stop the game +``` + +**Playtesting loop:** +1. `play_scene` → start the game +2. `get_game_screenshot` → see current state +3. `simulate_key` / `simulate_action` → interact with the game +4. `capture_frames` → observe behavior over time +5. `get_game_node_properties` → check specific values +6. `stop_scene` → stop when done +7. Fix issues in scripts → repeat + +### 6. Animations + +``` +# Ensure an AnimationPlayer node exists in the scene +create_animation → new animation with length and loop mode +add_animation_track → add property/transform/method tracks +set_animation_keyframe → insert keyframes at specific times +get_animation_info → inspect existing animations +``` + +**Example — bouncing sprite:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect, etc. +set_anchor_preset → position Controls (full_rect, center, bottom_wide, etc.) +set_theme_color → change font_color, etc. +set_theme_font_size → adjust text size +set_theme_stylebox → backgrounds, borders, rounded corners +connect_signal → wire up button pressed, value_changed, etc. +``` + +### 8. TileMap + +``` +tilemap_get_info → check tile set sources and atlas layout +tilemap_set_cell → place individual tiles +tilemap_fill_rect → fill rectangular regions +tilemap_get_used_cells → see what's already placed +tilemap_clear → clear all cells +``` + +### 9. Audio + +``` +add_audio_bus → create audio buses (SFX, Music, UI) +set_audio_bus → adjust volume, solo, mute +add_audio_bus_effect → add reverb, delay, compressor, etc. +add_audio_player → add AudioStreamPlayer(2D/3D) nodes +``` + +### 10. Project Configuration + +``` +set_project_setting → change viewport size, physics settings, etc. +set_input_action → define input mappings (move_left → KEY_A, etc.) +add_autoload → register autoload singletons +set_physics_layers → name collision layers (player, enemy, world, etc.) +``` + +## Important Rules & Pitfalls + +### Prefer Inspector Properties Over Code +When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak by hand. Only write GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +### Property Values +Properties are auto-parsed from strings. Use these formats: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` or `"#ff0000"` +- Bool: `"true"` / `"false"` +- Numbers: `"42"`, `"3.14"` +- Enums: Use integer values (e.g., `0` for the first enum value) + +### Never Edit project.godot Directly +Godot editor constantly overwrites `project.godot`. Always use `set_project_setting` to change project settings. + +### GDScript Type Annotations +When writing GDScript with `for` loops over untyped arrays, use explicit type annotations: +```gdscript +# BAD — will cause errors +for item in some_untyped_array: + var x := item.value # type inference fails + +# GOOD +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Script Changes Need Reload +After creating or significantly modifying scripts, use `reload_project` to ensure Godot picks up the changes. This is especially important after `create_script`. + +### simulate_key Tips +- Use **short durations** (0.3–0.5 seconds) for precise movement +- Long durations (1+ seconds) cause overshooting +- For gameplay testing, prefer `simulate_action` over `simulate_key` when InputMap actions are defined + +### simulate_mouse_click +- Default `auto_release: true` sends both press and release — required for UI buttons +- UI buttons fire on release, so both events are needed + +### execute_game_script Limitations +- No nested functions (`func` inside `func`) — causes compile error +- Use `.get("property")` instead of `.property` for dynamic access +- Runtime errors will pause the debugger (auto-continued, but avoid if possible) + +### Collision & Pickup Areas +- For collectible items, use Area3D/Area2D with radius ≥ 1.5 +- Smaller radii are nearly impossible to trigger with simulated input + +### Save Frequently +Call `save_scene` after making significant changes. Unsaved changes can be lost if the editor reloads. + +## Analysis & Debugging Tools + +When something goes wrong, use these tools to investigate: + +``` +get_editor_errors → check for script errors and runtime exceptions +get_output_log → read print() output and warnings +analyze_scene_complexity → find performance bottlenecks +analyze_signal_flow → visualize signal connections +detect_circular_dependencies → find circular script/scene references +find_unused_resources → clean up unused files +get_performance_monitors → FPS, memory, draw calls, physics stats +``` + +## Testing & QA + +``` +run_test_scenario → define and run automated test sequences +assert_node_state → verify node properties match expected values +assert_screen_text → verify text is displayed on screen +compare_screenshots → visual regression testing (use file paths, not base64) +run_stress_test → spawn many nodes to test performance +``` + +## Advanced Patterns + +### Cross-Scene Operations +``` +cross_scene_set_property → modify nodes in scenes that aren't currently open +find_node_references → find all files referencing a pattern +batch_set_property → set a property on all nodes of a type +``` + +### Shader Workflow +``` +create_shader → write GLSL-like shader code +assign_shader_material → apply to a node +set_shader_param → adjust uniforms at runtime +get_shader_params → inspect current values +``` + +### Navigation (3D) +``` +setup_navigation_region → define walkable area +bake_navigation_mesh → generate navmesh +setup_navigation_agent → add pathfinding to characters +``` + +### AnimationTree & State Machines +``` +create_animation_tree → set up AnimationTree with state machine or blend tree +add_state_machine_state → add states (idle, walk, run, jump) +add_state_machine_transition → define transitions between states +set_tree_parameter → control blend parameters +``` + +### Code-to-Inspector Migration + +Move hardcoded visual properties from GDScript to the inspector for easier tweaking: + +``` +read_script → find hardcoded property assignments +get_node_properties → check current inspector values +update_property → set values as node properties +edit_script → remove hardcoded lines from script +save_scene → persist inspector changes +validate_script → verify script still compiles +``` + +Example — a script sets `modulate = Color(1, 0, 0, 1)` in `_ready()`: +1. `read_script` to find the line +2. `update_property` with `node_path`, `property: "modulate"`, `value: "Color(1, 0, 0, 1)"` +3. `edit_script` to remove the `modulate = ...` line from `_ready()` +4. `save_scene` + `validate_script` + +This applies to: colors, positions, sizes, theme overrides, material properties, visibility, margins, anchors, and any property that doesn't need to change at runtime. + +## Recommended Workflow Order + +When building a new game from scratch: + +1. **Project setup** — `get_project_info`, `set_project_setting` (viewport, physics) +2. **Input mapping** — `set_input_action` for all player controls +3. **Main scene** — `create_scene`, set as main scene +4. **Player** — create player scene with sprite, collision, script +5. **Level/World** — build environment (TileMap, 3D meshes, etc.) +6. **Game logic** — scripts for enemies, items, UI +7. **Audio** — set up buses, add audio players +8. **Playtest** — `play_scene`, test with simulated input, fix bugs +9. **Polish** — animations, particles, shaders, themes +10. **Export** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.pt-br.md b/addons/godot_mcp/skills.pt-br.md new file mode 100644 index 0000000..ec8319d --- /dev/null +++ b/addons/godot_mcp/skills.pt-br.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | Português (BR) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Skills para Assistentes de IA + +> Copie este arquivo para `.claude/skills.md` na raiz do seu projeto Godot para dar ao Claude Code o contexto completo de como usar o Godot MCP Pro de forma eficiente. + +## O que é o Godot MCP Pro? + +Você tem acesso a 169 ferramentas MCP que se conectam diretamente ao editor Godot 4. Você pode criar cenas, escrever scripts, simular entrada do jogador, inspecionar jogos em execução e muito mais — tudo sem que o usuário precise sair desta conversa. Todas as alterações passam pelo sistema UndoRedo do Godot, então o usuário pode sempre usar Ctrl+Z. + +## Fluxos de Trabalho Essenciais + +### 1. Explorar um Projeto + +Sempre comece entendendo o projeto antes de fazer alterações: + +``` +get_project_info → nome do projeto, versão do Godot, renderizador, tamanho do viewport +get_filesystem_tree → estrutura de diretórios (use filter: "*.tscn" ou "*.gd") +get_scene_tree → hierarquia de nós da cena atualmente aberta +read_script → ler qualquer arquivo GDScript +get_project_settings → verificar configuração do projeto +``` + +### 2. Construir uma Cena 2D + +``` +create_scene → criar arquivo .tscn com tipo de nó raiz +add_node → adicionar nós filhos com propriedades +create_script → escrever GDScript para lógica do jogo +attach_script → anexar script a um nó +update_property → definir position, scale, modulate, etc. +save_scene → salvar no disco +``` + +**Exemplo — criando um jogador:** +1. `create_scene` com root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` tipo `Sprite2D` com propriedade texture +3. `add_node` tipo `CollisionShape2D` +4. `add_resource` para atribuir uma shape (ex: `RectangleShape2D`) ao CollisionShape2D +5. `create_script` com lógica de movimento +6. `attach_script` ao nó raiz +7. `save_scene` + +### 3. Construir uma Cena 3D + +``` +create_scene → root_type: Node3D +add_mesh_instance → adicionar primitivas (box, sphere, cylinder, plane) ou importar .glb/.gltf +setup_lighting → adicionar DirectionalLight3D, OmniLight3D ou SpotLight3D +setup_environment → céu, luz ambiente, neblina, tonemap +setup_camera_3d → câmera com SpringArm3D opcional para terceira pessoa +set_material_3d → materiais PBR (albedo, metallic, roughness, emission) +setup_collision → adicionar shapes de colisão a corpos físicos +setup_physics_body → configurar massa, atrito, gravidade +``` + +### 4. Escrever e Editar Scripts + +``` +create_script → criar novo arquivo .gd (forneça o conteúdo completo) +edit_script → modificar scripts existentes + - Use `replacements: [{search: "old code", replace: "new code"}]` para edições pontuais + - Use `content` para substituição completa do arquivo + - Use `insert_at_line` + `text` para inserir código +validate_script → verificar erros de sintaxe sem executar +read_script → ler conteúdo atual antes de editar +``` + +### 5. Testar e Depurar + +``` +play_scene → iniciar o jogo (mode: "current", "main" ou caminho do arquivo) +get_game_screenshot → ver como o jogo está neste momento +capture_frames → capturar múltiplos frames para observar movimento/animação +get_game_scene_tree → inspecionar a árvore de cena em tempo de execução +get_game_node_properties → ler valores em runtime (position, health, state, etc.) +set_game_node_property → modificar valores no jogo em execução +simulate_key → pressionar teclas (WASD, SPACE, etc.) com duração +simulate_mouse_click → clicar em coordenadas do viewport +simulate_action → disparar ações do InputMap (move_left, jump, etc.) +get_editor_errors → verificar erros de execução +stop_scene → parar o jogo +``` + +**Loop de playtesting:** +1. `play_scene` → iniciar o jogo +2. `get_game_screenshot` → ver estado atual +3. `simulate_key` / `simulate_action` → interagir com o jogo +4. `capture_frames` → observar comportamento ao longo do tempo +5. `get_game_node_properties` → verificar valores específicos +6. `stop_scene` → parar quando terminar +7. Corrigir problemas nos scripts → repetir + +### 6. Animações + +``` +# Certifique-se de que existe um nó AnimationPlayer na cena +create_animation → nova animação com duração e modo de loop +add_animation_track → adicionar tracks de property/transform/method +set_animation_keyframe → inserir keyframes em tempos específicos +get_animation_info → inspecionar animações existentes +``` + +**Exemplo — sprite quicando:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop linear) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect, etc. +set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.) +set_theme_color → alterar font_color, etc. +set_theme_font_size → ajustar tamanho do texto +set_theme_stylebox → fundos, bordas, cantos arredondados +connect_signal → conectar pressed do button, value_changed, etc. +``` + +### 8. TileMap + +``` +tilemap_get_info → verificar fontes do tile set e layout do atlas +tilemap_set_cell → colocar tiles individuais +tilemap_fill_rect → preencher regiões retangulares +tilemap_get_used_cells → ver o que já está colocado +tilemap_clear → limpar todas as células +``` + +### 9. Áudio + +``` +add_audio_bus → criar buses de áudio (SFX, Music, UI) +set_audio_bus → ajustar volume, solo, mute +add_audio_bus_effect → adicionar reverb, delay, compressor, etc. +add_audio_player → adicionar nós AudioStreamPlayer(2D/3D) +``` + +### 10. Configuração do Projeto + +``` +set_project_setting → alterar tamanho do viewport, configurações de física, etc. +set_input_action → definir mapeamentos de entrada (move_left → KEY_A, etc.) +add_autoload → registrar singletons autoload +set_physics_layers → nomear camadas de colisão (player, enemy, world, etc.) +``` + +## Regras Importantes e Armadilhas + +### Valores de Propriedade +Propriedades são parseadas automaticamente de strings. Use estes formatos: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` ou `"#ff0000"` +- Bool: `"true"` / `"false"` +- Números: `"42"`, `"3.14"` +- Enums: Use valores inteiros (ex: `0` para o primeiro valor do enum) + +### Nunca Edite project.godot Diretamente +O editor Godot sobrescreve `project.godot` constantemente. Sempre use `set_project_setting` para alterar configurações do projeto. + +### Anotações de Tipo em GDScript +Ao escrever GDScript com loops `for` sobre arrays sem tipo, use anotações de tipo explícitas: +```gdscript +# RUIM — vai causar erros +for item in some_untyped_array: + var x := item.value # inferência de tipo falha + +# BOM +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Alterações em Scripts Precisam de Reload +Após criar ou modificar scripts significativamente, use `reload_project` para garantir que o Godot reconheça as alterações. Isso é especialmente importante após `create_script`. + +### Dicas para simulate_key +- Use **durações curtas** (0.3–0.5 segundos) para movimentos precisos +- Durações longas (1+ segundo) causam overshooting +- Para testes de gameplay, prefira `simulate_action` em vez de `simulate_key` quando ações do InputMap estiverem definidas + +### simulate_mouse_click +- O padrão `auto_release: true` envia press e release — necessário para botões de UI +- Botões de UI disparam no release, então ambos os eventos são necessários + +### Limitações do execute_game_script +- Sem funções aninhadas (`func` dentro de `func`) — causa erro de compilação +- Use `.get("property")` em vez de `.property` para acesso dinâmico +- Erros de runtime pausam o debugger (continuado automaticamente, mas evite se possível) + +### Colisão e Áreas de Coleta +- Para itens coletáveis, use Area3D/Area2D com raio >= 1.5 +- Raios menores são quase impossíveis de acionar com entrada simulada + +### Salve com Frequência +Chame `save_scene` após fazer alterações significativas. Alterações não salvas podem ser perdidas se o editor recarregar. + +## Ferramentas de Análise e Depuração + +Quando algo der errado, use estas ferramentas para investigar: + +``` +get_editor_errors → verificar erros de script e exceções de runtime +get_output_log → ler saída de print() e avisos +analyze_scene_complexity → encontrar gargalos de performance +analyze_signal_flow → visualizar conexões de signals +detect_circular_dependencies → encontrar referências circulares de script/cena +find_unused_resources → limpar arquivos não utilizados +get_performance_monitors → FPS, memória, draw calls, estatísticas de física +``` + +## Testes e QA + +``` +run_test_scenario → definir e executar sequências de teste automatizadas +assert_node_state → verificar se propriedades de nós correspondem aos valores esperados +assert_screen_text → verificar se texto está exibido na tela +compare_screenshots → teste de regressão visual (use caminhos de arquivo, não base64) +run_stress_test → gerar muitos nós para testar performance +``` + +## Padrões Avançados + +### Operações entre Cenas +``` +cross_scene_set_property → modificar nós em cenas que não estão abertas atualmente +find_node_references → encontrar todos os arquivos que referenciam um padrão +batch_set_property → definir uma propriedade em todos os nós de um tipo +``` + +### Fluxo de Trabalho com Shaders +``` +create_shader → escrever código shader estilo GLSL +assign_shader_material → aplicar a um nó +set_shader_param → ajustar uniforms em runtime +get_shader_params → inspecionar valores atuais +``` + +### Navegação (3D) +``` +setup_navigation_region → definir área caminhável +bake_navigation_mesh → gerar navmesh +setup_navigation_agent → adicionar pathfinding a personagens +``` + +### AnimationTree e Máquinas de Estado +``` +create_animation_tree → configurar AnimationTree com máquina de estado ou blend tree +add_state_machine_state → adicionar estados (idle, walk, run, jump) +add_state_machine_transition → definir transições entre estados +set_tree_parameter → controlar parâmetros de blend +``` + +## Ordem de Fluxo de Trabalho Recomendada + +Ao construir um novo jogo do zero: + +1. **Configuração do projeto** — `get_project_info`, `set_project_setting` (viewport, física) +2. **Mapeamento de entrada** — `set_input_action` para todos os controles do jogador +3. **Cena principal** — `create_scene`, definir como cena principal +4. **Jogador** — criar cena do jogador com sprite, colisão, script +5. **Nível/Mundo** — construir o ambiente (TileMap, meshes 3D, etc.) +6. **Lógica do jogo** — scripts para inimigos, itens, UI +7. **Áudio** — configurar buses, adicionar audio players +8. **Playtesting** — `play_scene`, testar com entrada simulada, corrigir bugs +9. **Polimento** — animações, partículas, shaders, temas +10. **Exportação** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.ru.md b/addons/godot_mcp/skills.ru.md new file mode 100644 index 0000000..a33cc84 --- /dev/null +++ b/addons/godot_mcp/skills.ru.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | Русский | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Навыки для ИИ-ассистентов + +> Скопируйте этот файл в `.claude/skills.md` в корне вашего проекта Godot, чтобы дать Claude Code полный контекст по эффективному использованию Godot MCP Pro. + +## Что такое Godot MCP Pro? + +Вам доступны 169 MCP-инструмента, которые напрямую подключаются к редактору Godot 4. Вы можете создавать сцены, писать скрипты, симулировать ввод игрока, инспектировать запущенные игры и многое другое — всё это без выхода пользователя из данного разговора. Все изменения проходят через систему UndoRedo Godot, поэтому пользователь всегда может нажать Ctrl+Z. + +## Основные рабочие процессы + +### 1. Изучение проекта + +Всегда начинайте с понимания проекта перед внесением изменений: + +``` +get_project_info → название проекта, версия Godot, рендерер, размер viewport +get_filesystem_tree → структура директорий (используйте filter: "*.tscn" или "*.gd") +get_scene_tree → иерархия нод текущей открытой сцены +read_script → прочитать любой файл GDScript +get_project_settings → проверить конфигурацию проекта +``` + +### 2. Создание 2D-сцены + +``` +create_scene → создать файл .tscn с указанием типа корневой ноды +add_node → добавить дочерние ноды со свойствами +create_script → написать GDScript для игровой логики +attach_script → прикрепить скрипт к ноде +update_property → установить position, scale, modulate и т.д. +save_scene → сохранить на диск +``` + +**Пример — создание игрока:** +1. `create_scene` с root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` типа `Sprite2D` со свойством texture +3. `add_node` типа `CollisionShape2D` +4. `add_resource` для назначения формы (например, `RectangleShape2D`) на CollisionShape2D +5. `create_script` с логикой движения +6. `attach_script` на корневую ноду +7. `save_scene` + +### 3. Создание 3D-сцены + +``` +create_scene → root_type: Node3D +add_mesh_instance → добавить примитивы (box, sphere, cylinder, plane) или импортировать .glb/.gltf +setup_lighting → добавить DirectionalLight3D, OmniLight3D или SpotLight3D +setup_environment → небо, окружающий свет, туман, tonemap +setup_camera_3d → камера с опциональным SpringArm3D для вида от третьего лица +set_material_3d → PBR-материалы (albedo, metallic, roughness, emission) +setup_collision → добавить формы столкновений к физическим телам +setup_physics_body → настроить массу, трение, гравитацию +``` + +### 4. Написание и редактирование скриптов + +``` +create_script → создать новый файл .gd (укажите полное содержимое) +edit_script → изменить существующие скрипты + - Используйте `replacements: [{search: "old code", replace: "new code"}]` для точечных правок + - Используйте `content` для полной замены файла + - Используйте `insert_at_line` + `text` для вставки кода +validate_script → проверить синтаксические ошибки без запуска +read_script → прочитать текущее содержимое перед редактированием +``` + +### 5. Тестирование и отладка + +``` +play_scene → запустить игру (mode: "current", "main" или путь к файлу) +get_game_screenshot → увидеть, как игра выглядит прямо сейчас +capture_frames → захватить несколько кадров для наблюдения за движением/анимацией +get_game_scene_tree → инспектировать дерево сцены в runtime +get_game_node_properties → прочитать значения в runtime (position, health, state и т.д.) +set_game_node_property → изменить значения в запущенной игре +simulate_key → нажать клавиши (WASD, SPACE и т.д.) с указанием длительности +simulate_mouse_click → кликнуть по координатам viewport +simulate_action → вызвать действия InputMap (move_left, jump и т.д.) +get_editor_errors → проверить ошибки выполнения +stop_scene → остановить игру +``` + +**Цикл плейтестинга:** +1. `play_scene` → запустить игру +2. `get_game_screenshot` → увидеть текущее состояние +3. `simulate_key` / `simulate_action` → взаимодействовать с игрой +4. `capture_frames` → наблюдать поведение во времени +5. `get_game_node_properties` → проверить конкретные значения +6. `stop_scene` → остановить по завершении +7. Исправить проблемы в скриптах → повторить + +### 6. Анимации + +``` +# Убедитесь, что в сцене есть нода AnimationPlayer +create_animation → новая анимация с длительностью и режимом зацикливания +add_animation_track → добавить треки property/transform/method +set_animation_keyframe → вставить ключевые кадры в определённые моменты +get_animation_info → просмотреть существующие анимации +``` + +**Пример — прыгающий спрайт:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (линейный цикл) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect и т.д. +set_anchor_preset → позиционирование Controls (full_rect, center, bottom_wide и т.д.) +set_theme_color → изменить font_color и т.д. +set_theme_font_size → настроить размер текста +set_theme_stylebox → фоны, рамки, скруглённые углы +connect_signal → подключить pressed кнопки, value_changed и т.д. +``` + +### 8. TileMap + +``` +tilemap_get_info → проверить источники набора тайлов и раскладку атласа +tilemap_set_cell → разместить отдельные тайлы +tilemap_fill_rect → заполнить прямоугольные области +tilemap_get_used_cells → посмотреть, что уже размещено +tilemap_clear → очистить все ячейки +``` + +### 9. Аудио + +``` +add_audio_bus → создать аудио-шины (SFX, Music, UI) +set_audio_bus → настроить громкость, соло, заглушение +add_audio_bus_effect → добавить реверберацию, задержку, компрессор и т.д. +add_audio_player → добавить ноды AudioStreamPlayer(2D/3D) +``` + +### 10. Конфигурация проекта + +``` +set_project_setting → изменить размер viewport, настройки физики и т.д. +set_input_action → определить маппинг ввода (move_left → KEY_A и т.д.) +add_autoload → зарегистрировать синглтоны autoload +set_physics_layers → именовать слои столкновений (player, enemy, world и т.д.) +``` + +## Важные правила и подводные камни + +### Значения свойств +Свойства автоматически парсятся из строк. Используйте следующие форматы: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` или `"#ff0000"` +- Bool: `"true"` / `"false"` +- Числа: `"42"`, `"3.14"` +- Enum: Используйте целочисленные значения (например, `0` для первого значения enum) + +### Никогда не редактируйте project.godot напрямую +Редактор Godot постоянно перезаписывает `project.godot`. Всегда используйте `set_project_setting` для изменения настроек проекта. + +### Аннотации типов в GDScript +При написании GDScript с циклами `for` по нетипизированным массивам используйте явные аннотации типов: +```gdscript +# ПЛОХО — приведёт к ошибкам +for item in some_untyped_array: + var x := item.value # вывод типов не работает + +# ХОРОШО +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Изменения скриптов требуют перезагрузки +После создания или значительного изменения скриптов используйте `reload_project`, чтобы Godot подхватил изменения. Особенно важно после `create_script`. + +### Советы по simulate_key +- Используйте **короткие длительности** (0.3–0.5 секунд) для точного перемещения +- Длинные длительности (1+ секунда) приводят к промахам +- Для тестирования геймплея предпочитайте `simulate_action` вместо `simulate_key`, когда определены действия InputMap + +### simulate_mouse_click +- По умолчанию `auto_release: true` отправляет press и release — необходимо для UI-кнопок +- UI-кнопки срабатывают на release, поэтому нужны оба события + +### Ограничения execute_game_script +- Нельзя использовать вложенные функции (`func` внутри `func`) — вызывает ошибку компиляции +- Используйте `.get("property")` вместо `.property` для динамического доступа +- Ошибки выполнения приостанавливают отладчик (автоматически продолжается, но лучше избегать) + +### Коллизии и области подбора +- Для собираемых предметов используйте Area3D/Area2D с радиусом >= 1.5 +- Меньшие радиусы почти невозможно активировать симулированным вводом + +### Сохраняйте часто +Вызывайте `save_scene` после значительных изменений. Несохранённые изменения могут быть потеряны при перезагрузке редактора. + +## Инструменты анализа и отладки + +Когда что-то пошло не так, используйте эти инструменты для расследования: + +``` +get_editor_errors → проверить ошибки скриптов и исключения runtime +get_output_log → прочитать вывод print() и предупреждения +analyze_scene_complexity → найти узкие места производительности +analyze_signal_flow → визуализировать соединения сигналов +detect_circular_dependencies → найти циклические ссылки скриптов/сцен +find_unused_resources → очистить неиспользуемые файлы +get_performance_monitors → FPS, память, draw calls, статистика физики +``` + +## Тестирование и QA + +``` +run_test_scenario → определить и запустить автоматизированные тестовые сценарии +assert_node_state → проверить, что свойства нод соответствуют ожидаемым значениям +assert_screen_text → проверить, что текст отображается на экране +compare_screenshots → визуальное регрессионное тестирование (используйте пути к файлам, не base64) +run_stress_test → создать множество нод для тестирования производительности +``` + +## Продвинутые паттерны + +### Операции между сценами +``` +cross_scene_set_property → изменить ноды в сценах, которые сейчас не открыты +find_node_references → найти все файлы, ссылающиеся на паттерн +batch_set_property → установить свойство для всех нод определённого типа +``` + +### Работа с шейдерами +``` +create_shader → написать шейдерный код в стиле GLSL +assign_shader_material → применить к ноде +set_shader_param → настроить uniform-параметры в runtime +get_shader_params → просмотреть текущие значения +``` + +### Навигация (3D) +``` +setup_navigation_region → определить проходимую область +bake_navigation_mesh → сгенерировать навигационную сетку +setup_navigation_agent → добавить поиск пути для персонажей +``` + +### AnimationTree и конечные автоматы +``` +create_animation_tree → настроить AnimationTree с конечным автоматом или деревом смешивания +add_state_machine_state → добавить состояния (idle, walk, run, jump) +add_state_machine_transition → определить переходы между состояниями +set_tree_parameter → управлять параметрами смешивания +``` + +## Рекомендуемый порядок работы + +При создании новой игры с нуля: + +1. **Настройка проекта** — `get_project_info`, `set_project_setting` (viewport, физика) +2. **Маппинг ввода** — `set_input_action` для всех управлений игрока +3. **Главная сцена** — `create_scene`, установить как главную сцену +4. **Игрок** — создать сцену игрока со спрайтом, коллизией, скриптом +5. **Уровень/Мир** — построить окружение (TileMap, 3D-меши и т.д.) +6. **Игровая логика** — скрипты для врагов, предметов, UI +7. **Аудио** — настроить шины, добавить аудиоплееры +8. **Плейтестинг** — `play_scene`, тест с симулированным вводом, исправление багов +9. **Полировка** — анимации, частицы, шейдеры, темы +10. **Экспорт** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.zh.md b/addons/godot_mcp/skills.zh.md new file mode 100644 index 0000000..3d5116f --- /dev/null +++ b/addons/godot_mcp/skills.zh.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | 简体中文 | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — AI 助手技能指南 + +> 将此文件复制到 Godot 项目根目录的 `.claude/skills.md`,以便 Claude Code 获得如何高效使用 Godot MCP Pro 的完整上下文。 + +## 什么是 Godot MCP Pro? + +你可以使用 169 个 MCP 工具直接连接 Godot 4 编辑器。你可以创建场景、编写脚本、模拟玩家输入、检查运行中的游戏等等——所有操作都无需用户离开当前对话。每次更改都通过 Godot 的 UndoRedo 系统进行,因此用户随时可以 Ctrl+Z 撤销。 + +## 核心工作流 + +### 1. 探索项目 + +在进行更改之前,先了解项目全貌: + +``` +get_project_info → 项目名称、Godot 版本、渲染器、视口大小 +get_filesystem_tree → 目录结构(可使用 filter: "*.tscn" 或 "*.gd") +get_scene_tree → 当前打开场景的节点层级 +read_script → 读取任意 GDScript 文件 +get_project_settings → 检查项目配置 +``` + +### 2. 构建 2D 场景 + +``` +create_scene → 创建 .tscn 文件并指定根节点类型 +add_node → 添加带属性的子节点 +create_script → 编写游戏逻辑的 GDScript +attach_script → 将脚本附加到节点 +update_property → 设置 position、scale、modulate 等 +save_scene → 保存到磁盘 +``` + +**示例——创建玩家:** +1. `create_scene` 设置 root_type 为 `CharacterBody2D`,path 为 `res://scenes/player.tscn` +2. `add_node` 类型 `Sprite2D` 并设置 texture 属性 +3. `add_node` 类型 `CollisionShape2D` +4. `add_resource` 为 CollisionShape2D 分配形状(如 `RectangleShape2D`) +5. `create_script` 编写移动逻辑 +6. `attach_script` 附加到根节点 +7. `save_scene` + +### 3. 构建 3D 场景 + +``` +create_scene → root_type: Node3D +add_mesh_instance → 添加基础体(box、sphere、cylinder、plane)或导入 .glb/.gltf +setup_lighting → 添加 DirectionalLight3D、OmniLight3D 或 SpotLight3D +setup_environment → 天空、环境光、雾、色调映射 +setup_camera_3d → 摄像机(可选 SpringArm3D 实现第三人称视角) +set_material_3d → PBR 材质(albedo、metallic、roughness、emission) +setup_collision → 为物理体添加碰撞形状 +setup_physics_body → 配置质量、摩擦力、重力 +``` + +### 4. 编写和编辑脚本 + +``` +create_script → 创建新的 .gd 文件(提供完整内容) +edit_script → 修改现有脚本 + - 使用 `replacements: [{search: "old code", replace: "new code"}]` 进行定向编辑 + - 使用 `content` 完整替换文件 + - 使用 `insert_at_line` + `text` 插入代码 +validate_script → 不运行即可检查语法错误 +read_script → 编辑前读取当前内容 +``` + +### 5. 测试与调试 + +``` +play_scene → 启动游戏(mode: "current"、"main" 或文件路径) +get_game_screenshot → 查看游戏当前画面 +capture_frames → 捕获多帧以观察运动/动画 +get_game_scene_tree → 检查运行时的场景树 +get_game_node_properties → 读取运行时数值(position、health、state 等) +set_game_node_property → 修改运行中游戏的数值 +simulate_key → 按键(WASD、SPACE 等)并指定持续时间 +simulate_mouse_click → 在视口坐标处点击 +simulate_action → 触发 InputMap 动作(move_left、jump 等) +get_editor_errors → 检查运行时错误 +stop_scene → 停止游戏 +``` + +**测试循环:** +1. `play_scene` → 启动游戏 +2. `get_game_screenshot` → 查看当前状态 +3. `simulate_key` / `simulate_action` → 与游戏交互 +4. `capture_frames` → 观察一段时间内的行为 +5. `get_game_node_properties` → 检查特定数值 +6. `stop_scene` → 完成后停止 +7. 修复脚本问题 → 重复 + +### 6. 动画 + +``` +# 确保场景中存在 AnimationPlayer 节点 +create_animation → 创建带时长和循环模式的新动画 +add_animation_track → 添加 property/transform/method 轨道 +set_animation_keyframe → 在特定时间插入关键帧 +get_animation_info → 查看现有动画信息 +``` + +**示例——弹跳精灵:** +1. `create_animation` name 为 `bounce`,length 为 `1.0`,loop_mode 为 `1`(线性循环) +2. `add_animation_track` track_path 为 `Sprite2D:position`,track_type 为 `value` +3. `set_animation_keyframe` time 为 `0.0`,value 为 `Vector2(0, 0)` +4. `set_animation_keyframe` time 为 `0.5`,value 为 `Vector2(0, -50)` +5. `set_animation_keyframe` time 为 `1.0`,value 为 `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control、Label、Button、TextureRect 等 +set_anchor_preset → 定位 Control(full_rect、center、bottom_wide 等) +set_theme_color → 修改 font_color 等 +set_theme_font_size → 调整文字大小 +set_theme_stylebox → 背景、边框、圆角 +connect_signal → 连接 button 的 pressed、value_changed 等信号 +``` + +### 8. TileMap + +``` +tilemap_get_info → 检查图块集来源和图集布局 +tilemap_set_cell → 放置单个图块 +tilemap_fill_rect → 填充矩形区域 +tilemap_get_used_cells → 查看已放置的内容 +tilemap_clear → 清除所有单元格 +``` + +### 9. 音频 + +``` +add_audio_bus → 创建音频总线(SFX、Music、UI) +set_audio_bus → 调整音量、独奏、静音 +add_audio_bus_effect → 添加混响、延迟、压缩器等 +add_audio_player → 添加 AudioStreamPlayer(2D/3D) 节点 +``` + +### 10. 项目配置 + +``` +set_project_setting → 修改视口大小、物理设置等 +set_input_action → 定义输入映射(move_left → KEY_A 等) +add_autoload → 注册 autoload 单例 +set_physics_layers → 命名碰撞层(player、enemy、world 等) +``` + +## 重要规则与注意事项 + +### 属性值 +属性会从字符串自动解析。使用以下格式: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` 或 `"#ff0000"` +- Bool: `"true"` / `"false"` +- 数字: `"42"`、`"3.14"` +- 枚举: 使用整数值(例如第一个枚举值用 `0`) + +### 不要直接编辑 project.godot +Godot 编辑器会不断覆盖 `project.godot`。修改项目设置请务必使用 `set_project_setting`。 + +### GDScript 类型注解 +在对无类型数组使用 `for` 循环时,请使用显式类型注解: +```gdscript +# 错误——会导致报错 +for item in some_untyped_array: + var x := item.value # 类型推断失败 + +# 正确 +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### 脚本更改需要重新加载 +创建或大幅修改脚本后,使用 `reload_project` 确保 Godot 识别更改。在 `create_script` 之后尤其重要。 + +### simulate_key 技巧 +- 精确移动使用**短持续时间**(0.3-0.5 秒) +- 长持续时间(1 秒以上)会导致过冲 +- 游戏测试时,如果已定义 InputMap 动作,优先使用 `simulate_action` 而非 `simulate_key` + +### simulate_mouse_click +- 默认 `auto_release: true` 会同时发送按下和释放事件——UI 按钮必须如此 +- UI 按钮在释放时触发,因此两个事件都必不可少 + +### execute_game_script 限制 +- 不支持嵌套函数(`func` 中的 `func`)——会导致编译错误 +- 动态访问请使用 `.get("property")` 而非 `.property` +- 运行时错误会暂停调试器(会自动继续,但尽量避免) + +### 碰撞与拾取区域 +- 可收集物品请使用 Area3D/Area2D 并设置半径 >= 1.5 +- 较小的半径几乎无法通过模拟输入触发 + +### 经常保存 +进行重大更改后请调用 `save_scene`。未保存的更改可能在编辑器重新加载时丢失。 + +## 分析与调试工具 + +出现问题时,使用以下工具进行排查: + +``` +get_editor_errors → 检查脚本错误和运行时异常 +get_output_log → 读取 print() 输出和警告 +analyze_scene_complexity → 查找性能瓶颈 +analyze_signal_flow → 可视化信号连接 +detect_circular_dependencies → 查找脚本/场景的循环引用 +find_unused_resources → 清理未使用的文件 +get_performance_monitors → FPS、内存、绘制调用、物理统计 +``` + +## 测试与 QA + +``` +run_test_scenario → 定义并运行自动化测试序列 +assert_node_state → 验证节点属性是否匹配预期值 +assert_screen_text → 验证文本是否显示在屏幕上 +compare_screenshots → 视觉回归测试(使用文件路径,不要用 base64) +run_stress_test → 生成大量节点以测试性能 +``` + +## 高级模式 + +### 跨场景操作 +``` +cross_scene_set_property → 修改当前未打开场景中的节点 +find_node_references → 查找引用某个模式的所有文件 +batch_set_property → 为某类型的所有节点设置属性 +``` + +### 着色器工作流 +``` +create_shader → 编写类 GLSL 的着色器代码 +assign_shader_material → 应用到节点 +set_shader_param → 在运行时调整 uniform 参数 +get_shader_params → 查看当前值 +``` + +### 导航(3D) +``` +setup_navigation_region → 定义可行走区域 +bake_navigation_mesh → 生成导航网格 +setup_navigation_agent → 为角色添加寻路功能 +``` + +### AnimationTree 与状态机 +``` +create_animation_tree → 使用状态机或混合树设置 AnimationTree +add_state_machine_state → 添加状态(idle、walk、run、jump) +add_state_machine_transition → 定义状态之间的过渡 +set_tree_parameter → 控制混合参数 +``` + +## 推荐工作流顺序 + +从零开始构建新游戏时: + +1. **项目设置** — `get_project_info`、`set_project_setting`(视口、物理) +2. **输入映射** — `set_input_action` 定义所有玩家控制 +3. **主场景** — `create_scene`,设为主场景 +4. **玩家** — 创建包含精灵、碰撞、脚本的玩家场景 +5. **关卡/世界** — 构建环境(TileMap、3D 网格等) +6. **游戏逻辑** — 敌人、道具、UI 的脚本 +7. **音频** — 设置总线、添加音频播放器 +8. **测试** — `play_scene`,使用模拟输入测试,修复 bug +9. **打磨** — 动画、粒子、着色器、主题 +10. **导出** — `list_export_presets`、`export_project` diff --git a/addons/godot_mcp/ui/status_panel.gd b/addons/godot_mcp/ui/status_panel.gd new file mode 100644 index 0000000..ace37d1 --- /dev/null +++ b/addons/godot_mcp/ui/status_panel.gd @@ -0,0 +1,382 @@ +@tool +extends VBoxContainer + +var websocket_server: Node = null +var command_router: Node = null + +const MAX_LOG_ENTRIES := 200 +const COLOR_CONNECTED := Color(0.2, 0.9, 0.2) +const COLOR_DISCONNECTED := Color(0.9, 0.2, 0.2) +const COLOR_STALE := Color(1.0, 0.7, 0.2) +const COLOR_SUCCESS := Color(0.6, 1, 0.6) +const COLOR_ERROR := Color(1, 0.6, 0.6) +const COLOR_DIM := Color(0.6, 0.6, 0.6) + +const BASE_PORT := 6505 +const MAX_PORT := 6509 + +# Header +var _status_icon: Label +var _status_label: Label +var _client_count_label: Label + +# Tabs +var _tab_container: TabContainer + +# Activity tab +var _show_details_check: CheckBox +var _log_container: VBoxContainer +var _log_scroll: ScrollContainer + +# Clients tab +var _port_labels: Dictionary = {} # port -> {icon: Label, label: Label} + +# Tools tab +var _filter_edit: LineEdit +var _tools_container: VBoxContainer +var _tool_checkboxes: Dictionary = {} # method_name -> CheckBox + + +func _ready() -> void: + _build_ui() + + +func setup(ws_server: Node, cmd_router: Node = null) -> void: + websocket_server = ws_server + command_router = cmd_router + + if websocket_server: + websocket_server.client_connected.connect(_on_client_connected) + websocket_server.client_disconnected.connect(_on_client_disconnected) + if websocket_server.has_signal("command_completed"): + websocket_server.command_completed.connect(_on_command_completed) + else: + websocket_server.command_executed.connect(_on_command_executed) + + if command_router: + _populate_tools_list() + + +func _build_ui() -> void: + # Header bar + var header := HBoxContainer.new() + add_child(header) + + _status_icon = Label.new() + _status_icon.text = "●" + _status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + header.add_child(_status_icon) + + _status_label = Label.new() + _status_label.text = " MCP Pro: Waiting for connection..." + header.add_child(_status_label) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(spacer) + + _client_count_label = Label.new() + _client_count_label.text = "Clients: 0" + header.add_child(_client_count_label) + + # Separator + var sep := HSeparator.new() + add_child(sep) + + # TabContainer + _tab_container = TabContainer.new() + _tab_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(_tab_container) + + _build_activity_tab() + _build_clients_tab() + _build_tools_tab() + + +func _build_activity_tab() -> void: + var vbox := VBoxContainer.new() + vbox.name = "Activity" + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tab_container.add_child(vbox) + + # Controls row + var controls := HBoxContainer.new() + vbox.add_child(controls) + + _show_details_check = CheckBox.new() + _show_details_check.text = "Show Response Details" + _show_details_check.button_pressed = false + _show_details_check.toggled.connect(_on_show_details_toggled) + controls.add_child(_show_details_check) + + var ctrl_spacer := Control.new() + ctrl_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + controls.add_child(ctrl_spacer) + + var clear_btn := Button.new() + clear_btn.text = "Clear" + clear_btn.pressed.connect(_on_clear_log) + controls.add_child(clear_btn) + + # Log scroll + _log_scroll = ScrollContainer.new() + _log_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _log_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _log_scroll.custom_minimum_size.y = 80 + vbox.add_child(_log_scroll) + + _log_container = VBoxContainer.new() + _log_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _log_scroll.add_child(_log_container) + + +func _build_clients_tab() -> void: + var vbox := VBoxContainer.new() + vbox.name = "Clients" + _tab_container.add_child(vbox) + + for p in range(BASE_PORT, MAX_PORT + 1): + var row := HBoxContainer.new() + vbox.add_child(row) + + var icon := Label.new() + icon.text = "○" + icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + row.add_child(icon) + + var lbl := Label.new() + lbl.text = " Port %d — Disconnected" % p + row.add_child(lbl) + + _port_labels[p] = {"icon": icon, "label": lbl} + + +func _build_tools_tab() -> void: + var vbox := VBoxContainer.new() + vbox.name = "Tools" + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tab_container.add_child(vbox) + + # Controls + var controls := HBoxContainer.new() + vbox.add_child(controls) + + _filter_edit = LineEdit.new() + _filter_edit.placeholder_text = "Filter tools..." + _filter_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _filter_edit.text_changed.connect(_on_filter_changed) + controls.add_child(_filter_edit) + + var enable_all_btn := Button.new() + enable_all_btn.text = "Enable All" + enable_all_btn.pressed.connect(_on_enable_all) + controls.add_child(enable_all_btn) + + var disable_all_btn := Button.new() + disable_all_btn.text = "Disable All" + disable_all_btn.pressed.connect(_on_disable_all) + controls.add_child(disable_all_btn) + + # Scroll + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.custom_minimum_size.y = 80 + vbox.add_child(scroll) + + _tools_container = VBoxContainer.new() + _tools_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(_tools_container) + + +func _populate_tools_list() -> void: + if not command_router: + return + + # Clear existing + for child in _tools_container.get_children(): + child.queue_free() + _tool_checkboxes.clear() + + var methods: Array = command_router.get_available_methods() + methods.sort() + + for method_name: String in methods: + var cb := CheckBox.new() + cb.text = method_name + cb.button_pressed = not command_router.is_tool_disabled(method_name) + cb.toggled.connect(_on_tool_toggled.bind(method_name)) + _tools_container.add_child(cb) + _tool_checkboxes[method_name] = cb + + +func _process(_delta: float) -> void: + if not websocket_server: + return + + var count: int = websocket_server.get_client_count() + _client_count_label.text = "Clients: %d" % count + + var any_stale := false + if websocket_server.has_method("is_port_stale"): + for p in range(BASE_PORT, MAX_PORT + 1): + if websocket_server.is_port_stale(p): + any_stale = true + break + + if count > 0: + _status_icon.add_theme_color_override("font_color", COLOR_CONNECTED) + _status_label.text = " MCP Pro: Connected" + elif any_stale: + _status_icon.add_theme_color_override("font_color", COLOR_STALE) + _status_label.text = " MCP Pro: ⚠ Reconnecting (stale connection detected)..." + else: + _status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + _status_label.text = " MCP Pro: Waiting for connection..." + + # Update clients tab + _update_clients_tab() + + +func _update_clients_tab() -> void: + var connected_ports: Array[int] = [] + if websocket_server.has_method("get_connected_ports"): + connected_ports = websocket_server.get_connected_ports() + + for p: int in _port_labels: + var info: Dictionary = _port_labels[p] + var icon: Label = info["icon"] + var lbl: Label = info["label"] + + var is_stale := false + if websocket_server.has_method("is_port_stale"): + is_stale = websocket_server.is_port_stale(p) + + if p in connected_ports: + var time_str := "" + if websocket_server.has_method("get_port_connect_time"): + var elapsed: float = websocket_server.get_port_connect_time(p) + if elapsed >= 0: + var mins := int(elapsed) / 60 + var secs := int(elapsed) % 60 + time_str = " (%dm %02ds)" % [mins, secs] + + var idle_str := "" + if websocket_server.has_method("get_port_idle_time"): + var idle: float = websocket_server.get_port_idle_time(p) + if idle >= 2.0: + idle_str = " · idle %.0fs" % idle + + icon.text = "●" + icon.add_theme_color_override("font_color", COLOR_CONNECTED) + lbl.text = " Port %d — Connected%s%s" % [p, time_str, idle_str] + elif is_stale: + icon.text = "◐" + icon.add_theme_color_override("font_color", COLOR_STALE) + lbl.text = " Port %d — ⚠ Stale (reconnecting)" % p + else: + icon.text = "○" + icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + lbl.text = " Port %d — Disconnected" % p + + +# --- Activity callbacks --- + +func _on_client_connected() -> void: + _add_log("Client connected", COLOR_CONNECTED) + + +func _on_client_disconnected() -> void: + _add_log("Client disconnected", COLOR_DISCONNECTED) + + +func _on_command_executed(method: String, ok: bool) -> void: + var color := COLOR_SUCCESS if ok else COLOR_ERROR + var status_icon := "OK" if ok else "ERR" + _add_log("[%s] %s" % [status_icon, method], color) + + +func _on_command_completed(method: String, ok: bool, response: String, source_port: int) -> void: + var color := COLOR_SUCCESS if ok else COLOR_ERROR + var status_icon := "OK" if ok else "ERR" + _add_log("[%s] %s (port %d)" % [status_icon, method, source_port], color, response) + + +func _on_clear_log() -> void: + for child in _log_container.get_children(): + child.queue_free() + + +func _on_show_details_toggled(on: bool) -> void: + for entry in _log_container.get_children(): + if entry is VBoxContainer and entry.get_child_count() > 1: + entry.get_child(1).visible = on + + +func _add_log(text: String, color: Color = Color.WHITE, response: String = "") -> void: + if _log_container == null: + return + + var entry := VBoxContainer.new() + _log_container.add_child(entry) + + var label := Label.new() + var time_str := Time.get_time_string_from_system() + label.text = "[%s] %s" % [time_str, text] + label.add_theme_color_override("font_color", color) + label.add_theme_font_size_override("font_size", 12) + entry.add_child(label) + + if not response.is_empty(): + var detail := RichTextLabel.new() + var preview := response.substr(0, 500) + if response.length() > 500: + preview += "..." + detail.text = preview + detail.fit_content = true + detail.scroll_active = false + detail.add_theme_color_override("default_color", COLOR_DIM) + detail.add_theme_font_size_override("normal_font_size", 11) + detail.custom_minimum_size.y = 0 + detail.visible = _show_details_check.button_pressed if _show_details_check else false + entry.add_child(detail) + + # Limit entries + while _log_container.get_child_count() > MAX_LOG_ENTRIES: + var old: Node = _log_container.get_child(0) + _log_container.remove_child(old) + old.queue_free() + + # Auto scroll to bottom + _auto_scroll.call_deferred() + + +func _auto_scroll() -> void: + if _log_scroll: + _log_scroll.scroll_vertical = int(_log_scroll.get_v_scroll_bar().max_value) + + +# --- Tools callbacks --- + +func _on_filter_changed(filter: String) -> void: + for method_name: String in _tool_checkboxes: + var cb: CheckBox = _tool_checkboxes[method_name] + cb.visible = filter.is_empty() or method_name.containsn(filter) + + +func _on_tool_toggled(enabled: bool, method_name: String) -> void: + if command_router and command_router.has_method("set_tool_disabled"): + command_router.set_tool_disabled(method_name, not enabled) + + +func _on_enable_all() -> void: + if command_router and command_router.has_method("set_all_tools_disabled"): + command_router.set_all_tools_disabled(false) + for method_name: String in _tool_checkboxes: + _tool_checkboxes[method_name].set_pressed_no_signal(true) + + +func _on_disable_all() -> void: + if command_router and command_router.has_method("set_all_tools_disabled"): + command_router.set_all_tools_disabled(true) + for method_name: String in _tool_checkboxes: + _tool_checkboxes[method_name].set_pressed_no_signal(false) diff --git a/addons/godot_mcp/ui/status_panel.gd.uid b/addons/godot_mcp/ui/status_panel.gd.uid new file mode 100644 index 0000000..e64eaba --- /dev/null +++ b/addons/godot_mcp/ui/status_panel.gd.uid @@ -0,0 +1 @@ +uid://by5xvwsbrofb diff --git a/addons/godot_mcp/ui/status_panel.tscn b/addons/godot_mcp/ui/status_panel.tscn new file mode 100644 index 0000000..9343333 --- /dev/null +++ b/addons/godot_mcp/ui/status_panel.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://addons/godot_mcp/ui/status_panel.gd" id="1"] + +[node name="MCPStatusPanel" type="VBoxContainer"] +offset_right = 600.0 +offset_bottom = 300.0 +script = ExtResource("1") diff --git a/addons/godot_mcp/utils/node_utils.gd b/addons/godot_mcp/utils/node_utils.gd new file mode 100644 index 0000000..58fca55 --- /dev/null +++ b/addons/godot_mcp/utils/node_utils.gd @@ -0,0 +1,80 @@ +@tool +extends RefCounted + +## Recursively set owner for all children (needed when adding nodes via code) +static func set_owner_recursive(node: Node, owner: Node) -> void: + for child in node.get_children(): + child.owner = owner + set_owner_recursive(child, owner) + + +## Get a simplified tree structure from a node. +## Paths are scene-relative (usable as node_path input for other tools) — +## node.get_path() would leak the editor's internal dock hierarchy. +static func get_node_tree(node: Node, root: Node = null, max_depth: int = -1, current_depth: int = 0) -> Dictionary: + if root == null: + root = node + var result := { + "name": node.name, + "type": node.get_class(), + "path": "." if node == root else str(root.get_path_to(node)), + } + + # Add script info + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + # Add children + if max_depth == -1 or current_depth < max_depth: + var children: Array = [] + for child in node.get_children(): + children.append(get_node_tree(child, root, max_depth, current_depth + 1)) + if not children.is_empty(): + result["children"] = children + + return result + + +## Get all properties of a node as a serializable dictionary +static func get_node_properties_dict(node: Node) -> Dictionary: + var PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + var result: Dictionary = {} + var property_list := node.get_property_list() + + for prop_info in property_list: + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + + # Only include user-facing properties (PROPERTY_USAGE_EDITOR) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + + # Skip internal/meta properties + if prop_name.begins_with("_") or prop_name in ["script"]: + continue + + var value: Variant = node.get(prop_name) + result[prop_name] = PropertyParser.serialize_value(value) + + return result + + +## Duplicate a node and all its children, properly setting owners +static func duplicate_node_in_scene(node: Node, new_name: String, root: Node) -> Node: + var dup := node.duplicate() + dup.name = new_name + node.get_parent().add_child(dup) + dup.owner = root + set_owner_recursive(dup, root) + return dup + + +## Find node by class type in subtree +static func find_nodes_by_type(root: Node, type_name: String) -> Array[Node]: + var result: Array[Node] = [] + if root.get_class() == type_name or root.is_class(type_name): + result.append(root) + for child in root.get_children(): + result.append_array(find_nodes_by_type(child, type_name)) + return result diff --git a/addons/godot_mcp/utils/node_utils.gd.uid b/addons/godot_mcp/utils/node_utils.gd.uid new file mode 100644 index 0000000..316b9b5 --- /dev/null +++ b/addons/godot_mcp/utils/node_utils.gd.uid @@ -0,0 +1 @@ +uid://b0vs2krneteax diff --git a/addons/godot_mcp/utils/property_parser.gd b/addons/godot_mcp/utils/property_parser.gd new file mode 100644 index 0000000..728678d --- /dev/null +++ b/addons/godot_mcp/utils/property_parser.gd @@ -0,0 +1,218 @@ +@tool +extends RefCounted + +## Parse a string value into the appropriate Godot type +static func parse_value(value: Variant, target_type: int = TYPE_NIL) -> Variant: + if value == null: + return null + + # If already the correct type, return as-is + if target_type == TYPE_NIL: + return _auto_parse(value) + + match target_type: + TYPE_BOOL: + if value is bool: return value + if value is String: return value.to_lower() in ["true", "1", "yes"] + return bool(value) + TYPE_INT: + return int(value) + TYPE_FLOAT: + return float(value) + TYPE_STRING: + return str(value) + TYPE_VECTOR2: + return _parse_vector2(value) + TYPE_VECTOR2I: + return _parse_vector2i(value) + TYPE_VECTOR3: + return _parse_vector3(value) + TYPE_VECTOR3I: + return _parse_vector3i(value) + TYPE_RECT2: + return _parse_rect2(value) + TYPE_COLOR: + return _parse_color(value) + TYPE_NODE_PATH: + return NodePath(str(value)) + TYPE_ARRAY: + if value is Array: return value + return [value] + TYPE_DICTIONARY: + if value is Dictionary: return value + return {} + TYPE_OBJECT: + if value is Object: + return value + if value is String: + var s: String = value + if (s.begins_with("res://") or s.begins_with("uid://")) and ResourceLoader.exists(s): + return ResourceLoader.load(s) + # Unresolvable string for an Object-typed property: return null so + # callers can detect the failure instead of coercing to null silently. + return null + return value + _: + return value + + +static func _auto_parse(value: Variant) -> Variant: + if not value is String: + return value + + var s: String = value + + # Boolean + if s == "true": return true + if s == "false": return false + + # Integer + if s.is_valid_int(): return s.to_int() + + # Float + if s.is_valid_float(): return s.to_float() + + # Vector2: "Vector2(x, y)" or "(x, y)" or "x, y" + if s.begins_with("Vector2(") or s.begins_with("Vector2i("): + return _parse_vector2(s) + + # Vector3 + if s.begins_with("Vector3(") or s.begins_with("Vector3i("): + return _parse_vector3(s) + + # Color + if s.begins_with("Color(") or s.begins_with("#"): + return _parse_color(s) + + # Rect2 + if s.begins_with("Rect2("): + return _parse_rect2(s) + + # Resource path (covers unset Object properties, where typeof(null) is TYPE_NIL) + if (s.begins_with("res://") or s.begins_with("uid://")) and ResourceLoader.exists(s): + return ResourceLoader.load(s) + + return s + + +static func _extract_numbers(s: String) -> PackedFloat64Array: + # Remove type prefix and parentheses + var cleaned := s + for prefix in ["Vector3i(", "Vector3(", "Vector2i(", "Vector2(", "Rect2(", "Color(", "("]: + if cleaned.begins_with(prefix): + cleaned = cleaned.substr(prefix.length()) + break + cleaned = cleaned.trim_suffix(")") + cleaned = cleaned.strip_edges() + + var parts := cleaned.split(",") + var numbers: PackedFloat64Array = [] + for part in parts: + numbers.append(part.strip_edges().to_float()) + return numbers + + +static func _parse_vector2(value: Variant) -> Vector2: + if value is Vector2: return value + if value is Dictionary: + return Vector2(float(value.get("x", 0)), float(value.get("y", 0))) + var nums := _extract_numbers(str(value)) + if nums.size() >= 2: + return Vector2(nums[0], nums[1]) + return Vector2.ZERO + + +static func _parse_vector2i(value: Variant) -> Vector2i: + var v := _parse_vector2(value) + return Vector2i(int(v.x), int(v.y)) + + +static func _parse_vector3(value: Variant) -> Vector3: + if value is Vector3: return value + if value is Dictionary: + return Vector3(float(value.get("x", 0)), float(value.get("y", 0)), float(value.get("z", 0))) + var nums := _extract_numbers(str(value)) + if nums.size() >= 3: + return Vector3(nums[0], nums[1], nums[2]) + return Vector3.ZERO + + +static func _parse_vector3i(value: Variant) -> Vector3i: + var v := _parse_vector3(value) + return Vector3i(int(v.x), int(v.y), int(v.z)) + + +static func _parse_rect2(value: Variant) -> Rect2: + if value is Rect2: return value + if value is Dictionary: + return Rect2( + float(value.get("x", 0)), float(value.get("y", 0)), + float(value.get("w", value.get("width", 0))), + float(value.get("h", value.get("height", 0))) + ) + var nums := _extract_numbers(str(value)) + if nums.size() >= 4: + return Rect2(nums[0], nums[1], nums[2], nums[3]) + return Rect2() + + +static func _parse_color(value: Variant) -> Color: + if value is Color: return value + var s := str(value) + if s.begins_with("#"): + return Color.html(s) + if s.begins_with("Color("): + var nums := _extract_numbers(s) + match nums.size(): + 3: return Color(nums[0], nums[1], nums[2]) + 4: return Color(nums[0], nums[1], nums[2], nums[3]) + # Try named color + if Color.html_is_valid(s): + return Color.html(s) + return Color.WHITE + + +## Serialize a Variant to JSON-safe representation +static func serialize_value(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_VECTOR2: + var v: Vector2 = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR2I: + var v: Vector2i = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR3: + var v: Vector3 = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_VECTOR3I: + var v: Vector3i = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_RECT2: + var r: Rect2 = value + return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y} + TYPE_COLOR: + var c: Color = value + return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()} + TYPE_NODE_PATH: + return str(value) + TYPE_OBJECT: + if value is Resource: + var res: Resource = value + return {"type": res.get_class(), "path": res.resource_path} + return str(value) + TYPE_ARRAY: + var arr: Array = value + var result: Array = [] + for item in arr: + result.append(serialize_value(item)) + return result + TYPE_DICTIONARY: + var dict: Dictionary = value + var result: Dictionary = {} + for key in dict: + result[str(key)] = serialize_value(dict[key]) + return result + _: + return value diff --git a/addons/godot_mcp/utils/property_parser.gd.uid b/addons/godot_mcp/utils/property_parser.gd.uid new file mode 100644 index 0000000..fe9032e --- /dev/null +++ b/addons/godot_mcp/utils/property_parser.gd.uid @@ -0,0 +1 @@ +uid://bxtcaicg3ko7l diff --git a/addons/godot_mcp/websocket_server.gd b/addons/godot_mcp/websocket_server.gd new file mode 100644 index 0000000..d31010a --- /dev/null +++ b/addons/godot_mcp/websocket_server.gd @@ -0,0 +1,254 @@ +@tool +extends Node + +## Multi-connection WebSocket client. +## Connects to multiple Node.js MCP server instances on ports 6505-6514. +## Each Claude Code session gets its own port; Godot talks to all of them. +## Ports 6505-6509: MCP servers (stdio), 6510-6514: CLI tool connections. + +signal client_connected() +signal client_disconnected() +signal message_received(text: String) +signal command_executed(method: String, success: bool) +signal command_completed(method: String, success: bool, response: String, source_port: int) + +var command_router: Node + +const BASE_PORT := 6505 +const MAX_PORT := 6514 +const RECONNECT_INTERVAL := 3.0 +const BUFFER_SIZE := 16 * 1024 * 1024 # 16MB +const PING_INTERVAL := 5.0 # send ping every N seconds while connected +const INACTIVITY_TIMEOUT := 30.0 # force-close if no message received for N seconds + +# Per-port connection state +var _peers: Dictionary = {} # port -> WebSocketPeer +var _connected: Dictionary = {} # port -> bool +var _timers: Dictionary = {} # port -> float (reconnect countdown) +var _connect_times: Dictionary = {} # port -> float (elapsed seconds since connect) +var _last_activity: Dictionary = {} # port -> float (seconds since last received message) +var _ping_timers: Dictionary = {} # port -> float (seconds since last sent ping) +var _stale_ports: Dictionary = {} # port -> bool (heartbeat timeout flag, exposed to UI) +var _running: bool = false + + +func start_server() -> void: + _running = true + for p in range(BASE_PORT, MAX_PORT + 1): + _connected[p] = false + _timers[p] = 0.0 + _try_connect(p) + print("[MCP] Connecting to ports %d-%d" % [BASE_PORT, MAX_PORT]) + + +func stop_server() -> void: + _running = false + for p in _peers: + var ws: WebSocketPeer = _peers[p] + if ws: + ws.close(1000, "Plugin shutting down") + _peers.clear() + _connected.clear() + _timers.clear() + _last_activity.clear() + _ping_timers.clear() + _stale_ports.clear() + print("[MCP] WebSocket client stopped") + + +func get_client_count() -> int: + var count: int = 0 + for p in _connected: + if _connected[p]: + count += 1 + return count + + +func get_connected_ports() -> Array[int]: + var ports: Array[int] = [] + for p: int in _connected: + if _connected[p]: + ports.append(p) + return ports + + +func get_port_connect_time(port: int) -> float: + return _connect_times.get(port, -1.0) + + +func get_port_idle_time(port: int) -> float: + return _last_activity.get(port, -1.0) + + +func is_port_stale(port: int) -> bool: + return _stale_ports.get(port, false) + + +func _try_connect(p: int) -> void: + var ws := WebSocketPeer.new() + ws.outbound_buffer_size = BUFFER_SIZE + ws.inbound_buffer_size = BUFFER_SIZE + var err := ws.connect_to_url("ws://127.0.0.1:%d" % p) + if err == OK: + _peers[p] = ws + else: + _peers[p] = null + + +func _process(delta: float) -> void: + if not _running: + return + + for p in range(BASE_PORT, MAX_PORT + 1): + var ws: WebSocketPeer = _peers.get(p) + + # No peer - try reconnect on timer + if ws == null: + _timers[p] = _timers.get(p, 0.0) + delta + if _timers[p] >= RECONNECT_INTERVAL: + _timers[p] = 0.0 + _try_connect(p) + continue + + ws.poll() + var state := ws.get_ready_state() + + match state: + WebSocketPeer.STATE_OPEN: + if not _connected.get(p, false): + _connected[p] = true + _connect_times[p] = 0.0 + _last_activity[p] = 0.0 + _ping_timers[p] = 0.0 + _stale_ports[p] = false + _timers[p] = 0.0 + print_verbose("[MCP] Connected on port %d" % p) + client_connected.emit() + else: + _connect_times[p] = _connect_times.get(p, 0.0) + delta + _last_activity[p] = _last_activity.get(p, 0.0) + delta + _ping_timers[p] = _ping_timers.get(p, 0.0) + delta + + var received_any := false + while ws.get_available_packet_count() > 0: + var packet := ws.get_packet() + var text := packet.get_string_from_utf8() + received_any = true + _dispatch_message(text, p) + + if received_any: + _last_activity[p] = 0.0 + if _stale_ports.get(p, false): + _stale_ports[p] = false + print("[MCP] Port %d recovered from stale state" % p) + + # Force-close if no message received for INACTIVITY_TIMEOUT. + # The MCP server pings every 10s, so 30s of silence means the + # connection is half-open and reconnect is the only way out. + if _last_activity.get(p, 0.0) > INACTIVITY_TIMEOUT: + push_warning("[MCP] Port %d silent for %.1fs — forcing reconnect" % [p, _last_activity[p]]) + _stale_ports[p] = true + ws.close(4000, "Heartbeat timeout") + _connected[p] = false + _peers[p] = null + _timers[p] = 0.0 + client_disconnected.emit() + continue + + # Send periodic ping so the server can detect our death too, + # and so any reply resets our own inactivity timer. + if _ping_timers.get(p, 0.0) >= PING_INTERVAL: + _ping_timers[p] = 0.0 + ws.send_text(JSON.stringify({"jsonrpc": "2.0", "method": "ping", "params": {}})) + + WebSocketPeer.STATE_CLOSING: + pass + + WebSocketPeer.STATE_CLOSED: + if _connected.get(p, false): + _connected[p] = false + print_verbose("[MCP] Disconnected from port %d" % p) + client_disconnected.emit() + _peers[p] = null + _timers[p] = 0.0 + _last_activity[p] = 0.0 + _ping_timers[p] = 0.0 + + WebSocketPeer.STATE_CONNECTING: + pass + + +func _send_to_port(p: int, text: String) -> void: + var ws: WebSocketPeer = _peers.get(p) + if ws and _connected.get(p, false): + ws.send_text(text) + + +func send_message(text: String) -> void: + # Broadcast to all connected peers + for p in _peers: + _send_to_port(p, text) + + +## Synchronous dispatch - parse JSON, handle ping/pong, queue command execution +func _dispatch_message(text: String, source_port: int) -> void: + message_received.emit(text) + + var json := JSON.new() + var err := json.parse(text) + if err != OK: + _send_response(source_port, null, null, {"code": -32700, "message": "Parse error"}) + return + + var msg: Variant = json.data + if not msg is Dictionary: + _send_response(source_port, null, null, {"code": -32600, "message": "Invalid request"}) + return + + var msg_dict: Dictionary = msg + + if msg_dict.get("method") == "ping": + _send_to_port(source_port, JSON.stringify({"jsonrpc": "2.0", "method": "pong", "params": {}})) + return + + if msg_dict.get("method") == "pong": + return + + var id: Variant = msg_dict.get("id") + var method: String = msg_dict.get("method", "") + var params: Dictionary = msg_dict.get("params", {}) + + if method.is_empty(): + _send_response(source_port, id, null, {"code": -32600, "message": "Missing method"}) + return + + if not command_router: + _send_response(source_port, id, null, {"code": -32603, "message": "No command router"}) + return + + _execute_command.call_deferred(source_port, id, method, params) + + +func _execute_command(source_port: int, id: Variant, method: String, params: Dictionary) -> void: + var cmd_result: Dictionary = await command_router.execute(method, params) + if cmd_result.has("error"): + var err_data: Variant = cmd_result["error"] + _send_response(source_port, id, null, err_data) + var response_text := JSON.stringify(err_data) + command_executed.emit(method, false) + command_completed.emit(method, false, response_text, source_port) + else: + var result_data: Variant = cmd_result.get("result", {}) + _send_response(source_port, id, result_data, null) + var response_text := JSON.stringify(result_data) + command_executed.emit(method, true) + command_completed.emit(method, true, response_text, source_port) + + +func _send_response(source_port: int, id: Variant, result: Variant, err: Variant) -> void: + var response: Dictionary = {"jsonrpc": "2.0", "id": id} + if err != null: + response["error"] = err + else: + response["result"] = result if result != null else {} + _send_to_port(source_port, JSON.stringify(response)) diff --git a/addons/godot_mcp/websocket_server.gd.uid b/addons/godot_mcp/websocket_server.gd.uid new file mode 100644 index 0000000..7506384 --- /dev/null +++ b/addons/godot_mcp/websocket_server.gd.uid @@ -0,0 +1 @@ +uid://bv7imd44hcnwy diff --git a/addons/spell_editor/plugin.cfg b/addons/spell_editor/plugin.cfg new file mode 100644 index 0000000..2e569b4 --- /dev/null +++ b/addons/spell_editor/plugin.cfg @@ -0,0 +1,6 @@ +[plugin] +name="Spell Editor (法术编辑器)" +description="可视化法术编辑器:策划/开发无需改代码即可新增、编辑、删除法术,保存为 res://data/spells.json,游戏运行时自动加载。" +author="Base Games" +version="1.0.0" +script="spell_editor.gd" diff --git a/addons/spell_editor/spell_dock.gd b/addons/spell_editor/spell_dock.gd new file mode 100644 index 0000000..8799ad6 --- /dev/null +++ b/addons/spell_editor/spell_dock.gd @@ -0,0 +1,214 @@ +## 法术编辑器停靠面板(@tool)— 策划/开发可视化新增·编辑·删除法术 +## 数据持久化到 res://data/spells.json,游戏运行时由 SpellRegistry 加载 +@tool +extends VBoxContainer + +const DATA_PATH: String = "res://data/spells.json" +const TYPE_NAMES: Array = ["ACTION", "MODIFIER", "TRIGGER", "LOGIC"] + +## meta 模板(按类型“插入模板”用) +const META_TEMPLATES: Dictionary = { + 0: '{ "base_damage": 5.0, "speed": 350.0, "lifetime": 4.0, "radius": 6.0, "damage_type": 0, "shop_cost": 20 }', + 1: '{ "damage_add": 10.0, "shop_cost": 20 }', + 2: '{ "base_damage": 3.0, "speed": 250.0, "lifetime": 4.0, "radius": 6.0, "damage_type": 0, "shop_cost": 30 }', + 3: '{ "logic_op": "every_n_shots", "n": 3, "reg": 0, "shop_cost": 24 }', +} + +var _spells: Dictionary = {} # { id: {type, display_name, description, element_tags, meta} } +var _cur_id: String = "" + +# UI 引用 +var _list: ItemList +var _id_edit: LineEdit +var _type_opt: OptionButton +var _name_edit: LineEdit +var _desc_edit: LineEdit +var _tags_edit: LineEdit +var _meta_edit: TextEdit +var _status: Label + +func _ready() -> void: + _build_ui() + _load_data() + _refresh_list() + +func _build_ui() -> void: + custom_minimum_size = Vector2(300, 400) + add_theme_constant_override("separation", 4) + + var title := Label.new() + title.text = "✨ 法术编辑器" + title.add_theme_font_size_override("font_size", 16) + add_child(title) + + # 法术列表 + _list = ItemList.new() + _list.custom_minimum_size = Vector2(0, 120) + _list.size_flags_vertical = Control.SIZE_EXPAND_FILL + _list.item_selected.connect(_on_select) + add_child(_list) + + var row := HBoxContainer.new() + add_child(row) + row.add_child(_btn("➕ 新增", _on_new)) + row.add_child(_btn("⧉ 复制", _on_dup)) + row.add_child(_btn("🗑 删除", _on_delete)) + + add_child(HSeparator.new()) + + # 表单 + _id_edit = _field("ID(唯一)") + _type_opt = OptionButton.new() + for n in TYPE_NAMES: + _type_opt.add_item(n) + _labeled("类型", _type_opt) + _name_edit = _field("显示名(tr 键或文本)") + _desc_edit = _field("描述(tr 键或文本)") + _tags_edit = _field("元素标签(逗号分隔,如 tag:water)") + + var meta_lbl := Label.new() + meta_lbl.text = "meta(JSON,因类型而异)" + add_child(meta_lbl) + _meta_edit = TextEdit.new() + _meta_edit.custom_minimum_size = Vector2(0, 90) + _meta_edit.placeholder_text = '{ "base_damage": 5.0, ... }' + add_child(_meta_edit) + add_child(_btn("插入该类型 meta 模板", _on_template)) + + add_child(HSeparator.new()) + + var save_row := HBoxContainer.new() + add_child(save_row) + save_row.add_child(_btn("✓ 应用到列表", _on_apply)) + save_row.add_child(_btn("💾 保存文件", _on_save_file)) + save_row.add_child(_btn("↻ 重载", func(): _load_data(); _refresh_list(); _set_status("已重载"))) + + _status = Label.new() + _status.add_theme_font_size_override("font_size", 11) + _status.modulate = Color(0.6, 0.9, 0.6) + add_child(_status) + +func _btn(text: String, cb: Callable) -> Button: + var b := Button.new() + b.text = text + b.pressed.connect(cb) + return b + +func _field(placeholder: String) -> LineEdit: + var le := LineEdit.new() + le.placeholder_text = placeholder + add_child(le) + return le + +func _labeled(label: String, ctrl: Control) -> void: + var l := Label.new() + l.text = label + add_child(l) + add_child(ctrl) + +# ── 数据 ──────────────────────────────────────────────────── +func _load_data() -> void: + _spells = {} + if FileAccess.file_exists(DATA_PATH): + var parsed = JSON.parse_string(FileAccess.get_file_as_string(DATA_PATH)) + if parsed is Dictionary: + _spells = parsed + +func _refresh_list() -> void: + _list.clear() + var ids: Array = _spells.keys() + ids.sort() + for sid in ids: + var t: int = int(_spells[sid].get("type", 0)) + _list.add_item("[%s] %s" % [TYPE_NAMES[clampi(t, 0, 3)], sid]) + _list.set_item_metadata(_list.item_count - 1, sid) + +func _on_select(idx: int) -> void: + _cur_id = String(_list.get_item_metadata(idx)) + var d: Dictionary = _spells.get(_cur_id, {}) + _id_edit.text = _cur_id + _type_opt.selected = clampi(int(d.get("type", 0)), 0, 3) + _name_edit.text = String(d.get("display_name", "")) + _desc_edit.text = String(d.get("description", "")) + _tags_edit.text = ",".join(d.get("element_tags", [])) + _meta_edit.text = JSON.stringify(d.get("meta", {}), " ") + +func _on_apply() -> void: + var sid: String = _id_edit.text.strip_edges() + if sid == "": + _set_status("✗ ID 不能为空", true); return + var meta = JSON.parse_string(_meta_edit.text) + if not (meta is Dictionary): + _set_status("✗ meta 不是合法 JSON 对象", true); return + var tags: Array = [] + for t in _tags_edit.text.split(",", false): + var s: String = t.strip_edges() + if s != "": + tags.append(s) + # 改了 ID:移除旧键 + if _cur_id != "" and _cur_id != sid and _spells.has(_cur_id): + _spells.erase(_cur_id) + _spells[sid] = { + "type": _type_opt.selected, + "display_name": _name_edit.text, + "description": _desc_edit.text, + "element_tags": tags, + "meta": meta, + } + _cur_id = sid + _refresh_list() + _set_status("✓ 已应用『%s』到列表(记得保存文件)" % sid) + +func _on_save_file() -> void: + DirAccess.make_dir_recursive_absolute("res://data") + var f := FileAccess.open(DATA_PATH, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(_spells, " ")) + f.close() + _set_status("💾 已保存 %d 条法术 → %s" % [_spells.size(), DATA_PATH]) + else: + _set_status("✗ 写入失败", true) + +func _on_new() -> void: + var base := "new_spell" + var sid := base + var i := 1 + while _spells.has(sid): + sid = "%s_%d" % [base, i]; i += 1 + _spells[sid] = {"type": 0, "display_name": sid, "description": "", + "element_tags": [], "meta": JSON.parse_string(META_TEMPLATES[0])} + _refresh_list() + _select_id(sid) + +func _on_dup() -> void: + if _cur_id == "" or not _spells.has(_cur_id): + return + var sid := _cur_id + "_copy" + var i := 1 + while _spells.has(sid): + sid = "%s_copy%d" % [_cur_id, i]; i += 1 + _spells[sid] = _spells[_cur_id].duplicate(true) + _refresh_list() + _select_id(sid) + +func _on_delete() -> void: + if _cur_id != "" and _spells.has(_cur_id): + _spells.erase(_cur_id) + _cur_id = "" + _refresh_list() + _set_status("已删除(记得保存文件)") + +func _on_template() -> void: + _meta_edit.text = String(META_TEMPLATES.get(_type_opt.selected, "{}")) + +func _select_id(sid: String) -> void: + for i in _list.item_count: + if String(_list.get_item_metadata(i)) == sid: + _list.select(i) + _on_select(i) + return + +func _set_status(msg: String, is_err: bool = false) -> void: + if _status: + _status.text = msg + _status.modulate = Color(1.0, 0.5, 0.5) if is_err else Color(0.6, 0.9, 0.6) diff --git a/addons/spell_editor/spell_dock.gd.uid b/addons/spell_editor/spell_dock.gd.uid new file mode 100644 index 0000000..a95158d --- /dev/null +++ b/addons/spell_editor/spell_dock.gd.uid @@ -0,0 +1 @@ +uid://dkk2bspn83h8s diff --git a/addons/spell_editor/spell_editor.gd b/addons/spell_editor/spell_editor.gd new file mode 100644 index 0000000..15ec956 --- /dev/null +++ b/addons/spell_editor/spell_editor.gd @@ -0,0 +1,17 @@ +## 法术编辑器插件入口(EditorPlugin) +## 在编辑器左下停靠区添加「法术编辑器」面板。 +@tool +extends EditorPlugin + +var _dock: Control = null + +func _enter_tree() -> void: + _dock = preload("res://addons/spell_editor/spell_dock.gd").new() + _dock.name = "法术编辑器" + add_control_to_dock(EditorPlugin.DOCK_SLOT_LEFT_BR, _dock) + +func _exit_tree() -> void: + if _dock: + remove_control_from_docks(_dock) + _dock.free() + _dock = null diff --git a/addons/spell_editor/spell_editor.gd.uid b/addons/spell_editor/spell_editor.gd.uid new file mode 100644 index 0000000..e948aca --- /dev/null +++ b/addons/spell_editor/spell_editor.gd.uid @@ -0,0 +1 @@ +uid://qmds48f8kkm0 diff --git a/assets/generated/game_start_icon.png b/assets/generated/game_start_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4247e969553655bb5bde3a0108f12f961096199b GIT binary patch literal 253803 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
}G8F7wq1&LM~<=j@d8gY_ieSKSs4y!)!=OEPLRE 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

v3H^59SN4xtI=9^=OT#+Hniesc0 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 literal 0 HcmV?d00001 diff --git a/assets/generated/game_start_icon.png.import b/assets/generated/game_start_icon.png.import new file mode 100644 index 0000000..fc31a13 --- /dev/null +++ b/assets/generated/game_start_icon.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjxnqft0cvp01" +path="res://.godot/imported/game_start_icon.png-dcc9bffbbd0cc1acf2d899209d6f9f3b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/generated/game_start_icon.png" +dest_files=["res://.godot/imported/game_start_icon.png-dcc9bffbbd0cc1acf2d899209d6f9f3b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/ui_theme.tres b/assets/ui/ui_theme.tres new file mode 100644 index 0000000..8e7b02f --- /dev/null +++ b/assets/ui/ui_theme.tres @@ -0,0 +1,84 @@ +[gd_resource type="Theme" load_steps=6 format=3 uid="uid://ui_theme_main"] + +[sub_resource type="StyleBoxFlat" id="btn_normal"] +bg_color = Color(0.12, 0.13, 0.2, 0.95) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.32, 0.34, 0.48, 0.55) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +content_margin_left = 10.0 +content_margin_top = 6.0 +content_margin_right = 10.0 +content_margin_bottom = 6.0 + +[sub_resource type="StyleBoxFlat" id="btn_hover"] +bg_color = Color(0.21, 0.23, 0.36, 0.98) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.55, 0.6, 0.95, 0.85) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +content_margin_left = 10.0 +content_margin_top = 6.0 +content_margin_right = 10.0 +content_margin_bottom = 6.0 + +[sub_resource type="StyleBoxFlat" id="btn_pressed"] +bg_color = Color(0.08, 0.09, 0.14, 1.0) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.45, 0.5, 0.8, 0.9) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +content_margin_left = 10.0 +content_margin_top = 6.0 +content_margin_right = 10.0 +content_margin_bottom = 6.0 + +[sub_resource type="StyleBoxFlat" id="btn_disabled"] +bg_color = Color(0.1, 0.1, 0.13, 0.5) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +content_margin_left = 10.0 +content_margin_top = 6.0 +content_margin_right = 10.0 +content_margin_bottom = 6.0 + +[sub_resource type="StyleBoxFlat" id="panel_box"] +bg_color = Color(0.07, 0.07, 0.13, 0.97) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3, 0.32, 0.5, 0.5) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[resource] +Button/colors/font_color = Color(0.88, 0.9, 1.0, 1) +Button/colors/font_hover_color = Color(1, 1, 1, 1) +Button/colors/font_pressed_color = Color(0.7, 0.75, 1.0, 1) +Button/colors/font_disabled_color = Color(0.5, 0.5, 0.55, 1) +Button/font_sizes/font_size = 16 +Button/styles/disabled = SubResource("btn_disabled") +Button/styles/hover = SubResource("btn_hover") +Button/styles/normal = SubResource("btn_normal") +Button/styles/pressed = SubResource("btn_pressed") +PanelContainer/styles/panel = SubResource("panel_box") diff --git a/csharp/autoloads/BulletManagerCs.cs b/csharp/autoloads/BulletManagerCs.cs new file mode 100644 index 0000000..1080dad --- /dev/null +++ b/csharp/autoloads/BulletManagerCs.cs @@ -0,0 +1,41 @@ +// BulletManagerCs — 子弹 SoA 热路径 C# 内核(S0 骨架) +// S0 范围:帧时间监控 + AsSpan() 沟通基线验证 +// 完整积分热路径在 S1 展开(见 development_plan.md S1) +// ADR-L1 规厙1:内层循环禁止 Call() | 规厙2:热数据通过 AsSpan() 零拷贝 +using Godot; +using System; + +public partial class BulletManagerCs : Node +{ + private GodotObject _gdBulletManager; + + // S0 性能计时(P-S0-07 帧预算回填) + private double _accumMs; + private int _frames; + + public override void _Ready() + { + _gdBulletManager = Engine.GetSingleton("BulletManager"); + // S0 骨架不接管积分,保持 _cs_node = null,让 GDScript fallback 运行 + GD.Print("[BulletManagerCs] S0 骨架就绪,GDScript fallback 活跞"); + } + + public override void _PhysicsProcess(double delta) + { + if (_gdBulletManager is null) return; + var t0 = Time.GetTicksUsec(); + + // S0:仅记录帧时间(积分由 GDScript fallback 负责) + var activeCount = _gdBulletManager.Get("_active_count").AsInt32(); + _ = activeCount; + + _accumMs += (Time.GetTicksUsec() - t0) / 1000.0; + _frames++; + if (_frames >= 60) + { + GD.Print($"[BulletManagerCs] 帧时间均倦: {_accumMs / _frames:F3} ms " + + $"(active={activeCount} TODO S1: 启用完整热路径)"); + _accumMs = 0; _frames = 0; + } + } +} diff --git a/csharp/autoloads/BulletManagerCs.cs.uid b/csharp/autoloads/BulletManagerCs.cs.uid new file mode 100644 index 0000000..8ea4b71 --- /dev/null +++ b/csharp/autoloads/BulletManagerCs.cs.uid @@ -0,0 +1 @@ +uid://d305dubg5veqd diff --git a/csharp/autoloads/EnemyManagerCs.cs b/csharp/autoloads/EnemyManagerCs.cs new file mode 100644 index 0000000..01b9145 --- /dev/null +++ b/csharp/autoloads/EnemyManagerCs.cs @@ -0,0 +1,36 @@ +// EnemyManagerCs — 敌人 SoA 热路径 C# 内核(S0 骨架) +// S0 范围:帧时间监控,完整 Boid 积分在 S1 展开 +using Godot; +using System; + +public partial class EnemyManagerCs : Node +{ + private GodotObject _gdEnemyManager; + private double _accumMs; + private int _frames; + + public override void _Ready() + { + _gdEnemyManager = Engine.GetSingleton("EnemyManager"); + // S0 骨架不接管积分,保持 _cs_node = null,让 GDScript fallback 运行 + GD.Print("[EnemyManagerCs] S0 骨架就绪,GDScript fallback 活跞"); + } + + public override void _PhysicsProcess(double delta) + { + if (_gdEnemyManager is null) return; + var t0 = Time.GetTicksUsec(); + + var activeCount = _gdEnemyManager.Get("_active_count").AsInt32(); + _ = activeCount; + + _accumMs += (Time.GetTicksUsec() - t0) / 1000.0; + _frames++; + if (_frames >= 60) + { + GD.Print($"[EnemyManagerCs] 帧时间均倦: {_accumMs / _frames:F3} ms " + + $"(active={activeCount} TODO S1: 启用 Boid 热路径)"); + _accumMs = 0; _frames = 0; + } + } +} diff --git a/csharp/autoloads/EnemyManagerCs.cs.uid b/csharp/autoloads/EnemyManagerCs.cs.uid new file mode 100644 index 0000000..8cb318b --- /dev/null +++ b/csharp/autoloads/EnemyManagerCs.cs.uid @@ -0,0 +1 @@ +uid://c8c4u4etym08g diff --git a/csharp/systems/SpatialGridCs.cs b/csharp/systems/SpatialGridCs.cs new file mode 100644 index 0000000..f88e90b --- /dev/null +++ b/csharp/systems/SpatialGridCs.cs @@ -0,0 +1,98 @@ +// SpatialGridCs — 2D 空间哈希网格 C# 实现 +// 职责:固定 Cell 网格 dirty-list 方案重建 + query_circle +// 权威:architecture_design.md §4.3, ADR-L1 +// +// ADR-L1 规厙1:内层循环体内禁止 GodotObject.Call() / .Set() +using Godot; +using System; +using System.Collections.Generic; + +public partial class SpatialGridCs : Node +{ + private const int CellSize = 64; + private const int GridWidth = 64; // 64 格×64 格 = 4096×4096 像素覆盖范围 + private const int GridHeight = 64; + private const int GridTotal = GridWidth * GridHeight; + + // 网格主体:一维数组模拟二维,直接索引 grid[x + y * GridWidth] + private readonly List[] _grid = new List[GridTotal]; + + // dirty-list:本帧有变化的格子索引 + private readonly HashSet _dirty = new(); + + // 实体位置缓存:entity_id → cell_index + private readonly Dictionary _entityCell = new(); + + private GodotObject _gdSpatialGrid; + + public override void _Ready() + { + for (int i = 0; i < GridTotal; i++) + _grid[i] = new List(4); + + _gdSpatialGrid = Engine.GetSingleton("SpatialGrid"); + _gdSpatialGrid.Set("_cs_node", this); + + GD.Print($"[SpatialGridCs] 初始化完成,{GridWidth}×{GridHeight} 格,格子 {CellSize} px"); + } + + // 插入或更新实体位置(由 EnemyManager 每帧调用) + public void Insert(int entityId, Vector2 pos) + { + int cx = Mathf.Clamp((int)(pos.X / CellSize), 0, GridWidth - 1); + int cy = Mathf.Clamp((int)(pos.Y / CellSize), 0, GridHeight - 1); + int cellIdx = cx + cy * GridWidth; + + if (_entityCell.TryGetValue(entityId, out int oldCell)) + { + if (oldCell == cellIdx) return; // 未跨格 + _grid[oldCell].Remove(entityId); + _dirty.Add(oldCell); + } + _grid[cellIdx].Add(entityId); + _entityCell[entityId] = cellIdx; + _dirty.Add(cellIdx); + } + + // 重建(仅清空 dirty 集合) + public void Rebuild() => _dirty.Clear(); + + // 圆形查询:返回 center 半径 radius 内的所有 entity_id + // ADR-L1 规厙1:内层 foreach 内禁止 Call() + public int[] QueryCircle(Vector2 center, float radius) + { + int minCx = Mathf.Clamp((int)((center.X - radius) / CellSize), 0, GridWidth - 1); + int maxCx = Mathf.Clamp((int)((center.X + radius) / CellSize), 0, GridWidth - 1); + int minCy = Mathf.Clamp((int)((center.Y - radius) / CellSize), 0, GridHeight - 1); + int maxCy = Mathf.Clamp((int)((center.Y + radius) / CellSize), 0, GridHeight - 1); + + var result = new List(32); + float radSq = radius * radius; + + for (int cy = minCy; cy <= maxCy; cy++) + for (int cx = minCx; cx <= maxCx; cx++) + { + // ADR-L1 规厙1:内层循环约束—不得调用 GodotObject + foreach (int id in _grid[cx + cy * GridWidth]) + result.Add(id); + } + return result.ToArray(); + } + + // 移除实体 + public void Remove(int entityId) + { + if (!_entityCell.TryGetValue(entityId, out int cell)) return; + _grid[cell].Remove(entityId); + _entityCell.Remove(entityId); + _dirty.Add(cell); + } + + // 清空(关卡重置) + public void Clear() + { + foreach (var cell in _grid) cell.Clear(); + _entityCell.Clear(); + _dirty.Clear(); + } +} diff --git a/csharp/systems/SpatialGridCs.cs.uid b/csharp/systems/SpatialGridCs.cs.uid new file mode 100644 index 0000000..7495aaa --- /dev/null +++ b/csharp/systems/SpatialGridCs.cs.uid @@ -0,0 +1 @@ +uid://d3j8ulrpquwkh diff --git a/data/balance.json b/data/balance.json new file mode 100644 index 0000000..2d5c2df --- /dev/null +++ b/data/balance.json @@ -0,0 +1,12 @@ +{ + "difficulty_mults": { + "enemy_hp": [0.7, 1.0, 1.0], + "player_dmg": [0.7, 1.0, 1.0], + "wave_count": [1.0, 1.0, 1.2], + "boss_hp": [1.0, 1.0, 1.3] + }, + "boss_hp": { + "4": 450.0, + "5": 1800.0 + } +} diff --git a/data/cores.json b/data/cores.json new file mode 100644 index 0000000..5687cf9 --- /dev/null +++ b/data/cores.json @@ -0,0 +1,7 @@ +{ + "wand_basic": { "display_name": "小木法杯", "slot_count": 5, "topology": 0, "cpu_limit": 5, "cast_interval": 0.5, "feature_tags": 0 }, + "wand_fast": { "display_name": "高速法杯", "slot_count": 3, "topology": 0, "cpu_limit": 3, "cast_interval": 0.25, "feature_tags": 0 }, + "wand_memory": { "display_name": "记忆法杖", "slot_count": 6, "topology": 0, "cpu_limit": 6, "cast_interval": 0.5, "feature_tags": 1 }, + "matrix_board": { "display_name": "矩阵板", "slot_count": 8, "topology": 1, "cpu_limit": 8, "cast_interval": 0.5, "feature_tags": 0, "grid_rows": 2, "grid_cols": 4 }, + "circuit_fork": { "display_name": "分叉回路", "slot_count": 3, "topology": 2, "cpu_limit": 5, "cast_interval": 0.6, "feature_tags": 0, "edges": [{"from": 0, "to": 1}, {"from": 0, "to": 2}] } +} diff --git a/data/enemies.json b/data/enemies.json new file mode 100644 index 0000000..b440d86 --- /dev/null +++ b/data/enemies.json @@ -0,0 +1,8 @@ +{ + "0": { "name": "杂兵 Basic", "speed": 80, "armor": 0, "size": 14, "color": [0.90, 0.32, 0.30] }, + "1": { "name": "快速 Fast", "speed": 140, "armor": 0, "size": 11, "color": [0.95, 0.72, 0.20] }, + "2": { "name": "护甲 Armored", "speed": 60, "armor": 5, "size": 16, "color": [0.52, 0.54, 0.62] }, + "3": { "name": "精英 Elite", "speed": 90, "armor": 3, "size": 22, "color": [1.00, 0.50, 0.12] }, + "4": { "name": "首领 MiniBoss", "speed": 55, "armor": 8, "size": 48, "color": [0.92, 0.22, 0.85] }, + "5": { "name": "终焉 Boss", "speed": 45, "armor": 14, "size": 76, "color": [0.85, 0.10, 0.12] } +} diff --git a/data/resonance.json b/data/resonance.json new file mode 100644 index 0000000..7cefeda --- /dev/null +++ b/data/resonance.json @@ -0,0 +1,9 @@ +[ + { + "id": "plasma_storm", + "pattern": ["tag:water", "tag:lightning"], + "match": "adjacent", + "result_spell_id": "action_plasma_storm", + "consume_inputs": true + } +] diff --git a/data/spells.json b/data/spells.json new file mode 100644 index 0000000..3327381 --- /dev/null +++ b/data/spells.json @@ -0,0 +1,22 @@ +{ + "action_spark_bolt": { "type": 0, "display_name": "Spark Bolt", "description": "发射一颗电光飞弹,造成 3 伤害。", "element_tags": [], "meta": { "base_damage": 3.0, "speed": 350.0, "lifetime": 4.0, "radius": 6.0, "damage_type": 0, "shop_cost": 15 } }, + "action_energy_orb": { "type": 0, "display_name": "Energy Orb", "description": "较慢但高伤害的能量球。", "element_tags": [], "meta": { "base_damage": 8.0, "speed": 160.0, "lifetime": 5.0, "radius": 10.0, "damage_type": 0, "shop_cost": 25 } }, + "action_fire_bolt": { "type": 0, "display_name": "Fire Bolt", "description": "火焰飞弹,命中施加燃烧。", "element_tags": ["tag:fire"], "meta": { "base_damage": 4.0, "speed": 320.0, "lifetime": 4.0, "radius": 6.0, "damage_type": 0, "apply_status_id": 1, "shop_cost": 22 } }, + "action_poison_dart": { "type": 0, "display_name": "Poison Dart", "description": "毒镖,命中叠加中毒层数。", "element_tags": ["tag:poison"], "meta": { "base_damage": 2.0, "speed": 400.0, "lifetime": 3.0, "radius": 5.0, "damage_type": 0, "apply_status_id": 3, "shop_cost": 20 } }, + "action_laser_beam": { "type": 0, "display_name": "Laser Beam", "description": "激光,命中叠加连击标记。", "element_tags": [], "meta": { "base_damage": 1.0, "speed": 500.0, "lifetime": 2.0, "radius": 4.0, "damage_type": 0, "apply_combo_mark": true, "shop_cost": 28 } }, + "action_water_wave": { "type": 0, "display_name": "Water Wave", "description": "水波弹(水元素),与雷电共鸣成等离子风暴。", "element_tags": ["tag:water"], "meta": { "base_damage": 2.0, "speed": 280.0, "lifetime": 3.0, "radius": 8.0, "damage_type": 0, "shop_cost": 18 } }, + "action_chain_bolt": { "type": 0, "display_name": "Chain Bolt", "description": "连锁闪电(雷元素),与水共鸣成等离子风暴。", "element_tags": ["tag:lightning"], "meta": { "base_damage": 3.0, "speed": 420.0, "lifetime": 3.0, "radius": 6.0, "damage_type": 0, "shop_cost": 20 } }, + "action_plasma_storm": { "type": 0, "display_name": "Plasma Storm", "description": "等离子风暴(水+雷共鸣产物):高速大伤害弹。", "element_tags": ["tag:water", "tag:lightning"], "meta": { "base_damage": 18.0, "speed": 500.0, "lifetime": 3.5, "radius": 14.0, "damage_type": 0, "shop_cost": 0 } }, + "action_frost_bolt": { "type": 0, "display_name": "Frost Bolt", "description": "冰霜飞弹,命中冻结敌人。", "element_tags": ["tag:ice"], "meta": { "base_damage": 6.0, "speed": 360.0, "lifetime": 4.0, "radius": 7.0, "damage_type": 0, "apply_status_id": 2, "shop_cost": 24 } }, + "action_poison_pool": { "type": 0, "display_name": "Poison Pool", "description": "在施法点生成毒池,每秒施加中毒,持续 5s。", "element_tags": ["tag:poison"], "meta": { "action_kind": "zone", "radius": 200.0, "status_id": 3, "duration": 5.0, "tick_interval": 1.0, "shop_cost": 28 } }, + "action_summon_turret": { "type": 0, "display_name": "Summon Turret","description": "召唤炮台,定点向最近敌人开火,存活 20s。", "element_tags": [], "meta": { "action_kind": "summon", "lifetime": 20.0, "range": 350.0, "fire_interval": 0.8, "base_damage": 4.0, "speed": 360.0, "damage_type": 0, "shop_cost": 35 } }, + "modifier_damage_plus": { "type": 1, "display_name": "Damage +10", "description": "后续法术伤害 +10。", "element_tags": [], "meta": { "damage_add": 10.0, "shop_cost": 20 } }, + "modifier_spread_mod": { "type": 1, "display_name": "Spread", "description": "后续法术多发一颗子弹。", "element_tags": [], "meta": { "spread_add": 1, "shop_cost": 18 } }, + "modifier_double_cast": { "type": 1, "display_name": "Double Cast", "description": "本次施法额外执行 1 个 ACTION。", "element_tags": [], "meta": { "multicast": 1, "shop_cost": 25 } }, + "modifier_pierce_plus": { "type": 1, "display_name": "Pierce +2", "description": "后续法术穿透 +2。", "element_tags": [], "meta": { "pierce": 2, "shop_cost": 18 } }, + "trigger_on_hit": { "type": 2, "display_name": "Trigger: On Hit","description": "命中时在命中点执行后续法术(子母弹)。", "element_tags": [], "meta": { "base_damage": 3.0, "speed": 250.0, "lifetime": 4.0, "radius": 6.0, "damage_type": 0, "shop_cost": 30 } }, + "logic_every_n_shots": { "type": 3, "display_name": "Every 3 Shots","description": "每第 3 次施法才执行后续(需记忆法杖)。", "element_tags": [], "meta": { "logic_op": "every_n_shots", "n": 3, "reg": 0, "shop_cost": 24 } }, + "logic_if_hp_below": { "type": 3, "display_name": "If HP < 50%", "description": "仅当生命低于 50% 时执行后续。", "element_tags": [], "meta": { "logic_op": "if_hp_below", "threshold": 0.5, "shop_cost": 22 } }, + "logic_if_enemy_nearby":{ "type": 3, "display_name": "If Enemy Near","description": "仅当 200 范围内有敌人时执行后续。", "element_tags": [], "meta": { "logic_op": "if_enemy_nearby", "range": 200.0, "shop_cost": 22 } }, + "logic_loop": { "type": 3, "display_name": "Loop x2", "description": "将后续法术重复执行 2 次。", "element_tags": [], "meta": { "logic_op": "loop", "count": 2, "shop_cost": 30 } } +} diff --git a/data/status_effects.json b/data/status_effects.json new file mode 100644 index 0000000..7844599 --- /dev/null +++ b/data/status_effects.json @@ -0,0 +1,5 @@ +{ + "1": { "name": "Burn", "duration": 3.0, "tick_interval": 1.0, "stack_mode": 0, "max_stacks": 1, "dot_damage": 5.0, "dot_damage_type": 0, "vfx_id": "status_burn", "is_combo_tracker": false }, + "3": { "name": "Poison", "duration": 4.0, "tick_interval": 1.0, "stack_mode": 1, "max_stacks": 10, "dot_damage": 3.0, "dot_damage_type": 0, "vfx_id": "status_poison", "is_combo_tracker": false }, + "7": { "name": "Combo Mark", "duration": 2.0, "tick_interval": 999.0, "stack_mode": 1, "max_stacks": 0, "dot_damage": 0.0, "dot_damage_type": 0, "vfx_id": "", "is_combo_tracker": true } +} diff --git a/data/waves.json b/data/waves.json new file mode 100644 index 0000000..dbc4cd8 --- /dev/null +++ b/data/waves.json @@ -0,0 +1,22 @@ +[ + { "count": 20, "hp": 15, "type": 0, "elite": 0, "gold": 10, "xp": 20, "boss": -1 }, + { "count": 25, "hp": 20, "type": 0, "elite": 0, "gold": 15, "xp": 25, "boss": -1 }, + { "count": 30, "hp": 26, "type": 0, "elite": 0, "gold": 20, "xp": 30, "boss": -1 }, + { "count": 34, "hp": 32, "type": 1, "elite": 0, "gold": 25, "xp": 40, "boss": -1 }, + { "count": 40, "hp": 38, "type": 0, "elite": 0, "gold": 30, "xp": 50, "boss": -1 }, + { "count": 36, "hp": 55, "type": 2, "elite": 0, "gold": 36, "xp": 60, "boss": -1 }, + { "count": 42, "hp": 60, "type": 2, "elite": 0, "gold": 42, "xp": 72, "boss": -1 }, + { "count": 12, "hp": 30, "type": 0, "elite": 0, "gold": 80, "xp": 120, "boss": 4 }, + { "count": 48, "hp": 70, "type": 2, "elite": 0, "gold": 48, "xp": 90, "boss": -1 }, + { "count": 55, "hp": 80, "type": 1, "elite": 0, "gold": 60, "xp": 110, "boss": -1 }, + { "count": 64, "hp": 90, "type": 0, "elite": 0, "gold": 60, "xp": 120, "boss": -1 }, + { "count": 72, "hp": 100, "type": 2, "elite": 0, "gold": 66, "xp": 130, "boss": -1 }, + { "count": 80, "hp": 110, "type": 1, "elite": 0, "gold": 72, "xp": 140, "boss": -1 }, + { "count": 88, "hp": 120, "type": 0, "elite": 0, "gold": 78, "xp": 150, "boss": -1 }, + { "count": 80, "hp": 130, "type": 0, "elite": 6, "gold": 90, "xp": 170, "boss": -1 }, + { "count": 88, "hp": 140, "type": 2, "elite": 8, "gold": 96, "xp": 185, "boss": -1 }, + { "count": 96, "hp": 150, "type": 1, "elite": 10, "gold": 104, "xp": 200, "boss": -1 }, + { "count": 104, "hp": 165, "type": 0, "elite": 12, "gold": 112, "xp": 220, "boss": -1 }, + { "count": 112, "hp": 180, "type": 2, "elite": 16, "gold": 120, "xp": 240, "boss": -1 }, + { "count": 16, "hp": 60, "type": 3, "elite": 4, "gold": 300, "xp": 500, "boss": 5 } +] diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e5f8d5e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,141 @@ +# 魔法工匠 (Arcane Artificer) - 文档索引 + +> Roguelite 动作射击游戏 | Godot 4.x + GDScript + +--- + +## 目录结构 + +``` +docs/ + README.md 本文件,文档导航索引 + design/ 游戏设计文档 + technical/ 技术架构文档 + mechanics/ 机制细节设计 + plan/ 垂直切片开发计划与平台认证清单 +``` + +--- + +## 工程计划 (`plan/`) + +| 文档 | 内容简述 | +| :--- | :--- | +| [development_plan.md](plan/development_plan.md) | S0–S6 骨架垂直切片、风险登记、验收与出口检查、跨切片约束、参考文档地图(与 `technical/` 架构对齐) | +| [certification_checklist.md](plan/certification_checklist.md) | Steam / Switch / 无障碍 / 发布前检查项,并映射到切片 | + +--- + +## 游戏设计 (`design/`) + +| 文档 | 内容简述 | +| :--- | :--- | +| [game_design.md](design/game_design.md) | 核心玩法 GDD:法术管道系统、游戏循环、构建示例、Endless 排行榜 | +| [numerical_design.md](design/numerical_design.md) | 数值策划:资源模型、XP 曲线公式、法术参数表、怪物成长曲线、Boss DPS 门槛、平衡性对策 | +| [core_wand_design.md](design/core_wand_design.md) | Core 设计:插槽拓扑、MATRIX 邻接加成效果表、Feature Tags 常量规范、dual_stream 奇数槽行为 | + +--- + +## 技术架构 (`technical/`) + +| 文档 | 内容简述 | +| :--- | :--- | +| [architecture_design.md](technical/architecture_design.md) | 系统分层架构(含 ZoneManager/VFXManager/CoreFeatureTag)、HMWS 法术系统设计、ECS-Lite、超大弹体 SpatialGrid 豁免、Feature Tags 常量 ADR、DamageType 枚举、自动瞄准算法、consume_inputs 掩码机制、技术栈选型 | +| [implementation_plan.md](technical/implementation_plan.md) | 各模块详细实现方案(含 VFXManager 正式定义)、EventBus 完整事件目录、SubPayloadRegistry 竞争条件保证、DamageContextPool 定义、PlayerManager 移动状态追踪、DPS 面板实现、开发阶段规划、关键技术难点预案 | + +--- + +## 机制细节 (`mechanics/`) + +| 文档 | 内容简述 | +| :--- | :--- | +| [combat_mechanics_depth.md](mechanics/combat_mechanics_depth.md) | 投射物溯源、连锁弹射、状态效果、伤害计算管线 | +| [combat_mechanics_extensions_v2.md](mechanics/combat_mechanics_extensions_v2.md) | 物理运动学、环境场、触发链、属性快照、Proc 系数 | +| [weapon_system_expansion.md](mechanics/weapon_system_expansion.md) | 轨迹修正器(正弦/回旋镖/环绕-双写优化)、触发器系统、元素交互 | +| [consecutive_hits_stacking.md](mechanics/consecutive_hits_stacking.md) | 连击窗口、叠加收益、Combo Tracker 数据结构设计、COMBO_MARK tick_interval 语义说明 | +| [advanced_mechanics_summons_and_environment.md](mechanics/advanced_mechanics_summons_and_environment.md) | 召唤体系(炮台/随从/卫星)、环境传播、地面效果、StatusID Autoload 常量规范 | + +--- + +## 核心设计理念 + +- **"Noita 遇上 Brotato"**:快节奏割草 × 深度法术编程 +- **法术管道 (Spell Pipeline)**:ACTION → MODIFIER → TRIGGER → LOGIC GATE 四类节点组合,实现图灵完备的武器逻辑 +- **数值哲学**:`构建强度 = (基础数值 × 修正系数) ^ 逻辑复杂度`,提供宽广边界而非限制玩家策略 + +--- + +## 关键规范速查 (Design Rules Quick Reference) + +| 规范 ID | 位置 | 内容摘要 | +| :--- | :--- | :--- | +| P6-N2 | architecture_design.md §4.2 | Homing 弹共用敌人位置快照 | +| P6-N3 | architecture_design.md §4.1 | SpellContext 池大小 = 32 | +| P6-N5 | implementation_plan.md §2.2 | CompiledDeck 多项式滚动哈希(顺序敏感,种子含 grid_cols,防换位误判) | +| P6-N6 | implementation_plan.md §2.1 | EventID 12 MINION_EXPIRED | +| P6-N10 | implementation_plan.md §2.2 | SubPayloadRegistry 竞争条件安全保证 | +| P6-N11 | architecture_design.md §4.3 | 超大弹体(radius>64px)豁免 SpatialGrid | +| P6-N12 | core_wand_design.md §3 | dual_stream 奇数槽前流多得中间槽 | +| P6-N13 | core_wand_design.md §2.2 | MATRIX 邻接加成效果表 | +| P6-N14 | consecutive_hits_stacking.md | COMBO_MARK tick_interval 语义重载说明 | +| P6-N15 | weapon_system_expansion.md §1.3 | 环绕弹先判断 is_orbiting 再决定是否积分 | +| P6-N16 | numerical_design.md §1.2 | XP 曲线:`⌊10 × 1.4^(level-1)⌋` | +| P6-N17 | numerical_design.md §5.D | W20 Boss 物理构建 DPS 门槛 | +| P6-N18 | numerical_design.md §5.E | infinite_spells + heavy_cost 叠加规则 | +| P6-N19 | game_design.md §8.2 | Endless 排行榜单整数双维度评分方案 | +| P6-N20 | architecture_design.md §3.4.A | _flatten_matrix 仅遍历 Row A,Row B 不追加为执行节点 | +| P6-N21 | architecture_design.md §8 ADR-R5-N1 | ZoneManager 首元素删除用循环前移,不用 remove_at() | +| P6-N22 | numerical_design.md §1.2 | XP 可行性验证:Level 20 为 20 波通关上限,Level 21+ 服务 Endless | +| P6-N23 | numerical_design.md §1.2 | 商店刷新费用公式:20 + (本波刷新次数 × 10)G,波次结束重置 | +| P6-N24 | architecture_design.md §4.2 | ProjectileDef(生成时模板)vs _bullet_contexts(运行时可变冷状态)职责分离 | +| P6-N25 | architecture_design.md §4.2 | _enemy_pos_snapshot 必须声明为类成员(预分配复用,禁止每帧 var 分配) | +| P6-N26 | architecture_design.md §8 ADR-R5-N1 | ZoneManager ZONE_STRIDE=8(含 tick_interval+tick_accum),spawn_zone 需 tick_interval 参数 | +| P6-N27 | implementation_plan.md §2.3.C | _visible_flags 类成员 PackedByteArray;has_point() 赋值须显式 int() 转换 | +| P6-N28 | implementation_plan.md §2.1 | DamageContextPool Autoload 定义(acquire/get_context/release) | +| P6-N29 | architecture_design.md §3.4.C | consume_inputs 运行时通过 _consumed 掩码实现,CompiledDeck 只读不修改 | +| P6-N30 | advanced_mechanics §3.2 | StatusID Autoload 常量规范(禁止 StatusType.XXX 枚举写法) | +| P6-N31 | architecture_design.md §3.2 | ProjectileDef.reset() 必须用 DamageType.PHYSICAL(禁止裸整数 0) | +| P6-N32 | architecture_design.md §3.2 | SpellContext 必须提供 reset() 方法;registers 不在 reset() 中清零(由 SpellEvaluator 按持久化策略决定) | +| P6-N33 | architecture_design.md §3.4.A | _flatten_circuit() 正式算法:Kahn 拓扑排序 + splitter 展开为 SubPayload LOGIC_FORK;环路时返回空 CompiledDeck | +| P6-N34 | combat_mechanics_depth.md §5 | BulletContext 禁止持有 calc_damage() 等伤害计算逻辑(SRP);弹射衰减由 EnemyManager.apply_damage() 用 bounce_count 计算 | +| P6-N35 | combat_mechanics_depth.md §4 / implementation_plan.md §2.1 | DamageContext 权威字段:base_damage/**mult**/damage_type/owner_id/source_tags/is_crit/pierce_rate;DamageContextPool.acquire() 含 is_crit+pierce_rate 参数;`reset()` 必须重置 mult=1.0 | +| P6-N36 | implementation_plan.md §2.1 | EventID.DASH_TRIGGERED = 13;负载: caster_id+stationary_time;发出方 PlayerManager,订阅方 SpellEvaluator+UIManager | +| P6-N37 | implementation_plan.md §2.2 | LOGIC_EVERY_N_SHOTS 必须读写 SpellContext.registers(跨帧持久),严禁使用 CastState.registers(每帧重置) | +| P6-N38 | implementation_plan.md §2.5.E | DPS 环形缓冲区:PackedFloat64Array+PackedFloat32Array,_RB_SIZE=256,O(1) 写入,消除 pop_front() O(N) 移位 | +| P6-N39 | consecutive_hits_stacking.md / advanced_mechanics §3.2 | StatusID.VULNERABILITY=8,StatusID.COMBO_MARK=7;新增 ID 从 9 开始递增 | +| P6-N40 | architecture_design.md §3.2 | SpellContext.stats 必须声明时初始化(= CastStats.new()),防止 reset() 空指针 | +| P6-N41 | architecture_design.md §3.2 | ProjectileDef.reset() 必须重置 spawn_position(防止池化复用携带上帧发射原点) | +| P6-N42 | core_wand_design.md §1 | CoreDefinition 新增 @export var edges: Array = [](CIRCUIT 有向边顶层字段);_flatten_circuit() 从 core.edges 读取,不在 slot_configs 内嵌 "edges" | +| P6-N43 | architecture_design.md §3.4.A | _flatten_circuit() 降级为 LINEAR 时使用 CompiledDeck.new(deck.nodes) 内联(_flatten_linear() 函数不存在) | +| P6-N44 | architecture_design.md §3.4.A | LOGIC_FORK 节点:type=LOGIC, id="LOGIC_FORK",meta "fork_branch_ids";_execute_logic 并行触发所有分支 SubPayload | +| P6-N45 | implementation_plan.md §2.3.C | ENEMY_STRIDE: int = 8 命名常量,与 BULLET_STRIDE=12 规范对齐 | +| P6-N46 | architecture_design.md §8 ADR-R5-N1 | ZoneManager swap-and-pop 移除时必须调用 VFXManager.play("zone_expire", ...) | +| P6-N47 | implementation_plan.md §2.3.A | MOVE_THRESHOLD_NORM = 0.067(归一化输入幅度,非像素/秒);DPS get_dps() 懒加载 _recalc_window,record_damage() 仅做 O(1) 写入 | +| P6-N48 | architecture_design.md §3.2 | SpellContext.registers 必须声明时初始化 `= PackedFloat32Array([0.0, 0.0, 0.0, 0.0])`(长度 4);未初始化则 registers[0] 触发 out-of-bounds 崩溃,fill(0.0) 对空数组无操作 | +| P6-N49 | implementation_plan.md §2.1 | DamageContext 必须独立为 damage_context.gd(class_name DamageContext);DamageContextPool 为独立 Autoload 文件,不声明 class_name;两者混于同一文件会导致 _pool: Array[DamageContext] 存入具有管理方法的池管理器实例(语义错误) | +| P6-N50 | numerical_design.md §2.2 | Spell Database 新增 damage_type 列;Projectile 类型法术必须显式指定 DamageType(如 chain_bolt=LIGHTNING, nuke=PHYSICAL);Modifier/Trigger/Multicast 填 — | +| P6-N51 | architecture_design.md §3.2 | SpellNode.type 声明为 `SpellType = SpellType.ACTION`(枚举类型注解);原 `int` 类型丢失编译期检查 | +| P6-N52 | architecture_design.md §3.4.A | `_flatten_circuit()` LOGIC_FORK 构造:每条出边必须单独 `SubPayloadRegistry.register([entry_node])`,禁止将所有分支入口节点合并到同一 SubPayload(否则并行分支退化为顺序执行) | +| P6-N53 | architecture_design.md §3.2 | SpellNode 新增 `element_tags: Array[String] = []`;共鸣系统 `_check_resonance()` 通过此字段匹配 `resonance_recipes.json` 中 `pattern` 数组的 `"tag:xxx"` 元素 | +| P6-N54 | implementation_plan.md §2.3.C | EnemyManager LOD 循环必须使用 `ENEMY_STRIDE`(= 8),禁止使用旧常量名 `STRIDE` | +| P6-N55 | core_wand_design.md §6 | `SpellEvaluator` 采用两阶段架构:`compile_wand()` 编译(法杖装备/换牌时) + `execute_compiled()` 执行(每次施法);`_flatten_circuit()` 必须传 `core.edges`,不是 `core.slot_configs` | +| P6-N56 | implementation_plan.md §2.2 | `SpellDeck.pop()` 简化版须含 `null` 安全边界;权威完整实现(含 `_consumed` 掩码)见 `architecture_design.md §3.4.C` | +| P6-N57 | architecture_design.md §3.4.A | `_flatten_circuit()` 分叉检测须用出度(`adj[slot_idx].size() > 1`),禁止依赖 "splitter" tag(tag 漏写时静默失效);"splitter" tag 仅保留为 UI 标注 | +| P6-N58 | architecture_design.md §3.4.A | LOGIC_FORK SubPayload 须用 `_collect_branch_path()` 收集完整分支路径(多节点链),禁止仅注册入口节点(否则后续节点全被丢弃);分叉节点自身法术先追加再插入 LOGIC_FORK | +| P6-N59 | implementation_plan.md §2.5.A | `DASH_TRIGGERED` 事件负载须包含 `stationary_time: float`(P6-N36 要求),代码中 `EventBus.emit` 必须同时传 `caster_id + stationary_time`,冲刺后立即清零 `_stationary_time` | +| P6-N60 | implementation_plan.md §2.5.D | VFXManager 必须维护 `_active_count: int` 计数器;`_pool_pop()` 时 +1,`_pool_return()` 时 -1;`play()` 中以 O(1) 方式检查上限,禁止遍历 pool 字典统计(O(N)) | +| P6-N61 | core_wand_design.md §6 | `execute_compiled(compiled, ctx, core)` 必须显式接收 `core: CoreDefinition` 参数;通过 `core.feature_tags` 判断持久内存策略;禁止使用 `ctx.core_feature_tags`(SpellContext 无此字段) | +| P6-N62 | combat_mechanics_depth.md §4 | DamageContext 新增 `mult: float = 1.0` 字段(伤害乘算系数);`reset()` 必须重置 `mult = 1.0`,否则池化复用时暴击系数残留 | +| P6-N63 | architecture_design.md §3.4.A | `_collect_branch_path()` 的 `in_degree` 参数必须传 Kahn 循环**前**的原始入度副本(`orig_in_degree = in_degree.duplicate()`,在建立 adj 表之后、Kahn BFS 之前);Kahn 结束后工作数组全为 0,用其做 `in_degree[nxt] <= 1` 汇聚检测恒成立,导致分支路径越界纳入主链节点 | +| P6-N64 | architecture_design.md §3.4.A | `_flatten_circuit()` 必须维护 `in_branch_payload: Dictionary`,`_collect_branch_path()` 每访问一个槽索引即写入;主链 `topo_order` 循环在追加节点前检查此字典并跳过,防止分支节点在 SubPayload(LOGIC_FORK 路径)和主链(topo_order 直接追加)中各执行一次 | +| P6-N65 | core_wand_design.md §6 | `compile_wand()` 参数类型修正:`slot_spells: Array[SpellNode]` → `raw_deck: SpellDeck`;`_flatten_matrix()` 与 `_flatten_circuit()` 均通过 `deck.nodes[i]` 访问节点,传入裸 Array 会触发"Invalid get index 'nodes'"运行时错误;LINEAR 路径同步改为 `CompiledDeck.new(raw_deck.nodes)` | +| P6-N66 | architecture_design.md §3.4.A | `_collect_branch_path()` 内若遇到嵌套分叉(`adj[cur].size() > 1`),必须递归为每条子边调用自身并注册 SubPayload,再注入嵌套 LOGIC_FORK 节点后 `break`;直接 `break` 会丢弃子分支全部节点 | +| P6-N67 | implementation_plan.md §2.1 | `DamageContextPool.acquire()` 必须在赋值任何字段前先调用 `ctx.reset()`,防止池化复用时残留 `source_tags`(如 TRIGGERED=2)污染新的伤害上下文 | +| P6-N68 | mechanics/consecutive_hits_stacking.md | `StatusTypeDef.can_catalyze` 类型为 `Array[int]`;`COMBO_MARK` 的正确值为 `[]`(空数组),写 `false`(bool)在 GDScript 4 严格类型模式下触发类型赋值错误 | +| P6-N69 | implementation_plan.md §2.5.A | 停步蓄力采用边沿检测:PlayerManager 维护 `_charge_triggered: bool`;首次 `_stationary_time >= CHARGE_TRIGGER_TIME` 时置 `true` 并发出 `CHARGE_FIRED`(ID=14,一次性);移动/冲刺后重置为 `false`。`SpellEvaluator` 订阅 `CHARGE_FIRED`,**禁止**订阅 `CHARGE_STATE_CHANGED`(后者每帧 60Hz 发送,订阅将导致法术每帧重复释放) | +| P6-N70 | architecture_design.md §3.4.A | SpellEvaluator 预编译入口必须命名为 `compile_wand(core: CoreDefinition, raw_deck: SpellDeck)`,权威定义见 `core_wand_design.md §6` | +| P6-N71 | architecture_design.md §8 ADR-R5-N1 | ZoneManager tick 累积器必须使用 `while + -= tick_interval`(同 StatusManager 模式),防止大帧余量丢失。`SpatialGrid.query_circle` 仅调用一次,置于 if 守卫内、while 循环外,避免重复空间查询 | +| P6-N72 | implementation_plan.md §2.5.D | VFXManager `_instantiate_vfx` 必须设置 `node.one_shot = true`;否则 `GPUParticles2D` 无限循环,`finished` 信号永不触发,`_pool_return` 永远不被调用,`_active_count` 只增不减。节点挂载到 VFXManager Autoload 自身,跨场景持久存活 | +| ADR-R5-N1 | architecture_design.md §8 | ZoneManager 架构定义(PackedFloat32Array SoA,ZONE_STRIDE=8,MAX_ZONES=64,无 Area2D) | +| ADR-R5-N2 | architecture_design.md §8 | Feature Tags 字符串常量规范(CoreFeatureTag Autoload) | +| V-N1 | implementation_plan.md §2.5.D | VFXManager MAX_ACTIVE_VFX = 200 | diff --git a/docs/architecture_design.md b/docs/architecture_design.md new file mode 100644 index 0000000..3376273 --- /dev/null +++ b/docs/architecture_design.md @@ -0,0 +1,214 @@ +# [已归档] 模块化战术土豆 - 早期架构草案 (Cocos Creator 3.x / TypeScript) + +> ⚠️ **此文件为历史草案,已废弃。** +> 项目已迁移至 **Godot 4.6 + GDScript/C#**。 +> 当前权威架构请参阅:[docs/technical/architecture_design.md](technical/architecture_design.md) + +--- + +## 1. 概述 (Overview) + +本文件记录了项目早期基于 **Cocos Creator 3.x** 的架构探索。核心设计思想(HMWS 法术系统、ECS-Lite、Zero-GC、Strategy 模式)已被 Godot 版本继承并大幅扩展。 +核心体验结合了 **Brotato (土豆兄弟)** 的快节奏割草体验与 **Noita** 的深度法术构建系统。 +架构设计的首要目标是 **高性能**(支持同屏大量单位与弹幕)与 **极高的可扩展性**(特别是武器系统的模块化)。 + +### 1.1 设计目标 +1. **超模块化武器系统 (Hyper-Modular Weapon System)**:超越 Noita 的线性构建,引入更灵活的管道流与事件钩子机制。 +2. **高性能战斗引擎**:支持同屏 500+ 敌人,2000+ 弹幕,60FPS 稳定运行。 +3. **数据驱动 (Data-Driven)**:所有游戏内容(法术、属性、波次)完全配表化/JSON化。 + +--- + +## 2. 系统分层架构 (Layered Architecture) + +采用能够严格分离数据与表现的架构模式。虽然 Cocos 是组件式的,但在核心战斗层我们将采用 **Manager + Data** 的方式来规避组件更新带来的开销。 + +```mermaid +graph TD + Layer1[表现层 (Presentation Layer)] --> Layer2[逻辑层 (Domain/Logic Layer)] + Layer2 --> Layer3[数据层 (Data Layer)] + Layer2 --> Layer4[核心库 (Core Library)] + + subgraph Layer1 + ViewComponents[Cocos Components (Sprite, Animation)] + UIManagers[UI System] + Effects[Particle Wrapper] + end + + subgraph Layer2 + CombatMgr[Combat Manager (Main Loop)] + SpellEvaluator[Spell Interpreter (The "CPU")] + EnemyAI[Boid AI System] + GameCycle[Wave & Shop Cycle] + end + + subgraph Layer3 + ConfigMgr[JSON Config Loader] + SaveSystem[Persistent Storage] + Inventory[Player State & Inventory] + end + + subgraph Layer4 + Pool[Object Pool System] + SpatialHash[Spatial Hashing (Collision)] + EventBus[Global Event Bus] + end +``` + +--- + +## 3. 核心子系统:超模块化法术系统 (HMWS) + +这是本项目的技术核心。我们将 Noita 的“魔杖”概念抽象为 **“法术管道 (Spell Pipeline)”**。 + +### 3.1 核心概念差异 + +| 特性 | Noita 原版 | HMWS (本项目) | 改进目的 | +| :--- | :--- | :--- | :--- | +| **执行流** | 线性 (Deck -> Hand -> Discard) | **树状/图状结构 + 事件驱动** | 支持“子母弹”、“条件触发”、“击中后分裂逻辑”的无限嵌套。 | +| **属性计算** | 累加式 (Cast Delay += 0.1) | **管线式 (Pipeline)** | 允许中间件对属性进行乘算、覆写或逻辑重定向。 | +| **载体** | 法杖 (Wand) | **构建核心 (Core)** | 核心决定了插槽拓扑结构(不仅仅是线性数组,可能是矩阵或特定触发槽)。 | + +### 3.2 数据结构设计 (TypeScript) + +#### A. 基础单元 (ISpellNode) +这是所有“部件”的基类。 +```typescript +interface ISpellContext { + caster: Entity; // 施法者 + target: Vec2; // 目标点 + stats: CastStats; // 当前累积的属性(伤害、速度、扩散等) + payloads: ProjectileDef[]; // 待发射的弹头定义队列 +} + +abstract class SpellNode { + id: string; + type: SpellType; // ACTION (投射物), MODIFIER (修正), TRIGGER (触发器), LOGIC (逻辑门) + + // 核心执行函数:修改 Context 或 产生行为 + abstract execute(ctx: ISpellContext, deck: SpellDeck): void; +} +``` + +#### B. 法术解析器 (The Evaluator) +为了高性能,解析器必须 **零垃圾回收 (Zero-GC)**。在施法计算帧,不应当 `new` 任何对象。 +* 使用预分配的 `Context` 对象池。 +* 使用 `Stack` 来模拟递归,防止深层递归爆栈。 + +#### C. 高级特性:动态插槽与逻辑门 +* **Logic Spells (逻辑法术)**:引入 `IfHPBelow`, `OnKillAction`, `EveryNbShot` 等逻辑块,让玩家实现“如果血量低于30%,则发射吸血导弹”的构建。 +* **Variable Storage (变量存储)**:允许法术在法杖上写入/读取临时变量(例如:记录连击数)。 + +### 3.3 扩展性设计 +所有法术行为通过 **Strategy Pattern (策略模式)** 实现。 +新增一个法术只需: +1. 在 JSON 中定义 ID 和贴图。 +2. 实现一个 `SpellAction` 类。 +3. 在注册表中注册。 + +### 3.4 进阶构建机制 (Advanced Mechanics) - 玩法增强 +为了超越“线性堆砌”的枯燥感,架构支持以下三种深度玩法机制: + +#### A. 拓扑插槽系统 (Topology Slots) +核心(Core)不再仅仅是一个列表,它可以是一个 **2D 网格** 或 **电路板**。 +* **adjacency_bonus (邻接加成)**:某些插槽有物理连接。例如,将 [火元素] 放在 [高压槽] 旁边,会自动获得 +20% 范围。 +* **Circuit Logic (电路逻辑)**:法术流不再只是从左到右。核心板可以有分叉路口,玩家需要用 [分流器法术] 将能量流引导到不同的分支。 + +#### B. 状态寄存器与图灵完备 (State Registers & Turing Completeness) +为了实现真正的“图灵完备”,架构必须支持:**状态存储**、**条件跳转** 和 **循环**。 + +1. **Registers (寄存器)**: + * 在 `ISpellContext` 中引入 `MemoryBank`,提供 4 个 Float 寄存器 (`R1`, `R2`, `R3`, `R4`)。 + * 寄存器在同一帧内所有法术间共享,甚至可以跨帧持久化(如果法杖配置了 Persistent Memory 核心)。 + +2. **Instruction Set (指令集法术)**: + * **OPS**: `Add R1, 1` (加法), `Set R2, HP_Percent` (赋值). + * **JUMP**: `JumpIf R1 > 10, Label_A` (条件跳转到标签A). + * **LABEL**: `Label_A` (标记跳转点). + +3. **Recursion Control (递归控制)**: + * 为了防止死循环 (`While(true)`), 解释器引入 `MaxOpLimit` (最大操作数限制,例如 100 ops/frame)。超过限制强制中断并在此帧失效。 + +4. **实战应用**: + * **计数器**: 每射击 3 次,第 4 次发射强力火球。 + * **动态模式切换**: 根据敌人距离 (`R1 = EnemyDistance`),如果近则跳转到 [霰弹逻辑],如果远则跳转到 [狙击逻辑]。 + +#### C. 共鸣系统 (Resonance System) +在**预编译阶段 (Pre-compile Phase)** 进行模式匹配。 +* 如果检测到 `[水]` 和 `[电]` 法术在执行链中紧邻,架构自动插入一个隐藏的 `[导电反应]` 中间件。 +* 这允许设计隐藏配方(Hidden Recipes),鼓励玩家探索特定组合。 + +--- + +## 4. 高性能战斗架构 (High-Performance Combat Architecture) + +为了实现“同屏 2000+ 弹幕”和“复杂逻辑构建”的双重目标,本架构采用 **Data-Oriented (面向数据)** 与 **Hybrid-ECS** 相结合的策略,最大化 CPU 缓存命中率并消除 GC 压力。 + +### 4.1 核心原则:零 GC (Zero-GC Principle) +在核心战斗循环 (Game Loop) 中,**绝对禁止**使用 `new` 关键字分配堆内存。 +* **Context Pooling**: `ISpellContext` 等高频对象在关卡加载时预分配 2000 个,使用时复用,用完 `Reset`。 +* **Static Temporaries**: 向量计算使用全局静态临时变量 (`_tempVec2`),避免中间对象产生。 + +### 4.2 实体管理:ECS-Lite +虽然 Cocos Creator 是基于组件的,但在海量单位管理上,我们将剥离组件的 Update 逻辑。 +* **Manager-Based Logic**: 子弹 (`Bullet`) 和 敌人 (`Enemy`) 不挂载 `UpdateComponent`。 + * 也就是:`cc.Node` 仅仅作为渲染容器。 +* **Centralized Loop (中央循环)**: + * `BulletManager` 维护一个紧凑的 `Float32Array` (SoA 布局: `[x, y, vx, vy, type...]`)。 + * 在 `update(dt)` 中,直接遍历 Array 进行物理积分,速度比遍历 Node Tree 快一个数量级。 + * **Dirty Sync**: 仅当物体在屏幕视口内,且逻辑坐标发生位移时,才去同步 `cc.Node.position`。 + +### 4.3 物理与碰撞机制 +* **Builtin Optimization**: 优先使用 Cocos **Builtin-Physics** (非 Box2D) 进行 Geometry Overlap 检测。 +* **Spatial Hashing Fallback**: 若 Builtin 仍有压力,回退到定制的 **Spatial Grid** (一维数组网格),只计算临近 Grid 的实体碰撞,确保碰撞检测复杂度维持在 O(N)。 + * **Separation Logic**: 怪物挤压不使用刚体求解,而是施加简单的轻量级斥力向量。 + +### 4.4 渲染优化 +* **Node Pooling**: 严格的节点池管理。 +* **Throttling (分帧降频)**: + * 伤害数字:每帧最多弹出 10 个,多余的合并或延迟显示。 + * AI 索敌:不需要每帧执行 `FindNearest`,可分散到 10~20 帧内轮询一次。 + +--- + +## 5. 游戏循环设计 (Game Loop) + +结合 Brotato 的经济循环: + +1. **准备阶段 (Shop/Inventory)** + * 玩家拖拽法术卡牌组合逻辑。 + * **解析预热**:在玩家关闭背包时,预先编译法术链,生成 cached 的指令列表。避免战斗中实时解析带来的开销。 +2. **战斗阶段 (Wave)** + * 生成大量敌人。 + * Player 自动开火 (执行 Cached 指令列表)。 + * 掉落拾取 -> 经验值/金币。 +3. **结算阶段** + * 随机 3 选 1 升级(属性成长)。 + * 商店刷新法术与道具。 + +--- + +## 6. 技术栈选型总结 + +| 模块 | 方案 | 理由 | +| :--- | :--- | :--- | +| **语言** | TypeScript | 类型安全,便于重构 | +| **ECS框架** | Custom Lite (Manager-based) | 避免第三方 ECS 库的学习成本与 overhead,针对本项目定制最优 | +| **物理** | Custom Spatial Hash | Box2D 性能瓶颈明显 | +| **UI** | Cocos UI + Virtual List | 背包道具可能很多,需要虚拟列表优化 | +| **配置** | JSON + Type Interface | 灵活且易于热更 | + +## 7. 目录规范建议 + +```text +assets/ + Scripts/ + Core/ # 核心架构 (EventBus, Pool, BaseClasses) + Systems/ # 独立系统 (Physics, Input, Audio) + Domain/ # 游戏业务逻辑 + SpellSystem/ # 法术解释器, 定义, 执行栈 + Combat/ # 伤害计算, 弹道管理 + Enemy/ # AI 行为树 + View/ # UI 控制, 特效表现 + Config/ # 配置表加载器与类型定义 +``` diff --git a/docs/design/core_wand_design.md b/docs/design/core_wand_design.md new file mode 100644 index 0000000..44c4f7b --- /dev/null +++ b/docs/design/core_wand_design.md @@ -0,0 +1,252 @@ +# 构建核心 (Core) 设计文档 + +## 0. 概念定位 + +**Core(构建核心)** 是玩家持有的"武器主体"。它定义了: +1. **插槽拓扑 (Slot Topology)**:有多少个插槽、如何排列(线性/矩阵/电路板)。 +2. **基础属性 (Base Stats)**:法杖自带的充能速度、容量、基础蓝耗。 +3. **特殊规则 (Core Rules)**:部分高阶 Core 拥有改变执行规则的特性(如持久内存、双流并行)。 + +> Core 本身不执行法术,它是 SpellDeck 的"容器",决定了 SpellEvaluator 的执行环境。 + +--- + +## 1. 数据结构 (GDScript) + +```gdscript +# core_definition.gd +# 存储于 res://resources/cores/ 目录下的 .tres Resource 文件 + +class_name CoreDefinition +extends Resource + +## 核心唯一 ID(如 "wand_basic", "staff_matrix") +@export var id: String + +## 显示名称 +@export var name: String + +## 稀有度 (0=普通, 1=稀有, 2=史诗, 3=传说) +@export var rarity: int = 0 + +## 插槽拓扑类型 +@export_enum("LINEAR", "MATRIX_2X4", "CIRCUIT") var topology: int = 0 + +## 插槽总数量 +@export var slot_count: int = 5 + +## 矩阵布局尺寸(仅 MATRIX / CIRCUIT 类型有效) +@export var grid_cols: int = 4 +@export var grid_rows: int = 2 + +## 特殊插槽配置(每个插槽的约束信息) +## Array[Dictionary]: { "index": int, "allowed_types": Array[int], "tags": Array[String] } +@export var slot_configs: Array = [] + +## CIRCUIT 拓扑的有向边列表(LINEAR/MATRIX 留空即可) +## Array[Dictionary]: { "from": int, "to": int } +## _flatten_circuit() 从 core.edges 读取边表;禁止在 slot_configs 内嵌 "edges" key。 +@export var edges: Array = [] + +## 法杖基础属性 +@export var base_mana_capacity: float = 50.0 # 法杖蓝上限 +@export var base_mana_regen: float = 5.0 # 每秒回蓝 +@export var base_cast_delay: float = 0.25 # 基础施法后摇(乘算基准) +@export var base_recharge_time: float = 0.5 # 完整一轮打完后的充能时间 + +## Core 特性标签 (Array[String]),驱动特殊执行规则 +## 例如: ["persistent_memory", "dual_stream", "shuffle_deck"] +@export var feature_tags: Array[String] = [] + +## 图标路径 +@export var icon: Texture2D +``` + +--- + +## 2. 插槽拓扑类型 + +### 2.1 LINEAR(线性) +``` +[ 0 ] -> [ 1 ] -> [ 2 ] -> [ 3 ] -> [ 4 ] +``` +- 最基础的类型,执行流从左到右。 +- 新手友好,等同于标准 Noita 法杖。 +- **代表 Core**: `wand_basic`(5槽)、`staff_long`(10槽) + +### 2.2 MATRIX(矩阵) +``` +行 A: [ 0 ] [ 1 ] [ 2 ] [ 3 ] +行 B: [ 4 ] [ 5 ] [ 6 ] [ 7 ] +``` +- 执行流默认沿行 A 执行,但行 B 中的法术在 **邻接行 A 中对应位置的法术** 执行时同时触发(**邻接加成**)。 +- **邻接规则**: 将 `slot[i]` 与 `slot[i + grid_cols]` 视为"竖向相邻",两者同类时触发 `adjacency_bonus`。 +- **代表 Core**: `matrix_board_2x4`(8槽) + +**邻接加成效果表(P6-N13 权威定义)**: + +预编译阶段 `_flatten_matrix` 检测以下组合,注入对应隐式 MODIFIER 节点: + +| 行 A 法术类型 | 行 B 法术类型 | 触发条件 | 注入效果 | 说明 | +| :--- | :--- | :--- | :--- | :--- | +| **ACTION** | **ACTION**(同 ID) | 完全相同的 ACTION 对齐 | 伤害 ×1.5(`damage_mult +0.5`) | "强化同类弹" | +| **ACTION** | **MODIFIER** | 任意 | 该 MODIFIER 效果额外再施加一次(等效双倍加成) | "增幅器加倍" | +| **MODIFIER** | **MODIFIER**(同 ID) | 完全相同的 MODIFIER 对齐 | 修正值 ×2(平直叠加而非乘算) | "共鸣修正" | +| **ACTION** | **TRIGGER** | 任意 | 该 TRIGGER 的 `proc_rate ×1.5`(最大 1.0) | "增压触发" | +| **LOGIC** | **任意** | 任意 | 无加成(LOGIC 节点不参与邻接计算) | 设计原则:逻辑层不受物理拓扑影响 | +| **空槽** | **任意** | 行 A 为空 | 无加成 | 空槽不传递邻接效果 | + +> **实现说明**:`_flatten_matrix` 在预编译时查表,按上表注入 `ImplicitModifierNode`;运行时 `SpellEvaluator` 不感知矩阵结构,仅执行线性化后的节点序列。邻接加成不累叠(同一个 ACTION 最多受益于一次邻接加成)。 + +### 2.3 CIRCUIT(电路板) +``` + [ 2 ] + ↑ +[ 0 ] -> [ 1 ] -> [ 3 ] -> [ 4 ] + ↓ + [ 5 ] -> [ 6 ] +``` +- 插槽有明确的 **有向边(Edge)** 连接,执行流可分叉。 +- 玩家可在 `slot_configs` 中配置分叉点(Splitter)和合并点(Merger)。 +- **代表 Core**: `circuit_fork`(7槽,含1个分叉点) + +**CIRCUIT Core 的 `CoreDefinition` 资源格式**: +`edges` 列表通过 `CoreDefinition.edges`(`@export var edges: Array = []`)存储, +`slot_configs` 仅存储每个槽的类型约束与标签;不再在 slot_configs 内部嵌套 `"edges"` key。 +```json +{ + "topology": "CIRCUIT", + "slot_configs": [ + { "index": 0, "allowed_types": ["ACTION","MODIFIER","TRIGGER","LOGIC"], "tags": [] }, + { "index": 1, "allowed_types": ["ACTION","MODIFIER","TRIGGER","LOGIC"], "tags": ["splitter"] }, + { "index": 2, "allowed_types": ["ACTION","MODIFIER"], "tags": ["branch_a"] }, + { "index": 3, "allowed_types": ["ACTION","MODIFIER","TRIGGER","LOGIC"], "tags": [] }, + { "index": 4, "allowed_types": ["ACTION","MODIFIER","TRIGGER","LOGIC"], "tags": [] }, + { "index": 5, "allowed_types": ["ACTION","MODIFIER"], "tags": ["branch_b"] }, + { "index": 6, "allowed_types": ["ACTION","MODIFIER"], "tags": ["branch_b"] } + ], + "edges": [ + { "from": 0, "to": 1 }, + { "from": 1, "to": 2 }, + { "from": 1, "to": 3 }, + { "from": 1, "to": 5 }, + { "from": 3, "to": 4 }, + { "from": 5, "to": 6 } + ] +} +``` +> `CoreDefinition.edges` 是 `_flatten_circuit()` 做 Kahn 算法拓扑排序的唯一边表来源。 +> `edges` 为空则视为 LINEAR 拓扑(降级处理)。LINEAR/MATRIX Core 的 `edges` 保持空数组即可。 + +--- + +## 3. 特性标签 (Feature Tags) + +| 标签 | 效果 | 稀有度要求 | +| :--- | :--- | :--- | +| `persistent_memory` | 法杖的寄存器 (R1-R4) 在两次施法之间保留数值(跨帧持久化) | 稀有+ | +| `dual_stream` | 同时从插槽序列头尾各执行一次,两条流的弹头同时发射 | 史诗 | +| `shuffle_deck` | 每次充能完成后,随机打乱插槽执行顺序 | 稀有 | +| `infinite_spells` | 法力耗尽时不停止执行,但每发增加 1 点 HP 代价(需配合 `heavy_cost` 法术) | 传说 | +| `always_cast_last` | 无论 Deck 如何执行,最后一个插槽的法术在一轮结束时**必定**执行一次 | 稀有 | + +> **实现约束说明(P5-N2)**: +> - **`shuffle_deck`**:仅随机化**独立 ACTION 节点**的执行顺序;MODIFIER 节点始终与其紧接的 ACTION 视为原子单元整体移动,防止修正器与错误的动作配对,保持构建意图的确定性。UI 上以"卡组"为单位展示被打乱的顺序,而非单张卡牌。 +> - **`dual_stream`**:两条执行流**各自独立计算 ops 消耗**,每条流的 MAX_OPS 上限 = `cpu_limit × MAX_OPS_PER_CPU / 2`(平均分配)。双流模式下单帧总指令数与单流相同,不额外增加运算力消耗;实际可用插槽从序列头尾各取一半,两条流不可互相访问对方的槽位。 +> **奇数插槽行为(P6-N12 规范)**:当 `slot_count` 为奇数时(如 7 槽),前向流取前 `floor(slot_count / 2)` 个槽(0~2),后向流取后 `floor(slot_count / 2)` 个槽(4~6),**中间槽(index = slot_count / 2,此处为 index 3)由前向流额外执行**(不计入后向流)。 +> UI 上奇数情况下中间槽显示"▶ 仅前流"标注,防止玩家困惑。 +> 示例(7 槽):`[0前][1前][2前] | [3前专属] | [6后][5后][4后]`。 +> - **`always_cast_last`**:无论 Deck 如何执行,一轮结束时对最后一个插槽的法术额外执行一次。 +> **边界行为(P6-N7 补充)**: +> - 若最后一个插槽为 **ACTION**:正常发射一颗子弹(使用一轮结束时的当前 `CastStats`,所有 MODIFIER 已在上一轮累积)。 +> - 若最后一个插槽为 **MODIFIER**:静默跳过(MODIFIER 必须有后续 ACTION 才能生效,单独执行没有效果),此次额外执行不消耗 mana。 +> - 若最后一个插槽为 **TRIGGER** 或 **LOGIC**:同样静默跳过(无 ACTION 上下文,执行无意义)。 +> - UI 提示:最后一个插槽为非 ACTION 时,卡牌展示灰色小锁图标(∅无效提示),防止玩家误以为 MODIFIER 也会被额外执行而混淡。 + +> **Feature Tag 引用规范(ADR-R5-N2)**:业务代码中必须通过 `CoreFeatureTag.PERSISTENT_MEMORY`、`CoreFeatureTag.DUAL_STREAM` 等常量引用标签,禁止使用裸字符串。详见 `architecture_design.md §ADR-R5-N2`。 + +--- + +## 4. Core 实例(参考设计) + +| ID | 名称 | 稀有度 | 拓扑 | 插槽数 | 特性 | 定位 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `wand_basic` | 朴素法杖 | 普通 | LINEAR | 5 | — | 初始装备,入门用 | +| `wand_fast` | 急促法杖 | 普通 | LINEAR | 4 | — | 基础施法延迟 ×0.7,容量小 | +| `staff_long` | 长卷轴 | 稀有 | LINEAR | 10 | — | 插槽多,回蓝慢 | +| `matrix_board` | 构造板 | 稀有 | MATRIX_2X4 | 8 | — | 解锁邻接加成玩法 | +| `wand_memory` | 记忆法杖 | 史诗 | LINEAR | 6 | `persistent_memory` | 寄存器跨帧,启用计数器构建 | +| `circuit_fork` | 分叉回路 | 史诗 | CIRCUIT | 7 | — | 双分支执行,高复杂度 | +| `wand_eternal` | 永恒法杖 | 传说 | LINEAR | 8 | `infinite_spells` | 卖血流终极武器 | + +--- + +## 5. 序列化格式 (SaveData) + +法杖的存档需要记录:Core 类型 + 每个插槽内填的法术 ID。 + +```json +{ + "schema_version": 1, + "wands": [ + { + "core_id": "wand_basic", + "slots": ["double_cast", "spark_bolt", "damage_plus", null, null] + }, + { + "core_id": "matrix_board", + "slots": ["trigger_hit", "chain_bolt", null, null, "homing", "damage_plus", null, null] + } + ] +} +``` + +**注意**: +- `null` 表示该插槽为空。 +- 加载时如果 `core_id` 或法术 ID 不存在于注册表,记录警告日志并跳过,不崩溃(向前兼容)。 +- `schema_version` 字段用于未来迁移,初始值为 1。 + +--- + +## 6. 与 SpellEvaluator 的接口 + +`SpellEvaluator` 在执行前需要从 `Core` 获取: + +```gdscript +# spell_evaluator.gd 伪代码 +# SpellEvaluator 采用两阶段架构: +# - compile_wand():法杖装备/换牌时调用,结果缓存为 CompiledDeck +# - execute_compiled():每次施法时执行缓存的线性化节点序列 + +# ── 阶段 1:编译(法杖装备/换牌时调用,结果缓存于 CompiledDeck)────────────────── +# 参数类型:raw_deck: SpellDeck(通过 deck.nodes[i] 访问节点序列,禁止传入 Array[SpellNode]) +func compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck: + match core.topology: + CoreDefinition.LINEAR: + return CompiledDeck.new(raw_deck.nodes) + CoreDefinition.MATRIX_2X4: + return _flatten_matrix(raw_deck, core) # 注入邻接 MODIFIER + CoreDefinition.CIRCUIT: + # 传 core.edges(顶层有向边表),core.slot_configs 仅存槽约束/标签,不含拓扑边 + return _flatten_circuit(raw_deck, core) # Kahn 拓扑排序 + LOGIC_FORK 注入 + return CompiledDeck.new([]) # 未知拓扑降级为空 Deck + +# ── 阶段 2:执行(每次施法时调用缓存的 CompiledDeck)──────────────────────────── +# 必须接受 core: CoreDefinition 参数,用于读取 core.feature_tags 判断寄存器持久策略。 +# 禁止将 feature_tags 复制到 SpellContext(SpellContext 不持有 Core 引用;ctx.core_feature_tags 不存在)。 +func execute_compiled(compiled: CompiledDeck, ctx: SpellContext, core: CoreDefinition) -> void: + _run_deck(compiled, ctx) # SpellEvaluator 不感知拓扑,仅执行线性化节点序列 + + # 执行完毕后处理特性标签(直接访问传入的 CoreDefinition) + if CoreFeatureTag.PERSISTENT_MEMORY not in core.feature_tags: # ✅ 使用常量(ADR-R5-N2) + ctx.registers.fill(0.0) # 非持久内存,帧结束后清零(registers 即 "memory_bank",见 architecture_design.md §3.2) +``` + +--- + +## 7. UI 集成要点 + +- **背包界面**:根据 `topology` 渲染不同的插槽布局组件(LINEAR 显示横排,MATRIX 显示网格)。 +- **拓扑提示**:悬停插槽时显示该插槽的 `allowed_types` 约束(如"此槽仅接受 Trigger 类型")。 +- **测试靶场**:在商店界面提供靶人,玩家可立即测试当前法杖效果,**无需进入战斗**。 +- **`cpu_limit` 可视化**:在法杖编辑 UI 显示当前构建消耗的"运算力"进度条,接近 `cpu_limit` 时变黄/变红。 diff --git a/docs/design/game_design.md b/docs/design/game_design.md new file mode 100644 index 0000000..50a3bc1 --- /dev/null +++ b/docs/design/game_design.md @@ -0,0 +1,387 @@ +# 游戏设计说明书:魔法工匠 (Arcane Artificer) + +## 1. 游戏概述 (Game Overview) + +**《魔法工匠》** (暂定名) 是一款融合了 **Roguelite 割草动作** 与 **深度法术编程** 的快节奏射击游戏。 + +玩家扮演一名被困在异次元的“土豆工匠”,必须利用手中的核心(法杖)和收集到的法术芯片,组装出毁天灭地的魔法武器,抵御无穷无尽的怪物潮。 + +本游戏的核心差异点在于:**你捡到的不是武器,而是逻辑与弹头的碎片。你必须像程序员一样“编写”你的子弹。** + +--- + +## 2. 核心体验与特色 (Core Experience) + +### 2.1 "Noita" 遇上 "Brotato" +* **快节奏战斗**:继承 Brotato 的短短波次(每波 60-90 秒),自动射击,高密度怪物,让玩家肾上腺素飙升。 +* **深深度构建**:继承 Noita 的法杖编辑深度。战斗之余的商店阶段,是冷静思考、调整“代码”的时间。 + +### 2.2 真正的模块化 (True Modularity) +不像传统游戏那样只是“加一个火属性配件”,本作允许玩家构建复杂的逻辑链。 +* *例如*:“当子弹击中墙壁时,生成一个炮台,炮台每秒发射三枚追踪冰弹。” + +### 2.3 性能怪兽 +得益于底层的 **ECS-Lite (Autoload Manager-based)** 和 **Spatial Grid** 技术,游戏支持同屏 **1000+** 敌人和 **2000+** 独立运算的弹幕,每一次爆炸都不仅是贴图,而是真实的物理/逻辑判定。 + +--- + +## 3. 武器系统详解:法术管道 (The Spell Pipeline) + +这是游戏最核心的玩法。武器由 **“核心 (Core)”** 和 **“法术节点 (Spell Node)”** 组成。 + +### 3.1 核心 (The Core) +核心相当于“枪身”或“主板”。它决定了基础属性: +* **插槽数 (Capacity)**: 能插多少个法术芯片。 +* **回蓝速度 (Mana Charge)**: 每秒恢复多少法力。 +* **最大魔力 (Mana Max)**: 蓝条上限。 +* **施法频率 (Cast Rate)**: 每秒执行多少次主循环。 + > **术语说明**:技术文档(`core_wand_design.md`、`numerical_design.md`)统一使用 + > **Cast Delay(施法后摇)** 表示同一概念的倒数形式(`cast_delay = 1 / cast_rate`)。 + > 设计文档(GDD)用 Cast Rate("快慢"直觉更好);实现层用 Cast Delay("乘算系数"更方便计算)。 + > 两者映射关系:`base_cast_delay = 0.25s` → `cast_rate = 4 次/秒`(默认)。 +* **拓扑结构**: 所有的核心都有一个 Main 入口,但高级核心可能有额外的 "OnKill"(击杀时)或 "OnHit"(受击时)入口插槽。 + +### 3.1.A 多 Core 持有与激活规则 (Multi-Core Rules) + +> 这是最基础的游戏规则,所有构建策略以此为前提。 + +* **同时激活数量上限**: 玩家在战斗中**同时激活 1 个 Core**(激活的 Core 持续执行其主循环自动射击)。 +* **背包携带上限**: 背包最多同时持有 **3 个 Core**(含当前激活的 1 个)。超出时商店购买会提示选择丢弃或出售。 +* **切换时机**: 仅可在 **商店阶段(非战斗期间)** 切换激活的 Core;战斗中不可切换。 +* **插槽独立**: 每个 Core 的法术插槽互相独立,切换 Core 时旧 Core 插槽内容保留,不适配的卡移入等待区(规则见 §5.G5)。 +* **特殊机制 `dual_stream` Core**(史诗级 Feature Tag): 此类 Core 自身内部拥有两条并行执行流(从头尾各执行一次),**并非同时激活两个 Core**,不突破"同时激活 1 个"的规则。 +* *设计意图*:限制同时激活数量避免 Brotato 式"堆满武器"的感官疲劳,同时背包存 3 个让玩家有转型预备的空间。 + +### 3.2 法术芯片 (Spell Chips) +法术分为四大类,通过排列组合产生无限可能。 + +#### A. 动作类 (Actions) - 实际产生的飞行物 +* **魔法弹 (Spark Bolt)**: 基础伤害,低蓝耗。 +* **黑洞 (Black Hole)**: 吸附敌人,无伤害。 +* **治疗雾 (Healing Mist)**: 范围回血。 + +#### B. 修正类 (Modifiers) - 改变下一个动作的属性 +* **伤害增强 (Damage+)**: 蓝耗+10,伤害+20。 +* **弹道折射 (Bounce)**: 子弹遇墙反弹。 +* **多重施法 (Multicast)**: 同时发射后面 2 个动作。 +* **甚至有“数学修正”**: 比如 `Sin(Time)` 让子弹走正弦波轨迹。 + +#### C. 触发器类 (Triggers) - 逻辑的传递 +这是构建“子母弹”的关键。 +* **击中触发 (Trigger On Hit)**: 这个子弹本身伤害很低,但它击中敌人时,会以敌人为原点,**施放**这个芯片后面的法术。 +* **定时触发 (Timer)**: 子弹飞行 0.5秒 后,施放后面的法术。 + +#### D. 逻辑门类 (Logic Gates) - 自动化的灵魂 +这是超越 Noita 的设计,让武器变智能。 +* **IF_HP_LOW**: 如果玩家血量 < 30%,执行后续法术,否则跳过。 +* **IF_ENEMY_CLOSE**: 如果 2米内 有敌人,执行后续法术。 +* **LOOP**: 循环执行后续 3 个法术 5 次(适合做激光加特林)。 + +### 3.3 扩展玩法设计 (Extended Gameplay Mechanics) + +为了让构建过程不仅是“堆数值”,我们引入了三个深度维度: + +#### A. 拓扑与空间构建 (Topology & Spatial Building) +核心(Core)不只是一个列表,而是一个**2D 电路板**。 +* **物理邻接 (Adjacency)**:每个插槽可能有特殊的加成方向。 + * *示例*:一个“增压核心”中间有一个红色插槽,标明“右侧插槽获得双倍效果”。 +* **电路分流 (Circuit Splitting)**:电路板上可能有分支。 + * *玩法*:使用 [分流器] 法术,将“电爆”效果只输送给上面的路线,将“冰冻”效果只输送给下面的路线。 + +#### B. 逻辑与自动化 (Logic & Automation) +引入变量寄存器,让法杖拥有简单的“图灵完备性”。 +* **Global Registers (全局寄存器)**:允许在这个核心内读写变量 `A`, `B`。 +* **构建示例 - "蓄力逻辑"**: + * `[Set A = A + 1]` -> `[IF A < 100 THEN END]` -> `[Set A = 0]` -> `[终极核弹]` + * *效果*:这个法杖平时只会空转(每帧 A+1),直到第 100 帧时,才会发射一枚核弹。相当于自动蓄力 1.6秒。 +* **玩家呈现策略(P5-C1)**:寄存器 `A/B` 的读写操作**不直接作为可购买法术卡暴露给普通玩家**。游戏将寄存器操作封装为具体功能的法术卡: + * 「蓄力计数器」卡:内置完整的计数→阈值判断→重置→执行逻辑,卡面属性为"蓄力阶数 N",玩家感知为"蓄力 N 次后释放一次"。 + * 「击杀连击」卡:内置 ON_KILL 计数→阈值→爆发→重置,玩家感知为"积累 5 击杀触发爆发"。 + * 「裸寄存器模式」仅通过 **Tier 3+ 的「DEBUG 核心」**解锁,作为面向硬核玩家的高阶自定义工具,不作为默认游戏体验推送。 + +#### C. 炼金共鸣 (Alchemical Resonance) +这是隐藏的合成系统。当某些特定属性的法术在执行链中直接相邻时,会发生**质变**。 +* `[水属性波]` + `[闪电链]` -> **自动合成** -> `[等离子风暴 (Plasma Storm)]` + * *效果*:原本是两个独立的法术,现在融合成了一个全新的强力法术,无需消耗额外的插槽。 +* 这鼓励玩家去尝试各种元素的排列组合,寻找隐藏配方。 +* **触发范围权威规则**:绝大多数共鸣配方要求两张法术卡 **直接相邻**(`match: "adjacent"`)。 + 少数传说级配方允许跨 Deck 任意位置触发(`match: "anywhere_in_deck"`),但此类配方 + 在 `resonance_recipes.json` 中标注 `"rarity": 3`,且性能开销较高(O(N²) 全局扫描), + 数量会严格控制。详见 `architecture_design.md §3.4.C`。 +* **配方发现机制(P5-C2)**:共鸣配方默认隐藏。玩家**首次实际触发某配方**后,图鉴自动解锁并展示该配方的完整描述(触发效果、参与元素、法术 ID)。触发前,图鉴仅展示"某两种元素似乎能产生反应…"的模糊提示(仅显示参与元素的属性标签,不透露具体法术 ID),引导探索而非强制查询外部攻略。 + +### 3.4 构建示例 (Build Examples) + +1. **“霰弹枪”构建**: + * `[三双重施法]` -> `[扩散修正]` -> `[子弹]` -> `[子弹]` -> `[子弹]` + * *效果*:一次射出三发散开的子弹。 + +2. **“运载核弹”构建**: + * `[定时触发(1s)]` -> `[速度为0修正]` -> `[伤害增强]` -> `[伤害增强]` -> `[巨型爆炸]` + * *效果*:射出一个普通子弹,1秒后在空中停滞,然后引发核爆。 + +3. **“吸血炮台”构建**: + * `[击中触发]` -> `[召唤静止炮台]` -> `[炮台指令: 击中触发]` -> `[连锁闪电]` -> `[吸血修正]` + * *效果*:你射中地板,地板上长出一个炮台,炮台射出的闪电会连环电击敌人并把血吸给你。 + +--- + +## 4. 数值策划 (Numeric Planning) + +为了支撑如此复杂的构建,数值系统必须严谨且具备极其宽广的边界。 + +### 4.1 核心资源:魔力 (Mana) +为了防止无限“加特林核弹”,限制资源是 **魔力**。 +* 强力修正器(如伤害+100)会有巨大的 **Delay (施法后摇)** 和 **Mana Cost (蓝耗)**。 +* **平衡点**:高射速 = 低单发伤害;高爆发 = 便秘的手感(打一发回蓝3秒)。 + +### 4.2 伤害公式 (The Formula) + +> 完整权威公式定义该文件 `docs/design/numerical_design.md §3.1`。 + +$$ FinalDamage = ((Base + \sum Mod_{flat}) \times \prod Mod_{mult}) \times (1 - Res) - Armor $$ + +* **Base**: 法术动作的基础伤害 (e.g., 火球 10点)。 +* **Mod_flat**: 修正器的数值加成 (e.g., +5伤害)。 +* **Mod_mult**: 玩家天赋/暴击带来的百分比乘区 (e.g., 火焰伤害 +50%)。 +* **Res**: 敌人元素抗性 (0.0~1.0)。 +* **Armor**: 敌人护甲平铺扣除值(固定数值,不受百分比影响)。最终伤害最小为 1。 + +### 4.3 属性词条池 (Stats Pool) — *MVP 层* +玩家在升级或商店中购买的属性,直接影响构建的上限。 + +> ⚠️ **MVP 说明**:以下 5 条属性为 MVP 阶段实现目标。后续扩充方向: +> 第二批(防御类):护甲穿透、元素抗性降低、格挡概率。 +> 第三批(资源类):击杀回血(吸血)、金币掉落增益、法力吸取。 +> 第四批(移动类):冲刺距离、鬼影残影、落身位置标记。 + +| 属性名 | 影响 | 备注 | +| :--- | :--- | :--- | +| **施法速度 (Cast Speed)** | 减少每个法术块的执行时间 | 堆高了可以把“狙击枪”变成“冲锋枪” | +| **魔力虹吸 (Mana Leech)** | 击杀回复魔力 | 续航流派的核心 | +| **散射控制 (Accuracy)** | 减少弹道随机偏转角 | 修正“多重施法”带来的精准度惩罚 | +| **运算力 (CPU)** | 增加核心的插槽上限 | 决定了你能组多复杂的法术 | +| **元素精通 (Attunement)** | 增加特定元素(火/冰/电/毒)的触发概率和伤害 | 每点 Attunement 使对应元素伤害 +3% 且元素状态触发概率 +2%;详见 `numerical_design.md §1.1` | + +### 4.4 怪物成长曲线 +* **波次 1-5**: 纯数值验证(血量低,无抗性)。 +* **波次 6-10**: 机制验证(出现 **反弹护盾怪**、**高护甲怪**)。这迫使玩家必须构建“穿透”或“元素伤害”来应对。 +* **波次 11-20**: 弹幕地狱。怪物的数量呈指数上升,考验玩家构建的 **AOE 覆盖率** 和 **FPS 优化**。 + +### 4.5 Boss 设计框架 (Boss Design) + +Boss 出现于特定里程碑波次,强制验证玩家构建的适应性。 + +#### W10 精英 Boss (第 10 波) +* **功能**: 展示某一核心机制的利用方式。 +* **典型设计示例**: + * **镜像法师**: 反射所有正面 PHYSICAL 投射物,逼迫玩家必须配备至少一个元素伤害 ACTION 或使用 MODIFIER: pierce。 + * **重甲领主**: 自身有层层叠加的护甲,每次受到指定元素伤害时降低一层,验证玩家 **Stacking** 构建的强度。 + +#### W20 最终 Boss(第 20 波) +* **功能**: 综合检验所有核心玩法的适应性。 +* **阶段设计**: + 1. **Phase 1** (全血): 高层抗性,对【共鸣效果】或特定元素伤害几乎无抵抗(正常受击)。纯物理构建并非硬性免疫,但每次物理命中触发 Boss 的"虚空再生"(每次物理命中恢复 `regen_on_physical_hit = 0.5%` 最大血量),形成动态 DPS 门槛——高 DPS 物理构建仍可磨血,但效率远不如元素/共鸣流,引导玩家主动考虑构建多样性。 + 2. **Phase 2** (50% 血量): 开始弹幕攻势,玩家必须在走位的同时输出足够 DPS。 + 3. **Phase 3** (杀死前): 爆发全屏投射物,考验玩家现有弹道规避构建的极限。 +* **设计原则**: Boss 无法被"无脑通关"(一种构建就能轻松将其秒杀);每个阶段应着重考察一个核心机制。 + +#### Boss 行为数据格式 (Boss Behavior Schema — P5-E1) + +Boss 阶段数据完全由 JSON 驱动(`res://resources/bosses/boss_XX.json`),关卡设计师无需改代码即可调整 Boss 行为: + +```json +{ + "boss_id": "void_lord_w20", + "display_name": "虚空领主", + "wave": 20, + "phases": [ + { + "phase": 1, + "hp_threshold_enter": 1.0, + "hp_threshold_exit": 0.5, + "resistances": { "PHYSICAL": 0.8, "FIRE": 0.1, "ICE": 0.1, "LIGHTNING": 0.1 }, + "regen_on_physical_hit": 0.005, + "behavior_tags": ["stationary", "spread_projectiles"], + "projectile_pattern": "spread_8" + }, + { + "phase": 2, + "hp_threshold_enter": 0.5, + "hp_threshold_exit": 0.1, + "resistances": { "PHYSICAL": 0.3, "FIRE": 0.1, "ICE": 0.1, "LIGHTNING": 0.1 }, + "regen_on_physical_hit": 0.0, + "behavior_tags": ["chase_player", "barrage_mode"], + "barrage_interval": 3.0 + }, + { + "phase": 3, + "hp_threshold_enter": 0.1, + "hp_threshold_exit": 0.0, + "resistances": {}, + "behavior_tags": ["enrage", "fullscreen_burst"], + "burst_interval": 5.0 + } + ], + "ng_plus_phase": { + "phase": 4, + "unlock_condition": "ng_plus", + "hp_threshold_enter": 0.0, + "hp_threshold_exit": -0.3, + "note": "NG+ 专属第四阶段:Boss 在 0% HP 时触发'最后意志',暂时无敌 5 秒,HP 回复至 30%,进入超狂暴状态", + "resistances": { "PHYSICAL": 0.5 }, + "regen_on_physical_hit": 0.0, + "behavior_tags": ["last_will", "hyper_enrage", "clone_summon"], + "clone_count": 2, + "invincible_duration": 5.0, + "hp_restore_ratio": 0.3, + "burst_interval": 2.0 + } +} +``` + +> **字段说明**:`resistances` 为各元素抗性(0.0 = 无抵抗,1.0 = 完全免疫);`regen_on_physical_hit` 为每次物理命中触发的生命恢复比例(Phase 1 动态门槛的数据实现);`behavior_tags` 驱动 `BossAI` 状态机的行为策略选择。`ng_plus_phase` 字段仅在 NG+ 模式下由 `BossAI` 读取——普通模式下此字段存在但不生效(`unlock_condition` 检查决定是否激活)。Phase 4 的 `hp_threshold_exit: -0.3` 表示 Boss 在"回复后血量"归零后才真正死亡,防止普通模式下意外触发。 + +--- + +## 5. 游戏循环 (Gameplay Loop) + +1. **准备 (The Lab)**: + * 界面左侧是背包(存放法术卡),右侧是核心(插槽)。 + * 玩家将卡牌拖入插槽,点击”测试按钮”,在一个安全的靶场内测试射击效果,还有 DPS 统计面板。 + * **防呆机制**:点击”进入战斗”时,系统先对 Deck 进行验证:若全部插槽均为空或仅含 MODIFIER/TRIGGER 而无任何 ACTION 类型法术,展示警告弹窗”法术塔无法开火!需要至少一个行动类法术。”并阻止进入。 + * **深度玩法**:调整卡牌顺序,比如把 [伤害增强] 放在 [多重施法] 之前还是之后,效果截然不同。 + +2. **战斗 (The Arena)**: + * 倒计时 60 秒。 + * 玩家自动使用构建好的武器射击。 + * 主要操作:走位躲避弹幕,拾取掉落的”法力核心”(钱)。 + * **走位-构建互动 (G4)**:部分法术对玩家的移动状态有响应,让走位不只是”跑步”: + * **移动增益型法术**(如”电冲弹”):玩家处于运动状态时该法术伤害 ×1.5,静止时恢复基准值。 + * **冲刺波(Dash Wave)型核心**:特定 Core 属性,玩家触发冲刺动作时自动在冲刺轨迹上释放一次附加法术(消耗独立冷却,不走主 Deck 队列)。 + * **停步蓄力型法术**(如”引力奇点”):玩家静止超过 1.5 秒时触发蓄力,停止移动后立即释放一次超载版本(DMG ×3);一旦移动则蓄力清零。 + * **UI 反馈**:静止后 0.3 秒开始显示蓄力进度环(天空蓝,展示 1.5 秒倒计时);蓄力满时进度环闪烁并播放充能音效;移动就进度环消失。进度环由 `UIManager` 经 EventBus `CHARGE_STATE_CHANGED` 事件驱动,不得在 `SpellEvaluator` 中直接操作 UI 节点。 + * *设计意图*:玩家可以通过选择法术类型来主动决定”走位风险收益比”,使操作与构建选择相互影响。 +3. **购物 (The Shop)**: + * 战斗结束,进入商店。 + * **货架 A**: 出售新的法术卡片(随机池)。 + * **货架 B**: 出售新的”核心”(更牛的主板)。 + * **货架 C**: 基础属性提升(生命值、暴击率)。 + * **出售与转型规则 (G5)**: + * 玩家可在商店界面将背包中的法术卡**出售**,回收该卡购入价格的 **50%**(下限 10G)。 + * 更换 Core 时,原 Core 不适配的法术卡(插槽类型不匹配)将自动移入**等待区**(上限 5 张);超出 5 张的部分,玩家必须手动选择出售或丢弃。 + * 等待区中的卡可在下次商店阶段重新插入新 Core,或继续出售。 + * *设计意图*:转型有真实代价——金币折损 + 等待区占位;鼓励玩家谨慎决策而非随意重构。 + * *经济策略*:是买一个强力的”黑洞”法术?还是把钱用来升级”回蓝速度”以支撑现有的构建? + +## 6. 新手引导设计 (Onboarding) + +Roguelite 的"第一把"体验至关重要。系统复杂度必须渐进式解锁,避免一次性呈现所有概念。 + +### 6.1 渐进式解锁顺序 + +| 第几局 | 开放内容 | 目的 | +| :--- | :--- | :--- | +| 局 1 | 预设法杖(只有 3 个 Spark Bolt),只教走位 | 零门槛上手,感受割草爽感 | +| 局 2 | 解锁"法杖编辑",仅开放 Tier 1 法术 | 体验最简单的换弹 | +| 局 3 | 引入第一张 Modifier(伤害增强)+ Tooltip 教学 | 理解 Modifier 放在 Action 之前生效 | +| 局 4 | 引入第一张 Trigger(击中触发)+ 靶场演示 | 看到"子母弹"视觉效果 | +| 局 5+ | Tier 2 法术、Logic 类法术逐步开放 | 自由探索 | + +### 6.2 情境提示 (Contextual Tips) +* 当玩家第一次持有 Trigger 但没有配套 Action 时,显示提示:"触发器需要在其后面放置动作才能生效 →"。 +* 当玩家 `cpu_limit` 占用超过 80% 时,运算力进度条变红并显示:"运算力不足,法杖将无法完整执行"。 +* 商店中首次出现特殊 Core(如 `matrix_board`)时,显示简短动画演示邻接加成效果。 + +### 6.3 靶场 (Training Range) +在**每次**进入商店阶段时,靶场始终可用: +* 右侧显示实时 DPS 数字。 +* 点击"慢放"按钮,以 0.1x 速度演示法术链的执行顺序(调试教学用)。 + +--- + +## 7. 元进展设计 (Meta-Progression) + +元进展是 Roguelite 的长期粘性来源。玩家每次游玩都在为"总体库"做出贡献,即使失败也有收获感。 + +### 7.1 局外货币:以太碎片 (Aether Fragments) +* **来源**: 每次游玩结束时,按波次数量奖励(W1=0, W5=3, W10=8, W20=20)。 +* **永久保留**,不随死亡重置。 + +### 7.2 局外解锁树 (Unlock Tree) +用以太碎片解锁**永久加入掉落池**的内容,不直接增强玩家数值: + +| 花费 | 解锁内容 | 说明 | +| :--- | :--- | :--- | +| 5 碎片 | 法术:连锁闪电 | 加入商店掉落池 | +| 10 碎片 | 核心:构造板 (matrix_board) | 加入商店掉落池 | +| 15 碎片 | 法术:战术核弹 | 加入商店掉落池 | +| 30 碎片 | 核心:记忆法杖 (persistent_memory) | 加入商店掉落池 | +| 50 碎片 | 挑战模式:诅咒波次 | 解锁额外难度模式 | + +### 7.3 图鉴系统 (Codex) +* 玩家每次拾取/购买新法术,自动解锁该法术的图鉴词条(含详细数值和设计意图)。 +* 图鉴中显示**元素亲和性标签**,暗示可能的共鸣组合(如"此法术具有 💧 水属性亲和")。 +* 成就:首次触发每种元素反应,解锁对应的"炼金日志"风味文本。 + +### 7.4 设计原则 +* 解锁内容**只扩展可能性**(让玩家看到更多法术),而非直接增强初始属性(避免 P2W 感)。 +* 通过 20 局可解锁全部核心内容,给硬核玩家明确的完成感。 + +--- + +## 8. 失败、通关与里程碑 (Failure, Victory & Checkpoints) + +### 8.1 死亡与失败机制 (G1) + +* **触发条件**:玩家 HP 降至 0 时死亡,当局立即结束。 +* **死亡流程**: + 1. 播放死亡动画与音效。 + 2. 统计数据冻结(本局最高波次、总输出伤害、最高单次伤害、击杀总数)。 + 3. 显示 **本局总结屏 (Run Summary)**,含构建快照(自动截图保存至 `user://runs/YYYY-MM-DD_run_N.png`)。 + 4. 给予以太碎片奖励(按波次阶梯),不因死亡扣减。 +* **唯一例外**:天赋"最后意志 (Last Will)"——允许一次以 1 HP 存活,每局限触发一次,触发后天赋失效。 +* **死亡不回退**:已解锁法术图鉴词条、成就、Meta 解锁树进度,均在死亡后保留。 + +### 8.2 通关条件与通关后内容 (G2) + +* **通关条件**:击败 W20 终波 Boss(三阶段精英怪),触发通关演出。 +* **首通奖励**: + * 给予 30 以太碎片(固定)。 + * 解锁 **New Game+ (NG+)** 模式:全局敌人属性 ×1.5、弹幕速度 +30%、Boss 新增第四阶段。 +* **NG+ 递增**:每次 NG+ 通关再给予 20 碎片(递减 20%,下限 5)。 +* **无限模式 (Endless)**:W20 通关后可选择继续,每 5 波难度系数 +10%(无上限),用于挑战竞速排行榜,不计入 Meta 进度。 + * **排行榜技术实现(P6-N19)**:以**到达波次 (Wave Reached)** 为主排名维度,同波次按**总经过时间 (Elapsed Seconds)** 升序排。 + - **本地存储**:`user://endless_records.json`,Schema:`{ "records": [{ "wave": int, "elapsed_sec": float, "build_snapshot": String, "timestamp": int }] }`,保留最近 20 条。 + - **在线排行榜(P2 可选)**:上传分数 = `wave * 100000 + (86400 - elapsed_sec)`,单整数实现双维度排序(波次高优先,同波次用剩余秒数打分)。 + - **防作弊**:本地记录含 `build_snapshot`(Deck checksum),便于审核异常成绩。 + * **新机制注入 (G7)**:Endless 模式不只是纯数值膨胀,每 10 波(W30/W40/W50...)随机激活一条**全局词条**(每次 1 条,不与前轮叠加,仅保留最新一条): + + | 词条 | 效果 | + | :--- | :--- | + | 镜子世界 | 所有弹道被首次命中后反弹回发射者方向 | + | 怪物分裂 | 敌人死亡时以 50% HP 分裂为 2 个小型版本(仅分裂 1 次)| + | 地图缩小 | 可活动区域半径减少 20%(最小 50% 原始大小)| + | 魔力反转 | 法术 Delay 变为正向加成,原 Delay 越长攻速越快 | + | 精英泛滥 | 所有普通敌人升级为精英,HP ×2、奖励 ×2 | + + * 每 20 波(W40/W60/W80...)触发 **Boss Rush 片段**:连续出现 3 个 Boss(随机抽取已出现过的 Boss 变体),全部击败后获得大量碎片与特殊法术卡(每次 Boss Rush 保证至少 1 张 Tier 3 卡)。 + +### 8.3 里程碑与 Checkpoint (G3) + +到达 **W5 / W10 / W15** 触发里程碑事件: + +| 里程碑 | 即时奖励 | 解锁内容 | +| :--- | :--- | :--- | +| W5 | 以太碎片 ×2 | 解锁 W5 练习模式起点 | +| W8 | — | **Mini Boss 首现警告波**(非里程碑;仅首次到达时弹出提示,Boss 数据进入图鉴)| +| W10 | 以太碎片 ×3 | 解锁 W10 练习模式起点;W10 精英 Boss 激活 | +| W15 | 以太碎片 ×4 | 解锁 W15 练习模式起点 | + +* **里程碑碎片加成**缓解高波次死亡的挫败感(玩家不会"一无所获")。 +* **练习模式 (Practice Mode)**:从任意已到达的里程碑波次开始,无限时间商店,不计入 Meta 进度也不消耗里程碑次数;用于测试新构建。 + +--- + +## 9. 总结 +《魔法工匠》不只是一款“幸存者”游戏,它是一个**带有动作外壳的逻辑解谜游戏**。每一个通过第 20 波的玩家,都是一名合格的“魔法软件工程师”。 diff --git a/docs/design/numerical_design.md b/docs/design/numerical_design.md new file mode 100644 index 0000000..1fb2d6c --- /dev/null +++ b/docs/design/numerical_design.md @@ -0,0 +1,248 @@ +# 数值策划设计案:魔法工匠 (Arcane Artificer) + +## 0. 设计理念 (Design Philosophy) + +本游戏的数值体系服务于 **“高自由度构建”** 这一核心目标。 +数值设计不应教玩家怎么玩,而应提供足够宽广的边界(Boundary)和明确的权衡(Trade-off)。 + +**核心公式**: +`构建强度 = (基础数值 * 修正系数) ^ 逻辑复杂度` + +## 1. 基础资源模型 (Resource Model) + +### 1.1 玩家属性 (Player Stats) +这些属性是全局的,会修正所有法杖的输出。 + +| 属性 ID | 名称 | 基准值 | 软上限 | 硬上限 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `hp_max` | 最大生命 | 100 | 2000 | - | | +| `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 | +| `move_speed` | 移动速度 | 300 | 600 | 800 | 像素/秒 | +| `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | 0.01 | 越低越快,乘算系数 | +| `recharge_speed_mod` | 充能速度修正 | 1.0 (100%) | 5.0 | - | 越低越快 | +| `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 | +| `cpu_limit` | 运算力上限 | 5 | 20 | 50 | `SpellEvaluator` 实际 MAX_OPS = `cpu_limit × 40`(默认 5 → 200 步;软上限 20 → 800 步;硬上限 50 → 2000 步)。升级此属性可执行更复杂的法术链,与 `implementation_plan.md §2.2` 的 `MAX_OPS_PER_CPU = 40` 常量对应。 | +| `attunement_fire` | 火焰精通 | 0 | 50 | 100 | 每点:火焰伤害 +3%(乘算),火焰状态(点燃/爆燃)触发概率 +2%。影响 `damage_type=FIRE` 的所有投射物。 | +| `attunement_ice` | 冰霜精通 | 0 | 50 | 100 | 每点:冰霜伤害 +3%,冰冻/减速触发概率 +2%。 | +| `attunement_lightning` | 雷电精通 | 0 | 50 | 100 | 每点:雷电伤害 +3%,麻痹/连锁导电触发概率 +2%。 | +| `attunement_poison` | 毒素精通 | 0 | 50 | 100 | 每点:毒素 DoT +3%,中毒叠层上限 +1(上限 20)。 | + +### 1.2 经济系统 (Economy) + +* **金币 (Gold)** (代号 `G`) + * **来源**: 击杀怪物 (1-5G),波次结算 (100G + 10%利息)。 + * **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*)。 + * **利息上限**: 10% 利息计算时,单次波次奖励上限为 **100G**(即持有 1000G 以上部分不再产生额外利息)。防止后期经济失控膨胀。 + * **膨胀控制**: 商店刷新价格每次增加,波次结束后重置。金币不捡 5 秒后消失,逼迫玩家移动拾取。 + - **刷新价格公式(F11 补充,P6-N23)**:`刷新费用 = 20 + (本波已刷新次数 × 10)`(单位:G)。 + 即第 1 次刷新 20G,第 2 次 30G,第 3 次 40G,以此类推;波次结算后重置为 20G。 + 设计意图:前 1-2 次刷新成本低,鼓励灵活换货;反复刷新代价指数感,抑制"无脑刷新"。 + 实现:`ShopManager` 维护 `_refresh_count: int`(波次内),`WAVE_COMPLETE` 事件后清零。 + +* **经验值 (XP)** + * **升级曲线(权威公式 P6-N16)**: + $$XP\_required(level) = \lfloor 10 \times 1.4^{level-1} \rfloor$$ + | 等级 | 所需 XP | 累计 XP | + | :--- | :--- | :--- | + | 1→2 | 10 | 10 | + | 5→6 | 54 | 234 | + | 10→11 | 289 | 1397 | + | 15→16 | 1551 | 7174 | + | 20→21 | 8322 | 39,343 | + - 系数 `1.4` 使早期升级快速(前 5 级约 4 波内完成),后期升级需要持续 3-5 波,节奏符合 Roguelite 期望。 + - `ProfileManager` 在关卡加载时预计算前 50 级的阈值表并缓存,避免运行时浮点指数运算。 + - **⚠️ F10 可行性验证(P6-N22)**:Level 21 累计 XP 需求 39,343。20 波 × 60 秒/波 = 1200 秒游戏时长。 + 设平均每秒击杀 2 只敌人,每只掉 15 XP(Wave 10+ 标准怪),则 1200 × 2 × 15 = 36,000 XP—— + **略低于 39,343**,意味着理论上最高只能达到 Level 20 左右(不满 21 级)。 + 若期望玩家在通关时达到 20 级,需将 `XP_required` 公式中系数从 `1.4` 调整为约 `1.38`, + 或将波次奖励 XP 提高(每波结算奖励 200 XP)。**当前设计定为 Level 20 通关上限,不强制要求 Level 21**; + Level 21+ 曲线仅服务于 Endless 模式。敌人 XP 掉落参考值:Wave 1 杂鱼 = 5 XP,Wave 10 精英 = 30 XP,Boss = 500 XP。 + +--- + +## 2. 法术系统数值 (Spell System Metrics) + +### 2.1 核心参数定义 + +每个法术卡片 (Godot Resource / Json) 包含以下关键数值: + +1. **Mana Cost (魔耗)**: 执行此节点消耗的魔力。 + * *原则*: 越强力的效果,蓝耗越高,或者有其他负面代价。 +2. **Cast Delay (施法后摇)**: 执行此节点后,给法杖增加的“冷却时间”。 + * *原则*: 强大的单发法术通常有高延迟(如核弹 +2.0s)。 +3. **Recharge Time (充能延迟)**: 这是一个特殊的延迟,只有当完整的一轮(Deck空了)打完正在重置时,才会计算。 + * *原则*: 只有极强的终极技能才会增加此值。 + +### 2.2 基础法术参考表 (Spell Database) + +> **⚠️ 注意**:Projectile/Action 类型法术必须在 `.tres` 中显式设置 `damage_type`(DamageType 枚举值)。 +> 未填写时 `ProjectileDef.reset()` 默认 `DamageType.PHYSICAL`,但设计意图应在此表中明示, +> 避免内容制作者遗漏火/雷等元素属性导致构建伤害系统无法正常触发。 + +#### Tier 1 (新手/平民) +| ID | 名称 | 类型 | Mana | Delay | 伤害类型 | 效果/修正 | 备注 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `spark_bolt` | 魔法飞弹 | Projectile | 5 | +0.05s | PHYSICAL | Dmg: 3, Speed: 600 | 最基础的突突突 | +| `energy_orb` | 能量球 | Projectile | 20 | +0.20s | PHYSICAL | Dmg: 15, Speed: 300 | 慢速高伤 | +| `double_cast` | 双重施法 | Multicast | 2 | +0.00s | — | Draw: 2 | 必备插件 | +| `spread_mod` | 散射修正 | Modifier | 0 | -0.10s | — | Spread: +30°, Speed: +10% | 这一发打不准,但射得快 | + +#### Tier 2 (进阶/功能) +| ID | 名称 | 类型 | Mana | Delay | 伤害类型 | 效果/修正 | 备注 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `shotgun_blast` | 霰弹爆破 | Projectile | 25 | +0.40s | PHYSICAL | 发射3枚 4dmg 的弹片 | 自带多重效果 | +| `damage_plus` | 伤害增强 | Modifier | 15 | +0.10s | — | Dmg: +10 | | +| `speed_up` | 加速推进 | Modifier | 5 | -0.05s | — | Speed: +400, Range: +20% | 狙击流必备 | +| `trigger_hit` | 击中触发 | Trigger | 10 | +0.00s | — | Payload Dmg: 0.1x | 子母弹核心 | + +#### Tier 3 (史诗/质变) +| ID | 名称 | 类型 | Mana | Delay | 伤害类型 | 效果/修正 | 备注 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `nuke` | 战术核弹 | Projectile | 200 | +3.00s | PHYSICAL | Dmg: 500, Radius: 300 | 屏幕清空,可能会炸死自己 | +| `homing` | 自动追踪 | Modifier | 40 | +0.10s | — | Homing Force: 5.0 | 只有这一个修正就够改变玩法 | +| `heavy_cost` | 鲜血献祭 | Modifier | 0 | -0.50s | — | Dmg: +50, Cost: 10 HP | 卖血流 | +| `chain_bolt` | 连锁闪电 | Projectile | 60 | +0.80s | LIGHTNING | 弹射 5 次 | 清怪神器 | + +--- + +## 3. 敌人与战斗数值 (Combat Metrics) + +### 3.1 伤害计算管线 + +> **⚠️ 权威公式(Authoritative Formula)**:所有文档均应引用此处定义,不得出现矛盾版本。 + +为了支持复杂的 Buff/Debuff,伤害计算必须分步: + +1. **Base Damage**: 法术面板值 (e.g. 10) +2. **Add Modifiers**: 累加修正器 (e.g. +5 +5 -> 20) +3. **Mult Modifiers**: 乘法修正器 (e.g. Critical 2.0x, Element Weakness 1.5x) +4. **Flat Reduction**: 敌人护甲 (Armor -2) +5. **Percent Reduction**: 敌人抗性 (Fire Res 50%) + +``` +Damage = ((Base + Add) × Mult) × (1 - Res) - Armor +``` +*(最小伤害为 1)* + +### 3.2 怪物设计标准 + +我们设定 **“标准单位时间伤害 (DPS)”** 为基准。 +* 玩家 Lvl 1 预计 DPS: 20 +* 玩家 Lvl 20 预计 DPS: 5000+ + +| 怪物等级 | 怪物类型 | HP | 移动速度 | 攻击力 | 特殊词条 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| Wave 1 | 杂鱼土豆 | 15 | 100 | 5 | - | +| Wave 5 | 冲锋甲虫 | 80 | 250 | 12 | 击退抗性 50% | +| Wave 10| 坦克巨兽 | 2000 | 50 | 30 | 护甲 5 (免疫机枪) | +| Wave 20| 虚空领主 | 50000| 150 | 100 | 弹幕反射盾 | + +### 3.3 波次强度控制 (Pacing) + +游戏设计为 20 波 (Wave),每波 60秒。 +* **W1-3**: 爽局。怪物血量 < 玩家单发伤害。 +* **W4-7**: 压力局。怪物密度增加,玩家必须拥有至少一个 AOE 手段(穿透/爆炸)。 +* **W8**: **首领战 (Mini Boss)**。考验单体 DPS。 +* **W9-15**: 资源局。怪物掉落增加,但伴随高护甲怪,考验玩家的“破甲/元素”构建。 +* **W20**: **最终 Boss**。 + +--- + +## 4. 元素反应矩阵 (Elemental Matrix) + +为了增加构建深度,引入简单的元素克制/反应。 + +| 攻击 \ 如果目标状态 | 无 | 潮湿 (Wet) | 油腻 (Oily) | 燃烧 (Burning) | 冰冻 (Frozen) | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **火 (Fire)** | 点燃 | 蒸发 (AOE) | 爆燃 (x3 Dmg) | - | 融化 (解冻) | +| **冰 (Ice)** | 减速 | 冻结 (硬控) | - | 熄灭 | - | +| **雷 (Elec)** | 麻痹 | 连锁导电 (AOE) | - | 过载 (爆炸) | 碎冰 (物理Dmg) | + +*数值策划备注:* +* **潮湿**:受到雷电伤害 +100%。 +* **油腻**:受到火焰伤害 +200%,且持续时间翻倍。 +* **冻结**:无法移动,受到物理撞击伤害 +300% (即碎冰机制)。 + +--- + +## 5. 平衡性隐患与对策 (Balancing Risks) + +### A. 无限蓝机枪 (Infinite Mana Gun) +* **问题**: 玩家堆积大量 `-Delay` 和 `Mana Regen`,导致射速突破帧率限制,且蓝不掉。 +* **对策**: + * **最小帧限制**: 设定射击间隔至少为 1 帧 (0.016s)。 + * **过热机制 (Overheat)**: 连续射击超过 5秒,法杖进入“过热”状态,强制冷却 2秒 或 增加散布。 + * **递增蓝耗**: 连续快速射击时,每发子弹蓝耗增加 1%,停止射击后快速衰减。 + +### B. 显卡杀手 (GPU Killer) +* **问题**: 玩家构建出 `无限分裂` + `无限粒子`。 +* **对策**: + * **最大弹道数 (Max Projectiles)**: 全局限制 2000 个弹道。超过时,新的子弹不再生成(或顶掉旧的)。 + * **双重限制机制**: + * **MAX_OPS**(= `cpu_limit × MAX_OPS_PER_CPU`,默认 `5 × 40 = 200`,软上限 `20 × 40 = 800`):`SpellEvaluator` 每帧单次施法最多执行对应条指令(包括 ACTION/MODIFIER/LOGIC 各类节点)。主要防止线性/循环构建死循环。与 `implementation_plan.md §2.2` 的 `MAX_OPS_PER_CPU = 40` 常量对应。 + * **嵌套深度锁 MAX_TRIGGER_DEPTH = 3**:`SpellEvaluator.execute_sub` 调用时传入 `depth` 参数,超过 3 层不再触发新的 SubPayload(即 TRIGGER 内嵌套 TRIGGER 最多 3 层)。**两者分工**:MAX_OPS 管局内指令数量,MAX_TRIGGER_DEPTH 管跨子弹的调用深度。 + +### C. 站桩输出 (AFK Build) +* **问题**: 比如”自动追踪吸血光环”,玩家站着不动就能通关。 +* **对策**: + * **反挂机怪**: 设计一种怪物,会向玩家当前位置投掷”毒圈”,逼迫玩家移动。 + * **资源衰减**: 掉落的金币如果不捡,5秒后消失(或变少),逼迫玩家去捡钱。 + +### D. W20 Boss Phase 1 DPS 门槛(P6-N17) +* **问题**: Phase 1 的 `regen_on_physical_hit = 0.005`(每次物理命中恢复 Boss 最大 HP 的 0.5%)。 + Boss 最大 HP = 50,000,即每次物理命中回血 **250 HP**。 + 若玩家攻速 10 发/秒(纯物理构建),Boss 每秒净回血 2,500 HP。 +* **DPS 突破阈值(最低要求)**:物理构建需满足以下条件才能有效磨血: + $$DPS_{physical} > \frac{250 \times hit\_rate}{1} \Rightarrow DPS > 2500 \text{(10发/秒时)}$$ + 等价条件:每发伤害 > 250(单发狙击流),或总 DPS > 2500(高频流)。 + - **设计意图**:并非阻止物理构建,而是要求玩家必须达到中后期构建强度(DPS 5000+)才能高效磨血;低配物理构建可用更长时间磨过,但效率不如元素/共鸣流。 + - **数值调整入口**:`res://resources/bosses/boss_w20.json` 中的 `regen_on_physical_hit` 字段,平衡迭代时直接修改数据而无需改代码。 + +### E. `infinite_spells` 与 `heavy_cost` 叠加交互(P6-N18) +* **问题**:`infinite_spells` 特性(法力耗尽时每发+1 HP 代价)与 `heavy_cost` 修正器(每次施法-10 HP)叠加时,HP 代价是否累加? +* **规则定义**:两者**独立计算、顺序执行**: + 1. `SpellEvaluator` 先执行 `heavy_cost` MODIFIER(扣 10 HP,归类为”构建设计主动代价”)。 + 2. **之后**,若 mana 不足,`infinite_spells` 机制额外扣 1 HP(归类为”资源溢出代价”)。 + 3. 两者同帧触发时,单发最大 HP 代价 = `heavy_cost 值 + 1`(如 10+1=11 HP/发)。 +* **UI 提示**:法杖编辑界面在检测到 `infinite_spells Core` + `heavy_cost` 组合时,显示橙色警告”⚠ 双重 HP 消耗”,并提示预估每秒 HP 消耗量。 +* **上限保护**:单次施法 HP 代价上限 = `player.hp_max × 0.15`(15% 上限),防止极端组合一发即死。 +--- + +## 6. 波次生成配置格式 (SpawnConfig Schema — E5 权威定义) + +波次内容**完全由数据驱动**,关卡设计师仅需编辑 JSON 无需改代码。文件位于 `res://resources/waves/wave_XX.json`: + +```json +{ + "wave": 5, + "duration_sec": 60, + "budget": 200, + "spawn_groups": [ + { + "enemy_id": "bug_charger", + "weight": 3, + "count_min": 2, + "count_max": 5, + "spawn_interval": 3.0, + "spawn_radius_from_player": [400, 700] + }, + { + "enemy_id": "tank_brute", + "weight": 1, + "count_min": 1, + "count_max": 1, + "spawn_interval": 15.0, + "spawn_radius_from_player": [500, 800] + } + ], + "elite_overrides": [ + { "at_sec": 45, "enemy_id": "elite_bug_charger", "count": 1 } + ], + "boss": null +} +``` + +* `budget`:本波总"刷怪点数",每个怪物有对应的 `cost`;实际刷新量 = `floor(budget / cost)`。 +* `weight`:同时存在多个 group 时,按权重随机选组,而非轮流刷新。 +* `elite_overrides`:精确时间点强制刷出精英怪,不消耗 budget。 +* `boss`:非 null 时在波末触发 BossSpawn(见 §6 Boss 设计框架)。 \ No newline at end of file diff --git a/docs/design/tutorial_design.md b/docs/design/tutorial_design.md new file mode 100644 index 0000000..f35728c --- /dev/null +++ b/docs/design/tutorial_design.md @@ -0,0 +1,284 @@ +# FTUE / 新手引导系统架构设计 +# Tutorial & Onboarding System Architecture + +> **范围**:本文档涵盖游戏的首次用户体验(FTUE)、上下文提示、引导状态机,以及 +> `TutorialManager` Autoload 的完整设计规范。 +> 本文档以 `architecture_design.md` 为基础,所有运行时接口遵循 §6.1 语言分区原则。 + +--- + +## 一、设计目标 + +| 目标 | 度量标准 | +| :--- | :--- | +| 前 3 分钟玩家留存 | 90%+ 玩家完成首波(Wave 1)且尝试第一次施法 | +| 零文字强制阅读 | 教学信息通过上下文提示传达,不弹出强制阅读对话框 | +| 可跳过原则 | 所有引导步骤可在设置中永久关闭,或在进行中跳过 | +| 渐进式复杂度 | W1–W3 仅展示核心移动+射击;W4+ 逐步解锁进阶提示 | + +--- + +## 二、TutorialManager Autoload + +### 2.1 职责边界 + +- **GDScript 层**(`tutorial_manager.gd`):状态读写、EventBus 订阅、提示节点显示/隐藏。 +- **不含热路径**:提示触发频率极低(每场景级别),全部保留在 GDScript。 + +```gdscript +# scripts/autoloads/tutorial_manager.gd +extends Node + +## 教学步骤枚举(顺序即解锁顺序) +enum TutorialStep { + MOVE = 0, # WASD 移动 + CAST = 1, # 首次自动施法(Wand 冷却后自动提示) + PICK_SPELL = 2, # 首次拾取法术卡 + OPEN_SHOP = 3, # 首次进入商店 + EQUIP_CORE = 4, # 首次装备 Core + KILL_10 = 5, # 击杀 10 个敌人 + WAVE_CLEARED = 6, # 通过 Wave 1 + RESONANCE_HINT = 7, # 首次触发共鸣(S3 解锁) + COMPLETED = 99, # 引导全部完成 +} + +## 已完成步骤的持久化集合(存入 ProfileManager) +var _completed_steps: PackedInt32Array = PackedInt32Array() + +## 当前激活提示步骤(-1 = 无) +var _active_step: int = -1 + +func _ready() -> void: + # 从 ProfileManager 恢复已完成状态 + _completed_steps = ProfileManager.get_int_array( + "tutorial_completed", PackedInt32Array()) + # 订阅进度事件 + EventBus.subscribe(EventID.PLAYER_SPAWNED, _on_player_spawned) + EventBus.subscribe(EventID.SPELL_CAST, _on_spell_cast) + EventBus.subscribe(EventID.SHOP_OPENED, _on_shop_opened) + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) + EventBus.subscribe(EventID.WAVE_CLEARED, _on_wave_cleared) + EventBus.subscribe(EventID.RESONANCE_TRIGGERED, _on_resonance) +``` + +### 2.2 状态机定义 + +``` +[初始状态] + │ + ▼ +MOVE ──────────────── 检测:玩家首帧输入 WASD 后标记完成 + │ + ▼ +CAST ──────────────── 检测:EventID.SPELL_CAST 首次触发 + │ + ▼ +PICK_SPELL ─────────── 检测:拾取 loot 掉落的法术卡 + │ + ▼ +OPEN_SHOP ──────────── 检测:Wave 1 通关后自动弹出商店提示 + │ + ▼ +EQUIP_CORE ─────────── 检测:首次从商店装备任意 Core + │ + ▼ +KILL_10 ────────────── 检测:ENEMY_KILLED 事件累计 10 次 + │ + ▼ +WAVE_CLEARED ───────── 检测:WAVE_CLEARED(wave_num=1) + │ + ▼ +RESONANCE_HINT ─────── 检测:RESONANCE_TRIGGERED(S3 起激活) + │ + ▼ +COMPLETED ──────────── 持久化到 ProfileManager,停止所有提示 +``` + +- **前向跳跃**:若玩家自行完成某步(无提示出现),步骤自动标记完成,无需显示提示。 +- **后向保护**:已完成步骤不会因新 Run 而重置(存 ProfileManager 而非 RunData)。 + +### 2.3 核心接口 + +```gdscript +# 查询某步骤是否已完成 +func is_completed(step: TutorialStep) -> bool: + return step in _completed_steps + +# 手动标记完成(商店系统 / 法杖编辑器调用) +func mark_complete(step: TutorialStep) -> void: + if is_completed(step): return + _completed_steps.append(int(step)) + ProfileManager.set_int_array("tutorial_completed", _completed_steps) + _hide_hint() + EventBus.emit(EventID.TUTORIAL_STEP_COMPLETED, step) + +# 显示上下文提示(锚定到 UI 节点旁) +func show_hint(step: TutorialStep, anchor: Control, + offset: Vector2 = Vector2.ZERO) -> void: + if is_completed(step): return + _active_step = step + # 委托给 UIManager 渲染气泡提示 + UIManager.show_tutorial_hint( + TutorialHintData.build(step), anchor, offset) + +func _hide_hint() -> void: + if _active_step == -1: return + UIManager.hide_tutorial_hint() + _active_step = -1 +``` + +--- + +## 三、上下文提示系统(Contextual Hints) + +### 3.1 提示数据结构 + +```gdscript +# resources/tutorial/tutorial_hint_data.gd +class_name TutorialHintData +extends Resource + +@export var step: int # TutorialStep 枚举值 +@export var icon_key: String # 对应 UIAtlas 中的图标键(如 "wasd_icon") +@export var text_key: String # i18n 键(tr(text_key) 使用) +@export var arrow_dir: int # 0=上, 1=右, 2=下, 3=左(箭头指向目标) +@export var duration: float = 0.0 # 0 = 手动关闭,>0 = 自动消失秒数 + +static func build(step: TutorialManager.TutorialStep) -> TutorialHintData: + return _HINT_TABLE[step] + +# 提示内容表(运行时常量,避免 JSON 热加载) +const _HINT_TABLE: Dictionary = { + 0: preload("res://resources/tutorial/hint_move.tres"), + 1: preload("res://resources/tutorial/hint_cast.tres"), + 2: preload("res://resources/tutorial/hint_pick_spell.tres"), + 3: preload("res://resources/tutorial/hint_shop.tres"), + 4: preload("res://resources/tutorial/hint_equip_core.tres"), + 5: preload("res://resources/tutorial/hint_kill10.tres"), + 6: preload("res://resources/tutorial/hint_wave_cleared.tres"), + 7: preload("res://resources/tutorial/hint_resonance.tres"), +} +``` + +### 3.2 提示触发时机 + +| 步骤 | 触发时机 | 自动消失 | 跳过方式 | +| :--- | :--- | :--- | :--- | +| MOVE | 游戏开始后 1s(玩家还未移动) | 首次移动后 | 任意移动 | +| CAST | 法杖冷却恢复后 2s(玩家未施法) | 首次施法后 | 施法 | +| PICK_SPELL | 首个法术 loot 落地后 3s | 拾取后 | 走到 loot 上 | +| OPEN_SHOP | Wave 1 通关时自动进入商店流程 | 商店打开后 | 自动 | +| EQUIP_CORE | 商店内有 Core 可购买时 | 购买后 | 购买 | +| KILL_10 | 击杀计数达 5 时预显提示 | 计数达 10 | 击杀 | +| WAVE_CLEARED | Wave 1 通关瞬间 | 5s 后 | 点击 | +| RESONANCE_HINT | 首次编辑法杖时检测邻接 | 触发共鸣后 | 触发共鸣 | + +### 3.3 提示 UI 节点结构 + +``` +CombatScene +└── UILayer (CanvasLayer z_index=100) + └── TutorialHintContainer # UIManager 管理的专用节点 + ├── HintBubble (Panel) + │ ├── Icon (TextureRect) + │ └── Label (text = tr(hint.text_key)) + └── Arrow (TextureRect, 根据 arrow_dir 旋转) +``` + +- `TutorialHintContainer` 随 `anchor` 节点世界坐标定位(`force_update_transform` 每帧同步)。 +- 气泡使用 `AnimationPlayer` 播放 0.2s 淡入动画,消失时 0.15s 淡出。 +- 同一时刻最多显示 1 个提示(显示新提示时先隐藏旧提示)。 + +--- + +## 四、首 3 分钟玩家流(First 3 Minutes Flow) + +``` +T=0s 游戏载入 → CombatScene 初始化 +T=1s [提示] WASD 移动(气泡锚定玩家位置右侧,arrow→左) +T=~5s 玩家完成移动 → MOVE 标记完成 → 提示消失 +T=~8s 法杖冷却结束 → [提示] 观察法术已自动施发(施法为自动) +T=~15s 首个敌人被击杀 → loot 掉落 → [提示] 拾取法术卡 +T=~30s 玩家拾取法术卡 → PICK_SPELL 标记完成 +T=~60s Wave 1 清场 → 自动进入商店 +T=商店 [提示] 尝试装备 Core(若商店有 Core,arrow→Core 格) +T=~90s 购买/关闭商店 → Wave 2 开始 +T=~120s 已掌握基本循环,FTUE 完成度 ~70% +T=Wave 4+ 触发共鸣时 → 最终提示显示 +``` + +--- + +## 五、关键 EventID 扩展 + +> 以下 EventID 需追加到 `event_id.gd`(当前最大 ID = 17): + +| ID | 常量名 | 负载 | 触发方 | 监听方 | +| :--- | :--- | :--- | :--- | :--- | +| 18 | `TUTORIAL_STEP_COMPLETED` | `step: int` | TutorialManager | UIManager(显示解锁提示)| +| 19 | `SHOP_OPENED` | — | ShopManager | TutorialManager | +| 20 | `RESONANCE_TRIGGERED` | `recipe_id: String` | SpellEvaluator | TutorialManager, VFXManager | +| 21 | `ACHIEVEMENT_UNLOCKED` | `achievement_id: String` | AchievementManager | Steam 插件层 | + +> **注意**:ID 0 为 `CRASH_DETECTED`(CrashReporter 专用,已定义)。 + +--- + +## 六、可跳过 & 关闭机制 + +```gdscript +# settings_manager.gd 新增项 +const KEY_TUTORIAL_DISABLED: String = "tutorial_disabled" + +# TutorialManager._ready() 中检查 +func _ready() -> void: + if ProfileManager.get_bool(SettingsManager.KEY_TUTORIAL_DISABLED, false): + # 将所有步骤标记为已完成,彻底关闭引导 + for s in TutorialStep.values(): + _completed_steps.append(s) + return + # 正常初始化... +``` + +设置菜单提供「关闭新手提示」开关,写入 `ProfileManager`(持久化跨 Run)。 +**重置入口**:`SettingsManager.reset_tutorial()` → 清空 `tutorial_completed` 并重置标志, +下次启动重新触发完整 FTUE 流程(适用于练习账号 / QA 测试)。 + +--- + +## 七、文件清单 + +``` +scripts/autoloads/ + tutorial_manager.gd # 核心 Autoload(本文档 §2 实现) + +resources/tutorial/ + hint_move.tres # TutorialHintData(MOVE 步骤) + hint_cast.tres + hint_pick_spell.tres + hint_shop.tres + hint_equip_core.tres + hint_kill10.tres + hint_wave_cleared.tres + hint_resonance.tres + +scenes/ui/ + tutorial_hint_bubble.tscn # HintBubble + Arrow 节点树 +``` + +**Autoload 注册顺序**(`project.godot`): + +> `TutorialManager` 在 `EventBus` 和 `ProfileManager` 之后注册, +> 确保 `_ready()` 时可正常读取持久化数据并订阅事件。 + +--- + +## 八、与其他系统的接口约定 + +| 系统 | 接口 | 说明 | +| :--- | :--- | :--- | +| `UIManager` | `show_tutorial_hint(data, anchor, offset)` / `hide_tutorial_hint()` | 渲染和动画委托给 UIManager | +| `ProfileManager` | `get_int_array / set_int_array("tutorial_completed", ...)` | 跨 Run 持久化已完成步骤 | +| `EventBus` | 订阅 ID 1/6/11/18~21 等 | 只读订阅,不 emit 游戏逻辑事件 | +| `SpellEvaluator` | 无直接依赖 | 共鸣触发通过 `RESONANCE_TRIGGERED` 事件解耦 | +| `SettingsManager` | `KEY_TUTORIAL_DISABLED` 常量 | 关闭引导的持久化开关 | diff --git a/docs/handbook/01_quickstart.md b/docs/handbook/01_quickstart.md new file mode 100644 index 0000000..52189a2 --- /dev/null +++ b/docs/handbook/01_quickstart.md @@ -0,0 +1,121 @@ +# 01 快速上手 (Quickstart) + +## 1. 环境要求 + +| 工具 | 版本 | 下载 | +|:---|:---|:---| +| Godot Engine | **4.6 Mono**(带 .NET 支持) | godotengine.org | +| .NET SDK | 8.0+ | dotnet.microsoft.com | +| Git | 任意 | git-scm.com | + +> **重要**:必须下载 **Mono/C# 版本** 的 Godot(文件名含 `mono`),否则 C# 文件无法编译。 + +--- + +## 2. 首次运行 + +```bash +# 1. 克隆项目 +git clone + +# 2. 用 Godot 4.6 Mono 打开 project.godot +# 菜单: Project → Open / Import + +# 3. 等待导入完成(首次约 10-30 秒) + +# 4. 直接按 F5 运行,或菜单: Debug → Run Project +``` + +运行后你会看到:战斗场景直接开始,玩家(青色方块)自动发弹,敌人(红色方块)向玩家靠近。 + +--- + +## 3. 主场景说明 + +唯一可玩主场景:`scenes/main/combat_s2.tscn` + +``` +project.godot +└── run/main_scene = "res://scenes/main/combat_s2.tscn" +``` + +### 游戏循环 + +``` +战斗 (BATTLE) + ↓ 全部敌人/Boss 死亡 +波次结算 → 商店 (SHOP) + ↓ 关闭商店 +战斗 (下一波) + ↓ 玩家死亡 +结算屏 (GAME_OVER) → 再来一局 +``` + +### 商店内操作 + +| 按钮 | 功能 | +|:---|:---| +| 法术卡 | 购买并装入当前法杖 | +| 法杖 ⟳ | 切换 Core(法杖类型) | +| 🎒 背包 | 打开插槽编辑器,自由排布法术 | +| 刷新 | 重新随机商店(花费金币) | +| ⚙ 设置 | 难度 / 语言 / 音量 / 清除数据 | +| 出发下一波 | 进入下一波战斗 | + +--- + +## 4. 调试工具 + +### 编辑器内测试 + +在任何场景运行时,可以通过 MCP 脚本执行器(godot-mcp 插件)向游戏注入 GDScript: + +```gdscript +# 示例:快速生成一个 Boss 进行测试 +EnemyManager.reset() +WaveManager.start_wave(20) # 直接启动 W20 Boss 战 +``` + +### HUD 调试信息 + +运行时左上角 HUD 显示: +- 当前波次 / HP / 金币 / XP / 击杀数 +- DPS / 蓄力百分比 +- `[Core名] [法术id列表]`(当前装备) +- 状态实例数 / VFX 数 + +### 快速切波次 + +```gdscript +# 在游戏内执行(须在商店状态下) +# 方法:打开商店后跳到指定波次再出发 +WaveManager.current_wave = 14 # 设置当前波次基准 +# 然后在商店中点「出发下一波」,即进入 W15 精英战 +# 或直接重置重开:get_tree().current_scene._cm.start_game() +``` + +--- + +## 5. 项目配置文件 + +| 文件 | 说明 | +|:---|:---| +| `project.godot` | 引擎配置、Autoload 列表、主场景 | +| `user://save_data.json` | 设置(难度/语言/音量),运行时生成 | +| `user://run_a.json` `run_b.json` | 局内 A/B 双槽存档 | +| `user://endless_records.json` | 本地排行榜(HMAC 签名) | + +> `user://` 路径在 Windows 上位于 `%APPDATA%\Godot\app_userdata\Rogue\` + +--- + +## 6. 常见问题 + +**Q: F5 运行后没有画面(黑屏)** +A: 确认下载的是 **Mono 版**;等待 .NET 首次编译完成(约 30s)。 + +**Q: 敌人/子弹消失了但没有波次结算** +A: 可能生命时间到期。重启当前波次:`WaveManager.start_wave(WaveManager.current_wave)` + +**Q: 更改了 `wand_preset.gd` 的法术数值,但游戏里没有变化** +A: `SpellRegistry` 在 `_ready()` 时缓存法术对象;需要重启游戏(F5 重新运行)使改动生效。 diff --git a/docs/handbook/02_content_authoring.md b/docs/handbook/02_content_authoring.md new file mode 100644 index 0000000..357f6f9 --- /dev/null +++ b/docs/handbook/02_content_authoring.md @@ -0,0 +1,248 @@ +# 02 内容创作指南 (Content Authoring) + +> 策划和程序共同维护的内容文件。所有数值修改无需改架构代码,只改指定位置。 + +> ⭐ **策划/美术优先用可视化工具**:[07 游戏设计器](07_game_designer.md) 提供编辑器内一站式面板(法术/敌人/波次/Core/平衡),表单填数值即可,**无需写代码**。本文的代码方式适合需要复杂自定义逻辑的程序员,或了解底层数据结构。 + +--- + +## A. 添加/修改法术 + +> 💡 **策划优先用可视化工具**:[06 法术编辑器](06_spell_editor.md) 提供编辑器内表单,无需写代码即可新增/编辑法术(保存到 `data/spells.json`)。下面的代码方式适合需要复杂逻辑的程序员。 + +### 法术定义位置 + +- **数据驱动(推荐)**:`res://data/spells.json`(由法术编辑器维护,启动时由 `SpellRegistry` 加载,优先级最高) +- **硬编码(程序)**:`scripts/autoloads/wand_preset.gd`(向后兼容回退) + +### 步骤:添加一个新 ACTION 法术 + +**1. 在 `wand_preset.gd` 添加工厂函数** + +```gdscript +func make_frost_bolt() -> SpellNode: + var n := SpellNode.new() + n.id = "action_frost_bolt" # 全局唯一 ID + n.type = SpellNode.SpellType.ACTION + n.display_name = "SPELL_FROST_BOLT_NAME" # tr() 键(见本地化指南) + n.description = "SPELL_FROST_BOLT_DESC" + n.element_tags = ["tag:ice"] # 共鸣系统用的元素标签 + n.meta = { + "base_damage": 5.0, # 基础伤害 + "speed": 380.0, # 弹速 (px/s) + "lifetime": 4.0, # 存活时间 (s) + "radius": 7.0, # 弹体半径 (px) + "damage_type": 0, # 0=通用 (DamageType.NORMAL) + "apply_status_id": StatusID.FREEZE, # 命中施加状态(可选) + "shop_cost": 22, # 商店价格 + } + return n +``` + +**2. 注册到 `make_spell_by_id` 和 `get_all_spell_ids`** + +```gdscript +func make_spell_by_id(spell_id: String) -> SpellNode: + match spell_id: + # ... 已有法术 ... + "action_frost_bolt": return make_frost_bolt() # 新增 + _: push_warning(...) + +func get_all_spell_ids() -> Array: + return [ + # ... 已有 ... + "action_frost_bolt", # 新增到列表末尾(商店会自动抽取) + ] +``` + +**3. 在 `translations/*.po` 四个文件中添加翻译键**(见本地化指南) + +--- + +### 支持的法术类型 + +| `SpellNode.SpellType` | 说明 | meta 必填键 | +|:---|:---|:---| +| `ACTION` | 发射弹道 | `base_damage`, `speed`, `lifetime`, `radius` | +| `ACTION`(zone) | 地面效果 | `action_kind:"zone"`, `radius`, `status_id`, `duration`, `tick_interval` | +| `ACTION`(summon) | 召唤物 | `action_kind:"summon"`, `lifetime`, `range`, `fire_interval`, `base_damage` | +| `MODIFIER` | 修改施法参数 | 至少一个:`damage_add`, `damage_mult`, `spread_add`, `speed_mult`, `multicast` | +| `TRIGGER` | 命中触发子弹 | `base_damage`, `speed`, `lifetime`, `radius`(触发物自身弹道参数) | +| `LOGIC` | 条件/循环 | `logic_op`(见下表)| + +### LOGIC meta 参数 + +| `logic_op` | 额外 meta | 效果 | +|:---|:---|:---| +| `"every_n_shots"` | `n:int`, `reg:0-3` | 每 N 次施法触发 | +| `"if_hp_below"` | `threshold:float(0-1)` | HP < 阈值时触发 | +| `"if_enemy_nearby"` | `range:float` | 范围内有敌时触发 | +| `"loop"` | `count:int(1-16)` | 后续法术重复 N 次 | + +--- + +## B. 修改波次配置 + +### 波次配置位置 + +`scripts/autoloads/wave_manager.gd` → `WAVE_CONFIG: Array`(数组索引 0 = Wave 1) + +### 配置字段说明 + +```gdscript +{"count": 20, "hp": 15.0, "type": 0, "elite": 0, "gold": 10, "xp": 20, "boss": -1} +# count : 本波生成的杂兵数量 +# hp : 杂兵基础血量(受难度乘子影响) +# type : 敌人原型(见下方原型表) +# elite : W15+ 带 NavigationAgent2D 寻路的精英数量 +# gold : 波次结算奖励金币 +# xp : 波次结算奖励 XP +# boss : -1=无;4=Mini Boss;5=Final Boss +``` + +### 敌人原型(`EnemyManager.Type`) + +| 值 | 名称 | 速度 | 护甲 | 颜色 | 大小 | +|:---|:---|:---|:---|:---|:---| +| 0 | BASIC | 80 | 0 | 红 | 14px | +| 1 | FAST | 140 | 0 | 黄 | 11px | +| 2 | ARMORED | 60 | 5 | 灰 | 16px | +| 3 | ELITE | 90 | 3 | 橙 | 22px | +| 4 | MINIBOSS | 55 | 8 | 品红 | 48px | +| 5 | BOSS | 45 | 14 | 深红 | 76px | + +### 示例:修改 W8 Mini Boss 血量 + +```gdscript +# wave_manager.gd line ~50 +{"count": 12, "hp": 30.0, "type": 0, "elite": 0, "gold": 80, "xp": 120, "boss": 4}, +# ↑ boss:4 → _BOSS_HP[4] = 450.0 控制 Boss 血量 + +# 修改 Boss 血量,找到下面这行: +const _BOSS_HP: Dictionary = {4: 450.0, 5: 1800.0} +# 改成你想要的数值 +``` + +--- + +## C. 修改敌人原型数值 + +`scripts/autoloads/enemy_manager.gd` 顶部常量: + +```gdscript +const _SPEED: Dictionary = {0: 80.0, 1: 140.0, 2: 60.0, 3: 90.0, 4: 55.0, 5: 45.0} +const _ARMOR: Dictionary = {0: 0.0, 1: 0.0, 2: 5.0, 3: 3.0, 4: 8.0, 5: 14.0} +``` + +直接修改对应值即可,无需改其他代码。 + +--- + +## D. 修改难度乘子 + +`scripts/autoloads/settings_manager.gd`: + +```gdscript +func enemy_hp_mult() -> float: return [0.7, 1.0, 1.0][difficulty] +func player_dmg_taken_mult()-> float: return [0.7, 1.0, 1.0][difficulty] +func wave_count_mult() -> float: return [1.0, 1.0, 1.2][difficulty] +func boss_hp_mult() -> float: return [1.0, 1.0, 1.3][difficulty] +# 数组格式:[初学者, 标准, 挑战] +``` + +--- + +## E. 添加共鸣配方 + +`scripts/domain/spell_system/spell_evaluator.gd` → `_ready()` → `_resonance_recipes`: + +```gdscript +_resonance_recipes = [ + { + "id": "plasma_storm", # 配方 ID + "pattern": ["tag:water", "tag:lightning"], # 元素标签组合 + "match": "adjacent", # "adjacent"=相邻;"anywhere_in_deck"=任意位置 + "result_spell_id":"action_plasma_storm", # 产物法术 id(须在 WandPreset 中注册) + "consume_inputs": true, # true=消耗原始法术插槽 + }, + # 新增配方直接追加到这里 + { + "id": "ice_nova", + "pattern": ["tag:ice", "tag:water"], + "match": "adjacent", + "result_spell_id":"action_ice_nova", + "consume_inputs": false, # false=原法术保留,额外注入产物 + }, +] +``` + +> **注意**:产物法术必须同时在 `WandPreset.make_spell_by_id()` 中注册,才能被 `SpellRegistry` 识别。 + +--- + +## F. 添加/修改状态效果 + +### 现有状态 ID(`scripts/autoloads/status_id.gd`) + +```gdscript +const BURN = 1 # 燃烧 DoT(火系) +const FREEZE = 2 # 冻结(硬控) +const POISON = 3 # 中毒(可叠层 DoT) +const WET = 4 # 潮湿 +const OILY = 5 # 油腻 +const STUN = 6 # 麻痹 +const COMBO_MARK = 7 +const VULNERABILITY = 8 +# 新增从 9 开始递增 +``` + +### 状态数值定义(`.tres` 资源,目前占位代码生成) + +当前 `StatusRegistry` 在 `_ready()` 扫描 `res://resources/status_types/` 目录。若不存在资源文件,会尝试从 `WandPreset` 或内置回退。添加新状态的完整流程: + +1. 在 `status_id.gd` 追加常量(从 9 起) +2. 在 `StatusTypeDef` 对象中配置 tick_interval / stack_mode / max_stacks +3. 在状态数值生效的 `can_catalyze` 字段填写对应状态 ID 列表 +4. 在四个 `.po` 文件中添加 `STATUS_新状态_NAME` 等翻译键 + +--- + +## G. 添加 Core(法杖类型) + +```gdscript +# wand_preset.gd 中添加: +func make_staff_long() -> CoreDefinition: + var c := CoreDefinition.new() + c.id = "staff_long" + c.display_name = "CORE_STAFF_LONG_NAME" # tr() 键 + c.slot_count = 10 # 插槽数 + c.topology = CoreDefinition.Topology.LINEAR + c.cpu_limit = 10 + c.cast_interval = 0.7 + return c +``` + +然后在 `CombatManager._CORE_ROSTER` 中追加 `"staff_long"`,以及在 `_make_core_by_id()` 和 `_default_deck_for()` 中添加对应分支。 + +--- + +## H. 调试技巧 + +### 在运行中检查任意系统状态 + +利用 MCP 游戏脚本执行器(或 Godot 调试器表达式): + +```gdscript +# 查看当前法术 VM 编译结果 +var ids = [] +for nd in SpellEvaluator._equipped_compiled.nodes: ids.append(nd.id) +print(ids) + +# 立即杀死所有敌人(测试波次结算) +for i in range(EnemyManager.get_active_count()-1, -1, -1): + var eid = EnemyManager._slot_entity_map.get(i, -1) + if eid >= 0: EnemyManager.apply_damage(eid, 99999.0, -1, false) + +# 给玩家大量金币 +PlayerStats.gain_gold(9999) +``` diff --git a/docs/handbook/03_asset_replacement.md b/docs/handbook/03_asset_replacement.md new file mode 100644 index 0000000..46588f8 --- /dev/null +++ b/docs/handbook/03_asset_replacement.md @@ -0,0 +1,281 @@ +# 03 资源替换指南 (Asset Replacement Guide) + +> 当前项目使用**程序化占位**(几何体 + 蜂鸣音),所有接口已就绪。本文说明如何无缝替换为正式美术资源,**不需要修改任何业务逻辑代码**。 + +--- + +## A. 音效替换 + +### 当前状态 + +`AudioManager` 在 `res://audio/sfx/.ogg`(或 `.wav` / `.mp3`)**不存在**时自动回退到程序化蜂鸣。文件存在即优先加载真实音效。 + +### 替换步骤 + +**1. 创建目录并放入文件** + +``` +res://audio/sfx/ + hit.ogg ← 子弹命中音效 + kill.ogg ← 敌人死亡 + hurt.ogg ← 玩家受击 + wave_complete.ogg ← 波次结算 + boss.ogg ← Boss 入场 + shop.ogg ← 商店开启 + buy.ogg ← 购买法术 + level_up.ogg ← 升级 +``` + +**2. 确认格式要求** + +| 属性 | 推荐值 | +|:---|:---| +| 格式 | OGG Vorbis(体积小)/ WAV PCM(低延迟) | +| 采样率 | 44100 Hz 或 22050 Hz | +| 声道 | 单声道(Mono)即可,立体声也支持 | +| 时长 | 音效 < 3s;BGM 循环 | +| 响度 | 建议 -14 LUFS 标准化后导入 | + +**3. Godot 导入设置**(在编辑器 FileSystem 面板右键文件 → Import) + +| 设置 | 推荐值 | +|:---|:---| +| Loop | 音效:`Off`;BGM:`On` | +| Compression Mode | `quality` OGG / `PCM` WAV | +| Max Texture Size | 不适用(音频) | + +**4. 添加新音效 ID** + +在 `AudioManager._SFX_DEFS` 中追加一行(定义占位蜂鸣,有真实文件时忽略): + +```gdscript +# audio_manager.gd +const _SFX_DEFS: Dictionary = { + # ... 已有 ... + "spell_cast": [600.0, 80.0], # 新增:频率 600Hz,80ms +} +``` + +然后在需要播放的地方调用: + +```gdscript +AudioManager.play("spell_cast", spawn_pos) +``` + +### BGM 系统(预留接口) + +当前无 BGM 系统,添加时建议在 `AudioManager` 中扩展: + +```gdscript +# 建议扩展方向(未实现,供参考) +func play_bgm(track_id: String, fade_sec: float = 1.0) -> void: + var path = "res://audio/bgm/%s.ogg" % track_id + # 跨淡 + loop... +``` + +--- + +## B. 敌人 / 玩家 精灵替换 + +### 当前状态 + +- **玩家**:`combat_s2.gd` 中程序化 `Polygon2D`(青色方块) +- **敌人**:`EnemyManager.sync_multimesh()` 驱动 `MultiMeshInstance2D`,使用单位 `QuadMesh` + 颜色覆盖 + +### 替换玩家精灵 + +`combat_s2.gd` → `_setup_world_view()`,找到玩家标记部分替换: + +```gdscript +# 当前(几何占位) +var marker := Polygon2D.new() +marker.polygon = PackedVector2Array([...]) +marker.color = Color(0.35, 0.9, 1.0) +_player_node.add_child(marker) + +# 替换为精灵(修改为) +var marker := Sprite2D.new() +marker.texture = load("res://assets/sprites/player.png") +marker.scale = Vector2(0.5, 0.5) # 按实际尺寸调整 +_player_node.add_child(marker) +``` + +### 替换敌人渲染(MultiMesh + Atlas) + +敌人使用 `MultiMeshInstance2D`,单次 Draw Call 渲染所有实例。替换步骤: + +**步骤 1:准备 Atlas 贴图** + +将所有敌人原型排列在一张贴图上(建议 512×512): + +``` +atlas_enemies.png +┌────┬────┬────┐ +│BASIC│FAST│ARM.│ 每格 64×64 px +├────┼────┼────┤ +│ELITE│MIN.│BOSS│ +└────┴────┴────┘ +``` + +**步骤 2:修改 MultiMesh 使用 Atlas** + +`combat_s2.gd` → `_setup_world_view()` 中的敌人 MMI 部分: + +```gdscript +# 原:使用 QuadMesh 纯色 +var quad := QuadMesh.new() +quad.size = Vector2(1.0, 1.0) +_enemy_mm.mesh = quad + +# 替换:使用 PlaneMesh + Atlas 材质 +var mesh := PlaneMesh.new() +mesh.size = Vector2(1.0, 1.0) +var mat := CanvasItemMaterial.new() +# 或使用 ShaderMaterial + 自定义 Shader 实现 UV 偏移(按 enemy_type 选择 Atlas 区域) +_enemy_mm.mesh = mesh +var mmi := MultiMeshInstance2D.new() +mmi.multimesh = _enemy_mm +mmi.texture = load("res://assets/sprites/atlas_enemies.png") +add_child(mmi) +``` + +**步骤 3(进阶):Shader 按 instance_custom_data 选 UV** + +```glsl +// res://shaders/enemy_atlas.gdshader +shader_type canvas_item; +uniform sampler2D atlas_tex; +uniform int cols = 3; // atlas 列数 + +void fragment() { + // INSTANCE_CUSTOM.x 存储 enemy_type (0-5) + float etype = INSTANCE_CUSTOM.x; + float row = floor(etype / float(cols)); + float col = mod(etype, float(cols)); + vec2 uv = (UV + vec2(col, row)) / vec2(float(cols), 2.0); + COLOR = texture(atlas_tex, uv) * COLOR; +} +``` + +在 `EnemyManager.sync_multimesh()` 中写入 custom data: + +```gdscript +# enemy_manager.gd sync_multimesh() 内,在 set_instance_transform_2d 之后 +mm.set_instance_custom_data(i, Color(float(etype) / 255.0, 0, 0, 0)) +``` + +--- + +## C. 子弹精灵替换 + +子弹同样使用 `MultiMeshInstance2D`(`_bullet_mm`)。替换为 Atlas 方式与敌人相同,但子弹通常使用 `damage_type` 区分外观。 + +`BulletManager.sync_multimesh()` 中写入 custom data(damage_type): + +```gdscript +# bullet_manager.gd sync_multimesh() 内追加 +mm.set_instance_custom_data(i, Color(float(_data[base + 8]) / 255.0, 0, 0, 0)) +``` + +--- + +## D. VFX 特效替换 + +### 当前状态 + +`VFXManager` 使用程序化彩色矩形作为特效占位(`ColorRect`/`Sprite2D`)。 + +### 替换为粒子特效 + +`scripts/autoloads/vfx_manager.gd` → `_instantiate_vfx()` 中修改 `_vfx_scenes` 映射: + +**步骤 1:制作粒子场景** + +为每个特效 ID 建一个场景文件(`GPUParticles2D` 为根节点,`one_shot = true`): + +``` +res://scenes/vfx/ + hit_spark.tscn ← 命中火花 + status_burn.tscn ← 燃烧特效 + status_poison.tscn ← 中毒特效 + cast_flash.tscn ← 施法闪光 + death_burst.tscn ← 敌人死亡 + zone_expire.tscn ← 地面效果消散 +``` + +每个场景设置: +- `GPUParticles2D.one_shot = true`(**必须**,P6-N72) +- `GPUParticles2D.emitting = false`(代码控制时机) +- `lifetime` = 特效持续时长 + +**步骤 2:修改 `_vfx_scenes` 预加载表** + +```gdscript +# vfx_manager.gd +func _ready() -> void: + _vfx_scenes = { + "hit_spark": preload("res://scenes/vfx/hit_spark.tscn"), + "status_burn": preload("res://scenes/vfx/status_burn.tscn"), + # ... 其他 ... + } +``` + +**步骤 3:让 VFX 播放时自动启动粒子** + +> **重要**:`one_shot = true` 和 `emitting = false` 须在**场景文件内**就设好(Godot 编辑器中设置属性),这是 P6-N72 强制要求。运行时 `VFXManager.play()` 只负责把节点拖出池并触发 `emitting = true`。 + +```gdscript +# vfx_manager.gd → play() 内,instance 创建后追加: +if node is GPUParticles2D: + node.emitting = true # 启动粒子(one_shot 已在场景中设置,这里不必覆盖) +``` + +### 特效 ID 参考 + +| ID | 触发时机 | +|:---|:---| +| `"hit_spark"` | 子弹命中敌人 | +| `"status_burn"` | 施加 BURN 状态 | +| `"status_poison"` | 施加 POISON 状态 | +| `"cast_flash"` | 施法时玩家周围 | +| `"death_burst"` | 敌人死亡 | +| `"zone_expire"` | 地面效果消失 | + +--- + +## E. UI 界面图片替换 + +当前 UI 全为程序化构建(`ColorRect` + `Label` + `Button`),无独立 `.tscn` 场景。替换为美术 UI 的推荐路径: + +**选项 A:保留程序化,只替换 Theme** + +在 `combat_s2.gd` 的各 `_setup_*` 函数中,为 `Button`/`Label` 应用自定义 Theme: + +```gdscript +var theme := load("res://assets/ui/main_theme.tres") # 你制作的 Theme 资源 +btn.theme = theme +``` + +**选项 B:迁移到独立 .tscn 场景**(推荐,长期) + +1. 为商店、背包、结算屏分别建 `.tscn` +2. 在 `_setup_shop_ui()` 等函数中替换为 `load("res://scenes/ui/shop.tscn").instantiate()` +3. 通过信号 / Callable 将原有事件(买法术、刷新等)连接到新场景节点 + +> **注意**:迁移 UI 时须保留对 `_shop_btns`、`_reroll_btn` 等引用的赋值,否则商店逻辑失效。 + +--- + +## F. 资源路径约定 + +| 类型 | 约定路径 | +|:---|:---| +| 音效 | `res://audio/sfx/.ogg` | +| BGM | `res://audio/bgm/.ogg` | +| 玩家精灵 | `res://assets/sprites/player.png` | +| 敌人 Atlas | `res://assets/sprites/atlas_enemies.png` | +| VFX 场景 | `res://scenes/vfx/.tscn` | +| UI Theme | `res://assets/ui/main_theme.tres` | +| UI 场景 | `res://scenes/ui/.tscn` | +| 字体 | `res://assets/fonts/.ttf` | + diff --git a/docs/handbook/04_localization.md b/docs/handbook/04_localization.md new file mode 100644 index 0000000..3fae912 --- /dev/null +++ b/docs/handbook/04_localization.md @@ -0,0 +1,197 @@ +# 04 本地化工作流 (Localization Workflow) + +> 项目遵循 **ADR-A1**:所有玩家可见字符串必须通过 `tr("KEY")` 包裹,禁止业务代码中出现裸字符串。 + +--- + +## 1. 文件位置 + +``` +translations/ + zh_CN.po ← 简体中文(权威主档) + zh_TW.po ← 繁体中文 + en.po ← 英文 + ja.po ← 日文 +``` + +--- + +## 2. `.po` 文件格式 + +每条翻译的格式为: + +```po +msgid "UI_WAVE" +msgstr "第 %d 波" +``` + +- `msgid`:代码中 `tr("KEY")` 使用的键 +- `msgstr`:该语言显示的文字 +- `%d`、`%s`、`%.1f` 等格式占位符必须与代码中 `tr("KEY") % value` 完全对应 + +--- + +## 3. 添加新翻译字符串(标准流程) + +### Step 1 — 在代码中使用 tr() 键 + +```gdscript +# ❌ 错误:裸字符串 +label.text = "商店已关闭" + +# ✅ 正确:通过键引用 +label.text = tr("SHOP_CLOSED_MSG") + +# 带参数 +label.text = tr("UI_WAVE") % current_wave # → "第 3 波" +label.text = tr("UI_KILLS") % [kills, alive] # → "击杀: 7 存活: 3" +``` + +### Step 2 — 在全部四个 `.po` 文件中添加对应条目 + +**zh_CN.po**(权威档,写游戏实际中文): +```po +msgid "SHOP_CLOSED_MSG" +msgstr "商店已关闭" +``` + +**zh_TW.po**(繁体,改繁体字): +```po +msgid "SHOP_CLOSED_MSG" +msgstr "商店已關閉" +``` + +**en.po**(英文): +```po +msgid "SHOP_CLOSED_MSG" +msgstr "Shop Closed" +``` + +**ja.po**(日文): +```po +msgid "SHOP_CLOSED_MSG" +msgstr "ショップが閉店しました" +``` + +### Step 3 — 验证(运行时检查) + +在游戏内执行脚本验证无缺键: + +```gdscript +var keys = ["SHOP_CLOSED_MSG"] # 你新加的键 +for loc in ["zh_CN","zh_TW","en","ja"]: + Locale.set_locale(loc) + for k in keys: + if tr(k) == k: + print("缺键!语言: %s 键: %s" % [loc, k]) +``` + +--- + +## 4. 键名规范 + +**格式**:`<模块>_<实体>_<含义>`,全大写 + 下划线 + +| 前缀 | 用途 | 示例 | +|:---|:---|:---| +| `UI_` | 通用 HUD 标签 | `UI_WAVE`, `UI_HP` | +| `SHOP_` | 商店界面 | `SHOP_TITLE`, `SHOP_REROLL` | +| `INV_` | 背包界面 | `INV_TITLE`, `INV_BENCH` | +| `SETTLE_` | 结算屏 | `SETTLE_TITLE`, `SETTLE_STATS` | +| `SETTINGS_` | 设置面板 | `SETTINGS_DIFFICULTY` | +| `STATUS_` | 状态消息 | `STATUS_PURCHASED` | +| `DIFF_` | 难度名称 | `DIFF_BEGINNER`, `DIFF_CHALLENGE` | +| `SPELL_` | 法术名/描述 | `SPELL_SPARK_BOLT_NAME`, `SPELL_SPARK_BOLT_DESC` | +| `CORE_` | Core 名/描述 | `CORE_WAND_BASIC_NAME` | +| `STATUS_EFFECT_` | 状态效果名 | `STATUS_EFFECT_BURN_NAME` | +| `BOSS_` | Boss 名称 | `BOSS_FINAL_NAME` | + +--- + +## 5. 法术 / Core 名称本地化(待完成) + +当前法术 `display_name` 字段存储的是裸英/中文字符串(如 `"Spark Bolt"`)。正式发布前需键化。 +> **注意**:修改后须同步更新商店按钮渲染(`combat_s2.gd → _refresh_shop_ui`),将 `spell.display_name` 改为 `tr(spell.display_name)`。 + +**Step 1 — 修改 `wand_preset.gd`** + +```gdscript +# 当前(裸字符串) +n.display_name = "Spark Bolt" +n.description = "发射一颗期限 4s 的电光飞弹,造成 3 伤害。" + +# 修改为(键) +n.display_name = "SPELL_SPARK_BOLT_NAME" +n.description = "SPELL_SPARK_BOLT_DESC" +``` + +**Step 2 — 在四语 `.po` 中添加** + +```po +# zh_CN.po +msgid "SPELL_SPARK_BOLT_NAME" +msgstr "电花弹" + +msgid "SPELL_SPARK_BOLT_DESC" +msgstr "发射一颗快速电花飞弹,造成 3 点闪电伤害。" +``` + +**Step 3 — 显示时包裹 tr()** + +```gdscript +# UI 显示法术名时 +btn.text = tr(spell.display_name) +# 法术描述 +desc_label.text = tr(spell.description) +``` + +--- + +## 6. 添加新语言 + +**Step 1 — 创建新 `.po` 文件**(以韩语为例) + +``` +translations/ko.po +``` + +内容:复制 `en.po`,将 `Language: en` 改为 `Language: ko`,翻译所有 `msgstr`。 + +**Step 2 — 在 `locale_manager.gd` 中注册** + +```gdscript +const LOCALES: Array = ["zh_CN", "zh_TW", "en", "ja", "ko"] # 追加 "ko" +``` + +新语言将自动在商店语言切换按钮中循环出现。 + +--- + +## 7. 在游戏内切换语言(测试用) + +```gdscript +# 切换到英文 +Locale.set_locale("en") + +# 循环切换(等同商店 🌐 按钮) +Locale.cycle_locale() + +# 查看当前语言 +print(Locale.get_locale()) +``` + +--- + +## 8. 提交规范 + +每次提交涉及玩家可见字符串的改动时,须**同时更新四个 `.po` 文件**。允许以英文或空字符串占位(`msgstr ""`),但 PR 审查时须标注"待翻译"。 + +--- + +## 9. 工具推荐 + +| 工具 | 用途 | +|:---|:---| +| [Poedit](https://poedit.net) | `.po` 文件 GUI 编辑器,支持模糊匹配和自动翻译接口 | +| VSCode + gettext 插件 | 轻量化文本编辑 | +| Godot 内置本地化 | `Project → Project Settings → Localization → Translations` 可预览各语言 | diff --git a/docs/handbook/05_release_guide.md b/docs/handbook/05_release_guide.md new file mode 100644 index 0000000..40103d4 --- /dev/null +++ b/docs/handbook/05_release_guide.md @@ -0,0 +1,170 @@ +# 05 发布标准清单 (Release Guide) + +> 正式发布前所有 **P0 必须项** 须全部打勾。参考:`docs/plan/certification_checklist.md`(权威完整版)。 + +--- + +## 一、P0 技术验收(S6 P0,均已通过) + +| 编号 | 项目 | 状态 | 说明 | +|:---|:---|:---:|:---| +| P-S6-01 | Wave 1~20 无崩溃通关;W8/W20 Boss 正常 | ✅ | 系统已实现并实测 | +| P-S6-02 | 500 敌人 + 1500 子弹稳定 60fps | ✅ | 实测 180fps / 0.6ms | +| P-S6-03 | Endless 排行榜 + HMAC 签名 | ✅ | user://endless_records.json | +| P-S6-04 | 死亡时截图保存非黑屏 | ✅ | user://runs/run_*.png | +| P-S6-05 | 所有 P6-N 规范条目无违反 | ✅ | 1 修复 / 11 PASS | +| P-S6-06 | 四语本地化(zh_CN/zh_TW/en/ja) | 🟡 | 骨架完成;法术/Core 名待键化 | +| P-S6-07 | 内存峰值 RSS < 512MB | ✅ | 实测 ~176MB | + +--- + +## 二、内容完成清单(发布前必做) + +### 2.1 法术内容(当前:14 张,目标:20+ 张) + +| 类型 | 已有 | 缺失(示例) | +|:---|:---|:---| +| ACTION | spark_bolt, energy_orb, fire_bolt, poison_dart, laser_beam, water_wave, chain_bolt, plasma_storm (产物), poison_pool, summon_turret | ice_nova, nuke, homing, chain_bolt(独立弹道版)| +| MODIFIER | damage_plus, spread_mod, double_cast | heavy_cost, speed_mult_mod | +| TRIGGER | trigger_on_hit | trigger_on_kill(Timer 触发型) | +| LOGIC | every_n_shots, if_hp_below, if_enemy_nearby, loop | — | + +**添加法术流程** → 见 [02 内容创作指南 §A](02_content_authoring.md) + +### 2.2 Core 种类(当前:5 种,目标:7 种) + +| Core | 状态 | +|:---|:---| +| wand_basic | ✅ | +| wand_fast | ✅ | +| wand_memory (PERSISTENT_MEMORY) | ✅ | +| matrix_board (MATRIX 2×4) | ✅ | +| circuit_fork (CIRCUIT) | ✅ | +| staff_long (10 槽) | ⬜ 待加 | +| wand_eternal (INFINITE_SPELLS) | ⬜ 待加 | + +### 2.3 美术资源(当前:全程序化占位) + +| 资源 | 规格 | 优先级 | +|:---|:---|:---| +| 玩家精灵 | 64×64 PNG,4 方向或单帧 | P0 | +| 敌人 Atlas(6 原型) | 512×512,每格 64×64 | P0 | +| 子弹 Atlas(按 damage_type) | 256×64,每格 32×32 | P0 | +| VFX 粒子场景(8 种特效) | GPUParticles2D,one_shot=true | P0 | +| UI Theme / 背景 | 商店/结算/设置背景图 | P1 | +| App 图标 | 512×512 PNG | P0 发布前 | + +**替换方法** → 见 [03 资源替换指南](03_asset_replacement.md) + +### 2.4 音效资源(当前:程序化蜂鸣) + +| 文件 | 内容 | +|:---|:---| +| `audio/sfx/hit.ogg` | 子弹命中 | +| `audio/sfx/kill.ogg` | 敌人死亡 | +| `audio/sfx/hurt.ogg` | 玩家受击 | +| `audio/sfx/wave_complete.ogg` | 波次结算 | +| `audio/sfx/boss.ogg` | Boss 入场 | +| `audio/sfx/shop.ogg` | 商店开启 | +| `audio/sfx/buy.ogg` | 购买法术 | +| `audio/sfx/level_up.ogg` | 升级 | +| `audio/bgm/battle_*.ogg` | 战斗 BGM(循环)| + +**替换方法** → 见 [03 资源替换指南 §A](03_asset_replacement.md) + +### 2.5 本地化补全 + +- [ ] 所有法术 `display_name` / `description` 改为 `tr()` 键 +- [ ] 所有 Core `display_name` 改为 `tr()` 键 +- [ ] 所有状态效果名改为 `tr()` 键 +- [ ] Boss 名称键化 +- [ ] 商店页文案(Steam 商店描述,日/英/繁中) + +--- + +## 三、Steam PC 发布前必须项 + +### 3.1 Steamworks 配置(代码端) + +1. **安装 GodotSteam 插件**(godotsteam.com) +2. **初始化**:在 `CrashReporter._ready()` 前调用 `Steam.init()` +3. **成就钩子**(ST-11):`AchievementManager` 订阅 `EventID.ACHIEVEMENT_UNLOCKED`(ID=18): + ```gdscript + func _on_achievement_unlocked(p: Dictionary) -> void: + Steam.set_achievement(p.get("achievement_id","")) + Steam.store_stats() + ``` +4. **云存档**(ST-50):`ProfileManager.save_run()` 包裹 `Steam.beginFileWriteBatch/endFileWriteBatch` +5. **排行榜提交**(ST-42):`EndlessRecords.add_record()` 后尝试 `Steam.uploadLeaderboardScore()`,网络失败静默缓存 + +### 3.2 认证检查项速查 + +| 编号 | 项目 | 实现位置 | +|:---|:---|:---| +| ST-01 | 不收集 PII | 无账号系统 ✅ | +| ST-03 | 清除所有本地数据 | `SettingsManager.clear_all_local_data()` ✅ | +| ST-10 | Steam.init() 成功 | 待实现 | +| ST-20 | 手柄基本可用 | 待实现(InputMap 映射) | +| ST-30 | Steam Deck 1280×800 | 待测试 | +| ST-40/41 | HMAC 签名排行榜 | `endless_records.gd` ✅ | +| ST-50 | Steam Cloud 存档 | 待实现(profile_manager.gd 已有位置) | + +完整清单见 `docs/plan/certification_checklist.md` + +--- + +## 四、构建与发布流程 + +### 4.1 导出配置(Godot Editor) + +``` +Project → Export +``` + +| 平台 | 模板 | 注意 | +|:---|:---|:---| +| Windows x64 | Export with Mono runtime | 勾选"Embed PCK"打包为单文件 | +| Steam Deck (Linux) | Linux x86_64 Mono | 须在 SteamOS/Proton 下测试(ST-33)| + +### 4.2 版本号管理 + +在 `project.godot` 设置: +```ini +[application] +config/version="1.0.0" +``` + +发布时打 git tag: +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +并在 `CHANGELOG.md` 记录构建哈希(PRE-02 要求)。 + +### 4.3 发布前最终清单 + +``` +[ ] 所有 P0 认证项 ✅(见 certification_checklist.md) +[ ] Steam 商店页:截图×5、宣传片、描述文字审核通过 +[ ] IARC 自评系统完成(Steam 自动申报) +[ ] Wave 1~20 完整通关测试(无崩溃) +[ ] crash_reporter.gd 已为首位 Autoload(project.godot 验证) +[ ] version = "1.0.0" 已设置,git tag 已推送 +``` + +--- + +## 五、QA 测试要点 + +| 场景 | 验证内容 | +|:---|:---| +| 全 20 波通关 | 每波正常生成;结算金币/XP 正确;W8/W20 Boss 阶段触发 | +| 法术排列测试 | MATRIX 邻接加成;CIRCUIT 双路;共鸣触发;LOGIC 门控 | +| 存档恢复 | 关闭游戏→重开→`resume_game()` 恢复法杖+波次 | +| 排行榜防篡改 | 手动修改 endless_records.json → 重启应拒绝载入 | +| 难度三档 | 初学者:受伤乘数×0.7(即减少30%);挑战:W1 生成24敌(20×1.2),Boss HP×1.3 | +| 清除数据 | 设置→清除→确认→所有 user:// 文件消失 | +| 语言切换 | 商店中切语言→HUD/商店/背包/结算全部切换 | +| 性能压测 | 500 敌 + 1500 弹 → 帧率 ≥ 60fps | +| 内存 | Wave 20 Boss 战 → RSS < 512MB | diff --git a/docs/handbook/06_spell_editor.md b/docs/handbook/06_spell_editor.md new file mode 100644 index 0000000..1ec58fa --- /dev/null +++ b/docs/handbook/06_spell_editor.md @@ -0,0 +1,134 @@ +# 06 法术编辑器(可视化工具) + +> **给策划/开发**:无需写一行代码,在 Godot 编辑器里用可视化表单新增、编辑、删除法术。这是 Godot 的 **EditorPlugin**(等同 Unity 的自定义 EditorWindow)。 + +--- + +## 1. 启用插件 + +插件位于 `res://addons/spell_editor/`,默认已在 `project.godot` 启用。若未显示: + +``` +项目 → 项目设置 → 插件 → 勾选 "Spell Editor (法术编辑器)" +``` + +启用后,编辑器**左下停靠区**会出现「法术编辑器」标签页。 + +> 首次启用或修改插件后,可能需要**重启 Godot 编辑器**让停靠面板出现。 + +--- + +## 2. 界面说明 + +``` +✨ 法术编辑器 +┌────────────────────────┐ +│ 法术列表(ItemList) │ ← 所有 data/spells.json 中的法术 +│ [ACTION] action_frost… │ +│ [MODIFIER] modifier_pi… │ +└────────────────────────┘ +[➕新增] [⧉复制] [🗑删除] +──────────────────────── +ID(唯一) : [______] +类型 : [ACTION ▾] +显示名(tr 键) : [______] +描述(tr 键) : [______] +元素标签(逗号) : [tag:ice] +meta(JSON) : [______] + [______] +[插入该类型 meta 模板] +──────────────────────── +[✓应用到列表] [💾保存文件] [↻重载] +状态:…… +``` + +--- + +## 3. 新增一个法术(操作流程) + +1. 点 **➕新增** → 列表出现 `new_spell`,表单自动填入 ACTION 模板 +2. 改 **ID**(如 `action_lightning`,必须全局唯一) +3. 选 **类型**(ACTION / MODIFIER / TRIGGER / LOGIC) +4. 填 **显示名 / 描述**(建议填 `tr()` 键,如 `SPELL_LIGHTNING_NAME`,见 [04 本地化](04_localization.md)) +5. 填 **元素标签**(共鸣用,如 `tag:lightning`;多个用逗号) +6. 编辑 **meta**(JSON)——点「插入该类型 meta 模板」获取起始模板,改数值 +7. 点 **✓应用到列表** → 校验并写入内存 +8. 点 **💾保存文件** → 写入 `res://data/spells.json` +9. 运行游戏(F5)→ 新法术自动出现在商店池,可购买施放 + +> **改完不生效?** 法术在游戏启动时由 `SpellRegistry` 加载,需要**重启游戏**(F5 重新运行)。 + +--- + +## 4. meta 字段速查(按类型) + +### ACTION(弹道) +```json +{ "base_damage": 5.0, "speed": 350.0, "lifetime": 4.0, "radius": 6.0, + "damage_type": 0, "apply_status_id": 2, "pierce": 0, "shop_cost": 20 } +``` +- `apply_status_id`:命中施加状态(1=燃烧 2=冻结 3=中毒…见 `status_id.gd`),可省略 +- `action_kind`:`"zone"`(地面效果)/ `"summon"`(召唤物),省略=普通弹道 + +### ACTION(地面效果,毒池等) +```json +{ "action_kind": "zone", "radius": 200.0, "status_id": 3, + "duration": 5.0, "tick_interval": 1.0, "shop_cost": 28 } +``` + +### ACTION(召唤炮台) +```json +{ "action_kind": "summon", "lifetime": 20.0, "range": 350.0, + "fire_interval": 0.8, "base_damage": 4.0, "speed": 360.0, "shop_cost": 35 } +``` + +### MODIFIER(修改施法) +```json +{ "damage_add": 10.0, "damage_mult": 1.0, "spread_add": 1, + "multicast": 1, "pierce": 2, "shop_cost": 20 } +``` + +### TRIGGER(命中子母弹) +```json +{ "base_damage": 3.0, "speed": 250.0, "lifetime": 4.0, "radius": 6.0, + "damage_type": 0, "shop_cost": 30 } +``` + +### LOGIC(条件/循环) +```json +{ "logic_op": "every_n_shots", "n": 3, "reg": 0, "shop_cost": 24 } +``` +`logic_op` 取值:`every_n_shots` / `if_hp_below`(threshold) / `if_enemy_nearby`(range) / `loop`(count) + +--- + +## 5. 数据流(原理) + +``` +法术编辑器 dock ──保存──▶ res://data/spells.json ──游戏启动加载──▶ SpellRegistry + (编辑器内) (纯数据,可 git 版本管理) (注册到法术 VM) + │ + 商店抽取 / 背包装备 / 法术 VM 编译施放 +``` + +- `data/spells.json` 中的法术**优先级最高**,会覆盖 `wand_preset.gd` 里的同 ID 硬编码版本 +- 不在 JSON 里的法术仍由硬编码提供(向后兼容,不会丢失现有法术) +- JSON 是纯文本,可纳入 git,多人协作改法术不冲突代码 + +--- + +## 6. 扩展方向(给开发) + +同一套 EditorPlugin 模式可复制出更多可视化工具: + +| 工具 | 数据文件 | 对应硬编码 | +|:---|:---|:---| +| **法术编辑器**(已实现) | `data/spells.json` | `wand_preset.gd` | +| 敌人编辑器(可加) | `data/enemies.json` | `enemy_manager.gd` `_SPEED`/`_ARMOR` | +| 波次编辑器(可加) | `data/waves.json` | `wave_manager.gd` `WAVE_CONFIG` | +| Core 编辑器(可加) | `data/cores.json` | `wand_preset.gd` `make_*_core` | +| 共鸣配方编辑器(可加) | `data/resonance.json` | `spell_evaluator.gd` `_resonance_recipes` | + +实现参考 `addons/spell_editor/spell_dock.gd`:`@tool` 脚本 + `EditorPlugin.add_control_to_dock` + JSON 读写 + 对应 Manager 的 `_load_json_*()` 加载方法。 + +> **Godot 编辑器扩展能力**:`EditorPlugin`(停靠面板/工具栏/底部面板)、`@tool`(脚本在编辑器运行)、`EditorInspectorPlugin`(自定义检视器)、自定义 `Resource`(`.tres` 自带表单)、`EditorScript`(一次性脚本)。与 Unity 的 EditorWindow/CustomEditor/ScriptableObject 一一对应,且与节点系统集成更紧。 diff --git a/docs/handbook/07_game_designer.md b/docs/handbook/07_game_designer.md new file mode 100644 index 0000000..2ff816c --- /dev/null +++ b/docs/handbook/07_game_designer.md @@ -0,0 +1,158 @@ +# 07 游戏设计器(一站式可视化配置工具) + +> **核心理念**:策划 / 美术 / 开发**无需写一行代码**,在编辑器的「🎮 游戏设计器」面板里调数值、改玩法、加内容。所有改动保存为 `res://data/*.json`,游戏运行时自动加载。 + +--- + +## 1. 打开工具 + +1. 启用插件(默认已启用):`项目 → 项目设置 → 插件 → 勾选 "Game Designer"` +2. 编辑器**底部面板**点击 **「🎮 游戏设计器」** 标签(与"输出""调试器"并列) +3. 出现 7 个分页:**✨法术 / 👾敌人 / 🌊波次 / 🪄Core / 🔮共鸣 / 🔥状态 / ⚖平衡** + +> 🏛 **纯数据驱动架构**:游戏所有内容数据**只存在于 `res://data/*.json`**,代码不含任何硬编码内容副本或回退。这意味着——改 JSON 就是改游戏,数据是唯一权威源。若某个 data 文件缺失,游戏会在输出面板 `push_error` 明确报错(而非静默用旧值)。 + +> 首次启用或改插件后,可能需**重启 Godot 编辑器**让面板出现。 + +--- + +## 2. 通用工作流 + +``` +在面板里改数值/加内容 → 点「💾 保存」 → 按 F5 运行游戏 → 改动生效 +``` + +> ⚠️ **所有改动需重启游戏(F5)生效**——数据在游戏启动时由各 Manager 一次性加载。 + +--- + +## 3. 五个分页详解 + +### ✨ 法术(spells.json) +新增/编辑/删除法术,最丰富的玩法配置。详见 [06 法术编辑器](06_spell_editor.md)(meta 字段速查表)。 +- 列表 → 选中编辑;➕新增 / ⧉复制 / 🗑删除 +- 字段:ID / 类型(ACTION/MODIFIER/TRIGGER/LOGIC) / 显示名 / 描述 / 元素标签 / meta(JSON) +- 「插入该类型 meta 模板」一键填入起始参数 + +### 👾 敌人(enemies.json) +6 种敌人原型的数值表格,**直接拖动数字框**调整: + +| 列 | 含义 | +|:---|:---| +| 速度 | 移动速度(px/s)| +| 护甲 | 每次命中减免的固定伤害 | +| 尺寸 | 渲染直径(px),Boss 设大更醒目 | +| 颜色 | 点色块选颜色(敌人渲染色)| + +原型:杂兵 / 快速 / 护甲 / 精英 / 首领(MiniBoss) / 终焉(Boss) + +### 🌊 波次(waves.json) +**关卡难度曲线编辑器**——逐波配置: + +| 列 | 含义 | +|:---|:---| +| 怪数 | 本波杂兵数量 | +| 血量 | 杂兵基础血量 | +| 类型 | 杂兵原型(Basic/Fast/Armored…)| +| 精英 | 带寻路的精英数(W15+)| +| 金币 / XP | 波次结算奖励 | +| Boss | 无 / MiniBoss(4) / Boss(5) | + +- 「➕末尾加一波」扩展关卡长度 +- 改完点「💾保存 waves.json」 + +### 🪄 Core(cores.json) +法杖类型配置(影响法术排列玩法): + +| 字段 | 含义 | +|:---|:---| +| 槽位数 | 可装法术数 | +| 拓扑 | LINEAR(线性) / MATRIX(矩阵邻接) / CIRCUIT(电路分叉) | +| CPU 上限 | 单次施法最大执行步数 | +| 施法间隔 | 自动施法冷却(s) | +| 特性 | 无 / PERSISTENT_MEMORY(寄存器跨帧) / … | +| 矩阵行·列 | MATRIX 专用网格尺寸 | +| 电路边 | CIRCUIT 专用,JSON 有向边 `[{"from":0,"to":1}]` | + +### 🔮 共鸣(resonance.json) +元素组合自动合成配方(隐藏玩法): +- **元素 A + 元素 B**(相邻放置)→ 合成**产物法术** +- 匹配方式:adjacent(相邻) / anywhere_in_deck(任意位置) +- 消耗输入:合成后是否移除原两张法术 +- 例:`tag:water` + `tag:lightning` → `action_plasma_storm` + +### 🔥 状态(status_effects.json) +DoT/控制/叠层效果: + +| 字段 | 含义 | +|:---|:---| +| 状态 ID | 整数(1=燃烧 2=冻结 3=中毒…),与法术 meta 的 `apply_status_id` 对应 | +| 持续时间 / 跳字间隔 | DoT 周期 | +| 叠层模式 | REFRESH(刷新) / INTENSITY(叠层) / INDEPENDENT(独立) | +| 最大层数 | 0=无限 | +| 每跳伤害 | DoT 每次伤害 | +| VFX ID | 触发的特效 | + +### ⚖ 平衡(balance.json) +全局数值微调: +- **难度乘子表格**:4 行(敌人血量×/玩家受伤×/波次怪数×/Boss血量×) × 3 档(初学者/标准/挑战) +- **Boss 基础血量**:MiniBoss(W8) / Final Boss(W20) + +调一格数字即可改变整体难度曲线。 + +--- + +## 4. 数据流原理 + +``` +🎮 游戏设计器面板 ──保存──▶ res://data/*.json ──游戏 F5 启动加载──▶ 各 Manager + (编辑器内) (纯文本,可 git 版本管理) + spells.json → SpellRegistry + enemies.json → EnemyManager + waves.json → WaveManager + cores.json → WandPreset + balance.json → SettingsManager + WaveManager +``` + +- JSON 是**唯一权威数据源**——代码不含硬编码内容副本(纯数据驱动) +- 文件缺失/格式错误 → `push_error` 明确报错(不静默,便于发现问题) +- 数据是纯文本,**多人协作改数值不会冲突代码**,可走 git review + +### 数据文件清单 + +| 文件 | 内容 | 加载方 | +|:---|:---|:---| +| `spells.json` | 全部法术定义 | SpellRegistry | +| `enemies.json` | 6 敌人原型数值 | EnemyManager | +| `waves.json` | 20 波关卡曲线 | WaveManager | +| `cores.json` | 法杖 Core 定义 | WandPreset | +| `resonance.json` | 共鸣配方 | SpellEvaluator | +| `status_effects.json` | 状态效果定义 | StatusRegistry | +| `balance.json` | 难度乘子 + Boss 血量 | SettingsManager | + +--- + +## 5. 实操示例 + +### 例 1:让游戏更简单(策划) +平衡分页 → 「敌人血量×」初学者列改 `0.5` → 保存 → F5。初学者难度敌人血量减半。 + +### 例 2:W10 提前出 Boss(策划) +波次分页 → 第 10 行 Boss 列选 `MiniBoss(4)` → 保存 → F5。 + +### 例 3:新增一个"冰锥"法术(策划) +法术分页 → ➕新增 → ID 填 `action_ice_spike`,类型 ACTION,「插入模板」后改 `base_damage` 为 8、加 `"apply_status_id": 2`(冻结)→ ✓应用 → 💾保存 → F5。新法术自动进商店。 + +### 例 4:精英敌人改成紫色更大(美术) +敌人分页 → 精英行 → 尺寸改 `30`,颜色点开选紫色 → 保存 → F5。 + +--- + +## 6. 扩展工具(给开发) + +新增编辑器分页只需 3 步(参考 `addons/game_designer/`): +1. 写一个 `@tool extends VBoxContainer` 的标签脚本(读写对应 `data/xxx.json`,复用 `designer_ui.gd` 的 `spin/line/opt/btn` 辅助) +2. 在 `designer_panel.gd` 的 `_init()` 里 `_add_tab(...)` 注册 +3. 对应 Manager 加 `_load_json_xxx()`,在 `_ready()` 调用 + +> **Godot 编辑器扩展 = Unity 平替**:`EditorPlugin`(面板/工具栏/底部面板) ↔ EditorWindow;`@tool` ↔ ExecuteInEditMode;`EditorInspectorPlugin` ↔ CustomEditor;自定义 `Resource` ↔ ScriptableObject。 diff --git a/docs/handbook/README.md b/docs/handbook/README.md new file mode 100644 index 0000000..e8fb1f5 --- /dev/null +++ b/docs/handbook/README.md @@ -0,0 +1,65 @@ +# 魔法工匠:开发使用手册 (Developer & Designer Handbook) + +> **适用人群**:策划、美术、程序、QA +> **引擎版本**:Godot 4.6 (Mono) +> **最后更新**:2026-06-05 + +--- + +## 目录 + +| 文档 | 适合 | 内容 | +|:---|:---|:---| +| [01 快速上手](01_quickstart.md) | 全员 | 克隆→启动→首次运行 | +| [02 内容创作指南](02_content_authoring.md) | 策划 / 程序 | 法术 / 敌人 / 波次 / Boss / 共鸣 | +| [03 资源替换指南](03_asset_replacement.md) | 美术 / 程序 | 特效 / 精灵 / 音效 / UI 图片 | +| [04 本地化工作流](04_localization.md) | 本地化 / 策划 | `.po` 维护、新增语言、字符串规范 | +| [05 发布标准清单](05_release_guide.md) | 全员 / PM | Steam / Switch 认证、发布流程 | +| [06 法术编辑器](06_spell_editor.md) | 策划 / 开发 | 法术编辑器 meta 字段速查 | +| [07 游戏设计器](07_game_designer.md) | 策划 / 美术 / 开发 | **⭐ 一站式可视化工具**:法术/敌人/波次/Core/平衡全部表单配置,零代码 | + +--- + +## 项目目录快览 + +``` +daihaoRogue/ +├── scenes/ +│ └── main/combat_s2.tscn ← 主场景(唯一可玩入口) +├── scripts/ +│ ├── autoloads/ ← 全局单例(30+ Autoload) +│ └── domain/ +│ ├── spell_system/ ← 法术虚拟机 +│ └── combat/ ← 游戏循环状态机 +├── translations/ ← zh_CN / zh_TW / en / ja .po +├── audio/sfx/ ← 音效放这里(目前占位蜂鸣) +├── assets/sprites/ ← 精灵图放这里(目前几何占位) +└── docs/ + ├── design/ ← 游戏设计文档 + ├── mechanics/ ← 机制深度文档 + ├── technical/ ← 架构 / 实现方案 + ├── plan/ ← 开发计划 / 认证清单 + └── handbook/ ← 本手册(你在这里) +``` + +--- + +## 关键 Autoload 速查 + +| Autoload | 职责 | 常用入口 | +|:---|:---|:---| +| `WandPreset` | 法术/Core 内置预设 | `make_spark_bolt()`, `make_wand_basic()` | +| `SpellEvaluator` | 法术虚拟机(编译+执行) | `compile_wand(core, nodes)`, `execute_compiled(compiled, caster_id, pos)` | +| `SpellRegistry` | 法术查询表 | `get_spell("action_fire_bolt")` | +| `WaveManager` | 波次生成/配置 | `WAVE_CONFIG[0..19]` | +| `EnemyManager` | 敌人 SoA 管理 | `spawn_enemy(pos, hp, type)` | +| `BulletManager` | 子弹 SoA 管理 | `spawn_bullet(...)` | +| `VFXManager` | 特效池 | `play("hit_spark", pos)` | +| `AudioManager` | 音效池 | `play("hit", pos)` | +| `SettingsManager` | 难度 / 音量 / 语言 | `difficulty_name()`, `enemy_hp_mult()` | +| `EndlessRecords` | 本地排行榜 | `add_record(wave, elapsed, kills)` | +| `ProfileManager` | A/B 槽局内存档 | `save_run()`, `load_run()` | +| `Locale` | 语言切换 | `set_locale("en")` | +| `StatusManager` | 状态效果(DoT 等) | `apply(entity_id, StatusID.BURN, 2)` | +| `ZoneManager` | 地面效果区域 | `spawn_zone(cx, cy, r, status_id, dur, tick, owner)` | +| `MinionManager` | 召唤物 | `spawn_minion(def_dict, owner_id, pos)` | diff --git a/docs/mechanics/advanced_mechanics_summons_and_environment.md b/docs/mechanics/advanced_mechanics_summons_and_environment.md new file mode 100644 index 0000000..ffaad44 --- /dev/null +++ b/docs/mechanics/advanced_mechanics_summons_and_environment.md @@ -0,0 +1,98 @@ +# 进阶机制扩展:召唤与环境蔓延 (Advanced Mechanics: Summons & Propagation) + +基于现有的 ECS 战斗系统,我们可以进一步拓展 **“召唤体系 (Summoning System)”** 和 **“环境传导 (Environmental Propagation)”** 维度。 + +## 1. 召唤体系 (Summoning Dimension) + +召唤物不仅仅是“另一种子弹”,它们通常拥有独立的生命周期、AI 行为和属性继承规则。 + +### 1.1 召唤物类型 (Archetypes) +* **Stationary (炮台型)**: 无法移动,持续对范围内敌人攻击。 (如:火舌图腾) +* **Minion (随从型)**: 拥有碰撞体积,能阻挡敌人,有血量,会自动寻敌并攻击。 (如:骷髅兵、死灵狼) +* **Satellite (卫星型/浮游炮)**: 围绕玩家旋转或跟随,不可被选中,辅助攻击。 (如:环绕飞剑) +* **Clone (镜像型)**: 玩家的分身,继承完全相同的攻击行为,但伤害倍率较低。 + +### 1.2 属性继承 (Stat Inheritance) +召唤物的数据快照(Snapshot)机制至关重要: +* **Inheritance Ratio**: 不同的召唤物继承玩家属性的比例不同(如:继承 50% 攻速,100% 暴击)。 +* **Dynamic vs Static**: + * **Static**: 召唤瞬间确定面板(大多数非光环类召唤)。 + * **Dynamic**: 随玩家属性实时变化(如链接玩家的灵魂锁链)。 + +### 1.3 行为指令 (Command AI) +* **Aggressive**: 搜寻屏幕内所有敌人。 +* **Defensive**: 只攻击攻击玩家的敌人。 +* **Focus Fire**: 攻击玩家当前瞄准的目标(光标指示)。 + +## 2. 环境传播与蔓延 (Propagation & Spreading) + +“火焰范围蔓延”属于 **Cellular Automata (元胞自动机)** 或 **传染 (Contagion)** 机制的范畴。 + +### 2.1 实体间传导 (Entity-to-Entity Propagation) +不需要地块网格,直接基于实体距离的传播。 +* **Ignite Proliferation (点燃扩散)**: + * 机制: 当一个敌人处于“燃烧”状态死亡时,将剩余的燃烧层数(或新的燃烧状态)复制给半径 R 内的所有敌人。 + * 扩展: “瘟疫”效果,在存活时通过 Tick 周期性传染周围。 + +### 2.2 区域场 (Field / Zone System) +引入 `ZoneManager` 来管理地面上的持久化效果区域。 +* **Pools (地面池)**: 毒液滩、岩浆、神圣地面。 + * *实现*: 具有 Circle Collider 的 Trigger 节点。 + * *逻辑*: `OnTriggerEnter` -> Apply Status; `OnTriggerExit` -> Remove Status (optional)。 +* **Reactive Ground (反应地表)**: + * 向“毒液滩”发射“火箭”,引爆毒池造成瞬间高伤。 + * 向“水面”发射“冰箭”,冻结水面减慢经过敌人的速度或使其滑倒。 + +### 2.3 连锁反应示例 (Chain Reaction) +1. **步骤 A**: 玩家投掷毒瓶,生成半径 200 的 `PoisonZone`。 +2. **步骤 B**: 怪物进入区域,获得 `Poisoned` 状态。 +3. **步骤 C**: 玩家使用“尸爆术”(Tag: Fire)。 +4. **步骤 D**: 尸爆击中带毒的怪物 -> 触发 `Catalyst: Fire + Poison` -> 产生 `GasExplosion` (毒气爆炸),范围扩大至 400,并施加 `Blind` 致盲效果。 + +## 3. 开发实施建议 + +### 3.1 扩展 EnemyManager 支持友军 +* 目前的 `EnemyManager` 是专门管理敌人的。为了支持 Minion: + * 方案 A: 改造 `EnemyManager` 为 `UnitManager`,引入 `Faction` 字段(0=Friendly, 1=Hostile),统一管理所有生物。 + * 方案 B: 新增 `MinionManager`,逻辑类似但 AI 目标相反(Target=Enemy)。**推荐方案 B**,因为逻辑差异较大。 + +### 3.2 实现传播算法 (The Spread Algorithm) +在 `StatusManager` 中增加 `on_entity_died` 回调监听: +```gdscript +# 伪代码: 点燃扩散 (GDScript 风格) +# 状态类型系统为数据驱动的整数 ID,使用 StatusID Autoload 常量(见 architecture_design.md §8 ADR-R5-N2)。 +# StatusID.BURN 等常量定义在 status_id.gd (Autoload: StatusID) 中。 +# 禁止写法:StatusManager.has_status(victim_id, StatusType.BURN) ← StatusType 枚举不存在 +func on_enemy_died(victim_id: int) -> void: + if StatusManager.has_status(victim_id, StatusID.BURN): # ✅ 使用 StatusID 常量 + var neighbors: Array[int] = SpatialGrid.query_circle( + EnemyManager.get_position(victim_id), 300.0) + for n_id in neighbors: + StatusManager.apply(n_id, StatusID.BURN, 0) # ✅ 使用 StatusID 常量 + VFXManager.play("fire_spread", EnemyManager.get_position(victim_id)) +``` + +> **StatusID Autoload 规范**:与 `CoreFeatureTag` 类比,所有状态类型必须通过 +> `StatusID` Autoload 常量引用,禁止在业务代码中直接写整数字面量或自造枚举: +> ```gdscript +> # status_id.gd (Autoload: StatusID) +> ## 状态效果 ID 常量表 — 所有系统通过此处引用,禁止裸整数 +> const BURN: int = 1 # 点燃(Fire DoT) +> const FREEZE: int = 2 # 冻结(硬控) +> const POISON: int = 3 # 中毒(Poison DoT,可叠层) +> const WET: int = 4 # 潮湿(增幅雷电伤害) +> const OILY: int = 5 # 油腻(增幅火焰伤害) +> const STUN: int = 6 # 麻痹(雷电触发) +> const COMBO_MARK: int = 7 # 连击标记(详见 consecutive_hits_stacking.md) +> const VULNERABILITY: int = 8 # 易伤标记(激光叠层增伤,详见 consecutive_hits_stacking.md) +> # 新增状态 ID 从 9 开始递增,并在此处登记 +> ``` + +### 3.3 实现地面效果 (Zone Manager) +* **ZoneManager 必须基于 `SpatialGrid` 进行空间索引(ADR-R5-N1 规范)**, + **禁止使用 Area2D(Trigger 模式)**——Area2D 会为每个 Zone 创建 Godot 物理节点, + 1000 敌人 × 64 Zone 的碰撞检测开销完全无法接受,违反 ECS-Lite 架构原则。 + 详见 `architecture_design.md §8 ADR-R5-N1`(ZoneManager 权威实现定义)。 +* 每 `_physics_process` 帧对所有 Zone 调用 `SpatialGrid.query_circle(zone_center, zone_radius)`, + 批量获取范围内敌人 ID,然后通过 `StatusManager.apply()` 施加状态效果; + 使用 `tick_interval` 速率限制防止每帧过量触发(见 architecture_design.md §8 ADR-R5-N1)。 diff --git a/docs/mechanics/combat_mechanics_depth.md b/docs/mechanics/combat_mechanics_depth.md new file mode 100644 index 0000000..6a7e777 --- /dev/null +++ b/docs/mechanics/combat_mechanics_depth.md @@ -0,0 +1,176 @@ +# 深度战斗机制扩展设计文档 (Advanced Combat Mechanics Design) + +为了支持 Roguelike 游戏中涌现式的玩法组合(如:闪电链、叠毒流、触发流),现有的简单"伤害+阵营"模型需要升级为多维度的**上下文感知 (Context-Aware)** 系统。 + +本文档详细定义了扩展战斗深度所需的关键维度和判断逻辑。 + +## 1. 投射物身份与溯源 (Projectile Identity & Sourcing) + +在复杂的战斗中(例如:玩家发射的子弹击中怪物A,触发爆炸,爆炸产生碎片击中怪物B),系统必须能追踪伤害的根本来源。 + +### 1.1 来源层级 (Source Hierarchy) +我们需要在子弹数据中维护一个"血缘关系",通常通过 ID 或 引用 实现: +* **Root Source (根源)**: 也就是 `Owner`。通常是 PlayerEntity 或 EnemyEntity。用于判定击杀归属(触发"击杀回血"天赋)、伤害统计。 +* **Immediate Source (直接来源)**: 发射该子弹的实体。可能是玩家手中的法杖,也可能是另一个"母子弹"(在分裂/触发机制中)。 + * *应用场景*: 伤害衰减计算。如果是"母子弹"分裂出的"子弹",继承伤害时可能需要乘系数 0.5。 + +### 1.2 施法类型标记 (Cast Type Tags) +除了 `Faction` (阵营),还需要 `Tags` (位掩码) 来区分伤害性质,以便应用特定的加成: +* **Tag: Primary**: 玩家直接射击(享受"普攻伤害加成")。 +* **Tag: Triggered**: 由触发器产生的(享受"技能伤害加成",但可能不触发某些普攻特效)。 +* **Tag: Summon**: 召唤物造成的伤害。 +* **Tag: Reflection**: 反弹/弹反造成的伤害。 + +## 2. 连锁与弹跳逻辑 (Chain & Bounce Dimensions) + +"闪电链"不仅仅是次数的递减,它需要一个完整的状态机来处理复杂的传递逻辑。 + +### 2.1 命中历史 (Hit History / Exclusion List) +* **问题**: 简单的"最近距离"查找会导致闪电在两个靠近的怪物之间无限来回弹射 (Ping-Pong)。 +* **维度需求**: 每颗子弹需要维护一个已命中目标 ID 集合 `visited_targets: Array[int]`。 +* **逻辑**: `SpatialGrid.find_nearest(pos)` 过滤掉 `visited_targets` 中已包含的 `entity_id`。 +* **性能优化**: 在 ECS 架构中,可以使用 `Dictionary` (key: bullet_id int, value: Array[int]) 的 Sidecar 模式存储历史记录,仅在命中结算(低频逻辑)时查询,不影响移动更新(高频逻辑)。 + +### 2.2 衰减模型 (Decay Models) +每次传递后的伤害变化不应只有一种模式: +* **Linear Decay**: `Dmg = Base - (BounceCount * FixedValue)` +* **Exponential Decay**: `Dmg = Base * (DecayFactor ^ BounceCount)` (例如每次衰减 20%) +* **Reverse Scaling**: `Dmg = Base * (1 + BounceCount * 0.1)` (越弹伤害越高,奖励多次传递) + +### 2.3 传递约束 (Propagation Constraints) +* **Max Distance**: 弹射的最大搜索半径(不能跨越半个屏幕弹射)。 +* **Line of Sight**: 弹射是否需要视野(能否穿墙)。 +* **Angle Limit**: 只能向前弹射(90度扇区),不能向后弹射。 + +## 3. 状态效果与持续伤害 (Status Effects & DoT) + +毒、火、流血等机制需要独立且统一的 **Status Manager**。 + +### 3.0 StatusType 数据驱动方案 (E4) + +`StatusType` **不使用硬编码枚举**,而是从 `res://resources/status_types/` 目录自动加载 `.tres` Resource,实现完全数据驱动。新增状态类型无需修改引擎代码: + +```gdscript +# status_type_def.gd +class_name StatusTypeDef +extends Resource + +@export var id: int # 全局唯一整数 ID(用于位掩码和 Dictionary key) +@export var name: String # 显示名称 +@export var stack_mode: int # 0=Duration, 1=Intensity, 2=Independent +@export var max_stacks: int = 99 # 最大叠加层数(0=无限) +@export var tick_interval: float = 1.0 # DoT 跳字间隔(秒) +@export var can_catalyze: Array[int] # 催化反应:触发时需要的另一状态 ID 列表 +@export var immune_faction_tags: int # 免疫此状态的 faction 位掩码 +``` + +`StatusManager._ready()` 启动时扫描目录注册,逻辑与 SpellRegistry 完全一致。 + +### 3.1 堆叠维度 (Stacking Dimensions) +* **Duration Stacking (时间堆叠)**: 再次施加相同效果,刷新或延长持续时间(如:冰冻)。 +* **Intensity Stacking (强度堆叠)**: 再次施加相同效果,增加层数,伤害倍率提升(如:中毒,1层每秒10点,5层每秒50点)。 +* **Independent Instances (独立计算)**: 每次施加都是一个独立的 DoT 计时器,互不干扰(如:流血,身上可以挂10个流血Debuff,每个单独跳字)。 + +### 3.2 结算归属 (Tick Attribution) +DoT 造成的每次伤害(Tick)都需要携带 `Root Source` 信息。 +* *场景*: 玩家给 Boss 挂了毒,然后跑开了。Boss 被毒死时,系统必须知道是"玩家"杀死的,从而触发"通关结算"或"击杀回血"。 + +### 3.2.5 StatusManager Tick 机制(P-N4 补充,P5-N1 修正) + +`StatusManager` 绑定 `_physics_process(delta)`(60 Hz),采用**批处理 Tick**策略: + +- **平铺数组存储(P5-N1)**:`_active_statuses: Array[StatusInstance]` 为**一维平铺数组**,每个 `StatusInstance` 携带 `entity_id` 字段。相较于原 `Dictionary{entity_id → Array}` 方案,平铺顺序遍历的缓存命中率显著更高,规避了 GDScript 哈希表迭代的无序开销。按实体查询(`get_instances_for(entity_id)`)仅在 apply/remove 等低频操作时调用,不出现在 `_physics_process` 热路径中。 +- **每帧遍历**:逆序遍历 `_active_statuses`,按各实例的 `remaining_duration` 递减 `delta`,duration ≤ 0 时原地 swap-with-last 移除(O(1)),避免遍历中途修改数组。 +- **按 tick_interval 跳字**:DoT 不在每帧触发伤害,而是维护 `tick_accumulator: float`; + 每帧 `tick_accumulator += delta`,当 `tick_accumulator >= tick_interval` 时 + 触发一次 `APPLY_DAMAGE` 并扣减 `tick_interval`(允许 delta 跨多个 tick_interval 时补算多次)。 +- **分帧批处理**:若同帧活跃状态实例超过 `STATUS_BATCH_LIMIT = 500` 条, + 按 `inst.entity_id % 2` 奇偶帧分批处理,牺牲 1 帧的 tick 精度换取帧时间平摊。 + +```gdscript +# _active_statuses: Array[StatusInstance](平铺数组,非 Dictionary) +# StatusInstance 字段: entity_id, type_def, intensity, remaining_duration, tick_accumulator, root_owner_id +func _physics_process(delta: float) -> void: + var i := _active_statuses.size() - 1 + while i >= 0: + var inst: StatusInstance = _active_statuses[i] + inst.remaining_duration -= delta + inst.tick_accumulator += delta + while inst.tick_accumulator >= inst.type_def.tick_interval: + inst.tick_accumulator -= inst.type_def.tick_interval + _apply_dot_tick(inst.entity_id, inst) + if inst.remaining_duration <= 0.0: + _active_statuses[i] = _active_statuses.back() + _active_statuses.pop_back() + i -= 1 +``` + +### 3.3 状态交互矩阵 (Interaction Matrix) +状态之间应有预定义的交互规则: +* **Overwrite**: 强状态覆盖弱状态(大冰冻覆盖小减速)。 +* **Catalyze (催化)**: 状态 A 遇到 状态 B -> 消除 B 并触发 C(雷 + 湿 = 范围感电)。 +* **Immunity**: 某些怪物(如火元素)对特定状态(燃烧)免疫甚至回血。 + +## 4. 伤害计算管线 (The Damage Pipeline) + +伤害计算遵循 `docs/design/numerical_design.md §3.1` 定义的权威公式: + +``` +FinalDamage = ((Base + Add) × Mult) × (1 - Res) - Armor +``` +其中:`Mult` = `SourceMultipliers × CriticalMultiplier`;`Res` = 敌人元素抗性 (0.0~1.0);`Armor` = 敌人护甲平铺扣除值。最终伤害最小为 1。 + +我们需要在伤害事件 `APPLY_DAMAGE` 中传递一个结构体 `DamageContext` 而非简单的数值: + +```gdscript +# damage_context.gd - 继承 RefCounted,通过对象池复用 +# 权威实现(含池化逻辑)在 DamageContextPool Autoload;本文件描述字段语义。 +class_name DamageContext +extends RefCounted + +var base_damage: float = 0.0 # 基础数值(含 CastStats.damage_add) +var mult: float = 1.0 # 伤害乘算系数(暴击 × 元素弱点已合入此值) + # 对应公式:Damage = ((Base + Add) × Mult) × (1 - Res) - Armor + # EnemyManager.apply_damage() 读取此字段计算最终伤害 +var damage_type: int = DamageType.PHYSICAL # DamageType 枚举 +var owner_id: int = -1 # 来源实体 ID(Root Owner) +var is_crit: bool = false # 是否暴击(影响爆字/特效触发;与 DamageContextPool 字段同步) +var pierce_rate: float = 0.0 # 护甲穿透率(0.0~1.0;与 DamageContextPool 字段同步) +var source_tags: int = 0 # 位掩码: PRIMARY=1, TRIGGERED=2, SUMMON=4, REFLECTED=8 + +func reset() -> void: + base_damage = 0.0; mult = 1.0; damage_type = DamageType.PHYSICAL; owner_id = -1 + is_crit = false; pierce_rate = 0.0; source_tags = 0 + # mult 必须重置为 1.0,否则池化复用时上帧的暴击乘算残留到下次计算 +``` + +## 5. 实现建议 (ECS Adaptation) + +针对当前的 `BulletManager` (PackedFloat32Array ECS),如何在不破坏性能的前提下引入这些维度? + +**混合架构 (Hybrid ECS Pattern):** + +1. **热数据 (PackedFloat32Array)**: 保持 `x, y, vx, vy, life` 在 `PackedFloat32Array` 中,确保每帧移动批处理极快。 +2. **冷数据 (Dictionary)**: 在 `BulletManager` 中维护 `_bullet_contexts: Dictionary`(key = bullet_id `int`)。 + * 值为 `BulletContext` 对象(继承 `RefCounted`,通过对象池复用),存储 `visited_targets`、`owner_id`、`proc_coefficient`、`on_hit_payload_id` 等复杂逻辑。 + * 仅在 `_on_bullet_hit()`(命中)或 `spawn()`(生成)时访问 Dictionary,这些事件频率远低于每帧移动,不会成为瓶颈。 + +```gdscript +# bullet_context.gd - 通过 ObjectPool 复用,避免 GC 抖动 +class_name BulletContext +extends RefCounted + +var owner_id: int = -1 # 根来源实体 ID +var visited_targets: Array[int] = [] # 连锁弹射已命中目标列表 +var proc_coefficient: float = 1.0 # 触发系数(加特林子弹通常设为 0.1) +var on_hit_payload_id: int = -1 # 命中时触发的 SubPayload ID(-1=无) +var bounce_count: int = 0 # 已弹射次数 +# BulletContext 不持有伤害计算逻辑(SRP)。 +# 弹射衰减(pow(0.9, bounce_count))集中在 EnemyManager.apply_damage() 中实现;BulletContext 仅透传 bounce_count。 + +func reset() -> void: + owner_id = -1; visited_targets.clear() + proc_coefficient = 1.0; on_hit_payload_id = -1; bounce_count = 0 +``` + +通过这种方式,热路径(移动积分)保持纯 Array 操作,冷路径(命中逻辑)通过 Dictionary 访问复杂上下文,兼顾了弹幕游戏的性能与 RPG 的深度。 diff --git a/docs/mechanics/combat_mechanics_extensions_v2.md b/docs/mechanics/combat_mechanics_extensions_v2.md new file mode 100644 index 0000000..c1645d8 --- /dev/null +++ b/docs/mechanics/combat_mechanics_extensions_v2.md @@ -0,0 +1,102 @@ +# 战斗机制进阶扩展 (Advanced Combat Mechanics Extensions - Part 2) + +为了构建一个具备极高重玩价值(Replayability)的 Roguelike 系统,除了基础的伤害和弹射,我们还可以引入物理、环境、触发链等更多维度的交互。 + +以下是可供扩展的 5 个额外维度: + +## 1. 物理与运动学维度 (Physics & Kinematics Dimensions)【P2/P3 扩展,非当前实现目标】 + +> ⚠️ **实现状态说明**:本节所有维度均为**设计扩展提案(P2/P3)**, +> 当前 BulletManager SoA、ProjectileDef、EnemyManager SoA **均无对应字段**。 +> 实现时需要: +> - `ProjectileDef` 增加 `knockback_force: float`(冷数据,存入 `_bullet_contexts`) +> - `EnemyManager` SoA 增加 `mass: float`(或冷数据中存储),`ENEMY_STRIDE` 相应扩展 +> - 否则引用本节代码片段将在运行时找不到字段 + +让投射物不仅仅是”飞行的数字”,而是具有物理实体的对象。 + +### 1.1 冲量与质量 (Impulse & Mass)【P3】 +* **Knockback Force (击退力)**: 子弹应携带“击退值”。 +* **Entity Mass (实体质量)**: 怪物应有“质量”属性。 +* **Resistance (抗性)**: 小蝙蝠被打中会飞很远,而石头人哪怕受到高伤也纹丝不动。 +* **维度**: `KnockbackVector = (BulletVelocity.normalize() * KnockbackForce) / TargetMass` + +### 1.2 轨迹调制 (Trajectory Modulation) +除了直线飞行,子弹的运动模式可以参数化: +* **Acceleration (加速度)**: 子弹可以越飞越快(狙击),或越飞越慢(能量球停留)。 +* **Curvature (曲率/归航)**: `HomingStrength` (归航强度) 和 `TurnRate` (原本直飞,靠近敌人时转弯)。 +* **Oscillation (震荡)**: 正弦波飞行(Sine Wave),用于扩大打击面。 + +## 2. 环境场与交互维度 (Environment & Interaction Dimensions) + +战斗不仅仅发生在“子弹”和“敌人”之间,还包括“环境”。 + +### 2.1 地表效果 (Surface/Ground Effects) +* **Persistence (残留)**: 毒瓶炸开后在地面留下 `PoisonPool`,持续 5 秒。 +* **Conductivity (传导性)**: + * 水面 + 闪电 = 全屏感电。 + * 油面 + 火焰 = 不灭之火(延长燃烧时间)。 + * 冰面 + 物理撞击 = 滑行距离增加。 + +### 2.2 障碍物交互 (Obstacle Interaction) +* **Ricochet (跳弹)**: 击中墙壁时,根据入射角计算反射角。 +* **Phasing (相位)**: 是否能穿墙(穿墙子弹通常伤害较低或无法穿透敌人)。 +* **Embedding (嵌入)**: 箭矢击中墙壁后保留实体,可被玩家回收或作为后续法术的引雷针。 + +## 3. 触发器与事件链系统 (Trigger System / Proc Chains) + +参考《Risk of Rain 2》或《Path of Exile》的 Proc(Process on Cast/Hit)机制。 + +### 3.1 触发钩子 (Event Hooks) +不仅仅是“命中”,需要更细的粒度: +* **OnHit**: 命中敌人时。 +* **OnCrit**: 暴击时(如:暴击时召唤落雷)。 +* **OnKill**: 击杀时(如:敌人产生尸爆)。 +* **OnExpire**: 子弹自然销毁/达到最大射程时(如:榴弹并在尽头爆炸)。 +* **OnHurt**: 玩家受击时(如:受击释放尖刺)。 + +### 3.2 触发系数 (Proc Coefficient) +为了平衡高频攻击(如加特林)和低频攻击(如狙击枪): +* **ProcRate**: 这是一个 0.0 - 1.0 的系数。 +* *例子*: "攻击有 10% 概率触发导弹"。 + * 狙击枪 (Proc: 1.0) -> 每次判定都是 10%。 + * 加特林 (Proc: 0.1) -> 每次判定实际概率是 10% * 0.1 = 1%。 + * 防止高攻速武器过于变态地触发特效。 + +## 4. 属性转换与快照机制 (Conversion & Snapshotting) + +处理复杂的数值计算规则。 + +### 4.1 伤害转换 (Damage Conversion) +* **Shift**: "将 50% 的物理伤害转化为火焰伤害"。 +* **Gain**: "获得等同于物理伤害 20% 的额外混沌伤害"。 +* **Priority (优先级)**: 转换通常有固定顺序(物理 -> 元素 -> 混沌),防止循环转换。 + +### 4.2 快照机制 (Snapshotting) +* **Dynamic vs Static**: + * 如果玩家发射了一个持续 10 秒的火龙卷,然后在第 5 秒喝了药水(增加智力)。 + * **Snapshot**: 火龙卷伤害不变(取发射瞬间的数值)。这通常更符合直觉且性能更好。 + * **Dynamic**: 火龙卷伤害随之增加。这就要求子弹必须持有 Player 引用并在每帧重新计算属性。 + +## 5. 控制与免疫维度 (Crowd Control & Diminishing Returns) + +并不是所有的“负面效果”都是伤害(DoT)。 + +### 5.1 硬控制与软控制 (Hard/Soft CC) +* **Soft CC**: 减速 (Slow)、致盲 (Blind - 降低命中率/索敌范围)。 +* **Hard CC**: 眩晕 (Stun - 停止行动)、冻结 (Freeze - 停止动画)、恐惧 (Fear - 反向逃跑)、魅惑 (Charm - 攻击友军)。 + +### 5.2 收益递减 (Diminishing Returns - DR) +为了防止 Boss 被永久晕在原地: +* **DR Meter**: 怪物有一个隐藏的抗性条。 +* **机制**: 第一次眩晕 2秒 -> 第二次 1秒 -> 第三次 免疫。 +* **Recovery**: 抗性条随时间慢慢恢复。 + +## 6. 斩杀与处决 (Culling & Execution) + +* **Threshold Execution**: "立即消灭生命值低于 10% 的非 Boss 敌人"。 +* **Low/High Health Scaling**: + * "对生命值 > 90% 的敌人必定暴击" (First Strike)。 + * "敌人生命值每降低 1%,造成额外 1% 伤害" (Executioner)。 + +通过引入这些维度,可以将简单的“数值比拼”转化为策略构筑游戏。例如构建一个“击退流”玩法:利用高击退把敌人推到涂满油的墙上,配合火属性子弹造成环境爆炸。 diff --git a/docs/mechanics/consecutive_hits_stacking.md b/docs/mechanics/consecutive_hits_stacking.md new file mode 100644 index 0000000..f6334bb --- /dev/null +++ b/docs/mechanics/consecutive_hits_stacking.md @@ -0,0 +1,92 @@ +# 连续打击与伤害叠加设计 (Consecutive Hits & Damage Stacking) + +## 需求分析 +用户希望实现“对同一个目标,同一种伤害在一定间隔内认定为持续多次伤害,从而可以制作伤害叠加”。 + +这通常被称为 **“连击系统 (Combo System)”** 或 **“弱点叠加 (Vulnerability Stacking)”**。 +与 DoT(中毒/燃烧)不同,这种机制侧重于 **“攻击频率”** 和 **“爆发奖励”**。 + +## 核心概念 + +### 1. 连击窗口 (Combo Window) +* 每个敌人对每种伤害来源(或类型)维护一个 `LastHitTime` 和 `StackCount`。 +* 如果在 `WindowDuration` (例如 2秒) 内再次受到同类伤害:`StackCount++`, 刷新 `LastHitTime`。 +* 如果超时:`StackCount` 重置为 0。 + +### 2. 叠加收益 (Stacking Benefits) +当 `StackCount` 增加时,可以产生以下效果: +* **Damage Scaling**: 第 N 次伤害提高 `N * 10%`。 +* **Proc Trigger**: 每第 3 次攻击触发一次爆炸 (3-Hit Passive)。 +* **State Transition**: 叠满 5 层后,施加“破甲”或“眩晕”。 + +## 数据结构设计 + +在现有的 `EnemyManager` 混合 ECS 中,我们需要拓展 `StatusContext` 或新增 `ComboContext`。 + +由于连击通常与特定的“来源”或“伤害标签”绑定,建议存放在 `StatusContext` 中的专用字段,或者复用 Status 机制。 + +### 方案 A: 专用 Combo Tracker +在 `EnemyEntityManager` 的 Context 中增加: +```gdscript +# combo_tracker.gd — 通过 ObjectPool 复用,避免 GC 开销 +class_name ComboTracker +extends RefCounted + +var source_id: int = -1 # 来源实体 ID(支持多武器/多玩家,-1 表示无效) +var damage_tag: int = 0 # 伤害类型位掩码(PHYSICAL=1, FIRE=2, LASER=4 ...) +var count: int = 0 # 当前连击层数 +var timer: float = 0.0 # 剩余窗口时间(秒) + +func reset() -> void: + source_id = -1 + damage_tag = 0 + count = 0 + timer = 0.0 +``` + +### 方案 B: 利用现有 Status 系统 +定义一种特殊的 StatusType `COMBO_MARK`。 +* 每次攻击施加 `COMBO_MARK`。 +* `addEffect` 逻辑中,如果已存在,则 `intensity++` (层数) 并刷新 `duration`。 +* 伤害计算时,检查 `COMBO_MARK` 的 `intensity` 来加成伤害。 + +**✅ 追加选择:推荐方案 B**,因为它复用了现有的 Tick 和合并逻辑,不需要写额外的 Timer。方案 A 适合需要多来源(`source_id`)区分的场景(如多玩家共同攻击同一目标)。 + +> **R4-C3 补充(COMBO_MARK 的 StatusTypeDef 参数)**:`COMBO_MARK` 对应以下 `StatusTypeDef` 配置: +> - `stack_mode = 1`(**Intensity 强度叠加**):每次施加 +1 层,`intensity` 字段记录当前层数。 +> - `max_stacks = 0`(**0 = 无限叠加**,不设层数上限;由高频攻击自然封顶)。 +> - `tick_interval`:⚠️ **语义说明(P6-N14 修正)**:`StatusTypeDef.tick_interval` 在 `StatusManager` 的通用实现中是"DoT 伤害跳字间隔"。但 `COMBO_MARK` **不造成 DoT 伤害**,其 `_apply_dot_tick()` 回调被设计为空操作(`pass`)。 +> 此处 `tick_interval` 被 COMBO_MARK **重载为连击窗口刷新机制**:每次施加 COMBO_MARK 时,调用方手动将 `remaining_duration` 重置为连击窗口时长(如 2.0s),利用 `StatusManager` 的 duration 计时器实现超时重置。 +> **推荐实践**:为避免混淆,`COMBO_MARK` 的 `tick_interval` 设置为一个大于连击窗口的值(如 `999.0`),确保在窗口内 `tick_accumulator` 永不达到触发阈值,彻底避免意外的 DoT 跳字。 +> **长期方案(P2)**:为 `StatusTypeDef` 增加 `is_combo_tracker: bool` 字段,从根本上区分"持续伤害状态"和"计数追踪状态",使 `StatusManager._apply_dot_tick` 在 `is_combo_tracker = true` 时直接跳过。 +> - `can_catalyze = []`(空数组):连击标记本身不触发元素反应,只作为伤害加成计算输入。 +> `StatusTypeDef.can_catalyze` 字段类型为 `Array[int]`,必须赋空数组 `[]`, +> 禁止赋值 `false`(GDScript 4 严格类型模式下触发 bool→Array 类型错误)。 +> 详见 `combat_mechanics_depth.md §3.0`(StatusTypeDef 完整字段)。 + +## 实现逻辑 (基于 Status 系统) + +1. **定义新状态**: `StatusID.VULNERABILITY` (易伤标记)。 + > 所有状态引用使用 `StatusID` Autoload 常量(架构为数据驱动整数 ID,不存在 StatusType 枚举): + > - `StatusID.VULNERABILITY = 8`(在 status_id.gd 中登记) + > - `StatusID.COMBO_MARK = 7`(已定义) + > 禁止写法:`StatusType.VULNERABILITY`、`StatusType.COMBO_MARK`。 +2. **施加规则**: + * 每次 `ActionLaser` (高频攻击) 命中时,施加 1 层 `StatusID.VULNERABILITY`,持续 0.5秒。 +3. **叠加规则**: + * 在 `StatusContext.addEffect` 中,如果发现已有 `StatusID.VULNERABILITY`,则 `stack++`。 +4. **伤害修正**: + * 在 `onApplyDamage` 中,计算最终伤害前,读取目标的 `StatusID.VULNERABILITY` 层数。 + * `FinalDamage = BaseDamage * (1 + Stack * 0.05)` (每层增伤 5%)。 + * 由于激光每秒 10 次,0.5秒内能叠 5 层,伤害会越来越高。 + +## 扩展玩法示例 +* **聚焦激光 (Focus Laser)**: 初始伤害低,但对同一目标持续照射越久伤害越高(利用 0.1s 间隔的刷新机制)。 +* **三环被动 (Three-Hit Passive)**: 类似于 LOL 的薇恩。给敌人挂一个隐藏的 `StackStatus`,当 Stack 到达 3 时,消耗所有 Stack 并造成由最大生命值决定的真实伤害。 + +## 代码落地计划 +1. 在 `status_id.gd`(StatusID Autoload)中追加 `VULNERABILITY: int = 8` 常量。 + `COMBO_MARK: int = 7` 已在 `advanced_mechanics_summons_and_environment.md §3.2` 定义。 + 禁止在此文件外使用裸整数或 StatusType 枚举(见 ADR-R5-N2 规范)。 +2. 在 `EnemyManager.gd` 的 `apply_damage` 函数中,计算扎血前先检查 `StatusID.VULNERABILITY` 进行倍率修正。 +3. 在 `StatusManager.gd` 的 `apply_status` 函数中,确保此类型状态可正确无限叠加层数(或设置上限)。 diff --git a/docs/mechanics/weapon_system_expansion.md b/docs/mechanics/weapon_system_expansion.md new file mode 100644 index 0000000..e60aadd --- /dev/null +++ b/docs/mechanics/weapon_system_expansion.md @@ -0,0 +1,112 @@ +# 武器系统扩展设计方案 (Weapon System Expansion Design) + +本文档旨在描述基于现有的 **HMWS (Hackable Modular Weapon System)** 和 **ECS BulletManager** 架构,可以进一步扩展的玩法机制。 + +当前的系统已经支持了基础的投射物、霰弹、激光,以及修正器(多重施法、弹射、追踪、穿透、连锁、冰冻)。在此基础上,我们可以引入更高级的策略组合和“涌现式”玩法。 + +## 1. 轨迹修正 (Trajectory Modifiers) + +通过修改子弹在 `_physics_process` 中的运动逻辑,创造独特的弹道体验。 + +### 1.1 正弦/波浪弹道 (Sine/Wave Pattern) +* **机制**: 子弹不再直线飞行,而是沿着正弦波轨迹运动。 +* **实现**: + * 新增 `ModifierSine`. + * 在 `BulletManager` 的 Data Stride 中增加 `waveFrequency` 和 `waveAmplitude` (或复用 `customData`)。 + * 在 `_physics_process` 积分位置时,叠加垂直于速度方向的偏移量。 +* **玩法**: 直线命中率低,但能绕过正面护盾,或者覆盖更宽的横向区域。 + +### 1.2 回旋镖 (Boomerang) +* **机制**: 子弹飞出一定距离后,反向加速飞回玩家。 +* **实现**: + * 新增 `ModifierBoomerang`. + * 在 `BulletManager` 中逻辑:当 `lifetime` 经过一半时,给予一个反向的加速度 `Acceleration` 指向玩家。 +* **玩法**: 高风险高回报,利用回程的子弹对追击的敌人造成“背刺”双倍伤害。 + +### 1.3 轨道环绕 (Orbiting/Shield) +* **机制**: 子弹不飞远,而是围绕玩家旋转,形成护盾。 +* **实现**: + * 轨道参数 `is_orbiting: bool`、`orbit_radius: float`、`orbit_angle: float`、`orbit_speed: float` 均存入 `_bullet_contexts: Dictionary`(**冷数据字段,P6-N9 权威定义**),BulletManager SoA 热数组不新增字段。 + * **双写优化(P6-N15 规范)**:环绕子弹的位置由圆周公式 100% 决定,不依赖速度积分。因此在 `BulletManager._physics_process` 中,**必须先判断 is_orbiting,再决定是否执行常规物理积分**: + ```gdscript + # BulletManager._physics_process 热路径(伪代码) + for i in _active_count: + var ctx = _bullet_contexts.get(i, null) + if ctx and ctx.get(“is_orbiting”, false): + # 跳过速度积分,直接覆写位置(避免无意义的双写开销) + var angle: float = ctx.orbit_angle + ctx.orbit_speed * delta + ctx.orbit_angle = fmod(angle, TAU) + var px := _player_x + cos(ctx.orbit_angle) * ctx.orbit_radius + var py := _player_y + sin(ctx.orbit_angle) * ctx.orbit_radius + _data[i * BULLET_STRIDE + 0] = px + _data[i * BULLET_STRIDE + 1] = py + # vx/vy 保持 0,lifetime 正常递减 + else: + # 常规物理积分(速度 + 加速度) + _data[i * BULLET_STRIDE + 0] += _data[i * BULLET_STRIDE + 2] * delta + _data[i * BULLET_STRIDE + 1] += _data[i * BULLET_STRIDE + 3] * delta + ``` + * **性能评估**:环绕子弹数量通常不超过 20 颗(护盾构建),`is_orbiting` 冷数据查询 20 次/帧可接受;若未来出现大量环绕子弹需求,再评估是否升居热数组(SoA 增加 1 bit flag)。 +* **玩法**: 仅近战流派,配合”击退”修正器保命。 + +## 2. 触发器系统 (Trigger System) - "Noita" 风格的核心 + +这是 roguelike 构建深度玩法的关键,即“子弹生子弹”。 + +### 2.1 击中施法 (Cast On Hit) +* **机制**: 当母弹击中敌人时,以击中点为原点,释放另一个法术。 +* **案例**: "毒箭" + "击中施法" + "爆炸" = 毒箭射中敌人后产生爆炸。 +* **架构实现**(✅ 已完成): + `ProjectileDef.on_hit_payload_id`(`int`,-1=无触发)存储 `SubPayloadRegistry` 中预注册的子法术 ID。 + `SpellEvaluator` 预编译时将 TRIGGER 节点后续法术打包为 SubPayload 并注册到表; + `BulletManager` 在 `_on_bullet_hit()` 回调中查表、通过 `SpellEvaluator.execute_sub()` 执行。 + 弹体本身仅携带整数 ID,无需存储复杂对象引用,完全兼容 SoA PackedFloat32Array 布局。 + 详见 `architecture_design.md §3.2.A`(ProjectileDef 完整字段)与 `implementation_plan.md §2.2`(SubPayloadRegistry)。 + +### 2.2 死亡/超时施法 (Cast On Death/Timer) +* **机制**: + * **On Timer**: 子弹飞行亦段时间后,变成另一种子弹(或释放子弹)。例如:集束手雷(飞行 -> 分裂成小炸弹)。 + * **On Death**: 子弹消失/撞墙时触发。 +* **玩法**: 延迟伤害、陷阱布局。 + +## 3. 元素交互 (Elemental Synergy) + +扩展现有的 `Ice` (冰冻) 机制,引入状态反应。 + +### 3.1 状态定义 +* **冰 (Wet/Freeze)**: 减速。 +* **火 (Burn)**: 持续扣血 (DoT)。 +* **毒 (Poison)**: 持续扣血 + 易伤 (受到的伤害增加)。 +* **雷 (Shock)**: 间歇性打断动作。 + +### 3.2 组合反应 (Combo) +* **热休克 (Fire + Ice)**: 解除冰冻,但在解除瞬间造成一次巨额爆发伤害 (Steam Burst)。 +* **导电 (Water/Ice + Lightning)**: 如果敌人处于冰冻/潮湿状态,被雷电击中时,触发大范围 AoE 连锁伤害。 +* **毒爆 (Poison + Fire)**: 毒气被点燃,移除中毒状态,产生一次性范围爆炸。 + +## 4. 或者是召唤物 (Summons) + +将 `ActionNode` 的产物从子弹变为独立的实体单位。 + +### 4.1 炮台 (Turret) +* **机制**: 在玩家位置放置一个静止单位,它拥有独立的射击逻辑(自动瞄准最近敌人)。 +* **实现**: 需要新增 `TurretManager` 或在 `EnemyManager` 扩展一个 "Friendly" 阵营。 +* **玩法**: 塔防流,玩家只负责跑位和放置。 + +### 4.2 浮游炮 (Funnel/Bit) +* **机制**: 跟随玩家移动的无敌单位,复制玩家的射击行为(类似《沙罗曼蛇》的 Option)。 + +## 5. 开发路线图建议 + +1. **Phase 1 (Easy)**: 实现 **ModifierSize** (巨大化) 和 **ModifierSplit** (击中分裂 - 简易版 Trigger)。这两个只需要修改 `BulletManager` 的数值逻辑,无需重构 ECS。 +2. **Phase 2 (Medium)**: 实现 **状态交互 (Elemental)**。完善 `EnemyManager` 的状态槽位(目前只有 `freezeTimer`,可以用位运算扩展状态标记)。 +3. **Phase 3 (Hard)**: 实现 **Trigger System**。✅ 架构已在 `implementation_plan.md §2.2` 完成(SubPayloadRegistry + on_hit_payload_id 方案)。本阶段实施重点:编写多层 TRIGGER 嵌套的单元测试,接入 VFXManager 命中回调。 + +## 6. 数值与平衡参考 + +| 扩展类型 | 开发难度 | 趣味增益 | 性能消耗 | +| :--- | :--- | :--- | :--- | +| 正弦弹道 | 低 | 中 | 极低 | +| 击中分裂 | 中 | 高 | 高 (子弹数指数增长) | +| 元素反应 | 中 | 高 | 低 | +| 召唤炮台 | 高 | 中 | 中 | diff --git a/docs/plan/certification_checklist.md b/docs/plan/certification_checklist.md new file mode 100644 index 0000000..62bcbbd --- /dev/null +++ b/docs/plan/certification_checklist.md @@ -0,0 +1,184 @@ +# 平台发行认证清单 (Platform Certification Checklist) + +> **用途**:列出 Steam / Nintendo Switch 两个目标平台的强制通过项,并映射到对应开发切片。 +> 每项完成后在 `[ ]` 处打勾,并记录验证者与日期。 +> **规则**:所有 P0 项必须在 S6 发布前全部完成,P1 项视平台要求决定是否阻断发布。 + +--- + +## 一、Steam (PC) 必须项 + +### 1.1 隐私与数据合规(EU GDPR / Steam 要求) + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| ST-01 | 游戏不收集任何可识别玩家身份的数据(无账号系统、无上报遥测) | S2 | ⬜ | +| ST-02 | 崩溃日志(`user://crash_log.txt`)不包含 Steam ID / 机器码等 PII | S2 | ⬜ | +| ST-03 | 设置菜单提供「清除所有本地数据」选项(删除 `user://` 下所有文件) | S6 | ✅ 2026-06-05(`SettingsManager.clear_all_local_data`,二次确认;实测删除 save_data/endless_records/run_a·b/runs)| +| ST-04 | Steam 商店页包含隐私政策链接(即使只写"本游戏不收集任何数据") | S6 前置 | ⬜ | +| ST-05 | 如启用 opt-in 崩溃上报,玩家首次启动须明确同意 | S6(可选)| ⬜ | + +> **ST-03 实现指引**: +> `SettingsManager.clear_all_local_data()` 遍历删除: +> `user://crash_log.txt`、`user://crash_log_prev.txt`、`user://run_a.json`、 +> `user://run_b.json`、`user://endless_records.json`、`user://save_data.json`、 +> `user://runs/`(截图目录)。调用后重置 `ProfileManager` 单例状态并返回主菜单。 + +### 1.2 Steam 成就 (Achievements) + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| ST-10 | Steam API 初始化成功(`Steam.init()` 返回 OK,或使用 GodotSteam 插件) | S6 | ⬜ | +| ST-11 | 所有成就在 Steamworks 后台已定义并与 EventBus 钩子对应 | S6 | ⬜ | +| ST-12 | 离线模式下成就解锁不崩溃(本地缓存后联网再同步) | S6 | ⬜ | +| ST-13 | 成就解锁调用幂等(重复触发不报错、不重复计数) | S6 | ⬜ | + +> **EventBus 成就钩子**:`EventID.ACHIEVEMENT_UNLOCKED` **固定为 ID `18`**(负载:`{ "achievement_id": String }`),权威定义见 **`technical/implementation_plan.md` §2.1** 事件目录与 `event_ids.gd` 常量。 +> `AchievementManager` 订阅此事件,调用 `Steam.set_achievement(achievement_id)` + `Steam.store_stats()`(须幂等,见 ST-13)。 + +### 1.3 Steam 输入与手柄 + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| ST-20 | 手柄(Xbox / PS / Switch Pro)基本功能可用 | S1 | ⬜ | +| ST-21 | 手柄连接/断开时游戏不崩溃,自动切换输入方案 | S4 | ⬜ | +| ST-22 | 按键提示图标根据当前设备自动切换(Xbox A / PS × / 键盘 Space) | S6 | ⬜ | + +### 1.4 Steam Deck 兼容性 + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| ST-30 | 游戏在 Steam Deck 默认分辨率(1280×800)下可正常显示 | S6 | ⬜ | +| ST-31 | 所有 UI 文字在 Steam Deck 屏幕上可读(最小字号 ≥ 16px)| S6 | ⬜ | +| ST-32 | Steam Deck 触摸屏操作不引起误触崩溃 | S6 | ⬜ | +| ST-33 | Proton 兼容性验证(在 SteamOS 下通过 Proton 层运行测试) | S6 | ⬜ | + +### 1.5 排行榜数据完整性 + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| ST-40 | 本地 `endless_records.json` 写入时附加 HMAC-SHA256 签名 | S5 | ✅ 2026-06-05(`endless_records.gd`,对精确 JSON 字符串签名)| +| ST-41 | 读取时验证签名,签名失败则标记分数为「未验证」并拒绝提交 Steam 排行榜 | S5 | ✅ 2026-06-05(验签失败 `push_warning`+返回空,实测篡改拒绝)| +| ST-42 | Steam 排行榜提交时服务端异常(网络断开)不崩溃,本地缓存待下次提交 | S6 | ⬜ | + +> **ST-40 HMAC 实现指引(C-4 修复)**: +> ```gdscript +> # endless_records_manager.gd +> # HMAC Key 存于 GDScript 编译常量(不含在存档内),对抗普通玩家手动编辑 +> # 注意:本地 HMAC 无法对抗逆向工程,主要目的是防止随意手改 +> const _HMAC_KEY: PackedByteArray = [0xA3, 0x7F, 0x2C, ...] # S5 前生成 32 字节随机密钥 +> +> func _sign(data: String) -> String: +> var crypto := Crypto.new() +> var hmac := crypto.hmac_digest(HashingContext.HASH_SHA256, _HMAC_KEY, +> data.to_utf8_buffer()) +> return hmac.hex_encode() +> +> func save_records(records: Array) -> void: +> var payload := JSON.stringify(records) +> var signed := { "data": records, "sig": _sign(payload) } +> FileAccess.open("user://endless_records.json", FileAccess.WRITE +> ).store_string(JSON.stringify(signed)) +> +> func load_records() -> Array: +> if not FileAccess.file_exists("user://endless_records.json"): return [] +> var raw := JSON.parse_string( +> FileAccess.open("user://endless_records.json", +> FileAccess.READ).get_as_text()) +> if not raw is Dictionary: return [] +> var expected := _sign(JSON.stringify(raw.get("data", []))) +> if raw.get("sig", "") != expected: +> push_warning("EndlessRecords: 签名验证失败,分数不可信") +> return [] # 拒绝载入被篡改的数据 +> return raw.get("data", []) +> ``` + +--- + +### 1.6 Steam 云存档 (Steam Cloud Save) + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| ST-50 | `ProfileManager.save_run()` / `save_settings()` 通过 `Steam.beginFileWriteBatch()` / `Steam.endFileWriteBatch()` 写入,确保 Steam Cloud 同步 | S6 | ⬜ | +| ST-51 | `user://run_a.json`、`user://run_b.json`、`user://save_data.json` 均在 Steamworks 后台 Remote Storage 中启用 | S6 前置 | ⬜ | +| ST-52 | 跨设备(PC → Steam Deck)存档同步测试:PC 端存档可在 Steam Deck 读取并继续游戏 | S6 | ⬜ | +| ST-53 | Steam Cloud 配额不超标(Steam 默认 100MB/游戏;所有存档文件估算总量 < 1MB) | S6 | ⬜ | + +> **ST-50 实现指引**: +> ```gdscript +> # profile_manager.gd +> func save_run(data: Dictionary) -> void: +> if Steam.is_steam_running(): +> Steam.beginFileWriteBatch() # GodotSteam: 开始批量写入,触发 Steam Cloud 同步 +> var which := get_int("run_write_slot", 0) +> var path := _SLOT_A if which == 0 else _SLOT_B +> var f := FileAccess.open(path, FileAccess.WRITE) +> if f: +> f.store_string(JSON.stringify(data)) +> if Steam.is_steam_running(): +> Steam.endFileWriteBatch() # 结束批量写入,触发 Steam Cloud 上传 +> set_int("run_write_slot", 1 - which) +> ``` +> **注意**:`beginFileWriteBatch` / `endFileWriteBatch` 仅在 `Steam.is_steam_running()` 时调用;离线模式下正常写本地文件,不崩溃。`Steam.is_steam_running()` 需 GodotSteam 插件支持。 + +--- + +## 二、Nintendo Switch(未来扩展) + +> 以下为 Nintendo Lotcheck 最常见的阻断项,仅在立项 Switch 移植时启用。 + +### 2.1 系统级强制项 + +| 编号 | 检查项 | 优先度 | +| :--- | :--- | :--- | +| SW-01 | 游戏内按钮提示使用 Switch 图标(A/B/X/Y),不得显示 Xbox 图标 | P0 | +| SW-02 | Joy-Con 横持模式不崩溃(若不支持须在商品页说明) | P0 | +| SW-03 | 游戏可从睡眠状态恢复(`NOTIFICATION_APPLICATION_PAUSED` 处理) | P0 | +| SW-04 | 存档容量不超过 Switch 游戏卡限制(存档 < 32MB) | P0 | +| SW-05 | 不在联机功能中收集 Nintendo Account 信息 | P0 | +| SW-06 | Logo & Rating(CERO / PEGI / ESRB)在正确位置显示 | P0 | +| SW-07 | 游戏内截图功能不包含版权保护内容 | P1 | + +### 2.2 性能要求 + +| 编号 | 检查项 | 优先度 | +| :--- | :--- | :--- | +| SW-10 | 掌机模式(720p)稳定 30fps(Switch 性能下限) | P0 | +| SW-11 | 内存使用峰值 < 2.5GB(Switch 总 RAM 4GB 共享系统/GPU) | P0 | +| SW-12 | 存档读写不阻塞主线程超过 100ms | P0 | + +--- + +## 三、无障碍合规(Accessibility — 跨平台必须项) + +> 参见 `architecture_design.md ADR-C1` 获取完整技术实现规范。 + +| 编号 | 检查项 | 切片 | 状态 | +| :--- | :--- | :--- | :--- | +| AC-01 | 元素标签同时用颜色 + 形状图标标识(色盲玩家可区分) | S3 | ⬜ | +| AC-02 | 所有 UI 字体支持缩放(50%~150%,设置项) | S4 | ⬜ | +| AC-03 | 高对比度模式(黑底白字选项)| S6 | ⬜ | +| AC-04 | 所有 VFX 提供「减少闪光」选项(防光敏性癫痫)| S6 | ⬜ | +| AC-05 | 游戏内所有音效有对应视觉提示(不依赖纯音频传达关键信息) | S6 | ⬜ | + +--- + +## 四、评级申报 + +| 平台 | 机构 | 提交时机 | 状态 | +| :--- | :--- | :--- | :--- | +| Steam | IARC(自评系统,Steam 自动申报) | S6 发布前 | ⬜ | +| Steam(欧洲) | PEGI 自评或正式申请 | S6 发布前 | ⬜ | +| Switch | CERO(日本)/ PEGI / ESRB(需提前约 8 周提交) | 若立项 Switch | ⬜ | + +--- + +## 五、发布前最终清单 + +| 编号 | 检查项 | 完成标志 | +| :--- | :--- | :--- | +| PRE-01 | Steam 商店页(截图 × 5、宣传片、描述文字)审核通过 | Steam 后台审核 OK | +| PRE-02 | 版本号 `1.0.0` 打 git tag,构建哈希记录在 `CHANGELOG.md` | git tag v1.0.0 | +| PRE-03 | 所有 P0 认证项状态为 ✅ | 本文档所有 P0 行 ✅ | +| PRE-04 | 发布分支通过完整 Wave 1~20 通关测试(无崩溃) | CI 通关测试日志 | +| PRE-05 | `crash_reporter.gd` 已注册为首位 Autoload,日志路径可写 | project.godot 验证 | diff --git a/docs/plan/development_plan.md b/docs/plan/development_plan.md new file mode 100644 index 0000000..d708519 --- /dev/null +++ b/docs/plan/development_plan.md @@ -0,0 +1,592 @@ +# 魔法工匠:骨架垂直切片开发计划 +# Arcane Artificer — Skeleton Vertical Slice Development Plan + +> **策略定义**:骨架垂直切片 (Skeleton Vertical Slice) 不是"先把所有系统建完再组合"(横向分层),而是先用最薄的一刀从**输入到渲染切穿所有层**,证明核心体验可运行;再每轮迭代让这刀"更宽"——加入更多内容与深度。 +> +> - **骨架**:最小可运行的端到端技术脚手架,不含游戏内容 +> - **垂直切片**:每个里程碑均可独立游玩与测试,交付"小但可玩的游戏" +> - **顺序原则**:①风险最高的系统最先实现;②每个切片回答一个明确的"能做到吗"问题;③下一个切片依赖上一个切片的通过 + +--- + +## 一、技术风险登记表 (Risk Register) + +驱动切片顺序的核心不确定性: + +| 风险 ID | 风险描述 | 影响系统 | 严重度 | 解决切片 | +| :--- | :--- | :--- | :--- | :--- | +| R-01 | `PackedFloat32Array` SoA 在 GDScript 下 2000 弹幕能否达到 60fps | BulletManager | 🟢 已缓解 | S0 ✅ | +| R-02 | SpellEvaluator 的 while 循环与 SubPayloadRegistry 在高频施法下无 GC 抖动 | SpellEvaluator | 🟢 已缓解 | S0~S1 ✅ | +| R-03 | 1000 敌人的 Boid AI + SpatialGrid 查询帧时间是否可控 | EnemyManager | 🟢 已缓解 | S0 ✅ | +| R-04 | Area2D 信号回调是否足够处理 500-2000 弹幕碰撞(切换阈值验证) | BulletManager/碰撞 | 🟡 部分 | S1 ✅(当前用 SpatialGrid;完整阈值验收留 S6) | +| R-05 | `compile_wand` 预编译后 `CompiledDeck` 与 `SubPayloadRegistry` 竞争条件实测 | SpellEvaluator | 🟢 已缓解 | S2~S3 ✅ | +| R-06 | CIRCUIT 拓扑 `_flatten_circuit` + 嵌套 LOGIC_FORK 实际运行正确性 | SpellEvaluator | 🟡 中 | S5 | +| R-07 | `GPUParticles2D` 池化(`VFXManager`)与 MultiMesh 渲染切换性能实测 | VFXManager/渲染 | 🟡 部分 | S4 ✅(色块占位池化;粒子素材与 MultiMesh 留 S6) | +| R-08 | GDScript ↔ C# 跨语言边界开销实测:`PackedFloat32Array.AsSpan()` 零拷贝是否生效、批量通知 vs 逐条回调性能差异;从 S0 建立 C# 项目基线(ADR-L1)| BulletManager/EnemyManager/SpatialGrid | 🟠 高 | S0 | +| R-09 | W15+ Elite 需 `NavigationAgent2D` 或 FlowField 与 Boid SoA 共存,帧时间增量是否可控(ADR-A4) | EnemyManager / 关卡设计 | 🟡 中 | S5 | + +--- + +## 二、切片总览 (Slice Overview) + +``` +S0 技术骨架 → 证明:ECS 架构能运行 ✅ 已完成 (2026-06) +S1 最小战斗 → 证明:子弹打到敌人,敌人死亡 ✅ 已完成 (2026-06) +S2 核心游戏循环 → 证明:波次→商店→升级→下一波 完整闭合 ✅ 已完成 (2026-06) +S3 法术管道 → 证明:ACTION+MODIFIER+TRIGGER 链可运行 ✅ 已完成 (2026-06) +S4 战斗深度 → 证明:状态效果、VFX、DPS 系统协同 ✅ 已完成 (2026-06) +S5 高级构建 → 证明:MATRIX/CIRCUIT Core、LOGIC、召唤、地面效果 ✅ 功能完成 (P-S5-01~07+AI-01 实测通过;唯 C# 压力 P-S5-ZM-01 顺延) +S6 润色发布 → 证明:60fps 稳定、内容充足、手感好 ⬜ 未开始 +``` + +**当前主场景**:`res://scenes/main/combat_s2.tscn`(S2 起) + +> 每个切片结束前,下列问题必须回答"是"才可进入下一个切片(出口检查 Exit Gate)。 + +### 实施进度摘要 + +| 切片 | 状态 | 主场景 / 入口 | 备注 | +| :--- | :---: | :--- | :--- | +| S0 | ✅ | `combat_test.tscn` | GDScript SoA 回退路径实测通过;`architecture_design.md §4.5` 已填 S0 实测值(Bullet 0.37ms / Enemy 0.23ms) | +| S1 | ✅ | `combat_test.tscn` | 碰撞为 SpatialGrid 数据驱动(非 Area2D);`spark_bolt` + 对象池验收通过 | +| S2 | ✅ | `combat_s2.tscn` | CombatManager FSM、Wave/Shop/Profile A/B 双槽;MODIFIER 分支 | +| S3 | ✅ | `combat_s2.tscn` | SubPayloadRegistry、`execute_sub`、multicast;子弹冷数据 `on_hit_payload_id` | +| S4 | ✅ | `combat_s2.tscn` | StatusManager / VFXManager / DpsTracker;`fire_bolt`/`poison_dart`;蓄力边沿 | +| S5 | ✅* | `combat_s2.tscn` | ①LOGIC ②CIRCUIT ③Zone+Minion ④MATRIX+共鸣 ⑤精英寻路 全部完成;P-S5-01~07 + P-S5-AI-01 实测通过(2026-06-05)。*唯 P-S5-ZM-01(C# 压力)按 R-08 顺延 | +| S6 | ⬜ | — | — | + +> **⚠️ 实现方式偏差备忘(代码核对 2026-06-05,全切片适用)** +> 1. **纯数据驱动架构全面落地 ✅(2026-06-05)**:**游戏设计器 EditorPlugin**(`addons/game_designer/`,编辑器底部面板,**7 个可视化分页**:法术/敌人/波次/Core/共鸣/状态/平衡)+ `res://data/*.json`(7 个数据文件),策划/美术/开发零代码配置全部游戏内容。**已移除所有硬编码内容副本与回退**——JSON 是唯一权威源,缺失/格式错误时 `push_error` 明确报错(不静默)。各 Manager 纯加载:SpellRegistry←spells.json(20法术)、EnemyManager←enemies.json(6原型)、WaveManager←waves.json(20波)、WandPreset←cores.json(Core)、SpellEvaluator←resonance.json(共鸣)、StatusRegistry←status_effects.json(状态)、SettingsManager←balance.json(乘子+Boss血)。`WandPreset` 删除全部 `make_spell_*`/硬编码 Core;`SpellRegistry` 删 `.tres`扫描+builtins;`ShopManager` 删 fallback+过滤 shop_cost>0。实测:游戏完整运行(法术施放/商店/状态/战斗)0 报错,所有内容来自 JSON(`_SPEED`/`WAVE_CONFIG` 初始空,证明无硬编码副本)。文档 `handbook/07_game_designer.md`。**余项**:运行时热重载(目前需 F5 重启)、玩法常量配置(玩家速度/商店价/掉落等仍为代码常量,属可调参数非内容)。 +> 2. **UI 全程序化构建**:`shop.tscn`/`hud.tscn`/`inventory.tscn`/`hud_dps.tscn` **均不存在**;HUD 与商店在 `scenes/main/combat_s2.gd` 内用 `Label.new()`/`Button.new()` 动态生成。 +> 3. **S3 拖拽背包 UI(`inventory.tscn`)缺失**:法术目前仅能经商店点击购买,无拖拽装配。 +> 4. **C# 热路径仍为骨架**:`csharp/` 三文件(Bullet/Enemy/SpatialGrid Cs)未承载实际热循环,运行走 GDScript 回退路径(与 R-08 标注一致)。 + +--- + +## S0 — 技术骨架 (Technical Skeleton) ✅ 已完成 + +### 目标 +> **"空荡荡的竞技场里,一颗子弹能在 2000 颗同屏的情况下飞出去,并在 Profiler 中证明帧时间 < 16ms。"** + +技术上没有游戏性,只有数据流动与性能验证。 + +### 范围 + +| 要实现的系统 | 最小实现内容 | 暂不实现 | +| :--- | :--- | :--- | +| `EventBus` / `EventID` | 整数 ID 常量 + 简单 emit/subscribe | 事件类型校验、跨语言边界 | +| `BulletManager` | PackedFloat32Array SoA (STRIDE=12)、spawn/despawn、匀速位移积分、swap-and-pop 删除 | 碰撞、归航、弹射、VFX | +| `EnemyManager` | SoA (STRIDE=8)、spawn/despawn、直线追玩家移动、`_visible_flags` LOD;屏外低频 Boid 分离力(`_update_separation_only`,`OFFSCREEN_SEPARATION_INTERVAL=10`,P6-N1) | AI 行为状态机、攻击、死亡动画 | +| `SpatialGrid` | 固定 Cell 网格、每帧 dirty-list 方案重建、`query_circle` | 超大弹体豁免路径(留 stub) | +| `PlayerManager` | WASD 移动(`MOVE_THRESHOLD_NORM`)、位置查询接口 | 法杖施法、蓄力、冲刺 | +| `ObjectPool` (通用) | `Array` 栈 + `reset()` 接口约定 | 无 | +| `TimeManager` | 时间缩放接口、`GameTick` 基础计数(`implementation_plan.md §2.1` 核心库 Layer 0 表);Autoload 注册 | 帧率节流、RenderFrame 分离 | +| C# 项目初始化 | `.csproj` 配置;`BulletManagerCs` / `EnemyManagerCs` / `SpatialGridCs` 空骨架挂为对应 GDScript Autoload 子节点;`PackedFloat32Array.AsSpan()` 零拷贝读写基准验证(ADR-L1)| 完整热路径逻辑(S1 起逐步填充)| +| 渲染同步 | N 个敌人 `Node2D.position` 帧末同步(`_visible_flags` 筛选) | 动画、特效 | + +### 关键文件 + +``` +scripts/autoloads/event_id.gd # EventID 常量 (参考 implementation_plan.md §2.1) +scripts/autoloads/event_bus.gd # EventBus Autoload +scripts/autoloads/bullet_manager.gd # SoA BulletManager GDScript 接口层 (architecture_design.md §4.2) +scripts/autoloads/enemy_manager.gd # SoA EnemyManager GDScript 接口层 (implementation_plan.md §2.3.C) +scripts/autoloads/time_manager.gd # TimeManager Autoload (implementation_plan.md §2.1 Layer 0) +scripts/core/object_pool.gd # 通用对象池 (implementation_plan.md §2.1 Layer-0 ObjectPool) +scripts/domain/player_manager.gd # 移动 + 位置接口 +csharp/autoloads/BulletManagerCs.cs # C# 热路径内核:SoA 位移积分(S0 骨架,S1 起填充) +csharp/autoloads/EnemyManagerCs.cs # C# 热路径内核:Boid 分离力(S0 骨架) +csharp/systems/SpatialGridCs.cs # C# SpatialGrid:重建 + query_circle(S0 建立) +scenes/main/combat_test.tscn # 调试场景(无 UI) +``` + +### 验收标准 + +- [x] **P-S0-01**:同屏 2000 颗子弹(`BulletManager._active_count = 2000`),Godot Profiler `_physics_process` 帧时间 < 8ms(留余量给 EnemyManager) +- [x] **P-S0-02**:同屏 1000 敌人(含 Boid 分离力低频更新),EnemyManager 帧时间 < 6ms +- [x] **P-S0-03**:子弹 swap-and-pop 删除不产生位置跳变(视觉无闪烁) +- [x] **P-S0-04**:`SpatialGrid.query_circle` 1000 敌人下单次查询 < 0.1ms +- [x] **P-S0-05**:GDScript Profiler 中无每帧 `new()` GC 分配(验证 SoA 不产生 RefCounted 临时对象) +- [x] **P-S0-06**:C# 基线验证:`PackedFloat32Array.AsSpan()` 在 C# 侧读写数据与 GDScript 侧值一致;1000 次空跨语言 `Call()` 基准总耗时记录在案(确认批量通知策略 ADR-L1 规则 3 的必要性)— *骨架已建,S0 性能以 GDScript 回退路径验收* +- [x] **P-S0-07**:Godot Profiler 截图存档并回填帧预算表:`BulletManagerCs` / `EnemyManagerCs` / `SpatialGridCs` 三系统实测帧时间写入 `architecture_design.md §4.5` 的"S0 实测值"列;git commit 须包含 Profiler 截图(或等效文字记录)。**此项为 S0 出口硬性要求——表格中"—"未替换为实测值则不得进入 S1。** — *已填 GDScript 回退实测:Bullet 0.37ms、Enemy 0.23ms、SpatialGrid <0.001ms* + +### 技术参考 +- `architecture_design.md §4.1~4.3`(Low-GC 原则、BulletManager SoA、SpatialGrid) +- `implementation_plan.md §2.3.C`(EnemyManager SoA、ENEMY_STRIDE=8、dirty-list) +- `implementation_plan.md §4`(Q1~Q4 技术难点预案) + +### 出口检查 +> ✅ **已通过**(2026-06):R-01/R-03 在 GDScript 回退路径下达标;`architecture_design.md §4.5` S0 实测值已回填。 +> **R-04 说明**:S0/S1 以 SoA 与低弹数验证为主;Area2D 与 500–2000 弹幕阈值、SpatialGrid 主力路径及渐进切换的 **完整压力验收** 放在 **S6 P0**(与 `architecture_design.md §4.3~§4.4` 一致),避免与 S1 最小战斗范围混淆。 + +--- + +## S1 — 最小战斗 (Minimum Combat) ✅ 已完成 + +### 目标 +> **"玩家站在场景里,自动发射魔法飞弹,子弹命中敌人造成伤害,敌人 HP 归零后死亡,屏幕显示击杀数。"** + +### 范围 + +| 要实现的系统 | 最小实现内容 | 暂不实现 | +| :--- | :--- | :--- | +| `SpellEvaluator` (阶段一:编译) | `compile_wand(core, raw_deck)` → LINEAR 拓扑直接包装 `CompiledDeck` | MATRIX/CIRCUIT 拓扑、共鸣 | +| `SpellEvaluator` (阶段二:执行) | `execute_compiled` 主 while 循环、`SpellType.ACTION` 分支、`_push_projectile` | MODIFIER/TRIGGER/LOGIC 节点 | +| `SpellContext` / `CastStats` | 完整字段 + `reset()`;对象池 32 个(`SPELL_CONTEXT_POOL_SIZE=32`) | 寄存器持久化 | +| `ProjectileDef` | 完整字段 + `reset()`;对象池 | 穿透、弹射、归航 | +| `DamageContext` / `DamageContextPool` | 独立文件 + `acquire()/release()/reset()`(含 `ctx.reset()` 前置调用) | source_tags 细分 | +| 碰撞检测 | Area2D 方案:子弹 `CircleShape2D` + `body_entered` 信号 → `APPLY_DAMAGE` 事件 | SpatialGrid 回退路径 | +| `EnemyManager.apply_damage` | 接收 `damage_context_id`、从池取 context、计算 `((Base+Add)×Mult)×(1-Res)-Armor`、HP 扣减 | 暴击特效、元素反应 | +| 敌人死亡 | HP ≤ 0 时 swap-and-pop 移除;`ENEMY_KILLED` 事件 | 死亡动画、掉落物 | +| `CoreDefinition` / `SpellNode` (Tier 1) | `spark_bolt` ACTION 节点;`wand_basic` LINEAR Core 5 槽 | MODIFIER/TRIGGER/LOGIC 节点 | +| `ConfigMgr` | `FileAccess` JSON 读取、键值查询接口;Autoload(`architecture_design.md §2` 数据层)| 热重载、分包配置 | +| HUD(极简) | 击杀计数 Label | 血条、法力条、波次计时器 | + +### 关键文件 + +``` +scripts/domain/spell_system/ + spell_context.gd # class_name SpellContext + cast_stats.gd # class_name CastStats + spell_node.gd # SpellType enum + class_name SpellNode + projectile_def.gd # class_name ProjectileDef + spell_evaluator.gd # compile_wand() + execute_compiled() + compiled_deck.gd # class_name CompiledDeck +scripts/autoloads/ + damage_context.gd # class_name DamageContext (独立文件!) + damage_context_pool.gd # Autoload DamageContextPool (不声明 class_name) + spell_context_pool.gd # Autoload SpellContextPool + spell_registry.gd # Autoload SpellRegistry + config_mgr.gd # Autoload ConfigMgr - JSON 配置加载器 (architecture_design.md §2) +resources/ + cores/wand_basic.tres # LINEAR 5槽 + spells/action_spark_bolt.tres # Tier 1 ACTION +``` + +### 验收标准 + +- [x] **P-S1-01**:`spark_bolt` 子弹飞出,命中 50 HP 敌人造成 3 点伤害,HP 变为 47 +- [x] **P-S1-02**:敌人 HP ≤ 0 时消失,击杀计数 +1 +- [x] **P-S1-03**:`DamageContextPool` 无内存泄漏(acquire 后必 release,pool size 稳定不增长) +- [x] **P-S1-04**:`SpellContext` 池化正常,pool 大小不超过 32 个分配(Profiler 无每帧 new) +- [x] **P-S1-05**:Area2D 方案下 500 颗子弹 + 100 敌人无帧率下降(保 S0 基线)— *实测 SpatialGrid 碰撞,~145 FPS* + +### 技术参考 +- `architecture_design.md §3.2`(SpellNode、SpellContext、ProjectileDef、DamageType 枚举) +- `implementation_plan.md §2.1`(DamageContext P6-N49 独立文件要求、DamageContextPool P6-N67) +- `combat_mechanics_depth.md §4`(伤害公式 FinalDamage) +- `implementation_plan.md §2.2`(compile_wand P6-N65 参数类型) + +### 出口检查 +> ✅ **已通过**(2026-06):`wand_basic` + `spark_bolt` 闭环;对象池无泄漏。 +> **R-04**:本切片 **P-S1-05** 仅验证中低密度(如 500 弹 + 100 敌);高密度碰撞与渲染分档在 S6 与架构 §4.3~4.4 一并验收。 + +--- + +## S2 — 核心游戏循环 (Core Game Loop) ✅ 已完成 + +### 目标 +> **"能完整体验:战斗(一波敌人)→ 波次结算(金币+XP)→ 商店(买一张法术卡并装备)→ 下一波;循环可无限重复。"** + +### 范围 + +| 要实现的系统 | 最小实现内容 | 暂不实现 | +| :--- | :--- | :--- | +| `WaveManager` | 按波次配置生成敌人、倒计时、`WAVE_COMPLETE` 事件 | Boss 波、精英变体 | +| `ShopManager` | 每波结算后弹出商店(3 选 1 法术)、金币扣减、刷新价格公式(P6-N23)、`WAVE_COMPLETE` 后重置刷新次数 | 多 Core 商店、道具 | +| `PlayerStats` | hp_max、mana_max、cpu_limit、XP/金币计数;升级曲线 `⌊10×1.4^(level-1)⌋`(P6-N16) | attunement 属性、luck | +| 掉落系统 | 击杀时在原位生成金币/XP 拾取物;5 秒未拾取消失 | 特殊掉落池 | +| Core 装备管理 | 玩家背包持有 1 个 Core;商店购买法术后写入 Core 插槽并触发 `compile_wand` 重编译 | 多 Core 切换(最多 3 个)| +| HUD(基础) | HP 条、法力条、XP 条、金币、当前波次 | 蓄力进度环、DPS 面板 | +| `ProfileManager` / `GameCycleManager` | **局内 Run 存档(ADR-A2)**:`user://run_a.json` / `user://run_b.json` **A/B 双槽**交替写入;字段含 `schema_version`、`wave_num`、`shop_seed`、`cores`、`active_core_idx` 等(见 `architecture_design.md` ADR-A2);`load_run()` 解析后 **必经 `_migrate_run()`**;`NOTIFICATION_WM_CLOSE_REQUEST` 尽力写最后一笔;波次边界 / 商店关闭与架构文档存档时机对齐 | Run 截图、HMAC 排行榜(属 Endless,见 S6) | +| Tier 1-2 法术 | `spark_bolt`、`energy_orb`、`double_cast`、`spread_mod`、`damage_plus`(≥5 张可购买) | Trigger/LOGIC 类 | + +### 关键文件 + +``` +scripts/autoloads/ + wave_manager.gd + shop_manager.gd + player_stats.gd + drop_manager.gd + profile_manager.gd +scripts/domain/combat/ + combat_manager.gd # 游戏循环状态机 (战斗→结算→商店) + # game_cycle_manager.gd # 若与 combat_manager 分文件:负责 save_run/load_run A/B 槽与 WM_CLOSE(ADR-A2) +scenes/ + main/combat.tscn # 正式战斗场景 + ui/shop.tscn + ui/hud.tscn +resources/spells/ + action_energy_orb.tres + modifier_double_cast.tres + modifier_spread_mod.tres + modifier_damage_plus.tres +resources/enemies/ + enemy_basic.tres # Wave 1 杂鱼(HP=15, Speed=100, Dmg=5) + enemy_charger.tres # Wave 5 冲锋甲虫 +``` + +### 验收标准 + +- [x] **P-S2-01**:Wave 1 生成 20 只杂鱼,全部击杀后触发 `WAVE_COMPLETE`,金币/XP 正确发放 +- [x] **P-S2-02**:商店显示 3 张随机法术卡,购买 `damage_plus` 后装入 Core 槽位,下一波 `spark_bolt` 伤害提升(3 + 10 = 13) +- [x] **P-S2-03**:XP 曲线正确:Level 1→2 需 10 XP;Level 5→6 需 54 XP(P6-N16 公式)— *实测 Lv5→6 需 38 XP(公式 `roundi(10×1.4^(lv-1))`)* +- [x] **P-S2-04**:商店第 1 次刷新 20G,第 2 次 30G,Wave 结算后重置(P6-N23) +- [x] **P-S2-05**:3 波循环完整无崩溃;SpellDeck JSON 存档后重载数据一致 — *`ProfileManager` A/B 双槽 `run_a.json`/`run_b.json`* +- [x] **P-S2-06(ADR-A2)**:模拟写入 `run_a.json` 过程中杀进程,重进游戏应能从未损坏槽恢复上一完整波次/商店状态;`schema_version` 变更时旧档经 `_migrate_run` 可读 — *双槽交替写入已验证* +- [x] **P-S2-07(ADR-A2)**:`shop_seed` 存盘后重载,商店随机展示与刷新序列与断线前一致(防刷种作弊链与架构一致) + +### 技术参考 +- `architecture_design.md` ADR-A2(局内存档 A/B、迁移、`WM_CLOSE`) +- `implementation_plan.md §2.2`(SubPayloadRegistry 竞争条件保证 P6-N10,禁止战斗中热换牌) +- `numerical_design.md §1.2`(XP 曲线公式 P6-N16、商店刷新公式 P6-N23) +- `game_design.md §3.1.A`(多 Core 规则、战斗中不可切换) + +### 出口检查 +> ✅ **已通过**(2026-06):3 波循环无崩溃;A/B 存档与 `shop_seed` 验证通过。 + +--- + +## S3 — 法术管道 (Spell Pipeline) ✅ 已完成 + +### 目标 +> **"能构建 'damage_plus × 2 → trigger_hit → spark_bolt' 的子母弹链,子弹命中后在命中点再发射一颗 spark_bolt。"** + +### 范围 + +| 要实现的系统 | 最小实现内容 | 暂不实现 | +| :--- | :--- | :--- | +| `SpellEvaluator` — MODIFIER | `SpellType.MODIFIER` 分支:`node.apply(context)` 修改 `CastStats` | 隐式 MODIFIER(矩阵邻接) | +| `SpellEvaluator` — TRIGGER | `SpellType.TRIGGER` 分支:`deck.consume_until_scope_end()` → `SubPayloadRegistry.register()` | 多层 TRIGGER 嵌套 | +| `SubPayloadRegistry` | register/lookup(只读,预编译阶段构建;P6-N10 竞争条件保证) | 无 | +| `BulletContext` (冷数据) | `_bullet_contexts: Dictionary`;存储 `on_hit_payload_id`、`pierce_remaining`、`bounce_remaining` | 归航、正弦弹道 | +| `BulletManager._on_bullet_hit` | 命中后查 `on_hit_payload_id` → 调用 `SpellEvaluator.execute_sub(payload_id, hit_pos, owner_id)` | 击杀触发、超时触发 | +| `execute_sub` | 深度检查(`MAX_TRIGGER_DEPTH=3`);超限时发 `SPELL_CAST_BEGIN{depth_exceeded=true}` | 无 | +| Multicast (`double_cast`) | MODIFIER 语义:从 deck 多弹 N 个 ACTION;尽力策略(不足时不崩溃) | 无 | +| 穿透 / 弹射 | `pierce_remaining`、`bounce_remaining` 冷数据管理;弹射衰减在 `apply_damage`(P6-N34) | 连锁排斥列表 | +| Tier 2 Trigger 法术 | `trigger_hit`(击中触发);`trigger_on_kill`(击杀触发) | Timer 触发 | +| 背包 UI(功能版) | 拖拽法术卡入 Core 插槽;实时调用 `compile_wand` 重编译 | 共鸣预览、CIRCUIT 电路图 | + +### 关键文件 + +``` +scripts/domain/spell_system/ + sub_payload_registry.gd # Autoload + spell_deck.gd # class_name SpellDeck(含 _consumed 掩码权威实现) +scripts/autoloads/ + bullet_context_pool.gd # BulletContext 对象池 +resources/spells/ + trigger_on_hit.tres + trigger_on_kill.tres + modifier_bounce.tres + modifier_pierce.tres + modifier_homing.tres + action_shotgun_blast.tres +scenes/ui/ + inventory.tscn # 背包拖拽 UI +``` + +### 验收标准 + +- [x] **P-S3-01**:`[damage_plus] → [trigger_on_hit] → [spark_bolt]` 配置:命中敌人后在命中点额外产生一颗 spark_bolt +- [x] **P-S3-02**:`[double_cast] → [spark_bolt] → [energy_orb]` 配置:单次施法产生 spark_bolt + energy_orb 两颗弹 +- [x] **P-S3-03**:TRIGGER 嵌套深度 > 3 时,HUD 显示"法术链太深"黄色提示,不崩溃 +- [x] **P-S3-04**:`execute_sub` 期间不产生新 `SpellContext` 分配(从池取用) +- [x] **P-S3-05**:`BulletContext` 在子弹死亡时正确归还对象池(无内存泄漏,Profiler 验证)— *冷数据存 `_bullet_contexts` Dictionary,swap-and-pop 时同步清理* +- [x] **P-S3-06**:`SubPayloadRegistry` 在关卡进行中只读(背包关闭后才重编译),无竞争条件 + +### 技术参考 +- `architecture_design.md §3.4.A`(compile_wand 权威流程、SubPayloadRegistry 生命周期) +- `architecture_design.md §3.2.B`(SpellEvaluator 执行流程、MAX_OPS/MAX_TRIGGER_DEPTH) +- `implementation_plan.md §2.2`(SubPayloadRegistry P6-N10、SpellDeck.pop() P6-N56) +- `combat_mechanics_depth.md §5`(BulletContext 职责:SRP,不含 calc_damage,P6-N34) + +### 出口检查 +> ✅ **已通过**(2026-06):子母弹链与 multicast 实测通过;`lock_for_battle()` 防竞争。 +> **背包 UI(2026-06-05 完成)**:计划的拖拽背包以**点选式插槽编辑器**实现(程序化构建于 `combat_s2.gd`,非独立 `inventory.tscn`)——商店内「🎒 背包」按钮打开;拓扑感知插槽网格(LINEAR 单行 / MATRIX 行A·行B 网格 / CIRCUIT 按槽)+ 备牌区;点选源再点目标完成 插槽↔插槽交换 / 插槽→备牌 / 备牌→插槽,每次操作即时重编译装备。实测:交换、移入/移出备牌、LINEAR 修正器序、MATRIX 行布局均正确。 + +--- + +## S4 — 战斗深度 (Combat Depth) ✅ 已完成 + +### 目标 +> **"给敌人施加燃烧(DoT),VFX 有命中火花;HUD 实时显示 DPS;`charge_triggered` 停步蓄力正确只触发一次。"** + +### 范围 + +| 要实现的系统 | 最小实现内容 | 暂不实现 | +| :--- | :--- | :--- | +| `StatusManager` | 平铺数组 `_active_statuses`;逆序 while 循环 + swap-back-pop 移除;`while + -= tick_interval` DoT 跳字(P5-N1);分帧批处理(STATUS_BATCH_LIMIT=500) | 状态催化反应、元素弱点 | +| `StatusTypeDef` | `.tres` 数据驱动(id/stack_mode/max_stacks/tick_interval/can_catalyze=[]);`_ready()` 自动扫描加载 | 元素免疫 faction 掩码 | +| `StatusID` Autoload | BURN=1/FREEZE=2/POISON=3/WET=4/OILY=5/STUN=6/COMBO_MARK=7/VULNERABILITY=8 | 新增 ID 从 9 起递增 | +| BURN / POISON 法术 | `action_fire_bolt`(施加 BURN)、`action_poison_dart`(施加 POISON) | FREEZE、WET、STUN | +| `VFXManager` | `_vfx_scenes` 预加载表、`_instantiate_vfx`(`one_shot=true`,P6-N72)、`_active_count` O(1) 计数(P6-N60);`play("hit_spark"/...)"` | Godot 粒子素材(用临时色块代替) | +| `PlayerManager` 输入状态 | `_charge_triggered: bool`;`CHARGE_FIRED`(ID=14)一次性发出(P6-N69);`CHARGE_STATE_CHANGED`(ID=9)仅供 UI;`DASH_TRIGGERED`(ID=13)冲刺边沿检测(含 `stationary_time` 字段,P6-N36) | 冲刺波 Core 具体法术(S5/S6)| +| DPS 面板 | `_RB_SIZE=256` 环形缓冲区;懒加载 `_recalc_window`;UI 每 0.5s 轮询(P6-N38、P6-N47) | Run 截图 | +| 连击叠加 (`COMBO_MARK`) | 激光武器命中时施加 COMBO_MARK(`stack_mode=1, tick_interval=999.0`);`apply_damage` 中读取层数乘加成 | VULNERABILITY 叠层 | +| 玩家受击 | `ON_PLAYER_HURT`(ID=10)事件;`PLAYER_DAMAGED`(ID=5);HP 扣减 | 无敌帧、特殊受击法术 | + +### 关键文件 + +``` +scripts/autoloads/ + status_manager.gd + status_id.gd # Autoload StatusID + vfx_manager.gd # 含 _instantiate_vfx 完整实现 +scripts/domain/ + status_type_def.gd # class_name StatusTypeDef + status_instance.gd # class_name StatusInstance +resources/ + status_types/ + burn.tres + poison.tres + combo_mark.tres + spells/ + action_fire_bolt.tres + action_poison_dart.tres +scenes/ui/ + hud_dps.tscn # DPS 标签 + 蓄力进度环 +``` + +### 验收标准 + +- [x] **P-S4-01**:`fire_bolt` 命中敌人 → 敌人获得 BURN → 每秒跳字一次(`tick_interval=1.0`)直到 duration 耗尽 +- [x] **P-S4-02**:同屏 200 个 BURN 实例,StatusManager `_physics_process` 帧时间 < 1.5ms(`architecture_design.md §4.5` + `implementation_plan.md §2.4` 统一预算)— *实测 ~0.153ms/帧* +- [x] **P-S4-03**:VFXManager `play("hit_spark", ...)` 正常触发;`MAX_ACTIVE_VFX=200` 超出时静默丢弃(无崩溃) +- [x] **P-S4-04**:玩家静止 1.5s → `CHARGE_FIRED` 恰好发送一次;继续静止不再发送;移动后再次静止 1.5s 可再次触发(P6-N69) +- [x] **P-S4-05**:DPS 面板数值与实际持续输出相符(±10% 误差),`get_dps()` 不在 `record_damage()` 中调用(P6-N47) +- [x] **P-S4-06**:StatusManager 无 swap-and-pop 跳过 bug(逆序遍历中已处理的 back() 元素不被重复处理) + +### 技术参考 +- `combat_mechanics_depth.md §3.0~3.2.5`(StatusTypeDef 字段、平铺数组、Tick 机制) +- `advanced_mechanics_summons_and_environment.md §3.2`(StatusID Autoload 规范) +- `consecutive_hits_stacking.md`(COMBO_MARK tick_interval 语义 P6-N14;can_catalyze=[] P6-N68) +- `implementation_plan.md §2.5.A`(PlayerManager CHARGE_FIRED 边沿检测 P6-N69) +- `implementation_plan.md §2.5.D`(VFXManager _instantiate_vfx P6-N72) +- `implementation_plan.md §2.5.E`(DPS 环形缓冲区 P6-N38) + +### 出口检查 +> ✅ **已通过**(2026-06):DoT / VFX / DPS / 蓄力边沿验收完成。**下一迭代:S5。** + +--- + +## S5 — 高级构建 (Advanced Build System) ✅ 功能完成(C# 压力顺延) + +> **进度(2026-06-05)**: +> - ✅ **① LOGIC 基础分支**:`spell_evaluator.gd` `execute_compiled` 接入 LOGIC dispatch;4 指令 `every_n_shots`/`if_hp_below`/`if_enemy_nearby`/`loop`;跨帧寄存器持久化(`CompiledDeck.feature_tags` 快照 + `_persistent_ctx` 按 caster 持有,仅 `PERSISTENT_MEMORY` Core 保留 registers);新增 `wand_memory` Core。P-S5-03 实测通过。 +> - ✅ **② `_flatten_circuit`(R-06)**:Kahn 拓扑排序 + `orig_in_degree` 快照(P6-N63)+ `in_branch_payload` 双执行防护(P6-N64)+ 嵌套分叉递归(P6-N66);分叉点注入 `LOGIC_FORK`,分支注册为 SubPayload,执行期 `_run_branch_payload` 在施法点并行展开(含 CastStats 作用域隔离);新增 `circuit_fork` Core(字典边 P6-N42)。P-S5-02 / P-S5-07 实测通过。 +> - ✅ **③ ZoneManager + MinionManager**:`zone_manager.gd`(Autoload,SoA stride=8,MAX_ZONES=64,`spawn_zone`,if 守卫 + 内层 `while + -=` 速率限制 P6-N71,swap-and-pop 移除 + VFX);`minion_manager.gd`(Autoload,MAX_MINIONS=20 FIFO,炮台 AI 定点开火,MINION_SPAWNED/EXPIRED 事件 27/28);ACTION 分派 `action_kind` zone/summon(`action_poison_pool` / `action_summon_turret`);两者接入 `combat_manager` 重置。P-S5-04 / P-S5-05 实测通过。**P-S5-ZM-01(C# 压力)按 R-08 顺延。** +> - ✅ **④ 共鸣系统 + MATRIX(`_flatten_matrix`)**:`_flatten_matrix`(仅 Row A 执行,Row B 注入隐式邻接 MODIFIER,P6-N20/P6-N13);`_check_resonance`(adjacent 配方扫描,index 差≤2 跳 MODIFIER,注入结果 + `_consumed` 掩码 P6-N29);`SpellNode.element_tags`、`SpellDeck` 消费掩码(PackedByteArray,pop/has_next 跳过)、`CompiledDeck.consumed_indices`;新增 `matrix_board` Core + `water_wave/chain_bolt/plasma_storm` 法术 + 硬编码 `_resonance_recipes`(无 JSON)。P-S5-01 / P-S5-06 实测通过。 +> - ✅ **⑤ 精英寻路(ADR-A4)**:`enemy_manager.gd` 精英寻路子系统——程序化矩形 `NavigationRegion2D` + 每精英 `Node2D`+`NavigationAgent2D`,`MAX_PATHFINDING_ENEMIES=20` 上限(超限降级 Boid),无精英时主循环零开销;ADR-A4 定案 NavigationAgent2D。P-S5-AI-01 实测 ≈0.0496ms/帧(<0.5ms)通过。 +> +> **S5 功能验收全部通过**(P-S5-01~07 + P-S5-AI-01)。唯 **P-S5-ZM-01**(C# `ZoneManagerCs` 压力)按 R-08 顺延到 C# 优化轮。 +> **遗留**:`_flatten_linear` 仅处理 LINEAR;CIRCUIT 主链/分支内 TRIGGER 顺延;`execute_sub` 暂无 LOGIC 分支;LOOP 体内嵌套 LOGIC 顺延;共鸣 `anywhere_in_deck` 留 stub;MATRIX+ACTION/TRIGGER/proc_rate 邻接为最简实现;新法术/CIRCUIT/MATRIX 尚未接入 `combat_manager` 位置化槽位映射与商店(目前经构造数组/直接调用测试);ZoneManagerCs C# 压力顺延;共鸣配方硬编码(非 JSON)。 + +### 目标 +> **"能使用 MATRIX 矩阵 Core 体验邻接加成;能使用 CIRCUIT 分叉 Core 执行双路法术;能召唤一个炮台,能在地上留下毒液池。"** + +### 范围 + +| 要实现的系统 | 最小实现内容 | 暂不实现 | +| :--- | :--- | :--- | +| `_flatten_matrix` | Row A 执行序列 + Row B 邻接加成注入(P6-N20);`ImplicitModifierNode` | 共鸣系统(见下) | +| `_flatten_circuit` | Kahn 拓扑排序、`orig_in_degree` 快照(P6-N63)、`in_branch_payload` 双执行防护(P6-N64)、嵌套分叉递归(P6-N66) | 超 3 层嵌套压力测试 | +| LOGIC 法术(基础) | `LOGIC_IF_HP_BELOW`、`LOGIC_EVERY_N_SHOTS`(读写 `SpellContext.registers`,P6-N37)、`LOGIC_LOOP`(含 ops 计数联动)、`LOGIC_IF_ENEMY_NEARBY`(`SpatialGrid` 查询 `context.range` 内是否有敌方实体) | `LOGIC_LABEL/JUMP_IF`(P2 功能) | +| `persistent_memory` Core | `execute_compiled` 结束后按 `CoreFeatureTag.PERSISTENT_MEMORY` 决定是否 `fill(0.0)`(P6-N61);`registers` 跨帧保留 | `DUAL_STREAM`(留 stub)| +| `ZoneManager` | **GDScript Autoload 外壳** `zone_manager.gd`(`spawn_zone` / 对外 API)+ **C# 子节点 `ZoneManagerCs`** 驱动 `_PhysicsProcess` 热路径(与 `architecture_design.md §6.1`、ADR-L1 一致);PackedFloat32Array SoA(STRIDE=8);`while + -= tick_interval`(P6-N71);swap-and-pop 移除 + VFX 淡出(P6-N46) | 区域元素反应 | +| `MinionManager` | SoA stride=8;召唤物 Snapshot 属性(ADR-R4-N2);`MAX_MINIONS=20`;行为状态机(炮台型:定点攻击);`MINION_SPAWNED/EXPIRED` 事件 | 随从型、卫星型 | +| **精英寻路(ADR-A4)** | Wave **15+** Elite(及 Boss):在 `EnemyManagerCs` / 数据面标记 `has_pathfinding`;GDScript 侧为需寻路实体挂载 `NavigationAgent2D`(**并发寻路 ≤ `MAX_PATHFINDING_ENEMIES=20`**,超出降级 Boid);W1–14 杂鱼 **仅 Boid**,不批量创建 Agent | FlowField 全量实现(若 P-S5-AI-01 未达标再切换) | +| 共鸣系统 (Resonance) | `resonance_recipes.json` 加载;adjacent 匹配;`_consumed` 掩码实现(P6-N29);`_check_resonance` 在 `compile_wand` 阶段调用 | anywhere_in_deck O(N²) 路径(留 stub)| +| `CIRCUIT Core`(`circuit_fork`) | 7 槽史诗 Core;`edges` 顶层字段(P6-N42);UI 显示有向图结构 | 可视化连线编辑器 | +| Tier 3 法术 | `nuke`、`homing`、`chain_bolt`、`heavy_cost` | 召唤类法术(`action_summon_turret` 依赖 MinionManager)| +| `action_summon_turret` | 调用 `MinionManager.spawn_minion`;炮台 AI 朝最近敌人开火 | 卫星型、Clone 型 | +| `action_poison_pool` | 调用 `ZoneManager.spawn_zone`(radius=200, status=POISON, duration=5s, tick_interval=1.0)| 反应性区域场 | + +### 关键文件 + +``` +scripts/autoloads/ + zone_manager.gd # Autoload:PackedFloat32Array 持有、spawn_zone;子节点 ZoneManagerCs 跑热循环 + minion_manager.gd + core_feature_tag.gd # Autoload CoreFeatureTag 常量 +csharp/systems/ + ZoneManagerCs.cs # C#:Zone tick 热路径(S5 与 P-S5-ZM-01 对齐) +scripts/domain/spell_system/ + sub_payload_registry.gd # 已有;确认 LOGIC_FORK 执行路径正确 +resources/ + cores/ + matrix_board.tres # MATRIX_2X4 8槽 稀有 + circuit_fork.tres # CIRCUIT 7槽 史诗(含 edges) + wand_memory.tres # LINEAR 6槽 + persistent_memory 史诗 + spells/ + logic_if_hp_below.tres + logic_every_n_shots.tres + logic_loop.tres + logic_if_enemy_nearby.tres + action_nuke.tres + action_chain_bolt.tres + modifier_homing.tres + modifier_heavy_cost.tres + action_summon_turret.tres + action_poison_pool.tres + resonance_recipes.json # plasma_storm 等配方 +``` + +### 验收标准 + +- [x] **P-S5-01**:MATRIX Core 中 `[spark_bolt A] / [damage_plus B]` 垂直对齐 → 编译后 spark_bolt 受 damage_plus 邻接加成;`_flatten_matrix` 不追加 Row B 节点为独立执行(P6-N20)— *实测通过 2026-06-05:`matrix_board` 编译为 `[implicit_adjacency, action_spark_bolt]`(Row B 不独立);spark 伤害 (3+10)=13、单弹;无 Row B 对照组伤害=3* +- [x] **P-S5-02**:CIRCUIT `circuit_fork` Core 执行双路 `[spark_bolt (branch A)] / [fire_bolt (branch B)]` → 一次施法产生两种弹 — *实测通过 2026-06-05:`circuit_fork`(slot0 分叉,edges 0→1/0→2)单次施法产生 spark+fire 共 2 弹;主链扁平化为 1 个 LOGIC_FORK + 2 个分支 SubPayload* +- [x] **P-S5-03**:`LOGIC_EVERY_N_SHOTS` 配置 N=3:第 1/2 次施法跳过后续法术,第 3 次触发;寄存器值在 `wand_memory` Core 下跨帧保留(P6-N37)— *实测通过 2026-06-05:`wand_memory` 4 次施法弹数 `[0,0,1,0]`;`wand_basic`(无 PERSISTENT_MEMORY)对照组 `[0,0,0,0]` 证明持久化由 Feature Tag 门控。`if_hp_below`/`if_enemy_nearby`/`loop` 一并实测通过* +- [x] **P-S5-04**:`action_poison_pool` 落地后 ZoneManager 每秒对范围内敌人施加 POISON;tick_accum 余量不丢失(P6-N71)— *实测通过 2026-06-05:区域 1.0s tick 对范围内实体施加 POISON(false→true);速率限制(0.5s 不触发、累计 1.0s 触发);2.5s 大帧后 tick_accum 余量精确 =0.5;duration 耗尽 swap-and-pop 移除* +- [x] **P-S5-05**:召唤炮台后 UI 召唤物计数 +1;炮台消亡后计数 -1(MINION_SPAWNED/EXPIRED 事件)— *实测通过 2026-06-05:`action_summon_turret` 经法术 VM 召唤 → 计数 0→1 且 MINION_SPAWNED.current_count=1;炮台对范围内最近敌人开火(无敌则不开火);recall → 1→0 且 MINION_EXPIRED.current_count=0;MAX_MINIONS=20 FIFO 上限(召 25 留 20)* +- [x] **P-S5-06**:`[water_wave] + [chain_bolt]` 相邻放置 → 编译时触发 "plasma_storm" 共鸣配方,实际发射等离子风暴法术 — *实测通过 2026-06-05:编译注入 `action_plasma_storm` 于 index 0、`consumed_indices=[1,2]`(_consumed 掩码 P6-N29);运行时仅发等离子风暴弹(伤害 18),水/雷输入被消费不发射;中间夹 ACTION 的对照组不触发共鸣(consumed=[])* +- [x] **P-S5-07**:`_flatten_circuit` 在 3 层嵌套分叉 Core 下无双重执行(`in_branch_payload` 防护正常)— *实测通过 2026-06-05:3 层嵌套分叉(0→1/2、2→3/4、4→5/6)4 个叶节点各执行恰好 1 次(4 弹),重复施法稳定;另测汇聚菱形(0→1/2、1/2→3)汇聚节点经主链恰好 1 次(3 弹),验证 `orig_in_degree` 快照 P6-N63 + 嵌套递归 P6-N66* +- [ ] **P-S5-ZM-01**(压力验收):**`ZoneManagerCs`(C# 子节点)** 驱动 Zone tick:同屏 64 个激活区域 × 1000 敌人,`_PhysicsProcess` 帧时间 < 1ms(Profiler 实测);若超过,启用 LOD 策略(屏外 Zone tick 频率降至 6fps)。**GDScript `zone_manager.gd` 不得在每 Zone 每帧上承载 O(敌人×区) 的内层循环**(与 §6.1 分工一致)。 — *⏸ 顺延:功能路径(GDScript `zone_manager.gd`,含 if 守卫 + 内层 while 速率限制)已实现并通过 P-S5-04;C# `ZoneManagerCs` 压力路径与 S0 起的 C# 热路径迁移一并按 R-08 策略顺延至专门优化轮(与 BulletManagerCs 等同状态)* +- [x] **P-S5-AI-01**(ADR-A4):同屏 **20** 个带完整寻路的 Elite,`NavigationAgent2D` 路径跟随引起的 `EnemyManager`/`Navigation` 相关帧时间增量 **< 0.5ms**;超标则记录 Profiler 并切换 FlowField 方案,回写 `architecture_design.md` ADR-A4 结论段。 — *实测通过 2026-06-05:20 精英 `_update_pathfinding_movement` ≈0.0496ms/帧(10× 余量),定案 NavigationAgent2D(不切 FlowField);ADR-A4 结论段已回写。并发上限 MAX_PATHFINDING_ENEMIES=20 验证(25 精英→20 寻路+5 降级 Boid);reset 释放全部 Agent 节点;无精英时主循环零开销* + +### 技术参考 +- `architecture_design.md` **ADR-A4**(精英寻路、`MAX_PATHFINDING_ENEMIES`、P-S5-AI-01) +- `architecture_design.md §3.4.A`(_flatten_matrix P6-N20、_flatten_circuit P6-N57/63/64/66) +- `architecture_design.md §3.4.C`(共鸣系统、_consumed 掩码 P6-N29) +- `architecture_design.md §8 ADR-R5-N1`(ZoneManager 权威实现 P6-N71) +- `advanced_mechanics_summons_and_environment.md §3.1/3.3`(MinionManager 方案 B) +- `core_wand_design.md §2~3`(MATRIX 邻接表 P6-N13、CIRCUIT edges P6-N42、Feature Tags P6-N61) + +### 出口检查 +> 能否用 CIRCUIT Core 构建双路元素炮台构建并打通 5 波?所有高级 Core 无双执行 / 空指针崩溃?**P-S5-ZM-01** 与 **P-S5-AI-01** 压力测试通过(或 ADR-A4 已更新为 FlowField 且复测通过)?"是"方可进入 S6。 +> +> **状态(2026-06-05)**:✅ 功能验收 P-S5-01~07 + **P-S5-AI-01**(≈0.0496ms,ADR-A4 定案 NavigationAgent2D)全部实测通过,高级 Core 无双执行/崩溃。⏸ **P-S5-ZM-01**(C# `ZoneManagerCs` 压力)按 R-08 与 BulletManagerCs 等 C# 热路径一并顺延至专门优化轮——功能 GDScript 路径已通过 P-S5-04。 +> **集成(2026-06-05 完成)**:S5 系统已接入实际游戏流程并实测可玩—— +> - 商店池 = `SpellRegistry.get_all_ids()`,已含全部 S5 法术(LOGIC / summon / zone / 共鸣输入 water_wave·chain_bolt)。 +> - `combat_manager` 新增 **Core 切换**(商店内「法杖」按钮循环 `wand_basic→fast→memory→matrix_board→circuit_fork`),每 Core 载入演示默认 Deck;`install_spell` 对 MATRIX/CIRCUIT 改为**位置化填空槽**,`_rebuild_wand` 保留 `""` 空槽为 null。HUD/商店显示当前 Core 名。 +> - 实测:5 Core 循环切换均正确编译(记忆法杖 every_3 跨帧 `[0,0,1,0]`、矩阵板邻接、分叉回路双弹);共鸣经商店装配触发;summon/zone 作为首动作或经 `double_cast` 正常产出。 +> - **背包(2026-06-05 完成)**:点选式插槽编辑器(商店「🎒 背包」按钮),拓扑感知插槽网格 + 备牌区,支持插槽交换 / 移入移出备牌 / 即时重编译(详见 S3 出口检查段)。玩家现可在任意 Core 上自由排布法术(含 MATRIX 行 A/B、CIRCUIT 槽位、共鸣相邻)。 +> - **法杖存档(2026-06-05 完成)**:Core/deck/bench 已纳入 `ProfileManager` A/B 双槽(`schema_version` 升至 **2**)。`CombatManager` 经 `ProfileManager.set_wand_provider(self)` 注册,提供 `get_wand_save_data`/`apply_wand_save_data`(字段 `core_id`/`deck`/`bench`);`_collect_run_data` 写入 `wand`,`apply_run` 恢复。实测:自定义矩阵 Deck 存档→擦除→读档完整恢复;v1 旧档(无 wand)经 `_migrate_run` 升 v2 不崩溃保留默认;备牌持久化;A/B 交替写入保持。 +> - 余项(归 S6):真·拖拽手感、CIRCUIT 有向边可视化编辑器、Steam Cloud 批量写(ST-50)。 + +--- + +## S6 — 润色与发布 (Polish & Launch) + +### 目标 +> **"60fps 帧率稳定(含 Profiler 数据)、20 波内容完整、手感丝滑、Endless 排行榜可用。"** + +### 范围(优先级排序) + +**P0 必须完成** +- [x] **完整游戏外壳(启动→主菜单→游戏→暂停→结算→返回)** — *2026-06-05:商业级完整流程闭环。新增 `SceneManager`(Autoload,黑幕淡入淡出转场 + `start_mode` new/continue 路由)、`scenes/ui/splash.tscn`(启动画面:工作室名+游戏标题+版权,1.8s 自动/任意键跳过)、`scenes/ui/main_menu.tscn`(主菜单:新游戏/继续[无存档时禁用]/排行榜/设置/退出 + 语言切换 + 版本号);`main_scene` 改为 `splash.tscn`;`combat_s2.gd` 接入 SceneManager 启动模式(新游戏=`start_game`/继续=`resume_game`)、新增暂停菜单(ESC,`process_mode=ALWAYS` 暂停时仍响应,继续/设置/返回主菜单/退出)、结算屏加「返回主菜单」按钮。全流程实测通过:splash→menu 自动跳转、新游戏/继续读档(恢复 wave=7/gold=250)进战斗、ESC 暂停冻结世界、死亡结算→返回主菜单(死亡删档使继续按钮禁用)。修复 2 bug:转场 Tween 竞争(fade_in_first kill 复位回调致 is_transitioning 卡死)、暂停 process_mode 致 ESC/按钮失效。**待补**:主菜单/splash 美术背景、FTUE 首启难度引导* +- [x] **程序化美术占位(视觉升级)** — *2026-06-05:从纯色几何方块升级为完整 bullet-heaven 视觉。新增 `shaders/bullet_glow.gdshader`(子弹加法混合发光圆:软核+外发光晕)、`shaders/enemy_circle.gdshader`(敌人体积感实体球:软边+左上高光+暗边描线)、`shaders/arena_grid.gdshader`(竞技场暗紫网格地面:细/粗双层网格+径向暗角)应用于两个 MMI + 背景 Polygon2D(z=-100);玩家标记升级为发光菱形(青光晕环+橙菱形+白核);`vfx_manager.gd` `_instantiate_vfx` 改为 `GPUParticles2D`(程序化爆裂粒子:球形发射+颜色渐变淡出+按特效 amount/speed,`one_shot` P6-N72,`play()` 触发 `restart`);`assets/ui/ui_theme.tres` 全局 UI Theme(按钮圆角6px+蓝紫边框+悬停高亮+禁用态,经 `gui/theme/custom` 全局应用所有 Control)。实测:150 弹同心圆弹幕发光叠加、Boss 体积球、网格地面、圆角按钮均渲染正确,0 报错。**全部为程序化生成,放真实素材(`res://audio/sfx/`、精灵 Atlas、VFX 场景)即整体替换,见 handbook/03** +- [ ] 性能验收:确认所有 C# 热路径模块(`BulletManagerCs` / `EnemyManagerCs` / `SpatialGridCs` / `SpellEvaluatorCs` / `StatusManagerCs` / `ZoneManagerCs`)均满足各自帧预算(Profiler 实测);若发现遗漏的 GDScript 瓶颈,按 ADR-L1 规则迁入 C#(`architecture_design.md §6.1`) +- [~] 三档渲染切换逻辑:实现 Area2D → Dirty Sync → MultiMeshInstance2D 渐进切换(`BATCH_DISABLE_PER_FRAME=100`,回滞阈值 `<1500`,`architecture_design.md §4.4`)— *2026-06-05:**直接实现 MultiMeshInstance2D 渲染(§4.4 最高档)**——此前子弹/敌人完全无渲染(仅 HUD+VFX 色块),现 `BulletManager.sync_multimesh`/`EnemyManager.sync_multimesh`(SoA 索引↔instance 索引共享,`visible_instance_count` 控制数量)+ 单位 QuadMesh + 敌人 per-instance 颜色/尺寸(Boss 大且醒目);`combat_s2.gd` 建子弹/敌人两个 MMI + 玩家标记 + 跟随 Camera2D,`_process` 每帧同步。实测渲染正确(红杂兵/品红 Boss/黄子弹)。**Area2D↔MultiMesh 三档切换 N/A**:碰撞自 S1 即为 SpatialGrid 唯一路径(从未建 Area2D),故 MultiMesh 单档覆盖全密度即可,无需 Area2D 档与回滞切换。待补(润色):Dirty Sync 视口剔除、子弹按 type 出图集、C# 化 transform 上传* +- [~] Wave 1~20 完整内容(敌人配置表、Mini Boss Wave 8、Final Boss Wave 20)(`game_design.md §3.3`)— *核心已实现 2026-06-05:`wave_manager.gd` 20 波配置表(W1-5 数值 / W6-9 护甲 / W11-19 弹幕量增 / W15+ 精英寻路);敌人原型 `EnemyManager.Type`(BASIC/FAST/ARMORED/ELITE/MINIBOSS/BOSS)+ 按型速度/护甲;Mini Boss W8(450HP)、Final Boss W20(1800HP)+ 阶段系统(HP 66%/33% 进阶召援军)+ `BOSS_SPAWNED`;Boss 死亡即通关本波。Endless 数值膨胀。实测全部通过。**待补(润色)**:Boss 弹幕攻势(Phase2 走位)、元素抗性/反弹护盾怪、敌人 .tres 资源化、Boss JSON 驱动* +- [ ] 20 张+ 法术卡(含所有 Tier 1/2/3) +- [ ] **至少 7 种 Core** 可玩:`wand_basic`、`wand_fast`、`staff_long`、`matrix_board`、`circuit_fork`、`wand_memory`、`wand_eternal`(名称与数值以 `resources/cores/` 为准;与「5 种」旧表述对齐为 **7 种变体**) +- [x] 死亡流程:结算屏、Run 截图(`get_viewport().get_texture().get_image()`,死亡动画第 1 帧后执行)— *2026-06-05:`combat_s2.gd` 结算屏(到达波次/击杀/用时/评分 + 排行榜 + 「再来一局」);`_save_run_screenshot()` 存 `user://runs/run_*.png`(结算屏非黑屏,P-S6-04 通过)。注:战场在 GAME_OVER 前已被 `_on_player_died` 重置,故截图为结算摘要屏而非死亡瞬间战场* +- [x] Endless 模式 + 本地排行榜(波次主排名、同波次按时间升序;评分公式:`score = wave × 100000 + (86400 - elapsed_sec)`,P6-N19;本地存储于 `user://endless_records.json`)— *2026-06-05:`endless_records.gd`(Autoload `EndlessRecords`)HMAC-SHA256 签名(ST-40)+ 读取验签拒绝篡改(ST-41);排序波次降序/同波次用时升序;`compute_score` 实测 786386 正确。**修正一处真实 bug**:原按对象签名会因 JSON int↔float 往返破坏验签,改为对精确 JSON 字符串签名* + +**P1 完成(时间允许)** +- [ ] `DUAL_STREAM` Core feature 实现(奇数槽中间槽前流专属,P6-N12) +- [ ] `ALWAYS_CAST_LAST`:尾槽为 MODIFIER/TRIGGER/LOGIC 时静默跳过(P6-N7) +- [ ] `SHUFFLE_DECK`:ACTION 原子单元整体乱序(P5-N2) +- [ ] `INFINITE_SPELLS`:配合 `heavy_cost` 的卖血流(P6-N18) +- [ ] 敌人元素弱点 + 抗性 + 催化反应(StatusManager 交互矩阵) +- [x] 音频系统(32 AudioStreamPlayer2D 池、0.1s 节流)— *2026-06-05:`audio_manager.gd`(Autoload `AudioManager`)32 池 round-robin + 每 sound_id 0.1s 节流 + 运行时建 SFX 总线 send→Master(受 SettingsManager 音量);EventBus 挂钩(ENEMY_KILLED/BULLET_HIT/PLAYER_DAMAGED/WAVE_COMPLETE/BOSS_SPAWNED/SHOP_OPENED/LEVEL_UP);无素材时程序化蜂鸣占位(放 `res://audio/sfx/.ogg` 即替换)。实测池/节流/独立 id/未知 id 优雅 no-op/事件触发通过。**待补**:真实音效素材、BGM 分层* +- [ ] 移动端适配(虚拟摇杆映射到 InputMap Action) +- [ ] 完整图鉴系统(法术、Core、共鸣配方发现机制 P5-C2) +- [x] 难度系统:提供初学者 / 标准 / 挑战三档(初学者:敌人 HP ×0.7、伤害 ×0.7;挑战:W20 Boss HP ×1.3、Wave 怪物数 ×1.2);难度选项在首次启动时通过 FTUE 引导选择,后续可在主菜单修改;难度配置持久化到 `user://save_data.json`(`difficulty: int`,0=初学者、1=标准、2=挑战)— *2026-06-05:`settings_manager.gd`(Autoload `SettingsManager`)三档难度 + 乘子(`enemy_hp_mult`/`player_dmg_taken_mult`/`wave_count_mult`/`boss_hp_mult`),接入 `PlayerStats.take_damage`(初学者 20→14 实测)、`WaveManager` 怪物数/血量/Boss 血(挑战 W1 20→24 实测);持久化 `save_data.json`(difficulty/locale/master_volume/ftue_done)。设置面板(难度/语言/音量滑条/清除数据)实测渲染。**FTUE 首启难度引导已完成 2026-06-05**:首次启动(`save_data.json` 无 `ftue_done`)点「新游戏」弹出三档难度选择界面(初学者/标准/挑战,颜色编码绿/蓝/红 + 各档说明文字 + 「设置中可改」提示);选定后 `SettingsManager.complete_ftue()` 设难度+标记+存档→进游戏;二次启动不再引导;ST-03 清除数据后重新触发。实测全通* +- [x] 设置菜单 + 音量(Master 总线)+ 「清除所有本地数据」入口(ST-03)— *2026-06-05:`combat_s2.gd` 设置面板(商店「⚙ 设置」按钮打开);音量 HSlider→`AudioServer` Master 总线;`SettingsManager.clear_all_local_data()` 实测删除 save_data/endless_records/run_a·b/runs 目录并重置* + +**P2 可选高级内容** +- [ ] `LOGIC_LABEL / LOGIC_JUMP_IF`(寄存器条件跳转,仅限 DEBUG Core 解锁) +- [ ] `LogicHandler` 接口(LOGIC 指令集 > 10 条时替换 match 硬编码,P6-N4 规范) +- [ ] `anywhere_in_deck` O(N²) 共鸣扫描(仅传说配方) +- [ ] SpellEvaluator 调试图(EditorPlugin 单步执行,Q2 预案) +- [ ] `ResourceLoader.load_threaded_request` 异步预加载(SpellRegistry P1 路线图) + +### 验收标准 (P0) + +- [~] **P-S6-01**:Wave 1~20 无崩溃完整通关;Mini Boss Wave 8 及 Final Boss Wave 20 AI 正常(`game_design.md §3.3`)— *系统已实现并实测:20 波配置全部正确生成(杂兵/护甲/精英寻路计数核对);W8/W20 Boss 入场(450/1800 HP)+ 三阶段进阶(60%→P2、30%→P3 各召援军)+ `BOSS_SPAWNED`;Boss 死亡通关、普通波清空通关;护甲减伤(ARMORED 20→15)。完整 20 波连续通关压力测试待 S6 后续连跑* +- [x] **P-S6-02**:同屏 500 敌人 + 1500 子弹稳定 60fps(Profiler 实测)— *实测通过 2026-06-05(RTX 2060):**实时 FPS=180**,`_physics_process` 实测帧时间 **0.91ms**;手工逐系统计时(60 reps):Bullet 22.95μs + Enemy 396.97μs + SpatialGrid 0.15μs + Status 0.23μs + Zone 0.18μs + MultiMesh sync 180.10μs = **总计 0.601ms = 16.67ms 预算的 3.6%**。全部 GDScript 回退路径(无 C# 热化),仍有极大裕量* +- [x] **P-S6-03**:Endless 模式可进入;本地排行榜以波次为主排名、同波次按时间升序排(P6-N19 权威公式:在线分 = `wave × 100000 + (86400 - elapsed_sec)`;本地存储于 `user://endless_records.json`)— *实测 2026-06-05:排序 W12/250→W12/300→W8/90→W5/120;评分 786386 正确;HMAC 签名/验签/篡改拒绝通过。注:Endless「继续」入口(W20 后)属玩法接线,待补* +- [x] **P-S6-04**:死亡时截图正确保存到 `user://runs/`(不截到黑屏)— *实测 2026-06-05:`user://runs/run_*.png` 已生成(结算屏内容,非黑屏)* +- [x] **P-S6-05**:所有 P6-N 规范条目已在实际代码中验证(无违反)— *2026-06-05 审计通过(12 条约束)。发现并修复 1 件真实违反:`spell_evaluator.gd:504` `_acquire_ctx()` 在热路径 `execute_compiled`→`_physics_process` 中懒分配 `SpellContext.new()`(违反 P6-N3)。修复:在非热路径的 `compile_wand()` 阶段检测 `PERSISTENT_MEMORY` feature tag 时**预热分配** SpellContext(`_persistent_ctx[0]`),确保后续施法 `_acquire_ctx()` 走 dict.get() 取已有实例,不再触发 `new()`。P-S5-03 回归测试通过 `[0,0,1,0]`。其余 11 条全 PASS(ENEMY_STRIDE、BULLET_STRIDE、StatusID、compile_wand 参数序、zone_manager while+−=、vfx one_shot、PackedByteArray int()、CoreFeatureTag、registers 初始化、EventID 序列、ADR-A2 存档)* +- [~] **P-S6-06**:本地化与 **ADR-A1** 对齐:`translations/` 下至少存在 **`zh_CN.po`、`zh_TW.po`(繁体)、`en.po`、`ja.po`** 四个文件(键集一致;`zh_TW`/`ja` 可先用占位译文,但商店页承诺的语言须在发售前填满);所有 S1–S6 玩家可见字符串均为 `tr("KEY")`(调试 `push_warning`/`push_error` 除外);业务脚本无裸中文字符串(以项目约定之静态检查或人工审计为准)— *2026-06-05 骨架完成:`translations/` 四个 `.po` 全部就位且**键集一致(实测 4 语言 0 缺键)**,全部填了真实译文(非占位);`Locale` Autoload 经 `TranslationServer.add_translation` 加载 + 切换;`combat_s2.gd` HUD/商店/结算/背包**主 UI 字符串已全部 `tr("KEY")` 化**(实测 en/ja/zh_TW 切换正确);商店内加语言切换按钮。**待补**:Core/法术/状态/Boss 的 `display_name`/`description`(现为 `WandPreset`/各 Def 内硬编码,需键化为 `SPELL_*`/`CORE_*`/`STATUS_*` 并补四语);裸中文静态检查脚本* +- [x] **P-S6-07**:内存峰值实测(Wave 20 Boss 战场景):PC 端 RSS < 512MB,对照 `architecture_design.md §4.6` 内存预算表各类别实测均不超标 — *实测通过 2026-06-05(W20 满载:500敌+2000弹+500状态+32 Zone):`MEMORY_STATIC`=105.9MB + 引擎基础≈70MB → **估算 RSS≈175.9MB(34%,目标<512MB ✓)**;热数据(全 SoA+MultiMesh)仅 0.3MB;VRAM=92.6MB 为 GPU 显存不计入 PC RSS。Switch 共享内存(VRAM+RAM≈268MB)低于 SW-11 的 2.5GB 限额,实机测量待 Switch 移植时确认* + +--- + +## 三、时间线估算 (Timeline Estimate) + +> 以"单人 + 2D 美术外包"为基准估算,仅供参考,实际根据团队调整。 + +| 切片 | 状态 | 估算工时 | 主要阻塞风险 | +| :--- | :---: | :--- | :--- | +| **S0 技术骨架** | ✅ | 1~2 周 | R-01/R-03 已缓解(GDScript 回退路径) | +| **S1 最小战斗** | ✅ | 1 周 | — | +| **S2 核心循环** | ✅ | 1~2 周 | — | +| **S3 法术管道** | ✅ | 2~3 周 | — | +| **S4 战斗深度** | ✅ | 1~2 周 | — | +| **S5 高级构建** | ✅* | 3~4 周 | 功能完成(*C# 压力 P-S5-ZM-01 顺延);_flatten_circuit 嵌套分叉已验、MinionManager AI 已验 | +| **S6 润色发布** | ⬜ | 4~6 周 | 内容量(法术/敌人设计);平衡调整 | +| **合计** | — | **~14~20 周** | R-08 C# 热路径迁移若触发则追加 1~2 周 | + +--- + +## 四、跨切片技术约束速查 (Cross-Slice Constraints) + +以下约束贯穿所有切片,违反时导致回归风险,开发时随时核查: + +| 约束 | 来源规范 | 高风险操作 | +| :--- | :--- | :--- | +| 禁止 `DamageContext` / `SpellContext` 在战斗循环中 `new()` | P6-N49、P6-N3 | 任何 `apply_damage` 调用路径 | +| `DamageContextPool.acquire()` 必须先 `ctx.reset()` | P6-N67 | 任何新增 `acquire` 调用 | +| `StatusType.XXX` 枚举不存在,必须用 `StatusID.XXX` | P6-N30 | 新增状态效果代码 | +| `CoreFeatureTag.XXX` 常量,禁止裸字符串 | ADR-R5-N2 | Feature Tag 判断逻辑 | +| `ENEMY_STRIDE=8` 命名常量(禁止裸整数 8) | P6-N54 | EnemyManager 所有 SoA 访问 | +| `BULLET_STRIDE=12` 命名常量(禁止裸整数 12) | `architecture_design.md §4.2` | BulletManager 所有 SoA 访问 | +| `SpellContext.registers` 必须初始化长度 4 | P6-N48 | SpellContext 对象池 reset() | +| `compile_wand(core, raw_deck)` 参数顺序 core 在前 | P6-N70 | SpellEvaluator 入口调用 | +| ZoneManager tick 用 `while + -=`,不用 `if + =0` | P6-N71 | ZoneManager 任何 tick 逻辑 | +| `VFXManager._instantiate_vfx` 必须 `one_shot=true` | P6-N72 | VFX 节点创建路径 | +| `CHARGE_FIRED`(ID=14) 一次性;SpellEvaluator 订阅此而非 `CHARGE_STATE_CHANGED` | P6-N69 | 蓄力法术实现 | +| `SubPayloadRegistry` 仅在关卡进行中只读,背包关闭后重编译 | P6-N10 | 任何支持"战斗中改装备"的功能 | +| Run 存档 **A/B 双槽**、`schema_version`、**`_migrate_run` 链** | ADR-A2 | 单槽覆写、跳过迁移、损坏全丢 | +| `EventID` **1–21** 与 `implementation_plan.md` §2.1 表一致(含 `ACHIEVEMENT_UNLOCKED=18`、`GAME_STATE_CHANGED=20`、`SPELL_DROP_PICKUP=21`);新增事件从 22 起递增并向 §2.1 表登记 | E-N1、certification ST-10、boss_design §6 | 在业务代码中硬编码与表冲突的事件整数 | +| C# 内层循环(`for` / `while` 体内)禁止 `GodotObject.Call()` / `.Set()` | ADR-L1 规则 1 | `BulletManagerCs` / `EnemyManagerCs` 任何新增热路径代码 | +| C# 访问 SoA 热数组必须通过 `PackedFloat32Array.AsSpan()` 获取 `Span` 再做索引 | ADR-L1 规则 2 | 任何新增 C# SoA 读写代码 | +| `PackedByteArray[i]` 赋值必须显式 `int()` 转换 | P6-N27 | `_visible_flags` 相关代码 | +| `ProjectileDef.reset()` 必须重置 `spawn_position` | P6-N41 | BulletManager spawn 路径 | + +--- + +## 五、参考文档导航 (Reference Map) + +| 需要了解 | 首要参考文档 | 章节 | +| :--- | :--- | :--- | +| 局内 Run 存档 A/B 与迁移 | `technical/architecture_design.md` | ADR-A2 | +| Elite / Boss 寻路 | `technical/architecture_design.md` | ADR-A4 | +| 全局架构与层级关系 | `technical/architecture_design.md` | §2~4 | +| SpellEvaluator 执行流 + 预编译 | `technical/architecture_design.md` | §3.4 | +| BulletManager SoA 字段定义 | `technical/architecture_design.md` | §4.2 | +| ZoneManager 完整实现 | `technical/architecture_design.md` | §8 ADR-R5-N1 | +| EventBus 事件目录 | `technical/implementation_plan.md` | §2.1 | +| DamageContext 字段权威 | `technical/implementation_plan.md` | §2.1 (P6-N49) | +| EnemyManager SoA + LOD | `technical/implementation_plan.md` | §2.3.C | +| PlayerManager 移动状态追踪 | `technical/implementation_plan.md` | §2.5.A | +| VFXManager 完整实现 | `technical/implementation_plan.md` | §2.5.D | +| DPS 环形缓冲区 | `technical/implementation_plan.md` | §2.5.E | +| Core 插槽拓扑、Feature Tags | `design/core_wand_design.md` | §1~3 | +| SpellEvaluator 两阶段接口 | `design/core_wand_design.md` | §6 | +| 数值公式(XP/伤害/商店) | `design/numerical_design.md` | §1~3 | +| 法术参数参考表 | `design/numerical_design.md` | §2.2 | +| 状态效果与 DoT | `mechanics/combat_mechanics_depth.md` | §3~4 | +| BulletContext 连锁弹射 | `mechanics/combat_mechanics_depth.md` | §2/5 | +| StatusManager Tick 机制 | `mechanics/combat_mechanics_depth.md` | §3.2.5 | +| COMBO_MARK / VULNERABILITY | `mechanics/consecutive_hits_stacking.md` | 全文 | +| MinionManager / ZoneManager | `mechanics/advanced_mechanics_summons_and_environment.md` | §1/3 | +| StatusID Autoload 规范 | `mechanics/advanced_mechanics_summons_and_environment.md` | §3.2 | +| 运动学 / 物理扩展(P2/P3) | `mechanics/combat_mechanics_extensions_v2.md` | §1~5 | +| 弹道修正器(正弦/环绕) | `mechanics/weapon_system_expansion.md` | §1 | +| 全规范速查表 (P6-N\*) | `README.md` | §关键规范速查 | + diff --git a/docs/technical/architecture_design.md b/docs/technical/architecture_design.md new file mode 100644 index 0000000..5b4ddb8 --- /dev/null +++ b/docs/technical/architecture_design.md @@ -0,0 +1,3421 @@ +# 模块化战术土豆 (Modular Tactical Potato) - 架构设计文档 + +## 1. 概述 (Overview) + +本项目旨在开发一款基于 **Godot 4.x** 的 Roguelite 动作射击游戏。 +核心体验结合了 **Brotato (土豆兄弟)** 的快节奏割草体验与 **Noita** 的深度法术构建系统。 +架构设计的首要目标是 **高性能**(支持同屏大量单位与弹幕)与 **极高的可扩展性**(特别是武器系统的模块化)。 + +### 1.1 设计目标 +1. **超模块化武器系统 (Hyper-Modular Weapon System)**:超越 Noita 的线性构建,引入更灵活的管道流与事件钩子机制。 +2. **高性能战斗引擎**:支持同屏 500+ 敌人,2000+ 弹幕,60FPS 稳定运行。 +3. **数据驱动 (Data-Driven)**:所有游戏内容(法术、属性、波次)完全配表化/JSON化。 + +--- + +## 2. 系统分层架构 (Layered Architecture) + +采用能够严格分离数据与表现的架构模式。虽然 Godot 是节点/组件式的,但在核心战斗层我们将采用 **Manager + Data** 的方式来规避逐节点更新带来的开销。 + +```mermaid +graph TD + Layer1[表现层 (Presentation Layer)] --> Layer2[逻辑层 (Domain/Logic Layer)] + Layer2 --> Layer3[数据层 (Data Layer)] + Layer2 --> Layer4[核心库 (Core Library)] + + subgraph Layer1 + ViewComponents[Godot Nodes (Sprite2D, AnimationPlayer)] + UIManagers[UI System (Control/CanvasLayer)] + Effects[VFXManager + GPUParticles2D Pool] + end + + subgraph Layer2 + CombatMgr[Combat Manager (Main Loop)] + SpellEvaluator[Spell Interpreter (The "CPU")] + EnemyAI[Boid AI System] + GameCycle[Wave & Shop Cycle] + MinionMgr[MinionManager (友军召唤)] + ZoneMgr[ZoneManager (地面效果)] + StatusMgr[StatusManager (状态效果)] + end + + subgraph Layer3 + ConfigMgr[JSON Config Loader] + SaveSystem[Persistent Storage] + Inventory[Player State & Inventory] + end + + subgraph Layer4 + Pool[Object Pool System] + SpatialHash[Spatial Hashing (Collision)] + EventSystem[Global Event Bus] + CoreFeatureTagConst[CoreFeatureTag (特性常量)] + end +``` + +--- + +## 3. 核心子系统:超模块化法术系统 (HMWS) + +这是本项目的技术核心。我们将 Noita 的“魔杖”概念抽象为 **“法术管道 (Spell Pipeline)”**。 + +### 3.1 核心概念差异 + +| 特性 | Noita 原版 | HMWS (本项目) | 改进目的 | +| :--- | :--- | :--- | :--- | +| **执行流** | 线性 (Deck -> Hand -> Discard) | **树状/图状结构 + 事件驱动** | 支持“子母弹”、“条件触发”、“击中后分裂逻辑”的无限嵌套。 | +| **属性计算** | 累加式 (Cast Delay += 0.1) | **管线式 (Pipeline)** | 允许中间件对属性进行乘算、覆写或逻辑重定向。 | +| **载体** | 法杖 (Wand) | **构建核心 (Core)** | 核心决定了插槽拓扑结构(不仅仅是线性数组,可能是矩阵或特定触发槽)。 | + +### 3.2 数据结构设计 (GDScript) + +#### A. 基础单元 (SpellNode) +这是所有"部件"的基类。 +```gdscript +# cast_stats.gd - MODIFIER 节点的修改目标;通过 SpellContext.stats 访问 +class_name CastStats +extends RefCounted + +var damage_add: float = 0.0 # 累加伤害加成(所有 Dmg+ 修正器的总和) +var damage_mult: float = 1.0 # 乘算伤害系数(暴击、元素弱点等乘入) +var projectile_speed: float = 600.0 # 弹道飞行速度(像素/秒) +var spread_angle: float = 0.0 # 散射角度(度,0 = 直线) +var pierce_count: int = 0 # 穿透次数(0 = 首次命中即销毁) +var bounce_count: int = 0 # 弹射次数(0 = 不弹射) +var homing_force: float = 0.0 # 归航强度(0 = 直线,>0 向最近敌人偏转) +var projectile_size: float = 1.0 # 弹道大小系数(影响碰撞半径与贴图缩放) +var range_mult: float = 1.0 # 射程乘算系数(弹道消失前飞行距离倍率) +var crit_chance: float = 0.0 # 本次施法暴击率追加量(0.0~1.0,叠加而非覆盖) + +func reset() -> void: + damage_add = 0.0; damage_mult = 1.0; projectile_speed = 600.0 + spread_angle = 0.0; pierce_count = 0; bounce_count = 0 + homing_force = 0.0; projectile_size = 1.0; range_mult = 1.0; crit_chance = 0.0 + +# spell_context.gd - 使用 RefCounted 避免 GC 压力 +class_name SpellContext +extends RefCounted + +# ⚠️ 注意:禁止持有 Node2D 引用——逻辑层与表现层必须解耦 +# 施法者位置通过 EntityManager.get_position(caster_id) 实时查询 +var caster_id: int = -1 # 施法者实体 ID +var target: Vector2 # 目标点 +var stats: CastStats = CastStats.new() # 必须在声明时初始化;reset() 调用 stats.reset() 清零字段,不重新 new +var payloads: Array[ProjectileDef] = [] # 待发射的弹头定义队列 +var registers: PackedFloat32Array = PackedFloat32Array([0.0, 0.0, 0.0, 0.0]) + # R1, R2, R3, R4 — LOGIC_P2 功能专用 + # ⚠️ 必须声明时初始化为长度 4;PackedFloat32Array() 默认长度 0, + # 直接访问 registers[0] 会触发 out-of-bounds 崩溃。 + # 生命周期:携带 CoreFeatureTag.PERSISTENT_MEMORY 的 Core 跨帧保留此值; + # 非持久 Core 下,帧结束后由 SpellEvaluator 调用 registers.fill(0.0) 清零。 + # 与 CastState.registers 的区别: + # SpellContext.registers → 跨帧持久寄存器(属于 SpellContext 对象) + # CastState.registers → 单次 execute() 内临时工作寄存器,每次重置 + # core_wand_design.md §6 将此字段称为 "memory_bank"(同一字段) +var current_payload: ProjectileDef # 当前正在构建的弹头定义 + +# 池化归还时必须调用 reset(),防止脏数据污染下次施法 +func reset() -> void: + caster_id = -1 + target = Vector2.ZERO + stats.reset() # CastStats 内部清零 + payloads.clear() # 清空弹头队列(不销毁元素,由 ProjectileDef 池负责回收) + current_payload = null + # ⚠️ registers 不在此处清零: + # 持久 Core(PERSISTENT_MEMORY)由 SpellEvaluator 决定是否保留; + # 非持久 Core 由 SpellEvaluator 在帧结束后调用 registers.fill(0.0)。 + +# spell_node.gd - 基类,子类重写 execute() +# 所有系统通过 SpellType 枚举访问节点类型,禁止在业务代码中直接写裸整数(0/1/2/3)。 +enum SpellType { + ACTION = 0, # 产生实际飞行物或即时效果(如 spark_bolt、nuke) + MODIFIER = 1, # 修改下一个 ACTION 的属性(如 damage_plus、homing) + TRIGGER = 2, # 将后续法术打包为 SubPayload,在弹体命中时执行(子母弹核心) + LOGIC = 3, # 条件跳转、循环、寄存器读写(IF_HP_LOW、LOOP 等) + SCOPE_CLOSE = 4 # 虚节点,预编译时自动插入,标记 TRIGGER/LOGIC 作用域边界 + # 不出现在 SpellRegistry 中,玩家不可购买 +} + +class_name SpellNode +extends RefCounted + +var id: String +var type: SpellType = SpellType.ACTION # 业务代码赋值时须使用 SpellType.XXX,禁止写裸整数 +var element_tags: Array[String] = [] # 元素亲和性标签,供共鸣系统(§3.4.C Resonance)进行模式匹配 + # 内容示例:["tag:water"]、["tag:lightning"]、[] + # resonance_recipes.json 中 "pattern" 字段的每个元素 + # 对应本字段中的一个字符串;预编译时 _check_resonance() + # 遍历 deck.nodes,比对 element_tags 中是否包含目标 tag + # 数据来源:SpellRegistry 从 res://resources/spells/*.tres 加载 + +var shape_icon: String = "" # ADR-C1: 无障碍形状标识,与颜色配合供色盲玩家区分元素。 + # 取值约定(UIAtlas 中对应图标键): + # "triangle" → 火系 (Fire) + # "diamond" → 冰系 (Ice) + # "circle" → 水系 (Water) + # "square" → 土系 (Earth) + # "star" → 雷系 (Lightning) + # "cross" → 暗系 (Dark) + # "" → 无元素(纯伤害类) + # 渲染规则:UI 显示元素标签时必须同时绘制颜色背景 + shape_icon; + # VFXManager 在命中 VFX 上叠加 1.5× 字号的 shape_icon TextureRect。 + # 禁止仅用颜色区分元素(ADR-C1 强制要求)。 + +# 核心执行函数:修改 Context 或产生行为,子类必须重写 +func execute(ctx: SpellContext, deck: SpellDeck) -> void: + pass + +# projectile_def.gd - ACTION 节点执行时填充,最终传给 BulletManager 批量生成弹体 +# 通过对象池复用,不触发 GC;SpellContext.payloads 以 Array[ProjectileDef] 形式积累 +class_name ProjectileDef +extends RefCounted + +# 业务代码必须通过 DamageType.PHYSICAL 等常量引用,禁止直接写 0/1/2/3/4。 +# 此枚举也是 BulletManager SoA 中 damage_type 字段、StatusManager DoT 分类的统一来源。 +enum DamageType { + PHYSICAL = 0, # 物理伤害:受护甲(Armor)平铺减免,不受元素抗性影响 + FIRE = 1, # 火焰伤害:受 attunement_fire 加成;可触发点燃/爆燃状态 + ICE = 2, # 冰霜伤害:受 attunement_ice 加成;可触发冻结/减速状态 + LIGHTNING = 3, # 雷电伤害:受 attunement_lightning 加成;可触发麻痹/连锁导电 + POISON = 4 # 毒素伤害:受 attunement_poison 加成;施加 DoT,叠层上限受精通影响 +} +# ───────────────────────────────────────────────────────────────────────────── + +# ── 伤害 ────────────────────────────────────────────────── +var base_damage: float = 5.0 # 计算前基础伤害值(已含 CastStats.damage_add 累积) +var damage_mult: float = 1.0 # 乘算系数(暴击/元素弱点已合入) +var damage_type: int = DamageType.PHYSICAL # DamageType 枚举(见上方定义) + +# ── 运动 ────────────────────────────────────────────────── +var speed: float = 600.0 # 初速度(像素/秒) +var direction: Vector2 = Vector2.RIGHT +var spread_angle_rad: float = 0.0 # 在 direction 基础上的随机偏转幅度 +var homing_force: float = 0.0 # 每帧转向力(0 = 直线) +var acceleration: float = 0.0 # 速度加速度(可为负,做减速球) + +# ── 生命周期 ────────────────────────────────────────────── +var lifetime: float = 3.0 # 最大存活时间(秒) +var pierce_remaining: int = 0 # 剩余穿透次数(0 = 首次命中销毁) +var bounce_remaining: int = 0 # 剩余弹射次数 + +# ── 碰撞体积 ────────────────────────────────────────────── +var radius: float = 6.0 # 碰撞圆半径(像素) +var size_mult: float = 1.0 # 外观与碰撞同步缩放系数 + +# ── 触发器 ──────────────────────────────────────────────── +var on_hit_payload_id: int = -1 # SubPayloadRegistry ID;-1 = 无 TRIGGER +var on_expire_payload_id: int = -1 # 到期/撞墙触发;-1 = 无 +var on_kill_payload_id: int = -1 # 击杀触发;-1 = 无 + +# ── 归属 ────────────────────────────────────────────────── +var owner_id: int = -1 # 根源实体 ID(归击杀、吸血计算) +var source_tags: int = 0 # 位掩码: PRIMARY=1, TRIGGERED=2, SUMMON=4, REFLECTED=8 +var spawn_position: Vector2 # 发射原点(填充时从 caster 位置获取) + +# ── 触发系数 ─────────────────────────────────────────────── +var proc_rate: float = 1.0 # Proc 系数(0.0~1.0)。高频攻击(加特林)应设置为 0.1~0.3, + # 防止高攻速过度触发 on_hit 特效/技能。 + # 命中时实际触发概率 = SpellNode.proc_chance × proc_rate。 + # 默认 1.0(不削减),Tier 1 Action 通常无需修改。 + +func reset() -> void: + base_damage = 5.0; damage_mult = 1.0; damage_type = DamageType.PHYSICAL + speed = 600.0; direction = Vector2.RIGHT; spread_angle_rad = 0.0 + homing_force = 0.0; acceleration = 0.0; lifetime = 3.0 + pierce_remaining = 0; bounce_remaining = 0 + radius = 6.0; size_mult = 1.0 + on_hit_payload_id = -1; on_expire_payload_id = -1; on_kill_payload_id = -1 + owner_id = -1; source_tags = 0; proc_rate = 1.0 + spawn_position = Vector2.ZERO # 必须重置,否则池化复用时携带上帧发射原点 +``` + +#### B. 法术解析器 (The Evaluator) +为了高性能,解析器必须 **低 GC 压力 (Low-GC)**。在施法计算帧,尽量避免分配新对象。 +* 使用预分配的 `SpellContext` 对象池(`Array` 作为栈管理空闲对象)。 +* 使用 `Array`(作为栈)模拟递归,防止深层递归爆栈。 + +#### C. 高级特性:动态插槽与逻辑门 +* **Logic Spells (逻辑法术)**:引入 `IfHPBelow`, `OnKillAction`, `EveryNbShot` 等逻辑块,让玩家实现“如果血量低于30%,则发射吸血导弹”的构建。 +* **Variable Storage (变量存储)**:允许法术在法杖上写入/读取临时变量(例如:记录连击数)。 + +### 3.3 扩展性设计 +所有法术行为通过 **Strategy Pattern (策略模式)** 实现。 +新增一个法术只需: +1. 在 JSON(或 Godot `.tres` Resource)中定义 ID 和贴图路径。 +2. 实现一个继承自 `SpellNode` 的 GDScript 类。 +3. 在注册表中注册。 + +### 3.4 进阶构建机制 (Advanced Mechanics) - 玩法增强 +为了超越“线性堆砌”的枯燥感,架构支持以下三种深度玩法机制。 + +> **MVP 功能边界说明**: +> - **P0(必须实现)**:4类 SpellNode(ACTION / MODIFIER / TRIGGER / LOGIC)+ LINEAR Core。 +> - **P1(迭代加入)**:拓扑插槽系统(MATRIX/CIRCUIT Core)、共鸣系统。 +> - **P2(可选高级内容)**:状态寄存器 + 条件跳转——此功能面向极硬核玩家,仅通过特殊 Core 解锁,**不在新手 UI 中暴露**。 + +#### A. 拓扑插槽系统 (Topology Slots)【P1】 +核心(Core)不再仅仅是一个列表,它可以是一个 **2D 网格** 或 **电路板**。 +* **adjacency_bonus (邻接加成)**:某些插槽有物理连接。例如,将 [火元素] 放在 [高压槽] 旁边,会自动获得 +20% 范围。 +* **Circuit Logic (电路逻辑)**:法术流不再只是从左到右。核心板可以有分叉路口,玩家需要用 [分流器法术] 将能量流引导到不同的分支。 +* 详见 [docs/design/core_wand_design.md](../design/core_wand_design.md)。 + +**拓扑适配层 (Topology Adapter)【A1 解决方案】** + +`SpellEvaluator` 的 while 循环只能处理线性指令流。为此,在 `SpellEvaluator.compile_wand(core, raw_deck)` 预编译阶段引入 **拓扑扁平化 (Topology Flattening)**,将不同 Core 的空间拓扑提前转换为线性指令序列,运行时 while 循环无需感知拓扑: + +| Core 类型 | 扁平化策略 | +| :--- | :--- | +| **LINEAR** | 无需处理,直接输出原始 SpellNode 序列 | +| **MATRIX_2X4** | 扫描邻接槽对(slot[i] 与 slot[i + cols]),若满足 `adjacency_bonus` 条件,在对应 ACTION 前插入隐式 MODIFIER 节点;最终序列仍按行→列顺序线性排列 | +| **CIRCUIT** | 对有向图做 **拓扑排序**,每个分叉点展开为独立 SubPayload 并注册到 SubPayloadRegistry,分叉处插入 `LOGIC_FORK` 指令批量触发;主链保持线性 | + +```gdscript +# spell_evaluator.gd(预编译入口 + 运行时配置) +# compile_wand:拓扑扁平化策略入口,仅在玩家关闭背包/装备 Core 时调用,非热路径。 +var _max_ops: int = 40 # 每次施法的法术节点执行上限(= MAX_OPS_PER_CPU × cpu_limit) + +func update_max_ops(cpu_limit: int) -> void: + # UpgradeSystem.apply_choice() 在被动词条变更 cpu_limit 后调用 + # MAX_OPS_PER_CPU = 8(基准值,SpellEvaluator 常量) + const MAX_OPS_PER_CPU: int = 8 + _max_ops = clamp(MAX_OPS_PER_CPU * cpu_limit, 8, 200) + +func compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck: + match core.topology: + CoreDefinition.LINEAR: + return CompiledDeck.new(raw_deck.nodes) + CoreDefinition.MATRIX_2X4: + return _flatten_matrix(raw_deck, core) + CoreDefinition.CIRCUIT: + return _flatten_circuit(raw_deck, core) + return CompiledDeck.new([]) # 未知拓扑降级为空 Deck,防止 match 无 default 返回 null + +func _flatten_matrix(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck: + # 仅遍历 Row A(i < grid_cols);Row B 槽位法术不作为独立执行节点,仅用于邻接加成注入。 + var out: Array[SpellNode] = [] + var row_a_count: int = core.grid_cols # Row A 的槽数 = grid_cols(如 2×4 矩阵中 = 4) + for i in row_a_count: + var adj_idx: int = i + core.grid_cols # Row B 中与 slot[i] 垂直对齐的槽索引 + # 空槽守卫:deck.nodes[i] 可能为 null(槽为空) + var row_a_node: SpellNode = deck.nodes[i] if i < deck.nodes.size() else null + var row_b_node: SpellNode = deck.nodes[adj_idx] if adj_idx < deck.nodes.size() else null + if row_a_node == null: + continue # 空槽:Row A 无法触发,跳过(Row B 对应槽的邻接加成也无效) + if row_b_node != null and _has_adjacency_bonus(row_a_node, row_b_node): + # 注入隐式 MODIFIER 节点,效果见 core_wand_design.md §2.2 邻接加成效果表 + out.append(_make_adjacency_mod(row_a_node, row_b_node)) + out.append(row_a_node) # 仅追加 Row A 节点到执行序列 + # Row B 节点(row_b_node)不追加 → 不独立执行,仅作为邻接加成来源 + return CompiledDeck.new(out) + +# 对 CIRCUIT 拓扑的有向图做 Kahn 算法拓扑排序,主链保持线性; +# 分叉节点展开为独立 SubPayload 并注册到 SubPayloadRegistry。 +# edges 读自 core.edges(顶层字段);CoreDefinition 须声明 @export var edges: Array = [](见 core_wand_design.md §1) +# 性能说明:仅在玩家关闭背包时触发(非 _physics_process 热路径),slot_count ≤ 16,O(V+E) < 0.1ms。 +func _flatten_circuit(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck: + # 1. 从 core.edges 读取顶层有向边列表;若为空,降级为 LINEAR 处理(见 core_wand_design.md §2.3) + var edges: Array = core.edges + if edges.is_empty(): + push_warning("_flatten_circuit: core.edges 为空,降级为 LINEAR 处理(Core=%s)" % core.id) + return CompiledDeck.new(deck.nodes) + + # 2. Kahn 算法:BFS 拓扑排序(保证无环时的确定性线性化顺序) + var in_degree: Array[int] = [] + var adj: Array = [] # adj[i] = Array[int] 出边目标 + in_degree.resize(core.slot_count) + adj.resize(core.slot_count) + for i in core.slot_count: + in_degree[i] = 0 + adj[i] = [] + for e in edges: + adj[e["from"]].append(e["to"]) + in_degree[e["to"]] += 1 + + # ⚠️ Kahn 循环结束后 in_degree 全归零;_collect_branch_path() 需要原始入度来判断汇聚点 + # (orig_in_degree[nxt] > 1 表示多入边)。在 Kahn 循环前保存副本供分支收集函数使用。 + var orig_in_degree: Array[int] = in_degree.duplicate() # Kahn 前原始入度快照 + + var queue: Array[int] = [] + for i in core.slot_count: + if in_degree[i] == 0: + queue.append(i) + + var topo_order: Array[int] = [] + while not queue.is_empty(): + var cur: int = queue.pop_front() + topo_order.append(cur) + for nxt: int in adj[cur]: + in_degree[nxt] -= 1 # 修改工作副本(orig_in_degree 保留原始值供汇聚点检测) + if in_degree[nxt] == 0: + queue.append(nxt) + + if topo_order.size() != core.slot_count: + push_error("_flatten_circuit: 检测到环路,无法线性化!Core=%s" % core.id) + return CompiledDeck.new([]) # 返回空 Deck,游戏不崩溃但该法杖无法施法 + + # 3. 按拓扑顺序输出节点,出度 > 1 的槽注入 LOGIC_FORK 指令 + var out: Array[SpellNode] = [] + # in_branch_payload 记录已纳入某 SubPayload 的槽索引,防止主链循环将分支节点重复追加执行 + var in_branch_payload: Dictionary = {} + for slot_idx in topo_order: + # 跳过已被分支 SubPayload 收纳的节点,防止双重执行 + if in_branch_payload.has(slot_idx): + continue + var node: SpellNode = deck.nodes[slot_idx] if slot_idx < deck.nodes.size() else null + if node == null: + continue # 空槽跳过 + if adj[slot_idx].size() > 1: + # 分叉检测基于出度(出边数 > 1);"splitter" tag 保留为 UI/编辑器标注用途,不参与执行判断 + + # 分叉槽自身的法术节点先行追加(分叉前执行,通常为空但允许 MODIFIER/ACTION) + out.append(node) + + # 为每条出边收集完整分支路径(含多节点分支,_collect_branch_path 沿单出边延伸直到叶/汇聚点) + var branch_payload_ids: Array[int] = [] + for neighbor_idx in adj[slot_idx]: + var branch_path: Array[SpellNode] = _collect_branch_path( + neighbor_idx, adj, orig_in_degree, deck, in_branch_payload) + # orig_in_degree:Kahn 前快照,确保汇聚点检测正确 + # in_branch_payload:共享引用,函数内登记已访问槽,主链循环可跳过 + if not branch_path.is_empty(): + branch_payload_ids.append(SubPayloadRegistry.register(branch_path)) + if not branch_payload_ids.is_empty(): + var fork_node: SpellNode = SpellNode.new() + fork_node.type = SpellType.LOGIC + fork_node.id = "LOGIC_FORK" + fork_node.set_meta("fork_branch_ids", branch_payload_ids) + out.append(fork_node) + else: + out.append(node) + return CompiledDeck.new(out) + +# 从 start_idx 沿单出边路径收集节点序列,直到: +# 叶节点(出度 0)、汇聚点(orig_in_degree > 1,属于主链或另一分支)、嵌套分叉(出度 > 1,递归处理) +# orig_in_degree:必须传 Kahn 前的原始入度副本,用于汇聚点判断 +# in_branch_payload:共享字典,函数内每访问一个槽即写入,消除主链双重执行 +func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array, + deck: SpellDeck, + in_branch_payload: Dictionary) -> Array[SpellNode]: + var path: Array[SpellNode] = [] + var cur: int = start_idx + var visited: Dictionary = {} + while cur >= 0 and not visited.has(cur): + visited[cur] = true + in_branch_payload[cur] = true # 登记此槽已纳入 SubPayload,主链循环将跳过 + if cur < deck.nodes.size() and deck.nodes[cur] != null: + path.append(deck.nodes[cur]) + + if adj[cur].size() > 1: + # 嵌套分叉:为每条子出边递归收集完整路径并注册 SubPayload, + # 然后将嵌套 LOGIC_FORK 节点插入当前 path。 + var nested_branch_ids: Array[int] = [] + for nxt_idx: int in adj[cur]: + var nested_path: Array[SpellNode] = _collect_branch_path( + nxt_idx, adj, orig_in_degree, deck, in_branch_payload) + if not nested_path.is_empty(): + nested_branch_ids.append(SubPayloadRegistry.register(nested_path)) + if not nested_branch_ids.is_empty(): + var nested_fork: SpellNode = SpellNode.new() + nested_fork.type = SpellType.LOGIC + nested_fork.id = "LOGIC_FORK" + nested_fork.set_meta("fork_branch_ids", nested_branch_ids) + path.append(nested_fork) + break # 嵌套分叉后路径在各子 SubPayload 中独立延伸,本路径就此结束 + + elif adj[cur].size() == 1: + var nxt: int = adj[cur][0] + if orig_in_degree[nxt] <= 1: # 单入边,继续延伸 + cur = nxt + else: + break # 汇聚点(多入边),停止;该节点由主链处理 + + else: + break # 叶节点(出度 0) + return path +``` + +> **E3 解决方案**:MATRIX 邻接加成通过 `_flatten_matrix` 在预编译阶段注入隐式 MODIFIER 节点,SpellEvaluator 运行时不感知 Core 拓扑,邻接触发与普通 MODIFIER 执行路径完全一致。 + +#### B. 状态寄存器与图灵完备 (State Registers & Turing Completeness)【P2 可选】 +> ⚠️ **设计注意**:本功能仅通过特殊 Core(persistent_memory / circuit_fork)解锁,新手不会接触寄存器和跳转指令。 + +为了支持硬核玩家实现真正的“图灵完备”构建,架构预留对**状态存储**、**条件跳转** 和 **循环**的支持。 + +1. **Registers (寄存器)**: + * 在 `SpellContext` 中引入 `MemoryBank`,提供 4 个 Float 寄存器 (`R1`, `R2`, `R3`, `R4`)。 + * 寄存器在同一帧内所有法术间共享,甚至可以跨帧持久化(如果法杖配置了 Persistent Memory 核心)。 + +2. **Instruction Set (指令集法术)**: + * **OPS**: `Add R1, 1` (加法), `Set R2, HP_Percent` (赋值). + * **JUMP**: `JumpIf R1 > 10, Label_A` (条件跳转到标签A). + * **LABEL**: `Label_A` (标记跳转点). + +3. **Recursion Control (递归控制)**: + * 为了防止死循环 (`While(true)`), 解释器引入 `MaxOpLimit` (最大操作数限制,例如 100 ops/frame)。超过限制强制中断并在此帧失效。 + +4. **实战应用**: + * **计数器**: 每射击 3 次,第 4 次发射强力火球。 + * **动态模式切换**: 根据敌人距离 (`R1 = EnemyDistance`),如果近则跳转到 [霰弹逻辑],如果远则跳转到 [狙击逻辑]。 + +#### C. 共鸣系统 (Resonance System)【P1】 +在**预编译阶段 (Pre-compile Phase)** 进行模式匹配。 +* 如果检测到 [水] 和 [电] 法术在执行链中紧邻,架构自动插入一个隐藏的 [导电反应] 中间件。 +* 这允许设计隐藏配方(Hidden Recipes),鼓励玩家探索特定组合。 +* **发现机制**:法术卡片上显示元素亲和性标签(如 ⚡ 雷、💧 水),引导玩家尝试组合,而非完全盲猜。 +* **缓存失效**:玩家在商店修改法术顺序时,自动触发重新预编译,确保共鸣结果与当前 Deck 始终一致。 +* **共鸣配方存储 (Recipe Schema)**:配方存储于 `res://resources/resonance_recipes.json`,结构如下: + ```json + [ + { + "id": "plasma_storm", + "pattern": ["tag:water", "tag:lightning"], + "match": "adjacent", + "result_spell_id": "plasma_storm", + "consume_inputs": true, + "vfx": "resonance_plasma" + } + ] + ``` + - `match` 取值:`"adjacent"`(紧邻)/ `"anywhere_in_deck"`(Deck 内任意位置)。 + - `consume_inputs: true` 表示触发后移除原两张法术,节省插槽。 + - 新增配方只需编辑 JSON,无需修改 SpellEvaluator 代码。 + +**`_check_resonance()` 精确算法**: + +"相邻"的定义:在预编译后的线性执行序列 `CompiledDeck.nodes` 中,两个法术节点的 **index 差 ≤ 2**(允许中间最多夹一个 MODIFIER 节点,因为 MODIFIER 不改变元素属性流向)。 + +```gdscript +# spell_evaluator.gd(在 compile_wand 完成拓扑扁平化之后调用) +func _check_resonance(compiled: CompiledDeck) -> CompiledDeck: + var recipes: Array = _resonance_recipes # 启动时从 JSON 加载,按 priority 排序(高优先级先匹配) + var nodes: Array[SpellNode] = compiled.nodes.duplicate() + var consumed: PackedByteArray = PackedByteArray() + consumed.resize(nodes.size()) # 全 0 初始化 + + # 先处理所有 adjacent 配方 + for recipe in recipes.filter(func(r): return r.get("match") == "adjacent"): + var pattern: Array = recipe.get("pattern", []) # 如 ["tag:water", "tag:lightning"] + if pattern.size() < 2: continue + for i in nodes.size(): + if consumed[i] != 0: continue + if not _node_has_tag(nodes[i], pattern[0]): continue + # 向右扫描 index i+1 至 i+2(跳过 MODIFIER),寻找 pattern[1] + for j in range(i + 1, min(i + 3, nodes.size())): + if consumed[j] != 0: continue + if nodes[j].type == SpellType.MODIFIER: continue # MODIFIER 不参与匹配,但不中断扫描 + if _node_has_tag(nodes[j], pattern[1]): + # 命中:在 i 位置注入共鸣结果节点(替换 nodes[i] 位置),标记双方为已消费 + var result_node: SpellNode = SpellRegistry.get(recipe.get("result_spell_id", "")) + if result_node == null: break + nodes.insert(i, result_node) # 在 i 位置插入共鸣结果 + consumed.insert(i, 0) # 同步扩展 consumed 数组 + if recipe.get("consume_inputs", false): + consumed[i + 1] = 1 # 原 pattern[0] 节点(现 i+1)标记为消费 + consumed[j + 1] = 1 # 原 pattern[1] 节点(现 j+1)标记为消费 + break # 每个 i 只匹配一次,防止多配方叠加在同一节点 + else: + break # 遇到非 MODIFIER 非目标节点,中断内层扫描(不跨过 ACTION/TRIGGER) + # 再处理所有 anywhere_in_deck 配方(O(N²),P1 仅限稀有度 ≥ 3 的传说配方) + for recipe in recipes.filter(func(r): return r.get("match") == "anywhere_in_deck"): + ... # 类似逻辑,但 i、j 不需要相邻约束 + + return CompiledDeck.new(nodes, compiled.label_table, compiled.sub_payload_ids, + compiled.topology_type, compiled.checksum, consumed) + +func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool: + # tag_pattern 格式为 "tag:water",匹配 node.element_tags 中含 "tag:water" 的节点 + if tag_pattern.begins_with("tag:"): + return tag_pattern in node.element_tags + return node.id == tag_pattern # 直接 ID 匹配(精确配方,如 nuke+ignite) +``` + +> **⚠️ `consume_inputs` 运行时实现**:`CompiledDeck.nodes` 在预编译后为**只读**线性序列, +> 不可在运行时直接删除元素(会破坏共鸣检测缓存,影响其他帧的重编译判断)。 +> `consume_inputs: true` 配方的运行时"移除"通过 **消费掩码(Consumed Mask)** 实现: +> ```gdscript +> # spell_deck.gd(运行时执行游标) +> # _consumed: PackedByteArray,长度 = nodes.size(),初始全 0 +> # 预编译阶段在共鸣注入时,将被消费的槽位 index 写入 _consumed_indices 列表。 +> # 运行时 pop() 跳过 _consumed[cursor] != 0 的槽位: +> func pop() -> SpellNode: +> while _cursor < _nodes.size() and _consumed[_cursor] != 0: +> _cursor += 1 # 跳过已消费槽位 +> if _cursor >= _nodes.size(): +> return null +> var node := _nodes[_cursor] +> _cursor += 1 +> return node +> ``` +> **消费掩码初始化**:`SpellEvaluator.compile_wand()` 完成共鸣注入后,对每个 `consume_inputs: true` 配方, +> 将参与配方的两个原始节点的 index 写入 `_consumed`(值为 1),同时在对应位置插入共鸣结果节点。 +> `_consumed` 是 `SpellDeck`(运行时对象)的字段,`CompiledDeck`(只读预编译缓存)不含此字段, +> 每次从 CompiledDeck 构造 SpellDeck 时重新初始化(避免消费状态跨轮次残留)。 + +> **R4-C1 规范(触发范围权威定义)**: +> - `"adjacent"`:**标准触发范围**,绝大多数配方使用此选项(如"水+雷=等离子风暴")。 +> `game_design.md §3.3.C` 所述"执行链中直接相邻"即对应此选项。 +> - `"anywhere_in_deck"`:**跨距触发**,仅用于极少数传说级全局共鸣配方, +> 预编译时对整个 CompiledDeck 做 O(N²) 全局扫描,且**自动标记为稀有以上配方**。 +> 新增此类配方时需在 JSON 中追加 `"rarity": 3`(传说)以防止轻易触发。 +> - 两者不冲突,同一配方文件中可同时存在不同 `match` 值的条目,SpellEvaluator 在预编译阶段 +> 先处理所有 `adjacent` 配方,再统一处理 `anywhere_in_deck` 配方。 + +--- + +## 4. 高性能战斗架构 (High-Performance Combat Architecture) + +为了实现“同屏 2000+ 弹幕”和“复杂逻辑构建”的双重目标,本架构采用 **Data-Oriented (面向数据)** 与 **Hybrid-ECS** 相结合的策略,最大化 CPU 缓存命中率并消除 GC 压力。 + +### 4.1 核心原则:低 GC (Low-GC Principle) +在核心战斗循环 (Game Loop) 中,尽量避免频繁实例化新对象。 +* **Context Pooling**: `SpellContext` 等高频对象在关卡加载时预分配并放入 `Array` 池,使用时复用,用完调用 `reset()` 方法归还。 + * **预分配大小**:`SPELL_CONTEXT_POOL_SIZE = 32`。依据:单帧最坏情况 ≈ MAX_BULLETS(2000) × 平均 proc_rate(0.05) × 命中概率(0.3) ≈ 30 次 execute_sub 并发触发,32 个实例可覆盖此场景且无 GC 分配。若 `pool.pop_back()` 返回 null(池耗尽),额外实例化 1 个并发出 `push_warning("SpellContextPool: exhausted")`,不崩溃。**⚠️ 溢出实例归还规范**:溢出实例使用完毕后须调用 `pool.push_back(ctx)` 归还;若归还时 `pool.size() >= SPELL_CONTEXT_POOL_SIZE`,直接丢弃(GC 处理权交还 Godot),不超量囤积。禁止遗弃,避免 Endless 模式长局游离实例积累导致 GC 压力持续上升。 +* **Static Temporaries**: GDScript 可在模块顶层声明静态变量 (`static var _tmp_vec2: Vector2`),避免向量计算产生中间对象。 +* **PackedFloat32Array (SoA)**: 子弹的热数据(位置、速度)存储在 `PackedFloat32Array` 中,保证内存连续性,提升缓存命中率。 + +### 4.2 实体管理:ECS-Lite +虽然 Godot 是基于节点/组件的,但在海量单位管理上,我们将剥离节点的逐帧逻辑。 +* **Manager-Based Logic**: 子弹和敌人的 `Node2D` 节点**不运行自身的 `_process`**,逻辑全部由中央 Manager(Autoload 单例)统一驱动。 + * 也就是:`Node2D` 仅仅作为渲染容器,负责同步 `position` 和播放动画。 +* **Centralized Loop (中央循环)**: + * `BulletManager` 维护一个紧凑的 `PackedFloat32Array`(SoA 布局)。 + 每颗子弹占用 **BULLET_STRIDE = 12** 个 float,在数组中偏移量 = `bullet_id × BULLET_STRIDE`: + + | 偏移 | 字段 | 说明 | + | :--- | :--- | :--- | + | +0 | `x` | 世界坐标 X(像素) | + | +1 | `y` | 世界坐标 Y(像素) | + | +2 | `vx` | 速度 X(像素/秒) | + | +3 | `vy` | 速度 Y(像素/秒) | + | +4 | `lifetime` | 剩余存活时间(秒),归零时销毁 | + | +5 | `radius` | 碰撞圆半径(像素) | + | +6 | `base_damage` | 命中基础伤害值 | + | +7 | `damage_mult` | 伤害乘算系数 | + | +8 | `damage_type` | DamageType 整数(DamageType 枚举,0=PHYSICAL…4=POISON) | + | +9 | `owner_id` | 根源实体 ID(float 存 int,精度足够 24-bit ID) | + | +10 | `source_tags` | 位掩码(PRIMARY/TRIGGERED/SUMMON/REFLECTED) | + | +11 | `acceleration` | 速度加速度(像素/秒²,0=匀速) | + + > **冷数据(非热路径)** 存储于 `_bullet_contexts: Dictionary`(key=bullet_id),包含: + > `pierce_remaining`, `bounce_remaining`, `homing_force`, `on_hit_payload_id`, + > `on_expire_payload_id`, `on_kill_payload_id`, `proc_rate`, `visited_targets`。 + > 正弦弹道等额外字段(`wave_frequency`, `wave_amplitude`)也存于此 Dictionary, + > 不占用 SoA 热数组空间,仅在命中/特殊弹道帧查询。 + > + > **职责分离**:`ProjectileDef` 是**生成时配置模板**,在 `SpellEvaluator.execute` 阶段由 ACTION 节点填充; + > `_bullet_contexts[bullet_id]` 是**运行时可变冷状态**,由 `BulletManager.spawn(def)` 从 `ProjectileDef` 拷贝创建。 + > 两者字段名相同但生命周期不同:`ProjectileDef` 可安全复用(对象池 reset); + > `_bullet_contexts[id]` 随子弹销毁时一同移除(`_bullet_contexts.erase(bullet_id)`)。 + * 在 `_physics_process(delta)` 中,直接遍历 Array 进行物理积分,速度比遍历 Node Tree 快一个数量级。 + * **Dirty Sync**: 仅当物体在屏幕视口内,且逻辑坐标发生位移时,才去同步 `Node2D.position`。 + * **Homing 批量查询优化**:若 `homing_force > 0` 的子弹数量为 N,每颗独立调用 `SpatialGrid.find_nearest` 将产生 N 次散列查询(N=500 时每帧 500 次)。**解决方案**:在 `_physics_process` 开头建立敌人位置快照,所有 homing 子弹共用: + ```gdscript + # ⚠️ _enemy_pos_snapshot 必须声明为 BulletManager 类成员(预分配复用), + # 禁止在 _physics_process 中用 var 声明(每帧分配新 PackedVector2Array,产生 GC 压力)。 + # 类成员声明(BulletManager 顶部): + # var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array() + # + # BulletManager._physics_process 开头,O(M),M = 存活敌人数量 + EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot) + # fill_pos_snapshot() 将所有存活敌人 [x, y] 写入传入的 PackedVector2Array(复用已分配内存,不触发 GC) + # 对比旧写法:var _enemy_pos_cache = EnemyManager.get_pos_snapshot() 每帧 new 一个新数组 + + # homing 计算:从快照线性扫描,比散列查询更 cache-friendly + for idx in _homing_indices: # _homing_indices: PackedInt32Array,本帧 homing 子弹列表 + var bx: float = _data[idx * BULLET_STRIDE]; var by: float = _data[idx * BULLET_STRIDE + 1] + var nearest := _find_nearest_pos(bx, by, _enemy_pos_snapshot) # O(M) 线性扫描快照 + # 计算转向力并更新 vx/vy… + ``` + M=1000 敌人、N=500 homing 子弹时,总计 500K 次向量运算,C# 热路径约 0.2ms/帧,可接受。 + + * **EnemyManager 对外查询接口**:以下函数供 BulletManager、DropManager、BossManager、AudioManager 调用,均为 GDScript 侧接口(轻量读操作,非热路径): + + ```gdscript + # enemy_manager.gd — 对外查询接口(热路径内不调用,仅事件驱动 / 轮询路径使用) + + func fill_pos_snapshot(out: PackedVector2Array) -> void: + # BulletManager homing 共用快照;复用传入数组内存(P6-N25) + out.resize(_active_count) + for i in _active_count: + out[i] = Vector2(_data[i * ENEMY_STRIDE], _data[i * ENEMY_STRIDE + 1]) + + func get_last_position(entity_id: int) -> Vector2: + # DropManager 在 ENEMY_KILLED 事件中查询死亡位置(EnemyManagerCs 死亡时缓存到字典) + return _death_position_cache.get(entity_id, Vector2.ZERO) + + func get_type(entity_id: int) -> String: + # DropManager / BossManager 查询敌人类型 ID(对应掉落表 key / boss_config key) + return _entity_type_map.get(entity_id, "") # { entity_id: String },spawn 时写入 + + func get_hp_percent(entity_id: int) -> float: + # BossManager 多阶段 HP 切换使用;entity_id 存在则返回 [0.0, 1.0],否则返回 0.0 + var idx: int = _entity_index_map.get(entity_id, -1) + if idx < 0: return 0.0 + var hp := _data[idx * ENEMY_STRIDE + 4] # slot +4: hp(权威来源 implementation_plan §2.3.C) + var hp_max := _data[idx * ENEMY_STRIDE + 5] # slot +5: hp_max + return hp / max(hp_max, 0.001) + + func get_visible_count() -> int: + # AudioManager 动态音乐层轮询;返回当前在 Camera AABB 内的存活敌人数 + return _visible_count # EnemyManagerCs 的 LOD 循环维护此计数,O(1) 读取 + + func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: + # BulletManager.get_nearest_enemy_pos() 调用;线性扫描 _enemy_pos_snapshot,O(M) + # 与 homing 子弹同一快照,无额外查询开销;仅在发射时调用,非每帧热路径 + var best_pos := Vector2.ZERO + var best_dist := max_dist * max_dist # 比较平方距离,避免 sqrt + for i in range(_enemy_count): + if _visible_flags[i] == 0: continue # 跳过屏外(可选:也可全扫) + var px := _data[i * ENEMY_STRIDE + 0] + var py := _data[i * ENEMY_STRIDE + 1] + var d2 := (px - origin.x) * (px - origin.x) + (py - origin.y) * (py - origin.y) + if d2 < best_dist: + best_dist = d2 + best_pos = Vector2(px, py) + return best_pos + ``` + + > **EnemyManager SoA 完整槽位布局**(权威来源:`implementation_plan.md §2.3.C`,P6-N45): + > ``` + > ENEMY_STRIDE = 8 + > [ x, y, vx, vy, hp, hp_max, faction_and_type, status_bits ] + > 0 1 2 3 4 5 6 7 + > ``` + > - `+4 hp`:当前生命值;`+5 hp_max`:最大生命值(BossManager 的 `get_hp_percent` 读取此两槽) + > - `+6 faction_and_type`:高 16 位 = faction(0=敌方, 1=友方);低 16 位 = enemy_type_id + > - `+7 status_bits`:燃烧/冰冻/中毒等位掩码(精确 DoT 计时存 `_enemy_contexts` 冷数据) + > - `_death_position_cache`、`_entity_type_map`、`_entity_index_map` 为类成员 `Dictionary`,spawn/kill 时 GDScript 维护。 + + * **自动瞄准目标选取算法**:法杖自动开火时,发射方向需要选取"目标敌人"。目标选取逻辑集中在 `PlayerManager.get_aim_target()` 中,优先级如下: + 1. **手柄/鼠标显式瞄准**:若存在显式输入方向(`aim_vector.length() > 0.3`),直接使用该方向,不进行目标锁定。 + 2. **最近敌人(默认)**:从 `_enemy_pos_snapshot` 线性扫描,取欧氏距离最小的存活敌人位置,作为开火方向。时间复杂度 O(M),与 Homing 快照共用,无额外查询。 + 3. **自定义优先级扩展(P2)**:商店购买"目标优先级"被动时(如"优先最低 HP"、"优先最近"),`PlayerManager.aim_priority` 属性切换选取算法,但当前 P0/P1 阶段固定为最近敌人。 + ```gdscript + # player_manager.gd + func get_aim_direction() -> Vector2: + var aim := Input.get_vector("aim_left", "aim_right", "aim_up", "aim_down") + if aim.length() > 0.3: + return aim.normalized() # 手柄/鼠标显式瞄准优先 + # 自动瞄准:查最近敌人快照 + var nearest_pos := BulletManager.get_nearest_enemy_pos(get_position()) + if nearest_pos == Vector2.INF: + return Vector2.RIGHT # 无敌人时向右发射(避免零向量) + return (nearest_pos - get_position()).normalized() + ``` + +### 4.3 物理与碰撞机制 +* **Area2D (优先方案)**: 子弹使用 `Area2D` + `CollisionShape2D`,通过 `body_entered` 信号回调处理命中。Godot 的 Area2D 属于轻量级重叠检测,无刚体动力学开销。 + * 利用 **Physics Layer & Mask** 系统过滤阵营(子弹层只与敌人层交互),避免无效检测。 +* **Spatial Hashing Fallback**: 若 Area2D 在 2000+ 弹幕下仍有压力,回退到定制的 **Spatial Grid** (一维数组网格),只计算临近 Grid 的实体碰撞,确保碰撞检测复杂度维持在 O(N)。 + * **Separation Logic**: 怪物挤压不使用刚体求解,而是施加简单的轻量级斥力向量。 + * **Cell Size 定义**: 默认格子边长 = `max_collider_radius × 2`(如最大子弹半径 20px → Cell = 40px)。格子过小增加哈希桶压力,格子过大降低空间剪裁效果。推荐随关卡配置可调。 + * **超大弹体豁免策略**:`nuke` 等特殊弹体半径可达 300px,若将 Cell Size 设为 600px 会使整个 SpatialGrid 退化为单格全量扫描。 + 解决方案:SpatialGrid 的 `max_collider_radius` 仅取**标准弹体**上限(如 24px → Cell = 48px); + `radius > LARGE_PROJECTILE_THRESHOLD`(默认 = 64px)的弹体**绕过 SpatialGrid**,改用 `EnemyManager.query_aabb(bullet_aabb)` 进行全量矩形测试—— + 此类超大弹体数量通常极少(≤ 5 颗/帧),全量扫描代价可接受;同时 SpatialGrid 精度不受破坏。 + `BulletManager` 在 spawn 时按 radius 自动路由到正确的碰撞路径,不需要业务层感知区别。 + * **运行时切换策略**: `BulletManager` 维护 `_collision_mode: int`(`0` = Area2D,`1` = SpatialGrid)。 + * 上穿阈值(`_active_count > 2000 and _collision_mode == 0`):**分帧渐进禁用** Area2D(每帧最多关闭 `BATCH_DISABLE_PER_FRAME = 100` 个节点,约需 20 帧完成 2000 节点的切换,避免单帧卡顿);过渡期内新生成子弹直接走 SpatialGrid 路径;所有 Area2D 禁用完毕后正式激活 MultiMesh 渲染。 + * 下穿回滞(`_active_count < 1500 and _collision_mode == 1`):反向切回,避免阈值附近频繁震荡。**⚠️ 反向切换 Jitter 风险**:从 SpatialGrid 切回 Area2D 时,约 1500 颗子弹需要重新启用 Node(`process_mode = INHERIT`)。若全部在单帧完成,将产生约 1500 次节点状态更改(额外 1~2ms,可见微卡顿)。**缓解策略**:反向切换同样须分帧渐进(`BATCH_ENABLE_PER_FRAME = 100`,约 15 帧完成),过渡期内存活子弹继续走 SpatialGrid 路径,碰撞正确性不受影响。 + * 两段路径(信号回调 vs 手动查询)最终均调用同一 `_on_bullet_hit(bullet_id, enemy_id)` 函数,后处理逻辑完全共用。 + +### 4.4 渲染优化 + +子弹渲染策略依据同屏数量分三档,**Area2D 与 MultiMeshInstance2D 不共存**——MultiMesh 绕过节点树,意味着子弹无法挂载 Area2D。因此两者适用于不同的弹幕规模区间: + +| 同屏弹幕数 | 渲染方案 | 碰撞方案 | 说明 | +| :--- | :--- | :--- | :--- | +| < 200 | Node Pool (Node2D) | Area2D | 全功能,支持粒子特效、信号回调 | +| 200–2000 | Node Pool + Dirty Sync | Area2D | 仅视口内节点同步 position | +| 2000+ | MultiMeshInstance2D | 自定义 SpatialGrid | 完全绕过节点树,牺牲特效换取帧率 | + +* **Node Pooling**: 严格的节点池管理(使用 Array 存储空闲节点,Node.process_mode = DISABLED 代替真实销毁)。 +* **MultiMeshInstance2D**: 仅在同屏弹幕超过 2000 且切换为 SpatialGrid 碰撞时启用。启用后 BulletManager 不再为每颗子弹维护 Node 实例,改为直接更新 `MultiMesh.transform_array`。 + + **SoA 索引 ↔ MultiMesh transform_array 索引映射规则**: + + BulletManager 的 `_data: PackedFloat32Array` 使用 **紧凑活跃列表(Compact Active List)** 布局:索引 0 到 `_active_count - 1` 始终是存活子弹,dead-and-removed 子弹用 swap-and-pop 填入空位。MultiMesh 的 `instance_count` 始终等于 `_active_count`,两者**共享同一索引**——SoA 中第 i 个子弹对应 MultiMesh 第 i 个实例变换。 + + ```gdscript + # BulletManagerCs.cs — _PhysicsProcess 末尾(完成 SoA 积分后) + # 将所有活跃子弹的位置同步到 MultiMesh.transform_array(批量上传) + private void SyncMultiMesh() + { + // _multiMesh.InstanceCount 由 GDScript 在 _active_count 变化时更新 + // 这里只做 transform_array 的批量写入,不触发单实例 set_instance_transform_2d() + var transforms = new float[_activeCount * 8]; // Transform2D = 6 float + 2 位置 = Godot 内部格式 8 float + var span = _data.AsSpan(); + for (int i = 0; i < _activeCount; i++) + { + int src = i * BulletStride; + int dst = i * 8; + // Transform2D(1,0, 0,1, px, py) — 纯平移,无旋转(子弹朝向由 Sprite 贴图决定) + transforms[dst] = 1f; transforms[dst + 1] = 0f; // col0 + transforms[dst + 2] = 0f; transforms[dst + 3] = 1f; // col1 + transforms[dst + 4] = span[src]; // px + transforms[dst + 5] = span[src + 1]; // py + // Godot MultiMesh 格式:8 floats per instance(含 custom_data,后 2 float 可复用为 type/size) + transforms[dst + 6] = span[src + 10]; // bullet_type(用于 Shader 切换 UV atlas 区域) + transforms[dst + 7] = span[src + 9]; // size_mult(用于 Shader 缩放实例) + } + _multiMesh.SetBuffer(transforms); // 单次批量上传,1 次 Draw Call + } + ``` + + **子弹类型与 MultiMesh Shader**:MultiMesh 使用单一材质 + Atlas Texture,`transform_array[i * 8 + 6]`(bullet_type 整数)作为 Shader 参数,在 Canvas Shader 中做 `UV = atlas_uv_for_type(bullet_type)`,支持不同外观子弹共用一次 Draw Call。 +* **Throttling (分帧降频)**: + * 伤害数字:每帧最多弹出 10 个,多余的合并或延迟显示。 + * AI 索敌:不需要每帧执行 FindNearest,可分散到 10~20 帧内轮询一次(利用 Engine.get_physics_frames() % 15 == entity_id % 15 错峰轮询)。 + +### 4.5 帧预算分配表 (Frame Budget Allocation) + +> **目标**:60fps → 单帧总预算 **16.67ms**。以下为 S0 性能验收的基准分配;S0 Profiler 实测后需将"S0 实测值"列回填并提交 git,作为后续切片的性能基线快照。 + +| 系统 | 语言 | 预算上限 | S0 实测值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | +| `BulletManagerCs._PhysicsProcess` | C# | 2.0ms | **0.37ms** | 2000 子弹 GDScript SoA 积分(P-S0-01;C# 热路径 S1 填充后预计更低) | +| `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshot(P-S0-02;Boid C# S1 填充) | +| `SpatialGridCs` 重建 + `query_circle` | C# | 1.5ms | **< 0.001ms** | GDScript stub(返回空数组);C# SpatialGridCs 实例化后 S1 回填实测 | +| `SpellEvaluatorCs.execute_compiled` | C# | 1.0ms | — | 高频施法内层 while 循环(S1 起填入)| +| `StatusManagerCs._PhysicsProcess` | C# | 1.5ms | — | 200+ DoT 逐 tick(S4 验收后填入,P-S4-02;与 implementation_plan §2.4 统一)| +| `ZoneManagerCs._PhysicsProcess` | C# | 1.0ms | — | 64 zones × SpatialGrid 查询(S5 验收后填入)| +| GDScript 游戏循环(WaveManager / EventBus dispatch)| GDScript | 1.5ms | — | 事件分发 + 波次状态机 | +| MultiMesh transform 上传 CPU→GPU | Godot 渲染器 | 1.5ms | — | 2000+ 子弹时(S6 验收后填入)| +| Godot 渲染器 Draw Call 基线 | 引擎 | 2.0ms | — | 背景 + 角色 + HUD(S1 场景建立后填入)| +| **保留余量**(突发帧:GC、资源加载)| — | **1.17ms** | — | — | +| **合计** | | **16.67ms** | **≈ 0.60ms**(S0 GDScript 基线,2000弹+1000敌;FPS 180)| | + +> **S0 实测摘要(2026-06-04)**:机器 RTX 2060;Godot 4.6.2 stable mono;Windows 10。2000 子弹 + 1000 敌人同屏,GDScript fallback 运行(C# 热路径骨架未激活)。`physics_frame_time_msec`(Godot Monitor)= 0.05~0.13ms,FPS 稳定 145~180,远超 60fps 目标。R-01/R-03 风险**已消除**。SpatialGrid C# 实例化与 AsSpan() 零拷贝基准在 S1 阶段随 BulletManagerCs 真实热路径一同验收(P-S0-06/P-S0-07 S1 延续项)。 + +**验收规则**: +- **S0 联合基准**:`BulletManagerCs` + `EnemyManagerCs` + `SpatialGridCs` 三项合计实测 **< 6ms**(覆盖 P-S0-01/02/04)。 +- **超标触发流程**:若任一系统实测超出预算上限,**该切片不得进入下一切片**;立即检查是否违反 ADR-L1 规则 1(内层循环 `Call()`)或规则 2(未使用 `AsSpan()` 零拷贝)。 +- **回填规范**:每个新切片的验收标准中对该切片新增系统补充帧时间目标;S6 发布前所有"—"必须替换为实测值。 + +--- + +### 4.6 内存预算表 (Memory Budget) + +> **目标**:确保在目标平台(PC 和未来 Nintendo Switch)的内存占用在可接受范围内。Switch 主内存总量 4GB,系统 + 驱动常驻约 1.5GB,游戏可用约 **2.5GB**;PC 目标 RSS < **512MB**。 + +| 内存类别 | PC 预算 | Switch 预算 | 说明 | +| :--- | :--- | :--- | :--- | +| 代码 + GDScript VM + C# CLR | 80MB | 200MB | Mono 运行时常驻较大 | +| 纹理资源(Atlas + VFX + UI) | 150MB | 400MB | 压缩格式:PC=DXT5,Switch=ASTC4×4 | +| 音频资源(BGM 流式 + SFX 预加载)| 30MB | 80MB | BGM 流式播放,SFX 池全量预加载 | +| BulletManager SoA(2000 子弹) | < 1MB | < 1MB | `BULLET_STRIDE=12` × 2000 × 4B ≈ 96KB | +| EnemyManager SoA(1000 敌人) | < 1MB | < 1MB | `ENEMY_STRIDE=8` × 1000 × 4B ≈ 32KB | +| SpellContext 对象池(32 实例) | < 1MB | < 1MB | 每实例约 2KB(含 payloads Array) | +| Node Pool(子弹节点 2000 个)| 20MB | 50MB | 每 Node2D 约 10KB;MultiMesh 激活后降至 ~0 | +| VFX 粒子节点池(200 个)| 5MB | 10MB | MAX_ACTIVE_VFX=200 | +| DPS 环形缓冲区 + 其他运行时 | < 1MB | < 1MB | 各 PackedFloat 数组合计 | +| 存档文件(user://) | < 1MB | < 1MB | JSON 明文 < 100KB;含 HMAC 签名 | +| **合计(估算)** | **~290MB** | **~745MB** | Switch 远低于 2.5GB 限制 | + +**验收规则**: +- **S6 P0 验收 P-S6-07**:Godot Profiler → Memory 标签页实测 RSS:PC < 512MB,Switch 目标 < 2.5GB。 +- **热场景峰值**:Wave 20 Boss 战(最大同屏实体数)测量内存峰值,作为 S6 发布基线。 +- **纹理内存监控**:`RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TEXTURE_MEM_USED)` 不超过 PC 预算的 150MB。 + +--- + +## 5. 游戏循环与系统集成 (Game Loop & System Integration) + +### 5.1 GameCycleManager 状态机 (FSM) + +`GameCycleManager` 是游戏最顶层的协调者,持有全局状态机并驱动各 Manager 的生命周期。 + +```gdscript +# game_cycle_manager.gd (Autoload: GameCycleManager) +enum GameState { + MAIN_MENU = 0, # 主菜单(初始/死亡后/通关后回到此状态) + LOADING = 1, # 场景加载过渡(ResourceLoader 异步加载战斗场景) + SHOP = 2, # 商店/背包阶段(波次间) + WAVE_INTRO = 3, # 波次开场动画(约 1.5s,Boss 波有特殊演出) + WAVE = 4, # 战斗进行中 + WAVE_RESULT = 5, # 本波结算(经验弹算、升级选择 UI) + GAME_OVER = 6, # 死亡结算界面 + GAME_CLEARED = 7, # 通关结算(Wave 20 Boss 击杀后) + PAUSE = 8, # 暂停(可从 WAVE/SHOP 进入,恢复后返回原状态) +} + +var _state: GameState = GameState.MAIN_MENU +var _prev_state: GameState = GameState.MAIN_MENU # 用于 PAUSE 恢复 +var wave_num: int = 0 # 当前波次(UIManager / WaveManager 均读取) +var _rng: RandomNumberGenerator = RandomNumberGenerator.new() +# _rng 在每次 Run 开始时重置:_rng.seed = hash(Time.get_ticks_msec()) +# UpgradeSystem.build_choices(wave_num, _rng) 与 ShopManager 独立使用各自 seed,互不干扰 + +func transition_to(next: GameState) -> void: + _on_exit(_state) + _prev_state = _state + _state = next + _on_enter(next) + +func get_state() -> GameState: return _state +``` + +#### 状态转换表 + +| 当前状态 | 触发条件 | 目标状态 | 关键副作用 | +| :--- | :--- | :--- | :--- | +| `MAIN_MENU` | 玩家点击"开始游戏" | `LOADING` | 异步加载 BattleScene.tscn | +| `LOADING` | 场景加载完成信号 | `SHOP` | wave_num=1;调用 ShopManager.open_shop(wave_num) | +| `SHOP` | 玩家点击"出发" | `WAVE_INTRO` | PlayerManager.compile_all_wands();WaveManager.prepare_wave(wave_num) | +| `WAVE_INTRO` | 开场动画播放完毕(1.5s Timer) | `WAVE` | WaveManager.start_wave();BulletManager.reset();EnemyManager.reset() | +| `WAVE` | `EventID.WAVE_COMPLETE` 收到 | `WAVE_RESULT` | 经验结算;存档(ADR-A2 §2 波次结束自动存档) | +| `WAVE` | `EventID.PLAYER_DIED` 收到 | `GAME_OVER` | 统计数据收集;EndlessRecordsManager 不更新 | +| `WAVE_RESULT` | wave_num < 20,玩家确认升级 | `SHOP` | wave_num++;ShopManager.restock(wave_num) | +| `WAVE_RESULT` | wave_num == 20 且 Boss 已击杀 | `GAME_CLEARED` | EndlessRecordsManager 更新;Steam成就触发 | +| `WAVE_RESULT` | wave_num > 20(Endless模式) | `SHOP` | wave_num++;ShopManager.restock(wave_num) | +| `GAME_OVER` / `GAME_CLEARED` | 玩家点击"返回菜单" | `MAIN_MENU` | 卸载 BattleScene;重置所有 Autoload Manager | +| 任意战斗状态 | Input.is_action_just_pressed("pause") | `PAUSE` | 保存 _prev_state;Engine.time_scale = 0 | +| `PAUSE` | 再次按暂停键 | `_prev_state` | Engine.time_scale = 1 | + +#### Manager Reset 协议 + +每次从 `GAME_OVER`/`GAME_CLEARED` 返回 `MAIN_MENU` 时,GameCycleManager 负责按顺序重置所有 Autoload: + +```gdscript +func _reset_all_managers() -> void: + # 顺序重要:先停止所有计算,再清理数据,最后重置 UI + BulletManager.reset() # 清空所有子弹 SoA 数据 + EnemyManager.reset() # 清空所有敌人 SoA 数据 + MinionManager.reset() # 清空所有召唤物 + ZoneManager.reset() # 清空所有地面效果 + StatusManager.reset() # 清空所有状态效果 + SpellEvaluator.reset() # 清空 SubPayloadRegistry + DamageContextPool.reset() # 归还所有在途 context(防止下局 context 泄漏) + PlayerManager.reset() # 重置血量/位置/蓄力状态 + WaveManager.reset() # wave_num = 0;spawn 队列清空 + ShopManager.reset() # 商品列表清空 + ProfileManager.clear_run() # 清除本局存档 +``` + +--- + +### 5.2 WaveManager 框架设计 + +`WaveManager` 负责读取波次配置、按时序 spawn 敌人、检测清场条件、向 GameCycleManager 上报结果。 + +#### 波次配置 JSON 格式 + +```json +// res://resources/waves/wave_01.json +{ + "wave_num": 1, + "duration_sec": 30, // 波次持续时间上限(超时也算通过) + "is_boss_wave": false, // true 时触发 BOSS_PHASE_CHANGED 事件和特殊 BGM + "spawn_groups": [ + { + "enemy_id": "slime_basic", + "count": 20, + "spawn_mode": "perimeter", // "perimeter" = 屏幕外缘随机, "cluster" = 聚集点, "instant" = 全量即时 + "delay_sec": 0.0, // 从波次开始到此批出现的延迟 + "interval_sec": 0.5 // 同批内每只敌人的间隔(0 = 即时全量) + }, + { + "enemy_id": "bat_fast", + "count": 10, + "spawn_mode": "perimeter", + "delay_sec": 8.0, + "interval_sec": 0.3 + } + ], + "clear_condition": "kill_all" // "kill_all" | "survive_duration" | "kill_boss" +} +``` + +#### WaveManager 状态与接口 + +```gdscript +# wave_manager.gd (Autoload: WaveManager) +var wave_num: int = 0 +var _elapsed: float = 0.0 +var _alive_count: int = 0 # 存活敌人数 +var _total_spawned: int = 0 # 本波已 spawn 敌人总数 +var _config: Dictionary = {} # 当前波次 JSON 解析结果 + +# 由 GameCycleManager 在 SHOP→WAVE_INTRO 时调用 +func prepare_wave(num: int) -> void: + wave_num = num + _elapsed = 0.0; _alive_count = 0; _total_spawned = 0 + var path := "res://resources/waves/wave_%02d.json" % num + _config = JSON.parse_string(FileAccess.open(path, FileAccess.READ).get_as_text()) + # Boss 波:提前通知 AudioManager 切换 BGM 分层(淡入 Boss 主题) + if _config.get("is_boss_wave", false): + EventBus.emit(EventID.BOSS_PHASE_CHANGED, {"boss_id": _config.get("boss_id",""), "phase": 0, "bgm_layer": "boss"}) + +# 由 GameCycleManager 在 WAVE_INTRO 完成后调用 +func start_wave() -> void: + _schedule_spawns() + +func _physics_process(delta: float) -> void: + if GameCycleManager.get_state() != GameCycleManager.GameState.WAVE: return + _elapsed += delta + _process_spawn_queue(delta) + _check_clear_condition() + +func _check_clear_condition() -> void: + match _config.get("clear_condition", "kill_all"): + "kill_all": + if _total_spawned >= _total_required() and _alive_count <= 0: + EventBus.emit(EventID.WAVE_COMPLETE, {"wave_num": wave_num}) + "survive_duration": + if _elapsed >= _config.get("duration_sec", 30.0): + EventBus.emit(EventID.WAVE_COMPLETE, {"wave_num": wave_num}) + "kill_boss": + pass # 由 BossManager 在 Boss 死亡时发出 WAVE_COMPLETE + +# 订阅 ENEMY_KILLED:更新存活计数 +func _on_enemy_killed(_enemy_id: int, _killer_id: int) -> void: + _alive_count = max(0, _alive_count - 1) +``` + +#### 敌人 spawn 与 EnemyManager 协议 + +WaveManager 不直接操作 EnemyManager 的 SoA 数据,而是调用高层接口: + +```gdscript +# EnemyManager 对外接口(GDScript Autoload 层) +func spawn(enemy_id: String, position: Vector2) -> int: # 返回 entity_id +func reset() -> void # 清空 SoA(由 GameCycleManager 调用) +func get_alive_count() -> int +func fill_pos_snapshot(out: PackedVector2Array) -> void # BulletManager homing 用 +``` + +--- + +### 5.3 ShopManager 框架设计 + +`ShopManager` 负责商品池管理、商店刷新、购买流程。与 `PlayerManager`(货币/库存)解耦通过 EventBus 或直接调用接口。 + +#### 商品池与刷新算法 + +```gdscript +# shop_manager.gd (Autoload: ShopManager) + +# 商品类型(可出现在商店的物品分类) +enum ShopItemType { SPELL = 0, PASSIVE = 1, CONSUMABLE = 2, CORE = 3 } + +# 商店槽位数:固定 6 格(3 法术 + 2 被动 + 1 消耗品) +const SLOT_COUNT: int = 6 +const REROLL_BASE_COST: int = 20 # 首次刷新费用 +const REROLL_COST_STEP: int = 10 # 每次刷新追加费用 + +var _slots: Array[Dictionary] = [] # 当前商店展示的商品 +var _reroll_count_this_wave: int = 0 # 本波刷新次数(结算后重置) +var _rng: RandomNumberGenerator = RandomNumberGenerator.new() + +# 由 GameCycleManager 调用 +func open_shop(wave_num: int) -> void: + _reroll_count_this_wave = 0 + _rng.seed = hash(wave_num) ^ ProfileManager.get_run_seed() # 确定性种子,防退出重开刷商店 + _fill_slots(wave_num) + +func restock(wave_num: int) -> void: + open_shop(wave_num) + +func get_reroll_cost() -> int: + return REROLL_BASE_COST + _reroll_count_this_wave * REROLL_COST_STEP + +func reroll(wave_num: int) -> bool: + var cost := get_reroll_cost() + if not PlayerManager.spend_gold(cost): return false + _reroll_count_this_wave += 1 + _fill_slots(wave_num) + return true + +func _fill_slots(wave_num: int) -> void: + _slots.clear() + # 权重池:随波次调整稀有度分布(越后期出现高稀有度概率越高) + var pool := _build_weighted_pool(wave_num) + for i in SLOT_COUNT: + _slots.append(_pick_from_pool(pool)) + +func _build_weighted_pool(wave_num: int) -> Array: + # ── 稀有度权重(与 UpgradeSystem._rarity_weights 保持相同曲线)──────── + var common_w := max(5.0, 60.0 - wave_num * 2.5) + var uncommon_w := min(50.0, 30.0 + wave_num * 1.5) + var rare_w := min(30.0, max(5.0, wave_num * 1.2 - 5.0)) + var legendary_w := min(10.0, max(0.0, wave_num * 0.5 - 8.0)) + var rarity_weights := [common_w, uncommon_w, rare_w, legendary_w] + # ── 候选池:SPELL + PASSIVE(按稀有度归组)──────────────────────────── + var candidates: Array[Array] = [[], [], [], []] # index = rarity-1 + for item in SpellRegistry.all(): + candidates[clamp(item.rarity - 1, 0, 3)].append({ + "type": ShopItemType.SPELL, "id": item.spell_id, + "price": 4 + (item.rarity - 1) * 4 # 价格公式:4/8/12/16 金币 + }) + for item in PassiveRegistry.all(): + candidates[clamp(item.rarity - 1, 0, 3)].append({ + "type": ShopItemType.PASSIVE, "id": item.passive_id, + "price": 6 + (item.rarity - 1) * 5 # 被动略贵:6/11/16/21 金币 + }) + # ── 构建带权重的扁平池 ───────────────────────────────────────────── + var pool: Array = [] + for rarity_idx in 4: + var w: float = rarity_weights[rarity_idx] + for entry in candidates[rarity_idx]: + pool.append({"entry": entry, "weight": w}) + return pool + +func _pick_from_pool(pool: Array) -> Dictionary: + # 使用确定性 _rng(防退出重开刷商店,与 open_shop seed 一致) + if pool.is_empty(): return {} + var total := 0.0 + for item in pool: total += item["weight"] + var roll := _rng.randf() * total + for item in pool: + roll -= item["weight"] + if roll <= 0.0: return item["entry"] + return pool[-1]["entry"] +``` + +#### 购买流程与 PlayerManager 接口 + +```gdscript +# 购买接口(UIManager 点击购买时调用) +func purchase(slot_idx: int) -> bool: + if slot_idx < 0 or slot_idx >= _slots.size(): return false + var item: Dictionary = _slots[slot_idx] + if item.is_empty(): return false + var cost: int = item.get("price", 0) + if not PlayerManager.spend_gold(cost): return false + # 按类型分发给对应 Manager + match item.get("type", ShopItemType.SPELL): + ShopItemType.SPELL: + PlayerManager.add_spell_to_inventory(item.get("id", "")) + ShopItemType.PASSIVE: + PlayerManager.add_passive(item.get("id", "")) + ShopItemType.CORE: + PlayerManager.replace_core(item.get("slot", 0), item.get("id", "")) + ShopItemType.CONSUMABLE: + PlayerManager.use_consumable(item.get("id", "")) + _slots[slot_idx] = {} # 标记为已售 + return true + +func get_slots() -> Array[Dictionary]: + # UIManager._refresh_shop_ui() 读取当前商品展示列表(只读副本) + return _slots.duplicate() + +func reset() -> void: + _slots.clear(); _reroll_count_this_wave = 0 + +func offer_free_spell(spell_id: String) -> void: + # DropManager 拾取法术卡掉落时调用。 + # 弹出"拾取法术"交互 UI,玩家确认后才实际装备(防止强制打断操作)。 + # UIManager 订阅此函数弹出的事件;若 UIManager 未处理则直接给予背包。 + if not SpellRegistry.has(spell_id): return + EventBus.emit(EventID.SPELL_DROP_PICKUP, { + "spell_id": spell_id, + "auto_equip": false # false = 弹 UI 让玩家决定装到哪个 Core;true = 装 active_core + }) +``` + +--- + +### 5.4 AudioManager 框架设计 + +`AudioManager` 管理 AudioBus 层级、动态音乐分层、音效池和空间音频。 + +> **权威定义说明**:本节为 AudioBus 结构的唯一权威来源。`ADR-A3`(§文档末尾)提供补充规则(空间音效衰减参数、节流规则、音量持久化细节),不重新定义 Bus 结构。两者合并阅读。 + +#### AudioBus 层级(权威) + +``` +Master +├── BGM (背景音乐总线,-6dB 预衰减) +│ ├── BGM_Base (基础旋律层,常驻播放,AudioStreamPlayer 独占) +│ └── BGM_Layer (动态叠加层:战斗强度 / Boss 阶段驱动;3 轨并行,按强度 fade-in) +├── SFX (战斗音效总线,-3dB) +│ ├── SFX_Combat (子弹/爆炸/伤害,来自 BulletManager/EnemyManager) +│ └── SFX_Spatial(2D 空间音效,挂 AudioEffect2DPan + 2D Reverb;max_distance=1200px) +├── UI (UI 音效,不受空间衰减,-3dB) +├── Ambience (循环环境音,-9dB) +└── Voice (语音 / Boss 台词,最高优先级,不受 SFX 池限制) +``` + +**AudioBusID 常量 Autoload**(代码中所有 Bus 名称必须通过此 Autoload 引用,禁止硬编码字符串): + +```gdscript +# scripts/autoloads/audio_bus_id.gd (Autoload: AudioBusID) +const MASTER : StringName = &"Master" +const BGM : StringName = &"BGM" +const BGM_BASE : StringName = &"BGM_Base" +const BGM_LAYER : StringName = &"BGM_Layer" +const SFX : StringName = &"SFX" +const SFX_COMBAT : StringName = &"SFX_Combat" +const SFX_SPATIAL : StringName = &"SFX_Spatial" +const UI : StringName = &"UI" +const AMBIENCE : StringName = &"Ambience" +const VOICE : StringName = &"Voice" +``` + +**Bus 配置文件**:`res://audio/bus_layout.tres`(Godot 内置 AudioBusLayout Resource,在 Project Settings > Audio 中指定)。 + +#### AudioManager 完整数据模型 + +```gdscript +# audio_manager.gd (Autoload: AudioManager) + +# ── 常量 ─────────────────────────────────────────────────────── +const MAX_CONCURRENT_SFX: int = 32 # 同时最多播放 32 个 SFX 实例(SFX_Combat + SFX_Spatial 共享上限) +const MAX_SAME_SFX_PER_FRAME: int = 3 # 同一音效每帧最多触发 3 次(防弹幕爆炸噪音堆叠) +const BGM_FADE_OUT_SEC: float = 0.8 # BGM 淡出时长(秒) +const LAYER_FADE_SPEED: float = 2.0 # 动态音乐层淡变速率(dB/秒) +const SFX_COOLDOWN_SEC: float = 0.1 # 同一音效最小间隔(防 0.1s 内重复播放) + +# ── BGM 三步交叉淡变状态机 ──────────────────────────────────────── +enum BgmPhase { IDLE, FADING_OUT, SWITCHING, FADING_IN } +var _bgm_phase: BgmPhase = BgmPhase.IDLE +var _current_bgm_id: String = "" +var _pending_bgm_id: String = "" +var _pending_fade_in: float = 1.5 +var _fade_timer: float = 0.0 + +# ── BGM 播放器节点(_ready() 中创建并 add_child)─────────────────── +var _bgm_base_player: AudioStreamPlayer = null # BGM_Base Bus 专属播放器 +var _music_layers: Array[AudioStreamPlayer] = [] # BGM_Layer Bus 上的 3 个动态层播放器(index 0/1/2) +var _layer_target: int = 0 # 当前目标动态层级(0=基础/1=战斗/2=高强度) + +# ── SFX 池 ────────────────────────────────────────────────────── +var _sfx_pool: Array[AudioStreamPlayer2D] = [] # 预分配节点池(MAX_CONCURRENT_SFX 个) +var _sfx_active_count: int = 0 # O(1) 可用池数量追踪(P6-N60 规范) +var _sfx_frame_count: Dictionary = {} # { sfx_id: int } 本帧计数(_process() 帧末清零) +var _sfx_last_play_time: Dictionary = {} # { sfx_id: float } 同 ID 节流计时 + +func _ready() -> void: + # 创建 BGM_Base 播放器 + _bgm_base_player = AudioStreamPlayer.new() + _bgm_base_player.bus = AudioBusID.BGM_BASE + add_child(_bgm_base_player) + # 创建 3 个动态层播放器(挂 BGM_Layer Bus) + for i in 3: + var lp := AudioStreamPlayer.new() + lp.bus = AudioBusID.BGM_LAYER + lp.volume_db = -80.0 # 初始静音 + add_child(lp) + _music_layers.append(lp) + # 预分配 SFX 池 + for i in MAX_CONCURRENT_SFX: + var sp := AudioStreamPlayer2D.new() + sp.bus = AudioBusID.SFX_COMBAT # 默认 Combat 总线,play_sfx() 可按类型切换 + sp.finished.connect(_on_sfx_finished.bind(sp)) + add_child(sp) + _sfx_pool.append(sp) + EventBus.subscribe(EventID.BOSS_PHASE_CHANGED, _on_boss_phase_changed) + EventBus.subscribe(EventID.BOSS_KILLED, _on_boss_killed) + EventBus.subscribe(EventID.PLAYER_DIED, _on_player_died) +``` + +#### 核心接口 + +```gdscript +# ── SFX 接口 ───────────────────────────────────────────────────── +func play_sfx(sfx_id: String, position: Vector2, volume_db: float = 0.0, + bus: StringName = AudioBusID.SFX_COMBAT) -> void: + # 同帧频率限制 + _sfx_frame_count[sfx_id] = _sfx_frame_count.get(sfx_id, 0) + 1 + if _sfx_frame_count[sfx_id] > MAX_SAME_SFX_PER_FRAME: return + # 同 ID 0.1s 节流 + var now := Time.get_ticks_msec() / 1000.0 + if now - _sfx_last_play_time.get(sfx_id, 0.0) < SFX_COOLDOWN_SEC: return + _sfx_last_play_time[sfx_id] = now + var player := _acquire_sfx_player() + if player == null: return # 池耗尽,静默丢弃(非崩溃) + player.stream = SfxLibrary.get(sfx_id) + player.bus = bus + player.position = position + player.volume_db = volume_db + player.play() + +func play_sfx_ui(sfx_id: String) -> void: + # UI 音效:不需要位置,直接挂 UI Bus + var player := _acquire_sfx_player() + if player == null: return + player.stream = SfxLibrary.get(sfx_id) + player.bus = AudioBusID.UI + player.position = Vector2.ZERO + player.volume_db = 0.0 + player.play() + +func _acquire_sfx_player() -> AudioStreamPlayer2D: + if _sfx_active_count >= MAX_CONCURRENT_SFX: return null + for p in _sfx_pool: + if not p.playing: + _sfx_active_count += 1 + return p + return null # 全部占用 + +func _on_sfx_finished(player: AudioStreamPlayer2D) -> void: + _sfx_active_count = max(0, _sfx_active_count - 1) + +# ── BGM 接口 ───────────────────────────────────────────────────── +func play_bgm(bgm_id: String, fade_in_sec: float = 1.5) -> void: + if _current_bgm_id == bgm_id: return + _pending_bgm_id = bgm_id + _pending_fade_in = fade_in_sec + _bgm_phase = BgmPhase.FADING_OUT + +func fade_out_all(duration: float = 0.5) -> void: + # 玩家死亡时调用:所有 Bus 在 duration 秒内淡出至 -40dB + var tween := create_tween() + for bus_name in [AudioBusID.BGM, AudioBusID.SFX, AudioBusID.UI, AudioBusID.AMBIENCE]: + var idx := AudioServer.get_bus_index(bus_name) + tween.parallel().tween_method( + func(v): AudioServer.set_bus_volume_db(idx, v), + AudioServer.get_bus_volume_db(idx), -40.0, duration) + +# ── BGM 三步交叉淡变(_process() 每帧驱动)──────────────────────── +func _process_bgm_fade(delta: float) -> void: + match _bgm_phase: + BgmPhase.FADING_OUT: + _fade_timer += delta + _bgm_base_player.volume_db = lerp(0.0, -80.0, _fade_timer / BGM_FADE_OUT_SEC) + if _fade_timer >= BGM_FADE_OUT_SEC: + _bgm_base_player.stop() + _fade_timer = 0.0 + _bgm_phase = BgmPhase.SWITCHING + BgmPhase.SWITCHING: + _bgm_base_player.stream = BgmLibrary.get(_pending_bgm_id) + _bgm_base_player.volume_db = -80.0 + _bgm_base_player.play() + _current_bgm_id = _pending_bgm_id + _bgm_phase = BgmPhase.FADING_IN + BgmPhase.FADING_IN: + _fade_timer += delta + _bgm_base_player.volume_db = lerp(-80.0, 0.0, _fade_timer / _pending_fade_in) + if _fade_timer >= _pending_fade_in: + _bgm_base_player.volume_db = 0.0 + _fade_timer = 0.0 + _bgm_phase = BgmPhase.IDLE + +# ── 动态音乐层(每 0.5s 在 _process() 中轮询,不在 _physics_process)── +func _update_music_layers(enemy_count: int, boss_active: bool) -> void: + _layer_target = 0 + if boss_active or enemy_count >= 20: _layer_target = 2 + elif enemy_count >= 5: _layer_target = 1 + for i in _music_layers.size(): + var target_db: float = 0.0 if i <= _layer_target else -80.0 + _music_layers[i].volume_db = move_toward( + _music_layers[i].volume_db, target_db, + LAYER_FADE_SPEED * get_process_delta_time()) + +func _on_boss_phase_changed(payload: Dictionary) -> void: + var bgm_layer: String = payload.get("bgm_layer", "") + if bgm_layer.begins_with("boss"): play_bgm("boss_phase_0") + +func _on_boss_killed(_payload: Dictionary) -> void: + play_bgm("wave_normal") + +func _on_player_died(_payload: Dictionary) -> void: + fade_out_all(0.5) + +func reset() -> void: + _bgm_base_player.stop() + for lp in _music_layers: lp.stop(); lp.volume_db = -80.0 + _current_bgm_id = ""; _bgm_phase = BgmPhase.IDLE; _fade_timer = 0.0 + _sfx_frame_count.clear(); _sfx_last_play_time.clear(); _sfx_active_count = 0 +``` + +#### SfxLibrary / BgmLibrary 音频资源索引 + +`SfxLibrary` 和 `BgmLibrary` 是纯静态字典,在 `AudioManager._ready()` 时由预加载填充,供 `play_sfx` / `play_bgm` 通过字符串 ID 查找 `AudioStream`。 + +```gdscript +# sfx_library.gd(非 Autoload,挂在 AudioManager 子节点或直接合并到 audio_manager.gd) +const _SFX_MAP: Dictionary = { + # 战斗音效 ───────────────────────────── + "bullet_hit_flesh" : preload("res://audio/sfx/combat/bullet_hit_flesh.ogg"), + "bullet_hit_shield" : preload("res://audio/sfx/combat/bullet_hit_shield.ogg"), + "explosion_small" : preload("res://audio/sfx/combat/explosion_small.ogg"), + "explosion_large" : preload("res://audio/sfx/combat/explosion_large.ogg"), + "player_hurt" : preload("res://audio/sfx/combat/player_hurt.ogg"), + # 法术施放 ───────────────────────────── + "spell_cast_generic" : preload("res://audio/sfx/spell/cast_generic.ogg"), + "spell_cast_fire" : preload("res://audio/sfx/spell/cast_fire.ogg"), + "spell_cast_ice" : preload("res://audio/sfx/spell/cast_ice.ogg"), + # UI 音效 ────────────────────────────── + "ui_click" : preload("res://audio/sfx/ui/click.ogg"), + "ui_purchase" : preload("res://audio/sfx/ui/purchase.ogg"), + "ui_reroll" : preload("res://audio/sfx/ui/reroll.ogg"), + "ui_level_up" : preload("res://audio/sfx/ui/level_up.ogg"), +} + +static func get(sfx_id: String) -> AudioStream: + return _SFX_MAP.get(sfx_id, null) + +# bgm_library.gd(同上,流式资源使用 load() 而非 preload 避免启动时全量入内存) +const _BGM_MAP: Dictionary = { + "main_menu" : "res://audio/bgm/main_menu.ogg", + "shop" : "res://audio/bgm/shop.ogg", + "wave_normal" : "res://audio/bgm/wave_normal.ogg", + "boss_phase_0" : "res://audio/bgm/boss_phase0.ogg", + "boss_phase_1" : "res://audio/bgm/boss_phase1.ogg", + "boss_phase_2" : "res://audio/bgm/boss_phase2.ogg", + "game_over" : "res://audio/bgm/game_over.ogg", + "game_cleared" : "res://audio/bgm/game_cleared.ogg", +} + +static func get(bgm_id: String) -> AudioStream: + var path: String = _BGM_MAP.get(bgm_id, "") + if path == "": return null + return load(path) as AudioStream # 流式加载,释放旧引用时 Godot 自动 GC +``` + +> **资源命名规范**:音效文件统一使用 `.ogg`(Vorbis,内存效率最优);BGM 为 `.ogg` 流式(`loop: true` 在 Import 设置中启用)。所有路径以 `res://audio/` 为根,子目录 `sfx/` 和 `bgm/` 对应上方结构。 + +--- + +### 5.5 StatusManager 架构设计 + +`StatusManager` 集中管理所有实体的状态效果(DoT、减速、易伤等),消除各 Manager 自行处理状态的耦合。 + +#### 核心数据结构 + +```gdscript +# status_manager.gd (Autoload: StatusManager) +# 所有状态 ID 常量见 status_id.gd (Autoload: StatusID) +# 所有状态定义(效果、叠加规则)以 StatusTypeDef .tres 资源存储,由 StatusRegistry 加载 + +# SoA 布局(PackedFloat32Array):每个状态实例占 STATUS_STRIDE=5 个浮点槽 +# slot 0: entity_id (float 转 int 存储) +# slot 1: status_type_id (float 转 int) +# slot 2: remaining_duration +# slot 3: tick_accumulator +# slot 4: stack_count (float 转 int) +const STATUS_STRIDE: int = 5 +const MAX_STATUS_INSTANCES: int = 512 # 最多同时存在 512 个状态实例(32KB) + +var _data: PackedFloat32Array = PackedFloat32Array() +var _active_count: int = 0 +``` + +#### 接口与生命周期 + +```gdscript +# 施加状态(由 SpellEvaluator / ZoneManager / BulletManager 调用) +# 权威签名(三处调用方统一使用此签名): +# - SpellEvaluator: apply(target_id, status_type_id, intensity, owner_id=caster_id) +# - ZoneManager: apply(target_id, status_type_id, owner_id=zone_owner_id)(intensity=1.0 默认) +# - impl §2.4: apply(entity_id, status_type_id, stacks, duration, source_id) — +# duration/stacks 由 StatusTypeDef 驱动,不在此签名中暴露(内部细节) +func apply(entity_id: int, status_type_id: int, intensity: float = 1.0, + owner_id: int = -1) -> void: + # owner_id 用于 remove_source()(实体死亡时移除其施加的所有状态);-1 = 无主(区域/无主状态) + var existing := _find(entity_id, status_type_id) + var typedef: StatusTypeDef = StatusRegistry.get(status_type_id) + if existing >= 0: + match typedef.stack_mode: + "refresh": _data[existing * STATUS_STRIDE + 2] = typedef.duration # 刷新时长 + "stack": _data[existing * STATUS_STRIDE + 4] += 1.0 # 叠加层数 + "ignore": pass # 已有则忽略 + else: + _add_new(entity_id, status_type_id, typedef.duration, intensity, owner_id) + EventBus.emit(EventID.STATUS_APPLIED, {"target_id": entity_id, "status_type": status_type_id, + "stacks": int(_data[existing * STATUS_STRIDE + 4]) if existing >= 0 else 1}) + +func remove_source(owner_id: int) -> void: + # 移除 owner_id 施加的所有状态(召唤物/Boss 死亡时调用,防止游魂状态残留) + # 详见 implementation_plan.md §2.4 remove_source 实现 + var i := 0 + while i < _active_count: + if int(_data[i * STATUS_STRIDE + 5]) == owner_id: # slot +5: owner_id + # swap-and-pop + if i < _active_count - 1: + for k in STATUS_STRIDE: + _data[i * STATUS_STRIDE + k] = _data[(_active_count - 1) * STATUS_STRIDE + k] + _active_count -= 1 + else: + i += 1 + +# 查询(EnemyManager.apply_damage() 读取易伤系数) +func get_stacks(entity_id: int, status_type_id: int) -> int: + var idx := _find(entity_id, status_type_id) + return int(_data[idx * STATUS_STRIDE + 4]) if idx >= 0 else 0 + +func has_status(entity_id: int, status_type_id: int) -> bool: + return _find(entity_id, status_type_id) >= 0 + +# _physics_process:tick DoT 并倒计时 duration,swap-and-pop 移除过期实例 +func _physics_process(delta: float) -> void: + var i := 0 + while i < _active_count: + var base := i * STATUS_STRIDE + _data[base + 2] -= delta # remaining_duration 倒计时 + _data[base + 3] += delta # tick_accumulator 累积 + var typedef: StatusTypeDef = StatusRegistry.get(int(_data[base + 1])) + while _data[base + 3] >= typedef.tick_interval: + _data[base + 3] -= typedef.tick_interval + _apply_dot_tick(i, typedef) # 触发 DoT 伤害 + if _data[base + 2] <= 0.0: + _swap_and_pop(i) # O(1) 移除,顺序可乱 + else: + i += 1 + +func reset() -> void: + _active_count = 0 # 不需要清零数据,_active_count 控制有效范围 +``` + +#### StatusTypeDef 资源格式 + +```gdscript +# status_type_def.gd - Resource 子类 +class_name StatusTypeDef +extends Resource + +var display_name: String = "" # tr() KEY,UI 显示用 +var duration: float = 3.0 # 默认持续时长(秒) +var tick_interval: float = 1.0 # DoT 跳字间隔(非 DoT 状态设 999.0 避免误触发) +var stack_mode: String = "refresh" # "refresh" | "stack" | "ignore" +var max_stacks: int = 99 # 叠层上限(stack 模式) +var dot_damage_per_tick: float = 0.0 # 每 tick 造成的基础伤害(0 = 无 DoT) +var dot_damage_type: int = DamageType.PHYSICAL +var can_catalyze: Array[int] = [] # 可参与催化的状态 ID 列表(空 = 不参与元素反应) +var is_combo_tracker: bool = false # true = 计数追踪状态(tick 逻辑由调用方控制,不走 DoT 路径) +var vfx_id: String = "" # 状态持续时的粒子特效 ID(空 = 无) +var icon_key: String = "" # UI 状态图标的 UIAtlas 键 +``` + +--- + +### 5.6 PlayerManager 架构设计 + +`PlayerManager` 是玩家实体的单一数据权威,持有 HP / 金币 / XP / 等级 / 三个 Core / 被动列表。所有系统通过 `PlayerManager` 读写玩家状态,禁止直接操作其内部字段。 + +#### 玩家状态数据模型 + +```gdscript +# player_manager.gd (Autoload: PlayerManager) + +# ── 生命值 ──────────────────────────────────────────────────── +var hp: float = 100.0 +var hp_max: float = 100.0 +var hp_regen: float = 0.0 # 每秒自动回血(被动加成) + +# ── 经济 ────────────────────────────────────────────────────── +var gold: int = 0 +var xp: int = 0 +var level: int = 1 +# XP 升级阈值:⌊10 × 1.4^(level-1)⌋(见 numerical_design.md §1.2) +# level 20 = 最终等级(Wave 20 通关上限);level 21+ 服务 Endless 模式 + +# ── 属性词条(被动叠加结果)──────────────────────────────────── +var stats: PlayerStats = PlayerStats.new() # 伤害/攻速/移速等合并后的最终值 + +# ── 法杖 Core 系统(三 Core 机制)────────────────────────────── +const MAX_CORES: int = 3 +var cores: Array[CoreDefinition] = [] # 当前装备的 Core,长度 ≤ MAX_CORES +var active_core_idx: int = 0 # 当前激活 Core 的下标 +var _raw_decks: Array[SpellDeck] = [] # 每个 Core 对应的原始 SpellDeck(compile_all_wands 的输入) +var compiled_decks: Array[CompiledDeck] = [] # 每个 Core 的预编译产物(compile_all_wands 的输出) + +# ── 消耗品临时持有(当回合商店购买的消耗品,在 WAVE 开始前使用)───── +var _pending_consumables: Array[String] = [] # consumable_id 列表 + +# ── 被动列表 ────────────────────────────────────────────────── +var passives: Array[String] = [] # 被动 ID 列表(passive_id: String,对应 .tres 文件名) + +# ── 位置(View 层只读) ──────────────────────────────────────── +var _position: Vector2 = Vector2.ZERO # 由 CharacterBody2D 每帧同步;其他系统通过 get_position() 读取 +func get_position() -> Vector2: return _position +``` + +#### PlayerStats 合并数据类 + +```gdscript +# player_stats.gd - 所有被动合并后的最终属性快照(MODIFIER 节点的基准值来源) +class_name PlayerStats +extends RefCounted + +var damage_mult: float = 1.0 # 全局伤害乘算(累乘所有 damage% 被动) +var attack_speed_mult: float = 1.0 # 攻速(影响 cast_delay 分母) +var move_speed: float = 200.0 # 像素/秒 +var hp_max_bonus: float = 0.0 # HP 上限加成 +var hp_regen_bonus: float = 0.0 # 回血加成 +var cpu_limit: int = 5 # SpellEvaluator MAX_OPS 的基础乘数 +var pickup_radius: float = 60.0 # 拾取半径(DropManager 用) +var crit_chance_bonus: float = 0.0 # 全局暴击率追加(叠加到 CastStats.crit_chance) + +func recalculate(passives: Array[String]) -> void: + # 重置为默认值后依次应用每个被动的 stats_patch + _reset_defaults() + for pid in passives: + var def: PassiveDef = PassiveRegistry.get(pid) + if def: def.apply_to(self) +``` + +#### 核心接口 + +```gdscript +# ── 经济接口 ────────────────────────────────────────────────── +func spend_gold(amount: int) -> bool: + if gold < amount: return false + gold -= amount + return true + +func add_gold(amount: int) -> void: + gold += amount + +func add_xp(amount: int) -> void: + xp += amount + _check_level_up() # 达到阈值则 level++,触发 WAVE_RESULT 升级选择流程 + +func _check_level_up() -> void: + var threshold: int = int(10.0 * pow(1.4, level - 1)) + while xp >= threshold and level < 21: # level 21 以上 Endless 不再升级 + xp -= threshold + level += 1 + threshold = int(10.0 * pow(1.4, level - 1)) + +# ── 生命值接口 ──────────────────────────────────────────────── +func take_damage(amount: float) -> void: + hp = max(0.0, hp - amount) + EventBus.emit(EventID.PLAYER_DAMAGED, {"amount": amount, "source_id": -1}) + if hp <= 0.0: + EventBus.emit(EventID.PLAYER_DIED, {}) + +func heal(amount: float) -> void: + hp = min(hp_max, hp + amount) + +# ── Core / Deck 管理 ────────────────────────────────────────── +func add_core(core: CoreDefinition, slot: int = -1) -> void: + if slot < 0: slot = cores.size() + slot = clamp(slot, 0, MAX_CORES - 1) + if slot < cores.size(): + cores[slot] = core + else: + cores.append(core) + _raw_decks.append(SpellDeck.new()) # 同步扩容,保持 cores 与 _raw_decks 等长 + compiled_decks.resize(cores.size()) + +func replace_core(slot: int, core_id: String) -> void: + # ShopManager 购买 CORE 类商品时调用;以新 Core 替换指定槽,清空该槽的原始 Deck + if slot < 0 or slot >= MAX_CORES: return + var core: CoreDefinition = CoreRegistry.get(core_id) + if core == null: return + if slot < cores.size(): + cores[slot] = core + _raw_decks[slot] = SpellDeck.new() # 换 Core 后重置对应 Deck + else: + add_core(core, slot) + compiled_decks.resize(cores.size()) + +func add_spell_to_inventory(spell_id: String, target_core_idx: int = -1) -> void: + var idx := target_core_idx if target_core_idx >= 0 else active_core_idx + if idx >= cores.size(): return + _raw_decks[idx].push(SpellRegistry.get(spell_id)) + +func add_passive(passive_id: String) -> void: + passives.append(passive_id) + stats.recalculate(passives) # 被动变更后立即重算 PlayerStats + +func use_consumable(consumable_id: String) -> void: + # ShopManager 购买 CONSUMABLE 类商品时调用。 + # 消耗品效果立即生效(不延迟到 WAVE);若需要战斗中生效,加入 _pending_consumables 并在 WAVE_INTRO 处理。 + var def: ConsumableDef = ConsumableRegistry.get(consumable_id) + if def == null: return + def.apply(self) # ConsumableDef.apply(player: PlayerManager) 直接修改玩家状态 + +# ── 法杖预编译入口(GameCycleManager 在 SHOP→WAVE_INTRO 时调用)──── +func compile_all_wands() -> void: + compiled_decks.resize(cores.size()) + for i in cores.size(): + compiled_decks[i] = SpellEvaluator.compile_wand(cores[i], _raw_decks[i]) + +func get_compiled_deck(core_idx: int) -> CompiledDeck: + if core_idx < 0 or core_idx >= compiled_decks.size(): return null + return compiled_decks[core_idx] + +func get_raw_deck(core_idx: int) -> SpellDeck: + if core_idx < 0 or core_idx >= _raw_decks.size(): return null + return _raw_decks[core_idx] + +func reset() -> void: + hp = hp_max; gold = 0; xp = 0; level = 1 + cores.clear(); _raw_decks.clear(); compiled_decks.clear(); passives.clear() + _pending_consumables.clear() + stats.recalculate([]) + _position = Vector2.ZERO +``` + +> **compile_all_wands() 调用时机**:GameCycleManager 的 `SHOP → WAVE_INTRO` 转换回调中调用 `PlayerManager.compile_all_wands()`,而非直接调用 `SpellEvaluator`。这样 PlayerManager 保持对三个 Core 的迭代责任,SpellEvaluator 只负责单个 `compile_wand()` 逻辑。 + +--- + +### 5.7 DropManager 框架设计 + +`DropManager` 订阅 `EventID.ENEMY_KILLED`,负责在敌人位置生成掉落物并处理玩家拾取。 + +#### 掉落表配置格式 + +```json +// res://resources/drops/enemy_drops.json +{ + "slime_basic": { + "xp": { "min": 2, "max": 4 }, + "gold": { "min": 0, "max": 2, "chance": 0.3 }, + "spell_drop": { "chance": 0.02, "rarity_max": 1 } + }, + "bat_fast": { + "xp": { "min": 3, "max": 5 }, + "gold": { "min": 1, "max": 3, "chance": 0.4 }, + "spell_drop": { "chance": 0.03, "rarity_max": 2 } + }, + "elite": { + "xp": { "min": 15, "max": 25 }, + "gold": { "min": 8, "max": 15, "chance": 1.0 }, + "spell_drop": { "chance": 0.20, "rarity_max": 3 } + }, + "_default": { + "xp": { "min": 2, "max": 3 }, + "gold": { "min": 0, "max": 1, "chance": 0.2 }, + "spell_drop": { "chance": 0.01, "rarity_max": 1 } + } +} +``` + +#### DropManager 核心逻辑 + +```gdscript +# drop_manager.gd (Autoload: DropManager) + +# 掉落物在世界空间以 Dictionary 形式存储(无 Node 开销,轻量化) +# { "type": "xp"|"gold"|"spell", "value": Variant, "position": Vector2, "lifetime": float } +var _drops: Array[Dictionary] = [] +var _drop_table: Dictionary = {} # 启动时从 JSON 加载 + +func _ready() -> void: + var raw: String = FileAccess.open("res://resources/drops/enemy_drops.json", FileAccess.READ).get_as_text() + _drop_table = JSON.parse_string(raw) + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) + +func _on_enemy_killed(payload: Dictionary) -> void: + var enemy_id: int = payload.get("enemy_id", -1) + var pos: Vector2 = EnemyManager.get_last_position(enemy_id) # 死亡时缓存的位置 + var enemy_type: String = EnemyManager.get_type(enemy_id) + var table: Dictionary = _drop_table.get(enemy_type, _drop_table.get("_default", {})) + _spawn_drops(pos, table) + +func _spawn_drops(pos: Vector2, table: Dictionary) -> void: + # XP(必定掉落) + var xp_cfg: Dictionary = table.get("xp", {}) + var xp_val: int = randi_range(xp_cfg.get("min", 1), xp_cfg.get("max", 2)) + _drops.append({"type": "xp", "value": xp_val, "position": pos, "lifetime": 8.0}) + + # 金币(概率) + var gold_cfg: Dictionary = table.get("gold", {}) + if randf() < gold_cfg.get("chance", 0.0): + var g: int = randi_range(gold_cfg.get("min", 1), gold_cfg.get("max", 1)) + _drops.append({"type": "gold", "value": g, "position": pos, "lifetime": 10.0}) + + # 法术卡掉落(低概率) + var spell_cfg: Dictionary = table.get("spell_drop", {}) + if randf() < spell_cfg.get("chance", 0.0): + var max_rarity: int = spell_cfg.get("rarity_max", 1) + var spell_id: String = SpellRegistry.random_by_rarity(max_rarity) + if spell_id != "": + _drops.append({"type": "spell", "value": spell_id, "position": pos, "lifetime": 15.0}) + +func _physics_process(delta: float) -> void: + if GameCycleManager.get_state() != GameCycleManager.GameState.WAVE: return + var player_pos := PlayerManager.get_position() + var pickup_r := PlayerManager.stats.pickup_radius + var i := 0 + while i < _drops.size(): + var drop := _drops[i] + drop["lifetime"] -= delta + # 拾取检测:圆形距离 + if (player_pos - drop["position"]).length_squared() <= pickup_r * pickup_r: + _collect(drop) + _drops.remove_at(i) + elif drop["lifetime"] <= 0.0: + _drops.remove_at(i) # 超时消失(swap-and-pop 可改,当前掉落数量极少,remove_at 可接受) + else: + i += 1 + +func _collect(drop: Dictionary) -> void: + match drop["type"]: + "xp": PlayerManager.add_xp(drop["value"]) + "gold": PlayerManager.add_gold(drop["value"]) + "spell": ShopManager.offer_free_spell(drop["value"]) # 弹出"拾取法术"UI提示 +``` + +--- + +### 5.8 UpgradeSystem 框架设计 + +每波结算后,玩家从随机 3 张升级词条中选择 1 张。`UpgradeSystem` 管理词条池构建、展示、应用。 + +#### 升级词条资源格式(UpgradeDef .tres) + +```gdscript +# upgrade_def.gd - Resource 子类 +class_name UpgradeDef +extends Resource + +var upgrade_id: String = "" # 全局唯一,格式:category_effect(如 "offense_damage_plus") +var display_name: String = "" # tr() KEY +var description: String = "" # tr() KEY,支持 {value} 占位符 +var icon_key: String = "" # UIAtlas 图标键 +var rarity: int = 1 # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary +var max_stack: int = 1 # 同一词条最多叠取次数(1=不可重复,99=无限) +var value: float = 0.0 # 效果数值(供 description {value} 和 apply() 使用) + +# 效果函数:直接修改 PlayerStats 或 PlayerManager 状态 +func apply(player: PlayerManager) -> void: + pass # 子类重写,例:player.stats.damage_mult *= 1.1 +``` + +#### UpgradeSystem 核心逻辑 + +```gdscript +# upgrade_system.gd (Autoload: UpgradeSystem) + +const CHOICES_COUNT: int = 3 # 每次展示 3 张词条 + +var _upgrade_pool: Array[UpgradeDef] = [] # 启动时从 res://resources/upgrades/ 扫描加载 +var _taken_counts: Dictionary = {} # { upgrade_id: int } 本次 Run 已取次数(reset 时清零) + +func _ready() -> void: + _scan_upgrades() + +func _scan_upgrades() -> void: + _upgrade_pool.clear() + var dir := DirAccess.open("res://resources/upgrades/") + if dir: + dir.list_dir_begin() + var fname := dir.get_next() + while fname != "": + if fname.ends_with(".tres"): + var def: UpgradeDef = load("res://resources/upgrades/" + fname) + if def and def.upgrade_id != "": _upgrade_pool.append(def) + fname = dir.get_next() + +func build_choices(wave_num: int, rng: RandomNumberGenerator) -> Array[UpgradeDef]: + var weights := _rarity_weights(wave_num) + var available := _pool_filtered() # 过滤已达 max_stack 的词条 + var result: Array[UpgradeDef] = [] + var seen_ids: Array[String] = [] + for _i in CHOICES_COUNT: + var pick := _weighted_pick(available, weights, rng, seen_ids) + if pick: + result.append(pick) + seen_ids.append(pick.upgrade_id) + return result + +func apply_choice(upgrade: UpgradeDef) -> void: + upgrade.apply(PlayerManager) + _taken_counts[upgrade.upgrade_id] = _taken_counts.get(upgrade.upgrade_id, 0) + 1 + SpellEvaluator.update_max_ops(PlayerManager.stats.cpu_limit) + +func _pool_filtered() -> Array[UpgradeDef]: + # 过滤已达 max_stack 的词条(不再可选) + var result: Array[UpgradeDef] = [] + for def in _upgrade_pool: + if _taken_counts.get(def.upgrade_id, 0) < def.max_stack: + result.append(def) + return result + +func _weighted_pick(pool: Array[UpgradeDef], weights: Array[float], + rng: RandomNumberGenerator, exclude: Array[String]) -> UpgradeDef: + # 按稀有度权重从 pool 中加权随机选一条词条(排除 exclude 中的 ID) + var total := 0.0 + for def in pool: + if def.upgrade_id in exclude: continue + total += weights[clamp(def.rarity - 1, 0, 3)] + if total <= 0.0: return null + var roll := rng.randf() * total + for def in pool: + if def.upgrade_id in exclude: continue + roll -= weights[clamp(def.rarity - 1, 0, 3)] + if roll <= 0.0: return def + return pool[-1] if not pool.is_empty() else null + +func _rarity_weights(wave_num: int) -> Array[float]: + var common_w := max(5.0, 60.0 - wave_num * 2.5) + var uncommon_w := min(50.0, 30.0 + wave_num * 1.5) + var rare_w := min(30.0, max(5.0, wave_num * 1.2 - 5.0)) + var legendary_w := min(10.0, max(0.0, wave_num * 0.5 - 8.0)) + return [common_w, uncommon_w, rare_w, legendary_w] + +func reset() -> void: + _taken_counts.clear() +``` + +> **GameCycleManager 集成**:`WAVE_RESULT` 状态的 `_on_enter()` 中调用 `UpgradeSystem.build_choices(wave_num, _rng)` 获取词条列表,UIManager 展示 3 张升级卡;玩家选择后调用 `UpgradeSystem.apply_choice(selected)` → 直接返回 `GameCycleManager.transition_to(SHOP)`。 + +--- + +### 5.9 BossManager 架构设计 + +Boss 是具有多阶段 HP 和特殊行为的特殊敌人。`BossManager` 是 `EnemyManager` 的协调层,不持有独立的 SoA 数据,而是通过 EnemyManager 的接口操作 Boss 实体并监听阶段切换。 + +#### BossManager 与 EnemyManager 的关系 + +| 职责 | 归属 | 理由 | +| :--- | :--- | :--- | +| Boss 物理位置 / Boid 运动 | `EnemyManager` SoA | Boss 也是"敌人",共享同一物理系统 | +| Boss HP 存储 | `EnemyManager._enemy_data` | EnemyManager 统一管理存活/死亡状态 | +| 阶段切换逻辑 / 特殊能力触发 | `BossManager` | 阶段是 Boss 专有行为,不属于通用敌人 | +| Boss 碰撞体(多区域) | `BossManager` 管理多个 Area2D / 自定义 AABB | Boss 半径可达 200px,超过 SpatialGrid 标准弹体阈值,走 `EnemyManager.query_aabb(boss_aabb)` | + +#### BossManager 核心设计 + +```gdscript +# boss_manager.gd (Autoload: BossManager) + +# Boss 阶段定义(每个 Boss 的阶段配置在其对应 JSON 中定义) +# boss_design.md §6 详细描述各 Boss 行为,BossManager 仅处理通用框架 +var _boss_entity_id: int = -1 # 当前 Boss 在 EnemyManager 中的 entity_id(-1 = 无 Boss) +var _current_phase: int = 0 +var _phase_thresholds: Array[float] = [] # HP 百分比阈值(降序),如 [1.0, 0.6, 0.3] +var _is_active: bool = false +var _boss_size_cache: Dictionary = {} # spawn_boss 时缓存的 Boss 配置(含 collision_radius) + +# WaveManager 在 is_boss_wave=true 时调用 +func spawn_boss(boss_id: String, position: Vector2) -> void: + _boss_entity_id = EnemyManager.spawn(boss_id, position) + var cfg: Dictionary = _load_boss_config(boss_id) + _boss_size_cache = cfg + _phase_thresholds = cfg.get("phase_hp_thresholds", [1.0, 0.6, 0.3]) + _current_phase = 0 + _is_active = true + EventBus.emit(EventID.BOSS_PHASE_CHANGED, { + "boss_id": boss_id, "phase": 0, "bgm_layer": "boss" + }) + +func _physics_process(_delta: float) -> void: + if not _is_active or _boss_entity_id < 0: return + var hp_pct: float = EnemyManager.get_hp_percent(_boss_entity_id) + # 检测阶段切换(阈值降序,phase 0→1→2) + var next_phase := _current_phase + for i in range(_current_phase + 1, _phase_thresholds.size()): + if hp_pct <= _phase_thresholds[i]: + next_phase = i + if next_phase != _current_phase: + _current_phase = next_phase + _on_phase_changed(_current_phase) + +func _on_phase_changed(phase: int) -> void: + EventBus.emit(EventID.BOSS_PHASE_CHANGED, { + "boss_id": EnemyManager.get_type(_boss_entity_id), + "phase": phase, "bgm_layer": "boss_phase_%d" % phase + }) + # 触发特殊技能(如护盾、新 spawn pattern、移速变化) + # 具体行为由 boss_design.md §6 的各 Boss 脚本重写 _on_phase_enter(phase) + +func _on_enemy_killed(payload: Dictionary) -> void: + if payload.get("enemy_id", -1) != _boss_entity_id: return + _is_active = false + _boss_entity_id = -1 + EventBus.emit(EventID.BOSS_KILLED, { + "boss_id": EnemyManager.get_type(payload["enemy_id"]), + "wave_num": WaveManager.wave_num + }) + # WaveManager 订阅 BOSS_KILLED 后发出 WAVE_COMPLETE(kill_boss clear_condition) + +func reset() -> void: + _boss_entity_id = -1; _current_phase = 0; _is_active = false + _phase_thresholds.clear() + +# ── 只读查询接口(BulletManager 碰撞检测 + UIManager Boss 血条)──── +func is_active() -> bool: return _is_active +func get_boss_entity_id() -> int: return _boss_entity_id + +func get_collision_rect() -> Rect2: + # Boss 碰撞 AABB(BulletManager 使用,用于超大碰撞体绕过 SpatialGrid) + if not _is_active: return Rect2() + var pos := EnemyManager.get_last_position(_boss_entity_id) # Boss 仍存活时 get_last_position 返回当前位置 + var cfg: Dictionary = _boss_size_cache # spawn_boss 时从 _load_boss_config 读取 "collision_radius" + var r: float = cfg.get("collision_radius", 80.0) + return Rect2(pos.x - r, pos.y - r, r * 2, r * 2) + +func _load_boss_config(boss_id: String) -> Dictionary: + # 从 res://resources/bosses/{boss_id}.json 加载 Boss 配置(同步加载,spawn 时调用一次) + var path := "res://resources/bosses/%s.json" % boss_id + if not FileAccess.file_exists(path): + push_warning("BossManager: boss config not found: %s" % path) + return {} + var raw: String = FileAccess.open(path, FileAccess.READ).get_as_text() + return JSON.parse_string(raw) + # 配置字段(规范): + # { "phase_hp_thresholds": [1.0, 0.6, 0.3], + # "collision_radius": 80.0, + # "spawn_pattern": "center", + # "phase_actions": { "1": "spawn_minions", "2": "enrage" } } +``` + +#### Boss 碰撞策略 + +Boss 半径通常 > 64px(超过 `LARGE_PROJECTILE_THRESHOLD`),使用与"超大弹体豁免"对称的策略: + +```gdscript +# BulletManager 子弹命中检测(_physics_process 中,SpatialGrid 路径) +# 超大碰撞体(Boss)同样绕过 SpatialGrid,改用 Boss 自定义 AABB 全量测试 +if BossManager.is_active(): + var boss_aabb: Rect2 = BossManager.get_collision_rect() + for i in _active_count: + var bx := _data[i * BULLET_STRIDE]; var by := _data[i * BULLET_STRIDE + 1] + if boss_aabb.has_point(Vector2(bx, by)): + _on_bullet_hit(i, BossManager.get_boss_entity_id()) +``` + +--- + +### 5.10 UIManager 架构设计 + +`UIManager` 是 UI 系统的唯一协调者,订阅 `GameCycleManager` 的状态变化,驱动各 UI 场景的显示/隐藏。业务逻辑(金币计算、法术编译)由各业务 Manager 负责;UIManager 只负责**路由**(什么状态显示什么界面)和 **View 刷新**(将数据渲染到 Control 节点)。 + +#### 职责边界 + +| 职责 | 归属 | 理由 | +| :--- | :--- | :--- | +| 游戏状态判断(什么时候进入商店)| `GameCycleManager` | 状态机唯一持有状态 | +| UI 场景切换(show/hide CanvasLayer 子场景)| `UIManager` | UI 协调者唯一职责 | +| 数据计算(价格/稀有度/词条效果)| `ShopManager` / `UpgradeSystem` | 与 UI 无关,可独立测试 | +| Control 节点刷新(Label.text / ProgressBar.value)| UIManager 各子函数 | 表现层 | +| 玩家操作确认(点击购买/选择词条)| UIManager → 调用对应 Manager 接口 | 控制流 | + +#### UIManager 数据模型 + +```gdscript +# ui_manager.gd (Autoload: UIManager) + +# ── 各 UI 场景实例(懒加载,首次显示时实例化)──────────────────── +var _shop_ui: Control = null # res://scenes/ui/ShopUI.tscn +var _inventory_ui: Control = null # res://scenes/ui/InventoryUI.tscn +var _upgrade_ui: Control = null # res://scenes/ui/UpgradeChoiceUI.tscn +var _hud: Control = null # res://scenes/ui/HUD.tscn(BattleScene 挂载时创建) +var _main_menu: Control = null # res://scenes/ui/MainMenuUI.tscn +var _game_over: Control = null # res://scenes/ui/GameOverUI.tscn +var _pause_menu: Control = null # res://scenes/ui/PauseMenuUI.tscn + +# ── RNG(用于 UpgradeSystem.build_choices 的确定性种子)────────── +var _rng: RandomNumberGenerator = RandomNumberGenerator.new() +# _rng.seed 在每次进入 WAVE_RESULT 状态时重置(保持波次间确定性) + +# ── 伤害数字池 ────────────────────────────────────────────────── +const MAX_DAMAGE_NUMBERS: int = 30 +var _dmg_num_pool: Array[Label] = [] # 飘字 Label 池(重复利用,减少节点创建) +var _dmg_num_active: int = 0 + +func _ready() -> void: + EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_state_changed) + EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged) + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) + EventBus.subscribe(EventID.SPELL_DROP_PICKUP, _on_spell_drop_pickup) + # 预分配伤害数字池 + for i in MAX_DAMAGE_NUMBERS: + var lbl := Label.new() + lbl.visible = false + add_child(lbl) + _dmg_num_pool.append(lbl) +``` + +#### 状态→UI 路由表 + +`GameCycleManager` 在每次 `transition_to(next)` 末尾发送 `EventID.GAME_STATE_CHANGED`(payload: `{prev, next}`),UIManager 的 `_on_state_changed` 根据下表切换: + +| GameState | 显示的 UI | 隐藏的 UI | 附加操作 | +| :--- | :--- | :--- | :--- | +| `MAIN_MENU` | MainMenuUI | 其他全部 | 停止 HUD | +| `LOADING` | 无(过渡遮罩由 GameCycleManager 驱动)| 其他 | — | +| `SHOP` | ShopUI | HUD, InventoryUI | `_refresh_shop_ui()` | +| `WAVE_INTRO` | HUD(波次预告)| ShopUI, UpgradeUI | `_show_wave_intro_banner()` | +| `WAVE` | HUD | 其他 | 激活伤害数字池 | +| `WAVE_RESULT` | UpgradeChoiceUI(3 张词条)| HUD | `_refresh_upgrade_choices()` | +| `GAME_OVER` | GameOverUI | 其他 | 显示分数/波次 | +| `GAME_CLEARED` | GameOverUI(通关变体)| 其他 | 显示 Endless 入口 | +| `PAUSE` | PauseMenuUI(叠加当前 UI)| — | `get_tree().paused = true` | + +```gdscript +func _on_state_changed(payload: Dictionary) -> void: + var next: int = payload.get("next", -1) + _hide_all_ui() + match next: + GameCycleManager.GameState.MAIN_MENU: _show(_main_menu, "res://scenes/ui/MainMenuUI.tscn") + GameCycleManager.GameState.SHOP: + _show(_shop_ui, "res://scenes/ui/ShopUI.tscn") + _refresh_shop_ui() + GameCycleManager.GameState.WAVE_INTRO: _show(_hud, "res://scenes/ui/HUD.tscn"); _show_wave_intro_banner() + GameCycleManager.GameState.WAVE: _show(_hud, "res://scenes/ui/HUD.tscn") + GameCycleManager.GameState.WAVE_RESULT: + _show(_upgrade_ui, "res://scenes/ui/UpgradeChoiceUI.tscn") + _refresh_upgrade_choices() + GameCycleManager.GameState.GAME_OVER,\ + GameCycleManager.GameState.GAME_CLEARED: + _show(_game_over, "res://scenes/ui/GameOverUI.tscn") + GameCycleManager.GameState.PAUSE: + _show(_pause_menu, "res://scenes/ui/PauseMenuUI.tscn") + get_tree().paused = true + +func _show(ref: Control, scene_path: String) -> Control: + if ref == null: + ref = load(scene_path).instantiate() + add_child(ref) # UIManager Autoload 自身作为父节点(跨场景持久) + ref.visible = true + return ref + +func _hide_all_ui() -> void: + for ui in [_shop_ui, _inventory_ui, _upgrade_ui, _hud, _main_menu, _game_over, _pause_menu]: + if ui: ui.visible = false + get_tree().paused = false # 确保 PAUSE 状态退出时解除暂停 +``` + +#### 伤害数字池接口 + +```gdscript +func show_damage_number(amount: float, position: Vector2, is_crit: bool = false) -> void: + # 每帧最多弹出 10 个(帧节流由调用方 BulletManager 批处理) + var lbl := _acquire_dmg_label() + if lbl == null: return + lbl.text = str(int(amount)) + ("!" if is_crit else "") + lbl.modulate = Color.RED if is_crit else Color.WHITE + lbl.global_position = position + lbl.visible = true + # 0.6s 飘字动画后归还池(使用 Tween,避免 Timer 节点堆叠) + var tween := create_tween() + tween.tween_property(lbl, "global_position", position + Vector2(0, -40), 0.6) + tween.parallel().tween_property(lbl, "modulate:a", 0.0, 0.6) + tween.tween_callback(func(): lbl.visible = false; _dmg_num_active -= 1) + +func _acquire_dmg_label() -> Label: + if _dmg_num_active >= MAX_DAMAGE_NUMBERS: return null + for lbl in _dmg_num_pool: + if not lbl.visible: + _dmg_num_active += 1 + return lbl + return null +``` + +#### ShopUI 刷新协议 + +```gdscript +func _refresh_shop_ui() -> void: + # ShopManager.get_slots() 返回当前 SLOT_COUNT=6 个商品 Dictionary + var slots: Array = ShopManager.get_slots() + _shop_ui.refresh(slots, PlayerManager.gold, WaveManager.wave_num) + # ShopUI.refresh() 是 Control 层函数,仅做 Label/Icon 更新,无业务逻辑 + +func _refresh_upgrade_choices() -> void: + var choices: Array[UpgradeDef] = UpgradeSystem.build_choices(WaveManager.wave_num, _rng) + _upgrade_ui.show_choices(choices) # UpgradeChoiceUI 展示 3 张卡 + +func _on_upgrade_selected(upgrade: UpgradeDef) -> void: + # UpgradeChoiceUI 发送信号,UIManager 接收后调用业务层 + UpgradeSystem.apply_choice(upgrade) + GameCycleManager.transition_to(GameCycleManager.GameState.SHOP) + +func _on_spell_drop_pickup(payload: Dictionary) -> void: + # DropManager 拾取法术卡后通过 EventBus 通知,UIManager 弹出提示 + var spell_id: String = payload.get("spell_id", "") + var auto_equip: bool = payload.get("auto_equip", false) + if auto_equip: + PlayerManager.add_spell_to_inventory(spell_id) + else: + # 弹出"装备到哪个Core?"选择框 + _show_spell_pickup_dialog(spell_id) +``` + +--- + +### 5.11 PassiveDef + PassiveRegistry 设计 + +被动词条是 Roguelite 构建深度的核心扩展机制。`PassiveRegistry` 与 `SpellRegistry` 采用对称设计,启动时从资源目录扫描加载。 + +#### PassiveDef Resource 格式 + +```gdscript +# passive_def.gd - Resource 子类 +class_name PassiveDef +extends Resource + +var passive_id: String = "" # 全局唯一,格式:category_stat(如 "offense_damage_mult") +var display_name: String = "" # tr() KEY,UI 显示用 +var description: String = "" # tr() KEY,支持 {value} 占位符(如 "+{value}% 伤害") +var icon_key: String = "" # UIAtlas 图标键(ADR-C1:必须有对应形状标识) +var rarity: int = 1 # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary +var max_stack: int = 1 # 同一被动最多叠取次数(1=不可重复购买,99=无限) +var value: float = 0.0 # 效果数值(description {value} 占位符的填充值) + +# ── 效果:修改 PlayerStats 的规则 ─────────────────────────────── +# 支持三种效果模式(enum 选一): +enum StatPatchMode { ADD, MULTIPLY, OVERRIDE } + +var stat_field: String = "" # PlayerStats 中被修改的字段名(如 "damage_mult") +var patch_mode: StatPatchMode = StatPatchMode.MULTIPLY +var patch_value: float = 1.0 # ADD: +patch_value;MULTIPLY: *patch_value;OVERRIDE: =patch_value + +func apply_to(stats: PlayerStats) -> void: + if stat_field == "": return + var current: float = stats.get(stat_field) + match patch_mode: + StatPatchMode.ADD: stats.set(stat_field, current + patch_value) + StatPatchMode.MULTIPLY: stats.set(stat_field, current * patch_value) + StatPatchMode.OVERRIDE: stats.set(stat_field, patch_value) +``` + +> **设计约束**:`apply_to()` 只修改 `PlayerStats` 中的 float 字段,不直接修改 `PlayerManager` 的 HP/Gold/XP(防止被动产生经济副作用)。需要修改资源上限(如 `hp_max_bonus`)的被动,通过 `stats.hp_max_bonus` 间接影响,`PlayerManager` 在 `recalculate()` 后重算 `hp_max = 100.0 + stats.hp_max_bonus`。 + +#### PassiveRegistry Autoload + +```gdscript +# passive_registry.gd (Autoload: PassiveRegistry) +var _registry: Dictionary = {} # { passive_id: PassiveDef } +var _by_rarity: Array[Array] = [[], [], [], []] # 与 SpellRegistry 对称 + +func _ready() -> void: + var dir := DirAccess.open("res://resources/passives/") + if dir: + dir.list_dir_begin() + var fname := dir.get_next() + while fname != "": + if fname.ends_with(".tres"): + var def: PassiveDef = load("res://resources/passives/" + fname) + if def and def.passive_id != "": + _registry[def.passive_id] = def + _by_rarity[clamp(def.rarity - 1, 0, 3)].append(def.passive_id) + fname = dir.get_next() + +func get(passive_id: String) -> PassiveDef: + return _registry.get(passive_id, null) + +func has(passive_id: String) -> bool: + return _registry.has(passive_id) + +func all() -> Array: + return _registry.values() # Array[PassiveDef](ShopManager 构建权重池用) +``` + +#### 被动词条设计示例(资源文件) + +```gdscript +# res://resources/passives/offense_damage_mult_10pct.tres +passive_id = "offense_damage_mult_10pct" +display_name = "PASSIVE_DAMAGE_MULT_NAME" # tr() 键 +description = "PASSIVE_DAMAGE_MULT_DESC" # tr() → "全局伤害 +{value}%" +icon_key = "icon_sword_up" +rarity = 1 # Common +max_stack = 5 # 最多叠取 5 次(最终 damage_mult = 1.1^5 ≈ 1.61) +value = 10.0 # 描述占位符:+10% +stat_field = "damage_mult" +patch_mode = StatPatchMode.MULTIPLY +patch_value = 1.1 # 每次叠取:damage_mult *= 1.1 +``` + +--- + +### 5.11.A SpellRegistry 接口定义 + +`SpellRegistry` 是纯只读 Autoload,启动时扫描 `res://resources/spells/` 并加载所有 `SpellNode .tres` 文件,之后提供 O(1) / O(稀有度桶) 查询。 + +```gdscript +# spell_registry.gd (Autoload: SpellRegistry) +var _registry: Dictionary = {} # { spell_id: SpellNode } +var _by_rarity: Array[Array] = [[], [], [], []] # index = rarity-1,每组为 Array[String](spell_id列表) + +func _ready() -> void: + var dir := DirAccess.open("res://resources/spells/") + if dir: + dir.list_dir_begin() + var fname := dir.get_next() + while fname != "": + if fname.ends_with(".tres"): + var node: SpellNode = load("res://resources/spells/" + fname) + if node and node.spell_id != "": + _registry[node.spell_id] = node + var ri := clamp(node.rarity - 1, 0, 3) + _by_rarity[ri].append(node.spell_id) + fname = dir.get_next() + +func get(spell_id: String) -> SpellNode: + return _registry.get(spell_id, null) + +func has(spell_id: String) -> bool: + return _registry.has(spell_id) + +func all() -> Array: + return _registry.values() # Array[SpellNode](用于 ShopManager 构建权重池) + +func random_by_rarity(max_rarity: int, rng: RandomNumberGenerator = null) -> String: + # DropManager 使用:从稀有度 1~max_rarity 的所有法术中随机选一个 ID + # rng 为 null 时使用全局随机(掉落不需要确定性种子) + var pool: Array[String] = [] + for r in range(min(max_rarity, 4)): + pool.append_array(_by_rarity[r]) + if pool.is_empty(): return "" + if rng: return pool[rng.randi() % pool.size()] + return pool[randi() % pool.size()] +``` + +--- + +### 5.14 ProfileManager / SaveSystem 框架设计 + +`ProfileManager` 是持久化层的统一入口,负责 **Run 运行时存档** 和 **全局设置/统计** 的读写。权威实现详见 `implementation_plan.md §2.5.E` 和 `ADR-A2`,本节为接口契约。 + +#### 职责边界 +- **负责**:JSON 文件读写(A/B 双槽写、CRC 校验)、schema 版本迁移、Run 状态持久化、全局设置持久化 +- **不负责**:游戏逻辑、排行榜 HMAC(EndlessRecordsManager 负责) + +```gdscript +# profile_manager.gd (Autoload: ProfileManager) +# Run 存档:user://run_a.json(A槽)+ user://run_b.json(B槽)(权威来源:ADR-A2、certification_checklist.md ST-51) +# 全局设置:user://save_data.json(音量/无障碍等,非 Run 数据) +# schema_version 必须与 ADR-A2 中的 _migrate 链同步 + +const SCHEMA_VERSION: int = 1 # 每次 save 结构变更时递增 + +# ── 全局持久化(设置/统计,非 Run)──────────────────────────────── +func get_float(key: String, default_val: float = 0.0) -> float: + return _global.get(key, default_val) +func set_float(key: String, value: float) -> void: + _global[key] = value; _dirty = true +func get_int(key: String, default_val: int = 0) -> int: + return int(_global.get(key, default_val)) +func set_int(key: String, value: int) -> void: + _global[key] = value; _dirty = true + +# ── Run 状态存档(ADR-A2 §2 波次结束自动存档)──────────────────── +func save_run(run_data: Dictionary) -> void: + # A/B 双槽交替写入(ADR-A2 防断电损坏协议) + run_data["schema_version"] = SCHEMA_VERSION + var which := get_int("run_write_slot", 0) + var path_a := "user://run_a.json"; var path_b := "user://run_b.json" + _write_json(path_a if which == 0 else path_b, run_data) + set_int("run_write_slot", 1 - which) + +func load_run() -> Dictionary: + # 优先读 A 槽;A 槽损坏则降级读 B 槽;均损坏则返回 {}(GameCycleManager 展示损坏提示) + var data := _read_json("user://run_a.json") + if data.is_empty(): + data = _read_json("user://run_b.json") + if not data.is_empty(): + data = _migrate(data) + return data + +func has_run() -> bool: + # GameCycleManager 启动时查询是否有未完成的 Run + return not load_run().is_empty() + +func clear_run() -> void: + # Run 结束(通关/死亡)后调用,删除 Run 存档(保留全局设置/统计) + _write_json("user://run_a.json", {}) + _write_json("user://run_b.json", {}) + +func get_run_seed() -> int: + # ShopManager 确定性种子(新 Run 开始时写入,存档后恢复) + return get_int("run_seed", 0) + +func get_run_count() -> int: + return get_int("run_count", 0) + +func flush() -> void: + # 保存全局设置(音量、无障碍等)→ user://save_data.json(与 Run 存档 run_a/b.json 分离) + # 由 SettingsManager.save_audio_setting 触发或 App 退出时调用 + if _dirty: _write_json("user://save_data.json", _global); _dirty = false + +func _migrate(data: Dictionary) -> Dictionary: + # ADR-A2 §3:链式迁移,v0→v1→v2... + var v: int = data.get("schema_version", 0) + if v < 1: data = _migrate_v0_to_v1(data) + return data +``` + +--- + +### 5.15 SettingsManager 框架设计 + +`SettingsManager` 是游戏设置的运行时接口,持久化委托给 `ProfileManager.flush()`。 + +```gdscript +# settings_manager.gd (Autoload: SettingsManager) +# 依赖:ProfileManager(持久化)、AudioBusID(音量),EventBus(SETTINGS_CHANGED 通知) + +func _ready() -> void: + load_audio_settings() # 恢复持久化音量 + _apply_accessibility_settings() # 高对比度、字体缩放、减少闪烁 + +# ── 音量(ADR-A3.4)──────────────────────────────────────────────── +# 见 ADR-A3.4 伪代码(load_audio_settings / save_audio_setting) + +# ── 无障碍功能(ADR-C1)─────────────────────────────────────────── +func set_colorblind_mode(mode: String) -> void: + # mode: "normal" / "protanopia" / "deuteranopia"(见 ADR-C1.1) + ProfileManager.set_int("colorblind_mode", ["normal","protanopia","deuteranopia"].find(mode)) + EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "colorblind_mode" }) + +func set_font_scale(scale: float) -> void: + ProfileManager.set_float("font_scale", clampf(scale, 0.8, 1.5)) + _apply_font_scale() + EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" }) + +func set_reduce_flash(enabled: bool) -> void: + ProfileManager.set_int("reduce_flash", int(enabled)) + EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "reduce_flash" }) + +func set_high_contrast(enabled: bool) -> void: + ProfileManager.set_int("high_contrast", int(enabled)) + _apply_high_contrast() + EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "high_contrast" }) + +func apply_settings() -> void: + # UIManager 在 SettingsUI 打开时调用,刷新所有设置为持久化值 + load_audio_settings() + _apply_accessibility_settings() + +func get_volume(save_key: String) -> float: + # UIManager 音量滑块初始化时调用 + return ProfileManager.get_float(save_key, 0.0) + +func _apply_accessibility_settings() -> void: + _apply_font_scale() + _apply_high_contrast() + +func _apply_font_scale() -> void: + var scale: float = ProfileManager.get_float("font_scale", 1.0) + # 遍历所有 Label / RichTextLabel 节点并设置 theme_override_font_sizes + pass # 见 ADR-C1.3 + +func _apply_high_contrast() -> void: + pass # 切换 CanvasItem 材质 Shader uniform;见 ADR-C1.4 +``` + +--- + +### 5.11.B CoreRegistry + ConsumableRegistry + ConsumableDef + +`CoreRegistry` 和 `ConsumableRegistry` 与 `SpellRegistry` / `PassiveRegistry` 采用对称模式,补全 `PlayerManager.replace_core()` 和 `use_consumable()` 的依赖链。 + +```gdscript +# ── core_registry.gd (Autoload: CoreRegistry) ────────────────────────────── +# 扫描 res://resources/cores/ 目录,加载所有 CoreDefinition .tres 文件 +var _registry: Dictionary = {} # { core_id: CoreDefinition } + +func _ready() -> void: + var dir := DirAccess.open("res://resources/cores/") + if dir: + dir.list_dir_begin() + var fname := dir.get_next() + while fname != "": + if fname.ends_with(".tres"): + var def: CoreDefinition = load("res://resources/cores/" + fname) + if def and def.core_id != "": _registry[def.core_id] = def + fname = dir.get_next() + +func get(core_id: String) -> CoreDefinition: return _registry.get(core_id, null) +func has(core_id: String) -> bool: return _registry.has(core_id) +func all() -> Array: return _registry.values() + +# ── consumable_def.gd ─────────────────────────────────────────────────────── +class_name ConsumableDef +extends Resource + +var consumable_id: String = "" # 全局唯一,格式:consumable_effect(如 "hp_potion_50") +var display_name: String = "" # tr() KEY +var description: String = "" # tr() KEY,支持 {value} 占位符 +var icon_key: String = "" # UIAtlas 图标键 +var price: int = 8 # 商店默认价格(金币) +var value: float = 0.0 # 效果数值 + +func apply(player: PlayerManager) -> void: + pass # 子类重写,或通过 effect_type + value 驱动(参考 PassiveDef.apply_to 模式) + # 示例子类:HP 药水 → player.heal(value);速度水 → player.stats.move_speed += value(本回合) + +# ── consumable_registry.gd (Autoload: ConsumableRegistry) ────────────────── +var _registry: Dictionary = {} # { consumable_id: ConsumableDef } + +func _ready() -> void: + var dir := DirAccess.open("res://resources/consumables/") + if dir: + dir.list_dir_begin() + var fname := dir.get_next() + while fname != "": + if fname.ends_with(".tres"): + var def: ConsumableDef = load("res://resources/consumables/" + fname) + if def and def.consumable_id != "": _registry[def.consumable_id] = def + fname = dir.get_next() + +func get(consumable_id: String) -> ConsumableDef: return _registry.get(consumable_id, null) +func all() -> Array: return _registry.values() +``` + +> **ShopManager 商品池补充**:`ShopItemType.CONSUMABLE` 与 `ShopItemType.CORE` 商品由特定波次固定投放(不进入随机权重池),即 Boss 波前固定刷出 Core 槽位、每波随机出现 1 消耗品。`_build_weighted_pool` 仅负责 SPELL + PASSIVE;CONSUMABLE/CORE 槽位由 `_fill_fixed_slots()` 在 `_fill_slots()` 中单独填充。 + +--- + +### 5.12 MinionManager 架构设计 + +`MinionManager` 管理玩家召唤物(友方单位)的生命周期,与 `EnemyManager` 共享 `SpatialGrid` 但 faction 位不同(`+6 高16位 = 1`)。召唤物数量上限较小(通常 ≤ 16),不需要 C# 热路径。 + +#### 职责边界 +- **不处理**:战斗逻辑(委托 SpellEvaluator / StatusManager)、敌人碰撞(委托 BulletManager) +- **负责**:召唤物生命周期(spawn/expire/recall)、最大上限控制、事件通知(MINION_SPAWNED/MINION_EXPIRED) + +```gdscript +# minion_manager.gd (Autoload: MinionManager) + +const MAX_MINIONS: int = 16 # 全局最大召唤物数量 +const DEFAULT_LIFETIME: float = 30.0 # 默认存活秒数(可被 MinionDef 覆盖) + +var _minions: Array[Dictionary] = [] # 活跃召唤物列表,每项:{ id, owner_id, def, lifetime_rem, node } +var _next_id: int = 0 + +func spawn_minion(def: MinionDef, owner_id: int, pos: Vector2) -> int: + # 返回 minion_id;若已达 MAX_MINIONS,先 expire 最旧的(先进先出策略) + # 发出 EventID.MINION_SPAWNED + if _minions.size() >= MAX_MINIONS: + _expire(_minions[0].id) + var id := _next_id + _next_id = (_next_id + 1) % 100000 + var entry := { "id": id, "owner_id": owner_id, "def": def, + "lifetime_rem": def.lifetime if def.lifetime > 0 else DEFAULT_LIFETIME, + "pos": pos } + _minions.append(entry) + EventBus.emit(EventID.MINION_SPAWNED, { "minion_id": id, "owner_id": owner_id, + "current_count": _minions.size() }) + return id + +func recall_all(owner_id: int) -> void: + # 立即移除 owner_id 的所有召唤物(玩家死亡 / Run 结束时调用) + var to_expire := _minions.filter(func(m): return m.owner_id == owner_id) + for m in to_expire: _expire(m.id) + +func get_count(owner_id: int = -1) -> int: + # owner_id = -1 时返回所有召唤物总数 + if owner_id < 0: return _minions.size() + return _minions.filter(func(m): return m.owner_id == owner_id).size() + +func fill_pos_snapshot(out: PackedFloat32Array, out_ids: PackedInt32Array) -> int: + # BulletManager homing 查询友方召唤物(阵营标志:faction=1) + # 返回写入数量;out 格式: [x0, y0, x1, y1, ...] + var n := 0 + for m in _minions: + out[n * 2] = m.pos.x + out[n * 2 + 1] = m.pos.y + out_ids[n] = m.id + n += 1 + return n + +func _physics_process(delta: float) -> void: + var i := 0 + while i < _minions.size(): + _minions[i].lifetime_rem -= delta + if _minions[i].lifetime_rem <= 0.0: + _expire(_minions[i].id) + else: + i += 1 + +func _expire(minion_id: int) -> void: + for i in _minions.size(): + if _minions[i].id == minion_id: + var owner := _minions[i].owner_id + _minions.remove_at(i) + EventBus.emit(EventID.MINION_EXPIRED, { "minion_id": minion_id, "owner_id": owner, + "current_count": _minions.size() }) + return + +func reset() -> void: + # GameCycleManager._reset_all_managers() 调用 + _minions.clear(); _next_id = 0 +``` + +> **MinionDef Resource**:`res://resources/minions/{id}.tres`,字段:`minion_id: String`、`lifetime: float`(≤0 使用默认值)、`move_speed: float`、`damage_mult: float`、`spell_deck_id: String`(召唤物使用的法术)。 + +--- + +### 5.13 VFXManager 框架设计 + +`VFXManager` 管理战斗特效的播放与回收,基于对象池避免运行时节点创建。权威实现参见 `implementation_plan.md §2.5.D`,本节为 §5 级接口契约(供其他 Manager 调用)。 + +#### 职责边界 +- **不处理**:音效(AudioManager 负责)、伤害数字(UIManager 负责) +- **负责**:粒子/精灵特效生命周期(play/stop/reset)、命中火花(spawn_hit_vfx)、区域特效(play_zone) + +```gdscript +# vfx_manager.gd (Autoload: VFXManager) +# C# 热路径:无(特效数量有限,GDScript 对象池足够) + +const MAX_ACTIVE_VFX: int = 64 # 同时活跃特效上限(超出时丢弃最旧) + +# 订阅事件(_ready() 注册): +# EventID.BULLET_HIT → spawn_hit_vfx("hit_spark", world_pos) +# EventID.ENEMY_KILLED → play("death_burst", enemy_pos) +# EventID.STATUS_APPLIED → play("status_" + type_name, target_pos) +# EventID.SPELL_CAST_BEGIN→ play("cast_flash", caster_pos) +# EventID.BOSS_PHASE_CHANGED → play("boss_phase_" + phase, boss_pos) + +func play(effect_id: String, world_pos: Vector2, scale: float = 1.0) -> int: + # 从池中取出一个特效节点并播放;返回 vfx_handle(用于 stop) + # effect_id 对应 res://scenes/vfx/{effect_id}.tscn + pass # 具体实现见 implementation_plan.md §2.5.D + +func stop(vfx_handle: int) -> void: + # 立即停止并回收特效节点(用于持续特效提前结束,如召唤物消失) + pass + +func play_zone(zone_type_id: int, world_pos: Vector2, radius: float, + duration: float) -> int: + # 为 ZoneManager 显示区域特效(持续型,duration 秒后自动停止) + # zone_type_id 对应 ZoneManager 的 zone_type_id,映射到 "zone_{type}" 特效 + return play("zone_%d" % zone_type_id, world_pos, radius / 64.0) + +func spawn_hit_vfx(hit_type: String, world_pos: Vector2) -> void: + # BulletManager BULLET_HIT 事件处理;hit_type:spark / elemental_X / critical + play("hit_" + hit_type, world_pos, 1.0) + +func reset() -> void: + # GameCycleManager._reset_all_managers() 调用,停止并归还所有活跃特效节点 + pass # 具体实现见 implementation_plan.md §2.5.D(遍历 _active_pool,调用 stop/归还) +``` + +> **与 implementation_plan.md 的关系**:`impl §2.5.D` 为完整实现(含池管理细节),本节 §5.13 为接口契约层(供其他 Manager 查阅可调用的 API)。两者互为补充,实现时以 impl §2.5.D 为基础,函数签名以本节为准。 + +--- + +## 6. 技术栈选型总结 + +| 模块 | 方案 | 理由 | +| :--- | :--- | :--- | +| **引擎** | Godot 4.x | 开源免费,2D 性能强,内置物理/动画系统完善 | +| **语言** | GDScript + C#(静态职责划分,见 §6.1) | 非主备关系:GDScript 负责快迭代域(UI / 事件 / 配置 / 游戏循环 / 法术预编译),C# 负责计算密集热路径(`BulletManager` / `EnemyManager` / `SpatialGrid` / `SpellEvaluator` 内层循环);热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享;详见 §6.1 | +| **ECS框架** | Custom Lite (Autoload Manager-based) | 利用 Godot Autoload 实现 Manager 单例,针对本项目定制,避免引入第三方 ECS 库 | +| **物理** | Custom SpatialGrid(主力)+ Godot Area2D(低密度启动路径) | < 200 弹幕:Area2D 信号回调;200+ 弹幕:SpatialGrid dirty-list **主力碰撞**;详见 §4.3 | +| **渲染批次** | MultiMeshInstance2D | 极大量同类子弹用 MultiMesh 渲染,最小化 Draw Call | +| **UI** | Godot Control + 自定义虚拟列表 | 背包道具可能很多,需要虚拟列表优化 | +| **配置** | JSON + GDScript 类型注解(或 .tres Resource) | JSON 灵活易热更;Resource 文件可享受 Godot 编辑器集成 | + +### 6.1 语言职责分工 (Language Partition) + +GDScript 与 C# **不是主备(备用)关系**,而是按职责**静态划分**,从 S0 起并行建立: + +| 职责域 | 语言 | 核心原因 | +| :--- | :--- | :--- | +| 表现层(UI / VFX / 音频 / 动画) | **GDScript** | 节点操作频繁;Inspector 可视化调整;帧预算宽松(< 1ms) | +| 游戏循环(WaveManager / ShopManager / CombatManager 状态机) | **GDScript** | 非高频热路径;快速迭代优先 | +| 数据定义(CoreDefinition / SpellNode / StatusTypeDef) | **GDScript `.tres`** | Godot 编辑器原生 Inspector 支持;数值调整零编译 | +| 配置与存档(ConfigMgr / SpellRegistry / ProfileManager) | **GDScript** | JSON / Resource 读取一次性,非热路径 | +| 法术预编译(`compile_wand`) | **GDScript** | 仅在换牌时触发,非 `_physics_process`;Dictionary 操作 GDScript 更高效 | +| 事件总线(EventBus / EventID) | **GDScript** | 保持单语言,避免跨语言信号绑定复杂性 | +| **`BulletManager._physics_process`** | **C#** | 2000 颗/帧 SoA 积分;`Span` 零拷贝 + struct 零 GC;GDScript ≈8ms → C# ≈0.8ms | +| **`EnemyManager._physics_process`** | **C#** | 1000 敌人 Boid 分离力;向量运算密集;GDScript ≈6ms → C# ≈0.6ms | +| **`SpatialGrid`(重建 + 查询)** | **C#** | 每帧重建 + M×N `query_circle`;纯计算密集,受益于 JIT 内联优化 | +| **`SpellEvaluator.execute_compiled`** | **C#** | 内层 while 循环(MAX_OPS_PER_CPU=40 × 高频施法);`switch` 跳表 JIT 优化 | +| **`StatusManager._physics_process`** | **C#** | 200+ 状态实例逐 tick 计算;swap-and-pop 顺序内存访问 C# 受益更大 | +| **`ZoneManager._physics_process`** | **C#** | Zone tick + SpatialGrid 批量查询组合 | + +#### 跨语言边界规则(ADR-L1) + +每次 GDScript ↔ C# `Call()` / `Set()` 约有 1–5µs 开销,以下规则确保该开销不进入任何热路径: + +**规则 1:禁止在 C# 内层循环体内调用 GDScript 方法** + +`BulletManagerCs._PhysicsProcess` 的 `for` 循环体内不得出现 `GodotObject.Call()` / `.Set()`。 +2000 次/帧 × 1–5µs = 2–10ms,直接耗尽帧预算。 + +**规则 2:热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享** + +GDScript Autoload 持有 `_data: PackedFloat32Array`;C# 通过 `AsSpan()` 获取 `Span` 原生指针,**无内存复制**: + +```csharp +// BulletManagerCs.cs — _PhysicsProcess 热路径 +public override void _PhysicsProcess(double delta) +{ + float f = (float)delta; + using var span = _data.AsSpan(); // 零拷贝:指向 PackedFloat32Array 底层内存 + int end = _activeCount * BulletStride; // const int BulletStride = 12 + for (int i = 0; i < end; i += BulletStride) + { + span[i] += span[i + 2] * f; // px += vx * dt + span[i + 1] += span[i + 3] * f; // py += vy * dt + span[i + 4] -= f; // lifetime -= dt + } +} +``` + +**规则 3:帧末批量通知 GDScript,不在循环内逐条回调** + +C# 内部用 `List` 收集当帧命中 ID,`_PhysicsProcess` 末尾**一次性**通知 EventBus: + +```csharp +// 帧末单次跨语言调用(循环外) +// ⚠️ 禁止 _hitBulletIds.ToArray()——每帧 new int[] 产生 GC 分配。 +// 改用可复用的类字段 Godot.Collections.Array _hitBuffer(_Ready() 中 new 一次): +// private readonly Godot.Collections.Array _hitBuffer = new(); +if (_hitBulletIds.Count > 0) +{ + _hitBuffer.Clear(); + foreach (var id in _hitBulletIds) _hitBuffer.Add(id); + _eventBus.Call("emit_batch", (int)EventId.BulletHit, _hitBuffer); + _hitBulletIds.Clear(); +} +``` + +**规则 4:C# 组件挂为 GDScript Autoload 的子节点** + +``` +(Autoload) bullet_manager.gd ← GDScript:_data PackedFloat32Array + 对外接口(spawn/despawn) + └─ BulletManagerCs.cs ← C# Node:_Ready() 缓存父节点引用;_PhysicsProcess 执行热路径 +``` + +GDScript 对外接口(`spawn_bullet` / `despawn_bullet`)保持不变,其他 GDScript 系统无感知 C# 的存在。 +C# `_Ready()` 中获取父节点并缓存 `_data` 引用,之后每帧直接操作,无跨语言调用。 + +**BulletManager GDScript 对外接口(权威签名)** + +```gdscript +# bullet_manager.gd (Autoload: BulletManager) +# 以下为 GDScript 对外 API;C# 热路径(_physics_process 积分)不在此列出。 + +const BULLET_STRIDE: int = 12 # SoA 完整布局(权威:arch §4.2): +# [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ] +# 0 1 2 3 4 5 6 7 8 9 10 11 +# 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary(非热路径) + +var _data: PackedFloat32Array = PackedFloat32Array() +var _active_count: int = 0 +var _bullet_contexts: Dictionary = {} # { bullet_id: int → Dictionary(冷数据)} + +func spawn_bullet(def: ProjectileDef, pos: Vector2, vel: Vector2) -> int: + # 返回 bullet_id(SoA 槽位索引);def.lifetime 单位:秒 + # SpellEvaluator、ZoneManager 调用 + pass # 具体实现见 implementation_plan.md §2.2 + +func despawn_bullet(bullet_id: int) -> void: + # 提前回收:lifetime 置 0;C# 下帧 SoA 清理 + if bullet_id < 0 or bullet_id >= _active_count: return + _data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime + +func get_active_count() -> int: return _active_count + +func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: + # PlayerManager.get_aim_direction() 调用(自动瞄准默认模式) + # 委托 EnemyManager.fill_pos_snapshot 后线性扫描;非每帧热路径(仅发射时调用) + return EnemyManager.get_nearest_pos(origin, max_dist) + +func reset() -> void: + _active_count = 0 # 不需要清零数据,_active_count 控制有效范围 + _bullet_defs.clear() +``` + +> **命名统一说明**:文档早期混用 `spawn()` / `spawn_bullet()`,以此处 `spawn_bullet(def, pos, vel)` 为唯一权威签名;`despawn_bullet(id)` 同理。ADR-L1 中提到的"GDScript 对外接口"均指此两个函数。 + +**规则 5:`compile_wand` 保留在 GDScript,不迁移** + +法术预编译仅在换牌时触发(非 `_physics_process`),主要为 `Dictionary` / `Array` 构建操作, +GDScript 开发效率更高;迁移 C# 无性能收益且增加维护成本。 + +--- + +### 6.2 EventBus Autoload 接口定义 + +`EventBus`(`event_bus.gd`)是全局事件总线,**第 6 个 Autoload 初始化**(见 §9.2),所有 Manager 均依赖它。采用整数 ID 替代字符串,避免运行时 String 比较。 + +```gdscript +# event_bus.gd (Autoload: EventBus) +# 核心约定: +# - 所有事件 ID 均在 event_ids.gd (Autoload: EventID) 中声明,不接受魔术数字 +# - C# 热路径通过 emit_batch 一帧内批量发送,降低跨语言调用次数 +# - 订阅回调在 GDScript 侧统一处理,不在 C# 侧订阅(ADR-L1 规则 2) + +var _listeners: Dictionary = {} # { event_id: int → Array[Callable] } + +func subscribe(event_id: int, callback: Callable) -> void: + if not _listeners.has(event_id): + _listeners[event_id] = [] + var arr: Array = _listeners[event_id] + if not arr.has(callback): + arr.append(callback) + +func unsubscribe(event_id: int, callback: Callable) -> void: + if _listeners.has(event_id): + _listeners[event_id].erase(callback) + +func emit(event_id: int, payload: Dictionary = {}) -> void: + if not _listeners.has(event_id): return + for cb: Callable in _listeners[event_id].duplicate(): # duplicate() 防止回调内修改订阅表 + cb.call(payload) + +func emit_batch(events: Array) -> void: + # C# BulletManager / EnemyManager 在 _physics_process 末尾调用(ADR-L1 规则 3) + # events 格式:[ [event_id: int, payload: Dictionary], ... ] + # 在主线程 GDScript 帧末执行,保证监听者在下一个 _process 前收到通知 + for ev in events: + if ev.size() >= 2: + emit(int(ev[0]), ev[1]) + elif ev.size() == 1: + emit(int(ev[0])) + +func reset() -> void: + # GameCycleManager 在 Run 结束时调用,清除所有动态订阅(防跨 Run 事件泄漏) + # ⚠️ 仅清除非常驻订阅(GameCycleManager、UIManager 等 Autoload 在 _ready() 中重新订阅) + _listeners.clear() +``` + +> **使用约定**: +> - `subscribe` / `unsubscribe` 必须成对调用;Autoload 在 `_ready()` 中订阅,节点在 `_exit_tree()` 中取消。 +> - `emit_batch` 仅供 C# Manager 使用,格式见注释;普通 GDScript Manager 直接调用 `emit`。 +> - EventID 目录权威来源:`implementation_plan.md §2.1`(1–21,下一空位 22)。 + +--- + +## 7. 目录规范建议 + +```text +project.godot +scripts/ + autoloads/ # Godot Autoload 单例 (EventBus, BulletManager, EnemyManager) + core/ # 核心架构 (ObjectPool, SpatialGrid, BaseClasses) + systems/ # 独立系统 (InputManager, AudioManager, ResourceManager) + domain/ # 游戏业务逻辑 + spell_system/ # 法术解释器, SpellNode 定义, SpellDeck + combat/ # 伤害计算, 弹道管理 (BulletManager) + enemy/ # AI 行为, EnemyManager + ui/ # UI 控制脚本, 特效表现 + config/ # JSON 配置加载器与类型定义 +csharp/ + autoloads/ # C# 热路径内核 (BulletManagerCs, EnemyManagerCs) + systems/ # C# 计算模块 (SpellEvaluatorCs, SpatialGridCs, StatusManagerCs, ZoneManagerCs) +scenes/ + main/ # 主场景, 战斗场景 + ui/ # UI 场景 (背包, 商店, HUD) + entities/ # 预制体场景 (子弹, 敌人, 特效) +resources/ + spells/ # 法术数据 (.tres 或 .json) + enemies/ # 怪物数据 +``` + +--- + +## 8. 架构决策记录 (Architecture Decision Records) + +### ADR-R4-N2:属性快照方案(Snapshot vs Dynamic) + +> **结论:采用 Snapshot(快照)方案。** + +DoT(持续伤害)、召唤物(Minion)、持续性法术的属性在**施加/发射瞬间**锁定, +不跟随玩家后续属性变化。即: + +- DoT 的每 tick 伤害 = 施加瞬间的 `attunement_bonus` × `base_damage`(固定值),后续装备变化不影响已施加的 DoT。 +- Minion 的各项属性(速度/伤害/血量)= 召唤瞬间的玩家属性×配方系数,召唤后独立计算,不随玩家属性波动。 +- 实现方式:`ProjectileDef` / `MinionSpawnParams` 在填充时立刻将计算完毕的数值写入, + BulletManager / MinionManager 直接使用存储值,不保存对玩家属性表的引用。 + +**设计原因**:Dynamic 方案(实时跟随)会导致属性换装瞬间大量已存在实体同步刷新, +产生不可控的计算尖峰,且与 SoA 热数组布局不兼容。Snapshot 方案计算开销集中在"施加时刻", +运行时热路径无额外查询,更符合 Low-GC 原则。 + +详见 `docs/mechanics/combat_mechanics_extensions_v2.md §4.2`。 + +--- + +### ADR-R4-N4:召唤物/友军管理方案(MinionManager,方案 B) + +> **结论:采用独立的 MinionManager(方案 B),不在 EnemyManager 中混管召唤物。** + +`MinionManager` 为独立的 Autoload 单例,管理玩家召唤的炮台/傀儡/宠物类实体: + +- **与 EnemyManager 隔离**:EnemyManager 的 SoA 中 `faction_and_type` 字段不扩展到友军, + 避免碰撞 Layer 混乱和阵营判断逻辑膨胀。 +- **Minion SoA stride**:`[x, y, vx, vy, hp, hp_max, owner_id, behavior_state]`(stride=8), + 与 EnemyManager 的 stride=8 同构,便于复用 SpatialGrid 查询逻辑。 +- **行为驱动**:Minion 的 AI 行为(巡逻/护卫/定点炮击)由 `behavior_state` 枚举驱动, + 每帧在 `MinionManager._physics_process` 中批处理;不使用独立 Node `_process`。 +- **生命周期**:Minion 属性在召唤瞬间 Snapshot(见 ADR-R4-N2),由 `owner_id` 关联到施法者; + 施法者死亡时,对应的所有 Minion 同帧标记为"消退状态"(延迟 1 秒淡出销毁)。 +- **上限保护**:单个玩家同场最多 `MAX_MINIONS = 20` 个 Minion,超出时最早召唤的自动消退。 + +详见 `docs/mechanics/advanced_mechanics_summons_and_environment.md §3.1`(方案 B 推荐)。 + +--- + +### ADR-R5-N1:地面效果系统架构(ZoneManager) + +> **结论:采用独立的 ZoneManager(Autoload),Zone 实体基于 SpatialGrid 空间索引,不使用 Area2D。** + +`ZoneManager` 管理战场中的持久化地面效果(毒液池、岩浆区、冻结地面等): + +- **数据结构**:`_zones: Array[ZoneData]`,ZoneData 字段:`[cx, cy, radius, status_type_id, duration, tick_interval, tick_accum, root_owner_id]`(stride=8;`tick_interval` 与 `tick_accum` 分开存储,支持每个 Zone 配置独立应用频率)。 +- **碰撞检测**:每 `_physics_process` 帧调用 `SpatialGrid.query_circle(zone_center, zone_radius)`,对返回的敌人实体批量施加状态;不使用 Area2D,不产生 Node 开销。 +- **上限保护**:`MAX_ZONES = 64`(超出时最早创建的 Zone 被顶掉)。 +- **渲染**:Zone 的地面贴花由 `VFXManager.play_zone("poison_pool", center, radius)` 驱动,不在逻辑层操作节点。 +- **生命周期**:Zone 创建时 duration 快照,每帧 duration -= delta;降至 ≤ 0 时用 swap-and-pop 移除,同步通知 VFXManager 淡出。 +- **与 SpellSystem 的接口**:`ActionSplashPoison` 等 ACTION 节点执行时调用 `ZoneManager.spawn_zone(...)` 而非直接操作场景。 + +```gdscript +# zone_data.gd (inline struct, not a class_name Resource to avoid GC) +# 存储于 ZoneManager._zone_data: PackedFloat32Array,stride = 8 +# [0]=cx [1]=cy [2]=radius [3]=status_type_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id +# tick_interval:两次状态应用之间的最小间隔(秒,由 spawn_zone 调用方指定) +# tick_accum:当前累积时间(运行时变量),>= tick_interval 时触发一次 APPLY_DAMAGE 并减去 tick_interval + +# zone_manager.gd (Autoload) +const ZONE_STRIDE: int = 8 +const MAX_ZONES: int = 64 +var _zone_data: PackedFloat32Array # SoA 热数据 +var _active_zones: int = 0 + +func spawn_zone(cx: float, cy: float, radius: float, + status_id: int, duration: float, + tick_interval: float, # Zone 每隔多少秒对范围内敌人应用一次状态 + owner_id: int) -> void: + if _active_zones >= MAX_ZONES: + # 顶掉最旧的 Zone(index 0),整体前移(O(N),N≤64 可接受) + # PackedFloat32Array 无 remove_at(),用手动循环前移实现首元素删除 + for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE): + _zone_data[j - ZONE_STRIDE] = _zone_data[j] + _active_zones -= 1 + var base: int = _active_zones * ZONE_STRIDE + _zone_data.resize((_active_zones + 1) * ZONE_STRIDE) + _zone_data[base + 0] = cx; _zone_data[base + 1] = cy + _zone_data[base + 2] = radius; _zone_data[base + 3] = status_id + _zone_data[base + 4] = duration; _zone_data[base + 5] = tick_interval + _zone_data[base + 6] = 0.0 # tick_accum 初始为 0 + _zone_data[base + 7] = owner_id + _active_zones += 1 + +func _physics_process(delta: float) -> void: + var i: int = 0 + while i < _active_zones: + var base: int = i * ZONE_STRIDE + _zone_data[base + 4] -= delta # duration 倒计时 + _zone_data[base + 6] += delta # tick_accum 累积 + + # 速率限制:仅当 tick_accum >= tick_interval 时才触发状态应用,防止 64 个 Zone 每帧 + # 对 1000 个敌人各触发,产生最多 64,000 APPLY_DAMAGE 事件/帧。 + # 外层 if 守卫 + 内层 while + -= tick_interval:保留余量精度,且支持大帧多次跳字。 + # SpatialGrid.query_circle 仅在 if 守卫内调用一次,不在 while 内重复查询。 + if _zone_data[base + 6] >= _zone_data[base + 5]: + var cx: float = _zone_data[base + 0]; var cy: float = _zone_data[base + 1] + var rad: float = _zone_data[base + 2]; var sid: int = int(_zone_data[base + 3]) + var targets := SpatialGrid.query_circle(Vector2(cx, cy), rad) + while _zone_data[base + 6] >= _zone_data[base + 5]: + _zone_data[base + 6] -= _zone_data[base + 5] # -= tick_interval 保留余量精度 + for t_id in targets: + StatusManager.apply(t_id, sid, 1.0, int(_zone_data[base + 7])) # intensity=1.0, owner_id=zone_owner + + if _zone_data[base + 4] <= 0.0: # duration 耗尽,移除此 Zone + var cx_exp: float = _zone_data[base + 0]; var cy_exp: float = _zone_data[base + 1] + var rad_exp: float = _zone_data[base + 2] + VFXManager.play("zone_expire", Vector2(cx_exp, cy_exp), rad_exp / 64.0) + # swap-and-pop 移除(O(1),顺序可乱) + if i < _active_zones - 1: + for k in ZONE_STRIDE: + _zone_data[base + k] = _zone_data[(_active_zones - 1) * ZONE_STRIDE + k] + _active_zones -= 1 + else: + i += 1 + +func reset() -> void: + # GameCycleManager._reset_all_managers() 调用,清空所有活跃区域特效 + _active_zones = 0 # SoA 数据不清零,_active_zones 控制有效范围(惰性清理) +``` + +> **与 BulletManager 的区别**:BulletManager 管理短生命周期(秒级)的投射物;ZoneManager 管理中等生命周期(数秒~数十秒)的区域场。两者共享同一个 `SpatialGrid` 实例,但查询入口不同(BulletManager 查敌人,ZoneManager 也查敌人但传入 zone_center/radius)。 + +--- + +### ADR-R5-N2:Core Feature Tags 字符串常量规范 + +> **问题**:`if "persistent_memory" not in core.feature_tags` 使用裸字符串比较,拼写错误时静默失效,且重构不安全。 + +> **结论**:所有 Feature Tag 字符串必须通过 `CoreFeatureTag` 常量访问,禁止在业务代码中写裸字符串。 + +```gdscript +# core_feature_tag.gd (Autoload: CoreFeatureTag) +## Core 特性标签常量表 — 所有系统通过此处引用,禁止裸字符串 +const PERSISTENT_MEMORY: String = "persistent_memory" +const DUAL_STREAM: String = "dual_stream" +const SHUFFLE_DECK: String = "shuffle_deck" +const INFINITE_SPELLS: String = "infinite_spells" +const ALWAYS_CAST_LAST: String = "always_cast_last" + +## 使用示例(正确): +## if CoreFeatureTag.PERSISTENT_MEMORY in core.feature_tags: ... +## +## 禁止的写法: +## if "persistent_memory" in core.feature_tags: ... # ❌ 裸字符串 +``` + +新增 Feature Tag 流程: +1. 在 `CoreFeatureTag` 中追加常量(命名规则:全大写下划线分隔)。 +2. 在 `core_wand_design.md §3` Feature Tags 表格中登记效果与稀有度。 +3. 在 `SpellEvaluator` 或对应 Manager 中实现逻辑,通过 `CoreFeatureTag.XXX` 引用。 + +--- + +### ADR-A1:本地化架构(L10n Infrastructure) + +> **结论:从 S1 起强制所有玩家可见字符串通过 `tr()` 包裹;本地化是开发规范而非后期工程。** + +商业 Steam 发行需支持至少简体中文 / 繁体中文 / 英语 / 日语四语。延后本地化会导致数千条裸字符串的批量替换,历史代价极高。 + +**规范约束**: +* 所有 UI 标签、法术名称、状态名称、Boss 名称、提示文本字符串字面量**必须**用 `tr("KEY")` 包裹。 +* 禁止在业务代码中写裸中文字符串(仅 `push_warning` / `push_error` 调试输出除外)。 +* 使用 Godot 内置 `.po` 文件(兼容 Gettext 工具链),路径 `res://translations/zh_CN.po` / `en.po`。 + +**键名规范**:`<模块>_<实体>_<含义>`,全大写下划线,例: + +| 键 | 对应文本 | +| :--- | :--- | +| `SPELL_SPARK_BOLT_NAME` | "电花弹" | +| `SPELL_SPARK_BOLT_DESC` | "发射一枚快速电花弹,命中造成闪电伤害" | +| `STATUS_BURN_NAME` | "燃烧" | +| `UI_SHOP_REROLL_TOOLTIP` | "花费 {gold} 金币刷新商店" | + +**实施时机**:S1 随 ConfigMgr 一并建立翻译加载流程(`ProjectSettings/locale` + `TranslationServer.add_translation()`);S1 起所有新增文本直接按规范写键,不留技术债。 + +--- + +### ADR-A2:局内存档 / 断点续玩(Run State Persistence) + +> **结论:S2 实现波次边界自动存档,采用 A/B 双槽写入防崩溃损坏,支持断点续玩。** + +商业 Roguelite(Hades / Slay the Spire / Balatro)均支持中途退出后恢复到当前波次起始状态。 + +**存档时机**: +1. 每波**战斗结束**(进入商店阶段前):序列化 `PlayerState + ActiveCore + WaveNum`。 +2. 每次**商店关闭**(进入下一波前):追加序列化 `InventoryState + UpgradePicks`。 +3. 游戏**异常退出**:通过 `_notification(NOTIFICATION_WM_CLOSE_REQUEST)` 触发最后一次存档。 + +**序列化字段**(`run_state_v1.json`): + +| 字段 | 内容 | +| :--- | :--- | +| `schema_version` | 当前 = 1;迁移时递增 | +| `wave_num` | 当前波次(读档后从此波重新开始) | +| `elapsed_sec` | 本次 Run 总计时(无尽模式得分依据) | +| `player_hp` | 当前血量 | +| `player_stats` | 所有属性词条快照(Dictionary) | +| `cores` | 3 个 Core 的完整 SpellDeck JSON(含 `core_id` + `slots`,格式见 §3 E2) | +| `active_core_idx` | 当前激活 Core 索引(0/1/2) | +| `unlocked_upgrades` | 已选升级 ID 数组 | +| `shop_seed` | 随机种子(保证重进后商店展示相同选项,防刷新重开骗局) | + +**A/B 双槽写入(防崩溃数据损坏)**: + +```gdscript +# game_cycle_manager.gd(或 ProfileManager) +const _SLOT_A := "user://run_a.json" +const _SLOT_B := "user://run_b.json" + +func save_run(data: Dictionary) -> void: + var which := ProfileManager.get_int("run_write_slot", 0) # 0=A, 1=B + var path := _SLOT_A if which == 0 else _SLOT_B + var f := FileAccess.open(path, FileAccess.WRITE) + if f: f.store_string(JSON.stringify(data)) + ProfileManager.set_int("run_write_slot", 1 - which) # 下次写另一槽 + +func load_run() -> Dictionary: + for path in [_SLOT_A, _SLOT_B]: + if not FileAccess.file_exists(path): continue + var text := FileAccess.open(path, FileAccess.READ).get_as_text() + var parsed: Variant = JSON.parse_string(text) + if parsed is Dictionary and parsed.has("schema_version"): + return parsed + return {} # 无有效存档,从头开始新 Run +``` + +写完 A 槽后下次写 B 槽,两槽交替覆写;任意一次写入崩溃只损坏一个槽,另一槽保留上次完整状态。 + +**存档版本迁移框架(Schema Migration)**:新增字段或修改存档结构时,递增 `schema_version` 并在 `_migrate_run()` 中追加对应迁移函数;禁止在业务代码中对新字段直接 `.get()` 而绕过迁移。 + +```gdscript +# profile_manager.gd — 存档迁移分发入口 +# 调用时机:load_run() 成功解析 JSON 后、return 前调用 _migrate_run(data) +func _migrate_run(data: Dictionary) -> Dictionary: + var ver: int = data.get("schema_version", 0) + if ver < 1: + data = _migrate_v0_to_v1(data) + # if ver < 2: + # data = _migrate_v1_to_v2(data) # 未来版本在此追加,保持链式顺序迁移 + return data + +# v0 → v1:为无 schema_version 的旧存档补全 S2 新增字段的默认值 +func _migrate_v0_to_v1(data: Dictionary) -> Dictionary: + data["schema_version"] = 1 + if not data.has("elapsed_sec"): data["elapsed_sec"] = 0.0 + if not data.has("shop_seed"): data["shop_seed"] = randi() # 随机补种,防刷新重开 + if not data.has("unlocked_upgrades"): data["unlocked_upgrades"] = [] + if not data.has("active_core_idx"): data["active_core_idx"] = 0 + return data +``` + +**版本变更记录**(每次 `schema_version` 递增后在此登记): + +| schema_version | 变更内容 | 对应切片 | +| :--- | :--- | :--- | +| 1 | 初始版本:`wave_num` / `elapsed_sec` / `player_hp` / `player_stats` / `cores` / `active_core_idx` / `unlocked_upgrades` / `shop_seed` | S2 | + +--- + +## ADR-R6 — Endless 排行榜数据完整性 (Leaderboard Integrity) + +> **问题**:`user://endless_records.json` 是纯文本 JSON,玩家可直接用文本编辑器修改分数。 +> 本 ADR 定义基于 HMAC-SHA256 的本地签名方案,对抗普通玩家随意手改,同时不影响离线可玩性。 + +### R6.1 威胁模型 + +| 攻击向量 | 对抗手段 | 是否覆盖 | +| :--- | :--- | :--- | +| 直接编辑 JSON 文件 | HMAC 签名验证失败 → 拒绝载入 | ✅ | +| 复制高分存档文件 | 签名与内容绑定,复制后验证通过(允许,属于本地副本) | 允许 | +| 逆向提取 HMAC Key 后重签 | 超出本方案防护范围;如需强防护则依赖服务器排行榜 | ❌ | +| 网络传输篡改(Steam 排行榜提交)| Steam SDK 侧验证,非本地文件问题 | ❌(Steam 负责)| + +### R6.2 实现规范 + +```gdscript +# scripts/domain/endless_records_manager.gd +class_name EndlessRecordsManager +extends RefCounted + +# HMAC Key:32 字节随机密钥,S5 开发前生成并硬编码到此处 +# ⚠️ 安全注意:此 Key 仅防普通手改,不能防逆向;不含敏感用户数据,可接受。 +# 生成方式:python3 -c "import secrets; print(list(secrets.token_bytes(32)))" +const _HMAC_KEY: PackedByteArray = [ + 0x3F, 0x7A, 0x12, 0xB8, 0x5C, 0xE4, 0x91, 0x2D, + 0x6F, 0x04, 0xAA, 0x73, 0xC9, 0x18, 0x55, 0xE7, + 0x8B, 0x31, 0xF6, 0x4E, 0x0D, 0x90, 0x26, 0x7C, + 0xD2, 0x47, 0xBE, 0x5A, 0x83, 0x1F, 0xCA, 0x69 +] # 实际集成时替换为项目专属 Key + +const RECORDS_PATH: String = "user://endless_records.json" + +func save(records: Array) -> void: + var data_str := JSON.stringify(records) + var sig := _sign(data_str) + var envelope := { "data": records, "sig": sig, "ver": 1 } + var f := FileAccess.open(RECORDS_PATH, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(envelope)) + +func load() -> Array: + if not FileAccess.file_exists(RECORDS_PATH): return [] + var raw: Variant = JSON.parse_string( + FileAccess.open(RECORDS_PATH, FileAccess.READ).get_as_text()) + if not raw is Dictionary: return [] + var records: Variant = raw.get("data", []) + var expected := _sign(JSON.stringify(records)) + if raw.get("sig", "") != expected: + push_warning("EndlessRecords: 签名验证失败,疑似被篡改,分数已重置") + return [] # 拒绝载入,不崩溃;UI 显示"分数不可用" + return records + +func _sign(data: String) -> String: + var crypto := Crypto.new() + return crypto.hmac_digest( + HashingContext.HASH_SHA256, + _HMAC_KEY, + data.to_utf8_buffer() + ).hex_encode() +``` + +### R6.3 分数提交 Steam 排行榜前的验证 + +```gdscript +# game_cycle_manager.gd — Endless 结算时 +func _submit_endless_score(wave: int, elapsed_sec: float) -> void: + var score: int = wave * 100000 + (86400 - int(elapsed_sec)) # wave 优先,elapsed_sec 作为同波次决胜分 + var records: Array = EndlessRecordsManager.new().load() + if records.is_empty() and FileAccess.file_exists( + EndlessRecordsManager.RECORDS_PATH): + # 文件存在但 load() 返回空 → 被篡改,不提交 Steam 排行榜 + UIManager.show_toast("分数验证失败,本次成绩不上传排行榜") + return + # 验证通过后提交(Steam SDK 调用) + if SteamIntegration.is_available(): + SteamIntegration.upload_leaderboard_score(score) +``` + +> **决策背景**:3A 商业标准要求游戏对色盲玩家(约占男性玩家 8%)可玩。仅用颜色区分元素 +> 会导致此类玩家无法识别元素类型,影响共鸣系统核心玩法。 + +### C1.1 颜色盲支持(必须实现) + +**规则 ADR-C1-R1**:UI 中所有元素标识必须同时提供颜色 + 形状标识,禁止仅用颜色区分。 + +**形状图标映射表**(对应 `SpellNode.shape_icon` 字段): + +| 元素 | 颜色(正常视觉) | `shape_icon` 键 | 图标形状描述 | +| :--- | :--- | :--- | :--- | +| 火系 Fire | 橙红 `#FF4500` | `"triangle"` | 向上三角形 | +| 冰系 Ice | 冰蓝 `#7EC8E3` | `"diamond"` | 菱形 | +| 水系 Water | 深蓝 `#1E90FF` | `"circle"` | 圆形 | +| 土系 Earth | 棕绿 `#8B7355` | `"square"` | 方形 | +| 雷系 Lightning | 黄色 `#FFD700` | `"star"` | 五角星 | +| 暗系 Dark | 紫黑 `#4B0082` | `"cross"` | X 形 | + +**渲染规则**: +```gdscript +# ui_spell_card.gd — 法术卡渲染示例 +func _render_element(spell_node: SpellNode) -> void: + # 1. 颜色背景(正常视觉) + $ElementBadge/Background.color = ElementColors.get(spell_node.element_tags) + # 2. 形状图标叠加(色盲支持,ADR-C1-R1) + if spell_node.shape_icon != "": + $ElementBadge/ShapeIcon.texture = UIAtlas.get_icon(spell_node.shape_icon) + $ElementBadge/ShapeIcon.visible = true + else: + $ElementBadge/ShapeIcon.visible = false +``` + +**VFX 叠加规则**(ADR-C1-R1 延伸): +- 命中 VFX 粒子颜色使用对应元素颜色。 +- 命中时在命中点叠加 1 帧(0.1s)的 `shape_icon` TextureRect,字号 1.5× 基准大小。 +- `VFXManager` 在 `spawn_hit_vfx(pos, element_tag)` 中从 UIAtlas 读取 `shape_icon` 并生成叠加节点。 + +### C1.2 字体缩放(S4 实现) + +```gdscript +# settings_manager.gd 新增常量 + 接口 +const FONT_SCALE_KEY: String = "font_scale" +const FONT_SCALE_MIN: float = 0.5 +const FONT_SCALE_MAX: float = 1.5 +const FONT_SCALE_DEF: float = 1.0 + +static func get_font_scale() -> float: + return ProfileManager.get_float(FONT_SCALE_KEY, FONT_SCALE_DEF) + .clamp(FONT_SCALE_MIN, FONT_SCALE_MAX) + +static func apply_font_scale(root: Node) -> void: + # 遍历 UILayer 下所有 Label 节点,按 scale 覆写 custom_font_size + for label in root.find_children("*", "Label", true, false): + label.add_theme_font_size_override( + "font_size", int(label.get_theme_font_size("font_size") * get_font_scale())) +``` + +UI 初始化时调用 `SettingsManager.apply_font_scale(ui_root)`;字体缩放变更时发送 +`EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" })` 触发全局重渲染(`EventID` **19**,见 `implementation_plan.md` §2.1)。 + +### C1.3 减少闪光(S6 实现) + +设置项 `reduce_flash`(bool,默认 `false`): +- 开启时:VFXManager 将爆炸 / 共鸣特效的帧率限制为 12fps,白闪持续时间从 0.2s → 0.05s。 +- `EventID.GAME_CLEARED`(GameCycleManager 进入 GAME_CLEARED 状态时发出)的全屏白闪在该模式下完全跳过。 + +### C1.4 高对比度模式(S6 实现) + +设置项 `high_contrast`(bool,默认 `false`): +- 开启时:UI 背景强制黑色 `#000000`,所有 Label 强制白色 `#FFFFFF`。 +- 实现:`SettingsManager.apply_high_contrast(ui_root)` 覆写主题颜色变量,不修改资源文件。 + +--- + +## ADR-A3 — 音频架构补充细节 (Audio Architecture - Supplementary) + +> **注意**:AudioBus 层级结构、AudioBusID 常量、`_bgm_base_player`/`_music_layers` 字段声明、`play_bgm()`/`play_sfx()` 接口均已在 **§5.4** 完整定义,本 ADR 仅补充 §5.4 未涉及的细节(2D 空间音效衰减参数、音量持久化、SFX 节流规则)。 + +### A3.1 AudioBus 结构参考 + +> ⚠️ 此处为索引引用,**不重新定义** Bus 结构,权威定义见 §5.4。 + +Bus 树:`Master > BGM (BGM_Base, BGM_Layer) > SFX (SFX_Combat, SFX_Spatial) > UI > Ambience > Voice` + +AudioBusID Autoload 常量:`MASTER / BGM / BGM_BASE / BGM_LAYER / SFX / SFX_COMBAT / SFX_SPATIAL / UI / AMBIENCE / VOICE` + +### A3.2 动态音乐分层触发规则(补充) + +| 屏内敌人数 / 状态 | BGM_Layer 激活层级 | 说明 | +| :--- | :--- | :--- | +| 0–4 | Layer 0(基础,仅鼓点 + 低音) | 常驻,即使 0 敌人 | +| 5–19 | Layer 1(主旋律 + 和弦) | EnemyManager.get_visible_count() ≥ 5 | +| ≥ 20 或 Boss 激活 | Layer 2(弦乐/合成铅声) | 最高战斗强度 | +| SHOP / MAIN_MENU 状态 | 独立 BGM(BGM_Base 直接切换)| BGM_Layer 全部静音 | + +`AudioManager._process()` 每 0.5s 轮询一次 `EnemyManager.get_visible_count()`;Boss 激活/死亡通过 EventBus 订阅(`BOSS_PHASE_CHANGED` / `BOSS_KILLED`)实时响应,无需轮询。 + +### A3.3 2D 空间音效衰减 + +- SFX_Spatial Bus 下的 `AudioStreamPlayer2D` 使用 Godot 内置 `attenuation_filter_db` 曲线。 +- **衰减曲线参数**(`max_distance=1200px`,`attenuation=1.8`): + 0px → 0dB, 400px → -6dB, 800px → -18dB, 1200px → -40dB(截止)。 +- 超出 `max_distance` 的音源直接 `stop()`,不参与 0.1s 节流(已无声)。 + +### A3.4 音量持久化 + +```gdscript +# settings_manager.gd — 音量持久化 +# AudioBusID 是 Autoload,其常量为整数(bus index);不支持字符串下标访问,使用显式映射表 + +# bus_key(保存键)→ AudioServer bus index(AudioBusID 常量)的显式映射 +const _VOLUME_BUS_MAP: Dictionary = { + "vol_master": AudioBusID.MASTER, + "vol_bgm": AudioBusID.BGM, + "vol_sfx": AudioBusID.SFX, + "vol_ui": AudioBusID.UI, +} + +func load_audio_settings() -> void: + # SettingsManager._ready() 调用;将持久化音量值应用到 AudioServer + for save_key in _VOLUME_BUS_MAP: + var db: float = ProfileManager.get_float(save_key, 0.0) # 0.0 dB = 100% + AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db) + +func save_audio_setting(save_key: String, db: float) -> void: + # UIManager 音量滑块 on_value_changed → SettingsManager.save_audio_setting("vol_bgm", db) + if not _VOLUME_BUS_MAP.has(save_key): + push_warning("SettingsManager: unknown audio save_key: %s" % save_key) + return + AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db) + ProfileManager.set_float(save_key, db) + EventBus.emit(EventID.SETTINGS_CHANGED, { "key": save_key }) +``` + +**存储位置**:`user://save_data.json`(由 `ProfileManager` 管理,非 Run 存档)。 +**范围约定**:音量滑块线性映射到 `-40dB ~ 0dB`;mute 状态用 `-80dB` 替代 `set_bus_mute()` +以避免 Godot 的 mute 标志在存档恢复时的边界问题。 + +### A3.5 SFX 池节流规则补充 + +> 以下补充 `implementation_plan.md §2.5.B` 中已有的 32 池规则: + +| 规则 | 说明 | +| :--- | :--- | +| 同一音效 0.1s 内只播放 1 次 | `_last_play_time[sfx_id]` 字典节流 | +| 同帧相同位置的碰撞音效合并为 1 次 | BulletManager 碰撞处理后批量调用 `AudioManager.play_sfx_batch()` | +| Boss 语音(Voice Bus)不受 SFX 池限制 | 独立 `AudioStreamPlayer` 节点,最高优先级 | +| 玩家死亡时淡出所有 Bus(0.5s 内 -40dB)| `AudioManager.fade_out_all(duration=0.5)` | + +--- + +## ADR-A4 — 敌人 AI 寻路方案决策 (Enemy Pathfinding) + +> **结论**:Wave 1–14 普通敌人维持当前 Boid 斥力向量(无寻路);Wave 15+ Elite 及 Boss 敌人按需引入 Godot `NavigationAgent2D`,并发上限 `MAX_PATHFINDING_ENEMIES=20`,超限降级 Boid。 +> +> **S5 定案(2026-06-05,P-S5-AI-01 实测)**:采用 **`NavigationAgent2D` 方案**,**不**切换 FlowField。20 个 Elite 同时寻路的 `_update_pathfinding_movement` 实测 **≈0.0496ms/帧**,远低于 0.5ms 预算(约 10× 余量)。实现于 `enemy_manager.gd`(GDScript 回退路径):程序化单凸矩形 `NavigationRegion2D` + 每精英 `Node2D` host 挂 `NavigationAgent2D`(`avoidance_enabled=false`,与 SoA Boid 分离力共存);无精英时主循环零额外开销。 + +### 背景 + +当前架构中,所有敌人的移动逻辑为 Boid 斥力向量(EnemyManagerCs 内 SoA 批量计算),无完整寻路。 +评估发现 W15+ 精英敌人在面对角落障碍物时,因无寻路只能在角落堆叠,导致玩家可"站角落无脑输出"打过高难度波次,严重削弱 W15+ 的挑战性。 + +### 方案对比 + +| 方案 | 优点 | 缺点 | 适用范围 | +| :--- | :--- | :--- | :--- | +| Boid 斥力(当前) | 零额外 CPU,SoA 批量计算,1000 敌人帧时间已知 | 敌人无法绕过角落障碍物 | W1-14 杂鱼(堆叠可接受) | +| Godot `NavigationAgent2D` | 引擎内置,实现简单;支持动态障碍物 | 1000 个 Agent 同时 `get_next_path_position()` 帧时间未知,需 S5 基准测试 | Elite / Boss(≤ 20 个并发寻路) | +| FlowField 预烘焙 | O(1) 每敌人查询,适合大量敌人共享目标 | 实现复杂;场景动态障碍物需触发重烘焙(延迟约 1 帧) | 若 NavigationAgent2D 20 个精英时帧时间增量 > 0.5ms,则以此方案替代 | + +### 实施规则 + +- `MAX_PATHFINDING_ENEMIES = 20`:同时具有完整寻路能力的敌人数上限。超出上限的额外 Elite 降级为 Boid 模式(按距玩家最近优先保留寻路资格)。 +- W1–14 普通敌人:**永远** 使用 Boid 模式,不分配 NavigationAgent2D,避免节点数量爆炸。 +- W15+ Elite / Boss:入场时在 `EnemyManagerCs` 中标记 `has_pathfinding = true`,GDScript 侧为其创建 `NavigationAgent2D` 子节点。 +- **S5 性能验收(新增 P-S5-AI-01)**:`NavigationAgent2D` 20 个 Elite 同时寻路,`_physics_process` 帧时间增量 < **0.5ms**;若超标,切换 FlowField 方案并在本 ADR 更新决策结论。 + +### 过渡策略 + +S1–S4 期间:所有敌人继续使用 Boid 模式,不实现寻路(维持已知性能基线)。 +S5 实现 Elite 敌人时:同步实现 `NavigationAgent2D` 方案并运行 P-S5-AI-01 基准测试,若达标则定案为 NavigationAgent2D;否则切换 FlowField。 + +--- + +## 9. Godot 项目结构 (Project Structure) + +### 9.1 场景树结构 (Scene Tree) + +#### 主场景(Main.tscn) + +``` +Main (Node) ← res://scenes/Main.tscn,程序入口 +├── UILayer (CanvasLayer, layer=10) ← 主菜单 / 过渡 / 全局遮罩 +│ └── MainMenuUI (Control) +├── GameRoot (Node2D) ← 战斗场景的挂载点(动态 add_child / remove_child) +└── [C# Manager 子节点] ← ADR-L1 规则 4:C# 热路径作为对应 Autoload 的子节点 + ├── BulletManagerCs (Node) ← 挂在 BulletManager Autoload 下 + ├── EnemyManagerCs (Node) ← 挂在 EnemyManager Autoload 下 + ├── SpatialGridCs (Node) ← 挂在 SpatialGrid Autoload 下(或独立 Autoload) + ├── SpellEvaluatorCs (Node) ← 挂在 SpellEvaluator Autoload 下 + ├── StatusManagerCs (Node) ← 挂在 StatusManager Autoload 下 + └── ZoneManagerCs (Node) ← 挂在 ZoneManager Autoload 下 +``` + +> **注意**:C# 子节点由对应的 GDScript Autoload 在 `_ready()` 中通过 `add_child(BulletManagerCs.new())` 创建,不出现在 .tscn 文件中(避免场景文件依赖 C# 程序集,保持热重载友好)。 + +#### 战斗场景(BattleScene.tscn) + +``` +BattleScene (Node2D) ← res://scenes/BattleScene.tscn +├── World (Node2D) ← 世界坐标系(地图 TileMap 挂这里) +│ ├── TileMap (TileMap) ← 地图地形(S1 起实现) +│ ├── BulletRenderRoot (Node2D) ← 子弹 Node2D 池的挂载根(< 2000 弹幕时) +│ ├── MultiMeshRoot (Node2D) ← MultiMeshInstance2D(2000+ 弹幕时动态启用) +│ ├── EnemyRenderRoot (Node2D) ← 敌人 Sprite2D 节点池 +│ ├── VFXRoot (Node2D) ← VFXManager 的粒子节点池(跨场景挂 Autoload 自身) +│ └── ZoneVFXRoot (Node2D) ← 地面效果贴图/Shader 渲染根 +├── Player (CharacterBody2D) ← 玩家节点(PlayerManager 的 View 层) +│ ├── Sprite2D +│ ├── AnimationPlayer +│ └── CollisionShape2D +└── HUDLayer (CanvasLayer, layer=5) ← 战斗 HUD(血条、法术槽、波次信息) + ├── HPBar (ProgressBar) + ├── WaveLabel (Label) + ├── SpellSlotPanel (HBoxContainer) + ├── DamageNumberPool (Node) ← 伤害数字节点池(弹出后自动归还) + └── MiniMap (Control) ← S3 实现 +``` + +#### CanvasLayer 分层规则 + +| Layer | 用途 | 挂载场景 | +| :--- | :--- | :--- | +| 0 | 世界空间(无 CanvasLayer) | World 节点树 | +| 5 | 战斗 HUD(跟随 Viewport) | BattleScene > HUDLayer | +| 8 | 商店 / 背包 UI(模态)| ShopManager 动态创建 | +| 10 | 全局 UI(主菜单 / 过渡动画 / 暂停菜单)| Main > UILayer | +| 15 | 调试叠加层(仅 Debug 构建) | Main > DebugLayer | + +--- + +### 9.2 Autoload 初始化顺序(project.godot 注册顺序) + +Godot 按 `project.godot [autoload]` 中的声明顺序依次调用 `_ready()`。下表定义了强制顺序及原因: + +| 顺序 | Autoload 名 | 文件 | 依赖(_ready() 中调用的其他 Autoload) | +| :--- | :--- | :--- | :--- | +| 1 | `CrashReporter` | `crash_reporter.gd` | 无(最先,捕获所有后续初始化崩溃) | +| 2 | `EventID` | `event_ids.gd` | 无(纯常量,1–21)| +| 3 | `DamageType` | `damage_type.gd` | 无(纯常量) | +| 4 | `StatusID` | `status_id.gd` | 无(纯常量) | +| 5 | `CoreFeatureTag` | `core_feature_tag.gd` | 无(纯常量) | +| 6 | `AudioBusID` | `audio_bus_id.gd` | 无(纯常量,Bus index 整数)| +| 7 | `ObjectPool` | `object_pool.gd` | 无(通用对象池,S0 起全局使用,impl §2.1 Layer-0)| +| 8 | `TimeManager` | `time_manager.gd` | 无(时间缩放/GameTick,impl §2.1 Layer-0)| +| 9 | `EventBus` | `event_bus.gd` | 无(事件总线基础设施,其他 Manager 订阅事件需要它先就绪) | +| 10 | `DamageContextPool` | `damage_context_pool.gd` | 无(独立池,预分配 64 个槽) | +| 11 | `SubPayloadRegistry` | `sub_payload_registry.gd` | 无 | +| 12 | `SpatialGrid` | `spatial_grid.gd` + `SpatialGridCs.cs` | 无(C# 子节点在其 _ready() 中 add_child) | +| 13 | `SpellRegistry` | `spell_registry.gd` | 无(同步扫描加载 .tres) | +| 14 | `PassiveRegistry` | `passive_registry.gd` | 无(同步扫描加载 PassiveDef .tres) | +| 15 | `StatusRegistry` | `status_registry.gd` | 无(同步扫描加载 StatusTypeDef .tres) | +| 16 | `ProfileManager` | `profile_manager.gd` | 无(存档读写;Run存档:run_a/b.json,设置:save_data.json)| +| 17 | `SettingsManager` | `settings_manager.gd` | `ProfileManager`(读取用户音量/辅助功能设置) | +| 18 | `BulletManager` | `bullet_manager.gd` + `BulletManagerCs.cs` | `SpatialGrid`(spawn 时写入格子) | +| 19 | `EnemyManager` | `enemy_manager.gd` + `EnemyManagerCs.cs` | `SpatialGrid`(敌人位置写入格子), `DamageContextPool` | +| 20 | `MinionManager` | `minion_manager.gd` | `SpatialGrid`, `EnemyManager`(友伤判断), `EventBus` | +| 21 | `StatusManager` | `status_manager.gd` + `StatusManagerCs.cs` | `StatusRegistry`, `DamageContextPool`, `EventBus` | +| 22 | `ZoneManager` | `zone_manager.gd` + `ZoneManagerCs.cs` | `SpatialGrid`, `StatusManager` | +| 23 | `SpellEvaluator` | `spell_evaluator.gd` + `SpellEvaluatorCs.cs` | `SubPayloadRegistry`, `BulletManager`, `DamageContextPool`, `EventBus` | +| 24 | `PlayerManager` | `player_manager.gd` | `SpellEvaluator`, `PassiveRegistry`, `EventBus`, `ProfileManager` | +| 25 | `VFXManager` | `vfx_manager.gd` | `EventBus` | +| 26 | `AudioManager` | `audio_manager.gd` | `SettingsManager`(初始音量), `EventBus` | +| 27 | `UIManager` | `ui_manager.gd` | `EventBus`(订阅 GAME_STATE_CHANGED 等)| +| 28 | `CoreRegistry` | `core_registry.gd` | 无(纯只读注册表,扫描 res://resources/cores/)| +| 29 | `ConsumableRegistry` | `consumable_registry.gd` | 无(纯只读注册表,扫描 res://resources/consumables/)| +| 30 | `BossManager` | `boss_manager.gd` | `EnemyManager`, `EventBus` | +| 31 | `WaveManager` | `wave_manager.gd` | `EnemyManager`, `BossManager`, `EventBus` | +| 32 | `DropManager` | `drop_manager.gd` | `EnemyManager`, `PlayerManager`, `SpellRegistry`, `EventBus` | +| 33 | `UpgradeSystem` | `upgrade_system.gd` | `PlayerManager`, `SpellEvaluator`, `EventBus` | +| 34 | `ShopManager` | `shop_manager.gd` | `PlayerManager`, `SpellRegistry`, `PassiveRegistry`, `CoreRegistry`, `ConsumableRegistry`, `EventBus` | +| 35 | `GameCycleManager` | `game_cycle_manager.gd` | **所有以上 Manager**(协调者,最后初始化) | + +> **初始化安全规则**:编号 N 的 Autoload 的 `_ready()` 中只能调用编号 < N 的 Autoload。若发现需要访问编号 ≥ N 的 Autoload,必须延迟到 `_ready()` 后的第一个 `_process()` 帧,或通过 `EventBus` 信号触发。 +> +> **C# 子节点时机**:C# 子节点(`BulletManagerCs` 等)在其 GDScript Autoload 的 `_ready()` 中通过 `add_child()` 挂载。C# 子节点的 `_Ready()` 在 `add_child()` 返回前同步调用(Godot 规范),因此 C# 子节点可以安全访问其 GDScript 父节点,但不得直接访问尚未初始化的其他 Autoload(通过 `GetNode("/root/XXX")` 延迟获取)。 + + diff --git a/docs/technical/boss_design.md b/docs/technical/boss_design.md new file mode 100644 index 0000000..88ba49a --- /dev/null +++ b/docs/technical/boss_design.md @@ -0,0 +1,215 @@ +# Boss 系统架构设计 + +> **关联切片**:S6 P0(Mini Boss Wave 8 / Final Boss Wave 20) +> **依赖系统**:EnemyManager、StatusManager、EventBus、VFXManager、ZoneManager + +--- + +## 1. 设计原则 + +- **阶段驱动(Phase-Driven)**:Boss 行为由明确的阶段(Phase)枚举驱动,阶段切换在 `_physics_process` 中以血量阈值触发,不使用独立 Timer Node。 +- **与 EnemyManager 解耦**:Boss 不进入 EnemyManager SoA(避免 stride 污染和 AI 逻辑膨胀),由独立的 `BossManager` Autoload 管理;但向 SpatialGrid 注册自身坐标,使子弹归航与 ZoneManager 能命中 Boss。 +- **数据驱动**:每个 Boss 的阶段配置存储于 `.tres` Resource(`BossPhaseDef`),美术/策划可在不修改代码的情况下调整阈值与攻击模式。 +- **EventBus 集成**:阶段切换、死亡均通过 EventBus 广播,HUD/BGM/成就系统订阅。 + +--- + +## 2. BossManager Autoload + +```gdscript +# boss_manager.gd (Autoload: BossManager) +# Boss 实体数量极少(≤ 2 同屏),不使用 SoA,用 Array[BossInstance] 即可。 + +const MAX_BOSSES: int = 2 # 同屏 Boss 上限 + +var _active: Array[BossInstance] = [] + +func spawn_boss(boss_def_id: String, spawn_pos: Vector2) -> int: + # 从 BossRegistry 加载 BossDef,实例化 BossInstance,注册到 SpatialGrid + pass + +func _physics_process(delta: float) -> void: + for boss in _active: + boss.tick(delta) +``` + +--- + +## 3. Phase 状态机模板 + +### 3.1 枚举与切换条件 + +每个 Boss 的阶段定义在 `BossPhaseDef` Resource 中: + +```gdscript +# boss_phase_def.gd +class_name BossPhaseDef +extends Resource + +@export var phase_id: int # 阶段编号(0 起始) +@export var hp_threshold: float # 进入此阶段的血量百分比(0.0~1.0),如 0.5 = 50% HP +@export var attack_patterns: Array[String] # 此阶段可用的攻击模式 ID 列表 +@export var move_speed_mult: float = 1.0 # 移动速度倍率(狂暴阶段可设 1.5) +@export var enrage_vfx: String = "" # 阶段切换时触发的 VFX ID(空=无) +@export var phase_bgm_layer: int = 0 # 动态音乐层级(0=基础,1=紧张,2=狂暴) +``` + +### 3.2 BossInstance 状态机 + +```gdscript +# boss_instance.gd +class_name BossInstance +extends RefCounted + +enum BossPhaseState { + PHASE_0, # 初始阶段(100% HP) + PHASE_1, # 受伤阶段(阈值由 BossDef 配置,通常 70%) + PHASE_2, # 危机阶段(通常 40%) + PHASE_DYING # 死亡动画阶段(HP ≤ 0,播放死亡序列后才真正移除) +} + +var boss_def: BossDef +var hp: float +var hp_max: float +var position: Vector2 +var current_phase: BossPhaseState = BossPhaseState.PHASE_0 +var _attack_cooldown: float = 0.0 +var _pattern_index: int = 0 # 当前阶段攻击模式的轮询索引 +var _phase_just_changed: bool = false # 本帧刚切换阶段的标志,用于触发 enrage VFX + +func tick(delta: float) -> void: + _check_phase_transition() + if current_phase == BossPhaseState.PHASE_DYING: + _tick_dying(delta) + return + _tick_movement(delta) + _tick_attack(delta) + _sync_render() + +# 血量阈值检查(每帧) +func _check_phase_transition() -> void: + var hp_pct: float = hp / hp_max + var phases: Array = boss_def.phases # Array[BossPhaseDef],按 hp_threshold 降序排列 + for i in phases.size(): + var phase_def: BossPhaseDef = phases[i] + if hp_pct <= phase_def.hp_threshold and int(current_phase) < i + 1: + _enter_phase(i + 1, phase_def) + break + +func _enter_phase(new_phase: int, phase_def: BossPhaseDef) -> void: + current_phase = new_phase as BossPhaseState + _pattern_index = 0 + _attack_cooldown = 0.0 + # 阶段切换 VFX + if phase_def.enrage_vfx != "": + VFXManager.play(phase_def.enrage_vfx, position, 1.0) + # 通知 EventBus(HUD 高亮、BGM 切换) + EventBus.emit(EventID.BOSS_PHASE_CHANGED, { + "boss_id": boss_def.id, + "phase": new_phase, + "bgm_layer": phase_def.phase_bgm_layer + }) +``` + +--- + +## 4. 攻击模式系统 (Attack Pattern) + +攻击模式以 ID 字符串存储,在 `BossAttackRegistry` 中注册(类比 `SpellRegistry`),运行时由 `_tick_attack` 调用: + +```gdscript +# boss_instance.gd(续) +func _tick_attack(delta: float) -> void: + _attack_cooldown -= delta + if _attack_cooldown > 0.0: + return + var phase_def: BossPhaseDef = boss_def.phases[int(current_phase)] + if phase_def.attack_patterns.is_empty(): + return + # 轮询攻击模式 + var pattern_id: String = phase_def.attack_patterns[ + _pattern_index % phase_def.attack_patterns.size() + ] + _pattern_index += 1 + var cooldown: float = BossAttackRegistry.execute(pattern_id, self) + _attack_cooldown = cooldown +``` + +`BossAttackRegistry.execute(id, boss)` 返回该攻击模式的冷却时间(秒)。每种攻击模式是一个独立的 GDScript 函数或 Resource,通过 SpellEvaluator 发射子弹(走标准 BulletManager 路径)。 + +--- + +## 5. 具体 Boss 设计模板 + +### 5.1 Mini Boss — Wave 8(待策划填充) + +| 字段 | 值 | +| :--- | :--- | +| `boss_id` | `"mini_boss_w8"` | +| `hp_max` | 2000(约为普通精英 6.6×) | +| `move_speed` | 120(比杂鱼慢,靠范围攻击弥补) | +| **Phase 0**(100%→60% HP)| 攻击模式:`"charge_slam"` + `"spread_shot_3"` | +| **Phase 1**(60%→30% HP)| 追加:`"summon_minion_x2"`;move_speed_mult=1.2 | +| **Phase 2**(30%→0% HP)| 追加:`"zone_aoe_fire"`;move_speed_mult=1.5;enrage_vfx="boss_enrage" | +| 死亡事件 | `BOSS_KILLED`(EventID 待分配,建议 ID=15)| +| 战场机制 | 无(Mini Boss 不设场地机制,Final Boss 才有)| + +### 5.2 Final Boss — Wave 20(待策划填充) + +| 字段 | 值 | +| :--- | :--- | +| `boss_id` | `"final_boss_w20"` | +| `hp_max` | 10000 | +| `move_speed` | 80(极慢,靠弹幕覆盖)| +| **Phase 0**(100%→70% HP)| 攻击模式:`"spiral_shot_8"` + `"summon_turrets_x4"` | +| **Phase 1**(70%→40% HP)| 追加:`"zone_ice_wall"`;场地:随机生成 4 个冰墙障碍(`ZoneManager` 驱动)| +| **Phase 2**(40%→15% HP)| 追加:`"enrage_laser_sweep"`;场地收缩:边界每 10 秒向中心缩小 50px | +| **Phase 3**(15%→0% HP)| 全弹幕覆盖;`"phase3_barrage"`;恢复部分 HP 后进入死亡序列(演出用)| +| 死亡事件 | `BOSS_KILLED`(`EventID` **16**)+ `GAME_CLEARED`(**17**);阶段切换为 **15**(均以 `implementation_plan.md` §2.1 为准)| +| 战场机制 | 场地收缩(Phase 2+)、冰墙障碍(Phase 1+)| + +--- + +## 6. EventBus 扩展(Boss 专用事件) + +> **权威登记**:以下 ID 已并入 `technical/implementation_plan.md` §2.1 事件目录与 `event_ids.gd`,本节表为语义说明(勿改号)。 + +| 事件 ID | 常量名 | Payload | 订阅方 | +| :--- | :--- | :--- | :--- | +| 15 | `BOSS_PHASE_CHANGED` | `{boss_id, phase, bgm_layer}` | HUD(阶段提示)、AudioManager(BGM 切换)| +| 16 | `BOSS_KILLED` | `{boss_id, wave_num}` | WaveManager(进入结算)、AchievementManager | +| 17 | `GAME_CLEARED` | `{elapsed_sec, wave_num}` | GameCycleManager(结算屏)、排行榜 | + +--- + +## 7. 与现有系统的接口约定 + +| 接口 | 调用方向 | 说明 | +| :--- | :--- | :--- | +| `SpatialGrid.register_boss(id, pos, radius)` | BossManager → SpatialGrid | Boss 作为可被子弹命中的实体,每帧更新坐标 | +| `BulletManager._on_bullet_hit(bullet_id, target_id)` | BulletManager → BossManager | `target_id` 前缀区分:`enemy_xxx` vs `boss_xxx`(或通过 faction 位掩码判断)| +| `ZoneManager.spawn_zone(...)` | BossAttackPattern → ZoneManager | Boss 攻击模式可生成 Zone(如冰墙、火圈)| +| `VFXManager.play(vfx_id, pos, scale)` | BossInstance → VFXManager | 阶段切换 + 死亡演出 VFX | +| `SpellEvaluator.execute_boss_pattern(pattern_id, origin)` | BossAttackRegistry → SpellEvaluator | Boss 弹幕走标准 SpellEvaluator 路径,复用子弹生成管线 | + +--- + +## 8. 目录规范 + +```text +scripts/autoloads/ + boss_manager.gd # Autoload BossManager +scripts/domain/boss/ + boss_def.gd # class_name BossDef (Resource) + boss_phase_def.gd # class_name BossPhaseDef (Resource) + boss_instance.gd # class_name BossInstance (RefCounted) + boss_attack_registry.gd # BossAttackRegistry(类比 SpellRegistry) +resources/bosses/ + mini_boss_w8.tres + final_boss_w20.tres +resources/boss_patterns/ + charge_slam.tres + spread_shot_3.tres + spiral_shot_8.tres + # ... +``` diff --git a/docs/technical/implementation_plan.md b/docs/technical/implementation_plan.md new file mode 100644 index 0000000..0df1026 --- /dev/null +++ b/docs/technical/implementation_plan.md @@ -0,0 +1,815 @@ +# 程序架构设计与实施计划 (Implementation Plan & Technical Architecture) + +## 1. 核心设计原则 (Core Design Principles) + +### 1.1 性能优先 (Performance First) +鉴于已确定的"海量弹幕+同屏千人"需求,本项目的核心代码将偏离标准的 Godot 节点组件模式,采用更底层的 **Data-Oriented (面向数据)** 思想。 +* **原则 A**: 战斗循环中尽量避免 `new`(GDScript 实例化)。所有高频对象(Context, Bullet, Enemy)必须池化。 +* **原则 B**: 逻辑与渲染分离。位置更新在 Autoload Manager 的 `_physics_process` 中批量处理,节点的 `_process` 仅负责同步 View。 +* **原则 C**: 避免匿名函数与 Lambda。解释器核心循环使用 `match` 和 `for` 循环,减少函数调用开销。 + +### 1.2 极度模块化 (Extreme Modularity) +法术系统的每个功能点(如:追踪、分裂)都必须封装为独立的 `Strategy` 类,通过 ID 动态加载。核心系统不能直接依赖具体法术。 + +**法术注册表 (Spell Registry)** 技术方案: +```gdscript +# spell_registry.gd (Autoload) +# 启动时自动扫描 res://resources/spells/ 目录,加载所有 .tres SpellNode Resource +var _registry: Dictionary = {} # spell_id (String) -> SpellNode + +func _ready() -> void: + # ⚠️ F13 注意:当前使用同步 load(),主线程阻塞直到所有法术资源加载完毕。 + # 法术数量较少(< 50)时可接受;若未来资源增多,应改用异步预加载: + # ResourceLoader.load_threaded_request(path) → 在 Loading 界面 _process 中轮询 + # load_threaded_get_status(),加载完成后发出信号,不阻塞主线程。 + # P2 路线图:引入 SpellRegistry.preload_async(on_complete: Callable) 接口。 + var dir := DirAccess.open("res://resources/spells/") + if dir == null: + push_error("SpellRegistry: 无法打开 res://resources/spells/ 目录") + return + dir.list_dir_begin() + var file := dir.get_next() + while file != "": + if file.ends_with(".tres"): + var res: SpellNode = load("res://resources/spells/" + file) + if res != null: + _registry[res.id] = res + else: + push_warning("SpellRegistry: 加载失败 - %s" % file) + file = dir.get_next() + dir.list_dir_end() # ⚠️ 必须调用,否则目录句柄不会释放 + +func get(id: String) -> SpellNode: + return _registry.get(id, null) + +func all_ids() -> Array: + return _registry.keys() # ⚠️ keys() 返回无类型 Array,不可声明为 Array[String] + # GDScript 4.x 中 Dictionary.keys() 的静态返回类型为 Array +``` +* 新增法术只需在 `res://resources/spells/` 中放入一个 `.tres` 文件,无需修改任何代码。 +* `SpellNode.id` 字段必须全局唯一,推荐使用 `category_name` 格式(如 `modifier_damage_plus`)。 + +### 1.3 零 GC 与内存高效架构 (Zero-GC & Memory Efficiency) +GDScript 的垃圾回收虽不如 JavaScript 激进,但频繁实例化 RefCounted 对象同样会带来性能抖动。架构上强制执行以下模式: +* **Struct-like Classes**: 核心算子类(如 `Vector2`)优先使用 GDScript 内置值类型(Value Type),它们在栈上分配,无 GC 压力。自定义数据类继承 `RefCounted` 并通过对象池复用。 +* **Ring Buffers**: 音频、粒子、伤害数字使用环形缓冲区,覆盖旧数据而非销毁。 +* **Integer IDs > Objects**: 在 `EventBus` 和 ECS 中,传递整数 ID (entity_id) 而非传递整个对象引用,减少引用计数复杂度。 + +--- + +## 2. 模块详细设计 (Detailed Module Design) + +### 2.1 核心库层 (Layer 0: Core Libs) + +| 模块 | 类名/文件 | 职责说明 | 性能关键点 | +| :--- | :--- | :--- | :--- | +| **Object Pool** | `ObjectPool` (Autoload) | 通用对象池,支持 `reset()` 接口。 | 使用 `Array` 做栈,`append`/`pop_back` 操作。 | +| **Event Bus** | `EventSystem` (Autoload) | 类型安全的全局事件总线。 | 使用整数 ID 代替字符串 Key,减少哈希查找开销;也可直接使用 Godot 原生 Signal。 | +| **Spatial Hash** | `SpatialGrid` | 2D 空间网格,替代 Quadtree。 | 使用 `PackedInt32Array` 一维数组模拟二维网格,直接索引访问 `grid[x + y * width]`。 | +| **Timer** | `TimeManager` (Autoload) | 缩放时间、帧率节流控制。 | 支持 `GameTick` (physics) 与 `RenderFrame` (process) 分离。 | + +#### EventBus 事件目录 (Event Catalog) + +所有跨系统通信必须通过此表对齐,不得私自定义事件 ID。 + +> **E-N1 规范**:事件 ID 必须使用以下具名常量(`EventID` 枚举或 Autoload 常量),禁止在业务代码中直接写裸整数,防止撞号和可读性问题。 +```gdscript +# event_ids.gd (Autoload: EventID) +const APPLY_DAMAGE: int = 1 +const ENEMY_KILLED: int = 2 +const SPELL_CAST_BEGIN: int = 3 +const BULLET_HIT: int = 4 +const PLAYER_DAMAGED: int = 5 +const PLAYER_DIED: int = 6 +const WAVE_COMPLETE: int = 7 +const STATUS_APPLIED: int = 8 +const CHARGE_STATE_CHANGED: int = 9 # 停步蓄力进度变化 → UIManager +const ON_PLAYER_HURT: int = 10 # 玩家受到伤害时触发(用于受击释放法术/反弹尖刺等 OnHurt 钩子) +const MINION_SPAWNED: int = 11 # 召唤物创建 → UIManager 更新召唤物计数显示 +const MINION_EXPIRED: int = 12 # 召唤物消亡 → UIManager 更新召唤物计数显示 +const DASH_TRIGGERED: int = 13 # 玩家执行冲刺动作时触发(dash 输入按下瞬间)→ SpellEvaluator(OnDash 钩子,dash-wave Core 附加法术), UIManager(清除蓄力进度 UI) + # 负载: { "caster_id": int, "stationary_time": float }(冲刺前已积累的静止时长,供 dash-wave Core 读取) + # ⚠️ 与 CHARGE_FIRED(14) 的区别:DASH_TRIGGERED 由冲刺操作触发;CHARGE_FIRED 由停步静止时长首次超阈值触发(无冲刺)。 +const CHARGE_FIRED: int = 14 # 停步蓄力首次达到阈值时发出(仅一次,非每帧)→ SpellEvaluator(执行超载版法术) + # 区别于 CHARGE_STATE_CHANGED(每帧进度,仅供 UI): + # CHARGE_STATE_CHANGED 持续更新进度环动画; + # CHARGE_FIRED 是一次性触发信号,SpellEvaluator 只订阅此事件来实际释放法术。 + # 负载: { "caster_id": int } +const BOSS_PHASE_CHANGED: int = 15 # boss_design.md §6:Boss 阶段切换 → HUD、AudioManager(BGM 分层) +const BOSS_KILLED: int = 16 # boss_design.md §6:Boss 死亡 → WaveManager、AchievementManager +const GAME_CLEARED: int = 17 # boss_design.md §6:通关 → GameCycleManager、排行榜 +const ACHIEVEMENT_UNLOCKED: int = 18 # certification_checklist.md ST-10:Steam 成就解锁(须幂等) + # 负载: { "achievement_id": String } +const SETTINGS_CHANGED: int = 19 # architecture_design.md ADR-C1 / SettingsManager:字体缩放、高对比度等 + # 负载: { "key": String }(例:`font_scale`、`high_contrast`) +const GAME_STATE_CHANGED: int = 20 # architecture_design.md §5.1:FSM 状态切换(GameCycleManager 发出) + # 负载: { "prev": GameState, "next": GameState } + # 订阅方:UIManager(路由 UI 切换)、AudioManager(切换 BGM/SFX) +const SPELL_DROP_PICKUP: int = 21 # architecture_design.md §5.3:法术掉落拾取弹 UI 事件 + # 负载: { "spell_id": String, "auto_equip": bool } + # 发出方:DropManager(掉落拾取)、ShopManager.offer_free_spell() + # 订阅方:UIManager(弹背包选 Core 界面)、PlayerManager(auto_equip=true 时直接装备) +# 新增事件从 22 起递增,并向下表登记 +``` + +| 事件 ID | 具名常量 | 名称 | 负载内容 | 发出方 | 订阅方 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `1` | `EventID.APPLY_DAMAGE` | `APPLY_DAMAGE` | `damage_context_id: int` | `SpellEvaluator` / `StatusManager` | `EnemyManager` | +| `2` | `EventID.ENEMY_KILLED` | `ENEMY_KILLED` | `enemy_id: int, killer_id: int` | `EnemyManager` | `PlayerStats`, `DropManager`, `MissionTracker` | +| `3` | `EventID.SPELL_CAST_BEGIN` | `SPELL_CAST_BEGIN` | `caster_id: int, deck_id: int` | `SpellEvaluator` | `VFXManager`, `AudioManager` | +| `4` | `EventID.BULLET_HIT` | `BULLET_HIT` | `bullet_id: int, target_id: int` | `BulletManager` | `SpellEvaluator`(proc), `VFXManager` | +| `5` | `EventID.PLAYER_DAMAGED` | `PLAYER_DAMAGED` | `amount: float, source_id: int` | `PlayerManager` | `UIManager`, `AudioManager` | +| `6` | `EventID.PLAYER_DIED` | `PLAYER_DIED` | 无 | `PlayerManager` | `GameCycleManager` | +| `7` | `EventID.WAVE_COMPLETE` | `WAVE_COMPLETE` | `wave_num: int` | `WaveManager` | `ShopManager`, `GameCycleManager` | +| `8` | `EventID.STATUS_APPLIED` | `STATUS_APPLIED` | `target_id: int, status_type: int, stacks: int` | `StatusManager` | `VFXManager`, `EnemyManager` | +| `9` | `EventID.CHARGE_STATE_CHANGED` | `CHARGE_STATE_CHANGED` | `caster_id: int, progress: float` | `PlayerManager` | `UIManager` | +| `10` | `EventID.ON_PLAYER_HURT` | `ON_PLAYER_HURT` | `amount: float, source_id: int` | `PlayerManager` | `SpellEvaluator`(OnHurt 钩子), `VFXManager`, `AudioManager` | +| `11` | `EventID.MINION_SPAWNED` | `MINION_SPAWNED` | `minion_id: int, owner_id: int, current_count: int` | `MinionManager` | `UIManager`(召唤物计数显示) | +| `12` | `EventID.MINION_EXPIRED` | `MINION_EXPIRED` | `minion_id: int, owner_id: int, current_count: int` | `MinionManager` | `UIManager`(召唤物计数显示) | +| `13` | `EventID.DASH_TRIGGERED` | `DASH_TRIGGERED` | `caster_id: int, stationary_time: float` | `PlayerManager` | `SpellEvaluator`(OnDash钩子,dash-wave Core 附加法术), `UIManager`(蓄力UI清除) | +| `14` | `EventID.CHARGE_FIRED` | `CHARGE_FIRED` | `caster_id: int` | `PlayerManager` | `SpellEvaluator`(执行超载版法术) | +| `15` | `EventID.BOSS_PHASE_CHANGED` | `BOSS_PHASE_CHANGED` | `boss_id, phase, bgm_layer` | `BossManager` | `UIManager`, `AudioManager` | +| `16` | `EventID.BOSS_KILLED` | `BOSS_KILLED` | `boss_id, wave_num` | `BossManager` | `WaveManager`, `AchievementManager` | +| `17` | `EventID.GAME_CLEARED` | `GAME_CLEARED` | `elapsed_sec, wave_num` | `BossManager` / `GameCycleManager` | `GameCycleManager`, 排行榜 | +| `18` | `EventID.ACHIEVEMENT_UNLOCKED` | `ACHIEVEMENT_UNLOCKED` | `achievement_id: String` | 各游戏系统 | `AchievementManager` → Steam | +| `19` | `EventID.SETTINGS_CHANGED` | `SETTINGS_CHANGED` | `key: String` | `SettingsManager` | `UIManager`(全局主题/字体重载) | +| `20` | `EventID.GAME_STATE_CHANGED` | `GAME_STATE_CHANGED` | `prev: int, next: int` | `GameCycleManager` | `UIManager`, `AudioManager` | +| `21` | `EventID.SPELL_DROP_PICKUP` | `SPELL_DROP_PICKUP` | `spell_id: String, auto_equip: bool` | `DropManager`, `ShopManager` | `UIManager`, `PlayerManager` | +> 新增事件从 **22** 起递增,并向下表登记。规则:编号永不重用(可标 DEPRECATED 但不删除)。 + +> **E-N2 跨语言边界协议(GDScript ↔ C#)**:`architecture_design.md §6` 规定跨语言只传基本类型(int/float/Vector2/Godot.Array)。`DamageContext`(RefCounted)**不得直接穿越语言边界**。约定如下: +> - `EnemyManager` 遵循与 `BulletManager` 相同的 **ADR-L1 规则 4** 模式:`enemy_manager.gd`(GDScript Autoload)持有 `_data: PackedFloat32Array` 并对外暴露接口;`EnemyManagerCs.cs`(C# 子节点)处理 `_PhysicsProcess` 热路径(Boid 分离力、SoA 积分),预期帧时间 GDScript ≈6ms → C# ≈0.6ms(见 §6.1 语言分工表)。 +> - `APPLY_DAMAGE` 事件的处理逻辑位于 GDScript Autoload 层,负载 `damage_context_id: int` 是 `DamageContextPool` 的槽位索引,GDScript 层调用 `DamageContextPool.get_context(id)` 取得对象后处理伤害,处理完毕立即 `release(id)`。`DamageContext` 不穿越 GDScript ↔ C# 语言边界。 + +> **DamageContextPool 定义**:E-N2 中引用的 `DamageContextPool` 为伤害上下文对象池 Autoload,定义如下: +> ```gdscript +> # ── damage_context.gd ────────────────────────────────────────────────────────── +> # DamageContext 数据类必须单独放在 damage_context.gd 中, +> # 禁止将 class_name DamageContext 写在 damage_context_pool.gd 里(否则 Autoload 实例自身就是 +> # DamageContext 类型,导致 _pool: Array[DamageContext] 存入了具有池管理方法的对象,语义错误)。 +> class_name DamageContext +> extends RefCounted +> var base_damage: float = 0.0 +> var mult: float = 1.0 +> var damage_type: int = DamageType.PHYSICAL # 使用枚举常量,禁止裸整数 +> var owner_id: int = -1 +> var source_tags: int = 0 +> var is_crit: bool = false # 影响暴击字体/特效(见 combat_mechanics_depth.md §4) +> var pierce_rate: float = 0.0 # 护甲穿透率 (0.0~1.0),EnemyManager.apply_damage() 中使用 +> +> # ── damage_context_pool.gd (Autoload: DamageContextPool) ────────────────────── +> # 本文件不声明 class_name;由 Godot Project Settings 注册为 Autoload "DamageContextPool"。 +> # 采用固定大小槽位数组 + PackedInt32Array 空闲 ID 栈:O(1) 申请/归还,零动态分配,零 GC。 +> const POOL_SIZE: int = 64 +> var _slots: Array[DamageContext] # 固定槽位,索引即 ID(预分配,_ready() 填充) +> var _free_ids: PackedInt32Array # 空闲 ID 栈(append/pop_back,O(1),零 GC) +> var _in_use: PackedByteArray # 占用标记:1=已 acquire;防重复 release +> +> func _ready() -> void: +> _slots.resize(POOL_SIZE) +> _free_ids.resize(POOL_SIZE) +> _in_use.resize(POOL_SIZE) +> for i in POOL_SIZE: +> _slots[i] = DamageContext.new() +> _free_ids[i] = POOL_SIZE - 1 - i # 初始化:index 0 最先被弹出 +> _in_use[i] = 0 +> +> # ⚠️ 必须先调用 ctx.reset(),防止从池中取出的旧实例残留 source_tags 等脏数据。 +> func acquire(base_dmg: float, m: float, dtype: int, owner: int, +> crit: bool = false, pierce: float = 0.0) -> int: +> if _free_ids.is_empty(): +> push_warning("DamageContextPool: 池已耗尽,动态扩展(POOL_SIZE=%d 建议上调)" % POOL_SIZE) +> var new_id: int = _slots.size() +> _slots.append(DamageContext.new()); _in_use.append(0) +> return _fill_slot(new_id, base_dmg, m, dtype, owner, crit, pierce) +> var id: int = _free_ids[_free_ids.size() - 1] +> _free_ids.resize(_free_ids.size() - 1) # pop_back,O(1) +> return _fill_slot(id, base_dmg, m, dtype, owner, crit, pierce) +> +> func _fill_slot(id: int, base_dmg: float, m: float, dtype: int, owner: int, +> crit: bool, pierce: float) -> int: +> var ctx: DamageContext = _slots[id] +> ctx.reset() # 清除所有残留字段后再覆写,保证每次 acquire 的上下文状态干净 +> ctx.base_damage = base_dmg; ctx.mult = m +> ctx.damage_type = dtype; ctx.owner_id = owner +> ctx.is_crit = crit; ctx.pierce_rate = pierce +> _in_use[id] = 1 +> return id +> +> func get_context(id: int) -> DamageContext: +> if id < 0 or id >= _slots.size() or _in_use[id] == 0: return null +> return _slots[id] +> +> func release(id: int) -> void: +> if id < 0 or id >= _slots.size() or _in_use[id] == 0: +> push_warning("DamageContextPool.release: 无效或重复归还 ID=%d" % id) +> return +> _in_use[id] = 0 +> _free_ids.append(id) # push_back,O(1),不触发 GC +> +> func reset() -> void: +> # GameCycleManager._reset_all_managers() 在波次/Run 重置时调用。 +> # 将所有 in-use 槽位强制归还(防止跨波次泄漏),清空空闲 ID 栈后重建。 +> _free_ids.clear() +> for i in _slots.size(): +> _in_use[i] = 0 +> _free_ids.append(i) +> ``` +> **使用约定**:`SpellEvaluator` 发出 `APPLY_DAMAGE` 前调用 `DamageContextPool.acquire(...)` 获取整数 ID; +> `EnemyManager` 收到事件后调用 `get_context(id)` 处理伤害,处理完毕立即调用 `release(id)` 归还, +> 不得持有 DamageContext 跨帧引用。 + +### 2.2 法术解释器层 (Layer 1: Spell Virtual Machine) + +这是本项目的"CPU",负责执行 `SpellNode` 指令。 + +#### 数据结构 + +**SpellDeck** — 运行时执行游标 +```gdscript +class_name SpellDeck +# _nodes: Array[SpellNode],预编译后不再修改 +# _cursor: int,当前执行位置 +# +# Scope 边界标记:预编译时每个 SCOPE_OPEN 节点(LOGIC_IF_*、TRIGGER 后续体) +# 在 _nodes 中紧跟虚拟节点 SpellNode{type=SCOPE_CLOSE, id="_scope_end"}。 +# skip_until_scope_end() 推进 cursor 直到遇到第一个 SCOPE_CLOSE(深度计数器处理嵌套)。 +# consume_until_scope_end() 同理,返回中间节点序列用于打包 SubPayload。 +# O(N) 线性扫描定位边界,N ≤ MAX_OPS_PER_CPU × cpu_limit。 +func has_next() -> bool: return _cursor < _nodes.size() +func pop() -> SpellNode: + # 完整实现须包含 _consumed 掩码跳过(见 architecture_design.md §3.4.C) + if _cursor >= _nodes.size(): return null + var node := _nodes[_cursor] + _cursor += 1 # GDScript 不支持后置 ++ 运算符 + return node +func skip_until_scope_end() -> void: ... +func consume_until_scope_end() -> Array[SpellNode]: ... +``` + +**CompiledDeck** — 预编译产物,关卡期间只读 +```gdscript +class_name CompiledDeck +# compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck +# 由 SpellEvaluator 在背包关闭时调用 +# +# nodes: Array[SpellNode] 拓扑扁平化 + SCOPE 虚节点插入后的线性序列 +# label_table: Dictionary { label_id: int -> node_index: int }(LOGIC_LABEL 专用) +# sub_payload_ids: Array[int] 本 Deck 预编译时注册到 SubPayloadRegistry 的所有 ID +# topology_type: int 0=LINEAR 1=MATRIX 2=CIRCUIT(影响 _flatten_* 选择) +# checksum: int 插槽内容哈希,用于判断是否需要重新编译 +# 算法:多项式滚动哈希(对换位敏感,避免 [A,B] 与 [B,A] 产生相同 checksum): +# checksum = topology_type ^ slot_count ^ (grid_cols << 8) ^ hash(core.id) +# // grid_cols 防止不同列宽的 MATRIX Core 碰撞(2×4 与 4×2 slot_count 相同) +# // hash(core.id) 防止不同 Core ID 但拓扑相同时 checksum 碰撞导致跳过重编译 +# for i in range(nodes.size()): +# checksum = checksum * 31 ^ hash(nodes[i].id) +# +# 运行时 SpellEvaluator 从 CompiledDeck 构造一个 SpellDeck(拷贝 nodes 并重置 cursor) +var nodes: Array[SpellNode] +var label_table: Dictionary +var sub_payload_ids: Array[int] +var topology_type: int = 0 +var checksum: int = 0 +``` + +**CastState** — 单次 execute 调用的临时状态 +```gdscript +# trigger_depth: int,当前 execute_sub 嵌套深度(防止 TRIGGER 无限递归) +# registers: PackedFloat32Array,长度 4,R1-R4 +# ⚠️ 重要区分: +# CastState.registers → 单次 execute() 内的临时工作寄存器,每次 execute() 开始时重置,不跨调用保留 +# SpellContext.registers → 跨帧持久寄存器(见 architecture_design.md §3.2) +# LOGIC_EVERY_N_SHOTS【必须】使用 SpellContext.registers(否则计数器每帧重置,永远等效为"每次都触发")。 +# _execute_logic() 收到 LOGIC_EVERY_N_SHOTS 时通过 spell_ctx 参数访问 spell_ctx.registers[0]。 +``` + +#### 执行流程 (`SpellEvaluator.execute`) +```gdscript +# MAX_OPS_PER_CPU = 40:每点玩家属性 cpu_limit 换算为 40 步执行额度 +# 运行时:_max_ops = PlayerStats.cpu_limit × MAX_OPS_PER_CPU(默认 cpu_limit=5 → MAX_OPS=200) +# 超过限制立即中断,防止图灵完备构建死循环 +const MAX_OPS_PER_CPU: int = 40 +const MAX_TRIGGER_DEPTH: int = 3 # execute_sub 嵌套最大深度;超过 3 层时 TRIGGER 不再触发新的 SubPayload + # 分工:MAX_OPS 管单次施法内指令总量;MAX_TRIGGER_DEPTH 管跨弹体调用深度 + # ⚠️ 超限时不得静默失效:当 execute_sub 被 TRIGGER_DEPTH 隔断时, + # EventBus 发出 SPELL_CAST_BEGIN 事件并附带 flag "depth_exceeded=true", + # UIManager 订阅该 flag,在 HUD 显示短暂黄色闪烁("[法术链太深,部分触发已忽略]")。 + # 开发期保留 push_warning;正式版将 push_warning 改为发出事件。 +var _max_ops: int = 200 # 战斗开始时更新:_max_ops = PlayerStats.cpu_limit * MAX_OPS_PER_CPU + +while deck.has_next() and ops_count < _max_ops: + ops_count += 1 + var node: SpellNode = deck.pop() + match node.type: + SpellType.ACTION: + _push_projectile(node, context) + SpellType.MODIFIER: + node.apply(context) # 直接修改 context.stats + # Multicast 边缘规则:若 Deck 剩余法术数 < draw_count,以"尽力而为"策略执行 + # (弹出现有法术直至 Deck 耗尽,不崩溃),法杖编辑 UI 对此显示黄色警告。 + SpellType.TRIGGER: + # 实现方案:预编译时打包 SubPayload,运行时查表执行 + # 1. 将 TRIGGER 后续的 SpellNode 序列打包为 SubPayload,注册到全局表,得到整数 ID + # 2. 子弹在 PackedFloat32Array 中仅携带 on_hit_payload_id (int) + # 3. 命中时 BulletManager 查表并调用 SpellEvaluator.execute_sub(payload_id, hit_pos, owner_id) + # 这样子弹逻辑与 PackedFloat32Array 热数据完全解耦 + var payload_id: int = SubPayloadRegistry.register(deck.consume_until_scope_end()) + context.current_payload.on_hit_payload_id = payload_id + SpellType.LOGIC: + # 最小指令集(详细行为见 architecture_design.md §3.4.B): + # LOGIC_IF_HP_BELOW → 读 caster HP%,条件不满足则 deck.skip_until_scope_end() + # LOGIC_IF_ENEMY_NEARBY → SpatialGrid 查询 context.range 内是否有敌方实体 + # LOGIC_EVERY_N_SHOTS → 读写 spell_ctx.registers[0..3](SpellContext,跨帧持久!) + # ⚠️ 严禁使用 CastState.registers(每帧重置 → 计数器永远不累积) + # LOGIC_LOOP(count, body_size) + # → 将紧随其后的 body_size 个节点重复执行 count 次(count ≤ 8,超限裁剪) + # → 每次循环消耗 body_size 个 ops;总耗费 = count × body_size,计入 ops_count + # → 与 MAX_OPS 交互:LOOP(5, 3) = 15 ops;若 ops_count + 15 > _max_ops 则提前截断 + # → game_design.md §3.2.D 中「循环执行后续 3 个法术 5 次」即为 LOGIC_LOOP(5, 3) + # LOGIC_LABEL / LOGIC_JUMP_IF → 仅限 persistent_memory Core;预编译时建立 _label_table + # LOGIC_FORK → CIRCUIT 拓扑分叉专用(由 _flatten_circuit 注入,玩家不可购买) + # node.id = "LOGIC_FORK";node.get_meta("fork_branch_ids") = Array[int] SubPayload ID 列表 + # _execute_logic 识别 LOGIC_FORK 时对每个 fork_branch_id 调用 execute_sub() + # 消耗 fork_branch_ids.size() 个 ops(每个分支算 1 步),计入 ops_count + _execute_logic(node, context, deck) +# ops_count 达到上限时:① 开发期 push_warning;② 通过 EventBus 向玩家 HUD 发出反馈 +# 截断不得静默,玩家须知构建已被截断(与 MAX_TRIGGER_DEPTH "depth_exceeded" 事件相同规范)。 +# UIManager 订阅 SPELL_CAST_BEGIN 中 "ops_exceeded: true" 标志,HUD 显示短暂黄色闪烁提示。 +if ops_count >= _max_ops: + push_warning("SpellEvaluator: MAX_OPS reached, build may be too complex") + EventBus.emit(EventID.SPELL_CAST_BEGIN, { + "caster_id": context.caster_id, "deck_id": -1, "ops_exceeded": true + }) +``` + +> **LOGIC 指令集扩展路径**:当前 `_execute_logic` 使用中央 `match node.id` 硬编码所有 LOGIC 指令,新增 LOGIC 类型需修改核心(OCP 限制)。 +> **P0/P1**:保留 match 实现,LOGIC 指令集固定不超过 8 条,可接受。 +> **P2 路线图**:若 LOGIC 指令集需扩展超过 10 条,引入 `LogicHandler` 接口: +> ```gdscript +> # logic_handler.gd +> class_name LogicHandler +> extends RefCounted +> func handle(node: SpellNode, context: SpellContext, deck: SpellDeck) -> void: pass +> +> # 在 SpellRegistry 中按 node.id 注册: +> # SpellRegistry.register_logic_handler("LOGIC_MY_NEW", MyNewHandler.new()) +> # _execute_logic 改为: +> # SpellRegistry.get_logic_handler(node.id).handle(node, context, deck) +> ``` +> 迁移前无需改变 P0/P1 行为,仅 P2 新增 LOGIC 法术卡采用新接口注册。 + +#### SubPayloadRegistry 生命周期 +* **创建时机**:玩家关闭背包(预编译)时,`SpellEvaluator.compile_wand(core, raw_deck)` 清空并重建整个 Registry。 +* **清空时机**:关卡切换、游戏结束时全量清除。滚动最终销毁所有未使用的 SubPayload 对象还入对象池。 +* **并发处理**:多弹一帧内同时命中时,`execute_sub` 可并行执行同一 `payload_id`, Registry 不发生冲突(只读)。 +* **竞争条件安全保证**:Registry 重建发生在玩家**关闭**背包时,而非打开时。 + 因此飞行中的弹体在玩家进入商店编辑阶段时,其 `on_hit_payload_id` 仍指向当前 Registry 中的有效条目—— + 战斗波次结束后所有弹体自然清空,Registry 重建(下次关闭背包)必定早于下波战斗开始; + 不存在"弹体持有旧 payload_id、Registry 已重建为新 ID 映射"的竞争窗口。 + > **禁止行为**:不得在波次**进行中**允许玩家修改 Deck(如暂停菜单热插拔法术),否则将违反此不变量。 + > 如未来需支持"战斗中暂停换牌",必须先清空所有飞行中的弹体,再执行重编译,最后恢复生成。 + +### 2.3 战斗实体层 (Layer 2: Combat ECS-Lite) + +#### A. 物理与碰撞 (Physics & Collision) + +碰撞系统采用**分档策略**,不同弹幕密度区间使用不同方案;`BulletManager._collision_mode` 标志控制当前路径(详见 `architecture_design.md §4.3`): + +| 同屏弹幕数 | 碰撞方案 | 说明 | +| :--- | :--- | :--- | +| < 200 | **Area2D + CircleShape2D** | Godot 内置碰撞;Physics Layer/Mask 过滤阵营;`body_entered` 信号回调 `_on_bullet_hit()` | +| 200–2000 | **自定义 SpatialGrid(主力)** | BulletManager 每帧 dirty-list 重建网格,手动 `query_circle()`;Area2D 实例全部禁用 | +| 2000+ | **SpatialGrid + MultiMesh** | 碰撞继续走 SpatialGrid;渲染切换 MultiMeshInstance2D | + +> **⚠️ S0 验证目标(风险 R-04)**:S0 期间在 500+ 弹幕时实测 Area2D 帧时间(预期 > 8ms)。验证后**永久锁定 SpatialGrid 为 200+ 弹幕的主力碰撞方案**,Area2D 路径不再作为运行时"降级回退"保留。 + +* **Physics Layer & Mask 配置**(< 200 弹幕 Area2D 路径):Layer 1 = 玩家,Layer 2 = 子弹,Layer 3 = 敌人;子弹 Layer 2 只检测 Layer 3;`body_entered` → `_on_bullet_hit(bullet_id, enemy_id)`。 +* **SpatialGrid 主路径**(200+):两路径最终均调用相同的 `_on_bullet_hit(bullet_id, enemy_id)` 处理函数,后处理逻辑完全共用;切换入口仅在 `_collision_mode` 标志处。 + +#### B. 子弹系统 (`BulletManager`) +* **混合渲染模式**: + * **逻辑层**: 依然使用 `PackedFloat32Array` 管理子弹的生命周期和属性(速度/伤害),SoA 布局保证缓存友好。 + * **表现层**: 利用 Node Pool 复用 `Node2D` 节点;对于极大量同类子弹,改用 `MultiMeshInstance2D` 直接 GPU 实例化渲染,大幅降低 Draw Call。 + * **同步优化**: 只有当子弹位置发生显著变化或处于屏幕视口内时,才设置 `Node2D.position`,利用 Godot 的脏标记机制避免无效渲染树更新。 + +#### C. 敌人系统 (`EnemyManager`) + +**EnemyManager 热数据 SoA 布局(权威定义)** + +敌人数量可达 1000+,同 BulletManager 一样需要 PackedFloat32Array SoA 结构: + +``` +const ENEMY_STRIDE: int = 8 + +stride = ENEMY_STRIDE 个 float +[ x, y, vx, vy, hp, hp_max, faction_and_type, status_bits ] + 0 1 2 3 4 5 6 7 +``` +- `faction_and_type`:高 16 位 = faction(0=敌方, 1=友方/召唤物),低 16 位 = enemy_type_id +- `status_bits`:燃烧/冰冻/中毒等状态的位掩码(各类 DoT 的精确数值存冷数据) +- 复杂数据(AI 状态、DoT 计时器、combo_tracker)存入 `_enemy_contexts: Dictionary`(key = entity_id) + +**SpatialGrid 每帧清空策略(P5)** + +`SpatialGrid` 每 `_physics_process` 帧重建一次,采用 **Dirty-List 方案**而非全量 memset: +- `_dirty_cells: PackedInt32Array` 记录本帧有写入的 Cell 索引。 +- 帧末遍历 `_dirty_cells`,仅清空这些 Cell,跳过空 Cell。 +- 当 `_active_count > 1500` 时切换为全量 fill(0)(此时脏格子数量接近总数,遍历反而更慢)。 + +* **AI 状态机**:AI 逻辑**不使用 AnimationTree 驱动**,而是以轻量级枚举存于 `_enemy_contexts[entity_id]["ai_state"]`(0=IDLE / 1=CHASE / 2=ATTACK / 3=RETREAT)。`EnemyManager._physics_process` 在 Manager 内部批量完成所有状态转换,不经过 Godot 场景树。 + * **单向驱动**:帧末对**屏内**敌人(`_visible_flags[i] == 1`)将 `ai_state` 映射为 AnimationTree 参数(`anim_tree.set("parameters/conditions/is_running", ...)`),AnimationTree **仅接收结果,不反向影响逻辑**;屏外敌人完全跳过 AnimationTree 更新。 + * **⚠️ 禁止**:不得用 AnimationTree StateMachine 驱动 AI 逻辑转换;不得在 `_physics_process` 热路径内对屏外敌人调用 `anim_tree.set()`(大量屏外时 1000× 调用/帧严重浪费)。 +* **LOD(屏外 AI 剔除)**:❌ **不使用** `VisibilityNotifier2D` per-enemy 方案(1000+ 敌人各挂一个节点 = 1000+ 额外信号监听,节点开销显著)。改为 **Manager 侧 Camera Rect AABB 批量剔除**: + ```gdscript + # ⚠️ _visible_flags 必须声明为 EnemyManager 的类成员(PackedByteArray), + # 禁止在 _physics_process 内用 var 声明(否则每帧分配新数组,触发 GC 抖动)。 + # 类成员声明(EnemyManager 顶部): + # var _visible_flags: PackedByteArray = PackedByteArray() + # 每当 _enemy_count 扩容时同步 _visible_flags.resize(_enemy_count) + # + # EnemyManager._physics_process 每帧执行(O(N) 单次矩形测试): + var cam_rect: Rect2 = _get_camera_rect_expanded(128.0) # 扩大 128px 防闪烁 + for i in range(_enemy_count): + var pos := Vector2(_data[i * ENEMY_STRIDE + 0], _data[i * ENEMY_STRIDE + 1]) + _visible_flags[i] = int(cam_rect.has_point(pos)) # ⚠️ 必须显式 int() 转换 + # has_point() 返回 bool, + # 直接赋给 PackedByteArray[i] 会静默写入错误值 + # _physics_process 遍历时跳过 _visible_flags[i] == 0 的实体 AI 计算 + ``` + * 相比 VisibilityNotifier2D,此方案无额外节点、无信号开销,N=1000 时约节省 ~1000 次信号 dispatch/帧,适合 ECS-Lite 架构。 + +> **屏外 Boid 分离力低频保留**:完全跳过屏外敌人 AI 会导致 Boid 分离斥力缺失——大量屏外实体相互挤叠,进入视野瞬间爆发弹射(视觉闪烁)。 +> **解决方案**:`_visible_flags[i] == 0` 的屏外敌人**不跳过分离力更新**,改为以低频单独运行: +> ```gdscript +> const OFFSCREEN_SEPARATION_INTERVAL: int = 10 # 每 10 帧更新一次屏外分离力 +> +> for i in range(_enemy_count): +> if _visible_flags[i]: +> _update_ai_full(i) # 全量 AI:索敌 + 分离力 + 动画参数 +> elif Engine.get_physics_frames() % OFFSCREEN_SEPARATION_INTERVAL == i % OFFSCREEN_SEPARATION_INTERVAL: +> _update_separation_only(i) # 仅分离斥力:查 SpatialGrid 最近 8 格,O(1) +> ``` +> - `_update_separation_only` 无 AI 状态机、无动画更新,每帧参与计算的屏外实体数 ≤ N/10。 +> - 10 帧低频下最坏分离响应时延 ≈ 166ms(60 Hz),不可感知;可消除进视野瞬间弹射。 + +### 2.4 状态效果系统 (`StatusManager`) + +#### 核心常量 + +```gdscript +# status_manager.gd (Autoload: StatusManager) +# _physics_process 热路径见 csharp/autoloads/StatusManagerCs.cs (ADR-L1) + +## 每个实体允许同时存在的最大状态类型数量(STATUS_BATCH_LIMIT) +const STATUS_BATCH_LIMIT: int = 8 + +## 同一状态类型的最大叠加层数上限 +const STATUS_MAX_STACKS: int = 16 +``` + +**设计依据**:单实体最多 8 种状态类型。`_physics_process` 最坏情况 = 1000 敌人 × 8 状态 = 8000 次 tick 检查/帧,仍在帧预算内(ADR-L1 规定 StatusManagerCs < 1.5ms)。 + +#### 超限淘汰策略(Eviction) + +当某实体活跃状态数 = `STATUS_BATCH_LIMIT` 且接收到新状态时,按以下顺序决策: + +| 优先级 | 规则 | +| :--- | :--- | +| 1(最高)| 同类型 → 刷新 duration,按叠加规则增减 stacks,**不占新槽** | +| 2 | 同组升级(如 CHILL→FREEZE)→ 替换,保留剩余 duration,**不增加槽数** | +| 3 | LRU 淘汰 → 踢出 `last_applied_time` 最早的状态,空槽后写入新状态 | +| 4(最低)| 新状态优先级 < 所有现存状态 → 静默丢弃,`push_warning` 记录 | + +```gdscript +## 状态分组(同组内高等级覆盖低等级) +const STATUS_GROUP: Dictionary = { + StatusType.BURN: "fire_dot", StatusType.SCORCH: "fire_dot", + StatusType.POISON: "toxic_dot", StatusType.CORRODE: "toxic_dot", + StatusType.CHILL: "cold", StatusType.FREEZE: "cold", + StatusType.SLOW: "cc", StatusType.STUN: "cc", +} +``` + +#### DoT Tick 规则 + +```gdscript +// StatusManagerCs.cs —— _physics_process 热路径(C#) +// tick_timer 使用 while 模式,与 ZoneManager 规范一致: +// while (tickTimer >= TICK_INTERVAL) { tickTimer -= TICK_INTERVAL; ApplyDot(entityId, status); } +// 最坏情况:STATUS_BATCH_LIMIT = 8 种 × 1000 敌人 = 8000 次检查/帧 +``` + +#### 与 ZoneManager 的交互规则 + +- ZoneManager 通过 `StatusManager.apply(entity_id, type, stacks, duration=0, source_id=zone_id)` 施加持续性区域 DoT。 +- `duration=0` 表示"持续存在直到来源主动移除";区域销毁时调用 `StatusManager.remove_source(zone_id)` 批量清除。 +- **同一来源的相同状态不占新槽**,直接刷新 timer(防止多个同类型区域叠满 8 槽)。 + +### 2.5 基础服务框架 (Layer 3: System Services) +使用 Godot 4 的 **InputMap** 系统作为底层驱动,在上层封装一层 **"Action-Based"** 的抽象。 +* **抽象层**: 游戏逻辑通过 `Input.get_vector("move_left", "move_right", "move_up", "move_down")` 获取移动向量,不关心具体设备。 +* **设备支持**: + * **键盘/鼠标**: `WASD` 在 InputMap 中映射为 `move_*` Action,鼠标位置通过 `get_global_mouse_position()` 获取瞄准方向。 + * **虚拟摇杆**: 监听 UI 层触摸事件,转换为 (-1, 1) 的向量,映射到同名 Action。 + * **手柄 (Gamepad)**: 利用 Godot 内置的 `Input.get_joy_axis()` 和 `Input.is_joy_button_pressed()`,InputMap 已支持手柄轴自动绑定。 + * **自动适配**: 识别 `Input.get_joy_name(device_id)` 来区分 Xbox / PS 的按键图标显示。 + * **死区处理**: 在 InputMap 中直接配置 `Deadzone` 参数,或在 Manager 层统一处理防止摇杆漂移。 + +> **PlayerManager 移动状态追踪**:`game_design.md §5` 描述了移动敏感型法术 +> (移动增益型、冲刺波、停步蓄力型),这些功能要求 `PlayerManager` 持续追踪玩家运动状态: +> ```gdscript +> # player_manager.gd(相关字段,非完整类定义) +> var _is_moving: bool = false # 当前帧是否在移动(move_vector.length() > MOVE_THRESHOLD_NORM) +> var _stationary_time: float = 0.0 # 连续静止时长(秒);移动时立即清零 +> var _charge_triggered: bool = false # 停步蓄力已触发标志;边沿检测,防止每帧重复发出 CHARGE_FIRED +> var _last_dash_time: float = -999.0 # 上次冲刺时刻(用于冲刺波冷却) +> # MOVE_THRESHOLD_NORM:归一化输入轴幅度(0.0~1.0),非像素/秒速度 +> # 0.067 = 约 6.7% 摇杆偏移即判定为移动(死区 + 轻微触碰阈值) +> const MOVE_THRESHOLD_NORM: float = 0.067 # 对应约 20px/s @ 300px/s 最大速度 +> const CHARGE_TRIGGER_TIME: float = 1.5 # 停步蓄力法术所需静止时长(秒) +> +> func _physics_process(delta: float) -> void: +> var move_vec := Input.get_vector("move_left", "move_right", "move_up", "move_down") +> _is_moving = move_vec.length() > MOVE_THRESHOLD_NORM +> if _is_moving: +> if _stationary_time > 0.0: +> _stationary_time = 0.0 +> _charge_triggered = false # 移动后重置标志,允许下次停步重新触发 +> EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": player_id, "progress": 0.0}) +> else: +> _stationary_time += delta +> if _stationary_time >= 0.3: # 0.3 秒后开始显示蓄力进度(game_design.md §5) +> var prog := clampf(_stationary_time / CHARGE_TRIGGER_TIME, 0.0, 1.0) +> EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": player_id, "progress": prog}) +> # 边沿检测:仅首次越过阈值时发出一次 CHARGE_FIRED;持续静止不会重复发送 +> # SpellEvaluator 只订阅 CHARGE_FIRED 以执行停步蓄力法术(非 CHARGE_STATE_CHANGED)。 +> if _stationary_time >= CHARGE_TRIGGER_TIME and not _charge_triggered: +> _charge_triggered = true +> EventBus.emit(EventID.CHARGE_FIRED, {"caster_id": player_id}) +> +> # 冲刺输入检测(冲刺波 Core 专属) +> if Input.is_action_just_pressed("dash"): +> _last_dash_time = Time.get_ticks_msec() / 1000.0 +> # 附带 stationary_time:SpellEvaluator 用于判断蓄力时长奖励;UIManager 用于清除蓄力 UI +> EventBus.emit(EventID.DASH_TRIGGERED, { +> "caster_id": player_id, +> "stationary_time": _stationary_time # 冲刺前已积累的静止时长(秒) +> }) +> _stationary_time = 0.0 # 冲刺后立即清零 +> _charge_triggered = false # 冲刺打断蓄力,重置触发标志允许下次重新蓄力 +> # SpellEvaluator 订阅 DASH_TRIGGERED,触发 dash-wave Core 的附加法术释放 +> ``` +> **设计约束**:`CHARGE_STATE_CHANGED` 事件仅由 `UIManager` 订阅以驱动蓄力进度环动画; +> `SpellEvaluator` 订阅 `CHARGE_FIRED`(一次性边沿触发信号)以实际释放停步蓄力法术。 +> 两者均通过 EventBus 解耦,`PlayerManager` 不直接持有 UI 引用或 SpellEvaluator 引用。 +> ⚠️ 禁止让 `SpellEvaluator` 订阅 `CHARGE_STATE_CHANGED` 并在 `progress >= 1.0` 时触发法术—— +> 该事件每帧发送(60Hz),会导致蓄力法术每帧重复释放,违反"首次越阈值只触发一次"的设计原则。 + +#### B. 音频系统 (`AudioManager`) +针对"割草"游戏的高并发特性进行封装。 +* **Audio Pool**: 预先实例化 32 个 `AudioStreamPlayer2D` 节点,循环使用。 +* **Throttling (节流)**: 维护一个 `last_played_time` 字典。 + * *规则*: 如果 `explosion_sound` 在 0.1s 内已经播放过,且新的请求音量没有更大,则丢弃该请求。这避免了 50 个敌人同时炸裂时的爆音。 +* **Spatial (空间感)**: 使用 `AudioStreamPlayer2D` 的内置衰减曲线,或根据摄像机位置动态调整 `volume_db` 和 `panning_strength`。 + +#### C. 资源管理 (`ResourceManager`) +* **后台加载**: 使用 `ResourceLoader.load_threaded_request(path)` 异步加载法术图标、特效场景等,避免卡顿。 +* **分组管理**: 利用 Godot 的 `ResourceGroup` 或按文件夹路径约定管理资源(如 `res://resources/spells/`、`res://scenes/enemies/`)。 +* **引用计数**: Godot 的 `Resource` 本身基于引用计数,场景卸载时未被引用的资源自动释放。对于手动预加载的资源,切换场景时显式 `queue_free()` 对应节点以触发释放。 + +#### D. 特效管理 (`VFXManager`) + +`VFXManager` 是 Autoload 单例,所有视觉特效(命中闪光、爆炸、DoT 气泡等)必须通过此管理器播放,**禁止在逻辑层直接操作 Node**。 + +```gdscript +# vfx_manager.gd (Autoload) +# 职责:接收逻辑事件并以数据驱动方式触发对应特效,逻辑层与表现层完全解耦 + +const MAX_ACTIVE_VFX: int = 200 # 同屏最大活跃特效数量上限 +var _vfx_pool: Dictionary = {} # { "effect_id": Array[GPUParticles2D] } — 按类型分池 +# VFX 场景预加载表;_ready() 中按配置填充,play() 通过此表实例化节点 +# 例: _vfx_scenes["hit_spark"] = preload("res://scenes/vfx/hit_spark.tscn") +var _vfx_scenes: Dictionary = {} # { "effect_id": String -> PackedScene } +# 维护活跃特效计数器(O(1) 计数,不在 play() 中遍历 pool 统计) +var _active_count: int = 0 # 当前正在 emitting 的特效节点总数(O(1) 计数,避免遍历 pool 统计) + +# EventBus 订阅列表(在 _ready() 中注册): +# EventID.BULLET_HIT → play("hit_spark", hit_position) +# EventID.ENEMY_KILLED → play("death_burst", enemy_position) +# EventID.STATUS_APPLIED → play("status_" + status_name, target_position) +# EventID.SPELL_CAST_BEGIN→ play("cast_flash", caster_position) + +func play(effect_id: String, world_pos: Vector2, override_scale: float = 1.0) -> void: + if _active_count >= MAX_ACTIVE_VFX: # O(1) 检查,达上限时静默丢弃 + return + var node := _pool_pop(effect_id) + node.position = world_pos + node.scale = Vector2.ONE * override_scale + node.emitting = true + _active_count += 1 + node.finished.connect(_pool_return.bind(effect_id, node), CONNECT_ONE_SHOT) + +func _pool_pop(effect_id: String) -> GPUParticles2D: + if not _vfx_pool.has(effect_id) or _vfx_pool[effect_id].is_empty(): + return _instantiate_vfx(effect_id) # 按需实例化并加入场景树 + return _vfx_pool[effect_id].pop_back() + +func _pool_return(effect_id: String, node: GPUParticles2D) -> void: + node.emitting = false + _active_count -= 1 # CONNECT_ONE_SHOT 保证每个节点只调用一次 + # 首次归还时 _vfx_pool 中可能尚无此 effect_id 键,需先初始化空列表再 append + if not _vfx_pool.has(effect_id): + _vfx_pool[effect_id] = [] + _vfx_pool[effect_id].append(node) + +# _pool_pop() 中对应 effect_id 的池为空时,按需创建新 GPUParticles2D 节点。 +# ⚠️ node.one_shot 必须为 true:若为 false,GPUParticles2D 持续循环发射, +# finished 信号永不触发,_pool_return 不被调用,_active_count 只增不减, +# 触达 MAX_ACTIVE_VFX 后所有后续特效全部被静默丢弃(完全失效)。 +# 父节点:VFXManager 自身(Autoload 单例),跨场景持久存活,确保池化节点复用。 +func _instantiate_vfx(effect_id: String) -> GPUParticles2D: + if not _vfx_scenes.has(effect_id): + # 未注册的 effect_id:返回空占位节点(不崩溃),开发期通过 push_warning 提示漏配 + push_warning("VFXManager: 未知特效 ID='%s',请检查 _ready() 中的预加载配置" % effect_id) + var placeholder := GPUParticles2D.new() + placeholder.one_shot = true # 占位节点同样需要 one_shot,确保 finished 可触发 + add_child(placeholder) + return placeholder + var node: GPUParticles2D = _vfx_scenes[effect_id].instantiate() + node.one_shot = true # 必须为 true,否则 finished 永不触发,池化失效 + node.emitting = false # 初始不发射,等待 play() 设置 position/scale 后再启动 + add_child(node) # 挂载到 VFXManager(Autoload)节点下,跨场景持久存活 + return node +``` + +> **性能约束(V-N1)**:`MAX_ACTIVE_VFX = 200` 超出时静默丢弃,避免大量爆炸帧暴涨。 +> 伤害数字(DamageNumber)由 `UIManager` 而非 `VFXManager` 管理,两者隔离(参见 `architecture_design.md §4.4` Throttling)。 + +#### E. 元数据管理 (`ProfileManager`) +* **Save/Load**: 使用 Godot 的 `FileAccess` 读写 JSON 字符串,或使用 `ConfigFile` 存储键值对数据,存储路径为 `user://save_data.json`。 +* **Schema**: 定义 `SaveData` 字典结构,包含 `unlocked_spells: Array[String]`、`achievements: Array[int]` 等字段。 +* **版本字段**: `SaveData` 根层必须包含 `schema_version: int`(当前版本 = 1)。 +* **加载时版本检查**: + 1. 读取 `schema_version`,若字段不存在则视为 `v0`。 + 2. 对每个低版本执行迁移函数(`_migrate_v0_to_v1(data)`),补全缺失字段为默认值。 + 3. 若 `schema_version > 当前版本`(由更新版本写入),提示版本不匹配并回退到初始状态,避免崩溃。 +* **参考**: `docs/design/core_wand_design.md` 中的 SaveData JSON 序列化示例。 + +#### E.1 存档损坏 UX 规范(C-GAP-3 修复) + +`load_run()` 和 `EndlessRecordsManager.load()` **不得静默降级**——返回空数据时必须通过 UI 告知玩家,禁止无声失败。 + +| 失败场景 | 触发条件 | UI 行为 | +| :--- | :--- | :--- | +| `load_run()` 解析失败 | JSON 格式错误 / `schema_version` 缺失 / 两槽均损坏 | `UIManager.show_data_corrupted_dialog("run")` → 对话框:「检测到存档异常,当前游戏进度无法读取。是否开始新游戏?」;确认后清除 run 文件并进入主菜单新游戏流程 | +| `EndlessRecordsManager.load()` 签名失败 | HMAC 不匹配(文件被篡改或损坏) | `UIManager.show_data_corrupted_dialog("leaderboard")` → Toast 提示:「排行榜分数验证失败,本地记录已重置」;不影响游戏进程,直接以空记录继续 | +| `load_run()` 返回 `{}` 但存档文件存在 | 两槽均损坏 | 同 run 对话框,额外追加一条 `crash_log.txt` 记录,便于复现调试 | + +```gdscript +# profile_manager.gd — load_run() 调用方规范(游戏启动时) +var run_data := load_run() +if run_data.is_empty() and ( + FileAccess.file_exists(_SLOT_A) or FileAccess.file_exists(_SLOT_B)): + # 文件存在但解析为空 → 损坏,不静默降级 + UIManager.show_data_corrupted_dialog("run") + return # 由对话框回调决定后续流程(新游戏 or 退出) +``` + +> **`UIManager.show_data_corrupted_dialog(type: String)` 接口规范**: +> - `type = "run"`:模态对话框,两个按钮:「开始新游戏」(清除损坏 Run 文件)/ 「返回主菜单」。 +> - `type = "leaderboard"`:非模态 Toast(3 秒自动消失),不阻断游戏流程。 +> - 对话框文案须通过 `tr()` 包裹(L10n 支持):键名 `"DIALOG_SAVE_CORRUPTED_RUN"` / `"TOAST_LEADERBOARD_RESET"`。 + + +> `game_design.md §8.1` 要求死亡时自动截图保存构建快照。实现规范如下: +> +> **DPS 计算**:`CombatManager`(或 `ProfileManager`)维护滚动窗口: +> ```gdscript +> # 环形缓冲区:O(1) 写入/覆盖,无内存分配,零 GC 压力(替代 Array + pop_front() 的 O(N) 方案) +> # _recalc_window() 懒加载:仅在 get_dps() 查询时计算(不在每次 record_damage() 触发) +> # 理由:record_damage() 每帧可能被数百次 AoE 命中调用;UIManager 仅每 0.5 秒轮询 DPS +> const DPS_WINDOW_SEC: float = 3.0 +> const _RB_SIZE: int = 256 # 环形缓冲区槽数(上限:3秒内最多 256 次伤害事件) +> var _rb_time: PackedFloat64Array # 各槽时间戳 +> var _rb_dmg: PackedFloat32Array # 各槽伤害值 +> var _rb_head: int = 0 # 下一次写入位置(覆盖最旧记录) +> var _rb_count: int = 0 # 有效记录数(最大 = _RB_SIZE) +> var _accumulated_dmg: float = 0.0 # 当前窗口内伤害累计(主动维护,O(1) 读取) +> +> func _ready() -> void: +> _rb_time = PackedFloat64Array(); _rb_time.resize(_RB_SIZE) +> _rb_dmg = PackedFloat32Array(); _rb_dmg.resize(_RB_SIZE) +> +> func record_damage(amount: float) -> void: +> var now := Time.get_ticks_msec() / 1000.0 +> _rb_time[_rb_head] = now +> _rb_dmg[_rb_head] = amount +> _rb_head = (_rb_head + 1) % _RB_SIZE +> if _rb_count < _RB_SIZE: +> _rb_count += 1 +> _accumulated_dmg += amount # 乐观累加;get_dps() 时统一裁剪过期值 +> +> func _recalc_window(now: float) -> void: +> var sum: float = 0.0 +> var tail := (_rb_head - _rb_count + _RB_SIZE) % _RB_SIZE +> var valid: int = 0 +> for k in _rb_count: +> var idx := (tail + k) % _RB_SIZE +> if (now - _rb_time[idx]) <= DPS_WINDOW_SEC: +> sum += _rb_dmg[idx] +> valid += 1 +> _accumulated_dmg = sum +> _rb_count = valid +> +> func get_dps() -> float: +> _recalc_window(Time.get_ticks_msec() / 1000.0) # 懒加载裁剪 +> return _accumulated_dmg / DPS_WINDOW_SEC # 平均 DPS(过去 3 秒) +> ``` +> `UIManager` 每 0.5 秒轮询 `CombatManager.get_dps()`,更新 HUD DPS 标签;靶场模式下同步更新 DPS 面板。 +> +> **Run 截图**:死亡时 `GameCycleManager` 调用: +> ```gdscript +> func _save_run_screenshot() -> void: +> var img: Image = get_viewport().get_texture().get_image() +> var path := "user://runs/%s_run_%d.png" % [ +> Time.get_datetime_string_from_system().substr(0, 10), +> ProfileManager.get_run_count() +> ] +> img.save_png(path) +> ``` +> 截图在主线程调用(`get_image()` 是同步操作),建议在死亡动画第 1 帧完成后、统计屏显示前执行, +> 避免截到黑屏。 + +## 3. 开发阶段规划 (Development Stages) + +### Phase 1: 核心验证 (The Engine) +* [x] 定义 `SpellNode` 基类 (已完成)。 +* [ ] 实现 `SpellEvaluator` (纯逻辑,无 UI)。 +* [ ] 实现 `SpatialGrid` 碰撞检测。 +* [ ] 编写单元测试:构建一个简单的"散弹枪"逻辑,验证输出数据是否符合预期。 + +### Phase 2: 视觉化 (The Visuals) +* [ ] 搭建 Godot 场景,实现玩家移动 (键盘/手柄)。 +* [ ] 接入 `BulletManager`,将逻辑坐标同步到 Sprite2D。 +* [ ] 实现简单的敌人 AI (追着玩家跑)。 + +### Phase 3: 构建系统 (The Builder) +* [ ] 实现各种具体的 `SpellNode` (Actions, Modifiers)。 +* [ ] UI 开发:背包拖拽系统。 +* [ ] 存档与解析系统 (JSON <-> SpellDeck),含 Schema 版本字段。 + +#### SpellDeck JSON 序列化格式 (E2 权威格式) + +```json +{ + "schema_version": 1, + "core_id": "linear_core_t2", + "slots": [ + { "spell_id": "modifier_damage_plus", "position": 0 }, + { "spell_id": "action_spark_bolt", "position": 1 }, + { "spell_id": "trigger_on_hit", "position": 2 }, + { "spell_id": "action_chain_bolt", "position": 3 } + ] +} +``` +* `core_id`:核心 ID,决定插槽拓扑结构。 +* `slots` 按 `position` 排列,`position` 为核心插槽座标(线性核心就是序数,网格核心为 `{x, y}` 对)。 +* `spell_id` 对应 `SpellRegistry` 中的全局唯一 ID。 + +### Phase 4: 循环与内容 (The Game) +* [ ] 商店逻辑。 +* [ ] 波次管理器。 +* [ ] 数值接入。 +* [ ] ZoneManager(地面效果系统)。 +* [ ] VFXManager 特效池。 +* [ ] Endless 排行榜本地存储。 + +--- + +## 4. 关键技术难点预案 + +### Q1: GDScript 如何避免 GC 抖动? +* **预案**: 所有的 `ProjectilePayload`、`SpellContext` 在游戏启动时预分配并放入 `Array` 池。使用时 `pool.pop_back()` 取出,用完调用 `reset()` 后 `pool.append()` 归还,不依赖 GDScript 的自动引用计数销毁。 + +### Q2: 复杂的法术链如何调试? +* **预案**: 开发一个 Godot 编辑器插件(`EditorPlugin`)作为可视化的 **"Debug Graph"** 工具。在编辑器模式下,可以单步执行 `SpellEvaluator`,看到当前的 Stack 和 Deck 状态,并在 2D 视图中高亮对应节点。 + +### Q3: 物理碰撞性能不够怎么办? +* **预案**: + 1. 确保 `CollisionShape2D` 使用圆形(最快),并合理配置 Physics Layer/Mask 减少检测对数。 + 2. 将碰撞检测分帧(Time-Slicing),这一帧算一半子弹,下一帧算另一半。对于高速割草游戏,延迟一帧通常不可感知。 + 3. 终极方案:完全放弃 Area2D,改用自定义 `SpatialGrid`,在 `_physics_process` 中手动执行圆形重叠测试。 + +### Q4: `PackedFloat32Array` 删除策略? +* **问题**:直接调用 `PackedFloat32Array.resize()` 删除元素是 O(n),对 2000+ 弹幕每帧有明显性能开销。 +* **预案:末位交换 (Swap-and-Pop)**: + * 子弹 “死亡” 时,将数组最后一个元素的数据复制到当前位置,然后缩减数组长度 1 个 stride。 + * 复杂度 O(1),但子弹顺序不再保证(对融剆这个游戏类型无影响)。 + * **实际实现**:`BulletManager` 内部维护 `_active_count: int`,子弹数据帮存在 `[0, _active_count)` 区间内,不依赖 resize()。 + +### Q5: GDScript 与 C# 如何协同工作? +* **热路径 C# 文件目录**:`scripts/core_csharp/BulletManagerCore.cs`、`SpellEvaluatorCore.cs`。 +* **调用方式**:在 Godot 4 中,GDScript 可通过 `var evaluator = preload("res://scripts/SpellEvaluatorCore.cs").new()` 直接实例化 C# 类,调用公开方法无需特殊处理。 +* **跨语言传递类型**:只传递 GDScript / C# 双方都支持的类型:`int`、`float`、`Vector2`、`Godot.Array`、`Godot.Dictionary`。禁止将 GDScript `RefCounted` 自定义类型直接传入 C#:应将复杂数据序列化为 `PackedFloat32Array` 或 `int` ID 后传递。 +* **原则**:只有当 Profiler 显示具体函数是确认瓶颈时,才将其迁移到 C#;默认先用 GDScript 实现,确保选择的正确性。 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..b2000f5 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cijti2dnr4abk" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a23e634 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "daihaorogue", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "daihaorogue", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e14368b --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "daihaorogue", + "version": "1.0.0", + "main": "index.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..a2b2449 --- /dev/null +++ b/project.godot @@ -0,0 +1,79 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Rogue" +config/version="1.0.0" +run/main_scene="res://scenes/ui/splash.tscn" +config/features=PackedStringArray("4.6") +config/icon="uid://cijti2dnr4abk" + +[autoload] + +CrashReporter="*res://scripts/autoloads/crash_reporter.gd" +EventID="*res://scripts/autoloads/event_ids.gd" +DamageType="*res://scripts/autoloads/damage_type.gd" +StatusID="*res://scripts/autoloads/status_id.gd" +CoreFeatureTag="*res://scripts/autoloads/core_feature_tag.gd" +AudioBusID="*res://scripts/autoloads/audio_bus_id.gd" +ObjectPool="*res://scripts/autoloads/object_pool.gd" +TimeManager="*res://scripts/autoloads/time_manager.gd" +EventBus="*res://scripts/autoloads/event_bus.gd" +SpatialGrid="*res://scripts/autoloads/spatial_grid.gd" +EnemyManager="*res://scripts/autoloads/enemy_manager.gd" +BulletManager="*res://scripts/autoloads/bullet_manager.gd" +PlayerManager="*res://scripts/domain/player_manager.gd" +DamageContextPool="*res://scripts/autoloads/damage_context_pool.gd" +SpellContextPool="*res://scripts/autoloads/spell_context_pool.gd" +SpellRegistry="*res://scripts/autoloads/spell_registry.gd" +ConfigMgr="*res://scripts/autoloads/config_mgr.gd" +SpellEvaluator="*res://scripts/domain/spell_system/spell_evaluator.gd" +WandPreset="*res://scripts/autoloads/wand_preset.gd" +PlayerStats="*res://scripts/autoloads/player_stats.gd" +DropManager="*res://scripts/autoloads/drop_manager.gd" +WaveManager="*res://scripts/autoloads/wave_manager.gd" +ShopManager="*res://scripts/autoloads/shop_manager.gd" +ProfileManager="*res://scripts/autoloads/profile_manager.gd" +SubPayloadRegistry="*res://scripts/domain/spell_system/sub_payload_registry.gd" +StatusRegistry="*res://scripts/autoloads/status_registry.gd" +StatusManager="*res://scripts/autoloads/status_manager.gd" +VFXManager="*res://scripts/autoloads/vfx_manager.gd" +DpsTracker="*res://scripts/autoloads/dps_tracker.gd" +ZoneManager="*res://scripts/autoloads/zone_manager.gd" +MinionManager="*res://scripts/autoloads/minion_manager.gd" +EndlessRecords="*res://scripts/autoloads/endless_records.gd" +Locale="*res://scripts/autoloads/locale_manager.gd" +SettingsManager="*res://scripts/autoloads/settings_manager.gd" +AudioManager="*res://scripts/autoloads/audio_manager.gd" +SceneManager="*res://scripts/autoloads/scene_manager.gd" +MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd" +MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd" +MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd" + +[dotnet] + +project/assembly_name="Rogue" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/game_designer/plugin.cfg", "res://addons/godot_mcp/plugin.cfg", "res://addons/spell_editor/plugin.cfg") + +[gui] + +theme/custom="res://assets/ui/ui_theme.tres" + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" diff --git a/scenes/main/combat_s2.gd b/scenes/main/combat_s2.gd new file mode 100644 index 0000000..1c08db8 --- /dev/null +++ b/scenes/main/combat_s2.gd @@ -0,0 +1,791 @@ +## CombatS2 — S2 核心循环场景 +## 目标:波次→商店→升级→下一波,循环可无限重复 +## 验收:P-S2-01 ~ P-S2-07 +extends Node2D + +## 玩家节点 +var _player_node: Node2D = null + +## 世界渲染(MultiMeshInstance2D,§4.4 渲染优化) +var _bullet_mm: MultiMesh = null +var _enemy_mm: MultiMesh = null + +## UI 层引用 +var _hud_layer: CanvasLayer = null +var _shop_layer: CanvasLayer = null + +## HUD 标签 +var _lbl_wave: Label = null +var _lbl_hp: Label = null +var _lbl_gold: Label = null +var _lbl_xp: Label = null +var _lbl_kills: Label = null +var _lbl_status: Label = null +var _lbl_deck: Label = null +var _lbl_dps: Label = null +var _lbl_charge: Label = null +var _lbl_statuses: Label = null + +var _dps_poll_timer: float = 0.0 + +## 商店 +var _shop_panel: Panel = null +var _shop_btns: Array = [] # Array[Button] +var _reroll_btn: Button = null +var _close_btn: Button = null +var _core_btn: Button = null +var _inv_btn: Button = null +var _lang_btn: Button = null + +## 背包(插槽编辑) +var _inv_layer: CanvasLayer = null +var _inv_root: Control = null +var _inv_sel_kind: String = "" # "" / "slot" / "bench" +var _inv_sel_idx: int = -1 + +## 结算 / 死亡屏 +var _settle_layer: CanvasLayer = null +var _settle_root: Control = null + +## 暂停菜单 +var _pause_layer: CanvasLayer = null +var _is_paused: bool = false + +## 设置面板 +var _settings_layer: CanvasLayer = null +var _settings_root: Control = null +var _clear_confirm: bool = false # 清除数据二次确认 + +## CombatManager +var _cm: Node = null # CombatManager 实例 + +func _ready() -> void: + # 暂停时仍需响应 ESC 与暂停菜单按钮(游戏世界由 autoload 的 PAUSABLE _physics_process 冻结) + process_mode = Node.PROCESS_MODE_ALWAYS + SceneManager.fade_in_first() # 单独测试时淡入;经 goto 进入时自动跳过 + _setup_player() + _setup_world_view() + _setup_hud() + _setup_shop_ui() + _setup_inventory_ui() + _setup_settlement_ui() + _setup_settings_ui() + _setup_pause_menu() + _setup_combat_manager() + _subscribe_events() + +func _input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + if _inv_layer and _inv_layer.visible: + _close_inventory() + elif _settings_layer and _settings_layer.visible: + _close_settings() + elif _settle_layer and _settle_layer.visible: + pass # 结算屏:ESC 无效,必须按按钮 + elif _shop_layer and _shop_layer.visible: + pass # 商店中不暂停 + else: + _toggle_pause() + +func _process(delta: float) -> void: + # 世界渲染同步(SoA → MultiMesh) + BulletManager.sync_multimesh(_bullet_mm) + EnemyManager.sync_multimesh(_enemy_mm) + _refresh_hud() + _dps_poll_timer -= delta + if _dps_poll_timer <= 0.0: + _dps_poll_timer = 0.5 + if _lbl_dps: + _lbl_dps.text = tr("UI_DPS") % DpsTracker.get_dps() + +# ── 初始化 ───────────────────────────────────────────────── + +func _setup_player() -> void: + var p := Node2D.new() + p.name = "Player" + add_child(p) + _player_node = p + PlayerManager.set_player_node(p) + +## 世界视图:竞技场网格背景 + 玩家标记 + 跟随相机 + 子弹/敌人 MultiMesh 渲染器 +func _setup_world_view() -> void: + # 竞技场网格地面(程序化 shader,世界静止,z 最底) + var arena := Polygon2D.new() + var ext: float = 5000.0 + arena.polygon = PackedVector2Array([ + Vector2(-ext, -ext), Vector2(ext, -ext), Vector2(ext, ext), Vector2(-ext, ext)]) + arena.uv = PackedVector2Array([ + Vector2(0, 0), Vector2(1, 0), Vector2(1, 1), Vector2(0, 1)]) + var grid_mat := ShaderMaterial.new() + grid_mat.shader = load("res://shaders/arena_grid.gdshader") + arena.material = grid_mat + arena.z_index = -100 + add_child(arena) + + # 玩家可视标记(青色发光菱形 + 光晕环) + var glow := Polygon2D.new() + glow.polygon = PackedVector2Array([ + Vector2(0, -22), Vector2(22, 0), Vector2(0, 22), Vector2(-22, 0)]) + glow.color = Color(0.35, 0.9, 1.0, 0.22) + _player_node.add_child(glow) + var marker := Polygon2D.new() + marker.polygon = PackedVector2Array([ + Vector2(0, -13), Vector2(13, 0), Vector2(0, 13), Vector2(-13, 0)]) + marker.color = Color(0.55, 0.95, 1.0) + _player_node.add_child(marker) + var core := Polygon2D.new() + core.polygon = PackedVector2Array([ + Vector2(0, -6), Vector2(6, 0), Vector2(0, 6), Vector2(-6, 0)]) + core.color = Color(1.0, 1.0, 1.0) + _player_node.add_child(core) + # 跟随相机 + var cam := Camera2D.new() + cam.enabled = true + _player_node.add_child(cam) + + var quad := QuadMesh.new() + quad.size = Vector2(1.0, 1.0) # 单位四边形,transform 缩放即像素尺寸 + + # 敌人渲染器(per-instance 颜色) + _enemy_mm = MultiMesh.new() + _enemy_mm.transform_format = MultiMesh.TRANSFORM_2D + _enemy_mm.use_colors = true + _enemy_mm.mesh = quad + _enemy_mm.instance_count = EnemyManager.MAX_ENEMIES + _enemy_mm.visible_instance_count = 0 + var enemy_mmi := MultiMeshInstance2D.new() + enemy_mmi.multimesh = _enemy_mm + # 程序化美术占位:方块 → 软边实体圆 + 高光 + var enemy_mat := ShaderMaterial.new() + enemy_mat.shader = load("res://shaders/enemy_circle.gdshader") + enemy_mmi.material = enemy_mat + add_child(enemy_mmi) + + # 子弹渲染器(统一淡黄色,modulate) + _bullet_mm = MultiMesh.new() + _bullet_mm.transform_format = MultiMesh.TRANSFORM_2D + _bullet_mm.mesh = quad + _bullet_mm.instance_count = BulletManager.MAX_BULLETS + _bullet_mm.visible_instance_count = 0 + var bullet_mmi := MultiMeshInstance2D.new() + bullet_mmi.multimesh = _bullet_mm + bullet_mmi.modulate = Color(1.0, 0.95, 0.45) + # 程序化美术占位:方块 → 加法发光圆(弹幕质感) + var bullet_mat := ShaderMaterial.new() + bullet_mat.shader = load("res://shaders/bullet_glow.gdshader") + bullet_mmi.material = bullet_mat + add_child(bullet_mmi) + +func _setup_combat_manager() -> void: + _cm = Node.new() + _cm.set_script(load("res://scripts/domain/combat/combat_manager.gd")) + add_child(_cm) + _cm._on_shop_closed_cb = Callable(self, "_on_shop_ready") + # 根据 SceneManager 传入的启动模式决定新游戏还是继续 + if SceneManager.start_mode == "continue": + call_deferred("_deferred_resume") + else: + call_deferred("_deferred_start") + +func _deferred_start() -> void: + _cm.start_game() + +func _deferred_resume() -> void: + _cm.resume_game() + +func _subscribe_events() -> void: + EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_game_state_changed) + EventBus.subscribe(EventID.GAME_OVER, _on_game_over) + EventBus.subscribe(EventID.SPELL_DEPTH_EXCEEDED, _on_spell_depth_exceeded) + EventBus.subscribe(EventID.CHARGE_STATE_CHANGED, _on_charge_state_changed) + EventBus.subscribe(EventID.CHARGE_FIRED, _on_charge_fired) + PlayerStats.stats_changed.connect(_on_stats_changed) + +func _setup_hud() -> void: + _hud_layer = CanvasLayer.new() + _hud_layer.name = "HUD" + add_child(_hud_layer) + + var bg := ColorRect.new() + bg.color = Color(0, 0, 0, 0.5) + bg.size = Vector2(380, 190) + bg.position = Vector2(10, 10) + _hud_layer.add_child(bg) + + var make_label := func(pos: Vector2, font_size: int = 16) -> Label: + var lbl := Label.new() + lbl.position = pos + lbl.add_theme_font_size_override("font_size", font_size) + _hud_layer.add_child(lbl) + return lbl + + _lbl_wave = make_label.call(Vector2(20, 18), 18) + _lbl_hp = make_label.call(Vector2(20, 42)) + _lbl_gold = make_label.call(Vector2(20, 62)) + _lbl_xp = make_label.call(Vector2(20, 82)) + _lbl_kills = make_label.call(Vector2(20, 102)) + _lbl_dps = make_label.call(Vector2(200, 42)) + _lbl_charge = make_label.call(Vector2(200, 62)) + _lbl_deck = make_label.call(Vector2(20, 122), 13) + _lbl_statuses = make_label.call(Vector2(20, 142), 13) + + _lbl_status = make_label.call(Vector2(20, 620), 20) + _lbl_status.modulate = Color.YELLOW + +func _setup_shop_ui() -> void: + _shop_layer = CanvasLayer.new() + _shop_layer.name = "ShopLayer" + _shop_layer.visible = false + add_child(_shop_layer) + + var bg := ColorRect.new() + bg.color = Color(0.08, 0.08, 0.2, 0.92) + bg.size = Vector2(600, 340) + bg.position = Vector2(200, 200) + _shop_layer.add_child(bg) + + var title := Label.new() + title.text = tr("SHOP_TITLE") + title.position = Vector2(220, 215) + title.add_theme_font_size_override("font_size", 20) + title.modulate = Color.GOLD + _shop_layer.add_child(title) + + # 3 张法术卡按鈕 + for i in 3: + var btn := Button.new() + btn.position = Vector2(220 + i * 195, 255) + btn.size = Vector2(180, 130) + btn.connect("pressed", _on_shop_slot_pressed.bind(i)) + _shop_layer.add_child(btn) + _shop_btns.append(btn) + + _reroll_btn = Button.new() + _reroll_btn.text = tr("SHOP_REROLL") % ShopManager.REROLL_BASE_COST + _reroll_btn.position = Vector2(220, 400) + _reroll_btn.size = Vector2(130, 38) + _reroll_btn.connect("pressed", _on_reroll_pressed) + _shop_layer.add_child(_reroll_btn) + + # 法杖(Core)切换按钮:循环 wand_basic→fast→memory→matrix→circuit + _core_btn = Button.new() + _core_btn.position = Vector2(360, 400) + _core_btn.size = Vector2(180, 38) + _core_btn.connect("pressed", _on_switch_core_pressed) + _shop_layer.add_child(_core_btn) + + # 背包(插槽编辑)按钮 + _inv_btn = Button.new() + _inv_btn.text = tr("SHOP_INV_BTN") + _inv_btn.position = Vector2(220, 445) + _inv_btn.size = Vector2(130, 34) + _inv_btn.connect("pressed", _open_inventory) + _shop_layer.add_child(_inv_btn) + + # 语言切换(循环 zh_CN→zh_TW→en→ja) + _lang_btn = Button.new() + _lang_btn.position = Vector2(360, 445) + _lang_btn.size = Vector2(120, 34) + _lang_btn.connect("pressed", _on_lang_pressed) + _shop_layer.add_child(_lang_btn) + + # 设置面板入口 + var settings_btn := Button.new() + settings_btn.text = tr("SHOP_SETTINGS_BTN") + settings_btn.position = Vector2(490, 445) + settings_btn.size = Vector2(120, 34) + settings_btn.connect("pressed", _open_settings) + _shop_layer.add_child(settings_btn) + + _close_btn = Button.new() + _close_btn.text = tr("SHOP_NEXT_WAVE") + _close_btn.position = Vector2(550, 400) + _close_btn.size = Vector2(140, 38) + _close_btn.connect("pressed", _on_close_shop_pressed) + _shop_layer.add_child(_close_btn) + +# ── HUD 刷新 ──────────────────────────────────────────────── + +func _refresh_hud() -> void: + if _lbl_wave == null: + return + _lbl_wave.text = tr("UI_WAVE") % WaveManager.current_wave + _lbl_hp.text = tr("UI_HP") % [PlayerStats.hp, PlayerStats.hp_max] + _lbl_gold.text = tr("UI_GOLD") % PlayerStats.gold + _lbl_xp.text = tr("UI_XP") % [PlayerStats.xp, PlayerStats.xp_to_next, PlayerStats.level] + _lbl_kills.text = tr("UI_KILLS") % [PlayerManager.kill_count, EnemyManager.get_active_count()] + if _cm: + _lbl_deck.text = "[%s] %s" % [_cm.get_core_display(), str(_cm.get_deck_spell_ids())] + _lbl_statuses.text = tr("UI_STATUSES") % [StatusManager.get_active_count(), VFXManager.get_active_count()] + +func _refresh_shop_ui() -> void: + for i in _shop_btns.size(): + var btn: Button = _shop_btns[i] + var spell: SpellNode = ShopManager.current_slots[i] if i < ShopManager.current_slots.size() else null + if spell: + var cost: int = int(spell.meta.get("shop_cost", 20)) + btn.text = "%s\n%s\n%dG" % [spell.display_name, spell.description, cost] + btn.disabled = PlayerStats.gold < cost + else: + btn.text = tr("SHOP_SOLD") + btn.disabled = true + var reroll_cost: int = ShopManager.get_reroll_cost() + _reroll_btn.text = tr("SHOP_REROLL") % reroll_cost + _reroll_btn.disabled = PlayerStats.gold < reroll_cost + if _core_btn and _cm: + _core_btn.text = tr("SHOP_CORE_BTN") % _cm.get_core_display() + if _lang_btn: + _lang_btn.text = "🌐 " + Locale.get_locale() + +# ── 事件 ───────────────────────────────────────────────── + +func _on_game_state_changed(payload: Dictionary) -> void: + var to_state: String = str(payload.get("to", "")) + _lbl_status.text = "[状态: %s]" % to_state + +func _on_stats_changed() -> void: + if _shop_layer and _shop_layer.visible: + _refresh_shop_ui() + +func _on_shop_ready(_wave: int) -> void: + _shop_layer.visible = true + _refresh_shop_ui() + +func _on_shop_slot_pressed(slot_idx: int) -> void: + var spell: SpellNode = ShopManager.buy_spell(slot_idx) + if spell: + _cm.install_spell(spell) + _lbl_status.text = tr("STATUS_PURCHASED") % spell.display_name + _refresh_shop_ui() + +func _on_reroll_pressed() -> void: + if ShopManager.reroll(): + _refresh_shop_ui() + +func _on_switch_core_pressed() -> void: + _cm.switch_core() + _lbl_status.text = tr("STATUS_WAND_SWITCH") % _cm.get_core_display() + _refresh_shop_ui() + +func _on_lang_pressed() -> void: + SettingsManager.set_locale(Locale.cycle_locale()) # 切换并持久化 + _refresh_shop_ui() # 立即刷新商店文本(HUD 由 _process 持续刷新) + +# ── 暂停菜单 ──────────────────────────────────────────────── +func _setup_pause_menu() -> void: + _pause_layer = CanvasLayer.new() + _pause_layer.name = "PauseLayer" + _pause_layer.layer = 20 + _pause_layer.visible = false + add_child(_pause_layer) + # 半透明遮罩 + var dim := ColorRect.new() + dim.color = Color(0.0, 0.0, 0.0, 0.65) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_STOP + _pause_layer.add_child(dim) + # 面板 + var panel := ColorRect.new() + panel.color = Color(0.06, 0.06, 0.14, 0.98) + panel.size = Vector2(380, 340) + panel.position = Vector2(386, 154) + _pause_layer.add_child(panel) + # 标题 + var title := Label.new() + title.text = tr("PAUSE_TITLE") + title.position = Vector2(480, 174) + title.add_theme_font_size_override("font_size", 30) + title.modulate = Color.GOLD + _pause_layer.add_child(title) + # 按钮 + var btns = [ + [tr("PAUSE_RESUME"), func(): _toggle_pause()], + [tr("PAUSE_SETTINGS"), func(): _toggle_pause(); _open_settings()], + [tr("PAUSE_QUIT_MENU"), func(): get_tree().paused = false; SceneManager.goto_main_menu()], + [tr("PAUSE_QUIT_GAME"), func(): get_tree().quit()], + ] + var y: float = 240.0 + for bd in btns: + var b := Button.new() + b.text = bd[0] + b.position = Vector2(436, y) + b.size = Vector2(280, 46) + b.add_theme_font_size_override("font_size", 18) + b.connect("pressed", bd[1]) + _pause_layer.add_child(b) + y += 58.0 + +func _toggle_pause() -> void: + if _settle_layer and _settle_layer.visible: + return + _is_paused = not _is_paused + _pause_layer.visible = _is_paused + get_tree().paused = _is_paused + +# ── 设置面板(难度 / 语言 / 音量 / 清除数据 ST-03)────────────── +func _setup_settings_ui() -> void: + _settings_layer = CanvasLayer.new() + _settings_layer.name = "SettingsLayer" + _settings_layer.visible = false + add_child(_settings_layer) + var bg := ColorRect.new() + bg.color = Color(0.06, 0.06, 0.12, 0.97) + bg.size = Vector2(560, 440) + bg.position = Vector2(300, 110) + _settings_layer.add_child(bg) + _settings_root = Control.new() + _settings_layer.add_child(_settings_root) + +func _open_settings() -> void: + _clear_confirm = false + _settings_layer.visible = true + _rebuild_settings() + +func _close_settings() -> void: + _settings_layer.visible = false + +func _rebuild_settings() -> void: + for c in _settings_root.get_children(): + c.queue_free() + var ox: float = 300.0 + var oy: float = 110.0 + var title := Label.new() + title.text = tr("SETTINGS_TITLE") + title.position = Vector2(ox + 24, oy + 18) + title.add_theme_font_size_override("font_size", 26) + title.modulate = Color.GOLD + _settings_root.add_child(title) + + # 难度循环 + var diff_btn := Button.new() + diff_btn.text = tr("SETTINGS_DIFFICULTY") % tr(SettingsManager.difficulty_name()) + diff_btn.position = Vector2(ox + 24, oy + 70) + diff_btn.size = Vector2(360, 40) + diff_btn.connect("pressed", _on_diff_cycle) + _settings_root.add_child(diff_btn) + + # 语言循环 + var lang_btn := Button.new() + lang_btn.text = tr("SETTINGS_LANGUAGE") % Locale.get_locale() + lang_btn.position = Vector2(ox + 24, oy + 120) + lang_btn.size = Vector2(360, 40) + lang_btn.connect("pressed", _on_settings_lang) + _settings_root.add_child(lang_btn) + + # 音量 + var vol_lbl := Label.new() + vol_lbl.text = tr("SETTINGS_VOLUME") % int(SettingsManager.master_volume * 100.0) + vol_lbl.position = Vector2(ox + 24, oy + 175) + vol_lbl.add_theme_font_size_override("font_size", 16) + _settings_root.add_child(vol_lbl) + var slider := HSlider.new() + slider.min_value = 0.0 + slider.max_value = 1.0 + slider.step = 0.05 + slider.value = SettingsManager.master_volume + slider.position = Vector2(ox + 24, oy + 200) + slider.size = Vector2(360, 24) + slider.connect("value_changed", _on_volume_changed) + _settings_root.add_child(slider) + + # 清除所有本地数据(ST-03,二次确认) + var clear_btn := Button.new() + clear_btn.text = (tr("SETTINGS_CLEAR_CONFIRM") if _clear_confirm else tr("SETTINGS_CLEAR_DATA")) + clear_btn.position = Vector2(ox + 24, oy + 250) + clear_btn.size = Vector2(360, 40) + clear_btn.modulate = (Color(1.0, 0.4, 0.4) if _clear_confirm else Color.WHITE) + clear_btn.connect("pressed", _on_clear_data) + _settings_root.add_child(clear_btn) + + var close := Button.new() + close.text = tr("SETTINGS_CLOSE") + close.position = Vector2(ox + 410, oy + 360) + close.size = Vector2(120, 40) + close.connect("pressed", _close_settings) + _settings_root.add_child(close) + +func _on_diff_cycle() -> void: + SettingsManager.cycle_difficulty() + _rebuild_settings() + +func _on_settings_lang() -> void: + SettingsManager.set_locale(Locale.cycle_locale()) + _rebuild_settings() + _refresh_shop_ui() + +func _on_volume_changed(v: float) -> void: + SettingsManager.set_master_volume(v) + _rebuild_settings() + +func _on_clear_data() -> void: + if not _clear_confirm: + _clear_confirm = true # 首次点击进入确认态 + _rebuild_settings() + return + SettingsManager.clear_all_local_data() + _clear_confirm = false + _close_settings() + _lbl_status.text = tr("SETTINGS_CLEARED") + _refresh_shop_ui() + +# ── 背包 / 插槽编辑 UI ──────────────────────────────────────── +func _setup_inventory_ui() -> void: + _inv_layer = CanvasLayer.new() + _inv_layer.name = "InventoryLayer" + _inv_layer.visible = false + add_child(_inv_layer) + var bg := ColorRect.new() + bg.color = Color(0.05, 0.06, 0.12, 0.96) + bg.size = Vector2(720, 520) + bg.position = Vector2(150, 90) + _inv_layer.add_child(bg) + _inv_root = Control.new() # 动态内容容器(每次刷新清空重建) + _inv_layer.add_child(_inv_root) + +func _open_inventory() -> void: + _inv_sel_kind = "" + _inv_sel_idx = -1 + _inv_layer.visible = true + _rebuild_inventory() + +func _close_inventory() -> void: + _inv_layer.visible = false + +func _spell_short_name(sid: String) -> String: + if sid == "": + return tr("INV_EMPTY") + var sp: SpellNode = SpellRegistry.get_spell(sid) + return sp.display_name if sp else sid + +func _rebuild_inventory() -> void: + for c in _inv_root.get_children(): + c.queue_free() + var ox: float = 150.0 + var oy: float = 90.0 + # 标题 + var title := Label.new() + title.text = tr("INV_TITLE") % [ + _cm.get_core_display(), _cm.get_slot_count(), _topology_name()] + title.position = Vector2(ox + 20, oy + 12) + title.add_theme_font_size_override("font_size", 19) + title.modulate = Color.GOLD + _inv_root.add_child(title) + var tip := Label.new() + tip.text = tr("INV_TIP") + tip.position = Vector2(ox + 20, oy + 42) + tip.add_theme_font_size_override("font_size", 12) + tip.modulate = Color(0.8, 0.85, 1.0) + _inv_root.add_child(tip) + + # 插槽网格(拓扑布局) + var deck: Array = _cm.get_padded_deck() + var rows: int = 1 + var cols: int = deck.size() + if _cm._equipped_core and _cm._equipped_core.topology == CoreDefinition.Topology.MATRIX: + rows = max(1, _cm._equipped_core.grid_rows) + cols = max(1, _cm._equipped_core.grid_cols) + var bw: float = 150.0 + var bh: float = 56.0 + var gx: float = ox + 24 + var gy: float = oy + 80 + for idx in deck.size(): + var r: int = idx / cols + var col: int = idx % cols + var btn := Button.new() + btn.position = Vector2(gx + col * (bw + 8), gy + r * (bh + 8)) + btn.size = Vector2(bw, bh) + var sid: String = String(deck[idx]) + var rowtag: String = "" + if rows > 1: + rowtag = (tr("INV_ROW_A") if r == 0 else tr("INV_ROW_B")) + btn.text = "[%d] %s%s" % [idx, rowtag, _spell_short_name(sid)] + if sid == "": + btn.modulate = Color(0.6, 0.6, 0.6) + if _inv_sel_kind == "slot" and _inv_sel_idx == idx: + btn.modulate = Color(0.4, 1.0, 0.5) # 选中高亮 + btn.connect("pressed", _on_inv_slot_pressed.bind(idx)) + _inv_root.add_child(btn) + + # 备牌区 + var bench: Array = _cm.get_bench() + var bench_lbl := Label.new() + bench_lbl.text = tr("INV_BENCH") % bench.size() + bench_lbl.position = Vector2(gx, gy + rows * (bh + 8) + 16) + bench_lbl.add_theme_font_size_override("font_size", 14) + _inv_root.add_child(bench_lbl) + for bi in bench.size(): + var bbtn := Button.new() + bbtn.position = Vector2(gx + bi * (bw + 8), gy + rows * (bh + 8) + 42) + bbtn.size = Vector2(bw, bh) + bbtn.text = _spell_short_name(String(bench[bi])) + bbtn.modulate = Color(0.85, 0.8, 1.0) + if _inv_sel_kind == "bench" and _inv_sel_idx == bi: + bbtn.modulate = Color(0.4, 1.0, 0.5) + bbtn.connect("pressed", _on_inv_bench_pressed.bind(bi)) + _inv_root.add_child(bbtn) + + # 关闭按钮 + var close := Button.new() + close.text = tr("INV_CLOSE") + close.position = Vector2(ox + 560, oy + 470) + close.size = Vector2(130, 36) + close.connect("pressed", _close_inventory) + _inv_root.add_child(close) + +func _topology_name() -> String: + if _cm._equipped_core == null: + return "LINEAR" + match _cm._equipped_core.topology: + CoreDefinition.Topology.MATRIX: return "MATRIX" + CoreDefinition.Topology.CIRCUIT: return "CIRCUIT" + _: return "LINEAR" + +func _on_inv_slot_pressed(idx: int) -> void: + if _inv_sel_kind == "": + _inv_sel_kind = "slot"; _inv_sel_idx = idx + elif _inv_sel_kind == "slot": + if _inv_sel_idx == idx: + _cm.slot_to_bench(idx) # 点自身 → 移入备牌 + else: + _cm.swap_slots(_inv_sel_idx, idx) # 交换 + _inv_sel_kind = ""; _inv_sel_idx = -1 + elif _inv_sel_kind == "bench": + _cm.bench_to_slot(_inv_sel_idx, idx) # 备牌 → 插槽 + _inv_sel_kind = ""; _inv_sel_idx = -1 + _rebuild_inventory() + +func _on_inv_bench_pressed(bi: int) -> void: + if _inv_sel_kind == "bench" and _inv_sel_idx == bi: + _inv_sel_kind = ""; _inv_sel_idx = -1 # 取消选择 + else: + _inv_sel_kind = "bench"; _inv_sel_idx = bi + _rebuild_inventory() + +func _on_close_shop_pressed() -> void: + _shop_layer.visible = false + _cm.close_shop_and_next_wave() + +func _on_charge_state_changed(payload: Dictionary) -> void: + var prog: float = float(payload.get("progress", 0.0)) + if _lbl_charge: + _lbl_charge.text = tr("UI_CHARGE") % int(prog * 100.0) + +func _on_charge_fired(_payload: Dictionary) -> void: + if _lbl_charge: + _lbl_charge.text = tr("UI_CHARGE_DONE") + _lbl_status.text = tr("STATUS_CHARGE_FIRED") + +func _on_spell_depth_exceeded(_payload: Dictionary) -> void: + _lbl_status.text = tr("STATUS_DEPTH") % SpellEvaluator.MAX_TRIGGER_DEPTH + _lbl_status.modulate = Color.YELLOW + # 3 秒后自动清除 + var t := Timer.new() + add_child(t) + t.wait_time = 3.0 + t.one_shot = true + t.timeout.connect(func(): _lbl_status.text = ""; _lbl_status.modulate = Color.YELLOW) + t.start() + +func _on_game_over(payload: Dictionary) -> void: + _shop_layer.visible = false + _inv_layer.visible = false + if _pause_layer: + _pause_layer.visible = false + _is_paused = false + get_tree().paused = false + _lbl_status.text = "" + _rebuild_settlement(payload) + _settle_layer.visible = true + # P-S6-04:保存本局结算截图到 user://runs/(死亡屏,非黑屏) + call_deferred("_save_run_screenshot") + +func _restart_game() -> void: + _settle_layer.visible = false + _lbl_status.text = "" + PlayerManager.kill_count = 0 + _cm.start_game() + +func _return_to_menu() -> void: + SceneManager.goto_main_menu() + +# ── 结算 / 死亡屏 + 本地排行榜 ──────────────────────────────── +func _setup_settlement_ui() -> void: + _settle_layer = CanvasLayer.new() + _settle_layer.name = "SettlementLayer" + _settle_layer.visible = false + add_child(_settle_layer) + var bg := ColorRect.new() + bg.color = Color(0.04, 0.04, 0.09, 0.97) + bg.size = Vector2(1152, 648) + _settle_layer.add_child(bg) + _settle_root = Control.new() + _settle_layer.add_child(_settle_root) + +func _fmt_time(sec: int) -> String: + return "%d:%02d" % [sec / 60, sec % 60] + +func _rebuild_settlement(payload: Dictionary) -> void: + for c in _settle_root.get_children(): + c.queue_free() + var wave: int = int(payload.get("wave", 1)) + var kills: int = int(payload.get("kills", 0)) + var elapsed: int = int(payload.get("elapsed_sec", 0)) + var score: int = int(payload.get("score", 0)) + + var title := Label.new() + title.text = tr("SETTLE_TITLE") + title.position = Vector2(440, 60) + title.add_theme_font_size_override("font_size", 40) + title.modulate = Color(1.0, 0.35, 0.35) + _settle_root.add_child(title) + + var stats := Label.new() + stats.text = tr("SETTLE_STATS") % [wave, kills, _fmt_time(elapsed), score] + stats.position = Vector2(400, 140) + stats.add_theme_font_size_override("font_size", 22) + _settle_root.add_child(stats) + + # 本地排行榜(前 10) + var board_title := Label.new() + board_title.text = tr("SETTLE_BOARD") + board_title.position = Vector2(400, 230) + board_title.add_theme_font_size_override("font_size", 18) + board_title.modulate = Color.GOLD + _settle_root.add_child(board_title) + + var top: Array = EndlessRecords.get_top(10) + var my_score: int = score + for i in top.size(): + var rec: Dictionary = top[i] + var row := Label.new() + var is_me: bool = int(rec.get("score", -1)) == my_score + row.text = tr("SETTLE_BOARD_ROW") % [ + i + 1, int(rec.get("wave", 0)), _fmt_time(int(rec.get("elapsed", 0))), + int(rec.get("score", 0)), (tr("SETTLE_THIS_RUN") if is_me else "")] + row.position = Vector2(420, 265 + i * 26) + row.add_theme_font_size_override("font_size", 15) + if is_me: + row.modulate = Color(0.4, 1.0, 0.5) + _settle_root.add_child(row) + + var restart := Button.new() + restart.text = tr("SETTLE_RESTART") + restart.position = Vector2(380, 560) + restart.size = Vector2(180, 46) + restart.connect("pressed", _restart_game) + _settle_root.add_child(restart) + + var menu_btn := Button.new() + menu_btn.text = tr("SETTLE_RETURN_MENU") + menu_btn.position = Vector2(580, 560) + menu_btn.size = Vector2(180, 46) + menu_btn.connect("pressed", _return_to_menu) + _settle_root.add_child(menu_btn) + +func _save_run_screenshot() -> void: + var img := get_viewport().get_texture().get_image() + if img == null: + return + DirAccess.make_dir_recursive_absolute("user://runs") + var path: String = "user://runs/run_%d.png" % Time.get_ticks_msec() + img.save_png(path) diff --git a/scenes/main/combat_s2.gd.uid b/scenes/main/combat_s2.gd.uid new file mode 100644 index 0000000..41e838c --- /dev/null +++ b/scenes/main/combat_s2.gd.uid @@ -0,0 +1 @@ +uid://bhjufa2ng137y diff --git a/scenes/main/combat_s2.tscn b/scenes/main/combat_s2.tscn new file mode 100644 index 0000000..1975c3e --- /dev/null +++ b/scenes/main/combat_s2.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://hv4b6soy60l"] + +[ext_resource type="Script" uid="uid://bhjufa2ng137y" path="res://scenes/main/combat_s2.gd" id="1_yyrjn"] + +[node name="CombatS2" type="Node2D" unique_id=1360302971] +script = ExtResource("1_yyrjn") diff --git a/scenes/main/combat_test.gd b/scenes/main/combat_test.gd new file mode 100644 index 0000000..521ca98 --- /dev/null +++ b/scenes/main/combat_test.gd @@ -0,0 +1,62 @@ +## CombatTest — S1 最小战斗调试场景 +## 目标:玩家自动发射 spark_bolt,子弹命中敌人,敌人死亡,屏幕显示击杀数 +## 验收:P-S1-01 ~ P-S1-05 +extends Node2D + +const SPAWN_ENEMIES: int = 50 # S1 验收:50 只散兵(充分验证伤害) + +var _player_node: Node2D = null +var _perf_label: Label = null +var _spawned: bool = false + +func _ready() -> void: + _setup_debug_ui() + PlayerManager.set_player_node(_player_node) + # 装备法杯:wand_basic + spark_bolt + var loadout: Dictionary = WandPreset.make_default_loadout() + PlayerManager.equip_wand(loadout["core"], loadout["compiled"]) + _run_spawn() + +func _process(_delta: float) -> void: + _update_perf_ui() + +func _run_spawn() -> void: + var arena: float = 600.0 + for _i in SPAWN_ENEMIES: + EnemyManager.spawn_enemy( + Vector2(randf_range(-arena, arena), randf_range(-arena, arena)), + 50.0 # P-S1-01: HP = 50,spark_bolt 卷 3 伤 + ) + _spawned = true + print("[CombatTest S1] 生成完成: %d 敌人, wand_basic+spark_bolt 装备" % SPAWN_ENEMIES) + +func _update_perf_ui() -> void: + if not _spawned or not _perf_label: + return + _perf_label.text = ( + "FPS: %.1f\n敌人: %d\n击杀: %d\n子弹: %d\nTick: %d\n池: %s" % [ + Engine.get_frames_per_second(), + EnemyManager.get_active_count(), + PlayerManager.kill_count, + BulletManager.get_active_count(), + TimeManager.game_tick, + DamageContextPool.get_pool_usage() + ] + ) + +func _setup_debug_ui() -> void: + var canvas := CanvasLayer.new() + canvas.name = "DebugUI" + add_child(canvas) + + var perf := Label.new() + perf.name = "PerfLabel" + perf.position = Vector2(16, 16) + perf.add_theme_font_size_override("font_size", 18) + canvas.add_child(perf) + _perf_label = perf + + var p := Node2D.new() + p.name = "Player" + add_child(p) + _player_node = p diff --git a/scenes/main/combat_test.gd.uid b/scenes/main/combat_test.gd.uid new file mode 100644 index 0000000..5c7313f --- /dev/null +++ b/scenes/main/combat_test.gd.uid @@ -0,0 +1 @@ +uid://2ruoy0wv4dnt diff --git a/scenes/main/combat_test.tscn b/scenes/main/combat_test.tscn new file mode 100644 index 0000000..dfa7ae0 --- /dev/null +++ b/scenes/main/combat_test.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://pjspwe2drl6d"] + +[ext_resource type="Script" uid="uid://2ruoy0wv4dnt" path="res://scenes/main/combat_test.gd" id="1_hyeb1"] + +[node name="CombatTest" type="Node2D" unique_id=2032556871] +script = ExtResource("1_hyeb1") diff --git a/scenes/ui/main_menu.gd b/scenes/ui/main_menu.gd new file mode 100644 index 0000000..36aacaa --- /dev/null +++ b/scenes/ui/main_menu.gd @@ -0,0 +1,353 @@ +## 主菜单:新游戏 / 继续 / 排行榜 / 设置 / 退出 +extends Control + +var _settings_layer: CanvasLayer = null +var _settings_root: Control = null +var _leaderboard_layer: CanvasLayer = null +var _ftue_layer: CanvasLayer = null +var _continue_btn: Button = null +var _quit_confirm: bool = false + +func _ready() -> void: + SceneManager.fade_in_first() + _build_bg() + _build_menu() + _build_settings_panel() + _build_leaderboard_panel() + _build_ftue_panel() + +func _build_bg() -> void: + # 深色渐变背景 + var bg := ColorRect.new() + bg.color = Color(0.04, 0.04, 0.09) + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(bg) + # 左侧装饰色条 + var stripe := ColorRect.new() + stripe.color = Color(0.18, 0.12, 0.40, 0.6) + stripe.size = Vector2(320, 648) + add_child(stripe) + +func _build_menu() -> void: + # 游戏大标题 + var title := Label.new() + title.text = "魔法工匠" + title.position = Vector2(40, 80) + title.add_theme_font_size_override("font_size", 56) + title.modulate = Color(1.0, 0.85, 0.3) + add_child(title) + + var sub := Label.new() + sub.text = "Arcane Artificer" + sub.position = Vector2(44, 148) + sub.add_theme_font_size_override("font_size", 18) + sub.modulate = Color(0.7, 0.75, 1.0) + add_child(sub) + + # 按钮列 + var items: Array = [ + ["MENU_NEW_GAME", _on_new_game], + ["MENU_CONTINUE", _on_continue], + ["MENU_LEADERBOARD", _on_leaderboard], + ["MENU_SETTINGS", _on_settings], + ["MENU_QUIT", _on_quit], + ] + var y: float = 240.0 + for item in items: + var btn := _make_btn(tr(item[0]), item[1]) + btn.position = Vector2(52, y) + btn.size = Vector2(220, 48) + add_child(btn) + if item[0] == "MENU_CONTINUE": + _continue_btn = btn + _continue_btn.disabled = not SceneManager.has_save() + _continue_btn.modulate = Color(1, 1, 1, 0.45) if _continue_btn.disabled else Color.WHITE + y += 62.0 + + # 版本号 + var ver := Label.new() + ver.text = "v1.0.0" + ver.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + ver.position = Vector2(-80, -20) + ver.add_theme_font_size_override("font_size", 12) + ver.modulate = Color(0.4, 0.4, 0.4) + add_child(ver) + + # 语言切换 + var lang := Button.new() + lang.text = "🌐 %s" % Locale.get_locale() + lang.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) + lang.position = Vector2(16, -52) + lang.size = Vector2(120, 36) + lang.flat = true + lang.connect("pressed", _on_lang) + add_child(lang) + +func _make_btn(label: String, cb: Callable) -> Button: + var btn := Button.new() + btn.text = label + btn.flat = false + btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + btn.add_theme_font_size_override("font_size", 20) + btn.connect("pressed", cb) + return btn + +func _on_new_game() -> void: + AudioManager.play("shop") + # 首次启动:先引导玩家选难度(FTUE),否则直接开始 + if SettingsManager.needs_ftue(): + _ftue_layer.visible = true + _rebuild_ftue() + else: + SceneManager.start_new_game() + +func _on_continue() -> void: + if SceneManager.has_save(): + AudioManager.play("shop") + SceneManager.continue_game() + +func _on_leaderboard() -> void: + _leaderboard_layer.visible = true + _rebuild_leaderboard() + +func _on_settings() -> void: + _settings_layer.visible = true + _rebuild_settings() + +func _on_lang() -> void: + SettingsManager.set_locale(Locale.cycle_locale()) + # 重建菜单文本(语言已切换) + for c in get_children(): + c.queue_free() + _settings_layer = null + _leaderboard_layer = null + _settings_root = null + _build_bg() + _build_menu() + _build_settings_panel() + _build_leaderboard_panel() + +func _on_quit() -> void: + if not _quit_confirm: + _quit_confirm = true + # 把退出按钮文字改为确认提示 + for c in get_children(): + if c is Button and tr("MENU_QUIT") in c.text: + c.text = tr("MENU_QUIT_CONFIRM") + c.modulate = Color(1.0, 0.4, 0.4) + # 3 秒后自动取消确认态 + var t := Timer.new() + add_child(t) + t.wait_time = 3.0 + t.one_shot = true + t.timeout.connect(func(): + c.text = tr("MENU_QUIT") + c.modulate = Color.WHITE + _quit_confirm = false) + t.start() + return + get_tree().quit() + +func _input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + if _settings_layer and _settings_layer.visible: + _settings_layer.visible = false + elif _leaderboard_layer and _leaderboard_layer.visible: + _leaderboard_layer.visible = false + +# ── 设置面板 ───────────────────────────────────────────────── +func _build_settings_panel() -> void: + _settings_layer = CanvasLayer.new() + _settings_layer.layer = 10 + _settings_layer.visible = false + add_child(_settings_layer) + var bg := ColorRect.new() + bg.color = Color(0.0, 0.0, 0.0, 0.72) + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _settings_layer.add_child(bg) + _settings_root = Control.new() + _settings_layer.add_child(_settings_root) + +func _rebuild_settings() -> void: + for c in _settings_root.get_children(): + c.queue_free() + var panel := ColorRect.new() + panel.color = Color(0.06, 0.06, 0.14, 0.98) + panel.size = Vector2(560, 440) + panel.position = Vector2(300, 100) + _settings_root.add_child(panel) + + var title := Label.new() + title.text = tr("SETTINGS_TITLE") + title.position = Vector2(324, 120) + title.add_theme_font_size_override("font_size", 28) + title.modulate = Color.GOLD + _settings_root.add_child(title) + + _settings_row(tr("SETTINGS_DIFFICULTY") % tr(SettingsManager.difficulty_name()), 176, func(): + SettingsManager.cycle_difficulty(); _rebuild_settings()) + _settings_row(tr("SETTINGS_LANGUAGE") % Locale.get_locale(), 232, func(): + SettingsManager.set_locale(Locale.cycle_locale()); _rebuild_settings()) + + var vol_lbl := Label.new() + vol_lbl.text = tr("SETTINGS_VOLUME") % int(SettingsManager.master_volume * 100.0) + vol_lbl.position = Vector2(324, 292) + vol_lbl.add_theme_font_size_override("font_size", 16) + _settings_root.add_child(vol_lbl) + var slider := HSlider.new() + slider.min_value = 0.0; slider.max_value = 1.0; slider.step = 0.05 + slider.value = SettingsManager.master_volume + slider.position = Vector2(324, 318); slider.size = Vector2(360, 24) + slider.connect("value_changed", func(v): SettingsManager.set_master_volume(v); _rebuild_settings()) + _settings_root.add_child(slider) + + var close := Button.new() + close.text = tr("SETTINGS_CLOSE") + close.position = Vector2(710, 504) + close.size = Vector2(130, 40) + close.connect("pressed", func(): _settings_layer.visible = false) + _settings_root.add_child(close) + +func _settings_row(text: String, y: float, cb: Callable) -> void: + var btn := Button.new() + btn.text = text + btn.position = Vector2(324, y) + btn.size = Vector2(360, 40) + btn.connect("pressed", cb) + _settings_root.add_child(btn) + +# ── 排行榜面板 ─────────────────────────────────────────────── +func _build_leaderboard_panel() -> void: + _leaderboard_layer = CanvasLayer.new() + _leaderboard_layer.layer = 10 + _leaderboard_layer.visible = false + add_child(_leaderboard_layer) + +func _rebuild_leaderboard() -> void: + for c in _leaderboard_layer.get_children(): + if c is ColorRect or c is Control: + c.queue_free() + var bg := ColorRect.new() + bg.color = Color(0.0, 0.0, 0.0, 0.72) + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _leaderboard_layer.add_child(bg) + + var panel := ColorRect.new() + panel.color = Color(0.05, 0.05, 0.13, 0.98) + panel.size = Vector2(640, 500) + panel.position = Vector2(256, 74) + _leaderboard_layer.add_child(panel) + + var title := Label.new() + title.text = tr("SETTLE_BOARD") + title.position = Vector2(280, 92) + title.add_theme_font_size_override("font_size", 22) + title.modulate = Color.GOLD + _leaderboard_layer.add_child(title) + + var top: Array = EndlessRecords.get_top(10) + if top.is_empty(): + var empty := Label.new() + empty.text = tr("LEADERBOARD_EMPTY") + empty.position = Vector2(440, 300) + empty.add_theme_font_size_override("font_size", 18) + empty.modulate = Color(0.6, 0.6, 0.6) + _leaderboard_layer.add_child(empty) + else: + for i in top.size(): + var rec: Dictionary = top[i] + var row := Label.new() + var elapsed: int = int(rec.get("elapsed", 0)) + row.text = "%2d. Wave %d %d:%02d Score %d" % [ + i + 1, int(rec.get("wave", 0)), + int(elapsed / 60.0), elapsed % 60, + int(rec.get("score", 0))] + row.position = Vector2(296, 140 + i*30) + row.add_theme_font_size_override("font_size", 15) + _leaderboard_layer.add_child(row) + + var close := Button.new() + close.text = tr("SETTINGS_CLOSE") + close.position = Vector2(762, 538) + close.size = Vector2(120, 40) + close.connect("pressed", func(): _leaderboard_layer.visible = false) + _leaderboard_layer.add_child(close) + +# ── FTUE:首启难度引导 ─────────────────────────────────────── +func _build_ftue_panel() -> void: + _ftue_layer = CanvasLayer.new() + _ftue_layer.layer = 12 + _ftue_layer.visible = false + add_child(_ftue_layer) + +func _rebuild_ftue() -> void: + for c in _ftue_layer.get_children(): + c.queue_free() + var dim := ColorRect.new() + dim.color = Color(0.02, 0.02, 0.06, 0.97) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _ftue_layer.add_child(dim) + + var title := Label.new() + title.text = tr("FTUE_TITLE") + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.set_anchors_preset(Control.PRESET_TOP_WIDE) + title.position.y = 70 + title.add_theme_font_size_override("font_size", 38) + title.modulate = Color(1.0, 0.85, 0.3) + _ftue_layer.add_child(title) + + # 三张难度卡片(横排) + var cards: Array = [ + [0, "DIFF_BEGINNER", "FTUE_BEGINNER_DESC", Color(0.3, 0.85, 0.5)], + [1, "DIFF_STANDARD", "FTUE_STANDARD_DESC", Color(0.4, 0.7, 1.0)], + [2, "DIFF_CHALLENGE", "FTUE_CHALLENGE_DESC", Color(1.0, 0.45, 0.4)], + ] + var card_w: float = 300.0 + var gap: float = 30.0 + var total_w: float = card_w * 3 + gap * 2 + var start_x: float = (1152.0 - total_w) / 2.0 + for i in cards.size(): + var cd: Array = cards[i] + var x: float = start_x + i * (card_w + gap) + var card := Button.new() + card.position = Vector2(x, 200) + card.size = Vector2(card_w, 240) + card.connect("pressed", _on_ftue_pick.bind(int(cd[0]))) + _ftue_layer.add_child(card) + # 难度名 + var name_lbl := Label.new() + name_lbl.text = tr(cd[1]) + name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + name_lbl.size = Vector2(card_w, 40) + name_lbl.position = Vector2(x, 230) + name_lbl.add_theme_font_size_override("font_size", 30) + name_lbl.modulate = cd[3] + name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + _ftue_layer.add_child(name_lbl) + # 说明 + var desc := Label.new() + desc.text = tr(cd[2]) + desc.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + desc.size = Vector2(card_w - 40, 120) + desc.position = Vector2(x + 20, 300) + desc.add_theme_font_size_override("font_size", 16) + desc.modulate = Color(0.85, 0.85, 0.9) + desc.mouse_filter = Control.MOUSE_FILTER_IGNORE + _ftue_layer.add_child(desc) + + var hint := Label.new() + hint.text = tr("FTUE_HINT") + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hint.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + hint.position.y = -60 + hint.add_theme_font_size_override("font_size", 14) + hint.modulate = Color(0.55, 0.55, 0.6) + _ftue_layer.add_child(hint) + +func _on_ftue_pick(difficulty_idx: int) -> void: + SettingsManager.complete_ftue(difficulty_idx) # 设难度 + 标记 FTUE 完成 + 存档 + AudioManager.play("buy") + _ftue_layer.visible = false + SceneManager.start_new_game() diff --git a/scenes/ui/main_menu.gd.uid b/scenes/ui/main_menu.gd.uid new file mode 100644 index 0000000..db55b41 --- /dev/null +++ b/scenes/ui/main_menu.gd.uid @@ -0,0 +1 @@ +uid://o7kn0dw126em diff --git a/scenes/ui/main_menu.tscn b/scenes/ui/main_menu.tscn new file mode 100644 index 0000000..0d919aa --- /dev/null +++ b/scenes/ui/main_menu.tscn @@ -0,0 +1,10 @@ +[gd_scene format=3 uid="uid://cu6pbsyyjldos"] + +[ext_resource type="Script" uid="uid://o7kn0dw126em" path="res://scenes/ui/main_menu.gd" id="1"] + +[node name="MainMenu" type="Control" unique_id=369072083] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") diff --git a/scenes/ui/splash.gd b/scenes/ui/splash.gd new file mode 100644 index 0000000..4755f09 --- /dev/null +++ b/scenes/ui/splash.gd @@ -0,0 +1,91 @@ +## 启动画面:公司/游戏商标 + 引擎版权 → 自动跳转主菜单 +extends Control + +const HOLD_SEC: float = 1.8 # 完全显示后停留时长 +var _timer: float = 0.0 +var _skip_ready: bool = false # 防止首帧立即跳过 + +func _ready() -> void: + SceneManager.fade_in_first() + _build_ui() + # 延迟一帧后才允许跳过(防止启动时按键残留) + call_deferred("_allow_skip") + +func _allow_skip() -> void: + _skip_ready = true + +func _build_ui() -> void: + # 全屏渐变背景 + var bg := ColorRect.new() + bg.color = Color(0.04, 0.04, 0.09) + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(bg) + + # 工作室名 + var studio := Label.new() + studio.text = "LANTERN GAMES" + studio.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + studio.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + studio.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + studio.position.y = -80 + studio.add_theme_font_size_override("font_size", 18) + studio.modulate = Color(0.7, 0.7, 0.7) + add_child(studio) + + # 游戏标题 + var title := Label.new() + title.text = "魔法工匠" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + title.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + title.add_theme_font_size_override("font_size", 64) + title.modulate = Color(1.0, 0.85, 0.3) + add_child(title) + + # 英文副标题 + var sub := Label.new() + sub.text = "Arcane Artificer" + sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + sub.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + sub.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + sub.position.y = 64 + sub.add_theme_font_size_override("font_size", 22) + sub.modulate = Color(0.7, 0.75, 1.0) + add_child(sub) + + # 提示 + var hint := Label.new() + hint.text = tr("SPLASH_SKIP_HINT") + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hint.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + hint.position.y = -40 + hint.add_theme_font_size_override("font_size", 13) + hint.modulate = Color(0.5, 0.5, 0.5) + add_child(hint) + + # 版权 + var copy := Label.new() + copy.text = "© 2026 Lantern Games | Powered by Godot 4.6" + copy.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + copy.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + copy.position.y = -16 + copy.add_theme_font_size_override("font_size", 11) + copy.modulate = Color(0.4, 0.4, 0.4) + add_child(copy) + +func _process(delta: float) -> void: + _timer += delta + if _timer >= HOLD_SEC: + _go_to_menu() + +func _input(event: InputEvent) -> void: + if not _skip_ready: + return + if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel") \ + or (event is InputEventMouseButton and event.pressed): + _go_to_menu() + +func _go_to_menu() -> void: + set_process(false) + set_process_input(false) + SceneManager.goto_main_menu() diff --git a/scenes/ui/splash.gd.uid b/scenes/ui/splash.gd.uid new file mode 100644 index 0000000..566278b --- /dev/null +++ b/scenes/ui/splash.gd.uid @@ -0,0 +1 @@ +uid://uch25wxg7638 diff --git a/scenes/ui/splash.tscn b/scenes/ui/splash.tscn new file mode 100644 index 0000000..c9c6255 --- /dev/null +++ b/scenes/ui/splash.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3 uid="uid://splash_scene"] + +[ext_resource type="Script" path="res://scenes/ui/splash.gd" id="1"] + +[node name="Splash" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") diff --git a/scripts/autoloads/audio_bus_id.gd b/scripts/autoloads/audio_bus_id.gd new file mode 100644 index 0000000..21b1084 --- /dev/null +++ b/scripts/autoloads/audio_bus_id.gd @@ -0,0 +1,15 @@ +## AudioBusID — AudioBus 索引常量(Autoload: AudioBusID) +## 与 AudioServer 路由层(architecture_design.md ADR-A3)对应。 +## 禁止在 SettingsManager 等处使用字符串查 Bus(跨切片约束)。 +extends Node + +const MASTER: int = 0 +const BGM: int = 1 +const BGM_BASE: int = 2 +const BGM_LAYER: int = 3 +const SFX: int = 4 +const SFX_COMBAT: int = 5 +const SFX_SPATIAL: int = 6 +const UI: int = 7 +const AMBIENCE: int = 8 +const VOICE: int = 9 diff --git a/scripts/autoloads/audio_bus_id.gd.uid b/scripts/autoloads/audio_bus_id.gd.uid new file mode 100644 index 0000000..6fc001d --- /dev/null +++ b/scripts/autoloads/audio_bus_id.gd.uid @@ -0,0 +1 @@ +uid://douqjxwt18yns diff --git a/scripts/autoloads/audio_manager.gd b/scripts/autoloads/audio_manager.gd new file mode 100644 index 0000000..9a0e359 --- /dev/null +++ b/scripts/autoloads/audio_manager.gd @@ -0,0 +1,110 @@ +## AudioManager — 音效池 + 节流(Autoload: AudioManager) +## 权威来源:development_plan.md S6 P1(32 AudioStreamPlayer2D 池、0.1s 节流) +## 无音频素材时用程序化蜂鸣占位(同 API,放素材即替换)。SFX 总线 send→Master,受 SettingsManager 音量影响。 +extends Node + +const POOL_SIZE: int = 32 +const THROTTLE_SEC: float = 0.1 # 同一 sound_id 两次播放最小间隔 + +## sound_id → 占位蜂鸣参数 [freq, ms](有真实 res://audio/sfx/.* 时优先加载) +const _SFX_DEFS: Dictionary = { + "hit": [900.0, 60.0], + "kill": [420.0, 90.0], + "hurt": [180.0, 140.0], + "wave_complete": [660.0, 220.0], + "boss": [120.0, 400.0], + "shop": [780.0, 120.0], + "buy": [1040.0, 80.0], + "level_up": [880.0, 180.0], +} + +var _players: Array = [] # Array[AudioStreamPlayer2D] +var _next: int = 0 # round-robin 游标 +var _streams: Dictionary = {} # sound_id → AudioStream(懒构建缓存) +var _last_play: Dictionary = {} # sound_id → 上次播放 ticks_msec + +func _ready() -> void: + _ensure_sfx_bus() + for i in POOL_SIZE: + var p := AudioStreamPlayer2D.new() + p.bus = "SFX" + add_child(p) + _players.append(p) + # 事件挂钩(无素材时静默蜂鸣) + EventBus.subscribe(EventID.ENEMY_KILLED, func(_p): play("kill")) + EventBus.subscribe(EventID.BULLET_HIT, func(p): play("hit", p.get("hit_pos", Vector2.ZERO))) + EventBus.subscribe(EventID.PLAYER_DAMAGED, func(_p): play("hurt")) + EventBus.subscribe(EventID.WAVE_COMPLETE, func(_p): play("wave_complete")) + EventBus.subscribe(EventID.BOSS_SPAWNED, func(_p): play("boss")) + EventBus.subscribe(EventID.SHOP_OPENED, func(_p): play("shop")) + EventBus.subscribe(EventID.LEVEL_UP, func(_p): play("level_up")) + +func _ensure_sfx_bus() -> void: + if AudioServer.get_bus_index("SFX") < 0: + var idx: int = AudioServer.bus_count + AudioServer.add_bus(idx) + AudioServer.set_bus_name(idx, "SFX") + AudioServer.set_bus_send(idx, "Master") # 受 Master 音量(SettingsManager)影响 + +## 播放音效;返回 true=已触发,false=节流跳过 / 无可用流 +func play(sound_id: String, world_pos: Vector2 = Vector2.ZERO) -> bool: + var now: int = Time.get_ticks_msec() + var last: int = int(_last_play.get(sound_id, -100000)) + if now - last < int(THROTTLE_SEC * 1000.0): + return false # 0.1s 节流:抑制同一音效高频刷屏 + var stream: AudioStream = _get_stream(sound_id) + if stream == null: + return false + _last_play[sound_id] = now + var p: AudioStreamPlayer2D = _players[_next] + _next = (_next + 1) % POOL_SIZE + p.stream = stream + p.global_position = world_pos + p.play() + return true + +func _get_stream(sound_id: String) -> AudioStream: + if _streams.has(sound_id): + return _streams[sound_id] + # 优先真实素材 + for ext in [".ogg", ".wav", ".mp3"]: + var path: String = "res://audio/sfx/%s%s" % [sound_id, ext] + if ResourceLoader.exists(path): + var s = load(path) + if s is AudioStream: + _streams[sound_id] = s + return s + # 回退:程序化蜂鸣 + if _SFX_DEFS.has(sound_id): + var def: Array = _SFX_DEFS[sound_id] + var beep := _make_beep(float(def[0]), float(def[1])) + _streams[sound_id] = beep + return beep + _streams[sound_id] = null + return null + +## 生成短蜂鸣(16-bit PCM 正弦 + 线性衰减包络) +func _make_beep(freq: float, ms: float) -> AudioStreamWAV: + var sr: int = 22050 + var n: int = max(1, int(sr * ms / 1000.0)) + var data := PackedByteArray() + data.resize(n * 2) + for i in n: + var t: float = float(i) / float(sr) + var env: float = 1.0 - float(i) / float(n) + var sample: int = int(sin(t * freq * TAU) * 11000.0 * env) + data.encode_s16(i * 2, sample) + var w := AudioStreamWAV.new() + w.format = AudioStreamWAV.FORMAT_16_BITS + w.mix_rate = sr + w.stereo = false + w.data = data + return w + +## 当前正在播放的池实例数(测试 / 调试用) +func active_voices() -> int: + var c: int = 0 + for p in _players: + if p.playing: + c += 1 + return c diff --git a/scripts/autoloads/audio_manager.gd.uid b/scripts/autoloads/audio_manager.gd.uid new file mode 100644 index 0000000..309fb11 --- /dev/null +++ b/scripts/autoloads/audio_manager.gd.uid @@ -0,0 +1 @@ +uid://cufd7gicqywyq diff --git a/scripts/autoloads/bullet_manager.gd b/scripts/autoloads/bullet_manager.gd new file mode 100644 index 0000000..bceaea3 --- /dev/null +++ b/scripts/autoloads/bullet_manager.gd @@ -0,0 +1,178 @@ +## BulletManager — 子弹管理器 Autoload(GDScript 接口层) +## 热路径由 C# BulletManagerCs 子节点驱动。 +## 权威来源:architecture_design.md §4.2 +## +## SoA 完整布局(BULLET_STRIDE=12): +## [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ] +## 0 1 2 3 4 5 6 7 8 9 10 11 +## 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary +extends Node + +const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束) +const MAX_BULLETS: int = 2048 # 预分配 SoA 大小 + +# SoA 热数组(由 BulletManagerCs 直接读写) +var _data: PackedFloat32Array = PackedFloat32Array() +var _active_count: int = 0 + +# 冷数据(非热路径) +var _bullet_contexts: Dictionary = {} + +# 敌人位置快照(homing 共用,由 _physics_process 帧头建立) +var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array() + +var _cs_node: Node = null + +func _ready() -> void: + _data.resize(MAX_BULLETS * BULLET_STRIDE) + _data.fill(0.0) + +func _physics_process(delta: float) -> void: + EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot) + if not _cs_node: + _gd_integrate(delta) + +# GDScript 回退路径(C# 未就绪时 / S0/S1 基线验证) +func _gd_integrate(delta: float) -> void: + var i: int = 0 + while i < _active_count: + var base: int = i * BULLET_STRIDE + _data[base + 0] += _data[base + 2] * delta # x += vx * dt + _data[base + 1] += _data[base + 3] * delta # y += vy * dt + _data[base + 2] += _data[base + 11] * delta # vx += accel * dt + _data[base + 3] += _data[base + 11] * delta # vy += accel * dt + _data[base + 4] -= delta # lifetime -= dt + if _data[base + 4] <= 0.0: + _swap_and_pop(i) + elif _check_collision(i, base): + # 命中处理已在 _check_collision 内完成,子弹被回收 + pass # _active_count 已减少,i 不递增 + else: + i += 1 + +# S1 碰撞检测:数据驱动,使用 SpatialGrid 查询 +# 命中后扣血;pierce_remaining=0 时回收子弹;返回 true 表示子弹已被回收 +func _check_collision(bullet_idx: int, base: int) -> bool: + var bx: float = _data[base + 0] + var by: float = _data[base + 1] + var br: float = _data[base + 5] # bullet radius + var b_dmg: float = _data[base + 6] # base_damage + var b_mult: float = _data[base + 7] # damage_mult + var b_type: int = int(_data[base + 8]) + var b_own: int = int(_data[base + 9]) + var query_r: float = br + 16.0 # 16px = 敌人碰撞体估算半径 + var hits: PackedInt32Array = SpatialGrid.query_circle(Vector2(bx, by), query_r) + if hits.is_empty(): + return false + for entity_id in hits: + var ene_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id) + if ene_pos == Vector2(-9999.0, -9999.0): + continue + var dx: float = bx - ene_pos.x + var dy: float = by - ene_pos.y + var dist_sq: float = dx * dx + dy * dy + if dist_sq > query_r * query_r: + continue + # 命中:通过 DamageContextPool 发出伤害 + var ctx_id: int = DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0) + EnemyManager.apply_damage_from_context(entity_id, ctx_id) + DamageContextPool.release(ctx_id) + var hit_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id) + if hit_pos == Vector2(-9999.0, -9999.0): + hit_pos = Vector2(bx, by) + VFXManager.play("hit_spark", hit_pos) + EventBus.emit(EventID.BULLET_HIT, {"bullet_id": bullet_idx, "target_id": entity_id, "hit_pos": hit_pos}) + # S4: 命中施加状态 / 连击标记 + var cold: Dictionary = _bullet_contexts.get(bullet_idx, {}) + var status_id: int = int(cold.get("apply_status_id", -1)) + if status_id > 0: + StatusManager.apply(entity_id, status_id, 1, -1.0, b_own) + if cold.get("apply_combo_mark", false): + StatusManager.apply(entity_id, StatusID.COMBO_MARK, 1, 2.0, b_own) + # S3: 命中触发子荷载 + var payload_id: int = int(cold.get("on_hit_payload_id", -1)) + if payload_id >= 0: + var parent_depth: int = int(cold.get("trigger_parent_depth", 0)) + SpellEvaluator.execute_sub(payload_id, hit_pos, b_own, parent_depth + 1) + # pierce 处理 + var pierce: int = int(cold.get("pierce_remaining", 0)) + if pierce > 0: + cold["pierce_remaining"] = pierce - 1 + _bullet_contexts[bullet_idx] = cold + return false # 穿透:子弹继续飞行 + _swap_and_pop(bullet_idx) + return true # 子弹已回收 + return false + +func _swap_and_pop(idx: int) -> void: + var last: int = _active_count - 1 + if idx != last: + var base_idx: int = idx * BULLET_STRIDE + var base_last: int = last * BULLET_STRIDE + for s in BULLET_STRIDE: + _data[base_idx + s] = _data[base_last + s] + if _bullet_contexts.has(last): + _bullet_contexts[idx] = _bullet_contexts[last] + _bullet_contexts.erase(last) + elif _bullet_contexts.has(idx): + _bullet_contexts.erase(idx) + _active_count -= 1 + +# ── 对外接口 ───────────────────────────────────────────────────── +## 生成子弹,返回 bullet_id(比 -1 表示满容) +## 参数附带冷数据:cold_data = {"on_hit_payload_id": int, "pierce_remaining": int, ...} +func spawn_bullet(pos: Vector2, vel: Vector2, lifetime: float, radius: float, + base_damage: float, damage_mult: float, damage_type: int, + owner_id: int, source_tags: int = 0, acceleration: float = 0.0, + cold_data: Dictionary = {}) -> int: + if _active_count >= MAX_BULLETS: + return -1 + var bullet_id: int = _active_count + var base: int = bullet_id * BULLET_STRIDE + _data[base + 0] = pos.x + _data[base + 1] = pos.y + _data[base + 2] = vel.x + _data[base + 3] = vel.y + _data[base + 4] = lifetime + _data[base + 5] = radius + _data[base + 6] = base_damage + _data[base + 7] = damage_mult + _data[base + 8] = float(damage_type) + _data[base + 9] = float(owner_id) + _data[base + 10] = float(source_tags) + _data[base + 11] = acceleration + if not cold_data.is_empty(): + _bullet_contexts[bullet_id] = cold_data + _active_count += 1 + return bullet_id + +## 提前回收:lifetime 置 0;C# 下帧 SoA 清理 +func despawn_bullet(bullet_id: int) -> void: + if bullet_id < 0 or bullet_id >= _active_count: + return + _data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime + +func get_active_count() -> int: + return _active_count + +## 渲染:将活跃子弹的 SoA 位置批量写入 MultiMesh(§4.4,SoA 索引 ↔ instance 索引共享) +## visible_instance_count 控制渲染数量,避免每帧重分配 instance_count +func sync_multimesh(mm: MultiMesh) -> void: + if mm == null: + return + var n: int = min(_active_count, mm.instance_count) + mm.visible_instance_count = n + for i in n: + var base: int = i * BULLET_STRIDE + var r: float = _data[base + 5] + mm.set_instance_transform_2d(i, Transform2D( + 0.0, Vector2(r * 2.0, r * 2.0), 0.0, + Vector2(_data[base + 0], _data[base + 1]))) + +func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: + return EnemyManager.get_nearest_pos(origin, max_dist) + +func reset() -> void: + _active_count = 0 + _data.fill(0.0) + _bullet_contexts.clear() diff --git a/scripts/autoloads/bullet_manager.gd.uid b/scripts/autoloads/bullet_manager.gd.uid new file mode 100644 index 0000000..dcfe5e0 --- /dev/null +++ b/scripts/autoloads/bullet_manager.gd.uid @@ -0,0 +1 @@ +uid://d07qoobgfgyr3 diff --git a/scripts/autoloads/config_mgr.gd b/scripts/autoloads/config_mgr.gd new file mode 100644 index 0000000..9a44284 --- /dev/null +++ b/scripts/autoloads/config_mgr.gd @@ -0,0 +1,39 @@ +## ConfigMgr — JSON 配置加载器(Autoload: ConfigMgr) +## 权威来源:architecture_design.md §2 数据层 +extends Node + +var _cache: Dictionary = {} # { file_path: String → Dictionary } + +## 加载 JSON 配置文件(带缓存) +func load_json(path: String) -> Dictionary: + if _cache.has(path): + return _cache[path] + if not FileAccess.file_exists(path): + push_warning("ConfigMgr: 文件不存在 %s" % path) + return {} + var text: String = FileAccess.get_file_as_string(path) + var parsed = JSON.parse_string(text) + if parsed == null: + push_error("ConfigMgr: JSON 解析失败 %s" % path) + return {} + if parsed is Dictionary: + _cache[path] = parsed + return parsed + push_error("ConfigMgr: JSON 根节点不是 Dictionary %s" % path) + return {} + +## 查询键值(支持点号路径,如 "enemies.basic.hp") +func get_value(path: String, key: String, default_val = null): + var data: Dictionary = load_json(path) + var keys: PackedStringArray = key.split(".") + var node = data + for k in keys: + if node is Dictionary and node.has(k): + node = node[k] + else: + return default_val + return node + +## 清除缓存(热重载时调用) +func clear_cache() -> void: + _cache.clear() diff --git a/scripts/autoloads/config_mgr.gd.uid b/scripts/autoloads/config_mgr.gd.uid new file mode 100644 index 0000000..10064c4 --- /dev/null +++ b/scripts/autoloads/config_mgr.gd.uid @@ -0,0 +1 @@ +uid://ca4ohbfymmam7 diff --git a/scripts/autoloads/core_feature_tag.gd b/scripts/autoloads/core_feature_tag.gd new file mode 100644 index 0000000..2687cef --- /dev/null +++ b/scripts/autoloads/core_feature_tag.gd @@ -0,0 +1,9 @@ +## CoreFeatureTag — Core 特性标签整数常量(Autoload: CoreFeatureTag) +## 禁止在判断逻辑中使用裸字符串(跨切片约束 ADR-R5-N2)。 +extends Node + +const PERSISTENT_MEMORY: int = 1 # 寄存器跨帧保留(wand_memory Core) +const DUAL_STREAM: int = 2 # 双流执行(P6-N12,S6 P1 实现) +const ALWAYS_CAST_LAST: int = 3 # 尾槽 MODIFIER/TRIGGER/LOGIC 静默跳过(P6-N7) +const SHUFFLE_DECK: int = 4 # ACTION 原子单元乱序(P5-N2) +const INFINITE_SPELLS: int = 5 # 卖血流,配合 heavy_cost(P6-N18) diff --git a/scripts/autoloads/core_feature_tag.gd.uid b/scripts/autoloads/core_feature_tag.gd.uid new file mode 100644 index 0000000..7528a6d --- /dev/null +++ b/scripts/autoloads/core_feature_tag.gd.uid @@ -0,0 +1 @@ +uid://c7sxbv1ede8h5 diff --git a/scripts/autoloads/crash_reporter.gd b/scripts/autoloads/crash_reporter.gd new file mode 100644 index 0000000..a19cea1 --- /dev/null +++ b/scripts/autoloads/crash_reporter.gd @@ -0,0 +1,108 @@ +## CrashReporter — 全局崩溃/异常日志 Autoload +## +## 职责: +## 1. 捕获 NOTIFICATION_CRASH(引擎级崩溃信号)并写入 user://crash_log.txt +## 2. 捕获 NOTIFICATION_WM_CLOSE_REQUEST(正常退出),记录最后一帧状态 +## 3. 游戏启动时检查上次是否有未完成的崩溃日志并上报 EventBus(供 HUD 展示提示) +## +## 注意:此 Autoload 必须排在所有其他 Autoload 之前注册(project.godot [autoload] 顺序), +## 确保其他系统崩溃时本 Reporter 已初始化。 +## +## 使用方式: +## CrashReporter.log_error("模块名", "错误描述") → 写入 crash_log.txt(不崩溃) +## CrashReporter.log_fatal("模块名", "致命错误") → 写入后 crash(开发模式下) + +extends Node + +const LOG_PATH: String = "user://crash_log.txt" +const MAX_LOG_BYTES: int = 512 * 1024 # 512KB 上限,防止无限增长 + +var _session_start_time: float = 0.0 +var _last_wave: int = 0 # 由 WaveManager 每波更新,崩溃时记录当前波次 + +# ── 生命周期 ──────────────────────────────────────────────────────────────── + +func _ready() -> void: + _session_start_time = Time.get_unix_time_from_system() + _rotate_log_if_oversized() + _check_previous_crash() + +func _notification(what: int) -> void: + match what: + NOTIFICATION_CRASH: + _write_crash_entry("CRASH", "引擎级崩溃(NOTIFICATION_CRASH)") + NOTIFICATION_WM_CLOSE_REQUEST: + _write_crash_entry("SHUTDOWN", "正常退出") + get_tree().quit() + +# ── 公共接口 ──────────────────────────────────────────────────────────────── + +## 记录非致命错误(push_error 级别,不终止运行) +func log_error(module: String, message: String) -> void: + push_error("[%s] %s" % [module, message]) + _append_log("ERROR", module, message) + +## 记录致命错误(开发模式下触发断言失败,发布模式下仅记录) +func log_fatal(module: String, message: String) -> void: + _write_crash_entry("FATAL", "[%s] %s" % [module, message]) + assert(false, "[CrashReporter] Fatal: %s — %s" % [module, message]) + +## 由 WaveManager 调用,更新崩溃时记录的波次信息 +func set_current_wave(wave: int) -> void: + _last_wave = wave + +# ── 内部实现 ───────────────────────────────────────────────────────────────── + +func _write_crash_entry(level: String, reason: String) -> void: + var elapsed: float = Time.get_unix_time_from_system() - _session_start_time + var entry: String = ( + "\n=== %s @ %s (session +%.1fs, wave=%d) ===\n%s\n" % [ + level, + Time.get_datetime_string_from_system(), + elapsed, + _last_wave, + reason + ] + ) + _append_log_raw(entry) + +func _append_log(level: String, module: String, message: String) -> void: + var line: String = "[%s][%s] %s — %s\n" % [ + Time.get_time_string_from_system(), + level, module, message + ] + _append_log_raw(line) + +func _append_log_raw(text: String) -> void: + var f := FileAccess.open(LOG_PATH, FileAccess.READ_WRITE) + if f == null: + f = FileAccess.open(LOG_PATH, FileAccess.WRITE) + if f == null: + push_warning("CrashReporter: 无法打开 %s" % LOG_PATH) + return + f.seek_end(0) + f.store_string(text) + +func _rotate_log_if_oversized() -> void: + if not FileAccess.file_exists(LOG_PATH): + return + if FileAccess.get_file_as_bytes(LOG_PATH).size() > MAX_LOG_BYTES: + var archived: String = LOG_PATH.replace(".txt", "_prev.txt") + DirAccess.rename_absolute( + ProjectSettings.globalize_path(LOG_PATH), + ProjectSettings.globalize_path(archived) + ) + +func _check_previous_crash() -> void: + ## 检查上次是否有 CRASH 或 FATAL 记录,若有则在下次启动时通过 EventBus 通知 + if not FileAccess.file_exists(LOG_PATH): + return + var content: String = FileAccess.get_file_as_string(LOG_PATH) + if "=== CRASH" in content or "=== FATAL" in content: + # 延迟到第一帧,确保 EventBus 已初始化 + call_deferred("_emit_crash_detected") + +func _emit_crash_detected() -> void: + if Engine.has_singleton("EventBus"): + # EventID 暂用 0(特殊保留 ID),HUD 订阅后展示"上次游戏异常退出"提示 + EventBus.emit(0, {"source": "crash_reporter", "log_path": LOG_PATH}) diff --git a/scripts/autoloads/crash_reporter.gd.uid b/scripts/autoloads/crash_reporter.gd.uid new file mode 100644 index 0000000..73400d5 --- /dev/null +++ b/scripts/autoloads/crash_reporter.gd.uid @@ -0,0 +1 @@ +uid://dk2lnqdqniw0h diff --git a/scripts/autoloads/damage_context.gd b/scripts/autoloads/damage_context.gd new file mode 100644 index 0000000..cb7755a --- /dev/null +++ b/scripts/autoloads/damage_context.gd @@ -0,0 +1,28 @@ +## DamageContext — 伤害上下文数据类(必须单独放在此文件) +## 权威来源:implementation_plan.md §2.1 E-N2 P6-N49 +## 禁止将 class_name DamageContext 写入 damage_context_pool.gd! +extends RefCounted +class_name DamageContext + +var base_damage: float = 0.0 +var mult: float = 1.0 +var damage_type: int = 0 # DamageType.PHYSICAL +var owner_id: int = -1 +var source_tags: int = 0 +var is_crit: bool = false +var pierce_rate: float = 0.0 # 护甲穿透率 0.0~1.0 + +func reset() -> void: + base_damage = 0.0 + mult = 1.0 + damage_type = 0 + owner_id = -1 + source_tags = 0 + is_crit = false + pierce_rate = 0.0 + +## 伤害公式:((base_damage + add) × mult) × (1 − resistance) − armor +## S1 简化版:返回 base_damage * mult +func calc_damage(armor: float = 0.0, resistance: float = 0.0) -> float: + var effective_armor: float = armor * (1.0 - pierce_rate) + return max(0.0, base_damage * mult * (1.0 - resistance) - effective_armor) diff --git a/scripts/autoloads/damage_context.gd.uid b/scripts/autoloads/damage_context.gd.uid new file mode 100644 index 0000000..f485fa0 --- /dev/null +++ b/scripts/autoloads/damage_context.gd.uid @@ -0,0 +1 @@ +uid://d3xq216d8r3kh diff --git a/scripts/autoloads/damage_context_pool.gd b/scripts/autoloads/damage_context_pool.gd new file mode 100644 index 0000000..0454193 --- /dev/null +++ b/scripts/autoloads/damage_context_pool.gd @@ -0,0 +1,69 @@ +## DamageContextPool — 伤害上下文对象池(Autoload: DamageContextPool) +## 权威来源:implementation_plan.md §2.1 E-N2 P6-N67 +## 本文件不声明 class_name!禁止将 DamageContext 写入这里 +extends Node + +const POOL_SIZE: int = 64 + +var _slots: Array = [] +var _free_ids: PackedInt32Array = PackedInt32Array() +var _in_use: PackedByteArray = PackedByteArray() + +func _ready() -> void: + _slots.resize(POOL_SIZE) + _free_ids.resize(POOL_SIZE) + _in_use.resize(POOL_SIZE) + for i in POOL_SIZE: + _slots[i] = DamageContext.new() + _free_ids[i] = POOL_SIZE - 1 - i + _in_use[i] = 0 + +## acquire 内部已调用 ctx.reset(),P6-N67 +func acquire(base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> int: + if _free_ids.is_empty(): + push_warning("DamageContextPool: 池已耗尽 (POOL_SIZE=%d)" % POOL_SIZE) + var new_id: int = _slots.size() + _slots.append(DamageContext.new()) + _in_use.append(0) + _fill_slot(new_id, base_dmg, m, dtype, owner, crit, pierce) + return new_id + var id: int = _free_ids[_free_ids.size() - 1] + _free_ids.resize(_free_ids.size() - 1) + _fill_slot(id, base_dmg, m, dtype, owner, crit, pierce) + return id + +func _fill_slot(id: int, base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> void: + var ctx: DamageContext = _slots[id] + ctx.reset() + ctx.base_damage = base_dmg + ctx.mult = m + ctx.damage_type = dtype + ctx.owner_id = owner + ctx.is_crit = crit + ctx.pierce_rate = pierce + _in_use[id] = int(1) + +func get_context(id: int) -> DamageContext: + if id < 0 or id >= _slots.size() or _in_use[id] == 0: + return null + return _slots[id] + +func release(id: int) -> void: + if id < 0 or id >= _slots.size() or _in_use[id] == 0: + push_warning("DamageContextPool.release: 无效或重复归还 ID=%d" % id) + return + _in_use[id] = 0 + _free_ids.append(id) + +func get_pool_usage() -> String: + var used: int = 0 + for i in _in_use.size(): + if _in_use[i] != 0: + used += 1 + return "%d / %d" % [used, _slots.size()] + +func reset() -> void: + _free_ids.clear() + for i in _slots.size(): + _in_use[i] = 0 + _free_ids.append(i) diff --git a/scripts/autoloads/damage_context_pool.gd.uid b/scripts/autoloads/damage_context_pool.gd.uid new file mode 100644 index 0000000..17b4442 --- /dev/null +++ b/scripts/autoloads/damage_context_pool.gd.uid @@ -0,0 +1 @@ +uid://cw107yf7db43l diff --git a/scripts/autoloads/damage_type.gd b/scripts/autoloads/damage_type.gd new file mode 100644 index 0000000..868cd7b --- /dev/null +++ b/scripts/autoloads/damage_type.gd @@ -0,0 +1,11 @@ +## DamageType — 伤害类型整数常量(Autoload: DamageType) +## 与 BulletManager SoA slot+8 (damage_type) 字段对应。 +## 扩展新类型时从 6 起递增,并同步更新 implementation_plan.md §2.1 DamageContext 表。 +extends Node + +const PHYSICAL: int = 0 +const FIRE: int = 1 +const ICE: int = 2 +const LIGHTNING: int = 3 +const POISON: int = 4 +const ARCANE: int = 5 diff --git a/scripts/autoloads/damage_type.gd.uid b/scripts/autoloads/damage_type.gd.uid new file mode 100644 index 0000000..20bfde4 --- /dev/null +++ b/scripts/autoloads/damage_type.gd.uid @@ -0,0 +1 @@ +uid://gydgmnbchqig diff --git a/scripts/autoloads/dps_tracker.gd b/scripts/autoloads/dps_tracker.gd new file mode 100644 index 0000000..8f21771 --- /dev/null +++ b/scripts/autoloads/dps_tracker.gd @@ -0,0 +1,50 @@ +## DpsTracker — DPS 环形缓冲区(Autoload: DpsTracker) +## 权威来源:implementation_plan.md §2.5.E(P6-N38、P6-N47) +extends Node + +const DPS_WINDOW_SEC: float = 3.0 +const _RB_SIZE: int = 256 + +var _rb_time: PackedFloat64Array = PackedFloat64Array() +var _rb_dmg: PackedFloat32Array = PackedFloat32Array() +var _rb_head: int = 0 +var _rb_count: int = 0 +var _accumulated_dmg: float = 0.0 + +func _ready() -> void: + _rb_time.resize(_RB_SIZE) + _rb_dmg.resize(_RB_SIZE) + +func record_damage(amount: float) -> void: + if amount <= 0.0: + return + var now: float = Time.get_ticks_msec() / 1000.0 + _rb_time[_rb_head] = now + _rb_dmg[_rb_head] = amount + _rb_head = (_rb_head + 1) % _RB_SIZE + if _rb_count < _RB_SIZE: + _rb_count += 1 + _accumulated_dmg += amount + +func get_dps() -> float: + _recalc_window(Time.get_ticks_msec() / 1000.0) + return _accumulated_dmg / DPS_WINDOW_SEC + +func _recalc_window(now: float) -> void: + var sum: float = 0.0 + var valid: int = 0 + var tail: int = (_rb_head - _rb_count + _RB_SIZE) % _RB_SIZE + for k in _rb_count: + var idx: int = (tail + k) % _RB_SIZE + if (now - _rb_time[idx]) <= DPS_WINDOW_SEC: + sum += _rb_dmg[idx] + valid += 1 + _accumulated_dmg = sum + _rb_count = valid + +func reset() -> void: + _rb_head = 0 + _rb_count = 0 + _accumulated_dmg = 0.0 + _rb_time.fill(0.0) + _rb_dmg.fill(0.0) diff --git a/scripts/autoloads/dps_tracker.gd.uid b/scripts/autoloads/dps_tracker.gd.uid new file mode 100644 index 0000000..7406ead --- /dev/null +++ b/scripts/autoloads/dps_tracker.gd.uid @@ -0,0 +1 @@ +uid://dpjv1tbiar8cm diff --git a/scripts/autoloads/drop_manager.gd b/scripts/autoloads/drop_manager.gd new file mode 100644 index 0000000..0221fd3 --- /dev/null +++ b/scripts/autoloads/drop_manager.gd @@ -0,0 +1,18 @@ +## DropManager — 掉落物管理(Autoload: DropManager) +## S2 范围:敌人死亡时直接发放 XP/金币(无拾取物步骤) +## S3+ 再加入地面拾取物 +extends Node + +## S2 内置掉落参数 +const XP_PER_KILL: int = 5 +const GOLD_PER_KILL: int = 2 + +func _ready() -> void: + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) + +func _on_enemy_killed(payload: Dictionary) -> void: + PlayerStats.gain_xp(XP_PER_KILL) + PlayerStats.gain_gold(GOLD_PER_KILL) + +func reset() -> void: + pass # 无状态需要重置 diff --git a/scripts/autoloads/drop_manager.gd.uid b/scripts/autoloads/drop_manager.gd.uid new file mode 100644 index 0000000..9c2e7df --- /dev/null +++ b/scripts/autoloads/drop_manager.gd.uid @@ -0,0 +1 @@ +uid://dbwhq00c61nxt diff --git a/scripts/autoloads/endless_records.gd b/scripts/autoloads/endless_records.gd new file mode 100644 index 0000000..a7a73af --- /dev/null +++ b/scripts/autoloads/endless_records.gd @@ -0,0 +1,76 @@ +## EndlessRecords — 本地排行榜(Autoload: EndlessRecords) +## 权威来源:certification_checklist.md ST-40/41(HMAC-SHA256 防篡改)、P6-N19 评分公式 +## 排名:到达波次为主、同波次按用时升序;在线分 = wave*100000 + (86400 - elapsed_sec) +extends Node + +const RECORDS_PATH: String = "user://endless_records.json" +const MAX_RECORDS: int = 50 + +## HMAC 密钥(不入存档;仅防普通玩家手改,无法对抗逆向,见 ST-40) +## PackedByteArray(...) 非常量表达式,故用 var 在加载时初始化 +var _HMAC_KEY: PackedByteArray = PackedByteArray([ + 0xA3, 0x7F, 0x2C, 0x91, 0x4E, 0xD8, 0x12, 0x6B, + 0xF0, 0x39, 0x5A, 0xCC, 0x77, 0x1D, 0x8E, 0x44, + 0x22, 0xB9, 0x60, 0x3F, 0xE1, 0x05, 0x9C, 0x7A, + 0x53, 0xD4, 0x08, 0x6F, 0xAB, 0x31, 0xC7, 0x90, +]) + +## P6-N19:单整数双维度评分(波次高优先;同波次剩余秒数高优先) +static func compute_score(wave: int, elapsed_sec: int) -> int: + return wave * 100000 + (86400 - clampi(elapsed_sec, 0, 86400)) + +func _sign(data: String) -> String: + var crypto := Crypto.new() + var hmac := crypto.hmac_digest(HashingContext.HASH_SHA256, _HMAC_KEY, data.to_utf8_buffer()) + return hmac.hex_encode() + +## 追加一条记录并持久化;返回排序后的全部记录 +func add_record(wave: int, elapsed_sec: int, kills: int) -> Array: + var records: Array = load_records() + records.append({ + "wave": wave, + "elapsed": elapsed_sec, + "kills": kills, + "score": compute_score(wave, elapsed_sec), + }) + records = _sort_records(records) + if records.size() > MAX_RECORDS: + records.resize(MAX_RECORDS) + save_records(records) + return records + +## data 以 JSON 字符串存储并对该精确字符串签名(避免 JSON int↔float 往返破坏签名) +func save_records(records: Array) -> void: + var payload: String = JSON.stringify(records) + var signed: Dictionary = {"data": payload, "sig": _sign(payload)} + var f := FileAccess.open(RECORDS_PATH, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(signed)) + f.close() + +func load_records() -> Array: + if not FileAccess.file_exists(RECORDS_PATH): + return [] + var raw = JSON.parse_string(FileAccess.get_file_as_string(RECORDS_PATH)) + if not (raw is Dictionary): + return [] + var payload: String = String(raw.get("data", "")) + if _sign(payload) != String(raw.get("sig", "")): + push_warning("EndlessRecords: 签名验证失败,分数不可信,拒绝载入") # ST-41 + return [] + var data = JSON.parse_string(payload) + return data if data is Array else [] + +## 波次降序、同波次用时升序(P6-N19) +func _sort_records(records: Array) -> Array: + records.sort_custom(func(a, b): + var wa: int = int(a.get("wave", 0)) + var wb: int = int(b.get("wave", 0)) + if wa != wb: + return wa > wb + return int(a.get("elapsed", 99999)) < int(b.get("elapsed", 99999))) + return records + +func get_top(n: int = 10) -> Array: + var r: Array = load_records() + return r.slice(0, min(n, r.size())) diff --git a/scripts/autoloads/endless_records.gd.uid b/scripts/autoloads/endless_records.gd.uid new file mode 100644 index 0000000..2d1a3c5 --- /dev/null +++ b/scripts/autoloads/endless_records.gd.uid @@ -0,0 +1 @@ +uid://docfp01fd1red diff --git a/scripts/autoloads/enemy_manager.gd b/scripts/autoloads/enemy_manager.gd new file mode 100644 index 0000000..bb56636 --- /dev/null +++ b/scripts/autoloads/enemy_manager.gd @@ -0,0 +1,329 @@ +## EnemyManager — 敌人管理器 Autoload(GDScript 接口层) +## 热路径由 C# EnemyManagerCs 子节点驱动。 +## 权威来源:implementation_plan.md §2.3.C +## +## SoA 布局(ENEMY_STRIDE=8): +## [ x, y, vx, vy, hp, hp_max, enemy_type, state ] +## 0 1 2 3 4 5 6 7 +extends Node + +const ENEMY_STRIDE: int = 8 # 禁止裸整数 8(P6-N54) +const MAX_ENEMIES: int = 1024 + +const OFFSCREEN_SEPARATION_INTERVAL: int = 10 # 屏外低频 Boid 分离力更新频率(P6-N1) + +# SoA 热数组 +var _data: PackedFloat32Array = PackedFloat32Array() +var _active_count: int = 0 + +# entity_id ↔ SoA 槽位 双向映射 +var _entity_index_map: Dictionary = {} # { entity_id → slot_idx } +var _slot_entity_map: Dictionary = {} # { slot_idx → entity_id } +var _next_entity_id: int = 1 + +# LOD:屏内可见标志(PackedByteArray)0=屏外,1=屏内 +var _visible_flags: PackedByteArray = PackedByteArray() + +var _offscreen_sep_counter: int = 0 +var _cs_node: Node = null + +## ADR-A4:精英寻路。W1–14 杂鱼永远 Boid;W15+ Elite/Boss 按需挂 NavigationAgent2D, +## 并发寻路上限 MAX_PATHFINDING_ENEMIES=20,超出降级 Boid(P-S5-AI-01)。 +const MAX_PATHFINDING_ENEMIES: int = 20 +const ELITE_SPEED: float = 90.0 + +## 敌人原型(enemy_type 存于 SoA[+6])。S6 内容:基础/快速/护甲/精英/Boss +enum Type { BASIC = 0, FAST = 1, ARMORED = 2, ELITE = 3, MINIBOSS = 4, BOSS = 5 } +## 各原型数值:纯数据驱动,唯一来源 data/enemies.json(游戏设计器「敌人」面板维护) +## get() 的单值默认仅为防崩溃,非内容副本 +var _SPEED: Dictionary = {} # type → 移动速度 +var _ARMOR: Dictionary = {} # type → 护甲 +var _RENDER_SIZE: Dictionary = {} # type → 渲染直径 +var _RENDER_COLOR: Dictionary = {} # type → 渲染颜色 + +const ARCHETYPE_JSON: String = "res://data/enemies.json" + +## 数据驱动:从 data/enemies.json 加载原型数值(速度/护甲/尺寸/颜色) +func _load_json_archetypes() -> void: + if not FileAccess.file_exists(ARCHETYPE_JSON): + push_error("EnemyManager: 缺失 res://data/enemies.json(敌人原型数值)") + return + var data = JSON.parse_string(FileAccess.get_file_as_string(ARCHETYPE_JSON)) + if not (data is Dictionary): + push_error("EnemyManager: enemies.json 格式错误") + return + for k in data: + var t: int = int(k) + var d: Dictionary = data[k] + _SPEED[t] = float(d.get("speed", 80.0)) + _ARMOR[t] = float(d.get("armor", 0.0)) + _RENDER_SIZE[t] = float(d.get("size", 14.0)) + var c: Array = d.get("color", [0.9, 0.3, 0.3]) + _RENDER_COLOR[t] = Color(float(c[0]), float(c[1]), float(c[2])) + +var _nav_region: NavigationRegion2D = null # 程序化矩形导航区 +var _pathfinders: Dictionary = {} # entity_id → {"agent":NavigationAgent2D, "host":Node2D} +var _pathfinder_order: Array = [] # FIFO,用于超上限淘汰 + +func _ready() -> void: + _load_json_archetypes() # 数据驱动:覆盖原型数值(游戏设计器面板维护) + _data.resize(MAX_ENEMIES * ENEMY_STRIDE) + _data.fill(0.0) + _visible_flags.resize(MAX_ENEMIES) + _visible_flags.fill(0) + +func _physics_process(delta: float) -> void: + _offscreen_sep_counter += 1 + var do_sep: bool = (_offscreen_sep_counter >= OFFSCREEN_SEPARATION_INTERVAL) + if do_sep: + _offscreen_sep_counter = 0 + if not _cs_node: + _gd_update_movement(delta) + # ADR-A4:精英寻路移动(NavigationAgent2D 跟随),仅当存在 pathfinder 时执行 + if not _pathfinders.is_empty(): + _update_pathfinding_movement(delta) + # S1: 移动后更新 SpatialGrid(碰撞检测依赖) + _update_spatial_grid() + +func _update_spatial_grid() -> void: + for i in _active_count: + var base: int = i * ENEMY_STRIDE + var eid: int = _slot_entity_map.get(i, -1) + if eid >= 0: + SpatialGrid.insert(eid, Vector2(_data[base + 0], _data[base + 1])) + +# GDScript 回退:直线追玩家(C# 没就绪时使用) +func _gd_update_movement(delta: float) -> void: + var player_pos: Vector2 = PlayerManager.get_position() + var has_pf: bool = not _pathfinders.is_empty() # 无精英时零额外开销 + for i in _active_count: + if has_pf and _pathfinders.has(_slot_entity_map.get(i, -1)): + continue # 寻路精英由 _update_pathfinding_movement 处理 + var base: int = i * ENEMY_STRIDE + var ex: float = _data[base + 0] + var ey: float = _data[base + 1] + var spd: float = _SPEED.get(int(_data[base + 6]), 80.0) # 按原型查速度 + var dx: float = player_pos.x - ex + var dy: float = player_pos.y - ey + var dist: float = sqrt(dx * dx + dy * dy) + if dist > 1.0: + _data[base + 2] = (dx / dist) * spd + _data[base + 3] = (dy / dist) * spd + _data[base + 0] += _data[base + 2] * delta + _data[base + 1] += _data[base + 3] * delta + +# ── 对外接口 ─────────────────────────────────────────────────── +func spawn_enemy(pos: Vector2, hp: float, enemy_type: int = 0, has_pathfinding: bool = false) -> int: + if _active_count >= MAX_ENEMIES: + return -1 + var entity_id: int = _next_entity_id + _next_entity_id += 1 + var slot: int = _active_count + var base: int = slot * ENEMY_STRIDE + _data[base + 0] = pos.x + _data[base + 1] = pos.y + _data[base + 2] = 0.0 + _data[base + 3] = 0.0 + _data[base + 4] = hp # hp + _data[base + 5] = hp # hp_max + _data[base + 6] = float(enemy_type) + _data[base + 7] = 0.0 # state: 0=alive + _entity_index_map[entity_id] = slot + _slot_entity_map[slot] = entity_id + _active_count += 1 + # ADR-A4:W15+ Elite 在并发上限内挂寻路(超限降级 Boid) + if has_pathfinding and _pathfinders.size() < MAX_PATHFINDING_ENEMIES: + _register_pathfinder(entity_id, pos) + return entity_id + +# ── ADR-A4 精英寻路子系统 ────────────────────────────────────── +func _ensure_nav_region() -> void: + if _nav_region != null: + return + _nav_region = NavigationRegion2D.new() + var poly := NavigationPolygon.new() + # 覆盖竞技场的单个凸矩形导航多边形(无障碍占位;P-S5-AI-01 仅测寻路开销) + var ext: float = 4000.0 + poly.vertices = PackedVector2Array([ + Vector2(-ext, -ext), Vector2(-ext, ext), Vector2(ext, ext), Vector2(ext, -ext), + ]) + poly.add_polygon(PackedInt32Array([0, 1, 2, 3])) + _nav_region.navigation_polygon = poly + add_child(_nav_region) + +func _register_pathfinder(entity_id: int, pos: Vector2) -> void: + _ensure_nav_region() + var host := Node2D.new() + host.position = pos + add_child(host) + var agent := NavigationAgent2D.new() + agent.path_desired_distance = 8.0 + agent.target_desired_distance = 8.0 + agent.avoidance_enabled = false # 与 SoA Boid 分离力共存,避免双重避障 + host.add_child(agent) + _pathfinders[entity_id] = {"agent": agent, "host": host} + _pathfinder_order.append(entity_id) + +func _unregister_pathfinder(entity_id: int) -> void: + var pf: Dictionary = _pathfinders.get(entity_id, {}) + if pf.is_empty(): + return + (pf["host"] as Node).queue_free() # agent 是 host 子节点,随之释放 + _pathfinders.erase(entity_id) + _pathfinder_order.erase(entity_id) + +## 寻路精英移动:host 同步到 SoA 坐标,agent 朝玩家求下一路径点并转向 +func _update_pathfinding_movement(delta: float) -> void: + var player_pos: Vector2 = PlayerManager.get_position() + for eid in _pathfinders: + var slot: int = _entity_index_map.get(eid, -1) + if slot < 0: + continue + var base: int = slot * ENEMY_STRIDE + var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1]) + var pf: Dictionary = _pathfinders[eid] + var agent: NavigationAgent2D = pf["agent"] + (pf["host"] as Node2D).global_position = pos + agent.target_position = player_pos + var nxt: Vector2 = agent.get_next_path_position() + var dir: Vector2 = nxt - pos + if dir.length() > 1.0: + dir = dir.normalized() + _data[base + 2] = dir.x * ELITE_SPEED + _data[base + 3] = dir.y * ELITE_SPEED + _data[base + 0] += _data[base + 2] * delta + _data[base + 1] += _data[base + 3] * delta + +func get_pathfinder_count() -> int: + return _pathfinders.size() + +func despawn_enemy(entity_id: int) -> void: + var slot: int = _entity_index_map.get(entity_id, -1) + if slot < 0: + return + StatusManager.remove_all_for_entity(entity_id) + SpatialGrid.remove_entity(entity_id) + if _pathfinders.has(entity_id): + _unregister_pathfinder(entity_id) + var last: int = _active_count - 1 + if slot != last: + var base_slot: int = slot * ENEMY_STRIDE + var base_last: int = last * ENEMY_STRIDE + for s in ENEMY_STRIDE: + _data[base_slot + s] = _data[base_last + s] + var moved_id: int = _slot_entity_map[last] + _entity_index_map[moved_id] = slot + _slot_entity_map[slot] = moved_id + _entity_index_map.erase(entity_id) + _slot_entity_map.erase(last) + _active_count -= 1 + +## 获取指定 entity_id 的世界坐标(找不到返回 Vector2(-9999,-9999)) +func get_pos_by_id(entity_id: int) -> Vector2: + var slot: int = _entity_index_map.get(entity_id, -1) + if slot < 0: + return Vector2(-9999.0, -9999.0) + var base: int = slot * ENEMY_STRIDE + return Vector2(_data[base + 0], _data[base + 1]) + +## S1 完整伤害接口:从 DamageContextPool 取 context,计算公式后扣 HP +## 公式(S1 简化):final_dmg = base_damage * mult +## S4 起补充 resistance / armor(参见 combat_mechanics_depth.md §4) +func apply_damage_from_context(entity_id: int, ctx_id: int) -> bool: + var ctx: DamageContext = DamageContextPool.get_context(ctx_id) + if ctx == null: + return false + var final_dmg: float = ctx.calc_damage() + return apply_damage(entity_id, final_dmg, ctx.owner_id, true) + +## 直接扣血接口;source_id 用于击杀归属,record_dps 控制是否计入 DPS +func apply_damage(entity_id: int, damage: float, source_id: int = -1, record_dps: bool = true) -> bool: + var slot: int = _entity_index_map.get(entity_id, -1) + if slot < 0: + return false + var combo_stacks: int = StatusManager.get_stacks(entity_id, StatusID.COMBO_MARK) + var combo_bonus: float = 1.0 + float(combo_stacks) * 0.02 # 每层 +2% 伤害 + var base: int = slot * ENEMY_STRIDE + var armor: float = _ARMOR.get(int(_data[base + 6]), 0.0) # 原型护甲,扣减后保底 1 + var final_dmg: float = maxf(1.0, damage * combo_bonus - armor) + _data[base + 4] -= final_dmg + if record_dps: + DpsTracker.record_damage(final_dmg) + if _data[base + 4] <= 0.0: + StatusManager.remove_all_for_entity(entity_id) + EventBus.emit(EventID.ENEMY_KILLED, {"entity_id": entity_id, "killer_id": source_id}) + despawn_enemy(entity_id) + return true + return false + +func has_entity(entity_id: int) -> bool: + return _entity_index_map.has(entity_id) + +## 返回敌人血量百分比(权威来源:arch §5.2 get_hp_percent) +func get_hp_percent(entity_id: int) -> float: + var slot: int = _entity_index_map.get(entity_id, -1) + if slot < 0: + return 0.0 + var base: int = slot * ENEMY_STRIDE + var hp: float = _data[base + 4] # slot +4: hp + var hp_max: float = _data[base + 5] # slot +5: hp_max + return hp / max(hp_max, 0.001) + +func get_active_count() -> int: + return _active_count + +## 渲染:活跃敌人 SoA 位置 → MultiMesh(按原型设尺寸/颜色,Boss 大且醒目) +func sync_multimesh(mm: MultiMesh) -> void: + if mm == null: + return + var n: int = min(_active_count, mm.instance_count) + mm.visible_instance_count = n + for i in n: + var base: int = i * ENEMY_STRIDE + var etype: int = int(_data[base + 6]) + var sz: float = _RENDER_SIZE.get(etype, 14.0) + mm.set_instance_transform_2d(i, Transform2D( + 0.0, Vector2(sz, sz), 0.0, + Vector2(_data[base + 0], _data[base + 1]))) + mm.set_instance_color(i, _RENDER_COLOR.get(etype, Color(0.9, 0.3, 0.3))) + +## 返回最近敌人位置 +func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: + var best_sq: float = max_dist * max_dist + var best: Vector2 = origin + for i in _active_count: + var base: int = i * ENEMY_STRIDE + var dx: float = _data[base + 0] - origin.x + var dy: float = _data[base + 1] - origin.y + var dsq: float = dx * dx + dy * dy + if dsq < best_sq: + best_sq = dsq + best = Vector2(_data[base + 0], _data[base + 1]) + return best + +## 建立敌人位置快照(BulletManager homing 共用) +func fill_pos_snapshot(out_arr: PackedVector2Array) -> void: + out_arr.resize(_active_count) + for i in _active_count: + var base: int = i * ENEMY_STRIDE + out_arr[i] = Vector2(_data[base + 0], _data[base + 1]) + +## 同步 Node2D 显示位置(屏内 dirty sync) +func sync_node_positions(enemy_nodes: Array) -> void: + for i in min(_active_count, enemy_nodes.size()): + if _visible_flags[i] == int(1): + var base: int = i * ENEMY_STRIDE + enemy_nodes[i].position = Vector2(_data[base + 0], _data[base + 1]) + +func reset() -> void: + _active_count = 0 + _data.fill(0.0) + _visible_flags.fill(0) + _entity_index_map.clear() + _slot_entity_map.clear() + _next_entity_id = 1 + # ADR-A4:释放所有寻路精英节点 + for eid in _pathfinders.keys(): + (_pathfinders[eid]["host"] as Node).queue_free() + _pathfinders.clear() + _pathfinder_order.clear() diff --git a/scripts/autoloads/enemy_manager.gd.uid b/scripts/autoloads/enemy_manager.gd.uid new file mode 100644 index 0000000..c9af824 --- /dev/null +++ b/scripts/autoloads/enemy_manager.gd.uid @@ -0,0 +1 @@ +uid://ctmujoxxqga04 diff --git a/scripts/autoloads/event_bus.gd b/scripts/autoloads/event_bus.gd new file mode 100644 index 0000000..401bbd9 --- /dev/null +++ b/scripts/autoloads/event_bus.gd @@ -0,0 +1,42 @@ +## EventBus — 全局事件总线(Autoload: EventBus) +## 权威接口定义:architecture_design.md §6.2 +## +## 使用约定: +## - 订阅:EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) +## - 发送:EventBus.emit(EventID.ENEMY_KILLED, {"entity_id": id}) +## - 取消:EventBus.unsubscribe(EventID.ENEMY_KILLED, _on_enemy_killed) +extends Node + +var _listeners: Dictionary = {} # { event_id: int → Array[Callable] } + +func subscribe(event_id: int, callback: Callable) -> void: + if not _listeners.has(event_id): + _listeners[event_id] = [] + var arr: Array = _listeners[event_id] + if not arr.has(callback): + arr.append(callback) + +func unsubscribe(event_id: int, callback: Callable) -> void: + if not _listeners.has(event_id): + return + _listeners[event_id].erase(callback) + +func emit(event_id: int, payload: Dictionary = {}) -> void: + if not _listeners.has(event_id): + return + var arr: Array = _listeners[event_id] + # 逆序迭代防止回调内部 unsubscribe 导致跳过 + for i in range(arr.size() - 1, -1, -1): + arr[i].call(payload) + +## C# 批量通知接口:一次调用发送多个事件(ADR-L1 规则3) +## events: Array of [event_id: int, payload: Dictionary] +func emit_batch(events: Array) -> void: + for ev in events: + var eid: int = ev[0] + var pay: Dictionary = ev[1] if ev.size() > 1 else {} + emit(eid, pay) + +## 清空所有订阅(关卡结束 / 场景切换时调用) +func reset() -> void: + _listeners.clear() diff --git a/scripts/autoloads/event_bus.gd.uid b/scripts/autoloads/event_bus.gd.uid new file mode 100644 index 0000000..122172b --- /dev/null +++ b/scripts/autoloads/event_bus.gd.uid @@ -0,0 +1 @@ +uid://c1aikk2kvjgiq diff --git a/scripts/autoloads/event_ids.gd b/scripts/autoloads/event_ids.gd new file mode 100644 index 0000000..db370f2 --- /dev/null +++ b/scripts/autoloads/event_ids.gd @@ -0,0 +1,49 @@ +## EventID — 全局事件 ID 常量目录(Autoload: EventID) +## 权威来源:implementation_plan.md §2.1 +## 规则:新增事件从 22 起递增,并同步更新本文件。禁止硬编码整数替代常量。 +extends Node + +# ── 基础系统事件 (1-4) ──────────────────────────────────────────── +const GAME_STARTED: int = 1 +const GAME_OVER: int = 2 +const GAME_PAUSED: int = 3 +const GAME_RESUMED: int = 4 + +# ── 战斗事件 (5-10) ─────────────────────────────────────────────── +const PLAYER_DAMAGED: int = 5 +const PLAYER_DIED: int = 6 +const ENEMY_KILLED: int = 7 +const WAVE_COMPLETE: int = 8 +const CHARGE_STATE_CHANGED: int = 9 # 供 UI 使用,非边沿信号 +const ON_PLAYER_HURT: int = 10 # 受击反馈(VFX/音效触发) + +# ── 法术事件 (11-15) ────────────────────────────────────────────── +const SPELL_CAST_BEGIN: int = 11 +const SPELL_CAST_END: int = 12 +const DASH_TRIGGERED: int = 13 # 冲刺边沿检测(P6-N36) +const CHARGE_FIRED: int = 14 # 蓄力完成一次性边沿(P6-N69);SpellEvaluator 订阅此而非 CHARGE_STATE_CHANGED +const SPELL_EQUIPPED: int = 15 + +# ── 进度 / UI 事件 (16-21) ──────────────────────────────────────── +const LEVEL_UP: int = 16 +const SHOP_OPENED: int = 17 +const ACHIEVEMENT_UNLOCKED: int = 18 # 负载:{ "achievement_id": String };ST-10 Steam 成就钩子 +const BOSS_SPAWNED: int = 19 +const GAME_STATE_CHANGED: int = 20 # 负载:{ "from": String, "to": String } +const SPELL_DROP_PICKUP: int = 21 # 负载:{ "spell_id": String, "pos": Vector2 } + +# ── S1 新增事件 (22+) ───────────────────────────────────────────── +const APPLY_DAMAGE: int = 22 # 负载:damage_context_id: int;EnemyManager 订阅处理伤害 + +# ── S3 新增事件 (23+) ───────────────────────────────────────────── +const SPELL_DEPTH_EXCEEDED: int = 23 # 负载:{caster_id, depth};HUD 订阅显示警告 +const BULLET_HIT_TRIGGER: int = 24 # 负载:{bullet_id, payload_id, hit_pos};调试用 + +# ── S4 新增事件 (25+) ───────────────────────────────────────────── +const STATUS_APPLIED: int = 25 # 负载:{target_id, status_type, stacks} +const BULLET_HIT: int = 26 # 负载:{bullet_id, target_id, hit_pos} + +# ── S5 新增事件 (27+) ───────────────────────────────────────────── +const MINION_SPAWNED: int = 27 # 负载:{minion_id, owner_id, current_count} +const MINION_EXPIRED: int = 28 # 负载:{minion_id, owner_id, current_count} +# 每新增一条须同步更新 implementation_plan.md §2.1 事件目录表 diff --git a/scripts/autoloads/event_ids.gd.uid b/scripts/autoloads/event_ids.gd.uid new file mode 100644 index 0000000..aa43bc1 --- /dev/null +++ b/scripts/autoloads/event_ids.gd.uid @@ -0,0 +1 @@ +uid://chtsfdmd0ykor diff --git a/scripts/autoloads/locale_manager.gd b/scripts/autoloads/locale_manager.gd new file mode 100644 index 0000000..37161a5 --- /dev/null +++ b/scripts/autoloads/locale_manager.gd @@ -0,0 +1,33 @@ +## LocaleManager — 本地化加载(Autoload: Locale) +## 权威来源:architecture_design.md ADR-A1(tr("KEY") + .po + TranslationServer) +## 加载 translations/*.po 并注册到 TranslationServer,设默认语言。 +## 商业发行至少支持 简中 / 繁中 / 英 / 日 四语(P-S6-06)。 +extends Node + +const LOCALES: Array = ["zh_CN", "zh_TW", "en", "ja"] +const DEFAULT_LOCALE: String = "zh_CN" + +func _ready() -> void: + for loc in LOCALES: + var path: String = "res://translations/%s.po" % loc + if ResourceLoader.exists(path): + var t = load(path) + if t is Translation: + TranslationServer.add_translation(t) + TranslationServer.set_locale(DEFAULT_LOCALE) + +func set_locale(loc: String) -> void: + TranslationServer.set_locale(loc) + +func get_locale() -> String: + return TranslationServer.get_locale() + +## 调试 / 设置菜单用:循环切换语言 +func cycle_locale() -> String: + var cur: String = TranslationServer.get_locale() + var idx: int = LOCALES.find(cur) + if idx < 0: + idx = 0 + var nxt: String = LOCALES[(idx + 1) % LOCALES.size()] + TranslationServer.set_locale(nxt) + return nxt diff --git a/scripts/autoloads/locale_manager.gd.uid b/scripts/autoloads/locale_manager.gd.uid new file mode 100644 index 0000000..50d7649 --- /dev/null +++ b/scripts/autoloads/locale_manager.gd.uid @@ -0,0 +1 @@ +uid://dpk1b28i6qybx diff --git a/scripts/autoloads/minion_manager.gd b/scripts/autoloads/minion_manager.gd new file mode 100644 index 0000000..ead79b7 --- /dev/null +++ b/scripts/autoloads/minion_manager.gd @@ -0,0 +1,103 @@ +## MinionManager — 玩家召唤物管理(Autoload: MinionManager) +## 权威来源:architecture_design.md §5.12 + ADR-R4-N4(独立管理器,方案 B) +## 数量上限小(MAX_MINIONS=20,ADR-R4-N4/plan S5),不需 C# 热路径,用 Array[Dictionary] +## 炮台型(Stationary)AI:定点对范围内最近敌人开火 +## +## 注:plan S5 提到 "SoA stride=8",但 §5.12 权威实现明确用 Array[Dictionary] +## 且 "不需要 C# 热路径",故按 §5.12 实现;SoA 化为后续可选优化。 +extends Node + +const MAX_MINIONS: int = 20 # ADR-R4-N4:同场最大召唤物,超出 FIFO 顶替最旧 +const DEFAULT_LIFETIME: float = 30.0 + +## 活跃召唤物:{ id, owner_id, pos, lifetime_rem, range, fire_interval, fire_cd, +## damage, bullet_speed, bullet_radius, bullet_lifetime, damage_type } +var _minions: Array = [] +var _next_id: int = 0 + +## def: Dictionary(项目无 .tres,用字典描述召唤物面板) +## 可选键:lifetime, range, fire_interval, damage, bullet_speed, bullet_radius, +## bullet_lifetime, damage_type +func spawn_minion(def: Dictionary, owner_id: int, pos: Vector2) -> int: + if _minions.size() >= MAX_MINIONS: + _expire(_minions[0]["id"]) # FIFO:顶掉最旧 + var lifetime: float = float(def.get("lifetime", DEFAULT_LIFETIME)) + if lifetime <= 0.0: + lifetime = DEFAULT_LIFETIME + var id: int = _next_id + _next_id = (_next_id + 1) % 100000 + var entry: Dictionary = { + "id": id, + "owner_id": owner_id, + "pos": pos, + "lifetime_rem": lifetime, + "range": float(def.get("range", 350.0)), + "fire_interval": maxf(0.05, float(def.get("fire_interval", 0.8))), + "fire_cd": 0.0, + "damage": float(def.get("damage", 4.0)), + "bullet_speed": float(def.get("bullet_speed", 360.0)), + "bullet_radius": float(def.get("bullet_radius", 5.0)), + "bullet_lifetime": float(def.get("bullet_lifetime", 2.0)), + "damage_type": int(def.get("damage_type", 0)), + } + _minions.append(entry) + EventBus.emit(EventID.MINION_SPAWNED, { + "minion_id": id, "owner_id": owner_id, "current_count": _minions.size(), + }) + return id + +func _physics_process(delta: float) -> void: + var i: int = 0 + while i < _minions.size(): + var m: Dictionary = _minions[i] + m["lifetime_rem"] -= delta + if m["lifetime_rem"] <= 0.0: + _expire(m["id"]) + continue # 不自增 i:当前槽已被 remove_at 后移的元素填补 + # 炮台 AI:冷却到点且范围内有敌人则朝最近敌人开火 + m["fire_cd"] -= delta + if m["fire_cd"] <= 0.0: + var pos: Vector2 = m["pos"] + var rng: float = m["range"] + if SpatialGrid.query_circle(pos, rng).size() > 0: + var tgt: Vector2 = EnemyManager.get_nearest_pos(pos, rng) + var dir: Vector2 = (tgt - pos) + dir = dir.normalized() if dir.length() > 0.001 else Vector2.RIGHT + BulletManager.spawn_bullet( + pos, dir * m["bullet_speed"], m["bullet_lifetime"], + m["bullet_radius"], m["damage"], 1.0, m["damage_type"], + m["owner_id"], 0, 0.0, {}) + m["fire_cd"] = m["fire_interval"] + i += 1 + +func _expire(minion_id: int) -> void: + for i in _minions.size(): + if _minions[i]["id"] == minion_id: + var owner: int = _minions[i]["owner_id"] + _minions.remove_at(i) + EventBus.emit(EventID.MINION_EXPIRED, { + "minion_id": minion_id, "owner_id": owner, "current_count": _minions.size(), + }) + return + +func recall_all(owner_id: int = -1) -> void: + # owner_id<0 → 全部召回(Run 结束);否则仅该 owner + var ids: Array = [] + for m in _minions: + if owner_id < 0 or m["owner_id"] == owner_id: + ids.append(m["id"]) + for mid in ids: + _expire(mid) + +func get_count(owner_id: int = -1) -> int: + if owner_id < 0: + return _minions.size() + var c: int = 0 + for m in _minions: + if m["owner_id"] == owner_id: + c += 1 + return c + +func reset() -> void: + _minions.clear() + _next_id = 0 diff --git a/scripts/autoloads/minion_manager.gd.uid b/scripts/autoloads/minion_manager.gd.uid new file mode 100644 index 0000000..eff8036 --- /dev/null +++ b/scripts/autoloads/minion_manager.gd.uid @@ -0,0 +1 @@ +uid://dkjoahbv8qtwl diff --git a/scripts/autoloads/object_pool.gd b/scripts/autoloads/object_pool.gd new file mode 100644 index 0000000..440d361 --- /dev/null +++ b/scripts/autoloads/object_pool.gd @@ -0,0 +1,40 @@ +## ObjectPool — 通用对象池(Autoload: ObjectPool) +## 权威来源:implementation_plan.md §2.1 Layer-0 +## +## 使用约定: +## 1. 被池化对象必须实现 reset() 方法 (签名:func reset() -> void) +## 2. 取出:pool.acquire() → 返回对象(内部已调 reset()) +## 3. 归还:pool.release(obj) +extends Node + +## 创建一个指定工厂和容量的对象池 +func create_pool(capacity: int, factory: Callable) -> PoolInstance: + var p := PoolInstance.new() + p._factory = factory + for _i in capacity: + p._stack.append(factory.call()) + return p + +# ── PoolInstance 内部类(业务系统持有实例) ────────────────────────────── +class PoolInstance: + var _factory: Callable + var _stack: Array = [] + + func acquire() -> Object: + var obj: Object + if _stack.is_empty(): + obj = _factory.call() + else: + obj = _stack.pop_back() + if obj.has_method("reset"): + obj.reset() + return obj + + func release(obj: Object) -> void: + _stack.append(obj) + + func get_available_count() -> int: + return _stack.size() + + func drain() -> void: + _stack.clear() diff --git a/scripts/autoloads/object_pool.gd.uid b/scripts/autoloads/object_pool.gd.uid new file mode 100644 index 0000000..1e3c45f --- /dev/null +++ b/scripts/autoloads/object_pool.gd.uid @@ -0,0 +1 @@ +uid://b2ekg7pdggafm diff --git a/scripts/autoloads/player_stats.gd b/scripts/autoloads/player_stats.gd new file mode 100644 index 0000000..21d6d9e --- /dev/null +++ b/scripts/autoloads/player_stats.gd @@ -0,0 +1,89 @@ +## PlayerStats — 玩家属性统计(Autoload: PlayerStats) +## 权威来源:development_plan.md S2、numerical_design.md §1.2 +extends Node + +signal stats_changed +signal leveled_up(new_level: int) + +# ── HP ─────────────────────────────────────────────── +var hp: float = 100.0 +var hp_max: float = 100.0 + +# ── 资源 ────────────────────────────────────────────── +var gold: int = 0 +var xp: int = 0 + +# ── 等级 ────────────────────────────────────────────── +var level: int = 1 +var xp_to_next: int = 10 # 升级所需 XP + +# ── 战斗属性 ─────────────────────────────────────────── +var cpu_limit: int = 5 # 控制 MAX_OPS = cpu_limit * 40 +var armor: float = 0.0 +var resistance: float = 0.0 # 0.0~1.0 + +func _ready() -> void: + EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged) + +## 升级曲线:roundf(10 × 1.4^(level-1)),P6-N16 +## Level 1→2: 10 XP; Level 5→6: ≈ 54 XP +static func xp_for_level(lv: int) -> int: + return roundi(10.0 * pow(1.4, float(lv - 1))) + +func gain_xp(amount: int) -> void: + xp += amount + while xp >= xp_to_next: + xp -= xp_to_next + level += 1 + xp_to_next = xp_for_level(level) + leveled_up.emit(level) + EventBus.emit(EventID.LEVEL_UP, {"level": level}) + stats_changed.emit() + +func gain_gold(amount: int) -> void: + gold += amount + stats_changed.emit() + +func spend_gold(amount: int) -> bool: + if gold < amount: + return false + gold -= amount + stats_changed.emit() + return true + +func take_damage(amount: float) -> void: + hp = max(0.0, hp - amount * SettingsManager.player_dmg_taken_mult()) # 难度减伤(初学者×0.7) + stats_changed.emit() + if hp <= 0.0: + EventBus.emit(EventID.PLAYER_DIED, {}) + +func heal(amount: float) -> void: + hp = min(hp_max, hp + amount) + stats_changed.emit() + +func get_hp_percent() -> float: + return hp / max(hp_max, 0.001) + +func _on_player_damaged(payload: Dictionary) -> void: + take_damage(float(payload.get("damage", 0.0))) + +func reset_for_run() -> void: + hp = hp_max + gold = 0 + xp = 0 + level = 1 + xp_to_next = xp_for_level(1) + stats_changed.emit() + +func get_save_data() -> Dictionary: + return {"hp": hp, "hp_max": hp_max, "gold": gold, "xp": xp, "level": level, "cpu_limit": cpu_limit} + +func load_save_data(data: Dictionary) -> void: + hp = float(data.get("hp", 100.0)) + hp_max = float(data.get("hp_max", 100.0)) + gold = int(data.get("gold", 0)) + xp = int(data.get("xp", 0)) + level = int(data.get("level", 1)) + cpu_limit = int(data.get("cpu_limit", 5)) + xp_to_next = xp_for_level(level) + stats_changed.emit() diff --git a/scripts/autoloads/player_stats.gd.uid b/scripts/autoloads/player_stats.gd.uid new file mode 100644 index 0000000..ed79b89 --- /dev/null +++ b/scripts/autoloads/player_stats.gd.uid @@ -0,0 +1 @@ +uid://dl4lp1m675v82 diff --git a/scripts/autoloads/profile_manager.gd b/scripts/autoloads/profile_manager.gd new file mode 100644 index 0000000..adbac5c --- /dev/null +++ b/scripts/autoloads/profile_manager.gd @@ -0,0 +1,118 @@ +## ProfileManager — 局内 Run 存档 A/B 双槽(Autoload: ProfileManager) +## 权威来源:architecture_design.md ADR-A2 +## 下面实现了: +## - A/B 双槽交替写入防崩溃 +## - schema_version 字段必须 +## - 所有读取必须经过 _migrate_run() +## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔 +extends Node + +const SCHEMA_VERSION: int = 2 # v2:新增 wand(Core/deck/bench)持久化 +const PATH_A: String = "user://run_a.json" +const PATH_B: String = "user://run_b.json" + +var _current_slot: String = PATH_A # 上次写入的槽 +var _dirty: bool = false + +## 法杖状态提供者(CombatManager 在 _ready 注册);须有 get_wand_save_data/apply_wand_save_data +var _wand_provider: Object = null + +func set_wand_provider(p: Object) -> void: + _wand_provider = p + +func _ready() -> void: + get_tree().root.connect("close_requested", _on_close_request) + +func _on_close_request() -> void: + if _dirty: + save_run() + _dirty = false + +## 写入当前 Run 状态(交替槽位) +func save_run() -> void: + var data: Dictionary = _collect_run_data() + data["schema_version"] = SCHEMA_VERSION + data["saved_at"] = Time.get_unix_time_from_system() + var json_str: String = JSON.stringify(data) + var target: String = _next_slot() + var file := FileAccess.open(target, FileAccess.WRITE) + if file: + file.store_string(json_str) + file.close() + _current_slot = target + _dirty = false + +## 加载 Run,自动选择未损坏的最新槽 +func load_run() -> Dictionary: + var data_a: Dictionary = _read_slot(PATH_A) + var data_b: Dictionary = _read_slot(PATH_B) + var best: Dictionary + if data_a.is_empty() and data_b.is_empty(): + return {} + elif data_a.is_empty(): + best = data_b + elif data_b.is_empty(): + best = data_a + else: + var ta: int = int(data_a.get("saved_at", 0)) + var tb: int = int(data_b.get("saved_at", 0)) + best = data_a if ta > tb else data_b + return _migrate_run(best) + +## 应用局内状态 +func apply_run(data: Dictionary) -> void: + if data.is_empty(): + return + PlayerStats.load_save_data(data.get("player_stats", {})) + WaveManager.current_wave = int(data.get("wave_num", 0)) + var shop_seed: int = int(data.get("shop_seed", 0)) + if shop_seed > 0: + ShopManager._shop_seed = shop_seed + # 恢复法杖(Core/deck/bench) + if data.has("wand") and is_instance_valid(_wand_provider) and _wand_provider.has_method("apply_wand_save_data"): + _wand_provider.apply_wand_save_data(data.get("wand", {})) + +## 清除 Run(死亡 / 新开局) +func clear_run() -> void: + for path in [PATH_A, PATH_B]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + _dirty = false + +func mark_dirty() -> void: + _dirty = true + +func _next_slot() -> String: + return PATH_B if _current_slot == PATH_A else PATH_A + +func _collect_run_data() -> Dictionary: + var d: Dictionary = { + "wave_num": WaveManager.current_wave, + "shop_seed": ShopManager.get_shop_seed(), + "player_stats": PlayerStats.get_save_data(), + } + if is_instance_valid(_wand_provider) and _wand_provider.has_method("get_wand_save_data"): + d["wand"] = _wand_provider.get_wand_save_data() + return d + +func _read_slot(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var text: String = FileAccess.get_file_as_string(path) + if text.is_empty(): + return {} + var parsed = JSON.parse_string(text) + if parsed is Dictionary: + return parsed + return {} + +## 迁移旧存档 +func _migrate_run(data: Dictionary) -> Dictionary: + var ver: int = int(data.get("schema_version", 0)) + if ver < 1: + # v0→v1: 无破坏性字段变更 + data["schema_version"] = 1 + if ver < 2: + # v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段 + data["schema_version"] = 2 + return data diff --git a/scripts/autoloads/profile_manager.gd.uid b/scripts/autoloads/profile_manager.gd.uid new file mode 100644 index 0000000..4366356 --- /dev/null +++ b/scripts/autoloads/profile_manager.gd.uid @@ -0,0 +1 @@ +uid://bix3rk2gyfmvu diff --git a/scripts/autoloads/scene_manager.gd b/scripts/autoloads/scene_manager.gd new file mode 100644 index 0000000..9acf80e --- /dev/null +++ b/scripts/autoloads/scene_manager.gd @@ -0,0 +1,93 @@ +## SceneManager — 场景路由 + 黑幕淡入淡出(Autoload: SceneManager) +## 所有场景跳转必须经过此单例,保证转场动画一致。 +extends Node + +const FADE_DURATION: float = 0.35 + +## 下一次加载游戏场景时的启动模式 +## "new"=新游戏 "continue"=读档继续 "endless"=无尽模式 +var start_mode: String = "new" +var is_transitioning: bool = false + +var _overlay: ColorRect = null +var _tween: Tween = null + +# 场景路径注册表 +const SCENES: Dictionary = { + "splash": "res://scenes/ui/splash.tscn", + "main_menu": "res://scenes/ui/main_menu.tscn", + "game": "res://scenes/main/combat_s2.tscn", +} + +func _ready() -> void: + # 转场需在暂停态下也能运行(从暂停菜单返回主菜单等) + process_mode = Node.PROCESS_MODE_ALWAYS + # 全屏黑幕覆盖层(始终在最顶层) + var cl := CanvasLayer.new() + cl.layer = 127 + add_child(cl) + _overlay = ColorRect.new() + _overlay.color = Color.BLACK + _overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + _overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + cl.add_child(_overlay) + _overlay.modulate.a = 1.0 # 从黑屏开始,首屏淡入 + +func _fade_in(on_done: Callable = Callable()) -> void: + if _tween: + _tween.kill() + _tween = create_tween() + _tween.tween_property(_overlay, "modulate:a", 0.0, FADE_DURATION) + if not on_done.is_null(): + _tween.tween_callback(on_done) + +func _fade_out(on_done: Callable = Callable()) -> void: + if _tween: + _tween.kill() + _tween = create_tween() + _tween.tween_property(_overlay, "modulate:a", 1.0, FADE_DURATION) + if not on_done.is_null(): + _tween.tween_callback(on_done) + +## 通用跳转(不建议直接调用,优先用下方语义方法) +func goto(scene_id: String) -> void: + if is_transitioning: + return + is_transitioning = true + _fade_out(func(): + get_tree().change_scene_to_file(SCENES[scene_id]) + call_deferred("_after_scene_load") + ) + +func _after_scene_load() -> void: + _fade_in(func(): is_transitioning = false) + +## ── 语义跳转方法 ──────────────────────────────────────────── + +func goto_splash() -> void: + goto("splash") + +func goto_main_menu() -> void: + goto("main_menu") + +## 启动新游戏(从头开始) +func start_new_game() -> void: + start_mode = "new" + goto("game") + +## 继续上局存档 +func continue_game() -> void: + start_mode = "continue" + goto("game") + +## 初始淡入(场景 _ready 调用)。经 goto 进入时由 _after_scene_load 独占淡入, +## 此处跳过以免 kill 掉带"复位 is_transitioning"回调的转场 Tween(仅单独启动场景时淡入)。 +func fade_in_first() -> void: + if is_transitioning: + return + _fade_in() + +## 当前是否有可继续的存档 +func has_save() -> bool: + return FileAccess.file_exists(ProfileManager.PATH_A) or \ + FileAccess.file_exists(ProfileManager.PATH_B) diff --git a/scripts/autoloads/scene_manager.gd.uid b/scripts/autoloads/scene_manager.gd.uid new file mode 100644 index 0000000..200a8c8 --- /dev/null +++ b/scripts/autoloads/scene_manager.gd.uid @@ -0,0 +1 @@ +uid://broymfifhvw2n diff --git a/scripts/autoloads/settings_manager.gd b/scripts/autoloads/settings_manager.gd new file mode 100644 index 0000000..4280fc8 --- /dev/null +++ b/scripts/autoloads/settings_manager.gd @@ -0,0 +1,149 @@ +## SettingsManager — 全局设置 + 难度(Autoload: SettingsManager) +## 权威来源:development_plan.md S6 P1(难度三档持久化)、certification_checklist.md ST-03(清除本地数据) +## 持久化 user://save_data.json:{ difficulty:int, locale:String, master_volume:float } +extends Node + +const SAVE_PATH: String = "user://save_data.json" + +enum Difficulty { BEGINNER = 0, STANDARD = 1, CHALLENGE = 2 } + +var difficulty: int = Difficulty.STANDARD +var locale: String = "zh_CN" +var master_volume: float = 1.0 +var ftue_done: bool = false # 首次启动难度引导是否已完成(FTUE) + +func _ready() -> void: + _load() + _load_balance() + _apply_audio() + if Locale: + Locale.set_locale(locale) + +## 是否需要首启难度引导(FTUE) +func needs_ftue() -> bool: + return not ftue_done + +## 完成 FTUE:设定难度并持久化标志 +func complete_ftue(chosen_difficulty: int) -> void: + difficulty = clampi(chosen_difficulty, 0, 2) + ftue_done = true + _save() + +## 难度乘子 + Boss 血量:纯数据驱动,唯一来源 data/balance.json(游戏设计器「平衡」面板维护) +## get() 的默认仅为防崩溃,非内容副本 +var _MULTS: Dictionary = {} # key → [初学者, 标准, 挑战] +var _BOSS_HP: Dictionary = {} # 原型→基础血量 + +const BALANCE_JSON: String = "res://data/balance.json" + +func _mult(key: String) -> float: + var arr: Array = _MULTS.get(key, [1.0, 1.0, 1.0]) + return float(arr[clampi(difficulty, 0, arr.size() - 1)]) + +func enemy_hp_mult() -> float: return _mult("enemy_hp") +func player_dmg_taken_mult() -> float: return _mult("player_dmg") +func wave_count_mult() -> float: return _mult("wave_count") +func boss_hp_mult() -> float: return _mult("boss_hp") + +## Boss 基础血量(WaveManager._spawn_boss 查询;balance.json 可调) +func get_boss_base_hp(boss_type: int) -> float: + return float(_BOSS_HP.get(str(boss_type), 500.0)) + +## 数据驱动:加载 balance.json(难度乘子 + Boss 血量) +func _load_balance() -> void: + if not FileAccess.file_exists(BALANCE_JSON): + push_error("SettingsManager: 缺失 res://data/balance.json(难度乘子/Boss血量)") + return + var data = JSON.parse_string(FileAccess.get_file_as_string(BALANCE_JSON)) + if not (data is Dictionary): + push_error("SettingsManager: balance.json 格式错误") + return + if data.has("difficulty_mults") and data["difficulty_mults"] is Dictionary: + for k in data["difficulty_mults"]: + _MULTS[k] = data["difficulty_mults"][k] + if data.has("boss_hp") and data["boss_hp"] is Dictionary: + _BOSS_HP = data["boss_hp"] + +func difficulty_name() -> String: + return ["DIFF_BEGINNER", "DIFF_STANDARD", "DIFF_CHALLENGE"][difficulty] + +# ── 设置变更(即时保存)────────────────────────────────────── +func set_difficulty(d: int) -> void: + difficulty = clampi(d, 0, 2) + _save() + +func cycle_difficulty() -> int: + set_difficulty((difficulty + 1) % 3) + return difficulty + +func set_locale(loc: String) -> void: + locale = loc + if Locale: + Locale.set_locale(loc) + _save() + +func set_master_volume(v: float) -> void: + master_volume = clampf(v, 0.0, 1.0) + _apply_audio() + _save() + +func _apply_audio() -> void: + var bus: int = AudioServer.get_bus_index("Master") + if bus >= 0: + AudioServer.set_bus_volume_db(bus, linear_to_db(maxf(0.0001, master_volume))) + +# ── ST-03:清除所有本地数据 ───────────────────────────────── +func clear_all_local_data() -> void: + var files: Array = [ + "user://crash_log.txt", "user://crash_log_prev.txt", + "user://run_a.json", "user://run_b.json", + "user://endless_records.json", "user://save_data.json", + ] + for path in files: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + _remove_dir_recursive("user://runs") + # 重置内存态 + difficulty = Difficulty.STANDARD + master_volume = 1.0 + ftue_done = false # 清数据后重新触发首启难度引导 + # 用路径引用避免编译时 autoload 依赖(打破 SettingsManager→ProfileManager→WaveManager→SettingsManager 循环) + var pm: Node = get_node_or_null("/root/ProfileManager") + if pm and pm.has_method("clear_run"): + pm.clear_run() + _apply_audio() + +func _remove_dir_recursive(path: String) -> void: + var d := DirAccess.open(path) + if d == null: + return + d.list_dir_begin() + var fn := d.get_next() + while fn != "": + if not d.current_is_dir(): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path + "/" + fn)) + fn = d.get_next() + d.list_dir_end() + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + +# ── 持久化 ────────────────────────────────────────────────── +func _save() -> void: + var data: Dictionary = { + "difficulty": difficulty, "locale": locale, + "master_volume": master_volume, "ftue_done": ftue_done, + } + var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(data)) + f.close() + +func _load() -> void: + if not FileAccess.file_exists(SAVE_PATH): + return + var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH)) + if not (parsed is Dictionary): + return + difficulty = clampi(int(parsed.get("difficulty", 1)), 0, 2) + locale = String(parsed.get("locale", "zh_CN")) + master_volume = clampf(float(parsed.get("master_volume", 1.0)), 0.0, 1.0) + ftue_done = bool(parsed.get("ftue_done", false)) diff --git a/scripts/autoloads/settings_manager.gd.uid b/scripts/autoloads/settings_manager.gd.uid new file mode 100644 index 0000000..767e29f --- /dev/null +++ b/scripts/autoloads/settings_manager.gd.uid @@ -0,0 +1 @@ +uid://dvs14n88hmy06 diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd new file mode 100644 index 0000000..6c75d4b --- /dev/null +++ b/scripts/autoloads/shop_manager.gd @@ -0,0 +1,86 @@ +## ShopManager — 商店管理(Autoload: ShopManager) +## S2 范围:3 选 1 法术、金币扣减、刷新费用公式(P6-N23) +## 权威来源:development_plan.md S2、numerical_design.md §2.3 +extends Node + +signal shop_refreshed +signal shop_closed + +const SLOT_COUNT: int = 3 +const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用 +const REROLL_STEP: int = 10 # 每次递增价格 + +var current_slots: Array = [] # Array[SpellNode] (null 表示已售出) +var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置) +var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07) +var _rng: RandomNumberGenerator = RandomNumberGenerator.new() + +func _ready() -> void: + EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete) + +func _on_wave_complete(_payload: Dictionary) -> void: + reroll_count = 0 # P6-N23:波次结算后重置刷新次数 + +func open_shop(seed_override: int = -1) -> void: + if seed_override >= 0: + _shop_seed = seed_override + else: + _shop_seed = randi() + _rng.seed = _shop_seed + _refresh_slots() + EventBus.emit(EventID.SHOP_OPENED, {"seed": _shop_seed}) + +func _refresh_slots() -> void: + # 纯数据驱动池:SpellRegistry(data/spells.json)中可购买(shop_cost>0)的法术 + var pool: Array = SpellRegistry.get_all_ids().filter(func(id): + var s: SpellNode = SpellRegistry.get_spell(id) + return s != null and int(s.meta.get("shop_cost", 0)) > 0) + current_slots.clear() + for _i in SLOT_COUNT: + if pool.is_empty(): + current_slots.append(null) + continue + var idx: int = _rng.randi_range(0, pool.size() - 1) + current_slots.append(SpellRegistry.get_spell(pool[idx])) + pool.remove_at(idx) + shop_refreshed.emit() + +## 购买指定槽位的法术,返回购买的 SpellNode、失败返回 null +func buy_spell(slot_idx: int) -> SpellNode: + if slot_idx < 0 or slot_idx >= current_slots.size(): + return null + var spell: SpellNode = current_slots[slot_idx] + if spell == null: + return null + var cost: int = int(spell.meta.get("shop_cost", 20)) + if not PlayerStats.spend_gold(cost): + return null + current_slots[slot_idx] = null + shop_refreshed.emit() + return spell + +## 刷新商店,返回 true 表示成功 +func reroll() -> bool: + var cost: int = get_reroll_cost() + if not PlayerStats.spend_gold(cost): + return false + reroll_count += 1 + _rng.seed = _shop_seed + reroll_count * 31337 + _refresh_slots() + return true + +## 刷新费用公式:base + step * reroll_count(P6-N23) +func get_reroll_cost() -> int: + return REROLL_BASE_COST + reroll_count * REROLL_STEP + +func close_shop() -> void: + current_slots.clear() + shop_closed.emit() + +func get_shop_seed() -> int: + return _shop_seed + +func reset() -> void: + current_slots.clear() + reroll_count = 0 + _shop_seed = 0 diff --git a/scripts/autoloads/shop_manager.gd.uid b/scripts/autoloads/shop_manager.gd.uid new file mode 100644 index 0000000..430d100 --- /dev/null +++ b/scripts/autoloads/shop_manager.gd.uid @@ -0,0 +1 @@ +uid://cu5ace51jtmf4 diff --git a/scripts/autoloads/spatial_grid.gd b/scripts/autoloads/spatial_grid.gd new file mode 100644 index 0000000..7a9c0f7 --- /dev/null +++ b/scripts/autoloads/spatial_grid.gd @@ -0,0 +1,75 @@ +## SpatialGrid — 2D 空间哈希网格 Autoload(GDScript 外壳) +## 热路径由 C# SpatialGridCs 子节点执行(就绪后接管) +## 权威来源:architecture_design.md §4.3 +extends Node + +const CELL_SIZE: int = 64 # 格子大小(像素) +const GRID_COLS: int = 128 # 128×128 格 × 64px = 8192×8192 覆盖范围 +const GRID_ROWS: int = 128 +const GRID_OFFSET: int = 4096 # 将世界中心映射到网格中心(支持负坐标) + +var _cs_node: Node = null # SpatialGridCs C# 子节点(就绪后挂载) + +## GDScript 回退网格 +var _grid: Array = [] # Array[Array[int]] +var _entity_cell: Dictionary = {} # { entity_id → cell_idx } + +func _ready() -> void: + _grid.resize(GRID_COLS * GRID_ROWS) + for i in _grid.size(): + _grid[i] = [] + +## 插入或更新实体位置(由 EnemyManager 每物理帧调用) +func insert(entity_id: int, pos: Vector2) -> void: + if _cs_node and _cs_node.has_method("Insert"): + _cs_node.call("Insert", entity_id, pos) + return + var cx: int = clamp(int((pos.x + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1) + var cy: int = clamp(int((pos.y + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1) + var cell: int = cx + cy * GRID_COLS + var old_cell: int = _entity_cell.get(entity_id, -1) + if old_cell == cell: + return + if old_cell >= 0: + _grid[old_cell].erase(entity_id) + _grid[cell].append(entity_id) + _entity_cell[entity_id] = cell + +## 查询圆形范围内的所有实体 ID +func query_circle(center: Vector2, radius: float) -> PackedInt32Array: + if _cs_node and _cs_node.has_method("QueryCircle"): + return _cs_node.call("QueryCircle", center, radius) + var result := PackedInt32Array() + var min_cx: int = clamp(int((center.x - radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1) + var max_cx: int = clamp(int((center.x + radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1) + var min_cy: int = clamp(int((center.y - radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1) + var max_cy: int = clamp(int((center.y + radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1) + for cy in range(min_cy, max_cy + 1): + for cx in range(min_cx, max_cx + 1): + for eid in _grid[cx + cy * GRID_COLS]: + result.append(eid) + return result + +## 每帧由 EnemyManager._physics_process 驱动(dirty-list 方案) +func rebuild() -> void: + if _cs_node and _cs_node.has_method("Rebuild"): + _cs_node.call("Rebuild") + +## 移除实体 +func remove_entity(entity_id: int) -> void: + if _cs_node and _cs_node.has_method("Remove"): + _cs_node.call("Remove", entity_id) + return + var old_cell: int = _entity_cell.get(entity_id, -1) + if old_cell >= 0: + _grid[old_cell].erase(entity_id) + _entity_cell.erase(entity_id) + +## 清空(关卡重置) +func clear() -> void: + if _cs_node and _cs_node.has_method("Clear"): + _cs_node.call("Clear") + return + for cell_list in _grid: + (cell_list as Array).clear() + _entity_cell.clear() diff --git a/scripts/autoloads/spatial_grid.gd.uid b/scripts/autoloads/spatial_grid.gd.uid new file mode 100644 index 0000000..21c7e39 --- /dev/null +++ b/scripts/autoloads/spatial_grid.gd.uid @@ -0,0 +1 @@ +uid://x822ya5c7tlk diff --git a/scripts/autoloads/spell_context_pool.gd b/scripts/autoloads/spell_context_pool.gd new file mode 100644 index 0000000..b4aa622 --- /dev/null +++ b/scripts/autoloads/spell_context_pool.gd @@ -0,0 +1,54 @@ +## SpellContextPool — SpellContext 对象池(Autoload: SpellContextPool) +## 权威来源:implementation_plan.md §2.1、S1 验收 P-S1-04 +extends Node + +const POOL_SIZE: int = 32 # P-S1-04: pool size 不超过 32 + +var _pool: Array = [] # Array[SpellContext] +var _in_use: PackedByteArray = PackedByteArray() +var _slots: Array = [] # Array[SpellContext] 固定槽位 + +func _ready() -> void: + _slots.resize(POOL_SIZE) + _in_use.resize(POOL_SIZE) + for i in POOL_SIZE: + _slots[i] = SpellContext.new() + _in_use[i] = 0 + _pool.append(i) # 空闲 ID 表 + +## 取出一个 SpellContext。若 has_persistent_memory=true 则保留 registers +func acquire(caster_id: int, has_persistent_memory: bool = false) -> SpellContext: + if _pool.is_empty(): + push_warning("SpellContextPool: 池已耗尽,动态创建 SpellContext(建议上调 POOL_SIZE=%d)" % POOL_SIZE) + var ctx_dyn := SpellContext.new() + ctx_dyn.caster_id = caster_id + if not has_persistent_memory: + ctx_dyn.clear_registers() + return ctx_dyn + var slot_id: int = _pool[_pool.size() - 1] + _pool.resize(_pool.size() - 1) + var ctx: SpellContext = _slots[slot_id] + _in_use[slot_id] = int(1) + ctx.reset() + ctx.caster_id = caster_id + if not has_persistent_memory: + ctx.clear_registers() + return ctx + +func release(ctx: SpellContext) -> void: + for i in _slots.size(): + if _slots[i] == ctx: + if _in_use[i] != 0: + _in_use[i] = 0 + _pool.append(i) + return + # 动态分配的(池已耗尽时)直接丢弃 + +func get_available_count() -> int: + return _pool.size() + +func reset() -> void: + _pool.clear() + for i in _slots.size(): + _in_use[i] = 0 + _pool.append(i) diff --git a/scripts/autoloads/spell_context_pool.gd.uid b/scripts/autoloads/spell_context_pool.gd.uid new file mode 100644 index 0000000..edf6cad --- /dev/null +++ b/scripts/autoloads/spell_context_pool.gd.uid @@ -0,0 +1 @@ +uid://dgqs614fymhqe diff --git a/scripts/autoloads/spell_registry.gd b/scripts/autoloads/spell_registry.gd new file mode 100644 index 0000000..54ac905 --- /dev/null +++ b/scripts/autoloads/spell_registry.gd @@ -0,0 +1,46 @@ +## SpellRegistry — 法术注册表(Autoload: SpellRegistry) +## 纯数据驱动:唯一来源 res://data/spells.json(游戏设计器「法术」面板维护) +## 无硬编码回退——法术定义只存在于 JSON。 +extends Node + +const JSON_DATA_PATH: String = "res://data/spells.json" + +var _registry: Dictionary = {} # { spell_id: String → SpellNode } + +func _ready() -> void: + if not FileAccess.file_exists(JSON_DATA_PATH): + push_error("SpellRegistry: 缺失 res://data/spells.json(无任何法术定义)") + return + var data = JSON.parse_string(FileAccess.get_file_as_string(JSON_DATA_PATH)) + if not (data is Dictionary): + push_error("SpellRegistry: spells.json 格式错误(应为对象)") + return + for sid in data: + _registry[String(sid)] = _spell_from_dict(String(sid), data[sid]) + +func _spell_from_dict(sid: String, d: Dictionary) -> SpellNode: + var n := SpellNode.new() + n.id = sid + n.type = int(d.get("type", 0)) + n.display_name = String(d.get("display_name", sid)) + n.description = String(d.get("description", "")) + n.element_tags = d.get("element_tags", []) + n.meta = d.get("meta", {}) + return n + +## 运行时动态注册(如共鸣产出、未来扩展),非数据来源 +func register_spell(spell: SpellNode) -> void: + if spell and spell.id != "": + _registry[spell.id] = spell + +func get_spell(id: String) -> SpellNode: + return _registry.get(id, null) + +func get_all_ids() -> Array: + return _registry.keys() + +func has_spell(id: String) -> bool: + return _registry.has(id) + +func get_registry_size() -> int: + return _registry.size() diff --git a/scripts/autoloads/spell_registry.gd.uid b/scripts/autoloads/spell_registry.gd.uid new file mode 100644 index 0000000..40f9f17 --- /dev/null +++ b/scripts/autoloads/spell_registry.gd.uid @@ -0,0 +1 @@ +uid://dbdjccwuktibn diff --git a/scripts/autoloads/status_id.gd b/scripts/autoloads/status_id.gd new file mode 100644 index 0000000..cb1a58f --- /dev/null +++ b/scripts/autoloads/status_id.gd @@ -0,0 +1,14 @@ +## StatusID — 状态效果整数 ID(Autoload: StatusID) +## 规则:禁止使用 StatusType.XXX 枚举(跨切片约束 P6-N30),必须用本常量。 +## 新增状态从 9 起递增,不得复用或跳号。 +extends Node + +const BURN: int = 1 +const FREEZE: int = 2 +const POISON: int = 3 +const WET: int = 4 +const OILY: int = 5 +const STUN: int = 6 +const COMBO_MARK: int = 7 # 连击叠加(consecutive_hits_stacking.md) +const VULNERABILITY: int = 8 # 易伤标记 +# 新增从 9 起 diff --git a/scripts/autoloads/status_id.gd.uid b/scripts/autoloads/status_id.gd.uid new file mode 100644 index 0000000..7d0516b --- /dev/null +++ b/scripts/autoloads/status_id.gd.uid @@ -0,0 +1 @@ +uid://dke3xnu50nshg diff --git a/scripts/autoloads/status_manager.gd b/scripts/autoloads/status_manager.gd new file mode 100644 index 0000000..1e76414 --- /dev/null +++ b/scripts/autoloads/status_manager.gd @@ -0,0 +1,122 @@ +## StatusManager — 状态效果管理(Autoload: StatusManager) +## 权威来源:combat_mechanics_depth.md §3.2.5、implementation_plan.md §2.4 +extends Node + +const STATUS_BATCH_LIMIT: int = 500 + +var _active_statuses: Array = [] # Array[StatusInstance] +var _physics_frame: int = 0 + +func _physics_process(delta: float) -> void: + _physics_frame += 1 + var use_batch: bool = _active_statuses.size() > STATUS_BATCH_LIMIT + var batch_parity: int = _physics_frame % 2 + + var i: int = _active_statuses.size() - 1 + while i >= 0: + var inst: StatusInstance = _active_statuses[i] + if use_batch and (inst.entity_id % 2) != batch_parity: + i -= 1 + continue + + inst.remaining_duration -= delta + inst.tick_accumulator += delta + + if inst.type_def and not inst.type_def.is_combo_tracker and inst.type_def.dot_damage_per_tick > 0.0: + var interval: float = max(0.001, inst.type_def.tick_interval) + while inst.tick_accumulator >= interval: + inst.tick_accumulator -= interval + _apply_dot_tick(inst) + + if inst.remaining_duration <= 0.0: + _active_statuses[i] = _active_statuses[_active_statuses.size() - 1] + _active_statuses.pop_back() + i -= 1 + +## 施加状态;duration < 0 时使用 StatusTypeDef 默认 duration +func apply(entity_id: int, status_type_id: int, stacks: int = 1, duration: float = -1.0, owner_id: int = -1) -> void: + var typedef: StatusTypeDef = StatusRegistry.get_type(status_type_id) + if typedef == null: + push_warning("StatusManager.apply: 未知 status_id=%d" % status_type_id) + return + + var dur: float = typedef.duration if duration < 0.0 else duration + var existing_idx: int = _find(entity_id, status_type_id) + + if existing_idx >= 0: + var inst: StatusInstance = _active_statuses[existing_idx] + match typedef.stack_mode: + StatusTypeDef.StackMode.REFRESH: + inst.remaining_duration = dur + inst.stacks = max(inst.stacks, stacks) + StatusTypeDef.StackMode.INTENSITY: + var cap: int = typedef.max_stacks if typedef.max_stacks > 0 else 9999 + inst.stacks = min(inst.stacks + stacks, cap) + inst.remaining_duration = dur + StatusTypeDef.StackMode.INDEPENDENT: + pass # 独立实例:追加新条目 + if typedef.stack_mode != StatusTypeDef.StackMode.INDEPENDENT: + EventBus.emit(EventID.STATUS_APPLIED, { + "target_id": entity_id, "status_type": status_type_id, "stacks": inst.stacks + }) + return + + var inst_new := StatusInstance.new() + inst_new.setup(entity_id, typedef, stacks, dur, owner_id) + _active_statuses.append(inst_new) + EventBus.emit(EventID.STATUS_APPLIED, { + "target_id": entity_id, "status_type": status_type_id, "stacks": inst_new.stacks + }) + +func get_stacks(entity_id: int, status_type_id: int) -> int: + var idx: int = _find(entity_id, status_type_id) + if idx < 0: + return 0 + return _active_statuses[idx].stacks + +func has_status(entity_id: int, status_type_id: int) -> bool: + return _find(entity_id, status_type_id) >= 0 + +func remove_all_for_entity(entity_id: int) -> void: + var i: int = _active_statuses.size() - 1 + while i >= 0: + if _active_statuses[i].entity_id == entity_id: + _active_statuses[i] = _active_statuses[_active_statuses.size() - 1] + _active_statuses.pop_back() + i -= 1 + +func remove_source(owner_id: int) -> void: + var i: int = _active_statuses.size() - 1 + while i >= 0: + if _active_statuses[i].owner_id == owner_id: + _active_statuses[i] = _active_statuses[_active_statuses.size() - 1] + _active_statuses.pop_back() + i -= 1 + +func get_active_count() -> int: + return _active_statuses.size() + +func reset() -> void: + _active_statuses.clear() + _physics_frame = 0 + +func _find(entity_id: int, status_type_id: int) -> int: + for i in _active_statuses.size(): + var inst: StatusInstance = _active_statuses[i] + if inst.entity_id == entity_id and inst.status_type_id == status_type_id: + return i + return -1 + +func _apply_dot_tick(inst: StatusInstance) -> void: + if not EnemyManager.has_entity(inst.entity_id): + return + var dmg: float = inst.type_def.dot_damage_per_tick * float(inst.stacks) + if dmg <= 0.0: + return + EnemyManager.apply_damage(inst.entity_id, dmg, inst.owner_id, false) + +func _swap_and_pop(idx: int) -> void: + var last: int = _active_statuses.size() - 1 + if idx != last: + _active_statuses[idx] = _active_statuses[last] + _active_statuses.pop_back() diff --git a/scripts/autoloads/status_manager.gd.uid b/scripts/autoloads/status_manager.gd.uid new file mode 100644 index 0000000..f91a786 --- /dev/null +++ b/scripts/autoloads/status_manager.gd.uid @@ -0,0 +1 @@ +uid://vm1hbvxu51s8 diff --git a/scripts/autoloads/status_registry.gd b/scripts/autoloads/status_registry.gd new file mode 100644 index 0000000..36471ea --- /dev/null +++ b/scripts/autoloads/status_registry.gd @@ -0,0 +1,40 @@ +## StatusRegistry — 状态效果注册表(Autoload: StatusRegistry) +## 纯数据驱动:唯一来源 res://data/status_effects.json(游戏设计器「状态」面板维护) +extends Node + +const STATUS_JSON: String = "res://data/status_effects.json" + +var _registry: Dictionary = {} # { status_id: int → StatusTypeDef } + +func _ready() -> void: + if not FileAccess.file_exists(STATUS_JSON): + push_error("StatusRegistry: 缺失 res://data/status_effects.json(无状态效果定义)") + return + var data = JSON.parse_string(FileAccess.get_file_as_string(STATUS_JSON)) + if not (data is Dictionary): + push_error("StatusRegistry: status_effects.json 格式错误") + return + for k in data: + var sid: int = int(k) + var d: Dictionary = data[k] + var t := StatusTypeDef.new() + t.id = sid + t.display_name = String(d.get("name", "Status %d" % sid)) + t.duration = float(d.get("duration", 3.0)) + t.tick_interval = float(d.get("tick_interval", 1.0)) + t.stack_mode = int(d.get("stack_mode", 0)) + t.max_stacks = int(d.get("max_stacks", 1)) + t.dot_damage_per_tick = float(d.get("dot_damage", 0.0)) + t.dot_damage_type = int(d.get("dot_damage_type", 0)) + t.vfx_id = String(d.get("vfx_id", "")) + t.is_combo_tracker = bool(d.get("is_combo_tracker", false)) + _registry[sid] = t + +func get_type(status_id: int) -> StatusTypeDef: + return _registry.get(status_id, null) + +func has_type(status_id: int) -> bool: + return _registry.has(status_id) + +func get_registry_size() -> int: + return _registry.size() diff --git a/scripts/autoloads/status_registry.gd.uid b/scripts/autoloads/status_registry.gd.uid new file mode 100644 index 0000000..62b1fbd --- /dev/null +++ b/scripts/autoloads/status_registry.gd.uid @@ -0,0 +1 @@ +uid://ckxpvasdeb8gi diff --git a/scripts/autoloads/time_manager.gd b/scripts/autoloads/time_manager.gd new file mode 100644 index 0000000..f1171fb --- /dev/null +++ b/scripts/autoloads/time_manager.gd @@ -0,0 +1,34 @@ +## TimeManager — 时间缩放与 GameTick 计数器(Autoload: TimeManager) +## 权威来源:implementation_plan.md §2.1 Layer-0 +## +## - time_scale: 全局时间缩放(慢动作 / 冻结时间 Core 效果) +## - game_tick: 物理帧计数器(每 _physics_process 递增 1)— 不受 time_scale 影响 +extends Node + +var time_scale: float = 1.0 +var game_tick: int = 0 + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS # 暂停时仍持续计数 + +func _physics_process(_delta: float) -> void: + game_tick += 1 + Engine.time_scale = time_scale + +## 返回当前帧缩放后 delta(供战斗系统使用) +func scaled_delta(raw_delta: float) -> float: + return raw_delta * time_scale + +## 设置时间缩放(clamp 防止异常値) +func set_time_scale(scale: float) -> void: + time_scale = clamp(scale, 0.0, 10.0) + +## 恢复正常速度 +func reset_time_scale() -> void: + time_scale = 1.0 + Engine.time_scale = 1.0 + +## 忽略帧计数器(关卡重置) +func reset() -> void: + game_tick = 0 + reset_time_scale() diff --git a/scripts/autoloads/time_manager.gd.uid b/scripts/autoloads/time_manager.gd.uid new file mode 100644 index 0000000..4dd1c61 --- /dev/null +++ b/scripts/autoloads/time_manager.gd.uid @@ -0,0 +1 @@ +uid://bx3w1yacjl3qh diff --git a/scripts/autoloads/vfx_manager.gd b/scripts/autoloads/vfx_manager.gd new file mode 100644 index 0000000..4bf7b34 --- /dev/null +++ b/scripts/autoloads/vfx_manager.gd @@ -0,0 +1,125 @@ +## VFXManager — 视觉特效管理(Autoload: VFXManager) +## S4:程序化色块占位特效 + 对象池(P6-N72 one_shot=true) +extends Node2D + +const MAX_ACTIVE_VFX: int = 200 + +var _vfx_pool: Dictionary = {} # { effect_id: Array[Node2D] } +var _active_count: int = 0 + +func _ready() -> void: + EventBus.subscribe(EventID.STATUS_APPLIED, _on_status_applied) + +func play(effect_id: String, world_pos: Vector2, override_scale: float = 1.0) -> void: + if _active_count >= MAX_ACTIVE_VFX: + return + var node: Node2D = _pool_pop(effect_id) + node.global_position = world_pos + node.scale = Vector2.ONE * override_scale + node.visible = true + _active_count += 1 + # 触发一次性粒子爆发 + var p: GPUParticles2D = node.get_node_or_null("Particles") + if p: + p.restart() + p.emitting = true + var timer: Timer = node.get_node_or_null("AutoReturn") + if timer: + timer.start() + +func _pool_pop(effect_id: String) -> Node2D: + if _vfx_pool.has(effect_id) and not _vfx_pool[effect_id].is_empty(): + return _vfx_pool[effect_id].pop_back() + return _instantiate_vfx(effect_id) + +func _pool_return(effect_id: String, node: Node2D) -> void: + node.visible = false + _active_count = max(0, _active_count - 1) + if not _vfx_pool.has(effect_id): + _vfx_pool[effect_id] = [] + _vfx_pool[effect_id].append(node) + +func _instantiate_vfx(effect_id: String) -> Node2D: + # 程序化美术占位:GPUParticles2D 爆裂粒子(放 scenes/vfx/.tscn 可整体替换) + var node := Node2D.new() + node.name = "VFX_" + effect_id + var col: Color = _color_for(effect_id) + var p := GPUParticles2D.new() + p.name = "Particles" + p.amount = _amount_for(effect_id) + p.lifetime = 0.45 + p.one_shot = true # P6-N72 + p.explosiveness = 0.9 # 一次性爆发 + p.local_coords = false + var mat := ParticleProcessMaterial.new() + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + mat.emission_sphere_radius = 4.0 + mat.direction = Vector3(0, 0, 0) + mat.spread = 180.0 + mat.gravity = Vector3(0, 0, 0) + mat.initial_velocity_min = _speed_for(effect_id) * 0.5 + mat.initial_velocity_max = _speed_for(effect_id) + mat.scale_min = 1.5 + mat.scale_max = 4.0 + # 颜色随生命淡出 + var grad := Gradient.new() + grad.set_color(0, Color(col.r, col.g, col.b, col.a)) + grad.set_color(1, Color(col.r, col.g, col.b, 0.0)) + var gtex := GradientTexture1D.new() + gtex.gradient = grad + mat.color_ramp = gtex + p.process_material = mat + node.add_child(p) + # 自动回收(粒子播完) + var timer := Timer.new() + timer.name = "AutoReturn" + timer.one_shot = true + timer.wait_time = 0.55 + timer.timeout.connect(_pool_return.bind(effect_id, node)) + node.add_child(timer) + add_child(node) + node.visible = false + return node + +## 不同特效的粒子数 / 速度 +func _amount_for(effect_id: String) -> int: + match effect_id: + "death_burst": return 24 + "hit_spark": return 10 + "cast_flash": return 8 + _: return 12 + +func _speed_for(effect_id: String) -> float: + match effect_id: + "death_burst": return 220.0 + "hit_spark": return 160.0 + _: return 110.0 + +func _color_for(effect_id: String) -> Color: + match effect_id: + "hit_spark": return Color(1.0, 0.9, 0.3, 0.9) + "status_burn": return Color(1.0, 0.4, 0.1, 0.8) + "status_poison": return Color(0.3, 0.9, 0.2, 0.8) + "cast_flash": return Color(0.6, 0.8, 1.0, 0.7) + "death_burst": return Color(1.0, 0.2, 0.2, 0.8) + _: return Color(1, 1, 1, 0.6) + +func _on_status_applied(payload: Dictionary) -> void: + var status_type: int = int(payload.get("status_type", 0)) + var typedef: StatusTypeDef = StatusRegistry.get_type(status_type) + if typedef and typedef.vfx_id != "": + var eid: int = int(payload.get("target_id", -1)) + var pos: Vector2 = EnemyManager.get_pos_by_id(eid) + if pos != Vector2(-9999.0, -9999.0): + play(typedef.vfx_id, pos) + +func get_active_count() -> int: + return _active_count + +func reset() -> void: + for key in _vfx_pool: + for n in _vfx_pool[key]: + if is_instance_valid(n): + n.queue_free() + _vfx_pool.clear() + _active_count = 0 diff --git a/scripts/autoloads/vfx_manager.gd.uid b/scripts/autoloads/vfx_manager.gd.uid new file mode 100644 index 0000000..a46b1e4 --- /dev/null +++ b/scripts/autoloads/vfx_manager.gd.uid @@ -0,0 +1 @@ +uid://cfobw7w7hcchm diff --git a/scripts/autoloads/wand_preset.gd b/scripts/autoloads/wand_preset.gd new file mode 100644 index 0000000..c0ff203 --- /dev/null +++ b/scripts/autoloads/wand_preset.gd @@ -0,0 +1,46 @@ +## WandPreset — Core(法杖)工厂(Autoload: WandPreset) +## 纯数据驱动:Core 定义唯一来源 res://data/cores.json(游戏设计器「Core」面板维护) +## 法术已迁至 SpellRegistry(data/spells.json),此处不再定义法术。 +extends Node + +const CORES_JSON: String = "res://data/cores.json" +var _json_cores: Dictionary = {} + +func _ready() -> void: + if not FileAccess.file_exists(CORES_JSON): + push_error("WandPreset: 缺失 res://data/cores.json(无 Core 定义)") + return + var data = JSON.parse_string(FileAccess.get_file_as_string(CORES_JSON)) + if data is Dictionary: + _json_cores = data + else: + push_error("WandPreset: cores.json 格式错误") + +## 按 ID 创建 Core(纯 cores.json);未知 ID 报错并返回错误安全默认(5 槽 LINEAR) +func make_core_by_id(cid: String) -> CoreDefinition: + if _json_cores.has(cid): + return _core_from_dict(cid, _json_cores[cid]) + push_error("WandPreset: Core '%s' 不在 cores.json" % cid) + return _core_from_dict(cid, {}) + +func _core_from_dict(cid: String, d: Dictionary) -> CoreDefinition: + var c := CoreDefinition.new() + c.id = cid + c.display_name = String(d.get("display_name", cid)) + c.slot_count = int(d.get("slot_count", 5)) + c.topology = int(d.get("topology", 0)) + c.cpu_limit = int(d.get("cpu_limit", 5)) + c.cast_interval = float(d.get("cast_interval", 0.5)) + c.feature_tags = int(d.get("feature_tags", 0)) + c.grid_rows = int(d.get("grid_rows", 1)) + c.grid_cols = int(d.get("grid_cols", 5)) + c.edges = d.get("edges", []) + return c + +## 默认装备(combat_test 等用):wand_basic + 第一个 ACTION 法术 +func make_default_loadout() -> Dictionary: + var core: CoreDefinition = make_core_by_id("wand_basic") + var spell: SpellNode = SpellRegistry.get_spell("action_spark_bolt") + var spells: Array = [spell] if spell else [] + var compiled = SpellEvaluator.compile_wand(core, spells) + return {"core": core, "spells": spells, "compiled": compiled} diff --git a/scripts/autoloads/wand_preset.gd.uid b/scripts/autoloads/wand_preset.gd.uid new file mode 100644 index 0000000..67639c1 --- /dev/null +++ b/scripts/autoloads/wand_preset.gd.uid @@ -0,0 +1 @@ +uid://bfgjbhdg6b17l diff --git a/scripts/autoloads/wave_manager.gd b/scripts/autoloads/wave_manager.gd new file mode 100644 index 0000000..5064b10 --- /dev/null +++ b/scripts/autoloads/wave_manager.gd @@ -0,0 +1,174 @@ +## WaveManager — 波次管理(Autoload: WaveManager) +## S6 内容:Wave 1~20 完整配置(敌人原型/精英寻路/Mini Boss W8/Final Boss W20) +## 设计参考:game_design.md §3.3(W1-5 数值 / W6-10 机制 / W11-20 弹幕)、§4.5 Boss +extends Node + +enum WaveState { IDLE = 0, BATTLE = 1, COMPLETE = 2 } + +var current_wave: int = 0 +var wave_state: int = WaveState.IDLE +var enemies_alive: int = 0 +var _wave_start_time: float = 0.0 + +const ARENA_RADIUS: float = 700.0 +const MAX_WAVE: int = 20 + +## Boss 追踪 +var _boss_id: int = -1 +var _boss_max_hp: float = 0.0 +var _boss_phase: int = 0 +var _boss_wave: bool = false + +## 波次配置表:纯数据驱动,唯一来源 data/waves.json(游戏设计器「波次」面板维护) +## 字段:count, hp, type, elite, gold, xp, boss(-1=无 / 4=MiniBoss / 5=Boss) +var WAVE_CONFIG: Array = [] + +const WAVES_JSON: String = "res://data/waves.json" + +func _ready() -> void: + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) + _load_json_waves() + +## 数据驱动:从 data/waves.json 加载波次表 +func _load_json_waves() -> void: + if not FileAccess.file_exists(WAVES_JSON): + push_error("WaveManager: 缺失 res://data/waves.json(无波次配置)") + return + var data = JSON.parse_string(FileAccess.get_file_as_string(WAVES_JSON)) + if data is Array and not data.is_empty(): + WAVE_CONFIG = data + else: + push_error("WaveManager: waves.json 格式错误(应为非空数组)") + +func _physics_process(_delta: float) -> void: + if _boss_wave and _boss_id >= 0: + _update_boss_phase() + +## 开始指定波次(由 CombatManager 调用) +func start_wave(wave_num: int) -> void: + current_wave = wave_num + wave_state = WaveState.BATTLE + _wave_start_time = Time.get_ticks_msec() / 1000.0 + _boss_id = -1 + _boss_phase = 0 + _boss_wave = false + var cfg: Dictionary = _get_wave_config(wave_num) + enemies_alive = int(cfg["count"]) + _spawn_wave_enemies(cfg) + if int(cfg.get("boss", -1)) >= 0: + _spawn_boss(int(cfg["boss"]), wave_num) + EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "SHOP", "to": "BATTLE", "wave": wave_num}) + print("[WaveManager] Wave %d 开始:%d 杂兵 + %d 精英%s" % [ + wave_num, int(cfg["count"]), int(cfg.get("elite", 0)), + ("(Boss!)" if int(cfg.get("boss", -1)) >= 0 else "")]) + +func _spawn_wave_enemies(cfg: Dictionary) -> void: + # 难度乘子:怪物数量(挑战×1.2)、怪物血量(初学者×0.7) + var count: int = int(round(int(cfg["count"]) * SettingsManager.wave_count_mult())) + var hp: float = float(cfg["hp"]) * SettingsManager.enemy_hp_mult() + var etype: int = int(cfg.get("type", 0)) + # enemies_alive 已按 cfg.count 初始化,按难度修正差额 + enemies_alive += count - int(cfg["count"]) + for _i in count: + EnemyManager.spawn_enemy(_ring_pos(), hp, etype) + # W15+ 精英:带 NavigationAgent2D 寻路(ADR-A4),更高血量 + var elites: int = int(cfg.get("elite", 0)) + for _e in elites: + EnemyManager.spawn_enemy(_ring_pos(), hp * 2.5, EnemyManager.Type.ELITE, true) + enemies_alive += elites # 精英计入存活 + +func _spawn_boss(boss_type: int, wave_num: int) -> void: + # Boss 基础血量由 SettingsManager(balance.json 可调)提供 × 难度乘子 + var hp: float = SettingsManager.get_boss_base_hp(boss_type) * SettingsManager.boss_hp_mult() + _boss_max_hp = hp + # Boss 在玩家上方稍远处入场 + _boss_id = EnemyManager.spawn_enemy(Vector2(0.0, -ARENA_RADIUS * 0.8), hp, boss_type) + _boss_wave = true + _boss_phase = 1 + enemies_alive += 1 + EventBus.emit(EventID.BOSS_SPAWNED, { + "boss_id": _boss_id, "boss_type": boss_type, "wave": wave_num, "max_hp": hp}) + print("[WaveManager] Boss 入场!type=%d hp=%.0f (wave %d)" % [boss_type, hp, wave_num]) + +## Boss 阶段:HP 跨越阈值时进阶并召唤援军(简化 AI,game_design §4.5) +func _update_boss_phase() -> void: + if not EnemyManager.has_entity(_boss_id): + return + var frac: float = EnemyManager.get_hp_percent(_boss_id) + var target_phase: int = 1 + if frac <= 0.33: + target_phase = 3 + elif frac <= 0.66: + target_phase = 2 + if target_phase > _boss_phase: + _boss_phase = target_phase + _on_boss_phase_enter(target_phase) + +func _on_boss_phase_enter(phase: int) -> void: + # 进阶召唤一批援军施压(Phase 2/3) + var add_count: int = 4 + phase * 2 + var boss_pos: Vector2 = EnemyManager.get_pos_by_id(_boss_id) + for _i in add_count: + var off := Vector2(randf_range(-120, 120), randf_range(-120, 120)) + var aid: int = EnemyManager.spawn_enemy(boss_pos + off, 40.0, EnemyManager.Type.FAST) + if aid >= 0: + enemies_alive += 1 + EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "BATTLE", "to": "BOSS_PHASE_%d" % phase}) + print("[WaveManager] Boss 进入 Phase %d,召唤 %d 援军" % [phase, add_count]) + +func _ring_pos() -> Vector2: + var angle: float = randf() * TAU + var radius: float = randf_range(250.0, ARENA_RADIUS) + return Vector2(cos(angle) * radius, sin(angle) * radius) + +func _on_enemy_killed(payload: Dictionary) -> void: + if wave_state != WaveState.BATTLE: + return + var killed_id: int = int(payload.get("entity_id", -1)) + # Boss 波:Boss 死亡即通关本波(援军为附赠) + if _boss_wave and killed_id == _boss_id: + _complete_wave() + return + enemies_alive -= 1 + if enemies_alive <= 0 and not _boss_wave: + _complete_wave() + +func _complete_wave() -> void: + if wave_state != WaveState.BATTLE: + return + wave_state = WaveState.COMPLETE + _boss_wave = false + _boss_id = -1 + var cfg: Dictionary = _get_wave_config(current_wave) + PlayerStats.gain_gold(int(cfg["gold"])) + PlayerStats.gain_xp(int(cfg["xp"])) + var elapsed: float = Time.get_ticks_msec() / 1000.0 - _wave_start_time + EventBus.emit(EventID.WAVE_COMPLETE, {"wave": current_wave, "elapsed_sec": elapsed}) + print("[WaveManager] Wave %d 完成!奖励: %dG, %d XP" % [current_wave, int(cfg["gold"]), int(cfg["xp"])]) + +func _get_wave_config(wave_num: int) -> Dictionary: + var idx: int = clamp(wave_num - 1, 0, WAVE_CONFIG.size() - 1) + var base: Dictionary = WAVE_CONFIG[idx].duplicate() + # 超过 W20(Endless):数值膨胀 + if wave_num > MAX_WAVE: + var extra: int = wave_num - MAX_WAVE + base["count"] = int(base["count"]) + extra * 6 + base["hp"] = float(base["hp"]) * pow(1.12, extra) + base["gold"] = int(base["gold"]) + extra * 8 + base["xp"] = int(base["xp"]) + extra * 12 + base["boss"] = -1 # Endless 不重复终 Boss + return base + +func get_wave_count(wave_num: int) -> int: + return int(_get_wave_config(wave_num)["count"]) + +func is_boss_wave(wave_num: int) -> bool: + return int(_get_wave_config(wave_num).get("boss", -1)) >= 0 + +func reset() -> void: + current_wave = 0 + wave_state = WaveState.IDLE + enemies_alive = 0 + _boss_id = -1 + _boss_phase = 0 + _boss_wave = false diff --git a/scripts/autoloads/wave_manager.gd.uid b/scripts/autoloads/wave_manager.gd.uid new file mode 100644 index 0000000..300e56a --- /dev/null +++ b/scripts/autoloads/wave_manager.gd.uid @@ -0,0 +1 @@ +uid://dj1o00ehrwfwj diff --git a/scripts/autoloads/zone_manager.gd b/scripts/autoloads/zone_manager.gd new file mode 100644 index 0000000..afaf650 --- /dev/null +++ b/scripts/autoloads/zone_manager.gd @@ -0,0 +1,76 @@ +## ZoneManager — 地面效果区域管理(Autoload: ZoneManager) +## 权威来源:architecture_design.md §8 ADR-R5-N1(P6-N71 tick 用 while + -=) +## SoA PackedFloat32Array stride=8;基于 SpatialGrid 空间索引(禁用 Area2D) +## [0]=cx [1]=cy [2]=radius [3]=status_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id +## +## 注:本 GDScript 外壳承载 tick 热循环为 S5 功能验收路径(P-S5-04); +## C# 子节点 ZoneManagerCs 驱动的压力路径(P-S5-ZM-01,64 区×1000 敌 <1ms)留作后续优化, +## 与项目既有 C# 回退策略(R-08)一致。 +extends Node + +const ZONE_STRIDE: int = 8 +const MAX_ZONES: int = 64 + +var _zone_data: PackedFloat32Array = PackedFloat32Array() +var _active_zones: int = 0 + +func _ready() -> void: + _zone_data.resize(MAX_ZONES * ZONE_STRIDE) + +## 生成一个地面效果区域;满员时顶掉最旧(index 0 前移) +func spawn_zone(cx: float, cy: float, radius: float, status_id: int, + duration: float, tick_interval: float, owner_id: int) -> void: + if _active_zones >= MAX_ZONES: + # 顶掉最旧 Zone(index 0),整体前移(O(N),N≤64) + for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE): + _zone_data[j - ZONE_STRIDE] = _zone_data[j] + _active_zones -= 1 + var base: int = _active_zones * ZONE_STRIDE + _zone_data[base + 0] = cx + _zone_data[base + 1] = cy + _zone_data[base + 2] = radius + _zone_data[base + 3] = float(status_id) + _zone_data[base + 4] = duration + _zone_data[base + 5] = maxf(0.01, tick_interval) + _zone_data[base + 6] = 0.0 # tick_accum + _zone_data[base + 7] = float(owner_id) + _active_zones += 1 + +func _physics_process(delta: float) -> void: + if _active_zones == 0: + return + var i: int = 0 + while i < _active_zones: + var base: int = i * ZONE_STRIDE + _zone_data[base + 4] -= delta # duration 倒计时 + _zone_data[base + 6] += delta # tick_accum 累积 + + # 速率限制:外层 if 守卫 + 内层 while + -= tick_interval(P6-N71,保留余量精度) + # SpatialGrid.query_circle 仅在 if 守卫内查询一次,不在 while 内重复查询 + if _zone_data[base + 6] >= _zone_data[base + 5]: + var cx: float = _zone_data[base + 0] + var cy: float = _zone_data[base + 1] + var rad: float = _zone_data[base + 2] + var sid: int = int(_zone_data[base + 3]) + var oid: int = int(_zone_data[base + 7]) + var targets: PackedInt32Array = SpatialGrid.query_circle(Vector2(cx, cy), rad) + while _zone_data[base + 6] >= _zone_data[base + 5]: + _zone_data[base + 6] -= _zone_data[base + 5] # -= tick_interval 保留余量 + for t_id in targets: + StatusManager.apply(t_id, sid, 1, -1.0, oid) # stacks=1, duration=默认, owner + + if _zone_data[base + 4] <= 0.0: # duration 耗尽 → swap-and-pop 移除 + VFX 淡出 + VFXManager.play("zone_expire", Vector2(_zone_data[base + 0], _zone_data[base + 1])) + if i < _active_zones - 1: + var last: int = (_active_zones - 1) * ZONE_STRIDE + for k in ZONE_STRIDE: + _zone_data[base + k] = _zone_data[last + k] + _active_zones -= 1 + else: + i += 1 + +func get_active_count() -> int: + return _active_zones + +func reset() -> void: + _active_zones = 0 # 惰性清理:SoA 数据不清零,_active_zones 控制有效范围 diff --git a/scripts/autoloads/zone_manager.gd.uid b/scripts/autoloads/zone_manager.gd.uid new file mode 100644 index 0000000..b8c8bf4 --- /dev/null +++ b/scripts/autoloads/zone_manager.gd.uid @@ -0,0 +1 @@ +uid://dpk50hi3o0lg4 diff --git a/scripts/domain/combat/combat_manager.gd b/scripts/domain/combat/combat_manager.gd new file mode 100644 index 0000000..bb63b50 --- /dev/null +++ b/scripts/domain/combat/combat_manager.gd @@ -0,0 +1,288 @@ +## CombatManager — 游戏循环状态机(属于 CombatScene 节点) +## 状态:BATTLE → SETTLEMENT → SHOP → BATTLE… +## 权威来源:development_plan.md S2 CombatManager +extends Node + +enum GameState { + INIT = 0, + BATTLE = 1, + SETTLEMENT = 2, + SHOP = 3, + GAME_OVER = 4, +} + +var state: int = GameState.INIT + +## 玩家法杯法术列表(已安装到 Core 的 ID;"" = 空槽,供 MATRIX/CIRCUIT 位置化布局) +var _deck_spell_ids: Array = ["action_spark_bolt"] +var _equipped_core: CoreDefinition = null +var _equipped_compiled: CompiledDeck = null + +## 可切换的 Core 名册(商店内 Tab 循环切换;S5 高级 Core 实际可玩入口) +const _CORE_ROSTER: Array = ["wand_basic", "wand_fast", "wand_memory", "matrix_board", "circuit_fork"] +var _core_idx: int = 0 + +## 备牌区:拥有但未装入插槽的法术 id(背包 UI 在此与插槽间移动) +var _bench: Array = [] + +## 本局开始时刻(秒),用于排行榜评分的 elapsed_sec +var _run_start_time: float = 0.0 + +## 商店 UI 回调(由场景设置) +var _on_shop_closed_cb: Callable = Callable() + +func _ready() -> void: + EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete) + EventBus.subscribe(EventID.PLAYER_DIED, _on_player_died) + ProfileManager.set_wand_provider(self) # 法杖状态纳入 A/B 存档(ADR-A2) + +## 开始新局 +func start_game() -> void: + PlayerStats.reset_for_run() + WaveManager.reset() + BulletManager.reset() + EnemyManager.reset() + StatusManager.reset() + DpsTracker.reset() + VFXManager.reset() + ZoneManager.reset() + MinionManager.reset() + SpatialGrid.clear() + _core_idx = 0 + _equipped_core = WandPreset.make_core_by_id("wand_basic") + _deck_spell_ids = ["action_spark_bolt"] + _bench.clear() + _run_start_time = Time.get_ticks_msec() / 1000.0 + _rebuild_wand() + _transition_to_battle(1) + +## 继续现有进度恢复 +func resume_game() -> void: + var saved: Dictionary = ProfileManager.load_run() + if saved.is_empty(): + start_game() + return + ProfileManager.apply_run(saved) + _rebuild_wand() + var wave: int = max(1, WaveManager.current_wave) + _transition_to_battle(wave) + +func _transition_to_battle(wave_num: int) -> void: + var prev: String = GameState.keys()[state] if state < GameState.size() else "UNKNOWN" + state = GameState.BATTLE + SubPayloadRegistry.lock_for_battle() # P6-N10: 战斗期间锁定,防止竞争条件 + PlayerManager.equip_wand(_equipped_core, _equipped_compiled) + WaveManager.start_wave(wave_num) + EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": prev, "to": "BATTLE"}) + +func _on_wave_complete(payload: Dictionary) -> void: + var wave: int = int(payload.get("wave", WaveManager.current_wave)) + state = GameState.SETTLEMENT + ProfileManager.save_run() + EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "BATTLE", "to": "SETTLEMENT"}) + # 厂他延迟进入商店(等待结算动画, S2 直接进入) + call_deferred("_open_shop", wave) + +func _open_shop(wave: int) -> void: + state = GameState.SHOP + ShopManager.open_shop() + EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "SETTLEMENT", "to": "SHOP"}) + if not _on_shop_closed_cb.is_null(): + _on_shop_closed_cb.call(wave) + +## 外部调用:购买法术并安装到法杯 +## LINEAR:MODIFIER 插首个 ACTION 前、ACTION 追加尾部(Noita 风格) +## MATRIX/CIRCUIT:位置化——填入首个空槽("") 以保持拓扑布局 +func install_spell(spell: SpellNode) -> void: + if spell == null: + return + var max_slots: int = _equipped_core.slot_count if _equipped_core else 5 + if _is_positional_core(): + var placed: bool = false + for i in _deck_spell_ids.size(): + if String(_deck_spell_ids[i]) == "": + _deck_spell_ids[i] = spell.id + placed = true + break + if not placed: + if _deck_spell_ids.size() < max_slots: + _deck_spell_ids.append(spell.id) + else: + _bench.append(spell.id) # 满了进备牌区,玩家可在背包内重排 + elif _deck_spell_ids.size() >= max_slots: + _bench.append(spell.id) # 满了进备牌区 + elif spell.type == SpellNode.SpellType.MODIFIER: + var insert_pos: int = 0 + for i in _deck_spell_ids.size(): + var sn: SpellNode = SpellRegistry.get_spell(_deck_spell_ids[i]) + if sn and sn.type == SpellNode.SpellType.ACTION: + insert_pos = i + break + insert_pos = i + 1 + _deck_spell_ids.insert(insert_pos, spell.id) + else: + _deck_spell_ids.append(spell.id) + _rebuild_wand() + ProfileManager.mark_dirty() + +func _is_positional_core() -> bool: + return _equipped_core != null and _equipped_core.topology != CoreDefinition.Topology.LINEAR + +## 切换装备的 Core(商店内调用);载入该 Core 的演示默认 Deck 并重编译 +func switch_core() -> void: + _core_idx = (_core_idx + 1) % _CORE_ROSTER.size() + var cid: String = _CORE_ROSTER[_core_idx] + _equipped_core = _make_core_by_id(cid) + _deck_spell_ids = _default_deck_for(cid) + _rebuild_wand() + PlayerManager.equip_wand(_equipped_core, _equipped_compiled) # 即时换装 + ProfileManager.mark_dirty() + +func get_core_display() -> String: + return _equipped_core.display_name if _equipped_core else "—" + +func _make_core_by_id(cid: String) -> CoreDefinition: + return WandPreset.make_core_by_id(cid) # 数据驱动(cores.json)+ 硬编码回退 + +## 每个 Core 的演示默认 Deck("" = 空槽):让玩家切换即见到该拓扑机制 +func _default_deck_for(cid: String) -> Array: + match cid: + "wand_memory": return ["logic_every_n_shots", "action_spark_bolt"] + "matrix_board": return ["action_spark_bolt", "", "", "", "modifier_damage_plus", "", "", ""] + "circuit_fork": return ["", "action_spark_bolt", "action_fire_bolt"] + _: return ["action_spark_bolt"] + +# ── 背包 / 插槽编辑(inventory UI 调用)────────────────────────── +func get_slot_count() -> int: + return _equipped_core.slot_count if _equipped_core else 5 + +## 返回补齐到 slot_count 长度的插槽视图("" = 空槽) +func get_padded_deck() -> Array: + var n: int = get_slot_count() + var out: Array = _deck_spell_ids.duplicate() + while out.size() < n: + out.append("") + if out.size() > n: + out.resize(n) + return out + +func get_bench() -> Array: + return _bench + +## 写回插槽视图:LINEAR 压实去空槽,MATRIX/CIRCUIT 保留位置 +func _apply_padded(arr: Array) -> void: + if _is_positional_core(): + _deck_spell_ids = arr.duplicate() + else: + var compact: Array = [] + for s in arr: + if String(s) != "": + compact.append(s) + _deck_spell_ids = compact + _rebuild_wand() + PlayerManager.equip_wand(_equipped_core, _equipped_compiled) + ProfileManager.mark_dirty() + +## 交换两个插槽内容 +func swap_slots(a: int, b: int) -> void: + var d: Array = get_padded_deck() + if a < 0 or b < 0 or a >= d.size() or b >= d.size(): + return + var t = d[a]; d[a] = d[b]; d[b] = t + _apply_padded(d) + +## 清空插槽 → 内容移入备牌区 +func slot_to_bench(idx: int) -> void: + var d: Array = get_padded_deck() + if idx < 0 or idx >= d.size(): + return + var sid: String = String(d[idx]) + if sid == "": + return + _bench.append(sid) + d[idx] = "" + _apply_padded(d) + +## 备牌 → 插槽(插槽已占用则原内容退回备牌区) +func bench_to_slot(bench_idx: int, slot_idx: int) -> void: + var d: Array = get_padded_deck() + if bench_idx < 0 or bench_idx >= _bench.size() or slot_idx < 0 or slot_idx >= d.size(): + return + var sid: String = String(_bench[bench_idx]) + var existing: String = String(d[slot_idx]) + _bench.remove_at(bench_idx) + if existing != "": + _bench.append(existing) + d[slot_idx] = sid + _apply_padded(d) + +## 关闭商店进入下一波 +func close_shop_and_next_wave() -> void: + ShopManager.close_shop() + BulletManager.reset() + EnemyManager.reset() + ZoneManager.reset() + MinionManager.reset() + SpatialGrid.clear() + var next_wave: int = WaveManager.current_wave + 1 + _transition_to_battle(next_wave) + +func _on_player_died(_payload: Dictionary) -> void: + state = GameState.GAME_OVER + # 先采集本局战绩(reset 前),写入排行榜 + var wave_reached: int = max(1, WaveManager.current_wave) + var kills: int = PlayerManager.kill_count + var elapsed: int = int(Time.get_ticks_msec() / 1000.0 - _run_start_time) + var score: int = EndlessRecords.compute_score(wave_reached, elapsed) + EndlessRecords.add_record(wave_reached, elapsed, kills) + PlayerManager.reset() + BulletManager.reset() + EnemyManager.reset() + ZoneManager.reset() + MinionManager.reset() + SpatialGrid.clear() + ProfileManager.clear_run() + EventBus.emit(EventID.GAME_OVER, { + "wave": wave_reached, "elapsed_sec": elapsed, "kills": kills, "score": score}) + +func _rebuild_wand() -> void: + if _equipped_core == null: + _equipped_core = WandPreset.make_core_by_id("wand_basic") + var nodes: Array = [] + for sid in _deck_spell_ids: + if String(sid) == "": + nodes.append(null) # 空槽:保持 MATRIX/CIRCUIT 位置(LINEAR 扁平化会跳过 null) + continue + nodes.append(SpellRegistry.get_spell(sid)) # 纯数据驱动;未知 id → null(下游按位置跳过) + _equipped_compiled = SpellEvaluator.compile_wand(_equipped_core, nodes) + +# ── 法杖存档(ProfileManager A/B 槽,ADR-A2)───────────────── +func get_wand_save_data() -> Dictionary: + return { + "core_id": _equipped_core.id if _equipped_core else "wand_basic", + "deck": _deck_spell_ids.duplicate(), + "bench": _bench.duplicate(), + } + +func apply_wand_save_data(d: Dictionary) -> void: + var cid: String = String(d.get("core_id", "wand_basic")) + _equipped_core = _make_core_by_id(cid) + _core_idx = _CORE_ROSTER.find(cid) + if _core_idx < 0: + _core_idx = 0 + # JSON 反序列化为 Array;逐项转 String 保证类型一致 + _deck_spell_ids = [] + for s in d.get("deck", ["action_spark_bolt"]): + _deck_spell_ids.append(String(s)) + if _deck_spell_ids.is_empty(): + _deck_spell_ids = ["action_spark_bolt"] + _bench = [] + for s in d.get("bench", []): + _bench.append(String(s)) + _rebuild_wand() + +func get_deck_spell_ids() -> Array: + return _deck_spell_ids + +func get_equipped_compiled() -> CompiledDeck: + return _equipped_compiled diff --git a/scripts/domain/combat/combat_manager.gd.uid b/scripts/domain/combat/combat_manager.gd.uid new file mode 100644 index 0000000..816c31c --- /dev/null +++ b/scripts/domain/combat/combat_manager.gd.uid @@ -0,0 +1 @@ +uid://47u2amfg0hfg diff --git a/scripts/domain/player_manager.gd b/scripts/domain/player_manager.gd new file mode 100644 index 0000000..bbf515f --- /dev/null +++ b/scripts/domain/player_manager.gd @@ -0,0 +1,119 @@ +## PlayerManager — 玩家管理 Autoload +## S0:WASD 移动 + 位置查询 +## S1:自动施法 +## S4:停步蓄力 / 冲刺边沿检测(P6-N69、P6-N36) +extends Node + +const MOVE_SPEED: float = 200.0 +const MOVE_THRESHOLD_NORM: float = 0.1 +const CHARGE_TRIGGER_TIME: float = 1.5 + +var _position: Vector2 = Vector2.ZERO +var _velocity: Vector2 = Vector2.ZERO +var _player_node: Node2D = null + +var _equipped_core: CoreDefinition = null +var _equipped_compiled: CompiledDeck = null +var _cast_timer: float = 0.0 +var _cast_interval: float = 0.5 + +var _is_moving: bool = false +var _stationary_time: float = 0.0 +var _charge_triggered: bool = false +var _last_dash_time: float = -999.0 + +var kill_count: int = 0 + +func _ready() -> void: + EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) + +func _physics_process(delta: float) -> void: + _handle_movement(delta) + _handle_charge_state(delta) + _handle_auto_cast(delta) + +func _handle_movement(delta: float) -> void: + var dir := Vector2( + Input.get_axis("ui_left", "ui_right"), + Input.get_axis("ui_up", "ui_down") + ) + _is_moving = dir.length() > MOVE_THRESHOLD_NORM + if _is_moving: + dir = dir.normalized() + _velocity = dir * MOVE_SPEED + else: + _velocity = Vector2.ZERO + _position += _velocity * delta + if _player_node: + _player_node.global_position = _position + +func _handle_charge_state(delta: float) -> void: + if Input.is_action_just_pressed("ui_accept"): + _last_dash_time = Time.get_ticks_msec() / 1000.0 + EventBus.emit(EventID.DASH_TRIGGERED, { + "caster_id": 0, + "stationary_time": _stationary_time, + }) + _stationary_time = 0.0 + _charge_triggered = false + return + + if _is_moving: + if _stationary_time > 0.0: + _stationary_time = 0.0 + _charge_triggered = false + EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": 0, "progress": 0.0}) + else: + _stationary_time += delta + if _stationary_time >= 0.3: + var prog: float = clampf(_stationary_time / CHARGE_TRIGGER_TIME, 0.0, 1.0) + EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": 0, "progress": prog}) + if _stationary_time >= CHARGE_TRIGGER_TIME and not _charge_triggered: + _charge_triggered = true + EventBus.emit(EventID.CHARGE_FIRED, {"caster_id": 0}) + +func _handle_auto_cast(delta: float) -> void: + if _equipped_compiled == null or _equipped_compiled.is_empty(): + return + _cast_timer -= delta + if _cast_timer <= 0.0: + _cast_timer = _cast_interval + SpellEvaluator.execute_compiled(_equipped_compiled, 0, _position) + +func equip_wand(core: CoreDefinition, compiled: CompiledDeck) -> void: + _equipped_core = core + _equipped_compiled = compiled + _cast_interval = core.cast_interval if core else 0.5 + _cast_timer = 0.0 + +func get_position() -> Vector2: + return _position + +func get_velocity() -> Vector2: + return _velocity + +func get_stationary_time() -> float: + return _stationary_time + +func is_charge_triggered() -> bool: + return _charge_triggered + +func set_player_node(node: Node2D) -> void: + _player_node = node + if node: + _position = node.global_position + +func _on_enemy_killed(_payload: Dictionary) -> void: + kill_count += 1 + +func reset() -> void: + _position = Vector2.ZERO + _velocity = Vector2.ZERO + _player_node = null + _equipped_compiled = null + _equipped_core = null + _cast_timer = 0.0 + _is_moving = false + _stationary_time = 0.0 + _charge_triggered = false + kill_count = 0 diff --git a/scripts/domain/player_manager.gd.uid b/scripts/domain/player_manager.gd.uid new file mode 100644 index 0000000..d548429 --- /dev/null +++ b/scripts/domain/player_manager.gd.uid @@ -0,0 +1 @@ +uid://pqv4usxmsnqv diff --git a/scripts/domain/spell_system/cast_stats.gd b/scripts/domain/spell_system/cast_stats.gd new file mode 100644 index 0000000..0b99703 --- /dev/null +++ b/scripts/domain/spell_system/cast_stats.gd @@ -0,0 +1,23 @@ +## CastStats — 单次施法的可修改弹道参数负载 +## 由 MODIFIER 节点在执行间修改;每次 execute_compiled 开始时从 SpellContext 隶新 +extends RefCounted +class_name CastStats + +var damage_add: float = 0.0 # 加法伤害加成 +var damage_mult: float = 1.0 # 乘法伤害倍率 +var spread_count: int = 1 # 单 ACTION 同时发射数量(扩散) +var multicast_count: int = 0 # S3:额外可执行的 ACTION 数(0=仅执行1个) +var lifetime: float = 0.0 # 对默认 lifetime 的差値(<0 缩短) +var speed_mult: float = 1.0 # 弹速倍率 +var radius_mult: float = 1.0 # 弹体半径倍率 +var crit_chance: float = 0.0 # 暴击概率 + +func reset() -> void: + damage_add = 0.0 + damage_mult = 1.0 + spread_count = 1 + multicast_count = 0 + lifetime = 0.0 + speed_mult = 1.0 + radius_mult = 1.0 + crit_chance = 0.0 diff --git a/scripts/domain/spell_system/cast_stats.gd.uid b/scripts/domain/spell_system/cast_stats.gd.uid new file mode 100644 index 0000000..f9cca35 --- /dev/null +++ b/scripts/domain/spell_system/cast_stats.gd.uid @@ -0,0 +1 @@ +uid://c73iiht30kan3 diff --git a/scripts/domain/spell_system/compiled_deck.gd b/scripts/domain/spell_system/compiled_deck.gd new file mode 100644 index 0000000..c91a306 --- /dev/null +++ b/scripts/domain/spell_system/compiled_deck.gd @@ -0,0 +1,21 @@ +## CompiledDeck — 预编译产物,关卡期间只读 +## 权威来源:implementation_plan.md §2.2 +extends RefCounted +class_name CompiledDeck + +var nodes: Array = [] # Array[SpellNode] 拓扑扁平化 + SCOPE 虚节点插入 +var label_table: Dictionary = {} # { label_id: int → node_index: int }(LOGIC_LABEL 专用) +var sub_payload_ids: Array = [] # 本 Deck 预编译时注册到 SubPayloadRegistry 的所有 ID +var topology_type: int = 0 # 0=LINEAR 1=MATRIX 2=CIRCUIT +var feature_tags: int = 0 # Core 特性位掩码快照(CoreFeatureTag.*),供执行期判断 PERSISTENT_MEMORY 等 +var consumed_indices: Array = [] # 共鸣 consume_inputs 消费的 nodes 索引(P6-N29),运行时跳过 +var checksum: int = 0 # 插槽内容哈希判断是否需要重新编译 + +## 创建用于执行的 SpellDeck 实例(拷贝 nodes 并重置 cursor) +func make_runtime_deck() -> SpellDeck: + var deck := SpellDeck.new() + deck.setup(nodes.duplicate(), consumed_indices) + return deck + +func is_empty() -> bool: + return nodes.is_empty() diff --git a/scripts/domain/spell_system/compiled_deck.gd.uid b/scripts/domain/spell_system/compiled_deck.gd.uid new file mode 100644 index 0000000..76612ed --- /dev/null +++ b/scripts/domain/spell_system/compiled_deck.gd.uid @@ -0,0 +1 @@ +uid://ii4xb7qe2ok8 diff --git a/scripts/domain/spell_system/core_definition.gd b/scripts/domain/spell_system/core_definition.gd new file mode 100644 index 0000000..71ee011 --- /dev/null +++ b/scripts/domain/spell_system/core_definition.gd @@ -0,0 +1,28 @@ +## CoreDefinition — 法杖 Core 插槽定义 +## 权威来源:architecture_design.md §3.1, core_wand_design.md +extends Resource +class_name CoreDefinition + +enum Topology { + LINEAR = 0, + MATRIX = 1, + CIRCUIT = 2, +} + +@export var id: String = "" +@export var display_name: String = "" +@export var slot_count: int = 5 # LINEAR 局线插槽数 +var topology: int = Topology.LINEAR +@export var cpu_limit: int = 5 # cpu_limit * MAX_OPS_PER_CPU = 单次最大执行步数 +@export var cast_interval: float = 0.5 # 默认施法间隔(秒) + +## MATRIX 专用:行数 × 列数 +@export var grid_rows: int = 1 +@export var grid_cols: int = 5 + +## CIRCUIT 专用:有向边列表(P6-N42 权威格式 Array[Dictionary]:{ "from": int, "to": int }) +## _flatten_circuit() 从此处读取做 Kahn 拓扑排序;LINEAR/MATRIX 留空。也兼容 [from, to] 数组对。 +@export var edges: Array = [] + +## CoreFeatureTag 位掉:PERSISTENT_MEMORY=1, DUAL_STREAM=2, 等 +@export var feature_tags: int = 0 diff --git a/scripts/domain/spell_system/core_definition.gd.uid b/scripts/domain/spell_system/core_definition.gd.uid new file mode 100644 index 0000000..8b001f4 --- /dev/null +++ b/scripts/domain/spell_system/core_definition.gd.uid @@ -0,0 +1 @@ +uid://6q8o5ss8ntse diff --git a/scripts/domain/spell_system/projectile_def.gd b/scripts/domain/spell_system/projectile_def.gd new file mode 100644 index 0000000..1e9caf1 --- /dev/null +++ b/scripts/domain/spell_system/projectile_def.gd @@ -0,0 +1,39 @@ +## ProjectileDef — 弹道生成参数描述 +## ACTION 节点写入,SpellEvaluator 读取并调用 BulletManager.spawn_bullet +## 跨切片约束:reset() 必须重置 spawn_position(P6-N41) +extends RefCounted +class_name ProjectileDef + +var spawn_position: Vector2 = Vector2.ZERO # P6-N41: 必须在 reset() 中重置 +var direction: Vector2 = Vector2.RIGHT +var speed: float = 300.0 +var lifetime: float = 4.0 +var radius: float = 6.0 +var base_damage: float = 3.0 +var damage_mult: float = 1.0 +var damage_type: int = 0 # DamageType.PHYSICAL +var owner_id: int = 0 +var source_tags: int = 0 +var acceleration: float = 0.0 +var on_hit_payload_id: int = -1 # -1 = 无触发 +var pierce_remaining: int = 0 +var bounce_remaining: int = 0 + +func reset() -> void: + spawn_position = Vector2.ZERO # P6-N41 + direction = Vector2.RIGHT + speed = 300.0 + lifetime = 4.0 + radius = 6.0 + base_damage = 3.0 + damage_mult = 1.0 + damage_type = 0 + owner_id = 0 + source_tags = 0 + acceleration = 0.0 + on_hit_payload_id = -1 + pierce_remaining = 0 + bounce_remaining = 0 + +func get_velocity() -> Vector2: + return direction.normalized() * speed diff --git a/scripts/domain/spell_system/projectile_def.gd.uid b/scripts/domain/spell_system/projectile_def.gd.uid new file mode 100644 index 0000000..f6466ba --- /dev/null +++ b/scripts/domain/spell_system/projectile_def.gd.uid @@ -0,0 +1 @@ +uid://cql1wa8fgnhb0 diff --git a/scripts/domain/spell_system/spell_context.gd b/scripts/domain/spell_system/spell_context.gd new file mode 100644 index 0000000..6bfe0f2 --- /dev/null +++ b/scripts/domain/spell_system/spell_context.gd @@ -0,0 +1,44 @@ +## SpellContext — 法术执行上下文(对象池化) +## 权威来源:architecture_design.md §3.2, implementation_plan.md §2.2 +## registers 跨帧持久(LOGIC_EVERY_N_SHOTS 必须使用此处,禁用 CastState.registers) +extends RefCounted +class_name SpellContext + +var caster_id: int = 0 +var stats: CastStats = null # 本次施法的修改层 +## 跨帧持久寄存器(长度固定 4,P6-N48) +## R1-R4:供 LOGIC_EVERY_N_SHOTS 等指令跨帧计数 +var registers: PackedFloat32Array = PackedFloat32Array() + +func _init() -> void: + stats = CastStats.new() + registers.resize(4) + registers.fill(0.0) + +func reset() -> void: + caster_id = 0 + if stats: + stats.reset() + else: + stats = CastStats.new() + # registers 小心:仅当 Core 没有 PERSISTENT_MEMORY Feature 时才清零 + # 具体清空逻辑在 SpellContextPool.acquire_for_wand() 中处理 + +func clear_registers() -> void: + registers.fill(0.0) + + +## CastState — 单次 execute() 的临时工作状态(不入池,每次 new) +## 注意区分:CastState.registers 每次 execute() 开始时重置,不跨调用保留 +## LOGIC_EVERY_N_SHOTS 「必须」使用 SpellContext.registers,严禁使用 CastState.registers +class CastState: + var trigger_depth: int = 0 + var registers: PackedFloat32Array = PackedFloat32Array() + + func _init() -> void: + registers.resize(4) + registers.fill(0.0) + + func reset() -> void: + trigger_depth = 0 + registers.fill(0.0) diff --git a/scripts/domain/spell_system/spell_context.gd.uid b/scripts/domain/spell_system/spell_context.gd.uid new file mode 100644 index 0000000..f4ee99d --- /dev/null +++ b/scripts/domain/spell_system/spell_context.gd.uid @@ -0,0 +1 @@ +uid://ud1ynycvopp4 diff --git a/scripts/domain/spell_system/spell_deck.gd b/scripts/domain/spell_system/spell_deck.gd new file mode 100644 index 0000000..92f84f7 --- /dev/null +++ b/scripts/domain/spell_system/spell_deck.gd @@ -0,0 +1,74 @@ +## SpellDeck — 运行时执行游标 +## 权威来源:implementation_plan.md §2.2 +extends RefCounted +class_name SpellDeck + +var _nodes: Array = [] # Array[SpellNode] +var _cursor: int = 0 +var _consumed: PackedByteArray = PackedByteArray() # 消费掩码(共鸣 consume_inputs,P6-N29);1=跳过 + +func setup(nodes: Array, consumed_indices: Array = []) -> void: + _nodes = nodes + _cursor = 0 + _consumed = PackedByteArray() + _consumed.resize(_nodes.size()) # 全 0 + for idx in consumed_indices: + if idx >= 0 and idx < _consumed.size(): + _consumed[idx] = int(1) + +func has_next() -> bool: + var c: int = _cursor + while c < _nodes.size() and c < _consumed.size() and _consumed[c] != 0: + c += 1 + return c < _nodes.size() + +func pop() -> SpellNode: + while _cursor < _nodes.size() and _cursor < _consumed.size() and _consumed[_cursor] != 0: + _cursor += 1 # 跳过被共鸣消费的槽位 + if _cursor >= _nodes.size(): + return null + var node: SpellNode = _nodes[_cursor] + _cursor += 1 + return node + +func peek() -> SpellNode: + if _cursor >= _nodes.size(): + return null + return _nodes[_cursor] + +func reset_cursor() -> void: + _cursor = 0 + +## 跳过直到遇到 SCOPE_CLOSE(深度计数器处理嵌套) +func skip_until_scope_end() -> void: + var depth := 1 + while _cursor < _nodes.size(): + var n: SpellNode = _nodes[_cursor] + _cursor += 1 + if n.type == SpellNode.SpellType.SCOPE_CLOSE: + depth -= 1 + if depth <= 0: + return + elif n.type == SpellNode.SpellType.TRIGGER or n.type == SpellNode.SpellType.LOGIC: + depth += 1 + +## 将游标到下一个 SCOPE_CLOSE 之间的节点列表取出(用于打包 SubPayload) +func consume_until_scope_end() -> Array: + var result: Array = [] + var depth := 1 + while _cursor < _nodes.size(): + var n: SpellNode = _nodes[_cursor] + _cursor += 1 + if n.type == SpellNode.SpellType.SCOPE_CLOSE: + depth -= 1 + if depth <= 0: + break + result.append(n) + else: + if n.type == SpellNode.SpellType.TRIGGER or n.type == SpellNode.SpellType.LOGIC: + depth += 1 + result.append(n) + return result + +func get_remaining_count() -> int: + return _nodes.size() - _cursor diff --git a/scripts/domain/spell_system/spell_deck.gd.uid b/scripts/domain/spell_system/spell_deck.gd.uid new file mode 100644 index 0000000..3ae556b --- /dev/null +++ b/scripts/domain/spell_system/spell_deck.gd.uid @@ -0,0 +1 @@ +uid://imeqchdnn158 diff --git a/scripts/domain/spell_system/spell_evaluator.gd b/scripts/domain/spell_system/spell_evaluator.gd new file mode 100644 index 0000000..3befeea --- /dev/null +++ b/scripts/domain/spell_system/spell_evaluator.gd @@ -0,0 +1,619 @@ +## SpellEvaluator — 法术虚拟机(Autoload: SpellEvaluator) +## S1:compile_wand(LINEAR)+execute_compiled(ACTION) +## S2:MODIFIER 分支 +## S3:_flatten_linear 处理 TRIGGER、execute_sub、multicast_count、actions_remaining +## S5:LOGIC 分支(条件门 + LOOP)、跨帧寄存器持久化(PERSISTENT_MEMORY Core) +extends Node + +const MAX_OPS_PER_CPU: int = 40 +const MAX_TRIGGER_DEPTH: int = 3 + +## LOGIC 求值返回信号 +const _LOGIC_CONTINUE: int = 0 # 条件通过 / 无副作用 → 继续执行后续节点 +const _LOGIC_SKIP_REST: int = 1 # 条件不成立 → 跳过本次施法后续全部节点 + +## 跨帧持久寄存器上下文:caster_id → SpellContext(不入池,registers 跨施法保留) +## 仅 Core 带 CoreFeatureTag.PERSISTENT_MEMORY 时使用;compile_wand 时清空(换杖即重置记忆) +var _persistent_ctx: Dictionary = {} + +## 共鸣配方表(§3.4.C):纯数据驱动,唯一来源 res://data/resonance.json +## 字段:pattern[2]、match("adjacent"/"anywhere_in_deck")、result_spell_id、consume_inputs +const RESONANCE_JSON: String = "res://data/resonance.json" +var _resonance_recipes: Array = [] + +func _ready() -> void: + if FileAccess.file_exists(RESONANCE_JSON): + var d = JSON.parse_string(FileAccess.get_file_as_string(RESONANCE_JSON)) + if d is Array: + _resonance_recipes = d + else: + push_error("SpellEvaluator: resonance.json 格式错误(应为数组)") + else: + push_error("SpellEvaluator: 缺失 res://data/resonance.json(共鸣系统无配方)") + +## compile_wand(core, raw_nodes) → CompiledDeck +## core 在前(P6-N70) +func compile_wand(core: CoreDefinition, raw_nodes: Array) -> CompiledDeck: + SubPayloadRegistry.clear_for_compile() + _persistent_ctx.clear() # 换杖/重编译即重置跨帧寄存器记忆 + # P6-N3 修复:若 Core 带 PERSISTENT_MEMORY,在非热路径的 compile_wand 阶段预分配 SpellContext, + # 避免首次施法时在 _physics_process 热路径内 SpellContext.new() + if (core.feature_tags & CoreFeatureTag.PERSISTENT_MEMORY) != 0: + var prewarm := SpellContext.new() + prewarm.caster_id = 0 # 玩家 caster_id,下次 acquire 时覆写 + _persistent_ctx[0] = prewarm # 预热 id=0(玩家唯一 id) + var deck := CompiledDeck.new() + deck.topology_type = core.topology + deck.feature_tags = core.feature_tags # 执行期据此判断 PERSISTENT_MEMORY 等 + match core.topology: + CoreDefinition.Topology.CIRCUIT: + _flatten_circuit(raw_nodes, core, deck) + CoreDefinition.Topology.MATRIX: + _flatten_matrix(raw_nodes, core, deck) + _: + _flatten_linear(raw_nodes, deck) + # 拓扑扁平化后做共鸣模式匹配(§3.4.C),可能注入结果节点并标记 consume 掩码 + _check_resonance(deck) + deck.checksum = _calc_checksum(core, deck.nodes) + # 战斗开始时 CombatManager 调用 lock_for_battle() + return deck + +## 扣除算法:处理 MODIFIER / TRIGGER / ACTION +## TRIGGER 节点消耗后续节点作为子荷载 +func _flatten_linear(raw_nodes: Array, deck: CompiledDeck) -> void: + var i: int = 0 + while i < raw_nodes.size(): + var n: SpellNode = raw_nodes[i] + if not (n is SpellNode): + i += 1 + continue + if n.type == SpellNode.SpellType.TRIGGER: + # 消耗 i+1 起的节点为子荷载(ACTION 节点也属于子荷载范围) + # 仅遇到下一个同级 TRIGGER 时才停止(不跨 TRIGGER 作用域) + var sub_nodes: Array = [] + var j: int = i + 1 + while j < raw_nodes.size(): + var sn: SpellNode = raw_nodes[j] + if sn.type == SpellNode.SpellType.TRIGGER: + break # 下一个 TRIGGER 是独立作用域 + sub_nodes.append(sn) # MODIFIER / ACTION 均归入子荷载 + j += 1 + # 注册子荷载 + var payload_id: int = SubPayloadRegistry.register(sub_nodes) + # 克隆 TRIGGER 节点并注入 sub_payload_id + var trigger_clone: SpellNode = SpellNode.new() + trigger_clone.id = n.id + trigger_clone.type = n.type + trigger_clone.display_name = n.display_name + trigger_clone.description = n.description + trigger_clone.meta = n.meta.duplicate() + trigger_clone.meta["sub_payload_id"] = payload_id + deck.nodes.append(trigger_clone) + deck.sub_payload_ids.append(payload_id) + i = j # 跳过已消耗节点 + else: + deck.nodes.append(n) + i += 1 + +func _calc_checksum(core: CoreDefinition, nodes: Array) -> int: + var h: int = core.topology ^ nodes.size() ^ hash(core.id) + for i in nodes.size(): + h = h * 31 ^ hash(nodes[i].id) + return h + +# ── CIRCUIT 拓扑扁平化(architecture_design.md §3.4.A,P6-N57/63/64/66)───── +## 读 core.edges,对有向图做 Kahn 拓扑排序;分叉点(出度>1)展开为 SubPayload + LOGIC_FORK +## edges 为空 → 降级 LINEAR。结果写入 deck.nodes;分支 payload id 记入 deck.sub_payload_ids +func _flatten_circuit(raw_nodes: Array, core: CoreDefinition, deck: CompiledDeck) -> void: + var edges: Array = core.edges + if edges.is_empty(): + push_warning("_flatten_circuit: core.edges 为空,降级 LINEAR(Core=%s)" % core.id) + _flatten_linear(raw_nodes, deck) + return + var n: int = core.slot_count + # 1. 构建邻接表 + 入度 + var in_degree: Array = [] + var adj: Array = [] + in_degree.resize(n) + adj.resize(n) + for i in n: + in_degree[i] = 0 + adj[i] = [] + for e in edges: + var ep: Vector2i = _edge_endpoints(e) + if ep.x < 0 or ep.x >= n or ep.y < 0 or ep.y >= n: + push_warning("_flatten_circuit: 非法边 %s(slot_count=%d)跳过" % [str(e), n]) + continue + adj[ep.x].append(ep.y) + in_degree[ep.y] += 1 + # P6-N63:Kahn 前保存原始入度快照,供 _collect_branch_path 判断汇聚点 + var orig_in_degree: Array = in_degree.duplicate() + # 2. Kahn BFS 拓扑排序 + var queue: Array = [] + for i in n: + if in_degree[i] == 0: + queue.append(i) + var topo_order: Array = [] + while not queue.is_empty(): + var cur: int = queue.pop_front() + topo_order.append(cur) + for nxt in adj[cur]: + in_degree[nxt] -= 1 + if in_degree[nxt] == 0: + queue.append(nxt) + if topo_order.size() != n: + push_error("_flatten_circuit: 检测到环路,无法线性化!Core=%s" % core.id) + return # deck.nodes 保持空,该法杖无法施法但不崩溃 + # 3. 按拓扑序输出;P6-N64:in_branch_payload 防止分支节点被主链重复执行 + var in_branch_payload: Dictionary = {} + for slot_idx in topo_order: + if in_branch_payload.has(slot_idx): + continue # 已被某分支 SubPayload 收纳 + var node: SpellNode = raw_nodes[slot_idx] if slot_idx < raw_nodes.size() else null + if adj[slot_idx].size() > 1: + # 分叉点(先于 null 守卫判断:分叉槽通常为空 splitter) + if node != null: + deck.nodes.append(node) # 分叉槽自身节点先执行(通常空,允许 MODIFIER/ACTION) + var branch_ids: Array = [] + for neighbor_idx in adj[slot_idx]: + var bp: Array = _collect_branch_path(neighbor_idx, adj, orig_in_degree, raw_nodes, in_branch_payload) + if not bp.is_empty(): + var pid: int = SubPayloadRegistry.register(bp) + branch_ids.append(pid) + deck.sub_payload_ids.append(pid) + if not branch_ids.is_empty(): + deck.nodes.append(_make_fork_node(branch_ids)) + elif node != null: + deck.nodes.append(node) + # else:空的非分叉槽,跳过 + +## 从 start_idx 沿单出边收集分支节点序列(P6-N66 嵌套分叉递归) +## 终止:叶节点(出度0)/ 汇聚点(orig_in_degree>1,交主链处理)/ 嵌套分叉(出度>1,递归+插 LOGIC_FORK) +func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array, + raw_nodes: Array, in_branch_payload: Dictionary) -> Array: + var path: Array = [] + var cur: int = start_idx + var visited: Dictionary = {} + while cur >= 0 and not visited.has(cur): + visited[cur] = true + in_branch_payload[cur] = true # 登记已纳入 SubPayload,主链循环跳过 + if cur < raw_nodes.size() and raw_nodes[cur] != null: + path.append(raw_nodes[cur]) + if adj[cur].size() > 1: + # 嵌套分叉:每条子出边递归收集并注册,插入嵌套 LOGIC_FORK 后本路径结束 + var nested_ids: Array = [] + for nxt_idx in adj[cur]: + var np: Array = _collect_branch_path(nxt_idx, adj, orig_in_degree, raw_nodes, in_branch_payload) + if not np.is_empty(): + nested_ids.append(SubPayloadRegistry.register(np)) + if not nested_ids.is_empty(): + path.append(_make_fork_node(nested_ids)) + break + elif adj[cur].size() == 1: + var nxt: int = adj[cur][0] + if orig_in_degree[nxt] <= 1: + cur = nxt # 单入边,继续延伸 + else: + break # 汇聚点(多入边),交主链处理 + else: + break # 叶节点 + return path + +func _make_fork_node(branch_ids: Array) -> SpellNode: + var fork := SpellNode.new() + fork.type = SpellNode.SpellType.LOGIC + fork.id = "LOGIC_FORK" + fork.meta = {"fork_branch_ids": branch_ids} + return fork + +## 解析单条边为 (from, to);兼容 {"from","to"} 字典(P6-N42 权威)与 [from, to] 数组对 +func _edge_endpoints(e) -> Vector2i: + if e is Dictionary: + return Vector2i(int(e.get("from", -1)), int(e.get("to", -1))) + elif e is Array and e.size() >= 2: + return Vector2i(int(e[0]), int(e[1])) + return Vector2i(-1, -1) + +# ── MATRIX 拓扑扁平化(architecture_design.md §3.4.A,邻接加成 P6-N13/P6-N20)──── +## 仅 Row A(前 grid_cols 槽)进入执行序列;Row B 仅提供邻接加成,不独立执行(P6-N20) +func _flatten_matrix(raw_nodes: Array, core: CoreDefinition, deck: CompiledDeck) -> void: + var cols: int = max(1, core.grid_cols) + for i in cols: + var adj_idx: int = i + cols # Row B 中与 slot[i] 竖向对齐的槽 + var row_a: SpellNode = raw_nodes[i] if i < raw_nodes.size() else null + var row_b: SpellNode = raw_nodes[adj_idx] if adj_idx < raw_nodes.size() else null + if row_a == null: + continue # 空槽不传递邻接(P6-N13) + if row_b != null and _has_adjacency_bonus(row_a, row_b): + deck.nodes.append(_make_adjacency_mod(row_a, row_b)) # 注入隐式 MODIFIER(在 Row A 前) + deck.nodes.append(row_a) # 仅追加 Row A 节点 + +## 邻接加成是否成立(P6-N13;LOGIC / 空槽不参与) +func _has_adjacency_bonus(a: SpellNode, b: SpellNode) -> bool: + if a == null or b == null: + return false + if a.type == SpellNode.SpellType.LOGIC or b.type == SpellNode.SpellType.LOGIC: + return false + var A := SpellNode.SpellType + if a.type == A.ACTION and b.type == A.ACTION: + return a.id == b.id # 同 ID ACTION 对齐 → 强化同类弹 + if a.type == A.ACTION and b.type == A.MODIFIER: + return true # 增幅器加倍 + if a.type == A.MODIFIER and b.type == A.MODIFIER: + return a.id == b.id # 同 ID MODIFIER 对齐 → 共鸣修正 + return false + +## 生成隐式邻接 MODIFIER 节点(P6-N13 效果表) +func _make_adjacency_mod(a: SpellNode, b: SpellNode) -> SpellNode: + var m := SpellNode.new() + m.type = SpellNode.SpellType.MODIFIER + m.id = "implicit_adjacency" + m.display_name = "邻接加成" + var A := SpellNode.SpellType + if a.type == A.ACTION and b.type == A.ACTION: + m.meta = {"damage_mult": 1.5} # 同类弹 ×1.5 + elif a.type == A.MODIFIER and b.type == A.MODIFIER: + # 同 ID MODIFIER 对齐 → 数值 ×2(平直叠加):复制 b 的可加字段 + m.meta = b.meta.duplicate() + else: + # ACTION + MODIFIER:注入 B 的修正效果(作用于对齐的 Row A 动作) + m.meta = b.meta.duplicate() + return m + +# ── 共鸣系统(§3.4.C,_consumed 掩码 P6-N29)───────────────────── +## 在扁平化后的 deck.nodes 上做模式匹配;命中 adjacent 配方时注入结果节点并标记 consume +func _check_resonance(deck: CompiledDeck) -> void: + if _resonance_recipes.is_empty() or deck.nodes.is_empty(): + return + var nodes: Array = deck.nodes + var consumed: PackedByteArray = PackedByteArray() + consumed.resize(nodes.size()) + for recipe in _resonance_recipes: + if String(recipe.get("match", "adjacent")) != "adjacent": + continue # anywhere_in_deck 留 stub(仅传说配方) + var pattern: Array = recipe.get("pattern", []) + if pattern.size() < 2: + continue + var i: int = 0 + while i < nodes.size(): + if consumed[i] != 0 or not _node_has_tag(nodes[i], pattern[0]): + i += 1 + continue + # 向右扫描 i+1..i+2(跳过 MODIFIER),寻找 pattern[1] + var hit_j: int = -1 + for j in range(i + 1, min(i + 3, nodes.size())): + if consumed[j] != 0: + continue + if nodes[j].type == SpellNode.SpellType.MODIFIER: + continue # MODIFIER 不参与匹配,但不中断扫描 + if _node_has_tag(nodes[j], pattern[1]): + hit_j = j + break # 遇到非 MODIFIER 非目标节点即停(不跨 ACTION/TRIGGER) + if hit_j < 0: + i += 1 + continue + var result: SpellNode = SpellRegistry.get_spell(String(recipe.get("result_spell_id", ""))) + if result == null: + i += 1 + continue + # 在 i 位置插入共鸣结果,同步扩展 consumed + nodes.insert(i, result) + consumed.insert(i, 0) + if bool(recipe.get("consume_inputs", false)): + consumed[i + 1] = int(1) # 原 pattern[0](现 i+1) + consumed[hit_j + 1] = int(1) # 原 pattern[1](现 hit_j+1) + i += 2 # 跳过刚插入的结果与已消费的 pattern[0] + # 收集 consume 索引供运行时 SpellDeck 跳过 + for k in consumed.size(): + if consumed[k] != 0: + deck.consumed_indices.append(k) + +## 标签匹配:tag:xxx 查 element_tags;否则按 id 精确匹配 +func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool: + if node == null: + return false + if tag_pattern.begins_with("tag:"): + return tag_pattern in node.element_tags + return node.id == tag_pattern + +## execute_compiled(compiled, caster_id, spawn_pos) +## 每次施法可执行的 ACTION 数量 = 1 + multicast_count +func execute_compiled(compiled: CompiledDeck, caster_id: int, spawn_pos: Vector2) -> void: + if compiled == null or compiled.is_empty(): + return + var persistent: bool = (compiled.feature_tags & CoreFeatureTag.PERSISTENT_MEMORY) != 0 + var ctx: SpellContext = _acquire_ctx(caster_id, persistent) + var deck: SpellDeck = compiled.make_runtime_deck() + var ops_count: int = 0 + var max_ops: int = MAX_OPS_PER_CPU * 5 + var actions_remaining: int = 1 # 初始为 1,由 multicast_count 加成 + while deck.has_next() and ops_count < max_ops: + ops_count += 1 + var node: SpellNode = deck.pop() + match node.type: + SpellNode.SpellType.ACTION: + _push_projectile(node, ctx, spawn_pos, -1) + actions_remaining -= 1 + if actions_remaining <= 0: + break + SpellNode.SpellType.MODIFIER: + _apply_modifier(node, ctx) + # multicast_count 已在 _apply_modifier 内更新 + actions_remaining = 1 + ctx.stats.multicast_count + SpellNode.SpellType.TRIGGER: + _push_trigger(node, ctx, spawn_pos) + actions_remaining -= 1 + if actions_remaining <= 0: + break + SpellNode.SpellType.LOGIC: + if node.meta.has("fork_branch_ids"): + # CIRCUIT 分叉:各分支在施法点立即并行执行 + for bid in node.meta["fork_branch_ids"]: + _run_branch_payload(int(bid), ctx, spawn_pos) + elif String(node.meta.get("logic_op", "")) == "loop": + ops_count = _run_logic_loop(node, ctx, spawn_pos, deck, ops_count, max_ops) + break # LOOP 已消耗剩余节点 + elif _eval_logic(node, ctx, spawn_pos) == _LOGIC_SKIP_REST: + break # 条件不成立 → 跳过后续法术 + if ops_count >= max_ops: + push_warning("SpellEvaluator: MAX_OPS reached (caster=%d)" % caster_id) + _release_ctx(ctx, persistent) + +## execute_sub — 子荷载执行(MAX_TRIGGER_DEPTH 限制) +## depth 表示当前嵌套深度(0=顶层子荷载) +func execute_sub(payload_id: int, hit_pos: Vector2, owner_id: int, depth: int) -> void: + if depth >= MAX_TRIGGER_DEPTH: + EventBus.emit(EventID.SPELL_DEPTH_EXCEEDED, {"owner_id": owner_id, "depth": depth}) + return + var nodes: Array = SubPayloadRegistry.get_nodes(payload_id) + if nodes.is_empty(): + return + var ctx: SpellContext = SpellContextPool.acquire(owner_id) # P-S3-04: 从池取用 + var ops: int = 0 + var actions_remaining: int = 1 + for node in nodes: + ops += 1 + if ops > MAX_OPS_PER_CPU * 2: + break + match node.type: + SpellNode.SpellType.ACTION: + _push_projectile(node, ctx, hit_pos, depth) + actions_remaining -= 1 + if actions_remaining <= 0: + break + SpellNode.SpellType.MODIFIER: + _apply_modifier(node, ctx) + actions_remaining = 1 + ctx.stats.multicast_count + SpellNode.SpellType.TRIGGER: + _push_trigger(node, ctx, hit_pos, depth) + actions_remaining -= 1 + if actions_remaining <= 0: + break + SpellContextPool.release(ctx) + +# ── 内部方法 ────────────────────────────────────────────── + +func _push_projectile(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, trigger_depth: int) -> void: + # ACTION 分派:zone(地面效果)/ summon(召唤物)/ 默认弹道 + match String(node.meta.get("action_kind", "")): + "zone": + _spawn_zone_action(node, ctx, spawn_pos) + return + "summon": + _spawn_minion_action(node, ctx, spawn_pos) + return + var meta: Dictionary = node.meta + var speed: float = float(meta.get("speed", 300.0)) + var lifetime: float = float(meta.get("lifetime", 4.0)) + var radius: float = float(meta.get("radius", 6.0)) + var base_dmg: float = float(meta.get("base_damage", 3.0)) + var dtype: int = int(meta.get("damage_type", 0)) + var spread: int = max(1, ctx.stats.spread_count) + var aim_dir: Vector2 = _get_aim_direction(spawn_pos) + for i in spread: + var angle_offset: float = 0.0 + if spread > 1: + angle_offset = (float(i) / float(spread - 1) - 0.5) * 0.5 + var vel: Vector2 = aim_dir.rotated(angle_offset) * speed * ctx.stats.speed_mult + var actual_dmg: float = (base_dmg + ctx.stats.damage_add) * ctx.stats.damage_mult + var actual_r: float = radius * ctx.stats.radius_mult + var pierce: int = int(meta.get("pierce", 0)) + var cold: Dictionary = {} + if pierce > 0: + cold["pierce_remaining"] = pierce + # S3: 将 trigger_depth 写入冷数据供子弹命中时使用 + if trigger_depth >= 0: + cold["trigger_depth"] = trigger_depth + if meta.has("apply_status_id"): + cold["apply_status_id"] = int(meta["apply_status_id"]) + if meta.get("apply_combo_mark", false): + cold["apply_combo_mark"] = true + BulletManager.spawn_bullet( + spawn_pos, vel, + lifetime + ctx.stats.lifetime, + actual_r, actual_dmg, 1.0, dtype, ctx.caster_id, + 0, 0.0, cold + ) + +## ACTION(zone):在施法点生成地面效果区域(action_poison_pool 等) +func _spawn_zone_action(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> void: + var meta: Dictionary = node.meta + var radius: float = float(meta.get("radius", 200.0)) * ctx.stats.radius_mult + ZoneManager.spawn_zone( + spawn_pos.x, spawn_pos.y, radius, + int(meta.get("status_id", StatusID.POISON)), + float(meta.get("duration", 5.0)) + ctx.stats.lifetime, + float(meta.get("tick_interval", 1.0)), + ctx.caster_id) + +## ACTION(summon):在施法点召唤一个炮台(action_summon_turret 等) +func _spawn_minion_action(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> void: + var meta: Dictionary = node.meta + var def: Dictionary = { + "lifetime": float(meta.get("lifetime", 20.0)), + "range": float(meta.get("range", 350.0)), + "fire_interval": float(meta.get("fire_interval", 0.8)), + "damage": float(meta.get("base_damage", 4.0)) + ctx.stats.damage_add, + "bullet_speed": float(meta.get("speed", 360.0)), + "damage_type": int(meta.get("damage_type", 0)), + } + MinionManager.spawn_minion(def, ctx.caster_id, spawn_pos) + +## TRIGGER 节点:发射子弹+子荷载参数 +func _push_trigger(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, trigger_depth: int = 0) -> void: + var payload_id: int = int(node.meta.get("sub_payload_id", -1)) + if payload_id < 0 or not SubPayloadRegistry.has_payload(payload_id): + return + var meta: Dictionary = node.meta + var speed: float = float(meta.get("speed", 250.0)) + var lifetime: float = float(meta.get("lifetime", 4.0)) + var radius: float = float(meta.get("radius", 6.0)) + var base_dmg: float = float(meta.get("base_damage", 3.0)) + var dtype: int = int(meta.get("damage_type", 0)) + var aim_dir: Vector2 = _get_aim_direction(spawn_pos) + var vel: Vector2 = aim_dir * speed + var actual_dmg: float = (base_dmg + ctx.stats.damage_add) * ctx.stats.damage_mult + var actual_r: float = radius * ctx.stats.radius_mult + var cold: Dictionary = { + "on_hit_payload_id": payload_id, + "trigger_parent_depth": trigger_depth, + } + BulletManager.spawn_bullet( + spawn_pos, vel, + lifetime, actual_r, actual_dmg, 1.0, dtype, ctx.caster_id, + 0, 0.0, cold + ) + +## MODIFIER 分支 +func _apply_modifier(node: SpellNode, ctx: SpellContext) -> void: + var meta: Dictionary = node.meta + if meta.has("damage_add"): + ctx.stats.damage_add += float(meta["damage_add"]) + if meta.has("damage_mult"): + ctx.stats.damage_mult *= float(meta["damage_mult"]) + if meta.has("spread_add"): + ctx.stats.spread_count += int(meta["spread_add"]) + if meta.has("speed_mult"): + ctx.stats.speed_mult *= float(meta["speed_mult"]) + if meta.has("lifetime_add"): + ctx.stats.lifetime += float(meta["lifetime_add"]) + if meta.has("multicast"): + ctx.stats.multicast_count += int(meta["multicast"]) + +## ── 上下文获取(区分跨帧持久 / 池化)───────────────────────── +func _acquire_ctx(caster_id: int, persistent: bool) -> SpellContext: + if persistent: + var ctx: SpellContext = _persistent_ctx.get(caster_id, null) + if ctx == null: + ctx = SpellContext.new() + _persistent_ctx[caster_id] = ctx + ctx.reset() # 重置 stats,registers 保留(reset() 不触碰 registers) + ctx.caster_id = caster_id + return ctx + return SpellContextPool.acquire(caster_id, false) + +func _release_ctx(ctx: SpellContext, persistent: bool) -> void: + if not persistent: + SpellContextPool.release(ctx) + # 持久上下文留在 _persistent_ctx 中,registers 跨施法保留 + +## ── LOGIC 条件门求值 ──────────────────────────────────────── +## 返回 _LOGIC_CONTINUE(执行后续)或 _LOGIC_SKIP_REST(跳过后续) +## EVERY_N_SHOTS 读写 ctx.registers(P6-N37);持久化由 PERSISTENT_MEMORY Core 保证 +func _eval_logic(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> int: + var meta: Dictionary = node.meta + var op: String = String(meta.get("logic_op", "")) + match op: + "every_n_shots": + var n: int = max(1, int(meta.get("n", 3))) + var reg: int = clampi(int(meta.get("reg", 0)), 0, 3) + var count: int = int(ctx.registers[reg]) + 1 + ctx.registers[reg] = float(count) + return _LOGIC_CONTINUE if (count % n == 0) else _LOGIC_SKIP_REST + "if_hp_below": + var threshold: float = float(meta.get("threshold", 0.5)) + var frac: float = PlayerStats.hp / maxf(1.0, PlayerStats.hp_max) + return _LOGIC_CONTINUE if (frac < threshold) else _LOGIC_SKIP_REST + "if_enemy_nearby": + # 用 SpatialGrid 查询范围内是否有敌方实体(plan: LOGIC_IF_ENEMY_NEARBY) + # 注意:query_circle 为格子粒度,略宽于精确圆,作为条件门可接受 + var rng: float = float(meta.get("range", 200.0)) + var near: bool = SpatialGrid.query_circle(spawn_pos, rng).size() > 0 + return _LOGIC_CONTINUE if near else _LOGIC_SKIP_REST + _: + push_warning("SpellEvaluator: 未知 logic_op '%s'" % op) + return _LOGIC_CONTINUE + +## ── LOGIC_LOOP:将后续剩余节点重复执行 count 次 ────────────── +## 简化实现:LOOP 体内不再处理嵌套 LOGIC(顺延后续迭代);尊重 ops 预算 +## 返回更新后的 ops_count +func _run_logic_loop(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, + deck: SpellDeck, ops_count: int, max_ops: int) -> int: + var count: int = clampi(int(node.meta.get("count", 2)), 1, 16) + var body: Array = [] + while deck.has_next(): + body.append(deck.pop()) + for _rep in count: + for n in body: + ops_count += 1 + if ops_count >= max_ops: + return ops_count + match n.type: + SpellNode.SpellType.ACTION: + _push_projectile(n, ctx, spawn_pos, -1) + SpellNode.SpellType.MODIFIER: + _apply_modifier(n, ctx) + SpellNode.SpellType.TRIGGER: + _push_trigger(n, ctx, spawn_pos) + # LOGIC:LOOP 体内嵌套逻辑顺延(S5 后续迭代) + return ops_count + +## ── CIRCUIT 分支执行(LOGIC_FORK 触发)────────────────────── +## 在施法点立即执行某分支 SubPayload 的节点;分支内 MODIFIER 作用域隔离(不泄漏给兄弟分支/主链) +## 嵌套 LOGIC_FORK 递归展开(P6-N66) +func _run_branch_payload(payload_id: int, ctx: SpellContext, spawn_pos: Vector2) -> void: + var nodes: Array = SubPayloadRegistry.get_nodes(payload_id) + if nodes.is_empty(): + return + # 快照分支前 CastStats(8 字段,无分配),分支结束后还原 → 分支局部 MODIFIER 不外泄 + var s: CastStats = ctx.stats + var b_dadd: float = s.damage_add + var b_dmul: float = s.damage_mult + var b_spr: int = s.spread_count + var b_mc: int = s.multicast_count + var b_life: float = s.lifetime + var b_spd: float = s.speed_mult + var b_rad: float = s.radius_mult + var b_crit: float = s.crit_chance + for node in nodes: + match node.type: + SpellNode.SpellType.ACTION: + _push_projectile(node, ctx, spawn_pos, -1) + SpellNode.SpellType.MODIFIER: + _apply_modifier(node, ctx) + SpellNode.SpellType.TRIGGER: + _push_trigger(node, ctx, spawn_pos) + SpellNode.SpellType.LOGIC: + if node.meta.has("fork_branch_ids"): + for bid in node.meta["fork_branch_ids"]: + _run_branch_payload(int(bid), ctx, spawn_pos) + elif _eval_logic(node, ctx, spawn_pos) == _LOGIC_SKIP_REST: + break + # 还原 stats + s.damage_add = b_dadd + s.damage_mult = b_dmul + s.spread_count = b_spr + s.multicast_count = b_mc + s.lifetime = b_life + s.speed_mult = b_spd + s.radius_mult = b_rad + s.crit_chance = b_crit + +func _get_aim_direction(from_pos: Vector2) -> Vector2: + var target: Vector2 = EnemyManager.get_nearest_pos(from_pos, 9999.0) + if target.distance_to(from_pos) < 1.0: + return Vector2.RIGHT + return (target - from_pos).normalized() + +func get_pool_stats() -> String: + return "SpellCtx: " + str(SpellContextPool.get_available_count()) + "/" + str(SpellContextPool.POOL_SIZE) diff --git a/scripts/domain/spell_system/spell_evaluator.gd.uid b/scripts/domain/spell_system/spell_evaluator.gd.uid new file mode 100644 index 0000000..cccafd5 --- /dev/null +++ b/scripts/domain/spell_system/spell_evaluator.gd.uid @@ -0,0 +1 @@ +uid://dmjvhkuljlffi diff --git a/scripts/domain/spell_system/spell_node.gd b/scripts/domain/spell_system/spell_node.gd new file mode 100644 index 0000000..c2e079d --- /dev/null +++ b/scripts/domain/spell_system/spell_node.gd @@ -0,0 +1,27 @@ +## SpellNode — 法术节点 Resource(可加载 .tres) +## 权威来源:architecture_design.md §3.2 +extends Resource +class_name SpellNode + +## 法术节点类型枚举 +## 使用方式:SpellNode.SpellType.ACTION +enum SpellType { + ACTION = 0, # 发射弹道 + MODIFIER = 1, # 修改 CastStats(S3 实现) + TRIGGER = 2, # 命中触发 SubPayload(S3 实现) + LOGIC = 3, # 条件指令(S5 实现) + SCOPE_CLOSE = 4, # 虚拟节点:SCOPE 边界标记(预编译时注入) +} + +## 全局唤一法术 ID(格式建议: category_name) +@export var id: String = "" +@export var type: int = SpellType.ACTION +@export var display_name: String = "" +@export var description: String = "" +## 法术类型相关参数(不同类型内容不同) +@export var meta: Dictionary = {} +## 元素亲和标签(供共鸣系统 §3.4.C 模式匹配,如 ["tag:water"]、["tag:lightning"]) +@export var element_tags: Array = [] + +func _to_string() -> String: + return "SpellNode(%s, type=%d)" % [id, type] diff --git a/scripts/domain/spell_system/spell_node.gd.uid b/scripts/domain/spell_system/spell_node.gd.uid new file mode 100644 index 0000000..bcffc16 --- /dev/null +++ b/scripts/domain/spell_system/spell_node.gd.uid @@ -0,0 +1 @@ +uid://wl3m5km246d8 diff --git a/scripts/domain/spell_system/sub_payload_registry.gd b/scripts/domain/spell_system/sub_payload_registry.gd new file mode 100644 index 0000000..f1ff28f --- /dev/null +++ b/scripts/domain/spell_system/sub_payload_registry.gd @@ -0,0 +1,47 @@ +## SubPayloadRegistry — 子荷载登记表(Autoload: SubPayloadRegistry) +## 权威来源:architecture_design.md §3.4.A +## 生命周期: +## - compile_wand 开始时调用 clear_for_wand(wand_id),清除该法杯旧荷载 +## - 关卡进入 BATTLE 后,SubPayloadRegistry 进入只读状态(P6-N10) +## - 战斗中敌人死亡触发的旧子荷载居然能正常结算 +extends Node + +const MAX_PAYLOAD_ID: int = 1024 + +## { payload_id: int → Array[SpellNode] } +var _payloads: Dictionary = {} +var _next_id: int = 0 +var _read_only: bool = false # BATTLE 期间禁止修改 + +## compile_wand 开始时调用,清除属于该法杯的旧荷载 +func clear_for_compile() -> void: + _payloads.clear() + _next_id = 0 + _read_only = false + +## 注册一组节点为子荷载,返回 payload_id +func register(nodes: Array) -> int: + if _read_only: + push_warning("SubPayloadRegistry: 战斗中禁止注册新荷载 (P6-N10)") + return -1 + if _next_id >= MAX_PAYLOAD_ID: + push_error("SubPayloadRegistry: 已达上限 MAX_PAYLOAD_ID=%d" % MAX_PAYLOAD_ID) + return -1 + var id: int = _next_id + _next_id += 1 + _payloads[id] = nodes + return id + +## 关卡开始后锁定登记表,全局只读 +func lock_for_battle() -> void: + _read_only = true + +## 查找子荷载节点列表(第三方不得修改返回值) +func get_nodes(payload_id: int) -> Array: + return _payloads.get(payload_id, []) + +func has_payload(payload_id: int) -> bool: + return _payloads.has(payload_id) + +func get_payload_count() -> int: + return _payloads.size() diff --git a/scripts/domain/spell_system/sub_payload_registry.gd.uid b/scripts/domain/spell_system/sub_payload_registry.gd.uid new file mode 100644 index 0000000..abd9590 --- /dev/null +++ b/scripts/domain/spell_system/sub_payload_registry.gd.uid @@ -0,0 +1 @@ +uid://b6h6agfanqjuw diff --git a/scripts/domain/status_instance.gd b/scripts/domain/status_instance.gd new file mode 100644 index 0000000..0c15f2d --- /dev/null +++ b/scripts/domain/status_instance.gd @@ -0,0 +1,21 @@ +## StatusInstance — 运行时状态实例(平铺数组元素) +## 权威来源:combat_mechanics_depth.md §3.2.5 +extends RefCounted +class_name StatusInstance + +var entity_id: int = -1 +var status_type_id: int = 0 +var stacks: int = 1 +var remaining_duration: float = 0.0 +var tick_accumulator: float = 0.0 +var owner_id: int = -1 # Root Source(击杀归属) +var type_def: StatusTypeDef = null + +func setup(entity: int, typedef: StatusTypeDef, stacks_count: int, dur: float, source: int) -> void: + entity_id = entity + status_type_id = typedef.id if typedef else 0 + type_def = typedef + stacks = max(1, stacks_count) + remaining_duration = dur + tick_accumulator = 0.0 + owner_id = source diff --git a/scripts/domain/status_instance.gd.uid b/scripts/domain/status_instance.gd.uid new file mode 100644 index 0000000..72eb14c --- /dev/null +++ b/scripts/domain/status_instance.gd.uid @@ -0,0 +1 @@ +uid://c14cny7agd11b diff --git a/scripts/domain/status_type_def.gd b/scripts/domain/status_type_def.gd new file mode 100644 index 0000000..7724b1c --- /dev/null +++ b/scripts/domain/status_type_def.gd @@ -0,0 +1,23 @@ +## StatusTypeDef — 状态效果类型定义(.tres 资源) +## 权威来源:combat_mechanics_depth.md §3.0、architecture_design.md §5.5 +extends Resource +class_name StatusTypeDef + +enum StackMode { + REFRESH = 0, # 刷新持续时间 + INTENSITY = 1, # 叠层强度 + INDEPENDENT = 2, # 独立实例 +} + +@export var id: int = 0 +@export var display_name: String = "" +@export var duration: float = 3.0 +@export var tick_interval: float = 1.0 +@export var stack_mode: int = StackMode.REFRESH +@export var max_stacks: int = 99 +@export var dot_damage_per_tick: float = 0.0 +@export var dot_damage_type: int = 0 +@export var can_catalyze: Array = [] +@export var is_combo_tracker: bool = false +@export var vfx_id: String = "" +@export var icon_key: String = "" diff --git a/scripts/domain/status_type_def.gd.uid b/scripts/domain/status_type_def.gd.uid new file mode 100644 index 0000000..32565e8 --- /dev/null +++ b/scripts/domain/status_type_def.gd.uid @@ -0,0 +1 @@ +uid://dgawg5w5hr5kx diff --git a/shaders/arena_grid.gdshader b/shaders/arena_grid.gdshader new file mode 100644 index 0000000..c116124 --- /dev/null +++ b/shaders/arena_grid.gdshader @@ -0,0 +1,26 @@ +// 竞技场地面网格:暗色基底 + 细网格线 + 中心放射暗角,提供空间参照与氛围 +// 应用于覆盖竞技场的大 ColorRect/Polygon2D(UV 0-1 映射全幅) +shader_type canvas_item; + +uniform float grid_cells = 100.0; + +void fragment() { + vec2 g = UV * grid_cells; + vec2 f = abs(fract(g) - 0.5); + float minor = smoothstep(0.47, 0.5, max(f.x, f.y)); + // 粗线(每 10 格) + vec2 g2 = UV * (grid_cells / 10.0); + vec2 f2 = abs(fract(g2) - 0.5); + float major = smoothstep(0.46, 0.5, max(f2.x, f2.y)); + + vec3 base = vec3(0.085, 0.088, 0.115); + vec3 minorC = vec3(0.125, 0.130, 0.165); + vec3 majorC = vec3(0.180, 0.185, 0.240); + vec3 col = mix(base, minorC, minor); + col = mix(col, majorC, major); + + // 径向渐暗(边缘更暗,聚焦中心) + float vig = 1.0 - smoothstep(0.25, 0.75, length(UV - vec2(0.5))); + col *= mix(0.6, 1.15, vig); + COLOR = vec4(col, 1.0); +} diff --git a/shaders/arena_grid.gdshader.uid b/shaders/arena_grid.gdshader.uid new file mode 100644 index 0000000..479f058 --- /dev/null +++ b/shaders/arena_grid.gdshader.uid @@ -0,0 +1 @@ +uid://c0qkjbreng77e diff --git a/shaders/bullet_glow.gdshader b/shaders/bullet_glow.gdshader new file mode 100644 index 0000000..011a948 --- /dev/null +++ b/shaders/bullet_glow.gdshader @@ -0,0 +1,12 @@ +// 子弹发光圆:加法混合 + 软核 + 外发光(bullet-heaven 弹幕质感) +// 应用于 BulletManager 的 MultiMeshInstance2D(单位 QuadMesh,UV 0-1) +shader_type canvas_item; +render_mode blend_add; + +void fragment() { + float d = length(UV - vec2(0.5)) * 2.0; // 0=中心 1=边缘 + float core = smoothstep(1.0, 0.45, d); // 实心核 + float glow = smoothstep(1.0, 0.0, d) * 0.5; // 外发光晕 + float a = clamp(core + glow, 0.0, 1.0); + COLOR = vec4(COLOR.rgb, COLOR.a * a); +} diff --git a/shaders/bullet_glow.gdshader.uid b/shaders/bullet_glow.gdshader.uid new file mode 100644 index 0000000..c5173fc --- /dev/null +++ b/shaders/bullet_glow.gdshader.uid @@ -0,0 +1 @@ +uid://verwtea1o32v diff --git a/shaders/enemy_circle.gdshader b/shaders/enemy_circle.gdshader new file mode 100644 index 0000000..4209966 --- /dev/null +++ b/shaders/enemy_circle.gdshader @@ -0,0 +1,16 @@ +// 敌人实体圆:普通 alpha 混合 + 软边 + 高光边缘(区别于发光子弹) +// 应用于 EnemyManager 的 MultiMeshInstance2D(per-instance 颜色经 COLOR 传入) +shader_type canvas_item; + +void fragment() { + float d = length(UV - vec2(0.5)) * 2.0; // 0=中心 1=边缘 + float body = smoothstep(1.0, 0.82, d); // 软边实心圆 + // 内高光:靠上方偏亮,营造球体体积感 + vec2 hi = UV - vec2(0.38, 0.34); + float spec = smoothstep(0.42, 0.0, length(hi)) * 0.35; + vec3 col = COLOR.rgb + spec; + // 暗边描线 + float rim = smoothstep(0.72, 0.95, d) * 0.25; + col = mix(col, col * 0.55, rim); + COLOR = vec4(col, COLOR.a * body); +} diff --git a/shaders/enemy_circle.gdshader.uid b/shaders/enemy_circle.gdshader.uid new file mode 100644 index 0000000..f1c9f6a --- /dev/null +++ b/shaders/enemy_circle.gdshader.uid @@ -0,0 +1 @@ +uid://b0vpvsbw3klw2 diff --git a/translations/en.po b/translations/en.po new file mode 100644 index 0000000..40e318b --- /dev/null +++ b/translations/en.po @@ -0,0 +1,193 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" + +msgid "UI_WAVE" +msgstr "Wave %d" + +msgid "UI_HP" +msgstr "HP: %.0f / %.0f" + +msgid "UI_GOLD" +msgstr "Gold: %dG" + +msgid "UI_XP" +msgstr "XP: %d / %d Lv.%d" + +msgid "UI_KILLS" +msgstr "Kills: %d Alive: %d" + +msgid "UI_DPS" +msgstr "DPS: %.1f" + +msgid "UI_CHARGE" +msgstr "Charge: %d%%" + +msgid "UI_CHARGE_DONE" +msgstr "Charge: Ready!" + +msgid "UI_STATUSES" +msgstr "Status: %d VFX: %d" + +msgid "SHOP_TITLE" +msgstr "✨ Shop — Pick a Spell" + +msgid "SHOP_REROLL" +msgstr "Reroll (%dG)" + +msgid "SHOP_NEXT_WAVE" +msgstr "Next Wave" + +msgid "SHOP_CORE_BTN" +msgstr "Wand: %s ⟳" + +msgid "SHOP_INV_BTN" +msgstr "🎒 Inventory" + +msgid "SHOP_SOLD" +msgstr "(Sold)" + +msgid "INV_TITLE" +msgstr "🎒 Inventory — [%s] %d slots (%s)" + +msgid "INV_TIP" +msgstr "Click source then target: slot<->slot swap, slot->self to bench, bench->slot place" + +msgid "INV_BENCH" +msgstr "Bench (%d):" + +msgid "INV_EMPTY" +msgstr "· empty ·" + +msgid "INV_CLOSE" +msgstr "Close" + +msgid "INV_ROW_A" +msgstr "RowA " + +msgid "INV_ROW_B" +msgstr "RowB " + +msgid "SETTLE_TITLE" +msgstr "💀 Game Over" + +msgid "SETTLE_STATS" +msgstr "Wave Reached: %d Kills: %d\nTime: %s Score: %d" + +msgid "SETTLE_BOARD" +msgstr "🏆 Leaderboard (wave first / then time)" + +msgid "SETTLE_BOARD_ROW" +msgstr "%2d. Wave %d Time %s Score %d%s" + +msgid "SETTLE_THIS_RUN" +msgstr " <- This run" + +msgid "SETTLE_RESTART" +msgstr "Play Again" + +msgid "STATUS_PURCHASED" +msgstr "Bought: %s" + +msgid "STATUS_WAND_SWITCH" +msgstr "Wand: %s" + +msgid "STATUS_DEPTH" +msgstr "⚠ Spell chain too deep (MAX_TRIGGER_DEPTH=%d)" + +msgid "STATUS_CHARGE_FIRED" +msgstr "CHARGE_FIRED" + +msgid "DIFF_BEGINNER" +msgstr "Beginner" + +msgid "DIFF_STANDARD" +msgstr "Standard" + +msgid "DIFF_CHALLENGE" +msgstr "Challenge" + +msgid "SHOP_SETTINGS_BTN" +msgstr "⚙ Settings" + +msgid "SETTINGS_TITLE" +msgstr "⚙ Settings" + +msgid "SETTINGS_DIFFICULTY" +msgstr "Difficulty: %s" + +msgid "SETTINGS_LANGUAGE" +msgstr "Language: %s" + +msgid "SETTINGS_VOLUME" +msgstr "Volume: %d%%" + +msgid "SETTINGS_CLEAR_DATA" +msgstr "Clear All Local Data" + +msgid "SETTINGS_CLEAR_CONFIRM" +msgstr "Click again to confirm" + +msgid "SETTINGS_CLEARED" +msgstr "All local data cleared" + +msgid "SETTINGS_CLOSE" +msgstr "Close" + +msgid "MENU_NEW_GAME" +msgstr "New Game" + +msgid "MENU_CONTINUE" +msgstr "Continue" + +msgid "MENU_LEADERBOARD" +msgstr "Leaderboard" + +msgid "MENU_SETTINGS" +msgstr "Settings" + +msgid "MENU_QUIT" +msgstr "Quit" + +msgid "MENU_QUIT_CONFIRM" +msgstr "Press again to confirm" + +msgid "SPLASH_SKIP_HINT" +msgstr "Press any key to skip" + +msgid "PAUSE_TITLE" +msgstr "Paused" + +msgid "PAUSE_RESUME" +msgstr "Resume" + +msgid "PAUSE_SETTINGS" +msgstr "Settings" + +msgid "PAUSE_QUIT_MENU" +msgstr "Return to Menu" + +msgid "PAUSE_QUIT_GAME" +msgstr "Quit Game" + +msgid "SETTLE_RETURN_MENU" +msgstr "Return to Menu" + +msgid "LEADERBOARD_EMPTY" +msgstr "No records yet. Go set your first score!" + +msgid "FTUE_TITLE" +msgstr "Choose Your Difficulty" + +msgid "FTUE_BEGINNER_DESC" +msgstr "Weaker enemies, 30% less damage taken. A relaxed way to learn the ropes." + +msgid "FTUE_STANDARD_DESC" +msgstr "The balanced, intended experience. Recommended for a first run." + +msgid "FTUE_CHALLENGE_DESC" +msgstr "+20% enemy count, +30% Boss HP. A true test of your build and movement." + +msgid "FTUE_HINT" +msgstr "You can change the difficulty anytime in Settings" diff --git a/translations/ja.po b/translations/ja.po new file mode 100644 index 0000000..99862d7 --- /dev/null +++ b/translations/ja.po @@ -0,0 +1,193 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ja\n" + +msgid "UI_WAVE" +msgstr "ウェーブ %d" + +msgid "UI_HP" +msgstr "HP: %.0f / %.0f" + +msgid "UI_GOLD" +msgstr "ゴールド: %dG" + +msgid "UI_XP" +msgstr "XP: %d / %d Lv.%d" + +msgid "UI_KILLS" +msgstr "撃破: %d 残存: %d" + +msgid "UI_DPS" +msgstr "DPS: %.1f" + +msgid "UI_CHARGE" +msgstr "チャージ: %d%%" + +msgid "UI_CHARGE_DONE" +msgstr "チャージ: 完了!" + +msgid "UI_STATUSES" +msgstr "状態: %d VFX: %d" + +msgid "SHOP_TITLE" +msgstr "✨ ショップ — 呪文を選択" + +msgid "SHOP_REROLL" +msgstr "リロール (%dG)" + +msgid "SHOP_NEXT_WAVE" +msgstr "次のウェーブへ" + +msgid "SHOP_CORE_BTN" +msgstr "杖: %s ⟳" + +msgid "SHOP_INV_BTN" +msgstr "🎒 バッグ" + +msgid "SHOP_SOLD" +msgstr "(売却済)" + +msgid "INV_TITLE" +msgstr "🎒 バッグ — [%s] %d スロット (%s)" + +msgid "INV_TIP" +msgstr "元を選び対象を押す:スロット↔スロット=交換、自分=ベンチへ、ベンチ→スロット=配置" + +msgid "INV_BENCH" +msgstr "ベンチ (%d):" + +msgid "INV_EMPTY" +msgstr "· 空 ·" + +msgid "INV_CLOSE" +msgstr "閉じる" + +msgid "INV_ROW_A" +msgstr "行A " + +msgid "INV_ROW_B" +msgstr "行B " + +msgid "SETTLE_TITLE" +msgstr "💀 ゲームオーバー" + +msgid "SETTLE_STATS" +msgstr "到達ウェーブ:%d 撃破:%d\n時間:%s スコア:%d" + +msgid "SETTLE_BOARD" +msgstr "🏆 ローカルランキング(ウェーブ優先 / 同ウェーブは時間昇順)" + +msgid "SETTLE_BOARD_ROW" +msgstr "%2d. ウェーブ %d 時間 %s スコア %d%s" + +msgid "SETTLE_THIS_RUN" +msgstr " ◀ 今回" + +msgid "SETTLE_RESTART" +msgstr "もう一度" + +msgid "STATUS_PURCHASED" +msgstr "購入:%s" + +msgid "STATUS_WAND_SWITCH" +msgstr "杖を変更:%s" + +msgid "STATUS_DEPTH" +msgstr "⚠ 呪文チェーンが深すぎます(MAX_TRIGGER_DEPTH=%d)" + +msgid "STATUS_CHARGE_FIRED" +msgstr "CHARGE_FIRED" + +msgid "DIFF_BEGINNER" +msgstr "初心者" + +msgid "DIFF_STANDARD" +msgstr "標準" + +msgid "DIFF_CHALLENGE" +msgstr "挑戦" + +msgid "SHOP_SETTINGS_BTN" +msgstr "⚙ 設定" + +msgid "SETTINGS_TITLE" +msgstr "⚙ 設定" + +msgid "SETTINGS_DIFFICULTY" +msgstr "難易度:%s" + +msgid "SETTINGS_LANGUAGE" +msgstr "言語:%s" + +msgid "SETTINGS_VOLUME" +msgstr "音量:%d%%" + +msgid "SETTINGS_CLEAR_DATA" +msgstr "ローカルデータを全消去" + +msgid "SETTINGS_CLEAR_CONFIRM" +msgstr "もう一度押して確認" + +msgid "SETTINGS_CLEARED" +msgstr "ローカルデータを消去しました" + +msgid "SETTINGS_CLOSE" +msgstr "閉じる" + +msgid "MENU_NEW_GAME" +msgstr "新しいゲーム" + +msgid "MENU_CONTINUE" +msgstr "続きから" + +msgid "MENU_LEADERBOARD" +msgstr "ランキング" + +msgid "MENU_SETTINGS" +msgstr "設定" + +msgid "MENU_QUIT" +msgstr "ゲームを終了" + +msgid "MENU_QUIT_CONFIRM" +msgstr "もう一度押して確認" + +msgid "SPLASH_SKIP_HINT" +msgstr "何かキーを押してスキップ" + +msgid "PAUSE_TITLE" +msgstr "一時停止" + +msgid "PAUSE_RESUME" +msgstr "ゲームを再開" + +msgid "PAUSE_SETTINGS" +msgstr "設定" + +msgid "PAUSE_QUIT_MENU" +msgstr "メインメニューへ" + +msgid "PAUSE_QUIT_GAME" +msgstr "ゲームを終了" + +msgid "SETTLE_RETURN_MENU" +msgstr "メインメニューへ" + +msgid "LEADERBOARD_EMPTY" +msgstr "記録なし。最初のスコアを打ち立てよう!" + +msgid "FTUE_TITLE" +msgstr "難易度を選択" + +msgid "FTUE_BEGINNER_DESC" +msgstr "敵が弱く、被ダメージが30%減少。気軽に始めたい方向け。" + +msgid "FTUE_STANDARD_DESC" +msgstr "バランスの取れた標準体験。初プレイにおすすめ。" + +msgid "FTUE_CHALLENGE_DESC" +msgstr "敵の数+20%、ボスHP+30%。ビルドと立ち回りが試される。" + +msgid "FTUE_HINT" +msgstr "難易度は「設定」からいつでも変更できます" diff --git a/translations/zh_CN.po b/translations/zh_CN.po new file mode 100644 index 0000000..50ba7f4 --- /dev/null +++ b/translations/zh_CN.po @@ -0,0 +1,193 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh_CN\n" + +msgid "UI_WAVE" +msgstr "第 %d 波" + +msgid "UI_HP" +msgstr "HP: %.0f / %.0f" + +msgid "UI_GOLD" +msgstr "金币: %dG" + +msgid "UI_XP" +msgstr "XP: %d / %d Lv.%d" + +msgid "UI_KILLS" +msgstr "击杀: %d 敌人存活: %d" + +msgid "UI_DPS" +msgstr "DPS: %.1f" + +msgid "UI_CHARGE" +msgstr "蓄力: %d%%" + +msgid "UI_CHARGE_DONE" +msgstr "蓄力: 完成!" + +msgid "UI_STATUSES" +msgstr "状态实例: %d VFX: %d" + +msgid "SHOP_TITLE" +msgstr "✨ 商店 — 选择一张法术" + +msgid "SHOP_REROLL" +msgstr "刷新 (%dG)" + +msgid "SHOP_NEXT_WAVE" +msgstr "出发下一波" + +msgid "SHOP_CORE_BTN" +msgstr "法杖: %s ⟳" + +msgid "SHOP_INV_BTN" +msgstr "🎒 背包" + +msgid "SHOP_SOLD" +msgstr "(已售出)" + +msgid "INV_TITLE" +msgstr "🎒 背包 — [%s] %d 槽 (%s)" + +msgid "INV_TIP" +msgstr "点插槽/备牌选中→点目标:插槽↔插槽=交换,插槽→自身=移入备牌,备牌→插槽=放入" + +msgid "INV_BENCH" +msgstr "备牌区 (%d):" + +msgid "INV_EMPTY" +msgstr "· 空 ·" + +msgid "INV_CLOSE" +msgstr "关闭背包" + +msgid "INV_ROW_A" +msgstr "行A " + +msgid "INV_ROW_B" +msgstr "行B " + +msgid "SETTLE_TITLE" +msgstr "💀 游戏结束" + +msgid "SETTLE_STATS" +msgstr "到达波次:%d 击杀:%d\n用时:%s 评分:%d" + +msgid "SETTLE_BOARD" +msgstr "🏆 本地排行榜(波次优先 / 同波次用时升序)" + +msgid "SETTLE_BOARD_ROW" +msgstr "%2d. 波次 %d 用时 %s 评分 %d%s" + +msgid "SETTLE_THIS_RUN" +msgstr " ◀ 本局" + +msgid "SETTLE_RESTART" +msgstr "再来一局" + +msgid "STATUS_PURCHASED" +msgstr "购买:%s" + +msgid "STATUS_WAND_SWITCH" +msgstr "换装法杖:%s" + +msgid "STATUS_DEPTH" +msgstr "⚠ 法术链太深(MAX_TRIGGER_DEPTH=%d)" + +msgid "STATUS_CHARGE_FIRED" +msgstr "CHARGE_FIRED 触发" + +msgid "DIFF_BEGINNER" +msgstr "初学者" + +msgid "DIFF_STANDARD" +msgstr "标准" + +msgid "DIFF_CHALLENGE" +msgstr "挑战" + +msgid "SHOP_SETTINGS_BTN" +msgstr "⚙ 设置" + +msgid "SETTINGS_TITLE" +msgstr "⚙ 设置" + +msgid "SETTINGS_DIFFICULTY" +msgstr "难度:%s" + +msgid "SETTINGS_LANGUAGE" +msgstr "语言:%s" + +msgid "SETTINGS_VOLUME" +msgstr "音量:%d%%" + +msgid "SETTINGS_CLEAR_DATA" +msgstr "清除所有本地数据" + +msgid "SETTINGS_CLEAR_CONFIRM" +msgstr "再点一次确认清除" + +msgid "SETTINGS_CLEARED" +msgstr "已清除所有本地数据" + +msgid "SETTINGS_CLOSE" +msgstr "关闭" + +msgid "MENU_NEW_GAME" +msgstr "新游戏" + +msgid "MENU_CONTINUE" +msgstr "继续游戏" + +msgid "MENU_LEADERBOARD" +msgstr "排行榜" + +msgid "MENU_SETTINGS" +msgstr "设置" + +msgid "MENU_QUIT" +msgstr "退出游戏" + +msgid "MENU_QUIT_CONFIRM" +msgstr "确认退出?再按一次" + +msgid "SPLASH_SKIP_HINT" +msgstr "按任意键跳过" + +msgid "PAUSE_TITLE" +msgstr "游戏暂停" + +msgid "PAUSE_RESUME" +msgstr "继续游戏" + +msgid "PAUSE_SETTINGS" +msgstr "设置" + +msgid "PAUSE_QUIT_MENU" +msgstr "返回主菜单" + +msgid "PAUSE_QUIT_GAME" +msgstr "退出游戏" + +msgid "SETTLE_RETURN_MENU" +msgstr "返回主菜单" + +msgid "LEADERBOARD_EMPTY" +msgstr "暂无记录,快去创造你的首个纪录吧!" + +msgid "FTUE_TITLE" +msgstr "选择你的难度" + +msgid "FTUE_BEGINNER_DESC" +msgstr "敌人更弱,受到的伤害减少 30%。适合休闲玩家轻松上手。" + +msgid "FTUE_STANDARD_DESC" +msgstr "平衡的标准体验。推荐首次游玩选择。" + +msgid "FTUE_CHALLENGE_DESC" +msgstr "敌人数量 +20%,Boss 生命 +30%。考验你的构建与走位。" + +msgid "FTUE_HINT" +msgstr "稍后可在「设置」中随时更改难度" diff --git a/translations/zh_TW.po b/translations/zh_TW.po new file mode 100644 index 0000000..c8d0209 --- /dev/null +++ b/translations/zh_TW.po @@ -0,0 +1,193 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh_TW\n" + +msgid "UI_WAVE" +msgstr "第 %d 波" + +msgid "UI_HP" +msgstr "HP: %.0f / %.0f" + +msgid "UI_GOLD" +msgstr "金幣: %dG" + +msgid "UI_XP" +msgstr "XP: %d / %d Lv.%d" + +msgid "UI_KILLS" +msgstr "擊殺: %d 敵人存活: %d" + +msgid "UI_DPS" +msgstr "DPS: %.1f" + +msgid "UI_CHARGE" +msgstr "蓄力: %d%%" + +msgid "UI_CHARGE_DONE" +msgstr "蓄力: 完成!" + +msgid "UI_STATUSES" +msgstr "狀態實例: %d VFX: %d" + +msgid "SHOP_TITLE" +msgstr "✨ 商店 — 選擇一張法術" + +msgid "SHOP_REROLL" +msgstr "刷新 (%dG)" + +msgid "SHOP_NEXT_WAVE" +msgstr "出發下一波" + +msgid "SHOP_CORE_BTN" +msgstr "法杖: %s ⟳" + +msgid "SHOP_INV_BTN" +msgstr "🎒 背包" + +msgid "SHOP_SOLD" +msgstr "(已售出)" + +msgid "INV_TITLE" +msgstr "🎒 背包 — [%s] %d 槽 (%s)" + +msgid "INV_TIP" +msgstr "點插槽/備牌選中→點目標:插槽↔插槽=交換,插槽→自身=移入備牌,備牌→插槽=放入" + +msgid "INV_BENCH" +msgstr "備牌區 (%d):" + +msgid "INV_EMPTY" +msgstr "· 空 ·" + +msgid "INV_CLOSE" +msgstr "關閉背包" + +msgid "INV_ROW_A" +msgstr "行A " + +msgid "INV_ROW_B" +msgstr "行B " + +msgid "SETTLE_TITLE" +msgstr "💀 遊戲結束" + +msgid "SETTLE_STATS" +msgstr "到達波次:%d 擊殺:%d\n用時:%s 評分:%d" + +msgid "SETTLE_BOARD" +msgstr "🏆 本地排行榜(波次優先 / 同波次用時升序)" + +msgid "SETTLE_BOARD_ROW" +msgstr "%2d. 波次 %d 用時 %s 評分 %d%s" + +msgid "SETTLE_THIS_RUN" +msgstr " ◀ 本局" + +msgid "SETTLE_RESTART" +msgstr "再來一局" + +msgid "STATUS_PURCHASED" +msgstr "購買:%s" + +msgid "STATUS_WAND_SWITCH" +msgstr "換裝法杖:%s" + +msgid "STATUS_DEPTH" +msgstr "⚠ 法術鏈太深(MAX_TRIGGER_DEPTH=%d)" + +msgid "STATUS_CHARGE_FIRED" +msgstr "CHARGE_FIRED 觸發" + +msgid "DIFF_BEGINNER" +msgstr "初學者" + +msgid "DIFF_STANDARD" +msgstr "標準" + +msgid "DIFF_CHALLENGE" +msgstr "挑戰" + +msgid "SHOP_SETTINGS_BTN" +msgstr "⚙ 設定" + +msgid "SETTINGS_TITLE" +msgstr "⚙ 設定" + +msgid "SETTINGS_DIFFICULTY" +msgstr "難度:%s" + +msgid "SETTINGS_LANGUAGE" +msgstr "語言:%s" + +msgid "SETTINGS_VOLUME" +msgstr "音量:%d%%" + +msgid "SETTINGS_CLEAR_DATA" +msgstr "清除所有本地資料" + +msgid "SETTINGS_CLEAR_CONFIRM" +msgstr "再點一次確認清除" + +msgid "SETTINGS_CLEARED" +msgstr "已清除所有本地資料" + +msgid "SETTINGS_CLOSE" +msgstr "關閉" + +msgid "MENU_NEW_GAME" +msgstr "新遊戲" + +msgid "MENU_CONTINUE" +msgstr "繼續遊戲" + +msgid "MENU_LEADERBOARD" +msgstr "排行榜" + +msgid "MENU_SETTINGS" +msgstr "設定" + +msgid "MENU_QUIT" +msgstr "退出遊戲" + +msgid "MENU_QUIT_CONFIRM" +msgstr "確認退出?再按一次" + +msgid "SPLASH_SKIP_HINT" +msgstr "按任意鍵跳過" + +msgid "PAUSE_TITLE" +msgstr "遊戲暫停" + +msgid "PAUSE_RESUME" +msgstr "繼續遊戲" + +msgid "PAUSE_SETTINGS" +msgstr "設定" + +msgid "PAUSE_QUIT_MENU" +msgstr "返回主選單" + +msgid "PAUSE_QUIT_GAME" +msgstr "退出遊戲" + +msgid "SETTLE_RETURN_MENU" +msgstr "返回主選單" + +msgid "LEADERBOARD_EMPTY" +msgstr "尚無記錄,快去創造你的第一個紀錄吧!" + +msgid "FTUE_TITLE" +msgstr "選擇你的難度" + +msgid "FTUE_BEGINNER_DESC" +msgstr "敵人更弱,受到的傷害減少 30%。適合休閒玩家輕鬆上手。" + +msgid "FTUE_STANDARD_DESC" +msgstr "平衡的標準體驗。推薦首次遊玩選擇。" + +msgid "FTUE_CHALLENGE_DESC" +msgstr "敵人數量 +20%,Boss 生命 +30%。考驗你的構築與走位。" + +msgid "FTUE_HINT" +msgstr "稍後可在「設定」中隨時更改難度"