Compare commits
23
Commits
bffa517ed4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b19b404a | ||
|
|
3fd2c89020 | ||
|
|
0dc80ff144 | ||
|
|
34f698e5fb | ||
|
|
9f1e293d4c | ||
|
|
682b79852d | ||
|
|
22d1aa6a93 | ||
|
|
354ac8a58c | ||
|
|
d19d337bf2 | ||
|
|
dfcaf7e4d0 | ||
|
|
39b0aee2c9 | ||
|
|
920774d7d7 | ||
|
|
29236e8e1c | ||
|
|
938df26cbb | ||
|
|
e8cc0162af | ||
|
|
5195986fc4 | ||
|
|
2afc2cade6 | ||
|
|
81928c6d33 | ||
|
|
56343db806 | ||
|
|
1bfd657f99 | ||
|
|
99e9ec1434 | ||
|
|
1b19054da1 | ||
|
|
353d3940a9 |
@@ -1,27 +0,0 @@
|
|||||||
<!-- rtk-instructions v2 -->
|
|
||||||
# RTK — Token-Optimized CLI
|
|
||||||
|
|
||||||
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
|
|
||||||
|
|
||||||
## Rule
|
|
||||||
|
|
||||||
Always prefix shell commands with `rtk`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Instead of: Use:
|
|
||||||
git status rtk git status
|
|
||||||
git log -10 rtk git log -10
|
|
||||||
cargo test rtk cargo test
|
|
||||||
docker ps rtk docker ps
|
|
||||||
kubectl get pods rtk kubectl pods
|
|
||||||
```
|
|
||||||
|
|
||||||
## Meta commands (use directly)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rtk gain # Token savings dashboard
|
|
||||||
rtk gain --history # Per-command savings history
|
|
||||||
rtk discover # Find missed rtk opportunities
|
|
||||||
rtk proxy <cmd> # Run raw (no filtering) but track usage
|
|
||||||
```
|
|
||||||
<!-- /rtk-instructions -->
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PreToolUse": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "rtk hook copilot",
|
|
||||||
"cwd": ".",
|
|
||||||
"timeout": 5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# 环境变量 / 凭据(.env.example 模板除外)
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
!*.env.example
|
||||||
|
|
||||||
|
# vps-xray:部署脚本生成的真实节点配置,含 UUID / 私钥 / 服务器 IP
|
||||||
|
# vps-xray/client-config/*-info*.md
|
||||||
|
# vps-xray/client-config/*.yaml
|
||||||
|
# vps-xray/client-config/*.yml
|
||||||
|
|
||||||
|
# 备份产物
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# 系统 / 编辑器
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
# Xray 定时重启:可配置的「每 N 天 + 固定时间点」
|
||||||
|
|
||||||
|
日期:2026-08-06
|
||||||
|
涉及文件:`vps-xray/deploy.sh`、`vps-xray/.env.example`、`vps-xray/uninstall.sh`、`vps-xray/README.md`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 `deploy.sh` 只有一个布尔开关 `XRAY_DAILY_RESTART`,为真时创建 `OnCalendar=*-*-* 04:00:00` 的每日重启 timer,为假时删除该 timer。默认关闭,理由是崩溃恢复已由 `Restart=always` + `RestartSec=3` 覆盖(秒级),而每日硬重启会切断全部活动连接。
|
||||||
|
|
||||||
|
这个判断本身没变,但「一天一次」粒度太粗:想要「每 7 天凌晨 4 点重启一次」这种低频维护性重启时,只能在「每天重启」和「完全不重启」之间二选一。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 支持任意 N 天间隔 + 指定时间点,如「每 7 天 04:00」「每 3 天 05:30」
|
||||||
|
2. 保持默认关闭,已部署机器重跑脚本时行为不变
|
||||||
|
3. 提供一个轻量命令行入口,只改重启计划,不触碰 Xray 与现有 vless 链接
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不做「每月/每季度」等日历语义(真有需要时用户可直接编辑 timer)
|
||||||
|
- 不做多时间点(如一天重启两次)
|
||||||
|
- 不改动 `Restart=always` 这一层崩溃恢复逻辑
|
||||||
|
|
||||||
|
## 关键约束:systemd 表达不了任意 N 天
|
||||||
|
|
||||||
|
`OnCalendar` 的 `*-*-1/7 04:00:00` 是「每月的 1、8、15、22、29 号」,**跨月会重置**——29 号到下月 1 号只隔 2~3 天。因此不能直接用 `OnCalendar` 表达「每 N 天」。
|
||||||
|
|
||||||
|
`OnUnitActiveSec=7d` 是真实间隔,但无法钉住具体时间点,且会随每次触发缓慢漂移。
|
||||||
|
|
||||||
|
本设计采用第三种:**timer 每天到点唤醒,由 service 里的守卫脚本决定这次要不要真的重启。**
|
||||||
|
|
||||||
|
## 配置模型
|
||||||
|
|
||||||
|
`.env` 三个字段:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XRAY_RESTART_EVERY_DAYS=0 # 0 或缺失 = 关闭;N = 每 N 天重启一次
|
||||||
|
XRAY_RESTART_TIME=04:00 # 24 小时制 HH:MM,默认 04:00
|
||||||
|
XRAY_RESTART_TIMEZONE=Asia/Shanghai # 默认保持现状
|
||||||
|
```
|
||||||
|
|
||||||
|
首次部署默认 `0`(关闭),与当前行为一致。
|
||||||
|
|
||||||
|
### 旧字段迁移
|
||||||
|
|
||||||
|
只在 `XRAY_RESTART_EVERY_DAYS` **未设置**时才回退去读 `XRAY_DAILY_RESTART`:
|
||||||
|
|
||||||
|
| 旧值 | 迁移结果 |
|
||||||
|
|---|---|
|
||||||
|
| `true` / `yes` / `on` / `1`(大小写不敏感) | `XRAY_RESTART_EVERY_DAYS=1` |
|
||||||
|
| 其它或缺失 | `XRAY_RESTART_EVERY_DAYS=0` |
|
||||||
|
|
||||||
|
`save_env` 之后只写新字段,不再写 `XRAY_DAILY_RESTART`,`.env` 随重跑自动换代。两个字段同时存在时以新字段为准。
|
||||||
|
|
||||||
|
现存四台服务器的 `.env` 都是 `XRAY_DAILY_RESTART=false`,迁移后为 `0`,行为不变。
|
||||||
|
|
||||||
|
## 实现架构
|
||||||
|
|
||||||
|
```
|
||||||
|
xray-restart.timer OnCalendar=*-*-* <HH:MM>:00 每天到点唤醒
|
||||||
|
TimeZone=<tz>
|
||||||
|
Persistent=true
|
||||||
|
↓
|
||||||
|
xray-restart.service Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/xray-restart-guard
|
||||||
|
↓
|
||||||
|
xray-restart-guard 读 /var/lib/xray/last-restart
|
||||||
|
距上次不足 N 天 → 打日志后 exit 0
|
||||||
|
满 N 天 → 写入新时间戳 → systemctl restart xray
|
||||||
|
```
|
||||||
|
|
||||||
|
「每 N 天」因此是**距上次实际重启**的真实间隔,不受跨月重置影响,N=3 与 N=7 同样准确。
|
||||||
|
|
||||||
|
### 守卫脚本的两个关键细节
|
||||||
|
|
||||||
|
**300 秒容差。** systemd timer 默认 `AccuracySec=1min`,第 N 次触发的实际间隔可能是 `N×86400 - 30s`。严格比较会判定「不满 N 天」从而顺延整整一天,且误差会持续累积。阈值取 `N×86400 - 300` 规避。容差远小于一天,不会造成重复触发。
|
||||||
|
|
||||||
|
**先写时间戳,再重启。** `systemctl restart xray` 会中断 guard 所在的 systemd 事务。顺序反了会丢失时间戳记录,导致每天都重启。代价是:重启失败时时间戳仍已更新,本轮被跳过——这比陷入每日重启循环可接受。
|
||||||
|
|
||||||
|
### 状态文件
|
||||||
|
|
||||||
|
`/var/lib/xray/last-restart`,内容为一个 Unix 时间戳。guard 以 root 运行,读写无权限问题。内容非法(非纯数字)时按 `0` 处理,即立即允许重启。
|
||||||
|
|
||||||
|
## 参数校验
|
||||||
|
|
||||||
|
在脚本早期(`.env` 载入后)完成,不合法直接报错退出,**不静默回退到默认值**——避免用户以为设了每 7 天、实际在每天重启。
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| `XRAY_RESTART_EVERY_DAYS` | `^[0-9]+$` |
|
||||||
|
| `XRAY_RESTART_TIME` | `^([01]?[0-9]\|2[0-3]):[0-5][0-9]$`,校验通过后**补零归一化**为 `HH:MM` |
|
||||||
|
| `XRAY_RESTART_TIMEZONE` | 非空即可,交由 systemd 校验并在启动失败时报错 |
|
||||||
|
|
||||||
|
时间同时接受 `4:00` 与 `04:00`,但写入 `.env` 和 timer 前一律归一化成两位小时(`04:00`),保证 `OnCalendar` 字符串格式统一、`.env` 内容可预测。
|
||||||
|
|
||||||
|
## `--restart` 轻量入口
|
||||||
|
|
||||||
|
### 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy.sh --restart 7 04:00 # 每 7 天 04:00
|
||||||
|
bash deploy.sh --restart 3 # 每 3 天,时间沿用 .env
|
||||||
|
bash deploy.sh --restart 0 # 关闭
|
||||||
|
bash deploy.sh --restart # 按 .env 现有值重建 timer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数解析
|
||||||
|
|
||||||
|
`--restart` 后可跟 0~2 个位置参数,按形态判断,不会误吞后续选项:
|
||||||
|
|
||||||
|
- 下一个 token 匹配 `^[0-9]+$` → 取作 N
|
||||||
|
- 再下一个匹配 `^([01]?[0-9]|2[0-3]):[0-5][0-9]$` → 取作 HH:MM
|
||||||
|
- 不匹配则不消费,沿用 `.env` 现值
|
||||||
|
|
||||||
|
`--restart` 与 `--mode` / `--show` / `--redetect` 互斥,同时给出直接报错。
|
||||||
|
|
||||||
|
### 执行路径
|
||||||
|
|
||||||
|
在 `main()` 中早退,与 `--show` 同一位置:
|
||||||
|
|
||||||
|
1. 校验 N 与 HH:MM,时间归一化为 `HH:MM`
|
||||||
|
2. 就地更新 `.env`:命令行给了 N 就写 `XRAY_RESTART_EVERY_DAYS`,给了时间才写 `XRAY_RESTART_TIME`;两者都没给则不改 `.env`,只按现值重建 timer。字段不存在时追加(`TIME` 追加时用默认值 `04:00`)
|
||||||
|
3. 调用 `configure_restart_timer()`
|
||||||
|
4. `systemctl daemon-reload`,按需 enable / disable timer
|
||||||
|
5. 打印结果与 `systemctl list-timers xray-restart.timer`
|
||||||
|
|
||||||
|
### 明确不做的事
|
||||||
|
|
||||||
|
不装依赖、不升级 Xray、不生成密钥、不选伪装目标、不重写 `config.json`、不动防火墙与 sysctl、**不重启 xray**。因此 vless 链接与现有连接均不受影响。这些约束会以注释形式写在函数头部。
|
||||||
|
|
||||||
|
### 前置检查
|
||||||
|
|
||||||
|
- `.env` 不存在 → 报错退出(无法确认这是已部署的机器)
|
||||||
|
- xray 主单元不存在 → 报错退出(避免在未部署的机器上留下孤儿 timer)
|
||||||
|
|
||||||
|
## 代码组织
|
||||||
|
|
||||||
|
**timer / service / guard 的创建逻辑抽成独立函数 `configure_restart_timer()`**,由 `harden_system()` 与 `--restart` 路径共用。两条路径各写一份实现迟早会漂移成不一致。
|
||||||
|
|
||||||
|
`.env` 更新走两条不同路径,这是有意的:
|
||||||
|
|
||||||
|
- 完整部署走既有的 `save_env()`(`cat >` 全量重写)
|
||||||
|
- `--restart` 走 `sed` 就地替换 —— 轻量入口只应改自己那两行,不该抹掉用户在 `.env` 里加的注释和自定义字段
|
||||||
|
|
||||||
|
## 关闭与清理
|
||||||
|
|
||||||
|
`XRAY_RESTART_EVERY_DAYS=0` 时:
|
||||||
|
|
||||||
|
- `systemctl disable --now xray-restart.timer`
|
||||||
|
- 删除 `xray-restart.timer`、`xray-restart.service`、`/usr/local/bin/xray-restart-guard`
|
||||||
|
- **保留** `/var/lib/xray/last-restart`
|
||||||
|
|
||||||
|
保留状态文件的理由:以后重新启用时,若距上次已超过 N 天则首次触发即重启,符合直觉;若刚重启过则不会立刻再来一次。
|
||||||
|
|
||||||
|
`uninstall.sh` 需补上 guard 脚本与状态文件的清理(当前只删了 timer 和 service)。
|
||||||
|
|
||||||
|
## 受影响文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `vps-xray/deploy.sh` | 参数解析、配置校验与迁移、`configure_restart_timer()`、`--restart` 路径、`save_env`、`usage` |
|
||||||
|
| `vps-xray/.env.example` | 三个新字段与说明 |
|
||||||
|
| `vps-xray/uninstall.sh` | 清理 guard 脚本与状态文件 |
|
||||||
|
| `vps-xray/README.md` | 定时重启一节 |
|
||||||
|
|
||||||
|
## 验证方案
|
||||||
|
|
||||||
|
在真实 VPS 上执行,不接受仅靠阅读代码判断:
|
||||||
|
|
||||||
|
1. **语法** —— `bash -n deploy.sh`
|
||||||
|
2. **启用** —— `bash deploy.sh --restart 7 04:00`,确认:`.env` 两字段已更新;`systemctl list-timers xray-restart.timer` 有下次触发时间;xray 主服务的 `ActiveEnterTimestamp` **未变**(证明没重启)
|
||||||
|
3. **守卫生效** —— 手动连跑 `xray-restart-guard` 两次,第一次应重启并写入时间戳,第二次应打印「跳过」且 xray 未重启
|
||||||
|
4. **关闭** —— `bash deploy.sh --restart 0`,确认 timer / service / guard 三个文件均被删除,状态文件仍在
|
||||||
|
5. **迁移** —— 构造一份只含 `XRAY_DAILY_RESTART=true` 的 `.env`,跑完整部署,确认得到 `XRAY_RESTART_EVERY_DAYS=1` 且 timer 已建立
|
||||||
|
6. **校验** —— `bash deploy.sh --restart 7 25:00` 与 `--restart abc` 均应报错退出,且不留下任何单元文件
|
||||||
|
7. **互斥** —— `bash deploy.sh --restart 7 --redetect` 应报错退出
|
||||||
|
|
||||||
|
## 已知取舍
|
||||||
|
|
||||||
|
- **间隔锚点是「上次实际重启时间」,不是固定日历日期。** 若某次因机器关机错过了触发点,下次开机后的第一个触发点会补上(`Persistent=true`),此后间隔重新从那一刻起算,会缓慢偏移触发日期。这是「真实间隔」语义的必然结果,也是用户选择的语义。
|
||||||
|
- **重启失败时时间戳仍已更新**,本轮被跳过,下一轮照常。理由见上文「先写时间戳」。
|
||||||
|
- **时区错误要到 timer 启动时才暴露**。脚本不预校验时区字符串,靠 `systemctl` 报错——自行维护时区白名单成本高且易过期。
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
data/
|
data/
|
||||||
backups/
|
backups/
|
||||||
.env
|
.env
|
||||||
|
.env.bak-*
|
||||||
|
.upgrade-state
|
||||||
|
|||||||
+83
-30
@@ -326,42 +326,93 @@ docker compose up -d # 启动
|
|||||||
|
|
||||||
#### 升级 Gitea
|
#### 升级 Gitea
|
||||||
|
|
||||||
Gitea 以 Docker 容器运行,升级 = 拉取新镜像 + 重启容器。数据库结构变更会在启动时自动迁移。
|
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/gitea
|
cd /opt/gitea
|
||||||
|
|
||||||
# 1. 备份(必须!)
|
bash upgrade.sh --check # 先看看会发生什么,不做任何改动
|
||||||
bash backup.sh
|
bash upgrade.sh # 正式升级到 GitHub 最新 release
|
||||||
|
|
||||||
# 2. 查看当前版本
|
|
||||||
curl -s http://127.0.0.1:3000/api/v1/version
|
|
||||||
|
|
||||||
# 3. 修改目标版本(编辑 .env 中的 GITEA_IMAGE)
|
|
||||||
# ● 查看最新版本号: https://github.com/go-gitea/gitea/releases
|
|
||||||
# ● 或访问: https://hub.docker.com/r/gitea/gitea/tags
|
|
||||||
vi .env
|
|
||||||
# 修改: GITEA_IMAGE=gitea/gitea:1.25.5 ← 替换为目标版本号
|
|
||||||
|
|
||||||
# 4. 拉取新镜像
|
|
||||||
docker compose pull server
|
|
||||||
|
|
||||||
# 5. 重启(自动执行数据库迁移)
|
|
||||||
docker compose up -d server
|
|
||||||
|
|
||||||
# 6. 检查日志确认启动成功
|
|
||||||
docker compose logs -f server
|
|
||||||
# 看到 "Starting new Web server: tcp:0.0.0.0:3000" 表示成功
|
|
||||||
# Ctrl+C 退出日志
|
|
||||||
|
|
||||||
# 7. 验证新版本
|
|
||||||
curl -s http://127.0.0.1:3000/api/v1/version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意事项:**
|
脚本做的事:
|
||||||
- 务必查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes
|
|
||||||
- 不支持版本降级,升级前务必备份
|
| 步骤 | 内容 | 失败时 |
|
||||||
- 跨多个大版本建议逐版本升级(如 1.21 → 1.22 → 1.23)
|
|------|------|--------|
|
||||||
|
| 1 | 预检:root、依赖命令、MySQL 连通性、数据表数量、磁盘空间 | 直接退出,服务零影响 |
|
||||||
|
| 2 | 拉取目标镜像 | 直接退出,服务零影响 |
|
||||||
|
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
|
||||||
|
| 4 | 停止 Gitea 容器(**MySQL 保持运行**) | 自动拉起原版本 |
|
||||||
|
| 5 | mysqldump + 打包数据目录 + 校验备份完整性 | 自动拉起原版本 |
|
||||||
|
| 6 | 切换镜像启动,Gitea 自动执行数据库迁移 | 自动回滚 |
|
||||||
|
| 7 | 校验镜像 ID / 运行版本 / 接口 / 表行数 / 迁移版本 | 自动回滚 |
|
||||||
|
|
||||||
|
常用参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash upgrade.sh --check # 只检查,零改动
|
||||||
|
bash upgrade.sh --version 1.26.4 # 升级到指定版本
|
||||||
|
bash upgrade.sh --full-backup # 连仓库和 LFS 一起备份(小实例推荐)
|
||||||
|
bash upgrade.sh --timeout 600 # 大库迁移慢,放宽就绪超时(默认 300 秒)
|
||||||
|
bash upgrade.sh --doctor # 升级后跑一次 gitea doctor 一致性检查
|
||||||
|
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
|
||||||
|
bash upgrade.sh --rollback # 回滚到上次升级前的状态
|
||||||
|
```
|
||||||
|
|
||||||
|
**备份范围说明**
|
||||||
|
|
||||||
|
默认只打包会被升级影响的部分:`gitea/conf`(app.ini)、`gitea/jwt`(OAuth2 签名密钥)、
|
||||||
|
`ssh`(SSH host key)、`gitea/indexers`、`gitea/queues`、头像目录。
|
||||||
|
|
||||||
|
仓库(`git/repositories`)、LFS、软件包**默认不打包** —— 它们是内容寻址的追加式存储,
|
||||||
|
版本迁移只改数据库结构,不会改写这些文件。上百 GB 的实例每次升级都全量打包并不现实。
|
||||||
|
数据库则**每次都完整 mysqldump**,因为那才是升级真正会动的东西。
|
||||||
|
|
||||||
|
小实例想要更保险,加 `--full-backup` 连仓库一起打包。
|
||||||
|
|
||||||
|
**回滚**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash upgrade.sh --rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
读取 `.upgrade-state` 里记录的备份与镜像锚点,**回灌 SQL 备份** + 恢复配置目录 + 切回旧镜像。
|
||||||
|
|
||||||
|
> 回滚会把数据库整体退回到升级前的状态,升级之后新产生的 issue、PR、评论、仓库记录都会丢失。
|
||||||
|
> 数据目录用的是合并解压,**不会删除任何仓库或 LFS 文件**。
|
||||||
|
|
||||||
|
#### 为什么不要手动升级
|
||||||
|
|
||||||
|
**一、跨小版本升级不可逆,只切镜像回滚是无效的**
|
||||||
|
|
||||||
|
按 [Gitea 官方文档](https://docs.gitea.com/installation/upgrade-from-gitea/),只有
|
||||||
|
`a.b.x → a.b.y`(补丁级)才保证数据库结构不变、可以来回切。跨小版本(如 1.25 → 1.27)
|
||||||
|
新版会在启动时改写数据库结构,之后旧版二进制看到新库会直接拒绝启动:
|
||||||
|
|
||||||
|
```
|
||||||
|
Your database (migration version: 286) is for a newer Gitea,
|
||||||
|
newer database for this old Gitea release (280).
|
||||||
|
Gitea will exit to keep your database safe and unchanged.
|
||||||
|
```
|
||||||
|
|
||||||
|
所以想退回旧版本,**唯一正确的办法是回灌升级前的 SQL 备份**。手动改 `version` 表是官方
|
||||||
|
明确警告会丢数据的操作。`upgrade.sh` 的回滚就是按这个路径做的,并且会拒绝执行跨小版本降级。
|
||||||
|
|
||||||
|
**二、`docker compose up -d` 不会因为镜像变了就重建容器**
|
||||||
|
|
||||||
|
容器处于 stopped 状态时,`up -d` 只是把原容器重新 start,结果是「改了配置但还跑着旧镜像」。
|
||||||
|
必须 `--force-recreate`。脚本在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,
|
||||||
|
不匹配直接回滚。
|
||||||
|
|
||||||
|
**三、`.env` 里的变量已经被 export 到当前 shell**
|
||||||
|
|
||||||
|
部署脚本用 `set -a; source .env` 导出过 `GITEA_IMAGE`,而 docker compose 对环境变量的
|
||||||
|
优先级高于 `.env` 文件 —— 只改文件不改环境变量,compose 仍会读到旧镜像。
|
||||||
|
|
||||||
|
**其它注意事项:**
|
||||||
|
- 升级前查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes
|
||||||
|
- 自定义模板与新版不兼容会导致 5xx 或页面错乱,脚本的首页检查能发现这类问题
|
||||||
|
- MySQL 容器不在 `upgrade.sh` 的升级范围内,需要单独处理(见下)
|
||||||
|
|
||||||
#### 升级 MySQL
|
#### 升级 MySQL
|
||||||
|
|
||||||
@@ -749,6 +800,8 @@ docker compose exec server gitea admin user change-password -u 管理员用户
|
|||||||
├── .env # 运行时配置(自动生成)
|
├── .env # 运行时配置(自动生成)
|
||||||
├── deploy.sh # 全新服务器一键部署脚本
|
├── deploy.sh # 全新服务器一键部署脚本
|
||||||
├── backup.sh # MySQL + 数据备份脚本
|
├── backup.sh # MySQL + 数据备份脚本
|
||||||
|
├── upgrade.sh # 安全升级脚本(备份 → 校验 → 自动回滚)
|
||||||
|
├── .upgrade-state # 升级回滚锚点(upgrade.sh 自动生成)
|
||||||
├── uninstall.sh # 完全卸载脚本
|
├── uninstall.sh # 完全卸载脚本
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|||||||
+1090
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,11 @@ SENDS_ALLOWED=true
|
|||||||
# ===== 镜像配置 =====
|
# ===== 镜像配置 =====
|
||||||
|
|
||||||
# Vaultwarden 镜像
|
# Vaultwarden 镜像
|
||||||
VAULTWARDEN_IMAGE=vaultwarden/server:latest
|
# 留空或填 latest 时,deploy.sh 会自动查询 GitHub 最新 release 并把具体版本号写回这里。
|
||||||
|
# 不要长期使用 latest:该标签只在 docker compose pull 时才重新解析,容器不重建就会
|
||||||
|
# 一直跑旧版本,而且从配置上看不出实际运行的是哪一版。
|
||||||
|
# 后续升级请用 upgrade.sh,它会自动改写此处的版本号。
|
||||||
|
VAULTWARDEN_IMAGE=
|
||||||
|
|
||||||
# ===== 目录与端口 =====
|
# ===== 目录与端口 =====
|
||||||
|
|
||||||
|
|||||||
+112
-16
@@ -16,7 +16,7 @@ Bitwarden 兼容的自托管密码管理器,轻量、安全、功能完整。
|
|||||||
|
|
||||||
| 组件 | 版本 | 说明 |
|
| 组件 | 版本 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Vaultwarden | latest | Bitwarden 兼容服务端(Rust 实现) |
|
| Vaultwarden | 固定版本号 | Bitwarden 兼容服务端(Rust 实现),由 `upgrade.sh` 升级 |
|
||||||
| SQLite | 内置 | 轻量数据库,无需额外部署 |
|
| SQLite | 内置 | 轻量数据库,无需额外部署 |
|
||||||
| Nginx | 系统包 | 反向代理 + HTTPS(Bitwarden 客户端必须 HTTPS) |
|
| Nginx | 系统包 | 反向代理 + HTTPS(Bitwarden 客户端必须 HTTPS) |
|
||||||
| Docker | 最新版 | 容器运行环境 |
|
| Docker | 最新版 | 容器运行环境 |
|
||||||
@@ -35,6 +35,7 @@ vaultwarden/
|
|||||||
├── docker-compose.yml # 容器编排
|
├── docker-compose.yml # 容器编排
|
||||||
├── .env.example # 配置模板
|
├── .env.example # 配置模板
|
||||||
├── deploy.sh # 一键部署脚本
|
├── deploy.sh # 一键部署脚本
|
||||||
|
├── upgrade.sh # 安全升级脚本(带备份、校验、自动回滚)
|
||||||
├── backup.sh # 备份脚本
|
├── backup.sh # 备份脚本
|
||||||
├── uninstall.sh # 完全卸载脚本
|
├── uninstall.sh # 完全卸载脚本
|
||||||
├── nginx/
|
├── nginx/
|
||||||
@@ -97,9 +98,12 @@ bash deploy.sh
|
|||||||
cd /opt/vaultwarden
|
cd /opt/vaultwarden
|
||||||
# 编辑 .env,将 SIGNUPS_ALLOWED 改为 false
|
# 编辑 .env,将 SIGNUPS_ALLOWED 改为 false
|
||||||
vi .env
|
vi .env
|
||||||
docker compose restart
|
docker compose up -d # 注意:不是 restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **不要用 `docker compose restart`**。环境变量是在容器**创建**时注入的,`restart` 只是重启
|
||||||
|
> 原容器、不会重建,改了 `.env` 也不会生效。必须用 `up -d` 让 compose 检测到配置变更并重建容器。
|
||||||
|
|
||||||
### 第五步:安装客户端
|
### 第五步:安装客户端
|
||||||
|
|
||||||
1. 下载 Bitwarden 客户端:https://bitwarden.com/download/
|
1. 下载 Bitwarden 客户端:https://bitwarden.com/download/
|
||||||
@@ -224,23 +228,65 @@ docker compose up -d
|
|||||||
|
|
||||||
### 升级
|
### 升级
|
||||||
|
|
||||||
|
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/vaultwarden
|
cd /opt/vaultwarden
|
||||||
|
|
||||||
# 1. 备份数据
|
bash upgrade.sh --check # 先看看会发生什么,不做任何改动
|
||||||
bash backup.sh
|
bash upgrade.sh # 正式升级到 GitHub 最新 release
|
||||||
|
|
||||||
# 2. 拉取新镜像
|
|
||||||
docker compose pull
|
|
||||||
|
|
||||||
# 3. 重启
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 4. 检查运行状态
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs --tail 20
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
脚本执行流程:
|
||||||
|
|
||||||
|
| 步骤 | 动作 | 失败时 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 1 | 预检:root、依赖命令、数据目录、磁盘空间 | 直接退出,未动服务 |
|
||||||
|
| 2 | 拉取新镜像 | 直接退出,**服务零影响**,可安全重试 |
|
||||||
|
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
|
||||||
|
| 4 | 停止容器 | 自动拉起原服务 |
|
||||||
|
| 5 | WAL checkpoint + `integrity_check` + 冷备份整个数据目录 | 自动拉起原服务 |
|
||||||
|
| 6 | 切换镜像并 `--force-recreate` 重建容器 | 自动回滚 |
|
||||||
|
| 7 | 校验容器实际镜像 == 目标镜像 | 自动回滚 |
|
||||||
|
| 8 | 校验 `/alive`、新旧 prelogin 路由、Nginx 反代 | 自动回滚 |
|
||||||
|
| 9 | 比对升级前后各表行数,任一表减少即判定数据丢失 | 自动回滚 |
|
||||||
|
|
||||||
|
常用选项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash upgrade.sh --check # 只检查,零改动
|
||||||
|
bash upgrade.sh --version 1.37.0 # 升级到指定版本
|
||||||
|
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
|
||||||
|
bash upgrade.sh --rollback # 回滚到上次升级前的状态
|
||||||
|
```
|
||||||
|
|
||||||
|
升级完成后请**登录 Web 端确认数据无误**,再清理回滚镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker image rm vaultwarden/server:pre-upgrade-<时间戳>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vaultwarden
|
||||||
|
bash upgrade.sh --rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚会读取 `.upgrade-state` 里记录的备份和镜像锚点,恢复数据目录并切回旧镜像。
|
||||||
|
|
||||||
|
> **注意**:回滚会丢弃升级后新增/修改的密码条目。
|
||||||
|
> 回滚**不会删除**升级后的数据,而是把它改名保留为 `<数据目录>.failed-<时间戳>`,确认无需后再自行删除。
|
||||||
|
|
||||||
|
### 为什么不要手动升级
|
||||||
|
|
||||||
|
手动 `docker compose pull && docker compose up -d` 有两个坑,都会导致「看起来升级了,其实没有」:
|
||||||
|
|
||||||
|
1. **停止状态的容器不会因镜像变更而重建**。如果先 `stop` 再 `up -d`,compose 只是把原容器重新 `start`,仍然跑旧镜像。必须 `--force-recreate`。
|
||||||
|
2. **环境变量优先级高于 `.env` 文件**。如果当前 shell 里已经 `export` 过 `VAULTWARDEN_IMAGE`(比如脚本里 `source .env` 过),改 `.env` 文件无效,compose 依然用旧值。
|
||||||
|
|
||||||
|
`upgrade.sh` 处理了这两点,并在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,不匹配直接回滚。
|
||||||
|
|
||||||
### 停止 / 启动
|
### 停止 / 启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -316,10 +362,24 @@ rm -rf /opt/vaultwarden
|
|||||||
注册好所有需要的账号后:
|
注册好所有需要的账号后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env 中设置
|
cd /opt/vaultwarden
|
||||||
SIGNUPS_ALLOWED=false
|
sed -i 's/^SIGNUPS_ALLOWED=.*/SIGNUPS_ALLOWED=false/' .env
|
||||||
|
docker compose up -d # 必须 up -d,restart 不生效
|
||||||
```
|
```
|
||||||
|
|
||||||
|
验证是否真的关闭了(应返回 `Registration not allowed or user already exists`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8080/identity/accounts/register/send-verification-email \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"probe@example.invalid","name":"probe"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **不要用 `/api/config` 里的 `disableUserRegistration` 判断**。该字段来自
|
||||||
|
> `is_signup_disabled()`,含义是「是否隐藏 UI 上的注册链接」,而非「注册是否被拒绝」。
|
||||||
|
> 当 `INVITATIONS_ALLOWED=true` 且未配置 SMTP 时,即使 `SIGNUPS_ALLOWED=false`
|
||||||
|
> 它也会显示为 `false`(因为管理员邀请的用户仍需走注册流程)。真正的拦截在注册接口里。
|
||||||
|
|
||||||
### 2. 限制管理面板访问
|
### 2. 限制管理面板访问
|
||||||
|
|
||||||
在 `nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。
|
在 `nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。
|
||||||
@@ -347,6 +407,42 @@ docker compose ps
|
|||||||
curl http://127.0.0.1:8080/alive
|
curl http://127.0.0.1:8080/alive
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 客户端登录报错,控制台 404 /identity/accounts/prelogin/password
|
||||||
|
|
||||||
|
**症状**:已登录的会话正常,但重新登录失败,浏览器控制台显示:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://vault.example.com/identity/accounts/prelogin/password 404 (Not Found)
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:服务端版本过旧。Bitwarden 客户端从 v2026.4.0 起改用 `/identity/accounts/prelogin/password`
|
||||||
|
这个新路由,而 Vaultwarden 在 **1.36.0** 才实现它。客户端会自动更新,服务端不会。
|
||||||
|
|
||||||
|
**确认**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 看服务端实到底跑的是哪个版本
|
||||||
|
curl -s https://vault.example.com/api/config | grep -o '"gitHash":"[^"]*"'
|
||||||
|
|
||||||
|
# 直接探测新旧两个路由
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"x@example.com"}' https://vault.example.com/identity/accounts/prelogin/password
|
||||||
|
```
|
||||||
|
|
||||||
|
旧路由返回 200、新路由返回 404,即可确诊。
|
||||||
|
|
||||||
|
**修复**:升级到 1.36.0 以上。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vaultwarden && bash upgrade.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
如果暂时无法升级,可在 Nginx 的 443 server 块内加一条重写作为临时过渡:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
rewrite ^/identity/accounts/prelogin/password/?$ /identity/accounts/prelogin last;
|
||||||
|
```
|
||||||
|
|
||||||
### 502 Bad Gateway
|
### 502 Bad Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+84
-3
@@ -24,11 +24,72 @@ source "$BASE_DIR/setup.sh"
|
|||||||
# Vaultwarden 专用函数
|
# Vaultwarden 专用函数
|
||||||
# =============================================================
|
# =============================================================
|
||||||
|
|
||||||
|
GITHUB_REPO="dani-garcia/vaultwarden"
|
||||||
|
|
||||||
# 生成随机密码
|
# 生成随机密码
|
||||||
generate_password() {
|
generate_password() {
|
||||||
openssl rand -base64 32 | tr -d '/+=' | head -c 32
|
openssl rand -base64 32 | tr -d '/+=' | head -c 32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 查询 GitHub 上的最新 release 版本号(不含前缀 v),失败返回非零
|
||||||
|
resolve_latest_version() {
|
||||||
|
local ver
|
||||||
|
ver=$(curl -sf --max-time 20 \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
|
||||||
|
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
|
||||||
|
[ -n "$ver" ] || return 1
|
||||||
|
echo "$ver"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 把镜像固定到具体版本号。
|
||||||
|
# 必须同时更新 .env 和已导出的环境变量:init_env 里 `set -a; source .env` 已把旧值
|
||||||
|
# 导出到本进程,而 docker compose 对环境变量的优先级高于 .env 文件,只改文件不生效。
|
||||||
|
pin_image_version() {
|
||||||
|
local ref="$1"
|
||||||
|
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
|
||||||
|
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${ref}|" .env
|
||||||
|
else
|
||||||
|
printf '\nVAULTWARDEN_IMAGE=%s\n' "$ref" >> .env
|
||||||
|
fi
|
||||||
|
export VAULTWARDEN_IMAGE="$ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 确定本次部署使用的镜像版本。
|
||||||
|
# 不使用 latest 标签的原因:它只在 docker compose pull 时才重新解析,容器不重建就会
|
||||||
|
# 一直跑旧版本,且从配置上看不出实际运行的是哪一版。
|
||||||
|
setup_image_version() {
|
||||||
|
local current="${VAULTWARDEN_IMAGE:-}"
|
||||||
|
|
||||||
|
# 已固定到具体版本 → 尊重现有配置,升级交给 upgrade.sh
|
||||||
|
if [ -n "$current" ] && [[ "$current" != *":latest" ]]; then
|
||||||
|
log " 镜像: ${current}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 已有实例却仍在用 latest:此时固定版本等同于一次升级,
|
||||||
|
# 而升级必须走 upgrade.sh(带冷备份、完整性校验、自动回滚),这里不擅自处理。
|
||||||
|
if docker compose ps -a -q vaultwarden 2>/dev/null | grep -q .; then
|
||||||
|
warn "检测到已存在的实例,且 .env 仍在使用 latest 标签"
|
||||||
|
warn "latest 不会自动更新,也看不出实际运行版本,建议固定到具体版本号:"
|
||||||
|
warn " bash upgrade.sh # 安全升级到最新版,并自动写回版本号"
|
||||||
|
warn "本次部署不改动镜像配置"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "正在查询 Vaultwarden 最新版本..."
|
||||||
|
local ver
|
||||||
|
if ! ver="$(resolve_latest_version)"; then
|
||||||
|
warn "无法从 GitHub 获取最新版本号(网络问题?),本次回退使用 latest 标签"
|
||||||
|
warn "部署完成后建议手动固定: VAULTWARDEN_IMAGE=vaultwarden/server:<版本号>"
|
||||||
|
[ -n "$current" ] || pin_image_version "vaultwarden/server:latest"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
pin_image_version "vaultwarden/server:${ver}"
|
||||||
|
log " 镜像: vaultwarden/server:${ver}(当前最新 release,已写入 .env)"
|
||||||
|
}
|
||||||
|
|
||||||
init_env() {
|
init_env() {
|
||||||
step "初始化 Vaultwarden 配置"
|
step "初始化 Vaultwarden 配置"
|
||||||
|
|
||||||
@@ -83,6 +144,8 @@ init_env() {
|
|||||||
log " 域名: ${VAULTWARDEN_DOMAIN}"
|
log " 域名: ${VAULTWARDEN_DOMAIN}"
|
||||||
log " 邮箱: ${CERTBOT_EMAIL}"
|
log " 邮箱: ${CERTBOT_EMAIL}"
|
||||||
log " 注册: ${SIGNUPS_ALLOWED:-true}"
|
log " 注册: ${SIGNUPS_ALLOWED:-true}"
|
||||||
|
|
||||||
|
setup_image_version
|
||||||
}
|
}
|
||||||
|
|
||||||
create_dirs() {
|
create_dirs() {
|
||||||
@@ -95,21 +158,39 @@ create_dirs() {
|
|||||||
log "备份目录: $backup_dir"
|
log "备份目录: $backup_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 确认容器真的跑在配置指定的镜像上,避免「配置改了但容器没重建」这类静默失败
|
||||||
|
verify_running_version() {
|
||||||
|
local cid actual expect="${VAULTWARDEN_IMAGE:-}"
|
||||||
|
cid=$(docker compose ps -a -q vaultwarden 2>/dev/null | head -1)
|
||||||
|
[ -n "$cid" ] || return 0
|
||||||
|
|
||||||
|
actual=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
|
||||||
|
if [ -n "$expect" ] && [ "$actual" != "$expect" ]; then
|
||||||
|
warn "容器实际镜像 (${actual}) 与配置 (${expect}) 不一致"
|
||||||
|
warn "请检查: docker compose up -d --force-recreate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log " 运行镜像: ${actual}"
|
||||||
|
}
|
||||||
|
|
||||||
start_services() {
|
start_services() {
|
||||||
step "启动 Vaultwarden 服务"
|
step "启动 Vaultwarden 服务"
|
||||||
|
|
||||||
log "正在拉取镜像..."
|
log "正在拉取镜像..."
|
||||||
docker compose pull
|
docker compose pull
|
||||||
|
|
||||||
|
# --force-recreate:容器处于 stopped 状态时,up -d 只会把原容器重新 start,
|
||||||
|
# 不会因镜像或环境变量变更而重建,导致「配置改了但没生效」。
|
||||||
log "正在启动容器..."
|
log "正在启动容器..."
|
||||||
docker compose up -d
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
local port="${VAULTWARDEN_PORT:-8080}"
|
local port="${VAULTWARDEN_PORT:-8080}"
|
||||||
log "等待 Vaultwarden 就绪..."
|
log "等待 Vaultwarden 就绪..."
|
||||||
local max_wait=30
|
local max_wait=30
|
||||||
for i in $(seq 1 "$max_wait"); do
|
for _ in $(seq 1 "$max_wait"); do
|
||||||
if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then
|
if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then
|
||||||
log "Vaultwarden 启动成功!"
|
log "Vaultwarden 启动成功!"
|
||||||
|
verify_running_version
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -144,7 +225,7 @@ show_info() {
|
|||||||
if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then
|
if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then
|
||||||
echo -e "${GREEN}║${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}"
|
echo -e "${GREEN}║${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}"
|
||||||
echo -e "${GREEN}║${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}"
|
echo -e "${GREEN}║${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}"
|
||||||
echo -e "${GREEN}║${NC} ${YELLOW} 然后 docker compose restart${NC}"
|
echo -e "${GREEN}║${NC} ${YELLOW} 然后 docker compose up -d(不是 restart)${NC}"
|
||||||
fi
|
fi
|
||||||
echo -e "${GREEN}║${NC}"
|
echo -e "${GREEN}║${NC}"
|
||||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
|||||||
@@ -0,0 +1,700 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# -E 让 ERR trap 能在函数内部生效,保证不可逆区间的异常一定触发回滚
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Vaultwarden 安全升级脚本
|
||||||
|
#
|
||||||
|
# 设计原则:
|
||||||
|
# 1. 先拉镜像再动服务 —— 网络失败时服务零影响
|
||||||
|
# 2. 冷备份(停容器 + WAL checkpoint)—— 保证快照一致
|
||||||
|
# 3. 升级前后比对数据指纹 —— 数据异常自动回滚
|
||||||
|
# 4. 回滚不删数据 —— 旧数据目录改名保留,不 rm
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash upgrade.sh 升级到 GitHub 最新 release
|
||||||
|
# bash upgrade.sh --check 只检查,不做任何改动
|
||||||
|
# bash upgrade.sh --version 1.37.0
|
||||||
|
# bash upgrade.sh --rollback 回滚到上次升级前的状态
|
||||||
|
# bash upgrade.sh --yes 跳过交互确认(用于自动化)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# ===== 输出样式 =====
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
|
||||||
|
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
|
||||||
|
else
|
||||||
|
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||||
|
info() { echo -e "${CYAN}[i]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
error(){ echo -e "${RED}[✗]${NC} $*" >&2; }
|
||||||
|
step() { echo ""; echo -e "${BOLD}${CYAN}━━━ $* ━━━${NC}"; }
|
||||||
|
|
||||||
|
# ===== 全局状态 =====
|
||||||
|
GITHUB_REPO="dani-garcia/vaultwarden"
|
||||||
|
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
STATE_FILE="$SCRIPT_DIR/.upgrade-state"
|
||||||
|
MODE="upgrade" # upgrade | check | rollback
|
||||||
|
TARGET_VERSION=""
|
||||||
|
ASSUME_YES=0
|
||||||
|
SAFETY_STAGE="none" # none → stopped → armed → done,决定异常退出时如何兜底
|
||||||
|
BACKUP_PATH=""
|
||||||
|
ROLLBACK_TAG=""
|
||||||
|
OLD_IMAGE_REF=""
|
||||||
|
COUNTS_BEFORE=""
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 参数解析
|
||||||
|
# =============================================================
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Vaultwarden 安全升级脚本
|
||||||
|
|
||||||
|
用法: bash upgrade.sh [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
-c, --check 只做预检和版本对比,不做任何改动
|
||||||
|
-v, --version <ver> 指定目标版本(如 1.37.0),默认取 GitHub 最新 release
|
||||||
|
-r, --rollback 回滚到上次升级前的状态(读取 .upgrade-state)
|
||||||
|
-y, --yes 跳过交互确认
|
||||||
|
-h, --help 显示本帮助
|
||||||
|
|
||||||
|
示例:
|
||||||
|
bash upgrade.sh --check # 先看看会发生什么
|
||||||
|
bash upgrade.sh # 正式升级
|
||||||
|
bash upgrade.sh --rollback # 升级后发现问题,回滚
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-c|--check) MODE="check" ;;
|
||||||
|
-r|--rollback) MODE="rollback" ;;
|
||||||
|
-y|--yes) ASSUME_YES=1 ;;
|
||||||
|
-v|--version)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || { error "--version 需要一个参数"; exit 1; }
|
||||||
|
TARGET_VERSION="${1#v}"
|
||||||
|
;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) error "未知参数: $1"; echo ""; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
[ "$ASSUME_YES" -eq 1 ] && return 0
|
||||||
|
local prompt="$1" answer
|
||||||
|
echo ""
|
||||||
|
read -r -p "${prompt} [y/N] " answer
|
||||||
|
[[ "$answer" =~ ^[Yy]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 环境加载与预检
|
||||||
|
# =============================================================
|
||||||
|
load_env() {
|
||||||
|
[ -f .env ] || { error ".env 不存在,请先运行 deploy.sh"; exit 1; }
|
||||||
|
sed -i 's/\r$//' .env
|
||||||
|
set -a; source .env; set +a
|
||||||
|
|
||||||
|
DATA_DIR="${VAULTWARDEN_DATA_DIR:-/var/lib/vaultwarden}"
|
||||||
|
BACKUP_BASE="${BACKUP_DIR:-/var/backups/vaultwarden}"
|
||||||
|
PORT="${VAULTWARDEN_PORT:-8080}"
|
||||||
|
DOMAIN="${VAULTWARDEN_DOMAIN:-}"
|
||||||
|
CURRENT_IMAGE="${VAULTWARDEN_IMAGE:-vaultwarden/server:latest}"
|
||||||
|
DB_FILE="$DATA_DIR/db.sqlite3"
|
||||||
|
}
|
||||||
|
|
||||||
|
preflight() {
|
||||||
|
step "环境预检"
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限运行"; exit 1; }
|
||||||
|
|
||||||
|
local missing=0
|
||||||
|
for cmd in docker curl tar; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || { error "缺少命令: $cmd"; missing=1; }
|
||||||
|
done
|
||||||
|
docker compose version >/dev/null 2>&1 || { error "docker compose 不可用"; missing=1; }
|
||||||
|
[ "$missing" -eq 0 ] || exit 1
|
||||||
|
|
||||||
|
[ -f docker-compose.yml ] || { error "docker-compose.yml 不存在"; exit 1; }
|
||||||
|
[ -d "$DATA_DIR" ] || { error "数据目录不存在: $DATA_DIR"; exit 1; }
|
||||||
|
[ -f "$DB_FILE" ] || { error "数据库不存在: $DB_FILE"; exit 1; }
|
||||||
|
|
||||||
|
# 磁盘空间:要求可用空间 ≥ 数据目录的 3 倍(备份 + 解压余量),至少 500MB
|
||||||
|
mkdir -p "$BACKUP_BASE"
|
||||||
|
local data_kb avail_kb need_kb
|
||||||
|
data_kb=$(du -sk "$DATA_DIR" | cut -f1)
|
||||||
|
avail_kb=$(df -Pk "$BACKUP_BASE" | awk 'NR==2{print $4}')
|
||||||
|
[ -n "$avail_kb" ] || { error "无法检测 $BACKUP_BASE 的可用空间"; exit 1; }
|
||||||
|
need_kb=$(( data_kb * 3 ))
|
||||||
|
[ "$need_kb" -lt 512000 ] && need_kb=512000
|
||||||
|
if [ "$avail_kb" -lt "$need_kb" ]; then
|
||||||
|
error "磁盘空间不足: 可用 $((avail_kb/1024))MB,需要 $((need_kb/1024))MB"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "root 权限、依赖命令、数据目录、磁盘空间 均正常"
|
||||||
|
info "数据目录 $DATA_DIR ($((data_kb/1024))MB),可用空间 $((avail_kb/1024))MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_sqlite3() {
|
||||||
|
command -v sqlite3 >/dev/null 2>&1 && return 0
|
||||||
|
warn "sqlite3 未安装,正在安装(用于 WAL checkpoint 和完整性校验)..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sqlite3
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y -q sqlite
|
||||||
|
else
|
||||||
|
error "无法自动安装 sqlite3,请手动安装后重试"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
command -v sqlite3 >/dev/null 2>&1 || { error "sqlite3 安装失败"; exit 1; }
|
||||||
|
log "sqlite3 已安装"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 版本解析
|
||||||
|
# =============================================================
|
||||||
|
resolve_target_version() {
|
||||||
|
if [ -n "$TARGET_VERSION" ]; then
|
||||||
|
info "使用指定版本: $TARGET_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
info "查询 GitHub 最新 release..."
|
||||||
|
TARGET_VERSION=$(curl -sf --max-time 20 \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
|
||||||
|
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
|
||||||
|
|
||||||
|
if [ -z "$TARGET_VERSION" ]; then
|
||||||
|
error "无法从 GitHub 获取最新版本(网络问题?)"
|
||||||
|
error "请手动指定: bash upgrade.sh --version 1.37.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "GitHub 最新 release: $TARGET_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 从 compose 项目推导容器 ID(而非硬编码容器名),停止状态也能拿到
|
||||||
|
compose_container_id() {
|
||||||
|
docker compose ps -a -q vaultwarden 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# 当前容器实际运行的镜像 ID(sha256:...),拿不到则返回空
|
||||||
|
running_image_id() {
|
||||||
|
local cid
|
||||||
|
cid="$(compose_container_id)"
|
||||||
|
[ -n "$cid" ] || return 0
|
||||||
|
docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
get_running_githash() {
|
||||||
|
curl -sf --max-time 10 "http://127.0.0.1:${PORT}/api/config" 2>/dev/null \
|
||||||
|
| grep -o '"gitHash"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| sed 's/.*"\([^"]*\)"$/\1/' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 数据指纹
|
||||||
|
# =============================================================
|
||||||
|
# 统计关键表行数,用于升级前后比对。表不存在时输出 "-"
|
||||||
|
snapshot_counts() {
|
||||||
|
local db="$1" t out=""
|
||||||
|
for t in users ciphers folders sends attachments organizations collections; do
|
||||||
|
local n
|
||||||
|
n=$(sqlite3 "$db" "SELECT COUNT(*) FROM $t;" 2>/dev/null || echo "-")
|
||||||
|
out+="${t}=${n} "
|
||||||
|
done
|
||||||
|
echo "${out% }"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_counts() {
|
||||||
|
local label="$1" counts="$2" kv
|
||||||
|
echo " ${label}:"
|
||||||
|
for kv in $counts; do
|
||||||
|
printf " %-16s %s\n" "${kv%%=*}" "${kv##*=}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 备份
|
||||||
|
# =============================================================
|
||||||
|
cold_backup() {
|
||||||
|
step "冷备份(服务已停止,快照一致)"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_BASE"
|
||||||
|
|
||||||
|
info "执行 WAL checkpoint,将未落盘数据合并进主库..."
|
||||||
|
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE);" >/dev/null
|
||||||
|
log "WAL 已合并"
|
||||||
|
|
||||||
|
info "校验数据库完整性..."
|
||||||
|
local integrity
|
||||||
|
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
|
||||||
|
if [ "$integrity" != "ok" ]; then
|
||||||
|
error "数据库完整性检查未通过: $integrity"
|
||||||
|
error "升级已中止,服务未改动。请先修复数据库。"
|
||||||
|
docker compose up -d
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "数据库完整性: ok"
|
||||||
|
|
||||||
|
COUNTS_BEFORE="$(snapshot_counts "$DB_FILE")"
|
||||||
|
print_counts "升级前数据统计" "$COUNTS_BEFORE"
|
||||||
|
|
||||||
|
BACKUP_PATH="${BACKUP_BASE}/pre-upgrade-${TIMESTAMP}.tar.gz"
|
||||||
|
info "打包整个数据目录 → $BACKUP_PATH"
|
||||||
|
tar czf "$BACKUP_PATH" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
|
||||||
|
|
||||||
|
# 校验备份包。注意:这里先把清单收进变量再用 here-string 匹配,
|
||||||
|
# 不能写成 `tar tzf ... | grep -q`:grep -q 命中即退出会让 tar 收到
|
||||||
|
# SIGPIPE,在 pipefail 下整条管道被判为失败,导致好备份被误判成坏的。
|
||||||
|
info "校验备份包..."
|
||||||
|
local listing
|
||||||
|
listing="$(tar tzf "$BACKUP_PATH")" || { error "备份包损坏,无法列出内容"; exit 1; }
|
||||||
|
grep -q 'db\.sqlite3$' <<<"$listing" || { error "备份包中没有数据库文件"; exit 1; }
|
||||||
|
grep -q 'rsa_key\.pem$' <<<"$listing" || warn "备份包中没有 rsa_key.pem"
|
||||||
|
|
||||||
|
# 同时备份 .env(含 ADMIN_TOKEN)
|
||||||
|
cp .env "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
|
||||||
|
chmod 600 "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
|
||||||
|
|
||||||
|
log "备份完成: $BACKUP_PATH ($(du -h "$BACKUP_PATH" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 镜像与容器
|
||||||
|
# =============================================================
|
||||||
|
tag_rollback_image() {
|
||||||
|
step "锚定当前镜像(用于回滚)"
|
||||||
|
|
||||||
|
local image_id
|
||||||
|
image_id="$(running_image_id)"
|
||||||
|
if [ -z "$image_id" ]; then
|
||||||
|
image_id=$(docker image inspect "$CURRENT_IMAGE" --format '{{.Id}}' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
[ -n "$image_id" ] || { error "找不到当前运行的镜像,无法建立回滚点"; exit 1; }
|
||||||
|
|
||||||
|
ROLLBACK_TAG="vaultwarden/server:pre-upgrade-${TIMESTAMP}"
|
||||||
|
docker tag "$image_id" "$ROLLBACK_TAG"
|
||||||
|
OLD_IMAGE_REF="$CURRENT_IMAGE"
|
||||||
|
|
||||||
|
log "当前镜像已标记为 $ROLLBACK_TAG"
|
||||||
|
info "镜像 ID: ${image_id#sha256:}"
|
||||||
|
}
|
||||||
|
|
||||||
|
pull_target_image() {
|
||||||
|
step "拉取目标镜像(此步骤不影响运行中的服务)"
|
||||||
|
|
||||||
|
local target="vaultwarden/server:${TARGET_VERSION}"
|
||||||
|
info "docker pull $target ..."
|
||||||
|
if ! docker pull "$target"; then
|
||||||
|
error "镜像拉取失败,服务未做任何改动,可安全重试"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "镜像已就绪: $target"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch_image_in_env() {
|
||||||
|
local new_ref="$1"
|
||||||
|
cp .env ".env.bak-${TIMESTAMP}"
|
||||||
|
chmod 600 ".env.bak-${TIMESTAMP}"
|
||||||
|
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
|
||||||
|
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${new_ref}|" .env
|
||||||
|
else
|
||||||
|
printf '\nVAULTWARDEN_IMAGE=%s\n' "$new_ref" >> .env
|
||||||
|
fi
|
||||||
|
# 必须同步更新导出的变量:load_env 里 `set -a; source .env` 已经把旧值导出到
|
||||||
|
# 本进程环境,而 docker compose 对环境变量的优先级高于 .env 文件 —— 只改文件
|
||||||
|
# 的话 compose 仍会读到旧镜像,表现为「容器重建了但版本没变」。
|
||||||
|
export VAULTWARDEN_IMAGE="$new_ref"
|
||||||
|
info ".env 中 VAULTWARDEN_IMAGE → $new_ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_healthy() {
|
||||||
|
local max_wait="${1:-60}" i
|
||||||
|
info "等待服务就绪(最多 ${max_wait}s)..."
|
||||||
|
for i in $(seq 1 "$max_wait"); do
|
||||||
|
if curl -sf --max-time 3 "http://127.0.0.1:${PORT}/alive" >/dev/null 2>&1; then
|
||||||
|
log "服务已就绪(${i}s)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 验证
|
||||||
|
# =============================================================
|
||||||
|
# 确认容器真的跑在目标镜像上,防止「改了配置但容器没重建」这类静默失败
|
||||||
|
verify_running_image() {
|
||||||
|
local expect="$1" cid actual_ref actual_id expect_id
|
||||||
|
cid="$(compose_container_id)"
|
||||||
|
[ -n "$cid" ] || { error "找不到容器"; return 1; }
|
||||||
|
|
||||||
|
actual_ref=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
|
||||||
|
actual_id=$(docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true)
|
||||||
|
expect_id=$(docker image inspect "$expect" --format '{{.Id}}' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$actual_id" != "$expect_id" ]; then
|
||||||
|
error "容器未运行在目标镜像上"
|
||||||
|
error " 期望: $expect ($expect_id)"
|
||||||
|
error " 实际: $actual_ref ($actual_id)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "容器镜像确认: $actual_ref"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# $1: strict(默认) —— 要求新版 prelogin 路由存在(升级后)
|
||||||
|
# lenient —— 该路由 404 属正常(回滚到 1.36.0 之前的版本后)
|
||||||
|
verify_endpoints() {
|
||||||
|
local strict="${1:-strict}"
|
||||||
|
step "接口验证"
|
||||||
|
local failed=0
|
||||||
|
|
||||||
|
# 1. /alive
|
||||||
|
if curl -sf --max-time 5 "http://127.0.0.1:${PORT}/alive" >/dev/null; then
|
||||||
|
log "/alive 正常"
|
||||||
|
else
|
||||||
|
error "/alive 无响应"; failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 新版 prelogin 路由(1.36.0+ 才有,是本次升级的核心目标)
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
|
||||||
|
-X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"healthcheck@example.invalid"}' \
|
||||||
|
"http://127.0.0.1:${PORT}/identity/accounts/prelogin/password" || echo "000")
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
log "/identity/accounts/prelogin/password → 200(新版客户端可登录)"
|
||||||
|
elif [ "$strict" = "lenient" ]; then
|
||||||
|
info "/identity/accounts/prelogin/password → $code(该版本无此路由,回滚后属预期)"
|
||||||
|
else
|
||||||
|
error "/identity/accounts/prelogin/password → $code(期望 200)"; failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 旧版 prelogin 路由(兼容老客户端)
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
|
||||||
|
-X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"healthcheck@example.invalid"}' \
|
||||||
|
"http://127.0.0.1:${PORT}/identity/accounts/prelogin" || echo "000")
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
log "/identity/accounts/prelogin → 200(旧客户端兼容)"
|
||||||
|
else
|
||||||
|
warn "/identity/accounts/prelogin → $code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. 经 Nginx 的外部访问(失败只告警,属于代理层问题,不触发回滚)
|
||||||
|
if [ -n "$DOMAIN" ]; then
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
|
||||||
|
"https://${DOMAIN}/api/config" || echo "000")
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
log "https://${DOMAIN}/api/config → 200(Nginx 反代正常)"
|
||||||
|
else
|
||||||
|
warn "https://${DOMAIN}/api/config → $code(检查 Nginx/证书,不影响本次升级判定)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return $failed
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_data_integrity() {
|
||||||
|
step "数据完整性验证"
|
||||||
|
|
||||||
|
local integrity
|
||||||
|
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
|
||||||
|
if [ "$integrity" != "ok" ]; then
|
||||||
|
error "升级后数据库完整性检查失败: $integrity"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "数据库完整性: ok"
|
||||||
|
|
||||||
|
local counts_after
|
||||||
|
counts_after="$(snapshot_counts "$DB_FILE")"
|
||||||
|
print_counts "升级后数据统计" "$counts_after"
|
||||||
|
|
||||||
|
# 逐表比对:行数只允许持平或增加,减少视为数据丢失
|
||||||
|
local kv table before after lost=0
|
||||||
|
for kv in $COUNTS_BEFORE; do
|
||||||
|
table="${kv%%=*}"; before="${kv##*=}"
|
||||||
|
after=$(echo "$counts_after" | tr ' ' '\n' | grep "^${table}=" | cut -d= -f2 || true)
|
||||||
|
# 升级前该表就不存在 / 升级后读不到 → 跳过比对
|
||||||
|
if [ "$before" = "-" ] || [ -z "$after" ] || [ "$after" = "-" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$after" -lt "$before" ]; then
|
||||||
|
error "表 $table 行数减少: $before → $after"
|
||||||
|
lost=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$lost" -eq 1 ]; then
|
||||||
|
error "检测到数据丢失!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "所有表行数无减少,数据完好"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 回滚
|
||||||
|
# =============================================================
|
||||||
|
save_state() {
|
||||||
|
cat > "$STATE_FILE" <<EOF
|
||||||
|
# Vaultwarden 升级状态记录 —— 由 upgrade.sh 自动生成
|
||||||
|
UPGRADE_TIMESTAMP=${TIMESTAMP}
|
||||||
|
BACKUP_PATH=${BACKUP_PATH}
|
||||||
|
ROLLBACK_TAG=${ROLLBACK_TAG}
|
||||||
|
OLD_IMAGE_REF=${OLD_IMAGE_REF}
|
||||||
|
NEW_IMAGE_REF=vaultwarden/server:${TARGET_VERSION}
|
||||||
|
DATA_DIR=${DATA_DIR}
|
||||||
|
ENV_BACKUP=${SCRIPT_DIR}/.env.bak-${TIMESTAMP}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$STATE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 恢复数据目录:旧目录改名保留,绝不 rm
|
||||||
|
restore_data_dir() {
|
||||||
|
local backup_tar="$1" ts="$2"
|
||||||
|
local parent failed_dir
|
||||||
|
parent="$(dirname "$DATA_DIR")"
|
||||||
|
failed_dir="${DATA_DIR}.failed-${ts}"
|
||||||
|
|
||||||
|
if [ -d "$DATA_DIR" ]; then
|
||||||
|
mv "$DATA_DIR" "$failed_dir"
|
||||||
|
warn "升级后的数据目录已保留为: $failed_dir(未删除)"
|
||||||
|
fi
|
||||||
|
tar xzf "$backup_tar" -C "$parent"
|
||||||
|
[ -f "$DB_FILE" ] || { error "恢复失败:$DB_FILE 不存在"; return 1; }
|
||||||
|
log "数据目录已从备份恢复"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 服务已停、但备份尚未就绪时的紧急恢复:此阶段 .env 未改动,直接拉起原版本即可
|
||||||
|
emergency_start_old() {
|
||||||
|
step "紧急恢复"
|
||||||
|
warn "升级在备份阶段中止,数据未被改动,正在拉起原服务..."
|
||||||
|
docker compose up -d || true
|
||||||
|
if wait_healthy 60; then
|
||||||
|
log "原服务已恢复运行,本次升级未造成任何影响"
|
||||||
|
else
|
||||||
|
error "原服务恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d"
|
||||||
|
error "查看日志: docker compose logs --tail 100"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统一的退出兜底:确保任何异常路径都不会把服务丢在停机状态
|
||||||
|
on_exit() {
|
||||||
|
local code=$?
|
||||||
|
trap - EXIT ERR
|
||||||
|
[ "$code" -eq 0 ] && return 0
|
||||||
|
case "$SAFETY_STAGE" in
|
||||||
|
stopped) emergency_start_old ;;
|
||||||
|
armed) do_rollback "脚本异常中止(退出码 $code)" ;;
|
||||||
|
*) : ;; # 尚未动过服务,无需处理
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
do_rollback() {
|
||||||
|
trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归
|
||||||
|
SAFETY_STAGE="done"
|
||||||
|
set +e
|
||||||
|
local reason="$1"
|
||||||
|
step "自动回滚"
|
||||||
|
error "触发原因: $reason"
|
||||||
|
|
||||||
|
docker compose down || true
|
||||||
|
restore_data_dir "$BACKUP_PATH" "$TIMESTAMP" || {
|
||||||
|
error "自动恢复失败!请手动处理:"
|
||||||
|
error " tar xzf $BACKUP_PATH -C $(dirname "$DATA_DIR")"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
switch_image_in_env "$ROLLBACK_TAG"
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
if wait_healthy 60; then
|
||||||
|
log "回滚完成,已恢复到升级前状态"
|
||||||
|
else
|
||||||
|
error "回滚后服务仍未就绪,请检查: docker compose logs --tail 100"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
warn "升级失败已回滚。备份保留在: $BACKUP_PATH"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
manual_rollback() {
|
||||||
|
step "手动回滚"
|
||||||
|
|
||||||
|
[ -f "$STATE_FILE" ] || { error "找不到升级记录 $STATE_FILE,无法自动回滚"; exit 1; }
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$STATE_FILE"
|
||||||
|
DB_FILE="$DATA_DIR/db.sqlite3" # 状态文件里的 DATA_DIR 可能与 .env 不同,重新推导
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "将回滚到:"
|
||||||
|
info " 镜像: $ROLLBACK_TAG"
|
||||||
|
info " 备份: $BACKUP_PATH"
|
||||||
|
info " 时间: $UPGRADE_TIMESTAMP"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
[ -f "$BACKUP_PATH" ] || { error "备份文件不存在: $BACKUP_PATH"; exit 1; }
|
||||||
|
docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1 \
|
||||||
|
|| { error "回滚镜像不存在: $ROLLBACK_TAG"; exit 1; }
|
||||||
|
|
||||||
|
warn "回滚会丢弃升级后产生的所有数据变更(新增/修改的密码条目)"
|
||||||
|
confirm "确认回滚?" || { info "已取消"; exit 0; }
|
||||||
|
|
||||||
|
docker compose down || true
|
||||||
|
restore_data_dir "$BACKUP_PATH" "$(date +%Y%m%d_%H%M%S)" || exit 1
|
||||||
|
switch_image_in_env "$ROLLBACK_TAG"
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
if wait_healthy 60; then
|
||||||
|
log "回滚完成"
|
||||||
|
verify_endpoints lenient || true
|
||||||
|
else
|
||||||
|
error "服务未就绪,请检查: docker compose logs --tail 100"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# 主流程
|
||||||
|
# =============================================================
|
||||||
|
show_plan() {
|
||||||
|
local githash
|
||||||
|
githash="$(get_running_githash)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}══════════ 升级计划 ══════════${NC}"
|
||||||
|
echo " 域名: ${DOMAIN:-未配置}"
|
||||||
|
echo " 当前镜像: $CURRENT_IMAGE"
|
||||||
|
echo " 当前 gitHash: ${githash:-未知(服务未响应?)}"
|
||||||
|
echo " 目标版本: vaultwarden/server:${TARGET_VERSION}"
|
||||||
|
echo " 数据目录: $DATA_DIR"
|
||||||
|
echo " 备份目录: $BACKUP_BASE"
|
||||||
|
echo ""
|
||||||
|
echo " 执行步骤:"
|
||||||
|
echo " 1. 锚定当前镜像为回滚点"
|
||||||
|
echo " 2. 拉取新镜像(失败则零影响退出)"
|
||||||
|
echo " 3. 停止容器"
|
||||||
|
echo " 4. WAL checkpoint + 完整性校验 + 冷备份"
|
||||||
|
echo " 5. 切换镜像并启动"
|
||||||
|
echo " 6. 验证接口 + 比对数据行数"
|
||||||
|
echo " 7. 任一验证失败 → 自动回滚"
|
||||||
|
echo -e "${BOLD}═════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
echo -e "${CYAN}${BOLD}"
|
||||||
|
echo " Vaultwarden 安全升级"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
load_env
|
||||||
|
|
||||||
|
if [ "$MODE" = "rollback" ]; then
|
||||||
|
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限"; exit 1; }
|
||||||
|
manual_rollback
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
preflight
|
||||||
|
resolve_target_version
|
||||||
|
show_plan
|
||||||
|
|
||||||
|
# 已是目标版本?
|
||||||
|
local cur_image_id target_image_id
|
||||||
|
cur_image_id="$(running_image_id)"
|
||||||
|
target_image_id=$(docker image inspect "vaultwarden/server:${TARGET_VERSION}" \
|
||||||
|
--format '{{.Id}}' 2>/dev/null || true)
|
||||||
|
if [ -n "$cur_image_id" ] && [ "$cur_image_id" = "$target_image_id" ]; then
|
||||||
|
echo ""
|
||||||
|
log "当前运行的已经是 ${TARGET_VERSION},无需升级"
|
||||||
|
if [ "$MODE" = "check" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
confirm "仍要强制重新部署一次?" || { info "已取消"; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$MODE" = "check" ]; then
|
||||||
|
echo ""
|
||||||
|
info "--check 模式,未做任何改动"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
confirm "确认开始升级?" || { info "已取消"; exit 0; }
|
||||||
|
|
||||||
|
ensure_sqlite3
|
||||||
|
# 先拉镜像再打回滚标签:拉取失败时服务未动,也不留下无用的标签
|
||||||
|
pull_target_image
|
||||||
|
tag_rollback_image
|
||||||
|
|
||||||
|
# ===== 从这里开始服务会中断,全程由 on_exit 兜底 =====
|
||||||
|
trap on_exit EXIT
|
||||||
|
trap 'exit 1' ERR
|
||||||
|
|
||||||
|
step "停止容器"
|
||||||
|
SAFETY_STAGE="stopped" # 异常退出 → 直接拉起原版本
|
||||||
|
docker compose stop
|
||||||
|
log "容器已停止"
|
||||||
|
|
||||||
|
cold_backup
|
||||||
|
save_state
|
||||||
|
SAFETY_STAGE="armed" # 备份已就位,异常退出 → 完整回滚
|
||||||
|
|
||||||
|
step "启动新版本"
|
||||||
|
switch_image_in_env "vaultwarden/server:${TARGET_VERSION}"
|
||||||
|
# 必须 --force-recreate:容器处于 stopped 状态时,docker compose up -d 只会
|
||||||
|
# 把原容器重新 start,不会因为镜像变更而重建,结果是「升级了但还跑着旧镜像」。
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
if ! wait_healthy 90; then
|
||||||
|
docker compose logs --tail 40 || true
|
||||||
|
do_rollback "服务在 90 秒内未就绪"
|
||||||
|
fi
|
||||||
|
if ! verify_running_image "vaultwarden/server:${TARGET_VERSION}"; then
|
||||||
|
do_rollback "容器未运行在目标镜像上"
|
||||||
|
fi
|
||||||
|
if ! verify_endpoints; then
|
||||||
|
do_rollback "接口验证失败"
|
||||||
|
fi
|
||||||
|
if ! verify_data_integrity; then
|
||||||
|
do_rollback "数据完整性验证失败"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 成功 =====
|
||||||
|
SAFETY_STAGE="done"
|
||||||
|
trap - EXIT ERR
|
||||||
|
step "升级成功"
|
||||||
|
local new_githash
|
||||||
|
new_githash="$(get_running_githash)"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}${BOLD}══════════ 完成 ══════════${NC}"
|
||||||
|
echo " 运行版本: vaultwarden/server:${TARGET_VERSION}"
|
||||||
|
echo " gitHash: ${new_githash:-未知}"
|
||||||
|
echo " 备份文件: $BACKUP_PATH"
|
||||||
|
echo " 回滚镜像: $ROLLBACK_TAG"
|
||||||
|
echo ""
|
||||||
|
echo " 如需回滚: bash upgrade.sh --rollback"
|
||||||
|
echo " 查看日志: docker compose logs --tail 50"
|
||||||
|
echo -e "${GREEN}${BOLD}══════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
warn "请登录 Web 端确认数据无误后,再考虑清理旧镜像:"
|
||||||
|
warn " docker image rm $ROLLBACK_TAG"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+27
-3
@@ -6,11 +6,35 @@
|
|||||||
# XRAY_MODE=reality
|
# XRAY_MODE=reality
|
||||||
|
|
||||||
# 监听端口(默认 443,建议不改)
|
# 监听端口(默认 443,建议不改)
|
||||||
|
# NAT / 端口转发的机器请填服务商实际映射给你的端口
|
||||||
# XRAY_PORT=443
|
# XRAY_PORT=443
|
||||||
|
|
||||||
# Reality 伪装目标(留空则自动选择延迟最低的)
|
# 分享链接中使用的 IP,留空则自动探测出口 IP
|
||||||
# REALITY_DEST=www.microsoft.com
|
# NAT / 端口转发的机器上出口 IP 与入站 IP 可能不同,此时必须手动指定
|
||||||
# REALITY_SNI=www.microsoft.com
|
# SERVER_IP=1.2.3.4
|
||||||
|
|
||||||
|
# Reality 伪装目标(留空则自动从候选中挑选,见 deploy.sh 的 select_reality_dest)
|
||||||
|
# 手动指定时务必确认新域名满足两个条件,否则客户端会刷 REALITY 报错:
|
||||||
|
# 1) TLS 1.3 且证书链 < 8192 字节(用 xray tls ping <域名> 查「with SNI」那段)
|
||||||
|
# 2) 该域名会被客户端路由判成「直连」(命中 geosite:cn)
|
||||||
|
# 已知不可用:dl.google.com(命中 geosite:google)、www.microsoft.com、www.amazon.com
|
||||||
|
# REALITY_DEST=www.apple.com
|
||||||
|
# REALITY_SNI=www.apple.com
|
||||||
|
|
||||||
|
# ===== 定时重启 =====
|
||||||
|
# 0 = 关闭(默认);N = 每 N 天重启一次 Xray。
|
||||||
|
# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级响应),而硬重启会切断
|
||||||
|
# 全部活动连接。仅在确实观察到长期运行后性能退化时才启用。
|
||||||
|
# XRAY_RESTART_EVERY_DAYS=0
|
||||||
|
# XRAY_RESTART_TIME=04:00
|
||||||
|
# XRAY_RESTART_TIMEZONE=Asia/Shanghai
|
||||||
|
#
|
||||||
|
# 改这几项无需完整重新部署,用轻量入口即可(不影响 vless 链接):
|
||||||
|
# bash deploy.sh --restart 7 04:00
|
||||||
|
# bash deploy.sh --restart 0
|
||||||
|
#
|
||||||
|
# 旧字段 XRAY_DAILY_RESTART 已废弃,仅在上面三项都没设时作为兼容回退:
|
||||||
|
# true → 每 1 天,其它 → 关闭。重跑部署后会自动换写成新字段。
|
||||||
|
|
||||||
# 备份目录
|
# 备份目录
|
||||||
# BACKUP_DIR=/var/backups/xray
|
# BACKUP_DIR=/var/backups/xray
|
||||||
|
|||||||
+193
-6
@@ -52,14 +52,20 @@
|
|||||||
```
|
```
|
||||||
vps-xray/
|
vps-xray/
|
||||||
├── deploy.sh # 一键部署脚本
|
├── deploy.sh # 一键部署脚本
|
||||||
├── backup.sh # 备份脚本
|
|
||||||
├── uninstall.sh # 完全卸载脚本
|
├── uninstall.sh # 完全卸载脚本
|
||||||
|
├── backup.sh # 备份脚本
|
||||||
|
├── upload.sh # 上传脚本(仅上传服务端必要文件)
|
||||||
├── .env.example # 配置模板
|
├── .env.example # 配置模板
|
||||||
├── README.md # 本文件
|
├── README.md # 本文件
|
||||||
├── vps-xray-optimized.md # Reality 方案详细文档
|
├── vps-xray-optimized.md # Reality 方案详细文档
|
||||||
└── vps-xray-fast.md # Fast TCP 方案详细文档
|
├── vps-xray-fast.md # Fast TCP 方案详细文档
|
||||||
|
└── client-config/ # 客户端配置(本地使用,不上传服务器)
|
||||||
|
├── gen-clash.sh # 由节点信息生成 Clash.Meta 配置
|
||||||
|
└── client-macos.md # macOS 客户端指南(模板,可提交)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `client-config/` 下含真实 UUID / 服务器 IP 的节点配置(`*-info*.md`、`*.yaml`)已在根 `.gitignore` 中排除,不要提交到版本库。
|
||||||
|
|
||||||
服务器上的文件位置:
|
服务器上的文件位置:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -73,8 +79,11 @@ vps-xray/
|
|||||||
|
|
||||||
### 第一步:上传文件到 VPS
|
### 第一步:上传文件到 VPS
|
||||||
|
|
||||||
|
使用 `upload.sh` 只上传服务端必要文件(`deploy.sh`、`uninstall.sh`、`.env.example`),跳过文档和客户端配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scp -r vps-xray/ root@<VPS_IP>:/opt/vps-xray
|
# 在本地 vps-xray/ 目录下执行
|
||||||
|
bash upload.sh <VPS_IP>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第二步:登录 VPS 执行部署
|
### 第二步:登录 VPS 执行部署
|
||||||
@@ -94,7 +103,15 @@ bash deploy.sh --mode fast
|
|||||||
- 连接参数(IP、端口、UUID、密钥等)
|
- 连接参数(IP、端口、UUID、密钥等)
|
||||||
- VLESS 分享链接(可直接导入客户端)
|
- VLESS 分享链接(可直接导入客户端)
|
||||||
|
|
||||||
> **⚠️ 请妥善保存输出的连接信息!密钥仅显示一次。**凭据同时保存在 `/opt/vps-xray/.env` 中。
|
凭据保存在 `/opt/vps-xray/.env`(权限 600)。**信息不会丢** —— 随时可以重新打印:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vps-xray && bash deploy.sh --show
|
||||||
|
```
|
||||||
|
|
||||||
|
`--show` 只读取 `.env` 输出连接信息和分享链接,不安装、不改配置、不重启服务。
|
||||||
|
|
||||||
|
> 即使 `.env` 被误删,只要 `/usr/local/etc/xray/config.json` 还在,UUID、shortId、SNI 都能从中读出,公钥可用 `xray x25519 -i <privateKey>` 从私钥反推。
|
||||||
|
|
||||||
### 第三步:客户端配置
|
### 第三步:客户端配置
|
||||||
|
|
||||||
@@ -107,16 +124,186 @@ bash deploy.sh --mode fast
|
|||||||
| iOS | Shadowrocket / Streisand |
|
| iOS | Shadowrocket / Streisand |
|
||||||
| Android | v2rayNG |
|
| Android | v2rayNG |
|
||||||
|
|
||||||
|
#### 生成 Clash 配置
|
||||||
|
|
||||||
|
把每台 VPS 的 `bash deploy.sh --show` 输出保存成 `client-config/xxx-info.md`,然后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client-config
|
||||||
|
bash gen-clash.sh # 交互式勾选要写入的节点
|
||||||
|
bash gen-clash.sh --all # 全部节点
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本解析文件里的 `vless://` 链接,生成 `vpn-vps-xray-optimized.yaml`(多节点自动组成 url-test 自动选择组,原文件会先备份为 `.bak`)。
|
||||||
|
|
||||||
|
> 生成的配置需要 **Clash.Meta / mihomo** 内核(Clash Verge Rev、ClashX Meta、FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
|
||||||
|
|
||||||
详细的 Clash Meta / Sing-Box 配置参见 [vps-xray-optimized.md](vps-xray-optimized.md)。
|
详细的 Clash Meta / Sing-Box 配置参见 [vps-xray-optimized.md](vps-xray-optimized.md)。
|
||||||
|
|
||||||
## 日常运维
|
## 日常运维
|
||||||
|
|
||||||
|
### 查看连接信息 / 分享链接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vps-xray
|
||||||
|
bash deploy.sh --show
|
||||||
|
```
|
||||||
|
|
||||||
|
只读操作,不会改动任何服务。
|
||||||
|
|
||||||
|
### 更换 Reality 伪装目标
|
||||||
|
|
||||||
|
伪装目标在首次部署时按握手延迟自动挑选,之后固定写入 `.env` 不再变动。后续每次部署会检查它是否仍然可达,不可达时告警但**不会自动更换**——因为更换会改变 SNI,导致所有客户端链接静默失效。
|
||||||
|
|
||||||
|
确认要换时显式执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy.sh --redetect
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 换完后 SNI 变了,**所有客户端都必须用新的分享链接重新配置**。脚本会在更换时打印醒目提示。
|
||||||
|
|
||||||
|
#### 自定义伪装目标时的两个硬条件
|
||||||
|
|
||||||
|
在 `.env` 里手动指定 `REALITY_DEST` / `REALITY_SNI` 前,必须确认新域名同时满足:
|
||||||
|
|
||||||
|
**1. 技术条件** — TLS 1.3,且证书链总长 < 8192 字节。上限是 `xtls/reality` 里的硬编码值,超出会直接握手失败(见 [XTLS/Xray-core#6356](https://github.com/XTLS/Xray-core/issues/6356),`www.microsoft.com` 的 8273 字节即触发)。在服务器上自查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xray tls ping www.apple.com # 只看「Pinging with SNI」那一段
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 客户端路由条件** — 该域名必须会被客户端判成「直连」。
|
||||||
|
|
||||||
|
这条不直观。TUN 模式下客户端偶尔会把「连接本代理服务器」这件事本身也抓进代理(v2rayN 的 sing-box + xray 双核心靠进程嗅探防回环,Windows 上存在竞态)。这种回环连接的去向完全由 SNI 决定:
|
||||||
|
|
||||||
|
| SNI 被判为 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 直连 | 连接真的到达本服务器,REALITY 握手成功,**用户无感** |
|
||||||
|
| 走代理 | 进隧道 → 服务器替客户端连了真正的伪装站 → 客户端收到真证书 → 刷屏 `REALITY: received real certificate` |
|
||||||
|
|
||||||
|
实测(v2rayN 默认规则模板,`geosite:google → proxy` 排在 `geosite:cn → direct` 之前):
|
||||||
|
|
||||||
|
| 域名 | 命中规则 | 可用性 |
|
||||||
|
|---|---|---|
|
||||||
|
| `www.apple.com`、`*.apple.com` | `geosite:cn` | ✅ 安全 |
|
||||||
|
| `dl.google.com` | `geosite:google` | ❌ 不安全 |
|
||||||
|
| `www.microsoft.com` | 都不命中 → `final:proxy` | ❌ 不安全 |
|
||||||
|
| `www.amazon.com` | 都不命中 → `final:proxy` | ❌ 不安全 |
|
||||||
|
|
||||||
|
脚本的候选列表因此只保留 Apple 系域名。
|
||||||
|
|
||||||
|
### 定时重启
|
||||||
|
|
||||||
|
默认**关闭**。原因是:
|
||||||
|
|
||||||
|
- 崩溃恢复已由 systemd 的 `Restart=always` + `RestartSec=3` 覆盖(秒级响应),不必等到凌晨
|
||||||
|
- Xray 无已知需要定期重启的缺陷(内存、句柄上限均已配置)
|
||||||
|
- 而硬重启会切断全部活动连接——跨夜下载、备份、CI 都会中断
|
||||||
|
|
||||||
|
即"为假想问题付出真实代价"。仅在确实观察到长期运行后性能退化时才启用。
|
||||||
|
|
||||||
|
需要时用轻量入口调整,**不会重装 Xray、不会重写配置、不会重启服务,现有 vless 链接完全不受影响**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy.sh --restart 7 04:00 # 每 7 天 04:00 重启
|
||||||
|
bash deploy.sh --restart 3 # 每 3 天,时间沿用 .env
|
||||||
|
bash deploy.sh --restart 0 # 关闭
|
||||||
|
bash deploy.sh --restart # 按 .env 现有值重建 timer
|
||||||
|
```
|
||||||
|
|
||||||
|
对应 `.env` 三个字段:
|
||||||
|
|
||||||
|
| 字段 | 含义 | 默认 |
|
||||||
|
|---|---|---|
|
||||||
|
| `XRAY_RESTART_EVERY_DAYS` | 0 = 关闭;N = 每 N 天 | `0` |
|
||||||
|
| `XRAY_RESTART_TIME` | 24 小时制 `HH:MM` | `04:00` |
|
||||||
|
| `XRAY_RESTART_TIMEZONE` | 时区 | `Asia/Shanghai` |
|
||||||
|
|
||||||
|
#### 查看当前配置与重启记录
|
||||||
|
|
||||||
|
**一条命令看全** —— 不带参数的 `--restart` 只按 `.env` 现值重建 timer,不改配置、不重启服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy.sh --restart
|
||||||
|
```
|
||||||
|
|
||||||
|
输出里包含当前间隔与时间、**上次实际重启**、**下次实际重启**。
|
||||||
|
|
||||||
|
分项查看:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 配置值
|
||||||
|
grep '^XRAY_RESTART_' /opt/vps-xray/.env
|
||||||
|
|
||||||
|
# 2. timer 是否启用、下次唤醒时间
|
||||||
|
systemctl list-timers xray-restart.timer
|
||||||
|
|
||||||
|
# 3. 确认时区真的生效(最容易出错的一项)
|
||||||
|
systemctl show xray-restart.timer -p TimersCalendar
|
||||||
|
|
||||||
|
# 4. 上次实际重启的时间戳
|
||||||
|
cat /var/lib/xray/last-restart # Unix 时间戳
|
||||||
|
date -d "@$(cat /var/lib/xray/last-restart)" # 可读格式
|
||||||
|
|
||||||
|
# 5. 守卫每次唤醒后的判断记录(跳过还是执行)
|
||||||
|
journalctl -u xray-restart.service --no-pager -n 50
|
||||||
|
|
||||||
|
# 6. xray 进程最近一次启动的时刻
|
||||||
|
systemctl show xray -p ActiveEnterTimestamp --value
|
||||||
|
```
|
||||||
|
|
||||||
|
第 3 条的输出应形如:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ OnCalendar=*-*-* 04:00:00 Asia/Shanghai ; next_elapse=Fri 2026-08-07 20:00:00 UTC }
|
||||||
|
```
|
||||||
|
|
||||||
|
**里面必须看得到时区。** 若只有 `OnCalendar=*-*-* 04:00:00` 而没有时区,说明时区没生效,systemd 会按服务器的系统时区解析——多数 VPS 是 UTC,那样设的「04:00 北京时间」实际会在**中午 12 点**触发,偏移 8 小时且没有任何提示。
|
||||||
|
|
||||||
|
> 早期实现把时区写成 `[Timer]` 段的 `TimeZone=`,而 `systemd.timer` 根本没有这个 key——写了只会在 journal 里留一行 `Unknown key name 'TimeZone' in section 'Timer', ignoring.` 然后按本地时区解析。排查时可以顺手确认一下:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> journalctl -b | grep -i "unknown key name"
|
||||||
|
> systemd-analyze verify /etc/systemd/system/xray-restart.timer
|
||||||
|
> ```
|
||||||
|
|
||||||
|
第 5 条的日志长这样:
|
||||||
|
|
||||||
|
```
|
||||||
|
xray-restart-guard[1234]: 距上次重启不足 7 天(已过 3 天),跳过
|
||||||
|
xray-restart-guard[5678]: 距上次重启已满 7 天,执行重启
|
||||||
|
```
|
||||||
|
|
||||||
|
出现下面这行说明状态文件写不进去(磁盘满或只读挂载),守卫会主动跳过本轮——宁可不重启,也不让保护失效后退化成每天硬重启:
|
||||||
|
|
||||||
|
```
|
||||||
|
xray-restart-guard[...]: 无法写入 /var/lib/xray/last-restart,跳过本轮重启以免退化成每日重启
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `list-timers` 的 `NEXT` 是 **timer 下次「唤醒」的时间,不是下次重启的时间**。timer 每天唤醒一次,NEXT 因此恒为次日的 `XRAY_RESTART_TIME`;是否真的重启由守卫按间隔判断。设了「每 7 天」且 5 天前刚重启过时,NEXT 显示明天凌晨,实际还要再等 2 天。想看真实的下次重启时间,用上面那条不带参数的 `bash deploy.sh --restart`。
|
||||||
|
|
||||||
|
#### 为什么不是纯 systemd timer
|
||||||
|
|
||||||
|
`OnCalendar` 表达不了任意 N 天:`*-*-1/7 04:00:00` 是「每月 1、8、15、22、29 号」,**跨月会重置**——29 号到下月 1 号只隔 2~3 天。
|
||||||
|
|
||||||
|
所以实现是 timer 每天到点唤醒,由 `/usr/local/bin/xray-restart-guard` 读 `/var/lib/xray/last-restart`,满 N 天才真正重启。间隔因此是「距上次实际重启」的真实天数,`N=3` 与 `N=7` 同样准确。
|
||||||
|
|
||||||
|
具体怎么查看配置与执行记录,见上面的「查看当前配置与重启记录」。
|
||||||
|
|
||||||
|
#### 已知取舍
|
||||||
|
|
||||||
|
- **间隔锚点是「上次由守卫执行的重启」,不是任何一次 xray 重启。** 完整部署(`bash deploy.sh`)里的 `systemctl restart xray` 不会更新 `/var/lib/xray/last-restart`——设了每 7 天、距上次守卫重启已 6.9 天时跑一次完整部署,次日守卫仍会判定满 7 天而再重启一次。介意的话可在部署后手动执行 `date +%s > /var/lib/xray/last-restart` 把锚点对齐。
|
||||||
|
- **重启失败时时间戳仍已更新**,本轮被跳过,下一轮照常——这是为了避免陷入每天硬重启的循环。但若时间戳**写不进去**(磁盘满、只读挂载),守卫会跳过本轮重启并在日志里报错,宁可不重启也不退化成每日重启。
|
||||||
|
|
||||||
|
> 旧字段 `XRAY_DAILY_RESTART` 已废弃。三个新字段都未设置时它仍作为兼容回退(`true` → 每 1 天,其它 → 关闭),重跑部署后 `.env` 会自动换写成新字段。
|
||||||
|
|
||||||
### 查看状态 / 日志
|
### 查看状态 / 日志
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status xray
|
systemctl status xray
|
||||||
journalctl -u xray -f
|
journalctl -u xray -f
|
||||||
journalctl -u xray --tail 100
|
journalctl -u xray -n 100
|
||||||
```
|
```
|
||||||
|
|
||||||
### 备份
|
### 备份
|
||||||
@@ -235,7 +422,7 @@ systemctl status xray
|
|||||||
ss -tlnp | grep 443
|
ss -tlnp | grep 443
|
||||||
|
|
||||||
# 查看错误日志
|
# 查看错误日志
|
||||||
journalctl -u xray --tail 50
|
journalctl -u xray -n 50
|
||||||
```
|
```
|
||||||
|
|
||||||
### 速度慢
|
### 速度慢
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Xray 备份脚本
|
||||||
|
# 备份 Xray 配置 / 部署配置 / 网络调优参数
|
||||||
|
# 输出目录默认 /var/backups/xray,自动清理 30 天前的旧备份
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
error "请使用 root 用户运行: sudo bash backup.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
sed -i 's/\r$//' .env
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/xray}"
|
||||||
|
KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}"
|
||||||
|
STAMP="$(date '+%Y%m%d%H%M%S')"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
chmod 700 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
backed_up=0
|
||||||
|
|
||||||
|
# ===== 1. Xray 配置 =====
|
||||||
|
# 打包 /usr/local/etc/xray 整个目录,恢复时 tar xzf ... -C /usr/local/etc/
|
||||||
|
if [ -d /usr/local/etc/xray ]; then
|
||||||
|
tar czf "${BACKUP_DIR}/xray_config_${STAMP}.tar.gz" -C /usr/local/etc xray
|
||||||
|
log "Xray 配置已备份: ${BACKUP_DIR}/xray_config_${STAMP}.tar.gz"
|
||||||
|
backed_up=$((backed_up + 1))
|
||||||
|
else
|
||||||
|
warn "/usr/local/etc/xray 不存在,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 2. 部署配置(.env + 脚本)=====
|
||||||
|
# .env 含私钥,打包后权限收紧到 600
|
||||||
|
deploy_files=()
|
||||||
|
for f in .env deploy.sh uninstall.sh backup.sh upload.sh .env.example; do
|
||||||
|
[ -f "$f" ] && deploy_files+=("$f")
|
||||||
|
done
|
||||||
|
if [ "${#deploy_files[@]}" -gt 0 ]; then
|
||||||
|
tar czf "${BACKUP_DIR}/deploy_${STAMP}.tar.gz" -C "$SCRIPT_DIR" "${deploy_files[@]}"
|
||||||
|
chmod 600 "${BACKUP_DIR}/deploy_${STAMP}.tar.gz"
|
||||||
|
log "部署配置已备份: ${BACKUP_DIR}/deploy_${STAMP}.tar.gz(权限 600,含私钥)"
|
||||||
|
backed_up=$((backed_up + 1))
|
||||||
|
else
|
||||||
|
warn "未找到可备份的部署文件,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 3. 网络调优参数 =====
|
||||||
|
if [ -f /etc/sysctl.d/99-xray-turbo.conf ]; then
|
||||||
|
cp /etc/sysctl.d/99-xray-turbo.conf "${BACKUP_DIR}/sysctl_${STAMP}.conf"
|
||||||
|
log "网络调优参数已备份: ${BACKUP_DIR}/sysctl_${STAMP}.conf"
|
||||||
|
backed_up=$((backed_up + 1))
|
||||||
|
else
|
||||||
|
warn "/etc/sysctl.d/99-xray-turbo.conf 不存在,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$backed_up" -eq 0 ]; then
|
||||||
|
error "没有任何内容被备份,请确认 Xray 是否已部署"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 4. 清理过期备份 =====
|
||||||
|
old_count=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
|
||||||
|
\( -name 'xray_config_*.tar.gz' -o -name 'deploy_*.tar.gz' -o -name 'sysctl_*.conf' \) \
|
||||||
|
-mtime "+${KEEP_DAYS}" | wc -l)
|
||||||
|
if [ "$old_count" -gt 0 ]; then
|
||||||
|
find "$BACKUP_DIR" -maxdepth 1 -type f \
|
||||||
|
\( -name 'xray_config_*.tar.gz' -o -name 'deploy_*.tar.gz' -o -name 'sysctl_*.conf' \) \
|
||||||
|
-mtime "+${KEEP_DAYS}" -delete
|
||||||
|
log "已清理 ${old_count} 个超过 ${KEEP_DAYS} 天的旧备份"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "备份完成,目录: ${BACKUP_DIR}"
|
||||||
|
ls -lh "$BACKUP_DIR" | tail -n +2 | tail -10
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Clash.Meta 配置生成器
|
||||||
|
#
|
||||||
|
# 从 deploy.sh 输出的节点信息文件(*info*.md,内含 vless:// 链接)
|
||||||
|
# 生成 Clash.Meta / mihomo 可用的 YAML 配置。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash gen-clash.sh # 交互式选择节点文件
|
||||||
|
# bash gen-clash.sh a.md b.md # 直接指定
|
||||||
|
# bash gen-clash.sh -o my.yaml a.md # 指定输出文件
|
||||||
|
# bash gen-clash.sh --all # 使用全部 *info*.md
|
||||||
|
#
|
||||||
|
# 本脚本仅在本地运行,不会上传到 VPS。
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 不切换工作目录:候选文件在脚本所在目录里找,
|
||||||
|
# 但命令行显式传入的路径仍相对调用者的当前目录解析
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
OUTPUT="${SCRIPT_DIR}/vpn-vps-xray-optimized.yaml"
|
||||||
|
USE_ALL=0
|
||||||
|
FILES=()
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '4,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o|--output) OUTPUT="${2:?-o 需要一个文件名}"; shift 2 ;;
|
||||||
|
--all) USE_ALL=1; shift ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
-*) error "未知参数: $1"; usage ;;
|
||||||
|
*) FILES+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ===== 收集候选文件 =====
|
||||||
|
mapfile -t CANDIDATES < <(ls -1 "${SCRIPT_DIR}"/*info*.md 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||||
|
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
|
||||||
|
error "当前目录下找不到任何 *info*.md 节点信息文件"
|
||||||
|
error "请先在 VPS 上执行 bash deploy.sh --show,把输出保存为 xxx-info.md 放到这里"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$USE_ALL" -eq 1 ]; then
|
||||||
|
FILES=("${CANDIDATES[@]}")
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}可用的节点信息文件:${NC}"
|
||||||
|
for i in "${!CANDIDATES[@]}"; do
|
||||||
|
f="${CANDIDATES[$i]}"
|
||||||
|
# 顺带把链接里的备注名显示出来,方便辨认
|
||||||
|
tag=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null | sed 's/.*#//' || true)
|
||||||
|
printf " %2d) %-45s %s\n" "$((i + 1))" "${f#"${SCRIPT_DIR}"/}" "${tag:+[${tag}]}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
read -r -p "选择要写入配置的节点(空格分隔序号,回车=全选): " picks
|
||||||
|
if [ -z "${picks// /}" ]; then
|
||||||
|
FILES=("${CANDIDATES[@]}")
|
||||||
|
else
|
||||||
|
for n in $picks; do
|
||||||
|
if ! [[ "$n" =~ ^[0-9]+$ ]] || [ "$n" -lt 1 ] || [ "$n" -gt "${#CANDIDATES[@]}" ]; then
|
||||||
|
error "无效序号: $n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
FILES+=("${CANDIDATES[$((n - 1))]}")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 工具函数 =====
|
||||||
|
urldecode() {
|
||||||
|
local s="${1//+/ }"
|
||||||
|
printf '%b' "${s//%/\\x}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 从 query string 中取出指定参数
|
||||||
|
qs_get() {
|
||||||
|
local query="$1" key="$2" kv
|
||||||
|
local IFS='&'
|
||||||
|
for kv in $query; do
|
||||||
|
if [ "${kv%%=*}" = "$key" ]; then
|
||||||
|
urldecode "${kv#*=}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 解析所有节点 =====
|
||||||
|
NAMES=(); SERVERS=(); PORTS=(); UUIDS=(); SNIS=(); PBKS=(); SIDS=(); FPS=(); FLOWS=(); SECS=()
|
||||||
|
|
||||||
|
declare -A name_seen=()
|
||||||
|
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
[ -f "$f" ] || { error "文件不存在: $f"; exit 1; }
|
||||||
|
|
||||||
|
link=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null || true)
|
||||||
|
if [ -z "$link" ]; then
|
||||||
|
warn "跳过 ${f#"${SCRIPT_DIR}"/}:未找到 vless:// 链接"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
rest="${link#vless://}"
|
||||||
|
|
||||||
|
# 备注名(# 之后)
|
||||||
|
if [[ "$rest" == *"#"* ]]; then
|
||||||
|
name=$(urldecode "${rest##*#}")
|
||||||
|
rest="${rest%%#*}"
|
||||||
|
else
|
||||||
|
name=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
uuid="${rest%%@*}"
|
||||||
|
hpq="${rest#*@}"
|
||||||
|
hostport="${hpq%%\?*}"
|
||||||
|
query=""
|
||||||
|
[[ "$hpq" == *"?"* ]] && query="${hpq#*\?}"
|
||||||
|
|
||||||
|
# 兼容 IPv6 字面量 [::1]:443
|
||||||
|
if [[ "$hostport" == \[* ]]; then
|
||||||
|
server="${hostport%%]*}]"
|
||||||
|
port="${hostport##*]:}"
|
||||||
|
else
|
||||||
|
server="${hostport%%:*}"
|
||||||
|
port="${hostport##*:}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sec=$(qs_get "$query" security); sec="${sec:-none}"
|
||||||
|
sni=$(qs_get "$query" sni)
|
||||||
|
pbk=$(qs_get "$query" pbk)
|
||||||
|
sid=$(qs_get "$query" sid)
|
||||||
|
fp=$(qs_get "$query" fp); fp="${fp:-chrome}"
|
||||||
|
flow=$(qs_get "$query" flow)
|
||||||
|
net=$(qs_get "$query" type); net="${net:-tcp}"
|
||||||
|
|
||||||
|
if [ -z "$uuid" ] || [ -z "$server" ] || [ -z "$port" ]; then
|
||||||
|
error "${f#"${SCRIPT_DIR}"/} 的链接缺少 uuid/server/port,无法解析"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$sec" = "reality" ] && { [ -z "$pbk" ] || [ -z "$sni" ]; }; then
|
||||||
|
error "${f#"${SCRIPT_DIR}"/} 是 reality 节点但缺少 pbk 或 sni"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$net" != "tcp" ]; then
|
||||||
|
warn "${f#"${SCRIPT_DIR}"/}: transport=${net},本生成器目前只支持 tcp,已跳过"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -z "$name" ] && name="${server}"
|
||||||
|
# 名称去重,Clash 要求 proxy name 唯一
|
||||||
|
base="$name"; n=2
|
||||||
|
while [ -n "${name_seen[$name]:-}" ]; do
|
||||||
|
name="${base}-${n}"; n=$((n + 1))
|
||||||
|
done
|
||||||
|
name_seen["$name"]=1
|
||||||
|
|
||||||
|
NAMES+=("$name"); SERVERS+=("$server"); PORTS+=("$port"); UUIDS+=("$uuid")
|
||||||
|
SNIS+=("$sni"); PBKS+=("$pbk"); SIDS+=("$sid")
|
||||||
|
FPS+=("$fp"); FLOWS+=("$flow"); SECS+=("$sec")
|
||||||
|
|
||||||
|
log "解析成功: ${name} (${server}:${port}, ${sec})"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#NAMES[@]}" -eq 0 ]; then
|
||||||
|
error "没有解析出任何可用节点"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 备份既有输出 =====
|
||||||
|
if [ -f "$OUTPUT" ]; then
|
||||||
|
cp "$OUTPUT" "${OUTPUT}.bak"
|
||||||
|
log "已备份原配置: ${OUTPUT}.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== 生成 YAML =====
|
||||||
|
{
|
||||||
|
cat <<'HEADER'
|
||||||
|
# ============================================================
|
||||||
|
# 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖)
|
||||||
|
#
|
||||||
|
# ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、
|
||||||
|
# FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
|
||||||
|
#
|
||||||
|
# ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
mixed-port: 7890
|
||||||
|
allow-lan: false
|
||||||
|
mode: rule
|
||||||
|
log-level: info
|
||||||
|
|
||||||
|
# fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效
|
||||||
|
sniffer:
|
||||||
|
enable: true
|
||||||
|
sniff:
|
||||||
|
HTTP:
|
||||||
|
ports: [80, 8080-8880]
|
||||||
|
override-destination: true
|
||||||
|
TLS:
|
||||||
|
ports: [443, 8443]
|
||||||
|
QUIC:
|
||||||
|
ports: [443, 8443]
|
||||||
|
skip-domain:
|
||||||
|
- "+.push.apple.com"
|
||||||
|
|
||||||
|
dns:
|
||||||
|
enable: true
|
||||||
|
ipv6: false
|
||||||
|
enhanced-mode: fake-ip
|
||||||
|
fake-ip-range: 198.18.0.1/16
|
||||||
|
# 这些域名必须拿到真实 IP,否则局域网发现、时间同步、
|
||||||
|
# 网络连通性检测等会出问题
|
||||||
|
fake-ip-filter:
|
||||||
|
- "*.lan"
|
||||||
|
- "*.local"
|
||||||
|
- "*.localdomain"
|
||||||
|
- "*.home.arpa"
|
||||||
|
- "+.msftconnecttest.com"
|
||||||
|
- "+.msftncsi.com"
|
||||||
|
- "time.*.com"
|
||||||
|
- "ntp.*.com"
|
||||||
|
- "+.pool.ntp.org"
|
||||||
|
- "localhost.ptlogin2.qq.com"
|
||||||
|
# 用于解析下面 fallback 里 DoH 服务器自身的域名
|
||||||
|
default-nameserver:
|
||||||
|
- 223.5.5.5
|
||||||
|
- 119.29.29.29
|
||||||
|
nameserver:
|
||||||
|
- 223.5.5.5
|
||||||
|
- 119.29.29.29
|
||||||
|
# fallback 的意义是绕过投毒,必须用加密 DNS——
|
||||||
|
# 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback
|
||||||
|
fallback:
|
||||||
|
- https://1.1.1.1/dns-query
|
||||||
|
- https://dns.google/dns-query
|
||||||
|
fallback-filter:
|
||||||
|
geoip: true
|
||||||
|
geoip-code: CN
|
||||||
|
ipcidr:
|
||||||
|
- 240.0.0.0/4
|
||||||
|
- 0.0.0.0/32
|
||||||
|
|
||||||
|
proxies:
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
for i in "${!NAMES[@]}"; do
|
||||||
|
echo " - name: \"${NAMES[$i]}\""
|
||||||
|
echo " type: vless"
|
||||||
|
echo " server: ${SERVERS[$i]}"
|
||||||
|
echo " port: ${PORTS[$i]}"
|
||||||
|
echo " uuid: ${UUIDS[$i]}"
|
||||||
|
echo " network: tcp"
|
||||||
|
echo " udp: true"
|
||||||
|
if [ "${SECS[$i]}" = "reality" ]; then
|
||||||
|
echo " tls: true"
|
||||||
|
[ -n "${FLOWS[$i]}" ] && echo " flow: ${FLOWS[$i]}"
|
||||||
|
echo " servername: ${SNIS[$i]}"
|
||||||
|
echo " client-fingerprint: ${FPS[$i]}"
|
||||||
|
echo " reality-opts:"
|
||||||
|
echo " public-key: ${PBKS[$i]}"
|
||||||
|
[ -n "${SIDS[$i]}" ] && echo " short-id: ${SIDS[$i]}"
|
||||||
|
else
|
||||||
|
echo " tls: false"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
cat <<'GROUPS_HEAD'
|
||||||
|
proxy-groups:
|
||||||
|
- name: "Proxy"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- Auto-Select
|
||||||
|
GROUPS_HEAD
|
||||||
|
|
||||||
|
for n in "${NAMES[@]}"; do echo " - \"${n}\""; done
|
||||||
|
echo " - DIRECT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cat <<'AUTO_HEAD'
|
||||||
|
- name: "Auto-Select"
|
||||||
|
type: url-test
|
||||||
|
url: "http://www.gstatic.com/generate_204"
|
||||||
|
interval: 300
|
||||||
|
tolerance: 50
|
||||||
|
proxies:
|
||||||
|
AUTO_HEAD
|
||||||
|
|
||||||
|
for n in "${NAMES[@]}"; do echo " - \"${n}\""; done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "rules:"
|
||||||
|
echo " # 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」"
|
||||||
|
echo " # 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。"
|
||||||
|
declare -A srv_seen=()
|
||||||
|
for s in "${SERVERS[@]}"; do
|
||||||
|
[ -n "${srv_seen[$s]:-}" ] && continue
|
||||||
|
srv_seen["$s"]=1
|
||||||
|
if [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo " - IP-CIDR,${s}/32,DIRECT,no-resolve"
|
||||||
|
elif [[ "$s" == \[*\] ]]; then
|
||||||
|
echo " - IP-CIDR6,${s//[\[\]]/}/128,DIRECT,no-resolve"
|
||||||
|
else
|
||||||
|
echo " - DOMAIN,${s},DIRECT"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cat <<'RULES'
|
||||||
|
# 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理)
|
||||||
|
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
|
||||||
|
# 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中,
|
||||||
|
# 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析)
|
||||||
|
- GEOSITE,cn,DIRECT
|
||||||
|
- GEOIP,CN,DIRECT
|
||||||
|
# 兜底。MATCH 是终结规则,其后任何规则都不可达
|
||||||
|
- MATCH,Proxy
|
||||||
|
RULES
|
||||||
|
} > "$OUTPUT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "已生成: ${OUTPUT}(${#NAMES[@]} 个节点)"
|
||||||
|
for n in "${NAMES[@]}"; do echo " - ${n}"; done
|
||||||
|
echo ""
|
||||||
|
warn "该文件含真实凭据,已被根 .gitignore 排除,请勿提交或外传"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
==========================================================
|
||||||
|
Xray VLESS-Reality 部署完成 ✅
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
IP : 64.81.25.247
|
||||||
|
端口 : 443
|
||||||
|
协议 : VLESS
|
||||||
|
UUID : 7c7de15f-d408-4534-b367-f6253e850178
|
||||||
|
流控 : xtls-rprx-vision
|
||||||
|
传输 : tcp
|
||||||
|
安全 : reality
|
||||||
|
SNI : www.apple.com
|
||||||
|
Fingerprint : chrome
|
||||||
|
PublicKey : AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM
|
||||||
|
ShortId : fa4235504a09feeb
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
>>> VLESS 分享链接(可直接导入客户端):
|
||||||
|
|
||||||
|
vless://7c7de15f-d408-4534-b367-f6253e850178@64.81.25.247:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM&sid=fa4235504a09feeb&type=tcp#vps-reality-lisa-1
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
⚠️ 请妥善保存以上信息!
|
||||||
|
配置文件: /usr/local/etc/xray/config.json
|
||||||
|
凭据备份: /opt/vps-xray/.env
|
||||||
|
再次查看: bash deploy.sh --show
|
||||||
|
查看日志: journalctl -u xray -f
|
||||||
|
重启服务: systemctl restart xray
|
||||||
|
==========================================================
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# ============================================================
|
||||||
|
# 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖)
|
||||||
|
#
|
||||||
|
# ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、
|
||||||
|
# FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
|
||||||
|
#
|
||||||
|
# ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
mixed-port: 7890
|
||||||
|
allow-lan: false
|
||||||
|
mode: rule
|
||||||
|
log-level: info
|
||||||
|
|
||||||
|
# fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效
|
||||||
|
sniffer:
|
||||||
|
enable: true
|
||||||
|
sniff:
|
||||||
|
HTTP:
|
||||||
|
ports: [80, 8080-8880]
|
||||||
|
override-destination: true
|
||||||
|
TLS:
|
||||||
|
ports: [443, 8443]
|
||||||
|
QUIC:
|
||||||
|
ports: [443, 8443]
|
||||||
|
skip-domain:
|
||||||
|
- "+.push.apple.com"
|
||||||
|
|
||||||
|
dns:
|
||||||
|
enable: true
|
||||||
|
ipv6: false
|
||||||
|
enhanced-mode: fake-ip
|
||||||
|
fake-ip-range: 198.18.0.1/16
|
||||||
|
# 这些域名必须拿到真实 IP,否则局域网发现、时间同步、
|
||||||
|
# 网络连通性检测等会出问题
|
||||||
|
fake-ip-filter:
|
||||||
|
- "*.lan"
|
||||||
|
- "*.local"
|
||||||
|
- "*.localdomain"
|
||||||
|
- "*.home.arpa"
|
||||||
|
- "+.msftconnecttest.com"
|
||||||
|
- "+.msftncsi.com"
|
||||||
|
- "time.*.com"
|
||||||
|
- "ntp.*.com"
|
||||||
|
- "+.pool.ntp.org"
|
||||||
|
- "localhost.ptlogin2.qq.com"
|
||||||
|
# 用于解析下面 fallback 里 DoH 服务器自身的域名
|
||||||
|
default-nameserver:
|
||||||
|
- 223.5.5.5
|
||||||
|
- 119.29.29.29
|
||||||
|
nameserver:
|
||||||
|
- 223.5.5.5
|
||||||
|
- 119.29.29.29
|
||||||
|
# fallback 的意义是绕过投毒,必须用加密 DNS——
|
||||||
|
# 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback
|
||||||
|
fallback:
|
||||||
|
- https://1.1.1.1/dns-query
|
||||||
|
- https://dns.google/dns-query
|
||||||
|
fallback-filter:
|
||||||
|
geoip: true
|
||||||
|
geoip-code: CN
|
||||||
|
ipcidr:
|
||||||
|
- 240.0.0.0/4
|
||||||
|
- 0.0.0.0/32
|
||||||
|
|
||||||
|
proxies:
|
||||||
|
- name: "vps-reality-1"
|
||||||
|
type: vless
|
||||||
|
server: 45.32.69.133
|
||||||
|
port: 443
|
||||||
|
uuid: 5b05151b-a681-46db-a223-95dd45b13589
|
||||||
|
network: tcp
|
||||||
|
udp: true
|
||||||
|
tls: true
|
||||||
|
flow: xtls-rprx-vision
|
||||||
|
servername: www.apple.com
|
||||||
|
client-fingerprint: chrome
|
||||||
|
reality-opts:
|
||||||
|
public-key: IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk
|
||||||
|
short-id: c6bdf22bcb510f78
|
||||||
|
|
||||||
|
- name: "vps-reality-2"
|
||||||
|
type: vless
|
||||||
|
server: 45.32.71.199
|
||||||
|
port: 443
|
||||||
|
uuid: cbf3836d-517d-4dfb-86d4-a636bca27713
|
||||||
|
network: tcp
|
||||||
|
udp: true
|
||||||
|
tls: true
|
||||||
|
flow: xtls-rprx-vision
|
||||||
|
servername: www.apple.com
|
||||||
|
client-fingerprint: chrome
|
||||||
|
reality-opts:
|
||||||
|
public-key: UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
|
||||||
|
short-id: ab115ca2444053d1
|
||||||
|
|
||||||
|
proxy-groups:
|
||||||
|
- name: "Proxy"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- Auto-Select
|
||||||
|
- "vps-reality-1"
|
||||||
|
- "vps-reality-2"
|
||||||
|
- DIRECT
|
||||||
|
|
||||||
|
- name: "Auto-Select"
|
||||||
|
type: url-test
|
||||||
|
url: "http://www.gstatic.com/generate_204"
|
||||||
|
interval: 300
|
||||||
|
tolerance: 50
|
||||||
|
proxies:
|
||||||
|
- "vps-reality-1"
|
||||||
|
- "vps-reality-2"
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」
|
||||||
|
# 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。
|
||||||
|
- IP-CIDR,45.32.69.133/32,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,45.32.71.199/32,DIRECT,no-resolve
|
||||||
|
# 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理)
|
||||||
|
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
|
||||||
|
# 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中,
|
||||||
|
# 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析)
|
||||||
|
- GEOSITE,cn,DIRECT
|
||||||
|
- GEOIP,CN,DIRECT
|
||||||
|
# 兜底。MATCH 是终结规则,其后任何规则都不可达
|
||||||
|
- MATCH,Proxy
|
||||||
+9
-2
@@ -9,7 +9,7 @@
|
|||||||
流控 : xtls-rprx-vision
|
流控 : xtls-rprx-vision
|
||||||
传输 : tcp
|
传输 : tcp
|
||||||
安全 : reality
|
安全 : reality
|
||||||
SNI : dl.google.com
|
SNI : www.apple.com
|
||||||
Fingerprint : chrome
|
Fingerprint : chrome
|
||||||
PublicKey : UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
|
PublicKey : UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
|
||||||
ShortId : ab115ca2444053d1
|
ShortId : ab115ca2444053d1
|
||||||
@@ -18,6 +18,13 @@
|
|||||||
|
|
||||||
>>> VLESS 分享链接(可直接导入客户端):
|
>>> VLESS 分享链接(可直接导入客户端):
|
||||||
|
|
||||||
vless://cbf3836d-517d-4dfb-86d4-a636bca27713@45.32.71.199:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=dl.google.com&fp=chrome&pbk=UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc&sid=ab115ca2444053d1&type=tcp#VPS-Reality
|
vless://cbf3836d-517d-4dfb-86d4-a636bca27713@45.32.71.199:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc&sid=ab115ca2444053d1&type=tcp#vps-reality-2
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
⚠️ 请妥善保存以上信息!
|
||||||
|
配置文件: /usr/local/etc/xray/config.json
|
||||||
|
凭据备份: /opt/vps-xray/.env
|
||||||
|
查看日志: journalctl -u xray -f
|
||||||
|
重启服务: systemctl restart xray
|
||||||
==========================================================
|
==========================================================
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
==========================================================
|
||||||
|
Xray VLESS-Reality 部署完成 ✅
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
IP : 140.82.23.27
|
||||||
|
端口 : 443
|
||||||
|
协议 : VLESS
|
||||||
|
UUID : 3cac4c5e-c9ae-488f-9cde-0c65b2d23409
|
||||||
|
流控 : xtls-rprx-vision
|
||||||
|
传输 : tcp
|
||||||
|
安全 : reality
|
||||||
|
SNI : www.apple.com
|
||||||
|
Fingerprint : chrome
|
||||||
|
PublicKey : 5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8
|
||||||
|
ShortId : 4c5999dd1b15ff3c
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
>>> VLESS 分享链接(可直接导入客户端):
|
||||||
|
|
||||||
|
vless://3cac4c5e-c9ae-488f-9cde-0c65b2d23409@140.82.23.27:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8&sid=4c5999dd1b15ff3c&type=tcp#vps-reality-3
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
⚠️ 请妥善保存以上信息!
|
||||||
|
配置文件: /usr/local/etc/xray/config.json
|
||||||
|
凭据备份: /opt/vps-xray/.env
|
||||||
|
再次查看: bash deploy.sh --show
|
||||||
|
查看日志: journalctl -u xray -f
|
||||||
|
重启服务: systemctl restart xray
|
||||||
|
==========================================================
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
==========================================================
|
||||||
|
Xray VLESS-Reality 部署完成 ✅
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
IP : 45.32.69.133
|
||||||
|
端口 : 443
|
||||||
|
协议 : VLESS
|
||||||
|
UUID : 5b05151b-a681-46db-a223-95dd45b13589
|
||||||
|
流控 : xtls-rprx-vision
|
||||||
|
传输 : tcp
|
||||||
|
安全 : reality
|
||||||
|
SNI : www.apple.com
|
||||||
|
Fingerprint : chrome
|
||||||
|
PublicKey : IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk
|
||||||
|
ShortId : c6bdf22bcb510f78
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
>>> VLESS 分享链接(可直接导入客户端):
|
||||||
|
|
||||||
|
vless://5b05151b-a681-46db-a223-95dd45b13589@45.32.69.133:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk&sid=c6bdf22bcb510f78&type=tcp#vps-reality-1
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
⚠️ 请妥善保存以上信息!
|
||||||
|
配置文件: /usr/local/etc/xray/config.json
|
||||||
|
凭据备份: /opt/vps-xray/.env
|
||||||
|
查看日志: journalctl -u xray -f
|
||||||
|
重启服务: systemctl restart xray
|
||||||
|
==========================================================
|
||||||
+801
-68
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh 中重启计划相关逻辑的单元测试
|
||||||
|
#
|
||||||
|
# 用法: bash vps-xray/tests/test-restart-schedule.sh
|
||||||
|
#
|
||||||
|
# 不需要 root,不接触真实 systemd:所有写入路径都被重定向到临时目录,
|
||||||
|
# systemctl 用 PATH 打桩拦截。
|
||||||
|
#
|
||||||
|
# 加载方式说明:deploy.sh 结尾会直接调用 main,且顶层有 root 检查与参数解析,
|
||||||
|
# 不能整份 source。这里只抽取「多行函数定义」(形如 `name() {` 开头、`}` 结尾)。
|
||||||
|
# 单行函数(log/warn/error/step)抽不到,正好由下面的桩覆盖。
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DEPLOY="${TEST_DIR}/../deploy.sh"
|
||||||
|
|
||||||
|
if [ ! -f "$DEPLOY" ]; then
|
||||||
|
echo "找不到 $DEPLOY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$(sed -n '/^[a-z_][a-z_]*() {$/,/^}$/p' "$DEPLOY")"
|
||||||
|
|
||||||
|
# 日志桩:error 保留输出便于断言,其余静音
|
||||||
|
log() { :; }
|
||||||
|
warn() { :; }
|
||||||
|
step() { :; }
|
||||||
|
error() { echo "ERROR: $*" >&2; }
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
|
||||||
|
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; echo " 期望: $2"; echo " 实际: $3"; }
|
||||||
|
# is <描述> <实际值> <期望值>
|
||||||
|
is() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$3" "$2"; fi; }
|
||||||
|
|
||||||
|
echo "== normalize_restart_time =="
|
||||||
|
is "04:00 原样通过" "$(normalize_restart_time 04:00)" "04:00"
|
||||||
|
is "4:00 补零" "$(normalize_restart_time 4:00)" "04:00"
|
||||||
|
is "08:30 不被当八进制" "$(normalize_restart_time 08:30)" "08:30"
|
||||||
|
is "23:59 边界" "$(normalize_restart_time 23:59)" "23:59"
|
||||||
|
is "00:00 边界" "$(normalize_restart_time 00:00)" "00:00"
|
||||||
|
|
||||||
|
normalize_restart_time "24:00" >/dev/null 2>&1
|
||||||
|
is "24:00 被拒" "$?" "1"
|
||||||
|
normalize_restart_time "04:60" >/dev/null 2>&1
|
||||||
|
is "04:60 被拒" "$?" "1"
|
||||||
|
normalize_restart_time "abc" >/dev/null 2>&1
|
||||||
|
is "abc 被拒" "$?" "1"
|
||||||
|
normalize_restart_time "" >/dev/null 2>&1
|
||||||
|
is "空串被拒" "$?" "1"
|
||||||
|
|
||||||
|
echo "== resolve_restart_config =="
|
||||||
|
|
||||||
|
reset_env() {
|
||||||
|
unset XRAY_RESTART_EVERY_DAYS XRAY_RESTART_TIME XRAY_RESTART_TIMEZONE XRAY_DAILY_RESTART
|
||||||
|
RESTART_EVERY_DAYS=""; RESTART_TIME=""; RESTART_TZ=""
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_env
|
||||||
|
resolve_restart_config
|
||||||
|
is "全空时天数默认 0" "$RESTART_EVERY_DAYS" "0"
|
||||||
|
is "全空时时间默认" "$RESTART_TIME" "04:00"
|
||||||
|
is "全空时时区默认" "$RESTART_TZ" "Asia/Shanghai"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_EVERY_DAYS=7; XRAY_RESTART_TIME=5:30
|
||||||
|
resolve_restart_config
|
||||||
|
is "读取新字段天数" "$RESTART_EVERY_DAYS" "7"
|
||||||
|
is "读取新字段时间并补零" "$RESTART_TIME" "05:30"
|
||||||
|
|
||||||
|
reset_env; XRAY_DAILY_RESTART=true
|
||||||
|
resolve_restart_config
|
||||||
|
is "迁移 true → 1" "$RESTART_EVERY_DAYS" "1"
|
||||||
|
|
||||||
|
reset_env; XRAY_DAILY_RESTART=TRUE
|
||||||
|
resolve_restart_config
|
||||||
|
is "迁移 TRUE → 1" "$RESTART_EVERY_DAYS" "1"
|
||||||
|
|
||||||
|
reset_env; XRAY_DAILY_RESTART=false
|
||||||
|
resolve_restart_config
|
||||||
|
is "迁移 false → 0" "$RESTART_EVERY_DAYS" "0"
|
||||||
|
|
||||||
|
reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=3
|
||||||
|
resolve_restart_config
|
||||||
|
is "新字段优先于旧字段" "$RESTART_EVERY_DAYS" "3"
|
||||||
|
|
||||||
|
reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=0
|
||||||
|
resolve_restart_config
|
||||||
|
is "新字段显式 0 覆盖旧 true" "$RESTART_EVERY_DAYS" "0"
|
||||||
|
|
||||||
|
# 前导零必须按十进制归一化:这个值会原样写进 guard 的 DAYS=,
|
||||||
|
# 而 guard 里是算术展开——010 会变成 8 天,08/09 直接报错导致每天重启
|
||||||
|
reset_env; XRAY_RESTART_EVERY_DAYS=08
|
||||||
|
resolve_restart_config
|
||||||
|
is "08 归一化为 8(不当八进制)" "$RESTART_EVERY_DAYS" "8"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_EVERY_DAYS=09
|
||||||
|
resolve_restart_config
|
||||||
|
is "09 归一化为 9(不当八进制)" "$RESTART_EVERY_DAYS" "9"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_EVERY_DAYS=010
|
||||||
|
resolve_restart_config
|
||||||
|
is "010 归一化为 10(不当八进制)" "$RESTART_EVERY_DAYS" "10"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_EVERY_DAYS=abc
|
||||||
|
resolve_restart_config 2>/dev/null
|
||||||
|
is "天数非数字报错" "$?" "1"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_EVERY_DAYS=-1
|
||||||
|
resolve_restart_config 2>/dev/null
|
||||||
|
is "天数负数报错" "$?" "1"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_TIME=25:00
|
||||||
|
resolve_restart_config 2>/dev/null
|
||||||
|
is "时间非法报错" "$?" "1"
|
||||||
|
|
||||||
|
reset_env; XRAY_RESTART_TIMEZONE=""
|
||||||
|
resolve_restart_config 2>/dev/null
|
||||||
|
is "时区空串报错" "$?" "1"
|
||||||
|
|
||||||
|
echo "== update_env_field =="
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
cat > "${TMP}/.env" << 'ENVEOF'
|
||||||
|
# 这是用户自己加的注释
|
||||||
|
XRAY_PORT=443
|
||||||
|
XRAY_RESTART_EVERY_DAYS=0
|
||||||
|
# 尾部注释
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
update_env_field "${TMP}/.env" XRAY_RESTART_EVERY_DAYS 7
|
||||||
|
is "替换已有字段" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${TMP}/.env")" "XRAY_RESTART_EVERY_DAYS=7"
|
||||||
|
is "保留用户注释" "$(grep -c '这是用户自己加的注释' "${TMP}/.env")" "1"
|
||||||
|
is "保留尾部注释" "$(grep -c '尾部注释' "${TMP}/.env")" "1"
|
||||||
|
is "不影响其它字段" "$(grep '^XRAY_PORT=' "${TMP}/.env")" "XRAY_PORT=443"
|
||||||
|
|
||||||
|
update_env_field "${TMP}/.env" XRAY_RESTART_TIME 05:30
|
||||||
|
is "追加缺失字段" "$(grep '^XRAY_RESTART_TIME=' "${TMP}/.env")" "XRAY_RESTART_TIME=05:30"
|
||||||
|
|
||||||
|
update_env_field "${TMP}/.env" XRAY_RESTART_TIMEZONE Asia/Shanghai
|
||||||
|
is "含斜杠的值不被 sed 分隔符干扰" \
|
||||||
|
"$(grep '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "XRAY_RESTART_TIMEZONE=Asia/Shanghai"
|
||||||
|
|
||||||
|
update_env_field "${TMP}/.env" XRAY_RESTART_TIMEZONE Europe/Berlin
|
||||||
|
is "含斜杠的值可被再次替换" \
|
||||||
|
"$(grep '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "XRAY_RESTART_TIMEZONE=Europe/Berlin"
|
||||||
|
|
||||||
|
is "字段总行数正确" "$(grep -c '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "1"
|
||||||
|
|
||||||
|
update_env_field "${TMP}/nonexistent" FOO bar 2>/dev/null
|
||||||
|
is "文件不存在返回 1" "$?" "1"
|
||||||
|
|
||||||
|
printf 'KEY=old' > "${TMP}/.env2"
|
||||||
|
update_env_field "${TMP}/.env2" NEWKEY val
|
||||||
|
is "缺尾换行时不黏连" "$(grep -c '^NEWKEY=val$' "${TMP}/.env2")" "1"
|
||||||
|
is "缺尾换行时原字段完好" "$(grep -c '^KEY=old$' "${TMP}/.env2")" "1"
|
||||||
|
|
||||||
|
echo "== render_restart_units =="
|
||||||
|
|
||||||
|
SYSTEMD_DIR="${TMP}/systemd"
|
||||||
|
GUARD_BIN="${TMP}/bin/xray-restart-guard"
|
||||||
|
RESTART_STATE_FILE="${TMP}/state/last-restart"
|
||||||
|
mkdir -p "${TMP}/systemd" "${TMP}/bin" "${TMP}/state"
|
||||||
|
|
||||||
|
# systemctl 打桩:记录被调用的参数,避免碰真实 systemd
|
||||||
|
STUB="${TMP}/stub"
|
||||||
|
mkdir -p "$STUB"
|
||||||
|
cat > "${STUB}/systemctl" << 'STUBEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
|
||||||
|
STUBEOF
|
||||||
|
chmod 755 "${STUB}/systemctl"
|
||||||
|
export SYSTEMCTL_LOG="${TMP}/systemctl.log"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
export PATH="${STUB}:${PATH}"
|
||||||
|
|
||||||
|
render_restart_units 7 04:00 Asia/Shanghai
|
||||||
|
|
||||||
|
is "timer 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes)" "yes"
|
||||||
|
is "service 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes)" "yes"
|
||||||
|
is "guard 可执行" "$([ -x "$GUARD_BIN" ] && echo yes)" "yes"
|
||||||
|
|
||||||
|
# 时区必须写在日历表达式里:[Timer] 段没有 TimeZone= 这个 key,
|
||||||
|
# 写了会被 systemd 忽略并按系统本地时区(VPS 多为 UTC)解析,静默偏移 8 小时
|
||||||
|
is "OnCalendar 每天触发且带时区" \
|
||||||
|
"$(grep -c '^OnCalendar=\*-\*-\* 04:00:00 Asia/Shanghai$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
|
||||||
|
is "不再使用非法的 TimeZone= 指令" \
|
||||||
|
"$(grep -c '^TimeZone=' "${SYSTEMD_DIR}/xray-restart.timer")" "0"
|
||||||
|
is "Persistent 开启" \
|
||||||
|
"$(grep -c '^Persistent=true$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
|
||||||
|
is "service 指向 guard" \
|
||||||
|
"$(grep -c "^ExecStart=${GUARD_BIN}\$" "${SYSTEMD_DIR}/xray-restart.service")" "1"
|
||||||
|
is "guard 里天数正确" \
|
||||||
|
"$(grep -c '^DAYS=7$' "$GUARD_BIN")" "1"
|
||||||
|
is "guard 里状态文件路径正确" \
|
||||||
|
"$(grep -c "^STATE=\"${RESTART_STATE_FILE}\"\$" "$GUARD_BIN")" "1"
|
||||||
|
is "guard 用 PATH 查找 systemctl 而非绝对路径" \
|
||||||
|
"$(grep -c '^exec systemctl restart xray$' "$GUARD_BIN")" "1"
|
||||||
|
|
||||||
|
render_restart_units 3 23:05 Europe/Berlin
|
||||||
|
is "重复渲染覆盖天数" "$(grep -c '^DAYS=3$' "$GUARD_BIN")" "1"
|
||||||
|
is "重复渲染覆盖时间与时区" \
|
||||||
|
"$(grep -c '^OnCalendar=\*-\*-\* 23:05:00 Europe/Berlin$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
|
||||||
|
|
||||||
|
remove_restart_units
|
||||||
|
is "remove 后 timer 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
|
||||||
|
is "remove 后 service 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
|
||||||
|
is "remove 后 guard 消失" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
|
||||||
|
|
||||||
|
remove_restart_units
|
||||||
|
is "重复 remove 返回 1" "$?" "1"
|
||||||
|
|
||||||
|
echo "== guard 脚本行为 =="
|
||||||
|
|
||||||
|
render_restart_units 7 04:00 Asia/Shanghai
|
||||||
|
rm -f "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
|
||||||
|
# 无状态文件 → 首次必须执行
|
||||||
|
out=$("$GUARD_BIN" 2>&1)
|
||||||
|
is "首次执行重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
is "首次写入时间戳" "$([ -f "$RESTART_STATE_FILE" ] && echo yes)" "yes"
|
||||||
|
|
||||||
|
# 刚重启过 → 必须跳过
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
out=$("$GUARD_BIN" 2>&1)
|
||||||
|
is "刚重启过则跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
is "跳过时有提示" "$(echo "$out" | grep -c '跳过')" "1"
|
||||||
|
|
||||||
|
# 距上次 6 天 → 仍未满 7 天,跳过
|
||||||
|
echo "$(( $(date +%s) - 6 * 86400 ))" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "6 天不足 7 天则跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
# 距上次 7 天差 100 秒 → 300 秒容差内,必须执行
|
||||||
|
echo "$(( $(date +%s) - 7 * 86400 + 100 ))" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "容差内视为已满 7 天" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
|
||||||
|
# 距上次 7 天差 600 秒 → 超出容差,跳过
|
||||||
|
echo "$(( $(date +%s) - 7 * 86400 + 600 ))" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "超出容差仍判不足" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
# 状态文件内容被写坏 → 按 0 处理,立即执行
|
||||||
|
echo "garbage" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "状态文件非法时立即执行" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
is "非法内容被覆盖为数字" \
|
||||||
|
"$(grep -cE '^[0-9]+$' "$RESTART_STATE_FILE")" "1"
|
||||||
|
|
||||||
|
echo "garbage" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
guard_err=$("$GUARD_BIN" 2>&1 >/dev/null)
|
||||||
|
is "状态文件非法时不产生算术求值报错" \
|
||||||
|
"$(printf '%s' "$guard_err" | grep -c 'integer expression')" "0"
|
||||||
|
|
||||||
|
# 时间戳落在未来(RTC 错乱 / NTP 大幅向后校正)→ 必须按 0 处理立即执行,
|
||||||
|
# 否则时钟校回后 now-last 恒为负,此后永远跳过且无自愈路径
|
||||||
|
echo "$(( $(date +%s) + 30 * 86400 ))" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "未来时间戳不导致永久跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
is "未来时间戳被覆盖为当前时间" \
|
||||||
|
"$([ "$(cat "$RESTART_STATE_FILE")" -le "$(date +%s)" ] && echo yes || echo no)" "yes"
|
||||||
|
|
||||||
|
# 状态文件写不进去 → 必须跳过本轮。照常重启会让下一轮依旧读不到时间戳,
|
||||||
|
# 退化成每天硬重启且无限持续
|
||||||
|
guard_saved_state="$RESTART_STATE_FILE"
|
||||||
|
|
||||||
|
# (a) 目录建不出来:拿一个普通文件占住父目录的位置
|
||||||
|
: > "${TMP}/blocked"
|
||||||
|
RESTART_STATE_FILE="${TMP}/blocked/last-restart"
|
||||||
|
render_restart_units 7 04:00 Asia/Shanghai
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "状态目录建不出来时跳过重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
# (b) 目录能建但文件写不进去:让状态文件路径本身是个目录
|
||||||
|
mkdir -p "${TMP}/asdir/last-restart"
|
||||||
|
RESTART_STATE_FILE="${TMP}/asdir/last-restart"
|
||||||
|
render_restart_units 7 04:00 Asia/Shanghai
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "时间戳写不进去时跳过重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
RESTART_STATE_FILE="$guard_saved_state"
|
||||||
|
|
||||||
|
# 每日模式:距上次 1 天即执行
|
||||||
|
render_restart_units 1 04:00 Asia/Shanghai
|
||||||
|
echo "$(( $(date +%s) - 86400 ))" > "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "N=1 时满一天即执行" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
|
||||||
|
# 时间戳先于重启写入:用一个必定失败的 systemctl 桩验证
|
||||||
|
cat > "${STUB}/systemctl" << 'STUBEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
|
||||||
|
exit 1
|
||||||
|
STUBEOF
|
||||||
|
chmod 755 "${STUB}/systemctl"
|
||||||
|
rm -f "$RESTART_STATE_FILE"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
"$GUARD_BIN" > /dev/null 2>&1
|
||||||
|
is "重启失败时时间戳仍已写入" "$([ -s "$RESTART_STATE_FILE" ] && echo yes)" "yes"
|
||||||
|
|
||||||
|
# 恢复正常桩,避免影响后续
|
||||||
|
cat > "${STUB}/systemctl" << 'STUBEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
|
||||||
|
STUBEOF
|
||||||
|
chmod 755 "${STUB}/systemctl"
|
||||||
|
|
||||||
|
echo "== configure_restart_timer =="
|
||||||
|
|
||||||
|
# 启用方向
|
||||||
|
RESTART_EVERY_DAYS=7
|
||||||
|
RESTART_TIME="04:00"
|
||||||
|
RESTART_TZ="Asia/Shanghai"
|
||||||
|
rm -f "${SYSTEMD_DIR}/xray-restart.timer" "${SYSTEMD_DIR}/xray-restart.service" "$GUARD_BIN"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
configure_restart_timer > /dev/null 2>&1
|
||||||
|
is "启用时渲染出 timer" \
|
||||||
|
"$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "yes"
|
||||||
|
is "启用时渲染出 guard" "$([ -x "$GUARD_BIN" ] && echo yes || echo no)" "yes"
|
||||||
|
is "启用时执行 daemon-reload" \
|
||||||
|
"$(grep -c '^systemctl daemon-reload$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
is "启用时执行 enable --now" \
|
||||||
|
"$(grep -c '^systemctl enable --now xray-restart.timer$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
is "启用时不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
# enable 失败时不得中断调用方:configure_restart_timer 在部署主干上被裸调用
|
||||||
|
cat > "${STUB}/systemctl" << 'STUBEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
|
||||||
|
case "$1" in
|
||||||
|
enable) exit 1 ;;
|
||||||
|
esac
|
||||||
|
STUBEOF
|
||||||
|
chmod 755 "${STUB}/systemctl"
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
configure_restart_timer > /dev/null 2>&1
|
||||||
|
is "enable 失败时仍返回 0(不中断部署主干)" "$?" "0"
|
||||||
|
cat > "${STUB}/systemctl" << 'STUBEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
|
||||||
|
STUBEOF
|
||||||
|
chmod 755 "${STUB}/systemctl"
|
||||||
|
|
||||||
|
# 关闭方向
|
||||||
|
echo "12345" > "$RESTART_STATE_FILE"
|
||||||
|
RESTART_EVERY_DAYS=0
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
configure_restart_timer > /dev/null 2>&1
|
||||||
|
is "关闭时删除 timer" \
|
||||||
|
"$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
|
||||||
|
is "关闭时删除 service" \
|
||||||
|
"$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
|
||||||
|
is "关闭时删除 guard" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
|
||||||
|
is "关闭时保留状态文件" "$(cat "$RESTART_STATE_FILE")" "12345"
|
||||||
|
is "关闭时执行 disable --now" \
|
||||||
|
"$(grep -c '^systemctl disable --now xray-restart.timer$' "$SYSTEMCTL_LOG")" "1"
|
||||||
|
is "关闭时不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
echo "== apply_restart_only 安全不变式 =="
|
||||||
|
|
||||||
|
# 这一节守护本特性最核心的承诺:--restart 不重启 xray、不碰 vless 链接。
|
||||||
|
# 缺了它,谁在 apply_restart_only 里加一行 systemctl restart xray 都不会被发现。
|
||||||
|
APP_DIR="${TMP}/app"
|
||||||
|
mkdir -p "$APP_DIR"
|
||||||
|
SCRIPT_DIR="$APP_DIR"
|
||||||
|
|
||||||
|
make_app_env() {
|
||||||
|
cat > "${APP_DIR}/.env" << 'ENVEOF'
|
||||||
|
# Xray 部署配置(由 deploy.sh 自动生成)
|
||||||
|
|
||||||
|
XRAY_MODE=reality
|
||||||
|
XRAY_PORT=443
|
||||||
|
XRAY_UUID=11111111-2222-3333-4444-555555555555
|
||||||
|
# 这是用户自己加的注释,不许动
|
||||||
|
SERVER_IP=
|
||||||
|
XRAY_RESTART_EVERY_DAYS=0
|
||||||
|
XRAY_RESTART_TIME=04:00
|
||||||
|
XRAY_RESTART_TIMEZONE=Asia/Shanghai
|
||||||
|
XRAY_PUBLIC_KEY=pubkeyzzz
|
||||||
|
XRAY_SHORT_ID=abcdef0123456789
|
||||||
|
REALITY_SNI=www.apple.com
|
||||||
|
ENVEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 启用:--restart 7 5:30 ---
|
||||||
|
reset_env
|
||||||
|
make_app_env
|
||||||
|
cp "${APP_DIR}/.env" "${TMP}/env.before"
|
||||||
|
RESTART_ARG_DAYS=7
|
||||||
|
RESTART_ARG_TIME=5:30
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
apply_restart_only > /dev/null 2>&1
|
||||||
|
|
||||||
|
is "apply_restart_only 绝不重启 xray" \
|
||||||
|
"$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
is "天数写回 .env" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${APP_DIR}/.env")" "XRAY_RESTART_EVERY_DAYS=7"
|
||||||
|
is "时间归一化后写回" "$(grep '^XRAY_RESTART_TIME=' "${APP_DIR}/.env")" "XRAY_RESTART_TIME=05:30"
|
||||||
|
is ".env 只有这两行发生变化" \
|
||||||
|
"$(diff "${TMP}/env.before" "${APP_DIR}/.env" | grep -c '^[<>]')" "4"
|
||||||
|
is "用户注释原样保留" "$(grep -c '这是用户自己加的注释,不许动' "${APP_DIR}/.env")" "1"
|
||||||
|
is "UUID 未被改动" "$(grep -c '^XRAY_UUID=11111111-2222-3333-4444-555555555555$' "${APP_DIR}/.env")" "1"
|
||||||
|
is "SNI 未被改动" "$(grep -c '^REALITY_SNI=www\.apple\.com$' "${APP_DIR}/.env")" "1"
|
||||||
|
is "PublicKey 未被改动" "$(grep -c '^XRAY_PUBLIC_KEY=pubkeyzzz$' "${APP_DIR}/.env")" "1"
|
||||||
|
is "启用后 timer 已就位" \
|
||||||
|
"$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "yes"
|
||||||
|
|
||||||
|
# --- 不带位置参数:.env 必须完全不变 ---
|
||||||
|
reset_env
|
||||||
|
make_app_env
|
||||||
|
XRAY_RESTART_EVERY_DAYS=7
|
||||||
|
XRAY_RESTART_TIME=04:00
|
||||||
|
cp "${APP_DIR}/.env" "${TMP}/env.before2"
|
||||||
|
RESTART_ARG_DAYS=""
|
||||||
|
RESTART_ARG_TIME=""
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
apply_restart_only > /dev/null 2>&1
|
||||||
|
is "不带位置参数时 .env 一字不改" \
|
||||||
|
"$(diff "${TMP}/env.before2" "${APP_DIR}/.env" > /dev/null && echo same || echo differ)" "same"
|
||||||
|
is "不带位置参数时也不重启 xray" \
|
||||||
|
"$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
# --- 关闭:--restart 0 ---
|
||||||
|
reset_env
|
||||||
|
echo "12345" > "$RESTART_STATE_FILE"
|
||||||
|
RESTART_ARG_DAYS=0
|
||||||
|
RESTART_ARG_TIME=""
|
||||||
|
: > "$SYSTEMCTL_LOG"
|
||||||
|
apply_restart_only > /dev/null 2>&1
|
||||||
|
is "关闭后 timer 被删" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
|
||||||
|
is "关闭后 service 被删" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
|
||||||
|
is "关闭后 guard 被删" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
|
||||||
|
is "关闭后状态文件保留" "$(cat "$RESTART_STATE_FILE")" "12345"
|
||||||
|
is "关闭时天数写回 .env" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${APP_DIR}/.env")" "XRAY_RESTART_EVERY_DAYS=0"
|
||||||
|
is "关闭时也不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
|
||||||
|
|
||||||
|
echo "== print_next_restart =="
|
||||||
|
|
||||||
|
RESTART_EVERY_DAYS=7
|
||||||
|
rm -f "$RESTART_STATE_FILE"
|
||||||
|
is "无状态文件时提示下次唤醒即重启" \
|
||||||
|
"$(print_next_restart | grep -c '下次唤醒时即会重启')" "1"
|
||||||
|
|
||||||
|
now_ts=$(date +%s)
|
||||||
|
echo "$(( now_ts - 6 * 86400 ))" > "$RESTART_STATE_FILE"
|
||||||
|
is "未满 N 天时给出的是真实重启日期而非明天的唤醒时间" \
|
||||||
|
"$(print_next_restart | grep -c "下次实际重启: $(date -d "@$(( now_ts + 86400 ))" '+%Y-%m-%d')")" "1"
|
||||||
|
|
||||||
|
echo "$(( $(date +%s) - 9 * 86400 ))" > "$RESTART_STATE_FILE"
|
||||||
|
is "已满 N 天时提示下次唤醒即重启" \
|
||||||
|
"$(print_next_restart | grep -c '下次唤醒时即会重启')" "1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "通过 ${PASS},失败 ${FAIL}"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# 定时重启功能的真实环境验收脚本
|
||||||
|
#
|
||||||
|
# 用法(在 VPS 上,root):
|
||||||
|
# cd /opt/vps-xray && bash tests/verify-restart-on-vps.sh
|
||||||
|
#
|
||||||
|
# 为什么必须在真机上跑:本地的 62→101 项单元测试都是把生成的文件写进临时目录
|
||||||
|
# 再比对内容——它们能验证「我们写出了预期的字节」,但验证不了「systemd 认不认
|
||||||
|
# 这些字节」。TimeZone= 那个缺陷就是这么漏过去的:文件里确实有那一行,断言通过,
|
||||||
|
# 而 systemd 加载时只会记一条 Unknown key name 然后按系统本地时区解析。
|
||||||
|
#
|
||||||
|
# 本脚本只做安全操作:不升级 Xray、不重写 config.json、不重启 xray。
|
||||||
|
# 会临时改动 .env 的重启字段与三个 systemd 单元文件,结束时恢复原状。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
ok() { PASS=$((PASS+1)); echo " ✅ $1"; }
|
||||||
|
bad() { FAIL=$((FAIL+1)); echo " ❌ $1"; [ -n "${2:-}" ] && echo " 实际: $2"; }
|
||||||
|
sec() { echo ""; echo "===== $* ====="; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "需要 root"; exit 1; }
|
||||||
|
[ -f .env ] || { echo "找不到 .env,这台机器还没部署过"; exit 1; }
|
||||||
|
|
||||||
|
ENV_BAK="$(mktemp)"; cp .env "$ENV_BAK"
|
||||||
|
restore() {
|
||||||
|
echo ""
|
||||||
|
echo "===== 恢复原状 ====="
|
||||||
|
cp "$ENV_BAK" .env && echo " .env 已还原"
|
||||||
|
rm -f "$ENV_BAK"
|
||||||
|
bash deploy.sh --restart >/dev/null 2>&1 && echo " timer 已按还原后的 .env 重建" \
|
||||||
|
|| echo " timer 重建失败,请手工检查 systemctl list-timers xray-restart.timer"
|
||||||
|
}
|
||||||
|
trap restore EXIT
|
||||||
|
|
||||||
|
sec "0. 环境"
|
||||||
|
systemctl --version | head -1
|
||||||
|
timedatectl 2>/dev/null | grep -i "time zone" || cat /etc/timezone 2>/dev/null
|
||||||
|
|
||||||
|
sec "1. Critical-1:OnCalendar 尾随时区是否被 systemd 真正接受"
|
||||||
|
# 这是全套验证里最关键的一条。旧实现用 [Timer] 段的 TimeZone=,
|
||||||
|
# 而 systemd.timer 根本没有这个 key——写了会被忽略并按系统本地时区解析。
|
||||||
|
if out=$(systemd-analyze calendar "*-*-* 04:00:00 Asia/Shanghai" 2>&1); then
|
||||||
|
ok "systemd 接受带时区的日历表达式"
|
||||||
|
echo "$out" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
bad "systemd 不接受带时区的日历表达式(该 systemd 版本可能低于 242)" "$out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sec "2. 启用:--restart 7 04:00"
|
||||||
|
BEFORE_TS=$(systemctl show xray -p ActiveEnterTimestamp --value)
|
||||||
|
bash deploy.sh --restart 7 04:00
|
||||||
|
AFTER_TS=$(systemctl show xray -p ActiveEnterTimestamp --value)
|
||||||
|
|
||||||
|
[ "$BEFORE_TS" = "$AFTER_TS" ] \
|
||||||
|
&& ok "xray 未被重启(ActiveEnterTimestamp 前后一致)" \
|
||||||
|
|| bad "xray 被重启了!--restart 不该碰服务" "before=$BEFORE_TS after=$AFTER_TS"
|
||||||
|
|
||||||
|
grep -q '^XRAY_RESTART_EVERY_DAYS=7$' .env && ok ".env 天数已写入" || bad ".env 天数未写入" "$(grep '^XRAY_RESTART_EVERY_DAYS=' .env)"
|
||||||
|
grep -q '^XRAY_RESTART_TIME=04:00$' .env && ok ".env 时间已写入" || bad ".env 时间未写入" "$(grep '^XRAY_RESTART_TIME=' .env)"
|
||||||
|
|
||||||
|
sec "3. 生成的 timer 是否被 systemd 正确加载"
|
||||||
|
systemd-analyze verify /etc/systemd/system/xray-restart.timer 2>&1 | sed 's/^/ /'
|
||||||
|
if journalctl -b --no-pager 2>/dev/null | grep -q "Unknown key name.*xray-restart"; then
|
||||||
|
bad "journal 里有 Unknown key name 告警,说明 timer 里仍有非法指令"
|
||||||
|
else
|
||||||
|
ok "journal 无 Unknown key name 告警"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CAL=$(systemctl show xray-restart.timer -p TimersCalendar --value 2>/dev/null)
|
||||||
|
echo " TimersCalendar: $CAL"
|
||||||
|
case "$CAL" in
|
||||||
|
*Asia/Shanghai*) ok "解析后的日历规格里含时区" ;;
|
||||||
|
*) bad "解析后的日历规格里没有时区——时区没生效" "$CAL" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
grep -q '^TimeZone=' /etc/systemd/system/xray-restart.timer \
|
||||||
|
&& bad "timer 里仍有非法的 TimeZone= 指令" \
|
||||||
|
|| ok "timer 里无 TimeZone= 指令"
|
||||||
|
|
||||||
|
sec "4. 守卫脚本行为"
|
||||||
|
rm -f /var/lib/xray/last-restart
|
||||||
|
echo " -- 首次(无状态文件)应执行重启 --"
|
||||||
|
/usr/local/bin/xray-restart-guard 2>&1 | sed 's/^/ /'
|
||||||
|
[ -s /var/lib/xray/last-restart ] && ok "时间戳已写入" || bad "时间戳未写入"
|
||||||
|
|
||||||
|
echo " -- 第二次(刚重启过)应跳过 --"
|
||||||
|
G2=$(/usr/local/bin/xray-restart-guard 2>&1); echo "$G2" | sed 's/^/ /'
|
||||||
|
echo "$G2" | grep -q "跳过" && ok "正确跳过" || bad "未跳过" "$G2"
|
||||||
|
|
||||||
|
echo " -- Important-4:时间戳落在未来时应视为 0 并执行重启 --"
|
||||||
|
TS_BEFORE_FUTURE=$(systemctl show xray -p ActiveEnterTimestamp --value)
|
||||||
|
echo "$(( $(date +%s) + 86400*365 ))" > /var/lib/xray/last-restart
|
||||||
|
G3=$(/usr/local/bin/xray-restart-guard 2>&1); echo "$G3" | sed 's/^/ /'
|
||||||
|
if echo "$G3" | grep -q "跳过"; then
|
||||||
|
bad "未来时间戳导致永久跳过——保护未生效"
|
||||||
|
else
|
||||||
|
ok "未来时间戳被正确当成 0"
|
||||||
|
fi
|
||||||
|
echo " (这一步会真的重启一次 xray,是预期内的)"
|
||||||
|
sleep 2
|
||||||
|
systemctl is-active --quiet xray && ok "重启后 xray 仍然存活" || bad "xray 未能重新启动!"
|
||||||
|
|
||||||
|
sec "5. Important-6:下次实际重启时间是否被正确显示"
|
||||||
|
echo "$(( $(date +%s) - 3*86400 ))" > /var/lib/xray/last-restart
|
||||||
|
OUT=$(bash deploy.sh --restart 2>&1); echo "$OUT" | sed 's/^/ /'
|
||||||
|
echo " (N=7、距上次 3 天,所以真实的下次重启应在 4 天后,而非次日)"
|
||||||
|
|
||||||
|
sec "6. 关闭:--restart 0"
|
||||||
|
bash deploy.sh --restart 0 >/dev/null 2>&1
|
||||||
|
for f in /etc/systemd/system/xray-restart.timer /etc/systemd/system/xray-restart.service /usr/local/bin/xray-restart-guard; do
|
||||||
|
[ -f "$f" ] && bad "未删除: $f" || ok "已删除: $(basename "$f")"
|
||||||
|
done
|
||||||
|
[ -f /var/lib/xray/last-restart ] && ok "状态文件按设计保留" || bad "状态文件被误删(关闭≠卸载)"
|
||||||
|
|
||||||
|
sec "7. Critical-2:前导零天数"
|
||||||
|
bash deploy.sh --restart 09 >/dev/null 2>&1
|
||||||
|
D=$(grep '^XRAY_RESTART_EVERY_DAYS=' .env | cut -d= -f2)
|
||||||
|
[ "$D" = "9" ] && ok "09 被归一化成 9(未被当八进制)" || bad "09 未被正确归一化" "$D"
|
||||||
|
if [ -f /usr/local/bin/xray-restart-guard ]; then
|
||||||
|
grep -q '^DAYS=9$' /usr/local/bin/xray-restart-guard \
|
||||||
|
&& ok "guard 里 DAYS=9" || bad "guard 里天数不对" "$(grep '^DAYS=' /usr/local/bin/xray-restart-guard)"
|
||||||
|
OUT=$(/usr/local/bin/xray-restart-guard 2>&1)
|
||||||
|
echo "$OUT" | grep -qi "value too great\|error token" \
|
||||||
|
&& bad "guard 仍有八进制算术错误" "$OUT" \
|
||||||
|
|| ok "guard 无八进制算术错误"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sec "8. 参数校验与互斥"
|
||||||
|
bash deploy.sh --restart 7 25:00 >/dev/null 2>&1; [ $? -eq 1 ] && ok "非法时间退出码 1" || bad "非法时间退出码不是 1"
|
||||||
|
bash deploy.sh --badarg >/dev/null 2>&1; [ $? -eq 1 ] && ok "未知参数退出码 1" || bad "未知参数退出码不是 1"
|
||||||
|
bash deploy.sh --help >/dev/null 2>&1; [ $? -eq 0 ] && ok "--help 退出码 0" || bad "--help 退出码不是 0"
|
||||||
|
# 注意:不能写成 `bash deploy.sh ... | grep -q ...`。本脚本开了 pipefail,
|
||||||
|
# 而 deploy.sh 在这里本就该以退出码 1 结束,管道会因此被判为失败——
|
||||||
|
# 于是断言恒为「未生效」,与被测行为无关。必须先取输出再匹配。
|
||||||
|
MX_OUT=$(bash deploy.sh --restart 7 --redetect 2>&1 || true)
|
||||||
|
echo "$MX_OUT" | grep -q "不能与" && ok "互斥校验生效" || bad "互斥校验未生效" "$MX_OUT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 通过 ${PASS},失败 ${FAIL}"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
echo "还需要你手动做一步(本脚本刻意不做,因为它会重启服务、断掉所有连接):"
|
||||||
|
echo ""
|
||||||
|
echo " # 旧字段迁移验证 —— 建议挑一台当前没在用的机器"
|
||||||
|
echo " cp .env /tmp/env.bak"
|
||||||
|
echo " sed -i '/^XRAY_RESTART_/d' .env"
|
||||||
|
echo " echo 'XRAY_DAILY_RESTART=true' >> .env"
|
||||||
|
echo " bash deploy.sh # 完整部署,会重启 xray"
|
||||||
|
echo " grep -E '^XRAY_RESTART_|^XRAY_DAILY' .env"
|
||||||
|
echo " # 预期:出现 XRAY_RESTART_EVERY_DAYS=1,且 XRAY_DAILY_RESTART 已消失"
|
||||||
|
echo " cp /tmp/env.bak .env && bash deploy.sh --restart"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
+38
-5
@@ -83,11 +83,36 @@ else
|
|||||||
fi
|
fi
|
||||||
systemctl disable xray 2>/dev/null || true
|
systemctl disable xray 2>/dev/null || true
|
||||||
|
|
||||||
|
# 每日重启 timer(旧版部署可能残留)
|
||||||
|
if [ -f /etc/systemd/system/xray-restart.timer ] \
|
||||||
|
|| [ -f /usr/local/bin/xray-restart-guard ]; then
|
||||||
|
systemctl disable --now xray-restart.timer 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/xray-restart.timer \
|
||||||
|
/etc/systemd/system/xray-restart.service \
|
||||||
|
/usr/local/bin/xray-restart-guard \
|
||||||
|
/var/lib/xray/last-restart
|
||||||
|
log "已移除定时重启 timer 与状态文件"
|
||||||
|
fi
|
||||||
|
|
||||||
# ===== 2. 卸载 Xray =====
|
# ===== 2. 卸载 Xray =====
|
||||||
log "正在卸载 Xray..."
|
log "正在卸载 Xray..."
|
||||||
if [ -f /usr/local/bin/xray ]; then
|
if [ -f /usr/local/bin/xray ]; then
|
||||||
# 使用官方卸载方式
|
# 官方卸载:下载到临时文件再执行。
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh) --remove 2>/dev/null || true
|
# 不能用 bash <(curl ...)——curl 缺失时进程替换只产生空脚本,
|
||||||
|
# 外层 bash 仍返回 0,卸载会被静默跳过
|
||||||
|
if command -v curl &>/dev/null; then
|
||||||
|
installer="$(mktemp)"
|
||||||
|
if curl -fsSL --max-time 60 \
|
||||||
|
https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh \
|
||||||
|
-o "$installer" && [ -s "$installer" ]; then
|
||||||
|
bash "$installer" remove --purge || warn "官方卸载脚本执行失败,改为手动清理"
|
||||||
|
else
|
||||||
|
warn "无法下载官方卸载脚本,改为手动清理"
|
||||||
|
fi
|
||||||
|
rm -f "$installer"
|
||||||
|
else
|
||||||
|
warn "未安装 curl,跳过官方卸载脚本,改为手动清理"
|
||||||
|
fi
|
||||||
# 手动清理残留
|
# 手动清理残留
|
||||||
rm -f /usr/local/bin/xray
|
rm -f /usr/local/bin/xray
|
||||||
rm -rf /usr/local/etc/xray
|
rm -rf /usr/local/etc/xray
|
||||||
@@ -128,9 +153,17 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
read -r -p "是否删除部署目录 ${SCRIPT_DIR}?(y/N): " del_deploy
|
read -r -p "是否删除部署目录 ${SCRIPT_DIR}?(y/N): " del_deploy
|
||||||
if [[ "$del_deploy" =~ ^[Yy]$ ]]; then
|
if [[ "$del_deploy" =~ ^[Yy]$ ]]; then
|
||||||
cd /opt
|
# 删除前做路径断言,避免 SCRIPT_DIR 异常时误删根目录或系统目录
|
||||||
rm -rf "$SCRIPT_DIR"
|
case "$SCRIPT_DIR" in
|
||||||
log "已删除部署目录"
|
/|/root|/etc|/usr|/var|/home|/opt|"")
|
||||||
|
error "拒绝删除高危路径: '${SCRIPT_DIR}',请手动处理"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
cd /
|
||||||
|
rm -rf "$SCRIPT_DIR"
|
||||||
|
log "已删除部署目录: ${SCRIPT_DIR}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
else
|
else
|
||||||
warn "保留部署目录: ${SCRIPT_DIR}"
|
warn "保留部署目录: ${SCRIPT_DIR}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 仅上传服务端所需文件,跳过文档和客户端配置
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VPS_IP="${1:-}"
|
||||||
|
SSH_PORT="${2:-22}"
|
||||||
|
if [[ -z "$VPS_IP" ]]; then
|
||||||
|
echo "用法: bash upload.sh <VPS_IP> [SSH_PORT]"
|
||||||
|
echo "示例: bash upload.sh 1.2.3.4 # 使用默认端口 22"
|
||||||
|
echo "示例: bash upload.sh 1.2.3.4 2222 # 使用自定义端口 2222"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || (( SSH_PORT < 1 || SSH_PORT > 65535 )); then
|
||||||
|
echo "错误: 端口必须是 1-65535 之间的数字,当前为 '${SSH_PORT}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE_DIR="/opt/vps-xray"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo ">>> 创建远程目录 ${REMOTE_DIR} (端口 ${SSH_PORT})"
|
||||||
|
ssh -p "${SSH_PORT}" "root@${VPS_IP}" "mkdir -p ${REMOTE_DIR}"
|
||||||
|
|
||||||
|
echo ">>> 上传部署文件"
|
||||||
|
scp -P "${SSH_PORT}" \
|
||||||
|
"${SCRIPT_DIR}/deploy.sh" \
|
||||||
|
"${SCRIPT_DIR}/uninstall.sh" \
|
||||||
|
"${SCRIPT_DIR}/backup.sh" \
|
||||||
|
"${SCRIPT_DIR}/.env.example" \
|
||||||
|
"root@${VPS_IP}:${REMOTE_DIR}/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "上传完成,文件位于 root@${VPS_IP}:${REMOTE_DIR}"
|
||||||
|
echo ""
|
||||||
|
echo "下一步:"
|
||||||
|
if [[ "$SSH_PORT" == "22" ]]; then
|
||||||
|
echo " ssh root@${VPS_IP}"
|
||||||
|
else
|
||||||
|
echo " ssh -p ${SSH_PORT} root@${VPS_IP}"
|
||||||
|
fi
|
||||||
|
echo " cd ${REMOTE_DIR} && bash deploy.sh"
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
mixed-port: 7890
|
|
||||||
allow-lan: false
|
|
||||||
mode: rule
|
|
||||||
log-level: info
|
|
||||||
|
|
||||||
dns:
|
|
||||||
enable: true
|
|
||||||
ipv6: false
|
|
||||||
enhanced-mode: fake-ip
|
|
||||||
nameserver:
|
|
||||||
- 223.5.5.5
|
|
||||||
- 119.29.29.29
|
|
||||||
fallback:
|
|
||||||
- 8.8.8.8
|
|
||||||
- 1.1.1.1
|
|
||||||
fallback-filter:
|
|
||||||
geoip: true
|
|
||||||
geoip-code: CN
|
|
||||||
|
|
||||||
proxies:
|
|
||||||
- name: "VPS-Reality"
|
|
||||||
type: vless
|
|
||||||
server: 45.32.69.133
|
|
||||||
port: 443
|
|
||||||
uuid: e02bfa4e-8c3c-4625-a0dc-207dd37bffd8
|
|
||||||
network: tcp
|
|
||||||
tls: true
|
|
||||||
udp: true
|
|
||||||
flow: xtls-rprx-vision
|
|
||||||
servername: www.microsoft.com
|
|
||||||
client-fingerprint: chrome
|
|
||||||
reality-opts:
|
|
||||||
public-key: J0Q5maB5UZVzgAH1V6nGMHKuaaT4mNQRKMS5ztROKnY
|
|
||||||
short-id: 0def0ee07b3e5f05
|
|
||||||
|
|
||||||
proxy-groups:
|
|
||||||
- name: "Proxy"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- VPS-Reality
|
|
||||||
- DIRECT
|
|
||||||
|
|
||||||
rules:
|
|
||||||
# 国内直连
|
|
||||||
- GEOIP,CN,DIRECT
|
|
||||||
- GEOSITE,cn,DIRECT
|
|
||||||
# 其余走代理
|
|
||||||
- MATCH,Proxy
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
==========================================================
|
|
||||||
Xray VLESS-Reality 部署完成 ✅ 运行中
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
IP : 45.32.69.133
|
|
||||||
端口 : 443
|
|
||||||
协议 : VLESS
|
|
||||||
UUID : e02bfa4e-8c3c-4625-a0dc-207dd37bffd8
|
|
||||||
流控 : xtls-rprx-vision
|
|
||||||
传输 : tcp
|
|
||||||
安全 : reality
|
|
||||||
SNI : www.microsoft.com
|
|
||||||
Fingerprint : chrome
|
|
||||||
PublicKey : J0Q5maB5UZVzgAH1V6nGMHKuaaT4mNQRKMS5ztROKnY
|
|
||||||
ShortId : 0def0ee07b3e5f05
|
|
||||||
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
>>> VLESS 分享链接(可直接导入客户端):
|
|
||||||
|
|
||||||
vless://e02bfa4e-8c3c-4625-a0dc-207dd37bffd8@45.32.69.133:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.microsoft.com&fp=chrome&pbk=J0Q5maB5UZVzgAH1V6nGMHKuaaT4mNQRKMS5ztROKnY&sid=0def0ee07b3e5f05&type=tcp#VPS-Reality
|
|
||||||
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
⚠️ 请妥善保存以上信息!UUID 和密钥仅在此处显示一次。
|
|
||||||
配置文件位置: /usr/local/etc/xray/config.json
|
|
||||||
查看日志: journalctl -u xray -f
|
|
||||||
重启服务: systemctl restart xray
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# =============================================================
|
|
||||||
# Windows Dev Stack 配置文件
|
|
||||||
# 复制为 .env 并按需填写
|
|
||||||
# =============================================================
|
|
||||||
|
|
||||||
# ── Claude / API 配置 ────────────────────────────────────────
|
|
||||||
# 推荐:灵眸 AI(国内直连,无需代理)https://docs.lmuai.com/docs/tools/claude-code
|
|
||||||
ANTHROPIC_AUTH_TOKEN=sk-你的灵眸API密钥
|
|
||||||
ANTHROPIC_BASE_URL=https://api.lmuai.com
|
|
||||||
|
|
||||||
# 备选:Anthropic 官方 API Key(海外直连)
|
|
||||||
# ANTHROPIC_API_KEY=
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
|
||||||
|
|
||||||
# 备选:DeepSeek 兼容接口
|
|
||||||
# ANTHROPIC_API_KEY=
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
|
||||||
|
|
||||||
# 默认使用的模型
|
|
||||||
# 灵眸可选:claude-opus-4-7 | claude-sonnet-4-6 | claude-sonnet-4-5 | claude-haiku-4-5
|
|
||||||
CLAUDE_MODEL=claude-sonnet-4-6
|
|
||||||
|
|
||||||
# ── Godot MCP Pro ─────────────────────────────────────────────
|
|
||||||
# 本地包相对路径(相对于本脚本目录),默认引用 claude-dev-stack 中的包
|
|
||||||
# 通常无需修改
|
|
||||||
GODOT_MCP_VERSION=godot-mcp-pro-v1.14.1
|
|
||||||
GODOT_MCP_PACKAGE_DIR=.
|
|
||||||
|
|
||||||
# ── Unity MCP ─────────────────────────────────────────────────
|
|
||||||
# unity-mcp-server 安装目录(Windows 侧)
|
|
||||||
# UNITY_MCP_INSTALL_DIR=%USERPROFILE%\unity-mcp-server
|
|
||||||
|
|
||||||
# Unity Hub 路径(写入 MCP 配置 env 供 unity-mcp 使用)
|
|
||||||
# UNITY_HUB_PATH=C:\Program Files\Unity Hub\Unity Hub.exe
|
|
||||||
|
|
||||||
# Unity MCP Bridge 端口
|
|
||||||
BRIDGE_PORT=7890
|
|
||||||
|
|
||||||
# ── Rust / Token Killer ──────────────────────────────────────
|
|
||||||
# 国内加速镜像(安装 Rust 缓慢时启用)
|
|
||||||
# RUSTUP_DIST_SERVER=https://rsproxy.cn
|
|
||||||
# RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.env
|
|
||||||
node_modules/
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# Windows Dev Stack
|
|
||||||
|
|
||||||
**Windows 原生** Claude Code CLI + GitHub Copilot CLI + MCP + Rust Token Killer 一键部署方案。
|
|
||||||
|
|
||||||
不依赖 WSL2,所有组件直接运行在 Windows 本机。
|
|
||||||
|
|
||||||
## 组件清单
|
|
||||||
|
|
||||||
| 组件 | 说明 | 安装位置 |
|
|
||||||
|------|------|---------|
|
|
||||||
| **Node.js LTS** | JS 运行时 | winget → 系统 |
|
|
||||||
| **Claude Code CLI** | `@anthropic-ai/claude-code` | npm global |
|
|
||||||
| **GitHub CLI + Copilot CLI** | `gh` + `github/gh-copilot` 扩展 | winget + gh extension |
|
|
||||||
| **Unity MCP Server** | AnkleBreaker-Studio — Claude ↔ Unity Editor | `%USERPROFILE%\unity-mcp-server\` |
|
|
||||||
| **Godot MCP Pro** | 本地包 v1.14.1 — Claude ↔ Godot Editor | `%USERPROFILE%\godot-mcp-pro\` |
|
|
||||||
| **Rust** | rustup stable 工具链 | winget → `%USERPROFILE%\.cargo\` |
|
|
||||||
| **RTK** | Rust Token Killer — LLM token 优化 | cargo install → `%USERPROFILE%\.cargo\bin\` |
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
windows-dev-stack/
|
|
||||||
├── deploy.ps1 # Windows 一键部署(PowerShell 5.1+)
|
|
||||||
├── .env.example # 配置模板
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
> Godot MCP Pro 本地包位于 `../claude-dev-stack/godot-mcp-pro-v1.14.1/`,
|
|
||||||
> 两个 stack 共用同一份本地包,无需重复存储。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1. 复制并填写配置
|
|
||||||
cd path\to\windows-dev-stack
|
|
||||||
cp .env.example .env
|
|
||||||
notepad .env
|
|
||||||
|
|
||||||
# 2. 运行部署(普通用户即可;防火墙步骤会弹 UAC 提权)
|
|
||||||
pwsh .\deploy.ps1
|
|
||||||
|
|
||||||
# 3. GitHub CLI 登录(首次使用前)
|
|
||||||
gh auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 配置说明(`.env`)
|
|
||||||
|
|
||||||
### 推荐:灵眸 AI(国内直连,无需代理)
|
|
||||||
|
|
||||||
| 变量 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `ANTHROPIC_AUTH_TOKEN` | 灵眸 API Key,从 [lmuai.com](https://lmuai.com) 获取 |
|
|
||||||
| `ANTHROPIC_BASE_URL` | `https://api.lmuai.com`(默认) |
|
|
||||||
| `CLAUDE_MODEL` | `claude-sonnet-4-6`(默认) |
|
|
||||||
|
|
||||||
### 备选:Anthropic 官方 API
|
|
||||||
|
|
||||||
| 变量 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `ANTHROPIC_API_KEY` | 从 [console.anthropic.com](https://console.anthropic.com) 获取 |
|
|
||||||
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 与 claude-dev-stack 的对比
|
|
||||||
|
|
||||||
| 特性 | `claude-dev-stack` | `windows-dev-stack` |
|
|
||||||
|------|--------------------|---------------------|
|
|
||||||
| 运行环境 | WSL2(Linux) | Windows 原生 |
|
|
||||||
| Claude Code CLI | WSL2 内 npm global | Windows npm global |
|
|
||||||
| GitHub Copilot CLI | WSL2 gh extension | Windows gh extension |
|
|
||||||
| Rust / RTK | WSL2 cargo | Windows cargo |
|
|
||||||
| MCP Servers | WSL2 node 进程 | Windows node 进程 |
|
|
||||||
| 适合场景 | Linux 工具链 / 命令行重度用户 | 纯 Windows 开发者 / 不想装 WSL2 |
|
|
||||||
|
|
||||||
两套 stack **可以共存**,各自写入独立配置,互不干扰。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP 配置覆盖范围
|
|
||||||
|
|
||||||
`deploy.ps1` 自动写入以下客户端的 MCP 配置:
|
|
||||||
|
|
||||||
| 客户端 | 配置文件 / 方式 |
|
|
||||||
|--------|----------------|
|
|
||||||
| Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json`(`mcpServers` 格式) |
|
|
||||||
| Cursor | `%USERPROFILE%\.cursor\mcp.json`(`mcpServers` 格式) |
|
|
||||||
| Windsurf | `%APPDATA%\Windsurf\mcp_config.json`(`mcpServers` 格式) |
|
|
||||||
| **VS Code Copilot Agent** | `%APPDATA%\Code\User\settings.json` → `mcp.servers`(VS Code 专用格式) |
|
|
||||||
| Claude Code CLI | `claude mcp add --scope user`(写入用户级 MCP) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RTK 使用
|
|
||||||
|
|
||||||
RTK 通过 Claude Code 的 PreToolUse hook 自动生效(`deploy.ps1` 运行 `rtk init -g` 注册)。
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 查看 token 节省统计
|
|
||||||
rtk gain
|
|
||||||
rtk gain --history
|
|
||||||
|
|
||||||
# 手动使用
|
|
||||||
rtk git status
|
|
||||||
rtk cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
`.github/copilot-instructions.md` 已在仓库根目录配置,GitHub Copilot (VS Code) 会自动读取并在终端命令前加 `rtk` 前缀。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### Node.js 安装后找不到命令
|
|
||||||
```powershell
|
|
||||||
# 刷新当前会话 PATH
|
|
||||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" +
|
|
||||||
[System.Environment]::GetEnvironmentVariable("Path","User")
|
|
||||||
# 或直接重开 PowerShell
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust / cargo 安装后找不到命令
|
|
||||||
```powershell
|
|
||||||
# 手动加入 PATH(当前会话)
|
|
||||||
$env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path"
|
|
||||||
# 或重开终端(rustup installer 已写入用户 PATH)
|
|
||||||
```
|
|
||||||
|
|
||||||
### rtk 安装失败(需要 Build Tools)
|
|
||||||
```powershell
|
|
||||||
# 安装 Visual C++ Build Tools(Rust Windows 编译器后端依赖)
|
|
||||||
winget install Microsoft.VisualStudio.2022.BuildTools
|
|
||||||
# 安装后重开终端,再运行:
|
|
||||||
cargo install --git https://github.com/rtk-ai/rtk
|
|
||||||
```
|
|
||||||
|
|
||||||
### gh copilot 找不到扩展
|
|
||||||
```powershell
|
|
||||||
# 确认已登录
|
|
||||||
gh auth status
|
|
||||||
# 重新安装扩展
|
|
||||||
gh extension install github/gh-copilot
|
|
||||||
```
|
|
||||||
|
|
||||||
### Unity MCP Bridge 连接失败
|
|
||||||
```powershell
|
|
||||||
# 检查 Bridge 端口
|
|
||||||
Invoke-WebRequest http://127.0.0.1:7890/api/ping
|
|
||||||
# 确认防火墙已放行
|
|
||||||
Get-NetFirewallRule -DisplayName "Unity MCP Bridge*" | Select-Object DisplayName, Enabled
|
|
||||||
```
|
|
||||||
@@ -1,797 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Windows 原生 Claude Code CLI + GitHub Copilot CLI + MCP + Rust Token Killer 一键部署脚本
|
|
||||||
.DESCRIPTION
|
|
||||||
不依赖 WSL2,全部组件原生运行在 Windows 上。完成以下步骤:
|
|
||||||
1. Node.js LTS 检测 / 安装(winget)
|
|
||||||
2. Claude Code CLI (@anthropic-ai/claude-code) 安装
|
|
||||||
3. GitHub CLI + Copilot CLI 扩展安装(winget + gh extension)
|
|
||||||
4. Unity MCP Server 安装 & 写入各 AI 客户端 MCP 配置
|
|
||||||
5. Godot MCP Pro 安装(本地包)& 写入各 AI 客户端 MCP 配置
|
|
||||||
6. Rust 工具链 + RTK (Rust Token Killer) 安装
|
|
||||||
7. RTK hook 注册到 Claude Code + Copilot Instructions 生成
|
|
||||||
8. PowerShell Profile 快捷命令写入
|
|
||||||
.PARAMETER BridgePort
|
|
||||||
Unity MCP Bridge 端口,默认 7890
|
|
||||||
.PARAMETER SkipFirewall
|
|
||||||
跳过防火墙规则配置
|
|
||||||
.NOTES
|
|
||||||
防火墙规则需要管理员权限,其余步骤普通用户即可运行。
|
|
||||||
脚本可重复运行(幂等),已安装的组件自动跳过。
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[int] $BridgePort = 7890,
|
|
||||||
[string]$GodotMcpVersion = "godot-mcp-pro-v1.14.1",
|
|
||||||
[switch]$SkipFirewall
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 颜色日志辅助
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
function Write-Step { param($msg) Write-Host "`n==== $msg ====" -ForegroundColor Cyan }
|
|
||||||
function Write-OK { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
|
||||||
function Write-Info { param($msg) Write-Host " [..] $msg" -ForegroundColor DarkGray }
|
|
||||||
function Write-Warn { param($msg) Write-Host " [!!] $msg" -ForegroundColor Yellow }
|
|
||||||
function Write-Fail { param($msg) Write-Host " [ERR] $msg" -ForegroundColor Red }
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 加载 .env 配置
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
$ScriptDir = $PSScriptRoot
|
|
||||||
$EnvFile = Join-Path $ScriptDir ".env"
|
|
||||||
$EnvExample = Join-Path $ScriptDir ".env.example"
|
|
||||||
|
|
||||||
if (-not (Test-Path $EnvFile)) {
|
|
||||||
if (Test-Path $EnvExample) {
|
|
||||||
Copy-Item $EnvExample $EnvFile
|
|
||||||
Write-Warn "已从 .env.example 创建 .env,请按需填写后重新运行"
|
|
||||||
Write-Warn " notepad `"$EnvFile`""
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$Cfg = @{}
|
|
||||||
if (Test-Path $EnvFile) {
|
|
||||||
Get-Content $EnvFile | Where-Object { $_ -match "^\s*[^#].*=" } | ForEach-Object {
|
|
||||||
$parts = $_ -split "=", 2
|
|
||||||
$key = $parts[0].Trim()
|
|
||||||
$val = $parts[1].Trim().Trim('"').Trim("'")
|
|
||||||
$Cfg[$key] = $val
|
|
||||||
}
|
|
||||||
Write-Info "已加载配置:$EnvFile"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ANTHROPIC_API_KEY = $Cfg["ANTHROPIC_API_KEY"] ?? ""
|
|
||||||
$ANTHROPIC_AUTH_TOKEN = $Cfg["ANTHROPIC_AUTH_TOKEN"] ?? ""
|
|
||||||
$ANTHROPIC_BASE_URL = $Cfg["ANTHROPIC_BASE_URL"] ?? "https://api.lmuai.com"
|
|
||||||
$CLAUDE_MODEL = $Cfg["CLAUDE_MODEL"] ?? "claude-sonnet-4-6"
|
|
||||||
$BRIDGE_PORT_CFG = $Cfg["BRIDGE_PORT"] ?? "$BridgePort"
|
|
||||||
$UnityHubPath = $Cfg["UNITY_HUB_PATH"] ?? "C:\Program Files\Unity Hub\Unity Hub.exe"
|
|
||||||
$UnityMcpInstallDir = $Cfg["UNITY_MCP_INSTALL_DIR"] ?? "$env:USERPROFILE\unity-mcp-server"
|
|
||||||
$GodotMcpPackageDir = $Cfg["GODOT_MCP_PACKAGE_DIR"] ?? "."
|
|
||||||
|
|
||||||
# 参数 BridgePort 优先于 .env
|
|
||||||
if ($BridgePort -eq 7890 -and $BRIDGE_PORT_CFG -ne "7890") { $BridgePort = [int]$BRIDGE_PORT_CFG }
|
|
||||||
|
|
||||||
# 判断认证方式
|
|
||||||
$UseLmuAuth = ($ANTHROPIC_AUTH_TOKEN -ne "" -and $ANTHROPIC_BASE_URL -ne "https://api.anthropic.com")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Banner
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
|
|
||||||
Write-Host "║ Windows Native: Claude Code + Copilot CLI + MCP + RTK ║" -ForegroundColor Cyan
|
|
||||||
Write-Host "║ Unity MCP · Godot MCP Pro · Rust Token Killer ║" -ForegroundColor Cyan
|
|
||||||
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 辅助:刷新当前进程 PATH
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
function Refresh-Path {
|
|
||||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" +
|
|
||||||
[System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
||||||
}
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 辅助:winget 安装(静默)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
function Install-Winget {
|
|
||||||
param([string]$Id, [string]$DisplayName)
|
|
||||||
# pwsh (Core) 里 winget 不在 PATH,尝试固定路径
|
|
||||||
$wgPath = (Get-Command winget -ErrorAction SilentlyContinue)?.Source
|
|
||||||
if (-not $wgPath) {
|
|
||||||
$wgPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
|
|
||||||
if (-not (Test-Path $wgPath)) { $wgPath = $null }
|
|
||||||
}
|
|
||||||
if (-not $wgPath) {
|
|
||||||
Write-Warn "未找到 winget,请手动安装 $DisplayName"
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
Write-Info "winget install $Id ..."
|
|
||||||
& $wgPath install --id $Id --silent --accept-source-agreements --accept-package-agreements 2>&1 | Out-Null
|
|
||||||
Refresh-Path
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 辅助:写入 / 合并 MCP 配置文件(JSON)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
function Merge-McpConfig {
|
|
||||||
param(
|
|
||||||
[string] $ConfigFile,
|
|
||||||
[hashtable] $Entry,
|
|
||||||
[string] $Label,
|
|
||||||
[string] $ServerName = "unity-mcp"
|
|
||||||
)
|
|
||||||
$dir = Split-Path $ConfigFile
|
|
||||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
|
||||||
function ConvertTo-Hashtable($obj) {
|
|
||||||
if ($obj -is [System.Management.Automation.PSCustomObject]) {
|
|
||||||
$h = @{}
|
|
||||||
foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = ConvertTo-Hashtable $p.Value }
|
|
||||||
return $h
|
|
||||||
} elseif ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) {
|
|
||||||
return @($obj | ForEach-Object { ConvertTo-Hashtable $_ })
|
|
||||||
}
|
|
||||||
return $obj
|
|
||||||
}
|
|
||||||
$cfg = @{ mcpServers = @{} }
|
|
||||||
if (Test-Path $ConfigFile) {
|
|
||||||
try {
|
|
||||||
$raw = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
|
||||||
$converted = ConvertTo-Hashtable $raw
|
|
||||||
if ($converted -is [hashtable]) { $cfg = $converted }
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (-not $cfg.ContainsKey("mcpServers")) { $cfg["mcpServers"] = @{} }
|
|
||||||
$cfg["mcpServers"][$ServerName] = $Entry
|
|
||||||
$cfg | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile -Encoding UTF8
|
|
||||||
Write-OK "$Label -> $ConfigFile"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 辅助:写入 VS Code settings.json 中的 mcp.servers(VS Code 专用格式)
|
|
||||||
# VS Code Copilot Agent 读取 settings.json -> "mcp" -> "servers"
|
|
||||||
# 而非 mcp.json 中的 "mcpServers"(那是 Claude Desktop / Cursor 格式)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
function Merge-VsCodeMcpSettings {
|
|
||||||
param(
|
|
||||||
[string] $ServerName,
|
|
||||||
[hashtable] $Entry,
|
|
||||||
[string] $Label
|
|
||||||
)
|
|
||||||
$settingsFile = "$env:APPDATA\Code\User\settings.json"
|
|
||||||
$dir = Split-Path $settingsFile
|
|
||||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
|
||||||
|
|
||||||
function ConvertTo-OrderedHashtable($obj) {
|
|
||||||
if ($obj -is [System.Management.Automation.PSCustomObject]) {
|
|
||||||
$h = [ordered]@{}
|
|
||||||
foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = ConvertTo-OrderedHashtable $p.Value }
|
|
||||||
return $h
|
|
||||||
} elseif ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) {
|
|
||||||
return @($obj | ForEach-Object { ConvertTo-OrderedHashtable $_ })
|
|
||||||
}
|
|
||||||
return $obj
|
|
||||||
}
|
|
||||||
|
|
||||||
$cfg = [ordered]@{}
|
|
||||||
if (Test-Path $settingsFile) {
|
|
||||||
try {
|
|
||||||
$raw = Get-Content $settingsFile -Raw | ConvertFrom-Json
|
|
||||||
$cfg = ConvertTo-OrderedHashtable $raw
|
|
||||||
} catch {
|
|
||||||
Write-Warn "settings.json 解析失败,将新建 mcp 节点:$_"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $cfg.ContainsKey("mcp")) { $cfg["mcp"] = [ordered]@{} }
|
|
||||||
if (-not $cfg["mcp"].ContainsKey("servers")) { $cfg["mcp"]["servers"] = [ordered]@{} }
|
|
||||||
$cfg["mcp"]["servers"][$ServerName] = $Entry
|
|
||||||
|
|
||||||
$cfg | ConvertTo-Json -Depth 15 | Set-Content $settingsFile -Encoding UTF8
|
|
||||||
Write-OK "$Label -> $settingsFile [mcp.servers.$ServerName]"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 1: Node.js LTS
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "1/8 Node.js LTS"
|
|
||||||
|
|
||||||
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
|
|
||||||
if ($nodeCmd) {
|
|
||||||
Write-OK "Node.js 已安装: $(& node --version)"
|
|
||||||
} else {
|
|
||||||
Write-Info "通过 winget 安装 Node.js LTS..."
|
|
||||||
$ok = Install-Winget "OpenJS.NodeJS.LTS" "Node.js LTS"
|
|
||||||
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
|
|
||||||
if ($nodeCmd) {
|
|
||||||
Write-OK "Node.js 安装完成: $(& node --version)"
|
|
||||||
} else {
|
|
||||||
Write-Warn "未能自动安装 Node.js,请手动安装后重新运行:"
|
|
||||||
Write-Warn " https://nodejs.org/zh-cn/download 或 winget install OpenJS.NodeJS.LTS"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 2: Claude Code CLI
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "2/8 Claude Code CLI"
|
|
||||||
|
|
||||||
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
|
|
||||||
if ($claudeCmd) {
|
|
||||||
Write-OK "Claude Code 已安装: $(& claude --version 2>&1 | Select-Object -First 1)"
|
|
||||||
} else {
|
|
||||||
if (Get-Command node -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Info "安装 @anthropic-ai/claude-code ..."
|
|
||||||
& npm install -g @anthropic-ai/claude-code --quiet 2>&1 | Out-Null
|
|
||||||
Refresh-Path
|
|
||||||
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
|
|
||||||
if ($claudeCmd) {
|
|
||||||
Write-OK "Claude Code 安装完成: $(& claude --version 2>&1 | Select-Object -First 1)"
|
|
||||||
} else {
|
|
||||||
Write-Warn "安装后未找到 claude 命令,请重开终端后重试"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Warn "Node.js 未就绪,跳过 Claude Code 安装"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 写入 Claude Code 配置(settings.json)──────────────────
|
|
||||||
$mcpAllowedTools = @(
|
|
||||||
"Bash","Read","Write","Edit","MultiEdit","Glob","Grep","LS","WebFetch","TodoRead","TodoWrite",
|
|
||||||
# Unity MCP
|
|
||||||
"mcp__unity-mcp__unity_editor_ping","mcp__unity-mcp__unity_editor_state","mcp__unity-mcp__unity_project_info",
|
|
||||||
"mcp__unity-mcp__unity_scene_info","mcp__unity-mcp__unity_scene_open","mcp__unity-mcp__unity_scene_save",
|
|
||||||
"mcp__unity-mcp__unity_scene_new","mcp__unity-mcp__unity_scene_hierarchy","mcp__unity-mcp__unity_scene_stats",
|
|
||||||
"mcp__unity-mcp__unity_gameobject_create","mcp__unity-mcp__unity_gameobject_delete",
|
|
||||||
"mcp__unity-mcp__unity_gameobject_info","mcp__unity-mcp__unity_gameobject_set_transform",
|
|
||||||
"mcp__unity-mcp__unity_gameobject_duplicate","mcp__unity-mcp__unity_gameobject_set_active",
|
|
||||||
"mcp__unity-mcp__unity_gameobject_reparent",
|
|
||||||
"mcp__unity-mcp__unity_component_add","mcp__unity-mcp__unity_component_remove",
|
|
||||||
"mcp__unity-mcp__unity_component_get_properties","mcp__unity-mcp__unity_component_set_property",
|
|
||||||
"mcp__unity-mcp__unity_component_set_reference","mcp__unity-mcp__unity_component_batch_wire",
|
|
||||||
"mcp__unity-mcp__unity_component_get_referenceable",
|
|
||||||
"mcp__unity-mcp__unity_asset_list","mcp__unity-mcp__unity_asset_import",
|
|
||||||
"mcp__unity-mcp__unity_asset_delete","mcp__unity-mcp__unity_asset_create_prefab",
|
|
||||||
"mcp__unity-mcp__unity_asset_instantiate_prefab",
|
|
||||||
"mcp__unity-mcp__unity_script_create","mcp__unity-mcp__unity_script_read",
|
|
||||||
"mcp__unity-mcp__unity_script_update","mcp__unity-mcp__unity_execute_code",
|
|
||||||
"mcp__unity-mcp__unity_material_create","mcp__unity-mcp__unity_renderer_set_material",
|
|
||||||
"mcp__unity-mcp__unity_build","mcp__unity-mcp__unity_play_mode",
|
|
||||||
"mcp__unity-mcp__unity_console_log","mcp__unity-mcp__unity_console_clear",
|
|
||||||
"mcp__unity-mcp__unity_get_compilation_errors",
|
|
||||||
"mcp__unity-mcp__unity_execute_menu_item","mcp__unity-mcp__unity_undo",
|
|
||||||
"mcp__unity-mcp__unity_redo","mcp__unity-mcp__unity_undo_history",
|
|
||||||
"mcp__unity-mcp__unity_selection_get","mcp__unity-mcp__unity_selection_set",
|
|
||||||
"mcp__unity-mcp__unity_selection_focus_scene_view","mcp__unity-mcp__unity_selection_find_by_type",
|
|
||||||
"mcp__unity-mcp__unity_search_by_component","mcp__unity-mcp__unity_search_by_tag",
|
|
||||||
"mcp__unity-mcp__unity_search_by_layer","mcp__unity-mcp__unity_search_by_name",
|
|
||||||
"mcp__unity-mcp__unity_search_assets","mcp__unity-mcp__unity_search_missing_references",
|
|
||||||
"mcp__unity-mcp__unity_screenshot_game","mcp__unity-mcp__unity_screenshot_scene",
|
|
||||||
"mcp__unity-mcp__unity_graphics_scene_capture","mcp__unity-mcp__unity_graphics_game_capture",
|
|
||||||
"mcp__unity-mcp__unity_prefab_info","mcp__unity-mcp__unity_set_object_reference",
|
|
||||||
"mcp__unity-mcp__unity_packages_list","mcp__unity-mcp__unity_packages_add",
|
|
||||||
"mcp__unity-mcp__unity_packages_remove","mcp__unity-mcp__unity_packages_search",
|
|
||||||
"mcp__unity-mcp__unity_packages_info",
|
|
||||||
"mcp__unity-mcp__unity_queue_info","mcp__unity-mcp__unity_agents_list","mcp__unity-mcp__unity_agent_log",
|
|
||||||
"mcp__unity-mcp__unity_list_advanced_tools","mcp__unity-mcp__unity_advanced_tool",
|
|
||||||
"mcp__unity-mcp__unity_hub_list_editors","mcp__unity-mcp__unity_hub_available_releases",
|
|
||||||
"mcp__unity-mcp__unity_hub_install_editor","mcp__unity-mcp__unity_hub_install_modules",
|
|
||||||
"mcp__unity-mcp__unity_hub_get_install_path","mcp__unity-mcp__unity_hub_set_install_path",
|
|
||||||
"mcp__unity-mcp__unity_list_instances","mcp__unity-mcp__unity_select_instance",
|
|
||||||
"mcp__unity-mcp__unity_get_project_context",
|
|
||||||
# Godot MCP Pro
|
|
||||||
"mcp__godot-mcp-pro__get_project_info","mcp__godot-mcp-pro__get_filesystem_tree",
|
|
||||||
"mcp__godot-mcp-pro__search_files","mcp__godot-mcp-pro__search_in_files",
|
|
||||||
"mcp__godot-mcp-pro__get_project_settings","mcp__godot-mcp-pro__get_project_statistics",
|
|
||||||
"mcp__godot-mcp-pro__uid_to_project_path","mcp__godot-mcp-pro__project_path_to_uid",
|
|
||||||
"mcp__godot-mcp-pro__get_scene_tree","mcp__godot-mcp-pro__get_scene_file_content",
|
|
||||||
"mcp__godot-mcp-pro__get_scene_exports","mcp__godot-mcp-pro__get_scene_dependencies",
|
|
||||||
"mcp__godot-mcp-pro__create_scene","mcp__godot-mcp-pro__open_scene",
|
|
||||||
"mcp__godot-mcp-pro__save_scene","mcp__godot-mcp-pro__add_scene_instance",
|
|
||||||
"mcp__godot-mcp-pro__get_node_properties","mcp__godot-mcp-pro__get_node_groups",
|
|
||||||
"mcp__godot-mcp-pro__get_signals","mcp__godot-mcp-pro__find_nodes_in_group",
|
|
||||||
"mcp__godot-mcp-pro__find_nodes_by_type","mcp__godot-mcp-pro__find_signal_connections",
|
|
||||||
"mcp__godot-mcp-pro__find_node_references",
|
|
||||||
"mcp__godot-mcp-pro__add_node","mcp__godot-mcp-pro__duplicate_node",
|
|
||||||
"mcp__godot-mcp-pro__move_node","mcp__godot-mcp-pro__rename_node",
|
|
||||||
"mcp__godot-mcp-pro__update_property","mcp__godot-mcp-pro__add_resource",
|
|
||||||
"mcp__godot-mcp-pro__set_anchor_preset",
|
|
||||||
"mcp__godot-mcp-pro__connect_signal","mcp__godot-mcp-pro__disconnect_signal",
|
|
||||||
"mcp__godot-mcp-pro__set_node_groups",
|
|
||||||
"mcp__godot-mcp-pro__batch_set_property","mcp__godot-mcp-pro__cross_scene_set_property",
|
|
||||||
"mcp__godot-mcp-pro__list_scripts","mcp__godot-mcp-pro__read_script",
|
|
||||||
"mcp__godot-mcp-pro__get_open_scripts","mcp__godot-mcp-pro__validate_script",
|
|
||||||
"mcp__godot-mcp-pro__create_script","mcp__godot-mcp-pro__edit_script",
|
|
||||||
"mcp__godot-mcp-pro__attach_script",
|
|
||||||
"mcp__godot-mcp-pro__get_editor_errors","mcp__godot-mcp-pro__get_output_log",
|
|
||||||
"mcp__godot-mcp-pro__get_editor_screenshot","mcp__godot-mcp-pro__get_editor_performance",
|
|
||||||
"mcp__godot-mcp-pro__clear_output","mcp__godot-mcp-pro__reload_plugin",
|
|
||||||
"mcp__godot-mcp-pro__reload_project",
|
|
||||||
"mcp__godot-mcp-pro__read_resource","mcp__godot-mcp-pro__get_resource_preview",
|
|
||||||
"mcp__godot-mcp-pro__create_resource","mcp__godot-mcp-pro__edit_resource",
|
|
||||||
"mcp__godot-mcp-pro__read_shader","mcp__godot-mcp-pro__get_shader_params",
|
|
||||||
"mcp__godot-mcp-pro__create_shader","mcp__godot-mcp-pro__edit_shader",
|
|
||||||
"mcp__godot-mcp-pro__assign_shader_material","mcp__godot-mcp-pro__set_shader_param",
|
|
||||||
"mcp__godot-mcp-pro__list_animations","mcp__godot-mcp-pro__get_animation_info",
|
|
||||||
"mcp__godot-mcp-pro__create_animation","mcp__godot-mcp-pro__add_animation_track",
|
|
||||||
"mcp__godot-mcp-pro__set_animation_keyframe",
|
|
||||||
"mcp__godot-mcp-pro__get_animation_tree_structure",
|
|
||||||
"mcp__godot-mcp-pro__create_animation_tree","mcp__godot-mcp-pro__add_state_machine_state",
|
|
||||||
"mcp__godot-mcp-pro__add_state_machine_transition",
|
|
||||||
"mcp__godot-mcp-pro__set_blend_tree_node","mcp__godot-mcp-pro__set_tree_parameter",
|
|
||||||
"mcp__godot-mcp-pro__get_audio_bus_layout","mcp__godot-mcp-pro__get_audio_info",
|
|
||||||
"mcp__godot-mcp-pro__add_audio_bus","mcp__godot-mcp-pro__set_audio_bus",
|
|
||||||
"mcp__godot-mcp-pro__add_audio_bus_effect","mcp__godot-mcp-pro__add_audio_player",
|
|
||||||
"mcp__godot-mcp-pro__get_input_actions","mcp__godot-mcp-pro__set_input_action",
|
|
||||||
"mcp__godot-mcp-pro__get_physics_layers","mcp__godot-mcp-pro__get_collision_info",
|
|
||||||
"mcp__godot-mcp-pro__set_physics_layers",
|
|
||||||
"mcp__godot-mcp-pro__add_mesh_instance","mcp__godot-mcp-pro__setup_lighting",
|
|
||||||
"mcp__godot-mcp-pro__set_material_3d","mcp__godot-mcp-pro__setup_environment",
|
|
||||||
"mcp__godot-mcp-pro__setup_camera_3d","mcp__godot-mcp-pro__add_gridmap",
|
|
||||||
"mcp__godot-mcp-pro__setup_collision","mcp__godot-mcp-pro__add_raycast",
|
|
||||||
"mcp__godot-mcp-pro__setup_physics_body",
|
|
||||||
"mcp__godot-mcp-pro__get_navigation_info",
|
|
||||||
"mcp__godot-mcp-pro__setup_navigation_region","mcp__godot-mcp-pro__bake_navigation_mesh",
|
|
||||||
"mcp__godot-mcp-pro__setup_navigation_agent","mcp__godot-mcp-pro__set_navigation_layers",
|
|
||||||
"mcp__godot-mcp-pro__get_particle_info",
|
|
||||||
"mcp__godot-mcp-pro__create_particles","mcp__godot-mcp-pro__set_particle_material",
|
|
||||||
"mcp__godot-mcp-pro__set_particle_color_gradient","mcp__godot-mcp-pro__apply_particle_preset",
|
|
||||||
"mcp__godot-mcp-pro__get_theme_info",
|
|
||||||
"mcp__godot-mcp-pro__create_theme","mcp__godot-mcp-pro__set_theme_color",
|
|
||||||
"mcp__godot-mcp-pro__set_theme_constant","mcp__godot-mcp-pro__set_theme_font_size",
|
|
||||||
"mcp__godot-mcp-pro__set_theme_stylebox",
|
|
||||||
"mcp__godot-mcp-pro__tilemap_get_cell","mcp__godot-mcp-pro__tilemap_get_info",
|
|
||||||
"mcp__godot-mcp-pro__tilemap_get_used_cells",
|
|
||||||
"mcp__godot-mcp-pro__tilemap_set_cell","mcp__godot-mcp-pro__tilemap_fill_rect",
|
|
||||||
"mcp__godot-mcp-pro__get_autoload","mcp__godot-mcp-pro__add_autoload",
|
|
||||||
"mcp__godot-mcp-pro__set_project_setting",
|
|
||||||
"mcp__godot-mcp-pro__find_unused_resources","mcp__godot-mcp-pro__analyze_signal_flow",
|
|
||||||
"mcp__godot-mcp-pro__analyze_scene_complexity","mcp__godot-mcp-pro__find_script_references",
|
|
||||||
"mcp__godot-mcp-pro__detect_circular_dependencies",
|
|
||||||
"mcp__godot-mcp-pro__get_performance_monitors",
|
|
||||||
"mcp__godot-mcp-pro__get_export_info","mcp__godot-mcp-pro__list_export_presets",
|
|
||||||
"mcp__godot-mcp-pro__play_scene","mcp__godot-mcp-pro__stop_scene",
|
|
||||||
"mcp__godot-mcp-pro__get_game_scene_tree","mcp__godot-mcp-pro__get_game_node_properties",
|
|
||||||
"mcp__godot-mcp-pro__get_game_screenshot","mcp__godot-mcp-pro__set_game_node_property",
|
|
||||||
"mcp__godot-mcp-pro__find_nodes_by_script",
|
|
||||||
"mcp__godot-mcp-pro__simulate_key","mcp__godot-mcp-pro__simulate_mouse_click",
|
|
||||||
"mcp__godot-mcp-pro__simulate_mouse_move","mcp__godot-mcp-pro__simulate_action",
|
|
||||||
"mcp__godot-mcp-pro__simulate_sequence",
|
|
||||||
"mcp__godot-mcp-pro__capture_frames","mcp__godot-mcp-pro__record_frames",
|
|
||||||
"mcp__godot-mcp-pro__monitor_properties","mcp__godot-mcp-pro__batch_get_properties",
|
|
||||||
"mcp__godot-mcp-pro__start_recording","mcp__godot-mcp-pro__stop_recording",
|
|
||||||
"mcp__godot-mcp-pro__replay_recording",
|
|
||||||
"mcp__godot-mcp-pro__find_ui_elements","mcp__godot-mcp-pro__click_button_by_text",
|
|
||||||
"mcp__godot-mcp-pro__wait_for_node","mcp__godot-mcp-pro__find_nearby_nodes",
|
|
||||||
"mcp__godot-mcp-pro__navigate_to","mcp__godot-mcp-pro__move_to",
|
|
||||||
"mcp__godot-mcp-pro__run_test_scenario","mcp__godot-mcp-pro__assert_node_state",
|
|
||||||
"mcp__godot-mcp-pro__assert_screen_text","mcp__godot-mcp-pro__run_stress_test",
|
|
||||||
"mcp__godot-mcp-pro__get_test_report","mcp__godot-mcp-pro__compare_screenshots"
|
|
||||||
)
|
|
||||||
|
|
||||||
$claudeSettingsJson = if ($UseLmuAuth) {
|
|
||||||
[ordered]@{
|
|
||||||
env = [ordered]@{
|
|
||||||
ANTHROPIC_BASE_URL = $ANTHROPIC_BASE_URL
|
|
||||||
ANTHROPIC_AUTH_TOKEN = $ANTHROPIC_AUTH_TOKEN
|
|
||||||
API_TIMEOUT_MS = "3000000"
|
|
||||||
CLAUDE_CODE_ATTRIBUTION_HEADER = "0"
|
|
||||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"
|
|
||||||
}
|
|
||||||
model = $CLAUDE_MODEL
|
|
||||||
allowedTools = $mcpAllowedTools
|
|
||||||
} | ConvertTo-Json -Depth 4
|
|
||||||
} else {
|
|
||||||
[ordered]@{ model = $CLAUDE_MODEL; allowedTools = $mcpAllowedTools } | ConvertTo-Json -Depth 3
|
|
||||||
}
|
|
||||||
|
|
||||||
$claudeDir = "$env:USERPROFILE\.claude"
|
|
||||||
if (-not (Test-Path $claudeDir)) { New-Item -ItemType Directory -Path $claudeDir | Out-Null }
|
|
||||||
$claudeSettingsJson | Set-Content "$claudeDir\settings.json" -Encoding UTF8
|
|
||||||
|
|
||||||
if ($UseLmuAuth) {
|
|
||||||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $null, "User")
|
|
||||||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $null, "User")
|
|
||||||
[System.Environment]::SetEnvironmentVariable("CLAUDE_MODEL", $null, "User")
|
|
||||||
} elseif ($ANTHROPIC_API_KEY) {
|
|
||||||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $ANTHROPIC_API_KEY, "User")
|
|
||||||
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $ANTHROPIC_BASE_URL, "User")
|
|
||||||
[System.Environment]::SetEnvironmentVariable("CLAUDE_MODEL", $CLAUDE_MODEL, "User")
|
|
||||||
}
|
|
||||||
Write-OK "Claude Code 配置已写入 $claudeDir\settings.json"
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 3: GitHub CLI + Copilot CLI 扩展
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "3/8 GitHub CLI + Copilot CLI"
|
|
||||||
|
|
||||||
$ghCmd = Get-Command gh -ErrorAction SilentlyContinue
|
|
||||||
if ($ghCmd) {
|
|
||||||
Write-OK "GitHub CLI 已安装: $(& gh --version 2>&1 | Select-Object -First 1)"
|
|
||||||
} else {
|
|
||||||
Write-Info "通过 winget 安装 GitHub CLI..."
|
|
||||||
$ok = Install-Winget "GitHub.cli" "GitHub CLI"
|
|
||||||
$ghCmd = Get-Command gh -ErrorAction SilentlyContinue
|
|
||||||
if ($ghCmd) {
|
|
||||||
Write-OK "GitHub CLI 安装完成: $(& gh --version 2>&1 | Select-Object -First 1)"
|
|
||||||
} else {
|
|
||||||
Write-Warn "GitHub CLI 安装后未找到,请重开终端或手动安装:winget install GitHub.cli"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ghCmd) {
|
|
||||||
$extList = & gh extension list 2>&1 | Out-String
|
|
||||||
if ($extList -match "github/gh-copilot") {
|
|
||||||
Write-OK "GitHub Copilot CLI 扩展已安装"
|
|
||||||
} else {
|
|
||||||
Write-Info "安装 GitHub Copilot CLI 扩展..."
|
|
||||||
try {
|
|
||||||
& gh extension install github/gh-copilot 2>&1 | Out-Null
|
|
||||||
Write-OK "GitHub Copilot CLI 扩展安装完成(入口:gh copilot)"
|
|
||||||
} catch {
|
|
||||||
Write-Warn "安装失败(可能需要先 gh auth login),请手动运行:gh extension install github/gh-copilot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 4: Unity MCP Server
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "4/8 Unity MCP Server"
|
|
||||||
Write-Info "安装目录: $UnityMcpInstallDir"
|
|
||||||
|
|
||||||
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
|
||||||
if (-not $gitCmd) {
|
|
||||||
Write-Warn "未找到 git,跳过 Unity MCP Server 安装"
|
|
||||||
Write-Warn "请安装 Git for Windows:winget install Git.Git"
|
|
||||||
$UnityMcpReady = $false
|
|
||||||
} else {
|
|
||||||
if (Test-Path "$UnityMcpInstallDir\.git") {
|
|
||||||
Write-Info "已存在,更新至最新..."
|
|
||||||
Push-Location $UnityMcpInstallDir
|
|
||||||
try { & git pull --quiet 2>&1 | Out-Null } finally { Pop-Location }
|
|
||||||
} else {
|
|
||||||
if (Test-Path $UnityMcpInstallDir) { Remove-Item $UnityMcpInstallDir -Recurse -Force }
|
|
||||||
& git clone --quiet --depth 1 https://github.com/AnkleBreaker-Studio/unity-mcp-server.git $UnityMcpInstallDir
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Get-Command node -ErrorAction SilentlyContinue) {
|
|
||||||
Push-Location $UnityMcpInstallDir
|
|
||||||
try { & npm install --prefer-offline --quiet 2>&1 | Out-Null } finally { Pop-Location }
|
|
||||||
Write-OK "Unity MCP Server 就绪"
|
|
||||||
$UnityMcpReady = $true
|
|
||||||
} else {
|
|
||||||
Write-Warn "Node.js 未就绪,跳过 npm install"
|
|
||||||
$UnityMcpReady = $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 确定入口文件 ──────────────────────────────────────────────
|
|
||||||
$unityEntry = ""
|
|
||||||
if ($UnityMcpReady) {
|
|
||||||
foreach ($f in @("src/index.js","build/index.js","index.js")) {
|
|
||||||
$fp = Join-Path $UnityMcpInstallDir $f
|
|
||||||
if (Test-Path $fp) { $unityEntry = $fp -replace "\\","/"; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 写入 MCP 配置(四大 AI 客户端 + Claude Code CLI)─────────
|
|
||||||
if ($unityEntry) {
|
|
||||||
$unityMcpEntry = @{
|
|
||||||
command = "node"
|
|
||||||
args = @($unityEntry)
|
|
||||||
env = @{ UNITY_HUB_PATH = $UnityHubPath; UNITY_BRIDGE_PORT = "$BridgePort" }
|
|
||||||
}
|
|
||||||
Merge-McpConfig "$env:APPDATA\Claude\claude_desktop_config.json" $unityMcpEntry "Claude Desktop"
|
|
||||||
Merge-McpConfig "$env:USERPROFILE\.cursor\mcp.json" $unityMcpEntry "Cursor"
|
|
||||||
$wsDirC = if (Test-Path "$env:APPDATA\Windsurf") { "$env:APPDATA\Windsurf" } else { "$env:USERPROFILE\.codeium\windsurf" }
|
|
||||||
Merge-McpConfig "$wsDirC\mcp_config.json" $unityMcpEntry "Windsurf"
|
|
||||||
Merge-VsCodeMcpSettings "unity-mcp" $unityMcpEntry "VS Code Copilot Agent (Unity MCP)"
|
|
||||||
# Claude Code CLI(Windows)
|
|
||||||
if (Get-Command claude -ErrorAction SilentlyContinue) {
|
|
||||||
& claude mcp remove unity-mcp --scope user 2>&1 | Out-Null
|
|
||||||
& claude mcp add --scope user unity-mcp node $unityEntry -e "UNITY_BRIDGE_PORT=$BridgePort" 2>&1 | Out-Null
|
|
||||||
Write-OK "Claude Code CLI MCP -> unity-mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 5: Godot MCP Pro
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "5/8 Godot MCP Pro"
|
|
||||||
|
|
||||||
# 解析 Godot MCP Pro 本地包路径(相对于本脚本目录)
|
|
||||||
$godotPkgBase = if ([System.IO.Path]::IsPathRooted($GodotMcpPackageDir)) {
|
|
||||||
$GodotMcpPackageDir
|
|
||||||
} else {
|
|
||||||
[System.IO.Path]::GetFullPath((Join-Path $ScriptDir $GodotMcpPackageDir))
|
|
||||||
}
|
|
||||||
$godotSrcDir = Join-Path $godotPkgBase "$GodotMcpVersion\server"
|
|
||||||
$godotWinDir = "$env:USERPROFILE\godot-mcp-pro"
|
|
||||||
|
|
||||||
if (-not (Test-Path $godotSrcDir)) {
|
|
||||||
Write-Warn "未找到 Godot MCP Pro 源目录: $godotSrcDir"
|
|
||||||
Write-Warn "请确认 .env 中 GODOT_MCP_PACKAGE_DIR 指向包含 $GodotMcpVersion\ 的目录"
|
|
||||||
$godotEntry = ""
|
|
||||||
} else {
|
|
||||||
Write-Info "同步 server/ -> $godotWinDir"
|
|
||||||
$rcArgs = @($godotSrcDir, $godotWinDir, "/E", "/XD", "node_modules",
|
|
||||||
"/NFL", "/NDL", "/NJH", "/NJS", "/nc", "/ns", "/np")
|
|
||||||
& robocopy @rcArgs 2>&1 | Out-Null
|
|
||||||
|
|
||||||
if (Get-Command node -ErrorAction SilentlyContinue) {
|
|
||||||
Push-Location $godotWinDir
|
|
||||||
try { & node build/setup.js install 2>&1 | Out-Null } finally { Pop-Location }
|
|
||||||
Write-OK "Godot MCP Pro 就绪: $godotWinDir"
|
|
||||||
} else {
|
|
||||||
Write-Warn "Node.js 未就绪,跳过 Godot MCP Pro setup"
|
|
||||||
}
|
|
||||||
|
|
||||||
$godotEntry = ($godotWinDir -replace "\\","/") + "/build/index.js"
|
|
||||||
$godotMcpEntry = @{ command = "node"; args = @($godotEntry) }
|
|
||||||
|
|
||||||
Merge-McpConfig "$env:APPDATA\Claude\claude_desktop_config.json" $godotMcpEntry "Claude Desktop (Godot)" "godot-mcp-pro"
|
|
||||||
Merge-McpConfig "$env:USERPROFILE\.cursor\mcp.json" $godotMcpEntry "Cursor (Godot)" "godot-mcp-pro"
|
|
||||||
$wsDirG = if (Test-Path "$env:APPDATA\Windsurf") { "$env:APPDATA\Windsurf" } else { "$env:USERPROFILE\.codeium\windsurf" }
|
|
||||||
Merge-McpConfig "$wsDirG\mcp_config.json" $godotMcpEntry "Windsurf (Godot)" "godot-mcp-pro"
|
|
||||||
Merge-VsCodeMcpSettings "godot-mcp-pro" $godotMcpEntry "VS Code Copilot Agent (Godot MCP Pro)"
|
|
||||||
if (Get-Command claude -ErrorAction SilentlyContinue) {
|
|
||||||
& claude mcp remove godot-mcp-pro --scope user 2>&1 | Out-Null
|
|
||||||
& claude mcp add --scope user godot-mcp-pro node $godotEntry 2>&1 | Out-Null
|
|
||||||
Write-OK "Claude Code CLI MCP -> godot-mcp-pro"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 6: 防火墙放行 MCP Bridge 端口
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "6/8 Windows 防火墙规则"
|
|
||||||
|
|
||||||
if ($SkipFirewall) {
|
|
||||||
Write-Warn "已跳过防火墙配置(-SkipFirewall)"
|
|
||||||
} else {
|
|
||||||
$portRange = "$BridgePort-$($BridgePort + 9)"
|
|
||||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
|
||||||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
||||||
if ($isAdmin) {
|
|
||||||
foreach ($dir in @("Inbound","Outbound")) {
|
|
||||||
$name = "Unity MCP Bridge $dir"
|
|
||||||
Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue |
|
|
||||||
Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
|
||||||
New-NetFirewallRule -DisplayName $name -Direction $dir `
|
|
||||||
-LocalPort $portRange -Protocol TCP -Action Allow -Profile Any | Out-Null
|
|
||||||
Write-OK "防火墙规则: $name (TCP $portRange)"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Warn "非管理员权限,尝试以提升权限添加防火墙规则..."
|
|
||||||
$fwCmd = @"
|
|
||||||
`$p = '$portRange'
|
|
||||||
foreach (`$d in @('Inbound','Outbound')) {
|
|
||||||
`$n = "Unity MCP Bridge `$d"
|
|
||||||
Get-NetFirewallRule -DisplayName `$n -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
|
||||||
New-NetFirewallRule -DisplayName `$n -Direction `$d -LocalPort `$p -Protocol TCP -Action Allow -Profile Any | Out-Null
|
|
||||||
Write-Host "OK: `$n"
|
|
||||||
}
|
|
||||||
Start-Sleep 1
|
|
||||||
"@
|
|
||||||
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($fwCmd))
|
|
||||||
$proc = Start-Process powershell.exe -ArgumentList "-NoProfile -EncodedCommand $enc" `
|
|
||||||
-Verb RunAs -Wait -PassThru -ErrorAction SilentlyContinue
|
|
||||||
if ($proc -and $proc.ExitCode -eq 0) {
|
|
||||||
Write-OK "防火墙规则已添加 (TCP $portRange)"
|
|
||||||
} else {
|
|
||||||
Write-Warn "防火墙配置未成功,请手动以管理员运行:New-NetFirewallRule ... -LocalPort $portRange"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 7: Rust 工具链 & RTK
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "7/8 Rust 工具链 & Token Killer"
|
|
||||||
|
|
||||||
# 若 .env 配置了国内镜像,提前注入环境变量
|
|
||||||
$rustupMirror = $Cfg["RUSTUP_DIST_SERVER"] ?? ""
|
|
||||||
$rustupRoot = $Cfg["RUSTUP_UPDATE_ROOT"] ?? ""
|
|
||||||
if ($rustupMirror) { $env:RUSTUP_DIST_SERVER = $rustupMirror }
|
|
||||||
if ($rustupRoot) { $env:RUSTUP_UPDATE_ROOT = $rustupRoot }
|
|
||||||
|
|
||||||
# 先把 cargo\bin 加入 PATH(rustup 通过 winget 安装后 PATH 需刷新)
|
|
||||||
$cargoBin = "$env:USERPROFILE\.cargo\bin"
|
|
||||||
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
|
|
||||||
Refresh-Path
|
|
||||||
|
|
||||||
$cargoCmd = Get-Command cargo -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($cargoCmd) {
|
|
||||||
# 检查是否有默认 toolchain,若没有则安装 stable
|
|
||||||
$rustcVer = & rustc --version 2>&1 | Out-String
|
|
||||||
if ($rustcVer -match "no default is configured" -or $rustcVer -match "error:") {
|
|
||||||
Write-Info "rustup 无默认 toolchain,安装 stable..."
|
|
||||||
& rustup toolchain install stable 2>&1 | Select-Object -Last 3
|
|
||||||
& rustup default stable 2>&1 | Out-Null
|
|
||||||
Refresh-Path
|
|
||||||
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
|
|
||||||
$rustcVer = & rustc --version 2>&1 | Out-String
|
|
||||||
}
|
|
||||||
Write-OK "Rust 已安装: $($rustcVer.Trim())"
|
|
||||||
} else {
|
|
||||||
# 检查 winget 是否已记录安装(避免重复安装时阻塞)
|
|
||||||
$wgPath = (Get-Command winget -ErrorAction SilentlyContinue)?.Source
|
|
||||||
if (-not $wgPath) {
|
|
||||||
$fallback = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
|
|
||||||
if (Test-Path $fallback) { $wgPath = $fallback }
|
|
||||||
}
|
|
||||||
$wgList = if ($wgPath) { & $wgPath list --id Rustlang.Rustup --accept-source-agreements 2>&1 | Out-String } else { "" }
|
|
||||||
if ($wgList -match "Rustlang.Rustup") {
|
|
||||||
Write-Info "rustup 已安装,但当前会话 PATH 未更新。已将 $cargoBin 加入 PATH"
|
|
||||||
# rustup 首次运行会初始化 stable 工具链
|
|
||||||
if (Test-Path "$cargoBin\rustup.exe") { & "$cargoBin\rustup.exe" show 2>&1 | Out-Null }
|
|
||||||
$cargoCmd = Get-Command cargo -ErrorAction SilentlyContinue
|
|
||||||
if ($cargoCmd) { Write-OK "Rust: $(& rustc --version 2>&1)" }
|
|
||||||
} else {
|
|
||||||
Write-Info "通过 winget 安装 Rust (rustup)..."
|
|
||||||
$ok = Install-Winget "Rustlang.Rustup" "Rustup"
|
|
||||||
if ($ok) {
|
|
||||||
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
|
|
||||||
Refresh-Path
|
|
||||||
$cargoCmd = Get-Command cargo -ErrorAction SilentlyContinue
|
|
||||||
if ($cargoCmd) {
|
|
||||||
Write-OK "Rust 安装完成: $(& rustc --version 2>&1)"
|
|
||||||
} else {
|
|
||||||
Write-Warn "Rust 安装后未找到 cargo,请关闭并重开终端后继续"
|
|
||||||
Write-Warn " 或手动安装:https://rustup.rs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 先把 ~/.local/bin 加入 PATH(预编译包安装目录)
|
|
||||||
$localBin = "$env:USERPROFILE\.local\bin"
|
|
||||||
if (Test-Path $localBin) { $env:Path = "$localBin;$env:Path" }
|
|
||||||
|
|
||||||
$rtkCmd = Get-Command rtk -ErrorAction SilentlyContinue
|
|
||||||
if ($rtkCmd) {
|
|
||||||
Write-OK "rtk 已安装: $(& rtk --version 2>&1)"
|
|
||||||
} else {
|
|
||||||
# 从 GitHub Releases 下载预编译包(比 cargo install 快得多,且无需编译环境)
|
|
||||||
Write-Info "安装 rtk (Rust Token Killer) - 下载预编译包..."
|
|
||||||
try {
|
|
||||||
$rtkVer = "v0.42.0"
|
|
||||||
$rtkUrl = "https://github.com/rtk-ai/rtk/releases/download/$rtkVer/rtk-x86_64-pc-windows-msvc.zip"
|
|
||||||
New-Item -ItemType Directory -Force -Path $localBin | Out-Null
|
|
||||||
$rtkZip = "$env:TEMP\rtk-windows.zip"
|
|
||||||
Invoke-WebRequest -Uri $rtkUrl -OutFile $rtkZip -UseBasicParsing
|
|
||||||
Expand-Archive -Path $rtkZip -DestinationPath $localBin -Force
|
|
||||||
Remove-Item $rtkZip -ErrorAction SilentlyContinue
|
|
||||||
$env:Path = "$localBin;$env:Path"
|
|
||||||
$rtkCmd = Get-Command rtk -ErrorAction SilentlyContinue
|
|
||||||
if ($rtkCmd) {
|
|
||||||
Write-OK "rtk 安装完成: $(& rtk --version 2>&1)"
|
|
||||||
} else {
|
|
||||||
Write-Warn "rtk 安装失败,可手动运行:cargo install --git https://github.com/rtk-ai/rtk"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Warn "rtk 下载失败: $_"
|
|
||||||
Write-Warn "可手动运行:cargo install --git https://github.com/rtk-ai/rtk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── rtk init -g:注册 Claude Code PreToolUse hook ─────────────
|
|
||||||
if (Get-Command rtk -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Info "注册 rtk hook 到 Claude Code..."
|
|
||||||
# 先禁用遥测,避免交互式确认提示阻塞管道
|
|
||||||
$prevEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = "Continue"
|
|
||||||
& rtk telemetry disable 2>&1 | Out-Null
|
|
||||||
try {
|
|
||||||
& rtk init -g --auto-patch 2>&1 | Out-Null
|
|
||||||
Write-OK "rtk hook 已注册(重启 Claude Code 后生效)"
|
|
||||||
} catch {
|
|
||||||
Write-Warn "rtk init 异常(hook 可能已注册): $_"
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $prevEAP
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── rtk Copilot Instructions:写入 .github/copilot-instructions.md ──
|
|
||||||
# 使用已有的(位于仓库根 .github/),仅在缺失时生成
|
|
||||||
$copilotDir = Join-Path $ScriptDir ".." ".github"
|
|
||||||
$copilotInst = Join-Path $copilotDir "copilot-instructions.md"
|
|
||||||
if (-not (Test-Path $copilotInst)) {
|
|
||||||
Write-Info "生成 .github/copilot-instructions.md ..."
|
|
||||||
if (-not (Test-Path $copilotDir)) { New-Item -ItemType Directory -Path $copilotDir | Out-Null }
|
|
||||||
& rtk init -g --copilot 2>&1 | Out-Null
|
|
||||||
Write-OK "Copilot Instructions 已生成"
|
|
||||||
} else {
|
|
||||||
Write-OK "Copilot Instructions 已存在,跳过生成"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Step 8: PowerShell Profile 快捷命令
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Step "8/8 PowerShell Profile"
|
|
||||||
|
|
||||||
$pwshProfile = "$env:USERPROFILE\Documents\PowerShell\Microsoft.PowerShell_profile.ps1"
|
|
||||||
$profileDir = Split-Path $pwshProfile
|
|
||||||
if (-not (Test-Path $profileDir)) { New-Item -ItemType Directory -Path $profileDir | Out-Null }
|
|
||||||
|
|
||||||
$profileBlock = @"
|
|
||||||
|
|
||||||
# ── Windows Dev Stack (generated by windows-dev-stack\deploy.ps1) ─────
|
|
||||||
`$env:ANTHROPIC_BASE_URL = "$ANTHROPIC_BASE_URL"
|
|
||||||
$(if ($ANTHROPIC_API_KEY) { "`$env:ANTHROPIC_API_KEY = `"$ANTHROPIC_API_KEY`"" } else { "# ANTHROPIC_API_KEY= (配置 .env 后重新运行)" })
|
|
||||||
`$env:CLAUDE_MODEL = "$CLAUDE_MODEL"
|
|
||||||
# 将 rtk / cargo 加入 PATH(如尚未在系统 PATH 中)
|
|
||||||
`$_localBin = "`$env:USERPROFILE\.local\bin"
|
|
||||||
if ((Test-Path `$_localBin) -and `$env:Path -notlike "*.local\bin*") { `$env:Path = "`$_localBin;`$env:Path" }
|
|
||||||
`$_cargoBin = "`$env:USERPROFILE\.cargo\bin"
|
|
||||||
if ((Test-Path `$_cargoBin) -and `$env:Path -notlike "*cargo\bin*") { `$env:Path = "`$_cargoBin;`$env:Path" }
|
|
||||||
function unity-mcp-status { Invoke-RestMethod http://127.0.0.1:$BridgePort/api/ping -ErrorAction SilentlyContinue }
|
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
|
||||||
"@
|
|
||||||
|
|
||||||
$existing = if (Test-Path $pwshProfile) { Get-Content $pwshProfile -Raw } else { "" }
|
|
||||||
if ($existing -match "Windows Dev Stack") {
|
|
||||||
# 注意:直接用 -replace 会将 $_ 等解释为正则反向引用导致内容爆炸
|
|
||||||
# 使用 MatchEvaluator scriptblock 让替换字符串按字面量处理
|
|
||||||
$blockCapture = $profileBlock
|
|
||||||
$existing = [regex]::Replace($existing, '(?ms)# ── Windows Dev Stack.*?# ─+', { $blockCapture })
|
|
||||||
Set-Content $pwshProfile $existing -Encoding UTF8
|
|
||||||
} else {
|
|
||||||
Add-Content $pwshProfile $profileBlock -Encoding UTF8
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = Get-ExecutionPolicy -Scope CurrentUser
|
|
||||||
if ($policy -in @("Restricted","Undefined")) {
|
|
||||||
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force
|
|
||||||
}
|
|
||||||
Write-OK "PowerShell Profile 已配置: $pwshProfile"
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 安装总结
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Green
|
|
||||||
Write-Host "║ ✅ Windows 原生全栈部署完成 ║" -ForegroundColor Green
|
|
||||||
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { & node --version 2>&1 } else { "(未安装)" }
|
|
||||||
$claudeVer = if (Get-Command claude -ErrorAction SilentlyContinue) { & claude --version 2>&1 | Select-Object -First 1 } else { "(未安装)" }
|
|
||||||
$ghVer = if (Get-Command gh -ErrorAction SilentlyContinue) { & gh --version 2>&1 | Select-Object -First 1 } else { "(未安装)" }
|
|
||||||
$rustVer = if (Get-Command rustc -ErrorAction SilentlyContinue) { & rustc --version 2>&1 } else { "(未安装)" }
|
|
||||||
$rtkVer = if (Get-Command rtk -ErrorAction SilentlyContinue) { & rtk --version 2>&1 } else { "(未安装)" }
|
|
||||||
|
|
||||||
$items = @(
|
|
||||||
@("Node.js", $nodeVer),
|
|
||||||
@("Claude Code", $claudeVer),
|
|
||||||
@("GitHub CLI", $ghVer),
|
|
||||||
@("Rust", $rustVer),
|
|
||||||
@("rtk", $rtkVer),
|
|
||||||
@("Unity MCP", $(if ($unityEntry) { $unityEntry } else { "(未安装)" })),
|
|
||||||
@("Godot MCP Pro", $(if ($godotEntry) { $godotEntry } else { "(未安装)" })),
|
|
||||||
@("Bridge Port", "TCP $BridgePort (防火墙已放行 $BridgePort-$($BridgePort+9))")
|
|
||||||
)
|
|
||||||
foreach ($item in $items) {
|
|
||||||
$label = $item[0].PadRight(14)
|
|
||||||
Write-Host " $label $($item[1])" -ForegroundColor White
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 后续手动步骤:" -ForegroundColor Yellow
|
|
||||||
Write-Host " 1. gh auth login # GitHub CLI 登录(首次)" -ForegroundColor DarkGray
|
|
||||||
Write-Host " 2. Unity Editor 安装 unity-mcp-plugin:" -ForegroundColor DarkGray
|
|
||||||
Write-Host " https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git" -ForegroundColor DarkGray
|
|
||||||
Write-Host " 3. Godot 项目:复制 addons/godot_mcp/ 并在 Plugins 中启用" -ForegroundColor DarkGray
|
|
||||||
Write-Host " 源:$(Join-Path $godotPkgBase "$GodotMcpVersion\addons\godot_mcp")" -ForegroundColor DarkGray
|
|
||||||
Write-Host " 4. 重启 AI 客户端(Claude Desktop / Cursor / Windsurf / VS Code)" -ForegroundColor DarkGray
|
|
||||||
Write-Host ""
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,670 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to Godot MCP Pro will be documented in this file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.14.1 — 2026-05-24
|
|
||||||
|
|
||||||
**Patch** — `assert_node_state` regression fix
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`assert_node_state` "Unknown command" regression**: The game-side handler in `mcp_game_inspector_service.gd` had been removed during the v1.7.0 refactor (`4ea3989`, 2026-03-29) that introduced `batch_add_nodes` / `watch_signals` / `setup_control`. The TypeScript server and editor-side GDScript still routed the call, but the runtime match statement no longer recognized it — so `assert_node_state` (and any `type:"assert"` step inside `run_test_scenario`) returned `Unknown command: assert_node_state` on every call from v1.7.0 through v1.14.0. Restored the handler with all 8 operators (`eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `contains`, `type_is`) and sub-property access via `get_indexed()` (e.g. `position:y`). Reported by **Z_runner [CRWN]** on Discord.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.14.0 — 2026-05-18
|
|
||||||
|
|
||||||
**Feature / Safety** — File-conflict safety overhaul (community contribution)
|
|
||||||
|
|
||||||
This release is a coordinated overhaul of how the addon interacts with editor-owned resources. It prevents the "external change" dialog and silent in-memory/disk divergence that could occur when MCP commands wrote scenes, scripts, shaders, or resources while Godot still had them open. Contributed by **[@aallnneess](https://github.com/aallnneess)** (PR #31), with the design and patch reviewed and tested locally with GitHub Copilot using GPT-5.5 xhigh. Reported and validated against the previous v1.13.x behavior.
|
|
||||||
|
|
||||||
### Added — safety primitives in `base_command.gd`
|
|
||||||
- `guard_offline_scene_save(path)`: blocks `ResourceSaver.save(...)` to a `.tscn`/`.scn` path when that scene is currently open in the editor. Returns a structured conflict error (JSON-RPC code `-32009`) including the path, open-scenes list, and a recovery suggestion.
|
|
||||||
- `guard_text_resource_write(path, force)`: blocks writes to a script/shader file that is currently open in Godot's script editor (or, for shaders, currently loaded/cached in `ResourceLoader`). Override with `force=true`.
|
|
||||||
- `add_child_with_undo(...)` / `set_property_with_undo(...)`: register live scene mutations through `EditorUndoRedoManager` with correct `add_do_reference` / `add_undo_reference` retention for `Resource` values so they survive GC across the undo history.
|
|
||||||
- `get_open_scene_paths()` / `is_scene_path_open()` / `is_active_scene_path()` / `is_text_resource_open_in_script_editor()`: shared checks so per-command code never re-implements the same logic.
|
|
||||||
- `normalize_project_path()`: consistent comparison key for `res://`, project-relative, and absolute paths.
|
|
||||||
|
|
||||||
### Changed — scene saves go through `EditorInterface`
|
|
||||||
- `save_scene`: when the target path matches the active edited scene, calls `EditorInterface.save_scene()`; when the target differs or the active scene has no path yet, calls `EditorInterface.save_scene_as(path)`. Refuses to save an inactive open scene tab. No more silent `ResourceSaver.save` to an open path.
|
|
||||||
- `create_scene` / `create_theme` / `edit_resource`: all guarded against accidentally targeting an open scene path.
|
|
||||||
|
|
||||||
### Changed — broad cross-scene edits are opt-in
|
|
||||||
- **`cross_scene_set_property` defaults to `dry_run=true`** (breaking change). Real writes now require `dry_run=false` **and** `force=true`. The response includes a per-scene `mode` field (`dry_run` / `offline_saved` / `live_open_scene`) and a `skipped_open_scenes` list so callers can see exactly what happened.
|
|
||||||
- The active open scene is live-edited via `EditorUndoRedoManager` instead of being offline-overwritten, so changes are visible in the editor and undoable.
|
|
||||||
- Inactive open scenes are skipped and reported rather than silently overwritten.
|
|
||||||
|
|
||||||
### Changed — live scene mutations participate in UndoRedo
|
|
||||||
- `batch_add_nodes`, `batch_set_property`: routed through the shared UndoRedo helpers.
|
|
||||||
- `node_commands`: node creation, `set_anchor_preset` (computed on a duplicate Control before applying), signal connect/disconnect, group add/remove — all undoable.
|
|
||||||
- `animation_commands`: animation create/remove, track add, and keyframe edits. `_upsert_animation_key` / `_restore_animation_key` give round-trip undo for keyframe edits (including the previously-present-key replacement case) using `is_equal_approx` time matching.
|
|
||||||
- `animation_tree_commands`: AnimationTree create, state machine state/transition edits, blend tree node changes, tree parameter edits.
|
|
||||||
- `tilemap_commands`: single-cell set, rect fill, and clear capture the affected cells' old state and apply via UndoRedo. Round-trip undoable.
|
|
||||||
- `theme_commands`: color, constant, font-size, and stylebox theme overrides; `setup_control` computes target state on a duplicate Control before applying.
|
|
||||||
- `audio_commands`, `navigation_commands`, `particle_commands`: node creation and resource assignment go through UndoRedo. Navigation bake also marks the active scene unsaved.
|
|
||||||
- `shader_commands`: shader material assignment via `set_property_with_undo`.
|
|
||||||
|
|
||||||
### Fixed — open script/shader writes
|
|
||||||
- `create_script` / `edit_script`: refuse to write when the target is open in the script editor, unless `force=true` is explicitly passed. Also restricted to `.gd` / `.cs` extensions — scene and shader paths are rejected with a clear suggestion (added in `6d8d650`).
|
|
||||||
- `edit_script` now actually implements the 1-based inclusive `start_line` / `end_line` range replacement that the CLI had been advertising. The TypeScript `edit_script` schema and CLI `script edit` both expose the new parameters.
|
|
||||||
- `create_shader` / `edit_shader`: same open-file guard, with `force=true` to override.
|
|
||||||
- Shader cache refresh fixed: `_refresh_loaded_shader` uses `take_over_path()` + `emit_changed()` to update any cached/live `Shader` resource, replacing the unreliable `Shader.reload_from_file()` call. Live materials referencing the shader now pick up edits immediately.
|
|
||||||
|
|
||||||
### Fixed — `execute_editor_script` escape hatch
|
|
||||||
- Submitted code is scanned for direct file/resource write APIs (`ResourceSaver.save`, `FileAccess WRITE`, `ProjectSettings.save`, `ConfigFile.save`, `DirAccess` filesystem mutations). If present, the call is refused with a structured conflict error unless `allow_unsafe_editor_io=true` is explicitly passed. Closes the obvious workaround where an AI client could route around the per-command guards by submitting raw script.
|
|
||||||
|
|
||||||
### Changed — server schemas
|
|
||||||
- `create_script` / `edit_script` / `create_shader` / `edit_shader`: added optional `force` parameter and updated tool descriptions.
|
|
||||||
- `cross_scene_set_property`: added optional `dry_run` / `force` parameters and a description that reflects the new dry-run-by-default semantics.
|
|
||||||
- `execute_editor_script`: added optional `allow_unsafe_editor_io` parameter.
|
|
||||||
- `cli.ts`: `script create` and `script edit` accept `--force` flag.
|
|
||||||
|
|
||||||
### Migration notes
|
|
||||||
- **Breaking**: scripts/agents that previously relied on `cross_scene_set_property` writing on first call now need to add `dry_run=false force=true` to perform writes. Without those, the call returns a dry-run preview. This is intentional — silent project-wide writes were a footgun.
|
|
||||||
- **Soft-breaking**: scripts/agents that previously overwrote open files (scenes, scripts, shaders) without checking now hit a `-32009` conflict error. The fix is to either close the file in the editor first, save through the editor (for scenes), or pass `force=true` (for scripts/shaders, when you've verified no buffer holds unsaved changes).
|
|
||||||
- Existing wire-compatible calls that did *not* target open resources continue to work unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.13.2 — 2026-05-13
|
|
||||||
|
|
||||||
**Bug Fix** — Port allocation race when multiple Claude Code sessions start at the same time
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Parallel-session port collision** (Discord report by CrusherEAGLE): Two Claude Code sessions starting nearly simultaneously could both pre-check port 6505 as free, both attempt to bind, the loser would get `EADDRINUSE` and give up without retrying the next port. The session that lost the race had a server that never started, so every tool call failed for the remainder of that session with no way to recover short of restart. The fallback path in `index.ts` claimed it would "retry on first command" but no such retry existed.
|
|
||||||
- The fix replaces the racy pre-check + single bind with a proper bind-retry loop: each port in `6505–6509` is tried in turn, and `EADDRINUSE` triggers a fall-through to the next port. Only when the entire range is exhausted does `connect()` reject, with a clear error message and remediation hint.
|
|
||||||
- Cleaned up the misleading "will retry on first command" log line in `index.ts`.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- New `tests/godot-connection.test.ts` covers: first-port allocation, sequential fall-through, **simultaneous parallel connects** (the exact regression scenario), range exhaustion, and `fixedPort=true` fail-fast behavior. 5 new tests, 62 total.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.13.1 — 2026-05-12
|
|
||||||
|
|
||||||
**Bug Fix** — Silent disconnect / dead-connection recovery
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Heartbeat now actually detects dead connections** (Discord report by CrusherEAGLE): The `ping`/`pong` heartbeat was being sent every 10s but neither side tracked whether responses were arriving, so a half-open TCP connection (common on Windows after sleep/wake, VPN toggle, or a brief editor hang) left both sides holding a dead socket. `isConnected()` continued to return `true`, every command timed out at 30s, and the only way back was to restart Claude Code **and** the Godot editor. Fixed on both sides:
|
|
||||||
- **Server**: tracks the last `pong` timestamp; if 30s passes with no pong, forcibly destroys the socket (`terminate()`, vs `close()` which waits for a FIN ack that never comes on a dead link). Pending requests are rejected immediately rather than hanging for 30s.
|
|
||||||
- **Editor**: sends its own `ping` every 5s, tracks per-port inactivity, and after 30s of inbound silence force-closes the peer so the existing 3s reconnect cycle takes over.
|
|
||||||
- **OS-level TCP keepalive** enabled on the server socket (5s initial delay), surfacing half-open links faster than Windows' ~2-hour default.
|
|
||||||
- **Status panel surfaces stale state**: New yellow ⚠ indicator when a port is reconnecting from a stale state, plus per-port idle time (seconds since last received message) in the Clients tab. No more "looks fine while everything is broken" UI.
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- Recovery is automatic within ~30s after the connection dies. Watch the Output panel for `[MCP] Port NNNN silent for X.Xs — forcing reconnect` if you want to see it happen.
|
|
||||||
- No API or tool changes — same 172 tools, same behavior in the healthy path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.13.0 — 2026-05-05
|
|
||||||
|
|
||||||
**Bug Fixes & Polish** — Mouse motion dispatch, setup config, site pricing
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`simulate_mouse_move` honors explicit `unhandled: false`** (#24, #25): Drag motions (`button_mask > 0`) auto-promote to `push_input` so camera-pan use cases bypass GUI consumption. But callers writing UI drag-and-drop tests need events to reach the GUI dispatcher (so `_get_drag_data` / `_drop_data` fire). Now: if the caller explicitly passes `unhandled: false`, that wins; auto-promotion only happens when `unhandled` was omitted. Default behavior preserved.
|
|
||||||
- **v1.12.0 build blocker**: Restored missing `mcp/server/src/utils/load-instructions.ts` referenced by `index.ts` since v1.12.0. Fresh clones of v1.12.0 failed `npm run build` with `Cannot find module './utils/load-instructions.js'`. v1.13.0 now builds clean.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **`setup` no longer pins `GODOT_MCP_PORT`** (#27): Generated MCP client config (Claude Desktop, Cursor, etc.) omits the `GODOT_MCP_PORT` env var so the server can auto-scan ports `6505–6509`. Pinning a fixed port caused silent failures when a stale process held the port. Users who need a fixed port can still set it manually.
|
|
||||||
- **Site JSON-LD price → $15** (#26): Structured data on the landing page now reflects the current price.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **README clarity** (issue #7 follow-up): More prominent note that the public repo ships the addon only — the MCP server is distributed via Buy Me a Coffee / itch.io.
|
|
||||||
- **`build-release.sh` portability**: Falls back to system `zip` and `python3` when 7-Zip is not on PATH.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.12.0 — 2026-04-19
|
|
||||||
|
|
||||||
**Feature** — Android Remote Deploy · **Bug Fix** — Runtime IPC on custom user dirs
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Android Remote Deploy** (3 tools, Full mode only — #20):
|
|
||||||
- `list_android_devices` — wraps `adb devices -l`, returns serial/state/model/product. Resolves adb from Editor Settings > Export > Android > Adb, falling back to `adb` on PATH.
|
|
||||||
- `get_android_preset_info` — reads metadata (package name, export path, runnable) from an Android preset in `export_presets.cfg`.
|
|
||||||
- `deploy_to_android` — one-shot pipeline: Godot CLI export → `adb install -r` → `adb shell monkey` launch. Options: `preset_name`, `device_serial`, `debug`, `launch`, `skip_export`. Synchronous; export step can take tens of seconds.
|
|
||||||
- Full mode tool count: **169 → 172**. LITE / 3D / MINIMAL modes are unchanged.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`get_game_user_dir()`** (`commands/base_command.gd`) — #21: Runtime IPC commands (`get_game_scene_tree`, `get_game_node_properties`, `simulate_*`, etc.) failed with `Could not create game request file` when the project used `application/config/use_custom_user_dir=true`, or when `application/config/name` contained characters illegal on the host OS (e.g. `:` on Windows). Editor and game now resolve to the same dir: early-return `OS.get_user_data_dir()` for custom user dirs, and sanitize `config/name` via `xml_unescape().validate_filename().replace(".", "_")` — matching Godot's own logic in `ProjectSettings::_init`. Thanks @asim9834 for the detailed repro + patch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.11.0 — 2026-04-15
|
|
||||||
|
|
||||||
**Feature** — New `--3d` mode for 100-tool-limit clients
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **`--3d` mode**: Registers exactly 100 tools — the 81 core LITE tools plus Physics (6), AnimationTree (8), and Navigation (5). Designed for clients with a 100-tool cap (e.g. Google Antigravity with Claude Code proxy) that need full 3D game development capabilities. Usage: `node build/index.js --3d`
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **Troubleshooting docs**: Clarified port conflict advice in INSTALL.md — recommends letting the server auto-scan ports 6505–6509 instead of setting a fixed `GODOT_MCP_PORT`, which can cause silent failures with stale processes
|
|
||||||
|
|
||||||
### Mode comparison
|
|
||||||
|
|
||||||
| Flag | Tools | Use case |
|
|
||||||
|------|-------|----------|
|
|
||||||
| *(none)* | 169 | Full mode — all tools |
|
|
||||||
| `--3d` | 100 | 3D game dev under 100-tool limit |
|
|
||||||
| `--lite` | 81 | Tight tool limits (Cursor, etc.) |
|
|
||||||
| `--minimal` | 35 | Ultra-tight limits (local LLMs) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.10.3 — 2026-04-11
|
|
||||||
|
|
||||||
**Bug Fixes** — Autoload preservation, Windows build, port conflict warning
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Autoload deletion on `--import` / shutdown**: Plugin no longer removes pre-existing MCP autoloads from `project.godot`. Previously, `_remove_autoloads()` deleted all managed autoload keys unconditionally — even if they were project-owned. Now only autoloads injected by the current plugin session are removed. (#17)
|
|
||||||
- **Windows build failure**: Removed Unix-only `chmod -R a+x build || true` from the `build` script in `package.json`. The `chmod` and `true` commands don't exist on Windows cmd/PowerShell, causing `node build/setup.js install` to fail with "Build failed" even though TypeScript compilation succeeded. The fix is simply `"build": "tsc"` — execute permissions are not needed since the server runs via `node`. (#Discord)
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **Port conflict warning for explicit port**: When `GODOT_MCP_PORT` is set and the port is already occupied (e.g. by a stale process), the server now logs a clear warning with remediation steps instead of silently failing to bind. (#15)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.10.2 — 2026-04-11
|
|
||||||
|
|
||||||
**Fix** — Linux permission issue for build files
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Linux permission denied**: Added `chmod -R a+x` to build process so that `build/index.js` and other compiled files have execute permission out of the box on Linux/macOS. Previously, users had to manually run `chmod -R a+x build` after installation. (Thanks to kflamsted for reporting!)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.10.1 — 2026-04-08
|
|
||||||
|
|
||||||
**UX Improvement** — Bottom panel renamed, INSTALL.md rewritten
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **Bottom panel renamed**: "MCP Server" → "MCP Pro" for consistency with the product name. Status label also updated.
|
|
||||||
- **INSTALL.md rewritten**: Added zip structure diagram, clear separation of addon vs server, Claude Desktop config paths, and better troubleshooting. Clarified that `configure` must be run from the Godot project directory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.10.0 — 2026-04-07
|
|
||||||
|
|
||||||
**New Tools & Quality Sweep** — Editor camera control, 169 tools, comprehensive audit fixes
|
|
||||||
|
|
||||||
### New Tools
|
|
||||||
- **`get_editor_camera`**: Get the 3D editor viewport camera position, rotation, and FOV. Useful for understanding the current view before taking screenshots.
|
|
||||||
- **`set_editor_camera`**: Move the 3D editor viewport camera to a specific position and orientation. Supports position, rotation, look_at target, and FOV. Use this to frame a view before screenshots to validate changes visually.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`plugin.gd` version display**: Was hardcoded to "v1.6.0" since initial release. Now dynamically reads from `plugin.cfg` — always shows the correct version.
|
|
||||||
- **Tool count inconsistency**: Was showing 162/163/167 across different files. All references now correctly say 169.
|
|
||||||
- **`node setup.js` path**: All docs and help text now correctly say `node build/setup.js`.
|
|
||||||
- **`configure` cwd issue**: INSTALL.md now clearly separates `install` (run from server/) and `configure` (run from Godot project root) to avoid `.mcp.json` being placed in the wrong directory.
|
|
||||||
- **INSTALL.md**: Fixed step numbering skip, stale tool count (49→169), port range (6505-6514).
|
|
||||||
- **README.md**: Replaced hardcoded dev paths with `/path/to/` placeholders.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **"v1.x" wording removed**: All pricing and marketing text now says "lifetime updates" without version scope.
|
|
||||||
- **Plugin port range**: WebSocket comment and connection range expanded to 6505-6514 (6510-6514 reserved for CLI).
|
|
||||||
- **Pre-built JS in release zip**: `build/setup.js` and `build/cli.js` work immediately after extract + `npm install`.
|
|
||||||
- **Claude Desktop support**: Confirmed working, added to configure auto-detection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.9.4 — 2026-04-06
|
|
||||||
|
|
||||||
**Bug Fixes** — State enum type regression, zip plugin version
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`mcp_game_inspector_service.gd` State enum type error (regression)**: `var _state: State = State.IDLE` caused "Cannot assign a value of type mcp_game_inspector_service.gd.State to variable with specified type State" in some Godot versions. Changed back to `var _state := State.IDLE` (type inference). This was originally fixed in v1.6.4 but regressed. (Thanks @kalish)
|
|
||||||
- **Release zip contained wrong plugin version**: v1.9.3 zip shipped with plugin.cfg showing v1.9.2 due to a build order issue. Fixed build pipeline to ensure public repo is synced before zip creation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.9.3 — 2026-04-06
|
|
||||||
|
|
||||||
**Improvement** — Pre-built JS in release zip, docs cleanup
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **Pre-built JS files included in release zip**: `build/setup.js`, `build/cli.js`, and all other compiled files are now included. Users can run `node build/setup.js install` immediately after extracting — no need to manually run `npm run build` first.
|
|
||||||
- **CLI naming unified in docs**: Removed `godot-cli` shorthand. All docs consistently use `node build/cli.js`. Added note that server must be built before CLI use.
|
|
||||||
- **CLI help port range fixed**: `--help` output now correctly shows 6510-6514 (CLI range), not 6505-6509 (MCP server range).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.9.2 — 2026-04-06
|
|
||||||
|
|
||||||
**New Features** — Setup CLI, code-to-inspector workflow, CLI click fix
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
- **Setup CLI (`setup.js`)**: One-command server setup and management. Commands: `install` (npm install + build), `check-update` (GitHub release check with semver comparison), `configure` (auto-detect AI client and generate .mcp.json), `doctor` (environment diagnostics).
|
|
||||||
- **Code-to-inspector migration workflow**: New guideline in AGENTS.md and skills.md instructing AI to prefer `update_property` over hardcoded GDScript for visual properties (colors, sizes, theme overrides). Includes step-by-step migration pattern.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **CLI `input click --button` mapping**: The CLI sent string values ("left", "right", "middle") but the plugin expects numeric indices (1, 2, 3). Now correctly maps `left`→1, `right`→2, `middle`→3. (Thanks @Gogomy)
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **INSTALL.md**: Added quick setup flow using `setup.js` for both fresh install and updates.
|
|
||||||
- **Instruction files**: All 12 client instruction files updated with new workflow patterns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.9.1 — 2026-04-05
|
|
||||||
|
|
||||||
**Bug Fix** — GODOT_MCP_PORT env var now respected + Cursor Full mode
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`GODOT_MCP_PORT` env var ignored**: The server always scanned ports 6505-6509 for the first free port, ignoring the explicitly configured port. Now when `GODOT_MCP_PORT` is set, the server uses that port directly without scanning. (Fixes #13)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Cursor moved to Full mode**: Cursor removed its 40-tool limit with Dynamic Context Discovery — all 167 tools now work in Full mode. (Thanks to @CrossBread for PR #14)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.9.0 — 2026-04-05
|
|
||||||
|
|
||||||
**Universal Compatibility** — Minimal mode, CLI tool, and test suite
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
- **Minimal mode (`--minimal`)**: Registers only 35 essential tools for clients with tight tool limits (Cursor ~40, OpenCode, local LLMs with small context windows). Covers project info, scene management, node CRUD, script editing, editor errors, input simulation, and runtime inspection.
|
|
||||||
- **CLI tool (`godot-cli`)**: Command-line interface for controlling Godot directly from a terminal. LLMs discover capabilities progressively via `--help` instead of loading all tool definitions upfront — zero context overhead, works with any client that has bash/terminal access. 7 command groups: project, scene, node, script, editor, input, runtime.
|
|
||||||
- **Test suite**: Added vitest with 47 unit tests covering tool-filter, error utilities, zod coercion, and CLI help/error handling.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **Client compatibility guide**: README and landing page now include a compatibility matrix for 12+ MCP clients with recommended mode for each (Full/Lite/Minimal/CLI).
|
|
||||||
- **Landing page**: Added "Choose Your Mode" setup step, CLI documentation, and new FAQ entry for tool count limits.
|
|
||||||
- **`print_verbose` for connect/disconnect**: WebSocket connect/disconnect messages in the Godot plugin now use `print_verbose()` instead of `print()`, eliminating terminal spam during normal operation.
|
|
||||||
- **Per-client instruction files**: `instructions/` folder with ready-to-copy instruction files for 12 AI clients (Claude Code, Cursor, Cline, Windsurf, Gemini CLI, Codex CLI, OpenCode, Roo Code, JetBrains/Junie, Amazon Q, Continue, Augment). Includes CLI usage documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.8.1 — 2026-04-04
|
|
||||||
|
|
||||||
**Bug Fix** — @export node reference support in update_property
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`update_property` @export node references**: Setting `@export var` node references (e.g. `@export var hud: HUD`) via `update_property` now correctly resolves string paths to actual node references. Previously, `typeof(old_value)` returned `TYPE_NIL` for unset exports and `TYPE_OBJECT` for set ones, neither of which resolved the path string to a node. The fix checks `PROPERTY_HINT_NODE_TYPE` from the property's metadata to detect node reference exports and resolve accordingly. (Fixes #12)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.8.0 — 2026-04-02
|
|
||||||
|
|
||||||
**New Features** — HTTP transport, screenshot file saving, custom class support
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
- **Streamable HTTP transport**: New `--http` and `--http-port` flags for MCP clients that need HTTP instead of stdio. Starts an HTTP server at `http://127.0.0.1:8001/mcp` (default port).
|
|
||||||
- **Screenshot `save_path` option**: `get_editor_screenshot` and `get_game_screenshot` now accept an optional `save_path` parameter (e.g. `res://screenshot.png`) to save directly to disk instead of returning base64, avoiding MCP response cache bloat.
|
|
||||||
- **`add_node` custom class support**: `add_node` now resolves script-defined classes (`class_name`) in addition to built-in ClassDB types via `ProjectSettings.get_global_class_list()`.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- **INSTALL.md**: Added "Updating to a New Version" section with step-by-step upgrade instructions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.7.2 — 2026-03-31
|
|
||||||
|
|
||||||
**Bug Fixes & Improvements** — execute_game_script robustness + auto-dismiss control
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`execute_game_script` mixed indentation error**: User code with space indentation was prepended with tabs, causing "Mixed use of tabs and spaces" parse errors. Now auto-detects indent width and normalizes all leading spaces to tabs before wrapping.
|
|
||||||
- **`execute_game_script` standalone lambda error**: Top-level `func` definitions in user code were nested inside the wrapper's `run()` function, triggering "Standalone lambdas cannot be accessed" parse errors. Now extracts top-level functions to class level.
|
|
||||||
- **`command_router` crash on missing config section**: `_load_tool_config()` called `get_section_keys("disabled_tools")` without checking if the section exists, causing "Cannot get keys from nonexistent section" errors on fresh installs.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Auto-dismiss dialogs now opt-in**: Previously auto-dismissed blocking editor dialogs whenever an MCP client was connected. Now disabled by default — AI must explicitly enable via the new `set_auto_dismiss` tool before operations that trigger reload/save dialogs.
|
|
||||||
|
|
||||||
### New Tools
|
|
||||||
- **`set_auto_dismiss`**: Enable or disable automatic dismissal of blocking editor dialogs (e.g., "Reload from disk?", "Save changes?"). Use before external file modifications, disable when done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.7.1 — 2026-03-30
|
|
||||||
|
|
||||||
**Bug Fixes** — Scene transition crash fix and deprecated API cleanup
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`click_button_by_text` crash on scene transition**: Clicking a button that triggers a scene change (e.g., navigating from main menu to options) caused "Cannot get path of node as it is not in a scene tree" errors. Now caches button info before emitting the pressed signal and guards with `is_instance_valid()` / `is_inside_tree()` after the click.
|
|
||||||
- **Deprecated `push_unhandled_input()` warning**: Replaced with `push_input()` in `mcp_input_service.gd` per Godot 4.x API updates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.7.0 — 2026-03-29
|
|
||||||
|
|
||||||
**New Tools & Multi-Client Support** — 3 new tools for faster scene building, runtime signal debugging, and UI layout + instructions for non-Claude AI clients
|
|
||||||
|
|
||||||
### New Tools
|
|
||||||
- **`batch_add_nodes`**: Add multiple nodes in a single call. Nodes are processed in order so earlier nodes can be referenced as parents — build entire node trees in one shot instead of calling `add_node` repeatedly.
|
|
||||||
- **`watch_signals`**: Monitor signal emissions on specified nodes in the running game for a set duration. Returns a timestamped log of every signal fired with arguments — great for debugging event flow and verifying signal connections.
|
|
||||||
- **`setup_control`**: Configure a Control/Container node's layout in one call: anchor preset, min size, size flags, margins (MarginContainer), separation (VBox/HBoxContainer), and grow direction. Replaces 5+ `update_property` calls.
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **`AGENTS.md` template**: Custom instructions for non-Claude AI clients (OpenAI Codex, opencode/ollama, Cursor, etc.). Includes editor vs runtime tool categorization, workflow patterns, formatting rules, and common pitfalls. Included in release zip.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.6.5 — 2026-03-27
|
|
||||||
|
|
||||||
**assert_node_state Fix** — Game-side handler was missing, causing "Unknown command" error
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`assert_node_state` missing game-side handler**: The command was registered in the TypeScript server and editor-side GDScript, but `mcp_game_inspector_service.gd` had no handler — returning "Unknown command" at runtime. This also broke node assertions within `run_test_scenario`. All 8 operators (eq, neq, gt, lt, gte, lte, contains, type_is) now work correctly.
|
|
||||||
- **Sub-property access in assertions**: Properties like `position:y` now use `get_indexed()` instead of `get()`, enabling assertions on vector components and nested properties.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.6.4 — 2026-03-25
|
|
||||||
|
|
||||||
**Enum Type Fix** — Fixes script error on play in certain Godot versions
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`mcp_game_inspector_service.gd` State enum type error**: Explicit `State` type annotation on `_state` variable caused "Cannot assign a value of type mcp_game_inspector_service.gd.State to variable with specified type State" errors in some Godot versions. Changed to type inference (`:=`) which resolves the mismatch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.6.3 — 2026-03-24
|
|
||||||
|
|
||||||
**Camera Pan Fix** — Mouse drag events now bypass GUI layer to reach `_unhandled_input()`
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Mouse drag not reaching `_unhandled_input()`**: `simulate_mouse_move` with `button_mask` (drag simulation) was consumed by GUI Controls (`mouse_filter=STOP`) before reaching `_unhandled_input()`. Camera pan, drag-to-select, and other drag-based mechanics that rely on `_unhandled_input()` now work correctly. Events with `button_mask > 0` automatically use `push_unhandled_input()` to bypass the GUI layer.
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **`simulate_mouse_move` `unhandled` parameter**: Optional `unhandled` flag to force any mouse motion event to bypass GUI and go directly to `_unhandled_input()`. Auto-enabled when `button_mask > 0`.
|
|
||||||
- **`simulate_sequence` `unhandled` support**: Sequence `mouse_motion` events also support the `unhandled` flag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.6.2 — 2026-03-24
|
|
||||||
|
|
||||||
**Animation Easing & Mouse Drag Simulation** — Community-requested fixes
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **`set_animation_keyframe` easing parameter**: Optional `easing` param (default 1.0) to control keyframe transition curves. Values: 1.0=linear, <1.0=ease-in, >1.0=ease-out, negative=in-out variants.
|
|
||||||
- **`get_animation_info` easing field**: Each keyframe now returns its `easing` value.
|
|
||||||
- **`simulate_mouse_move` button_mask**: New `button_mask` parameter (1=left, 2=right, 4=middle) enables drag simulation. Required for games that check `InputEventMouseMotion.button_mask` (e.g. camera pan with mouse drag).
|
|
||||||
- **`simulate_sequence` button_mask**: Sequence events also support `button_mask` for drag operations.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Mouse sequence events**: `simulate_sequence` now correctly handles flat key format (`relative_x`, `relative_y`, `x`, `y`) in addition to nested format. Previously, mouse motion events in sequences had `relative=(0,0)` because the flat-to-nested conversion was missing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.6.1 — 2026-03-21
|
|
||||||
|
|
||||||
**Permission Presets** — Auto-approve tool permissions for Claude Code
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **`settings.local.json`** (conservative): Pre-configured permission file that auto-approves 152 of 163 tools. Destructive tools (`delete_node`, `delete_scene`, `execute_editor_script`, etc.) still require manual approval.
|
|
||||||
- **`settings.local.permissive.json`**: Allows all 163 tools and all Bash commands, with an explicit deny list for dangerous shell commands (`rm -rf`, `git push --force`, `git reset --hard`, etc.) and destructive MCP tools.
|
|
||||||
- Copy either file to `~/.claude/settings.local.json` to skip per-tool permission prompts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.6.0 — 2026-03-21
|
|
||||||
|
|
||||||
**Enhanced Editor Panel** — Activity log with response details, client monitor, and tool management
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **Activity tab**: Full command log showing method name, status, port, and timestamp. Toggle "Show Response Details" to inspect the JSON responses sent back to AI clients. Clear button to reset the log.
|
|
||||||
- **Clients tab**: Real-time view of all 5 WebSocket ports (6505-6509) with connection status and elapsed time since connection.
|
|
||||||
- **Tools tab**: Searchable list of all 163 tools with individual enable/disable checkboxes. Bulk "Enable All" / "Disable All" buttons. Disabled tools are persisted across sessions (`user://mcp_tool_config.cfg`) and return a clear error message to AI clients.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Status panel rebuilt with TabContainer (Activity / Clients / Tools)
|
|
||||||
- WebSocket server now emits `command_completed` signal with full response data and source port
|
|
||||||
- Connection time tracking per port for uptime display
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.5.3 — 2026-03-15
|
|
||||||
|
|
||||||
**New tool** — `record_frames` for long-running debug observation
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **`record_frames`**: Capture up to 600 screenshots saved as PNG files to `user://mcp_recorded_frames/`. Unlike `capture_frames` (which returns base64 images directly, max 30), this tool saves to disk and returns file paths — ideal for long-running debug sessions without flooding the AI context with image data. Supports optional `node_data` tracking for per-frame property snapshots (position, velocity, etc.).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.5.2 — 2026-03-13
|
|
||||||
|
|
||||||
**Bugfix** — Screenshot capture now works when the SceneTree is paused
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`mcp_screenshot_service.gd`**: Added `process_mode = Node.PROCESS_MODE_ALWAYS` so the file-polling loop in `_process()` keeps running during pause. The other two autoloads (`mcp_input_service.gd`, `mcp_game_inspector_service.gd`) already had this — screenshot service was the only one missing it.
|
|
||||||
- **`mcp_screenshot_service.gd`**: Replaced `await get_tree().process_frame` with `await get_tree().create_timer(0.05).timeout` — `process_frame` never fires when the tree is paused, but `create_timer()` with default `process_always=true` does.
|
|
||||||
|
|
||||||
Thanks to **mrkielbasa** for reporting this bug!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.5.1 — 2026-03-08
|
|
||||||
|
|
||||||
**Patch release** — AI Skills file for better out-of-the-box experience
|
|
||||||
|
|
||||||
### New
|
|
||||||
- **`skills.md`**: Added `addons/godot_mcp/skills.md` — a comprehensive guide for AI assistants covering all 162 tools, 10 practical workflows, best practices, and common pitfalls. Users can copy this to `.claude/skills.md` in their project root so Claude Code knows how to use the MCP tools effectively from the start.
|
|
||||||
- **README**: Added setup step for copying `skills.md` to `.claude/skills.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.5.0 — 2026-03-04
|
|
||||||
|
|
||||||
**Feature** — Lite mode for MCP clients with tool count limits
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
- **Lite mode (`--lite`)**: Launch with `--lite` flag to register only 76 core tools instead of 162. Designed for MCP clients with tool count limits (Windsurf: 100, Cursor: ~40, Antigravity: 100).
|
|
||||||
- Core categories (always loaded): project, scene, node, script, editor, input, runtime, input_map
|
|
||||||
- Extended categories (Full mode only): animation, animation_tree, audio, batch, export, navigation, particle, physics, profiling, resource, scene_3d, shader, test, theme, tilemap, analysis
|
|
||||||
- Usage: Add `"--lite"` to args in your MCP config
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.4.5 — 2026-03-04
|
|
||||||
|
|
||||||
**Patch release** — Godot 4.3 compatibility fix
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- **Godot 4.3 compatibility**: Fixed `scene_3d_commands.gd` parse error caused by `Environment.TONE_MAPPER_AGX` enum (added in Godot 4.4). Now uses integer value for backward compatibility. This was a blocking error that prevented the entire plugin from loading on Godot 4.3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.4.4 — 2026-03-04
|
|
||||||
|
|
||||||
**Patch release** — Revert Output panel filter expansion
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- **`get_editor_errors`**: Removed `W `, `WARN`, `GDScript` Output panel filters added in v1.4.3 — these patterns don't actually appear in Godot's Output panel (`push_warning` uses `WARNING:` prefix) and caused false positives on normal text.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.4.3 — 2026-03-04
|
|
||||||
|
|
||||||
**Patch release** — Comprehensive error/warning detection
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
- **`get_editor_errors`**: Now reads runtime errors from the debugger Errors tab (ScriptEditorDebugger), returned with `DEBUGGER:` prefix including stack traces. Previously only static analysis and Output panel errors were captured.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- **`get_editor_errors`**: Fixed debugger Errors tab not being found because the tab name includes a count suffix (e.g. "Errors (1)") — now uses prefix matching.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.4.2 — 2026-03-04
|
|
||||||
|
|
||||||
**Patch release** — Improved error detection in script editor
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
- **`get_editor_errors`**: Now reads GDScript analyzer errors and warnings from the script editor's error/warning panels (VSplitContainer RichTextLabels), in addition to the Output panel and CodeEdit line highlights. Catches static analysis messages like type mismatches and autoload name conflicts that were previously missed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.4.1 — 2026-03-02
|
|
||||||
|
|
||||||
**Patch release** — Bug fixes found during comprehensive tool audit
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- **`replay_recording`**: Fixed false crash recovery error (`_pending_command` flag not cleared for async replay loop)
|
|
||||||
- **`wait_for_node`**: Fixed false crash recovery error when polling for node appearance
|
|
||||||
- **`apply_particle_preset`**: Fixed editor crash in `gl_compatibility` renderer caused by immediate `GradientTexture1D` assignment — now uses `set_deferred` and reduced texture width
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.4.0 — 2026-03-01
|
|
||||||
|
|
||||||
**162 tools** across 23 categories (+15 new tools)
|
|
||||||
|
|
||||||
### New Tools
|
|
||||||
- **`move_to`** — Autopilot: automatically walk a character to target coordinates using pathfinding
|
|
||||||
- **`navigate_to`** — High-level navigation command for AI-driven movement
|
|
||||||
- **`find_nearby_nodes`** — Find nodes within a radius of a given position
|
|
||||||
- **`get_node_groups`** / **`set_node_groups`** — Read and write node group memberships
|
|
||||||
- **`find_nodes_in_group`** — Query all nodes belonging to a specific group
|
|
||||||
- **`get_output_log`** — Retrieve Godot's Output panel contents
|
|
||||||
- **`get_input_actions`** / **`set_input_action`** — Read and configure Input Map actions
|
|
||||||
- **`search_in_files`** — Full-text search across project files
|
|
||||||
- **`validate_script`** — Check GDScript for errors without running
|
|
||||||
- **`get_resource_preview`** — Get thumbnail previews of resources
|
|
||||||
- **`get_scene_exports`** — List exported variables in a scene's root script
|
|
||||||
- **`add_autoload`** / **`remove_autoload`** — Manage autoload singletons
|
|
||||||
|
|
||||||
### Bug Fixes & Improvements
|
|
||||||
- **Crash recovery**: `capture_frames` no longer triggers false crash recovery (`_pending_command` flag fix)
|
|
||||||
- **`capture_frames` node_data**: Optional per-frame property snapshots via `node_data` parameter
|
|
||||||
- **Debugger auto-continue**: Automatically presses Continue when runtime errors pause the debugger
|
|
||||||
- **`simulate_key` duration**: Now accepts fractional seconds (e.g., 0.3s) for precise movement
|
|
||||||
- **Command router fix**: All 8 command classes now properly registered (~47 tools were previously unreachable)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.3.1 — 2026-02-27
|
|
||||||
|
|
||||||
**Patch release**
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- **`get_editor_errors`**: Now reads from Output panel and CodeEdit error gutter (previously returned empty results)
|
|
||||||
- **Tonemap enum**: Fixed environment tonemap mode enum name mapping
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.3.0 — 2026-02-26
|
|
||||||
|
|
||||||
**147 tools** across 23 categories (+63 new tools)
|
|
||||||
|
|
||||||
### New Tool Categories
|
|
||||||
|
|
||||||
#### AnimationTree & State Machine (8 tools)
|
|
||||||
- `create_animation_tree`, `get_animation_tree_structure`, `set_tree_parameter`
|
|
||||||
- `add_state_machine_state`, `remove_state_machine_state`
|
|
||||||
- `add_state_machine_transition`, `remove_state_machine_transition`
|
|
||||||
- `set_blend_tree_node`
|
|
||||||
|
|
||||||
#### Physics & Collision (6 tools)
|
|
||||||
- `setup_collision`, `setup_physics_body`, `get_collision_info`
|
|
||||||
- `get_physics_layers`, `set_physics_layers`, `add_raycast`
|
|
||||||
|
|
||||||
#### 3D Scene (6 tools)
|
|
||||||
- `add_mesh_instance`, `setup_lighting`, `set_material_3d`
|
|
||||||
- `setup_environment`, `setup_camera_3d`, `add_gridmap`
|
|
||||||
|
|
||||||
#### Particles (5 tools)
|
|
||||||
- `create_particles`, `set_particle_material`, `set_particle_color_gradient`
|
|
||||||
- `get_particle_info`, `apply_particle_preset` (8 built-in presets: fire, smoke, sparks, rain, snow, explosion, magic, dust)
|
|
||||||
|
|
||||||
#### Navigation (5 tools)
|
|
||||||
- `setup_navigation_region`, `bake_navigation_mesh`, `setup_navigation_agent`
|
|
||||||
- `set_navigation_layers`, `get_navigation_info`
|
|
||||||
|
|
||||||
#### Audio (6 tools)
|
|
||||||
- `get_audio_bus_layout`, `add_audio_bus`, `set_audio_bus`
|
|
||||||
- `add_audio_bus_effect`, `add_audio_player`, `get_audio_info`
|
|
||||||
|
|
||||||
#### Testing & QA (5 tools)
|
|
||||||
- `run_test_scenario`, `assert_node_state`, `assert_screen_text`
|
|
||||||
- `run_stress_test`, `get_test_report`
|
|
||||||
|
|
||||||
#### Project Analysis (6 tools)
|
|
||||||
- `find_unused_resources`, `analyze_signal_flow`, `analyze_scene_complexity`
|
|
||||||
- `detect_circular_dependencies`, `get_scene_dependencies`, `get_project_statistics`
|
|
||||||
|
|
||||||
### Expanded: Runtime Analysis
|
|
||||||
- `find_ui_elements`, `click_button_by_text`, `wait_for_node`
|
|
||||||
- Runtime tools expanded from 4 to 15 tools
|
|
||||||
|
|
||||||
### Other Additions
|
|
||||||
- `add_resource`, `create_resource`, `edit_resource`, `read_resource` — Resource management tools
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.2.0 — 2026-02-24
|
|
||||||
|
|
||||||
**84 tools** across 14 categories (+34 new tools)
|
|
||||||
|
|
||||||
### New Tool Categories
|
|
||||||
|
|
||||||
#### Animation (6 tools)
|
|
||||||
- `list_animations`, `create_animation`, `add_animation_track`
|
|
||||||
- `set_animation_keyframe`, `get_animation_info`, `remove_animation`
|
|
||||||
|
|
||||||
#### TileMap (6 tools)
|
|
||||||
- `tilemap_set_cell`, `tilemap_fill_rect`, `tilemap_get_cell`
|
|
||||||
- `tilemap_clear`, `tilemap_get_info`, `tilemap_get_used_cells`
|
|
||||||
|
|
||||||
#### Theme & UI (6 tools)
|
|
||||||
- `create_theme`, `set_theme_color`, `set_theme_constant`
|
|
||||||
- `set_theme_font_size`, `set_theme_stylebox`, `get_theme_info`
|
|
||||||
|
|
||||||
#### Profiling (2 tools)
|
|
||||||
- `get_performance_monitors`, `get_editor_performance`
|
|
||||||
|
|
||||||
#### Batch Operations & Refactoring (5 tools)
|
|
||||||
- `find_nodes_by_type`, `find_signal_connections`, `batch_set_property`
|
|
||||||
- `find_node_references`, `get_scene_dependencies`
|
|
||||||
|
|
||||||
#### Shader (6 tools)
|
|
||||||
- `create_shader`, `read_shader`, `edit_shader`
|
|
||||||
- `assign_shader_material`, `set_shader_param`, `get_shader_params`
|
|
||||||
|
|
||||||
#### Export (3 tools)
|
|
||||||
- `list_export_presets`, `export_project`, `get_export_info`
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- Fixed game IPC connection when project name changes
|
|
||||||
- Added `set_project_setting` tool for safe project.godot modifications via EditorSettings API
|
|
||||||
- Fixed script reload behavior
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.1.0 — 2026-02-23
|
|
||||||
|
|
||||||
**49 tools** across 8 categories (+16 new tools)
|
|
||||||
|
|
||||||
### New Tool Categories
|
|
||||||
|
|
||||||
#### Input Simulation (4 tools)
|
|
||||||
- `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_sequence`
|
|
||||||
|
|
||||||
#### Runtime Analysis (4 tools)
|
|
||||||
- `play_scene`, `stop_scene`, `get_game_scene_tree`, `get_game_screenshot`
|
|
||||||
- `execute_game_script`, `get_game_node_properties`, `set_game_node_property`
|
|
||||||
- `monitor_properties`, `capture_frames`
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- Added `build-release.sh` for reproducible release packaging
|
|
||||||
- `start_recording` / `stop_recording` / `replay_recording` for input recording
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.0.0 — 2026-02-22
|
|
||||||
|
|
||||||
**~33 tools** across 6 categories — Initial release
|
|
||||||
|
|
||||||
### Tool Categories
|
|
||||||
- **Scene Management**: `create_scene`, `open_scene`, `save_scene`, `get_scene_tree`, `delete_scene`, `get_scene_file_content`, `add_scene_instance`
|
|
||||||
- **Node Operations**: `add_node`, `delete_node`, `rename_node`, `move_node`, `duplicate_node`, `update_property`, `get_node_properties`, `batch_get_properties`, `connect_signal`, `disconnect_signal`, `get_signals`
|
|
||||||
- **Script**: `create_script`, `read_script`, `edit_script`, `attach_script`, `list_scripts`, `find_nodes_by_script`, `find_script_references`, `get_open_scripts`
|
|
||||||
- **Editor**: `get_editor_screenshot`, `get_editor_errors`, `clear_output`, `execute_editor_script`, `reload_plugin`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `get_filesystem_tree`, `search_files`
|
|
||||||
- **UI**: Anchor presets (`set_anchor_preset`)
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- WebSocket-based communication between Godot editor plugin and MCP TypeScript server
|
|
||||||
- Supports Claude Code, Cursor, Windsurf, and any MCP-compatible AI coding tool
|
|
||||||
- Screenshot capture from both editor and game viewports
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Godot MCP Pro - Installation Guide
|
|
||||||
|
|
||||||
## What's in the zip
|
|
||||||
|
|
||||||
```
|
|
||||||
godot-mcp-pro/
|
|
||||||
├── addons/godot_mcp/ ← Godot plugin (copy into your Godot project)
|
|
||||||
├── server/ ← MCP server (keep anywhere, runs alongside Godot)
|
|
||||||
├── instructions/ ← AI client instruction files (optional)
|
|
||||||
├── INSTALL.md ← This file
|
|
||||||
├── README.md
|
|
||||||
├── CHANGELOG.md
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
The **addon** and **server** are two separate pieces:
|
|
||||||
- **Addon** → goes inside your Godot project
|
|
||||||
- **Server** → stays wherever you extracted it (does NOT go inside Godot)
|
|
||||||
|
|
||||||
## Step 1: Install the Godot Plugin
|
|
||||||
|
|
||||||
Copy the `addons/godot_mcp/` folder from the zip into your Godot project's `addons/` directory.
|
|
||||||
|
|
||||||
Enable the plugin in Godot:
|
|
||||||
**Project → Project Settings → Plugins → Godot MCP Pro → Enable**
|
|
||||||
|
|
||||||
You should see "MCP Pro" in the bottom panel with a green connection dot.
|
|
||||||
|
|
||||||
> **Note**: You do NOT need to download anything from the Godot Asset Library. The paid zip includes everything.
|
|
||||||
|
|
||||||
## Step 2: Build the MCP Server
|
|
||||||
|
|
||||||
The server requires **Node.js 18+**. Check with `node --version`.
|
|
||||||
|
|
||||||
Open a terminal and run from the `server/` directory inside the extracted zip:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/extracted/server
|
|
||||||
node build/setup.js install
|
|
||||||
```
|
|
||||||
|
|
||||||
This runs `npm install` (downloads dependencies) and `npm run build` (compiles TypeScript).
|
|
||||||
|
|
||||||
You can verify everything is working with:
|
|
||||||
```bash
|
|
||||||
node build/setup.js doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Configure Your AI Client
|
|
||||||
|
|
||||||
Run this from your **Godot project** directory (not the server directory):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/your/godot-project
|
|
||||||
node /path/to/extracted/server/build/setup.js configure
|
|
||||||
```
|
|
||||||
|
|
||||||
This auto-detects your AI client and creates a `.mcp.json` file in your project.
|
|
||||||
|
|
||||||
### Manual Configuration
|
|
||||||
|
|
||||||
If you prefer to configure manually, add this to your project's `.mcp.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"godot-mcp-pro": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/path/to/extracted/server/build/index.js"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `/path/to/extracted/` with the actual path where you extracted the zip.
|
|
||||||
|
|
||||||
### Claude Desktop
|
|
||||||
|
|
||||||
For Claude Desktop, add the same config to:
|
|
||||||
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
||||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
||||||
|
|
||||||
## Step 4: Use It
|
|
||||||
|
|
||||||
1. Open your Godot project with the plugin enabled
|
|
||||||
2. Start your AI client (Claude Code, Cursor, Cline, etc.) in your project directory
|
|
||||||
3. Ask the AI to interact with your Godot editor
|
|
||||||
|
|
||||||
The MCP Pro bottom panel in Godot shows connection status. A green dot means connected.
|
|
||||||
|
|
||||||
## Updating to a New Version
|
|
||||||
|
|
||||||
Check for updates:
|
|
||||||
```bash
|
|
||||||
node /path/to/server/build/setup.js check-update
|
|
||||||
```
|
|
||||||
|
|
||||||
To update:
|
|
||||||
1. Close Godot
|
|
||||||
2. Replace `addons/godot_mcp/` in your Godot project with the new version from the zip
|
|
||||||
3. Replace the `server/` folder with the new one, then rebuild:
|
|
||||||
```bash
|
|
||||||
cd /path/to/new/server
|
|
||||||
node build/setup.js install
|
|
||||||
```
|
|
||||||
4. Reopen Godot
|
|
||||||
|
|
||||||
Your `.mcp.json` configuration stays the same — no need to reconfigure.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **Plugin not connecting**: Make sure the MCP server is running (your AI client starts it automatically via `.mcp.json`)
|
|
||||||
- **"Godot editor is not connected" error**: This is usually caused by a stale `node.exe` process from a previous session holding the port. Open Task Manager, kill all `node.exe` processes, then restart your AI client.
|
|
||||||
- **Port conflict / `GODOT_MCP_PORT`**: Avoid setting a fixed `GODOT_MCP_PORT` in your config — the server auto-scans ports 6505–6509 and Godot connects to all of them automatically. A fixed port can cause silent failures if a stale process is already using it.
|
|
||||||
- **Bottom panel shows "Waiting for connection"**: Start your AI client — it launches the MCP server which connects to Godot
|
|
||||||
- **Need help?**: Contact abyo.software@gmail.com or join [Discord](https://discord.gg/zJ2u5zNUBZ)
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- Landing page & tool reference: https://godot-mcp.abyo.net
|
|
||||||
- Full tool list: `README.md`
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
Godot MCP Pro - Proprietary License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Godot MCP Pro. All rights reserved.
|
|
||||||
|
|
||||||
This software and associated documentation files (the "Software") are the
|
|
||||||
proprietary property of the copyright holder.
|
|
||||||
|
|
||||||
1. GRANT OF LICENSE
|
|
||||||
Upon purchase, you are granted a non-exclusive, non-transferable,
|
|
||||||
perpetual license to:
|
|
||||||
- Use the Software for personal and commercial game development projects
|
|
||||||
- Install the Software on any number of your own machines
|
|
||||||
- Use the Software in any number of your own projects
|
|
||||||
|
|
||||||
2. RESTRICTIONS
|
|
||||||
You may NOT:
|
|
||||||
- Redistribute, sublicense, sell, or share the Software or any portion
|
|
||||||
of its source code
|
|
||||||
- Modify and redistribute the Software as a competing product
|
|
||||||
- Remove or alter any copyright notices or license information
|
|
||||||
- Share your license key or purchased files with others
|
|
||||||
|
|
||||||
3. UPDATES
|
|
||||||
Your purchase includes lifetime access to updates for the version you
|
|
||||||
purchased (1.x). Major version upgrades (2.x, 3.x) may require a
|
|
||||||
separate purchase or upgrade fee.
|
|
||||||
|
|
||||||
4. NO WARRANTY
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
5. TERMINATION
|
|
||||||
This license is automatically terminated if you violate any of these
|
|
||||||
restrictions. Upon termination, you must destroy all copies of the
|
|
||||||
Software in your possession.
|
|
||||||
|
|
||||||
For questions about licensing, contact: abyo.software@gmail.com
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
# Godot MCP Pro
|
|
||||||
|
|
||||||
Premium MCP (Model Context Protocol) server for AI-powered Godot game development. Connects AI assistants like Claude directly to your Godot editor with **172 powerful tools**.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
AI Assistant ←—stdio/MCP—→ Node.js Server ←—WebSocket:6505—→ Godot Editor Plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Real-time**: WebSocket connection means instant feedback, no file polling
|
|
||||||
- **Editor Integration**: Full access to Godot's editor API, UndoRedo system, and scene tree
|
|
||||||
- **JSON-RPC 2.0**: Standard protocol with proper error codes and suggestions
|
|
||||||
|
|
||||||
## What's in this repo
|
|
||||||
|
|
||||||
> ⚠️ **This public repo only contains the free Godot addon/plugin.** The MCP server (Node.js, required to connect AI assistants) is distributed as part of the paid package — **one-time purchase**, lifetime updates:
|
|
||||||
>
|
|
||||||
> - **Buy Me a Coffee**: <https://buymeacoffee.com/y1uda/extras>
|
|
||||||
> - **itch.io**: <https://y1uda.itch.io/godot-mcp-pro>
|
|
||||||
>
|
|
||||||
> The paid zip includes the addon, the `server/` directory with pre-built JavaScript, `INSTALL.md`, and AI-client instructions. If you cloned this repo and don't see a `server/` folder, **that's expected** — grab the full package from one of the links above.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Install the Godot Plugin
|
|
||||||
|
|
||||||
Copy the `addons/godot_mcp/` folder into your Godot project's `addons/` directory.
|
|
||||||
|
|
||||||
Enable the plugin: **Project → Project Settings → Plugins → Godot MCP Pro → Enable**
|
|
||||||
|
|
||||||
### 2. Install the MCP Server
|
|
||||||
|
|
||||||
> The `server/` directory is only included in the **full paid package** (see above). After downloading and extracting the zip, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Configure Claude Code
|
|
||||||
|
|
||||||
Add to your `.mcp.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"godot-mcp-pro": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/path/to/server/build/index.js"],
|
|
||||||
"env": {
|
|
||||||
"GODOT_MCP_PORT": "6505"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Choose Your Mode
|
|
||||||
|
|
||||||
Godot MCP Pro offers four modes to fit any client's tool limit:
|
|
||||||
|
|
||||||
| Mode | Tools | Best For |
|
|
||||||
|------|-------|----------|
|
|
||||||
| **Full** (default) | 172 | Claude Code, Cline, VS Code Copilot, Cursor |
|
|
||||||
| **3D** (`--3d`) | 100 | Antigravity and other 100-tool-limit clients needing 3D |
|
|
||||||
| **Lite** (`--lite`) | 81 | Windsurf, JetBrains Junie, Gemini CLI |
|
|
||||||
| **Minimal** (`--minimal`) | 35 | OpenCode, local LLMs with small context |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"godot-mcp-pro": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/path/to/server/build/index.js", "--lite"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `--lite` with `--minimal` for the smallest footprint.
|
|
||||||
|
|
||||||
- **Lite** includes: project, scene, node, script, editor, input, runtime, and input_map tools.
|
|
||||||
- **Minimal** includes: 35 essential tools — project info, scene management, node CRUD, script editing, editor errors, input simulation, and runtime inspection.
|
|
||||||
|
|
||||||
### 5. CLI Mode (Alternative to MCP)
|
|
||||||
|
|
||||||
For clients without MCP support, or when you want zero context overhead, use the CLI directly from a terminal/bash tool. The CLI requires the server to be built first (Step 2).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Top-level help — shows all command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Group help — shows commands in a group
|
|
||||||
node /path/to/server/build/cli.js node --help
|
|
||||||
|
|
||||||
# Command help — shows options for a command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `/path/to/` with the actual path where you extracted the files.
|
|
||||||
|
|
||||||
The CLI connects directly to the Godot editor plugin via WebSocket. It requires:
|
|
||||||
- Godot editor running with the MCP plugin enabled
|
|
||||||
- Server built (`node build/setup.js install`)
|
|
||||||
- An available port in the 6510-6514 range
|
|
||||||
|
|
||||||
**Advantage**: LLMs discover capabilities progressively via `--help` instead of loading all tool definitions upfront. This works with any LLM client that has terminal access, regardless of tool count limits.
|
|
||||||
|
|
||||||
### 6. Client Compatibility
|
|
||||||
|
|
||||||
| Client | Recommended Mode | Notes |
|
|
||||||
|--------|-----------------|-------|
|
|
||||||
| Claude Code | Full (default) | Deferred tool loading — minimal context cost |
|
|
||||||
| VS Code Copilot | Full | Virtual Tools auto-group tools |
|
|
||||||
| OpenAI Codex CLI | Full | MCPSearch defers overflow |
|
|
||||||
| Cline | Full | No hard limit; use `enabledTools` to whitelist |
|
|
||||||
| Roo Code | Full | No hard limit |
|
|
||||||
| Windsurf | Lite | 100 tool limit |
|
|
||||||
| JetBrains Junie | Lite | 100 tool limit |
|
|
||||||
| Gemini CLI | Lite | ~100 client limit; use `excludeTools` for finer control |
|
|
||||||
| Cursor | Full | Tool limit removed (Dynamic Context Discovery) |
|
|
||||||
| OpenCode | Minimal or CLI | Models degrade past ~40 tools |
|
|
||||||
| Local LLMs (LM Studio, etc.) | Minimal or CLI | Context window is the bottleneck |
|
|
||||||
|
|
||||||
### 7. Use It
|
|
||||||
|
|
||||||
Open your Godot project with the plugin enabled, then use Claude Code to interact with the editor.
|
|
||||||
|
|
||||||
## All 172 Tools
|
|
||||||
|
|
||||||
### Project Tools (7)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `get_project_info` | Project metadata, version, viewport, autoloads |
|
|
||||||
| `get_filesystem_tree` | Recursive file tree with filtering |
|
|
||||||
| `search_files` | Fuzzy/glob file search |
|
|
||||||
| `get_project_settings` | Read project.godot settings |
|
|
||||||
| `set_project_setting` | Set project settings via editor API |
|
|
||||||
| `uid_to_project_path` | UID → res:// conversion |
|
|
||||||
| `project_path_to_uid` | res:// → UID conversion |
|
|
||||||
|
|
||||||
### Scene Tools (9)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `get_scene_tree` | Live scene tree with hierarchy |
|
|
||||||
| `get_scene_file_content` | Raw .tscn file content |
|
|
||||||
| `create_scene` | Create new scene files |
|
|
||||||
| `open_scene` | Open scene in editor |
|
|
||||||
| `delete_scene` | Delete scene file |
|
|
||||||
| `add_scene_instance` | Instance scene as child node |
|
|
||||||
| `play_scene` | Run scene (main/current/custom) |
|
|
||||||
| `stop_scene` | Stop running scene |
|
|
||||||
| `save_scene` | Save current scene to disk |
|
|
||||||
|
|
||||||
### Node Tools (14)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `add_node` | Add node with type and properties |
|
|
||||||
| `delete_node` | Delete node (with undo support) |
|
|
||||||
| `duplicate_node` | Duplicate node and children |
|
|
||||||
| `move_node` | Move/reparent node |
|
|
||||||
| `update_property` | Set any property (auto type parsing) |
|
|
||||||
| `get_node_properties` | Get all node properties |
|
|
||||||
| `add_resource` | Add Shape/Material/etc to node |
|
|
||||||
| `set_anchor_preset` | Set Control anchor preset |
|
|
||||||
| `rename_node` | Rename a node in the scene |
|
|
||||||
| `connect_signal` | Connect signal between nodes |
|
|
||||||
| `disconnect_signal` | Disconnect signal connection |
|
|
||||||
| `get_node_groups` | Get groups a node belongs to |
|
|
||||||
| `set_node_groups` | Set node group membership |
|
|
||||||
| `find_nodes_in_group` | Find all nodes in a group |
|
|
||||||
|
|
||||||
### Script Tools (8)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `list_scripts` | List all scripts with class info |
|
|
||||||
| `read_script` | Read script content |
|
|
||||||
| `create_script` | Create new script with template |
|
|
||||||
| `edit_script` | Search/replace or full edit |
|
|
||||||
| `attach_script` | Attach script to node |
|
|
||||||
| `get_open_scripts` | List scripts open in editor |
|
|
||||||
| `validate_script` | Validate GDScript syntax |
|
|
||||||
| `search_in_files` | Search content in project files |
|
|
||||||
|
|
||||||
### Editor Tools (9)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `get_editor_errors` | Get errors and stack traces |
|
|
||||||
| `get_editor_screenshot` | Capture editor viewport |
|
|
||||||
| `get_game_screenshot` | Capture running game |
|
|
||||||
| `execute_editor_script` | Run arbitrary GDScript in editor |
|
|
||||||
| `clear_output` | Clear output panel |
|
|
||||||
| `get_signals` | Get all signals of a node with connections |
|
|
||||||
| `reload_plugin` | Reload the MCP plugin (auto-reconnect) |
|
|
||||||
| `reload_project` | Rescan filesystem and reload scripts |
|
|
||||||
| `get_output_log` | Get output panel content |
|
|
||||||
|
|
||||||
### Input Tools (7)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `simulate_key` | Simulate keyboard key press/release |
|
|
||||||
| `simulate_mouse_click` | Simulate mouse click at position |
|
|
||||||
| `simulate_mouse_move` | Simulate mouse movement |
|
|
||||||
| `simulate_action` | Simulate Godot Input Action |
|
|
||||||
| `simulate_sequence` | Sequence of input events with frame delays |
|
|
||||||
| `get_input_actions` | List all input actions |
|
|
||||||
| `set_input_action` | Create/modify input action |
|
|
||||||
|
|
||||||
### Runtime Tools (19)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `get_game_scene_tree` | Scene tree of running game |
|
|
||||||
| `get_game_node_properties` | Node properties in running game |
|
|
||||||
| `set_game_node_property` | Set node property in running game |
|
|
||||||
| `execute_game_script` | Run GDScript in game context |
|
|
||||||
| `capture_frames` | Multi-frame screenshot capture |
|
|
||||||
| `monitor_properties` | Record property values over time |
|
|
||||||
| `start_recording` | Start input recording |
|
|
||||||
| `stop_recording` | Stop input recording |
|
|
||||||
| `replay_recording` | Replay recorded input |
|
|
||||||
| `find_nodes_by_script` | Find game nodes by script |
|
|
||||||
| `get_autoload` | Get autoload node properties |
|
|
||||||
| `batch_get_properties` | Batch get multiple node properties |
|
|
||||||
| `find_ui_elements` | Find UI elements in game |
|
|
||||||
| `click_button_by_text` | Click button by text content |
|
|
||||||
| `wait_for_node` | Wait for node to appear |
|
|
||||||
| `find_nearby_nodes` | Find nodes near position |
|
|
||||||
| `navigate_to` | Navigate to target position |
|
|
||||||
| `move_to` | Walk character to target |
|
|
||||||
|
|
||||||
### Animation Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `list_animations` | List all animations in AnimationPlayer |
|
|
||||||
| `create_animation` | Create new animation |
|
|
||||||
| `add_animation_track` | Add track (value/position/rotation/method/bezier) |
|
|
||||||
| `set_animation_keyframe` | Insert keyframe into track |
|
|
||||||
| `get_animation_info` | Detailed animation info with all tracks/keys |
|
|
||||||
| `remove_animation` | Remove an animation |
|
|
||||||
|
|
||||||
### TileMap Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `tilemap_set_cell` | Set a single tile cell |
|
|
||||||
| `tilemap_fill_rect` | Fill rectangular region with tiles |
|
|
||||||
| `tilemap_get_cell` | Get tile data at cell |
|
|
||||||
| `tilemap_clear` | Clear all cells |
|
|
||||||
| `tilemap_get_info` | TileMapLayer info and tile set sources |
|
|
||||||
| `tilemap_get_used_cells` | List of used cells |
|
|
||||||
|
|
||||||
### Theme & UI Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `create_theme` | Create Theme resource file |
|
|
||||||
| `set_theme_color` | Set theme color override |
|
|
||||||
| `set_theme_constant` | Set theme constant override |
|
|
||||||
| `set_theme_font_size` | Set theme font size override |
|
|
||||||
| `set_theme_stylebox` | Set StyleBoxFlat override |
|
|
||||||
| `get_theme_info` | Get theme overrides info |
|
|
||||||
|
|
||||||
### Profiling Tools (2)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `get_performance_monitors` | All performance monitors (FPS, memory, physics, etc.) |
|
|
||||||
| `get_editor_performance` | Quick performance summary |
|
|
||||||
|
|
||||||
### Batch & Refactoring Tools (8)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `find_nodes_by_type` | Find all nodes of a type |
|
|
||||||
| `find_signal_connections` | Find all signal connections in scene |
|
|
||||||
| `batch_set_property` | Set property on all nodes of a type |
|
|
||||||
| `find_node_references` | Search project files for pattern |
|
|
||||||
| `get_scene_dependencies` | Get resource dependencies |
|
|
||||||
| `cross_scene_set_property` | Set property across all scenes |
|
|
||||||
| `find_script_references` | Find where script/resource is used |
|
|
||||||
| `detect_circular_dependencies` | Find circular scene dependencies |
|
|
||||||
|
|
||||||
### Shader Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `create_shader` | Create shader with template |
|
|
||||||
| `read_shader` | Read shader file |
|
|
||||||
| `edit_shader` | Edit shader (replace/search-replace) |
|
|
||||||
| `assign_shader_material` | Assign ShaderMaterial to node |
|
|
||||||
| `set_shader_param` | Set shader parameter |
|
|
||||||
| `get_shader_params` | Get all shader parameters |
|
|
||||||
|
|
||||||
### Export Tools (3)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `list_export_presets` | List export presets |
|
|
||||||
| `export_project` | Get export command for preset |
|
|
||||||
| `get_export_info` | Export-related project info |
|
|
||||||
|
|
||||||
### Resource Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `read_resource` | Read .tres resource properties |
|
|
||||||
| `edit_resource` | Edit resource properties |
|
|
||||||
| `create_resource` | Create new .tres resource |
|
|
||||||
| `get_resource_preview` | Get resource thumbnail |
|
|
||||||
| `add_autoload` | Register autoload singleton |
|
|
||||||
| `remove_autoload` | Remove autoload singleton |
|
|
||||||
|
|
||||||
### Physics Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `setup_physics_body` | Configure physics body properties |
|
|
||||||
| `setup_collision` | Add collision shapes to nodes |
|
|
||||||
| `set_physics_layers` | Set collision layer/mask |
|
|
||||||
| `get_physics_layers` | Get collision layer/mask info |
|
|
||||||
| `get_collision_info` | Get collision shape details |
|
|
||||||
| `add_raycast` | Add RayCast2D/3D node |
|
|
||||||
|
|
||||||
### 3D Scene Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `add_mesh_instance` | Add MeshInstance3D with primitive mesh |
|
|
||||||
| `setup_camera_3d` | Configure Camera3D properties |
|
|
||||||
| `setup_lighting` | Add/configure light nodes |
|
|
||||||
| `setup_environment` | Configure WorldEnvironment |
|
|
||||||
| `add_gridmap` | Set up GridMap node |
|
|
||||||
| `set_material_3d` | Set StandardMaterial3D properties |
|
|
||||||
|
|
||||||
### Particle Tools (5)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `create_particles` | Create GPUParticles2D/3D |
|
|
||||||
| `set_particle_material` | Configure ParticleProcessMaterial |
|
|
||||||
| `set_particle_color_gradient` | Set color gradient for particles |
|
|
||||||
| `apply_particle_preset` | Apply preset (fire, smoke, sparks, etc.) |
|
|
||||||
| `get_particle_info` | Get particle system details |
|
|
||||||
|
|
||||||
### Navigation Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `setup_navigation_region` | Configure NavigationRegion |
|
|
||||||
| `setup_navigation_agent` | Configure NavigationAgent |
|
|
||||||
| `bake_navigation_mesh` | Bake navigation mesh |
|
|
||||||
| `set_navigation_layers` | Set navigation layers |
|
|
||||||
| `get_navigation_info` | Get navigation setup info |
|
|
||||||
|
|
||||||
### Audio Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `add_audio_player` | Add AudioStreamPlayer node |
|
|
||||||
| `add_audio_bus` | Add audio bus |
|
|
||||||
| `add_audio_bus_effect` | Add effect to audio bus |
|
|
||||||
| `set_audio_bus` | Configure audio bus properties |
|
|
||||||
| `get_audio_bus_layout` | Get audio bus layout info |
|
|
||||||
| `get_audio_info` | Get audio-related node info |
|
|
||||||
|
|
||||||
### AnimationTree Tools (4)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `create_animation_tree` | Create AnimationTree |
|
|
||||||
| `get_animation_tree_structure` | Get tree structure |
|
|
||||||
| `set_tree_parameter` | Set AnimationTree parameter |
|
|
||||||
| `add_state_machine_state` | Add state to state machine |
|
|
||||||
|
|
||||||
### State Machine Tools (3)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `remove_state_machine_state` | Remove state from state machine |
|
|
||||||
| `add_state_machine_transition` | Add transition between states |
|
|
||||||
| `remove_state_machine_transition` | Remove state transition |
|
|
||||||
|
|
||||||
### Blend Tree Tools (1)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `set_blend_tree_node` | Configure blend tree nodes |
|
|
||||||
|
|
||||||
### Analysis & Search Tools (4)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `analyze_scene_complexity` | Analyze scene performance |
|
|
||||||
| `analyze_signal_flow` | Map signal connections |
|
|
||||||
| `find_unused_resources` | Find unreferenced resources |
|
|
||||||
| `get_project_statistics` | Get project-wide statistics |
|
|
||||||
|
|
||||||
### Testing & QA Tools (6)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `run_test_scenario` | Run automated test scenario |
|
|
||||||
| `assert_node_state` | Assert node property values |
|
|
||||||
| `assert_screen_text` | Check for text on screen |
|
|
||||||
| `compare_screenshots` | Compare two screenshots |
|
|
||||||
| `run_stress_test` | Run performance stress test |
|
|
||||||
| `get_test_report` | Get test results report |
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- **UndoRedo Integration**: All node/property operations support Ctrl+Z
|
|
||||||
- **Smart Type Parsing**: `"Vector2(100, 200)"`, `"#ff0000"`, `"Color(1,0,0)"` auto-converted
|
|
||||||
- **Auto-Reconnect**: Exponential backoff reconnection (1s → 2s → 4s ... → 60s max)
|
|
||||||
- **Heartbeat**: 10s ping/pong keeps connection alive
|
|
||||||
- **Helpful Errors**: Error responses include suggestions for next steps
|
|
||||||
|
|
||||||
## Competitive Comparison
|
|
||||||
|
|
||||||
### Tool Count
|
|
||||||
|
|
||||||
| Category | Godot MCP Pro | GDAI MCP ($19) | tomyud1 (free) | Dokujaa (free) | Coding-Solo (free) | ee0pdt (free) | bradypp (free) |
|
|
||||||
|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| Project | 7 | 5 | 4 | 0 | 2 | 2 | 2 |
|
|
||||||
| Scene | 9 | 8 | 11 | 9 | 3 | 4 | 5 |
|
|
||||||
| Node | **14** | 8 | 0 | 8 | 2 | 3 | 0 |
|
|
||||||
| Script | **8** | 5 | 6 | 4 | 0 | 5 | 0 |
|
|
||||||
| Editor | **9** | 5 | 1 | 5 | 1 | 3 | 2 |
|
|
||||||
| Input | **7** | 2 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Runtime | **19** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Animation | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| TileMap | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Theme/UI | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Profiling | **2** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Batch/Refactor | **8** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Shader | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Export | **3** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Resource | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Physics | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| 3D Scene | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Particle | **5** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Navigation | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Audio | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| AnimationTree | **4** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| State Machine | **3** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Blend Tree | **1** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Analysis | **4** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Testing/QA | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| Asset/AI | 0 | 0 | 1 | 6 | 0 | 0 | 0 |
|
|
||||||
| Material | 0 | 0 | 0 | 2 | 0 | 0 | 0 |
|
|
||||||
| Other | 0 | 0 | 9 | 5 | 5 | 2 | 1 |
|
|
||||||
| Android Deploy | **3** | 0 | 0 | 0 | 0 | 0 | 0 |
|
|
||||||
| **Total** | **172** | ~30 | **32** | **39** | **13** | **19** | **10** |
|
|
||||||
|
|
||||||
### Feature Matrix
|
|
||||||
|
|
||||||
| Feature | Godot MCP Pro | GDAI MCP ($19) | tomyud1 (free) | Dokujaa (free) | Coding-Solo (free) |
|
|
||||||
|---------|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| **Connection** | WebSocket (real-time) | stdio (Python) | WebSocket | TCP Socket | Headless CLI |
|
|
||||||
| **Undo/Redo** | Yes | Yes | No | No | No |
|
|
||||||
| **JSON-RPC 2.0** | Yes | Custom | Custom | Custom | N/A |
|
|
||||||
| **Auto-reconnect** | Yes (exponential backoff) | N/A | No | No | N/A |
|
|
||||||
| **Heartbeat** | Yes (10s ping/pong) | No | No | No | No |
|
|
||||||
| **Error suggestions** | Yes (contextual hints) | No | No | No | No |
|
|
||||||
| **Screenshot capture** | Yes (editor + game) | Yes | No | No | No |
|
|
||||||
| **Game input simulation** | Yes (key/mouse/action/sequence) | Yes (basic) | No | No | No |
|
|
||||||
| **Runtime inspection** | Yes (scene tree + properties + monitor) | No | No | No | No |
|
|
||||||
| **Signal management** | Yes (connect/disconnect/inspect) | No | No | No | No |
|
|
||||||
| **Browser visualizer** | No | No | Yes | No | No |
|
|
||||||
| **AI 3D mesh generation** | No | No | No | Yes (Meshy API) | No |
|
|
||||||
|
|
||||||
### Exclusive Categories (No Competitor Has These)
|
|
||||||
|
|
||||||
| Category | Tools | Why It Matters |
|
|
||||||
|----------|-------|----------------|
|
|
||||||
| **Animation** | 6 tools | Create animations, add tracks, set keyframes — all programmatically |
|
|
||||||
| **TileMap** | 6 tools | Set cells, fill rects, query tile data — essential for 2D level design |
|
|
||||||
| **Theme/UI** | 6 tools | StyleBox, colors, fonts — build UI themes without manual editor work |
|
|
||||||
| **Profiling** | 2 tools | FPS, memory, draw calls, physics — performance monitoring |
|
|
||||||
| **Batch/Refactor** | 8 tools | Find by type, batch property changes, cross-scene updates, dependency analysis |
|
|
||||||
| **Shader** | 6 tools | Create/edit shaders, assign materials, set parameters |
|
|
||||||
| **Export** | 3 tools | List presets, get export commands, check templates |
|
|
||||||
| **Physics** | 6 tools | Set up collision shapes, bodies, raycasts, and layer management |
|
|
||||||
| **3D Scene** | 6 tools | Add meshes, cameras, lights, environment, GridMap support |
|
|
||||||
| **Particle** | 5 tools | Create particles with custom materials, presets, and gradients |
|
|
||||||
| **Navigation** | 6 tools | Configure navigation regions, agents, pathfinding, baking |
|
|
||||||
| **Audio** | 6 tools | Complete audio bus system, effects, players, live management |
|
|
||||||
| **AnimationTree** | 4 tools | Build state machines with transitions and blend trees |
|
|
||||||
| **State Machine** | 3 tools | Advanced state machine management for complex animations |
|
|
||||||
| **Testing/QA** | 6 tools | Automated testing, assertions, stress testing, screenshot comparison |
|
|
||||||
| **Runtime** | 19 tools | Inspect and control game at runtime: inspect, record, replay, navigate |
|
|
||||||
|
|
||||||
### Architecture Advantages
|
|
||||||
|
|
||||||
| Aspect | Godot MCP Pro | Typical Competitor |
|
|
||||||
|--------|--------------|-------------------|
|
|
||||||
| **Protocol** | JSON-RPC 2.0 (standard, extensible) | Custom JSON or CLI-based |
|
|
||||||
| **Connection** | Persistent WebSocket with heartbeat | Per-command subprocess or raw TCP |
|
|
||||||
| **Reliability** | Auto-reconnect with exponential backoff (1s→60s) | Manual reconnection required |
|
|
||||||
| **Type Safety** | Smart type parsing (Vector2, Color, Rect2, hex colors) | String-only or limited types |
|
|
||||||
| **Error Handling** | Structured errors with codes + suggestions | Generic error messages |
|
|
||||||
| **Undo Support** | All mutations go through UndoRedo system | Direct modifications (no undo) |
|
|
||||||
| **Port Management** | Auto-scan ports 6505-6509 | Fixed port, conflicts possible |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Proprietary — see [LICENSE](LICENSE) for details. Purchase includes lifetime updates.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
@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)
|
|
||||||
-518
@@ -1,518 +0,0 @@
|
|||||||
@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
|
|
||||||
-192
@@ -1,192 +0,0 @@
|
|||||||
@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,
|
|
||||||
})
|
|
||||||
-298
@@ -1,298 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"list_animations": _list_animations,
|
|
||||||
"create_animation": _create_animation,
|
|
||||||
"add_animation_track": _add_animation_track,
|
|
||||||
"set_animation_keyframe": _set_animation_keyframe,
|
|
||||||
"get_animation_info": _get_animation_info,
|
|
||||||
"remove_animation": _remove_animation,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _find_animation_player(node_path: String) -> AnimationPlayer:
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node is AnimationPlayer:
|
|
||||||
return node as AnimationPlayer
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _list_animations(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var player := _find_animation_player(node_path)
|
|
||||||
if player == null:
|
|
||||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
|
||||||
|
|
||||||
var animations: Array = []
|
|
||||||
for anim_name in player.get_animation_list():
|
|
||||||
var anim := player.get_animation(anim_name)
|
|
||||||
animations.append({
|
|
||||||
"name": anim_name,
|
|
||||||
"length": anim.length,
|
|
||||||
"loop_mode": anim.loop_mode,
|
|
||||||
"track_count": anim.get_track_count(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "animations": animations, "count": animations.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _create_animation(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var anim_name: String = result2[0]
|
|
||||||
|
|
||||||
var player := _find_animation_player(node_path)
|
|
||||||
if player == null:
|
|
||||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
|
||||||
|
|
||||||
var length: float = float(params.get("length", 1.0))
|
|
||||||
var loop_mode: int = int(params.get("loop_mode", 0)) # 0=none, 1=linear, 2=pingpong
|
|
||||||
|
|
||||||
var anim := Animation.new()
|
|
||||||
anim.length = length
|
|
||||||
anim.loop_mode = loop_mode as Animation.LoopMode
|
|
||||||
|
|
||||||
var lib := player.get_animation_library("")
|
|
||||||
var created_library := false
|
|
||||||
if lib == null:
|
|
||||||
lib = AnimationLibrary.new()
|
|
||||||
created_library = true
|
|
||||||
|
|
||||||
if lib.has_animation(anim_name):
|
|
||||||
return error_invalid_params("Animation '%s' already exists" % anim_name)
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Create animation %s" % anim_name)
|
|
||||||
if created_library:
|
|
||||||
undo_redo.add_do_method(player, "add_animation_library", "", lib)
|
|
||||||
undo_redo.add_do_reference(lib)
|
|
||||||
undo_redo.add_undo_method(player, "remove_animation_library", "")
|
|
||||||
undo_redo.add_do_method(lib, "add_animation", anim_name, anim)
|
|
||||||
undo_redo.add_do_reference(anim)
|
|
||||||
undo_redo.add_undo_method(lib, "remove_animation", anim_name)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"name": anim_name, "length": length, "created": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _add_animation_track(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "animation")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var anim_name: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "track_path")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var track_path: String = result3[0]
|
|
||||||
|
|
||||||
var player := _find_animation_player(node_path)
|
|
||||||
if player == null:
|
|
||||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
|
||||||
|
|
||||||
var anim := player.get_animation(anim_name)
|
|
||||||
if anim == null:
|
|
||||||
return error_not_found("Animation '%s'" % anim_name)
|
|
||||||
|
|
||||||
var track_type_str: String = optional_string(params, "track_type", "value")
|
|
||||||
var track_type: int
|
|
||||||
match track_type_str:
|
|
||||||
"value": track_type = Animation.TYPE_VALUE
|
|
||||||
"position_2d": track_type = Animation.TYPE_POSITION_3D # Godot uses 3D type for 2D too
|
|
||||||
"rotation_2d": track_type = Animation.TYPE_ROTATION_3D
|
|
||||||
"scale_2d": track_type = Animation.TYPE_SCALE_3D
|
|
||||||
"method": track_type = Animation.TYPE_METHOD
|
|
||||||
"bezier": track_type = Animation.TYPE_BEZIER
|
|
||||||
"blend_shape": track_type = Animation.TYPE_BLEND_SHAPE
|
|
||||||
_: track_type = Animation.TYPE_VALUE
|
|
||||||
|
|
||||||
var track_idx := anim.get_track_count()
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Add animation track")
|
|
||||||
undo_redo.add_do_method(anim, "add_track", track_type, track_idx)
|
|
||||||
undo_redo.add_do_method(anim, "track_set_path", track_idx, NodePath(track_path))
|
|
||||||
|
|
||||||
var update_mode_str: String = optional_string(params, "update_mode", "")
|
|
||||||
if not update_mode_str.is_empty() and track_type == Animation.TYPE_VALUE:
|
|
||||||
match update_mode_str:
|
|
||||||
"continuous": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CONTINUOUS)
|
|
||||||
"discrete": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_DISCRETE)
|
|
||||||
"capture": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CAPTURE)
|
|
||||||
undo_redo.add_undo_method(anim, "remove_track", track_idx)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"track_index": track_idx, "track_path": track_path, "track_type": track_type_str})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_animation_keyframe(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "animation")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var anim_name: String = result2[0]
|
|
||||||
|
|
||||||
var player := _find_animation_player(node_path)
|
|
||||||
if player == null:
|
|
||||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
|
||||||
|
|
||||||
var anim := player.get_animation(anim_name)
|
|
||||||
if anim == null:
|
|
||||||
return error_not_found("Animation '%s'" % anim_name)
|
|
||||||
|
|
||||||
var track_index: int = int(params.get("track_index", 0))
|
|
||||||
if track_index < 0 or track_index >= anim.get_track_count():
|
|
||||||
return error_invalid_params("Invalid track_index: %d" % track_index)
|
|
||||||
|
|
||||||
var time: float = float(params.get("time", 0.0))
|
|
||||||
var value = params.get("value")
|
|
||||||
|
|
||||||
# Parse value string for common types
|
|
||||||
if value is String:
|
|
||||||
var s: String = value
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(s) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed != null:
|
|
||||||
value = parsed
|
|
||||||
|
|
||||||
var easing: float = float(params.get("easing", 1.0))
|
|
||||||
var old_key_idx := _find_animation_key_at_time(anim, track_index, time)
|
|
||||||
var had_old_key := old_key_idx >= 0
|
|
||||||
var old_value: Variant = anim.track_get_key_value(track_index, old_key_idx) if had_old_key else null
|
|
||||||
var old_easing: float = anim.track_get_key_transition(track_index, old_key_idx) if had_old_key else 1.0
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set animation keyframe")
|
|
||||||
undo_redo.add_do_method(self, "_upsert_animation_key", anim, track_index, time, value, easing)
|
|
||||||
undo_redo.add_undo_method(self, "_restore_animation_key", anim, track_index, time, had_old_key, old_value, old_easing)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
var key_idx := _find_animation_key_at_time(anim, track_index, time)
|
|
||||||
|
|
||||||
return success({"track_index": track_index, "time": time, "key_index": key_idx, "easing": anim.track_get_key_transition(track_index, key_idx)})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_animation_info(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "animation")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var anim_name: String = result2[0]
|
|
||||||
|
|
||||||
var player := _find_animation_player(node_path)
|
|
||||||
if player == null:
|
|
||||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
|
||||||
|
|
||||||
var anim := player.get_animation(anim_name)
|
|
||||||
if anim == null:
|
|
||||||
return error_not_found("Animation '%s'" % anim_name)
|
|
||||||
|
|
||||||
var tracks: Array = []
|
|
||||||
for i in anim.get_track_count():
|
|
||||||
var track_info := {
|
|
||||||
"index": i,
|
|
||||||
"path": str(anim.track_get_path(i)),
|
|
||||||
"type": anim.track_get_type(i),
|
|
||||||
"key_count": anim.track_get_key_count(i),
|
|
||||||
}
|
|
||||||
var keys: Array = []
|
|
||||||
for k in anim.track_get_key_count(i):
|
|
||||||
keys.append({
|
|
||||||
"time": anim.track_get_key_time(i, k),
|
|
||||||
"value": str(anim.track_get_key_value(i, k)),
|
|
||||||
"easing": anim.track_get_key_transition(i, k),
|
|
||||||
})
|
|
||||||
track_info["keys"] = keys
|
|
||||||
tracks.append(track_info)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"name": anim_name,
|
|
||||||
"length": anim.length,
|
|
||||||
"loop_mode": anim.loop_mode,
|
|
||||||
"step": anim.step,
|
|
||||||
"tracks": tracks,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _remove_animation(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var anim_name: String = result2[0]
|
|
||||||
|
|
||||||
var player := _find_animation_player(node_path)
|
|
||||||
if player == null:
|
|
||||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
|
||||||
|
|
||||||
var lib := player.get_animation_library("")
|
|
||||||
if lib == null or not lib.has_animation(anim_name):
|
|
||||||
return error_not_found("Animation '%s'" % anim_name)
|
|
||||||
|
|
||||||
var anim := lib.get_animation(anim_name)
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Remove animation %s" % anim_name)
|
|
||||||
undo_redo.add_do_method(lib, "remove_animation", anim_name)
|
|
||||||
undo_redo.add_undo_method(lib, "add_animation", anim_name, anim)
|
|
||||||
undo_redo.add_undo_reference(anim)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
return success({"name": anim_name, "removed": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _find_animation_key_at_time(anim: Animation, track_index: int, time: float) -> int:
|
|
||||||
for key_index: int in anim.track_get_key_count(track_index):
|
|
||||||
if is_equal_approx(anim.track_get_key_time(track_index, key_index), time):
|
|
||||||
return key_index
|
|
||||||
return -1
|
|
||||||
|
|
||||||
|
|
||||||
func _upsert_animation_key(anim: Animation, track_index: int, time: float, value: Variant, easing: float) -> void:
|
|
||||||
var key_idx := _find_animation_key_at_time(anim, track_index, time)
|
|
||||||
if key_idx < 0:
|
|
||||||
key_idx = anim.track_insert_key(track_index, time, value)
|
|
||||||
else:
|
|
||||||
anim.track_set_key_value(track_index, key_idx, value)
|
|
||||||
if easing != 1.0:
|
|
||||||
anim.track_set_key_transition(track_index, key_idx, easing)
|
|
||||||
|
|
||||||
|
|
||||||
func _restore_animation_key(anim: Animation, track_index: int, time: float, had_old_key: bool, old_value: Variant, old_easing: float) -> void:
|
|
||||||
var key_idx := _find_animation_key_at_time(anim, track_index, time)
|
|
||||||
if had_old_key:
|
|
||||||
if key_idx < 0:
|
|
||||||
key_idx = anim.track_insert_key(track_index, time, old_value)
|
|
||||||
else:
|
|
||||||
anim.track_set_key_value(track_index, key_idx, old_value)
|
|
||||||
anim.track_set_key_transition(track_index, key_idx, old_easing)
|
|
||||||
elif key_idx >= 0:
|
|
||||||
anim.track_remove_key(track_index, key_idx)
|
|
||||||
-601
@@ -1,601 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"create_animation_tree": _create_animation_tree,
|
|
||||||
"get_animation_tree_structure": _get_animation_tree_structure,
|
|
||||||
"add_state_machine_state": _add_state_machine_state,
|
|
||||||
"remove_state_machine_state": _remove_state_machine_state,
|
|
||||||
"add_state_machine_transition": _add_state_machine_transition,
|
|
||||||
"remove_state_machine_transition": _remove_state_machine_transition,
|
|
||||||
"set_blend_tree_node": _set_blend_tree_node,
|
|
||||||
"set_tree_parameter": _set_tree_parameter,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
## Find AnimationTree on a node or return null
|
|
||||||
func _find_animation_tree(node_path: String) -> AnimationTree:
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node is AnimationTree:
|
|
||||||
return node as AnimationTree
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
## Navigate to a nested state machine by slash-separated path (e.g. "Run/SubState")
|
|
||||||
## Returns [state_machine, error_or_null]
|
|
||||||
func _resolve_state_machine(tree: AnimationTree, sm_path: String) -> Array:
|
|
||||||
var root := tree.tree_root
|
|
||||||
if not root is AnimationNodeStateMachine:
|
|
||||||
return [null, error_invalid_params("AnimationTree root is not an AnimationNodeStateMachine")]
|
|
||||||
|
|
||||||
if sm_path.is_empty() or sm_path == ".":
|
|
||||||
return [root as AnimationNodeStateMachine, null]
|
|
||||||
|
|
||||||
var current: AnimationNodeStateMachine = root as AnimationNodeStateMachine
|
|
||||||
var parts := sm_path.split("/")
|
|
||||||
for part in parts:
|
|
||||||
if not current.has_node(StringName(part)):
|
|
||||||
return [null, error_not_found("State machine node '%s' in path '%s'" % [part, sm_path])]
|
|
||||||
var child := current.get_node(StringName(part))
|
|
||||||
if not child is AnimationNodeStateMachine:
|
|
||||||
return [null, error_invalid_params("Node '%s' is not a StateMachine" % part)]
|
|
||||||
current = child as AnimationNodeStateMachine
|
|
||||||
return [current, null]
|
|
||||||
|
|
||||||
|
|
||||||
## Resolve a BlendTree inside the tree. bt_path can be a state name inside a state machine,
|
|
||||||
## or a slash-separated path. The last segment is the BlendTree node name.
|
|
||||||
## Returns [blend_tree, error_or_null]
|
|
||||||
func _resolve_blend_tree(tree: AnimationTree, sm_path: String, bt_name: String) -> Array:
|
|
||||||
var result := _resolve_state_machine(tree, sm_path)
|
|
||||||
if result[1] != null:
|
|
||||||
return result
|
|
||||||
|
|
||||||
var sm: AnimationNodeStateMachine = result[0]
|
|
||||||
if not sm.has_node(StringName(bt_name)):
|
|
||||||
return [null, error_not_found("BlendTree node '%s'" % bt_name)]
|
|
||||||
|
|
||||||
var node := sm.get_node(StringName(bt_name))
|
|
||||||
if not node is AnimationNodeBlendTree:
|
|
||||||
return [null, error_invalid_params("Node '%s' is not an AnimationNodeBlendTree" % bt_name)]
|
|
||||||
|
|
||||||
return [node as AnimationNodeBlendTree, null]
|
|
||||||
|
|
||||||
|
|
||||||
func _create_animation_tree(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var parent := find_node_by_path(node_path)
|
|
||||||
if parent == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var anim_player_path: String = optional_string(params, "anim_player", "")
|
|
||||||
var tree_name: String = optional_string(params, "name", "AnimationTree")
|
|
||||||
|
|
||||||
# Create the AnimationTree
|
|
||||||
var tree := AnimationTree.new()
|
|
||||||
tree.name = tree_name
|
|
||||||
|
|
||||||
# Set root to AnimationNodeStateMachine
|
|
||||||
var state_machine := AnimationNodeStateMachine.new()
|
|
||||||
tree.tree_root = state_machine
|
|
||||||
|
|
||||||
# Link to AnimationPlayer if provided
|
|
||||||
if not anim_player_path.is_empty():
|
|
||||||
tree.anim_player = NodePath(anim_player_path)
|
|
||||||
|
|
||||||
add_child_with_undo(parent, tree, root, "MCP: Create AnimationTree")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"name": tree.name,
|
|
||||||
"node_path": str(root.get_path_to(tree)),
|
|
||||||
"root_type": "AnimationNodeStateMachine",
|
|
||||||
"anim_player": anim_player_path,
|
|
||||||
"created": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_animation_tree_structure(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var tree := _find_animation_tree(node_path)
|
|
||||||
if tree == null:
|
|
||||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
|
||||||
|
|
||||||
var root := tree.tree_root
|
|
||||||
if root == null:
|
|
||||||
return success({"node_path": node_path, "root": null})
|
|
||||||
|
|
||||||
var structure := _read_node_structure(root)
|
|
||||||
structure["active"] = tree.active
|
|
||||||
structure["anim_player"] = str(tree.anim_player)
|
|
||||||
structure["node_path"] = node_path
|
|
||||||
|
|
||||||
return success(structure)
|
|
||||||
|
|
||||||
|
|
||||||
func _read_node_structure(node: AnimationNode) -> Dictionary:
|
|
||||||
if node is AnimationNodeStateMachine:
|
|
||||||
return _read_state_machine_structure(node as AnimationNodeStateMachine)
|
|
||||||
elif node is AnimationNodeBlendTree:
|
|
||||||
return _read_blend_tree_structure(node as AnimationNodeBlendTree)
|
|
||||||
elif node is AnimationNodeAnimation:
|
|
||||||
var anim_node := node as AnimationNodeAnimation
|
|
||||||
return {"type": "AnimationNodeAnimation", "animation": str(anim_node.animation)}
|
|
||||||
else:
|
|
||||||
return {"type": node.get_class()}
|
|
||||||
|
|
||||||
|
|
||||||
func _read_state_machine_structure(sm: AnimationNodeStateMachine) -> Dictionary:
|
|
||||||
var states: Array = []
|
|
||||||
# Iterate through graph nodes via get_node_name
|
|
||||||
# AnimationNodeStateMachine doesn't have get_node_list in 4.x, iterate using _get_child_nodes
|
|
||||||
var node_list := _get_sm_node_names(sm)
|
|
||||||
for state_name in node_list:
|
|
||||||
var child := sm.get_node(StringName(state_name))
|
|
||||||
var state_info := {
|
|
||||||
"name": state_name,
|
|
||||||
"position": {"x": sm.get_node_position(StringName(state_name)).x, "y": sm.get_node_position(StringName(state_name)).y},
|
|
||||||
}
|
|
||||||
state_info.merge(_read_node_structure(child))
|
|
||||||
states.append(state_info)
|
|
||||||
|
|
||||||
var transitions: Array = []
|
|
||||||
for i in sm.get_transition_count():
|
|
||||||
var from_node := sm.get_transition_from(i)
|
|
||||||
var to_node := sm.get_transition_to(i)
|
|
||||||
var trans := sm.get_transition(i)
|
|
||||||
var trans_info := {
|
|
||||||
"from": str(from_node),
|
|
||||||
"to": str(to_node),
|
|
||||||
"switch_mode": trans.switch_mode,
|
|
||||||
"advance_mode": trans.advance_mode,
|
|
||||||
}
|
|
||||||
if not trans.advance_expression.is_empty():
|
|
||||||
trans_info["advance_expression"] = trans.advance_expression
|
|
||||||
if trans.advance_mode == AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO:
|
|
||||||
trans_info["auto"] = true
|
|
||||||
transitions.append(trans_info)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": "AnimationNodeStateMachine",
|
|
||||||
"states": states,
|
|
||||||
"transitions": transitions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _get_sm_node_names(sm: AnimationNodeStateMachine) -> Array:
|
|
||||||
# Use the internal _get_child_nodes or iterate known patterns
|
|
||||||
# AnimationNodeStateMachine doesn't expose a simple list method,
|
|
||||||
# but we can use get_graph_offset and iterate via has_node with common checks.
|
|
||||||
# Actually in Godot 4.x we can get the node list by checking property list
|
|
||||||
# or using the script resource approach. The most reliable is iterating through
|
|
||||||
# the resource properties.
|
|
||||||
var names: Array = []
|
|
||||||
var prop_list := sm.get_property_list()
|
|
||||||
for prop in prop_list:
|
|
||||||
var pname: String = prop["name"]
|
|
||||||
# State machine stores nodes as "states/<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
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Add state machine state")
|
|
||||||
undo_redo.add_do_method(sm, "add_node", StringName(state_name), node, position)
|
|
||||||
undo_redo.add_do_reference(node)
|
|
||||||
undo_redo.add_undo_method(sm, "remove_node", StringName(state_name))
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"state_name": state_name,
|
|
||||||
"state_type": state_type,
|
|
||||||
"position": {"x": position_x, "y": position_y},
|
|
||||||
"added": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _remove_state_machine_state(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "state_name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var state_name: String = result2[0]
|
|
||||||
|
|
||||||
var tree := _find_animation_tree(node_path)
|
|
||||||
if tree == null:
|
|
||||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
|
||||||
|
|
||||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
|
||||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
|
||||||
if sm_result[1] != null:
|
|
||||||
return sm_result[1]
|
|
||||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
|
||||||
|
|
||||||
if not sm.has_node(StringName(state_name)):
|
|
||||||
return error_not_found("State '%s'" % state_name)
|
|
||||||
|
|
||||||
var old_node := sm.get_node(StringName(state_name))
|
|
||||||
var old_position := sm.get_node_position(StringName(state_name))
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Remove state machine state")
|
|
||||||
undo_redo.add_do_method(sm, "remove_node", StringName(state_name))
|
|
||||||
undo_redo.add_undo_method(sm, "add_node", StringName(state_name), old_node, old_position)
|
|
||||||
undo_redo.add_undo_reference(old_node)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"state_name": state_name, "removed": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _add_state_machine_transition(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "from_state")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var from_state: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "to_state")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var to_state: String = result3[0]
|
|
||||||
|
|
||||||
var tree := _find_animation_tree(node_path)
|
|
||||||
if tree == null:
|
|
||||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
|
||||||
|
|
||||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
|
||||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
|
||||||
if sm_result[1] != null:
|
|
||||||
return sm_result[1]
|
|
||||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
|
||||||
|
|
||||||
# Validate states exist (Start and End are special built-in nodes)
|
|
||||||
if from_state != "Start" and from_state != "End" and not sm.has_node(StringName(from_state)):
|
|
||||||
return error_not_found("State '%s'" % from_state)
|
|
||||||
if to_state != "Start" and to_state != "End" and not sm.has_node(StringName(to_state)):
|
|
||||||
return error_not_found("State '%s'" % to_state)
|
|
||||||
|
|
||||||
var transition := AnimationNodeStateMachineTransition.new()
|
|
||||||
|
|
||||||
# switch_mode: AT_END=0, IMMEDIATE=1, SYNC=2
|
|
||||||
var switch_mode_str: String = optional_string(params, "switch_mode", "immediate")
|
|
||||||
match switch_mode_str:
|
|
||||||
"at_end": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END
|
|
||||||
"immediate": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
|
|
||||||
"sync": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END # SYNC maps similarly
|
|
||||||
_: transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
|
|
||||||
|
|
||||||
# advance_mode: DISABLED=0, ENABLED=1, AUTO=2
|
|
||||||
var advance_mode_str: String = optional_string(params, "advance_mode", "enabled")
|
|
||||||
match advance_mode_str:
|
|
||||||
"disabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED
|
|
||||||
"enabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
|
|
||||||
"auto": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
|
|
||||||
_: transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
|
|
||||||
|
|
||||||
# advance_expression
|
|
||||||
var expression: String = optional_string(params, "advance_expression", "")
|
|
||||||
if not expression.is_empty():
|
|
||||||
transition.advance_expression = expression
|
|
||||||
|
|
||||||
# xfade_time
|
|
||||||
if params.has("xfade_time"):
|
|
||||||
transition.xfade_time = float(params["xfade_time"])
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Add state machine transition")
|
|
||||||
undo_redo.add_do_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
|
|
||||||
undo_redo.add_do_reference(transition)
|
|
||||||
undo_redo.add_undo_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"from": from_state,
|
|
||||||
"to": to_state,
|
|
||||||
"switch_mode": switch_mode_str,
|
|
||||||
"advance_mode": advance_mode_str,
|
|
||||||
"advance_expression": expression,
|
|
||||||
"added": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _remove_state_machine_transition(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "from_state")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var from_state: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "to_state")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var to_state: String = result3[0]
|
|
||||||
|
|
||||||
var tree := _find_animation_tree(node_path)
|
|
||||||
if tree == null:
|
|
||||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
|
||||||
|
|
||||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
|
||||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
|
||||||
if sm_result[1] != null:
|
|
||||||
return sm_result[1]
|
|
||||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
|
||||||
|
|
||||||
# Check if transition exists
|
|
||||||
var found := false
|
|
||||||
for i in sm.get_transition_count():
|
|
||||||
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
return error_not_found("Transition from '%s' to '%s'" % [from_state, to_state])
|
|
||||||
|
|
||||||
var transition: AnimationNodeStateMachineTransition = null
|
|
||||||
for i in sm.get_transition_count():
|
|
||||||
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
|
|
||||||
transition = sm.get_transition(i)
|
|
||||||
break
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Remove state machine transition")
|
|
||||||
undo_redo.add_do_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
|
|
||||||
undo_redo.add_undo_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
|
|
||||||
undo_redo.add_undo_reference(transition)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"from": from_state, "to": to_state, "removed": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_blend_tree_node(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "blend_tree_state")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var bt_state: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "bt_node_name")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var bt_node_name: String = result3[0]
|
|
||||||
|
|
||||||
var result4 := require_string(params, "bt_node_type")
|
|
||||||
if result4[1] != null:
|
|
||||||
return result4[1]
|
|
||||||
var bt_node_type: String = result4[0]
|
|
||||||
|
|
||||||
var tree := _find_animation_tree(node_path)
|
|
||||||
if tree == null:
|
|
||||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
|
||||||
|
|
||||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
|
||||||
var bt_result := _resolve_blend_tree(tree, sm_path, bt_state)
|
|
||||||
if bt_result[1] != null:
|
|
||||||
return bt_result[1]
|
|
||||||
var bt: AnimationNodeBlendTree = bt_result[0]
|
|
||||||
|
|
||||||
var position_x: float = float(params.get("position_x", 0.0))
|
|
||||||
var position_y: float = float(params.get("position_y", 0.0))
|
|
||||||
var position := Vector2(position_x, position_y)
|
|
||||||
|
|
||||||
var had_old_node := bt.has_node(StringName(bt_node_name))
|
|
||||||
var old_node: AnimationNode = bt.get_node(StringName(bt_node_name)) if had_old_node else null
|
|
||||||
var old_position := bt.get_node_position(StringName(bt_node_name)) if had_old_node else Vector2.ZERO
|
|
||||||
|
|
||||||
var node: AnimationNode
|
|
||||||
match bt_node_type:
|
|
||||||
"Animation":
|
|
||||||
var anim_node := AnimationNodeAnimation.new()
|
|
||||||
var anim_name: String = optional_string(params, "animation", "")
|
|
||||||
if not anim_name.is_empty():
|
|
||||||
anim_node.animation = StringName(anim_name)
|
|
||||||
node = anim_node
|
|
||||||
"Add2":
|
|
||||||
node = AnimationNodeAdd2.new()
|
|
||||||
"Blend2":
|
|
||||||
node = AnimationNodeBlend2.new()
|
|
||||||
"Add3":
|
|
||||||
node = AnimationNodeAdd3.new()
|
|
||||||
"Blend3":
|
|
||||||
node = AnimationNodeBlend3.new()
|
|
||||||
"TimeScale":
|
|
||||||
node = AnimationNodeTimeScale.new()
|
|
||||||
"TimeSeek":
|
|
||||||
node = AnimationNodeTimeSeek.new()
|
|
||||||
"Transition":
|
|
||||||
node = AnimationNodeTransition.new()
|
|
||||||
"OneShot":
|
|
||||||
node = AnimationNodeOneShot.new()
|
|
||||||
"Sub2":
|
|
||||||
node = AnimationNodeSub2.new()
|
|
||||||
_:
|
|
||||||
return error_invalid_params("Unknown bt_node_type: '%s'. Use: Animation, Add2, Blend2, Add3, Blend3, TimeScale, TimeSeek, Transition, OneShot, Sub2" % bt_node_type)
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set blend tree node")
|
|
||||||
if had_old_node:
|
|
||||||
undo_redo.add_do_method(bt, "remove_node", StringName(bt_node_name))
|
|
||||||
undo_redo.add_undo_method(bt, "add_node", StringName(bt_node_name), old_node, old_position)
|
|
||||||
undo_redo.add_undo_reference(old_node)
|
|
||||||
undo_redo.add_do_method(bt, "add_node", StringName(bt_node_name), node, position)
|
|
||||||
undo_redo.add_do_reference(node)
|
|
||||||
undo_redo.add_undo_method(bt, "remove_node", StringName(bt_node_name))
|
|
||||||
|
|
||||||
# Connect to another node if specified
|
|
||||||
var connect_to: String = optional_string(params, "connect_to", "")
|
|
||||||
var connect_port: int = optional_int(params, "connect_port", 0)
|
|
||||||
if not connect_to.is_empty():
|
|
||||||
undo_redo.add_do_method(bt, "connect_node", StringName(connect_to), connect_port, StringName(bt_node_name))
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
var connected_to_value: Variant = null
|
|
||||||
if not connect_to.is_empty():
|
|
||||||
connected_to_value = connect_to
|
|
||||||
return success({
|
|
||||||
"blend_tree_state": bt_state,
|
|
||||||
"bt_node_name": bt_node_name,
|
|
||||||
"bt_node_type": bt_node_type,
|
|
||||||
"position": {"x": position_x, "y": position_y},
|
|
||||||
"connected_to": connected_to_value,
|
|
||||||
"added": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_tree_parameter(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "parameter")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var parameter: String = result2[0]
|
|
||||||
|
|
||||||
var tree := _find_animation_tree(node_path)
|
|
||||||
if tree == null:
|
|
||||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
|
||||||
|
|
||||||
if not params.has("value"):
|
|
||||||
return error_invalid_params("Missing required parameter: value")
|
|
||||||
|
|
||||||
var value = params["value"]
|
|
||||||
|
|
||||||
# Prefix with "parameters/" if not already
|
|
||||||
if not parameter.begins_with("parameters/"):
|
|
||||||
parameter = "parameters/" + parameter
|
|
||||||
|
|
||||||
# Parse string values for common types
|
|
||||||
if value is String:
|
|
||||||
var s: String = value
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(s) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed != null:
|
|
||||||
value = parsed
|
|
||||||
|
|
||||||
set_property_with_undo(tree, parameter, value, "MCP: Set AnimationTree parameter")
|
|
||||||
|
|
||||||
# Read back to confirm
|
|
||||||
var actual = tree.get(parameter)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"parameter": parameter,
|
|
||||||
"value": str(actual),
|
|
||||||
"set": true,
|
|
||||||
})
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"get_audio_bus_layout": _get_audio_bus_layout,
|
|
||||||
"add_audio_bus": _add_audio_bus,
|
|
||||||
"set_audio_bus": _set_audio_bus,
|
|
||||||
"add_audio_bus_effect": _add_audio_bus_effect,
|
|
||||||
"add_audio_player": _add_audio_player,
|
|
||||||
"get_audio_info": _get_audio_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _get_audio_bus_layout(_params: Dictionary) -> Dictionary:
|
|
||||||
var buses: Array[Dictionary] = []
|
|
||||||
for i in range(AudioServer.bus_count):
|
|
||||||
var bus_data := {
|
|
||||||
"index": i,
|
|
||||||
"name": AudioServer.get_bus_name(i),
|
|
||||||
"volume_db": AudioServer.get_bus_volume_db(i),
|
|
||||||
"solo": AudioServer.is_bus_solo(i),
|
|
||||||
"mute": AudioServer.is_bus_mute(i),
|
|
||||||
"bypass_effects": AudioServer.is_bus_bypassing_effects(i),
|
|
||||||
"send": AudioServer.get_bus_send(i),
|
|
||||||
"effects": [],
|
|
||||||
}
|
|
||||||
var effects: Array[Dictionary] = []
|
|
||||||
for j in range(AudioServer.get_bus_effect_count(i)):
|
|
||||||
var effect := AudioServer.get_bus_effect(i, j)
|
|
||||||
var effect_data := {
|
|
||||||
"index": j,
|
|
||||||
"type": effect.get_class(),
|
|
||||||
"enabled": AudioServer.is_bus_effect_enabled(i, j),
|
|
||||||
}
|
|
||||||
# Include effect-specific parameters
|
|
||||||
effect_data["params"] = _get_effect_params(effect)
|
|
||||||
effects.append(effect_data)
|
|
||||||
bus_data["effects"] = effects
|
|
||||||
buses.append(bus_data)
|
|
||||||
return success({"bus_count": AudioServer.bus_count, "buses": buses})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_effect_params(effect: AudioEffect) -> Dictionary:
|
|
||||||
var params := {}
|
|
||||||
if effect is AudioEffectReverb:
|
|
||||||
var rev := effect as AudioEffectReverb
|
|
||||||
params = {"room_size": rev.room_size, "damping": rev.damping, "wet": rev.wet, "dry": rev.dry, "spread": rev.spread}
|
|
||||||
elif effect is AudioEffectDelay:
|
|
||||||
var d := effect as AudioEffectDelay
|
|
||||||
params = {"tap1_active": d.tap1_active, "tap1_delay_ms": d.tap1_delay_ms, "tap1_level_db": d.tap1_level_db, "tap2_active": d.tap2_active, "tap2_delay_ms": d.tap2_delay_ms, "tap2_level_db": d.tap2_level_db}
|
|
||||||
elif effect is AudioEffectCompressor:
|
|
||||||
var c := effect as AudioEffectCompressor
|
|
||||||
params = {"threshold": c.threshold, "ratio": c.ratio, "attack_us": c.attack_us, "release_ms": c.release_ms, "gain": c.gain, "mix": c.mix, "sidechain": c.sidechain}
|
|
||||||
elif effect is AudioEffectLimiter:
|
|
||||||
var l := effect as AudioEffectLimiter
|
|
||||||
params = {"ceiling_db": l.ceiling_db, "threshold_db": l.threshold_db, "soft_clip_db": l.soft_clip_db, "soft_clip_ratio": l.soft_clip_ratio}
|
|
||||||
elif effect is AudioEffectDistortion:
|
|
||||||
var dist := effect as AudioEffectDistortion
|
|
||||||
params = {"mode": dist.mode, "pre_gain": dist.pre_gain, "post_gain": dist.post_gain, "keep_hf_hz": dist.keep_hf_hz, "drive": dist.drive}
|
|
||||||
elif effect is AudioEffectChorus:
|
|
||||||
var ch := effect as AudioEffectChorus
|
|
||||||
params = {"voice_count": ch.voice_count, "dry": ch.dry, "wet": ch.wet}
|
|
||||||
elif effect is AudioEffectPhaser:
|
|
||||||
var ph := effect as AudioEffectPhaser
|
|
||||||
params = {"range_min_hz": ph.range_min_hz, "range_max_hz": ph.range_max_hz, "rate_hz": ph.rate_hz, "feedback": ph.feedback, "depth": ph.depth}
|
|
||||||
elif effect is AudioEffectFilter:
|
|
||||||
# Covers LowPassFilter, HighPassFilter, BandPassFilter, etc.
|
|
||||||
var f := effect as AudioEffectFilter
|
|
||||||
params = {"cutoff_hz": f.cutoff_hz, "resonance": f.resonance, "gain": f.gain, "db": f.db}
|
|
||||||
elif effect is AudioEffectAmplify:
|
|
||||||
var a := effect as AudioEffectAmplify
|
|
||||||
params = {"volume_db": a.volume_db}
|
|
||||||
return params
|
|
||||||
|
|
||||||
|
|
||||||
func _add_audio_bus(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "name")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var bus_name: String = result[0]
|
|
||||||
|
|
||||||
# Check if bus name already exists
|
|
||||||
for i in range(AudioServer.bus_count):
|
|
||||||
if AudioServer.get_bus_name(i) == bus_name:
|
|
||||||
return error_invalid_params("Audio bus '%s' already exists at index %d" % [bus_name, i])
|
|
||||||
|
|
||||||
var at_position: int = optional_int(params, "at_position", -1)
|
|
||||||
AudioServer.add_bus(at_position)
|
|
||||||
|
|
||||||
var idx: int = AudioServer.bus_count - 1 if at_position < 0 else at_position
|
|
||||||
AudioServer.set_bus_name(idx, bus_name)
|
|
||||||
|
|
||||||
if params.has("volume_db"):
|
|
||||||
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
|
|
||||||
|
|
||||||
var send: String = optional_string(params, "send", "")
|
|
||||||
if not send.is_empty():
|
|
||||||
AudioServer.set_bus_send(idx, send)
|
|
||||||
|
|
||||||
if params.has("solo"):
|
|
||||||
AudioServer.set_bus_solo(idx, bool(params["solo"]))
|
|
||||||
|
|
||||||
if params.has("mute"):
|
|
||||||
AudioServer.set_bus_mute(idx, bool(params["mute"]))
|
|
||||||
|
|
||||||
return success({"name": bus_name, "index": idx, "bus_count": AudioServer.bus_count})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_audio_bus(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "name")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var bus_name: String = result[0]
|
|
||||||
|
|
||||||
var idx := AudioServer.get_bus_index(bus_name)
|
|
||||||
if idx < 0:
|
|
||||||
return error_not_found("Audio bus '%s'" % bus_name)
|
|
||||||
|
|
||||||
var changes := 0
|
|
||||||
|
|
||||||
if params.has("volume_db"):
|
|
||||||
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
|
|
||||||
changes += 1
|
|
||||||
|
|
||||||
if params.has("solo"):
|
|
||||||
AudioServer.set_bus_solo(idx, bool(params["solo"]))
|
|
||||||
changes += 1
|
|
||||||
|
|
||||||
if params.has("mute"):
|
|
||||||
AudioServer.set_bus_mute(idx, bool(params["mute"]))
|
|
||||||
changes += 1
|
|
||||||
|
|
||||||
if params.has("bypass_effects"):
|
|
||||||
AudioServer.set_bus_bypass_effects(idx, bool(params["bypass_effects"]))
|
|
||||||
changes += 1
|
|
||||||
|
|
||||||
var send: String = optional_string(params, "send", "")
|
|
||||||
if not send.is_empty():
|
|
||||||
AudioServer.set_bus_send(idx, send)
|
|
||||||
changes += 1
|
|
||||||
|
|
||||||
if params.has("rename"):
|
|
||||||
var new_name: String = str(params["rename"])
|
|
||||||
AudioServer.set_bus_name(idx, new_name)
|
|
||||||
bus_name = new_name
|
|
||||||
changes += 1
|
|
||||||
|
|
||||||
return success({"name": bus_name, "index": idx, "changes": changes})
|
|
||||||
|
|
||||||
|
|
||||||
func _add_audio_bus_effect(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "bus")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var bus_name: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "effect_type")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var effect_type: String = result2[0]
|
|
||||||
|
|
||||||
var bus_idx := AudioServer.get_bus_index(bus_name)
|
|
||||||
if bus_idx < 0:
|
|
||||||
return error_not_found("Audio bus '%s'" % bus_name)
|
|
||||||
|
|
||||||
var effect: AudioEffect = null
|
|
||||||
var effect_params: Dictionary = params.get("params", {}) if params.has("params") else {}
|
|
||||||
|
|
||||||
match effect_type.to_lower():
|
|
||||||
"reverb":
|
|
||||||
var e := AudioEffectReverb.new()
|
|
||||||
if effect_params.has("room_size"):
|
|
||||||
e.room_size = float(effect_params["room_size"])
|
|
||||||
if effect_params.has("damping"):
|
|
||||||
e.damping = float(effect_params["damping"])
|
|
||||||
if effect_params.has("wet"):
|
|
||||||
e.wet = float(effect_params["wet"])
|
|
||||||
if effect_params.has("dry"):
|
|
||||||
e.dry = float(effect_params["dry"])
|
|
||||||
if effect_params.has("spread"):
|
|
||||||
e.spread = float(effect_params["spread"])
|
|
||||||
effect = e
|
|
||||||
"chorus":
|
|
||||||
var e := AudioEffectChorus.new()
|
|
||||||
if effect_params.has("voice_count"):
|
|
||||||
e.voice_count = int(effect_params["voice_count"])
|
|
||||||
if effect_params.has("dry"):
|
|
||||||
e.dry = float(effect_params["dry"])
|
|
||||||
if effect_params.has("wet"):
|
|
||||||
e.wet = float(effect_params["wet"])
|
|
||||||
effect = e
|
|
||||||
"delay":
|
|
||||||
var e := AudioEffectDelay.new()
|
|
||||||
if effect_params.has("tap1_active"):
|
|
||||||
e.tap1_active = bool(effect_params["tap1_active"])
|
|
||||||
if effect_params.has("tap1_delay_ms"):
|
|
||||||
e.tap1_delay_ms = float(effect_params["tap1_delay_ms"])
|
|
||||||
if effect_params.has("tap1_level_db"):
|
|
||||||
e.tap1_level_db = float(effect_params["tap1_level_db"])
|
|
||||||
if effect_params.has("tap2_active"):
|
|
||||||
e.tap2_active = bool(effect_params["tap2_active"])
|
|
||||||
if effect_params.has("tap2_delay_ms"):
|
|
||||||
e.tap2_delay_ms = float(effect_params["tap2_delay_ms"])
|
|
||||||
if effect_params.has("tap2_level_db"):
|
|
||||||
e.tap2_level_db = float(effect_params["tap2_level_db"])
|
|
||||||
effect = e
|
|
||||||
"compressor":
|
|
||||||
var e := AudioEffectCompressor.new()
|
|
||||||
if effect_params.has("threshold"):
|
|
||||||
e.threshold = float(effect_params["threshold"])
|
|
||||||
if effect_params.has("ratio"):
|
|
||||||
e.ratio = float(effect_params["ratio"])
|
|
||||||
if effect_params.has("attack_us"):
|
|
||||||
e.attack_us = float(effect_params["attack_us"])
|
|
||||||
if effect_params.has("release_ms"):
|
|
||||||
e.release_ms = float(effect_params["release_ms"])
|
|
||||||
if effect_params.has("gain"):
|
|
||||||
e.gain = float(effect_params["gain"])
|
|
||||||
if effect_params.has("mix"):
|
|
||||||
e.mix = float(effect_params["mix"])
|
|
||||||
effect = e
|
|
||||||
"limiter":
|
|
||||||
var e := AudioEffectLimiter.new()
|
|
||||||
if effect_params.has("ceiling_db"):
|
|
||||||
e.ceiling_db = float(effect_params["ceiling_db"])
|
|
||||||
if effect_params.has("threshold_db"):
|
|
||||||
e.threshold_db = float(effect_params["threshold_db"])
|
|
||||||
if effect_params.has("soft_clip_db"):
|
|
||||||
e.soft_clip_db = float(effect_params["soft_clip_db"])
|
|
||||||
if effect_params.has("soft_clip_ratio"):
|
|
||||||
e.soft_clip_ratio = float(effect_params["soft_clip_ratio"])
|
|
||||||
effect = e
|
|
||||||
"phaser":
|
|
||||||
var e := AudioEffectPhaser.new()
|
|
||||||
if effect_params.has("range_min_hz"):
|
|
||||||
e.range_min_hz = float(effect_params["range_min_hz"])
|
|
||||||
if effect_params.has("range_max_hz"):
|
|
||||||
e.range_max_hz = float(effect_params["range_max_hz"])
|
|
||||||
if effect_params.has("rate_hz"):
|
|
||||||
e.rate_hz = float(effect_params["rate_hz"])
|
|
||||||
if effect_params.has("feedback"):
|
|
||||||
e.feedback = float(effect_params["feedback"])
|
|
||||||
if effect_params.has("depth"):
|
|
||||||
e.depth = float(effect_params["depth"])
|
|
||||||
effect = e
|
|
||||||
"distortion":
|
|
||||||
var e := AudioEffectDistortion.new()
|
|
||||||
if effect_params.has("mode"):
|
|
||||||
e.mode = int(effect_params["mode"]) as AudioEffectDistortion.Mode
|
|
||||||
if effect_params.has("pre_gain"):
|
|
||||||
e.pre_gain = float(effect_params["pre_gain"])
|
|
||||||
if effect_params.has("post_gain"):
|
|
||||||
e.post_gain = float(effect_params["post_gain"])
|
|
||||||
if effect_params.has("keep_hf_hz"):
|
|
||||||
e.keep_hf_hz = float(effect_params["keep_hf_hz"])
|
|
||||||
if effect_params.has("drive"):
|
|
||||||
e.drive = float(effect_params["drive"])
|
|
||||||
effect = e
|
|
||||||
"lowpassfilter", "lowpass":
|
|
||||||
var e := AudioEffectLowPassFilter.new()
|
|
||||||
if effect_params.has("cutoff_hz"):
|
|
||||||
e.cutoff_hz = float(effect_params["cutoff_hz"])
|
|
||||||
if effect_params.has("resonance"):
|
|
||||||
e.resonance = float(effect_params["resonance"])
|
|
||||||
effect = e
|
|
||||||
"highpassfilter", "highpass":
|
|
||||||
var e := AudioEffectHighPassFilter.new()
|
|
||||||
if effect_params.has("cutoff_hz"):
|
|
||||||
e.cutoff_hz = float(effect_params["cutoff_hz"])
|
|
||||||
if effect_params.has("resonance"):
|
|
||||||
e.resonance = float(effect_params["resonance"])
|
|
||||||
effect = e
|
|
||||||
"bandpassfilter", "bandpass":
|
|
||||||
var e := AudioEffectBandPassFilter.new()
|
|
||||||
if effect_params.has("cutoff_hz"):
|
|
||||||
e.cutoff_hz = float(effect_params["cutoff_hz"])
|
|
||||||
if effect_params.has("resonance"):
|
|
||||||
e.resonance = float(effect_params["resonance"])
|
|
||||||
effect = e
|
|
||||||
"amplify":
|
|
||||||
var e := AudioEffectAmplify.new()
|
|
||||||
if effect_params.has("volume_db"):
|
|
||||||
e.volume_db = float(effect_params["volume_db"])
|
|
||||||
effect = e
|
|
||||||
"eq":
|
|
||||||
var e := AudioEffectEQ.new()
|
|
||||||
effect = e
|
|
||||||
_:
|
|
||||||
return error_invalid_params("Unknown effect type: '%s'. Valid types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq" % effect_type)
|
|
||||||
|
|
||||||
var at_position: int = optional_int(params, "at_position", -1)
|
|
||||||
AudioServer.add_bus_effect(bus_idx, effect, at_position)
|
|
||||||
|
|
||||||
var effect_idx: int = AudioServer.get_bus_effect_count(bus_idx) - 1 if at_position < 0 else at_position
|
|
||||||
return success({"bus": bus_name, "bus_index": bus_idx, "effect_type": effect.get_class(), "effect_index": effect_idx})
|
|
||||||
|
|
||||||
|
|
||||||
func _add_audio_player(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var player_name: String = result2[0]
|
|
||||||
|
|
||||||
var player_type: String = optional_string(params, "type", "AudioStreamPlayer")
|
|
||||||
var valid_types := ["AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D"]
|
|
||||||
if player_type not in valid_types:
|
|
||||||
return error_invalid_params("Invalid player type '%s'. Valid: %s" % [player_type, ", ".join(valid_types)])
|
|
||||||
|
|
||||||
var parent := find_node_by_path(node_path)
|
|
||||||
if parent == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var player: Node = null
|
|
||||||
match player_type:
|
|
||||||
"AudioStreamPlayer":
|
|
||||||
player = AudioStreamPlayer.new()
|
|
||||||
"AudioStreamPlayer2D":
|
|
||||||
player = AudioStreamPlayer2D.new()
|
|
||||||
"AudioStreamPlayer3D":
|
|
||||||
player = AudioStreamPlayer3D.new()
|
|
||||||
|
|
||||||
player.name = player_name
|
|
||||||
|
|
||||||
# Set stream if provided
|
|
||||||
var stream_path: String = optional_string(params, "stream", "")
|
|
||||||
if not stream_path.is_empty():
|
|
||||||
if ResourceLoader.exists(stream_path):
|
|
||||||
var stream = ResourceLoader.load(stream_path)
|
|
||||||
if stream is AudioStream:
|
|
||||||
player.set("stream", stream)
|
|
||||||
else:
|
|
||||||
player.queue_free()
|
|
||||||
return error_invalid_params("Resource at '%s' is not an AudioStream" % stream_path)
|
|
||||||
else:
|
|
||||||
player.queue_free()
|
|
||||||
return error_not_found("Audio stream at '%s'" % stream_path)
|
|
||||||
|
|
||||||
# Common properties
|
|
||||||
if params.has("volume_db"):
|
|
||||||
player.set("volume_db", float(params["volume_db"]))
|
|
||||||
|
|
||||||
var bus: String = optional_string(params, "bus", "")
|
|
||||||
if not bus.is_empty():
|
|
||||||
player.set("bus", bus)
|
|
||||||
|
|
||||||
if params.has("autoplay"):
|
|
||||||
player.set("autoplay", bool(params["autoplay"]))
|
|
||||||
|
|
||||||
# 2D-specific properties
|
|
||||||
if player is AudioStreamPlayer2D:
|
|
||||||
if params.has("max_distance"):
|
|
||||||
(player as AudioStreamPlayer2D).max_distance = float(params["max_distance"])
|
|
||||||
if params.has("attenuation"):
|
|
||||||
(player as AudioStreamPlayer2D).attenuation = float(params["attenuation"])
|
|
||||||
|
|
||||||
# 3D-specific properties
|
|
||||||
if player is AudioStreamPlayer3D:
|
|
||||||
if params.has("max_distance"):
|
|
||||||
(player as AudioStreamPlayer3D).max_distance = float(params["max_distance"])
|
|
||||||
if params.has("attenuation_model"):
|
|
||||||
(player as AudioStreamPlayer3D).attenuation_model = int(params["attenuation_model"]) as AudioStreamPlayer3D.AttenuationModel
|
|
||||||
if params.has("unit_size"):
|
|
||||||
(player as AudioStreamPlayer3D).unit_size = float(params["unit_size"])
|
|
||||||
|
|
||||||
add_child_with_undo(parent, player, root, "MCP: Add audio player")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"name": player_name,
|
|
||||||
"type": player_type,
|
|
||||||
"parent": node_path,
|
|
||||||
"stream": stream_path,
|
|
||||||
"bus": player.get("bus"),
|
|
||||||
"volume_db": player.get("volume_db"),
|
|
||||||
"autoplay": player.get("autoplay"),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_audio_info(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var players: Array[Dictionary] = []
|
|
||||||
_collect_audio_players(node, players)
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "audio_player_count": players.size(), "players": players})
|
|
||||||
|
|
||||||
|
|
||||||
func _collect_audio_players(node: Node, result: Array[Dictionary]) -> void:
|
|
||||||
if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D:
|
|
||||||
var info := {
|
|
||||||
"name": node.name,
|
|
||||||
"path": str(get_edited_root().get_path_to(node)),
|
|
||||||
"type": node.get_class(),
|
|
||||||
"volume_db": node.get("volume_db"),
|
|
||||||
"bus": node.get("bus"),
|
|
||||||
"autoplay": node.get("autoplay"),
|
|
||||||
"playing": node.get("playing"),
|
|
||||||
"stream": "",
|
|
||||||
}
|
|
||||||
var stream = node.get("stream")
|
|
||||||
if stream != null and stream is AudioStream:
|
|
||||||
info["stream"] = stream.resource_path
|
|
||||||
|
|
||||||
if node is AudioStreamPlayer2D:
|
|
||||||
info["max_distance"] = (node as AudioStreamPlayer2D).max_distance
|
|
||||||
info["attenuation"] = (node as AudioStreamPlayer2D).attenuation
|
|
||||||
elif node is AudioStreamPlayer3D:
|
|
||||||
info["max_distance"] = (node as AudioStreamPlayer3D).max_distance
|
|
||||||
info["attenuation_model"] = (node as AudioStreamPlayer3D).attenuation_model
|
|
||||||
info["unit_size"] = (node as AudioStreamPlayer3D).unit_size
|
|
||||||
|
|
||||||
result.append(info)
|
|
||||||
|
|
||||||
for child in node.get_children():
|
|
||||||
_collect_audio_players(child, result)
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
var editor_plugin: EditorPlugin
|
|
||||||
|
|
||||||
|
|
||||||
## Override in subclasses: return {"method_name": Callable}
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
## Helper: return a success result
|
|
||||||
func success(data: Dictionary = {}) -> Dictionary:
|
|
||||||
return {"result": data}
|
|
||||||
|
|
||||||
|
|
||||||
## Helper: return an error
|
|
||||||
func error(code: int, message: String, data: Dictionary = {}) -> Dictionary:
|
|
||||||
var err := {"code": code, "message": message}
|
|
||||||
if not data.is_empty():
|
|
||||||
err["data"] = data
|
|
||||||
return {"error": err}
|
|
||||||
|
|
||||||
|
|
||||||
## Error codes
|
|
||||||
func error_not_found(what: String, suggestion: String = "") -> Dictionary:
|
|
||||||
var data := {}
|
|
||||||
if suggestion:
|
|
||||||
data["suggestion"] = suggestion
|
|
||||||
return error(-32001, "%s not found" % what, data)
|
|
||||||
|
|
||||||
|
|
||||||
func error_invalid_params(message: String) -> Dictionary:
|
|
||||||
return error(-32602, message)
|
|
||||||
|
|
||||||
|
|
||||||
func error_no_scene() -> Dictionary:
|
|
||||||
return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"})
|
|
||||||
|
|
||||||
|
|
||||||
func error_internal(message: String) -> Dictionary:
|
|
||||||
return error(-32603, "Internal error: %s" % message)
|
|
||||||
|
|
||||||
|
|
||||||
func error_conflict(message: String, data: Dictionary = {}) -> Dictionary:
|
|
||||||
return error(-32009, message, data)
|
|
||||||
|
|
||||||
|
|
||||||
## Get required string param
|
|
||||||
func require_string(params: Dictionary, key: String) -> Array:
|
|
||||||
if not params.has(key) or not params[key] is String or (params[key] as String).is_empty():
|
|
||||||
return [null, error_invalid_params("Missing required parameter: %s" % key)]
|
|
||||||
return [params[key] as String, null]
|
|
||||||
|
|
||||||
|
|
||||||
## Get optional string param with default
|
|
||||||
func optional_string(params: Dictionary, key: String, default: String = "") -> String:
|
|
||||||
if params.has(key) and params[key] is String:
|
|
||||||
return params[key] as String
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
## Get optional bool param with default
|
|
||||||
func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool:
|
|
||||||
if params.has(key) and params[key] is bool:
|
|
||||||
return params[key] as bool
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
## Get optional int param with default
|
|
||||||
func optional_int(params: Dictionary, key: String, default: int = 0) -> int:
|
|
||||||
if params.has(key):
|
|
||||||
return int(params[key])
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
## Get the game process's user data directory.
|
|
||||||
## OS.get_user_data_dir() is cached at editor startup and won't reflect
|
|
||||||
## project name changes made to project.godot while the editor is running.
|
|
||||||
## The game process reads the name from disk, so we must do the same.
|
|
||||||
func get_game_user_dir() -> String:
|
|
||||||
var cached_dir := OS.get_user_data_dir()
|
|
||||||
var cfg := ConfigFile.new()
|
|
||||||
var err := cfg.load(ProjectSettings.globalize_path("res://project.godot"))
|
|
||||||
if err != OK:
|
|
||||||
return cached_dir
|
|
||||||
# When use_custom_user_dir=true, editor and game share the same dir
|
|
||||||
# (OS.get_user_data_dir() already resolves to the custom path).
|
|
||||||
if cfg.get_value("application", "config/use_custom_user_dir", false):
|
|
||||||
return cached_dir
|
|
||||||
var disk_name = cfg.get_value("application", "config/name", "")
|
|
||||||
if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty():
|
|
||||||
return cached_dir
|
|
||||||
# Sanitize exactly like Godot does when computing the default user dir
|
|
||||||
# (core/config/project_settings.cpp ProjectSettings::_init).
|
|
||||||
var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_")
|
|
||||||
if sanitized.is_empty():
|
|
||||||
return cached_dir
|
|
||||||
var base_dir := cached_dir.get_base_dir()
|
|
||||||
var game_dir := base_dir.path_join(sanitized)
|
|
||||||
# Ensure the directory exists (game may not have created it yet)
|
|
||||||
if not DirAccess.dir_exists_absolute(game_dir):
|
|
||||||
DirAccess.make_dir_recursive_absolute(game_dir)
|
|
||||||
return game_dir
|
|
||||||
|
|
||||||
|
|
||||||
## Get EditorInterface
|
|
||||||
func get_editor() -> EditorInterface:
|
|
||||||
return editor_plugin.get_editor_interface()
|
|
||||||
|
|
||||||
|
|
||||||
## Get the edited scene root
|
|
||||||
func get_edited_root() -> Node:
|
|
||||||
return EditorInterface.get_edited_scene_root()
|
|
||||||
|
|
||||||
|
|
||||||
## Get UndoRedo
|
|
||||||
func get_undo_redo() -> EditorUndoRedoManager:
|
|
||||||
return editor_plugin.get_undo_redo()
|
|
||||||
|
|
||||||
|
|
||||||
func normalize_project_path(path: String) -> String:
|
|
||||||
if path.is_empty():
|
|
||||||
return ""
|
|
||||||
if path.begins_with("res://") or path.begins_with("user://"):
|
|
||||||
return path.simplify_path()
|
|
||||||
return ProjectSettings.localize_path(path).simplify_path()
|
|
||||||
|
|
||||||
|
|
||||||
func is_scene_resource_path(path: String) -> bool:
|
|
||||||
var ext := path.get_extension().to_lower()
|
|
||||||
return ext == "tscn" or ext == "scn"
|
|
||||||
|
|
||||||
|
|
||||||
func get_open_scene_paths() -> Array[String]:
|
|
||||||
var paths: Array[String] = []
|
|
||||||
var open_scenes: PackedStringArray = EditorInterface.get_open_scenes()
|
|
||||||
for scene_path: String in open_scenes:
|
|
||||||
var normalized := normalize_project_path(scene_path)
|
|
||||||
if not normalized.is_empty() and normalized not in paths:
|
|
||||||
paths.append(normalized)
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root != null and not root.scene_file_path.is_empty():
|
|
||||||
var active_path := normalize_project_path(root.scene_file_path)
|
|
||||||
if active_path not in paths:
|
|
||||||
paths.append(active_path)
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
func is_scene_path_open(path: String) -> bool:
|
|
||||||
var normalized := normalize_project_path(path)
|
|
||||||
if normalized.is_empty():
|
|
||||||
return false
|
|
||||||
return normalized in get_open_scene_paths()
|
|
||||||
|
|
||||||
|
|
||||||
func is_active_scene_path(path: String) -> bool:
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return false
|
|
||||||
return normalize_project_path(root.scene_file_path) == normalize_project_path(path)
|
|
||||||
|
|
||||||
|
|
||||||
func guard_offline_scene_save(path: String) -> Dictionary:
|
|
||||||
if is_scene_resource_path(path) and is_scene_path_open(path):
|
|
||||||
return error_conflict(
|
|
||||||
"Refusing to save open scene '%s' outside the Godot editor state" % normalize_project_path(path),
|
|
||||||
{
|
|
||||||
"path": normalize_project_path(path),
|
|
||||||
"open_scenes": get_open_scene_paths(),
|
|
||||||
"suggestion": "Use live editor changes plus save_scene, or close the scene before offline edits.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
func is_shader_resource_path(path: String) -> bool:
|
|
||||||
var ext := path.get_extension().to_lower()
|
|
||||||
return ext == "gdshader" or ext == "gdshaderinc" or ext == "shader"
|
|
||||||
|
|
||||||
|
|
||||||
func is_text_resource_open_in_script_editor(path: String) -> bool:
|
|
||||||
var target := normalize_project_path(path)
|
|
||||||
if target.is_empty():
|
|
||||||
return false
|
|
||||||
if is_shader_resource_path(target) and ResourceLoader.has_cached(target):
|
|
||||||
return true
|
|
||||||
var script_editor := EditorInterface.get_script_editor()
|
|
||||||
if script_editor == null:
|
|
||||||
return false
|
|
||||||
for open_resource in script_editor.get_open_scripts():
|
|
||||||
if open_resource is Resource:
|
|
||||||
var resource_path := normalize_project_path((open_resource as Resource).resource_path)
|
|
||||||
if resource_path == target:
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
func guard_text_resource_write(path: String, force: bool) -> Dictionary:
|
|
||||||
if not force and is_text_resource_open_in_script_editor(path):
|
|
||||||
return error_conflict(
|
|
||||||
"Refusing to write open text resource '%s' outside the script editor state" % normalize_project_path(path),
|
|
||||||
{
|
|
||||||
"path": normalize_project_path(path),
|
|
||||||
"suggestion": "Close the file in Godot's script editor or pass force=true to overwrite it deliberately.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
func mark_current_scene_unsaved() -> void:
|
|
||||||
if EditorInterface.has_method("mark_scene_as_unsaved"):
|
|
||||||
EditorInterface.mark_scene_as_unsaved()
|
|
||||||
|
|
||||||
|
|
||||||
func add_child_with_undo(parent: Node, child: Node, root: Node, action_name: String) -> void:
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action(action_name)
|
|
||||||
undo_redo.add_do_method(parent, "add_child", child)
|
|
||||||
undo_redo.add_do_method(child, "set_owner", root)
|
|
||||||
undo_redo.add_do_reference(child)
|
|
||||||
undo_redo.add_undo_method(parent, "remove_child", child)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
|
|
||||||
func set_property_with_undo(target: Object, property: String, new_value: Variant, action_name: String) -> void:
|
|
||||||
var old_value: Variant = target.get(property)
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action(action_name)
|
|
||||||
undo_redo.add_do_property(target, property, new_value)
|
|
||||||
if new_value is Resource:
|
|
||||||
undo_redo.add_do_reference(new_value)
|
|
||||||
undo_redo.add_undo_property(target, property, old_value)
|
|
||||||
if old_value is Resource:
|
|
||||||
undo_redo.add_undo_reference(old_value)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"find_nodes_by_type": _find_nodes_by_type,
|
|
||||||
"find_signal_connections": _find_signal_connections,
|
|
||||||
"batch_set_property": _batch_set_property,
|
|
||||||
"batch_add_nodes": _batch_add_nodes,
|
|
||||||
"find_node_references": _find_node_references,
|
|
||||||
"get_scene_dependencies": _get_scene_dependencies,
|
|
||||||
"cross_scene_set_property": _cross_scene_set_property,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _find_nodes_by_type(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "type")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var type_name: String = result[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var recursive: bool = optional_bool(params, "recursive", true)
|
|
||||||
var matches: Array = []
|
|
||||||
_search_by_type(root, type_name, recursive, matches)
|
|
||||||
|
|
||||||
return success({"type": type_name, "matches": matches, "count": matches.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void:
|
|
||||||
if node.is_class(type_name) or node.get_class() == type_name:
|
|
||||||
var root := get_edited_root()
|
|
||||||
matches.append({
|
|
||||||
"name": node.name,
|
|
||||||
"path": str(root.get_path_to(node)),
|
|
||||||
"type": node.get_class(),
|
|
||||||
})
|
|
||||||
if recursive:
|
|
||||||
for child in node.get_children():
|
|
||||||
_search_by_type(child, type_name, recursive, matches)
|
|
||||||
|
|
||||||
|
|
||||||
func _find_signal_connections(params: Dictionary) -> Dictionary:
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var signal_filter: String = optional_string(params, "signal_name", "")
|
|
||||||
var node_filter: String = optional_string(params, "node_path", "")
|
|
||||||
|
|
||||||
var connections: Array = []
|
|
||||||
_collect_signals(root, root, signal_filter, node_filter, connections)
|
|
||||||
|
|
||||||
return success({"connections": connections, "count": connections.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void:
|
|
||||||
var node_path := str(root.get_path_to(node))
|
|
||||||
|
|
||||||
if node_filter.is_empty() or node_path.contains(node_filter):
|
|
||||||
for sig_info in node.get_signal_list():
|
|
||||||
var sig_name: String = sig_info["name"]
|
|
||||||
if not signal_filter.is_empty() and not sig_name.contains(signal_filter):
|
|
||||||
continue
|
|
||||||
for conn in node.get_signal_connection_list(sig_name):
|
|
||||||
connections.append({
|
|
||||||
"source": node_path,
|
|
||||||
"signal": sig_name,
|
|
||||||
"target": str(root.get_path_to(conn["callable"].get_object())),
|
|
||||||
"method": conn["callable"].get_method(),
|
|
||||||
})
|
|
||||||
|
|
||||||
for child in node.get_children():
|
|
||||||
_collect_signals(child, root, signal_filter, node_filter, connections)
|
|
||||||
|
|
||||||
|
|
||||||
func _batch_set_property(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "type")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var type_name: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "property")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var property: String = result2[0]
|
|
||||||
|
|
||||||
if not params.has("value"):
|
|
||||||
return error_invalid_params("Missing required parameter: value")
|
|
||||||
var value = params["value"]
|
|
||||||
|
|
||||||
# Parse value string
|
|
||||||
if value is String:
|
|
||||||
var s: String = value
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(s) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed != null:
|
|
||||||
value = parsed
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var affected: Array = []
|
|
||||||
var changes: Array = []
|
|
||||||
_batch_collect_property_changes(root, root, type_name, property, value, affected, changes)
|
|
||||||
if not changes.is_empty():
|
|
||||||
_apply_property_changes_with_undo(changes, property, "MCP: Batch set %s" % property)
|
|
||||||
|
|
||||||
return success({"property": property, "affected": affected, "count": affected.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _batch_collect_property_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
|
|
||||||
if node.is_class(type_name) or node.get_class() == type_name:
|
|
||||||
if property in node:
|
|
||||||
affected.append(str(root.get_path_to(node)))
|
|
||||||
changes.append({
|
|
||||||
"node": node,
|
|
||||||
"old_value": node.get(property),
|
|
||||||
"new_value": value,
|
|
||||||
})
|
|
||||||
for child in node.get_children():
|
|
||||||
_batch_collect_property_changes(child, root, type_name, property, value, affected, changes)
|
|
||||||
|
|
||||||
|
|
||||||
func _batch_add_nodes(params: Dictionary) -> Dictionary:
|
|
||||||
if not params.has("nodes") or not params["nodes"] is Array:
|
|
||||||
return error_invalid_params("Missing required parameter: nodes (Array)")
|
|
||||||
|
|
||||||
var nodes_data: Array = params["nodes"]
|
|
||||||
if nodes_data.is_empty():
|
|
||||||
return error_invalid_params("nodes array is empty")
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var created: Array = []
|
|
||||||
var errors: Array = []
|
|
||||||
|
|
||||||
for i: int in nodes_data.size():
|
|
||||||
var entry: Dictionary = nodes_data[i]
|
|
||||||
|
|
||||||
if not entry.has("type") or not entry["type"] is String:
|
|
||||||
errors.append({"index": i, "error": "Missing or invalid 'type'"})
|
|
||||||
continue
|
|
||||||
|
|
||||||
var type: String = entry["type"]
|
|
||||||
if not ClassDB.class_exists(type):
|
|
||||||
errors.append({"index": i, "error": "Unknown node type: %s" % type})
|
|
||||||
continue
|
|
||||||
|
|
||||||
var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "."
|
|
||||||
var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else ""
|
|
||||||
var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {}
|
|
||||||
|
|
||||||
var parent := find_node_by_path(parent_path)
|
|
||||||
if parent == null:
|
|
||||||
errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path})
|
|
||||||
continue
|
|
||||||
|
|
||||||
var node: Node = ClassDB.instantiate(type)
|
|
||||||
if not node_name.is_empty():
|
|
||||||
node.name = node_name
|
|
||||||
|
|
||||||
for prop_name: String in properties:
|
|
||||||
var prop_exists := false
|
|
||||||
for prop in node.get_property_list():
|
|
||||||
if prop["name"] == prop_name:
|
|
||||||
prop_exists = true
|
|
||||||
break
|
|
||||||
if prop_exists:
|
|
||||||
var current: Variant = node.get(prop_name)
|
|
||||||
var target_type := typeof(current)
|
|
||||||
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
|
|
||||||
|
|
||||||
add_child_with_undo(parent, node, root, "MCP: Batch add %s" % type)
|
|
||||||
|
|
||||||
created.append({
|
|
||||||
"index": i,
|
|
||||||
"type": type,
|
|
||||||
"name": str(node.name),
|
|
||||||
"parent": parent_path,
|
|
||||||
"node_path": str(root.get_path_to(node)),
|
|
||||||
})
|
|
||||||
|
|
||||||
var result := {"created": created, "count": created.size()}
|
|
||||||
if not errors.is_empty():
|
|
||||||
result["errors"] = errors
|
|
||||||
return success(result)
|
|
||||||
|
|
||||||
|
|
||||||
func _find_node_references(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "pattern")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var pattern: String = result[0]
|
|
||||||
|
|
||||||
# Search through all .tscn and .gd files for references
|
|
||||||
var matches: Array = []
|
|
||||||
_search_files_for_pattern("res://", pattern, matches, 100)
|
|
||||||
|
|
||||||
return success({"pattern": pattern, "matches": matches, "count": matches.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void:
|
|
||||||
if matches.size() >= max_results:
|
|
||||||
return
|
|
||||||
|
|
||||||
var dir := DirAccess.open(path)
|
|
||||||
if dir == null:
|
|
||||||
return
|
|
||||||
|
|
||||||
dir.list_dir_begin()
|
|
||||||
var file_name := dir.get_next()
|
|
||||||
|
|
||||||
while not file_name.is_empty() and matches.size() < max_results:
|
|
||||||
if file_name.begins_with("."):
|
|
||||||
file_name = dir.get_next()
|
|
||||||
continue
|
|
||||||
|
|
||||||
var full_path := path.path_join(file_name)
|
|
||||||
|
|
||||||
if dir.current_is_dir():
|
|
||||||
_search_files_for_pattern(full_path, pattern, matches, max_results)
|
|
||||||
elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]:
|
|
||||||
var file := FileAccess.open(full_path, FileAccess.READ)
|
|
||||||
if file:
|
|
||||||
var content := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
if content.contains(pattern):
|
|
||||||
# Find line numbers
|
|
||||||
var lines := content.split("\n")
|
|
||||||
var line_matches: Array = []
|
|
||||||
for i in lines.size():
|
|
||||||
if lines[i].contains(pattern):
|
|
||||||
line_matches.append(i + 1)
|
|
||||||
if line_matches.size() >= 5:
|
|
||||||
break
|
|
||||||
matches.append({
|
|
||||||
"file": full_path,
|
|
||||||
"lines": line_matches,
|
|
||||||
})
|
|
||||||
|
|
||||||
file_name = dir.get_next()
|
|
||||||
dir.list_dir_end()
|
|
||||||
|
|
||||||
|
|
||||||
func _cross_scene_set_property(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "type")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var type_name: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "property")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var property: String = result2[0]
|
|
||||||
|
|
||||||
if not params.has("value"):
|
|
||||||
return error_invalid_params("Missing required parameter: value")
|
|
||||||
var value = params["value"]
|
|
||||||
|
|
||||||
# Parse value string
|
|
||||||
if value is String:
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(value) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed != null:
|
|
||||||
value = parsed
|
|
||||||
|
|
||||||
var path_filter: String = optional_string(params, "path_filter", "res://")
|
|
||||||
var exclude_addons: bool = optional_bool(params, "exclude_addons", true)
|
|
||||||
var force: bool = optional_bool(params, "force", false)
|
|
||||||
var dry_run: bool = optional_bool(params, "dry_run", not force)
|
|
||||||
if not dry_run and not force:
|
|
||||||
return error_invalid_params("cross_scene_set_property requires force=true when dry_run=false")
|
|
||||||
|
|
||||||
var scenes_affected: Array = []
|
|
||||||
var skipped_open_scenes: Array = []
|
|
||||||
var total_nodes: int = 0
|
|
||||||
var scene_files: Array = []
|
|
||||||
_collect_scene_files(path_filter, scene_files, exclude_addons)
|
|
||||||
|
|
||||||
for scene_path: String in scene_files:
|
|
||||||
var normalized_scene_path := normalize_project_path(scene_path)
|
|
||||||
|
|
||||||
if is_scene_path_open(normalized_scene_path):
|
|
||||||
if is_active_scene_path(normalized_scene_path) and force and not dry_run:
|
|
||||||
var root := get_edited_root()
|
|
||||||
var live_changes: Array = []
|
|
||||||
var live_affected_nodes: Array = []
|
|
||||||
_cross_scene_collect_changes(root, root, type_name, property, value, live_affected_nodes, live_changes)
|
|
||||||
if not live_changes.is_empty():
|
|
||||||
_apply_property_changes_with_undo(live_changes, property, "MCP: Cross-scene set %s" % property)
|
|
||||||
scenes_affected.append({
|
|
||||||
"scene": normalized_scene_path,
|
|
||||||
"nodes": live_affected_nodes,
|
|
||||||
"count": live_affected_nodes.size(),
|
|
||||||
"mode": "live_open_scene",
|
|
||||||
})
|
|
||||||
total_nodes += live_affected_nodes.size()
|
|
||||||
else:
|
|
||||||
var reason := "open scene skipped during dry_run" if dry_run else "open scene is not the active editor scene"
|
|
||||||
skipped_open_scenes.append({"scene": normalized_scene_path, "reason": reason})
|
|
||||||
continue
|
|
||||||
|
|
||||||
var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene
|
|
||||||
if packed == null:
|
|
||||||
continue
|
|
||||||
var instance: Node = packed.instantiate()
|
|
||||||
if instance == null:
|
|
||||||
continue
|
|
||||||
|
|
||||||
var affected_nodes: Array = []
|
|
||||||
var changes: Array = []
|
|
||||||
_cross_scene_collect_changes(instance, instance, type_name, property, value, affected_nodes, changes)
|
|
||||||
|
|
||||||
if not changes.is_empty():
|
|
||||||
if not dry_run:
|
|
||||||
var guard := guard_offline_scene_save(normalized_scene_path)
|
|
||||||
if not guard.is_empty():
|
|
||||||
instance.free()
|
|
||||||
return guard
|
|
||||||
for change: Dictionary in changes:
|
|
||||||
(change["node"] as Node).set(property, value)
|
|
||||||
# Pack and save
|
|
||||||
var new_packed := PackedScene.new()
|
|
||||||
var pack_err := new_packed.pack(instance)
|
|
||||||
if pack_err != OK:
|
|
||||||
instance.free()
|
|
||||||
return error_internal("Failed to pack scene '%s': %s" % [normalized_scene_path, error_string(pack_err)])
|
|
||||||
var save_err := ResourceSaver.save(new_packed, normalized_scene_path)
|
|
||||||
if save_err != OK:
|
|
||||||
instance.free()
|
|
||||||
return error_internal("Failed to save scene '%s': %s" % [normalized_scene_path, error_string(save_err)])
|
|
||||||
scenes_affected.append({
|
|
||||||
"scene": normalized_scene_path,
|
|
||||||
"nodes": affected_nodes,
|
|
||||||
"count": affected_nodes.size(),
|
|
||||||
"mode": "dry_run" if dry_run else "offline_saved",
|
|
||||||
})
|
|
||||||
total_nodes += affected_nodes.size()
|
|
||||||
|
|
||||||
instance.free()
|
|
||||||
|
|
||||||
# Rescan filesystem so editor picks up changes
|
|
||||||
if not scenes_affected.is_empty():
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"type": type_name,
|
|
||||||
"property": property,
|
|
||||||
"dry_run": dry_run,
|
|
||||||
"force": force,
|
|
||||||
"scenes_affected": scenes_affected,
|
|
||||||
"skipped_open_scenes": skipped_open_scenes,
|
|
||||||
"total_scenes": scenes_affected.size(),
|
|
||||||
"total_nodes": total_nodes,
|
|
||||||
"message": "Dry run only. Re-run with force=true and dry_run=false to write closed scenes and live-edit the active open scene." if dry_run else "Changes applied.",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void:
|
|
||||||
var dir := DirAccess.open(path)
|
|
||||||
if dir == null:
|
|
||||||
return
|
|
||||||
dir.list_dir_begin()
|
|
||||||
var file_name := dir.get_next()
|
|
||||||
while not file_name.is_empty():
|
|
||||||
if file_name.begins_with("."):
|
|
||||||
file_name = dir.get_next()
|
|
||||||
continue
|
|
||||||
var full_path := path.path_join(file_name)
|
|
||||||
if dir.current_is_dir():
|
|
||||||
if exclude_addons and file_name == "addons":
|
|
||||||
file_name = dir.get_next()
|
|
||||||
continue
|
|
||||||
_collect_scene_files(full_path, files, exclude_addons)
|
|
||||||
elif file_name.get_extension() == "tscn":
|
|
||||||
files.append(full_path)
|
|
||||||
file_name = dir.get_next()
|
|
||||||
dir.list_dir_end()
|
|
||||||
|
|
||||||
|
|
||||||
func _cross_scene_collect_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
|
|
||||||
if node.is_class(type_name) or node.get_class() == type_name:
|
|
||||||
if property in node:
|
|
||||||
affected.append(str(root.get_path_to(node)))
|
|
||||||
changes.append({
|
|
||||||
"node": node,
|
|
||||||
"old_value": node.get(property),
|
|
||||||
"new_value": value,
|
|
||||||
})
|
|
||||||
for child in node.get_children():
|
|
||||||
_cross_scene_collect_changes(child, root, type_name, property, value, affected, changes)
|
|
||||||
|
|
||||||
|
|
||||||
func _apply_property_changes_with_undo(changes: Array, property: String, action_name: String) -> void:
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action(action_name)
|
|
||||||
for change: Dictionary in changes:
|
|
||||||
var node: Node = change["node"]
|
|
||||||
undo_redo.add_do_property(node, property, change["new_value"])
|
|
||||||
undo_redo.add_undo_property(node, property, change["old_value"])
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
|
|
||||||
func _get_scene_dependencies(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("File '%s'" % path)
|
|
||||||
|
|
||||||
var deps := ResourceLoader.get_dependencies(path)
|
|
||||||
var dependencies: Array = []
|
|
||||||
for dep: String in deps:
|
|
||||||
# Format: "path::type"
|
|
||||||
var parts := dep.split("::")
|
|
||||||
dependencies.append({
|
|
||||||
"path": parts[0] if parts.size() > 0 else dep,
|
|
||||||
"type": parts[2] if parts.size() > 2 else "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return success({"path": path, "dependencies": dependencies, "count": dependencies.size()})
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"get_editor_errors": _get_editor_errors,
|
|
||||||
"get_output_log": _get_output_log,
|
|
||||||
"get_editor_screenshot": _get_editor_screenshot,
|
|
||||||
"get_game_screenshot": _get_game_screenshot,
|
|
||||||
"execute_editor_script": _execute_editor_script,
|
|
||||||
"clear_output": _clear_output,
|
|
||||||
"reload_plugin": _reload_plugin,
|
|
||||||
"reload_project": _reload_project,
|
|
||||||
"get_signals": _get_signals,
|
|
||||||
"compare_screenshots": _compare_screenshots,
|
|
||||||
"set_auto_dismiss": _set_auto_dismiss,
|
|
||||||
"get_editor_camera": _get_editor_camera,
|
|
||||||
"set_editor_camera": _set_editor_camera,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _get_editor_errors(params: Dictionary) -> Dictionary:
|
|
||||||
var errors: Array = []
|
|
||||||
var max_lines: int = optional_int(params, "max_lines", 50)
|
|
||||||
var base: Control = get_editor().get_base_control()
|
|
||||||
|
|
||||||
# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
|
|
||||||
# This captures runtime errors, warnings, and print output
|
|
||||||
var editor_log: Node = base.find_child("Output", true, false)
|
|
||||||
if editor_log:
|
|
||||||
var rtl: RichTextLabel = _find_rtl(editor_log)
|
|
||||||
if rtl:
|
|
||||||
var content: String = rtl.get_parsed_text()
|
|
||||||
var lines: PackedStringArray = content.split("\n")
|
|
||||||
var start: int = maxi(0, lines.size() - max_lines)
|
|
||||||
for i in range(start, lines.size()):
|
|
||||||
var line: String = lines[i]
|
|
||||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
|
|
||||||
errors.append(line.strip_edges())
|
|
||||||
|
|
||||||
# 2. Check the script editor for compile errors (red background lines)
|
|
||||||
# These don't appear in the Output panel
|
|
||||||
var script_errors: Array = []
|
|
||||||
var script_editor: ScriptEditor = EditorInterface.get_script_editor()
|
|
||||||
if script_editor:
|
|
||||||
var current_script: Script = script_editor.get_current_script()
|
|
||||||
var ce: CodeEdit = _find_code_edit(script_editor)
|
|
||||||
if ce and current_script:
|
|
||||||
var script_path: String = current_script.resource_path
|
|
||||||
for i in range(ce.get_line_count()):
|
|
||||||
var bg: Color = ce.get_line_background_color(i)
|
|
||||||
if bg.r > 0.8 and bg.a > 0: # Red-ish background = error
|
|
||||||
var line_text: String = ce.get_line(i).strip_edges()
|
|
||||||
script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])
|
|
||||||
|
|
||||||
# 3. Read from script editor error/warning panels (GDScript analyzer messages)
|
|
||||||
# Each open script editor has a VSplitContainer with two RichTextLabels:
|
|
||||||
# child[1] = warnings panel, child[2] = errors panel
|
|
||||||
var analyzer_errors: Array = []
|
|
||||||
if script_editor:
|
|
||||||
var open_editors: Array = script_editor.get_open_script_editors()
|
|
||||||
var open_scripts: Array = script_editor.get_open_scripts()
|
|
||||||
for ei in range(open_editors.size()):
|
|
||||||
var editor_node: Node = open_editors[ei]
|
|
||||||
var script_path: String = ""
|
|
||||||
if ei < open_scripts.size() and open_scripts[ei] != null:
|
|
||||||
script_path = (open_scripts[ei] as Resource).resource_path
|
|
||||||
var vsplit: VSplitContainer = null
|
|
||||||
for c in editor_node.get_children():
|
|
||||||
if c is VSplitContainer:
|
|
||||||
vsplit = c as VSplitContainer
|
|
||||||
break
|
|
||||||
if vsplit == null:
|
|
||||||
continue
|
|
||||||
var children: Array = vsplit.get_children()
|
|
||||||
# child[1] = warnings panel (RichTextLabel)
|
|
||||||
if children.size() > 1 and children[1] is RichTextLabel:
|
|
||||||
var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
|
|
||||||
if not text.is_empty():
|
|
||||||
for line in text.split("\n"):
|
|
||||||
var stripped: String = line.strip_edges()
|
|
||||||
if stripped.is_empty() or stripped == "[Ignore]":
|
|
||||||
continue
|
|
||||||
# Remove leading "[Ignore]" prefix from warning lines
|
|
||||||
stripped = stripped.trim_prefix("[Ignore]")
|
|
||||||
var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
|
|
||||||
analyzer_errors.append(prefix + stripped)
|
|
||||||
# child[2] = errors panel (RichTextLabel)
|
|
||||||
if children.size() > 2 and children[2] is RichTextLabel:
|
|
||||||
var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
|
|
||||||
if not text.is_empty():
|
|
||||||
for line in text.split("\n"):
|
|
||||||
var stripped: String = line.strip_edges()
|
|
||||||
if stripped.is_empty():
|
|
||||||
continue
|
|
||||||
var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
|
|
||||||
analyzer_errors.append(prefix + stripped)
|
|
||||||
|
|
||||||
# 4. Read from the debugger Errors tab (runtime errors/warnings)
|
|
||||||
# Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
|
|
||||||
var debugger_errors: Array = []
|
|
||||||
var base2: Control = get_editor().get_base_control()
|
|
||||||
if base2:
|
|
||||||
var queue: Array[Node] = [base2]
|
|
||||||
while not queue.is_empty():
|
|
||||||
var node := queue.pop_front()
|
|
||||||
if node.get_class() == "ScriptEditorDebugger":
|
|
||||||
# Find TabContainer inside the debugger
|
|
||||||
for child in node.get_children():
|
|
||||||
if child is TabContainer:
|
|
||||||
var tab_container := child as TabContainer
|
|
||||||
for tab_idx in range(tab_container.get_tab_count()):
|
|
||||||
var tab_control: Control = tab_container.get_tab_control(tab_idx)
|
|
||||||
if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
|
|
||||||
# Find Tree inside the Errors tab
|
|
||||||
for vchild in tab_control.get_children():
|
|
||||||
if vchild is Tree:
|
|
||||||
var tree := vchild as Tree
|
|
||||||
var root_item: TreeItem = tree.get_root()
|
|
||||||
if root_item:
|
|
||||||
var item: TreeItem = root_item.get_first_child()
|
|
||||||
while item:
|
|
||||||
var col0: String = item.get_text(0).strip_edges()
|
|
||||||
var col1: String = item.get_text(1).strip_edges()
|
|
||||||
if not col0.is_empty() or not col1.is_empty():
|
|
||||||
var msg: String = col0
|
|
||||||
if not col1.is_empty():
|
|
||||||
msg += " " + col1 if not msg.is_empty() else col1
|
|
||||||
debugger_errors.append("DEBUGGER: " + msg)
|
|
||||||
# Also check child items (expanded error details)
|
|
||||||
var sub: TreeItem = item.get_first_child()
|
|
||||||
while sub:
|
|
||||||
var sub0: String = sub.get_text(0).strip_edges()
|
|
||||||
var sub1: String = sub.get_text(1).strip_edges()
|
|
||||||
if not sub0.is_empty() or not sub1.is_empty():
|
|
||||||
var sub_msg: String = sub0
|
|
||||||
if not sub1.is_empty():
|
|
||||||
sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
|
|
||||||
debugger_errors.append("DEBUGGER: " + sub_msg)
|
|
||||||
sub = sub.get_next()
|
|
||||||
item = item.get_next()
|
|
||||||
break # Found Errors tab, stop searching tabs
|
|
||||||
break # Found TabContainer, stop searching debugger children
|
|
||||||
break # Found ScriptEditorDebugger, stop BFS
|
|
||||||
for child in node.get_children():
|
|
||||||
queue.append(child)
|
|
||||||
|
|
||||||
# Fallback: read from log file if Output panel not accessible
|
|
||||||
if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
|
|
||||||
var log_path := "user://logs/godot.log"
|
|
||||||
if FileAccess.file_exists(log_path):
|
|
||||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
|
||||||
if file:
|
|
||||||
var content := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
var lines := content.split("\n")
|
|
||||||
var start: int = maxi(0, lines.size() - max_lines)
|
|
||||||
for i in range(start, lines.size()):
|
|
||||||
var line: String = lines[i]
|
|
||||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
|
|
||||||
errors.append(line.strip_edges())
|
|
||||||
|
|
||||||
errors.append_array(script_errors)
|
|
||||||
errors.append_array(analyzer_errors)
|
|
||||||
errors.append_array(debugger_errors)
|
|
||||||
return success({"errors": errors, "count": errors.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_output_log(params: Dictionary) -> Dictionary:
|
|
||||||
var max_lines: int = optional_int(params, "max_lines", 100)
|
|
||||||
var filter: String = optional_string(params, "filter", "")
|
|
||||||
var base: Control = get_editor().get_base_control()
|
|
||||||
|
|
||||||
var editor_log: Node = base.find_child("Output", true, false)
|
|
||||||
if editor_log == null:
|
|
||||||
# Fallback: read from log file
|
|
||||||
var log_path := "user://logs/godot.log"
|
|
||||||
if not FileAccess.file_exists(log_path):
|
|
||||||
return error_internal("Output panel not found and no log file available")
|
|
||||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot read log file")
|
|
||||||
var content := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
var lines := content.split("\n")
|
|
||||||
var start: int = maxi(0, lines.size() - max_lines)
|
|
||||||
var output_lines: Array = []
|
|
||||||
for i in range(start, lines.size()):
|
|
||||||
var line: String = lines[i]
|
|
||||||
if filter.is_empty() or line.contains(filter):
|
|
||||||
output_lines.append(line)
|
|
||||||
return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})
|
|
||||||
|
|
||||||
var rtl: RichTextLabel = _find_rtl(editor_log)
|
|
||||||
if rtl == null:
|
|
||||||
return error_internal("Could not find RichTextLabel in Output panel")
|
|
||||||
|
|
||||||
var content: String = rtl.get_parsed_text()
|
|
||||||
var all_lines: PackedStringArray = content.split("\n")
|
|
||||||
var start: int = maxi(0, all_lines.size() - max_lines)
|
|
||||||
var output_lines: Array = []
|
|
||||||
for i in range(start, all_lines.size()):
|
|
||||||
var line: String = all_lines[i]
|
|
||||||
if filter.is_empty() or line.contains(filter):
|
|
||||||
output_lines.append(line)
|
|
||||||
|
|
||||||
return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})
|
|
||||||
|
|
||||||
|
|
||||||
func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
|
|
||||||
if depth > 8:
|
|
||||||
return null
|
|
||||||
if node is CodeEdit:
|
|
||||||
return node as CodeEdit
|
|
||||||
for child in node.get_children():
|
|
||||||
var found: CodeEdit = _find_code_edit(child, depth + 1)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
|
|
||||||
if depth > 6:
|
|
||||||
return null
|
|
||||||
if node is RichTextLabel:
|
|
||||||
return node as RichTextLabel
|
|
||||||
for child in node.get_children():
|
|
||||||
var found: RichTextLabel = _find_rtl(child, depth + 1)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _get_editor_screenshot(params: Dictionary) -> Dictionary:
|
|
||||||
# Capture the editor's main viewport - no await to avoid timeout
|
|
||||||
var base_control: Control = get_editor().get_base_control()
|
|
||||||
if base_control == null:
|
|
||||||
return error_internal("Could not access editor base control")
|
|
||||||
|
|
||||||
var viewport: Viewport = base_control.get_viewport()
|
|
||||||
if viewport == null:
|
|
||||||
return error_internal("Could not access editor viewport")
|
|
||||||
|
|
||||||
var texture: ViewportTexture = viewport.get_texture()
|
|
||||||
if texture == null:
|
|
||||||
return error_internal("Could not get viewport texture")
|
|
||||||
|
|
||||||
var image: Image = texture.get_image()
|
|
||||||
if image == null:
|
|
||||||
return error_internal("Could not get image from viewport")
|
|
||||||
|
|
||||||
var save_path: String = params.get("save_path", "")
|
|
||||||
if save_path != "":
|
|
||||||
var abs_path := _resolve_save_path(save_path)
|
|
||||||
var err := image.save_png(abs_path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to save screenshot: %s" % error_string(err))
|
|
||||||
return success({
|
|
||||||
"saved_path": save_path,
|
|
||||||
"width": image.get_width(),
|
|
||||||
"height": image.get_height(),
|
|
||||||
"format": "png",
|
|
||||||
})
|
|
||||||
|
|
||||||
var png_buffer := image.save_png_to_buffer()
|
|
||||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"image_base64": base64,
|
|
||||||
"width": image.get_width(),
|
|
||||||
"height": image.get_height(),
|
|
||||||
"format": "png",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_game_screenshot(params: Dictionary) -> Dictionary:
|
|
||||||
var ei := get_editor()
|
|
||||||
if not ei.is_playing_scene():
|
|
||||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
|
||||||
|
|
||||||
# Communicate with the game process via file system
|
|
||||||
var user_dir := get_game_user_dir()
|
|
||||||
var request_path := user_dir + "/mcp_screenshot_request"
|
|
||||||
var screenshot_path := user_dir + "/mcp_screenshot.png"
|
|
||||||
|
|
||||||
# Clean up any stale screenshot file
|
|
||||||
if FileAccess.file_exists(screenshot_path):
|
|
||||||
DirAccess.remove_absolute(screenshot_path)
|
|
||||||
|
|
||||||
# Create the request file to signal the game process
|
|
||||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
|
||||||
if req == null:
|
|
||||||
return error_internal("Could not create screenshot request file")
|
|
||||||
req.close()
|
|
||||||
|
|
||||||
# Poll for the screenshot file (max 3 seconds, 0.1s interval)
|
|
||||||
var attempts := 30
|
|
||||||
while attempts > 0:
|
|
||||||
await get_tree().create_timer(0.1).timeout
|
|
||||||
if FileAccess.file_exists(screenshot_path):
|
|
||||||
break
|
|
||||||
attempts -= 1
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(screenshot_path):
|
|
||||||
# Clean up request file if it still exists
|
|
||||||
if FileAccess.file_exists(request_path):
|
|
||||||
DirAccess.remove_absolute(request_path)
|
|
||||||
return error(-32000, "Screenshot timed out", {
|
|
||||||
"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Load the PNG file
|
|
||||||
var image := Image.new()
|
|
||||||
var err := image.load(screenshot_path)
|
|
||||||
if err != OK:
|
|
||||||
DirAccess.remove_absolute(screenshot_path)
|
|
||||||
return error_internal("Failed to load screenshot: %s" % error_string(err))
|
|
||||||
|
|
||||||
# Clean up temp file
|
|
||||||
DirAccess.remove_absolute(screenshot_path)
|
|
||||||
|
|
||||||
var save_path_param: String = params.get("save_path", "")
|
|
||||||
if save_path_param != "":
|
|
||||||
var abs_path := _resolve_save_path(save_path_param)
|
|
||||||
var save_err := image.save_png(abs_path)
|
|
||||||
if save_err != OK:
|
|
||||||
return error_internal("Failed to save screenshot: %s" % error_string(save_err))
|
|
||||||
return success({
|
|
||||||
"saved_path": save_path_param,
|
|
||||||
"width": image.get_width(),
|
|
||||||
"height": image.get_height(),
|
|
||||||
"format": "png",
|
|
||||||
})
|
|
||||||
|
|
||||||
var png_buffer := image.save_png_to_buffer()
|
|
||||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"image_base64": base64,
|
|
||||||
"width": image.get_width(),
|
|
||||||
"height": image.get_height(),
|
|
||||||
"format": "png",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _resolve_save_path(path: String) -> String:
|
|
||||||
if path.begins_with("res://") or path.begins_with("user://"):
|
|
||||||
return ProjectSettings.globalize_path(path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
func _execute_editor_script(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "code")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var code: String = result[0]
|
|
||||||
var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
|
|
||||||
var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
|
|
||||||
if not unsafe_guard.is_empty():
|
|
||||||
return unsafe_guard
|
|
||||||
|
|
||||||
# Wrap user code in a @tool script
|
|
||||||
var wrapped_code := """@tool
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
var _mcp_output: Array = []
|
|
||||||
|
|
||||||
func _mcp_print(value: Variant) -> void:
|
|
||||||
_mcp_output.append(str(value))
|
|
||||||
|
|
||||||
func run() -> Variant:
|
|
||||||
# User code begins
|
|
||||||
%s
|
|
||||||
# User code ends
|
|
||||||
return _mcp_output
|
|
||||||
""" % _indent_code(code)
|
|
||||||
|
|
||||||
# Create a temporary script
|
|
||||||
var script := GDScript.new()
|
|
||||||
script.source_code = wrapped_code
|
|
||||||
var err := script.reload()
|
|
||||||
|
|
||||||
if err != OK:
|
|
||||||
return error(-32002, "Script compilation failed", {
|
|
||||||
"error": error_string(err),
|
|
||||||
"code": wrapped_code,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create temp node and execute
|
|
||||||
var temp_node := Node.new()
|
|
||||||
temp_node.set_script(script)
|
|
||||||
add_child(temp_node)
|
|
||||||
|
|
||||||
var output: Variant = null
|
|
||||||
|
|
||||||
# Execute with error handling
|
|
||||||
if temp_node.has_method("run"):
|
|
||||||
output = temp_node.run()
|
|
||||||
|
|
||||||
var mcp_output: Array = []
|
|
||||||
var raw_output: Variant = temp_node.get("_mcp_output")
|
|
||||||
if raw_output is Array:
|
|
||||||
mcp_output = raw_output
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
temp_node.queue_free()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"output": mcp_output,
|
|
||||||
"return_value": str(output) if output != null else null,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
|
|
||||||
if allow_unsafe_editor_io:
|
|
||||||
return {}
|
|
||||||
var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
|
|
||||||
var unsafe_patterns: Array[String] = []
|
|
||||||
if compact.contains("ResourceSaver.save("):
|
|
||||||
unsafe_patterns.append("ResourceSaver.save")
|
|
||||||
if compact.contains("ProjectSettings.save("):
|
|
||||||
unsafe_patterns.append("ProjectSettings.save")
|
|
||||||
if compact.contains("ConfigFile.save("):
|
|
||||||
unsafe_patterns.append("ConfigFile.save")
|
|
||||||
if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
|
|
||||||
unsafe_patterns.append("FileAccess.open WRITE")
|
|
||||||
if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
|
|
||||||
unsafe_patterns.append("DirAccess filesystem mutation")
|
|
||||||
if unsafe_patterns.is_empty():
|
|
||||||
return {}
|
|
||||||
return error_conflict(
|
|
||||||
"Refusing to execute editor script with direct file/resource write APIs",
|
|
||||||
{
|
|
||||||
"unsafe_patterns": unsafe_patterns,
|
|
||||||
"open_scenes": get_open_scene_paths(),
|
|
||||||
"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _contains_any(value: String, needles: Array[String]) -> bool:
|
|
||||||
for needle: String in needles:
|
|
||||||
if value.contains(needle):
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
func _indent_code(code: String) -> String:
|
|
||||||
var lines := code.split("\n")
|
|
||||||
var indented: PackedStringArray = []
|
|
||||||
for line in lines:
|
|
||||||
indented.append("\t" + line)
|
|
||||||
return "\n".join(indented)
|
|
||||||
|
|
||||||
|
|
||||||
func _clear_output(params: Dictionary) -> Dictionary:
|
|
||||||
print("\n".repeat(50))
|
|
||||||
return success({"cleared": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _reload_plugin(params: Dictionary) -> Dictionary:
|
|
||||||
# Disable and re-enable this plugin to reload all scripts
|
|
||||||
var plugin_name := "godot_mcp"
|
|
||||||
var ei := get_editor()
|
|
||||||
|
|
||||||
# Send success BEFORE reloading (connection will briefly drop)
|
|
||||||
# Use call_deferred so the response is sent first
|
|
||||||
_deferred_reload_plugin.call_deferred(ei, plugin_name)
|
|
||||||
return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})
|
|
||||||
|
|
||||||
|
|
||||||
func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
|
|
||||||
ei.set_plugin_enabled(plugin_name, false)
|
|
||||||
ei.set_plugin_enabled(plugin_name, true)
|
|
||||||
print("[MCP] Plugin reloaded")
|
|
||||||
|
|
||||||
|
|
||||||
func _reload_project(params: Dictionary) -> Dictionary:
|
|
||||||
# Rescan filesystem and reload changed scripts
|
|
||||||
var ei := get_editor()
|
|
||||||
ei.get_resource_filesystem().scan()
|
|
||||||
|
|
||||||
return success({"reloaded": true, "message": "Filesystem rescanned."})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_signals(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node '%s'" % node_path)
|
|
||||||
|
|
||||||
var signals: Array = []
|
|
||||||
for sig in node.get_signal_list():
|
|
||||||
var sig_info: Dictionary = {
|
|
||||||
"name": sig["name"],
|
|
||||||
"args": [],
|
|
||||||
}
|
|
||||||
for arg in sig["args"]:
|
|
||||||
sig_info["args"].append({"name": arg["name"], "type": arg["type"]})
|
|
||||||
|
|
||||||
# Get connections for this signal
|
|
||||||
var connections: Array = []
|
|
||||||
for conn in node.get_signal_connection_list(sig["name"]):
|
|
||||||
connections.append({
|
|
||||||
"target": str(root.get_path_to(conn["callable"].get_object())),
|
|
||||||
"method": conn["callable"].get_method(),
|
|
||||||
})
|
|
||||||
sig_info["connections"] = connections
|
|
||||||
signals.append(sig_info)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(root.get_path_to(node)),
|
|
||||||
"type": node.get_class(),
|
|
||||||
"signals": signals,
|
|
||||||
"count": signals.size(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _load_image_from_param(value: String, label: String) -> Array:
|
|
||||||
## Returns [Image, null] on success or [null, error_dict] on failure.
|
|
||||||
## Accepts a file path (res://, user://) or raw base64 PNG data.
|
|
||||||
var img := Image.new()
|
|
||||||
if value.begins_with("res://") or value.begins_with("user://"):
|
|
||||||
var err := img.load(value)
|
|
||||||
if err != OK:
|
|
||||||
return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
|
|
||||||
return [img, null]
|
|
||||||
# Treat as base64 PNG
|
|
||||||
var buf := Marshalls.base64_to_raw(value)
|
|
||||||
var err := img.load_png_from_buffer(buf)
|
|
||||||
if err != OK:
|
|
||||||
return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
|
|
||||||
return [img, null]
|
|
||||||
|
|
||||||
|
|
||||||
func _compare_screenshots(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "image_a")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var image_a_value: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "image_b")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var image_b_value: String = result2[0]
|
|
||||||
|
|
||||||
var threshold: int = optional_int(params, "threshold", 10)
|
|
||||||
|
|
||||||
# Load images (from path or base64)
|
|
||||||
var load_a := _load_image_from_param(image_a_value, "image_a")
|
|
||||||
if load_a[1] != null:
|
|
||||||
return load_a[1]
|
|
||||||
var img_a: Image = load_a[0]
|
|
||||||
|
|
||||||
var load_b := _load_image_from_param(image_b_value, "image_b")
|
|
||||||
if load_b[1] != null:
|
|
||||||
return load_b[1]
|
|
||||||
var img_b: Image = load_b[0]
|
|
||||||
|
|
||||||
if img_a.get_size() != img_b.get_size():
|
|
||||||
return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])
|
|
||||||
|
|
||||||
var width := img_a.get_width()
|
|
||||||
var height := img_a.get_height()
|
|
||||||
var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)
|
|
||||||
|
|
||||||
var changed_pixels: int = 0
|
|
||||||
var total_pixels: int = width * height
|
|
||||||
|
|
||||||
for y in height:
|
|
||||||
for x in width:
|
|
||||||
var ca: Color = img_a.get_pixel(x, y)
|
|
||||||
var cb: Color = img_b.get_pixel(x, y)
|
|
||||||
var dr := absi(int(ca.r8) - int(cb.r8))
|
|
||||||
var dg := absi(int(ca.g8) - int(cb.g8))
|
|
||||||
var db := absi(int(ca.b8) - int(cb.b8))
|
|
||||||
var max_diff := maxi(dr, maxi(dg, db))
|
|
||||||
if max_diff > threshold:
|
|
||||||
changed_pixels += 1
|
|
||||||
# Red highlight for changed pixels
|
|
||||||
diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
|
|
||||||
else:
|
|
||||||
# Dim version of original
|
|
||||||
diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))
|
|
||||||
|
|
||||||
var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
|
|
||||||
var identical: bool = changed_pixels == 0
|
|
||||||
|
|
||||||
# Encode diff image
|
|
||||||
var diff_png := diff_image.save_png_to_buffer()
|
|
||||||
var diff_base64 := Marshalls.raw_to_base64(diff_png)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"identical": identical,
|
|
||||||
"changed_pixels": changed_pixels,
|
|
||||||
"total_pixels": total_pixels,
|
|
||||||
"diff_percentage": snappedf(diff_percentage, 0.01),
|
|
||||||
"threshold": threshold,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"diff_image_base64": diff_base64,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_editor_camera(_params: Dictionary) -> Dictionary:
|
|
||||||
var vp3d := EditorInterface.get_editor_viewport_3d()
|
|
||||||
var cam := vp3d.get_camera_3d() if vp3d else null
|
|
||||||
if not cam:
|
|
||||||
return error(-32000, "No 3D editor camera found", {
|
|
||||||
"suggestion": "Make sure a 3D scene is open in the editor",
|
|
||||||
})
|
|
||||||
var pos := cam.global_position
|
|
||||||
var rot := cam.rotation_degrees
|
|
||||||
return success({
|
|
||||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
|
||||||
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
|
|
||||||
"fov": cam.fov,
|
|
||||||
"near": cam.near,
|
|
||||||
"far": cam.far,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_editor_camera(params: Dictionary) -> Dictionary:
|
|
||||||
var vp3d := EditorInterface.get_editor_viewport_3d()
|
|
||||||
var cam := vp3d.get_camera_3d() if vp3d else null
|
|
||||||
if not cam:
|
|
||||||
return error(-32000, "No 3D editor camera found", {
|
|
||||||
"suggestion": "Make sure a 3D scene is open in the editor",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Set position
|
|
||||||
if params.has("position"):
|
|
||||||
var p: Dictionary = params["position"]
|
|
||||||
cam.global_position = Vector3(
|
|
||||||
float(p.get("x", cam.global_position.x)),
|
|
||||||
float(p.get("y", cam.global_position.y)),
|
|
||||||
float(p.get("z", cam.global_position.z)),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set rotation
|
|
||||||
if params.has("rotation_degrees"):
|
|
||||||
var r: Dictionary = params["rotation_degrees"]
|
|
||||||
cam.rotation_degrees = Vector3(
|
|
||||||
float(r.get("x", cam.rotation_degrees.x)),
|
|
||||||
float(r.get("y", cam.rotation_degrees.y)),
|
|
||||||
float(r.get("z", cam.rotation_degrees.z)),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Look at target (overrides rotation if set)
|
|
||||||
if params.has("look_at"):
|
|
||||||
var t: Dictionary = params["look_at"]
|
|
||||||
cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))
|
|
||||||
|
|
||||||
# Set FOV
|
|
||||||
if params.has("fov"):
|
|
||||||
cam.fov = float(params["fov"])
|
|
||||||
|
|
||||||
var pos := cam.global_position
|
|
||||||
var rot := cam.rotation_degrees
|
|
||||||
return success({
|
|
||||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
|
||||||
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
|
|
||||||
"fov": cam.fov,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_auto_dismiss(params: Dictionary) -> Dictionary:
|
|
||||||
var enabled: bool = params.get("enabled", true)
|
|
||||||
editor_plugin.auto_dismiss_dialogs = enabled
|
|
||||||
return success({
|
|
||||||
"auto_dismiss": enabled,
|
|
||||||
"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
@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)
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
@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()
|
|
||||||
-151
@@ -1,151 +0,0 @@
|
|||||||
@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
|
|
||||||
-472
@@ -1,472 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"setup_navigation_region": _setup_navigation_region,
|
|
||||||
"bake_navigation_mesh": _bake_navigation_mesh,
|
|
||||||
"setup_navigation_agent": _setup_navigation_agent,
|
|
||||||
"set_navigation_layers": _set_navigation_layers,
|
|
||||||
"get_navigation_info": _get_navigation_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _is_3d_context(node: Node) -> bool:
|
|
||||||
if node is Node3D:
|
|
||||||
return true
|
|
||||||
if node is Node2D:
|
|
||||||
return false
|
|
||||||
# Walk up to detect context
|
|
||||||
var parent := node.get_parent()
|
|
||||||
while parent != null:
|
|
||||||
if parent is Node3D:
|
|
||||||
return true
|
|
||||||
if parent is Node2D:
|
|
||||||
return false
|
|
||||||
parent = parent.get_parent()
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_navigation_region(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var force_mode: String = optional_string(params, "mode", "auto")
|
|
||||||
var is_3d: bool
|
|
||||||
match force_mode:
|
|
||||||
"2d": is_3d = false
|
|
||||||
"3d": is_3d = true
|
|
||||||
_: is_3d = _is_3d_context(node)
|
|
||||||
|
|
||||||
if is_3d:
|
|
||||||
var region := NavigationRegion3D.new()
|
|
||||||
region.name = optional_string(params, "name", "NavigationRegion3D")
|
|
||||||
|
|
||||||
var nav_mesh := NavigationMesh.new()
|
|
||||||
nav_mesh.agent_radius = float(params.get("agent_radius", 0.5))
|
|
||||||
nav_mesh.agent_height = float(params.get("agent_height", 1.5))
|
|
||||||
nav_mesh.agent_max_climb = float(params.get("agent_max_climb", 0.25))
|
|
||||||
nav_mesh.agent_max_slope = float(params.get("agent_max_slope", 45.0))
|
|
||||||
nav_mesh.cell_size = float(params.get("cell_size", 0.25))
|
|
||||||
nav_mesh.cell_height = float(params.get("cell_height", 0.25))
|
|
||||||
region.navigation_mesh = nav_mesh
|
|
||||||
|
|
||||||
if params.has("navigation_layers"):
|
|
||||||
region.navigation_layers = int(params["navigation_layers"])
|
|
||||||
|
|
||||||
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion3D")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(region.get_path()),
|
|
||||||
"type": "NavigationRegion3D",
|
|
||||||
"agent_radius": nav_mesh.agent_radius,
|
|
||||||
"agent_height": nav_mesh.agent_height,
|
|
||||||
"cell_size": nav_mesh.cell_size,
|
|
||||||
"created": true,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
var region := NavigationRegion2D.new()
|
|
||||||
region.name = optional_string(params, "name", "NavigationRegion2D")
|
|
||||||
|
|
||||||
var nav_poly := NavigationPolygon.new()
|
|
||||||
|
|
||||||
# Set parsed geometry source if available
|
|
||||||
if params.has("source_geometry_mode"):
|
|
||||||
var mode_str: String = str(params["source_geometry_mode"])
|
|
||||||
match mode_str:
|
|
||||||
"root_node": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN
|
|
||||||
"groups_with_children": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN
|
|
||||||
"groups_explicit": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT
|
|
||||||
|
|
||||||
if params.has("cell_size"):
|
|
||||||
nav_poly.cell_size = float(params["cell_size"])
|
|
||||||
|
|
||||||
if params.has("agent_radius"):
|
|
||||||
nav_poly.agent_radius = float(params["agent_radius"])
|
|
||||||
|
|
||||||
region.navigation_polygon = nav_poly
|
|
||||||
|
|
||||||
if params.has("navigation_layers"):
|
|
||||||
region.navigation_layers = int(params["navigation_layers"])
|
|
||||||
|
|
||||||
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion2D")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(region.get_path()),
|
|
||||||
"type": "NavigationRegion2D",
|
|
||||||
"cell_size": nav_poly.cell_size,
|
|
||||||
"created": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _bake_navigation_mesh(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
if node is NavigationRegion3D:
|
|
||||||
var region: NavigationRegion3D = node as NavigationRegion3D
|
|
||||||
if region.navigation_mesh == null:
|
|
||||||
return error_invalid_params("NavigationRegion3D has no NavigationMesh resource")
|
|
||||||
region.bake_navigation_mesh()
|
|
||||||
mark_current_scene_unsaved()
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"type": "NavigationRegion3D",
|
|
||||||
"baked": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
elif node is NavigationRegion2D:
|
|
||||||
var region: NavigationRegion2D = node as NavigationRegion2D
|
|
||||||
if region.navigation_polygon == null:
|
|
||||||
var nav_poly := NavigationPolygon.new()
|
|
||||||
region.navigation_polygon = nav_poly
|
|
||||||
|
|
||||||
# Set outline vertices from params
|
|
||||||
if params.has("outline"):
|
|
||||||
var outline_data: Array = params["outline"]
|
|
||||||
var outline := PackedVector2Array()
|
|
||||||
for point in outline_data:
|
|
||||||
if point is Array and point.size() >= 2:
|
|
||||||
outline.append(Vector2(float(point[0]), float(point[1])))
|
|
||||||
elif point is Dictionary:
|
|
||||||
outline.append(Vector2(float(point.get("x", 0)), float(point.get("y", 0))))
|
|
||||||
|
|
||||||
if outline.size() >= 3:
|
|
||||||
# Clear existing outlines
|
|
||||||
while region.navigation_polygon.get_outline_count() > 0:
|
|
||||||
region.navigation_polygon.remove_outline(0)
|
|
||||||
region.navigation_polygon.add_outline(outline)
|
|
||||||
region.navigation_polygon.make_polygons_from_outlines()
|
|
||||||
mark_current_scene_unsaved()
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"type": "NavigationRegion2D",
|
|
||||||
"outline_vertices": outline.size(),
|
|
||||||
"baked": true,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return error_invalid_params("Outline must have at least 3 vertices")
|
|
||||||
else:
|
|
||||||
# Try baking from source geometry
|
|
||||||
region.bake_navigation_polygon()
|
|
||||||
mark_current_scene_unsaved()
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"type": "NavigationRegion2D",
|
|
||||||
"baked": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return error_invalid_params("Node '%s' is not a NavigationRegion2D or NavigationRegion3D" % node_path)
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_navigation_agent(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var force_mode: String = optional_string(params, "mode", "auto")
|
|
||||||
var is_3d: bool
|
|
||||||
match force_mode:
|
|
||||||
"2d": is_3d = false
|
|
||||||
"3d": is_3d = true
|
|
||||||
_: is_3d = _is_3d_context(node)
|
|
||||||
|
|
||||||
var agent_name: String = optional_string(params, "name", "NavigationAgent3D" if is_3d else "NavigationAgent2D")
|
|
||||||
|
|
||||||
if is_3d:
|
|
||||||
var agent := NavigationAgent3D.new()
|
|
||||||
agent.name = agent_name
|
|
||||||
|
|
||||||
if params.has("path_desired_distance"):
|
|
||||||
agent.path_desired_distance = float(params["path_desired_distance"])
|
|
||||||
if params.has("target_desired_distance"):
|
|
||||||
agent.target_desired_distance = float(params["target_desired_distance"])
|
|
||||||
if params.has("radius"):
|
|
||||||
agent.radius = float(params["radius"])
|
|
||||||
if params.has("neighbor_distance"):
|
|
||||||
agent.neighbor_distance = float(params["neighbor_distance"])
|
|
||||||
if params.has("max_neighbors"):
|
|
||||||
agent.max_neighbors = int(params["max_neighbors"])
|
|
||||||
if params.has("max_speed"):
|
|
||||||
agent.max_speed = float(params["max_speed"])
|
|
||||||
if params.has("avoidance_enabled"):
|
|
||||||
agent.avoidance_enabled = bool(params["avoidance_enabled"])
|
|
||||||
if params.has("navigation_layers"):
|
|
||||||
agent.navigation_layers = int(params["navigation_layers"])
|
|
||||||
|
|
||||||
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent3D")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(agent.get_path()),
|
|
||||||
"type": "NavigationAgent3D",
|
|
||||||
"radius": agent.radius,
|
|
||||||
"max_speed": agent.max_speed,
|
|
||||||
"avoidance_enabled": agent.avoidance_enabled,
|
|
||||||
"navigation_layers": agent.navigation_layers,
|
|
||||||
"created": true,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
var agent := NavigationAgent2D.new()
|
|
||||||
agent.name = agent_name
|
|
||||||
|
|
||||||
if params.has("path_desired_distance"):
|
|
||||||
agent.path_desired_distance = float(params["path_desired_distance"])
|
|
||||||
if params.has("target_desired_distance"):
|
|
||||||
agent.target_desired_distance = float(params["target_desired_distance"])
|
|
||||||
if params.has("radius"):
|
|
||||||
agent.radius = float(params["radius"])
|
|
||||||
if params.has("neighbor_distance"):
|
|
||||||
agent.neighbor_distance = float(params["neighbor_distance"])
|
|
||||||
if params.has("max_neighbors"):
|
|
||||||
agent.max_neighbors = int(params["max_neighbors"])
|
|
||||||
if params.has("max_speed"):
|
|
||||||
agent.max_speed = float(params["max_speed"])
|
|
||||||
if params.has("avoidance_enabled"):
|
|
||||||
agent.avoidance_enabled = bool(params["avoidance_enabled"])
|
|
||||||
if params.has("navigation_layers"):
|
|
||||||
agent.navigation_layers = int(params["navigation_layers"])
|
|
||||||
|
|
||||||
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent2D")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(agent.get_path()),
|
|
||||||
"type": "NavigationAgent2D",
|
|
||||||
"radius": agent.radius,
|
|
||||||
"max_speed": agent.max_speed,
|
|
||||||
"avoidance_enabled": agent.avoidance_enabled,
|
|
||||||
"navigation_layers": agent.navigation_layers,
|
|
||||||
"created": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_navigation_layers(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
# Support setting by bitmask value
|
|
||||||
if params.has("layers"):
|
|
||||||
var layers_val: int = int(params["layers"])
|
|
||||||
if node is NavigationRegion2D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationRegion3D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationAgent2D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationAgent3D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
|
||||||
else:
|
|
||||||
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"navigation_layers": layers_val,
|
|
||||||
"updated": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Support setting individual layer bits by number
|
|
||||||
if params.has("layer_bits"):
|
|
||||||
var bits: Array = params["layer_bits"]
|
|
||||||
var current_layers: int = 0
|
|
||||||
|
|
||||||
# Calculate bitmask from layer numbers (1-based)
|
|
||||||
for bit in bits:
|
|
||||||
var layer_num: int = int(bit)
|
|
||||||
if layer_num >= 1 and layer_num <= 32:
|
|
||||||
current_layers |= (1 << (layer_num - 1))
|
|
||||||
|
|
||||||
if node is NavigationRegion2D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationRegion3D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationAgent2D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationAgent3D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
else:
|
|
||||||
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"navigation_layers": current_layers,
|
|
||||||
"layer_bits": bits,
|
|
||||||
"updated": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Support named layers from ProjectSettings
|
|
||||||
if params.has("layer_names"):
|
|
||||||
var names: Array = params["layer_names"]
|
|
||||||
var current_layers: int = 0
|
|
||||||
var is_2d: bool = node is NavigationRegion2D or node is NavigationAgent2D
|
|
||||||
var prefix: String = "layer_names/2d_navigation/layer_" if is_2d else "layer_names/3d_navigation/layer_"
|
|
||||||
|
|
||||||
for i in range(1, 33):
|
|
||||||
var setting_key: String = prefix + str(i)
|
|
||||||
if ProjectSettings.has_setting(setting_key):
|
|
||||||
var layer_name: String = str(ProjectSettings.get_setting(setting_key))
|
|
||||||
if layer_name in names:
|
|
||||||
current_layers |= (1 << (i - 1))
|
|
||||||
|
|
||||||
if node is NavigationRegion2D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationRegion3D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationAgent2D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
elif node is NavigationAgent3D:
|
|
||||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
|
||||||
else:
|
|
||||||
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"navigation_layers": current_layers,
|
|
||||||
"layer_names": names,
|
|
||||||
"updated": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return error_invalid_params("Must provide 'layers' (bitmask), 'layer_bits' (array of layer numbers), or 'layer_names' (array of named layers)")
|
|
||||||
|
|
||||||
|
|
||||||
func _get_navigation_info(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var regions: Array = []
|
|
||||||
var agents: Array = []
|
|
||||||
|
|
||||||
_collect_navigation_nodes(node, regions, agents)
|
|
||||||
|
|
||||||
# Collect named layers from ProjectSettings
|
|
||||||
var layer_names_2d: Dictionary = {}
|
|
||||||
var layer_names_3d: Dictionary = {}
|
|
||||||
for i in range(1, 33):
|
|
||||||
var key_2d: String = "layer_names/2d_navigation/layer_" + str(i)
|
|
||||||
var key_3d: String = "layer_names/3d_navigation/layer_" + str(i)
|
|
||||||
if ProjectSettings.has_setting(key_2d):
|
|
||||||
var name_2d: String = str(ProjectSettings.get_setting(key_2d))
|
|
||||||
if not name_2d.is_empty():
|
|
||||||
layer_names_2d[i] = name_2d
|
|
||||||
if ProjectSettings.has_setting(key_3d):
|
|
||||||
var name_3d: String = str(ProjectSettings.get_setting(key_3d))
|
|
||||||
if not name_3d.is_empty():
|
|
||||||
layer_names_3d[i] = name_3d
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": node_path,
|
|
||||||
"regions": regions,
|
|
||||||
"agents": agents,
|
|
||||||
"region_count": regions.size(),
|
|
||||||
"agent_count": agents.size(),
|
|
||||||
"layer_names_2d": layer_names_2d,
|
|
||||||
"layer_names_3d": layer_names_3d,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _collect_navigation_nodes(node: Node, regions: Array, agents: Array) -> void:
|
|
||||||
if node is NavigationRegion2D:
|
|
||||||
var region: NavigationRegion2D = node as NavigationRegion2D
|
|
||||||
var region_info := {
|
|
||||||
"path": str(region.get_path()),
|
|
||||||
"type": "NavigationRegion2D",
|
|
||||||
"enabled": region.enabled,
|
|
||||||
"navigation_layers": region.navigation_layers,
|
|
||||||
"has_polygon": region.navigation_polygon != null,
|
|
||||||
}
|
|
||||||
if region.navigation_polygon != null:
|
|
||||||
var nav_poly: NavigationPolygon = region.navigation_polygon
|
|
||||||
region_info["outline_count"] = nav_poly.get_outline_count()
|
|
||||||
region_info["polygon_count"] = nav_poly.get_polygon_count()
|
|
||||||
region_info["cell_size"] = nav_poly.cell_size
|
|
||||||
region_info["agent_radius"] = nav_poly.agent_radius
|
|
||||||
regions.append(region_info)
|
|
||||||
|
|
||||||
elif node is NavigationRegion3D:
|
|
||||||
var region: NavigationRegion3D = node as NavigationRegion3D
|
|
||||||
var region_info := {
|
|
||||||
"path": str(region.get_path()),
|
|
||||||
"type": "NavigationRegion3D",
|
|
||||||
"enabled": region.enabled,
|
|
||||||
"navigation_layers": region.navigation_layers,
|
|
||||||
"has_mesh": region.navigation_mesh != null,
|
|
||||||
}
|
|
||||||
if region.navigation_mesh != null:
|
|
||||||
var nav_mesh: NavigationMesh = region.navigation_mesh
|
|
||||||
region_info["agent_radius"] = nav_mesh.agent_radius
|
|
||||||
region_info["agent_height"] = nav_mesh.agent_height
|
|
||||||
region_info["agent_max_climb"] = nav_mesh.agent_max_climb
|
|
||||||
region_info["agent_max_slope"] = nav_mesh.agent_max_slope
|
|
||||||
region_info["cell_size"] = nav_mesh.cell_size
|
|
||||||
region_info["cell_height"] = nav_mesh.cell_height
|
|
||||||
regions.append(region_info)
|
|
||||||
|
|
||||||
if node is NavigationAgent2D:
|
|
||||||
var agent: NavigationAgent2D = node as NavigationAgent2D
|
|
||||||
agents.append({
|
|
||||||
"path": str(agent.get_path()),
|
|
||||||
"type": "NavigationAgent2D",
|
|
||||||
"radius": agent.radius,
|
|
||||||
"max_speed": agent.max_speed,
|
|
||||||
"path_desired_distance": agent.path_desired_distance,
|
|
||||||
"target_desired_distance": agent.target_desired_distance,
|
|
||||||
"neighbor_distance": agent.neighbor_distance,
|
|
||||||
"max_neighbors": agent.max_neighbors,
|
|
||||||
"avoidance_enabled": agent.avoidance_enabled,
|
|
||||||
"navigation_layers": agent.navigation_layers,
|
|
||||||
})
|
|
||||||
|
|
||||||
elif node is NavigationAgent3D:
|
|
||||||
var agent: NavigationAgent3D = node as NavigationAgent3D
|
|
||||||
agents.append({
|
|
||||||
"path": str(agent.get_path()),
|
|
||||||
"type": "NavigationAgent3D",
|
|
||||||
"radius": agent.radius,
|
|
||||||
"max_speed": agent.max_speed,
|
|
||||||
"path_desired_distance": agent.path_desired_distance,
|
|
||||||
"target_desired_distance": agent.target_desired_distance,
|
|
||||||
"neighbor_distance": agent.neighbor_distance,
|
|
||||||
"max_neighbors": agent.max_neighbors,
|
|
||||||
"avoidance_enabled": agent.avoidance_enabled,
|
|
||||||
"navigation_layers": agent.navigation_layers,
|
|
||||||
})
|
|
||||||
|
|
||||||
for child in node.get_children():
|
|
||||||
_collect_navigation_nodes(child, regions, agents)
|
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
@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: Variant = resource.get(prop_name)
|
|
||||||
resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current)))
|
|
||||||
|
|
||||||
var old_value: Variant = node.get(property) if property in node else null
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name])
|
|
||||||
undo_redo.add_do_property(node, property, resource)
|
|
||||||
undo_redo.add_undo_property(node, property, old_value)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(root.get_path_to(node)),
|
|
||||||
"property": property,
|
|
||||||
"resource_type": resource_type,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_anchor_preset(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "preset")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var preset_name: String = result2[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
|
||||||
|
|
||||||
if not node is Control:
|
|
||||||
return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()])
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
var presets := {
|
|
||||||
"top_left": Control.PRESET_TOP_LEFT,
|
|
||||||
"top_right": Control.PRESET_TOP_RIGHT,
|
|
||||||
"bottom_left": Control.PRESET_BOTTOM_LEFT,
|
|
||||||
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
|
|
||||||
"center_left": Control.PRESET_CENTER_LEFT,
|
|
||||||
"center_top": Control.PRESET_CENTER_TOP,
|
|
||||||
"center_right": Control.PRESET_CENTER_RIGHT,
|
|
||||||
"center_bottom": Control.PRESET_CENTER_BOTTOM,
|
|
||||||
"center": Control.PRESET_CENTER,
|
|
||||||
"left_wide": Control.PRESET_LEFT_WIDE,
|
|
||||||
"top_wide": Control.PRESET_TOP_WIDE,
|
|
||||||
"right_wide": Control.PRESET_RIGHT_WIDE,
|
|
||||||
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
|
|
||||||
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
|
|
||||||
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
|
|
||||||
"full_rect": Control.PRESET_FULL_RECT,
|
|
||||||
}
|
|
||||||
|
|
||||||
if not presets.has(preset_name):
|
|
||||||
return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()])
|
|
||||||
|
|
||||||
var keep_offsets: bool = optional_bool(params, "keep_offsets", false)
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set anchor preset on %s" % node.name)
|
|
||||||
|
|
||||||
# Store old values
|
|
||||||
var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom]
|
|
||||||
var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom]
|
|
||||||
|
|
||||||
var target: Control = control.duplicate() as Control
|
|
||||||
target.set_anchors_and_offsets_preset(presets[preset_name],
|
|
||||||
Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE)
|
|
||||||
|
|
||||||
undo_redo.add_do_property(control, "anchor_left", target.anchor_left)
|
|
||||||
undo_redo.add_do_property(control, "anchor_top", target.anchor_top)
|
|
||||||
undo_redo.add_do_property(control, "anchor_right", target.anchor_right)
|
|
||||||
undo_redo.add_do_property(control, "anchor_bottom", target.anchor_bottom)
|
|
||||||
undo_redo.add_do_property(control, "offset_left", target.offset_left)
|
|
||||||
undo_redo.add_do_property(control, "offset_top", target.offset_top)
|
|
||||||
undo_redo.add_do_property(control, "offset_right", target.offset_right)
|
|
||||||
undo_redo.add_do_property(control, "offset_bottom", target.offset_bottom)
|
|
||||||
|
|
||||||
undo_redo.add_undo_property(control, "anchor_left", old_anchors[0])
|
|
||||||
undo_redo.add_undo_property(control, "anchor_top", old_anchors[1])
|
|
||||||
undo_redo.add_undo_property(control, "anchor_right", old_anchors[2])
|
|
||||||
undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3])
|
|
||||||
undo_redo.add_undo_property(control, "offset_left", old_offsets[0])
|
|
||||||
undo_redo.add_undo_property(control, "offset_top", old_offsets[1])
|
|
||||||
undo_redo.add_undo_property(control, "offset_right", old_offsets[2])
|
|
||||||
undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3])
|
|
||||||
|
|
||||||
target.free()
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"node_path": str(root.get_path_to(control)), "preset": preset_name})
|
|
||||||
|
|
||||||
|
|
||||||
func _rename_node(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "new_name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var new_name: String = result2[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
|
||||||
|
|
||||||
var old_name: String = node.name
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
|
|
||||||
undo_redo.add_do_property(node, "name", new_name)
|
|
||||||
undo_redo.add_undo_property(node, "name", old_name)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))})
|
|
||||||
|
|
||||||
|
|
||||||
func _connect_signal(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "source_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var source_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "signal_name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var signal_name: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "target_path")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var target_path: String = result3[0]
|
|
||||||
|
|
||||||
var result4 := require_string(params, "method_name")
|
|
||||||
if result4[1] != null:
|
|
||||||
return result4[1]
|
|
||||||
var method_name: String = result4[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var source := find_node_by_path(source_path)
|
|
||||||
if source == null:
|
|
||||||
return error_not_found("Source node '%s'" % source_path)
|
|
||||||
|
|
||||||
var target := find_node_by_path(target_path)
|
|
||||||
if target == null:
|
|
||||||
return error_not_found("Target node '%s'" % target_path)
|
|
||||||
|
|
||||||
if not source.has_signal(signal_name):
|
|
||||||
return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()])
|
|
||||||
|
|
||||||
if source.is_connected(signal_name, Callable(target, method_name)):
|
|
||||||
return success({"already_connected": true, "signal": signal_name})
|
|
||||||
|
|
||||||
var callable := Callable(target, method_name)
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Connect signal")
|
|
||||||
undo_redo.add_do_method(source, "connect", signal_name, callable)
|
|
||||||
undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"source": str(root.get_path_to(source)),
|
|
||||||
"signal": signal_name,
|
|
||||||
"target": str(root.get_path_to(target)),
|
|
||||||
"method": method_name,
|
|
||||||
"connected": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _disconnect_signal(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "source_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var source_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "signal_name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var signal_name: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "target_path")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var target_path: String = result3[0]
|
|
||||||
|
|
||||||
var result4 := require_string(params, "method_name")
|
|
||||||
if result4[1] != null:
|
|
||||||
return result4[1]
|
|
||||||
var method_name: String = result4[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var source := find_node_by_path(source_path)
|
|
||||||
if source == null:
|
|
||||||
return error_not_found("Source node '%s'" % source_path)
|
|
||||||
|
|
||||||
var target := find_node_by_path(target_path)
|
|
||||||
if target == null:
|
|
||||||
return error_not_found("Target node '%s'" % target_path)
|
|
||||||
|
|
||||||
if not source.is_connected(signal_name, Callable(target, method_name)):
|
|
||||||
return success({"was_connected": false})
|
|
||||||
|
|
||||||
var callable := Callable(target, method_name)
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Disconnect signal")
|
|
||||||
undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
|
||||||
undo_redo.add_undo_method(source, "connect", signal_name, callable)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"source": str(root.get_path_to(source)),
|
|
||||||
"signal": signal_name,
|
|
||||||
"target": str(root.get_path_to(target)),
|
|
||||||
"method": method_name,
|
|
||||||
"disconnected": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_node_groups(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
|
||||||
|
|
||||||
var groups: Array = []
|
|
||||||
for group: StringName in node.get_groups():
|
|
||||||
var g := str(group)
|
|
||||||
# Filter out internal groups (start with _)
|
|
||||||
if not g.begins_with("_"):
|
|
||||||
groups.append(g)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(root.get_path_to(node)),
|
|
||||||
"groups": groups,
|
|
||||||
"count": groups.size(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_node_groups(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
if not params.has("groups") or not params["groups"] is Array:
|
|
||||||
return error_invalid_params("'groups' array is required")
|
|
||||||
var desired_groups: Array = params["groups"]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
|
||||||
|
|
||||||
# Get current non-internal groups
|
|
||||||
var current_groups: Array = []
|
|
||||||
for group: StringName in node.get_groups():
|
|
||||||
var g := str(group)
|
|
||||||
if not g.begins_with("_"):
|
|
||||||
current_groups.append(g)
|
|
||||||
|
|
||||||
var added: Array = []
|
|
||||||
var removed: Array = []
|
|
||||||
|
|
||||||
for group: String in current_groups:
|
|
||||||
if group not in desired_groups:
|
|
||||||
removed.append(group)
|
|
||||||
|
|
||||||
for group in desired_groups:
|
|
||||||
var g: String = str(group)
|
|
||||||
if g not in current_groups:
|
|
||||||
added.append(g)
|
|
||||||
|
|
||||||
if not added.is_empty() or not removed.is_empty():
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set node groups")
|
|
||||||
for group: String in removed:
|
|
||||||
undo_redo.add_do_method(node, "remove_from_group", group)
|
|
||||||
undo_redo.add_undo_method(node, "add_to_group", group, true)
|
|
||||||
for group: String in added:
|
|
||||||
undo_redo.add_do_method(node, "add_to_group", group, true)
|
|
||||||
undo_redo.add_undo_method(node, "remove_from_group", group)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(root.get_path_to(node)),
|
|
||||||
"groups": desired_groups,
|
|
||||||
"added": added,
|
|
||||||
"removed": removed,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _find_nodes_in_group(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "group")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var group_name: String = result[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var matches: Array = []
|
|
||||||
_find_in_group_recursive(root, root, group_name, matches)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"group": group_name,
|
|
||||||
"nodes": matches,
|
|
||||||
"count": matches.size(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void:
|
|
||||||
if node.is_in_group(group_name):
|
|
||||||
matches.append({
|
|
||||||
"name": node.name,
|
|
||||||
"path": str(root.get_path_to(node)),
|
|
||||||
"type": node.get_class(),
|
|
||||||
})
|
|
||||||
for child in node.get_children():
|
|
||||||
_find_in_group_recursive(child, root, group_name, matches)
|
|
||||||
-612
@@ -1,612 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"create_particles": _create_particles,
|
|
||||||
"set_particle_material": _set_particle_material,
|
|
||||||
"set_particle_color_gradient": _set_particle_color_gradient,
|
|
||||||
"apply_particle_preset": _apply_particle_preset,
|
|
||||||
"get_particle_info": _get_particle_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _get_particles_node(node_path: String) -> GPUParticles2D:
|
|
||||||
# Returns any GPUParticles2D or GPUParticles3D (both share similar API)
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node is GPUParticles2D:
|
|
||||||
return node as GPUParticles2D
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _get_particles_node_any(node_path: String) -> Node:
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node is GPUParticles2D or node is GPUParticles3D:
|
|
||||||
return node
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _parse_color(color_str: String) -> Color:
|
|
||||||
# Support hex "#RRGGBB", "#RRGGBBAA", or named colors
|
|
||||||
if color_str.begins_with("#"):
|
|
||||||
return Color.html(color_str)
|
|
||||||
# Try named color
|
|
||||||
match color_str.to_lower():
|
|
||||||
"red": return Color.RED
|
|
||||||
"green": return Color.GREEN
|
|
||||||
"blue": return Color.BLUE
|
|
||||||
"white": return Color.WHITE
|
|
||||||
"black": return Color.BLACK
|
|
||||||
"yellow": return Color.YELLOW
|
|
||||||
"orange": return Color(1.0, 0.5, 0.0)
|
|
||||||
"gray", "grey": return Color.GRAY
|
|
||||||
"cyan": return Color.CYAN
|
|
||||||
"magenta": return Color.MAGENTA
|
|
||||||
"transparent": return Color(0, 0, 0, 0)
|
|
||||||
# Try Expression parser for Color(r,g,b,a)
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(color_str) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed is Color:
|
|
||||||
return parsed
|
|
||||||
return Color.WHITE
|
|
||||||
|
|
||||||
|
|
||||||
func _create_particles(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "parent_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var parent_path: String = result[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var parent := find_node_by_path(parent_path)
|
|
||||||
if parent == null:
|
|
||||||
return error_not_found("Node at '%s'" % parent_path)
|
|
||||||
|
|
||||||
var node_name: String = optional_string(params, "name", "Particles")
|
|
||||||
var is_3d: bool = optional_bool(params, "is_3d", false)
|
|
||||||
var amount: int = optional_int(params, "amount", 16)
|
|
||||||
var lifetime: float = float(params.get("lifetime", 1.0))
|
|
||||||
var one_shot: bool = optional_bool(params, "one_shot", false)
|
|
||||||
var explosiveness: float = float(params.get("explosiveness", 0.0))
|
|
||||||
var randomness: float = float(params.get("randomness", 0.0))
|
|
||||||
var emitting: bool = optional_bool(params, "emitting", true)
|
|
||||||
|
|
||||||
var particles_node: Node
|
|
||||||
if is_3d:
|
|
||||||
var p := GPUParticles3D.new()
|
|
||||||
p.name = node_name
|
|
||||||
p.amount = amount
|
|
||||||
p.lifetime = lifetime
|
|
||||||
p.one_shot = one_shot
|
|
||||||
p.explosiveness = explosiveness
|
|
||||||
p.randomness = randomness
|
|
||||||
p.emitting = emitting
|
|
||||||
var mat := ParticleProcessMaterial.new()
|
|
||||||
p.process_material = mat
|
|
||||||
particles_node = p
|
|
||||||
else:
|
|
||||||
var p := GPUParticles2D.new()
|
|
||||||
p.name = node_name
|
|
||||||
p.amount = amount
|
|
||||||
p.lifetime = lifetime
|
|
||||||
p.one_shot = one_shot
|
|
||||||
p.explosiveness = explosiveness
|
|
||||||
p.randomness = randomness
|
|
||||||
p.emitting = emitting
|
|
||||||
var mat := ParticleProcessMaterial.new()
|
|
||||||
p.process_material = mat
|
|
||||||
particles_node = p
|
|
||||||
|
|
||||||
add_child_with_undo(parent, particles_node, root, "MCP: Create particles")
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"name": particles_node.name,
|
|
||||||
"parent": parent_path,
|
|
||||||
"is_3d": is_3d,
|
|
||||||
"amount": amount,
|
|
||||||
"lifetime": lifetime,
|
|
||||||
"one_shot": one_shot,
|
|
||||||
"created": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_particle_material(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := _get_particles_node_any(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
|
||||||
|
|
||||||
var old_mat: ParticleProcessMaterial = node.get("process_material")
|
|
||||||
var mat: ParticleProcessMaterial
|
|
||||||
if old_mat != null:
|
|
||||||
mat = old_mat.duplicate(true) as ParticleProcessMaterial
|
|
||||||
else:
|
|
||||||
mat = ParticleProcessMaterial.new()
|
|
||||||
|
|
||||||
var changes: Array = []
|
|
||||||
|
|
||||||
# Direction
|
|
||||||
if params.has("direction"):
|
|
||||||
var dir = params["direction"]
|
|
||||||
if dir is Dictionary:
|
|
||||||
mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0)))
|
|
||||||
changes.append("direction")
|
|
||||||
elif dir is String:
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(dir) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed is Vector3:
|
|
||||||
mat.direction = parsed
|
|
||||||
changes.append("direction")
|
|
||||||
|
|
||||||
# Spread
|
|
||||||
if params.has("spread"):
|
|
||||||
mat.spread = float(params["spread"])
|
|
||||||
changes.append("spread")
|
|
||||||
|
|
||||||
# Initial velocity
|
|
||||||
if params.has("initial_velocity_min"):
|
|
||||||
mat.initial_velocity_min = float(params["initial_velocity_min"])
|
|
||||||
changes.append("initial_velocity_min")
|
|
||||||
if params.has("initial_velocity_max"):
|
|
||||||
mat.initial_velocity_max = float(params["initial_velocity_max"])
|
|
||||||
changes.append("initial_velocity_max")
|
|
||||||
|
|
||||||
# Gravity
|
|
||||||
if params.has("gravity"):
|
|
||||||
var grav = params["gravity"]
|
|
||||||
if grav is Dictionary:
|
|
||||||
mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0)))
|
|
||||||
changes.append("gravity")
|
|
||||||
elif grav is String:
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(grav) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed is Vector3:
|
|
||||||
mat.gravity = parsed
|
|
||||||
changes.append("gravity")
|
|
||||||
|
|
||||||
# Scale
|
|
||||||
if params.has("scale_min"):
|
|
||||||
mat.scale_min = float(params["scale_min"])
|
|
||||||
changes.append("scale_min")
|
|
||||||
if params.has("scale_max"):
|
|
||||||
mat.scale_max = float(params["scale_max"])
|
|
||||||
changes.append("scale_max")
|
|
||||||
|
|
||||||
# Color
|
|
||||||
if params.has("color"):
|
|
||||||
mat.color = _parse_color(str(params["color"]))
|
|
||||||
changes.append("color")
|
|
||||||
|
|
||||||
# Emission shape
|
|
||||||
if params.has("emission_shape"):
|
|
||||||
var shape_str: String = str(params["emission_shape"]).to_lower()
|
|
||||||
match shape_str:
|
|
||||||
"point":
|
|
||||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
|
|
||||||
"sphere":
|
|
||||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
|
||||||
if params.has("emission_sphere_radius"):
|
|
||||||
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
|
|
||||||
"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")
|
|
||||||
|
|
||||||
if not changes.is_empty():
|
|
||||||
set_property_with_undo(node, "process_material", mat, "MCP: Set particle material")
|
|
||||||
return success({"node_path": node_path, "changes": changes})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_particle_color_gradient(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := _get_particles_node_any(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
|
||||||
|
|
||||||
var old_mat: ParticleProcessMaterial = node.get("process_material")
|
|
||||||
var mat: ParticleProcessMaterial
|
|
||||||
if old_mat != null:
|
|
||||||
mat = old_mat.duplicate(true) as ParticleProcessMaterial
|
|
||||||
else:
|
|
||||||
mat = ParticleProcessMaterial.new()
|
|
||||||
|
|
||||||
if not params.has("stops") or not params["stops"] is Array:
|
|
||||||
return error_invalid_params("Missing required parameter: stops (array of {offset, color})")
|
|
||||||
|
|
||||||
var stops: Array = params["stops"]
|
|
||||||
if stops.is_empty():
|
|
||||||
return error_invalid_params("stops array must not be empty")
|
|
||||||
|
|
||||||
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
|
|
||||||
set_property_with_undo(node, "process_material", mat, "MCP: Set particle color gradient")
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "stops_count": gradient.get_point_count()})
|
|
||||||
|
|
||||||
|
|
||||||
func _apply_particle_preset(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "preset")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var preset: String = result2[0].to_lower()
|
|
||||||
|
|
||||||
var node := _get_particles_node_any(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
|
||||||
|
|
||||||
var old_state := _capture_particle_state(node)
|
|
||||||
var preset_state := {}
|
|
||||||
var mat := ParticleProcessMaterial.new()
|
|
||||||
var is_2d: bool = node is GPUParticles2D
|
|
||||||
|
|
||||||
# Default gravity for 2D (Y-down) vs 3D (Y-down)
|
|
||||||
var gravity_down := Vector3(0, 98.0 if is_2d else 9.8, 0)
|
|
||||||
var _gravity_up := Vector3(0, -98.0 if is_2d else -9.8, 0)
|
|
||||||
var gravity_none := Vector3.ZERO
|
|
||||||
|
|
||||||
match preset:
|
|
||||||
"explosion":
|
|
||||||
preset_state["amount"] = 32
|
|
||||||
preset_state["lifetime"] = 0.6
|
|
||||||
preset_state["one_shot"] = true
|
|
||||||
preset_state["explosiveness"] = 1.0
|
|
||||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
|
||||||
mat.spread = 180.0
|
|
||||||
mat.initial_velocity_min = 100.0 if is_2d else 5.0
|
|
||||||
mat.initial_velocity_max = 200.0 if is_2d else 10.0
|
|
||||||
mat.gravity = gravity_down * 0.5
|
|
||||||
mat.damping_min = 2.0
|
|
||||||
mat.damping_max = 4.0
|
|
||||||
mat.scale_min = 0.5
|
|
||||||
mat.scale_max = 1.5
|
|
||||||
mat.color = Color(1.0, 0.6, 0.1)
|
|
||||||
_apply_gradient(mat, [
|
|
||||||
{"offset": 0.0, "color": Color.WHITE},
|
|
||||||
{"offset": 0.3, "color": Color(1.0, 0.8, 0.2)},
|
|
||||||
{"offset": 0.7, "color": Color(1.0, 0.3, 0.0)},
|
|
||||||
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
|
|
||||||
])
|
|
||||||
|
|
||||||
"fire":
|
|
||||||
preset_state["amount"] = 24
|
|
||||||
preset_state["lifetime"] = 1.2
|
|
||||||
preset_state["one_shot"] = false
|
|
||||||
preset_state["explosiveness"] = 0.0
|
|
||||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
|
||||||
mat.spread = 15.0
|
|
||||||
mat.initial_velocity_min = 30.0 if is_2d else 1.5
|
|
||||||
mat.initial_velocity_max = 60.0 if is_2d else 3.0
|
|
||||||
mat.gravity = gravity_none
|
|
||||||
mat.scale_min = 0.8
|
|
||||||
mat.scale_max = 1.5
|
|
||||||
_apply_gradient(mat, [
|
|
||||||
{"offset": 0.0, "color": Color(1.0, 1.0, 0.5)},
|
|
||||||
{"offset": 0.3, "color": Color(1.0, 0.6, 0.0)},
|
|
||||||
{"offset": 0.7, "color": Color(0.8, 0.2, 0.0)},
|
|
||||||
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
|
|
||||||
])
|
|
||||||
|
|
||||||
"smoke":
|
|
||||||
preset_state["amount"] = 16
|
|
||||||
preset_state["lifetime"] = 3.0
|
|
||||||
preset_state["one_shot"] = false
|
|
||||||
preset_state["explosiveness"] = 0.0
|
|
||||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
|
||||||
mat.spread = 25.0
|
|
||||||
mat.initial_velocity_min = 10.0 if is_2d else 0.5
|
|
||||||
mat.initial_velocity_max = 25.0 if is_2d else 1.2
|
|
||||||
mat.gravity = gravity_none
|
|
||||||
mat.scale_min = 1.5
|
|
||||||
mat.scale_max = 3.0
|
|
||||||
mat.damping_min = 1.0
|
|
||||||
mat.damping_max = 2.0
|
|
||||||
_apply_gradient(mat, [
|
|
||||||
{"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)},
|
|
||||||
{"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)},
|
|
||||||
{"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)},
|
|
||||||
])
|
|
||||||
|
|
||||||
"sparks":
|
|
||||||
preset_state["amount"] = 48
|
|
||||||
preset_state["lifetime"] = 0.4
|
|
||||||
preset_state["one_shot"] = true
|
|
||||||
preset_state["explosiveness"] = 0.95
|
|
||||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
|
||||||
mat.spread = 180.0
|
|
||||||
mat.initial_velocity_min = 200.0 if is_2d else 8.0
|
|
||||||
mat.initial_velocity_max = 400.0 if is_2d else 16.0
|
|
||||||
mat.gravity = gravity_down
|
|
||||||
mat.scale_min = 0.1
|
|
||||||
mat.scale_max = 0.3
|
|
||||||
mat.damping_min = 1.0
|
|
||||||
mat.damping_max = 3.0
|
|
||||||
_apply_gradient(mat, [
|
|
||||||
{"offset": 0.0, "color": Color(1.0, 1.0, 0.8)},
|
|
||||||
{"offset": 0.5, "color": Color(1.0, 0.7, 0.2)},
|
|
||||||
{"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)},
|
|
||||||
])
|
|
||||||
|
|
||||||
"rain":
|
|
||||||
preset_state["amount"] = 64
|
|
||||||
preset_state["lifetime"] = 0.8
|
|
||||||
preset_state["one_shot"] = false
|
|
||||||
preset_state["explosiveness"] = 0.0
|
|
||||||
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
|
|
||||||
mat.spread = 5.0
|
|
||||||
mat.initial_velocity_min = 300.0 if is_2d else 12.0
|
|
||||||
mat.initial_velocity_max = 400.0 if is_2d else 16.0
|
|
||||||
mat.gravity = gravity_down
|
|
||||||
mat.scale_min = 0.1
|
|
||||||
mat.scale_max = 0.2
|
|
||||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
|
||||||
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
|
|
||||||
mat.color = Color(0.6, 0.7, 1.0, 0.7)
|
|
||||||
|
|
||||||
"snow":
|
|
||||||
preset_state["amount"] = 48
|
|
||||||
preset_state["lifetime"] = 4.0
|
|
||||||
preset_state["one_shot"] = false
|
|
||||||
preset_state["explosiveness"] = 0.0
|
|
||||||
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
|
|
||||||
mat.spread = 20.0
|
|
||||||
mat.initial_velocity_min = 20.0 if is_2d else 0.8
|
|
||||||
mat.initial_velocity_max = 40.0 if is_2d else 1.5
|
|
||||||
mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0)
|
|
||||||
mat.scale_min = 0.3
|
|
||||||
mat.scale_max = 0.8
|
|
||||||
mat.angular_velocity_min = -45.0
|
|
||||||
mat.angular_velocity_max = 45.0
|
|
||||||
mat.damping_min = 0.5
|
|
||||||
mat.damping_max = 1.5
|
|
||||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
|
||||||
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
|
|
||||||
mat.color = Color(1.0, 1.0, 1.0, 0.9)
|
|
||||||
|
|
||||||
"magic":
|
|
||||||
preset_state["amount"] = 24
|
|
||||||
preset_state["lifetime"] = 2.0
|
|
||||||
preset_state["one_shot"] = false
|
|
||||||
preset_state["explosiveness"] = 0.0
|
|
||||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
|
||||||
mat.spread = 180.0
|
|
||||||
mat.initial_velocity_min = 20.0 if is_2d else 1.0
|
|
||||||
mat.initial_velocity_max = 50.0 if is_2d else 2.5
|
|
||||||
mat.gravity = gravity_none
|
|
||||||
mat.orbit_velocity_min = 0.5
|
|
||||||
mat.orbit_velocity_max = 1.5
|
|
||||||
mat.scale_min = 0.3
|
|
||||||
mat.scale_max = 0.8
|
|
||||||
mat.damping_min = 1.0
|
|
||||||
mat.damping_max = 2.0
|
|
||||||
_apply_gradient(mat, [
|
|
||||||
{"offset": 0.0, "color": Color(0.3, 0.5, 1.0)},
|
|
||||||
{"offset": 0.25, "color": Color(1.0, 0.3, 0.8)},
|
|
||||||
{"offset": 0.5, "color": Color(0.3, 1.0, 0.5)},
|
|
||||||
{"offset": 0.75, "color": Color(1.0, 0.8, 0.2)},
|
|
||||||
{"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)},
|
|
||||||
])
|
|
||||||
|
|
||||||
"dust":
|
|
||||||
preset_state["amount"] = 12
|
|
||||||
preset_state["lifetime"] = 5.0
|
|
||||||
preset_state["one_shot"] = false
|
|
||||||
preset_state["explosiveness"] = 0.0
|
|
||||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
|
||||||
mat.spread = 180.0
|
|
||||||
mat.initial_velocity_min = 3.0 if is_2d else 0.1
|
|
||||||
mat.initial_velocity_max = 8.0 if is_2d else 0.3
|
|
||||||
mat.gravity = gravity_none
|
|
||||||
mat.scale_min = 0.2
|
|
||||||
mat.scale_max = 0.5
|
|
||||||
mat.damping_min = 0.5
|
|
||||||
mat.damping_max = 1.0
|
|
||||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
|
||||||
mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3)
|
|
||||||
_apply_gradient(mat, [
|
|
||||||
{"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
|
|
||||||
{"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)},
|
|
||||||
{"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)},
|
|
||||||
{"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
|
|
||||||
])
|
|
||||||
|
|
||||||
_:
|
|
||||||
return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset)
|
|
||||||
|
|
||||||
preset_state["process_material"] = mat
|
|
||||||
_register_particle_state_undo(node, old_state, preset_state, "MCP: Apply particle preset")
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "preset": preset, "applied": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _capture_particle_state(node: Node) -> Dictionary:
|
|
||||||
var state := {}
|
|
||||||
for property: String in ["amount", "lifetime", "one_shot", "explosiveness", "randomness", "emitting", "process_material"]:
|
|
||||||
if property in node:
|
|
||||||
state[property] = node.get(property)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
func _register_particle_state_undo(node: Node, old_state: Dictionary, new_state: Dictionary, action_name: String) -> void:
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action(action_name)
|
|
||||||
for property: String in new_state:
|
|
||||||
undo_redo.add_do_property(node, property, new_state[property])
|
|
||||||
if new_state[property] is Resource:
|
|
||||||
undo_redo.add_do_reference(new_state[property])
|
|
||||||
undo_redo.add_undo_property(node, property, old_state.get(property, null))
|
|
||||||
if old_state.get(property, null) is Resource:
|
|
||||||
undo_redo.add_undo_reference(old_state[property])
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
|
|
||||||
func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void:
|
|
||||||
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)
|
|
||||||
-757
@@ -1,757 +0,0 @@
|
|||||||
@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)
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
@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)
|
|
||||||
-390
@@ -1,390 +0,0 @@
|
|||||||
@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,
|
|
||||||
})
|
|
||||||
-201
@@ -1,201 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"read_resource": _read_resource,
|
|
||||||
"edit_resource": _edit_resource,
|
|
||||||
"create_resource": _create_resource,
|
|
||||||
"get_resource_preview": _get_resource_preview,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _read_resource(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Resource '%s'" % path)
|
|
||||||
|
|
||||||
var guard := guard_offline_scene_save(path)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
var resource: Resource = ResourceLoader.load(path)
|
|
||||||
if resource == null:
|
|
||||||
return error_internal("Failed to load resource: %s" % path)
|
|
||||||
|
|
||||||
var props: Dictionary = {}
|
|
||||||
for prop_info in resource.get_property_list():
|
|
||||||
var prop_name: String = prop_info["name"]
|
|
||||||
var usage: int = prop_info["usage"]
|
|
||||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
|
||||||
continue
|
|
||||||
if prop_name.begins_with("_") or prop_name == "script" or prop_name == "resource_local_to_scene" or prop_name == "resource_name" or prop_name == "resource_path":
|
|
||||||
continue
|
|
||||||
props[prop_name] = PropertyParser.serialize_value(resource.get(prop_name))
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"path": path,
|
|
||||||
"type": resource.get_class(),
|
|
||||||
"resource_name": resource.resource_name,
|
|
||||||
"properties": props,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _edit_resource(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not params.has("properties") or not params["properties"] is Dictionary:
|
|
||||||
return error_invalid_params("'properties' dictionary is required")
|
|
||||||
var new_props: Dictionary = params["properties"]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Resource '%s'" % path)
|
|
||||||
|
|
||||||
var guard := guard_offline_scene_save(path)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
var resource: Resource = ResourceLoader.load(path)
|
|
||||||
if resource == null:
|
|
||||||
return error_internal("Failed to load resource: %s" % path)
|
|
||||||
|
|
||||||
var changed: Dictionary = {}
|
|
||||||
for prop_name: String in new_props:
|
|
||||||
if not prop_name in resource:
|
|
||||||
continue
|
|
||||||
var old_value: Variant = resource.get(prop_name)
|
|
||||||
var target_type := typeof(old_value)
|
|
||||||
var new_value: Variant = PropertyParser.parse_value(new_props[prop_name], target_type)
|
|
||||||
resource.set(prop_name, new_value)
|
|
||||||
changed[prop_name] = {
|
|
||||||
"old": PropertyParser.serialize_value(old_value),
|
|
||||||
"new": PropertyParser.serialize_value(resource.get(prop_name)),
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed.is_empty():
|
|
||||||
return success({"path": path, "changed": {}, "message": "No properties were changed"})
|
|
||||||
|
|
||||||
var err := ResourceSaver.save(resource, path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to save resource: %s" % error_string(err))
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"path": path,
|
|
||||||
"type": resource.get_class(),
|
|
||||||
"changed": changed,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _create_resource(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "type")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var resource_type: String = result2[0]
|
|
||||||
|
|
||||||
if not ClassDB.class_exists(resource_type):
|
|
||||||
return error_invalid_params("Unknown resource type: %s" % resource_type)
|
|
||||||
if not ClassDB.is_parent_class(resource_type, "Resource"):
|
|
||||||
return error_invalid_params("'%s' is not a Resource type" % resource_type)
|
|
||||||
|
|
||||||
var overwrite: bool = optional_bool(params, "overwrite", false)
|
|
||||||
if FileAccess.file_exists(path) and not overwrite:
|
|
||||||
return error(-32000, "Resource already exists: %s" % path, {"suggestion": "Set overwrite=true to replace"})
|
|
||||||
|
|
||||||
var guard := guard_offline_scene_save(path)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
var resource: Resource = ClassDB.instantiate(resource_type)
|
|
||||||
if resource == null:
|
|
||||||
return error_internal("Failed to instantiate: %s" % resource_type)
|
|
||||||
|
|
||||||
# Apply properties
|
|
||||||
var properties: Dictionary = params.get("properties", {})
|
|
||||||
for prop_name: String in properties:
|
|
||||||
if prop_name in resource:
|
|
||||||
var current: Variant = resource.get(prop_name)
|
|
||||||
resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current)))
|
|
||||||
|
|
||||||
var err := ResourceSaver.save(resource, path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to save resource: %s" % error_string(err))
|
|
||||||
|
|
||||||
# Rescan filesystem
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"path": path,
|
|
||||||
"type": resource_type,
|
|
||||||
"properties_set": properties.keys(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_resource_preview(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Resource '%s'" % path)
|
|
||||||
|
|
||||||
var max_size: int = optional_int(params, "max_size", 256)
|
|
||||||
var image: Image = null
|
|
||||||
|
|
||||||
# Try loading as image file directly
|
|
||||||
var ext := path.get_extension().to_lower()
|
|
||||||
if ext in ["png", "jpg", "jpeg", "bmp", "webp", "svg"]:
|
|
||||||
image = Image.new()
|
|
||||||
var err := image.load(path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to load image: %s" % error_string(err))
|
|
||||||
else:
|
|
||||||
# Try loading as resource and extracting image
|
|
||||||
var resource: Resource = ResourceLoader.load(path)
|
|
||||||
if resource == null:
|
|
||||||
return error_internal("Failed to load resource: %s" % path)
|
|
||||||
|
|
||||||
if resource is Texture2D:
|
|
||||||
image = (resource as Texture2D).get_image()
|
|
||||||
elif resource is Image:
|
|
||||||
image = resource as Image
|
|
||||||
else:
|
|
||||||
return error_invalid_params("Resource type '%s' does not have an image preview" % resource.get_class())
|
|
||||||
|
|
||||||
if image == null:
|
|
||||||
return error_internal("Could not extract image from resource")
|
|
||||||
|
|
||||||
# Resize if needed
|
|
||||||
if image.get_width() > max_size or image.get_height() > max_size:
|
|
||||||
var scale_x := float(max_size) / float(image.get_width())
|
|
||||||
var scale_y := float(max_size) / float(image.get_height())
|
|
||||||
var scale := minf(scale_x, scale_y)
|
|
||||||
var new_w := int(image.get_width() * scale)
|
|
||||||
var new_h := int(image.get_height() * scale)
|
|
||||||
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
|
|
||||||
|
|
||||||
var png_buffer := image.save_png_to_buffer()
|
|
||||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"image_base64": base64,
|
|
||||||
"width": image.get_width(),
|
|
||||||
"height": image.get_height(),
|
|
||||||
"format": "png",
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
-398
@@ -1,398 +0,0 @@
|
|||||||
@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)
|
|
||||||
-680
@@ -1,680 +0,0 @@
|
|||||||
@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,
|
|
||||||
})
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
@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 guard := guard_offline_scene_save(path)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
var root_type: String = optional_string(params, "root_type", "Node2D")
|
|
||||||
var root_name: String = optional_string(params, "root_name", "")
|
|
||||||
|
|
||||||
# Validate root type exists
|
|
||||||
if not ClassDB.class_exists(root_type):
|
|
||||||
return error_invalid_params("Unknown node type: %s" % root_type)
|
|
||||||
|
|
||||||
# Create the scene
|
|
||||||
var root: Node = ClassDB.instantiate(root_type)
|
|
||||||
if root_name.is_empty():
|
|
||||||
root_name = path.get_file().get_basename()
|
|
||||||
root.name = root_name
|
|
||||||
|
|
||||||
var scene := PackedScene.new()
|
|
||||||
var err := scene.pack(root)
|
|
||||||
root.queue_free()
|
|
||||||
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to pack scene: %s" % error_string(err))
|
|
||||||
|
|
||||||
# Ensure directory exists
|
|
||||||
var dir_path := path.get_base_dir()
|
|
||||||
if not DirAccess.dir_exists_absolute(dir_path):
|
|
||||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
|
||||||
|
|
||||||
err = ResourceSaver.save(scene, path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to save scene: %s" % error_string(err))
|
|
||||||
|
|
||||||
# Refresh filesystem
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
|
|
||||||
return success({"path": path, "root_type": root_type, "root_name": root_name})
|
|
||||||
|
|
||||||
|
|
||||||
func _open_scene(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Scene file '%s'" % path)
|
|
||||||
|
|
||||||
EditorInterface.open_scene_from_path(path)
|
|
||||||
return success({"path": path, "opened": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _delete_scene(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Scene file '%s'" % path)
|
|
||||||
|
|
||||||
var err := DirAccess.remove_absolute(path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to delete scene: %s" % error_string(err))
|
|
||||||
|
|
||||||
# Also remove .import file if exists
|
|
||||||
var import_path := path + ".import"
|
|
||||||
if FileAccess.file_exists(import_path):
|
|
||||||
DirAccess.remove_absolute(import_path)
|
|
||||||
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
return success({"path": path, "deleted": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _add_scene_instance(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "scene_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var scene_path: String = result[0]
|
|
||||||
|
|
||||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
|
||||||
var instance_name: String = optional_string(params, "name", "")
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(scene_path):
|
|
||||||
return error_not_found("Scene file '%s'" % scene_path)
|
|
||||||
|
|
||||||
var parent := find_node_by_path(parent_path)
|
|
||||||
if parent == null:
|
|
||||||
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
|
|
||||||
|
|
||||||
var packed: PackedScene = load(scene_path)
|
|
||||||
if packed == null:
|
|
||||||
return error_internal("Failed to load scene: %s" % scene_path)
|
|
||||||
|
|
||||||
var instance := packed.instantiate()
|
|
||||||
if not instance_name.is_empty():
|
|
||||||
instance.name = instance_name
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Add scene instance")
|
|
||||||
undo_redo.add_do_method(parent, "add_child", instance)
|
|
||||||
undo_redo.add_do_method(instance, "set_owner", root)
|
|
||||||
undo_redo.add_do_reference(instance)
|
|
||||||
undo_redo.add_undo_method(parent, "remove_child", instance)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
NodeUtils.set_owner_recursive(instance, root)
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(root.get_path_to(instance)),
|
|
||||||
"scene_path": scene_path,
|
|
||||||
"name": instance.name,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _play_scene(params: Dictionary) -> Dictionary:
|
|
||||||
var mode: String = optional_string(params, "mode", "main") # "main", "current", or path
|
|
||||||
|
|
||||||
match mode:
|
|
||||||
"main":
|
|
||||||
EditorInterface.play_main_scene()
|
|
||||||
"current":
|
|
||||||
EditorInterface.play_current_scene()
|
|
||||||
_:
|
|
||||||
# Treat as scene path
|
|
||||||
if not FileAccess.file_exists(mode):
|
|
||||||
return error_not_found("Scene file '%s'" % mode)
|
|
||||||
EditorInterface.play_custom_scene(mode)
|
|
||||||
|
|
||||||
return success({"playing": true, "mode": mode})
|
|
||||||
|
|
||||||
|
|
||||||
func _stop_scene(_params: Dictionary) -> Dictionary:
|
|
||||||
if not EditorInterface.is_playing_scene():
|
|
||||||
return success({"stopped": false, "message": "No scene is currently playing"})
|
|
||||||
|
|
||||||
EditorInterface.stop_playing_scene()
|
|
||||||
|
|
||||||
# Clean up temp files
|
|
||||||
_cleanup_screenshot_files()
|
|
||||||
_cleanup_input_files()
|
|
||||||
_cleanup_inspector_files()
|
|
||||||
|
|
||||||
return success({"stopped": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _save_scene(params: Dictionary) -> Dictionary:
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var path: String = optional_string(params, "path", "")
|
|
||||||
if path.is_empty():
|
|
||||||
path = root.scene_file_path
|
|
||||||
|
|
||||||
if path.is_empty():
|
|
||||||
return error_invalid_params("No save path specified and scene has no existing path")
|
|
||||||
|
|
||||||
var normalized_path := normalize_project_path(path)
|
|
||||||
if is_scene_path_open(normalized_path) and not is_active_scene_path(normalized_path):
|
|
||||||
return error_conflict(
|
|
||||||
"Refusing to save inactive open scene '%s' from the active editor scene" % normalized_path,
|
|
||||||
{
|
|
||||||
"path": normalized_path,
|
|
||||||
"active_scene": normalize_project_path(root.scene_file_path),
|
|
||||||
"open_scenes": get_open_scene_paths(),
|
|
||||||
"suggestion": "Open the target scene tab before saving it, or close it before offline edits.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
var dir_path := normalized_path.get_base_dir()
|
|
||||||
if not DirAccess.dir_exists_absolute(dir_path):
|
|
||||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
|
||||||
|
|
||||||
var err: int
|
|
||||||
var save_method: String
|
|
||||||
if root.scene_file_path.is_empty() or normalize_project_path(root.scene_file_path) != normalized_path:
|
|
||||||
EditorInterface.save_scene_as(normalized_path)
|
|
||||||
err = OK
|
|
||||||
save_method = "EditorInterface.save_scene_as"
|
|
||||||
else:
|
|
||||||
err = EditorInterface.save_scene()
|
|
||||||
save_method = "EditorInterface.save_scene"
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to save scene via %s: %s" % [save_method, error_string(err)])
|
|
||||||
|
|
||||||
return success({"path": normalized_path, "saved": true, "method": save_method})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_scene_exports(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Scene file '%s'" % path)
|
|
||||||
|
|
||||||
var packed: PackedScene = load(path)
|
|
||||||
if packed == null:
|
|
||||||
return error_internal("Failed to load scene: %s" % path)
|
|
||||||
|
|
||||||
var instance: Node = packed.instantiate()
|
|
||||||
if instance == null:
|
|
||||||
return error_internal("Failed to instantiate scene: %s" % path)
|
|
||||||
|
|
||||||
var nodes_data: Array = []
|
|
||||||
_collect_exports_recursive(instance, instance, nodes_data)
|
|
||||||
|
|
||||||
instance.queue_free()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"path": path,
|
|
||||||
"nodes": nodes_data,
|
|
||||||
"count": nodes_data.size(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _collect_exports_recursive(node: Node, root: Node, nodes_data: Array) -> void:
|
|
||||||
var script: Script = node.get_script()
|
|
||||||
if script != null:
|
|
||||||
var exports: Dictionary = {}
|
|
||||||
for prop_info in script.get_script_property_list():
|
|
||||||
var usage: int = prop_info["usage"]
|
|
||||||
if (usage & PROPERTY_USAGE_EDITOR) and (usage & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
|
||||||
var prop_name: String = prop_info["name"]
|
|
||||||
exports[prop_name] = {
|
|
||||||
"value": PropertyParser.serialize_value(node.get(prop_name)),
|
|
||||||
"type": prop_info["type"],
|
|
||||||
"hint": prop_info.get("hint", 0),
|
|
||||||
"hint_string": prop_info.get("hint_string", ""),
|
|
||||||
}
|
|
||||||
if not exports.is_empty():
|
|
||||||
var node_path := "." if node == root else str(root.get_path_to(node))
|
|
||||||
nodes_data.append({
|
|
||||||
"node_path": node_path,
|
|
||||||
"node_name": node.name,
|
|
||||||
"node_type": node.get_class(),
|
|
||||||
"script_path": script.resource_path,
|
|
||||||
"exports": exports,
|
|
||||||
})
|
|
||||||
|
|
||||||
for child in node.get_children():
|
|
||||||
_collect_exports_recursive(child, root, nodes_data)
|
|
||||||
|
|
||||||
|
|
||||||
func _cleanup_screenshot_files() -> void:
|
|
||||||
var user_dir := get_game_user_dir()
|
|
||||||
var request_path := user_dir + "/mcp_screenshot_request"
|
|
||||||
var screenshot_path := user_dir + "/mcp_screenshot.png"
|
|
||||||
if FileAccess.file_exists(request_path):
|
|
||||||
DirAccess.remove_absolute(request_path)
|
|
||||||
if FileAccess.file_exists(screenshot_path):
|
|
||||||
DirAccess.remove_absolute(screenshot_path)
|
|
||||||
|
|
||||||
|
|
||||||
func _cleanup_input_files() -> void:
|
|
||||||
var user_dir := get_game_user_dir()
|
|
||||||
var commands_path := user_dir + "/mcp_input_commands"
|
|
||||||
if FileAccess.file_exists(commands_path):
|
|
||||||
DirAccess.remove_absolute(commands_path)
|
|
||||||
|
|
||||||
|
|
||||||
func _cleanup_inspector_files() -> void:
|
|
||||||
var user_dir := get_game_user_dir()
|
|
||||||
var request_path := user_dir + "/mcp_game_request"
|
|
||||||
var response_path := user_dir + "/mcp_game_response"
|
|
||||||
if FileAccess.file_exists(request_path):
|
|
||||||
DirAccess.remove_absolute(request_path)
|
|
||||||
if FileAccess.file_exists(response_path):
|
|
||||||
DirAccess.remove_absolute(response_path)
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"list_scripts": _list_scripts,
|
|
||||||
"read_script": _read_script,
|
|
||||||
"create_script": _create_script,
|
|
||||||
"edit_script": _edit_script,
|
|
||||||
"attach_script": _attach_script,
|
|
||||||
"get_open_scripts": _get_open_scripts,
|
|
||||||
"validate_script": _validate_script,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _guard_script_file_path(path: String, operation: String) -> Dictionary:
|
|
||||||
var ext := path.get_extension().to_lower()
|
|
||||||
if ext in ["gd", "cs"]:
|
|
||||||
return {}
|
|
||||||
return error(
|
|
||||||
-32602,
|
|
||||||
"%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)],
|
|
||||||
{
|
|
||||||
"path": normalize_project_path(path),
|
|
||||||
"extension": ext,
|
|
||||||
"suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _list_scripts(params: Dictionary) -> Dictionary:
|
|
||||||
var path: String = optional_string(params, "path", "res://")
|
|
||||||
var recursive: bool = optional_bool(params, "recursive", true)
|
|
||||||
|
|
||||||
var scripts: Array = []
|
|
||||||
_find_scripts(path, recursive, scripts)
|
|
||||||
|
|
||||||
return success({"scripts": scripts, "count": scripts.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _find_scripts(path: String, recursive: bool, scripts: Array) -> void:
|
|
||||||
var dir := DirAccess.open(path)
|
|
||||||
if dir == null:
|
|
||||||
return
|
|
||||||
|
|
||||||
dir.list_dir_begin()
|
|
||||||
var file_name := dir.get_next()
|
|
||||||
|
|
||||||
while not file_name.is_empty():
|
|
||||||
if file_name.begins_with("."):
|
|
||||||
file_name = dir.get_next()
|
|
||||||
continue
|
|
||||||
|
|
||||||
var full_path := path.path_join(file_name)
|
|
||||||
|
|
||||||
if dir.current_is_dir():
|
|
||||||
if recursive:
|
|
||||||
_find_scripts(full_path, recursive, scripts)
|
|
||||||
elif file_name.get_extension() in ["gd", "cs", "gdshader"]:
|
|
||||||
var info := {"path": full_path, "type": file_name.get_extension()}
|
|
||||||
# Get basic file info
|
|
||||||
var file := FileAccess.open(full_path, FileAccess.READ)
|
|
||||||
if file:
|
|
||||||
info["size"] = file.get_length()
|
|
||||||
# Read first line for class/extends info
|
|
||||||
var first_line := file.get_line().strip_edges()
|
|
||||||
if first_line.begins_with("class_name "):
|
|
||||||
info["class_name"] = first_line.substr(11).strip_edges()
|
|
||||||
elif first_line.begins_with("extends "):
|
|
||||||
info["extends"] = first_line.substr(8).strip_edges()
|
|
||||||
file.close()
|
|
||||||
scripts.append(info)
|
|
||||||
|
|
||||||
file_name = dir.get_next()
|
|
||||||
|
|
||||||
dir.list_dir_end()
|
|
||||||
|
|
||||||
|
|
||||||
func _read_script(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Script '%s'" % path)
|
|
||||||
|
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
var content := file.get_as_text()
|
|
||||||
var line_count := content.count("\n") + 1
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"path": path,
|
|
||||||
"content": content,
|
|
||||||
"line_count": line_count,
|
|
||||||
"size": content.length(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _create_script(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
var path_guard := _guard_script_file_path(path, "create_script")
|
|
||||||
if not path_guard.is_empty():
|
|
||||||
return path_guard
|
|
||||||
|
|
||||||
var content: String = optional_string(params, "content", "")
|
|
||||||
var base_class: String = optional_string(params, "extends", "Node")
|
|
||||||
var class_name_str: String = optional_string(params, "class_name", "")
|
|
||||||
var force: bool = optional_bool(params, "force", false)
|
|
||||||
|
|
||||||
var guard := guard_text_resource_write(path, force)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
# Generate template if no content provided
|
|
||||||
if content.is_empty():
|
|
||||||
var lines: PackedStringArray = []
|
|
||||||
if not class_name_str.is_empty():
|
|
||||||
lines.append("class_name %s" % class_name_str)
|
|
||||||
lines.append("extends %s" % base_class)
|
|
||||||
lines.append("")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("func _ready() -> void:")
|
|
||||||
lines.append("\tpass")
|
|
||||||
lines.append("")
|
|
||||||
content = "\n".join(lines)
|
|
||||||
|
|
||||||
# Ensure directory exists
|
|
||||||
var dir_path := path.get_base_dir()
|
|
||||||
if not DirAccess.dir_exists_absolute(dir_path):
|
|
||||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
|
||||||
|
|
||||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
file.store_string(content)
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
|
|
||||||
# Pre-load so the script is available immediately
|
|
||||||
if ResourceLoader.exists(path):
|
|
||||||
var script = load(path)
|
|
||||||
if script is Script:
|
|
||||||
script.reload(true)
|
|
||||||
|
|
||||||
return success({"path": path, "created": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _edit_script(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
var path_guard := _guard_script_file_path(path, "edit_script")
|
|
||||||
if not path_guard.is_empty():
|
|
||||||
return path_guard
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Script '%s'" % path)
|
|
||||||
|
|
||||||
var force: bool = optional_bool(params, "force", false)
|
|
||||||
var guard := guard_text_resource_write(path, force)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
# Read current content
|
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
var content := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
var changes_made := 0
|
|
||||||
|
|
||||||
# Support search-and-replace
|
|
||||||
if params.has("replacements") and params["replacements"] is Array:
|
|
||||||
var replacements: Array = params["replacements"]
|
|
||||||
for replacement in replacements:
|
|
||||||
if replacement is Dictionary:
|
|
||||||
var search: String = replacement.get("search", "")
|
|
||||||
var replace: String = replacement.get("replace", "")
|
|
||||||
if not search.is_empty():
|
|
||||||
var use_regex: bool = replacement.get("regex", false)
|
|
||||||
if use_regex:
|
|
||||||
var regex := RegEx.new()
|
|
||||||
var err := regex.compile(search)
|
|
||||||
if err == OK:
|
|
||||||
var new_content := regex.sub(content, replace, true)
|
|
||||||
if new_content != content:
|
|
||||||
content = new_content
|
|
||||||
changes_made += 1
|
|
||||||
else:
|
|
||||||
if content.contains(search):
|
|
||||||
content = content.replace(search, replace)
|
|
||||||
changes_made += 1
|
|
||||||
|
|
||||||
# Support 1-based inclusive line range replacement
|
|
||||||
elif params.has("content") and (params.has("start_line") or params.has("end_line")):
|
|
||||||
if not params.has("start_line"):
|
|
||||||
return error_invalid_params("start_line is required when end_line is provided")
|
|
||||||
var start_line: int = int(params["start_line"])
|
|
||||||
var end_line: int = int(params.get("end_line", start_line))
|
|
||||||
var lines := content.split("\n")
|
|
||||||
if start_line < 1:
|
|
||||||
return error_invalid_params("start_line must be >= 1")
|
|
||||||
if end_line < start_line:
|
|
||||||
return error_invalid_params("end_line must be >= start_line")
|
|
||||||
if start_line > lines.size():
|
|
||||||
return error_invalid_params("start_line is beyond the end of the file")
|
|
||||||
if end_line > lines.size():
|
|
||||||
return error_invalid_params("end_line is beyond the end of the file")
|
|
||||||
|
|
||||||
var replacement_lines := str(params["content"]).split("\n")
|
|
||||||
var start_index := start_line - 1
|
|
||||||
var remove_count := end_line - start_line + 1
|
|
||||||
for _i in range(remove_count):
|
|
||||||
lines.remove_at(start_index)
|
|
||||||
for i in range(replacement_lines.size()):
|
|
||||||
lines.insert(start_index + i, replacement_lines[i])
|
|
||||||
content = "\n".join(lines)
|
|
||||||
changes_made = 1
|
|
||||||
|
|
||||||
# Support full content replacement
|
|
||||||
elif params.has("content"):
|
|
||||||
content = str(params["content"])
|
|
||||||
changes_made = 1
|
|
||||||
|
|
||||||
# Support insert at line
|
|
||||||
elif params.has("insert_at_line") and params.has("text"):
|
|
||||||
var line_num: int = int(params["insert_at_line"])
|
|
||||||
var text: String = str(params["text"])
|
|
||||||
var lines := content.split("\n")
|
|
||||||
line_num = clampi(line_num, 0, lines.size())
|
|
||||||
lines.insert(line_num, text)
|
|
||||||
content = "\n".join(lines)
|
|
||||||
changes_made = 1
|
|
||||||
|
|
||||||
if changes_made == 0:
|
|
||||||
return success({"path": path, "changes_made": 0, "message": "No changes applied"})
|
|
||||||
|
|
||||||
# Write back
|
|
||||||
file = FileAccess.open(path, FileAccess.WRITE)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
file.store_string(content)
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
# Reload the script resource so the editor picks up changes immediately
|
|
||||||
_reload_script(path)
|
|
||||||
|
|
||||||
return success({"path": path, "changes_made": changes_made})
|
|
||||||
|
|
||||||
|
|
||||||
## Force-reload a script so the editor reflects disk changes immediately.
|
|
||||||
func _reload_script(path: String) -> void:
|
|
||||||
# First, trigger a filesystem scan so Godot knows the file changed
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
|
|
||||||
# If the script is already loaded in memory, reload it
|
|
||||||
if ResourceLoader.exists(path):
|
|
||||||
var script = load(path)
|
|
||||||
if script is Script:
|
|
||||||
script.reload(true)
|
|
||||||
|
|
||||||
# If the script is open in the script editor, the reload above updates it.
|
|
||||||
# But we also need to notify the editor to refresh its error indicators.
|
|
||||||
EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED)
|
|
||||||
|
|
||||||
|
|
||||||
func _attach_script(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "script_path")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var script_path: String = result2[0]
|
|
||||||
|
|
||||||
var root := get_edited_root()
|
|
||||||
if root == null:
|
|
||||||
return error_no_scene()
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(script_path):
|
|
||||||
return error_not_found("Script '%s'" % script_path)
|
|
||||||
|
|
||||||
var script: Script = load(script_path)
|
|
||||||
if script == null:
|
|
||||||
return error_internal("Failed to load script: %s" % script_path)
|
|
||||||
|
|
||||||
var old_script: Variant = node.get_script()
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Attach script to %s" % node.name)
|
|
||||||
undo_redo.add_do_method(node, "set_script", script)
|
|
||||||
undo_redo.add_undo_method(node, "set_script", old_script)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"node_path": str(root.get_path_to(node)),
|
|
||||||
"script_path": script_path,
|
|
||||||
"attached": true,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _validate_script(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
var path_guard := _guard_script_file_path(path, "validate_script")
|
|
||||||
if not path_guard.is_empty():
|
|
||||||
return path_guard
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Script '%s'" % path)
|
|
||||||
|
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
var source_code := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
var script := GDScript.new()
|
|
||||||
script.source_code = source_code
|
|
||||||
var err := script.reload()
|
|
||||||
|
|
||||||
if err == OK:
|
|
||||||
return success({"path": path, "valid": true, "message": "Script compiles successfully"})
|
|
||||||
|
|
||||||
return success({
|
|
||||||
"path": path,
|
|
||||||
"valid": false,
|
|
||||||
"error_code": err,
|
|
||||||
"error_string": error_string(err),
|
|
||||||
"message": "Compilation failed. Use get_output_log or get_editor_errors for details.",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_open_scripts(_params: Dictionary) -> Dictionary:
|
|
||||||
var script_editor := EditorInterface.get_script_editor()
|
|
||||||
var open_scripts: Array = []
|
|
||||||
|
|
||||||
for script_base in script_editor.get_open_scripts():
|
|
||||||
var info := {
|
|
||||||
"path": script_base.resource_path,
|
|
||||||
"type": script_base.get_class(),
|
|
||||||
}
|
|
||||||
open_scripts.append(info)
|
|
||||||
|
|
||||||
return success({"scripts": open_scripts, "count": open_scripts.size()})
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"create_shader": _create_shader,
|
|
||||||
"read_shader": _read_shader,
|
|
||||||
"edit_shader": _edit_shader,
|
|
||||||
"assign_shader_material": _assign_shader_material,
|
|
||||||
"set_shader_param": _set_shader_param,
|
|
||||||
"get_shader_params": _get_shader_params,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _create_shader(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
var content: String = optional_string(params, "content", "")
|
|
||||||
var shader_type: String = optional_string(params, "shader_type", "spatial")
|
|
||||||
var force: bool = optional_bool(params, "force", false)
|
|
||||||
|
|
||||||
var guard := guard_text_resource_write(path, force)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
if content.is_empty():
|
|
||||||
match shader_type:
|
|
||||||
"spatial":
|
|
||||||
content = "shader_type spatial;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tALBEDO = vec3(1.0);\n}\n"
|
|
||||||
"canvas_item":
|
|
||||||
content = "shader_type canvas_item;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tCOLOR = vec4(1.0);\n}\n"
|
|
||||||
"particles":
|
|
||||||
content = "shader_type particles;\n\nvoid start() {\n\t// Called when particle spawns\n}\n\nvoid process() {\n\t// Called every frame per particle\n}\n"
|
|
||||||
"sky":
|
|
||||||
content = "shader_type sky;\n\nvoid sky() {\n\tCOLOR = vec3(0.3, 0.5, 0.8);\n}\n"
|
|
||||||
|
|
||||||
# Ensure directory exists
|
|
||||||
var dir_path := path.get_base_dir()
|
|
||||||
if not DirAccess.dir_exists_absolute(dir_path):
|
|
||||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
|
||||||
|
|
||||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot create shader: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
file.store_string(content)
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
_refresh_loaded_shader(path, content)
|
|
||||||
|
|
||||||
return success({"path": path, "shader_type": shader_type, "created": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _read_shader(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Shader '%s'" % path)
|
|
||||||
|
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot read shader: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
|
|
||||||
var content := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
return success({"path": path, "content": content, "size": content.length()})
|
|
||||||
|
|
||||||
|
|
||||||
func _refresh_loaded_shader(path: String, content: String) -> void:
|
|
||||||
var normalized := normalize_project_path(path)
|
|
||||||
if normalized.is_empty():
|
|
||||||
return
|
|
||||||
if ResourceLoader.has_cached(normalized):
|
|
||||||
var shader := Shader.new()
|
|
||||||
shader.code = content
|
|
||||||
shader.take_over_path(normalized)
|
|
||||||
shader.emit_changed()
|
|
||||||
EditorInterface.get_resource_filesystem().update_file(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
func _edit_shader(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
|
||||||
return error_not_found("Shader '%s'" % path)
|
|
||||||
|
|
||||||
var force: bool = optional_bool(params, "force", false)
|
|
||||||
var guard := guard_text_resource_write(path, force)
|
|
||||||
if not guard.is_empty():
|
|
||||||
return guard
|
|
||||||
|
|
||||||
var changes_made := 0
|
|
||||||
var content := ""
|
|
||||||
|
|
||||||
if params.has("content"):
|
|
||||||
content = str(params["content"])
|
|
||||||
changes_made = 1
|
|
||||||
elif params.has("replacements") and params["replacements"] is Array:
|
|
||||||
# Read current
|
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot read shader")
|
|
||||||
content = file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
for replacement in params["replacements"]:
|
|
||||||
if replacement is Dictionary:
|
|
||||||
var search: String = replacement.get("search", "")
|
|
||||||
var replace: String = replacement.get("replace", "")
|
|
||||||
if not search.is_empty() and content.contains(search):
|
|
||||||
content = content.replace(search, replace)
|
|
||||||
changes_made += 1
|
|
||||||
|
|
||||||
if changes_made > 0:
|
|
||||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
||||||
if file == null:
|
|
||||||
return error_internal("Cannot write shader: %s" % error_string(FileAccess.get_open_error()))
|
|
||||||
file.store_string(content)
|
|
||||||
file.close()
|
|
||||||
_refresh_loaded_shader(path, content)
|
|
||||||
|
|
||||||
return success({"path": path, "changes_made": changes_made})
|
|
||||||
|
|
||||||
|
|
||||||
func _assign_shader_material(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "shader_path")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var shader_path: String = result2[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
if not ResourceLoader.exists(shader_path):
|
|
||||||
return error_not_found("Shader '%s'" % shader_path)
|
|
||||||
|
|
||||||
var shader: Shader = load(shader_path)
|
|
||||||
if shader == null:
|
|
||||||
return error_internal("Failed to load shader")
|
|
||||||
|
|
||||||
var material := ShaderMaterial.new()
|
|
||||||
material.shader = shader
|
|
||||||
|
|
||||||
if node is CanvasItem:
|
|
||||||
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
|
|
||||||
elif node is MeshInstance3D:
|
|
||||||
set_property_with_undo(node, "material_override", material, "MCP: Assign shader material")
|
|
||||||
else:
|
|
||||||
# Try generic material property
|
|
||||||
if "material" in node:
|
|
||||||
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
|
|
||||||
else:
|
|
||||||
return error_invalid_params("Node '%s' (%s) does not support materials" % [node_path, node.get_class()])
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "shader_path": shader_path, "assigned": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_shader_param(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "param")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var param_name: String = result2[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var material: ShaderMaterial = null
|
|
||||||
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
|
|
||||||
material = (node as CanvasItem).material
|
|
||||||
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
|
|
||||||
material = (node as MeshInstance3D).material_override
|
|
||||||
|
|
||||||
if material == null:
|
|
||||||
return error(-32000, "Node has no ShaderMaterial")
|
|
||||||
|
|
||||||
var value = params.get("value")
|
|
||||||
if value is String:
|
|
||||||
var s: String = value
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(s) == OK:
|
|
||||||
var parsed = expr.execute()
|
|
||||||
if parsed != null:
|
|
||||||
value = parsed
|
|
||||||
|
|
||||||
material.set_shader_parameter(param_name, value)
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "param": param_name, "value": str(value)})
|
|
||||||
|
|
||||||
|
|
||||||
func _get_shader_params(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null:
|
|
||||||
return error_not_found("Node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var material: ShaderMaterial = null
|
|
||||||
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
|
|
||||||
material = (node as CanvasItem).material
|
|
||||||
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
|
|
||||||
material = (node as MeshInstance3D).material_override
|
|
||||||
|
|
||||||
if material == null:
|
|
||||||
return error(-32000, "Node has no ShaderMaterial")
|
|
||||||
|
|
||||||
var shader_params: Dictionary = {}
|
|
||||||
for prop in material.get_property_list():
|
|
||||||
var pname: String = prop["name"]
|
|
||||||
if pname.begins_with("shader_parameter/"):
|
|
||||||
var key := pname.substr(17)
|
|
||||||
shader_params[key] = str(material.get(pname))
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "params": shader_params})
|
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
||||||
|
|
||||||
|
|
||||||
func get_commands() -> Dictionary:
|
|
||||||
return {
|
|
||||||
"create_theme": _create_theme,
|
|
||||||
"set_theme_color": _set_theme_color,
|
|
||||||
"set_theme_constant": _set_theme_constant,
|
|
||||||
"set_theme_font_size": _set_theme_font_size,
|
|
||||||
"set_theme_stylebox": _set_theme_stylebox,
|
|
||||||
"setup_control": _setup_control,
|
|
||||||
"get_theme_info": _get_theme_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _create_theme(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var path: String = result[0]
|
|
||||||
|
|
||||||
var theme := Theme.new()
|
|
||||||
|
|
||||||
# Optionally set default font size
|
|
||||||
var font_size: int = optional_int(params, "default_font_size", 0)
|
|
||||||
if font_size > 0:
|
|
||||||
theme.default_font_size = font_size
|
|
||||||
|
|
||||||
var scene_guard := guard_offline_scene_save(path)
|
|
||||||
if scene_guard != null:
|
|
||||||
return scene_guard
|
|
||||||
|
|
||||||
var err := ResourceSaver.save(theme, path)
|
|
||||||
if err != OK:
|
|
||||||
return error_internal("Failed to save theme: %s" % error_string(err))
|
|
||||||
|
|
||||||
EditorInterface.get_resource_filesystem().scan()
|
|
||||||
return success({"path": path, "created": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_theme_color(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var color_name: String = result2[0]
|
|
||||||
|
|
||||||
var result3 := require_string(params, "color")
|
|
||||||
if result3[1] != null:
|
|
||||||
return result3[1]
|
|
||||||
var color_str: String = result3[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null or not (node is Control):
|
|
||||||
return error_not_found("Control node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
var color := Color(color_str)
|
|
||||||
|
|
||||||
var theme_type: String = optional_string(params, "theme_type", "")
|
|
||||||
if theme_type.is_empty():
|
|
||||||
theme_type = control.get_class()
|
|
||||||
|
|
||||||
var had_old := control.has_theme_color_override(color_name)
|
|
||||||
var old_value: Variant = control.get("theme_override_colors/" + color_name) if had_old else null
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set theme color override")
|
|
||||||
undo_redo.add_do_method(control, "add_theme_color_override", color_name, color)
|
|
||||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "color", color_name, had_old, old_value)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "name": color_name, "color": color_str})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_theme_constant(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var const_name: String = result2[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null or not (node is Control):
|
|
||||||
return error_not_found("Control node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
var value: int = int(params.get("value", 0))
|
|
||||||
|
|
||||||
var had_old := control.has_theme_constant_override(const_name)
|
|
||||||
var old_value: Variant = control.get("theme_override_constants/" + const_name) if had_old else null
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set theme constant override")
|
|
||||||
undo_redo.add_do_method(control, "add_theme_constant_override", const_name, value)
|
|
||||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", const_name, had_old, old_value)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "name": const_name, "value": value})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_theme_font_size(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var font_name: String = result2[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null or not (node is Control):
|
|
||||||
return error_not_found("Control node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
var size: int = int(params.get("size", 16))
|
|
||||||
|
|
||||||
var had_old := control.has_theme_font_size_override(font_name)
|
|
||||||
var old_value: Variant = control.get("theme_override_font_sizes/" + font_name) if had_old else null
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set theme font size override")
|
|
||||||
undo_redo.add_do_method(control, "add_theme_font_size_override", font_name, size)
|
|
||||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "font_size", font_name, had_old, old_value)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "name": font_name, "size": size})
|
|
||||||
|
|
||||||
|
|
||||||
func _set_theme_stylebox(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var result2 := require_string(params, "name")
|
|
||||||
if result2[1] != null:
|
|
||||||
return result2[1]
|
|
||||||
var style_name: String = result2[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null or not (node is Control):
|
|
||||||
return error_not_found("Control node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
|
|
||||||
var stylebox := StyleBoxFlat.new()
|
|
||||||
|
|
||||||
var bg_color: String = optional_string(params, "bg_color", "")
|
|
||||||
if not bg_color.is_empty():
|
|
||||||
stylebox.bg_color = Color(bg_color)
|
|
||||||
|
|
||||||
var border_color: String = optional_string(params, "border_color", "")
|
|
||||||
if not border_color.is_empty():
|
|
||||||
stylebox.border_color = Color(border_color)
|
|
||||||
|
|
||||||
var border_width: int = optional_int(params, "border_width", 0)
|
|
||||||
if border_width > 0:
|
|
||||||
stylebox.border_width_left = border_width
|
|
||||||
stylebox.border_width_top = border_width
|
|
||||||
stylebox.border_width_right = border_width
|
|
||||||
stylebox.border_width_bottom = border_width
|
|
||||||
|
|
||||||
var corner_radius: int = optional_int(params, "corner_radius", 0)
|
|
||||||
if corner_radius > 0:
|
|
||||||
stylebox.corner_radius_top_left = corner_radius
|
|
||||||
stylebox.corner_radius_top_right = corner_radius
|
|
||||||
stylebox.corner_radius_bottom_left = corner_radius
|
|
||||||
stylebox.corner_radius_bottom_right = corner_radius
|
|
||||||
|
|
||||||
var padding: int = optional_int(params, "padding", 0)
|
|
||||||
if padding > 0:
|
|
||||||
stylebox.content_margin_left = padding
|
|
||||||
stylebox.content_margin_top = padding
|
|
||||||
stylebox.content_margin_right = padding
|
|
||||||
stylebox.content_margin_bottom = padding
|
|
||||||
|
|
||||||
var had_old := control.has_theme_stylebox_override(style_name)
|
|
||||||
var old_value: Variant = control.get("theme_override_styles/" + style_name) if had_old else null
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set theme stylebox override")
|
|
||||||
undo_redo.add_do_method(control, "add_theme_stylebox_override", style_name, stylebox)
|
|
||||||
undo_redo.add_do_reference(stylebox)
|
|
||||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "stylebox", style_name, had_old, old_value)
|
|
||||||
if old_value is Resource:
|
|
||||||
undo_redo.add_undo_reference(old_value)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"node_path": node_path, "name": style_name, "type": "StyleBoxFlat"})
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_control(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null or not (node is Control):
|
|
||||||
return error_not_found("Control node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
var applied: Array = []
|
|
||||||
var old_state := _capture_control_setup_state(control)
|
|
||||||
var target: Control = control.duplicate() as Control
|
|
||||||
|
|
||||||
# Anchor preset
|
|
||||||
var anchor_preset: String = optional_string(params, "anchor_preset", "")
|
|
||||||
if not anchor_preset.is_empty():
|
|
||||||
var preset_map := {
|
|
||||||
"top_left": Control.PRESET_TOP_LEFT,
|
|
||||||
"top_right": Control.PRESET_TOP_RIGHT,
|
|
||||||
"bottom_left": Control.PRESET_BOTTOM_LEFT,
|
|
||||||
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
|
|
||||||
"center_left": Control.PRESET_CENTER_LEFT,
|
|
||||||
"center_top": Control.PRESET_CENTER_TOP,
|
|
||||||
"center_right": Control.PRESET_CENTER_RIGHT,
|
|
||||||
"center_bottom": Control.PRESET_CENTER_BOTTOM,
|
|
||||||
"center": Control.PRESET_CENTER,
|
|
||||||
"left_wide": Control.PRESET_LEFT_WIDE,
|
|
||||||
"top_wide": Control.PRESET_TOP_WIDE,
|
|
||||||
"right_wide": Control.PRESET_RIGHT_WIDE,
|
|
||||||
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
|
|
||||||
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
|
|
||||||
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
|
|
||||||
"full_rect": Control.PRESET_FULL_RECT,
|
|
||||||
}
|
|
||||||
if preset_map.has(anchor_preset):
|
|
||||||
target.set_anchors_and_offsets_preset(preset_map[anchor_preset])
|
|
||||||
applied.append("anchor_preset=%s" % anchor_preset)
|
|
||||||
|
|
||||||
# Min size
|
|
||||||
var min_size_str: String = optional_string(params, "min_size", "")
|
|
||||||
if not min_size_str.is_empty():
|
|
||||||
var expr := Expression.new()
|
|
||||||
if expr.parse(min_size_str) == OK:
|
|
||||||
var val = expr.execute()
|
|
||||||
if val is Vector2:
|
|
||||||
target.custom_minimum_size = val
|
|
||||||
applied.append("min_size=%s" % min_size_str)
|
|
||||||
|
|
||||||
# Size flags horizontal
|
|
||||||
var sf_h: String = optional_string(params, "size_flags_h", "")
|
|
||||||
if not sf_h.is_empty():
|
|
||||||
var flags_map := {
|
|
||||||
"fill": Control.SIZE_FILL,
|
|
||||||
"expand": Control.SIZE_EXPAND,
|
|
||||||
"fill_expand": Control.SIZE_EXPAND_FILL,
|
|
||||||
"shrink_center": Control.SIZE_SHRINK_CENTER,
|
|
||||||
"shrink_end": Control.SIZE_SHRINK_END,
|
|
||||||
}
|
|
||||||
if flags_map.has(sf_h):
|
|
||||||
target.size_flags_horizontal = flags_map[sf_h]
|
|
||||||
applied.append("size_flags_h=%s" % sf_h)
|
|
||||||
|
|
||||||
# Size flags vertical
|
|
||||||
var sf_v: String = optional_string(params, "size_flags_v", "")
|
|
||||||
if not sf_v.is_empty():
|
|
||||||
var flags_map := {
|
|
||||||
"fill": Control.SIZE_FILL,
|
|
||||||
"expand": Control.SIZE_EXPAND,
|
|
||||||
"fill_expand": Control.SIZE_EXPAND_FILL,
|
|
||||||
"shrink_center": Control.SIZE_SHRINK_CENTER,
|
|
||||||
"shrink_end": Control.SIZE_SHRINK_END,
|
|
||||||
}
|
|
||||||
if flags_map.has(sf_v):
|
|
||||||
target.size_flags_vertical = flags_map[sf_v]
|
|
||||||
applied.append("size_flags_v=%s" % sf_v)
|
|
||||||
|
|
||||||
# Margins (for MarginContainer)
|
|
||||||
if params.has("margins") and params["margins"] is Dictionary:
|
|
||||||
var margins: Dictionary = params["margins"]
|
|
||||||
if target is MarginContainer:
|
|
||||||
if margins.has("left"):
|
|
||||||
target.add_theme_constant_override("margin_left", int(margins["left"]))
|
|
||||||
if margins.has("top"):
|
|
||||||
target.add_theme_constant_override("margin_top", int(margins["top"]))
|
|
||||||
if margins.has("right"):
|
|
||||||
target.add_theme_constant_override("margin_right", int(margins["right"]))
|
|
||||||
if margins.has("bottom"):
|
|
||||||
target.add_theme_constant_override("margin_bottom", int(margins["bottom"]))
|
|
||||||
applied.append("margins=%s" % str(margins))
|
|
||||||
|
|
||||||
# Separation (for VBox/HBoxContainer)
|
|
||||||
if params.has("separation"):
|
|
||||||
var sep: int = int(params["separation"])
|
|
||||||
if target is BoxContainer:
|
|
||||||
target.add_theme_constant_override("separation", sep)
|
|
||||||
applied.append("separation=%d" % sep)
|
|
||||||
|
|
||||||
# Grow direction horizontal
|
|
||||||
var grow_h: String = optional_string(params, "grow_h", "")
|
|
||||||
if not grow_h.is_empty():
|
|
||||||
var grow_map := {
|
|
||||||
"begin": Control.GROW_DIRECTION_BEGIN,
|
|
||||||
"end": Control.GROW_DIRECTION_END,
|
|
||||||
"both": Control.GROW_DIRECTION_BOTH,
|
|
||||||
}
|
|
||||||
if grow_map.has(grow_h):
|
|
||||||
target.grow_horizontal = grow_map[grow_h]
|
|
||||||
applied.append("grow_h=%s" % grow_h)
|
|
||||||
|
|
||||||
# Grow direction vertical
|
|
||||||
var grow_v: String = optional_string(params, "grow_v", "")
|
|
||||||
if not grow_v.is_empty():
|
|
||||||
var grow_map := {
|
|
||||||
"begin": Control.GROW_DIRECTION_BEGIN,
|
|
||||||
"end": Control.GROW_DIRECTION_END,
|
|
||||||
"both": Control.GROW_DIRECTION_BOTH,
|
|
||||||
}
|
|
||||||
if grow_map.has(grow_v):
|
|
||||||
target.grow_vertical = grow_map[grow_v]
|
|
||||||
applied.append("grow_v=%s" % grow_v)
|
|
||||||
|
|
||||||
if not applied.is_empty():
|
|
||||||
var new_state := _capture_control_setup_state(target)
|
|
||||||
_register_control_setup_undo(control, old_state, new_state)
|
|
||||||
target.free()
|
|
||||||
return success({"node_path": node_path, "applied": applied, "count": applied.size()})
|
|
||||||
|
|
||||||
|
|
||||||
func _restore_theme_override(control: Control, kind: String, override_name: String, had_old: bool, old_value: Variant) -> void:
|
|
||||||
match kind:
|
|
||||||
"color":
|
|
||||||
if had_old:
|
|
||||||
control.add_theme_color_override(override_name, old_value)
|
|
||||||
else:
|
|
||||||
control.remove_theme_color_override(override_name)
|
|
||||||
"constant":
|
|
||||||
if had_old:
|
|
||||||
control.add_theme_constant_override(override_name, old_value)
|
|
||||||
else:
|
|
||||||
control.remove_theme_constant_override(override_name)
|
|
||||||
"font_size":
|
|
||||||
if had_old:
|
|
||||||
control.add_theme_font_size_override(override_name, old_value)
|
|
||||||
else:
|
|
||||||
control.remove_theme_font_size_override(override_name)
|
|
||||||
"stylebox":
|
|
||||||
if had_old:
|
|
||||||
control.add_theme_stylebox_override(override_name, old_value)
|
|
||||||
else:
|
|
||||||
control.remove_theme_stylebox_override(override_name)
|
|
||||||
|
|
||||||
|
|
||||||
func _capture_control_setup_state(control: Control) -> Dictionary:
|
|
||||||
var state := {"properties": {}, "theme_constants": {}}
|
|
||||||
for property: String in [
|
|
||||||
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
|
|
||||||
"offset_left", "offset_top", "offset_right", "offset_bottom",
|
|
||||||
"custom_minimum_size", "size_flags_horizontal", "size_flags_vertical",
|
|
||||||
"grow_horizontal", "grow_vertical",
|
|
||||||
]:
|
|
||||||
state["properties"][property] = control.get(property)
|
|
||||||
for constant_name: String in ["margin_left", "margin_top", "margin_right", "margin_bottom", "separation"]:
|
|
||||||
var had_override := control.has_theme_constant_override(constant_name)
|
|
||||||
state["theme_constants"][constant_name] = {
|
|
||||||
"had": had_override,
|
|
||||||
"value": control.get("theme_override_constants/" + constant_name) if had_override else null,
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
func _register_control_setup_undo(control: Control, old_state: Dictionary, new_state: Dictionary) -> void:
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Setup Control")
|
|
||||||
for property: String in new_state["properties"]:
|
|
||||||
undo_redo.add_do_property(control, property, new_state["properties"][property])
|
|
||||||
undo_redo.add_undo_property(control, property, old_state["properties"][property])
|
|
||||||
for constant_name: String in new_state["theme_constants"]:
|
|
||||||
var new_constant: Dictionary = new_state["theme_constants"][constant_name]
|
|
||||||
var old_constant: Dictionary = old_state["theme_constants"][constant_name]
|
|
||||||
undo_redo.add_do_method(self, "_restore_theme_override", control, "constant", constant_name, new_constant["had"], new_constant["value"])
|
|
||||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", constant_name, old_constant["had"], old_constant["value"])
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
|
|
||||||
func _get_theme_info(params: Dictionary) -> Dictionary:
|
|
||||||
var result := require_string(params, "node_path")
|
|
||||||
if result[1] != null:
|
|
||||||
return result[1]
|
|
||||||
var node_path: String = result[0]
|
|
||||||
|
|
||||||
var node := find_node_by_path(node_path)
|
|
||||||
if node == null or not (node is Control):
|
|
||||||
return error_not_found("Control node at '%s'" % node_path)
|
|
||||||
|
|
||||||
var control: Control = node
|
|
||||||
var info := {"node_path": node_path, "class": control.get_class()}
|
|
||||||
|
|
||||||
# Check if node has a theme
|
|
||||||
var theme := control.theme
|
|
||||||
if theme:
|
|
||||||
info["theme_path"] = theme.resource_path
|
|
||||||
info["type_list"] = Array(theme.get_type_list())
|
|
||||||
|
|
||||||
# List overrides
|
|
||||||
var overrides := {"colors": {}, "constants": {}, "font_sizes": {}, "styleboxes": {}}
|
|
||||||
for prop in control.get_property_list():
|
|
||||||
var pname: String = prop["name"]
|
|
||||||
if pname.begins_with("theme_override_colors/"):
|
|
||||||
var key := pname.substr(22)
|
|
||||||
overrides["colors"][key] = "#" + (control.get(pname) as Color).to_html()
|
|
||||||
elif pname.begins_with("theme_override_constants/"):
|
|
||||||
var key := pname.substr(25)
|
|
||||||
overrides["constants"][key] = control.get(pname)
|
|
||||||
elif pname.begins_with("theme_override_font_sizes/"):
|
|
||||||
var key := pname.substr(26)
|
|
||||||
overrides["font_sizes"][key] = control.get(pname)
|
|
||||||
elif pname.begins_with("theme_override_styles/"):
|
|
||||||
var key := pname.substr(22)
|
|
||||||
var style = control.get(pname)
|
|
||||||
overrides["styleboxes"][key] = style.get_class() if style else null
|
|
||||||
|
|
||||||
info["overrides"] = overrides
|
|
||||||
return success(info)
|
|
||||||
-217
@@ -1,217 +0,0 @@
|
|||||||
@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))
|
|
||||||
|
|
||||||
var coords := Vector2i(x, y)
|
|
||||||
var old_cells := [_capture_cell(tilemap, coords)]
|
|
||||||
var new_cells := [_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative)]
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Set TileMap cell")
|
|
||||||
_add_do_set_cells(undo_redo, tilemap, new_cells)
|
|
||||||
_add_undo_set_cells(undo_redo, tilemap, old_cells)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"x": x, "y": y, "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
|
|
||||||
var old_cells: Array = []
|
|
||||||
var new_cells: Array = []
|
|
||||||
for cx in range(mini(x1, x2), maxi(x1, x2) + 1):
|
|
||||||
for cy in range(mini(y1, y2), maxi(y1, y2) + 1):
|
|
||||||
var coords := Vector2i(cx, cy)
|
|
||||||
old_cells.append(_capture_cell(tilemap, coords))
|
|
||||||
new_cells.append(_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative))
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Fill TileMap rect")
|
|
||||||
_add_do_set_cells(undo_redo, tilemap, new_cells)
|
|
||||||
_add_undo_set_cells(undo_redo, tilemap, old_cells)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
|
|
||||||
return success({"filled": count, "rect": [x1, y1, x2, y2]})
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
var old_cells: Array = []
|
|
||||||
for coords: Vector2i in tilemap.get_used_cells():
|
|
||||||
old_cells.append(_capture_cell(tilemap, coords))
|
|
||||||
|
|
||||||
var undo_redo := get_undo_redo()
|
|
||||||
undo_redo.create_action("MCP: Clear TileMap")
|
|
||||||
undo_redo.add_do_method(tilemap, "clear")
|
|
||||||
_add_undo_set_cells(undo_redo, tilemap, old_cells)
|
|
||||||
undo_redo.commit_action()
|
|
||||||
return success({"cleared": true})
|
|
||||||
|
|
||||||
|
|
||||||
func _make_cell(coords: Vector2i, source_id: int, atlas_coords: Vector2i, alternative: int) -> Dictionary:
|
|
||||||
return {
|
|
||||||
"coords": coords,
|
|
||||||
"source_id": source_id,
|
|
||||||
"atlas_coords": atlas_coords,
|
|
||||||
"alternative": alternative,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _capture_cell(tilemap: TileMapLayer, coords: Vector2i) -> Dictionary:
|
|
||||||
return _make_cell(
|
|
||||||
coords,
|
|
||||||
tilemap.get_cell_source_id(coords),
|
|
||||||
tilemap.get_cell_atlas_coords(coords),
|
|
||||||
tilemap.get_cell_alternative_tile(coords)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _add_do_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
|
|
||||||
for cell: Dictionary in cells:
|
|
||||||
undo_redo.add_do_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
|
|
||||||
|
|
||||||
|
|
||||||
func _add_undo_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
|
|
||||||
for cell: Dictionary in cells:
|
|
||||||
undo_redo.add_undo_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
|
|
||||||
|
|
||||||
|
|
||||||
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()})
|
|
||||||
-1722
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
|||||||
## Autoload injected by Godot MCP Pro plugin at runtime.
|
|
||||||
## Monitors for input commands from the editor and dispatches them as Input events.
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
const COMMANDS_PATH := "user://mcp_input_commands"
|
|
||||||
|
|
||||||
var _sequence_queue: Array = [] # Array of event dicts
|
|
||||||
var _sequence_frame_delay: int = 0
|
|
||||||
var _sequence_frames_waited: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
|
||||||
# Process queued sequence events
|
|
||||||
if not _sequence_queue.is_empty():
|
|
||||||
_process_sequence_tick()
|
|
||||||
|
|
||||||
# Check for new commands from file
|
|
||||||
if FileAccess.file_exists(COMMANDS_PATH):
|
|
||||||
_process_commands()
|
|
||||||
|
|
||||||
|
|
||||||
func _process_commands() -> void:
|
|
||||||
var file := FileAccess.open(COMMANDS_PATH, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
return
|
|
||||||
var text := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
DirAccess.remove_absolute(COMMANDS_PATH)
|
|
||||||
|
|
||||||
var parsed = JSON.parse_string(text)
|
|
||||||
if parsed == null:
|
|
||||||
push_warning("[MCP Input] Failed to parse input commands JSON")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if this is a sequence command (dict with "events" and "frame_delay")
|
|
||||||
if parsed is Dictionary and parsed.has("sequence_events"):
|
|
||||||
_start_sequence(parsed)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Otherwise treat as immediate event(s)
|
|
||||||
var events: Array = parsed if parsed is Array else [parsed]
|
|
||||||
for event_data: Dictionary in events:
|
|
||||||
var event := _create_event(event_data)
|
|
||||||
if event != null:
|
|
||||||
_dispatch_event(event, event_data)
|
|
||||||
|
|
||||||
|
|
||||||
func _start_sequence(data: Dictionary) -> void:
|
|
||||||
_sequence_queue = data.get("sequence_events", []).duplicate()
|
|
||||||
_sequence_frame_delay = data.get("frame_delay", 1)
|
|
||||||
_sequence_frames_waited = 0
|
|
||||||
# Dispatch first event immediately
|
|
||||||
if not _sequence_queue.is_empty():
|
|
||||||
_dispatch_next_sequence_event()
|
|
||||||
|
|
||||||
|
|
||||||
func _process_sequence_tick() -> void:
|
|
||||||
_sequence_frames_waited += 1
|
|
||||||
if _sequence_frames_waited >= _sequence_frame_delay:
|
|
||||||
_sequence_frames_waited = 0
|
|
||||||
_dispatch_next_sequence_event()
|
|
||||||
|
|
||||||
|
|
||||||
func _dispatch_next_sequence_event() -> void:
|
|
||||||
if _sequence_queue.is_empty():
|
|
||||||
return
|
|
||||||
var event_data: Dictionary = _sequence_queue.pop_front()
|
|
||||||
var event := _create_event(event_data)
|
|
||||||
if event != null:
|
|
||||||
_dispatch_event(event, event_data)
|
|
||||||
|
|
||||||
|
|
||||||
## Dispatch an input event using the appropriate method.
|
|
||||||
## Mouse drag motions (button_mask > 0) auto-promote to push_input to bypass
|
|
||||||
## GUI consumption and reach _unhandled_input — needed for camera-pan use
|
|
||||||
## cases where UI Controls would otherwise swallow drag events. But for UI
|
|
||||||
## drag-and-drop *testing* we want events to reach the GUI dispatcher so
|
|
||||||
## hit-testing and _get_drag_data / _drop_data fire. So: respect an explicit
|
|
||||||
## "unhandled": false in the event payload — only auto-promote when the
|
|
||||||
## caller did NOT pass an "unhandled" key. Default behavior preserved.
|
|
||||||
func _dispatch_event(event: InputEvent, event_data: Dictionary = {}) -> void:
|
|
||||||
var force_unhandled: bool
|
|
||||||
if event_data.has("unhandled"):
|
|
||||||
force_unhandled = bool(event_data.get("unhandled"))
|
|
||||||
else:
|
|
||||||
force_unhandled = event is InputEventMouseMotion and event.button_mask != 0
|
|
||||||
if force_unhandled:
|
|
||||||
var vp := get_viewport()
|
|
||||||
if vp:
|
|
||||||
vp.push_input(event, true)
|
|
||||||
else:
|
|
||||||
Input.parse_input_event(event)
|
|
||||||
else:
|
|
||||||
Input.parse_input_event(event)
|
|
||||||
|
|
||||||
|
|
||||||
func _create_event(data: Dictionary) -> InputEvent:
|
|
||||||
var type: String = data.get("type", "")
|
|
||||||
match type:
|
|
||||||
"key":
|
|
||||||
return _create_key_event(data)
|
|
||||||
"mouse_button":
|
|
||||||
return _create_mouse_button_event(data)
|
|
||||||
"mouse_motion":
|
|
||||||
return _create_mouse_motion_event(data)
|
|
||||||
"action":
|
|
||||||
return _create_action_event(data)
|
|
||||||
_:
|
|
||||||
push_warning("[MCP Input] Unknown event type: %s" % type)
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
## Convert viewport coordinates to window coordinates for Input.parse_input_event().
|
|
||||||
## Godot applies viewport.get_final_transform() to mouse events internally,
|
|
||||||
## so we must pass window-space coordinates (pre-transform).
|
|
||||||
func _viewport_to_window(viewport_pos: Vector2) -> Vector2:
|
|
||||||
var vp := get_viewport()
|
|
||||||
if vp == null:
|
|
||||||
return viewport_pos
|
|
||||||
var xform := vp.get_final_transform()
|
|
||||||
return xform * viewport_pos
|
|
||||||
|
|
||||||
|
|
||||||
func _create_key_event(data: Dictionary) -> InputEventKey:
|
|
||||||
var event := InputEventKey.new()
|
|
||||||
var keycode_str: String = data.get("keycode", "")
|
|
||||||
if keycode_str.begins_with("KEY_"):
|
|
||||||
var constant_value = ClassDB.class_get_integer_constant("@GlobalScope", keycode_str)
|
|
||||||
if constant_value != 0:
|
|
||||||
event.keycode = constant_value
|
|
||||||
else:
|
|
||||||
event.keycode = OS.find_keycode_from_string(keycode_str.substr(4))
|
|
||||||
else:
|
|
||||||
event.keycode = OS.find_keycode_from_string(keycode_str)
|
|
||||||
event.pressed = data.get("pressed", true)
|
|
||||||
event.shift_pressed = data.get("shift", false)
|
|
||||||
event.ctrl_pressed = data.get("ctrl", false)
|
|
||||||
event.alt_pressed = data.get("alt", false)
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
func _extract_position(data: Dictionary) -> Vector2:
|
|
||||||
# Support nested {"position": {"x": ..., "y": ...}} or flat {"x": ..., "y": ...}
|
|
||||||
var pos = data.get("position", null)
|
|
||||||
if pos is Dictionary:
|
|
||||||
return Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
|
|
||||||
return Vector2(data.get("x", 0.0), data.get("y", 0.0))
|
|
||||||
|
|
||||||
|
|
||||||
func _create_mouse_button_event(data: Dictionary) -> InputEventMouseButton:
|
|
||||||
var event := InputEventMouseButton.new()
|
|
||||||
event.button_index = data.get("button", MOUSE_BUTTON_LEFT)
|
|
||||||
event.pressed = data.get("pressed", true)
|
|
||||||
event.double_click = data.get("double_click", false)
|
|
||||||
var window_pos := _viewport_to_window(_extract_position(data))
|
|
||||||
event.position = window_pos
|
|
||||||
event.global_position = window_pos
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
func _create_mouse_motion_event(data: Dictionary) -> InputEventMouseMotion:
|
|
||||||
var event := InputEventMouseMotion.new()
|
|
||||||
var window_pos := _viewport_to_window(_extract_position(data))
|
|
||||||
event.position = window_pos
|
|
||||||
event.global_position = window_pos
|
|
||||||
# Support nested {"relative": {"x": ..., "y": ...}} or flat {"relative_x": ..., "relative_y": ...}
|
|
||||||
var rel_x: float = 0.0
|
|
||||||
var rel_y: float = 0.0
|
|
||||||
var rel = data.get("relative", null)
|
|
||||||
if rel is Dictionary:
|
|
||||||
rel_x = float(rel.get("x", 0.0))
|
|
||||||
rel_y = float(rel.get("y", 0.0))
|
|
||||||
else:
|
|
||||||
rel_x = float(data.get("relative_x", 0.0))
|
|
||||||
rel_y = float(data.get("relative_y", 0.0))
|
|
||||||
# Scale relative movement by the same transform (scale only, no offset)
|
|
||||||
var vp := get_viewport()
|
|
||||||
if vp:
|
|
||||||
var scale := vp.get_final_transform().get_scale()
|
|
||||||
event.relative = Vector2(rel_x, rel_y) * scale
|
|
||||||
else:
|
|
||||||
event.relative = Vector2(rel_x, rel_y)
|
|
||||||
# Set button_mask so drag detection works (e.g. camera pan checks button_mask)
|
|
||||||
var button_mask: int = int(data.get("button_mask", 0))
|
|
||||||
event.button_mask = button_mask
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
func _create_action_event(data: Dictionary) -> InputEventAction:
|
|
||||||
var event := InputEventAction.new()
|
|
||||||
event.action = data.get("action", "")
|
|
||||||
event.pressed = data.get("pressed", true)
|
|
||||||
event.strength = data.get("strength", 1.0)
|
|
||||||
return event
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
## 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)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[plugin]
|
|
||||||
|
|
||||||
name="Godot MCP Pro"
|
|
||||||
description="Premium MCP server for AI-powered Godot development. Connects via WebSocket to expose 172 editor tools."
|
|
||||||
author="godot-mcp-pro"
|
|
||||||
version="1.14.1"
|
|
||||||
script="plugin.gd"
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends EditorPlugin
|
|
||||||
|
|
||||||
const _MCP_AUTOLOADS: Array[Array] = [
|
|
||||||
["autoload/MCPScreenshot", "res://addons/godot_mcp/mcp_screenshot_service.gd"],
|
|
||||||
["autoload/MCPInputService", "res://addons/godot_mcp/mcp_input_service.gd"],
|
|
||||||
["autoload/MCPGameInspector", "res://addons/godot_mcp/mcp_game_inspector_service.gd"],
|
|
||||||
]
|
|
||||||
|
|
||||||
const _MCP_TEMP_FILES: Array[String] = [
|
|
||||||
"mcp_game_request",
|
|
||||||
"mcp_game_response",
|
|
||||||
"mcp_input_commands",
|
|
||||||
"mcp_screenshot_request",
|
|
||||||
]
|
|
||||||
|
|
||||||
var websocket_server: Node
|
|
||||||
var command_router: Node
|
|
||||||
var status_panel: Control
|
|
||||||
var auto_dismiss_dialogs: bool = false
|
|
||||||
# Track which autoloads THIS session injected (vs project-owned)
|
|
||||||
var _session_injected_autoloads: Array[String] = []
|
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
|
||||||
# Create command router
|
|
||||||
command_router = preload("res://addons/godot_mcp/command_router.gd").new()
|
|
||||||
command_router.name = "MCPCommandRouter"
|
|
||||||
command_router.editor_plugin = self
|
|
||||||
add_child(command_router)
|
|
||||||
|
|
||||||
# Create WebSocket server
|
|
||||||
websocket_server = preload("res://addons/godot_mcp/websocket_server.gd").new()
|
|
||||||
websocket_server.name = "MCPWebSocketServer"
|
|
||||||
websocket_server.command_router = command_router
|
|
||||||
add_child(websocket_server)
|
|
||||||
|
|
||||||
# Create status panel
|
|
||||||
var panel_scene: PackedScene = preload("res://addons/godot_mcp/ui/status_panel.tscn")
|
|
||||||
status_panel = panel_scene.instantiate()
|
|
||||||
add_control_to_bottom_panel(status_panel, "MCP Pro")
|
|
||||||
status_panel.call_deferred("setup", websocket_server, command_router)
|
|
||||||
|
|
||||||
# Inject MCP autoloads into project settings
|
|
||||||
_inject_autoloads()
|
|
||||||
|
|
||||||
websocket_server.start_server()
|
|
||||||
var cfg := ConfigFile.new()
|
|
||||||
var ver := "unknown"
|
|
||||||
if cfg.load("res://addons/godot_mcp/plugin.cfg") == OK:
|
|
||||||
ver = cfg.get_value("plugin", "version", "unknown")
|
|
||||||
print("[MCP] Godot MCP Pro v%s started (ports 6505-6514)" % ver)
|
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
|
||||||
# Remove MCP autoloads and clean up temp files
|
|
||||||
_remove_autoloads()
|
|
||||||
_cleanup_temp_files()
|
|
||||||
|
|
||||||
if websocket_server:
|
|
||||||
websocket_server.stop_server()
|
|
||||||
|
|
||||||
if status_panel:
|
|
||||||
remove_control_from_bottom_panel(status_panel)
|
|
||||||
status_panel.queue_free()
|
|
||||||
|
|
||||||
if command_router:
|
|
||||||
command_router.queue_free()
|
|
||||||
|
|
||||||
if websocket_server:
|
|
||||||
websocket_server.queue_free()
|
|
||||||
|
|
||||||
print("[MCP] Godot MCP Pro stopped")
|
|
||||||
|
|
||||||
|
|
||||||
func _inject_autoloads() -> void:
|
|
||||||
_session_injected_autoloads.clear()
|
|
||||||
var changed := false
|
|
||||||
for entry: Array in _MCP_AUTOLOADS:
|
|
||||||
var key: String = entry[0]
|
|
||||||
var script: String = entry[1]
|
|
||||||
if not ProjectSettings.has_setting(key):
|
|
||||||
ProjectSettings.set_setting(key, "*" + script)
|
|
||||||
_session_injected_autoloads.append(key)
|
|
||||||
changed = true
|
|
||||||
if changed:
|
|
||||||
ProjectSettings.save()
|
|
||||||
|
|
||||||
|
|
||||||
func _remove_autoloads() -> void:
|
|
||||||
# Only remove autoloads that THIS session injected.
|
|
||||||
# Pre-existing project-owned autoloads are preserved.
|
|
||||||
var changed := false
|
|
||||||
for key: String in _session_injected_autoloads:
|
|
||||||
if ProjectSettings.has_setting(key):
|
|
||||||
ProjectSettings.set_setting(key, null)
|
|
||||||
changed = true
|
|
||||||
_session_injected_autoloads.clear()
|
|
||||||
if changed:
|
|
||||||
ProjectSettings.save()
|
|
||||||
|
|
||||||
|
|
||||||
var _dialog_check_timer: float = 0.0
|
|
||||||
const _DIALOG_CHECK_INTERVAL: float = 0.5 # Check every 0.5 seconds
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
# Check if game inspector requested debugger continue
|
|
||||||
var flag_path := OS.get_user_data_dir() + "/mcp_debugger_continue"
|
|
||||||
if FileAccess.file_exists(flag_path):
|
|
||||||
DirAccess.remove_absolute(flag_path)
|
|
||||||
_try_debugger_continue()
|
|
||||||
|
|
||||||
# Periodically check for blocking editor dialogs (only when enabled by AI)
|
|
||||||
if auto_dismiss_dialogs:
|
|
||||||
_dialog_check_timer += delta
|
|
||||||
if _dialog_check_timer >= _DIALOG_CHECK_INTERVAL:
|
|
||||||
_dialog_check_timer = 0.0
|
|
||||||
_auto_dismiss_dialogs()
|
|
||||||
|
|
||||||
|
|
||||||
func _try_debugger_continue() -> void:
|
|
||||||
# Last resort: find and press the debugger Continue button to unstick the game
|
|
||||||
var base: Node = EditorInterface.get_base_control()
|
|
||||||
var continue_btn := _find_debugger_continue_button(base)
|
|
||||||
if continue_btn and continue_btn.visible and not continue_btn.disabled:
|
|
||||||
continue_btn.emit_signal("pressed")
|
|
||||||
push_warning("[MCP] Auto-pressed debugger Continue button")
|
|
||||||
else:
|
|
||||||
push_warning("[MCP] Could not find debugger Continue button")
|
|
||||||
|
|
||||||
|
|
||||||
func _find_debugger_continue_button(node: Node) -> Button:
|
|
||||||
# Search for the Continue button in ScriptEditorDebugger
|
|
||||||
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)
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
> **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`
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
@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)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[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")
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
## Multi-connection WebSocket client.
|
|
||||||
## Connects to multiple Node.js MCP server instances on ports 6505-6514.
|
|
||||||
## Each Claude Code session gets its own port; Godot talks to all of them.
|
|
||||||
## Ports 6505-6509: MCP servers (stdio), 6510-6514: CLI tool connections.
|
|
||||||
|
|
||||||
signal client_connected()
|
|
||||||
signal client_disconnected()
|
|
||||||
signal message_received(text: String)
|
|
||||||
signal command_executed(method: String, success: bool)
|
|
||||||
signal command_completed(method: String, success: bool, response: String, source_port: int)
|
|
||||||
|
|
||||||
var command_router: Node
|
|
||||||
|
|
||||||
const BASE_PORT := 6505
|
|
||||||
const MAX_PORT := 6514
|
|
||||||
const RECONNECT_INTERVAL := 3.0
|
|
||||||
const BUFFER_SIZE := 16 * 1024 * 1024 # 16MB
|
|
||||||
const PING_INTERVAL := 5.0 # send ping every N seconds while connected
|
|
||||||
const INACTIVITY_TIMEOUT := 30.0 # force-close if no message received for N seconds
|
|
||||||
|
|
||||||
# Per-port connection state
|
|
||||||
var _peers: Dictionary = {} # port -> WebSocketPeer
|
|
||||||
var _connected: Dictionary = {} # port -> bool
|
|
||||||
var _timers: Dictionary = {} # port -> float (reconnect countdown)
|
|
||||||
var _connect_times: Dictionary = {} # port -> float (elapsed seconds since connect)
|
|
||||||
var _last_activity: Dictionary = {} # port -> float (seconds since last received message)
|
|
||||||
var _ping_timers: Dictionary = {} # port -> float (seconds since last sent ping)
|
|
||||||
var _stale_ports: Dictionary = {} # port -> bool (heartbeat timeout flag, exposed to UI)
|
|
||||||
var _running: bool = false
|
|
||||||
|
|
||||||
|
|
||||||
func start_server() -> void:
|
|
||||||
_running = true
|
|
||||||
for p in range(BASE_PORT, MAX_PORT + 1):
|
|
||||||
_connected[p] = false
|
|
||||||
_timers[p] = 0.0
|
|
||||||
_try_connect(p)
|
|
||||||
print("[MCP] Connecting to ports %d-%d" % [BASE_PORT, MAX_PORT])
|
|
||||||
|
|
||||||
|
|
||||||
func stop_server() -> void:
|
|
||||||
_running = false
|
|
||||||
for p in _peers:
|
|
||||||
var ws: WebSocketPeer = _peers[p]
|
|
||||||
if ws:
|
|
||||||
ws.close(1000, "Plugin shutting down")
|
|
||||||
_peers.clear()
|
|
||||||
_connected.clear()
|
|
||||||
_timers.clear()
|
|
||||||
_last_activity.clear()
|
|
||||||
_ping_timers.clear()
|
|
||||||
_stale_ports.clear()
|
|
||||||
print("[MCP] WebSocket client stopped")
|
|
||||||
|
|
||||||
|
|
||||||
func get_client_count() -> int:
|
|
||||||
var count: int = 0
|
|
||||||
for p in _connected:
|
|
||||||
if _connected[p]:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
func get_connected_ports() -> Array[int]:
|
|
||||||
var ports: Array[int] = []
|
|
||||||
for p: int in _connected:
|
|
||||||
if _connected[p]:
|
|
||||||
ports.append(p)
|
|
||||||
return ports
|
|
||||||
|
|
||||||
|
|
||||||
func get_port_connect_time(port: int) -> float:
|
|
||||||
return _connect_times.get(port, -1.0)
|
|
||||||
|
|
||||||
|
|
||||||
func get_port_idle_time(port: int) -> float:
|
|
||||||
return _last_activity.get(port, -1.0)
|
|
||||||
|
|
||||||
|
|
||||||
func is_port_stale(port: int) -> bool:
|
|
||||||
return _stale_ports.get(port, false)
|
|
||||||
|
|
||||||
|
|
||||||
func _try_connect(p: int) -> void:
|
|
||||||
var ws := WebSocketPeer.new()
|
|
||||||
ws.outbound_buffer_size = BUFFER_SIZE
|
|
||||||
ws.inbound_buffer_size = BUFFER_SIZE
|
|
||||||
var err := ws.connect_to_url("ws://127.0.0.1:%d" % p)
|
|
||||||
if err == OK:
|
|
||||||
_peers[p] = ws
|
|
||||||
else:
|
|
||||||
_peers[p] = null
|
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
if not _running:
|
|
||||||
return
|
|
||||||
|
|
||||||
for p in range(BASE_PORT, MAX_PORT + 1):
|
|
||||||
var ws: WebSocketPeer = _peers.get(p)
|
|
||||||
|
|
||||||
# No peer - try reconnect on timer
|
|
||||||
if ws == null:
|
|
||||||
_timers[p] = _timers.get(p, 0.0) + delta
|
|
||||||
if _timers[p] >= RECONNECT_INTERVAL:
|
|
||||||
_timers[p] = 0.0
|
|
||||||
_try_connect(p)
|
|
||||||
continue
|
|
||||||
|
|
||||||
ws.poll()
|
|
||||||
var state := ws.get_ready_state()
|
|
||||||
|
|
||||||
match state:
|
|
||||||
WebSocketPeer.STATE_OPEN:
|
|
||||||
if not _connected.get(p, false):
|
|
||||||
_connected[p] = true
|
|
||||||
_connect_times[p] = 0.0
|
|
||||||
_last_activity[p] = 0.0
|
|
||||||
_ping_timers[p] = 0.0
|
|
||||||
_stale_ports[p] = false
|
|
||||||
_timers[p] = 0.0
|
|
||||||
print_verbose("[MCP] Connected on port %d" % p)
|
|
||||||
client_connected.emit()
|
|
||||||
else:
|
|
||||||
_connect_times[p] = _connect_times.get(p, 0.0) + delta
|
|
||||||
_last_activity[p] = _last_activity.get(p, 0.0) + delta
|
|
||||||
_ping_timers[p] = _ping_timers.get(p, 0.0) + delta
|
|
||||||
|
|
||||||
var received_any := false
|
|
||||||
while ws.get_available_packet_count() > 0:
|
|
||||||
var packet := ws.get_packet()
|
|
||||||
var text := packet.get_string_from_utf8()
|
|
||||||
received_any = true
|
|
||||||
_dispatch_message(text, p)
|
|
||||||
|
|
||||||
if received_any:
|
|
||||||
_last_activity[p] = 0.0
|
|
||||||
if _stale_ports.get(p, false):
|
|
||||||
_stale_ports[p] = false
|
|
||||||
print("[MCP] Port %d recovered from stale state" % p)
|
|
||||||
|
|
||||||
# Force-close if no message received for INACTIVITY_TIMEOUT.
|
|
||||||
# The MCP server pings every 10s, so 30s of silence means the
|
|
||||||
# connection is half-open and reconnect is the only way out.
|
|
||||||
if _last_activity.get(p, 0.0) > INACTIVITY_TIMEOUT:
|
|
||||||
push_warning("[MCP] Port %d silent for %.1fs — forcing reconnect" % [p, _last_activity[p]])
|
|
||||||
_stale_ports[p] = true
|
|
||||||
ws.close(4000, "Heartbeat timeout")
|
|
||||||
_connected[p] = false
|
|
||||||
_peers[p] = null
|
|
||||||
_timers[p] = 0.0
|
|
||||||
client_disconnected.emit()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Send periodic ping so the server can detect our death too,
|
|
||||||
# and so any reply resets our own inactivity timer.
|
|
||||||
if _ping_timers.get(p, 0.0) >= PING_INTERVAL:
|
|
||||||
_ping_timers[p] = 0.0
|
|
||||||
ws.send_text(JSON.stringify({"jsonrpc": "2.0", "method": "ping", "params": {}}))
|
|
||||||
|
|
||||||
WebSocketPeer.STATE_CLOSING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
WebSocketPeer.STATE_CLOSED:
|
|
||||||
if _connected.get(p, false):
|
|
||||||
_connected[p] = false
|
|
||||||
print_verbose("[MCP] Disconnected from port %d" % p)
|
|
||||||
client_disconnected.emit()
|
|
||||||
_peers[p] = null
|
|
||||||
_timers[p] = 0.0
|
|
||||||
_last_activity[p] = 0.0
|
|
||||||
_ping_timers[p] = 0.0
|
|
||||||
|
|
||||||
WebSocketPeer.STATE_CONNECTING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
func _send_to_port(p: int, text: String) -> void:
|
|
||||||
var ws: WebSocketPeer = _peers.get(p)
|
|
||||||
if ws and _connected.get(p, false):
|
|
||||||
ws.send_text(text)
|
|
||||||
|
|
||||||
|
|
||||||
func send_message(text: String) -> void:
|
|
||||||
# Broadcast to all connected peers
|
|
||||||
for p in _peers:
|
|
||||||
_send_to_port(p, text)
|
|
||||||
|
|
||||||
|
|
||||||
## Synchronous dispatch - parse JSON, handle ping/pong, queue command execution
|
|
||||||
func _dispatch_message(text: String, source_port: int) -> void:
|
|
||||||
message_received.emit(text)
|
|
||||||
|
|
||||||
var json := JSON.new()
|
|
||||||
var err := json.parse(text)
|
|
||||||
if err != OK:
|
|
||||||
_send_response(source_port, null, null, {"code": -32700, "message": "Parse error"})
|
|
||||||
return
|
|
||||||
|
|
||||||
var msg: Variant = json.data
|
|
||||||
if not msg is Dictionary:
|
|
||||||
_send_response(source_port, null, null, {"code": -32600, "message": "Invalid request"})
|
|
||||||
return
|
|
||||||
|
|
||||||
var msg_dict: Dictionary = msg
|
|
||||||
|
|
||||||
if msg_dict.get("method") == "ping":
|
|
||||||
_send_to_port(source_port, JSON.stringify({"jsonrpc": "2.0", "method": "pong", "params": {}}))
|
|
||||||
return
|
|
||||||
|
|
||||||
if msg_dict.get("method") == "pong":
|
|
||||||
return
|
|
||||||
|
|
||||||
var id: Variant = msg_dict.get("id")
|
|
||||||
var method: String = msg_dict.get("method", "")
|
|
||||||
var params: Dictionary = msg_dict.get("params", {})
|
|
||||||
|
|
||||||
if method.is_empty():
|
|
||||||
_send_response(source_port, id, null, {"code": -32600, "message": "Missing method"})
|
|
||||||
return
|
|
||||||
|
|
||||||
if not command_router:
|
|
||||||
_send_response(source_port, id, null, {"code": -32603, "message": "No command router"})
|
|
||||||
return
|
|
||||||
|
|
||||||
_execute_command.call_deferred(source_port, id, method, params)
|
|
||||||
|
|
||||||
|
|
||||||
func _execute_command(source_port: int, id: Variant, method: String, params: Dictionary) -> void:
|
|
||||||
var cmd_result: Dictionary = await command_router.execute(method, params)
|
|
||||||
if cmd_result.has("error"):
|
|
||||||
var err_data: Variant = cmd_result["error"]
|
|
||||||
_send_response(source_port, id, null, err_data)
|
|
||||||
var response_text := JSON.stringify(err_data)
|
|
||||||
command_executed.emit(method, false)
|
|
||||||
command_completed.emit(method, false, response_text, source_port)
|
|
||||||
else:
|
|
||||||
var result_data: Variant = cmd_result.get("result", {})
|
|
||||||
_send_response(source_port, id, result_data, null)
|
|
||||||
var response_text := JSON.stringify(result_data)
|
|
||||||
command_executed.emit(method, true)
|
|
||||||
command_completed.emit(method, true, response_text, source_port)
|
|
||||||
|
|
||||||
|
|
||||||
func _send_response(source_port: int, id: Variant, result: Variant, err: Variant) -> void:
|
|
||||||
var response: Dictionary = {"jsonrpc": "2.0", "id": id}
|
|
||||||
if err != null:
|
|
||||||
response["error"] = err
|
|
||||||
else:
|
|
||||||
response["result"] = result if result != null else {}
|
|
||||||
_send_to_port(source_port, JSON.stringify(response))
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ stdio/MCP ┌──────────────┐ WebSocket:6505 ┌──────────────────┐
|
|
||||||
│ AI Client │ ←────────────────→ │ Node.js MCP │ ←──────────────────→ │ Godot Plugin │
|
|
||||||
│ (Claude Code)│ │ Server │ JSON-RPC 2.0 │ (Editor Plugin) │
|
|
||||||
└─────────────┘ └──────────────┘ └──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Communication Flow
|
|
||||||
|
|
||||||
1. AI client sends MCP tool call (e.g. `add_node`)
|
|
||||||
2. Node.js server translates to JSON-RPC 2.0 request
|
|
||||||
3. WebSocket sends to Godot plugin
|
|
||||||
4. Plugin's command router dispatches to handler
|
|
||||||
5. Handler executes via Godot Editor API (with UndoRedo)
|
|
||||||
6. Result sent back as JSON-RPC 2.0 response
|
|
||||||
7. Node.js formats as MCP tool result
|
|
||||||
8. AI receives structured response
|
|
||||||
|
|
||||||
## Godot Plugin Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
plugin.gd (EditorPlugin)
|
|
||||||
├── websocket_server.gd (TCP+WebSocket server)
|
|
||||||
├── command_router.gd (dispatch hub)
|
|
||||||
│ ├── project_commands.gd (6 commands)
|
|
||||||
│ ├── scene_commands.gd (8 commands)
|
|
||||||
│ ├── node_commands.gd (8 commands)
|
|
||||||
│ ├── script_commands.gd (6 commands)
|
|
||||||
│ └── editor_commands.gd (5 commands)
|
|
||||||
└── ui/status_panel (connection monitor)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Design Decisions
|
|
||||||
|
|
||||||
### WebSocket over HTTP
|
|
||||||
- Real-time bidirectional communication
|
|
||||||
- Natural for editor integration (persistent connection)
|
|
||||||
- Heartbeat keeps connection alive
|
|
||||||
|
|
||||||
### JSON-RPC 2.0
|
|
||||||
- Standard protocol with well-defined error codes
|
|
||||||
- Each request has unique ID for tracking
|
|
||||||
- Easy to debug and extend
|
|
||||||
|
|
||||||
### UndoRedo Integration
|
|
||||||
- All scene modifications go through `EditorUndoRedoManager`
|
|
||||||
- Users can Ctrl+Z any AI-made change
|
|
||||||
- Prevents accidental data loss
|
|
||||||
|
|
||||||
### Type Parsing
|
|
||||||
- `PropertyParser` handles string → Godot type conversion
|
|
||||||
- Supports Vector2/3, Color, Rect2, NodePath, etc.
|
|
||||||
- AI can send simple strings, plugin handles the rest
|
|
||||||
|
|
||||||
## Error Codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| -32700 | Parse error (invalid JSON) |
|
|
||||||
| -32600 | Invalid request |
|
|
||||||
| -32601 | Method not found |
|
|
||||||
| -32602 | Invalid params |
|
|
||||||
| -32603 | Internal error |
|
|
||||||
| -32000 | No scene open |
|
|
||||||
| -32001 | Node/resource not found |
|
|
||||||
| -32002 | Script compilation failed |
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Installation Guide
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Godot 4.3+ (tested with 4.6)
|
|
||||||
- Node.js 18+
|
|
||||||
- An MCP-compatible AI client (Claude Code, Claude Desktop, etc.)
|
|
||||||
|
|
||||||
## Step 1: Godot Plugin
|
|
||||||
|
|
||||||
1. Copy the `addons/godot_mcp/` folder into your Godot project
|
|
||||||
2. Open Project → Project Settings → Plugins
|
|
||||||
3. Find "Godot MCP Pro" and click Enable
|
|
||||||
4. You should see "MCP Server" appear in the bottom panel
|
|
||||||
5. The status should show "Waiting for connection..."
|
|
||||||
|
|
||||||
## Step 2: Node.js Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates the compiled server in `server/build/`.
|
|
||||||
|
|
||||||
## Step 3: MCP Client Configuration
|
|
||||||
|
|
||||||
### Claude Code (.mcp.json)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"godot-mcp-pro": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/absolute/path/to/godot-mcp-pro/server/build/index.js"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Port
|
|
||||||
|
|
||||||
Set the `GODOT_MCP_PORT` environment variable (default: 6505):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"godot-mcp-pro": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/absolute/path/to/server/build/index.js"],
|
|
||||||
"env": { "GODOT_MCP_PORT": "6510" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the port in `plugin.gd` (line 3: `const PORT := 6505`).
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Plugin doesn't appear
|
|
||||||
- Make sure the `addons/godot_mcp/` directory is inside your Godot project
|
|
||||||
- Check that `plugin.cfg` exists in the directory
|
|
||||||
|
|
||||||
### Connection fails
|
|
||||||
- Verify the Godot editor is running with the plugin enabled
|
|
||||||
- Check the bottom panel "MCP Server" tab for status
|
|
||||||
- Ensure no firewall is blocking localhost port 6505
|
|
||||||
|
|
||||||
### Tools timeout
|
|
||||||
- Commands have a 30-second timeout
|
|
||||||
- Large operations (full filesystem scan) may need the `max_depth` parameter
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
# Tools Reference
|
|
||||||
|
|
||||||
## Project Tools
|
|
||||||
|
|
||||||
### get_project_info
|
|
||||||
Returns project metadata including name, Godot version, viewport settings, renderer, and autoloads.
|
|
||||||
|
|
||||||
### get_filesystem_tree
|
|
||||||
Scans the project directory and returns a tree structure.
|
|
||||||
- `path` (optional): Root path (default: `res://`)
|
|
||||||
- `filter` (optional): Glob pattern like `*.gd`, `*.tscn`
|
|
||||||
- `max_depth` (optional): Maximum recursion depth (default: 10)
|
|
||||||
|
|
||||||
### search_files
|
|
||||||
Fuzzy search for files by name.
|
|
||||||
- `query` (required): Search string or glob pattern
|
|
||||||
- `path` (optional): Root path
|
|
||||||
- `file_type` (optional): Extension filter (`gd`, `tscn`, etc.)
|
|
||||||
- `max_results` (optional): Limit results (default: 50)
|
|
||||||
|
|
||||||
### get_project_settings
|
|
||||||
Read settings from project.godot.
|
|
||||||
- `section` (optional): Filter by section prefix (e.g. `display/window`)
|
|
||||||
- `key` (optional): Get a specific setting
|
|
||||||
|
|
||||||
### uid_to_project_path / project_path_to_uid
|
|
||||||
Convert between UIDs (`uid://...`) and resource paths (`res://...`).
|
|
||||||
|
|
||||||
## Scene Tools
|
|
||||||
|
|
||||||
### get_scene_tree
|
|
||||||
Returns the live node hierarchy of the currently edited scene.
|
|
||||||
- `max_depth` (optional): Limit tree depth
|
|
||||||
|
|
||||||
### get_scene_file_content
|
|
||||||
Reads the raw .tscn file.
|
|
||||||
- `path` (required): Scene file path
|
|
||||||
|
|
||||||
### create_scene
|
|
||||||
Creates a new scene file.
|
|
||||||
- `path` (required): Where to save
|
|
||||||
- `root_type` (optional): Root node type (default: `Node2D`)
|
|
||||||
- `root_name` (optional): Root node name
|
|
||||||
|
|
||||||
### open_scene / delete_scene
|
|
||||||
Open or delete a scene file by path.
|
|
||||||
|
|
||||||
### add_scene_instance
|
|
||||||
Instance a scene as a child node.
|
|
||||||
- `scene_path` (required): Scene to instance
|
|
||||||
- `parent_path` (optional): Parent node (default: root)
|
|
||||||
- `name` (optional): Instance name
|
|
||||||
|
|
||||||
### play_scene / stop_scene
|
|
||||||
Run or stop scenes. `play_scene` accepts `mode`: `main`, `current`, or a file path.
|
|
||||||
|
|
||||||
## Node Tools
|
|
||||||
|
|
||||||
### add_node
|
|
||||||
Add a node to the scene.
|
|
||||||
- `type` (required): Node class name
|
|
||||||
- `parent_path` (optional): Parent node path
|
|
||||||
- `name` (optional): Node name
|
|
||||||
- `properties` (optional): Dict of property values
|
|
||||||
|
|
||||||
### delete_node / duplicate_node / move_node
|
|
||||||
Modify scene tree structure. All support undo.
|
|
||||||
|
|
||||||
### update_property
|
|
||||||
Set any node property. Values are auto-parsed:
|
|
||||||
- `"Vector2(100, 200)"` → Vector2
|
|
||||||
- `"#ff0000"` or `"Color(1, 0, 0)"` → Color
|
|
||||||
- `"true"` / `"false"` → bool
|
|
||||||
- Numbers → int/float
|
|
||||||
|
|
||||||
### get_node_properties
|
|
||||||
Get all editor-visible properties of a node.
|
|
||||||
- `category` (optional): Filter prefix
|
|
||||||
|
|
||||||
### add_resource
|
|
||||||
Create and assign a resource to a node property.
|
|
||||||
- `resource_type`: Class name (e.g. `RectangleShape2D`)
|
|
||||||
- `resource_properties` (optional): Properties for the resource
|
|
||||||
|
|
||||||
### set_anchor_preset
|
|
||||||
Set anchor preset on Control nodes. Available presets: `top_left`, `center`, `full_rect`, etc.
|
|
||||||
|
|
||||||
### rename_node
|
|
||||||
Rename a node in the current scene.
|
|
||||||
- `node_path` (required): Path to the node
|
|
||||||
- `new_name` (required): New name for the node
|
|
||||||
|
|
||||||
### connect_signal
|
|
||||||
Connect a signal from one node to a method on another node.
|
|
||||||
- `source_path` (required): Path to the source node (emitter)
|
|
||||||
- `signal_name` (required): Signal name to connect
|
|
||||||
- `target_path` (required): Path to the target node (receiver)
|
|
||||||
- `method_name` (required): Method name on target to call
|
|
||||||
|
|
||||||
### disconnect_signal
|
|
||||||
Disconnect a signal connection between two nodes.
|
|
||||||
- `source_path` (required): Path to the source node (emitter)
|
|
||||||
- `signal_name` (required): Signal name to disconnect
|
|
||||||
- `target_path` (required): Path to the target node (receiver)
|
|
||||||
- `method_name` (required): Method name on target
|
|
||||||
|
|
||||||
## Script Tools
|
|
||||||
|
|
||||||
### list_scripts
|
|
||||||
Find all scripts with class/extends info.
|
|
||||||
|
|
||||||
### read_script / create_script
|
|
||||||
Read or create script files.
|
|
||||||
|
|
||||||
### edit_script
|
|
||||||
Edit scripts via:
|
|
||||||
1. `replacements`: Array of `{search, replace, regex?}` operations
|
|
||||||
2. `content`: Full file replacement
|
|
||||||
3. `insert_at_line` + `text`: Insert at specific line
|
|
||||||
|
|
||||||
### attach_script
|
|
||||||
Attach a script to a node in the current scene.
|
|
||||||
|
|
||||||
### get_open_scripts
|
|
||||||
List scripts currently open in the script editor.
|
|
||||||
|
|
||||||
## Editor Tools
|
|
||||||
|
|
||||||
### get_editor_errors
|
|
||||||
Get recent errors from the Godot log.
|
|
||||||
|
|
||||||
### get_editor_screenshot / get_game_screenshot
|
|
||||||
Capture viewport as PNG (returned as base64 image).
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
Run arbitrary GDScript in the editor context. Use `_mcp_print(value)` to capture output.
|
|
||||||
|
|
||||||
### clear_output
|
|
||||||
Clear the editor output panel.
|
|
||||||
|
|
||||||
### get_signals
|
|
||||||
Get all signals of a node, including current connections.
|
|
||||||
- `node_path` (required): Path to the node to inspect
|
|
||||||
|
|
||||||
### reload_plugin
|
|
||||||
Reload the Godot MCP Pro plugin (disable/re-enable). Connection will briefly drop and auto-reconnect.
|
|
||||||
|
|
||||||
### reload_project
|
|
||||||
Rescan the Godot project filesystem and reload changed scripts. No reconnection needed.
|
|
||||||
|
|
||||||
### save_scene
|
|
||||||
Save the currently edited scene to disk.
|
|
||||||
- `path` (optional): Path to save to (defaults to current scene path)
|
|
||||||
|
|
||||||
### set_project_setting
|
|
||||||
Set a project setting value via the editor API.
|
|
||||||
- `key` (required): Setting key (e.g. `display/window/size/viewport_width`)
|
|
||||||
- `value` (required): Value to set (auto-parsed for Vector2, bool, int, float)
|
|
||||||
|
|
||||||
## Input Tools
|
|
||||||
|
|
||||||
### simulate_key
|
|
||||||
Simulate a keyboard key press/release in the running game.
|
|
||||||
- `keycode` (required): Key constant (e.g. `KEY_SPACE`, `KEY_W`)
|
|
||||||
- `pressed` (optional): true for press, false for release
|
|
||||||
- `ctrl`, `shift`, `alt` (optional): Modifier keys
|
|
||||||
|
|
||||||
### simulate_mouse_click
|
|
||||||
Simulate a mouse button click at a position in the running game.
|
|
||||||
- `x`, `y` (optional): Viewport position
|
|
||||||
- `button` (optional): 1=left, 2=right, 3=middle
|
|
||||||
- `pressed` (optional): true for press, false for release
|
|
||||||
|
|
||||||
### simulate_mouse_move
|
|
||||||
Simulate mouse movement in the running game.
|
|
||||||
- `x`, `y` (optional): Target position
|
|
||||||
- `relative_x`, `relative_y` (optional): Relative movement
|
|
||||||
|
|
||||||
### simulate_action
|
|
||||||
Simulate a Godot Input Action in the running game.
|
|
||||||
- `action` (required): Action name from Input Map
|
|
||||||
- `pressed` (optional): true for press, false for release
|
|
||||||
- `strength` (optional): 0.0–1.0
|
|
||||||
|
|
||||||
### simulate_sequence
|
|
||||||
Simulate a sequence of input events with frame delays.
|
|
||||||
- `events` (required): Array of input events
|
|
||||||
- `frame_delay` (optional): Frames between events
|
|
||||||
|
|
||||||
## Runtime Tools
|
|
||||||
|
|
||||||
### get_game_scene_tree
|
|
||||||
Get the scene tree of the currently running game.
|
|
||||||
- `max_depth` (optional): Maximum tree depth
|
|
||||||
|
|
||||||
### get_game_node_properties
|
|
||||||
Get properties of a node in the running game.
|
|
||||||
- `node_path` (required): Absolute node path
|
|
||||||
- `properties` (optional): Specific property names to read
|
|
||||||
|
|
||||||
### capture_frames
|
|
||||||
Capture multiple screenshots at regular frame intervals from the running game.
|
|
||||||
- `count` (optional): Number of frames (1–30)
|
|
||||||
- `frame_interval` (optional): Frames between captures
|
|
||||||
- `half_resolution` (optional): Halve resolution to reduce data size
|
|
||||||
|
|
||||||
### monitor_properties
|
|
||||||
Record property values over multiple frames from the running game.
|
|
||||||
- `node_path` (required): Absolute node path
|
|
||||||
- `properties` (required): Property names to monitor
|
|
||||||
- `frame_count` (optional): Number of samples (1–600)
|
|
||||||
- `frame_interval` (optional): Frames between samples
|
|
||||||
|
|
||||||
## Animation Tools
|
|
||||||
|
|
||||||
### list_animations
|
|
||||||
List all animations in an AnimationPlayer node.
|
|
||||||
- `node_path` (required): Path to the AnimationPlayer
|
|
||||||
|
|
||||||
### create_animation
|
|
||||||
Create a new animation in an AnimationPlayer.
|
|
||||||
- `node_path` (required): Path to the AnimationPlayer
|
|
||||||
- `name` (required): Animation name
|
|
||||||
- `length` (optional): Length in seconds (default: 1.0)
|
|
||||||
- `loop_mode` (optional): 0=none, 1=linear, 2=pingpong
|
|
||||||
|
|
||||||
### add_animation_track
|
|
||||||
Add a track to an animation.
|
|
||||||
- `node_path` (required): Path to the AnimationPlayer
|
|
||||||
- `animation` (required): Animation name
|
|
||||||
- `track_path` (required): Node path and property (e.g. `Sprite2D:position`)
|
|
||||||
- `track_type` (optional): value, position_2d, rotation_2d, scale_2d, method, bezier, blend_shape
|
|
||||||
- `update_mode` (optional): continuous, discrete, capture
|
|
||||||
|
|
||||||
### set_animation_keyframe
|
|
||||||
Insert a keyframe into an animation track.
|
|
||||||
- `node_path` (required): Path to the AnimationPlayer
|
|
||||||
- `animation` (required): Animation name
|
|
||||||
- `track_index` (required): Track index
|
|
||||||
- `time` (required): Time position in seconds
|
|
||||||
- `value` (required): Keyframe value (auto-parsed)
|
|
||||||
|
|
||||||
### get_animation_info
|
|
||||||
Get detailed info about an animation including all tracks and keyframes.
|
|
||||||
- `node_path` (required): Path to the AnimationPlayer
|
|
||||||
- `animation` (required): Animation name
|
|
||||||
|
|
||||||
### remove_animation
|
|
||||||
Remove an animation from an AnimationPlayer.
|
|
||||||
- `node_path` (required): Path to the AnimationPlayer
|
|
||||||
- `name` (required): Animation name
|
|
||||||
|
|
||||||
## TileMap Tools
|
|
||||||
|
|
||||||
### tilemap_set_cell
|
|
||||||
Set a single cell in a TileMapLayer.
|
|
||||||
- `node_path` (required): Path to the TileMapLayer
|
|
||||||
- `x`, `y` (required): Cell coordinates
|
|
||||||
- `source_id` (optional): Tile source ID
|
|
||||||
- `atlas_x`, `atlas_y` (optional): Atlas coordinates
|
|
||||||
- `alternative` (optional): Alternative tile ID
|
|
||||||
|
|
||||||
### tilemap_fill_rect
|
|
||||||
Fill a rectangular region with tiles.
|
|
||||||
- `node_path` (required): Path to the TileMapLayer
|
|
||||||
- `x1`, `y1`, `x2`, `y2` (required): Rectangle bounds
|
|
||||||
- `source_id`, `atlas_x`, `atlas_y`, `alternative` (optional): Tile data
|
|
||||||
|
|
||||||
### tilemap_get_cell
|
|
||||||
Get tile data at a specific cell.
|
|
||||||
- `node_path` (required): Path to the TileMapLayer
|
|
||||||
- `x`, `y` (required): Cell coordinates
|
|
||||||
|
|
||||||
### tilemap_clear
|
|
||||||
Clear all cells in a TileMapLayer.
|
|
||||||
- `node_path` (required): Path to the TileMapLayer
|
|
||||||
|
|
||||||
### tilemap_get_info
|
|
||||||
Get TileMapLayer info including tile set sources and cell count.
|
|
||||||
- `node_path` (required): Path to the TileMapLayer
|
|
||||||
|
|
||||||
### tilemap_get_used_cells
|
|
||||||
Get a list of used (non-empty) cells.
|
|
||||||
- `node_path` (required): Path to the TileMapLayer
|
|
||||||
- `max_count` (optional): Maximum cells to return (default: 500)
|
|
||||||
|
|
||||||
## Theme Tools
|
|
||||||
|
|
||||||
### create_theme
|
|
||||||
Create a new Theme resource file.
|
|
||||||
- `path` (required): Save path (e.g. `res://themes/main.tres`)
|
|
||||||
- `default_font_size` (optional): Default font size
|
|
||||||
|
|
||||||
### set_theme_color
|
|
||||||
Set a theme color override on a Control node.
|
|
||||||
- `node_path` (required): Path to the Control node
|
|
||||||
- `name` (required): Color name (e.g. `font_color`)
|
|
||||||
- `color` (required): Hex color string
|
|
||||||
|
|
||||||
### set_theme_constant
|
|
||||||
Set a theme constant override on a Control node.
|
|
||||||
- `node_path` (required): Path to the Control node
|
|
||||||
- `name` (required): Constant name
|
|
||||||
- `value` (required): Integer value
|
|
||||||
|
|
||||||
### set_theme_font_size
|
|
||||||
Set a theme font size override on a Control node.
|
|
||||||
- `node_path` (required): Path to the Control node
|
|
||||||
- `name` (required): Font size name (e.g. `font_size`)
|
|
||||||
- `size` (required): Font size in pixels
|
|
||||||
|
|
||||||
### set_theme_stylebox
|
|
||||||
Set a StyleBoxFlat override on a Control node.
|
|
||||||
- `node_path` (required): Path to the Control node
|
|
||||||
- `name` (required): Style name (e.g. `panel`, `normal`)
|
|
||||||
- `bg_color` (optional): Background color
|
|
||||||
- `border_color` (optional): Border color
|
|
||||||
- `border_width` (optional): Border width
|
|
||||||
- `corner_radius` (optional): Corner radius
|
|
||||||
- `padding` (optional): Content padding
|
|
||||||
|
|
||||||
### get_theme_info
|
|
||||||
Get theme information and overrides for a Control node.
|
|
||||||
- `node_path` (required): Path to the Control node
|
|
||||||
|
|
||||||
## Profiling Tools
|
|
||||||
|
|
||||||
### get_performance_monitors
|
|
||||||
Get all Godot performance monitors (FPS, memory, draw calls, physics, navigation).
|
|
||||||
- `category` (optional): Filter by prefix (e.g. `render`, `physics_2d`)
|
|
||||||
|
|
||||||
### get_editor_performance
|
|
||||||
Get a quick performance summary (FPS, frame time, draw calls, memory).
|
|
||||||
|
|
||||||
## Batch & Refactoring Tools
|
|
||||||
|
|
||||||
### find_nodes_by_type
|
|
||||||
Find all nodes of a specific type in the current scene.
|
|
||||||
- `type` (required): Node class name
|
|
||||||
- `recursive` (optional): Search recursively (default: true)
|
|
||||||
|
|
||||||
### find_signal_connections
|
|
||||||
Find all signal connections in the current scene.
|
|
||||||
- `signal_name` (optional): Filter by signal name
|
|
||||||
- `node_path` (optional): Filter by node path
|
|
||||||
|
|
||||||
### batch_set_property
|
|
||||||
Set a property on all nodes of a given type.
|
|
||||||
- `type` (required): Node type to target
|
|
||||||
- `property` (required): Property name
|
|
||||||
- `value` (required): Value to set (auto-parsed)
|
|
||||||
|
|
||||||
### find_node_references
|
|
||||||
Search through project files for a text pattern.
|
|
||||||
- `pattern` (required): Text pattern to search for
|
|
||||||
|
|
||||||
### get_scene_dependencies
|
|
||||||
Get all resource dependencies of a scene or resource file.
|
|
||||||
- `path` (required): Path to the file
|
|
||||||
|
|
||||||
## Shader Tools
|
|
||||||
|
|
||||||
### create_shader
|
|
||||||
Create a new shader file with template or custom content.
|
|
||||||
- `path` (required): Shader file path
|
|
||||||
- `shader_type` (optional): spatial, canvas_item, particles, sky
|
|
||||||
- `content` (optional): Full shader code
|
|
||||||
|
|
||||||
### read_shader
|
|
||||||
Read the content of a shader file.
|
|
||||||
- `path` (required): Path to the shader file
|
|
||||||
|
|
||||||
### edit_shader
|
|
||||||
Edit a shader file using full replacement or search-and-replace.
|
|
||||||
- `path` (required): Path to the shader file
|
|
||||||
- `content` (optional): Full replacement content
|
|
||||||
- `replacements` (optional): Array of `{search, replace}` operations
|
|
||||||
|
|
||||||
### assign_shader_material
|
|
||||||
Create a ShaderMaterial from a shader and assign to a node.
|
|
||||||
- `node_path` (required): Target node path
|
|
||||||
- `shader_path` (required): Path to the shader file
|
|
||||||
|
|
||||||
### set_shader_param
|
|
||||||
Set a shader parameter on a node's ShaderMaterial.
|
|
||||||
- `node_path` (required): Node with ShaderMaterial
|
|
||||||
- `param` (required): Parameter name
|
|
||||||
- `value` (required): Parameter value (auto-parsed)
|
|
||||||
|
|
||||||
### get_shader_params
|
|
||||||
Get all shader parameters from a node's ShaderMaterial.
|
|
||||||
- `node_path` (required): Node with ShaderMaterial
|
|
||||||
|
|
||||||
## Export Tools
|
|
||||||
|
|
||||||
### list_export_presets
|
|
||||||
List all export presets configured in export_presets.cfg.
|
|
||||||
|
|
||||||
### export_project
|
|
||||||
Get the export command for a preset.
|
|
||||||
- `preset_name` (optional): Preset name
|
|
||||||
- `preset_index` (optional): Preset index
|
|
||||||
- `debug` (optional): Debug export (default: true)
|
|
||||||
|
|
||||||
### get_export_info
|
|
||||||
Get export-related project info (executable path, templates directory, project path).
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Godot MCP Pro — AI Client Instructions
|
|
||||||
|
|
||||||
Copy the appropriate file to your project root so your AI assistant knows how to use Godot MCP Pro.
|
|
||||||
|
|
||||||
| Client | File to copy | Destination |
|
|
||||||
|--------|-------------|-------------|
|
|
||||||
| Claude Code | `CLAUDE.md` | Project root |
|
|
||||||
| Codex CLI / OpenCode | `AGENTS.md` | Project root |
|
|
||||||
| Gemini CLI | `GEMINI.md` | Project root |
|
|
||||||
| Cursor | `godot-mcp-pro.mdc` | `.cursor/rules/godot-mcp-pro.mdc` |
|
|
||||||
| Cline | `.clinerules` | Project root |
|
|
||||||
| Windsurf | `.windsurfrules` | Project root |
|
|
||||||
| Roo Code | `roo-godot-mcp-pro.md` | `.roo/rules/roo-godot-mcp-pro.md` |
|
|
||||||
| JetBrains / Junie | `junie-guidelines.md` | `.junie/guidelines.md` |
|
|
||||||
| Amazon Q | `amazonq-godot-mcp-pro.md` | `.amazonq/rules/amazonq-godot-mcp-pro.md` |
|
|
||||||
| Continue | `continue-godot-mcp-pro.md` | `.continue/rules/godot-mcp-pro.md` |
|
|
||||||
| Augment Code | `augment-godot-mcp-pro.md` | `.augment/instructions/godot-mcp-pro.md` |
|
|
||||||
|
|
||||||
All files contain the same instructions adapted for each client's format.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
---
|
|
||||||
description: Godot MCP Pro instructions for controlling Godot editor via MCP tools and CLI
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Godot MCP Pro - AI Assistant Instructions
|
|
||||||
|
|
||||||
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
|
|
||||||
|
|
||||||
## Critical: Editor vs Runtime Tools
|
|
||||||
|
|
||||||
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
|
|
||||||
|
|
||||||
### Editor Tools (always available)
|
|
||||||
These work on the currently open scene in the Godot editor:
|
|
||||||
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
|
|
||||||
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
|
|
||||||
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
|
|
||||||
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
|
|
||||||
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
|
|
||||||
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
|
|
||||||
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
|
|
||||||
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
|
|
||||||
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
|
|
||||||
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
|
|
||||||
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
|
|
||||||
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
|
|
||||||
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
|
|
||||||
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
|
|
||||||
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
|
|
||||||
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
|
|
||||||
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
|
|
||||||
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
|
|
||||||
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
|
|
||||||
- **Profiling**: `get_editor_performance`
|
|
||||||
|
|
||||||
### Runtime Tools (require `play_scene` first)
|
|
||||||
You MUST call `play_scene` before using any of these. They interact with the running game:
|
|
||||||
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
|
|
||||||
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
|
|
||||||
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
|
|
||||||
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
|
|
||||||
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
|
|
||||||
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
|
|
||||||
- **Control**: `play_scene`, `stop_scene`
|
|
||||||
|
|
||||||
## Workflow Patterns
|
|
||||||
|
|
||||||
### Building a scene from scratch
|
|
||||||
1. `create_scene` or `open_scene`
|
|
||||||
2. Use `add_node` or `batch_add_nodes` to add nodes
|
|
||||||
3. `create_script` + `attach_script` for behavior
|
|
||||||
4. `save_scene`
|
|
||||||
|
|
||||||
### Testing gameplay
|
|
||||||
1. Build scene with editor tools (above)
|
|
||||||
2. `play_scene` to start the game
|
|
||||||
3. Use `simulate_key`/`simulate_mouse_click` for input
|
|
||||||
4. `get_game_screenshot` or `capture_frames` to observe results
|
|
||||||
5. `stop_scene` when done
|
|
||||||
|
|
||||||
### Inspecting a project
|
|
||||||
1. `get_project_info` for overview
|
|
||||||
2. `get_scene_tree` for current scene structure
|
|
||||||
3. `read_script` to read code
|
|
||||||
4. `get_node_properties` for specific node details
|
|
||||||
|
|
||||||
### Migrating code properties to inspector
|
|
||||||
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
|
|
||||||
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
|
|
||||||
2. `get_node_properties` to see current inspector values
|
|
||||||
3. `update_property` to set the same values as node properties in the inspector
|
|
||||||
4. `edit_script` to remove the hardcoded lines from the script
|
|
||||||
5. `save_scene` to persist the inspector changes
|
|
||||||
6. `validate_script` to verify the script still works
|
|
||||||
|
|
||||||
## Formatting Rules
|
|
||||||
|
|
||||||
### execute_editor_script
|
|
||||||
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Correct
|
|
||||||
_mcp_print("hello")
|
|
||||||
|
|
||||||
# Correct - multi-line
|
|
||||||
var nodes = []
|
|
||||||
for child in EditorInterface.get_edited_scene_root().get_children():
|
|
||||||
nodes.append(child.name)
|
|
||||||
_mcp_print(str(nodes))
|
|
||||||
```
|
|
||||||
|
|
||||||
### execute_game_script
|
|
||||||
Same as above but runs inside the running game. Additional rules:
|
|
||||||
- No nested functions (`func` inside `func` is invalid GDScript)
|
|
||||||
- Use `.get("property")` instead of `.property` for safe access
|
|
||||||
- Runs in a temporary node — use `get_tree()` to access the scene tree
|
|
||||||
|
|
||||||
### batch_add_nodes
|
|
||||||
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"type": "Node2D", "name": "Container", "parent_path": "."},
|
|
||||||
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
|
|
||||||
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
|
|
||||||
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
|
|
||||||
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
|
|
||||||
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
|
|
||||||
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
|
|
||||||
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
|
|
||||||
|
|
||||||
## CLI Mode (Alternative to MCP Tools)
|
|
||||||
|
|
||||||
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
|
|
||||||
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discover available command groups
|
|
||||||
node /path/to/server/build/cli.js --help
|
|
||||||
|
|
||||||
# Discover commands in a group
|
|
||||||
node /path/to/server/build/cli.js scene --help
|
|
||||||
|
|
||||||
# Discover options for a specific command
|
|
||||||
node /path/to/server/build/cli.js node add --help
|
|
||||||
|
|
||||||
# Execute commands
|
|
||||||
node /path/to/server/build/cli.js project info
|
|
||||||
node /path/to/server/build/cli.js scene tree
|
|
||||||
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
|
|
||||||
node /path/to/server/build/cli.js script read --path res://player.gd
|
|
||||||
node /path/to/server/build/cli.js scene play
|
|
||||||
node /path/to/server/build/cli.js input key --key W --duration 0.5
|
|
||||||
node /path/to/server/build/cli.js runtime tree
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command groups**: project, scene, node, script, editor, input, runtime
|
|
||||||
|
|
||||||
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* godot-cli — Command-line interface for Godot MCP Pro
|
|
||||||
*
|
|
||||||
* Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0).
|
|
||||||
* Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits.
|
|
||||||
* Progressive disclosure via --help at each command level.
|
|
||||||
*/
|
|
||||||
export {};
|
|
||||||
//# sourceMappingURL=cli.d.ts.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;GAMG"}
|
|
||||||
@@ -1,705 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* godot-cli — Command-line interface for Godot MCP Pro
|
|
||||||
*
|
|
||||||
* Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0).
|
|
||||||
* Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits.
|
|
||||||
* Progressive disclosure via --help at each command level.
|
|
||||||
*/
|
|
||||||
import { WebSocketServer } from "ws";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { createServer } from "net";
|
|
||||||
const BASE_PORT = 6510;
|
|
||||||
const MAX_PORT = 6514;
|
|
||||||
const CONNECT_TIMEOUT_MS = 10000;
|
|
||||||
const COMMAND_TIMEOUT_MS = 30000;
|
|
||||||
const COMMANDS = {
|
|
||||||
project: {
|
|
||||||
description: "Project info, files, and settings",
|
|
||||||
commands: {
|
|
||||||
info: {
|
|
||||||
description: "Get project metadata (name, version, viewport, renderer, autoloads)",
|
|
||||||
method: "get_project_info",
|
|
||||||
},
|
|
||||||
files: {
|
|
||||||
description: "List project file/directory tree",
|
|
||||||
method: "get_filesystem_tree",
|
|
||||||
args: {
|
|
||||||
path: { description: "Root path (default: res://)" },
|
|
||||||
filter: { description: "Glob filter (e.g. '*.gd', '*.tscn')" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
search: {
|
|
||||||
description: "Search for files by name pattern",
|
|
||||||
method: "search_files",
|
|
||||||
args: {
|
|
||||||
query: { description: "Search query (fuzzy match or glob)", required: true },
|
|
||||||
path: { description: "Directory to search in" },
|
|
||||||
file_type: { description: "Filter by extension (e.g. 'gd', 'tscn')" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grep: {
|
|
||||||
description: "Search inside file contents",
|
|
||||||
method: "search_in_files",
|
|
||||||
args: {
|
|
||||||
query: { description: "Text/regex pattern", required: true },
|
|
||||||
path: { description: "Directory to search in" },
|
|
||||||
file_type: { description: "File extension filter (e.g. 'gd', 'tscn')" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"get-setting": {
|
|
||||||
description: "Get project settings",
|
|
||||||
method: "get_project_settings",
|
|
||||||
args: {
|
|
||||||
category: { description: "Settings category filter" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"set-setting": {
|
|
||||||
description: "Set a project setting",
|
|
||||||
method: "set_project_setting",
|
|
||||||
args: {
|
|
||||||
setting: { description: "Setting path (e.g. display/window/size/viewport_width)", required: true },
|
|
||||||
value: { description: "Value to set", required: true },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => ({ setting: p.setting, value: autoType(p.value) }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scene: {
|
|
||||||
description: "Scene tree and scene management",
|
|
||||||
commands: {
|
|
||||||
tree: {
|
|
||||||
description: "Get the current scene tree",
|
|
||||||
method: "get_scene_tree",
|
|
||||||
args: {
|
|
||||||
max_depth: { description: "Maximum depth to display", type: "number" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => (p.max_depth ? { max_depth: parseInt(p.max_depth) } : {}),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
description: "Create a new scene with a root node",
|
|
||||||
method: "create_scene",
|
|
||||||
args: {
|
|
||||||
path: { description: "Scene path (e.g. res://scenes/player.tscn)", required: true },
|
|
||||||
root_type: { description: "Root node type (default: Node2D)" },
|
|
||||||
root_name: { description: "Root node name" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
open: {
|
|
||||||
description: "Open a scene in the editor",
|
|
||||||
method: "open_scene",
|
|
||||||
args: {
|
|
||||||
path: { description: "Scene path to open", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
save: {
|
|
||||||
description: "Save the current scene",
|
|
||||||
method: "save_scene",
|
|
||||||
args: {
|
|
||||||
path: { description: "Optional path to save as" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
play: {
|
|
||||||
description: "Run the current/specified scene",
|
|
||||||
method: "play_scene",
|
|
||||||
args: {
|
|
||||||
mode: { description: "'main' (default), 'current', or a scene path" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stop: {
|
|
||||||
description: "Stop the running scene",
|
|
||||||
method: "stop_scene",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
description: "Get scene file content (tscn format parsed)",
|
|
||||||
method: "get_scene_file_content",
|
|
||||||
args: {
|
|
||||||
path: { description: "Scene file path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
exports: {
|
|
||||||
description: "Get exported variables of a scene",
|
|
||||||
method: "get_scene_exports",
|
|
||||||
args: {
|
|
||||||
path: { description: "Scene file path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
description: "Delete a scene file",
|
|
||||||
method: "delete_scene",
|
|
||||||
args: {
|
|
||||||
path: { description: "Scene file path to delete", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
instance: {
|
|
||||||
description: "Add a scene instance as child node",
|
|
||||||
method: "add_scene_instance",
|
|
||||||
args: {
|
|
||||||
scene_path: { description: "Path to .tscn file to instance", required: true },
|
|
||||||
parent_path: { description: "Parent node path (default: selected/root)" },
|
|
||||||
name: { description: "Instance name" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
node: {
|
|
||||||
description: "Add, modify, and delete scene nodes",
|
|
||||||
commands: {
|
|
||||||
add: {
|
|
||||||
description: "Add a new node to the scene",
|
|
||||||
method: "add_node",
|
|
||||||
args: {
|
|
||||||
type: { description: "Node type (e.g. CharacterBody3D, Sprite2D)", required: true },
|
|
||||||
name: { description: "Node name" },
|
|
||||||
parent_path: { description: "Parent node path (default: root '.')" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
description: "Delete a node from the scene",
|
|
||||||
method: "delete_node",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path to delete", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
get: {
|
|
||||||
description: "Get all properties of a node",
|
|
||||||
method: "get_node_properties",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set: {
|
|
||||||
description: "Set a property on a node",
|
|
||||||
method: "update_property",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
property: { description: "Property name", required: true },
|
|
||||||
value: { description: "Value to set", required: true },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }),
|
|
||||||
},
|
|
||||||
duplicate: {
|
|
||||||
description: "Duplicate a node",
|
|
||||||
method: "duplicate_node",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path to duplicate", required: true },
|
|
||||||
name: { description: "Name for the duplicate" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
move: {
|
|
||||||
description: "Move/reparent a node",
|
|
||||||
method: "move_node",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path to move", required: true },
|
|
||||||
new_parent_path: { description: "New parent path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rename: {
|
|
||||||
description: "Rename a node",
|
|
||||||
method: "rename_node",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
new_name: { description: "New name", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connect: {
|
|
||||||
description: "Connect a signal between nodes",
|
|
||||||
method: "connect_signal",
|
|
||||||
args: {
|
|
||||||
source_path: { description: "Source node path", required: true },
|
|
||||||
signal_name: { description: "Signal name", required: true },
|
|
||||||
target_path: { description: "Target node path", required: true },
|
|
||||||
method_name: { description: "Target method name", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
groups: {
|
|
||||||
description: "Get groups a node belongs to",
|
|
||||||
method: "get_node_groups",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
script: {
|
|
||||||
description: "Read, create, and edit GDScript/C# files",
|
|
||||||
commands: {
|
|
||||||
read: {
|
|
||||||
description: "Read a script file",
|
|
||||||
method: "read_script",
|
|
||||||
args: {
|
|
||||||
path: { description: "Script path (e.g. res://player.gd)", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
description: "Create a new script file (.gd or .cs only)",
|
|
||||||
method: "create_script",
|
|
||||||
args: {
|
|
||||||
path: { description: "Script path", required: true },
|
|
||||||
content: { description: "Script content", required: true },
|
|
||||||
base_type: { description: "Base class (default: Node)" },
|
|
||||||
force: { description: "Override open-script-editor guard" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const r = { path: p.path, content: p.content };
|
|
||||||
if (p.base_type)
|
|
||||||
r.base_type = p.base_type;
|
|
||||||
if (p.force !== undefined)
|
|
||||||
r.force = p.force === "true";
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
edit: {
|
|
||||||
description: "Edit an existing script (full replace or 1-based inclusive line range)",
|
|
||||||
method: "edit_script",
|
|
||||||
args: {
|
|
||||||
path: { description: "Script path", required: true },
|
|
||||||
content: { description: "New content", required: true },
|
|
||||||
start_line: { description: "Start line for partial edit (1-based inclusive)", type: "number" },
|
|
||||||
end_line: { description: "End line for partial edit (1-based inclusive)", type: "number" },
|
|
||||||
force: { description: "Override open-script-editor guard" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const r = { path: p.path, content: p.content };
|
|
||||||
if (p.start_line)
|
|
||||||
r.start_line = parseInt(p.start_line);
|
|
||||||
if (p.end_line)
|
|
||||||
r.end_line = parseInt(p.end_line);
|
|
||||||
if (p.force !== undefined)
|
|
||||||
r.force = p.force === "true";
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attach: {
|
|
||||||
description: "Attach a script to a node",
|
|
||||||
method: "attach_script",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
script_path: { description: "Script path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
validate: {
|
|
||||||
description: "Validate a GDScript for errors",
|
|
||||||
method: "validate_script",
|
|
||||||
args: {
|
|
||||||
path: { description: "Script path to validate", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
description: "List all scripts in the project",
|
|
||||||
method: "list_scripts",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
description: "Editor state, errors, screenshots, and utilities",
|
|
||||||
commands: {
|
|
||||||
errors: {
|
|
||||||
description: "Get current editor errors/warnings",
|
|
||||||
method: "get_editor_errors",
|
|
||||||
},
|
|
||||||
log: {
|
|
||||||
description: "Get editor output log",
|
|
||||||
method: "get_output_log",
|
|
||||||
args: {
|
|
||||||
lines: { description: "Number of lines (default: 50)", type: "number" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => (p.lines ? { lines: parseInt(p.lines) } : {}),
|
|
||||||
},
|
|
||||||
screenshot: {
|
|
||||||
description: "Take a screenshot of the running game",
|
|
||||||
method: "get_game_screenshot",
|
|
||||||
},
|
|
||||||
"editor-screenshot": {
|
|
||||||
description: "Take a screenshot of the editor",
|
|
||||||
method: "get_editor_screenshot",
|
|
||||||
},
|
|
||||||
exec: {
|
|
||||||
description: "Execute an editor script (GDScript in editor context)",
|
|
||||||
method: "execute_editor_script",
|
|
||||||
args: {
|
|
||||||
code: { description: "GDScript code to execute", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
signals: {
|
|
||||||
description: "Get signals of a node type",
|
|
||||||
method: "get_signals",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reload: {
|
|
||||||
description: "Reload the project",
|
|
||||||
method: "reload_project",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
description: "Simulate keyboard, mouse, and input actions",
|
|
||||||
commands: {
|
|
||||||
key: {
|
|
||||||
description: "Simulate a key press in the running game",
|
|
||||||
method: "simulate_key",
|
|
||||||
args: {
|
|
||||||
key: { description: "Key name (e.g. W, A, S, D, Space)", required: true },
|
|
||||||
duration: { description: "Hold duration in seconds", type: "number" },
|
|
||||||
pressed: { description: "true=press, false=release" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const r = { key: p.key };
|
|
||||||
if (p.duration)
|
|
||||||
r.duration = parseFloat(p.duration);
|
|
||||||
if (p.pressed !== undefined)
|
|
||||||
r.pressed = p.pressed === "true";
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
click: {
|
|
||||||
description: "Simulate a mouse click in the running game",
|
|
||||||
method: "simulate_mouse_click",
|
|
||||||
args: {
|
|
||||||
x: { description: "X coordinate", required: true, type: "number" },
|
|
||||||
y: { description: "Y coordinate", required: true, type: "number" },
|
|
||||||
button: { description: "Mouse button: left, right, or middle (default: left)" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const buttonMap = { left: 1, right: 2, middle: 3 };
|
|
||||||
const r = { x: parseInt(p.x), y: parseInt(p.y) };
|
|
||||||
if (p.button)
|
|
||||||
r.button = buttonMap[p.button.toLowerCase()] ?? (parseInt(p.button) || 1);
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
description: "Simulate an input action (as defined in Input Map)",
|
|
||||||
method: "simulate_action",
|
|
||||||
args: {
|
|
||||||
action: { description: "Action name (e.g. ui_accept, move_left)", required: true },
|
|
||||||
pressed: { description: "true=press, false=release" },
|
|
||||||
duration: { description: "Hold duration in seconds", type: "number" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const r = { action: p.action };
|
|
||||||
if (p.pressed !== undefined)
|
|
||||||
r.pressed = p.pressed === "true";
|
|
||||||
if (p.duration)
|
|
||||||
r.duration = parseFloat(p.duration);
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
description: "List all configured input actions",
|
|
||||||
method: "get_input_actions",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
description: "Inspect and control the running game",
|
|
||||||
commands: {
|
|
||||||
tree: {
|
|
||||||
description: "Get the running game's scene tree",
|
|
||||||
method: "get_game_scene_tree",
|
|
||||||
args: {
|
|
||||||
max_depth: { description: "Max depth (-1 for unlimited)", type: "number" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const r = {};
|
|
||||||
if (p.max_depth)
|
|
||||||
r.max_depth = parseInt(p.max_depth);
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
get: {
|
|
||||||
description: "Get properties of a node in the running game",
|
|
||||||
method: "get_game_node_properties",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
properties: { description: "Comma-separated property names (default: all)" },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => {
|
|
||||||
const r = { node_path: p.node_path };
|
|
||||||
if (p.properties)
|
|
||||||
r.properties = p.properties.split(",").map(s => s.trim());
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set: {
|
|
||||||
description: "Set a property on a running game node",
|
|
||||||
method: "set_game_node_property",
|
|
||||||
args: {
|
|
||||||
node_path: { description: "Node path", required: true },
|
|
||||||
property: { description: "Property name", required: true },
|
|
||||||
value: { description: "Value to set", required: true },
|
|
||||||
},
|
|
||||||
mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }),
|
|
||||||
},
|
|
||||||
exec: {
|
|
||||||
description: "Execute GDScript in the running game",
|
|
||||||
method: "execute_game_script",
|
|
||||||
args: {
|
|
||||||
code: { description: "GDScript code", required: true },
|
|
||||||
node_path: { description: "Node context (default: /root)" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
description: "Find UI elements (buttons, labels, etc.) in the running game",
|
|
||||||
method: "find_ui_elements",
|
|
||||||
args: {
|
|
||||||
type_filter: { description: "Filter by type (Button, Label, etc.)" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// ─── Argument parsing ─────────────────────────────────────────────────
|
|
||||||
function autoType(value) {
|
|
||||||
if (value === "true")
|
|
||||||
return true;
|
|
||||||
if (value === "false")
|
|
||||||
return false;
|
|
||||||
if (value === "null")
|
|
||||||
return null;
|
|
||||||
const num = Number(value);
|
|
||||||
if (!isNaN(num) && value.trim() !== "")
|
|
||||||
return num;
|
|
||||||
// Try JSON for arrays/objects
|
|
||||||
if ((value.startsWith("[") || value.startsWith("{")) && (value.endsWith("]") || value.endsWith("}"))) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
}
|
|
||||||
catch { /* fall through */ }
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const positional = [];
|
|
||||||
const flags = {};
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg.startsWith("--")) {
|
|
||||||
const key = arg.slice(2);
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (next && !next.startsWith("--")) {
|
|
||||||
flags[key] = next;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
flags[key] = "true";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
positional.push(arg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { positional, flags };
|
|
||||||
}
|
|
||||||
// ─── Help formatting ──────────────────────────────────────────────────
|
|
||||||
function showMainHelp() {
|
|
||||||
console.log(`godot-cli — Control Godot editor from the command line
|
|
||||||
|
|
||||||
Usage: godot-cli <group> <command> [options]
|
|
||||||
|
|
||||||
Groups:`);
|
|
||||||
for (const [name, group] of Object.entries(COMMANDS)) {
|
|
||||||
console.log(` ${name.padEnd(12)} ${group.description}`);
|
|
||||||
}
|
|
||||||
console.log(`
|
|
||||||
Options:
|
|
||||||
--port <N> Godot WebSocket port (default: auto-detect 6510-6514)
|
|
||||||
--help Show help for a group or command
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
godot-cli project info
|
|
||||||
godot-cli scene tree
|
|
||||||
godot-cli node add --type CharacterBody3D --name Player
|
|
||||||
godot-cli script read --path res://player.gd
|
|
||||||
godot-cli scene play
|
|
||||||
godot-cli input key --key W --duration 0.5`);
|
|
||||||
}
|
|
||||||
function showGroupHelp(groupName, group) {
|
|
||||||
console.log(`godot-cli ${groupName} — ${group.description}
|
|
||||||
|
|
||||||
Commands:`);
|
|
||||||
for (const [name, cmd] of Object.entries(group.commands)) {
|
|
||||||
console.log(` ${name.padEnd(18)} ${cmd.description}`);
|
|
||||||
}
|
|
||||||
console.log(`\nUse: godot-cli ${groupName} <command> --help for details`);
|
|
||||||
}
|
|
||||||
function showCommandHelp(groupName, cmdName, cmd) {
|
|
||||||
console.log(`godot-cli ${groupName} ${cmdName} — ${cmd.description}`);
|
|
||||||
if (cmd.args && Object.keys(cmd.args).length > 0) {
|
|
||||||
console.log(`\nOptions:`);
|
|
||||||
for (const [name, arg] of Object.entries(cmd.args)) {
|
|
||||||
const req = arg.required ? " (required)" : "";
|
|
||||||
console.log(` --${name.padEnd(16)} ${arg.description}${req}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ─── WebSocket connection ─────────────────────────────────────────────
|
|
||||||
// The Godot plugin is a WebSocket CLIENT that connects to servers on ports 6505-6514.
|
|
||||||
// The CLI starts a temporary WebSocket SERVER on an available port and waits for
|
|
||||||
// the Godot plugin to connect (it polls every 3 seconds).
|
|
||||||
function isPortFree(port) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = createServer();
|
|
||||||
server.once("error", () => resolve(false));
|
|
||||||
server.once("listening", () => {
|
|
||||||
server.close(() => resolve(true));
|
|
||||||
});
|
|
||||||
server.listen(port, "127.0.0.1");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function findFreePort(preferredPort) {
|
|
||||||
if (preferredPort) {
|
|
||||||
if (await isPortFree(preferredPort))
|
|
||||||
return preferredPort;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (let p = BASE_PORT; p <= MAX_PORT; p++) {
|
|
||||||
if (await isPortFree(p))
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Start a WebSocket server and wait for the Godot plugin to connect.
|
|
||||||
* Returns the connected client WebSocket and the server (for cleanup).
|
|
||||||
*/
|
|
||||||
function waitForGodot(port) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const wss = new WebSocketServer({ port, host: "127.0.0.1" });
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
wss.close();
|
|
||||||
reject(new Error(`Godot plugin did not connect within ${CONNECT_TIMEOUT_MS / 1000}s.\n` +
|
|
||||||
"Make sure the Godot editor is running with the MCP plugin enabled."));
|
|
||||||
}, CONNECT_TIMEOUT_MS);
|
|
||||||
wss.on("error", (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
wss.on("connection", (ws) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve({ client: ws, wss });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function sendCommand(ws, method, params) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const id = randomUUID();
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error(`Command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms`));
|
|
||||||
}, COMMAND_TIMEOUT_MS);
|
|
||||||
const handler = (data) => {
|
|
||||||
let msg;
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(data.toString());
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ignore ping/pong
|
|
||||||
if (msg.method === "pong" || msg.method === "ping")
|
|
||||||
return;
|
|
||||||
if (msg.id !== id)
|
|
||||||
return;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
ws.off("message", handler);
|
|
||||||
if (msg.error) {
|
|
||||||
reject(new Error(`Godot error: ${msg.error.message || JSON.stringify(msg.error)}`));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
resolve(msg.result);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.on("message", handler);
|
|
||||||
ws.send(JSON.stringify({ jsonrpc: "2.0", method, params, id }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// ─── Main ─────────────────────────────────────────────────────────────
|
|
||||||
async function main() {
|
|
||||||
const userArgs = process.argv.slice(2);
|
|
||||||
const { positional, flags } = parseArgs(userArgs);
|
|
||||||
// Global --help
|
|
||||||
if (positional.length === 0 || flags.help === "true" && positional.length === 0) {
|
|
||||||
showMainHelp();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const groupName = positional[0];
|
|
||||||
const group = COMMANDS[groupName];
|
|
||||||
if (!group) {
|
|
||||||
console.error(`Unknown group: ${groupName}`);
|
|
||||||
showMainHelp();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
// Group-level --help
|
|
||||||
if (positional.length === 1 || (flags.help === "true" && positional.length === 1)) {
|
|
||||||
showGroupHelp(groupName, group);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const cmdName = positional[1];
|
|
||||||
const cmd = group.commands[cmdName];
|
|
||||||
if (!cmd) {
|
|
||||||
console.error(`Unknown command: ${groupName} ${cmdName}`);
|
|
||||||
showGroupHelp(groupName, group);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
// Command-level --help
|
|
||||||
if (flags.help === "true") {
|
|
||||||
showCommandHelp(groupName, cmdName, cmd);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
// Validate required args
|
|
||||||
if (cmd.args) {
|
|
||||||
for (const [name, arg] of Object.entries(cmd.args)) {
|
|
||||||
if (arg.required && !flags[name]) {
|
|
||||||
console.error(`Missing required option: --${name}`);
|
|
||||||
showCommandHelp(groupName, cmdName, cmd);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Build params
|
|
||||||
const params = cmd.mapArgs ? cmd.mapArgs(flags) : { ...flags };
|
|
||||||
// Remove internal flags
|
|
||||||
delete params.port;
|
|
||||||
delete params.help;
|
|
||||||
// Connect and execute
|
|
||||||
const preferredPort = flags.port ? parseInt(flags.port) : undefined;
|
|
||||||
const port = await findFreePort(preferredPort);
|
|
||||||
if (!port) {
|
|
||||||
console.error(`No free ports in range ${BASE_PORT}-${MAX_PORT}.\n` +
|
|
||||||
"All ports are occupied by MCP server instances.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
let client;
|
|
||||||
let wss;
|
|
||||||
try {
|
|
||||||
process.stderr.write(`Waiting for Godot on port ${port}...`);
|
|
||||||
({ client, wss } = await waitForGodot(port));
|
|
||||||
process.stderr.write(" connected!\n");
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
process.stderr.write("\n");
|
|
||||||
console.error(err.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await sendCommand(client, cmd.method, params);
|
|
||||||
if (result !== undefined && result !== null) {
|
|
||||||
console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
client.close();
|
|
||||||
wss.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Fatal:", err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
//# sourceMappingURL=cli.js.map
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user