Initial project sync
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,6 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
/android/
|
||||
/build/windows/Rthythm_archor_game.exe
|
||||
.DS_Store
|
||||
*.import.tmp
|
||||
@@ -0,0 +1,656 @@
|
||||
# Rthythm_archor_game
|
||||
|
||||
`Rthythm_archor_game` 是一个使用 Godot 4.6 开发的 2D 节奏横板动作游戏原型。项目核心来自《Anchor》策划方案:玩家不是用传统方向键自由移动,而是在音乐拍点窗口内输入动作,通过攻击、蓄力、技能、位移、浮空、击退、时间锚点和 Past / Future 双相位来推进战斗。
|
||||
|
||||
当前版本已经完成可运行的第一关战斗闭环:菜单与难度选择、第一关小怪流程、Boss 房门控、Boss 战入场、HUD、节奏条、时间锚点、攻击 Buff、玩家与敌人动作、Boss 行为、双层音乐、判定门控与输入锁定(反狂按)、Windows 打包链路、自动化回归测试都已接入。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境
|
||||
|
||||
- 引擎:Godot 4.6.x
|
||||
- 主场景:`res://scenes/main/main.tscn`
|
||||
- 项目配置:`project.godot`
|
||||
- 默认音乐:`res://assets/audio/ev_past1.mp3`
|
||||
|
||||
在 Godot 中打开本目录后,运行主场景即可进入游戏流程。
|
||||
|
||||
以下命令中的 `<godot>` 代表 Godot 4.6.3 可执行文件路径。Windows 下请使用
|
||||
`Godot_v4.6.3-stable_win64_console.exe`(console 包装器才能看到 stdout 输出;
|
||||
它按自身文件名寻找同目录的 GUI 版,两个文件必须按原名放在同一目录)。
|
||||
|
||||
命令行运行单个测试示例:
|
||||
|
||||
```bash
|
||||
<godot> --headless --path . --script tests/test_level1_spawn_flow.gd
|
||||
```
|
||||
|
||||
重新导入 / 检查项目资源:
|
||||
|
||||
```bash
|
||||
<godot> --headless --path . --import
|
||||
```
|
||||
|
||||
## 基本操作
|
||||
|
||||
| 输入 | Godot action | 语义 |
|
||||
| ----- | -------------------------- | ---------------------------------- |
|
||||
| A | `combo_a` | 左向攻击;长按后释放为左向蓄力剑雨 |
|
||||
| D | `combo_d` | 右向攻击;长按后释放为右向蓄力剑雨 |
|
||||
| W | `combo_w` | 上挑、浮空、空中动作入口 |
|
||||
| S | `combo_s` | 下砸、防御或特殊蓄力入口 |
|
||||
| Space | `combo_space` | 特殊键,内部符号为 `SP` |
|
||||
| E | runtime `toggle_move_list` | 显示 / 隐藏招式表 |
|
||||
| Esc | `ui_cancel` | 游戏中暂停;暂停菜单内返回上一层 |
|
||||
|
||||
注意:
|
||||
|
||||
- A / D / W / S / Space 不直接自由移动角色。每次输入先进入节奏判定,再决定是否执行动作。
|
||||
- **判定门控(2026-07-05 new1 定案,反狂按)**:每个节奏点只能被一次有效输入消耗。同一节奏点被消耗后的第 1 次重复按键按双击容错静默忽略(视同没按、无惩罚);第 2 次重复、或落在 BAD 窗之外的空按,判 MISS——不出招、连击清零、四拍槽写 `Ø`,并触发 **0.5 秒输入锁定**。锁定期间的按键完全无效:不判定、不留痕、也不刷新锁定时长。门控只作用于实时判定的输入(测试 / AI 的预置判定绕过)。实现见 `RhythmManager.gate_judged_input`(`input_lockout_seconds = 0.5`、`consumed_repeat_grace = 1`)。
|
||||
- 输入锁定若横跨时间锚点拍,该锚点必破(锁定期无法产生维持锚点的判定 → 超时 → 相位切换)。这是有意设计:失误的代价随连击档位升高而变重。
|
||||
- W + A / D 会触发带明显横向位移的空中抛物线式动作表现。
|
||||
- S 蓄力后按 Space 释放的斩波按玩家当前朝向发射:朝左即向左,不再自动瞄准最近敌人。
|
||||
- A / D 蓄力释放的剑雨落点与玩家可见脚底地面线对齐(角色锚点 y=560、可见脚底线 y=520)。
|
||||
- A / D 蓄力已进入 armed / charging 状态时按下 Space 会中断蓄力并按四拍槽派生解算(四拍槽仍保有 `[A]`,故 `[A][SP]` 解算为可穿过 Boss 的冲刺斩);S 蓄力不受此规则影响。
|
||||
- 右上角没有常驻暂停按钮,暂停只通过 Esc 触发。
|
||||
|
||||
## 当前主流程
|
||||
|
||||
```text
|
||||
Title / DifficultySelect
|
||||
-> Gameplay_FrontArea
|
||||
-> 第一关小怪流程
|
||||
-> 全部小怪清场
|
||||
-> Boss 房路径解封
|
||||
-> 玩家进入 Boss 区域
|
||||
-> Boss HUD 立即显示,Boss AI 激活
|
||||
-> Gameplay_BossRoom
|
||||
-> Victory / Defeat / Result
|
||||
```
|
||||
|
||||
难度选择由 `GameFlowManager` 管理,菜单贴图位于 `assets/ui/menu/`。难度页面点击后会保留选中态,并通过确认按钮进入游戏;返回、暂停、音量、退出确认、结算页面也在同一流程管理器中。
|
||||
|
||||
标题侧表现:
|
||||
|
||||
- 标题、难度选择、退出确认界面持续播放节奏音乐(`ev_past1.mp3`,走 Music 总线,由 `GameFlowManager` 的常驻播放器承载,不受 SceneTree 暂停影响)。
|
||||
- 标题背景在「过去」与「现在」两张场景图之间循环交叉淡入淡出。
|
||||
- 点击开始游戏后标题音乐停止,游戏内音乐从第 0 拍重新播放(`RhythmManager.start()` + `AudioLayerController.restart_layers()`)。
|
||||
|
||||
难度影响:
|
||||
|
||||
| 难度 | 判定窗口(Perfect / Good / Bad 秒) | 小怪血量 |
|
||||
| ---- | ----------------------------------- | -------- |
|
||||
| 简单 | 0.090 / 0.160 / 0.260 | ×1(650)|
|
||||
| 普通 | 0.060 / 0.115 / 0.215 | ×3(1950)|
|
||||
| 困难 | 0.030 / 0.070 / 0.170 | ×5(3250)|
|
||||
|
||||
判定窗口定义在 `GameSettings.DIFFICULTY_WINDOWS`,经 `apply_judgement_windows` 写入 `RhythmManager`。
|
||||
|
||||
小怪血量倍率定义在 `GameSettings.DIFFICULTY_MINION_HEALTH_MULTIPLIER`,由 `minion.gd` 在 `_ready` 时读取;Boss 血量不受该倍率影响。
|
||||
|
||||
## 核心玩法循环
|
||||
|
||||
当前战斗循环如下:
|
||||
|
||||
```text
|
||||
RhythmManager 推进音乐时间
|
||||
-> ChartRunner 触发谱面事件与预告
|
||||
-> 玩家在节奏窗口输入 A / D / W / S / Space
|
||||
-> InputComponent 创建 InputIntent
|
||||
-> ActionController 调用 RhythmManager 判定
|
||||
-> 判定门控 gate_judged_input:锁定期 / 已消耗节奏点的按键在此被拦下
|
||||
(ok = 消耗该节奏点继续;ignored = 视同没按;miss = 记 MISS 并锁定 0.5s)
|
||||
-> ComboWindow 记录最近四拍输入
|
||||
-> ActionResolver 根据四拍槽与角色状态选择 ActionData
|
||||
-> ActionExecutor 执行动作、消耗或回复能量、打开命中窗口
|
||||
-> CombatManager / Resolver 结算伤害、击退、打断、防御、受击
|
||||
-> TimeAnchorSystem 根据判定事实结算时间锚点
|
||||
-> UI 刷新节奏、判定、combo、Buff、血量、能量、蓄力和四拍槽
|
||||
```
|
||||
|
||||
A / D 蓄力释放与 S + Space 二段施放两条蓄力路径同样经过判定门控。
|
||||
|
||||
时间锚点不会新增输入按键。同一次玩家输入同时服务于动作系统和锚点系统:动作系统决定是否出招,锚点系统决定是否维持当前时间相位。
|
||||
|
||||
## 第一关关卡实现
|
||||
|
||||
实现入口:
|
||||
|
||||
- `scenes/stage/stage.tscn`
|
||||
- `scenes/stage/stage.gd`
|
||||
- `scenes/stage/level_director.gd`
|
||||
- `scenes/stage/boss_room_gate.gd`
|
||||
- `scenes/enemies/minion_behavior.gd`
|
||||
|
||||
第一关沿用参考工程的角色、Boss、背景、HUD 和相机相对大小,只调整关卡流程、出怪方式、门控和位置关系。玩家、小怪、Boss 的地面锚点统一在 `y = 560`。
|
||||
|
||||
### 关键坐标
|
||||
|
||||
| 对象 | 坐标 / 范围 | 说明 |
|
||||
| ---------------------- | --------------: | ----------------------------------------------- |
|
||||
| 竞技场左边界 | `x = 1120` | 小怪巡逻 / 后撤左边界 |
|
||||
| 左侧增援刷新点 | `x = 1420` | 左侧入场起点 |
|
||||
| 玩家出生点 | `(1180, 560)` | 2026-07-05 new2:地图偏左出生,开场向右推进遇敌 |
|
||||
| 战斗中心 | `x = 2047` | 增援入场目标基准 |
|
||||
| 阶段一近战怪 | `(2200, 560)` | 开局预置;玩家从左侧出生点推进约 1000px 后遭遇 |
|
||||
| 右侧增援刷新点 | `x = 2240` | 右侧入场起点(与小怪右侧安全线同值) |
|
||||
| 小怪右侧安全线 | `x = 2240` | Boss 空气墙左侧 220px;所有小怪都限制在此线左侧 |
|
||||
| Boss 空气墙 / 触发参考 | `gate_x = 2460` | 小怪不会越过该区域 |
|
||||
| Boss 初始锚点 | `(2520, 560)` | Boss 战前 `combat_enabled = false` |
|
||||
| 相机初始位置 | `(1180, 395)` | 跟随玩家,`camera_follow_offset = (0, -165)` |
|
||||
|
||||
以上均为 `stage.tscn` 的实际覆盖值;`level_director.gd` / `boss_room_gate.gd` 脚本里的 export 默认值会被场景覆盖,以 tscn 为准。
|
||||
|
||||
`LevelDirector` 会根据 `BossRoomGate.gate_x` 和 `boss_gate_minion_clearance = 220` 自动计算前场最大活动范围。动态刷新的远程怪、近战怪,以及编辑器内预置的小怪,都会统一套用这条安全边界,避免刷到空气墙右侧或相机边缘。
|
||||
|
||||
### 出怪阶段
|
||||
|
||||
第一关总计 6 只小怪。
|
||||
|
||||
| 阶段 | 触发条件 | 内容 |
|
||||
| ------ | ----------------- | ---------------------------------------------------- |
|
||||
| 阶段一 | 开局 | 预置近战怪 × 1 |
|
||||
| 阶段二 | 击败第一只近战怪 | 玩家附近生成远程怪 × 1,生成点可感知但不贴脸 |
|
||||
| 阶段三 | 击败阶段二远程怪 | 按 2 秒间隔生成 4 只增援:近战L、远程R、近战R、远程L |
|
||||
| 清场 | 击败全部 6 只小怪 | 解封 Boss 房路径并广播 `front_area_cleared` |
|
||||
|
||||
阶段三的“右侧”增援也不会刷到空气墙右侧;运行时会被夹到前场安全线以内。
|
||||
|
||||
### Boss 房门控
|
||||
|
||||
`BossRoomGate` 当前规则:
|
||||
|
||||
- 开局 `sealed = true`,阻挡玩家进入 Boss 区域。
|
||||
- 小怪全灭后 `LevelDirector` 调用 `unseal()`,空气墙碰撞关闭,视觉保持透明。
|
||||
- 玩家跨过门线(`x >= gate_x`)的瞬间 `lock()` 立即触发:Boss 解锁、面板显示,不需要走到 Boss 面前。
|
||||
- `lock()` 只触发一次 Boss 战入场逻辑。
|
||||
- 进入 Boss 区域后空气墙不会重新显示,也不会重新启用碰撞,会完全消失。
|
||||
- Boss 入场时立即激活 Boss AI,并主动刷新 Boss HUD 绑定。
|
||||
- Boss 面板只在 `Gameplay_BossRoom` 显示;前场阶段隐藏,即使 Boss 已被绑定也不会提前显示。
|
||||
|
||||
相机在 Boss 区域未解锁时会限制右侧视野,避免提前看到 Boss 区域。
|
||||
|
||||
## 玩家系统
|
||||
|
||||
实现入口:
|
||||
|
||||
- `scenes/characters/player.tscn`
|
||||
- `scenes/characters/player.gd`
|
||||
- `scenes/components/`
|
||||
- `resources/actions/`
|
||||
- `resources/player_action_bindings.tres`
|
||||
|
||||
玩家由组件组合而成:
|
||||
|
||||
- `HealthComponent`:生命
|
||||
- `EnergyComponent`:能量
|
||||
- `ChargeComponent`:蓄力
|
||||
- `InputComponent`:输入转 intent
|
||||
- `ComboWindow`:四拍槽
|
||||
- `ActionController`:动作生命周期(含判定门控接入)
|
||||
- `ActionResolver`:四拍槽 + 角色状态 → ActionData 解算
|
||||
- `ActionExecutor`:动作效果执行
|
||||
- `MotionExecutor`:动作位移
|
||||
- `MovementMotor`:浮空、落地、击退等运动处理
|
||||
- `StateMachine`:地面 / 动作 / 防御 / 生死状态轴
|
||||
- `DamageEmitter` / `DamageReceiver`:攻击与受击区域
|
||||
- `EffectContainer`:Buff / Debuff 容器
|
||||
- `StreakCounter`:连续成功动作计数
|
||||
- `AttackBuffComponent`:时间锚点攻击 Buff(含回血与受伤掉层)
|
||||
- `BurstComponent`:爆发动作门槛
|
||||
- `FrameCollisionDriver`:帧动画命中框驱动
|
||||
- `ContactFxSpawner`:命中接触特效
|
||||
- `OverheadChargeSegments`:头顶蓄力段显示
|
||||
- `Visual/AttackBuffVisual`、`Visual/FxOverlay` + `Visual/FxAnimationPlayer`:Buff 层数与 FX 贴片表现
|
||||
|
||||
动作由 `ActionData` 资源驱动,数据包含输入模式、能量、伤害倍率、水平 / 竖直位移、击退、命中类型、Startup / Active / Recovery 拍数、可取消时机、可执行状态、标签、动画名等。
|
||||
|
||||
当前已接入:
|
||||
|
||||
- 普通左右攻击
|
||||
- 上挑与空中动作
|
||||
- 下砸
|
||||
- 防御
|
||||
- Dash / 冲刺斩
|
||||
- A / D 长按蓄力剑雨
|
||||
- S + SP 二段式蓄力
|
||||
- 四拍槽派生动作
|
||||
- 攻击命中特效与蓄力释放世界 FX
|
||||
- 强势 Buff 视觉效果,层数越多表现越明显
|
||||
|
||||
玩家死亡动画会保持地面线一致;死亡动画结束后尸体会消失。
|
||||
|
||||
## 小怪系统
|
||||
|
||||
实现入口:
|
||||
|
||||
- `scenes/enemies/minion.tscn`
|
||||
- `scenes/enemies/minion.gd`
|
||||
- `scenes/enemies/minion_behavior.gd`
|
||||
- `resources/actions/enemies/`
|
||||
- `resources/time_phase/profile_past_strong_enemy.tres`
|
||||
- `resources/time_phase/profile_future_strong_enemy.tres`
|
||||
|
||||
小怪分近战与远程两类,并有 Past / Future 两种形态。
|
||||
|
||||
基础攻击力按职责区分(2026-07-05 new3 定案):**近战 50、远程 25**。由 `LevelDirector` 按 `MinionBehavior.role` 统一写入 `DamageEmitter.damage`(预置怪与动态刷新怪都覆盖),最终伤害仍乘相位强弱倍率。
|
||||
|
||||
### 近战怪
|
||||
|
||||
- Past 形态:`jin_zhan_3`,强势
|
||||
- Future 形态:`jin_zhan_1`,弱势
|
||||
- 会巡逻、警戒、追击、按拍攻击
|
||||
- 强势相位 2 拍攻击一次,弱势相位 4 拍攻击一次
|
||||
- 距离参数(new3):警戒 360、追踪停步 120、出手距离 120、攻击判定前缘约 140(动作 `range = 130`)、巡逻半径 110
|
||||
- 强势相位命中会打断并击退玩家约 86px(`knockback_mult_x = 2.4`);弱势相位命中只掉血
|
||||
- 朝向按目标位置正确翻转,不再固定朝右 / 朝左
|
||||
|
||||
### 远程怪
|
||||
|
||||
- Past 形态:`yuan_cheng_1`,小女孩,弱势
|
||||
- Future 形态:`yuan_cheng_2`,女巫,强势
|
||||
- 不巡逻,固定站位射击(站位停步 340、警戒 540)
|
||||
- **最大开火距离 450,压在相机可视半宽(约 460px)以内——刚进画面即可开火,但绝不从屏幕外攻击玩家**
|
||||
- 近身后撤:玩家进入危险距离 220 持续 0.6 秒 → 向远离玩家方向退开 220px(贴墙无退路时反向跳过玩家身位);后撤结束按节拍停顿 1 拍再恢复攻击;3 秒冷却
|
||||
- 受击脱离(2026-07-05 新增):2 秒链窗内连续受击 4 次 → 0.4 秒闪烁无敌 + 跳跃形式高速脱离(3.2× 速度);3 秒冷却防连续触发。无敌通过 `FrameCollisionDriver.damage_receiver_enabled` 实现(直接改 Area2D 会被驱动器每帧重申覆盖)
|
||||
- 小女孩和女巫的视觉高度已对齐,两个形态都水平射击,projectile 发射高度一致(`projectile_height = 86`)
|
||||
- 小女孩素材原生朝右(`native_facing = RIGHT`),镜像翻转以此为准,攻击朝向始终对准玩家
|
||||
|
||||
小怪形态切换时会保持脚下锚点稳定,避免动画移动过程中 y 轴突变造成视觉瞬移。
|
||||
|
||||
## Boss 系统
|
||||
|
||||
实现入口:
|
||||
|
||||
- `scenes/enemies/boss.tscn`
|
||||
- `scenes/enemies/boss.gd`
|
||||
- `scenes/enemies/boss_behavior_tree.gd`
|
||||
- `resources/actions/enemies/`
|
||||
|
||||
Boss 当前支持:
|
||||
|
||||
- 近战连段
|
||||
- 远程射击
|
||||
- 二连发射击
|
||||
- Dash / retreat
|
||||
- 行为树决策
|
||||
- 谱面事件优先级
|
||||
- 受击链逃脱(1.2 秒链窗内连续受击 4 次 → 取消当前动作、`boss_retreat_dash` 逃脱;4 秒冷却;训练桩状态不逃脱)
|
||||
- Past / Future 招式池差异
|
||||
- **普攻恒霸体(2026-07-05 new3 定案)**:对不耗能量的攻击(`base_cost <= 0`,即玩家普通攻击)伤害照常结算,但不被打断、不被击退;只有耗能技能能压制 Boss。实现为 `boss.gd` 的 `shrugs_off_hit` + `CombatResolver` 受击方钩子。项目**没有**韧性 / 削韧系统——策划以霸体规则替代了 new3 §6.5 的韧性方案
|
||||
- 远程(Future)相位被近身 ≤240px 时优先 `boss_retreat_dash` 拉开距离(后撤本身无伤害、8 拍冷却、后撤完接远程攻击)
|
||||
- 相位切换后停顿 1 拍再决策(以事件总线最后拍号为基准)
|
||||
- Boss 战前静默,进入 Boss 区域后才激活
|
||||
- Boss 血条、名称和头像 HUD
|
||||
|
||||
行为树距离阈值(2026-07-05 调整后):`melee_distance = 180`、`ranged_distance = 360`、`dash_distance = 460`(超过则考虑突进,4 拍冷却)、远程相位后撤触发 `retreat_trigger_distance = 240`;决策间隔 2 拍(Future 相位 ×0.5)。
|
||||
|
||||
Boss 入场规则由 `BossRoomGate` 与 `GameFlowManager` 共同处理:玩家进入 Boss 区域后,Boss 从训练桩状态切换为可战斗状态,Boss 面板立即出现。
|
||||
|
||||
## 时间相位与时间锚点
|
||||
|
||||
实现入口:
|
||||
|
||||
- `autoload/time_phase_manager.gd`
|
||||
- `autoload/time_anchor_system.gd`
|
||||
- `resources/time_anchor_frequency_default.tres`
|
||||
- `resources/time_phase/`
|
||||
- `scenes/components/time_phase_adapter.gd`
|
||||
- `scenes/audio/audio_layer_controller.gd`
|
||||
- `scenes/ui/rhythm_track.gd`
|
||||
|
||||
世界有两个时间相位:
|
||||
|
||||
```text
|
||||
past
|
||||
future
|
||||
```
|
||||
|
||||
`TimePhaseManager` 是相位唯一写入者。相位改变后通过 `EventBus.time_phase_changed` 广播,Stage、UI、音乐、敌人强弱、Buff 系统分别响应。
|
||||
|
||||
时间锚点规则:
|
||||
|
||||
- 时间锚点是特殊节奏拍,不是额外按键。
|
||||
- 锚点拍内 `perfect / good / bad` 都能维持当前相位(锚点带 `min_grade` 字段可提高门槛,当前谱面均用默认 `bad`,即三档都维持)。
|
||||
- 锚点超时或 Miss 会切换 Past / Future。空按触发的 0.5 秒输入锁定若横跨锚点拍,该锚点必破。
|
||||
- `perfect / good` 成功锚定会增加 1 层攻击 Buff。
|
||||
- `bad` 只保持相位,不增加攻击 Buff。
|
||||
- 当前攻击 Buff 上限为 7 层;每层对 `damage_mult` 加算 +0.5(每层 +50% 伤害,线性叠加,7 层满层 ×4.5)。effect 资源为 `resources/effects/time_phase/effect_time_anchor_attack_buff.tres`。
|
||||
- 每次实际相位切换时,Buff 层数减少 2,最低为 0。
|
||||
- 锚点成功会按判定质量回血:Perfect 30、Good 20、Bad 10;生命低于 50% 时回血翻倍。回血按 4 拍窗口限一次——同一窗口内第二次锚点成功只加 Buff 不回血(高连击档位下每 4 拍最多 4 个锚点,只有第一个触发回血)。
|
||||
- 受伤掉层与霸体:玩家被攻击进入受伤状态时掉落一半 Buff 层数(向下取整),每掉 1 层获得 0.5 秒霸体(SuperArmor:伤害照吃、不被打断、无击退);霸体期间不会再次进入受伤状态,因此不会连锁掉层。实现见 `AttackBuffComponent` + `resources/effects/effect_hurt_super_armor.tres`。
|
||||
|
||||
连击数会影响时间锚点频率:
|
||||
|
||||
| 连击数 | 每 4 拍锚点数 |
|
||||
| -----: | ------------: |
|
||||
| 0 - 7 | 1 |
|
||||
| 8 - 15 | 2 |
|
||||
| 16+ | 4 |
|
||||
|
||||
节奏 UI 当前做过可读性强化:
|
||||
|
||||
- 特殊节奏点放大,时间锚点比普通节奏点更明显。
|
||||
- 节奏点命中中心后的 flash / hit 特效放大且保留更久。
|
||||
- 锚点成功、断裂会使用不同颜色反馈。
|
||||
- 节奏点从两侧向中心的移动直接由 `RhythmManager` 的歌曲时间驱动(`get_current_beat_progress`),全程连续丝滑,到达中心的时刻与拍点严格重合。
|
||||
|
||||
## UI 与 HUD
|
||||
|
||||
实现入口:
|
||||
|
||||
- `scenes/ui/main_ui.tscn`
|
||||
- `scenes/ui/main_ui.gd`
|
||||
- `scenes/ui/rhythm_track.tscn`
|
||||
- `scenes/ui/rhythm_track.gd`
|
||||
- `scenes/ui/combo_window_hud.tscn`
|
||||
- `scenes/ui/combo_window_hud.gd`
|
||||
- `assets/ui/`
|
||||
|
||||
当前 HUD 包含:
|
||||
|
||||
- 玩家状态栏:头像面板、HP、EN、蓄力条、蓄力等级
|
||||
- Boss 状态栏:Boss 头像、名称、血条,只在 Boss 战显示
|
||||
- 中央节奏条:左右节奏点向中心收束
|
||||
- 时间锚点:特殊节奏点纹理与放大显示
|
||||
- 判定艺术字:Perfect / Good / Bad / Miss
|
||||
- combo 计数
|
||||
- ATK Buff 读数
|
||||
- 7 格 Buff 点亮条
|
||||
- 四拍槽
|
||||
- E 键招式表
|
||||
- 调试面板,默认隐藏
|
||||
|
||||
HUD 由主场景绑定玩家和 Boss:
|
||||
|
||||
```text
|
||||
Main
|
||||
-> Stage
|
||||
-> ActorsContainer
|
||||
-> Player
|
||||
-> Boss
|
||||
-> UILayer
|
||||
-> UI
|
||||
```
|
||||
|
||||
Boss 面板显示条件是:
|
||||
|
||||
```text
|
||||
flow state == Gameplay_BossRoom
|
||||
and boss_actor is valid
|
||||
```
|
||||
|
||||
因此前场阶段不会提前显示 Boss 血条;进入 Boss 区域时 `BossRoomGate` 会主动绑定 Boss,确保 HUD 立即可见。
|
||||
|
||||
## 音频
|
||||
|
||||
实现入口:
|
||||
|
||||
- `autoload/sfx_manager.gd`
|
||||
- `scenes/audio/audio_layer_controller.gd`
|
||||
- `assets/audio/`
|
||||
|
||||
当前音频系统包含:
|
||||
|
||||
- Past / Future 双音乐层同步播放
|
||||
- 相位切换时交叉淡入淡出
|
||||
- 节奏时钟不因切相位重置
|
||||
- 标题侧节奏音乐(`GameFlowManager` 常驻播放器,Music 总线)
|
||||
- 开局重置:`_reset_run_state()` 调 `RhythmManager.start()` 归零时钟后,立即调 `AudioLayerController.restart_layers()` 把双音轨拉回第 0 拍,不等漂移校正
|
||||
- UI、攻击、命中、锚点等事件 SFX 入口
|
||||
|
||||
当前 Future 层仍使用占位处理,可继续替换为正式 Future 音乐资源。
|
||||
|
||||
## 谱面与事件
|
||||
|
||||
实现入口:
|
||||
|
||||
- `resources/beat_chart.gd`
|
||||
- `resources/chart_track.gd`
|
||||
- `resources/chart_event.gd`
|
||||
- `resources/charts/`
|
||||
- `scenes/chart/chart_runner.gd`
|
||||
|
||||
`BeatChart` 由多个 `ChartTrack` 组成,每个 track 内包含若干 `ChartEvent`。当前事件支持:
|
||||
|
||||
- `enemy_action`
|
||||
- `show_accent_marker`
|
||||
- `camera_pulse`
|
||||
- `time_anchor`
|
||||
- `force_time_phase`
|
||||
|
||||
`ChartRunner` 负责按音乐时间触发事件、提前广播 upcoming 事件给 UI、根据 `time_phase_mask` 过滤当前相位不可触发事件,并将敌人动作事件路由到目标 Actor。
|
||||
|
||||
当前谱面资源包括:
|
||||
|
||||
- `resources/charts/stage7_boss_opening.tres`
|
||||
- `resources/charts/stage9_boss_duel.tres`
|
||||
|
||||
## 战斗结算
|
||||
|
||||
实现入口:
|
||||
|
||||
- `autoload/combat_manager.gd`
|
||||
- `scripts/resolvers/stat_resolver.gd`
|
||||
- `scripts/resolvers/combat_resolver.gd`
|
||||
- `scripts/resolvers/defense_resolver.gd`
|
||||
- `scenes/components/damage_emitter.gd`
|
||||
- `scenes/components/damage_receiver.gd`
|
||||
- `scenes/components/effect_container.gd`
|
||||
|
||||
战斗结算采用 resolver 管线:
|
||||
|
||||
- `StatResolver` 计算伤害、消耗、回复、动作位移。
|
||||
- `CombatResolver` 处理命中结果、击退和命中上下文。
|
||||
- `DefenseResolver` 处理防御、格挡、减伤、打断规则。
|
||||
- `CombatManager` 负责一次命中事实的缓存、生命扣除、击退、打断、效果事件和 `hit_confirmed` 广播。
|
||||
|
||||
强弱与打断规则:
|
||||
|
||||
- 只有强势状态小怪能打断玩家。
|
||||
- 弱势小怪攻击只结算伤害,不打断、不击退、不取消玩家动作。
|
||||
- Boss 恒定视为强势,Boss 攻击可以打断玩家(玩家霸体动作除外)。
|
||||
- 强弱状态在动作开始时快照,命中前切相位不会 retroactively 改变本次攻击。
|
||||
- 受击方霸体钩子(2026-07-05 新增):`CombatResolver` 结算时调用受击方的 `shrugs_off_hit(action)`,返回 `true` 则本次命中的打断与击退一并取消、伤害照常。目前只有 Boss 实现该钩子(普攻恒霸体、耗能技能可压制)。
|
||||
- 击退跟随伤害判定通道:击退判定倍率走 damage 通道(perfect 1.0 / good 0.85 / bad 0.7),且仅在攻击方拥有打断权时结算——弱势攻击与被霸体消化的攻击击退为零;受击方处于 Invincible / SuperArmor 防御态时同样归零。
|
||||
- 所有耗能技能都带击退 / 击飞(dash_slash 1.5、combo_finisher 2.0、ground_smash 2.2 + 上挑、zhan_bo 1.0/1.3/1.6、jian_yu 0.8);玩家弹丸由 `ActorsContainer` 注入基准击退 `(120, 304)`。
|
||||
|
||||
## 目录结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ---------------------------- | ------------------------------------------------------------ |
|
||||
| `addons/godot_mcp/` | Godot MCP 插件 |
|
||||
| `assets/audio/` | 音乐与 SFX |
|
||||
| `assets/art/ground/` | 地面、远景、中景背景 |
|
||||
| `assets/art/characters/` | 玩家、Boss、小怪素材 |
|
||||
| `assets/art/effects/` | 通用特效图集 |
|
||||
| `assets/ui/menu/` | 标题、难度、暂停、确认等菜单贴图 |
|
||||
| `assets/ui/panels/` | 玩家与 Boss 状态栏面板、Boss 头像 |
|
||||
| `assets/ui/rhythm/` | 节奏条、锚点、节奏点、中心纹理 |
|
||||
| `assets/ui/buff/` | Buff 点亮 / 未点亮图标 |
|
||||
| `assets/ui/buff_effect/` | 玩家攻击 Buff 特效帧 |
|
||||
| `assets/ui/combo/` | combo 数字素材 |
|
||||
| `assets/ui/judgements/` | Perfect / Good / Bad / Miss 判定艺术字素材 |
|
||||
| `assets/ui/strong_buff/` | 强势状态视觉特效 |
|
||||
| `autoload/` | 9 个游戏单例:事件总线、节奏、难度设置、统计、战斗、相位、锚点、音效、流程 |
|
||||
| `build/windows/` | Windows 打包产物(`Rthythm_archor_game.exe`,单文件内嵌 pck) |
|
||||
| `docs/` | 策划、架构、第一关设计和决策文档 |
|
||||
| `resources/actions/` | 玩家动作数据 |
|
||||
| `resources/actions/enemies/` | 敌人与 Boss 动作数据 |
|
||||
| `resources/charts/` | 谱面资源 |
|
||||
| `resources/effects/` | Buff / Debuff / 相位效果 |
|
||||
| `resources/time_phase/` | Past / Future 强弱 profile |
|
||||
| `scenes/audio/` | 双音乐层控制 |
|
||||
| `scenes/characters/` | 玩家与角色基类 |
|
||||
| `scenes/chart/` | 谱面运行器 |
|
||||
| `scenes/combat/` | projectile、一次性 FX、动作解析 |
|
||||
| `scenes/components/` | 可复用 Actor 组件 |
|
||||
| `scenes/enemies/` | 小怪、Boss、行为树与 AI |
|
||||
| `scenes/ground/` | 地面与背景场景 |
|
||||
| `scenes/main/` | 主场景入口 |
|
||||
| `scenes/stage/` | 关卡、相机、门控、出怪导演、projectile 生成 |
|
||||
| `scenes/ui/` | HUD、节奏条、能量条、四拍槽 |
|
||||
| `scripts/resolvers/` | 战斗、数值、防御、动作规则 resolver |
|
||||
| `tests/` | SceneTree 自动化测试(66 个) |
|
||||
| `tools/` | 内容导出、谱面验证和模拟工具 |
|
||||
| `export_presets.cfg` | "Windows Desktop" 导出预设(x86_64 / embed_pck 单 exe) |
|
||||
|
||||
## 自动化测试
|
||||
|
||||
项目包含 66 个 Godot SceneTree 测试,覆盖:
|
||||
|
||||
- 架构迁移契约
|
||||
- 输入 intent 与节奏判定 payload
|
||||
- 判定门控与输入锁定(节奏点一次性消耗、双击容错、MISS + 0.5s 锁定)
|
||||
- 动作控制、四拍槽、蓄力、能量
|
||||
- 战斗 resolver、Effect、强弱打断规则
|
||||
- new3 怪物 / Boss 行为规则(Boss 霸体、按职责基础攻击、远程怪受击脱离)
|
||||
- 玩家场景、动画节奏、死亡表现、剑雨落点
|
||||
- 小怪节拍、朝向、远程射击、高度、行为状态机
|
||||
- 第一关出怪流程与 Boss 房门控
|
||||
- Boss 行为、Boss HUD、Boss projectile
|
||||
- 菜单、难度选择、暂停、GameFlow
|
||||
- 时间相位、时间锚点、攻击 Buff
|
||||
- HUD 布局、节奏条、E 键招式表、UI 动画回归
|
||||
|
||||
常用回归命令(`<godot>` 见「快速开始」):
|
||||
|
||||
```bash
|
||||
<godot> --headless --path . --script tests/test_input_gate.gd
|
||||
<godot> --headless --path . --script tests/test_new3_enemy_rules.gd
|
||||
<godot> --headless --path . --script tests/test_level1_spawn_flow.gd
|
||||
<godot> --headless --path . --script tests/test_boss_room_gate.gd
|
||||
<godot> --headless --path . --script tests/test_boss_room_entry_hud_and_wall.gd
|
||||
<godot> --headless --path . --script tests/test_minion_behavior_states.gd
|
||||
<godot> --headless --path . --script tests/test_minion_cadence_and_facing.gd
|
||||
<godot> --headless --path . --script tests/test_player2_fx_and_witch_projectile.gd
|
||||
<godot> --headless --path . --script tests/test_rhythm_feedback_and_move_list.gd
|
||||
<godot> --headless --path . --script tests/test_phase9_boss_health_ui.gd
|
||||
<godot> --headless --path . --script tests/test_title_music_and_background.gd
|
||||
<godot> --headless --path . --script tests/test_difficulty_minion_health.gd
|
||||
<godot> --headless --path . --script tests/test_hurt_buff_drop_and_super_armor.gd
|
||||
<godot> --headless --path . --script tests/test_blade_rain_alignment.gd
|
||||
```
|
||||
|
||||
也可以串行跑一组关键回归:
|
||||
|
||||
```bash
|
||||
GODOT=<godot 可执行文件路径>
|
||||
for test_file in \
|
||||
tests/test_input_gate.gd \
|
||||
tests/test_new3_enemy_rules.gd \
|
||||
tests/test_game_flow.gd \
|
||||
tests/test_level1_spawn_flow.gd \
|
||||
tests/test_boss_room_gate.gd \
|
||||
tests/test_boss_room_entry_hud_and_wall.gd \
|
||||
tests/test_minion_behavior_states.gd \
|
||||
tests/test_level1_melee_contact.gd \
|
||||
tests/test_boss_integration.gd \
|
||||
tests/test_minion_phase_system.gd \
|
||||
tests/test_main_ui_camera_canvas.gd \
|
||||
tests/test_phase9_boss_health_ui.gd \
|
||||
tests/test_rhythm_ui_layout.gd \
|
||||
tests/test_x1_visual_baseline.gd \
|
||||
tests/test_player_combo_input.gd \
|
||||
tests/test_combo_hud.gd \
|
||||
tests/test_blade_rain_alignment.gd
|
||||
do
|
||||
"$GODOT" --headless --path . --script "$test_file" || exit $?
|
||||
done
|
||||
```
|
||||
|
||||
已知存量失败:`test_stage4_player_scene`(期望旧版 2 倍 Visual 缩放的历史基线,与当前实现无关,排查回归时可忽略)。
|
||||
|
||||
部分测试在加载资源时可能打印 Godot 的 UID fallback 或 MCP port warning;如果最终输出 `PASS ...` 且退出码为 0,则测试通过。
|
||||
|
||||
## 设计文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
| ---------------------- | -------------------------------------- |
|
||||
| `docs/AnchorV1.0.md` | 核心策划方案 |
|
||||
| `docs/Level1Design.md` | 第一关关卡与小怪行为设计 |
|
||||
| `docs/new1.md` | 策划需求:节奏输入有效范围(判定门控) |
|
||||
| `docs/new2.md` | 策划需求:标题固定与出生点左移 |
|
||||
| `docs/new3.md` | 策划需求:怪物与 Boss 行为设定 |
|
||||
| `docs/decisions.md` | 迁移、调试、玩法手感和技术决策日志 |
|
||||
|
||||
注意:`new1.md` / `new3.md` 的原文与最终实现有出入(例如 new1 的"零代价无效输入"被改为"MISS + 锁定"、new3 的韧性系统被霸体规则替代、720 射程被否决)——最终口径以 `docs/decisions.md` 顶部的 "2026-07-05 new1/new2/new3 需求落地" 条目为准。
|
||||
|
||||
## 打包与发布
|
||||
|
||||
项目已具备完整的 Windows 打包链路:
|
||||
|
||||
- 导出预设:`export_presets.cfg`(已入库)——预设名 `"Windows Desktop"`,x86_64,`embed_pck = true`(单 exe 内嵌资源包)。
|
||||
- 产物路径:`build/windows/Rthythm_archor_game.exe`(约 143MB)。
|
||||
- 前提:Godot 4.6.3-stable 导出模板需安装在 `%APPDATA%\Godot\export_templates\4.6.3.stable\`(目录名必须精确匹配引擎版本号去掉 `.official.<hash>` 后缀;升级引擎版本时需换用对应版本的模板包)。
|
||||
|
||||
重新打包只需两条命令:
|
||||
|
||||
```bash
|
||||
<godot> --headless --path . --import
|
||||
<godot> --headless --path . --export-release "Windows Desktop" "build/windows/Rthythm_archor_game.exe"
|
||||
```
|
||||
|
||||
打包注意事项:导出包内 `DirAccess` 列出的资源名带 `.remap` / `.import` 后缀,运行时扫描 `res://` 的代码必须去后缀还原原名再 `load()` 并去重(`action_resolver.gd` 等三处已按此模式修复);新增运行时扫描代码时务必遵守,更稳的做法是预加载资源列表。
|
||||
|
||||
## Godot MCP
|
||||
|
||||
项目包含 Godot MCP 插件:
|
||||
|
||||
- `addons/godot_mcp/`
|
||||
- `project.godot` 中已启用插件
|
||||
- Autoload 注册了 `MCPScreenshot`、`MCPInputService`、`MCPGameInspector`
|
||||
|
||||
这允许外部工具对 Godot 运行场景进行截图、输入模拟、节点检查和自动化测试。命令行 headless import 时如果已有其他项目占用 MCP 端口,可能出现 project mismatch warning;通常不影响导入或测试结果。
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
已实现:
|
||||
|
||||
- 标题、难度选择、暂停、确认、胜负和结算流程
|
||||
- 第一关 6 小怪流程与 Boss 房解锁
|
||||
- 玩家从地图偏左出生(x=1180),开场向右推进遇敌(2026-07-05 new2)
|
||||
- 判定门控与输入锁定:节奏点一次性消耗、双击容错、空按 MISS + 0.5 秒锁定(2026-07-05 new1 定案)
|
||||
- 小怪全部限制在 Boss 空气墙左侧安全区
|
||||
- Boss 区域入场后 Boss HUD 立即显示
|
||||
- Boss 区域空气墙进入后完全消失,不再显示或阻挡
|
||||
- 玩家节奏输入、四拍槽、动作执行
|
||||
- 能量、蓄力、技能释放与剑雨落点对齐
|
||||
- 玩家 projectile 与敌人 projectile
|
||||
- 小女孩 / 女巫高度对齐,远程水平射击
|
||||
- 小怪节拍攻击、朝向、强弱打断规则
|
||||
- new3 怪物 / Boss 行为体系:近战基础攻击 50 / 远程 25、距离参数重调、远程怪受击 4 次闪烁无敌跳跃脱离、射程压到视距内、Boss 普攻恒霸体 + 耗能技能压制、Boss 远程相位近身后撤、相位切换停顿(2026-07-05)
|
||||
- Boss 行为树与谱面驱动动作
|
||||
- 命中、伤害、击退、打断、防御和生命结算
|
||||
- Effect / Buff / Debuff 管线
|
||||
- Past / Future 时间相位
|
||||
- 时间锚点调度、结算和放大视觉反馈
|
||||
- 攻击 Buff 7 层显示、伤害加成和玩家特效增强
|
||||
- 双音乐层同步切换
|
||||
- 世界色调与径向相位切换 wipe
|
||||
- HUD:节奏条、combo、ATK、Buff、判定艺术字、状态栏、Boss 栏、四拍槽、E 键招式表
|
||||
- Windows 打包链路(export_presets.cfg + 单 exe 内嵌 pck,打包版已做端到端验证)
|
||||
- 自动化测试覆盖关键系统(66 个测试)
|
||||
|
||||
仍可继续打磨:
|
||||
|
||||
- 替换正式 Future 音乐层
|
||||
- 继续扩展 Boss 相位差异和条件锚点
|
||||
- 增加更多正式谱面与战斗段落
|
||||
- 继续调优 HUD 美术尺寸、透明度和可读性
|
||||
- 扩展小怪组合与关卡后续段落
|
||||
- 播测校准 2026-07-05 数值:近战小怪强势单跳约 38、Boss 近战翻倍后的压力、高连击档位下单次失误(断连击 + 锁定 + 锚点必破)的代价
|
||||
- 进战斗场景瞬间批量启停碰撞体会刷非致命的 "flushing queries" 物理警告,可改 deferred 消除
|
||||
|
||||
## 开发约定
|
||||
|
||||
- `EventBus` 是系统间事实广播中心,避免跨系统硬引用。
|
||||
- `TimePhaseManager` 是时间相位唯一写入者。
|
||||
- `TimeAnchorSystem` 只消费判定事实,不参与输入链路裁决。
|
||||
- `GameFlowManager` 管理菜单、暂停、难度、胜负和 BossRoom 状态。
|
||||
- 动作数值优先写入 `ActionData` 资源,不硬编码在角色脚本中。
|
||||
- Buff / Debuff 优先走 `EffectContainer` 和 effect 资源。
|
||||
- 战斗数值通过 resolver 计算,减少场景脚本间耦合。
|
||||
- UI 尽量订阅事实信号,不主动推导战斗状态。
|
||||
- 关卡坐标优先通过 `Stage` / `LevelDirector` 导出参数表达,并补测试锁定。
|
||||
- 新功能或 bugfix 应补充对应 `tests/test_*.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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://csmce0ikcxvhv
|
||||
@@ -0,0 +1,518 @@
|
||||
@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
|
||||
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
|
||||
# Find res:// paths in file content
|
||||
var idx := 0
|
||||
while idx < content.length():
|
||||
var found := content.find("res://", idx)
|
||||
if found == -1:
|
||||
break
|
||||
# Extract the path (up to quote, space, or end of line)
|
||||
var end := found + 6
|
||||
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)
|
||||
referenced[ref_path] = true
|
||||
idx = end
|
||||
|
||||
# Step 4: Find unreferenced resources
|
||||
var unused: Array = []
|
||||
for res_path in all_resources:
|
||||
var p: String = res_path
|
||||
if not referenced.has(p):
|
||||
# Also check without uid:// prefix variants — some references use uid
|
||||
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)
|
||||
if connections.size() > 0:
|
||||
var targets: Array = []
|
||||
for conn in connections:
|
||||
var callable: Callable = conn["callable"]
|
||||
var target_node: Node = callable.get_object() as Node
|
||||
var target_path := ""
|
||||
if target_node != null:
|
||||
target_path = str(root.get_path_to(target_node))
|
||||
targets.append({
|
||||
"target_node": target_path,
|
||||
"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(),
|
||||
})
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://do5j22vn55a4h
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://di2ucc53dq8bx
|
||||
@@ -0,0 +1,237 @@
|
||||
@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
|
||||
|
||||
var lib := player.get_animation_library("")
|
||||
if lib == null:
|
||||
lib = AnimationLibrary.new()
|
||||
player.add_animation_library("", lib)
|
||||
|
||||
lib.add_animation(anim_name, anim)
|
||||
|
||||
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.add_track(track_type)
|
||||
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": anim.value_track_set_update_mode(track_idx, Animation.UPDATE_CONTINUOUS)
|
||||
"discrete": anim.value_track_set_update_mode(track_idx, Animation.UPDATE_DISCRETE)
|
||||
"capture": anim.value_track_set_update_mode(track_idx, Animation.UPDATE_CAPTURE)
|
||||
|
||||
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 key_idx := anim.track_insert_key(track_index, time, value)
|
||||
|
||||
var easing: float = float(params.get("easing", 1.0))
|
||||
if easing != 1.0:
|
||||
anim.track_set_key_transition(track_index, key_idx, easing)
|
||||
|
||||
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)
|
||||
|
||||
lib.remove_animation(anim_name)
|
||||
return success({"name": anim_name, "removed": true})
|
||||
@@ -0,0 +1 @@
|
||||
uid://ng11e1gsaj73
|
||||
@@ -0,0 +1,563 @@
|
||||
@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)
|
||||
|
||||
parent.add_child(tree, true)
|
||||
tree.owner = root
|
||||
|
||||
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/<name>/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
|
||||
var connections: Array = []
|
||||
# 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/<idx>/<input_node>/<input_port>"
|
||||
# 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)
|
||||
|
||||
sm.add_node(StringName(state_name), node, position)
|
||||
|
||||
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)
|
||||
|
||||
sm.remove_node(StringName(state_name))
|
||||
|
||||
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"])
|
||||
|
||||
sm.add_transition(StringName(from_state), StringName(to_state), transition)
|
||||
|
||||
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])
|
||||
|
||||
sm.remove_transition(StringName(from_state), StringName(to_state))
|
||||
|
||||
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)
|
||||
|
||||
# Remove existing node if replacing
|
||||
if bt.has_node(StringName(bt_node_name)):
|
||||
bt.remove_node(StringName(bt_node_name))
|
||||
|
||||
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)
|
||||
|
||||
bt.add_node(StringName(bt_node_name), node, position)
|
||||
|
||||
# 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():
|
||||
bt.connect_node(StringName(connect_to), connect_port, StringName(bt_node_name))
|
||||
|
||||
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": connect_to if not connect_to.is_empty() else null,
|
||||
"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
|
||||
|
||||
tree.set(parameter, value)
|
||||
|
||||
# Read back to confirm
|
||||
var actual = tree.get(parameter)
|
||||
|
||||
return success({
|
||||
"parameter": parameter,
|
||||
"value": str(actual),
|
||||
"set": true,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnyewiui5yq5h
|
||||
@@ -0,0 +1,429 @@
|
||||
@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"])
|
||||
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 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"])
|
||||
if params.has("unit_size"):
|
||||
(player as AudioStreamPlayer3D).unit_size = float(params["unit_size"])
|
||||
|
||||
parent.add_child(player)
|
||||
player.owner = get_edited_root()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://o7myqm3vv5dp
|
||||
@@ -0,0 +1,133 @@
|
||||
@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)
|
||||
|
||||
|
||||
## 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 get_editor().get_edited_scene_root()
|
||||
|
||||
|
||||
## Get UndoRedo
|
||||
func get_undo_redo() -> EditorUndoRedoManager:
|
||||
return editor_plugin.get_undo_redo()
|
||||
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bohowt8xbeyke
|
||||
@@ -0,0 +1,383 @@
|
||||
@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 = []
|
||||
_batch_set_recursive(root, root, type_name, property, value, affected)
|
||||
|
||||
return success({"property": property, "affected": affected, "count": affected.size()})
|
||||
|
||||
|
||||
func _batch_set_recursive(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array) -> void:
|
||||
if node.is_class(type_name) or node.get_class() == type_name:
|
||||
if property in node:
|
||||
node.set(property, value)
|
||||
affected.append(str(root.get_path_to(node)))
|
||||
for child in node.get_children():
|
||||
_batch_set_recursive(child, root, type_name, property, value, affected)
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Batch add %d nodes" % nodes_data.size())
|
||||
|
||||
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 directly so subsequent nodes can reference this as parent
|
||||
parent.add_child(node)
|
||||
node.set_owner(root)
|
||||
|
||||
# Register undo/redo (do methods re-add on redo, undo removes)
|
||||
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)
|
||||
|
||||
created.append({
|
||||
"index": i,
|
||||
"type": type,
|
||||
"name": str(node.name),
|
||||
"parent": parent_path,
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
})
|
||||
|
||||
# Commit without re-executing do methods (nodes already added)
|
||||
undo_redo.commit_action(false)
|
||||
|
||||
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 scenes_affected: 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 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 = []
|
||||
_cross_scene_set_recursive(instance, instance, type_name, property, value, affected_nodes)
|
||||
|
||||
if not affected_nodes.is_empty():
|
||||
# Pack and save
|
||||
var new_packed := PackedScene.new()
|
||||
new_packed.pack(instance)
|
||||
ResourceSaver.save(new_packed, scene_path)
|
||||
scenes_affected.append({
|
||||
"scene": scene_path,
|
||||
"nodes": affected_nodes,
|
||||
"count": affected_nodes.size(),
|
||||
})
|
||||
total_nodes += affected_nodes.size()
|
||||
|
||||
instance.free()
|
||||
|
||||
# Rescan filesystem so editor picks up changes
|
||||
if not scenes_affected.is_empty():
|
||||
get_editor().get_resource_filesystem().scan()
|
||||
|
||||
return success({
|
||||
"type": type_name,
|
||||
"property": property,
|
||||
"scenes_affected": scenes_affected,
|
||||
"total_scenes": scenes_affected.size(),
|
||||
"total_nodes": total_nodes,
|
||||
})
|
||||
|
||||
|
||||
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_set_recursive(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array) -> void:
|
||||
if node.is_class(type_name) or node.get_class() == type_name:
|
||||
if property in node:
|
||||
node.set(property, value)
|
||||
affected.append(str(root.get_path_to(node)))
|
||||
for child in node.get_children():
|
||||
_cross_scene_set_recursive(child, root, type_name, property, value, affected)
|
||||
|
||||
|
||||
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()})
|
||||
@@ -0,0 +1 @@
|
||||
uid://67nvcoxon6p5
|
||||
@@ -0,0 +1,644 @@
|
||||
@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]
|
||||
|
||||
# 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 _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"),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxs5156xdtne
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqo2ldb6gok1f
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7n50xbscvtl
|
||||
@@ -0,0 +1,151 @@
|
||||
@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)
|
||||
# Skip built-in UI actions unless requested
|
||||
if not include_builtin and action_str.begins_with("ui_"):
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce6ppbync0eke
|
||||
@@ -0,0 +1,464 @@
|
||||
@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 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"])
|
||||
|
||||
node.add_child(region, true)
|
||||
region.owner = get_edited_root()
|
||||
|
||||
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"])
|
||||
|
||||
node.add_child(region, true)
|
||||
region.owner = get_edited_root()
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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 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"])
|
||||
|
||||
node.add_child(agent, true)
|
||||
agent.owner = get_edited_root()
|
||||
|
||||
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"])
|
||||
|
||||
node.add_child(agent, true)
|
||||
agent.owner = get_edited_root()
|
||||
|
||||
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:
|
||||
(node as NavigationRegion2D).navigation_layers = layers_val
|
||||
elif node is NavigationRegion3D:
|
||||
(node as NavigationRegion3D).navigation_layers = layers_val
|
||||
elif node is NavigationAgent2D:
|
||||
(node as NavigationAgent2D).navigation_layers = layers_val
|
||||
elif node is NavigationAgent3D:
|
||||
(node as NavigationAgent3D).navigation_layers = layers_val
|
||||
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:
|
||||
(node as NavigationRegion2D).navigation_layers = current_layers
|
||||
elif node is NavigationRegion3D:
|
||||
(node as NavigationRegion3D).navigation_layers = current_layers
|
||||
elif node is NavigationAgent2D:
|
||||
(node as NavigationAgent2D).navigation_layers = current_layers
|
||||
elif node is NavigationAgent3D:
|
||||
(node as NavigationAgent3D).navigation_layers = current_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:
|
||||
(node as NavigationRegion2D).navigation_layers = current_layers
|
||||
elif node is NavigationRegion3D:
|
||||
(node as NavigationRegion3D).navigation_layers = current_layers
|
||||
elif node is NavigationAgent2D:
|
||||
(node as NavigationAgent2D).navigation_layers = current_layers
|
||||
elif node is NavigationAgent3D:
|
||||
(node as NavigationAgent3D).navigation_layers = current_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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cy2x6yf3qm20e
|
||||
@@ -0,0 +1,687 @@
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 _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 := 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]
|
||||
|
||||
control.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", control.anchor_left)
|
||||
undo_redo.add_do_property(control, "anchor_top", control.anchor_top)
|
||||
undo_redo.add_do_property(control, "anchor_right", control.anchor_right)
|
||||
undo_redo.add_do_property(control, "anchor_bottom", control.anchor_bottom)
|
||||
undo_redo.add_do_property(control, "offset_left", control.offset_left)
|
||||
undo_redo.add_do_property(control, "offset_top", control.offset_top)
|
||||
undo_redo.add_do_property(control, "offset_right", control.offset_right)
|
||||
undo_redo.add_do_property(control, "offset_bottom", control.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])
|
||||
|
||||
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})
|
||||
|
||||
source.connect(signal_name, Callable(target, method_name))
|
||||
|
||||
return success({
|
||||
"source": str(root.get_path_to(source)),
|
||||
"signal": signal_name,
|
||||
"target": str(root.get_path_to(target)),
|
||||
"method": method_name,
|
||||
"connected": 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})
|
||||
|
||||
source.disconnect(signal_name, Callable(target, method_name))
|
||||
|
||||
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 = []
|
||||
|
||||
# Remove groups not in desired
|
||||
for group: String in current_groups:
|
||||
if group not in desired_groups:
|
||||
node.remove_from_group(group)
|
||||
removed.append(group)
|
||||
|
||||
# Add groups not in current
|
||||
for group in desired_groups:
|
||||
var g: String = str(group)
|
||||
if g not in current_groups:
|
||||
node.add_to_group(g, true)
|
||||
added.append(g)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://l06yo5kv0pd1
|
||||
@@ -0,0 +1,582 @@
|
||||
@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
|
||||
|
||||
parent.add_child(particles_node, true)
|
||||
particles_node.owner = root
|
||||
|
||||
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 mat: ParticleProcessMaterial = node.get("process_material")
|
||||
if mat == null:
|
||||
mat = ParticleProcessMaterial.new()
|
||||
node.set("process_material", mat)
|
||||
|
||||
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"])
|
||||
"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"])
|
||||
"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)))
|
||||
"ring":
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING
|
||||
if params.has("emission_ring_radius"):
|
||||
mat.emission_ring_radius = float(params["emission_ring_radius"])
|
||||
if params.has("emission_ring_inner_radius"):
|
||||
mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"])
|
||||
if params.has("emission_ring_height"):
|
||||
mat.emission_ring_height = float(params["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")
|
||||
|
||||
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 mat: ParticleProcessMaterial = node.get("process_material")
|
||||
if mat == null:
|
||||
mat = ParticleProcessMaterial.new()
|
||||
node.set("process_material", mat)
|
||||
|
||||
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")
|
||||
|
||||
var gradient := Gradient.new()
|
||||
# Remove default points
|
||||
while gradient.get_point_count() > 0:
|
||||
gradient.remove_point(0)
|
||||
|
||||
for stop in stops:
|
||||
if stop is Dictionary:
|
||||
var offset: float = float(stop.get("offset", 0.0))
|
||||
var color: Color = _parse_color(str(stop.get("color", "#ffffff")))
|
||||
gradient.add_point(offset, color)
|
||||
|
||||
var grad_tex := GradientTexture1D.new()
|
||||
grad_tex.gradient = gradient
|
||||
mat.color_ramp = grad_tex
|
||||
|
||||
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 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 if is_2d else 9.8, 0)
|
||||
var gravity_up := Vector3(0, -98 if is_2d else -9.8, 0)
|
||||
var gravity_none := Vector3.ZERO
|
||||
|
||||
match preset:
|
||||
"explosion":
|
||||
node.set("amount", 32)
|
||||
node.set("lifetime", 0.6)
|
||||
node.set("one_shot", true)
|
||||
node.set("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":
|
||||
node.set("amount", 24)
|
||||
node.set("lifetime", 1.2)
|
||||
node.set("one_shot", false)
|
||||
node.set("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":
|
||||
node.set("amount", 16)
|
||||
node.set("lifetime", 3.0)
|
||||
node.set("one_shot", false)
|
||||
node.set("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":
|
||||
node.set("amount", 48)
|
||||
node.set("lifetime", 0.4)
|
||||
node.set("one_shot", true)
|
||||
node.set("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":
|
||||
node.set("amount", 64)
|
||||
node.set("lifetime", 0.8)
|
||||
node.set("one_shot", false)
|
||||
node.set("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":
|
||||
node.set("amount", 48)
|
||||
node.set("lifetime", 4.0)
|
||||
node.set("one_shot", false)
|
||||
node.set("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":
|
||||
node.set("amount", 24)
|
||||
node.set("lifetime", 2.0)
|
||||
node.set("one_shot", false)
|
||||
node.set("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":
|
||||
node.set("amount", 12)
|
||||
node.set("lifetime", 5.0)
|
||||
node.set("one_shot", false)
|
||||
node.set("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)
|
||||
|
||||
node.set("process_material", mat)
|
||||
|
||||
return success({"node_path": node_path, "preset": preset, "applied": true})
|
||||
|
||||
|
||||
func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void:
|
||||
var gradient := Gradient.new()
|
||||
# Remove default points before adding custom stops
|
||||
for i in range(gradient.get_point_count() - 1, -1, -1):
|
||||
gradient.remove_point(i)
|
||||
for stop in stops:
|
||||
gradient.add_point(stop["offset"], stop["color"])
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://brhvd8o3eo2av
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d01frowxu8aoy
|
||||
@@ -0,0 +1,69 @@
|
||||
@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:
|
||||
# Return all available performance monitors
|
||||
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)
|
||||
|
||||
# Filter by category if requested
|
||||
var category: String = optional_string(params, "category", "")
|
||||
if not category.is_empty():
|
||||
var filtered := {}
|
||||
for key: String in monitors:
|
||||
if key.begins_with(category):
|
||||
filtered[key] = monitors[key]
|
||||
return success({"monitors": filtered, "category": category})
|
||||
|
||||
return success({"monitors": monitors})
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1k2vr51iv0ur
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://dasry43axv6qm
|
||||
@@ -0,0 +1,189 @@
|
||||
@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 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 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 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 := resource.get(prop_name)
|
||||
resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current)))
|
||||
|
||||
var err := ResourceSaver.save(resource, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save resource: %s" % error_string(err))
|
||||
|
||||
# Rescan filesystem
|
||||
get_editor().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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://nb466gyf17iu
|
||||
@@ -0,0 +1,398 @@
|
||||
@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 error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
|
||||
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
|
||||
})
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
## Press the debugger "Continue" button to resume a paused game process.
|
||||
func _try_debugger_continue() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var queue: Array[Node] = [base]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
var inner: Array[Node] = [node]
|
||||
while not inner.is_empty():
|
||||
var n := inner.pop_front()
|
||||
if n is Button and n.tooltip_text == "Continue":
|
||||
n.emit_signal("pressed")
|
||||
push_warning("[MCP] Auto-resumed debugger after runtime error")
|
||||
return
|
||||
for c in n.get_children():
|
||||
inner.append(c)
|
||||
return
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5x2k2g6nfn1p
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0genhd54bvpt
|
||||
@@ -0,0 +1,310 @@
|
||||
@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, 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 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
|
||||
get_editor().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)
|
||||
|
||||
get_editor().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)
|
||||
|
||||
get_editor().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
|
||||
var ei := get_editor()
|
||||
|
||||
match mode:
|
||||
"main":
|
||||
ei.play_main_scene()
|
||||
"current":
|
||||
ei.play_current_scene()
|
||||
_:
|
||||
# Treat as scene path
|
||||
if not FileAccess.file_exists(mode):
|
||||
return error_not_found("Scene file '%s'" % mode)
|
||||
ei.play_custom_scene(mode)
|
||||
|
||||
return success({"playing": true, "mode": mode})
|
||||
|
||||
|
||||
func _stop_scene(params: Dictionary) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return success({"stopped": false, "message": "No scene is currently playing"})
|
||||
|
||||
ei.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 scene := PackedScene.new()
|
||||
var err := scene.pack(root)
|
||||
if err != OK:
|
||||
return error_internal("Failed to pack scene: %s" % error_string(err))
|
||||
|
||||
err = ResourceSaver.save(scene, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save scene: %s" % error_string(err))
|
||||
|
||||
return success({"path": path, "saved": true})
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfej2ub6lorfy
|
||||
@@ -0,0 +1,310 @@
|
||||
@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 _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 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", "")
|
||||
|
||||
# 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()
|
||||
|
||||
get_editor().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]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Script '%s'" % path)
|
||||
|
||||
# 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 old_content := content
|
||||
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 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
|
||||
get_editor().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.
|
||||
get_editor().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]
|
||||
|
||||
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 := get_editor().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()})
|
||||
@@ -0,0 +1 @@
|
||||
uid://luyrk86h6aii
|
||||
@@ -0,0 +1,226 @@
|
||||
@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")
|
||||
|
||||
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()
|
||||
|
||||
get_editor().get_resource_filesystem().scan()
|
||||
|
||||
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 _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 changes_made := 0
|
||||
|
||||
if params.has("content"):
|
||||
# Full replacement
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Cannot write shader")
|
||||
file.store_string(str(params["content"]))
|
||||
file.close()
|
||||
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")
|
||||
var 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
|
||||
|
||||
file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
if changes_made > 0:
|
||||
# Reload shader resource
|
||||
get_editor().get_resource_filesystem().scan()
|
||||
if ResourceLoader.exists(path):
|
||||
var shader = load(path)
|
||||
if shader is Shader:
|
||||
shader.reload_from_file()
|
||||
|
||||
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:
|
||||
(node as CanvasItem).material = material
|
||||
elif node is MeshInstance3D:
|
||||
(node as MeshInstance3D).material_override = material
|
||||
else:
|
||||
# Try generic material property
|
||||
if "material" in node:
|
||||
node.set("material", 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})
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbvpq7u2jbde8
|
||||
@@ -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
|
||||
|
||||
"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,
|
||||
}
|
||||
|
||||
# Store results for get_test_report
|
||||
_test_results.append_array(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)
|
||||
|
||||
# Store for test report
|
||||
if result.has("result"):
|
||||
_test_results.append(result["result"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
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:
|
||||
var passed: bool = result.get("passed", false)
|
||||
if passed:
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
details.append(result)
|
||||
|
||||
var report := {
|
||||
"total": _test_results.size(),
|
||||
"passed": pass_count,
|
||||
"failed": fail_count,
|
||||
"pass_rate": ("%.1f%%" % (100.0 * pass_count / _test_results.size())) if not _test_results.is_empty() else "N/A",
|
||||
"all_passed": fail_count == 0 and not _test_results.is_empty(),
|
||||
"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),
|
||||
})
|
||||
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
|
||||
for element: Dictionary in elements:
|
||||
var element_text: String = str(element.get("text", ""))
|
||||
if partial and element_text.contains(expected_text):
|
||||
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
|
||||
elif not partial and element_text == expected_text:
|
||||
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
|
||||
|
||||
return {"passed": false, "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("result"):
|
||||
return result["result"]
|
||||
elif result.has("error"):
|
||||
return {"passed": false, "error": str(result["error"])}
|
||||
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 error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
|
||||
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
|
||||
})
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
## Press the debugger "Continue" button to resume a paused game process.
|
||||
func _try_debugger_continue() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var queue: Array[Node] = [base]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
var inner: Array[Node] = [node]
|
||||
while not inner.is_empty():
|
||||
var n := inner.pop_front()
|
||||
if n is Button and n.tooltip_text == "Continue":
|
||||
n.emit_signal("pressed")
|
||||
push_warning("[MCP] Auto-resumed debugger after runtime error")
|
||||
return
|
||||
for c in n.get_children():
|
||||
inner.append(c)
|
||||
return
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
|
||||
|
||||
# ── 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b785wka7v3u4d
|
||||
@@ -0,0 +1,331 @@
|
||||
@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 err := ResourceSaver.save(theme, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save theme: %s" % error_string(err))
|
||||
|
||||
get_editor().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()
|
||||
|
||||
control.add_theme_color_override(color_name, color)
|
||||
|
||||
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))
|
||||
|
||||
control.add_theme_constant_override(const_name, value)
|
||||
|
||||
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))
|
||||
|
||||
control.add_theme_font_size_override(font_name, size)
|
||||
|
||||
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
|
||||
|
||||
control.add_theme_stylebox_override(style_name, stylebox)
|
||||
|
||||
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 = []
|
||||
|
||||
# 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):
|
||||
control.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:
|
||||
control.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):
|
||||
control.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):
|
||||
control.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 control is MarginContainer:
|
||||
if margins.has("left"):
|
||||
control.add_theme_constant_override("margin_left", int(margins["left"]))
|
||||
if margins.has("top"):
|
||||
control.add_theme_constant_override("margin_top", int(margins["top"]))
|
||||
if margins.has("right"):
|
||||
control.add_theme_constant_override("margin_right", int(margins["right"]))
|
||||
if margins.has("bottom"):
|
||||
control.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 control is BoxContainer:
|
||||
control.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):
|
||||
control.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):
|
||||
control.grow_vertical = grow_map[grow_v]
|
||||
applied.append("grow_v=%s" % grow_v)
|
||||
|
||||
return success({"node_path": node_path, "applied": applied, "count": applied.size()})
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bx6pai02qmme7
|
||||
@@ -0,0 +1,163 @@
|
||||
@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_path: String) -> TileMapLayer:
|
||||
var node := find_node_by_path(node_path)
|
||||
if node is TileMapLayer:
|
||||
return node as TileMapLayer
|
||||
return null
|
||||
|
||||
|
||||
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_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
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))
|
||||
|
||||
tilemap.set_cell(Vector2i(x, y), source_id, Vector2i(atlas_x, atlas_y), alternative)
|
||||
|
||||
return success({"x": x, "y": y, "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_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
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
|
||||
for cx in range(mini(x1, x2), maxi(x1, x2) + 1):
|
||||
for cy in range(mini(y1, y2), maxi(y1, y2) + 1):
|
||||
tilemap.set_cell(Vector2i(cx, cy), source_id, Vector2i(atlas_x, atlas_y), alternative)
|
||||
count += 1
|
||||
|
||||
return success({"filled": count, "rect": [x1, y1, x2, y2]})
|
||||
|
||||
|
||||
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_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var x: int = int(params.get("x", 0))
|
||||
var y: int = int(params.get("y", 0))
|
||||
var coords := Vector2i(x, y)
|
||||
|
||||
var source_id := tilemap.get_cell_source_id(coords)
|
||||
var atlas_coords := tilemap.get_cell_atlas_coords(coords)
|
||||
var alternative := tilemap.get_cell_alternative_tile(coords)
|
||||
|
||||
return success({
|
||||
"x": x, "y": y,
|
||||
"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_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
tilemap.clear()
|
||||
return success({"cleared": true})
|
||||
|
||||
|
||||
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_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var tile_set := tilemap.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)
|
||||
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"used_cells": tilemap.get_used_cells().size(),
|
||||
"tile_set_sources": sources,
|
||||
"tile_size": [tile_set.tile_size.x, tile_set.tile_size.y] if tile_set else [0, 0],
|
||||
})
|
||||
|
||||
|
||||
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_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var max_count: int = optional_int(params, "max_count", 500)
|
||||
var cells: Array = []
|
||||
var used := tilemap.get_used_cells()
|
||||
|
||||
for i in mini(used.size(), max_count):
|
||||
var pos: Vector2i = used[i]
|
||||
cells.append({"x": pos.x, "y": pos.y, "source_id": tilemap.get_cell_source_id(pos)})
|
||||
|
||||
return success({"cells": cells, "total": used.size(), "returned": cells.size()})
|
||||
@@ -0,0 +1 @@
|
||||
uid://2bsflxvsq7a5
|
||||
@@ -0,0 +1 @@
|
||||
uid://bivslqr2p1447
|
||||
@@ -0,0 +1,199 @@
|
||||
## 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) and events with "unhandled" flag
|
||||
## use push_input to bypass GUI consumption and reach _unhandled_input().
|
||||
## This fixes camera pan/drag not working when UI Controls consume mouse events.
|
||||
func _dispatch_event(event: InputEvent, event_data: Dictionary = {}) -> void:
|
||||
# Mouse drag motions (button_mask > 0) auto-promote to push_input to bypass
|
||||
# GUI consumption — needed for camera-pan. But for UI drag-and-drop testing
|
||||
# we want events to reach the GUI dispatcher. Respect an explicit
|
||||
# "unhandled": false in the event payload; only auto-promote when the
|
||||
# caller did NOT pass an "unhandled" key. Default behavior preserved.
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1furnhig18hg
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmhsrun0pw6ig
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="Godot MCP Pro"
|
||||
description="通过 AI 直接控制 Godot 编辑器的专业插件,提供 172+ 个编辑器工具。"
|
||||
author="wxm"
|
||||
version="1.13.2"
|
||||
script="plugin.gd"
|
||||
@@ -0,0 +1,188 @@
|
||||
@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:
|
||||
_init_plugin()
|
||||
|
||||
|
||||
func _init_plugin() -> void:
|
||||
"""Initialize plugin components."""
|
||||
# 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
|
||||
if node is Button:
|
||||
var btn: Button = node
|
||||
if btn.tooltip_text.contains("Continue") or btn.text == "Continue":
|
||||
return btn
|
||||
for child in node.get_children():
|
||||
var found: Button = _find_debugger_continue_button(child)
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d36bcgxlvqnng
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://3l5qwyrqbsgn
|
||||
@@ -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")
|
||||
@@ -0,0 +1,76 @@
|
||||
@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
|
||||
static func get_node_tree(node: Node, max_depth: int = -1, current_depth: int = 0) -> Dictionary:
|
||||
var result := {
|
||||
"name": node.name,
|
||||
"type": node.get_class(),
|
||||
"path": str(node.get_path()),
|
||||
}
|
||||
|
||||
# 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, 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://f0ek7wa1gkmy
|
||||
@@ -0,0 +1,203 @@
|
||||
@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 {}
|
||||
_:
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dihc7toqvjjhj
|
||||
@@ -0,0 +1,275 @@
|
||||
@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 _rejected_ports: Dictionary = {} # port -> true (handshake rejected by server)
|
||||
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()
|
||||
_connect_times.clear()
|
||||
_rejected_ports.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 (skip rejected ports)
|
||||
if ws == null:
|
||||
if _rejected_ports.get(p, false):
|
||||
continue
|
||||
_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)
|
||||
_send_handshake(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()
|
||||
# Check for project-mismatch rejection (close code 1008)
|
||||
if ws and ws.get_close_code() == 1008:
|
||||
_rejected_ports[p] = true
|
||||
push_warning("[MCP] Port %d rejected by server (project mismatch), won't retry" % p)
|
||||
if not _rejected_ports.get(p, false):
|
||||
_peers[p] = null
|
||||
_timers[p] = 0.0
|
||||
else:
|
||||
_peers[p] = null # still clear peer, timer stays 0 to block reconnect
|
||||
_last_activity[p] = 0.0
|
||||
_ping_timers[p] = 0.0
|
||||
|
||||
WebSocketPeer.STATE_CONNECTING:
|
||||
pass
|
||||
|
||||
|
||||
func _send_handshake(p: int) -> void:
|
||||
var project_path := ProjectSettings.globalize_path("res://").replace("\\", "/").rstrip("/").to_lower()
|
||||
var msg := JSON.stringify({
|
||||
"method": "handshake",
|
||||
"params": {"project_path": project_path}
|
||||
})
|
||||
_send_to_port(p, msg)
|
||||
|
||||
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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs6o0t7258m5x
|
||||
|
After Width: | Height: | Size: 740 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://32w0yxa63p34"
|
||||
path="res://.godot/imported/2_way_shoot_gun_1.png-836bd6d9c129b9a42da29ea92cf33f59.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/2_way_shoot_gun_1.png"
|
||||
dest_files=["res://.godot/imported/2_way_shoot_gun_1.png-836bd6d9c129b9a42da29ea92cf33f59.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
|
||||
|
After Width: | Height: | Size: 738 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dptqlwupasm1m"
|
||||
path="res://.godot/imported/2_way_shoot_gun_2.png-e617c7ad6d9ff6ef64b0751ca2c17ce4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/2_way_shoot_gun_2.png"
|
||||
dest_files=["res://.godot/imported/2_way_shoot_gun_2.png-e617c7ad6d9ff6ef64b0751ca2c17ce4.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
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cxdx0qxq5vsv1"
|
||||
path="res://.godot/imported/air_combo_1.png-46f8a7605ed3835f567f28ada730602c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/air_combo_1.png"
|
||||
dest_files=["res://.godot/imported/air_combo_1.png-46f8a7605ed3835f567f28ada730602c.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
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://xtgdjwdetlv7"
|
||||
path="res://.godot/imported/air_combo_2.png-285f4eaf93bc14da5cc7a80d7c0e71b9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/air_combo_2.png"
|
||||
dest_files=["res://.godot/imported/air_combo_2.png-285f4eaf93bc14da5cc7a80d7c0e71b9.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
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bynbutty3wol1"
|
||||
path="res://.godot/imported/air_combo_3.png-48729080eefe4179fc1678034a955d1e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/air_combo_3.png"
|
||||
dest_files=["res://.godot/imported/air_combo_3.png-48729080eefe4179fc1678034a955d1e.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
|
||||
|
After Width: | Height: | Size: 823 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cog6ryblxfsbu"
|
||||
path="res://.godot/imported/air_shoot_gun_1.png-f2596b973ce068b4feb1a666a507649d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/air_shoot_gun_1.png"
|
||||
dest_files=["res://.godot/imported/air_shoot_gun_1.png-f2596b973ce068b4feb1a666a507649d.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
|
||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bfrmmmbmuk5i8"
|
||||
path="res://.godot/imported/air_shoot_gun_2.png-6da5f2bbf321ec085b82fc58c4ab28e2.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/air_shoot_gun_2.png"
|
||||
dest_files=["res://.godot/imported/air_shoot_gun_2.png-6da5f2bbf321ec085b82fc58c4ab28e2.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
|
||||
|
After Width: | Height: | Size: 740 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b1c3a2umxrw6d"
|
||||
path="res://.godot/imported/boss_2way_shoot_1.png-105e5ca67b10b13e7cad96d51ee06ffc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/art/characters/boss/boss_2way_shoot_1.png"
|
||||
dest_files=["res://.godot/imported/boss_2way_shoot_1.png-105e5ca67b10b13e7cad96d51ee06ffc.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
|
||||
|
After Width: | Height: | Size: 738 B |