Compare commits

..
10 Commits
Author SHA1 Message Date
moyu 9fb16e45c5 Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-27 15:03:31 +08:00
Ryan-WZ d2b0a47850 新增载具jeep模型 ,测试decals应用(成功) 2026-10-27 15:02:15 +08:00
moyu e2491bc4e1 godot ai 更新 2026-10-23 16:56:35 +08:00
moyu 5c0adb3a96 2026-10-16 2026-10-16 16:01:18 +08:00
moyu b8bbe21c62 场景画面效果优化 2026-10-15 21:51:30 +08:00
moyu 1822a2211e 0 2026-10-15 17:02:45 +08:00
moyu a76c4aebef limbo hsm 2026-10-15 16:56:45 +08:00
moyu 18e7b2daa8 docs\limboai-stable 2026-10-15 16:30:44 +08:00
moyu ecebf76f47 toon_refresh 2026-10-14 16:36:34 +08:00
Ryan-WZ 877d3bbad0 Shader Test 2026-10-13 23:37:09 +08:00
244 changed files with 104843 additions and 519 deletions
+142
View File
@@ -0,0 +1,142 @@
# 2026-10-14
## shader/toon_apply.gd 自动刷新可行性咨询(未改动任何文件)
- 项目 Godot 4.7 Forward+。
- 现状:`toon_apply.gd` 是 @tool Node3D,`_apply_materials()` 对每个子 MeshInstance3D `duplicate()` 一份 ShaderMaterial 并 `material_override`。
- 结论:
1. 保存/编辑 `toon_apply.gd` 本身 → 编辑器热重载 @tool 脚本会重跑 `_ready()`,已能自动刷新(4.3 有热重载失效 bug,4.4+ 已修,本项目 4.7 无此问题)。
2. 编辑 `shader/toon.tres` 的 shader 参数(color / uv_scale 等)→ **不会**自动刷新。原因:ShaderMaterial 的 `Resource.changed` 仅在 resource_name 变化或 shader 本身变化时发出,参数改动不发出(官方文档明确);且每网格是 duplicate 的副本,与模板已无关联。
3. 若要 2 也自动刷新,需 EditorPlugin 监听 `EditorInterface.get_inspector().property_edited`(或 EditorFileSystem),回调里调用 `_apply_materials()`。
- 备选架构:改用 `GeometryInstance3D.set_instance_shader_parameter()` 的 per-instance uniform,可不 duplicate、直接共享 toon.tres,改参数即实时生效;但限制是**不支持贴图/数组**、每 shader 上限 16 个。本项目需要 per-mesh 的 `base_texture`,因此该方案只能覆盖 color 之类的标量/向量参数,贴图仍需 duplicate 或走 Texture2DArray + index。
## 追加:评估 per-instance uniform + Texture2DArray 方案(结论:不适合本项目)
- 实测贴图尺寸(脚本扫 PNG 头):全项目 289 张 PNG / 24 种尺寸。
- `assets` 180 张:1024×1024 ×108、512×512 ×40、4096×4096 ×25、2048×2048 ×5
- `assets/自建资产` 18 张:4096×4096 ×13(地板、地毯 PBR 套图)
- `character` 81 张:32×32 ×70、512×512 ×4、1024×1024 ×1
- `demo` 28 张:15 种尺寸混杂
- 结论:Texture2DArray 要求所有层同尺寸/同格式/同 mipmap,只能统一到 4096(单层带 mipmap 约 85 MiB 无损 / 21 MiB VRAM 压缩),几百张即 GB 级显存,且需额外打包流水线 → **per-instance uniform 路线不划算**,除非贴图少且尺寸统一。
- 推荐路线(未实施):
- A:保留 duplicate,加 EditorPlugin 监听 `EditorInspector.property_edited`,命中 toon.tres 时重刷 `_apply_materials()`(可加防抖)。
- B:把公共外观参数(color/specular/fresnel/normal_scale/uv_scale/uv_offset)提升为 `global uniform`(项目设置 → Shader Globals 定义),调参全局实时生效、无需重刷;贴图与 per-model 覆盖仍留在 duplicate 材质上。代价:需改 cel-shader-base.gdshader / outline.gdshader 并清掉 toon.tres 里的同名参数;全局值会写入 project.godot,需团队一起提交。
## 已实施:路线 A(新增 addons/toon_refresh 编辑器插件)
用户选择 A。改动:
- 新增 `addons/toon_refresh/plugin.cfg` + `plugin.gd`(EditorPlugin)。
- `project.godot` 的 `[editor_plugins] enabled` 追加 `res://addons/toon_refresh/plugin.cfg`。
实现要点:
- 监听 `EditorInterface.get_inspector().property_edited`,命中 `res://shader/toon.tres` 或其内联子资源时,0.2s 防抖后遍历当前编辑场景,对脚本文件名为 `toon_apply.gd` 且有 `_apply_materials` 的节点调用该方法。
- 另加 项目 > 工具 菜单项「刷新 toon 材质(当前场景)」作手动兜底。
- 只影响当前编辑场景;不覆盖手动改过的单个材质副本。
已实测确认的事实(重要,别再靠猜):
- **`toon.tres` 的 `next_pass` 内联子资源,`resource_path` 不是空串**,而是 `res://shader/toon.tres::ShaderMaterial_m5gih` 这种带 `::` 后缀的形式 → 匹配逻辑用 `begins_with(TARGET + "::")`。
- `ShaderMaterial.changed` **不会**因 `set_shader_parameter()` 触发;因改 `resource_name` 触发(与官方文档一致)。
- 编辑器路径:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64.exe`(另有 mono 版)。可用 `--headless --editor --path E:/Godot/agame --quit` 做插件加载冒烟测试;用 `--check-only --script res://xxx.gd` 做语法校验。
- 该项目 headless 启动时 godotsteam / limboai 的 GDExtension DLL 会报加载失败(既有问题,与本次改动无关)。
项目相关:`[shader_globals]` 里已有 diffuse_curve、specular_smoothness、fresnel_smoothness、outline_width、outline_color —— 描边与光照曲线本来就是全局 uniform,调参实时生效;真正需要重刷的是 toon.tres 上的材质级参数。
## 修复:USE_NORMAL_MAP=1 时"平视全场景变暗、俯瞰才亮"
用户报告的症状。根因是 `cel-shader-base.gdshader` 里的一行:
`NORMAL = normal.vector;`(原第 121 行)——`normal.vector` 是**切线空间**的法线贴图值,
而 Godot 4 的 `fragment()` 里 `NORMAL` 是**视图空间**(官方文档明确写
"inout vec3 NORMAL ... in view space")。把切线空间值写进视图空间的 NORMAL,
等于让所有像素的法线都指向"视图空间 +Z"≈ 朝向相机的一个常量 →
整个场景明暗只由相机朝向决定:平视时与光方向近乎垂直/反向 → 全黑且无明暗起伏;
俯瞰时与向下打的光对齐 → 恢复正常。`normal_map_fragment()` 里算好的正确视图空间法线
`ref_normal` 被算出来后就丢掉了(死代码)。
连带问题:`normal_map` 这个 uniform 从来没被赋过值(toon_apply.gd 只抓 base_texture),
采样到引擎默认纹理后算出的法线同样是垃圾。main2.tscn 里 413 个 toon_apply 节点刷出的
434 个网格中,430 个都没有法线贴图。
改动:
- `shader/includes/normal-map.gdshaderinc`:删掉 `ref_normal` / `vector.xy*2-1` / `vector.z`
重建等死代码,改为返回 `NormalData(map, normal_scale)`,map 就是原始纹理值(切线空间),
蓝通道交给引擎重建;补上坐标空间说明。
- `shader/cel-shader-base.gdshader`:`USE_NORMAL_MAP` 分支只写
`NORMAL_MAP = normal.map; NORMAL_MAP_DEPTH = normal.depth;`,不再碰 `NORMAL`。
- `shader/toon_apply.gd`:抓 `normal_texture`(StandardMaterial3D)或 `normal_map`
(ShaderMaterial)→ 有就赋给副本;**没有就显式把 `normal_scale` 设为 0.0**,否则会采样默认纹理。
验证(真实场景跑法,见 MEMORY.md):
- 对照实验确认 dummy 渲染后端仍会报 `SHADER ERROR`,所以无头运行可校验 shader 语法。
- 加载 `world/main2.tscn` 零 shader 错误、零脚本错误。
- 功能断言:toon_apply 子树 434 个网格全部应用成功;430 个 `normal_scale=0.0`(无贴图),
4 个 `normal_scale=1.0` 且 `normal_map` 已设置(源材质确实带法线贴图)。
遗留待用户确认(未改动):
- `world/main2.tscn` 的 `model/floor1`(不归 toon_apply 管,用自建 `surface_material_override/0`)
上 `normal_scale = -1.7749993243124995`,在新语义下等于 `NORMAL_MAP_DEPTH = -1.775`(反向外插),
几乎不可能是想要的,建议改成 1.0。
- `toon_apply.gd` 只读 `mesh.surface_get_material(0)`,忽略了 `surface_material_override`,
对把材质放在节点覆盖槽上的模型会抓不到贴图(本次未改,属潜在问题)。
## 排查:亮色模型"像自发光"(未改动任何项目文件)
用户问"没开自发光,为什么颜色较亮时有自发光感"。用 Godot 无头脚本采样截图
(`Image.load_from_file` + 逐像素统计,临时脚本已删):
- 红篮区域(25988 样本):**37.1% 的像素有通道被钉在 255**,均值 (164,57,56)
- 抽屉顶面(7616 样本):**63.0% 触顶,4758 个纯白像素**,均值 (212,212,205)
- 深色柜体正面(10048 样本):仅 **3.3% 触顶**,均值 (108,89,74)
- 背景:66452 样本 min==max==(54,72,87) → **纯色平涂,不是天空盒**
结论:是**通道削顶(clipping)**,不是自发光。shader 里 `USE_EMISSION 0`,且 toon.tres
的 specular/fresnel 的 rgb 都是 0 → 唯一光照项就是 `ALBEDO × LIGHT_COLOR × 阶梯`。
亮色/高饱和色在线性空间里本来就有通道接近 1.0,光照只要 >1 就把它钉死 →
整片变成没有渐变的满色 → 观感就像发光;中等亮度色需要 3 倍以上才触顶,所以正常。
阶梯曲线本身是平的(`new_gradient_texture_1d.tres`:u<0.127→0,0.14~0.50→0.198,
u>0.523→1.0),削顶后连最后一点层次也没了。
项目里的过曝来源候选:
- `world/main2.tscn` 的 SpotLight3D `sconce`:`light_energy = 2.655`、`light_indirect_energy = 2.41`
- `world/main.tscn` 的 Environment:`ambient_light_source = 3`(天空)+ 自定义天空
`atm_sun_intensity = 18`、`sun_disk_intensity = 30` → 环境光辐照度可远超 1
- **矛盾点(可用来定位)**:main.tscn 的 Environment 是 `tonemap_mode = 3`(ACES) + `tonemap_white = 6.0`,
ACES 会把高光滚降,理论上几乎不可能出现纯 255(输入 4.0 也只到 ~253)。但截图里大面积纯 255
→ 说明被观察的视口**没有套用那套 ACES 环境**:要么看的是 main2.tscn(自身无 WorldEnvironment、
无 tonemap → 硬削顶),要么编辑器 3D 视口没启用场景环境。
- 另一个坑:toon.tres 的 `color = 0.549` 带 `source_color`,线性空间里只有 **0.26**,
toon 管线把反照率整体压掉约 4 倍 → 亮暗反差被拉得很大。
- `world/main2.tscn` 里没有任何 `use_custom_color = true`,所以不存在自定义颜色覆盖。
- `world/main.tscn` 通过 ext_resource 把 `main2.tscn` 作为子场景实例化。
待用户确认:具体是哪个场景/哪个物体,以便定位"材质来源 + 光源 + 环境"这条链路。
## 重要:上游 godot4-cel-shader 的法线贴图路径是"从未运行过的死代码"
用户质疑"着色器源自 https://github.com/eldskald/godot4-cel-shader,理应无需修改"。
本地有干净的上游副本 `E:/Godot/godot4-cel-shader-main/godot4-cel-shader-main/`,比对结果:
- 本项目改动前的 `cel-shader-base.gdshader` / `includes/normal-map.gdshaderinc`
**与上游逐字一致**(包括 `NORMAL = normal.vector;` 和 `ref_normal` 算完即丢)。→ 当时不是本项目改坏的。
- 但上游 5 个 shader 变体**全部是 `#define USE_NORMAL_MAP 0`**,全仓库没有任何一处写成 1,
README 里 "normal" 出现 0 次 → **这条路径上游从未启用、从未测试过**。
- 本项目把它开成 1,才第一次走进这段代码,于是坐标空间错误暴露。
- 结论:**只要 `USE_NORMAL_MAP 1`,那行就必须改**;想严格保持上游 1:1,唯一办法是把开关保持 0。
- Godot 侧证据:引擎自己也会再做一次 NORMAL_MAP 合成
(Godot 源码 clearcoat 等价实现:`normalize(tangent*map.x + binormal*map.y + normal*map.z)`),
公式里的 `normal` 就是被污染过的 NORMAL → 污染会传播;且 depth=0 也无法"中和",
因为 mix 的基值本身就是被写坏的 NORMAL。
给用户的三个选项(待其选择):
A. `USE_NORMAL_MAP` 改回 0,并把两个 shader 文件完全还原成上游(代价:实测 434 个网格里那 4 个带法线贴图的会失去效果)
B. 只保留 `cel-shader-base.gdshader` 删掉 `NORMAL = normal.vector;` 这一行的差异,include 还原上游(1 行差异)
C. 保留当前版本(include 里的死代码也清掉,与上游差异最大但最干净)
### 已实施:用户选择 A
- 用 git 精确还原:`git checkout -- shader/cel-shader-base.gdshader shader/includes/normal-map.gdshaderinc shader/toon_apply.gd`
(先 `git diff` 逐 hunk 确认过全部改动都是本次会话产生的,没有混入用户未提交的修改)。
- 再把 `#define USE_NORMAL_MAP 1` 改成 `0`(HEAD 里本来就是 1,是用户自己开的)。
- 结果:`normal-map.gdshaderinc` 与上游**逐字一致**;`cel-shader-base.gdshader` 与上游的差异
只剩项目自己加的中文注释行(值已与上游相同);`toon_apply.gd` 无改动。
- 验证:`--headless res://world/main2.tscn --quit-after 60` 无 shader / 脚本 / 解析错误。
- 注意:`toon.tres` 里仍有 `shader_parameter/normal_scale = 1.0`,宏关闭时被忽略,无副作用;
`world/main2.tscn` 里 `model/floor1` 那份自建材质的 `normal_scale = -1.775` 同样被忽略。
+205
View File
@@ -0,0 +1,205 @@
# 2026-10-15
## 玩家状态管理迁移到 LimboAI LimboHSM
- 需求:玩家场景的状态管理完全用 LimboHSM 实现(addons/limboai v1.8.1,GDExtension 版,
**无需启用 editor plugin**,ClassDB 里没有 HSMPlayer —— LimboHSM/LimboState 本身就是 Node)。
- 方案讨论:BT vs HSM 选型 → 玩家是人操作的(决策已由人完成),用 HSM 建模身体状态;
BT 留给将来的 NPC/autopilot。
- 新增 `character/player/hsm/`(11 个脚本):
- `player_hsm.gd`(根,转换表 Grounded⇄Airborne:`airborne`/`landed` 事件)
- `grounded.gd`(Grounded 子机:12 条转换 + `_enter` 按当下输入挑初始子状态)
- `airborne.gd`(Jump→Fall on `falling`;`_enter` 按 vy>0 挑 Jump 否则 Fall)
- `ground_state.gd`(地面叶子基类:统一判 try_jump/踏空 → `airborne`,再走 `_think()`)
- idle/walk/sprint/crouch/sneak.gd(写 motor.target_speed/crouching/sprinting + 动画)
- jump.gd/fall.gd(落地判定 `is_on_floor and vy<=0`,带 vy 门槛防起跳帧弹回)
- 改动:
- `player.gd`:加 `class_name Player`;挂 HSM;`_ready` 末尾 `hsm.initialize(self)` +
`hsm.set_active(true)`(仅权威端);管线第一位 `hsm.update(delta)`;animator 改调 `set_gait`。
- `player_motor.gd`:删掉从输入推断 sprint/crouch/target 的逻辑,暴露
`target_speed`(状态写)、`has_move_input()`、`try_jump()`(起跳在状态判定处就地执行,
同帧转 Jump);crouching/sprinting 变成状态写入的事实镜像(Look/DebugPanel 继续用)。
- `player_animator.gd`:`set_motion` 拆成 `play_state(state)`(状态 _enter 调)+
`set_gait(velocity)`(每帧刷倍率);删 `_resolve_state` 和 `idle_threshold`。
anim_state/anim_speed 同步属性保持不变(远端靠 Sync 复制驱动)。
- `player.tscn`:HSM 节点子树(LimboHSM root + Grounded{Idle,Walk,Sprint,Crouch,Sneak} +
Airborne{Jump,Fall}),根机器 `update_mode = 2`(MANUAL,编辑器里写入 int 失败,
手改 tscn 后 force_reload 场景生效)。
- 行为变化(有意为之):行走/站立切换改按**输入**判定(旧版按速度阈值 0.2 m/s),
顶墙时播 walk 而非 idle;其余(蹲伏优先于疾跑、跳跃缓冲、落地按输入挑状态)与旧版一致。
- 验证:全部脚本 `--check-only` 通过;无头探针场景模拟输入跑完整状态链
(Idle→Walk→Sprint→Sneak→Crouch→Idle→Walk→Jump→落地Walk→Idle),数值与理论吻合。
## 二次重写:小人国定标(同日)
- 用户明确:不保旧手感、可重写;**小人国玩法,角色身高约 0.2 m**;旧代码可删。
- 定标(全部固化进 player_motor.gd 默认值,删掉了 tscn 里的 walk/sprint 覆盖):
walk 2.419(≈12 身长/s)、sprint 3.624(≈18 身长/s)、crouch 1.21(走速一半)、
accel 22(0.11s 到全速)、air_accel 5.9(地面 1/4)、jump 1.5 / gravity_scale 0.5
(跳高 0.23 m ≈ 1.15 身高、滞空 0.61 s)不变。DebugPanel body_height 0.15 → 0.2。
- **修掉一个真 bug**:场景里 walk 被调到 2.419 后,动画器基准速度还是 0.4286,
walk/run 动画一直被 clamp 在 1.6× 上限。重构:删掉 animator 的 4 个 *_ref_speed 导出,
`play_state(state, ref_speed)` 由 HSM 状态传入基准速度(状态知道目标速度是多少),
`set_gait` 用存下的 `_ref_speed` 算倍率 —— 两处对表的坑没了。
- 探针复测:三个位移动画全速倍率均 1.00,转换链不变。
### 踩坑(复用注意)
1. **无头 `--check-only` 对新 class_name 报「Could not find type」**:
全局类缓存 `.godot/global_script_class_cache.cfg` 没刷新。让编辑器跑一次
`EditorFileSystem.scan()`(godot_ai MCP 的 filesystem scan)即解;批量循环检查时
同一批里前后结果不一致也是这个原因。
2. **godot-ai MCP `node_set_property` 写 enum int 属性失败**
(WRONG_TYPE: Cannot write String to a int property,value 传 JSON number 也一样)。
变通:MCP scene_save 后直接手改 .tscn 补属性行,再 `scene_open force_reload=true`。
3. **MCP 场景节点路径**要含场景根:`/Player/HSM`,不是 `/HSM`。
4. **LimboHSM 嵌套用法**:子状态机的 `_enter`(脚本 override)先于其 initial_state 激活执行,
所以可以在 `_enter` 里 `set_initial_state()` 动态挑入口子状态(落地按输入挑、空中按 vy 挑)。
5. **转换触发的环境判定要放叶子状态**,不要放父机 `_update`:父机先于子状态执行,
在父机里 dispatch 转换后,本轮级联还可能调到已退出的旧子状态(叶子自己判、dispatch 后
立即 return,没有这个窗口)。
6. 无头模拟按键:`Input.action_press` 只改动作状态、**不发输入事件**(Motor 的跳跃缓冲在
`_unhandled_input` 收事件,探不到);要用 `Input.parse_input_event(InputEventAction)`。
---
## main2 卡通渲染 / 世界环境 / 光照 调优(达到可交付观感)
目标:把 `world/main2.tscn` 的卡通着色器参数 + WorldEnvironment + 光照调到"商业化"水平。
改了 5 个文件:`project.godot`、`world/main2.tscn`、`shader/toon.tres`、
新增 `shader/toon_ramp_1d.tres`、删除 `shader/new_gradient_texture_1d.tres`。
### 动画诊断出的三个致命问题
1. **描边等于不存在**。`cel-shader-base` 的 `next_pass` 链、`outline.gdshader` 都正常,
但 `outline_width = 2.0` 在这个 Godot 版本里只对应约 **0.1px**,肉眼不可能看见。
实测标定:`像素宽度 ≈ outline_width × 0.057`(1364px 视口)。已改为 **30**(≈1.7px)。
2. **`toon.tres` 的 `specular` / `fresnel` RGB 都是纯黑** —— 两个模块虽然连着,贡献恒为 0。
3. **漫反射渐变是"黑→硬边→白"的二值 ramp**,导致场景只有一层死板明暗。
### 最关键的一条坑(务必记住)
**编辑器只在启动时注册 `[shader_globals]`。** 外部(含 MCP `settings_set`)改
project.godot 的全局着色器参数,正在跑的编辑器视口**完全不变**。
我就是因为这个,一开始把 `outline_width` 从 2 调到 10 再到 60,截图却毫无变化,
最后才定位到根因。验证手段:写一个 `res://tests/test_*.gd`(必须 `extends McpTestSuite`),
在 `test_*` 方法里直接 `RenderingServer.global_shader_parameter_set(...)`,
再用 MCP `test_run` 执行 —— 能在编辑器进程内把值推进渲染器。
同时用这个办法做了隔离实验,排除了 4 个错误猜想:雾、SSAO、泛光、菲涅尔**都不是**
墙面噪点的来源;真正的噪点是**截图传输压缩**(480px 截图墙面完全干净,1364px 才有颗粒)。
另外顺手证实 `POSITION` 顶点覆盖是生效的(写 `p.y += p.w * 0.8` 面板会整体下移),
所以描边 pass 本身没问题,别去改上游 `outline.gdshader`。
### 最终取值
- **着色器全局**(`project.godot [shader_globals]`):
`diffuse_curve = toon_ramp_1d.tres`、`specular_smoothness = 0.08`、
`fresnel_smoothness = 0.12`、`outline_width = 30`、
`outline_color = Color(0.08, 0.07, 0.1, 1)`。
- **新 ramp**(`shader/toon_ramp_1d.tres`,GradientTexture1D/256):
0.00–0.30 → 0.24;0.38–0.62 → 0.60;0.70–1.00 → 1.00,两级转场各 0.08 软边。
- **材质**(`shader/toon.tres`,以及 main2 里地板的独立 ShaderMaterial):
`specular = (0.12,0.12,0.12,0.45)`、`fresnel = (0.4,0.55,0.8,0.18)`。
- **Environment**:天空换成项目现成的 `world/sky/cartoon_sky.tres`(与 character_select /
ocean_demo 一致);`ambient_light_source = 3`(Sky) + 冷色补光
`(0.3,0.38,0.55)`、sky_contribution 0.45、energy 0.5;ACES 色调映射(`tonemap_mode=3`,
这个版本枚举确实是 LINEAR0/REINHARDT1/FILMIC2/ACES3/AGX4);SSAO 轻度(intensity 1.0、
detail 0.3、light_affect 0);Glow 阈值抬到 2.2 防止白面爆光;雾 0.004 + aerial 0.3;
adjustment contrast 1.12 / saturation 1.08。
- **光照**:平行光改暖色 `(1,0.925,0.83)`、energy 0.95、`light_angular_distance 0.8` 软阴影、
bias 0.05 / n_bias 1.5、max_distance 60;sconce 聚光灯 energy 3.2 / range 12 /
angle 55 / 开启阴影。顺手开了 `anti_aliasing/quality/msaa_3d = 2`(4x,描边抗锯齿)。
### 排查时踩的 MCP 细节
- MCP `settings_set` **写不了 Color**:传 `{"r":..,"g":..}` 会落成嵌套字典、
传 `"#141219"` 会落成 String,两者都不是引擎要的 Color。
正解是手改 project.godot 写成 `Color(r,g,b,a)`。改完用无头探针脚本
(`extends SceneTree` + `ProjectSettings.get_setting()` 打印 `typeof(inner)`)复验。
- `settings_set` 会把**整个** ProjectSettings 从内存写回磁盘,
所以它写完之后我手改过的 `Color(...)` 会被覆盖成 String —— **必须最后一步再手改**。
- `editor_screenshot` 的 `view_target` 会**移动用户的编辑器相机**;`max_resolution=0`
才是原生分辨率。
### 追加:远处地面"发白"的根因 = fresnel 掠射角饱和(务必记住)
用户反馈:main2 里走廊/餐厅方向的地面(z≈15~25)呈一片**灰白偏冷的平面**,近处木地板正常。
- 排查路径:`floor1.glb` 只有 1 个 mesh / 1 个材质(用 Python 解 GLB 的 JSON chunk 确认),
AABB 覆盖 x −18.2~16.6、z −24.9~32.9 —— **餐厅地面就是这块地板**,不是缺几何、
也不是贴图问题(BaseColor 是纯木纹)。地毯不泛白是因为它的固有色深,把叠加量盖住了。
- **根因在 `shader/includes/base-cel-shader.gdshaderinc` 的 `fresnel_light()`**:
```
fresnel_dot = 1.0 - dot(normal, view); // 水平面越远越掠射 → 越接近 1
fresnel_threshold = pow(1.0 - fresnel_strength, dot(light, normal)); // ≈0.84
... * smoothstep(threshold ± fresnel_smoothness/2, fresnel_dot)
```
实测:5 m 处 fresnel_dot≈0.69(=0,无贡献),20 m 处≈0.92(=1,满强度)。
于是**远处地面被整片点亮**,叠加的是 `fresnel_color = (0.4,0.55,0.8)` 蓝白,
木地板 albedo(≈0.34,0.24,0.2) + 叠光(≈0.40,0.51,0.66) → 就是用户看到的灰白。
- **是上一轮调优引入的**:`fresnel` 原来 RGB=(0,0,0)(贡献恒 0,所以从没显形),
上一轮改成 (0.4,0.55,0.8) 后才出现。**大平面(地板/天花板)上放菲涅尔轮廓光必然有这副作用。**
- 修法(择一):`fresnel.a` 调到 0.05 以下;或把 `fresnel_color` 压暗到接近物色;
或把 `fresnel_smoothness`(全局 uniform)调小收紧过渡带。当前已按用户要求
**恢复为 (0.4,0.55,0.8,0.18)**,泛白会重现,等用户定夺。
### 追加:MCP `material_manage(apply_to_node)` 会写盘并**删除孤儿资源**
- 用 MCP `apply_to_node` 给 `model/floor1/pPlane1` 临时套一个纯红 unshaded 材质做隔离实验,
之后 `scene_open force_reload` **材质没有恢复** —— 因为该操作**直接改了磁盘上的 .tscn**
(返回值里的 `saved_to: ""` 不代表没落盘)。
- 更严重:Godot 重新序列化时把**失去引用的资源全删了** —— 地板配套的
`ShaderMaterial_mmyc1`(cel + outline)、`ShaderMaterial_tyxef`,
以及 3 个 `ext_resource`(`outline.gdshader`/`cel-shader-base.gdshader`/地板 BaseColor 贴图)
一并消失,同时把等于默认值的属性行省略(如 `glow_blend_mode = 1`)。
- **教训:拿 MCP 改材质做实验前先 `git stash` 或备份 .tscn**;事后用 `git diff` 对照逐行恢复。
恢复时注意用**调优后的值**(不是 HEAD 的原值)。
## 阴影锯齿专项(main2 沙发/盘子)
- 现象:沙发扶手上大片「梳齿/蕨叶」状黑纹 + 盘子影子边缘阶梯状锯齿。
- 逐个隔离(改 `shadow_enabled` / 单变量逐项)后确认:**都来自 `DirectionalLight3D`**。
- **根因 = `light_angular_distance = 0.8`**。它开启 PCSS 软阴影,半影半径随距离放大,
但 Godot 的采样数有限 → 出现稀疏采样尖刺(就是那片蕨叶纹)。
归零后(`0.0`)两个 artifact **同时消失**。
- 次要项:工程设置 `soft_shadow_filter_quality` 默认是 **2(Soft Low)**,
提到 **4(Soft High)** 后高频噪点能量 1.149 → 0.687(-40%),强跳变 756 → 630。
- `shadow_blur`(1.0/1.5/2.5/4.0)实测**几乎无影响**(0.669~0.687),已删掉该覆盖行。
- `shadow_bias`/`shadow_normal_bias` 归零会导致**全身自阴影爆发**(阴影面积 18484 → 114706),
必须用引擎默认 0.1 / 2.0(原场景的 0.05 / 1.5 偏低)。
- 最终:`light_angular_distance = 0.0`、删掉 bias/blur 覆盖行、
`directional_shadow_max_distance 60 → 45`、`project.godot` 加
`lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality=4`。
- 前后对比图存在 `docs/shadow_aliasing_fix.png`。
### 追加:用户把 blur 归零后「全画面三角形颗粒」
用户手动把 `shadow_blur = 0`,**同时把 `shadow_normal_bias` 也设成了 0**,
结果整幅画面(沙发、盘子、地板全部)盖满斜向的三角颗粒 / 摩尔纹。
在独立进程里做了 2×2 对照(`soft_shadow_filter_quality = 4`,
指标 = 区域内相邻像素亮度差的平均,越低越干净):
| 组合 | 4096 贴图 | 8192 贴图 |
|---|---|---|
| blur 0 + normal_bias 0(**用户当时**) | **12.730** | **17.923** |
| blur 0 + normal_bias 2 | 0.638 | 0.611 |
| blur 1 + normal_bias 0 | 0.566 | 0.522 |
| blur 1 + normal_bias 2(推荐) | 0.577 | 0.532 |
| blur 0.6 + normal_bias 2 + max_dist 25 | 0.546 | 0.537 |
结论:
- **必须「blur=0 且 normal_bias=0」同时成立才会爆**(12.7)。任意改回一个就回到 ~0.6。
机制:`normal_bias = 0` 会在**每个阴影贴图纹素**上产生自阴影判定抖动,
而 `shadow_blur` 正是那个 PCF 半径 —— 它被归零后抖动不再被平滑,
于是以贴图纹素的频率原样显现成三角颗粒(贴图纹素斜投影到受光面就是四边形/三角)。
- **加大阴影贴图分辨率没用,反而更糟**(12.730 → 17.923)。
分辨率越高的贴图纹素越细密 → 抖动频率越高 → 颗粒越密越明显。
**所以"锯齿是阴影贴图不够大"是错的直觉。**
- 想要更锐的阴影:`shadow_blur` 可以取 0(normal_bias 保持 2.0 就干净,0.638)。
- 已把编辑器的 `shadow_normal_bias` 恢复到 2.0、`shadow_blur` 恢复到 1.0
(用 MCP `node_set_property`,**实测它只改编辑器内存、不写盘**)。
- 对比图存在 `docs/shadow_normal_bias_fix.png`。
+26
View File
@@ -0,0 +1,26 @@
# 2026-10-16
## 修复:第一人称相机高达 1.16 m(玩家缩放被静默抹掉)
- 现象:进游戏后第一人称视角像一米多高看得见 woman 的胸口;DebugPanel 显示
「相机高度 1.161 m / 5.80 身高」。
- 根因:`world/main.gd` 的 `_create_player()` 里写 `player.global_position = ...`,而该节点
**此时还没入树**(MultiplayerSpawner 之后才 add_child)。`Node3D.get_global_transform()`
在未入树时 `ERR_FAIL` 返回**单位矩阵**,`set_global_transform()` 顺势把 Player 根节点的
basis 换成单位阵 → **player.tscn 那层 0.096096 缩放被抹掉**。
- 无头探针对照(复现两种写法,同一份 player.tscn):
| 写法 | Player.global_scale | 模型世界高 | 相机相对脚底 |
|---|---|---|---|
| 旧 `global_position` | 1.0 | 1.561 m | **1.1610 m** |
| 新 `position` | 0.096096 | 0.150 m | **0.1116 m** |
旧写法的 1.1610 m 与游戏内面板的 1.161 m 逐位吻合;控制台每个玩家还会刷一条
`ERROR: Condition "!is_inside_tree()" is true.`。
- 修复:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换,
局部坐标即世界坐标)。`--check-only` 通过。
- 离屏渲染三张对照图(woman 1.703 m 站位旁):`probe_fix_A.png`(同框,旧写法巨鼠几乎挡住
woman)、`probe_fix_B.png`(woman 脚边,修复后 15 cm 小鼠)、`probe_fix_C.png`(修复后
第一人称,面板读数 0.112 m / 0.56 身高)。
- 附带确认:`InteractionRay` 世界长度在两种状态下都是 0.25 m(`Camera3D` 全局缩放恒为 1.0),
所以修缩放不影响拾取距离。
+212
View File
@@ -0,0 +1,212 @@
# agame 项目长期笔记
Godot 4.7.2(Forward+,Jolt Physics,D3D12)。**可用编辑器(2026-10-16 实测):
`E:\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
(mono 版;同目录另有 `..._console.exe`)。**
⚠️ 旧记录里的 `D:\_yvmou\Godot\...` **已失效 —— D 盘根本没有 `_yvmou` 目录**,别再照抄。
## 代码约定
- **GDScript 警告被当作错误**(至少 `inference_on_variant`)。不要写成
`var x := some_variant_returning_call()`;要显式写类型,例如
`var s: Script = node.get_script() as Script`。改完脚本务必用
`--check-only --script res://xxx.gd` 自查。
- 缩进用 tab,注释用中文(项目既有风格)。
## 无头验证的正确姿势(踩过坑)
- `--check-only --script res://x.gd`:GDScript 语法校验,有效。
- `godot --headless --editor --path <proj> --quit`:验证编辑器插件能否加载,有效。
- **不要**用 `extends SceneTree` 的 `--script` 脚本去测 `_ready()` 相关行为:
这种模式下节点不会真正进入场景树(`is_inside_tree()` 为 false),`_ready` 不执行,
会得出完全错误的结论。正确做法是造一个临时场景,用位置参数当主场景跑:
`godot --headless --path <proj> res://tools/_tmp_probe.tscn`。
- 即使是 `--headless`(dummy 渲染后端),**shader 编译错误仍会被报告**
(`SHADER ERROR: ...`),所以可以用无头运行来校验 shader 语法。
## 玩家 = LimboAI LimboHSM(2026-10-15 起)
- LimboAI v1.8.1 是 **GDExtension 版**(addons/limboai 只有 bin/,无 plugin.cfg),
**不需要启用 editor plugin**;ClassDB 里没有 HSMPlayer —— `LimboHSM`/`LimboState` 本身就是 Node。
- 玩家状态机在 `character/player/hsm/`:根 `player_hsm.gd`(Grounded⇄Airborne)+
Grounded{Idle,Walk,Sprint,Crouch,Sneak} + Airborne{Jump,Fall},全是场景节点、脚本挂状态上。
- 分工:**HSM 管「是什么状态」**(写 motor.target_speed/crouching/sprinting + 调
animator.play_state),**motor 只管物理**(重力/加速度/跳跃缓冲 + try_jump),
animator 只管「怎么播」(play_state + set_gait 刷倍率)。player.gd 管线第一位
`hsm.update(delta)`(MANUAL 模式,仅权威端 initialize+set_active;远端靠 Sync 复制)。
- 走/站立转换按**输入**判定(非旧版的速度阈值)——顶墙时播 walk 不播 idle,是预期行为。
- 用法要点:子机 `_enter` 里可 `set_initial_state()` 动态挑入口子状态;环境判定
(起跳/踏空/落地)放**叶子**状态的 _update,别放父机;落地判定要带 `vy<=0` 门槛。
- 踩坑:新 class_name 后无头 `--check-only` 报「Could not find type」= 全局类缓存没刷新,
让编辑器 scan 一次即解;MCP node_set_property 写 enum int 失败 → 手改 tscn + 场景 force_reload。
## toon 卡通渲染管线
- `shader/cel-shader-base.gdshader`(`USE_*` 宏开关各模块)+ `shader/includes/*.gdshaderinc`,
上游是 godot4-cel-shader 项目。
- `shader/toon.tres` 是材质模板;`shader/outline.gdshader` 作为 `next_pass` 描边。
- `shader/toon_apply.gd`(@tool Node3D)给每个 MeshInstance3D 复制一份独立材质,
因为**每个模型有各自的贴图**,所以必须独立(不能共享一份材质)。
- 已在项目设置里用了 **global uniform**:`diffuse_curve`、`specular_smoothness`、
`fresnel_smoothness`、`outline_width`、`outline_color`。
⚠️ **但"全局实时生效"是错的**(2026-10-15 实测纠正):编辑器只在**启动时**
把 `[shader_globals]` 注册进 RenderingServer,之后外部改 project.godot(或
MCP 的 `project_manage settings_set`)**不会**影响正在运行的编辑器视口。
手动改完全局参数必须**重启编辑器**(或临时用 `test_run` 跑一段
`RenderingServer.global_shader_parameter_set()` 把值推进去)才能看到效果。
这正是当初"改了 outline_width 却毫无变化"的原因。
- `outline_width` 的实际像素宽度约为 `width × 0.057`(1364px 视口实测校准),
所以**原来的 2.0 实际只有约 0.1px —— 等于没描边**。要看到 2px 描边需要 width ≈ 30。
- 贴图尺寸极不统一(289 张 PNG / 24 种尺寸,1024²、512²、4096²、32² 混杂),
因此**不适合** Texture2DArray + per-instance uniform 方案(显存会爆且要额外打包流水线)。
- 编辑器插件 `addons/toon_refresh/`:改 toon.tres 时自动重刷 toon_apply 材质。
**只覆盖材质级参数**(color/specular/fresnel/uv),管不到 global uniform。
- ⚠️ **`fresnel`(轮廓光)不能给大平面用**:`fresnel_light()` 以
`1 - dot(normal, view)` 为激活量,水平地面/天花板稍远就是掠射角 → 直接饱和成满强度,
整片平面被 `fresnel_color` 点亮。**"远处地面发白"就是这么来的**
(原来是 (0,0,0) 贡献恒 0 所以没暴露,2026-10-15 改成 (0.4,0.55,0.8) 后显形)。
真要留轮廓光就把 `a` 压到 ≤0.05,或把颜色调暗到接近物色。
- ⚠️ **MCP `material_manage(op="apply_to_node")` 直接改磁盘 .tscn**,
且重新序列化时会**删除失去引用的子资源与 ext_resource**
(实测:地板实验后 `ShaderMaterial_mmyc1`/`tyxef` + 3 个 ext_resource 全被清掉,
`saved_to:""` 也照样落盘)。拿 MCP 做材质实验前先备份,事后用 `git diff` 逐行核对恢复。
## 卡通渲染调参(2026-10-15 定的基线取值)
- `shader/toon_ramp_1d.tres`(新,替代已删除的 `new_gradient_texture_1d.tres`):
三级 cel 明暗带,灰阶 0.24 / 0.60 / 1.00,转场各留 0.08 软边。
注意 `diffuse_sample()` **只取红通道**,所以渐变做成彩色也没用。
- `specular_smoothness = 0.08`、`fresnel_smoothness = 0.12`;
原值 10.0 会把高光压到几乎不可见(`smoothstep(0.05, 10.05, x)`)。
- `outline_width = 30`、`outline_color = Color(0.08, 0.07, 0.1, 1)`。
- `toon.tres` 的 `specular` / `fresnel` **RGB 原来都是纯黑 = 两个模块完全失效**,
现已改为 `specular=(0.12,0.12,0.12,α0.45)`、`fresnel=(0.4,0.55,0.8,α0.18)`。
注意 specular 的 α 只用来推 gloss 指数,**亮度只能靠 rgb 调**。
- MCP `settings_set` 写不了 Color:传 dict 会落成 `{"r":..}` 嵌套字典(引擎无法绑定),
传 `"#rrggbb"` 会落成 String。**正确做法是手改 project.godot 写成 `Color(r,g,b,a)`。**
int 型也一样:会落成 `={"type": "int","value": 4.0}` 的字典块,**用完记得手改回 `=4`**。
## 阴影质量(2026-10-15 定位完毕)
- **`DirectionalLight3D.light_angular_distance` 是本项目阴影锯齿的元凶。**
它开启 PCSS 软阴影:半影半径 = 遮挡距离 × tan(角径),但 Godot 的 PCSS
采样数固定 → 半径一大就变成**稀疏采样尖刺**,表现为:
1) 大面积表面(沙发扶手内侧面)上出现**成片的「梳齿/蕨叶」黑纹**;
2) 物体投影(白盘子)边缘出现**阶梯 + 毛刺**。
实测 `0.8°` 时 artifact 明显,**改成 `0.0` 后两者同时消失**。
⚠️ 别以为角径越大越"柔软自然"——在这个引擎里它只会换来尖刺。
- 工程设置 `rendering/lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality`
**默认只有 2(Soft Low)**,提到 **4(Soft High)** 实测高频噪点能量降 40%。枚举顺序:
`0 Hard, 1 Soft Very Low, 2 Soft Low, 3 Soft Medium, 4 Soft High, 5 Soft Ultra`。
- `shadow_blur`(0~10,默认 1.0)在 angular=0 时**几乎测不出影响**,不用调。
- `shadow_bias` / `shadow_normal_bias` **别低于引擎默认 0.1 / 2.0**:
归零会让全身自阴影爆发(阴影面积 18484px → 114706px)。原场景的 0.05 / 1.5 偏低。
- ⚠️ **`shadow_normal_bias = 0` + `shadow_blur = 0` 同时出现 = 整幅画面三角颗粒/摩尔纹**
(2026-10-15 实测,相邻像素亮度差 0.58 → 12.73,20 倍):
`normal_bias = 0` 让**每个阴影贴图纹素**都自阴影抖动,而 `shadow_blur` 就是 PCF 半径,
归零后抖动不再被平滑,于是按贴图纹素频率原样显现(纹素斜投影 → 三角/四边颗粒)。
任意恢复其中一个即可(0.638 / 0.566)。只想锐化就把 blur 取 0、**normal_bias 留 2.0**。
- ⚠️ **提高阴影贴图分辨率救不了锯齿**:`directional_shadow/size 4096 → 8192`
在正常配置下只改善 ~8%,在坏配置下**反而更糟**(12.73 → 17.92),
因为纹素越细抖动频率越高。"锯齿 = 贴图不够大"是错误直觉。
- 室内场景可把 `directional_shadow_max_distance` 收到 45(原来 60),近场纹素密度提高 1.3×。
## 验证"启动时固化"的设置(可复用套路)
`[shader_globals]`、`rendering/lights_and_shadows/*`(阴影贴图尺寸、软阴影滤波质量)、
MSAA 等**只在引擎启动时读一次**,改 project.godot 对运行中的编辑器视口无效。
不要靠编辑器截图验证这类改动,走这条独立进程路线:
1. 造一个探针场景 `tools/_probe.tscn` + `tools/_probe.gd`:`extends Node3D`,
在 `_ready()` 里 `load("res://world/xxx.tscn").instantiate()`、加一台 Camera3D、
等 ~60 帧、`await RenderingServer.frame_post_draw` 后
`get_viewport().get_texture().get_image().save_png(...)`、`quit()`。
2. `DisplayServer.window_set_position(Vector2i(-6000,-6000))` 把窗口挪到屏幕外,不打扰用户。
3. 用 `--resolution 1364x772` + 位置参数指定场景跑:
`godot --path <proj> --resolution 1364x772 res://tools/_probe.tscn`(**别加 `--headless`**,
dummy 渲染后端出不了图)。
4. 想在探针里改灯的参数做单变量对照,直接在脚本里改 `DirectionalLight3D` 的属性再截图,
一次进程就能出多张对照图。
5. **要像素级对齐编辑器视口**:`res://tests/test_xxx.gd`(`@tool` + `extends McpTestSuite`,
`print` 抓不到,得用 `FileAccess` 写文件)里读
`EditorInterface.get_editor_viewport_3d(0).get_camera_3d().global_transform`,
再 `test_run` 执行,就能拿到确切机位/ FOV。本项目的 sofa 机位是
`origin(-12.070005, 4.639197, -2.615989)`,`fov 70.01`,视口 1364×772。
6. 注意 main2 的「联机测试」UI 是 **autoload(`autoload/net.tscn`)**,不在场景树里,
探针要递归隐藏 `get_tree().root` 下的 `CanvasLayer`/`Control` 才遮不掉画面。
## 坐标系陷阱 + 上游着色器的边界(重要)
Godot 4 的 `fragment()` 里 `NORMAL` / `TANGENT` / `BINORMAL` 都处于**视图空间**,
而 `NORMAL_MAP` 要的是**切线空间**值、由引擎用 TANGENT/BINORMAL 还原。
把切线空间值直接赋给 `NORMAL` 会让整个场景的法线变成一个跟着相机转的常量 ——
表现为平视全黑、俯瞰才亮。
但**不要**因此去改上游文件:`cel-shader-base.gdshader` 与
`includes/normal-map.gdshaderinc` 来自 <https://github.com/eldskald/godot4-cel-shader>,
本项目的策略是**逐字跟随上游**。上游的法线贴图路径 `NORMAL = normal.vector;`
确实有上述坐标空间错误,但上游所有变体都是 `#define USE_NORMAL_MAP 0`,
全仓库从未启用过这条路径(README 也没提),所以是"能编译但结果错"的死代码。
本项目保持 `USE_NORMAL_MAP 0`,与上游一致;`toon.tres` 里残留的
`shader_parameter/normal_scale` 在宏关闭时会被忽略,无副作用。
**结论:要动上游 shader 之前,先用 `E:/Godot/godot4-cel-shader-main/` 里的干净副本 diff。**
正确做法(若将来真要开法线贴图):只写 `NORMAL_MAP = 原始切线空间值` +
`NORMAL_MAP_DEPTH`,让引擎合成;不要自己写 `NORMAL`,也不要预先做 `*2-1` 重映射
(引擎自己做,蓝通道由引擎重建)。
## 玩家尺寸 / 相机高度(2026-10-16 查证)
全在 `character/player/player.tscn`,「大小」是三层缩放叠加:
1. **`Player` 根节点整体 scale = 0.096096**(第 38 行)—— 主开关,模型+碰撞+相机一起缩放。
2. **`Body/mouse` 模型自身 scale = 0.18240134**(第 87 行,X 为负 = 180° 翻转朝向);
实际模型缩放 = 0.096096 × 0.1824 ≈ 0.0175。只想改模型不动碰撞就改这里。
3. **碰撞胶囊(局部)** `CapsuleShape3D: radius 0.54541016 / height 1.4063476`(第 33-35 行),
`Collision` 节点 y = 0.7031738(= height/2,贴地)。
- ⚠️ **相机高度与缩放联动,改一个必须同步改另一个**:站立视线高 = `CameraRig.position.y`
(当前 1.6,第 46 行),世界高度 = 1.6 × 0.096096 ≈ **0.154 m**;而 `Camera` 节点自身
scale = **10.4062605 = 1/0.096096**(第 49 行),唯一作用就是**抵消父级的 0.096 缩放**,
让相机在世界空间回到 1:1(否则 near/FOV 异常)。动了 `Player` 的 scale,这个倒数必须一起改。
- **无头探针实测(2026-10-16,真实实例化 `player.tscn`)**:`Player.global_scale = 0.096096`;
模型 `Body/mouse.global_scale = 0.017528`;**视觉包围盒 = 0.158 × 0.150 × 0.135 m(约 15cm)**;
胶囊世界尺寸 = 半径 5.24cm / 总高 13.5cm;**相机在脚底上方 0.1116 m**。
⚠️ 相机净高度 ≠ 1.6 × 0.096 = 0.154 —— `Camera` 自身局部还带 `y = −0.439`,
实际 = (1.6 − 0.439) × 0.096096 ≈ **0.1116 m**(`DebugPanel._camera_height()` 用
`camera.global_position.y − player.global_position.y` 读数就是这个值)。
- ⚠️⚠️ **2026-10-16 定位并修复的重大 bug:`world/main.gd` 的玩家生成会把 Player 根节点的缩放静默抹掉。**
旧代码在 `spawn_function` 里写 `player.global_position = player_spawn_pos.global_position`,
但该节点**此刻还没入树**(spawner 之后才 add_child)。`Node3D.get_global_transform()` 未入树时
直接 `ERR_FAIL_COND_V(!is_inside_tree(), Transform3D())` 返回**单位矩阵**,于是
`set_global_transform()` 把 Player 的 basis 整体换成单位阵 —— **0.096096 那层缩放没了**。
实测后果(与游戏内 DebugPanel 逐位吻合):玩家模型 **1.561 m**、第一人称相机 **1.1610 m**,
面板显示「相机高度 1.161 m / 5.80 身高」;控制台每生成一个玩家刷一条
`ERROR: Condition "!is_inside_tree()" is true.`。
修法:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换,
局部坐标即世界坐标)。**通用规则:未入树的节点只能写局部 position/transform。**
- 全项目**只有** `player.tscn` 一个 CharacterBody3D;`main2.tscn` 的 `World` 根节点无缩放(1:1),
所以 15cm 就是游戏内真实绝对尺寸。玩家是**约 15cm 高的小老鼠**,有意为之(见
`player_motor.gd` / `debug_panel.gd` 注释里的「小人国玩法,身高约 0.2 m」基准;
走速 2.419 m/s ≈ 12 身长/秒就是按 0.2 m 身高定标的 —— 缩放被抹掉时手感同样全错)。
- 相机自身 `scale = 10.4062605 = 1/0.096096`,作者意图是**抵消父级的 0.096**;实测
`Camera3D` 的全局缩放两种玩家缩放下都是 1.0,所以 `InteractionRay.target_position = (0,0.25,0)`
在世界里恒为 0.25 m —— **不随玩家缩放变化,改缩放时不用动它**。
- `ui/character_select.tscn`(选人界面)的预览模型只挂了 `0.18240133` **一层**缩放
(没有 Player 那层 0.096),模型 ≈ 1.56 m,机位 `PreviewCamera` 在 `(0,0.62,1.5)` 看向 `(0,0.45,0)`
也是按这个尺度配的。它是**进游戏前的展示界面**,与游戏内 15cm 天然不同尺(要一致就再乘 0.096
并把机位挪到 ~0.15 m 高)。
- **渲染/骨骼实测(2026-10-16)**:`main2.tscn` 的 `woman` AABB 高 **1.703 m**(世界是 1:1 真人尺度);
绕开 `main.gd` 直接实例化 `player.tscn` 时,玩家渲染出来约 15 cm、`Skeleton3D` 骨骼竖直跨度
0.099 m。⚠️ 这一组数据只对「直接实例化」成立 —— 走真实生成路径时缩放会被上面那条 bug 抹掉。
- ⚠️ **探针技巧**:`player.gd` 的 `_ready()` 会把**整只模型** `Body/mouse` 设为 `visible = false`,
探针里只把下面的 `MeshInstance3D` 设成 `true` **没用**(父节点还藏着),
必须显式 `player.get_node("Body/mouse").visible = true`。这个坑踩了三轮才排掉。
- ⚠️ 量 skinned mesh 时不能只看 `mi.global_transform * mi.get_aabb()`:那是 **mesh 资源**的
包围盒,**不含骨骼变换**,位置/尺寸都可能偏(实测玩家 mesh AABB 落在 Player 原点**下方**,
而真实骨骼在原点**上方**)。要真实数据就查 `Skeleton3D.get_bone_global_pose()`,或者直接渲染截图量像素。
- 蹲伏高度由 `player_look.gd` 的 `crouch_height_ratio = 0.6` 从 `CameraRig.position.y` 推导,
改 rig 的 Y 会自动跟随,不用改脚本。
- `world/main.tscn` 根节点 `Main`(Node3D)无缩放,`MultiplayerSpawner.spawn_path = ".."`,
所以 Player 的 0.096 即最终世界缩放。(`PlayerSpawnPos` 在 y=4.8,出生会先坠落。)
+651
View File
File diff suppressed because one or more lines are too long
+61
View File
@@ -613,6 +613,12 @@ static func configure(id: String, url: String = "", launch_context: Dictionary =
if not client.automatic_config_edits:
return _manual_edit_result(client, "configure")
var path_error := _config_path_resolution_error(client)
if path_error.is_empty():
path_error = _config_write_error(client, "configure")
if path_error.is_empty():
path_error = _config_create_error(client)
if path_error.is_empty():
path_error = _credentials_error(client)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
## Capture `url` once so a port flip in EditorSettings between write and
@@ -857,6 +863,8 @@ static func remove(id: String, url: String = "", launch_context: Dictionary = {}
if not client.automatic_config_edits:
return _manual_edit_result(client, "remove")
var path_error := _config_path_resolution_error(client)
if path_error.is_empty():
path_error = _config_write_error(client, "remove")
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if url.is_empty():
@@ -897,6 +905,49 @@ static func _config_path_resolution_error(client: Client) -> String:
return str(client.resolved_config_path_details().get("error", ""))
## Why Configure must not create this client's file although status may still
## read the path: `create_error` in `McpClient`'s candidate rules. Kept apart
## from the function above, whose signature `script/local-self-update-smoke`
## patches by exact match, in release trees as well as this one.
static func _config_create_error(client: Client) -> String:
if client.config_type == "cli":
return ""
return str(client.resolved_config_path_details().get("create_error", ""))
## Why neither Configure nor Remove may touch this client's file: this editor's
## Flatpak sandbox does not share it with the host (`McpClient.config_write_error`,
## or the CLI strategy's own rule for a client that writes through its CLI, where
## Configure and Remove differ). Every strategy refuses on it too; checking here
## puts the refusal ahead of launcher discovery and the lock.
static func _config_write_error(client: Client, action: String) -> String:
if client.config_type == "cli":
if action == "configure":
return CliStrategy.configure_write_error(client)
return CliStrategy.remove_write_error(client)
return client.config_write_error()
## Why Configure must not write an attach entry although the client's file is
## writable: this editor's Flatpak sandbox keeps Godot AI's credentials where
## the bridge that entry launches does not look. A missing entry is better
## than one known to be broken. Remove is not held to it.
static func _credentials_error(client: Client) -> String:
if client.command_shape == Client.CommandShape.NONE:
return ""
if not McpPathTemplate.flatpak_hides_credentials():
return ""
var app_id := McpPathTemplate.flatpak_app_id()
return (
"Godot runs in a Flatpak sandbox that keeps Godot AI's credentials to "
+ "itself, so the bridge %s starts could not connect and nothing was "
+ "changed. Run `flatpak override --user "
+ "--filesystem=xdg-config/godot-ai:create %s`, restart Godot, and try "
+ "again. docs/steam-capability-directory.md covers the other ways to "
+ "share them."
) % [client.display_name, app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
# --- Strategy dispatch + verify (testable seam) --------------------------
static func _manual_edit_result(client: Client, action: String) -> Dictionary:
@@ -1240,6 +1291,16 @@ static func manual_command(id: String) -> String:
)
if cmd.is_empty():
return cmd
## The path above is the one outside Flatpak. When this sandboxed editor
## cannot see where a Flatpak build keeps the same file, that may be the
## wrong one for this user, and this text is all a refused Configure (or a
## manual-only client) leaves them to go on.
var hidden_paths: Variant = path_resolution.get("hidden_paths", null)
if hidden_paths is PackedStringArray and not hidden_paths.is_empty():
cmd += (
"\n\nA Flatpak build keeps this file at %s instead. Godot's own Flatpak "
+ "sandbox cannot see it, so edit that one if it is the build you use."
) % " or ".join(hidden_paths)
## #507: when the allow-host opt-in names a non-loopback range, also
## surface the LAN URL so the user can copy-paste the right address into
## a remote agent. Informational only — configure/remove still WRITE the
+175 -6
View File
@@ -79,10 +79,21 @@ var path_template: Dictionary = {}
## a fallback path that may become invisible after copy-on-write. When
## that private leaf is new, Configure seeds it from the first later
## existing candidate so read-through content is not shadowed.
## 2. If no file or wildcard package match exists, the first non-wildcard
## template is the deterministic create target.
## 2. If no file or wildcard package match exists, the create target is the
## non-wildcard template whose own directories already exist, because the
## client has run from there (`_evident_create_path` gives the order),
## and otherwise the first non-wildcard template. This is what sends a
## Flatpak-only IDE's entry to `~/.var/app/<id>/config/...` instead of a
## `~/.config` it never reads.
## 3. Multiple matches within any wildcard group are ambiguous and fail
## closed instead of choosing an arbitrary package.
## 4. A template inside another Flatpak app's `~/.var/app/<id>` is never a
## create target while this editor's own Flatpak sandbox hides that
## directory (`McpPathTemplate.hidden_flatpak_app_dir`). When rule 2 then
## finds no existing directory among the rest, the resolution carries
## `create_error` and the unseen `hidden_paths`: status still reads the
## first template, and Configure refuses rather than create a file the
## client may never read.
##
## Exact-file and config-home environment overrides still have higher
## priority. When this map has no entry for the current platform,
@@ -331,6 +342,57 @@ func resolved_config_path_details() -> Dictionary:
return {"path": "", "error": unresolved_config_path_error(display_name, path)}
## Why Configure and Remove must leave this client's resolved file alone; ""
## when they may write it. See `unshared_flatpak_config_error`.
func config_write_error() -> String:
var path := resolved_config_path()
return "" if path.is_empty() else unshared_flatpak_config_error(display_name, path)
## Why Configure and Remove must leave the file at `path` alone when this
## editor runs in a Flatpak sandbox that does not share it read-write with the
## host; "" when it does, and outside Flatpak. A file written there is read
## back and verified by this editor and never seen by the client.
##
## Every strategy asks this before it writes, ahead of `create_error`: where a
## Flatpak build of the client keeps its settings is beside the point until
## the sandbox can write the ordinary location at all. Status does not ask.
## It keeps reading the path, and under a read-only grant that is the
## client's real file.
static func unshared_flatpak_config_error(client_name: String, path: String) -> String:
var block := McpPathTemplate.flatpak_write_block(path)
if block.is_empty():
return ""
var refused := (
"Godot runs in a Flatpak sandbox that cannot write %s where %s reads "
+ "it, so nothing was changed."
) % [path, client_name]
var by_hand := "or edit that file by hand (docs/steam-capability-directory.md)."
if block.has("unmounted"):
return refused + (
" Flatpak was asked to share %s but mounted nothing there, which "
+ "is what it does when the directory does not exist as Godot "
+ "starts. Create it, restart Godot, and try again, %s"
) % [block["unmounted"], by_hand]
if block.has("hidden"):
return refused + (
" Flatpak hides %s from this sandbox, as a --nofilesystem rule "
+ "does. Remove that rule, restart Godot, and try again, %s"
) % [block["hidden"], by_hand]
if block.has("read_only"):
return refused + (
" %s is mounted read-only. Make it writable, restart Godot, and "
+ "try again, %s"
) % [block["read_only"], by_hand]
var app_id := McpPathTemplate.flatpak_app_id()
return refused + (
" Run `flatpak override --user --filesystem=%s %s`, restart Godot, "
+ "and try again, or edit that file by hand: "
+ "docs/steam-capability-directory.md covers a sandbox without that "
+ "grant."
) % [block["needs"], app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
## Shared wording for a path template `McpPathTemplate.expand` could not fully
## resolve. Used by the guard above and by the merge-tier loader in
## `_json_strategy.gd`, which resolves its own templates and never passes
@@ -404,7 +466,11 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
var ordered_templates: Array = []
for template_variant in templates:
ordered_templates.append(str(template_variant))
var fallback_create_path := ""
## Candidates whose file does not exist yet and that Configure could create.
var create_templates := PackedStringArray()
var create_paths := PackedStringArray()
var hidden_app_dirs := PackedStringArray()
var hidden_paths := PackedStringArray()
for index in range(ordered_templates.size()):
var template := str(ordered_templates[index])
var expanded_template := McpPathTemplate.expand(template)
@@ -444,10 +510,113 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
return {"path": "", "error": seed["error"]}
_clear_config_path_warning()
return {"path": path, "error": "", "seed_path": seed.get("path", "")}
if fallback_create_path.is_empty():
fallback_create_path = path
# Creating below a directory the sandbox hides would land on its
# private tmpfs and vanish with the editor, so a hidden candidate can
# be neither evidence nor a create target.
var hidden_app_dir := McpPathTemplate.hidden_flatpak_app_dir(path)
if not hidden_app_dir.is_empty():
hidden_paths.append(path)
if not hidden_app_dirs.has(hidden_app_dir):
hidden_app_dirs.append(hidden_app_dir)
continue
create_templates.append(template)
create_paths.append(path)
_clear_config_path_warning()
return {"path": fallback_create_path, "error": ""}
var evident_create_path := _evident_create_path(create_templates, create_paths)
if not evident_create_path.is_empty():
return {"path": evident_create_path, "error": ""}
var fallback_create_path := "" if create_paths.is_empty() else create_paths[0]
if hidden_app_dirs.is_empty():
return {"path": fallback_create_path, "error": ""}
var hidden_error := hidden_flatpak_config_error(
display_name, fallback_create_path.get_base_dir(), hidden_app_dirs
)
if fallback_create_path.is_empty():
return {"path": "", "error": hidden_error}
return {
"path": fallback_create_path,
"error": "",
"create_error": hidden_error,
"hidden_paths": hidden_paths,
}
## The location the client has demonstrably run from, among candidates with no
## config file yet; "" when nothing says. Candidates are compared level by
## level, from the directory that would hold the file upward, and the first
## level at which one of those directories exists decides, in descriptor order.
## A client nested in its host app's settings (a VS Code extension) therefore
## follows the host app when the extension's own directory exists nowhere,
## instead of creating a second, unread copy of the host's settings tree.
##
## Only directories the templates name below their shared root are compared,
## and only as many levels as the shortest template has. Those belong to the
## client; a container such as ~/.config exists whether or not the client does.
static func _evident_create_path(templates: PackedStringArray, paths: PackedStringArray) -> String:
if paths.is_empty():
return ""
var shared := _shared_root_length(templates)
var levels := -1
for template in templates:
var own_directories := template.split("/").size() - 1 - shared
levels = own_directories if levels < 0 else mini(levels, own_directories)
var directories := PackedStringArray()
for path in paths:
directories.append(path.get_base_dir())
for _level in range(maxi(levels, 0)):
for index in range(directories.size()):
if DirAccess.dir_exists_absolute(directories[index]):
return paths[index]
for index in range(directories.size()):
directories[index] = directories[index].get_base_dir()
return ""
## How many leading path elements every template shares, counting the root
## element (`$XDG_CONFIG_HOME`, `~`, a drive) and never the file name. At least
## one: the root is never the client's own directory.
static func _shared_root_length(templates: PackedStringArray) -> int:
if templates.size() < 2:
return 1
var first := templates[0].split("/")
var shared := first.size() - 1
for index in range(1, templates.size()):
var other := templates[index].split("/")
var limit := mini(shared, other.size() - 1)
var same := 0
while same < limit and first[same] == other[same]:
same += 1
shared = same
return maxi(shared, 1)
## Why Configure will not create a client's settings file from inside a
## Flatpak editor: nothing shows the client runs from the ordinary location,
## and the location a Flatpak build of it uses cannot be inspected. Names the
## one grant that lets this editor see it.
static func hidden_flatpak_config_error(
client_name: String, settings_dir: String, hidden_app_dirs: PackedStringArray
) -> String:
var app_id := McpPathTemplate.flatpak_app_id()
var grants := PackedStringArray()
for app_dir in hidden_app_dirs:
grants.append("--filesystem=%s" % app_dir)
var missing := (
"%s does not exist" % settings_dir
if not settings_dir.is_empty()
else "it has no settings location outside Flatpak"
)
return (
"Could not find where %s keeps its settings: %s, and a Flatpak build "
+ "keeps them under %s, which Godot's own Flatpak sandbox cannot see. "
+ "If you use the Flatpak build, run `flatpak override --user %s %s`, "
+ "restart Godot, and configure again. If it is installed outside "
+ "Flatpak, start it once so it creates its settings, then configure "
+ "again."
) % [
client_name, missing, ", ".join(hidden_app_dirs),
" ".join(grants), app_id if not app_id.is_empty() else "<Godot's Flatpak ID>",
]
## Find a readable seed for a newly-created authoritative wildcard target.
+87 -11
View File
@@ -25,6 +25,8 @@ static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000
const _TRACE_PATH_LIMIT := 16
const _TRACE_PATH_MAX_CHARS := 512
## Find any of the supplied exe names; returns the first hit.
@@ -56,7 +58,15 @@ static func _find_one(exe_name: String, trace: Callable = Callable()) -> String:
var cached: String = _cache.get(exe_name, "")
_mutex.unlock()
if already_searched:
_trace_lookup(trace, "cache", "resolved" if not cached.is_empty() else "cached_miss", started, {}, true)
_trace_lookup(
trace,
"cache",
"resolved" if not cached.is_empty() else "cached_miss",
started,
{},
true,
cached,
)
return cached
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
@@ -76,12 +86,14 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations
var well_known_paths := PackedStringArray()
for dir in _well_known_dirs():
var full := dir.path_join(exe_name)
well_known_paths.append(full)
if FileAccess.file_exists(full):
_trace_lookup(trace, "well_known", "resolved", started)
_trace_lookup(trace, "well_known", "resolved", started, {}, false, full, well_known_paths)
return full
_trace_lookup(trace, "well_known", "no_match", started)
_trace_lookup(trace, "well_known", "no_match", started, {}, false, "", well_known_paths)
# 2. Login shell lookup (Unix only)
if not is_windows:
@@ -97,22 +109,50 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
_trace_lookup(trace, "login_shell", "resolved", started, login_result)
_trace_lookup(
trace,
"login_shell",
"resolved",
started,
login_result,
false,
login_found,
PackedStringArray([login_found]),
)
return login_found
_trace_lookup(trace, "login_shell", _lookup_failure(login_result, false), started, login_result)
# 3. which / where with inherited PATH
return _resolve_inherited_path(exe_name, is_windows, trace)
## Resolve from the inherited PATH through a single lookup command. The
## optional runner keeps the Windows/where failure modes deterministic in unit
## tests without changing the production invocation.
static func _resolve_inherited_path(
exe_name: String,
is_windows: bool,
trace: Callable = Callable(),
lookup_runner: Callable = Callable(),
) -> String:
var lookup := "where" if is_windows else "which"
started = Time.get_ticks_msec()
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
var started := Time.get_ticks_msec()
var result: Dictionary = {}
if lookup_runner.is_valid():
var injected: Variant = lookup_runner.call(lookup, exe_name)
if injected is Dictionary:
result = injected
else:
result = McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
_trace_lookup(trace, "inherited_path", "resolved", started, result)
_trace_lookup(trace, "inherited_path", "resolved", started, result, false, found, lines)
return found
_trace_lookup(trace, "inherited_path", _lookup_failure(result, true), started, result)
_trace_lookup(
trace, "inherited_path", _lookup_failure(result, true), started, result, false, "", lines
)
return ""
@@ -128,7 +168,8 @@ static func _lookup_failure(result: Dictionary, absence_exit: bool) -> String:
static func _trace_lookup(
trace: Callable, tier: String, status: String, started: int,
result: Dictionary = {}, cache_hit := false,
result: Dictionary = {}, cache_hit := false, resolved_path := "",
candidate_paths: PackedStringArray = PackedStringArray(),
) -> void:
if not trace.is_valid():
return
@@ -141,9 +182,44 @@ static func _trace_lookup(
"spawn_failed": bool(result.get("spawn_failed", false)),
"cancelled": bool(result.get("cancelled", false)),
"termination_failed": bool(result.get("termination_failed", false)),
"resolved_path": _bounded_trace_path(resolved_path),
"candidate_paths": _bounded_trace_paths(candidate_paths),
})
## Keep lookup diagnostics useful without retaining arbitrary command output or
## environment values. Only absolute executable paths cross this boundary.
static func _bounded_trace_paths(paths: PackedStringArray) -> Array[String]:
var bounded: Array[String] = []
for raw in paths:
var path := _bounded_trace_path(raw)
if path.is_empty() or bounded.has(path):
continue
bounded.append(path)
if bounded.size() >= _TRACE_PATH_LIMIT:
break
return bounded
static func _bounded_trace_path(raw: String) -> String:
var path := raw.strip_edges()
if not _is_traceable_path(path):
return ""
return path.left(_TRACE_PATH_MAX_CHARS)
static func _is_traceable_path(path: String) -> bool:
if path.is_empty() or path.contains("\n") or path.contains("\r"):
return false
if path.begins_with("/") or path.begins_with("\\\\"):
return true
return (
path.length() >= 3
and path.substr(1, 1) == ":"
and (path.substr(2, 1) == "\\" or path.substr(2, 1) == "/")
)
## Executable extensions Windows' CreateProcessW can launch from a path
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
+33
View File
@@ -30,6 +30,9 @@ static func configure(
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var write_error := configure_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
## Fail closed before any subprocess runs: a command-shape client without a
## verified attach launcher must not register anything (see
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
@@ -184,6 +187,9 @@ static func _status_details(
static func remove(client: McpClient, server_name: String) -> Dictionary:
var write_error := remove_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
@@ -440,6 +446,33 @@ static func uses_scope_token(client: McpClient) -> bool:
return client.cli_register_template.has(SCOPE_TOKEN)
## Why Configure may not run this client's CLI. The CLI runs inside this
## editor's Flatpak sandbox, so what it puts in the file the client keeps its
## servers in (the JSON-fallback file) lands where the client outside never
## reads it. That file takes the `user` scope's entry and the `local` scope's
## per-project block, and the pre-cleanup sweep (#872) removes from it at
## every scope, so the selected scope makes no difference here: at `project`
## the sweep would miss a stale entry in the host's file and Configure would
## still report success. A descriptor that names no such file has nothing to
## check.
static func configure_write_error(client: McpClient) -> String:
if not client.has_json_fallback():
return ""
return client.config_write_error()
## Why Remove may not. It removes from the selected scope only: `user` and
## `local` keep their entries in the JSON-fallback file, and `project` in the
## CLI's working directory, which this layer does not resolve and leaves to
## the CLI.
static func remove_write_error(client: McpClient) -> String:
if not client.has_json_fallback():
return ""
if uses_scope_token(client) and McpSettings.client_scope() == "project":
return ""
return client.config_write_error()
## Public view of the pre-cleanup sweep, for the manual-command text: what the
## dock tells the user to run has to match what Configure actually runs (#877).
static func cleanup_scopes(client: McpClient) -> Array[String]:
+8
View File
@@ -64,10 +64,16 @@ static func configure(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
## Fail closed before touching the file — same contract as JSON/TOML/YAML.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
@@ -191,6 +197,8 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
+29
View File
@@ -19,10 +19,16 @@ static func configure(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
@@ -59,6 +65,9 @@ static func _configure_merged(
launch: Dictionary,
project_roots: PackedStringArray,
) -> Dictionary:
var write_error := _merge_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
@@ -259,6 +268,8 @@ static func remove(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
@@ -284,6 +295,9 @@ static func remove(
static func _remove_merged(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary:
var write_error := _merge_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
## Scope ambiguity (omp named profiles) blocks Remove too: clearing the
## default files while a profile reads its own would report a success the
## running client never observes (#1085).
@@ -750,6 +764,21 @@ static func _merge_paths(client: McpClient) -> PackedStringArray:
return result
## The Flatpak write check `configure` and `remove` make on their one path,
## made here on every tier. Any tier this sandbox does not share with the host
## refuses the whole Configure or Remove: an unseen tier may hold the
## definition the client actually uses. Status reads past it, as it does for
## an ordinary client.
static func _merge_write_error(client: McpClient) -> String:
for path in _merge_paths(client):
if not path.is_absolute_path():
continue
var write_error := McpClient.unshared_flatpak_config_error(client.display_name, path)
if not write_error.is_empty():
return write_error
return ""
## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect
## plausible roots only to detect overrides; callers fail closed rather than
## mutating both guesses.
+207 -1
View File
@@ -23,6 +23,19 @@ extends RefCounted
static var _env_snapshot := {}
static var _env_snapshot_mutex := Mutex.new()
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
const _MOUNTINFO_PATH := "/proc/self/mountinfo"
## `/.flatpak-info` as Flatpak wrote it before this process started, or "" when
## this is not a Flatpak sandbox. The sandbox it describes cannot change while
## the process runs, so one read serves every thread. Guarded by
## `_env_snapshot_mutex`, like the snapshot it is consulted alongside.
static var _flatpak_info := ""
static var _flatpak_info_read := false
## What that sandbox mounted, from `/proc/self/mountinfo`: read with the file
## above, and only inside a sandbox. Empty when it could not be read.
static var _flatpak_mounts: Array[Dictionary] = []
## Every var this layer and its sibling consumers (`_base.gd`
## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
## `client_configurator.gd` mode/trace reads) can touch off-main.
@@ -32,6 +45,10 @@ const _BASE_ENV_VARS: Array[String] = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
## What `$XDG_CONFIG_HOME` reads in a Flatpak editor — see `_env_source`.
"HOST_XDG_CONFIG_HOME",
## Read by `flatpak_hides_credentials` on the workers that run Configure.
"GODOT_AI_CAPABILITY_DIR",
"APPDATA",
"LOCALAPPDATA",
"SHELL",
@@ -76,6 +93,120 @@ static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray(
for var_name in extra_vars:
if not String(var_name).is_empty():
env_lookup(String(var_name))
_flatpak_info_text()
## Cached `/.flatpak-info`, read on first use from whichever thread asks. Only
## Linux has Flatpak; elsewhere the leading slash would name a drive root.
static func _flatpak_info_text() -> String:
_env_snapshot_mutex.lock()
_read_flatpak_sandbox()
var info := _flatpak_info
_env_snapshot_mutex.unlock()
return info
## The sandbox's mounts, cached with the description above. The list is built
## once and never changed, so callers on any thread may read it.
static func _flatpak_mount_list() -> Array[Dictionary]:
_env_snapshot_mutex.lock()
_read_flatpak_sandbox()
var mounts := _flatpak_mounts
_env_snapshot_mutex.unlock()
return mounts
## Caller holds `_env_snapshot_mutex`.
static func _read_flatpak_sandbox() -> void:
if _flatpak_info_read:
return
_flatpak_info = McpTransportCapability._flatpak_info() if _os_key() == "linux" else ""
var mountinfo := ""
if not _flatpak_info.is_empty():
mountinfo = str(LinuxProc.read_text(_MOUNTINFO_PATH).text)
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
_flatpak_info_read = true
## Stand in for the sandbox description so tests can cover Flatpak resolution
## on any host, with `mountinfo` as its `/proc/self/mountinfo`: by default no
## mount evidence at all, never the real file's. Pass null to forget both; the
## next read then asks the real files.
static func _set_flatpak_info_for_test(info: Variant, mountinfo: String = "") -> void:
_env_snapshot_mutex.lock()
_flatpak_info = "" if info == null else str(info)
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
_flatpak_info_read = info != null
_env_snapshot_mutex.unlock()
## This editor's Flatpak application ID, or "" outside Flatpak.
static func flatpak_app_id() -> String:
return McpTransportCapability.flatpak_application_id(_flatpak_info_text())
## The host's config directory when this editor is a Flatpak that shares it
## (through the home directory, or a grant for the whole of `xdg-config`); ""
## anywhere else, and when it cannot be resolved. For callers whose own
## default is `OS.get_config_dir()`, which in that sandbox is the per-app
## directory that nothing outside it reads.
static func flatpak_host_config_home() -> String:
if not McpTransportCapability.flatpak_shares_config_home(_flatpak_info_text()):
return ""
var config_home := expand("$XDG_CONFIG_HOME")
return config_home if config_home.is_absolute_path() else ""
## Whether this editor's Flatpak sandbox keeps Godot AI's credentials where a
## client outside it does not look. The bridge a client starts reads the
## host's `<config home>/godot-ai/capabilities`, so an entry Configure writes
## from such a sandbox cannot connect, however right its file is.
##
## The server publishes in the host's config directory when the sandbox
## shares it (`McpTransportCapability.linux_config_home_variable`), and
## otherwise in the per-app one, which is the host's only where an
## `xdg-config/` grant mounted the host's directory inside it. Either way the
## host's directory has to be shared read-write, which `flatpak_write_block`
## decides. An editor given `GODOT_AI_CAPABILITY_DIR` publishes somewhere this
## cannot judge, and its client needs the same variable: never hidden here.
static func flatpak_hides_credentials() -> bool:
var info := _flatpak_info_text()
if info.is_empty():
return false
if not env_lookup(McpTransportCapability.CAPABILITY_DIR_ENV).strip_edges().is_empty():
return false
var config_home := expand("$XDG_CONFIG_HOME")
if not config_home.is_absolute_path():
return false
var host_directory := config_home.path_join("godot-ai/capabilities")
if not flatpak_write_block(host_directory.path_join("http.json")).is_empty():
return true
if McpTransportCapability.flatpak_shares_config_home(info):
return false
var grant := McpTransportCapability.flatpak_grant_over(
info, host_directory, _home(), config_home
)
return not str(grant.get("location", "")).begins_with("xdg-config/")
## The `~/.var/app/<id>` directory of another Flatpak app that `path` lies in,
## when this editor's own Flatpak sandbox does not show it; "" otherwise.
## Flatpak mounts only the running app's directory under `~/.var/app`, and a
## `host` or `home` grant does not widen that. From in here another app's
## settings are therefore unknown rather than absent until the user grants
## that directory by name, and anything created below it would land on the
## sandbox's private tmpfs.
static func hidden_flatpak_app_dir(path: String) -> String:
if _flatpak_info_text().is_empty():
return ""
var home := _home()
if home.is_empty():
return ""
var apps := home.path_join(".var/app") + "/"
if not path.begins_with(apps):
return ""
var app_dir := apps + path.trim_prefix(apps).get_slice("/", 0)
return "" if DirAccess.dir_exists_absolute(app_dir) else app_dir
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
@@ -183,7 +314,7 @@ static func expand(template: String) -> String:
var token := "$%s" % var_name
if out.find(token) < 0:
continue
var value := env_lookup(var_name)
var value := env_lookup(_env_source(var_name))
if value.is_empty():
value = _home_fallback(var_name)
if value.is_empty():
@@ -192,6 +323,81 @@ static func expand(template: String) -> String:
return out
## The environment variable a token's value is read from. Flatpak points
## `XDG_CONFIG_HOME` at the editor's own `~/.var/app/<id>/config`, where no
## client looks: a path expanded from it is written, read back, and reported
## as configured without the client ever seeing it. Inside a sandbox the token
## therefore reads the host's variable, so a template always names the file
## the client reads. Whether this sandbox can reach that file is a separate
## question, answered by `flatpak_write_block`. Every grant that shares part
## of the host's config directory mounts it at the host's path, an
## `xdg-config/<dir>` grant included, so no path has to go through the per-app
## directory, where a leftover from an earlier run would pass for the client's
## own file.
static func _env_source(var_name: String) -> String:
if var_name == "XDG_CONFIG_HOME" and not _flatpak_info_text().is_empty():
return "HOST_XDG_CONFIG_HOME"
return var_name
## Why a file this editor writes at `path` would not be the one a client
## outside its Flatpak sandbox reads there. {} when it would be, and always
## outside Flatpak. Otherwise one of:
## - {"needs": grant}: no read-write grant covers the file's directory, and
## this is the `--filesystem=` value that would. That is the read-only grant
## that does cover it when there is one, because a wider grant does not
## lift it.
## - {"unmounted": directory}: a read-write grant names this directory, but
## Flatpak mounted nothing there, which is what it does when the directory
## does not exist as the app starts.
## - {"hidden": directory}: inside a granted directory, this one is an empty
## tmpfs, which is how Flatpak carries out a denial (`--nofilesystem`).
## - {"read_only": directory}: granted read-write, mounted read-only.
##
## The directory is what has to be shared, not the file: `McpAtomicWrite`
## stages a temporary file beside the target and renames it into place, which
## cannot replace a file Flatpak mounted on its own. It also follows a
## symlinked config file to its target, so that is the file tested.
static func flatpak_write_block(path: String) -> Dictionary:
var info := _flatpak_info_text()
if info.is_empty():
return {}
var directory := McpAtomicWrite._resolve_symlink_target(path).get_base_dir()
var home := _home()
var inside_home := (
not home.is_empty() and McpTransportCapability.path_is_within(home, directory)
)
var grant := McpTransportCapability.flatpak_grant_over(
info, directory, home, expand("$XDG_CONFIG_HOME")
)
if not grant.get("writable", false):
if not grant.is_empty():
return {"needs": str(grant["location"])}
## Nothing under ~/.var/app comes with the home, so there the app's
## directory has to be granted by name.
var apps := home.path_join(".var/app") + "/"
if inside_home and directory.begins_with(apps):
return {"needs": apps + directory.trim_prefix(apps).get_slice("/", 0)}
return {"needs": "home" if inside_home else directory}
var root := str(grant["root"])
var real_root := McpTransportCapability.real_path(root)
var mount := McpTransportCapability.flatpak_blocking_mount(
_flatpak_mount_list(),
McpTransportCapability.real_path(directory),
McpTransportCapability.real_path(home) if inside_home else real_root,
)
if mount.is_empty():
return {}
var mount_point := str(mount["mount_point"])
if mount["read_only"]:
return {"read_only": mount_point}
## A tmpfs above the granted directory is not there because of the grant:
## the grant's own mount is what is missing.
if not McpTransportCapability.path_is_within(real_root, mount_point):
return {"unmounted": root}
return {"hidden": mount_point}
## Home-derived default for a var the environment does not define. Returns ""
## when home itself is unresolvable so `expand` leaves the token alone: rooting
## these at an empty home yields a RELATIVE path, not an absent one —
@@ -19,10 +19,16 @@ static func configure(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
@@ -198,6 +204,8 @@ static func remove(client: McpClient, _server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
@@ -24,10 +24,16 @@ static func configure(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
## Fail closed before touching the file — same contract as JSON/TOML.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
@@ -105,6 +111,8 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
+8
View File
@@ -14,6 +14,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
}
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
+8
View File
@@ -11,6 +11,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
}
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
+8
View File
@@ -11,6 +11,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
}
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"])
## Roo defaults an entry with no "type" to SSE transport — which returns
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
+9
View File
@@ -14,6 +14,15 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
}
## The Flathub build keeps its user data in the app's own directory and
## never reads ~/.config/Code. Verified against com.visualstudio.code
## 1.139.1: `code --add-mcp` writes the second path, under `servers`.
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/mcp.json",
],
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). VS Code stdio entries are flat command/args/env
+10
View File
@@ -21,6 +21,16 @@ func _init() -> void:
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
"windows": "$APPDATA/Zed/settings.json",
}
## The Flathub build restarts itself on the host with
## FLATPAK_XDG_CONFIG_HOME set to its own app directory, and reads
## `$FLATPAK_XDG_CONFIG_HOME/zed` instead of ~/.config/zed
## (crates/paths/src/paths.rs, crates/cli/src/main.rs).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/dev.zed.Zed/config/zed/settings.json",
],
}
server_key_path = PackedStringArray(["context_servers"])
## Attach migration (#838). Current Zed's context_servers entries are an
## untagged serde enum discriminated by shape: `command` (string) + args/env
+8
View File
@@ -11,6 +11,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
}
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"])
## Local validation against the installed extension shows Zoo stores MCP
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
+276 -34
View File
@@ -10,17 +10,30 @@ const MAX_ENTRIES := 10000
const MAX_FILE_BYTES := 256 * 1024
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
const MAX_TARGETS := 256
const BUDGET_USEC := 2000
## Container magic Godot writes for a compressed binary resource.
const COMPRESSED_MAGIC := "RSCC"
## Work allowed between frame yields: a share of how long the editor last took
## to come back, within these bounds. A fixed slice starves discovery once the
## editor is unfocused, because its low-processor mode then sleeps ~100 ms per
## frame (`interface/editor/timers/unfocused_low_processor_mode_sleep_usec`)
## and the frame count, not the work, reaches the deadline (#1120). The floor is
## what a focused editor gets; the ceiling bounds one slice on a stalled editor.
const BUDGET_MIN_USEC := 2000
const BUDGET_MAX_USEC := 50000
const BUDGET_GAP_DIVISOR := 3
const DEADLINE_MSEC := 25000
static var _busy := false
var _alive: Callable
var _deadline := 0
var _yield_at := 0
var _budget := BUDGET_MIN_USEC
var _bytes := 0
var _directories: Dictionary = {}
var _fingerprints: Dictionary = {}
var _large_owners: Dictionary = {}
var _fault := ""
var _fault_code := ""
var _io: Callable
## Dispatch stays synchronous; the retained job owns work across frame yields.
@@ -59,6 +72,13 @@ func run(params: Dictionary, operation: String, active: Callable = Callable()) -
_alive = active
_deadline = Time.get_ticks_msec() + DEADLINE_MSEC
_yield_at = Time.get_ticks_usec()
_budget = BUDGET_MIN_USEC
_bytes = 0
_directories.clear()
_fingerprints.clear()
_large_owners.clear()
_fault = ""
_fault_code = ""
var result := await _run(params, operation)
_busy = false
return result
@@ -100,7 +120,7 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
return _unchanged(_fault)
var files: Array[String] = []
if not await _collect_tree("res://", files):
return _unchanged(_fault)
return _fault_result()
if directory and not _directories.has(source):
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
var targets := {}
@@ -112,27 +132,46 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
if targets.size() > MAX_TARGETS:
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
for path in files:
if path == source or (directory and path.begins_with(source + "/")) or path in [source + ".uid", source + ".import"]:
if not _mutation_member(path, source, directory):
continue
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
# Owner-extension targets are read and fingerprinted by the owner
# pass below; reading them here too would charge the byte budget twice.
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
continue
if await _read_bounded(path) == null:
return _unchanged(_fault)
continue
if await _read_bounded(path) == null:
return _fault_result()
var owners := []
for path in files:
if path.get_extension().to_lower() not in OWNER_EXTENSIONS:
var extension := path.get_extension().to_lower()
if extension not in OWNER_EXTENSIONS:
continue
var content: Variant = await _read_bounded(path)
if content == null:
return _unchanged(_fault)
# One call per owner: _references lowercases and regex-scans the whole
# file, so a per-target loop repeated that up to MAX_TARGETS times.
var hits := await _references(path, content, targets)
var hits: Array[Dictionary]
var length := _file_length(path)
if length < 0:
return _fault_result()
if length > MAX_FILE_BYTES:
# Dependency enumeration can replace a literal scan for packed binary
# owners, but not for scripts or text resources: those can refer to a
# target in ordinary strings the graph does not report. Fail closed.
if extension not in ["res", "scn"]:
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
return _fault_result()
hits = await _large_owner_references(path, targets)
else:
var content: Variant = await _read_bounded(path)
if content == null:
return _fault_result()
if extension in ["res", "scn"]:
hits = await _binary_references(path, content, targets)
else:
# One call per owner: _references lowercases and regex-scans the
# whole file, so a per-target loop repeated that up to MAX_TARGETS
# times.
hits = await _references(path, content, targets)
if not _fault.is_empty():
return _unchanged(_fault)
return _fault_result()
if not await _yield_if_needed():
return _unchanged(_fault)
return _fault_result()
if not hits.is_empty() and not (operation == "remove" and targets.has(path)):
owners.append({"path": path, "references": hits})
var editor_guard := _editor_state_guard(targets)
@@ -152,17 +191,18 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
group.append({"from": source + suffix, "to": destination + suffix})
for item in group:
if not _confined_without_links(item.from):
return _unchanged(_fault)
return _fault_result()
if operation != "remove" and not _destination_available(item.from, item.to):
return _unchanged(_fault)
return _fault_result()
# Re-read owner bytes and directory entry censuses after the yielding plan.
if not await _revalidate():
return _unchanged(_fault)
return _fault_result()
if not _active():
return _unchanged("Filesystem request expired before mutation; nothing changed")
_set_fault("Filesystem request expired before mutation; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return _fault_result()
for item in group:
if not _confined_without_links(item.from) or (operation != "remove" and not _destination_available(item.from, item.to)):
return _unchanged(_fault)
return _fault_result()
# Revalidation yields. Recheck mutable editor authority after the last
# callback/yield, with no suspension between this guard and disk effects.
editor_guard = _editor_state_guard(targets)
@@ -179,17 +219,26 @@ func _active() -> bool:
func _yield_if_needed() -> bool:
if not _active():
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
if Time.get_ticks_usec() - _yield_at >= BUDGET_USEC:
var paused := Time.get_ticks_usec()
if paused - _yield_at >= _budget:
await (Engine.get_main_loop() as SceneTree).process_frame
_yield_at = Time.get_ticks_usec()
_budget = _frame_budget(_yield_at - paused)
if not _active():
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
return true
## The next slice for an editor that was away `gap_usec` between two slices.
## The frame sleep absorbs work shorter than itself, so a third of the gap is a
## quarter of a sleeping editor's frame and lengthens a busy one by a third.
static func _frame_budget(gap_usec: int) -> int:
return clampi(gap_usec / BUDGET_GAP_DIVISOR, BUDGET_MIN_USEC, BUDGET_MAX_USEC)
func _collect_tree(root: String, files: Array[String]) -> bool:
var pending: Array[String] = [root]
var count := 0
@@ -197,11 +246,11 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
var path: String = pending.pop_back()
var dir := DirAccess.open(path)
if dir == null:
_fault = "Cannot inspect directory: %s" % path
_set_fault("Cannot inspect directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
dir.include_hidden = true
if dir.list_dir_begin() != OK:
_fault = "Cannot list directory: %s" % path
_set_fault("Cannot list directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
var entries: Array[String] = []
var name := dir.get_next()
@@ -222,7 +271,7 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
files.append(child)
count += 1
if count > MAX_ENTRIES:
_fault = "Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES
_set_fault("Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES, Errors.FILESYSTEM_DISCOVERY_FAILED)
dir.list_dir_end()
return false
if not await _yield_if_needed():
@@ -237,20 +286,23 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
func _read_bounded(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > MAX_FILE_BYTES:
_fault = "Cannot safely read owner %s (unreadable or exceeds %d bytes)" % [path, MAX_FILE_BYTES]
if file == null:
_set_fault("Cannot safely read owner %s (unreadable)" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if file.get_length() > MAX_FILE_BYTES:
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
var bytes := PackedByteArray()
var length := file.get_length()
while file.get_position() < length:
var part := file.get_buffer(mini(16384, length - file.get_position()))
if part.is_empty():
_fault = "Short read while inspecting %s" % path
_set_fault("Short read while inspecting %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
bytes.append_array(part)
_bytes += part.size()
if _bytes > MAX_TOTAL_BYTES:
_fault = "Filesystem discovery exceeds its byte budget; nothing changed"
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if not await _yield_if_needed():
return null
@@ -262,11 +314,173 @@ func _read_bounded(path: String) -> Variant:
return bytes
func _mutation_member(path: String, source: String, directory: bool) -> bool:
if directory:
return path == source or path.begins_with(source + "/")
return path == source or path in [source + ".uid", source + ".import"]
func _file_length(path: String) -> int:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
_set_fault("Cannot inspect owner metadata: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return -1
var length := file.get_length()
file.close()
return length
func _owner_dependencies(path: String) -> PackedStringArray:
var dependencies := PackedStringArray()
for dependency in ResourceLoader.get_dependencies(path):
dependencies.append(str(dependency))
return dependencies
## Lowercased `uid://` and path segments of the engine's dependency records.
func _dependency_segments(dependencies: PackedStringArray) -> Dictionary:
var segments: Dictionary = {}
for dependency in dependencies:
for segment in dependency.split("::", false):
segments[segment.to_lower()] = true
return segments
func _large_owner_references(path: String, targets: Dictionary) -> Array[Dictionary]:
var dependencies := _owner_dependencies(path)
var dependency_paths := _dependency_segments(dependencies)
_large_owners[path] = {
"modified": FileAccess.get_modified_time(path),
"size": _file_length(path),
"dependencies": dependencies,
}
if int(_large_owners[path]["size"]) < 0:
return []
var hits: Array[Dictionary] = []
for target: String in targets:
if not await _yield_if_needed():
return hits
var uid: int = targets[target].uid
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
if dependency_paths.has(target.to_lower()) or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower())):
# A dependency path can be a UID-backed ext_resource. Without reading
# the oversized owner we cannot prove the file reference is safe to
# rewrite, so fail closed for moves; force-remove may still use it.
hits.append({"path": target, "kind": "path"})
return hits
func _binary_references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
var hits: Array[Dictionary] = []
var payload: Variant = _binary_scan_bytes(path, bytes)
if payload == null:
return hits
# The binary format stores an ext_resource UID as an integer, which no text
# scan can see, so the engine's dependency records are consulted as well.
var dependency_paths := _dependency_segments(_owner_dependencies(path))
for target: String in targets:
# Keep cancellation and frame progress even when a path match continues.
if not await _yield_if_needed():
return hits
var uid: int = targets[target].uid
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
var uses_path: bool = (
_bytes_contains_ci(payload, target.to_utf8_buffer())
or dependency_paths.has(target.to_lower())
or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower()))
)
var uses_uid: bool = not uid_text.is_empty() and _bytes_contains_ci(payload, uid_text.to_utf8_buffer())
if uses_path:
hits.append({"path": target, "kind": "path"})
elif uses_uid:
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
_set_fault("UID mapping is not authoritative for %s" % target)
return hits
hits.append({"path": target, "kind": "uid"})
return hits
## The bytes a literal scan can inspect. The editor saves binary resources
## compressed by default (`filesystem/on_save/compress_binary_resources`), and
## a compressed container shows none of its serialized paths, so expand it
## first. Layout: magic, mode, block size and expanded size (u32 each), one
## u32 compressed size per block, then the blocks.
##
## Returns an empty array when the expanded payload exceeds the per-file limit
## (the caller then relies on dependency records alone, like any oversized
## binary owner) and null, with a fault, for a container that cannot be read.
func _binary_scan_bytes(path: String, bytes: PackedByteArray) -> Variant:
if bytes.size() < 4 or bytes.slice(0, 4).get_string_from_ascii() != COMPRESSED_MAGIC:
return bytes
var unreadable := "Cannot inspect compressed binary owner: %s" % path
if bytes.size() < 16:
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
var mode := bytes.decode_u32(4)
var block_size := bytes.decode_u32(8)
var total := bytes.decode_u32(12)
if mode > FileAccess.COMPRESSION_BROTLI or block_size == 0:
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if total > MAX_FILE_BYTES:
return PackedByteArray()
var block_count := total / block_size + 1
var offset := 16 + block_count * 4
if offset > bytes.size():
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
var payload := PackedByteArray()
for index in block_count:
var compressed_size := bytes.decode_u32(16 + index * 4)
var expected := total % block_size if index == block_count - 1 else block_size
if offset + compressed_size > bytes.size():
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if expected > 0:
var block := bytes.slice(offset, offset + compressed_size).decompress(expected, mode)
if block.size() != expected:
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
payload.append_array(block)
offset += compressed_size
_bytes += payload.size()
if _bytes > MAX_TOTAL_BYTES:
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
return payload
func _ascii_lower(value: int) -> int:
if value >= 65 and value <= 90:
return value + 32
return value
func _bytes_contains_ci(haystack: PackedByteArray, needle: PackedByteArray) -> bool:
if needle.is_empty() or haystack.size() < needle.size():
return false
var lower := _ascii_lower(needle[0])
var upper := lower - 32 if lower >= 97 and lower <= 122 else lower
var candidates := [lower]
if candidates[0] != upper:
candidates.append(upper)
for candidate in candidates:
var position := haystack.find(candidate)
while position >= 0:
if position + needle.size() <= haystack.size():
var matches := true
for index in needle.size():
if _ascii_lower(haystack[position + index]) != _ascii_lower(needle[index]):
matches = false
break
if matches:
return true
position = haystack.find(candidate, position + 1)
return false
func _references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
var hits: Array[Dictionary] = []
if path.get_extension().to_lower() in ["res", "scn"]:
_fault = "Binary dependency discovery is unsupported for safe mutations: %s" % path
return hits
# Compare paths conservatively across case-insensitive project volumes.
# A case-only false positive on a sensitive volume is a safe refusal.
var text := bytes.get_string_from_utf8().to_lower()
@@ -358,9 +572,26 @@ func _revalidate() -> bool:
if _fingerprints[path] != original[path]:
_fault = "Owner changed during discovery: %s" % path
return false
var original_large := _large_owners.duplicate(true)
for path in original_large:
if not await _large_owner_unchanged(path, original_large[path]):
_fault = "Owner changed during discovery: %s" % path
return false
return true
func _large_owner_unchanged(path: String, original: Dictionary) -> bool:
if not FileAccess.file_exists(path):
return false
if FileAccess.get_modified_time(path) != int(original.get("modified", 0)):
return false
if _file_length(path) != int(original.get("size", -1)):
return false
if str(_owner_dependencies(path)) != str(original.get("dependencies", PackedStringArray())):
return false
return await _yield_if_needed()
func _confined_without_links(path: String) -> bool:
var dir := DirAccess.open("res://")
if dir == null or not dir.has_method("is_link"):
@@ -493,6 +724,17 @@ static func _is_sidecar(path: String) -> bool:
return path.ends_with(".uid") or path.ends_with(".import")
func _set_fault(message: String, code: String = "") -> void:
_fault = message
_fault_code = code
func _fault_result() -> Dictionary:
if _fault_code == Errors.FILESYSTEM_DISCOVERY_FAILED:
return _failure(_fault, "unchanged", {}, _fault_code)
return _unchanged(_fault)
static func _unchanged(message: String, details: Dictionary = {}) -> Dictionary:
return _failure(message, "unchanged", details, Errors.INVALID_PARAMS)
+99 -4
View File
@@ -160,6 +160,16 @@ func reparent_node(params: Dictionary) -> Dictionary:
_undo_redo.add_do_method(new_parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
for child in descendants:
## Preserve intentional null owners and owners that live inside the
## moved subtree. `remove_child` clears owners outside the subtree, so
## only those need to be re-normalized to scene_root. The subtree is
## still intact here because the recorded do-methods have not run yet.
##
## Re-owning an instance's internal nodes with scene_root flattened the
## sub-scene on save and dropped its overrides (#1118).
var prior_owner: Node = child.owner
if prior_owner == null or prior_owner == node or node.is_ancestor_of(prior_owner):
continue
_undo_redo.add_do_method(child, "set_owner", scene_root)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(new_parent, "remove_child", node)
@@ -203,10 +213,14 @@ func set_property(params: Dictionary) -> Dictionary:
var found := false
var prop_type: int = TYPE_NIL
var prop_hint: int = PROPERTY_HINT_NONE
var prop_hint_string: String = ""
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
prop_hint = prop.get("hint", PROPERTY_HINT_NONE)
prop_hint_string = prop.get("hint_string", "")
break
if not found:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
@@ -230,7 +244,45 @@ func set_property(params: Dictionary) -> Dictionary:
var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://")))
var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string)
if resource_string_value:
## Node-typed exports (`@export var target: Control`) are TYPE_OBJECT with
## PROPERTY_HINT_NODE_TYPE, so a string here is a node path, not a
## resource path (#1144): a leading "/" is a clean scene path like `path`;
## anything else is relative to the node, which is how the Inspector
## stores the reference. "" still clears through the branch below.
var node_typed_path: bool = (
target_type == TYPE_OBJECT
and prop_hint == PROPERTY_HINT_NODE_TYPE
and value is String
and not (value as String).is_empty()
)
if node_typed_path:
var target: Node = _resolve_node_value(value, node, scene_root)
if target == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
"value: no node at %s. Paths starting with \"/\" are scene paths (e.g. \"/%s/Player\"); others are relative to %s (e.g. \"../Player\")." % [
value, scene_root.name, node_path,
],
)
## An absolute editor-tree path ("/root", "/root/@EditorNode@/...") or
## a relative path climbing past the root resolves to a node outside
## the edited scene; it would commit but can never be saved with it.
if target != scene_root and not scene_root.is_ancestor_of(target):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"value: %s resolves to a node outside the edited scene; the target must be \"/%s\" or one of its descendants" % [
value, scene_root.name,
],
)
if not _node_matches_hint(target, prop_hint_string):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"value: %s is a %s, which is not a %s" % [
value, _object_type_label(target.get_class(), target.get_script()), prop_hint_string,
],
)
value = target
elif resource_string_value:
if value == "":
value = null
else:
@@ -305,13 +357,56 @@ func set_property(params: Dictionary) -> Dictionary:
"data": {
"path": node_path,
"property": property,
"value": _serialize_value(node.get(property)),
"old_value": _serialize_value(old_value),
"value": _serialize_slot(node.get(property), scene_root),
"old_value": _serialize_slot(old_value, scene_root),
"undoable": true,
}
}
## Resolve the string value of a Node-typed property (#1144): a leading "/"
## is a clean scene path with the same forms `path` accepts; anything else
## is a NodePath relative to `node`.
static func _resolve_node_value(value: String, node: Node, scene_root: Node) -> Node:
if value.begins_with("/"):
return McpScenePath.resolve(value, scene_root)
return node.get_node_or_null(NodePath(value))
## Does `target` satisfy a PROPERTY_HINT_NODE_TYPE hint string? The hint is
## the exported type's name — a ClassDB class ("Control") or a script
## class_name ("Player") — and may list several, comma-separated. An empty
## hint (a plain `Node` export on some engine versions) accepts any node.
static func _node_matches_hint(target: Node, hint_string: String) -> bool:
var any_named := false
for hint in hint_string.split(","):
var wanted := hint.strip_edges()
if wanted.is_empty():
continue
any_named = true
if ClassDB.class_exists(wanted):
if target.is_class(wanted):
return true
continue
var script: Variant = target.get_script()
while script is Script:
if String((script as Script).get_global_name()) == wanted:
return true
script = (script as Script).get_base_script()
return not any_named
## set_property's response value: a Node lands as its clean scene path so the
## agent sees "/Main/Player", not Godot's object repr (#1144); everything
## else serializes as before.
static func _serialize_slot(value: Variant, scene_root: Node) -> Variant:
if value is Node:
var clean := McpScenePath.from_node(value, scene_root)
if not clean.is_empty():
return clean
return _serialize_value(value)
func rename_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
@@ -1322,7 +1417,7 @@ func get_node_properties(params: Dictionary) -> Dictionary:
properties.append({
"name": prop.name,
"type": type_string(prop.type),
"value": _serialize_value(node.get(prop.name)),
"value": _serialize_slot(node.get(prop.name), scene_root),
})
# Requested names that matched no editor-visible property — distinguishes
# "you asked for something that doesn't exist" from "exists and is null".
+12 -16
View File
@@ -167,11 +167,6 @@ var _uv_recheck_pending := false
## (foreign-port branch never sets `_server_pid`, so `_stop_server`
## can't kill it); the line has to show the mismatch honestly.
var _setup_server_label: Label
## Last rendered server-version string. `_update_status_label` runs every
## frame; early-outs text repaint when nothing changed. Empty means
## "no line rendered yet" (dev-checkout branch doesn't render a
## user-mode Server line).
var _last_rendered_server_text: String = ""
## Restart-server button shown next to the Setup container when
## `McpConnection.server_version` drifts from the plugin version. Hidden
## in the match case so the UI stays calm.
@@ -1358,13 +1353,19 @@ func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
## look like it had multiple restart paths.
or (is_incompatible and can_recover and _crash_restart_btn == null)
)
if text == _last_rendered_server_text:
_setup_server_label.add_theme_color_override("font_color", color)
_update_restart_button(show_restart)
return
_last_rendered_server_text = text
## Runs every frame, so write only what changed (#1121). `Label.text`
## already ignores same-value writes; `add_theme_color_override` does
## not — every call re-sends NOTIFICATION_THEME_CHANGED and queues a
## redraw, which kept the idle editor redrawing while this row was
## visible. Compare against the label itself so a rebuilt row still
## gets its color and a same-text state change (amber drift -> red
## incompatible) still repaints.
_setup_server_label.text = text
_setup_server_label.add_theme_color_override("font_color", color)
if (
not _setup_server_label.has_theme_color_override("font_color")
or _setup_server_label.get_theme_color("font_color") != color
):
_setup_server_label.add_theme_color_override("font_color", color)
_update_restart_button(show_restart)
@@ -1382,12 +1383,10 @@ func _on_restart_stale_server() -> void:
if _server_restart_in_progress:
return
_server_restart_in_progress = true
_last_rendered_server_text = ""
_refresh_server_version_label()
if not is_inside_tree():
_dispatch_stale_server_restart()
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
return
call_deferred("_restart_stale_server_after_feedback")
@@ -1397,7 +1396,6 @@ func _restart_stale_server_after_feedback() -> void:
await get_tree().create_timer(0.15).timeout
if not _dispatch_stale_server_restart():
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
@@ -1418,7 +1416,6 @@ func present_lifecycle_action_result(accepted: bool) -> void:
if accepted:
return
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
@@ -1516,7 +1513,6 @@ func _refresh_setup_status() -> void:
_version_restart_btn.visible = false
server_row.add_child(_version_restart_btn)
_setup_container.add_child(server_row)
_last_rendered_server_text = ""
_refresh_server_version_label()
else:
_setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
+1 -1
View File
@@ -3,5 +3,5 @@
name="Godot AI"
description="MCP server and AI tools for Godot"
author="Godot AI"
version="4.2.3"
version="4.3.0"
script="plugin.gd"
+23 -8
View File
@@ -156,8 +156,13 @@ func _process(_delta: float) -> void:
## through the debugger packet path in a single tick. Surplus stays in
## `_pending_outbound` and bleeds out across subsequent frames.
if not _logger_attached or _logger == null:
_pending_outbound.clear()
return
if not EngineDebugger.is_active():
## No remote can consume these lines. Drop current and already-drained
## batches so a detached/headless run cannot retain logs indefinitely (#1123).
_logger.clear()
_pending_outbound.clear()
return
if _pending_outbound.is_empty():
if not _logger.has_pending():
@@ -1024,13 +1029,7 @@ func _handle_eval(data: Array) -> void:
_eval_token_counter += 1
var token := str(_eval_token_counter)
var run_fn := "_mcp_run_%s" % token
var script_source := (
"extends Node\n"
+ "func execute():\n"
+ "\treturn await %s()\n\n" % run_fn
+ "func %s():\n" % run_fn
+ _indent_eval_code(code)
)
var script_source := _build_eval_script_source(run_fn, code)
## Snapshot the logger's script-error seq BEFORE running so we only attribute
## errors raised by this eval. In a debug build a parse error aborts reload()
@@ -1227,7 +1226,23 @@ func _handle_eval_check(data: Array) -> void:
_try_report_eval_runtime_error(request_id)
func _indent_eval_code(code: String) -> String:
## `execute()` always returns, so it carries a return type. The inner function
## holds caller code that need not return on every path; a declared return type
## there makes Godot reject it with "Not all code paths return a value". It
## stays untyped and suppresses `untyped_declaration` on its own declaration
## instead (#1119). The annotation shares the `func` line so the line numbers
## reported for caller code do not move.
static func _build_eval_script_source(run_fn: String, code: String) -> String:
return (
"extends Node\n"
+ "func execute() -> Variant:\n"
+ "\treturn await %s()\n\n" % run_fn
+ "@warning_ignore(\"untyped_declaration\") func %s():\n" % run_fn
+ _indent_eval_code(code)
)
static func _indent_eval_code(code: String) -> String:
var lines: PackedStringArray = code.split("\n")
var out := ""
for line in lines:
+26
View File
@@ -37,6 +37,11 @@ var _mutex := Mutex.new()
## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread.
const _ERROR_TYPE_SCRIPT := 2
const _MAX_RECENT_SCRIPT_ERRORS := 64
const _MAX_PENDING := 4096
const _PENDING_TRIM_THRESHOLD := _MAX_PENDING * 2
## Lines trimmed from `_pending` since the last drain. The next drain reports
## the count as a leading warn entry, so a gap in the log is never silent.
var _dropped: int = 0
var _script_error_seq: int = 0
var _recent_script_errors: Array = []
@@ -93,6 +98,19 @@ func _append(level: String, text: String, details: Dictionary = {}) -> void:
_pending.append([level, text])
else:
_pending.append([level, text, details.duplicate(true)])
## Drop an old batch when a script floods the logger before the next frame.
## Trimming at 2x the cap amortizes the slice instead of doing it per line.
if _pending.size() >= _PENDING_TRIM_THRESHOLD:
_dropped += _pending.size() - _MAX_PENDING
_pending = _pending.slice(_pending.size() - _MAX_PENDING)
_mutex.unlock()
## Drop queued lines that no active debugger can consume.
func clear() -> void:
_mutex.lock()
_pending.clear()
_dropped = 0
_mutex.unlock()
@@ -102,7 +120,15 @@ func drain() -> Array:
_mutex.lock()
var out := _pending
_pending = []
var dropped := _dropped
_dropped = 0
_mutex.unlock()
if dropped > 0:
out.push_front([
"warn",
"[godot_ai] dropped %d older game log lines: the game logged faster than they could be forwarded"
% dropped,
])
return out
@@ -102,6 +102,14 @@ static func _root_path() -> String:
## Unlike user://, the OS config directory is shared by every Godot
## project owned by this account. User-scope client config is shared too,
## so a project-specific lock would leave the real cross-editor race open.
##
## A Flatpak editor that shares the home directory writes the host's client
## configs, the same files an editor outside the sandbox writes, while its
## own OS config directory is the per-app ~/.var/app/<id>/config. It takes
## the lock where those other editors look for it.
var host_config_home := McpPathTemplate.flatpak_host_config_home()
if not host_config_home.is_empty():
return host_config_home.path_join(ROOT_DIR_NAME)
return OS.get_config_dir().path_join(ROOT_DIR_NAME)
+5
View File
@@ -26,6 +26,11 @@ const EDITOR_NOT_READY := "EDITOR_NOT_READY"
const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
const INTERNAL_ERROR := "INTERNAL_ERROR"
const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
## #1120: filesystem mutation discovery failed before any disk effect
## (deadline/cancellation, entry/byte limits, or unreadable owner metadata).
## Keep this separate from INVALID_PARAMS so callers can distinguish an
## environmental discovery failure from a bad filesystem_manage request.
const FILESYSTEM_DISCOVERY_FAILED := "FILESYSTEM_DISCOVERY_FAILED"
## Python-originated transport/attach bridge codes. GDScript has no emit path,
## but the public registry intentionally mirrors protocol/errors.py.
const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
+39
View File
@@ -21,6 +21,12 @@ const SNAPSHOT_DIAGNOSTIC_CATEGORIES := [
"snapshot_size", "snapshot_json", "snapshot_shape", "collector_null", "row_shape",
"row_pid", "row_chain", "row_identity", "lineage_cycle",
]
## Public lifecycle evidence uses a smaller, stable vocabulary than the
## internal collector diagnostics. Keep the detailed category internally so a
## future fix can distinguish malformed JSON from an inaccessible ancestor.
const SNAPSHOT_FAILURE_CATEGORIES := [
"process_query", "json_shape", "ancestor_capture", "identity_mismatch",
]
static var _process_spawn_mutex := Mutex.new()
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
@@ -531,6 +537,39 @@ static func _snapshot_failure(
return {"capture_error": true}
## Map one internal collector refusal to the bounded category retained in the
## editor log. `row_identity` at depth zero is malformed target metadata;
## the same refusal on an ancestor is an ancestor-capture failure. A refusal
## this table does not know stays "unknown" instead of borrowing a category.
static func snapshot_failure_category(detail: String, depth := -1) -> String:
match detail:
"shell_exit", "empty_output", "invalid_pid", "collector_null":
return "process_query"
"lineage_cycle":
return "ancestor_capture"
"row_identity":
return "ancestor_capture" if depth > 0 else "json_shape"
"outer_size", "outer_json", "outer_shape", "snapshot_size", "snapshot_json", \
"snapshot_shape", "row_shape", "row_pid", "row_chain":
return "json_shape"
return "unknown"
## Return only the creation-time portion of a captured identity. Windows rows
## append the command line after `|`; Linux rows use `linux:<start>|<command>`.
## The command line is intentionally never copied into diagnostics.
static func process_creation_identity(pid: int, snapshot: Variant = null) -> String:
if pid <= 1 or not (snapshot is Dictionary) or capture_failed(snapshot):
return ""
var identity := str(_process_snapshot_row(snapshot, pid).get("identity", "")).strip_edges()
if identity.is_empty():
return ""
var separator := identity.find("|")
if separator > 0:
identity = identity.substr(0, separator).strip_edges()
return identity.substr(0, 128)
## An unavailable capture is not evidence that its PID has exited.
static func capture_failed(snapshot: Variant) -> bool:
+266 -42
View File
@@ -51,6 +51,7 @@ const STALE_PRE_V4_HINT := "stale_pre_v4_server"
const DEFAULT_PROVE_TIMEOUT_MS := 180_000
const LAUNCH_FINGERPRINT_TIMEOUT_MS := 15_000
const REPLACEMENT_TTL_MS := 15_000
const IDENTITY_CAPTURE_STAGES := ["launch", "first_server", "final_server"]
## An authenticated endpoint that drops (a late keepalive, a server restart)
## is re-probed automatically with this backoff, each attempt re-proving the
## server exactly as a start does; after the last one the block stays until
@@ -126,6 +127,8 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
"blocked_target": {},
"launch": {},
"after_stop": "",
"snapshot_diagnostic": {},
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
}
_publish()
if not probe:
@@ -461,6 +464,7 @@ func _complete_launch(result: Dictionary) -> void:
func _complete_prove(result: Dictionary) -> void:
_remember_snapshot_evidence(result)
if bool(result.get("pending", false)):
_episode["proof_pending_reason"] = str(result.get("reason", "unknown"))
if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)):
@@ -468,6 +472,7 @@ func _complete_prove(result: Dictionary) -> void:
"proof_timeout",
"The managed server proof timed out at %s."
% str(_episode.get("proof_pending_reason", "unknown"))
+ _snapshot_diagnostic_sentence()
+ startup_report_summary(str(_plan.get("startup_report", ""))),
)
return
@@ -478,6 +483,7 @@ func _complete_prove(result: Dictionary) -> void:
var pending_reason := str(_episode.get("proof_pending_reason", ""))
if not pending_reason.is_empty():
message += " Last pending proof: %s." % pending_reason
message += _snapshot_diagnostic_sentence()
_block(str(result.get("reason", "proof_failed")), message)
return
var launch: Dictionary = _episode.get("launch", {})
@@ -485,7 +491,11 @@ func _complete_prove(result: Dictionary) -> void:
var fingerprint := str(result.get("fingerprint", ""))
var exact_grant = _owned_process_grant(pid, fingerprint)
if not exact_grant.is_valid():
_block("process_proof_failed", "The server process identity changed before proof completed.")
_block(
"process_proof_failed",
"The server process identity changed before proof completed."
+ _snapshot_diagnostic_sentence(),
)
return
_process_grant = exact_grant
launch["pid"] = pid
@@ -861,8 +871,9 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
"ok": not fingerprint.is_empty(),
"reason": "launch_unproven" if fingerprint.is_empty() else "",
"message": (
_launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started)
+ _snapshot_diagnostic_summary(snapshot_diagnostics)
_launch_unproven_message(
pid, attempts, Time.get_ticks_msec() - capture_started, snapshot_diagnostics
)
if fingerprint.is_empty()
else ""
),
@@ -896,12 +907,13 @@ static func _snapshot_diagnostic_summary(diagnostics: Array) -> String:
## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
## values are read once more after the capture budget and never grant
## authority.
func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> String:
var snapshot: Variant = _capture_process_snapshot(pid)
func _launch_unproven_message(
pid: int, attempts: Array, elapsed_ms: int, snapshot_diagnostics: Array = []
) -> String:
var capture_diagnostics: Array = []
var snapshot: Variant = _capture_process_snapshot(pid, capture_diagnostics)
var alive := PortResolver.pid_alive(pid, snapshot)
var commandline := PortResolver.process_commandline(pid, snapshot) if alive else ""
if commandline.length() > 200:
commandline = commandline.substr(0, 199) + "…"
var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
## Summarise the per-attempt refusals as "reason×count" in first-seen order.
var counts := {}
var order: Array[String] = []
@@ -914,43 +926,218 @@ func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> Str
var summary: Array[String] = []
for key in order:
summary.append("%s×%d" % [key, int(counts[key])])
return (
var message := (
"The launched process identity could not be captured in %d attempts over %.1f s "
+ "(pid %d, now alive=%s, refusals: %s%s)."
+ "(pid %d, creation_identity=%s, now alive=%s, refusals: %s)."
) % [
attempts.size(),
elapsed_ms / 1000.0,
pid,
"unavailable" if creation_identity.is_empty() else creation_identity,
"unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"),
", ".join(summary) if not summary.is_empty() else "none recorded",
"" if commandline.is_empty() else "; command: " + commandline,
]
return message + _snapshot_diagnostic_summary(snapshot_diagnostics + capture_diagnostics)
func _capture_process_snapshot(pid: int, diagnostics: Variant = null) -> Variant:
return PortResolver.capture_process_snapshot(pid, diagnostics)
func _remember_snapshot_evidence(result: Dictionary) -> void:
var diagnostic: Variant = result.get("snapshot_diagnostic")
if diagnostic is Dictionary:
_episode["snapshot_diagnostic"] = diagnostic.duplicate(true)
var attempts: Variant = result.get("proof_stage_attempts")
if attempts is Dictionary:
_episode["proof_stage_attempts"] = attempts.duplicate(true)
func _snapshot_diagnostic_sentence() -> String:
var diagnostic: Variant = _episode.get("snapshot_diagnostic", {})
if not (diagnostic is Dictionary) or diagnostic.is_empty():
return ""
var stage := str(diagnostic.get("stage", ""))
if stage not in IDENTITY_CAPTURE_STAGES:
stage = "unknown"
var category := str(diagnostic.get("category", ""))
if category not in PortResolver.SNAPSHOT_FAILURE_CATEGORIES:
category = "unknown"
var identity := str(diagnostic.get("creation_identity", "")).strip_edges()
if identity.is_empty():
identity = "unavailable"
identity = identity.get_slice("|", 0).replace("\n", " ").replace("\r", " ").substr(0, 128)
## The collector's own refusal, when one was recorded: the fixed category
## alone cannot tell a shell that exited from one that printed nothing.
var detail := str(diagnostic.get("detail", ""))
if not detail.is_empty():
if detail not in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES:
detail = "unknown"
category += " detail=%s" % detail
var depth := int(diagnostic.get("depth", -1))
if depth >= 0:
category += " depth=%d" % clampi(depth, 0, 16)
return (
" Identity capture: stage=%s category=%s pid=%d creation_identity=%s attempt=%d elapsed_ms=%d."
) % [
stage,
category,
int(diagnostic.get("pid", -1)),
identity,
clampi(int(diagnostic.get("attempt", 1)), 1, 100000),
clampi(int(diagnostic.get("elapsed_ms", 0)), 0, 600000),
]
func _capture_process_snapshot(pid: int) -> Variant:
return PortResolver.capture_process_snapshot(pid)
func _capture_identity(
stage: String, pid: int, stage_attempts: Dictionary
) -> Dictionary:
var attempt := int(stage_attempts.get(stage, 0)) + 1
stage_attempts[stage] = attempt
var diagnostics: Array = []
var began := Time.get_ticks_msec()
var snapshot: Variant = _capture_process_snapshot(pid, diagnostics)
return {
"snapshot": snapshot,
"diagnostic": _snapshot_evidence(
stage, pid, snapshot, diagnostics, attempt, Time.get_ticks_msec() - began
),
}
func _record_snapshot_capture(
stage: String, pid: int, snapshot: Variant, stage_attempts: Dictionary, elapsed_ms: int = 0
) -> Dictionary:
var attempt := int(stage_attempts.get(stage, 0)) + 1
stage_attempts[stage] = attempt
return {
"snapshot": snapshot,
"diagnostic": _snapshot_evidence(stage, pid, snapshot, [], attempt, elapsed_ms),
}
static func _capture_elapsed_ms(capture: Dictionary) -> int:
var diagnostic: Variant = capture.get("diagnostic", {})
return int(diagnostic.get("elapsed_ms", 0)) if diagnostic is Dictionary else 0
static func _retarget_snapshot_capture(
capture: Dictionary, stage: String, pid: int
) -> Dictionary:
var snapshot: Variant = capture.get("snapshot")
var old_diagnostic: Variant = capture.get("diagnostic", {})
var attempt := int(old_diagnostic.get("attempt", 1)) if old_diagnostic is Dictionary else 1
var elapsed_ms := int(old_diagnostic.get("elapsed_ms", 0)) if old_diagnostic is Dictionary else 0
return {
"snapshot": snapshot,
"diagnostic": _snapshot_evidence(stage, pid, snapshot, [], attempt, elapsed_ms),
}
static func _snapshot_evidence(
stage: String,
pid: int,
snapshot: Variant,
diagnostics: Array,
attempt: int,
elapsed_ms: int,
category_override := "",
) -> Dictionary:
var detail := ""
var depth := -1
if not diagnostics.is_empty():
var last: Variant = diagnostics[diagnostics.size() - 1]
if last is Dictionary:
detail = str(last.get("category", ""))
depth = int(last.get("depth", -1))
var category := str(category_override)
if category.is_empty() and not detail.is_empty():
category = PortResolver.snapshot_failure_category(detail, depth)
elif category.is_empty() and PortResolver.capture_failed(snapshot):
category = "process_query"
var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
return {
"stage": stage if stage in IDENTITY_CAPTURE_STAGES else "unknown",
"category": (
category
if category.is_empty() or category in PortResolver.SNAPSHOT_FAILURE_CATEGORIES
else "unknown"
),
"detail": (
detail
if detail.is_empty() or detail in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES
else "unknown"
),
"depth": clampi(depth, -1, 16),
"pid": pid,
"creation_identity": "unavailable" if creation_identity.is_empty() else creation_identity,
"attempt": clampi(attempt, 1, 100000),
"elapsed_ms": clampi(elapsed_ms, 0, 600000),
}
func _proof_pending(
reason: String, stage_attempts: Dictionary, diagnostic: Dictionary = {}
) -> Dictionary:
var result := {
"pending": true,
"reason": reason,
"proof_stage_attempts": stage_attempts.duplicate(true),
}
if not diagnostic.is_empty():
result["snapshot_diagnostic"] = diagnostic.duplicate(true)
return result
func _capture_failure(
stage_attempts: Dictionary, capture: Dictionary, category_override := ""
) -> Dictionary:
var diagnostic: Dictionary = capture.get("diagnostic", {}).duplicate(true)
if not category_override.is_empty():
diagnostic["category"] = category_override
return _proof_pending("identity_unavailable", stage_attempts, diagnostic)
func _identity_mismatch(
reason: String, stage_attempts: Dictionary, capture: Dictionary
) -> Dictionary:
var diagnostic: Dictionary = capture.get("diagnostic", {}).duplicate(true)
diagnostic["category"] = "identity_mismatch"
return _proof_pending(reason, stage_attempts, diagnostic)
func _effect_prove(payload: Dictionary) -> Dictionary:
var launch_grant = payload.get("grant")
var launch_pid := int(launch_grant.process_id()) if launch_grant != null else -1
var stage_attempts: Variant = payload.get("proof_stage_attempts", {})
if not (stage_attempts is Dictionary):
stage_attempts = {}
else:
stage_attempts = stage_attempts.duplicate(true)
for stage in IDENTITY_CAPTURE_STAGES:
stage_attempts[stage] = maxi(0, int(stage_attempts.get(stage, 0)))
## The pid file is an untrusted hint. Reuse its ancestor snapshot only
## when it includes the launcher; the original PID read and all final
## capability, listener, identity, and lineage checks still follow.
var hinted_pid := -1
var first_snapshot: Variant = null
var launch_snapshot: Variant = null
var launch_capture: Dictionary = {}
var first_capture: Dictionary = {}
if OS.get_name() == "Windows" and launch_pid > 1:
hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", "")))
if hinted_pid > 1:
first_snapshot = _capture_process_snapshot(hinted_pid)
first_capture = _capture_identity("launch", hinted_pid, stage_attempts)
first_snapshot = first_capture.get("snapshot")
if PortResolver.process_descends_from(hinted_pid, launch_pid, first_snapshot):
launch_capture = _retarget_snapshot_capture(first_capture, "launch", launch_pid)
launch_snapshot = first_snapshot
if launch_snapshot == null:
launch_snapshot = _capture_process_snapshot(launch_pid)
launch_capture = _capture_identity("launch", launch_pid, stage_attempts)
launch_snapshot = launch_capture.get("snapshot")
first_snapshot = null
if PortResolver.capture_failed(launch_snapshot):
return {"pending": true, "reason": "identity_unavailable"}
return _capture_failure(stage_attempts, launch_capture)
var launch_alive := PortResolver.pid_alive(launch_pid, launch_snapshot)
var launch_fingerprint := (
PortResolver.process_fingerprint(launch_pid, launch_snapshot)
@@ -963,72 +1150,103 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
launch_fingerprint,
)
if launch_disposition != "owned":
return {
"ok": false,
"reason": "launch_%s" % launch_disposition,
"message": (
"The launched server process exited or changed identity before publishing capabilities."
+ startup_report_summary(str(payload.get("startup_report", "")))
),
}
if launch_disposition == "gone":
return {
"ok": false,
"reason": "launch_gone",
"message": (
"The launched server process exited or changed identity before publishing capabilities."
+ startup_report_summary(str(payload.get("startup_report", "")))
),
"proof_stage_attempts": stage_attempts.duplicate(true),
}
var mismatch := _identity_mismatch("launch_%s" % launch_disposition, stage_attempts, launch_capture)
mismatch.erase("pending")
mismatch["ok"] = false
mismatch["message"] = (
"The launched server process exited or changed identity before publishing capabilities."
+ startup_report_summary(str(payload.get("startup_report", "")))
)
return mismatch
var port := int(payload.http_port)
var capability := _read_capability(port)
if capability.is_empty() or str(capability.get("instance_nonce", "")) == str(payload.get("baseline_instance_id", "")):
return {"pending": true, "reason": "capability_record"}
return _proof_pending("capability_record", stage_attempts)
if (
str(capability.get("http", "")) != str(payload.get("http_capability", ""))
or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
):
return {"pending": true, "reason": "capability_pair"}
return _proof_pending("capability_pair", stage_attempts)
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
if not _authenticated_status_matches_record(live, capability):
return {"pending": true, "reason": "authenticated_status"}
return _proof_pending("authenticated_status", stage_attempts)
var version := str(live.get("version", ""))
var ws_port := int(live.get("ws_port", 0))
if not _server_status_compatibility(
version, str(payload.expected_version), ws_port, int(payload.expected_ws_port)
).get("compatible", false):
return {"ok": false, "reason": "incompatible", "message": "The launched server does not match this plugin."}
return {
"ok": false,
"reason": "incompatible",
"message": "The launched server does not match this plugin.",
"proof_stage_attempts": stage_attempts.duplicate(true),
}
var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
if pid <= 1:
return {"pending": true, "reason": "pid_file"}
return _proof_pending("pid_file", stage_attempts)
if not PortResolver.find_all_pids_on_port(port, Callable(), listeners).has(pid):
return {"pending": true, "reason": "listener_pid"}
return _proof_pending("listener_pid", stage_attempts)
if first_snapshot == null or pid != hinted_pid:
first_snapshot = (
launch_snapshot if pid == launch_pid else _capture_process_snapshot(pid)
if pid == launch_pid:
first_snapshot = launch_snapshot
first_capture = _record_snapshot_capture(
"first_server", pid, first_snapshot, stage_attempts,
_capture_elapsed_ms(launch_capture)
)
else:
first_capture = _capture_identity("first_server", pid, stage_attempts)
first_snapshot = first_capture.get("snapshot")
else:
first_capture = _record_snapshot_capture(
"first_server", pid, first_snapshot, stage_attempts,
_capture_elapsed_ms(first_capture)
)
if PortResolver.capture_failed(first_snapshot):
return {"pending": true, "reason": "identity_unavailable"}
return _capture_failure(stage_attempts, first_capture)
if not PortResolver.pid_cmdline_is_godot_ai(pid, first_snapshot):
return {"pending": true, "reason": "process_brand"}
return _identity_mismatch("process_brand", stage_attempts, first_capture)
if not PortResolver.process_descends_from(pid, launch_pid, first_snapshot):
return {"pending": true, "reason": "launch_lineage"}
return _identity_mismatch("launch_lineage", stage_attempts, first_capture)
var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot)
if fingerprint.is_empty():
return {"pending": true, "reason": "process_fingerprint"}
return _identity_mismatch("process_fingerprint", stage_attempts, first_capture)
## Close every capture window before minting process authority. The same
## launch-lineage PID must still own the listener, the private capability
## record must still name the authenticated instance, and the process
## fingerprint must remain unchanged after the final probe.
var final_capability := _read_capability(port)
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms), listeners)
var final_snapshot: Variant = _capture_process_snapshot(pid)
var final_capture := _capture_identity("final_server", pid, stage_attempts)
var final_snapshot: Variant = final_capture.get("snapshot")
if PortResolver.capture_failed(final_snapshot):
return {"pending": true, "reason": "identity_unavailable"}
return _capture_failure(stage_attempts, final_capture)
var final_fingerprint := PortResolver.process_fingerprint(pid, final_snapshot)
var final_listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
if (
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
or not PortResolver.find_all_pids_on_port(port, Callable(), final_listeners).has(pid)
or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
or final_capability != capability
or final_fingerprint != fingerprint
):
return _identity_mismatch("final_capture_window", stage_attempts, final_capture)
if (
final_capability != capability
or not _authenticated_status_matches_record(final_live, final_capability)
or str(final_live.get("instance_id", "")) != str(live.get("instance_id", ""))
):
return {"pending": true, "reason": "final_capture_window"}
return _proof_pending("final_capture_window", stage_attempts)
var final_version := str(final_live.get("version", ""))
var final_ws_port := int(final_live.get("ws_port", 0))
if not _server_status_compatibility(
@@ -1041,6 +1259,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
"ok": false,
"reason": "incompatible",
"message": "The launched server changed compatibility before authority was granted.",
"proof_stage_attempts": stage_attempts.duplicate(true),
}
return {
"ok": true,
@@ -1048,6 +1267,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
"fingerprint": fingerprint,
"version": final_version,
"transport": _transport_from(port, final_ws_port, final_live, final_capability),
"proof_stage_attempts": stage_attempts.duplicate(true),
}
@@ -1289,6 +1509,9 @@ func _prove_payload() -> Dictionary:
"ws_capability": str(launch.get("ws_capability", "")),
"baseline_instance_id": str(launch.get("baseline_instance_id", "")),
"grant": _process_grant,
"proof_stage_attempts": _episode.get(
"proof_stage_attempts", {"launch": 0, "first_server": 0, "final_server": 0}
),
}
@@ -1742,7 +1965,8 @@ static func _dormant_episode(episode_id: int) -> Dictionary:
"id": episode_id, "state": DORMANT, "phase": "", "reason": "",
"message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "",
"expected_version": "", "actual_version": "", "blocked_target": {},
"launch": {}, "after_stop": "",
"launch": {}, "after_stop": "", "snapshot_diagnostic": {},
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
}
+378 -4
View File
@@ -13,6 +13,19 @@ const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077
const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022
const _MAX_LINK_HOPS := 8
const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"]
const _FLATPAK_INFO_PATH := "/.flatpak-info"
## Every read-write spelling of the two grants that expose the home directory.
## Flatpak 1.16 writes plain read-write as the bare name; `:create` is
## read-write too. `:ro` is absent on purpose.
const _FLATPAK_HOME_GRANTS: Array[String] = [
"host", "host:rw", "host:create", "home", "home:rw", "home:create",
]
## Root directories a `host` grant leaves out of the sandbox, where Flatpak's
## own runtime lives (`dont_mount_in_root` in flatpak-context.c).
const _FLATPAK_HOST_EXCLUDED: Array[String] = [
"lib", "lib32", "lib64", "bin", "sbin", "usr", "boot", "root",
"tmp", "etc", "app", "run", "proc", "sys", "dev", "var",
]
const _KEYS: Array[String] = [
"version", "http", "websocket", "instance_nonce",
]
@@ -322,10 +335,371 @@ static func path_for_http_port(http_port: int) -> String:
"Library/Application Support/godot-ai/capabilities"
)
else:
directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges()
if directory.is_empty():
directory = OS.get_environment("HOME").path_join(".config")
directory = directory.path_join("godot-ai/capabilities")
directory = linux_config_home(_flatpak_info()).path_join("godot-ai/capabilities")
if directory.is_empty() or not directory.is_absolute_path():
return ""
return directory.simplify_path().path_join("http-%d.json" % http_port)
## The config directory a Linux process outside this one would also name.
## Flatpak points `XDG_CONFIG_HOME` at the app's own `~/.var/app/<id>/config`,
## which a client outside that sandbox never reads. When the sandbox shares
## the host's config directory, that is `HOST_XDG_CONFIG_HOME` if the host set
## one, else the `~/.config` default. Python's `capability_directory` applies
## the same rule, so the server publishes where this reads.
static func linux_config_home(flatpak_info: String) -> String:
var config := OS.get_environment(linux_config_home_variable(flatpak_info)).strip_edges()
if config.is_empty():
config = OS.get_environment("HOME").path_join(".config")
return config
## The variable that names that directory; unset or empty means `~/.config`.
static func linux_config_home_variable(flatpak_info: String) -> String:
return (
"HOST_XDG_CONFIG_HOME" if flatpak_shares_config_home(flatpak_info) else "XDG_CONFIG_HOME"
)
## Whether `/.flatpak-info` text describes a sandbox that can write the host's
## config directory at its own path: one that shares the home, or holds a
## read-write grant for the whole of `xdg-config`. Flatpak mounts that grant at
## the host's path only and leaves `XDG_CONFIG_HOME` on the per-app directory.
## An `xdg-config/<dir>` grant does not count: Flatpak mounts it inside the
## per-app directory as well, so `XDG_CONFIG_HOME` already reaches it.
static func flatpak_shares_config_home(flatpak_info: String) -> bool:
return (
flatpak_shares_home(flatpak_info)
or bool(_flatpak_filesystems(flatpak_info).get("xdg-config", false))
)
## Whether `/.flatpak-info` text describes a sandbox that can write the host's
## home directory. A read-write `host` or `home` grant exposes the real home at
## its own path; a `:ro` grant, a narrower one, or none leaves the app's private
## directories as the only ones a process outside the sandbox can also see.
static func flatpak_shares_home(flatpak_info: String) -> bool:
var group := ""
for line in flatpak_info.split("\n"):
if line.begins_with("["):
group = line.strip_edges()
elif group == "[Context]" and line.begins_with("filesystems="):
for grant in line.trim_prefix("filesystems=").strip_edges().split(";"):
if grant in _FLATPAK_HOME_GRANTS:
return true
return false
return false
## The grant in `/.flatpak-info` text that decides whether the sandbox shares
## `path` with the host, so that a process outside it finds the same file at
## the same location. Returned as {root, location, writable}: the directory or
## file the grant covers, the grant as Flatpak spells it (`home`, `~/dir`,
## `xdg-config/dir`), and whether it is read-write. {} when no grant covers
## the path.
##
## `home` and `host` share the home directory, except `~/.var/app`: there
## Flatpak hides every app's directory but the sandbox's own, whatever the
## grant, and shares that one with no grant at all. `host` also shares the
## root directories its runtime does not occupy. `~/dir` and `/dir` share that
## location, and `xdg-config[/dir]` the host's config directory,
## `host_config_home`. The narrowest grant decides, as the narrowest mount
## does. The other `xdg-` names share nothing here: where they lie depends on
## host settings the sandbox cannot read, so their paths fail closed.
##
## A grant says what Flatpak was asked for, not what it mounted. It skips a
## location that does not exist when the app starts, and `/.flatpak-info`
## leaves out denials (`--nofilesystem`). `flatpak_blocking_mount` checks the
## answer against the mounts.
static func flatpak_grant_over(
flatpak_info: String, path: String, home: String, host_config_home: String
) -> Dictionary:
var target := path.simplify_path()
if not target.is_absolute_path():
return {}
var grants := _flatpak_filesystems(flatpak_info)
var found := {}
for location in grants:
var root := _flatpak_grant_path(str(location), home, host_config_home)
if root.is_empty() or not path_is_within(root, target):
continue
var covered := str(found.get("root", ""))
if root.length() > covered.length():
found = {"root": root, "location": str(location), "writable": bool(grants[location])}
elif root == covered and found["writable"] and not grants[location]:
## Two spellings of one location: Flatpak mounts whichever it
## reaches last, so only agreement counts as writable.
found = {"root": root, "location": str(location), "writable": false}
var home_dir := home.simplify_path()
var top := target.get_slice("/", 1)
var wide := {}
if not home.is_empty() and path_is_within(home_dir, target):
var apps_dir := home_dir.path_join(".var/app")
var app_id := flatpak_application_id(flatpak_info)
if path_is_within(apps_dir, target):
if not app_id.is_empty() and path_is_within(apps_dir.path_join(app_id), target):
wide = {
"root": apps_dir.path_join(app_id),
"location": apps_dir.path_join(app_id),
"writable": true,
}
elif grants.has("home") or grants.has("host"):
## Flatpak applies the wider of the two modes to the home directory,
## so a read-write `home` is always enough for a path inside it.
wide = {
"root": home_dir,
"location": "home",
"writable": bool(grants.get("home", false)) or bool(grants.get("host", false)),
}
elif grants.has("host") and not top.is_empty() and top not in _FLATPAK_HOST_EXCLUDED:
wide = {"root": "/" + top, "location": "host", "writable": bool(grants["host"])}
if str(wide.get("root", "")).length() > str(found.get("root", "")).length():
return wide
return found
## The sandboxed app's ID from `/.flatpak-info` text, or "" outside Flatpak.
static func flatpak_application_id(flatpak_info: String) -> String:
var group := ""
for line in flatpak_info.split("\n"):
if line.begins_with("["):
group = line.strip_edges()
elif group == "[Application]" and line.begins_with("name="):
return line.trim_prefix("name=").strip_edges()
return ""
## Whether `directory` is `path` or one of its ancestors.
static func path_is_within(directory: String, path: String) -> bool:
return path == directory or path.begins_with(directory.trim_suffix("/") + "/")
## Where a grant's location lies in the sandbox, or "" for one this cannot
## place (`host-os`, `xdg-documents`, ...).
static func _flatpak_grant_path(location: String, home: String, host_config_home: String) -> String:
var placed := ""
if location == "~" or location.begins_with("~/"):
placed = home.path_join(location.substr(2))
elif location.begins_with("/"):
placed = _home_spelling(location, home)
elif not host_config_home.is_empty() and (
location == "xdg-config" or location.begins_with("xdg-config/")
):
placed = host_config_home + location.trim_prefix("xdg-config")
return placed.simplify_path() if placed.is_absolute_path() else ""
## `/home` and `/var/home` are one directory on ostree systems, and Flatpak
## mounts a grant at whichever is real, keeping the link. A grant spelled the
## way the home directory is not therefore reads as the one that is.
static func _home_spelling(path: String, home: String) -> String:
for spelling in [["/var/home/", "/home/"], ["/home/", "/var/home/"]]:
if home.begins_with(spelling[0]) and path.begins_with(spelling[1]):
return str(spelling[0]) + path.trim_prefix(spelling[1])
return path
## The `[Context] filesystems=` grants of `/.flatpak-info` text as
## {location: writable}, a location being the grant without its mode: `host`,
## `~/dir`, `/dir`, `xdg-config/dir`. Plain and `:create` grants are writable.
## `:ro`, a denial (`!`) and a mode this does not know are not.
static func _flatpak_filesystems(flatpak_info: String) -> Dictionary:
var grants := {}
var group := ""
for line in flatpak_info.split("\n"):
if line.begins_with("["):
group = line.strip_edges()
elif group == "[Context]" and line.begins_with("filesystems="):
for entry in _key_file_list(line.trim_prefix("filesystems=").strip_edges()):
var grant := _flatpak_location_and_mode(entry.trim_prefix("!"))
## `!host:reset` withdraws inherited grants. It names no location.
if grant[0].is_empty() or grant[1] == "reset":
continue
grants[grant[0]] = not entry.begins_with("!") and grant[1] in ["", "rw", "create"]
break
return grants
## A key file list value split on its unescaped `;`, with the key file's own
## escapes resolved. Flatpak's `\:` and `\\` reach the entry as `\:` and `\\`.
static func _key_file_list(value: String) -> PackedStringArray:
var entries := PackedStringArray()
var entry := ""
var index := 0
while index < value.length():
var character := value[index]
if character == "\\" and index + 1 < value.length():
index += 1
match value[index]:
"s":
entry += " "
"n":
entry += "\n"
"t":
entry += "\t"
"r":
entry += "\r"
_:
entry += value[index]
elif character == ";":
entries.append(entry)
entry = ""
else:
entry += character
index += 1
if not entry.is_empty():
entries.append(entry)
return entries
## One grant as [location, mode]. Flatpak escapes a `:` or `\` that belongs to
## the location, and the first bare `:` starts the mode (`ro`, `create`).
static func _flatpak_location_and_mode(entry: String) -> PackedStringArray:
var location := ""
var index := 0
while index < entry.length():
var character := entry[index]
if character == "\\" and index + 1 < entry.length():
index += 1
location += entry[index]
elif character == ":":
return PackedStringArray([location, entry.substr(index + 1)])
else:
location += character
index += 1
return PackedStringArray([location, ""])
## `/proc/self/mountinfo` text as one {mount_point, root, fstype, read_only}
## per mount, in the kernel's order. A line that does not parse is left out.
static func parse_mountinfo(mountinfo: String) -> Array[Dictionary]:
var mounts: Array[Dictionary] = []
for line in mountinfo.split("\n", false):
var fields := line.split(" ", false)
## Optional fields end at a lone "-"; the filesystem type follows it.
var separator := fields.find("-", 6)
if separator < 0 or separator + 1 >= fields.size():
continue
mounts.append({
"mount_point": _unescape_mount_path(fields[4]),
"root": _unescape_mount_path(fields[3]),
"fstype": fields[separator + 1],
"read_only": fields[5].split(",").has("ro"),
})
return mounts
## The mount that holds `path`: the one with the longest mount point at or
## above it and, among equals, the last listed, which is mounted on top.
## {} when none is.
static func mount_for_path(mounts: Array[Dictionary], path: String) -> Dictionary:
var target := path.simplify_path()
var found := {}
for mount in mounts:
var mount_point := str(mount["mount_point"])
if (
path_is_within(mount_point, target)
and mount_point.length() >= str(found.get("mount_point", "")).length()
):
found = mount
return found
## The mount that contradicts a grant covering `path`: a file written there
## would stay inside the sandbox, or cannot be written. {} when the mounts
## bear the grant out.
##
## An ungranted location lies on the tmpfs Flatpak builds the sandbox on, and
## so does a granted one that did not exist when the app started. A denial
## hides a location behind an empty tmpfs of its own, as Flatpak hides
## `~/.var/app`. Such a tmpfs is recognised by being mounted whole (root `/`)
## below `boundary`, the directory the caller knows to be the host's: the home
## directory for a path inside it, else the granted directory. A tmpfs at or
## above the boundary is the host's own (`/home` on a tmpfs) and blocks
## nothing. Neither does an empty mount list, so unreadable evidence leaves
## the grant's answer standing.
static func flatpak_blocking_mount(
mounts: Array[Dictionary], path: String, boundary: String
) -> Dictionary:
var mount := mount_for_path(mounts, path)
if mount.is_empty():
return {}
var mount_point := str(mount["mount_point"])
var host_directory := boundary.simplify_path()
if mount["fstype"] == "tmpfs" and (
mount_point == "/"
or (
mount["root"] == "/"
and mount_point != host_directory
and path_is_within(host_directory, mount_point)
)
):
return mount
return mount if mount["read_only"] else {}
## `path` as the kernel names it, with every symlink among its existing
## components replaced by its target. Mount points are listed by real path, so
## a home spelled `/home/<user>` on an ostree system has to be read as
## `/var/home/<user>` before it can be looked up among them. Components that do
## not exist are kept as written. A chain longer than `_MAX_LINK_HOPS` returns
## the path unchanged.
static func real_path(path: String) -> String:
if OS.get_name() == "Windows":
return path
var remaining := path.simplify_path().split("/", false)
var current := "/"
var hops := 0
while not remaining.is_empty():
var parent := DirAccess.open(current)
if parent == null:
break
var candidate := current.path_join(remaining[0])
remaining.remove_at(0)
if not parent.is_link(candidate):
current = candidate
continue
var target := parent.read_link(candidate)
if hops >= _MAX_LINK_HOPS or target.is_empty():
return path
hops += 1
if not target.is_absolute_path():
target = current.path_join(target)
var target_parts := target.simplify_path().split("/", false)
target_parts.append_array(remaining)
remaining = target_parts
current = "/"
return current if remaining.is_empty() else current.path_join("/".join(remaining))
## Undo the octal escapes mountinfo uses for space, tab, newline and backslash.
static func _unescape_mount_path(field: String) -> String:
if not field.contains("\\"):
return field
var path := ""
var index := 0
while index < field.length():
var code := field.substr(index + 1, 3)
if field[index] == "\\" and _is_octal_byte(code):
path += char(int(code[0]) * 64 + int(code[1]) * 8 + int(code[2]))
index += 4
else:
path += field[index]
index += 1
return path
static func _is_octal_byte(code: String) -> bool:
if code.length() != 3:
return false
for digit in code:
if digit not in "01234567":
return false
return true
static func _flatpak_info() -> String:
if not FileAccess.file_exists(_FLATPAK_INFO_PATH):
return ""
var file := FileAccess.open(_FLATPAK_INFO_PATH, FileAccess.READ)
return "" if file == null else file.get_as_text()
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="Toon 材质自动刷新"
description="在编辑器里修改 shader/toon.tres(含内联的描边 next_pass 子材质)参数时,自动重刷当前编辑场景中所有 toon_apply 节点的材质,不必再手动勾 refresh_now。也可从 项目 > 工具 菜单手动触发。"
author="agame"
version="1.0"
script="plugin.gd"
+143
View File
@@ -0,0 +1,143 @@
@tool
extends EditorPlugin
## 让 toon_apply 的材质在编辑器里"改模板即时生效"。
##
## 背景:toon_apply.gd 给每个 MeshInstance3D 都 duplicate() 了一份独立材质
## (因为每个模型有自己的贴图,必须独立),所以之后再改 shader/toon.tres 上的
## 材质级参数(color / specular / fresnel / uv_scale / uv_offset / normal_scale …)
## 不会传到那些副本上,必须重新跑一次 _apply_materials()。
##
## ShaderMaterial 的 Resource.changed 信号在改 shader 参数时不会发出(官方出于性能
## 考虑只在 resource_name 变化时发),所以没法在 toon_apply.gd 内部自己监听。
## 这里改从编辑器侧挂:监听 Inspector 的 property_edited,命中 toon.tres 时重刷。
##
## 注意:本插件只影响"当前正在编辑的场景"里的 toon_apply 节点。
const TARGET_MATERIAL := "res://shader/toon.tres"
const TARGET_SCRIPT_FILE := "toon_apply.gd"
## 防抖间隔:拖颜色滑块时每一步都会触发 property_edited,等停手再重刷一次。
const DEBOUNCE_SEC := 0.2
const MENU_ITEM := "刷新 toon 材质(当前场景)"
var _inspector: EditorInspector
var _dirty := false
var _elapsed := 0.0
func _enter_tree() -> void:
_inspector = EditorInterface.get_inspector()
if _inspector != null and not _inspector.property_edited.is_connected(_on_property_edited):
_inspector.property_edited.connect(_on_property_edited)
add_tool_menu_item(MENU_ITEM, refresh_edited_scene)
set_process(false)
print("[toon_refresh] 已启用:改 %s 会自动重刷当前场景的 toon_apply 材质" % TARGET_MATERIAL)
func _exit_tree() -> void:
remove_tool_menu_item(MENU_ITEM)
if _inspector != null and _inspector.property_edited.is_connected(_on_property_edited):
_inspector.property_edited.disconnect(_on_property_edited)
_inspector = null
func _process(delta: float) -> void:
# 即便 set_process(false) 被引擎重开也安全:这里第一件事就是挡掉
if not _dirty:
return
_elapsed += delta
if _elapsed < DEBOUNCE_SEC:
return
_dirty = false
_elapsed = 0.0
set_process(false)
refresh_edited_scene()
# --- 触发源 ---------------------------------------------------------------
func _on_property_edited(_property: String) -> void:
if _inspector == null:
return
if not _is_target_material(_inspector.get_edited_object()):
return
_dirty = true
_elapsed = 0.0
set_process(true)
## 判断被编辑的资源是不是 toon.tres,或者是它内联的 next_pass 子材质(描边)。
func _is_target_material(obj: Object) -> bool:
if not (obj is ShaderMaterial):
return false
var mat: ShaderMaterial = obj
if mat.resource_path == TARGET_MATERIAL:
return true
# 内联子资源(描边材质)的路径形如
# res://shader/toon.tres::ShaderMaterial_m5gih
if mat.resource_path.begins_with(TARGET_MATERIAL + "::"):
return true
# 兜底:按实例比对 next_pass 链
var template := load(TARGET_MATERIAL) as ShaderMaterial
if template == null:
return false
var pass_mat: Material = template.next_pass
while pass_mat != null:
if pass_mat == mat:
return true
pass_mat = pass_mat.next_pass
return false
# --- 刷新 -----------------------------------------------------------------
## 重刷当前编辑场景里所有挂着 toon_apply.gd 的节点。返回刷新的节点数。
func refresh_edited_scene() -> int:
var root := EditorInterface.get_edited_scene_root()
if root == null:
return 0
var count := _refresh_branch(root)
if count > 0:
print("[toon_refresh] 已重刷 %d 个 toon_apply 节点的材质" % count)
return count
func _refresh_branch(root: Node) -> int:
var count := 0
var stack: Array[Node] = [root]
while not stack.is_empty():
var node: Node = stack.pop_back()
if _is_toon_apply(node):
node.call("_apply_materials")
count += 1
for child in node.get_children():
stack.append(child)
return count
## 按脚本文件名匹配,避免依赖 class_name;同时要求确实有 _apply_materials,
## 防止脚本编译失败时调用出错。
func _is_toon_apply(node: Node) -> bool:
if not node.has_method("_apply_materials"):
return false
var scr := node.get_script()
if not (scr is Script):
return false
return (scr as Script).resource_path.get_file() == TARGET_SCRIPT_FILE
+1
View File
@@ -0,0 +1 @@
uid://dvoc5463koalp
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:SM_Wall_1_01": {
"generate/physics": true
}
}
}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:SM_Wall_2_01": {
"generate/physics": true
}
}
}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:SM_Wall_2_09": {
"generate/physics": true
}
}
}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:SM_Wall_3_01": {
"generate/physics": true
}
}
}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:SM_Wall_3_09": {
"generate/physics": true
}
}
}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:pCube6": {
"generate/physics": true
}
}
}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:pCube6": {
"generate/physics": true
}
}
}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
@@ -39,7 +39,13 @@ import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
_subresources={
"nodes": {
"PATH:pCube6": {
"generate/physics": true
}
}
}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
@@ -42,8 +42,7 @@ materials/extract_path=""
_subresources={
"nodes": {
"PATH:pCube6": {
"generate/physics": true,
"physics/shape_type": 0
"generate/physics": true
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d3bvsphy56vjg"
path.s3tc="res://.godot/imported/Graffiti_Tag_vkzicjwl_2K_BaseColor.jpg-14e912dce9853a620d93a82de946745f.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/decals/graffiti_tag_vkzicjwl_2k_extracted/Graffiti_Tag_vkzicjwl_2K_BaseColor.jpg"
dest_files=["res://.godot/imported/Graffiti_Tag_vkzicjwl_2K_BaseColor.jpg-14e912dce9853a620d93a82de946745f.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://e4jxgm37v084"
path.s3tc="res://.godot/imported/Graffiti_Tag_vkzicjwl_2K_Opacity.jpg-f58ba3b09559be0fca8746dffbb3d79e.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/decals/graffiti_tag_vkzicjwl_2k_extracted/Graffiti_Tag_vkzicjwl_2K_Opacity.jpg"
dest_files=["res://.godot/imported/Graffiti_Tag_vkzicjwl_2K_Opacity.jpg-f58ba3b09559be0fca8746dffbb3d79e.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://wdg5vgoqy1jr"
path="res://.godot/imported/Graffiti_Tag_vkzicjwl_2K_Specular.jpg-0fdd5c31ff8ed31082725218e234e284.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/decals/graffiti_tag_vkzicjwl_2k_extracted/Graffiti_Tag_vkzicjwl_2K_Specular.jpg"
dest_files=["res://.godot/imported/Graffiti_Tag_vkzicjwl_2K_Specular.jpg-0fdd5c31ff8ed31082725218e234e284.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bbrysr5pcooqp"
path="res://.godot/imported/test1.png-107947c3386671e0e1b4b03e7965dc5c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/decals/graffiti_tag_vkzicjwl_2k_extracted/test1.png"
dest_files=["res://.godot/imported/test1.png-107947c3386671e0e1b4b03e7965dc5c.ctex"]
[params]
compress/mode=1
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
@@ -0,0 +1,388 @@
{
"assetCategories": {
"decal": {
"graffiti": {}
}
},
"averageColor": "#000000",
"categories": [
"atlas",
"decals"
],
"comp_version": 1,
"description": "",
"environment": {
"biome": "undefined",
"region": "Europe"
},
"highest_available_res": 8192,
"id": "vkzicjwl",
"json": {
"contentLength": 7245,
"uri": "vkzicjwl.json"
},
"maps": [
{
"averageColor": "#006FB7",
"bitDepth": 16,
"colorSpace": "sRGB",
"contentLength": 9919920,
"maxIntensity": 87,
"mimeType": "image/x-exr",
"minIntensity": 78,
"name": "Basecolor",
"physicalSize": "1x0.5",
"resolution": "8192x4096",
"type": "basecolor",
"uri": "vkzicjwl_8K_Basecolor.exr"
},
{
"averageColor": "#006FB7",
"bitDepth": 8,
"colorSpace": "sRGB",
"contentLength": 1343958,
"maxIntensity": 87,
"mimeType": "image/jpeg",
"minIntensity": 78,
"name": "Basecolor",
"physicalSize": "1x0.5",
"resolution": "8192x4096",
"type": "basecolor",
"uri": "vkzicjwl_8K_Basecolor.jpg"
},
{
"averageColor": "#232323",
"bitDepth": 32,
"colorSpace": "Linear",
"contentLength": 8462827,
"maxIntensity": 255,
"mimeType": "image/x-exr",
"minIntensity": 0,
"name": "Opacity",
"physicalSize": "1x0.5",
"resolution": "8192x4096",
"type": "opacity",
"uri": "vkzicjwl_8K_Opacity.exr"
},
{
"averageColor": "#232323",
"bitDepth": 8,
"colorSpace": "Linear",
"contentLength": 4124283,
"maxIntensity": 255,
"mimeType": "image/jpeg",
"minIntensity": 0,
"name": "Opacity",
"physicalSize": "1x0.5",
"resolution": "8192x4096",
"type": "opacity",
"uri": "vkzicjwl_8K_Opacity.jpg"
},
{
"averageColor": "#393839",
"bitDepth": 16,
"colorSpace": "Linear",
"contentLength": 14179198,
"maxIntensity": 61,
"mimeType": "image/x-exr",
"minIntensity": 55,
"name": "Specular",
"physicalSize": "1x0.5",
"resolution": "8192x4096",
"type": "specular",
"uri": "vkzicjwl_8K_Specular.exr"
},
{
"averageColor": "#393839",
"bitDepth": 8,
"colorSpace": "Linear",
"contentLength": 2136045,
"maxIntensity": 61,
"mimeType": "image/jpeg",
"minIntensity": 55,
"name": "Specular",
"physicalSize": "1x0.5",
"resolution": "8192x4096",
"type": "specular",
"uri": "vkzicjwl_8K_Specular.jpg"
}
],
"meta": [
{
"key": "scanArea",
"name": "Scan Area",
"value": "1x0.5 m"
},
{
"key": "height",
"name": "Height",
"value": "0 m"
},
{
"key": "tileable",
"name": "Tileable",
"value": false
},
{
"key": "tiling_directions",
"name": "Tiling Directions",
"value": []
},
{
"key": "texelDensity",
"name": "Texel Density",
"value": "8192 px/m"
},
{
"key": "calibration",
"name": "Calibration",
"value": "GretagMacbeth ColorChecker Color Rendition Chart"
},
{
"key": "scanner",
"name": "Scanner",
"value": "MKX"
}
],
"models": [],
"name": "Graffiti Tag",
"pack": {
"_id": "graffiti",
"name": "Graffiti"
},
"physicalSize": null,
"points": 1,
"previews": {
"images": [
{
"contentLength": 147224,
"resolution": "360x360",
"tags": [
"thumb"
],
"uri": "vkzicjwl_Grid_360.png"
},
{
"contentLength": 194320,
"resolution": "420x420",
"tags": [
"thumb"
],
"uri": "vkzicjwl_Grid_420.png"
},
{
"contentLength": 521251,
"resolution": "720x720",
"tags": [
"thumb",
"retina"
],
"uri": "vkzicjwl_Grid_720.png"
},
{
"contentLength": 688337,
"resolution": "840x840",
"tags": [
"thumb",
"retina"
],
"uri": "vkzicjwl_Grid_840.png"
},
{
"contentLength": 1677632,
"resolution": "1440x768",
"tags": [
"preview",
"clay"
],
"uri": "vkzicjwl_PopupClay_1440.png"
},
{
"contentLength": 3058618,
"resolution": "1920x1080",
"tags": [
"preview",
"clay"
],
"uri": "vkzicjwl_PopupClay_1920.png"
},
{
"contentLength": 6399574,
"resolution": "2880x1536",
"tags": [
"preview",
"clay",
"retina"
],
"uri": "vkzicjwl_PopupClay_2880.png"
},
{
"contentLength": 11022974,
"resolution": "3840x2160",
"tags": [
"preview",
"clay",
"retina"
],
"uri": "vkzicjwl_PopupClay_3840.png"
},
{
"contentLength": 1666057,
"resolution": "1440x768",
"tags": [
"preview"
],
"uri": "vkzicjwl_Popup_1440.png"
},
{
"contentLength": 3060107,
"resolution": "1920x1080",
"tags": [
"preview"
],
"uri": "vkzicjwl_Popup_1920.png"
},
{
"contentLength": 6362275,
"resolution": "2880x1536",
"tags": [
"preview",
"retina"
],
"uri": "vkzicjwl_Popup_2880.png"
},
{
"contentLength": 11631795,
"resolution": "3840x2160",
"tags": [
"preview",
"retina"
],
"uri": "vkzicjwl_Popup_3840.png"
}
],
"relativeSize": "1x1",
"scaleReferences": []
},
"process_date": "2024-04-16 21:42:22",
"properties": [],
"referencePreviews": {
"maps": [],
"models": []
},
"references": [],
"revised": false,
"revision": 0,
"semanticTags": {
"architectural_style": {},
"asset_type": "decal",
"color": [
"Blue"
],
"contains": [
"graffiti",
"spray",
"paint",
"marker"
],
"country": "Sweden",
"descriptive": [
"painted",
"tagged",
"sprayed"
],
"environment": [
"urban"
],
"industry": [
"archviz",
"games",
"VFX"
],
"interior_exterior": [
"exterior"
],
"locations": {
"Europe": {
"Sweden": {}
}
},
"maxSize": 1,
"minSize": 0.5,
"name": "Graffiti Tag",
"orientation": [
"wall"
],
"region": "Europe",
"resolution": 8192,
"state": [
"new"
],
"subject_matter": "manmade",
"theme": [
"streets",
"walls",
"urban",
"alleys",
"sub-urban",
"vandalism",
"trains",
"subways"
]
},
"tags": [
"decal",
"blue",
"street",
"wall",
"urban",
"rural",
"art",
"alleys",
"graffiti",
"spray",
"paint",
"marker",
"painted",
"tagged",
"sprayed",
"colors"
],
"texelDensity": 8192,
"texture_type": "basic",
"ue_plugin_archives": [
{
"contentLength": 7685151,
"gltfVersion": "2.0",
"tier": 0,
"type": "gltf",
"uri": "ue_plugin_archives/tier_0/vkzicjwl_tier_0.zip",
"version": 1
},
{
"contentLength": 2890713,
"gltfVersion": "2.0",
"tier": 1,
"type": "gltf",
"uri": "ue_plugin_archives/tier_1/vkzicjwl_tier_1.zip",
"version": 1
},
{
"contentLength": 817118,
"gltfVersion": "2.0",
"tier": 2,
"type": "gltf",
"uri": "ue_plugin_archives/tier_2/vkzicjwl_tier_2.zip",
"version": 1
},
{
"contentLength": 269549,
"gltfVersion": "2.0",
"tier": 3,
"type": "gltf",
"uri": "ue_plugin_archives/tier_3/vkzicjwl_tier_3.zip",
"version": 1
}
],
"version": 2
}
+191
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
extends LimboHSM
## 空中子机(挂在 HSM/Airborne 上)。
##
## Jump(上升段)→ Fall(越过顶点开始下落)。进入时按当前竖直速度挑初始子状态:
## 起跳进来 vy > 0 → Jump;踏空掉进来 vy ≈ 0 → Fall(vy ≤ 0 一律算 Fall)。
@onready var jump: LimboState = $Jump
@onready var fall: LimboState = $Fall
func _setup() -> void:
add_transition(jump, fall, &"falling")
func _enter() -> void:
var player := get_agent() as Player
set_initial_state(fall if player == null or player.velocity.y <= 0.0 else jump)
+1
View File
@@ -0,0 +1 @@
uid://43psuo6pb4wq
+18
View File
@@ -0,0 +1,18 @@
extends PlayerGroundState
## 原地蹲:目标速度 0,播 crouch(蹲着不动)。
## 蹲着走是 Sneak —— 两者只差「有没有方向输入」,转换事件同为 move_start/stop。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = 0.0
player.motor.sprinting = false
player.motor.crouching = true
player.animation_player.play_state(PlayerAnimator.AnimState.CROUCH)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if not Input.is_action_pressed("crouch"):
dispatch(&"crouch_end")
elif player.motor.has_move_input():
dispatch(&"move_start")
+1
View File
@@ -0,0 +1 @@
uid://bb8dvx7nwuiwo
+14
View File
@@ -0,0 +1,14 @@
extends LimboState
## 下落段:播 fall。落地(is_on_floor 且不再上升)转回地面,由 Grounded._enter
## 按当下输入挑子状态(走/跑/蹲着落地不会闪站立)。
func _enter() -> void:
(get_agent() as Player).animation_player.play_state(PlayerAnimator.AnimState.FALL)
func _update(_delta: float) -> void:
var player := get_agent() as Player
if player == null:
return
if player.is_on_floor() and player.velocity.y <= 0.0:
dispatch(&"landed")
+1
View File
@@ -0,0 +1 @@
uid://yadts2ydgfs6
+32
View File
@@ -0,0 +1,32 @@
class_name PlayerGroundState
extends LimboState
## 地面叶子状态的公共基类(Idle / Walk / Sprint / Crouch / Sneak 共用)。
##
## 每帧先统一判定「环境事实」,之后才轮到 _think() 里各状态自己的输入转换:
## 1. 跳跃缓冲成立(try_jump 当场起跳)→ 转空中(vy > 0 → Jump);
## 2. 脚下没地板(踏空)→ 转空中(Fall)。
## 这两层判定放在**叶子**而不是父机:父机 _update 先于子状态执行,
## 若在父机里 dispatch,转换后父机本轮还会继续调(已被退出的)旧子状态,
## 存在「旧状态在已退出的机器里再次 dispatch」的顺序陷阱;叶子自己判、
## dispatch 后立即 return,则没有这个窗口。
##
## is_on_floor() 反映的是上一帧 move_and_slide() 的结果 ——
## 与推进的更新节奏一致,不引入额外的检测延迟。
func _update(delta: float) -> void:
var player := get_agent() as Player
if player == null:
return
if player.motor.try_jump():
dispatch(&"airborne")
return
if not player.is_on_floor():
dispatch(&"airborne")
return
_think(delta)
## 各状态自己的输入转换。要不要 dispatch 事件、往哪转,由具体状态决定
## (转换表注册在 grounded.gd 的 _setup 里)。
func _think(_delta: float) -> void:
pass
+1
View File
@@ -0,0 +1 @@
uid://dss42q7c4kqui
+45
View File
@@ -0,0 +1,45 @@
extends LimboHSM
## 地面子机(挂在 HSM/Grounded 上)。
##
## 五个叶子状态:
## Idle 站立不动 Walk 步行 Sprint 疾跑 Crouch 原地蹲 Sneak 蹲走
## 叶子各自的输入转换见 ground_state.gd 及各状态脚本;本机只注册转换表,
## 并在 _enter(首次激活 / 每次落地)时按「当下输入」挑初始子状态 ——
## 不然按着方向键落地会先闪一帧站立。
@onready var idle: LimboState = $Idle
@onready var walk: LimboState = $Walk
@onready var sprint: LimboState = $Sprint
@onready var crouch: LimboState = $Crouch
@onready var sneak: LimboState = $Sneak
func _setup() -> void:
# 事件口径:move_start/stop 有无方向输入,sprint_start/stop 疾跑键,
# crouch_start/end 蹲键。蹲伏优先于疾跑(蹲下时按 Shift 不算疾跑)。
add_transition(idle, walk, &"move_start")
add_transition(idle, crouch, &"crouch_start")
add_transition(walk, idle, &"move_stop")
add_transition(walk, sprint, &"sprint_start")
add_transition(walk, sneak, &"crouch_start")
add_transition(sprint, walk, &"sprint_stop")
add_transition(sprint, idle, &"move_stop")
add_transition(sprint, sneak, &"crouch_start")
add_transition(crouch, sneak, &"move_start")
add_transition(crouch, idle, &"crouch_end")
add_transition(sneak, crouch, &"move_stop")
add_transition(sneak, walk, &"crouch_end")
func _enter() -> void:
var player := get_agent() as Player
if player == null:
set_initial_state(idle)
return
var moving := player.motor.has_move_input()
if Input.is_action_pressed("crouch"):
set_initial_state(sneak if moving else crouch)
elif moving:
set_initial_state(sprint if Input.is_action_pressed("sprint") else walk)
else:
set_initial_state(idle)
+1
View File
@@ -0,0 +1 @@
uid://2bnkxlycmqqu
+17
View File
@@ -0,0 +1,17 @@
extends PlayerGroundState
## 站立:目标速度 0,播 idle。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = 0.0
player.motor.sprinting = false
player.motor.crouching = false
player.animation_player.play_state(PlayerAnimator.AnimState.IDLE)
func _think(_delta: float) -> void:
# 蹲键优先 —— 同帧又蹲又走时先蹲下,下一帧 Crouch 再转 Sneak,不会漏蹲。
if Input.is_action_pressed("crouch"):
dispatch(&"crouch_start")
elif (get_agent() as Player).motor.has_move_input():
dispatch(&"move_start")
+1
View File
@@ -0,0 +1 @@
uid://2isemrs6ov3
+20
View File
@@ -0,0 +1,20 @@
extends LimboState
## 上升段:播 jump。越过顶点(vy ≤ 0)转 Fall。
## 水平目标速度保持离地前的值 —— 空中加速度本来就小(air_acceleration),
## 不由本状态改写。
func _enter() -> void:
(get_agent() as Player).animation_player.play_state(PlayerAnimator.AnimState.JUMP)
func _update(_delta: float) -> void:
var player := get_agent() as Player
if player == null:
return
# 落地判定带 vy ≤ 0 门槛:起跳那一帧 is_on_floor() 还是 true
# (要等 move_and_slide() 之后才翻假),没有门槛会当帧弹回地面状态。
if player.is_on_floor() and player.velocity.y <= 0.0:
dispatch(&"landed")
return
if player.velocity.y <= 0.0:
dispatch(&"falling")
+1
View File
@@ -0,0 +1 @@
uid://cvfcvo7k1i5jr
+16
View File
@@ -0,0 +1,16 @@
extends LimboHSM
## 玩家状态机根(挂在 Player/HSM 节点上,update_mode = MANUAL)。
##
## 只管最粗的一层:「身体在地上还是空中」。细分状态在两个子机里:
## Grounded 地面 grounded.gd —— Idle / Walk / Sprint / Crouch / Sneak
## Airborne 空中 airborne.gd —— Jump / Fall
## 推进由 Player._physics_process 显式调 update(delta)(管线第一位)。
@onready var grounded: LimboHSM = $Grounded
@onready var airborne: LimboHSM = $Airborne
func _setup() -> void:
# 起跳或踏空 → 空中;落地 → 回地面。事件的发起在各叶子状态里。
add_transition(grounded, airborne, &"airborne")
add_transition(airborne, grounded, &"landed")
+1
View File
@@ -0,0 +1 @@
uid://cgjaw5xbg7gdj
+19
View File
@@ -0,0 +1,19 @@
extends PlayerGroundState
## 蹲走(潜行):目标速度 = crouch_speed,播 sneak。
## 蹲伏优先于疾跑:蹲着按 Shift 也只是潜行。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = player.motor.crouch_speed
player.motor.sprinting = false
player.motor.crouching = true
player.animation_player.play_state(PlayerAnimator.AnimState.SNEAK, player.motor.crouch_speed)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if not Input.is_action_pressed("crouch"):
dispatch(&"crouch_end")
elif not player.motor.has_move_input():
dispatch(&"move_stop")
+1
View File
@@ -0,0 +1 @@
uid://b4okw70vrah42
+19
View File
@@ -0,0 +1,19 @@
extends PlayerGroundState
## 疾跑:目标速度 = sprint_speed,播 run。只在按着方向键时成立(Sprint 由 Walk 进入)。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = player.motor.sprint_speed
player.motor.sprinting = true
player.motor.crouching = false
player.animation_player.play_state(PlayerAnimator.AnimState.RUN, player.motor.sprint_speed)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if Input.is_action_pressed("crouch"):
dispatch(&"crouch_start")
elif not player.motor.has_move_input():
dispatch(&"move_stop")
elif not Input.is_action_pressed("sprint"):
dispatch(&"sprint_stop")
+1
View File
@@ -0,0 +1 @@
uid://cklw8uq6xahe
+19
View File
@@ -0,0 +1,19 @@
extends PlayerGroundState
## 步行:目标速度 = walk_speed,播 walk。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = player.motor.walk_speed
player.motor.sprinting = false
player.motor.crouching = false
player.animation_player.play_state(PlayerAnimator.AnimState.WALK, player.motor.walk_speed)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if Input.is_action_pressed("crouch"):
dispatch(&"crouch_start")
elif not player.motor.has_move_input():
dispatch(&"move_stop")
elif Input.is_action_pressed("sprint"):
dispatch(&"sprint_start")
+1
View File
@@ -0,0 +1 @@
uid://6pa3038100o4
+22 -5
View File
@@ -1,3 +1,4 @@
class_name Player
extends CharacterBody3D
## 玩家协调器:只做三件事 —— 多人权限、依赖注入、按固定顺序编排各组件。
##
@@ -6,8 +7,12 @@ extends CharacterBody3D
## Motor 移动跳跃 character/player/player_motor.gd
## Pickup 拾取 character/player/player_pickup.gd
## Animator 动画表现 character/player/player_animator.gd(挂在 AnimationPlayer 上)
## HSM 状态机 character/player/hsm/(LimboAI LimboHSM,MANUAL 模式)
##
## 本脚本不实现任何操作,只负责「谁先谁后」和「谁有权跑」。
## 「玩家处于什么状态」(走 / 跑 / 蹲 / 跳 / 落)的判定完全在 HSM 里:
## 状态在 _enter 时写 motor.target_speed、motor.crouching/sprinting 和动画片段,
## 这里只按固定顺序推进各组件。
@onready var body: Node3D = $Body
## 本地玩家自己的模型(鼠)。第一人称要藏掉它,见 _ready()。
@@ -22,6 +27,9 @@ extends CharacterBody3D
@onready var look: PlayerLook = $Look
@onready var motor: PlayerMotor = $Motor
@onready var pickup: PlayerPickup = $Pickup
## LimboAI 层级状态机。update_mode = MANUAL,由 _physics_process 按管线顺序显式推进,
## 保证它永远排在 motor.step 之前、且只在权威端跑。
@onready var hsm: LimboHSM = $HSM
func _ready() -> void:
@@ -60,6 +68,12 @@ func _ready() -> void:
pickup.interact_state_changed.connect(_on_pickup_interact_state_changed)
# 调试面板接线:把要读的组件注入进去,面板自己不 get_node。
debug_panel.setup(self, motor, pickup, camera)
# 状态机接线:initialize 递归调所有状态的 _setup(注册各自的转换表),
# set_active 进入 initial_state(Grounded 会按当下输入挑子状态)。
# 只在权威端跑 —— 远端玩家由 Sync 复制 position / anim_state / anim_speed,
# 不需要也不允许本地再跑一套状态机;HSM 是 MANUAL 模式,不初始化就完全惰性。
hsm.initialize(self)
hsm.set_active(true)
set_mouse_captured(true)
@@ -91,13 +105,16 @@ func _on_pickup_interact_state_changed(hovering: bool, holding: bool) -> void:
func _physics_process(delta: float) -> void:
# 顺序固定:算速度 → 同步蹲伏高度 → 推进 → 播动画 → 搬物体。
# 由根节点统一编排,各组件自己不许写 _physics_process ——
# 顺序固定:状态机(判定状态,写目标速度与动画)→ 算速度 → 同步蹲伏高度 →
# 推进 → 刷步频 → 搬物体。由根节点统一编排,各组件自己不许写 _physics_process ——
# 否则顺序取决于节点树顺序,改个节点位置就会出现「先滑再算速度」这类隐形 bug。
# HSM 排最前:状态写入的 target_speed 当帧就被 motor 消费;
# 起跳在 Grounded 状态里就地执行(motor.try_jump),同一帧转进 Jump。
hsm.update(delta)
motor.step(delta)
# 蹲伏状态由 Motor 单点判定,再转交给 Look 去升降摄像机 ——
# 不让 Look 自己去读蹲键,免得两个组件各判一次、将来不同步。
# 蹲伏事实由 HSM 的 Crouch/Sneak 状态写进 motor.crouching ——
# 不让 Look 自己去读蹲键,免得两边各判一次、将来不同步。
look.set_crouching(motor.crouching)
move_and_slide()
animation_player.set_motion(velocity, is_on_floor(), motor.sprinting, motor.crouching)
animation_player.set_gait(velocity)
pickup.physics_step()
+41 -2
View File
@@ -19,6 +19,16 @@
[ext_resource type="Script" uid="uid://bo1hob2whrlu0" path="res://character/player/player_look.gd" id="13_look"]
[ext_resource type="Script" uid="uid://xta8h2snalsd" path="res://character/player/player_motor.gd" id="14_motor"]
[ext_resource type="Script" uid="uid://b3ko4cfjf0vgu" path="res://character/player/player_pickup.gd" id="15_pickup"]
[ext_resource type="Script" uid="uid://cgjaw5xbg7gdj" path="res://character/player/hsm/player_hsm.gd" id="20_7gte1"]
[ext_resource type="Script" uid="uid://2bnkxlycmqqu" path="res://character/player/hsm/grounded.gd" id="21_k6mlu"]
[ext_resource type="Script" uid="uid://2isemrs6ov3" path="res://character/player/hsm/idle.gd" id="22_r212m"]
[ext_resource type="Script" uid="uid://6pa3038100o4" path="res://character/player/hsm/walk.gd" id="23_8bmvw"]
[ext_resource type="Script" uid="uid://bb8dvx7nwuiwo" path="res://character/player/hsm/crouch.gd" id="24_2pong"]
[ext_resource type="Script" uid="uid://b4okw70vrah42" path="res://character/player/hsm/sneak.gd" id="25_tkl2s"]
[ext_resource type="Script" uid="uid://cklw8uq6xahe" path="res://character/player/hsm/sprint.gd" id="26_vhygx"]
[ext_resource type="Script" uid="uid://43psuo6pb4wq" path="res://character/player/hsm/airborne.gd" id="27_s2r15"]
[ext_resource type="Script" uid="uid://cvfcvo7k1i5jr" path="res://character/player/hsm/jump.gd" id="28_ed2qs"]
[ext_resource type="Script" uid="uid://yadts2ydgfs6" path="res://character/player/hsm/fall.gd" id="29_u55fa"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
radius = 0.54541016
@@ -98,8 +108,37 @@ script = ExtResource("13_look")
[node name="Motor" type="Node" parent="." unique_id=1043927815]
script = ExtResource("14_motor")
walk_speed = 2.419
sprint_speed = 3.624
[node name="Pickup" type="Node" parent="." unique_id=1587230140]
script = ExtResource("15_pickup")
[node name="HSM" type="LimboHSM" parent="." unique_id=100369043]
update_mode = 2
script = ExtResource("20_7gte1")
[node name="Grounded" type="LimboHSM" parent="HSM" unique_id=159625644]
script = ExtResource("21_k6mlu")
[node name="Idle" type="LimboState" parent="HSM/Grounded" unique_id=1876145612]
script = ExtResource("22_r212m")
[node name="Walk" type="LimboState" parent="HSM/Grounded" unique_id=689938768]
script = ExtResource("23_8bmvw")
[node name="Crouch" type="LimboState" parent="HSM/Grounded" unique_id=1470963204]
script = ExtResource("24_2pong")
[node name="Sneak" type="LimboState" parent="HSM/Grounded" unique_id=988596449]
script = ExtResource("25_tkl2s")
[node name="Sprint" type="LimboState" parent="HSM/Grounded" unique_id=674736640]
script = ExtResource("26_vhygx")
[node name="Airborne" type="LimboHSM" parent="HSM" unique_id=1349311867]
script = ExtResource("27_s2r15")
[node name="Jump" type="LimboState" parent="HSM/Airborne" unique_id=2079971118]
script = ExtResource("28_ed2qs")
[node name="Fall" type="LimboState" parent="HSM/Airborne" unique_id=233377038]
script = ExtResource("29_u55fa")
+26 -69
View File
@@ -2,8 +2,10 @@ class_name PlayerAnimator
extends Node
## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。
##
## 控制器每物理帧调一次 set_motion(),本节点据此决定 AnimState 并切片段。
## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,控制器不认识任何片段名。
## 「现在该播哪个状态、按什么速度为基准」由 hsm/ 的 LimboHSM 状态机决定:
## 状态在 _enter 时调 play_state(状态, 基准速度) 切片段,
## 控制器每物理帧调 set_gait() 按实际水平速度刷播放倍率。
## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,状态机不认识任何片段名。
##
## 节点接法:本脚本直接挂在 Body/AnimationPlayer 上 —— 这时 animation_player 留空,
## _ready() 会在父节点的子节点里找 AnimationPlayer,找到的就是它自己。
@@ -36,32 +38,21 @@ const ANIM_LOOPED: Array[int] = [
@export_group("播放")
## 动作切换的交叉淡入时间(秒)。0 为硬切
@export var blend_time: float = 0.15
## 低于此水平速度就视为站立(米/秒)
@export var idle_threshold: float = 0.2
## 播放倍率下限:被墙抵住、起步那一瞬,脚步不该慢到像在滑冰
@export var speed_scale_min: float = 0.65
## 播放倍率上限:防止速度被外力拉高时脚步快到抽搐
@export var speed_scale_max: float = 1.6
@export_group("片段基准速度")
## 各片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数:
## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,两边不用对齐。
##
## 注意:这几个数和「模型缩放」严格绑定 —— 角色尺寸一变,同一个片段一个循环跨出的
## 世界距离也按同比例变,所以基准速度必须跟着乘同一系数,否则脚步会明显打滑。
## 当前值对应 0.15 m 身高(基准系数 k ≈ 0.0857,推导见 player_motor.gd 顶部的说明)。
##
## sneak_ref_speed 要和 player_motor.gd 的 crouch_speed 保持一致 ——
## 蹲着走时播的就是 SNEAK 片段,两边不等就会打滑。(当前两者都是 0.2143)
@export var walk_ref_speed: float = 0.4286
@export var run_ref_speed: float = 0.7286
@export var crouch_ref_speed: float = 0.2143
@export var sneak_ref_speed: float = 0.2143
## 状态 → AnimationPlayer 里真正的片段键(形如 "walk/walk")。_ready() 里解析一次。
## 不直接拿 ANIM_CLIPS 的值去播,是因为多库时短名查不到 —— 见 _ready() 里的说明。
var _clips: Dictionary = {}
## 当前片段的基准速度(米/秒):play_state() 时由 HSM 状态传入。
## 倍率 = 实际水平速度 / 它。状态自己清楚它把 motor 的哪个速度当目标,
## 由它传入就不会出现「两处各存一份基准速度、改了一边忘了另一边」的打滑。
## 0(或负)= 该片段不参与速度缩放(站立 / 原地蹲 / 跳 / 落一律原速)。
var _ref_speed: float = 0.0
## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。
var anim_state: int = AnimState.IDLE:
set(value):
@@ -115,17 +106,23 @@ func _ready() -> void:
# ---------------- 对外接口 ----------------
## HSM 状态在 _enter 时调用:切到该动作状态(setter 负责切片段),并登记
## 该片段的基准速度。「播什么」是状态机的决定,「怎么播」(交叉淡入、循环、倍率)
## 才是本节点的职责。
func play_state(state: AnimState, ref_speed: float = 0.0) -> void:
# 先写基准速度再切片段:万一 setter 里有人读倍率,也不会用上旧的。
_ref_speed = maxf(ref_speed, 0.0)
anim_state = state
## 控制器每物理帧调用一次(放在 move_and_slide() 之后)。
## 只读入运动快照,不回写任何物理量 —— 这就是「单向耦合」的边界。
## crouching 就是「按住了蹲键」这个输入事实;蹲着不动算 CROUCH、蹲着走算 SNEAK,
## 这个区分在这里做,控制器不需要知道两个状态有什么不一样。不传就是普通站立。
func set_motion(
velocity: Vector3, on_floor: bool, sprinting: bool = false, crouching: bool = false
) -> void:
var state := _resolve_state(velocity, on_floor, sprinting, crouching)
# 先写倍率再切片段 —— 顺序反了会让新片段用一帧旧倍率。
anim_speed = _speed_scale_for(state, velocity)
anim_state = state # setter 负责切片段
## 只按当前水平速度刷播放倍率 —— 不切片段、不回写任何物理量,单向耦合的边界不变。
## play_state() 在状态 _enter(本帧更早)时执行,这里只会更新倍率。
func set_gait(velocity: Vector3) -> void:
if _ref_speed <= 0.0:
anim_speed = 1.0
return
anim_speed = Vector2(velocity.x, velocity.z).length() / _ref_speed
## 当前正在播的片段名。调试与测试用。
@@ -137,46 +134,6 @@ func current_clip() -> String:
# ---------------- 内部 ----------------
## 优先级:空中 > 蹲 > 移动。
## 空中最优先 —— 跳 / 落时不关心脚下是走还是蹲。
## 蹲排在移动前面:只要按着蹲键就播蹲的片段,不会因为没动就弹回站立。
## 蹲着不动是 CROUCH(原地蹲),蹲着走是 SNEAK(潜行)—— 两者只差「有没有在移动」。
func _resolve_state(
velocity: Vector3, on_floor: bool, sprinting: bool, crouching: bool
) -> int:
if not on_floor:
# 上升用 jump、越过顶点用 fall。jump 片段长 0.5s,而上升时间是
# jump_velocity / 有效重力(1.5 / 4.9 ≈ 0.31s),片段只播得完约六成 ——
# 越过顶点立刻切 fall,观感上是连贯的。
return AnimState.JUMP if velocity.y > 0.0 else AnimState.FALL
var moving := Vector2(velocity.x, velocity.z).length() >= idle_threshold
if crouching:
return AnimState.SNEAK if moving else AnimState.CROUCH
if not moving:
return AnimState.IDLE
return AnimState.RUN if sprinting else AnimState.WALK
## 播放倍率 = 实际水平速度 / 该片段的基准速度。
## 只有循环的位移动作参与缩放,跳跃 / 下落一律原速(它们不该被速度拉扯)。
func _speed_scale_for(state: int, velocity: Vector3) -> float:
var ref := 0.0
match state:
AnimState.WALK:
ref = walk_ref_speed
AnimState.RUN:
ref = run_ref_speed
AnimState.CROUCH:
ref = crouch_ref_speed
AnimState.SNEAK:
ref = sneak_ref_speed
_:
return 1.0
if ref <= 0.0:
return 1.0
return Vector2(velocity.x, velocity.z).length() / ref
func _play(clip: String, blend: float) -> void:
if animation_player == null or clip.is_empty():
return
+50 -43
View File
@@ -4,43 +4,41 @@ extends Node
##
## 只算速度写回 body.velocity,**不调 move_and_slide()** —— 推进由 Player 统一做。
## 这样「算速度 → 推进 → 播动画」的顺序永远固定,不受节点树顺序影响。
##
## 「走多快、蹲不蹲、跑不跑」由 hsm/ 的 LimboHSM(LimboAI)决定:
## 状态在 _enter 时写 target_speed / crouching / sprinting,
## 本组件只负责把这份「意图」用物理方式变成速度。
##
## 设计基准:**小人国玩法,角色身高约 0.2 m**。速度不按「人类等比缩放」推导,
## 直接按鼠类疾走的手感定标:走 ≈ 12 身长/秒、疾跑 ≈ 18 身长/秒、
## 跳高 ≈ 1.15 身高。速度/加速度按手感单独调即可,不必成套换算;
## 唯独 jump_velocity 与 gravity_scale 是**配对**的(反解公式见重力一节)。
## 跳跃输入缓冲(秒):按下跳跃后这段时间内若落地,自动补上一次起跳
const JUMP_BUFFER_TIME: float = 0.1
@export_group("移动")
## 【设计基准】以人类为参照做等比缩放:人 1.75 m,游戏惯例 walk 5.0 / sprint 8.5 m/s。
## 本角色身高 0.15 m,系数 k = 0.15 / 1.75 ≈ 0.0857,下面所有线性量都是原值 × k。
## 这样「每秒走过几个自身体长」和人类一致,动作比例看起来才对。
##
## 换尺寸时的公式:线性量(速度/加速度)× k';跳跃那两个数(gravity_scale 与 jump_velocity)用下面的反解公式重算。
##
## 常规移动速度(米/秒)—— 约 2.9 身长/秒,和人类走路同一比例
@export var walk_speed: float = 0.4286
## 疾跑速度(米/秒)—— 约 4.9 身长/秒
@export var sprint_speed: float = 0.7286
## 蹲行速度(米/秒)—— 默认是走速的一半。蹲下时按住 Shift 也不会疾跑。
## 注意:改这个值要同步改 player_animator.gd 的 sneak_ref_speed,否则蹲走时脚步会打滑。
@export var crouch_speed: float = 0.2143
## 地面加速度(米/秒²)—— 约 0.11 秒加到全速,起步干脆
@export var acceleration: float = 3.8571
## 空中加速度(米/秒²)
@export var air_acceleration: float = 1.0286
## 起跳初速度(米/秒)—— 配下面的 gravity_scale 0.5(g = 4.9)时,
## 跳跃高度 ≈ 0.230 m(约 1.53 身高)、滞空 ≈ 0.61 s。
## 这两个数是**配对**的:改重力必须按「反解公式」重算它,否则跳跃高度按 1/g 暴涨。
## 常规移动速度(米/秒)—— ≈ 12 身长/秒(0.2 m 身高),小鼠疾走的步频感
@export var walk_speed: float = 2.419
## 疾跑速度(米/秒)—— ≈ 18 身长/秒
@export var sprint_speed: float = 3.624
## 蹲行速度(米/秒)—— 走速的一半。蹲下时按住 Shift 也不疾跑(蹲伏优先)。
@export var crouch_speed: float = 1.21
## 地面加速度(米/秒²)—— 约 0.11 秒加到走速全速,起步干脆(鼠类的一窜一动)
@export var acceleration: float = 22.0
## 空中加速度(米/秒²)—— 约地面的 1/4:空中能修正方向,但不至于完全失控
@export var air_acceleration: float = 5.9
## 起跳初速度(米/秒)—— 配 gravity_scale 0.5(有效 g = 4.9)时,
## 跳高 ≈ 0.23 m(约 1.15 身高)、滞空 ≈ 0.61 s。**与 gravity_scale 配对**,见下。
@export var jump_velocity: float = 1.5
@export_group("重力")
## 重力缩放系数。0.5 = 重力减半(有效 g = 4.9 m/s²)。
## 重力缩放系数。0.5 = 有效重力 4.9 m/s²。
##
## 为什么不用 1.0(物理真实的 9.8):重力加速度本身和物体大小无关,但我们的速度是按身材
## 等比缩过的,于是落体相对过快。更关键的是跳跃 —— 由 h = v²/2g 和 T = 2v/g 可得
## h = g·T²/8:**固定重力下滞空翻倍、高度要涨 4 倍**。所以「想跳得久又不想跳太高」
## 只能降重力。取 0.5 时:跳高 0.230 m(1.53 身高)、滞空 0.61 s;
## 若维持 1.0,同样 0.23 m 的高度只有 0.43 s 滞空,落地明显仓促。
## 小个子角色若用真实 9.8,同样的起跳速度滞空短一半、跳起来像被拍回去 ——
## 降重力换滞空,跳跃才有「腾空感」。0.5 时:跳高 0.23 m、滞空 0.61 s。
##
## ⚠ 改这个值必须同步改 jump_velocity(跳跃高度 = v²/2g)。
## ⚠ 与 jump_velocity **配对**:改一个必须按反解公式重算另一个(跳跃高度 = v²/2g)。
## 反解公式 —— 定目标高度 h(米)与滞空 T(秒):
## g = 8h / T² → gravity_scale = g / 9.8
## jump_velocity = 4h / T
@@ -49,8 +47,12 @@ const JUMP_BUFFER_TIME: float = 0.1
## 那是全局的,会连带把拖拽/投掷的刚体一起变飘。
@export_range(0.0, 2.0, 0.01) var gravity_scale: float = 0.5
## 本帧是否在疾跑 / 是否按住蹲键。
## Player 读这两个值转交给 PlayerAnimator 切片段,crouching 还用来压低摄像机。
## 当前目标水平速度(米/秒)—— 由 HSM 状态在 _enter 时写入,step() 朝它加减速。
## Idle / Crouch 写 0(自然减速到停),Walk / Sprint / Sneak 写各自速度。
var target_speed: float = 0.0
## 是否蹲着 / 是否疾跑 —— 由 HSM 状态写入的**事实镜像**,本组件不再自己读输入推断:
## crouching 给 Look 压低摄像机,两者都给 DebugPanel 显示。
var sprinting: bool = false
var crouching: bool = false
@@ -73,6 +75,23 @@ func _unhandled_input(event: InputEvent) -> void:
_jump_buffer = JUMP_BUFFER_TIME
## 本帧是否按着方向键。HSM 状态(Idle/Walk 等的转换判定)与 step() 共用这个口径。
func has_move_input() -> bool:
return Input.get_vector("move_left", "move_right", "move_back", "move_forward") \
.length_squared() > 0.000001
## 由 HSM 地面状态每帧调用:跳跃缓冲还有效且脚下有地板,就当场起跳并返回 true。
## 起跳「动作」放在状态判定处执行(而不是 step() 里),
## 状态机同一帧就能拿到 vy > 0 这个事实、转进 Jump,动画不迟到一帧。
func try_jump() -> bool:
if _jump_buffer > 0.0 and _body != null and _body.is_on_floor():
_body.velocity.y = jump_velocity
_jump_buffer = 0.0
return true
return false
## 每物理帧由 Player 调用一次,必须排在 move_and_slide() 之前。
## 只写 _body.velocity,不做位移推进。
func step(delta: float) -> void:
@@ -87,12 +106,9 @@ func step(delta: float) -> void:
# gravity_scale 每帧现读,方便运行中拖着试手感(1.0 = 物理真实的 9.8)
_body.velocity.y -= _gravity * gravity_scale * delta
# --- 跳跃(读取输入缓冲)---
# --- 跳跃缓冲倒计时(执行挪到 try_jump(),由 HSM 地面状态调用)---
if _jump_buffer > 0.0:
_jump_buffer -= delta
if _body.is_on_floor():
_body.velocity.y = jump_velocity
_jump_buffer = 0.0
# --- 相对视线方向的水平输入 ---
# 只用 rig 的 basis(只有 yaw),低头看地时前进方向不会跟着往下扎。
@@ -103,16 +119,7 @@ func step(delta: float) -> void:
if wish.length_squared() > 0.000001:
wish = wish.normalized()
# 只有真正按了方向键才允许疾跑(避免原地"疾跑"触发跑动动画)
var wants_sprint := Input.is_action_pressed("sprint") and input_dir.length_squared() > 0.000001
crouching = Input.is_action_pressed("crouch")
# 蹲伏优先于疾跑:蹲着时按住 Shift 不算疾跑,速度取蹲行速度。
sprinting = wants_sprint and not crouching
var target_speed := walk_speed
if crouching:
target_speed = crouch_speed
elif sprinting:
target_speed = sprint_speed
# 目标速度来自 HSM 状态(target_speed),不在本组件里按蹲键/Shift 分档。
var accel := acceleration if _body.is_on_floor() else air_acceleration
var horizontal := Vector2(_body.velocity.x, _body.velocity.z)
+4
View File
@@ -0,0 +1,4 @@
# Sphinx build info version 1
# This file records the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 39b4a2a1961cc11b0ff4f34d2bc00a9a
tags: 33eac41acc08762151beb8f3b7b86c8f
@@ -0,0 +1,18 @@
# 2026-10-15
## LimboAI 文档汉化(index.html → index.zh.html)
- 目标:把 readthedocs 离线包(单页 Sphinx 文档,`index.html`,588KB,2190 个可翻译块)汉化为中文。
- 方案:块级抽取 + 占位符标记 + 批量人工翻译 + 回填重建。
- `_i18n/serialize.py`:把每个块(p/h1-h6/td/th/dt/dd/li)序列化为紧凑模板:
`<n/>`=不译的内联元素(code/img/abbr 等原样保留),`<lN>...</l>`=链接,`<b>`/`<i>`=加粗/斜体,其余内联标签透明化。
- `_i18n/make_batches.py`:按首次出现顺序去重并分配稳定 uid,切成 `batches/batchNN.txt`(11 批,1451 条唯一模板)。
- `_i18n/trans/batchNN.json`:uid → 中文模板(全部 11 批已完成)。
- `_i18n/build.py`:回填重建 + 校验(标记一致性、结构对比)、页脚/面包屑/abbr title/代码注释润色、侧栏汉化徽标。
- 关键实现坑(后续复用注意):
1. 片段回填必须用 `html.parser`,`lxml` 会把裸文本包成 `<p>` 破坏结构。
2. `<td>` 内含 `<p>` 的“容器块”要跳过重建,否则会拍平丢失内层 `<p>`(280 个,已用容器检测处理)。
3. 标题里的 `a.headerlink` 需在 clear() 前取出再回填,否则丢失 303 个标题锚点。
4. 校验只强制 `<n/>`、`<lN>`、`</l>` 必须一致,`<b>/<i>` 允许额外配对。
- 结果:`index.zh.html`(580KB),结构计数与原文件一致(section/p/li/a/code/pre/table/td 全部对齐),无英文残留(仅类名/方法名等标识符保留英文),45 处代码注释已译。
- 术语约定:Behavior Tree→行为树、State Machine→状态机、Blackboard→黑板(类名保留)、Inherits:→继承:、Inherited By:→被继承:、Properties→属性、Method Descriptions→方法说明;所有 API/类名/节点名/文件名保留英文。
@@ -0,0 +1,87 @@
<1> LimboAI Documentation
<2> <b>LimboAI</b> is an open-source C++ module for <b>Godot Engine 4</b> providing a combination of <b>Behavior Trees</b> and <b>State Machines</b> for crafting your game’s AI. It comes with a behavior tree editor, built-in documentation, visual debugger, and more! While it is implemented in C++, it fully supports GDScript for <l1>creating your own tasks</l> and states. The full list of features is available on the <l2>LimboAI GitHub</l> page.
<3> <b>Behavior Trees</b> are powerful hierarchical structures used to model and control the behavior of agents in a game (e.g., characters, enemies, entities). They are designed to make it easier to create complex and highly modular behaviors for your games. To learn more about behavior trees, check out <l1>Introduction to Behavior Trees</l>.
<4> <b>Hierarchical State Machines</b> are finite state machines that allow any state to host their own sub-state machine. This allows you to tackle your AI’s state and transition complexity by breaking down one big state machine into multiple smaller ones.
<5> Getting LimboAI
<6> LimboAI can be used as either a C++ module or as a GDExtension shared library. There are some differences between the two. In short, GDExtension version is more convenient to use but somewhat limited in features. The module version provides better editor experience and is slightly more performant, but it requires using custom engine builds including the export templates. Whichever you choose to use, your project will stay compatible with both and you can switch from one to the other any time.
<7> Choose the version you’d like to use. If you’re unsure, start with the GDExtension version. You can change your decision at any time - both versions are fully compatible.
<8> Get GDExtension version
<9> Precompiled builds are available on the official <l1>LimboAI GitHub</l> page, and in the Asset Library.
<10> GDExtension is the most convenient way of using the LimboAI plugin, but it comes with certain limitations:
<11> Documentation tooltips are not available.
<12> Handy <l1>BBParam</l> property editor is not available in the extension due to dependencies on the engine classes that are not available in the Godot API.
<13> <b>🛈 See also:</b> <l1>What is GDExtension?</l>
<14> Installation instructions:
<15> Make sure you’re using the latest stable version of the Godot editor.
<16> Create a new project for your experiments with LimboAI.
<17> In Godot, click AssetLib tab at the top of the screen and search for LimboAI. Download it. LimboAI plugin will be downloaded with the demo project files. Don’t mind the errors printed at this point, this is due to the extension library not being loaded just yet.
<18> Reload your project with Project -> Reload project. There shouldn’t be any errors printed now.
<19> In the project files, locate a scene file called showcase.tscn and run it. It’s the demo’s entry point.
<20> Get module version
<21> Precompiled builds are available on the official <l1>LimboAI GitHub</l> page.
<22> In <l1>GitHub releases</l>, download the latest pre-compiled release build for your platform.
<23> Download the demo project archive from the same release.
<24> Extract the pre-compiled editor and the demo project files.
<25> Launch the pre-compiled editor binary, import and open the demo project.
<26> Run the project.
<27> <b>Important</b>: To export your game using the module version of LimboAI, make sure to use the pre-compiled export templates included in the same GitHub release build.
<28> Using LimboAI with C#
<29> Here’s how you can use the module version with C# to write your tasks and states.
<30> Locate LimboAI NuGet package files.
<31> Each provided build comes with a GodotSharp folder, which has packages under “GodotSharp/Tools/nupkgs/”. Note down the directory path; you’ll need it in the next step.
<32> Add a local source for NuGet packages using the following command:
<33> Your C# project should be able to see LimboAI classes and compile.
<34> Regarding GDExtension, I can only confirm success with the module version and C#. Unfortunately, I haven’t had any luck with the GDExtension version yet. If you’ve had success with GDExtension, please let me know via Discord or email.
<35> Contributing
<36> We target the latest stable version of the Godot Engine for development until a third beta or a release candidate of an upcoming Godot release becomes available. If you want to contribute to the project, please ensure that you are using the corresponding Godot version.
<37> We follow the <l1>Godot code style guidelines</l>. Please use <2/> to maintain consistent styling. You can install <3/> hooks for Git using <4/> to automate this process.
<38> Please keep the minor versions backward-compatible when submitting pull requests.
<39> We support building LimboAI as a module for the Godot Engine and as a GDExtension library. Make sure your contribution is compatible with both. Our CI workflow will verify that your changes can be compiled in both configurations.
<40> Compiling as module of the Godot Engine
<41> Clone the Godot Engine repository.
<42> Switch to the latest stable tag.
<43> Clone the LimboAI repository into the <1/> directory.
<44> Consult the <l1>Godot Engine documentation</l> for detailed instructions on building the engine.
<45> <b>Unit tests</b> can be compiled using the <1/> build option. To execute them, run the compiled Godot binary with the <2/> command-line options.
<46> Building C# Packages
<47> If you want C# Support for LimboAI when building from source, follow Godot’s instructions for building from source with C# Support: <l1>Compiling with .NET</l>. LimboAI’s classes will be included in the output from that build process.
<48> Compiling as GDExtension library
<49> You’ll need the SCons build tool and a C++ compiler. See also <l1>Compiling</l>. Run <2/> to build the plugin library for your current platform.
<50> SCons will automatically clone the <1/> repository if it doesn’t already exist in the <2/> directory.
<51> By default, built targets are placed in the demo project: <1/>.
<52> Check <1/> for other options and targets.
<53> Contributing to the documentation
<54> Online documentation is created using <l1>Sphinx</l>. Source files are located in the <2/> directory in RST format and can be built locally with <3/>. See the <l4>Sphinx documentation</l> for more details.
<55> Class documentation resides in XML files within the <1/> directory. If you create a new class or modify an existing one, you can run the compiled Godot binary with the <2/> option in the root of the Godot source code to generate the missing XML files or sections within those files in the class documentation. After editing the XML files, please run the compiled editor binary with the <3/> option again to update and tidy up the XML files.
<56> Sphinx RST files for the class documentation are generated from XML files using the Godot script <1/> and stored in the <2/> directory. This process is performed using our own script <3/>. RST files in <4/> should not be edited manually.
<57> Introduction to Behavior Trees
<58> <b>🛈 Note:</b> Demo project includes a tutorial that provides an introduction to behavior trees through illustrative examples.
<59> <b>Behavior Trees (BT)</b> are hierarchical structures used to model and control the behavior of agents in a game (e.g., characters, enemies, entities). They are designed to make it easier to create complex and highly modular behaviors for your games.
<60> Behavior Trees are composed of tasks that represent specific actions or decision-making rules. Tasks can be broadly categorized into two main types: control tasks and leaf tasks. <b>Control tasks</b> determine the execution flow within the tree. They include <l1>Sequence</l>, <l2>Selector</l>, and <l3>Invert</l>. <b>Leaf tasks</b> represent specific actions to perform, like moving or attacking, or conditions that need to be checked. The <l4>BTTask</l> class provides the foundation for various building blocks of the Behavior Trees. Such tasks can <l5>share data using the Blackboard</l>.
<61> <b>🛈 Note:</b> To <l1>create your own actions</l>, extend the <l2>BTAction</l> class.
<62> A Behavior Tree is usually processed each frame. It is traversed from top to bottom, with the control tasks determining the control flow. Each task has a <l1>_tick</l> method, which performs the task’s work and returns a status indicating its progress: <2/>, <3/>, or <4/>. <5/> and <6/> indicate the outcome of finished work, while <7/> status is returned when a task requires more than one tick to complete its job. These statuses determine how the tree progresses, with the <8/> status usually meaning that the tree will continue execution during the next frame.
<63> There are <i>four types of tasks</i>:
<64> <b>Actions</b> are leaf tasks that perform the actual work.
<65> Examples: <l1>PlayAnimation</l>, <l2>Wait</l>.
<66> <b>Conditions</b> are leaf tasks that conduct various checks.
<67> Examples: <l1>CheckVar</l>, <l2>InRange</l>.
<68> <b>Composites</b> can have one or more child tasks, and dictate the execution flow of their children.
<69> Examples: <l1>Sequence</l>, <l2>Selector</l>, <l3>Parallel</l>.
<70> <b>Decorators</b> can only have a single child and they change how their child task operates.
<71> Examples: <l1>AlwaysSucceed</l>, <l2>Invert</l>, <l3>TimeLimit</l>.
<72> <l1>Sequence</l> is one of the core composite tasks. It executes its child tasks sequentially, from first to last, until one of them returns <2/>, or all of them result in <3/>. In other words, if any child task results in <4/>, the <l5>Sequence</l> execution will be aborted, and the <l6>Sequence</l> itself will return <7/>.
<73> <l1>Selector</l> is another essential composite task. It executes its child tasks sequentially, from first to last, until one of them returns <2/> or all of them result in <3/>. In other words, when a child task results in <4/>, it moves on to the next one until it finds the one that returns <5/>. Once a child task results in <6/>, the <l7>Selector</l> stops and also returns <8/>. The purpose of the <l9>Selector</l> is to find a child that succeeds.
<74> Behavior Trees handle conditional logic using <b>condition tasks</b>. These tasks check for specific conditions and return either <1/> or <2/> based on the state of the agent or its environment (e.g., “IsLowOnHealth”, “IsTargetInSight”). Conditions can be used together with <l3>Sequence</l> and <l4>Selector</l> to craft your decision-making logic.
<75> <b>🛈 Note:</b> To <l1>create your own conditions</l>, extend the <l2>BTCondition</l> class.
<76> Check out the <l1>BTTask</l> class documentation, which provides the foundation for various building blocks of Behavior Trees.
<77> Creating Behavior Trees
<78> This chapter describes how to create and debug behavior trees.
<79> Add a Behavior Tree to an agent
<80> Follow these steps to add a behavior tree to a new or existing agent:
<81> Make a scene file for your agent, or open an existing scene.
<82> Add a <l1>BTPlayer</l> node to your scene.
<83> Select <l1>BTPlayer</l>, and create a new behavior tree in the inspector.
<84> Optionally, you can save the behavior tree to a file using the property’s context menu.
<85> Click the behavior tree property to open it in the LimboAI editor.
<86> Debugging Behavior Trees
<87> In Godot Engine, follow to “Bottom Panel > Debugger > LimboAI” tab. With the LimboAI debugger, you can inspect any currently active behavior tree within the running project. The debugger can be detached from the main editor window, which can be particularly useful if you have a HiDPI or a secondary display.
@@ -0,0 +1,92 @@
<88> Inspecting task properties and blackboard
<89> When you click on a task in the LimboAI debugger, its properties are shown in the remote Inspector panel, including the <l1>Blackboard</l> reference. Click the <l2>Blackboard</l> to open it and view all variables across the full scope chain, grouped by scope. You can also edit variable values live, which can be useful for testing and debugging your game’s AI.
<90> Creating custom tasks
<91> By default, user tasks should be placed in the <1/> directory. You can set an alternative location for user tasks in the <2/> (To see those options, <3/> should be enabled in the Project Settings).
<92> Each subdirectory within the user tasks directory is treated as a category. Therefore, if you create a subdirectory named “motion_and_physics,” your custom tasks in that directory will automatically be categorized under “Motion And Physics.”
<93> When creating custom tasks, <b>extend one of the following</b> base classes: <l1>BTAction</l>, <l2>BTCondition</l>, <l3>BTDecorator</l>, <l4>BTComposite</l>. More on task types you can read in the <l5>Introduction to Behavior Trees</l>.
<94> <b>🛈 Note:</b> To help you write new tasks, you can add a script template to your project using “Misc → Create script template” menu option.
<95> Using the <l1>Blackboard</l> is covered in <l2>Accessing the Blackboard in a Task</l>.
<96> Task anatomy
<97> Example 1: A simple action
<98> Example 2: InRange condition
<99> Creating tasks in C#
<100> You can use the following script template for custom tasks:
<101> Sharing data using Blackboard
<102> To share data between different tasks and states, we employ a feature known as the <l1>Blackboard</l>. The <l2>Blackboard</l> serves as a central repository where tasks and states can store and retrieve named variables, allowing for seamless data interchange. Each instance of a behavior tree or a state machine gets its own dedicated <l3>Blackboard</l>. It has the capability to store various data types, including objects and resources.
<103> Using the <l1>Blackboard</l>, you can easily share data in your behavior trees, making the tasks in the behavior tree more flexible.
<104> Accessing the Blackboard in a Task
<105> Every <l1>BTTask</l> has access to the <l2>Blackboard</l>, providing a straightforward mechanism for data exchange. Here’s an example of how you can interact with the <l3>Blackboard</l> in GDScript:
<106> If you are accessing a variable that holds an object instance, and it is expected that the instance may be null or freed, you can do it like this:
<107> It is recommended to suffix variable name properties with <1/>, like in the example above, which enables the inspector to provide a more convenient property editor for the variable. This editor allows you to select or add the variable to the blackboard plan, and provides a warning icon if the variable does not exist in the blackboard plan.
<108> <b>🛈 Note:</b> The variable doesn’t need to exist when you set it in code.
<109> Editing the Blackboard Plan
<110> The Blackboard Plan, associated with each <l1>BehaviorTree</l> resource, dictates how the <l2>Blackboard</l> initializes for each new instance of the <l3>BehaviorTree</l>. BlackboardPlan resource stores default values, type information, and data bindings necessary for <l4>BehaviorTree</l> initialization.
<111> To add, modify, or remove variables from the Blackboard Plan, follow these steps:
<112> Open the LimboAI editor and load the behavior tree you want to edit.
<113> In the editor, click on the small button located inside the tab. This will open the <l1>BlackboardPlan</l> in the Inspector.
<114> In the Inspector, click the “Manage…” button to show the blackboard plan editor.
<115> In the blackboard plan editor, you can add, remove, or reorder variables, and modify their data type and hint.
<116> The hint provides additional information about the variable to the Inspector, such as minimum and maximum values for an integer variable. Learn more about <l1>property hints in the official Godot documentation</l>.
<117> You can specify the default values of the variables directly in the Inspector.
<118> Overriding variables in BTPlayer
<119> Each <l1>BTPlayer</l> node also has a “Blackboard Plan” property, providing the ability to override values of the BehaviorTree’s blackboard variables. These overrides are specific to the BTPlayer’s scene and do not impact other scenes using the same <l2>BehaviorTree</l>. To modify these values:
<120> Select the BTPlayer node in the scene tree.
<121> In the Inspector, locate the “Blackboard Plan” property.
<122> Override the desired values to tailor the blackboard variables for the specific scene.
<123> Task parameters
<124> In some cases, it can be beneficial to allow behavior tree tasks to export parameters that can either be <b>bound to a blackboard variable or specified directly</b> by the user. For this purpose, LimboAI provides special parameter types that begin with “BB”, such as <l1>BBInt</l>, <l2>BBBool</l>, <l3>BBString</l>, <l4>BBFloat</l>, <l5>BBNode</l>, and more. For a complete list, please refer to the <l6>BBParam</l> class reference.
<125> Usage example:
<126> Connecting variables in BTs to HSMs: Variable mapping
<127> Each <l1>BTState</l> creates a new blackboard scope for its BehaviorTree instance. Because BehaviorTrees are reusable resources that can run in different contexts or on different agents, they do not automatically see variables defined in the HSM. At runtime, HSM variables are usually accessible inside the BT via the parent scope blackboard, but the recommended way to access them is through <b>mapping</b>.
<128> Note
<129> To learn more about scopes, see <l1>Blackboard</l>.
<130> When both the BehaviorTree and the HSM declare variables in their respective <l1>BlackboardPlan</l> resources, the editor provides a <b>Mapping</b> section in the BlackboardPlan inspector. Mapping is the intended and recommended way to connect variables between related plans.
<131> Key points about mapping
<132> Mapping connects two variables so they behave as a single logical variable at runtime.
<133> Linked variables are updated immediately in both scopes — they literally share the same state in memory (no polling or copying).
<134> Mapping is explicit: you decide which variables the BT exposes as inputs and outputs.
<135> Linkage is bidirectional — there is no distinction between input and output.
<136> Mapping does not create new variables automatically — the variables must already exist in both blackboard plans before they can be linked.
<137> Inspector workflow
<138> Define the required variables in each BlackboardPlan (HSM plan and BT plan, for example).
<139> Select the <l1>BTState</l> node in the scene or a <l2>BTSubtree</l> inside a behavior tree.
<140> In the Inspector, select the Blackboard Plan property.
<141> In the BlackboardPlan resource inspector, locate the <b>Mapping</b> section.
<142> Create mappings by pairing variables (<1/>).
<143> On startup the mappings are applied, and the variables are automatically linked for the running instance.
<144> Programmatic alternative: linking variables in code
<145> You can also link variables directly in code using <l1>Blackboard.link_var</l>:
<146> Best practices
<147> Declare all BT dependencies in the BT’s BlackboardPlan instead of relying on parent scopes.
<148> Use <b>Mapping</b> to explicitly declare which variables should be shared with the HSM.
<149> Prefer the inspector-based Mapping workflow for clarity and editor support; use <1/> only when runtime or programmatic setup is required.
<150> Avoid writing to <1/> from inside BT tasks unless you have a very specific reason and accept the coupling it introduces.
<151> Advanced topic: Blackboard scopes
<152> The <l1>Blackboard</l> in LimboAI can act as a parent scope for another <l2>Blackboard</l>. This means that if a specific variable is not found in the active scope, the system will look in the parent <l3>Blackboard</l> to find it. This creates a “blackboard scope chain,” where each <l4>Blackboard</l> can have its own parent scope, and there is no limit to how many blackboards can be in this chain. It’s important to note that the <l5>Blackboard</l> doesn’t modify values in the parent scopes.
<153> Scopes are created automatically to prevent naming collisions between contextually separate environments:
<154> Within <l1>BTNewScope</l>.
<155> Under <l1>BTSubtree</l> decorators.
<156> With <l1>LimboState</l> that have a non-empty blackboard plan defined.
<157> Under <l1>LimboHSM</l> nodes: A new scope is created at the root level, and each <l2>BTState</l> child also receives its own separate scope.
<158> Sharing data between several agents
<159> The blackboard scope mechanism can also be used for sharing data between several agents. In the following example, we have a group of agents, and we want to share a common target between them:
<160> In this example, <l1>blackboard.top()</l> accesses the root scope of the <l2>Blackboard</l> chain. We assign that scope to each agent in a group through code:
<161> In conclusion, the <l1>Blackboard</l> scope chain not only prevents naming conflicts that can occur between state machines, behavior trees, and sub-trees, but it can also be used to share data between several agents.
<162> Debugging blackboard variables
<163> When debugging a running project, you can inspect <l1>Blackboard</l> variables in the remote Inspector. Click on a task in the LimboAI debugger to view its properties, then click the <l2>Blackboard</l> reference to see all variables across the full scope chain, grouped by scope. Variables can be edited live to aid in debugging. For state machines, select a <l3>LimboState</l> node in the Remote Scene Tree to access its <l4>Blackboard</l> in the same way.
<164> Accessing nodes in the scene tree
<165> There are several ways to access nodes in the agent’s scene tree from a <l1>BTTask</l>.
<166> <b>🛈 Note:</b> The root node of the agent’s scene tree can be accessed with the <l1>scene_root</l> property.
<167> With <1/> property
<168> With <1/> property
<169> Using blackboard plan
<170> You can <l1>create a blackboard variable</l> in the editor with the type <2/> and point it to the proper node in the <l3>BTPlayer</l> blackboard plan. By default, any <4/> variable will be replaced with the node instance when the blackboard is instantiated at runtime (see <l5>BlackboardPlan.prefetch_nodepath_vars</l>).
<171> The property BTPlayer.prefetch_nodepath_vars should be set to <1/>.
<172> Create State Machines
<173> This guide will show how to set up and use a state machine using <l1>LimboHSM</l>.
<174> Initialization
<175> To use the <l1>LimboHSM</l> state machine, you first need to initialize it in your code. This is typically done in the <2/> function of your script. Here’s an example of how to do this:
<176> In this example, we first declare the state machine and the states we want to use. Then, in the <1/> function, we add transitions between the states. Finally, we initialize the state machine and set it to active.
<177> State example
<178> You can define the behavior of a state in a script and attach it to the state node. Here’s an example of a state that plays an animation on an <1/> and waits for it to finish:
<179> In this example, the <1/> method is called when the state is entered, and it plays the specified animation. The <2/> method is called every frame, and it checks if the animation is finished. If it is, it dispatches the <3/> event, which can result in a transition to the next state.
@@ -0,0 +1,142 @@
<180> Events and transitions
<181> The <l1>LimboHSM</l> comes with an <b>event system</b> that helps to <b>decouple transitions</b> from the state implementations. A transition is associated with a specific event, a starting state, and a destination state, and it is performed automatically when such an event is dispatched.
<182> To register a transition and associate it with a specific event, you can use the <l1>LimboHSM.add_transition</l> method:
<183> In this example, we’re registering a transition from the <1/> to the <2/> when the <3/> event is dispatched.
<184> A transition can be also associated with no particular starting state:
<185> <b>Events are dispatched</b> with the <l1>LimboState.dispatch</l> method. It’s important to note that this method can be called from anywhere in the state machine hierarchy and outside of it. Events are <b>propagated from the leaf to the root</b> state. This means that if an event is consumed by a state, it won’t be propagated to its parent states.
<186> States can also define <b>event handlers</b>, which are methods that react to specific events. These event handlers typically don’t result in a state transition; they are simply a mechanism for states to react to particular events. You can use the <l1>LimboState.add_event_handler</l> method to register event handlers in your states:
<187> If the event handler returns <1/>, the event will be considered as consumed, and it won’t propagate further or result in a state transition.
<188> State anatomy
<189> Using behavior trees with state machines
<190> The <l1>BTState</l> is a specialized state node in <l2>LimboHSM</l> that can host a behavior tree. When a <l3>BTState</l> is active, it executes the hosted behavior tree each frame, effectively using the behavior tree as its implementation.
<191> This allows you to combine the power of behavior trees with the structure and control of state machines. Behavior trees are excellent for defining complex, hierarchical behaviors, while state machines are great for managing the flow and transitions between different behaviors.
<192> Once the <l1>LimboHSM</l> has been created, a <l2>BTState</l> can be created for a given <l3>LimboHSM</l>:
<193> Single-file state machine setup
<194> In certain scenarios, such as prototyping or during game jams, it’s practical to keep the state machine code in a single file. For such cases, <l1>LimboHSM</l> <b>supports delegation</b> and provides <b>chained methods</b> for easier setup. Let’s illustrate this with a practical code example:
<195> Important classes
<196> There are a lot of classes introduced in LimboAI. Here is a list of the most important ones you should know about:
<197> BTTask
<198> <b>Inherits:</b> <l1>BT</l>
<199> <b>Inherited By:</b> <l1>BTAction</l>, <l2>BTComment</l>, <l3>BTComposite</l>, <l4>BTCondition</l>, <l5>BTDecorator</l>
<200> Base class for all <l1>BehaviorTree</l> tasks.
<201> Description
<202> Base class for all <l1>BehaviorTree</l> tasks. A task is a basic building block in a <l2>BehaviorTree</l> that represents a specific behavior or control flow. Tasks are used to create complex behaviors by combining and nesting them in a hierarchy.
<203> A task can be one of the following types: action, condition, composite, or decorator. Each type of task has its own corresponding subclass: <l1>BTAction</l>, <l2>BTCondition</l>, <l3>BTDecorator</l>, <l4>BTComposite</l>.
<204> Tasks perform their work and return their status using the <l1>_tick</l> method. Status values are defined in <l2>Status</l>. Tasks can be initialized using the <l3>_setup</l> method. See also <l4>_enter</l> & <l5>_exit</l>.
<205> <b>Note:</b> Do not extend <b>BTTask</b> directly for your own tasks. Instead, extend one of the subtypes mentioned above.
<206> Properties
<207> <l1>agent</l>
<208> <l1>Blackboard</l>
<209> <l1>blackboard</l>
<210> <l1>custom_name</l>
<211> <l1>elapsed_time</l>
<212> <l1>scene_root</l>
<213> <l1>Status</l>
<214> <l1>status</l>
<215> Methods
<216> <l1>_enter</l>() <2/>
<217> <l1>_exit</l>() <2/>
<218> <l1>_generate_name</l>() <2/> <3/>
<219> <l1>_get_configuration_warnings</l>() <2/> <3/>
<220> <l1>_setup</l>() <2/>
<221> <l1>_tick</l>(delta: <2/>) <3/>
<222> <l1>abort</l>()
<223> <l1>add_child</l>(task: <l2>BTTask</l>)
<224> <l1>add_child_at_index</l>(task: <l2>BTTask</l>, idx: <3/>)
<225> <l1>BTTask</l>
<226> <l1>clone</l>() <2/>
<227> <l1>BehaviorTree</l>
<228> <l1>editor_get_behavior_tree</l>()
<229> <l1>execute</l>(delta: <2/>)
<230> <l1>get_child</l>(idx: <2/>) <3/>
<231> <l1>get_child_count</l>() <2/>
<232> <l1>get_child_count_excluding_comments</l>() <2/>
<233> <l1>get_index</l>() <2/>
<234> <l1>get_parent</l>() <2/>
<235> <l1>get_root</l>() <2/>
<236> <l1>get_task_name</l>()
<237> <l1>has_child</l>(task: <l2>BTTask</l>) <3/>
<238> <l1>initialize</l>(agent: <2/>, blackboard: <l3>Blackboard</l>, scene_root: <4/>)
<239> <l1>is_descendant_of</l>(task: <l2>BTTask</l>) <3/>
<240> <l1>is_root</l>() <2/>
<241> <l1>next_sibling</l>() <2/>
<242> <l1>print_tree</l>(initial_tabs: <2/> = 0)
<243> <l1>remove_child</l>(task: <l2>BTTask</l>)
<244> <l1>remove_child_at_index</l>(idx: <2/>)
<245> Property Descriptions
<246> <1/> <b>agent</b> <l2>🔗</l>
<247> <1/> <b>set_agent</b>(value: <2/>)
<248> <1/> <b>get_agent</b>()
<249> The agent is the contextual object for the <l1>BehaviorTree</l> instance. This is usually the parent of the <l2>BTPlayer</l> node that utilizes the <l3>BehaviorTree</l> resource.
<250> <l1>Blackboard</l> <b>blackboard</b> <l2>🔗</l>
<251> <l1>Blackboard</l> <b>get_blackboard</b>()
<252> Provides access to the <l1>Blackboard</l>. Blackboard is used to share data among tasks of the associated <l2>BehaviorTree</l>.
<253> See <l1>Blackboard</l> for additional info.
<254> <1/> <b>custom_name</b> = <2/> <l3>🔗</l>
<255> <1/> <b>set_custom_name</b>(value: <2/>)
<256> <1/> <b>get_custom_name</b>()
<257> User-provided name for the task. If not empty, it is used by the editor to represent the task. See <l1>get_task_name</l>.
<258> <1/> <b>elapsed_time</b> <l2>🔗</l>
<259> <1/> <b>get_elapsed_time</b>()
<260> Elapsed time since the task was “entered”. See <l1>_enter</l>.
<261> Returns <1/> when task is not <2/>.
<262> <1/> <b>scene_root</b> <l2>🔗</l>
<263> <1/> <b>get_scene_root</b>()
<264> Root node of the scene the behavior tree is used in (e.g., the owner of the <l1>BTPlayer</l> node). Can be uses to retrieve <2/> references.
<265> <b>Example:</b>
<266> <l1>Status</l> <b>status</b> <l2>🔗</l>
<267> <l1>Status</l> <b>get_status</b>()
<268> Last execution <l1>Status</l> returned by <l2>_tick</l>.
<269> Method Descriptions
<270> <1/> <b>_enter</b>() <2/> <l3>🔗</l>
<271> Called when task is “entered”, i.e. when task is executed while not having a <1/> <l2>status</l>.
<272> It is called before <l1>_tick</l> in the execution order. This method is used when preparation is needed before main work begins, usually when it takes more than one tick to finish the task. See also <l2>execute</l>.
<273> <1/> <b>_exit</b>() <2/> <l3>🔗</l>
<274> Called when task is “exited”, i.e. after <l1>_tick</l> returns <2/> or <3/> status. See also <l4>execute</l>.
<275> <1/> <b>_generate_name</b>() <2/> <3/> <l4>🔗</l>
<276> Called to generate a display name for the task unless <l1>custom_name</l> is set. See <l2>get_task_name</l>.
<277> <1/> <b>_get_configuration_warnings</b>() <2/> <3/> <l4>🔗</l>
<278> The string returned by this method is shown as a warning message in the behavior tree editor. Any task script that overrides this method must include <1/> annotation at the top of the file.
<279> <1/> <b>_setup</b>() <2/> <l3>🔗</l>
<280> Called to initialize a task during initialization step. It is called only once before the task’s first execution tick. This method allows you to set up any necessary state or configurations for the task before it begins executing.
<281> <l1>Status</l> <b>_tick</b>(delta: <2/>) <3/> <l4>🔗</l>
<282> Called when task is “ticked”, i.e. executed by <l1>BTPlayer</l> or <l2>BTState</l> during an update.
<283> Returns execution status as defined in <l1>Status</l>.
<284> <b>Note:</b> Tasks perform their main function by implementing this method.
<285> <1/> <b>abort</b>() <l2>🔗</l>
<286> Resets the task and its children recursively. If a task is in the <1/> state, it is exited and its status is reset to <2/>.
<287> <1/> <b>add_child</b>(task: <l2>BTTask</l>) <l3>🔗</l>
<288> Adds a child task. The <1/> is placed at the end of the children list.
<289> <1/> <b>add_child_at_index</b>(task: <l2>BTTask</l>, idx: <3/>) <l4>🔗</l>
<290> Adds a child task. The <1/> is placed at <2/> position in the children list.
<291> <l1>BTTask</l> <b>clone</b>() <2/> <l3>🔗</l>
<292> Duplicates the task and its children, copying the exported members. Sub-resources are shared for efficiency, except for <l1>BBParam</l> subtypes, which are always copied. Used by the editor to instantiate <l2>BehaviorTree</l> and copy-paste tasks.
<293> <l1>BehaviorTree</l> <b>editor_get_behavior_tree</b>() <l2>🔗</l>
<294> Returns the behavior tree that owns this task. This is only available in the editor.
<295> <l1>Status</l> <b>execute</b>(delta: <2/>) <l3>🔗</l>
<296> Performs task’s execution. The execution follows a specific sequence:
<297> If task’s current <l1>status</l> is not <2/>, the <l3>_enter</l> method is called first.
<298> Next, the <l1>_tick</l> method is called next to perform the task’s work.
<299> If the <l1>_tick</l> method returns <2/> or <3/> status, the <l4>_exit</l> method will be called next as part of the execution cleanup.
<300> <l1>BTTask</l> <b>get_child</b>(idx: <2/>) <3/> <l4>🔗</l>
<301> Returns a child task by specifying its index.
<302> <1/> <b>get_child_count</b>() <2/> <l3>🔗</l>
<303> Returns the number of child tasks.
<304> <1/> <b>get_child_count_excluding_comments</b>() <2/> <l3>🔗</l>
<305> Returns the number of child tasks not counting <l1>BTComment</l> tasks.
<306> <1/> <b>get_index</b>() <2/> <l3>🔗</l>
<307> Returns the task’s position in the behavior tree branch. Returns <1/> if the task doesn’t belong to a task tree, i.e. doesn’t have a parent.
<308> <l1>BTTask</l> <b>get_parent</b>() <2/> <l3>🔗</l>
<309> Returns the task’s parent.
<310> <l1>BTTask</l> <b>get_root</b>() <2/> <l3>🔗</l>
<311> Returns the root task of the behavior tree.
<312> <1/> <b>get_task_name</b>() <l2>🔗</l>
<313> The string returned by this method is used to represent the task in the editor.
<314> Method <l1>_generate_name</l> is called to generate a display name for the task unless <l2>custom_name</l> is set.
<315> <1/> <b>has_child</b>(task: <l2>BTTask</l>) <3/> <l4>🔗</l>
<316> Returns <1/> if <2/> is a child of this task.
<317> <1/> <b>initialize</b>(agent: <2/>, blackboard: <l3>Blackboard</l>, scene_root: <4/>) <l5>🔗</l>
<318> Initilizes the task. Assigns <l1>agent</l> and <l2>blackboard</l>, and calls <l3>_setup</l> for the task and its children.
<319> The method is called recursively for each child task. <1/> should be the root node of the scene the behavior tree is used in (e.g., the owner of the node that contains the behavior tree).
<320> <1/> <b>is_descendant_of</b>(task: <l2>BTTask</l>) <3/> <l4>🔗</l>
<321> Returns <1/> if this task is a descendant of <2/>. In other words, this task must be a child of <3/> or one of its children or grandchildren.
@@ -0,0 +1,138 @@
<322> <1/> <b>is_root</b>() <2/> <l3>🔗</l>
<323> Returns <1/> if this task is the root task of its behavior tree. A behavior tree can have only one root task.
<324> <l1>BTTask</l> <b>next_sibling</b>() <2/> <l3>🔗</l>
<325> Returns the next task after this task in the parent’s children list.
<326> Returns <1/> if this task has no parent or it is the last child in the parent’s children list.
<327> <1/> <b>print_tree</b>(initial_tabs: <2/> = 0) <l3>🔗</l>
<328> Prints the subtree that starts with this task to the console.
<329> <1/> <b>remove_child</b>(task: <l2>BTTask</l>) <l3>🔗</l>
<330> Removes <1/> from children.
<331> <1/> <b>remove_child_at_index</b>(idx: <2/>) <l3>🔗</l>
<332> Removes a child task at a specified index from children.
<333> BTPlayer
<334> <b>Inherits:</b>
<335> Player of <l1>BehaviorTree</l> resources.
<336> <b>BTPlayer</b> node is used to instantiate and play <l1>BehaviorTree</l> resources at runtime. During initialization, the behavior tree instance is given references to the agent, the <l2>blackboard</l>, and the current scene root. The agent can be specified by the <l3>agent_node</l> property (defaults to the BTPlayer’s parent node).
<337> For an introduction to behavior trees, see <l1>BehaviorTree</l>.
<338> <l1>active</l>
<339> <l1>agent_node</l>
<340> <l1>behavior_tree</l>
<341> <l1>blackboard</l>
<342> <l1>BlackboardPlan</l>
<343> <l1>blackboard_plan</l>
<344> <l1>monitor_performance</l>
<345> <l1>UpdateMode</l>
<346> <l1>update_mode</l>
<347> <l1>BTInstance</l>
<348> <l1>get_bt_instance</l>()
<349> <l1>restart</l>()
<350> <l1>set_bt_instance</l>(bt_instance: <l2>BTInstance</l>)
<351> <l1>set_scene_root_hint</l>(scene_root: <2/>)
<352> <l1>update</l>(delta: <2/>)
<353> Signals
<354> <b>behavior_tree_finished</b>(status: <1/>) <l2>🔗</l>
<355> <b>Deprecated:</b> Use <l1>updated</l> signal instead.
<356> Emitted when the behavior tree has finished executing and returned <1/> or <2/>.
<357> Argument <1/> holds the status returned by the behavior tree. See <l2>Status</l>.
<358> <b>updated</b>(status: <1/>) <l2>🔗</l>
<359> Emitted when BTPlayer has finished the behavior tree update.
<360> Enumerations
<361> enum <b>UpdateMode</b>: <l1>🔗</l>
<362> <l1>UpdateMode</l> <b>IDLE</b> = <2/>
<363> Execute behavior tree during the idle process.
<364> <l1>UpdateMode</l> <b>PHYSICS</b> = <2/>
<365> Execute behavior tree during the physics process.
<366> <l1>UpdateMode</l> <b>MANUAL</b> = <2/>
<367> Behavior tree is executed manually by calling <l1>update</l>.
<368> <1/> <b>active</b> = <2/> <l3>🔗</l>
<369> <1/> <b>set_active</b>(value: <2/>)
<370> <1/> <b>get_active</b>()
<371> If <1/>, the behavior tree will be executed during update.
<372> <1/> <b>agent_node</b> = <2/> <l3>🔗</l>
<373> <1/> <b>set_agent_node</b>(value: <2/>)
<374> <1/> <b>get_agent_node</b>()
<375> Path to the node that will be used as the agent. Setting it after instantiation will have no effect.
<376> <l1>BehaviorTree</l> <b>behavior_tree</b> <l2>🔗</l>
<377> <1/> <b>set_behavior_tree</b>(value: <l2>BehaviorTree</l>)
<378> <l1>BehaviorTree</l> <b>get_behavior_tree</b>()
<379> <l1>BehaviorTree</l> resource to instantiate and execute at runtime.
<380> <l1>Blackboard</l> <b>blackboard</b> <l2>🔗</l>
<381> <1/> <b>set_blackboard</b>(value: <l2>Blackboard</l>)
<382> Holds data shared by the behavior tree tasks. See <l1>Blackboard</l>.
<383> <l1>BlackboardPlan</l> <b>blackboard_plan</b> <l2>🔗</l>
<384> <1/> <b>set_blackboard_plan</b>(value: <l2>BlackboardPlan</l>)
<385> <l1>BlackboardPlan</l> <b>get_blackboard_plan</b>()
<386> Stores and manages variables that will be used in constructing new <l1>Blackboard</l> instances.
<387> <1/> <b>monitor_performance</b> = <2/> <l3>🔗</l>
<388> <1/> <b>set_monitor_performance</b>(value: <2/>)
<389> <1/> <b>get_monitor_performance</b>()
<390> If <1/>, adds a performance monitor to “Debugger->Monitors” for each instance of this <b>BTPlayer</b> node.
<391> <l1>UpdateMode</l> <b>update_mode</b> = <2/> <l3>🔗</l>
<392> <1/> <b>set_update_mode</b>(value: <l2>UpdateMode</l>)
<393> <l1>UpdateMode</l> <b>get_update_mode</b>()
<394> Determines when the behavior tree is executed. See <l1>UpdateMode</l>.
<395> <l1>BTInstance</l> <b>get_bt_instance</b>() <l2>🔗</l>
<396> Returns the behavior tree instance.
<397> <1/> <b>restart</b>() <l2>🔗</l>
<398> Resets the behavior tree’s execution. Each running task will be aborted and the next tree execution will start anew. This method does not reset <l1>Blackboard</l>.
<399> <1/> <b>set_bt_instance</b>(bt_instance: <l2>BTInstance</l>) <l3>🔗</l>
<400> Sets the <l1>BTInstance</l> to play. This method is useful when you want to switch to a different behavior tree instance at runtime. See also <l2>BehaviorTree.instantiate</l>.
<401> <1/> <b>set_scene_root_hint</b>(scene_root: <2/>) <l3>🔗</l>
<402> Sets the <1/> that will be used as the scene root for the newly instantiated behavior tree. Should be called before the <b>BTPlayer</b> is added to the scene tree (before <2/>). This is typically useful when creating <b>BTPlayer</b> nodes dynamically from code.
<403> <1/> <b>update</b>(delta: <2/>) <l3>🔗</l>
<404> Executes the root task of the behavior tree instance if <l1>active</l> is <2/>. Call this method when <l3>update_mode</l> is set to <l4>MANUAL</l>. When <l5>update_mode</l> is not <l6>MANUAL</l>, the <l7>update</l> will be called automatically. See <l8>UpdateMode</l>.
<405> Blackboard
<406> A key/value storage for sharing among <l1>LimboHSM</l> states and <l2>BehaviorTree</l> tasks.
<407> Blackboard is where data is stored and shared between states in the <l1>LimboHSM</l> system and tasks in a <l2>BehaviorTree</l>. Each state and task in the <l3>BehaviorTree</l> can access this Blackboard, allowing them to read and write data. This makes it easy to share information between different actions and behaviors.
<408> Blackboard can also act as a parent scope for another Blackboard. If a specific variable is not found in the active scope, it looks in the parent Blackboard to find it. A parent Blackboard can itself have its own parent scope, forming what we call a “blackboard scope chain.” Importantly, there is no limit to how many Blackboards can be in this chain, and the Blackboard doesn’t modify values in the parent scopes.
<409> New scopes can be created using the <l1>BTNewScope</l> and <l2>BTSubtree</l> decorators. Additionally, a new scope is automatically created for any <l3>LimboState</l> that has defined non-empty Blackboard data or for any root-level <l4>LimboHSM</l> node.
<410> <l1>bind_var_to_property</l>(var_name: <2/>, object: <3/>, property: <4/>, create: <5/> = false)
<411> <l1>clear</l>()
<412> <l1>erase_var</l>(var_name: <2/>)
<413> <l1>Blackboard</l>
<414> <l1>get_parent</l>() <2/>
<415> <l1>get_var</l>(var_name: <2/>, default: <3/> = null, complain: <4/> = true) <5/>
<416> <l1>get_vars_as_dict</l>() <2/>
<417> <l1>has_var</l>(var_name: <2/>) <3/>
<418> <l1>link_var</l>(var_name: <2/>, target_blackboard: <l3>Blackboard</l>, target_var: <4/>, create: <5/> = false)
<419> Array[<1/>]
<420> <l1>list_vars</l>() <2/>
<421> <l1>populate_from_dict</l>(dictionary: <2/>)
<422> <l1>print_state</l>() <2/>
<423> <l1>set_parent</l>(blackboard: <l2>Blackboard</l>)
<424> <l1>set_var</l>(var_name: <2/>, value: <3/>)
<425> <l1>top</l>() <2/>
<426> <l1>unbind_var</l>(var_name: <2/>)
<427> <1/> <b>bind_var_to_property</b>(var_name: <2/>, object: <3/>, property: <4/>, create: <5/> = false) <l6>🔗</l>
<428> Establish a binding between a variable and the object’s property specified by <1/> and <2/>. Changes to the variable update the property, and vice versa. If <3/> is <4/>, the variable will be created if it doesn’t exist.
<429> <1/> <b>clear</b>() <l2>🔗</l>
<430> Removes all variables from the Blackboard. Parent scopes are not affected.
<431> <1/> <b>erase_var</b>(var_name: <2/>) <l3>🔗</l>
<432> Removes a variable by its name.
<433> <l1>Blackboard</l> <b>get_parent</b>() <2/> <l3>🔗</l>
<434> Returns a Blackboard that serves as the parent scope for this instance.
<435> <1/> <b>get_var</b>(var_name: <2/>, default: <3/> = null, complain: <4/> = true) <5/> <l6>🔗</l>
<436> Returns variable value or <1/> if variable doesn’t exist. If <2/> is <3/>, an error will be printed if variable doesn’t exist. If the variable doesn’t exist in the current <b>Blackboard</b> scope, it will look in the parent scope <b>Blackboard</b> to find it.
<437> <1/> <b>get_vars_as_dict</b>() <2/> <l3>🔗</l>
<438> Returns all variables in the Blackboard as a dictionary. Keys are the variable names, values are the variable values. Parent scopes are not included.
<439> <1/> <b>has_var</b>(var_name: <2/>) <3/> <l4>🔗</l>
<440> Returns <1/> if the Blackboard contains the <2/> variable, including the parent scopes.
<441> <1/> <b>link_var</b>(var_name: <2/>, target_blackboard: <l3>Blackboard</l>, target_var: <4/>, create: <5/> = false) <l6>🔗</l>
<442> Links a variable to another Blackboard variable. If a variable is linked to another variable, their state will always be identical, and any change to one will be reflected in the other. If <1/> is <2/>, the variable will be created if it doesn’t exist.
<443> You can use this method to link a variable in the current scope to a variable in another scope, or in another Blackboard instance. A variable can only be linked to one other variable. Calling this method again will overwrite the previous link. However, it is possible to link to the same variable from multiple different variables.
<444> Array[<1/>] <b>list_vars</b>() <2/> <l3>🔗</l>
<445> Returns all variable names in the Blackboard. Parent scopes are not included.
<446> <1/> <b>populate_from_dict</b>(dictionary: <2/>) <l3>🔗</l>
<447> Fills the Blackboard with multiple variables from a dictionary. The dictionary keys must be variable names and the dictionary values must be variable values. Keys must be StringName or String.
<448> <1/> <b>print_state</b>() <2/> <l3>🔗</l>
<449> Prints the values of all variables in each scope.
<450> <1/> <b>set_parent</b>(blackboard: <l2>Blackboard</l>) <l3>🔗</l>
<451> Assigns the parent scope. If a value isn’t in the current Blackboard scope, it will look in the parent scope Blackboard to find it.
<452> <1/> <b>set_var</b>(var_name: <2/>, value: <3/>) <l4>🔗</l>
<453> Assigns a value to a variable in the current Blackboard scope. If the variable doesn’t exist, it will be created. If the variable already exists in the parent scope, the parent scope value will NOT be changed.
<454> <l1>Blackboard</l> <b>top</b>() <2/> <l3>🔗</l>
<455> Returns the topmost <b>Blackboard</b> in the scope chain.
<456> <1/> <b>unbind_var</b>(var_name: <2/>) <l3>🔗</l>
<457> Remove binding from a variable.
<458> BBParam
<459> <b>Inherited By:</b> <l1>BBAabb</l>, <l2>BBArray</l>, <l3>BBBasis</l>, <l4>BBBool</l>, <l5>BBByteArray</l>, <l6>BBColor</l>, <l7>BBColorArray</l>, <l8>BBDictionary</l>, <l9>BBFloat</l>, <l10>BBFloat32Array</l>, <l11>BBFloat64Array</l>, <l12>BBInt</l>, <l13>BBInt32Array</l>, <l14>BBInt64Array</l>, <l15>BBNode</l>, <l16>BBPlane</l>, <l17>BBProjection</l>, <l18>BBQuaternion</l>, <l19>BBRect2</l>, <l20>BBRect2i</l>, <l21>BBString</l>, <l22>BBStringArray</l>, <l23>BBStringName</l>, <l24>BBTransform2D</l>, <l25>BBTransform3D</l>, <l26>BBVariant</l>, <l27>BBVector2</l>, <l28>BBVector2Array</l>, <l29>BBVector2i</l>, <l30>BBVector3</l>, <l31>BBVector3Array</l>, <l32>BBVector3i</l>, <l33>BBVector4</l>, <l34>BBVector4i</l>
@@ -0,0 +1,163 @@
<460> A base class for LimboAI typed parameters.
<461> A base class for LimboAI typed parameters, with the ability to reference a <l1>Blackboard</l> variable or hold a raw value of a specific Variant.Type.
<462> <b>Note</b>: Don’t instantiate. Use specific subtypes instead.
<463> <l1>saved_value</l>
<464> <l1>ValueSource</l>
<465> <l1>value_source</l>
<466> <l1>variable</l>
<467> Variant.Type
<468> <l1>get_type</l>() <2/>
<469> <l1>get_value</l>(scene_root: <2/>, blackboard: <l3>Blackboard</l>, default: <4/> = null)
<470> enum <b>ValueSource</b>: <l1>🔗</l>
<471> <l1>ValueSource</l> <b>SAVED_VALUE</b> = <2/>
<472> The value is stored directly within the BBParam resource.
<473> <l1>ValueSource</l> <b>BLACKBOARD_VAR</b> = <2/>
<474> The value is referenced by a variable name and retrieved from the <l1>Blackboard</l>. The variable name is stored within the BBParam resource.
<475> <1/> <b>saved_value</b> = <2/> <l3>🔗</l>
<476> <1/> <b>set_saved_value</b>(value: <2/>)
<477> <1/> <b>get_saved_value</b>()
<478> Stores the parameter value when <l1>value_source</l> is set to <l2>SAVED_VALUE</l>. The data type of the value is determined by <l3>get_type</l>.
<479> <l1>ValueSource</l> <b>value_source</b> = <2/> <l3>🔗</l>
<480> <1/> <b>set_value_source</b>(value: <l2>ValueSource</l>)
<481> <l1>ValueSource</l> <b>get_value_source</b>()
<482> Specifies the source of the value for BBParam. See <l1>ValueSource</l>.
<483> <1/> <b>variable</b> <l2>🔗</l>
<484> <1/> <b>set_variable</b>(value: <2/>)
<485> <1/> <b>get_variable</b>()
<486> Stores the name of a <l1>Blackboard</l> variable when <l2>value_source</l> is set to <l3>BLACKBOARD_VAR</l>.
<487> Variant.Type <b>get_type</b>() <1/> <l2>🔗</l>
<488> Returns the expected data type of the parameter.
<489> <1/> <b>get_value</b>(scene_root: <2/>, blackboard: <l3>Blackboard</l>, default: <4/> = null) <l5>🔗</l>
<490> Returns the value of the parameter.
<491> LimboState
<492> <b>Inherited By:</b> <l1>BTState</l>, <l2>LimboHSM</l>
<493> A state node for Hierarchical State Machines (HSM).
<494> A LimboAI state node for Hierarchical State Machines (HSM).
<495> You can create your state behavior by extending this class. To implement your state logic, you can override <l1>_enter</l>, <l2>_exit</l>, <l3>_setup</l>, and <l4>_update</l>. Alternatively, you can delegate state implementation to external methods using the <5/> methods.
<496> For additional details on state machines, refer to <l1>LimboHSM</l>.
<497> <l1>EVENT_FINISHED</l>
<498> <l1>agent</l>
<499> <l1>blackboard</l>
<500> <l1>blackboard_plan</l>
<501> <l1>_enter</l>() <2/>
<502> <l1>_exit</l>() <2/>
<503> <l1>_setup</l>() <2/>
<504> <l1>_update</l>(delta: <2/>) <3/>
<505> <l1>add_event_handler</l>(event: <2/>, handler: <3/>)
<506> <l1>LimboState</l>
<507> <l1>call_on_enter</l>(callable: <2/>)
<508> <l1>call_on_exit</l>(callable: <2/>)
<509> <l1>call_on_update</l>(callable: <2/>)
<510> <l1>clear_guard</l>()
<511> <l1>dispatch</l>(event: <2/>, cargo: <3/> = null)
<512> <l1>get_root</l>() <2/>
<513> <l1>is_active</l>() <2/>
<514> <l1>named</l>(name: <2/>)
<515> <l1>set_guard</l>(guard_callable: <2/>)
<516> <b>entered</b>() <l1>🔗</l>
<517> Emitted when the state is entered.
<518> <b>exited</b>() <l1>🔗</l>
<519> Emitted when the state is exited.
<520> <b>setup</b>() <l1>🔗</l>
<521> Emitted when the state is initialized.
<522> <b>updated</b>(delta: <1/>) <l2>🔗</l>
<523> Emitted when the state is updated.
<524> <1/> <b>EVENT_FINISHED</b> <l2>🔗</l>
<525> <1/> <b>event_finished</b>()
<526> A commonly used event that indicates that the state has finished its work.
<527> <1/> <b>agent</b> <l2>🔗</l>
<528> An agent associated with the state, assigned during initialization.
<529> <l1>Blackboard</l> <b>blackboard</b> <l2>🔗</l>
<530> A key/value data store shared by states within the state machine to which this state belongs.
<531> <l1>BlackboardPlan</l> <b>blackboard_plan</b> <l2>🔗</l>
<532> <1/> <b>_enter</b>() <2/> <l3>🔗</l>
<533> Called when the state is entered.
<534> <1/> <b>_exit</b>() <2/> <l3>🔗</l>
<535> Called when the state is exited. This happens on a transition to another state, and when the state machine is removed from the scene tree (e.g., when the node is freed with Node.queue_free or the scene changes). Due to implementation details, <l1>_exit</l> will not be called on Object.free!
<536> <1/> <b>_setup</b>() <2/> <l3>🔗</l>
<537> Called once during initialization. Use this method to initialize your state.
<538> <1/> <b>_update</b>(delta: <2/>) <3/> <l4>🔗</l>
<539> Called during the update. Implement your state’s behavior with this method.
<540> <1/> <b>add_event_handler</b>(event: <2/>, handler: <3/>) <l4>🔗</l>
<541> Registers a <1/> to be called when <2/> is dispatched. The handler function should have the following signature:
<542> If the handler returns <1/>, the event will be consumed. Cargo is an optional parameter that can be passed to the handler. See also <l2>dispatch</l>.
<543> <l1>LimboState</l> <b>call_on_enter</b>(callable: <2/>) <l3>🔗</l>
<544> A chained method that connects the <l1>entered</l> signal to a <2/>.
<545> <l1>LimboState</l> <b>call_on_exit</b>(callable: <2/>) <l3>🔗</l>
<546> A chained method that connects the <l1>exited</l> signal to a <2/>.
<547> <l1>LimboState</l> <b>call_on_update</b>(callable: <2/>) <l3>🔗</l>
<548> A chained method that connects the <l1>updated</l> signal to a <2/>.
<549> <1/> <b>clear_guard</b>() <l2>🔗</l>
<550> Clears the guard function, removing the <1/> previously set by <l2>set_guard</l>.
<551> <1/> <b>dispatch</b>(event: <2/>, cargo: <3/> = null) <l4>🔗</l>
<552> Recursively dispatches a state machine event named <1/> with an optional argument <2/>. Returns <3/> if the event was consumed.
<553> Events propagate from the leaf state to the root state, and propagation stops as soon as any state consumes the event. States will consume the event if they have a related transition or event handler. For more information on event handlers, see <l1>add_event_handler</l>.
<554> <l1>LimboState</l> <b>get_root</b>() <2/> <l3>🔗</l>
<555> Returns the root <b>LimboState</b>.
<556> <1/> <b>is_active</b>() <2/> <l3>🔗</l>
<557> Returns <1/> if it is currently active, meaning it is the active substate of the parent <l2>LimboHSM</l>.
<558> <l1>LimboState</l> <b>named</b>(name: <2/>) <l3>🔗</l>
<559> A chained method for setting the name of this state.
<560> <1/> <b>set_guard</b>(guard_callable: <2/>) <l3>🔗</l>
<561> Sets the guard function, which is a function called each time a transition to this state is considered. If the function returns <1/>, the transition will be disallowed.
<562> LimboHSM
<563> <b>Inherits:</b> <l1>LimboState</l>
<564> Event-based Hierarchical State Machine (HSM).
<565> Event-based Hierarchical State Machine (HSM) that manages <l1>LimboState</l> instances and facilitates transitions between them. LimboHSM is a <l2>LimboState</l> in itself and can also serve as a child of another LimboHSM node.
<566> <l1>LimboState</l>
<567> <l1>ANYSTATE</l>
<568> <l1>initial_state</l>
<569> <l1>UpdateMode</l>
<570> <l1>update_mode</l>
<571> <l1>add_transition</l>(from_state: <l2>LimboState</l>, to_state: <l3>LimboState</l>, event: <4/>, guard: <5/> = Callable())
<572> <l1>change_active_state</l>(state: <l2>LimboState</l>)
<573> <l1>get_active_state</l>() <2/>
<574> <l1>get_leaf_state</l>() <2/>
<575> <l1>get_previous_active_state</l>() <2/>
<576> <l1>has_transition</l>(from_state: <l2>LimboState</l>, event: <3/>) <4/>
<577> <l1>initialize</l>(agent: <2/>, parent_scope: <l3>Blackboard</l> = null)
<578> <l1>remove_transition</l>(from_state: <l2>LimboState</l>, event: <3/>)
<579> <l1>set_active</l>(active: <2/>)
<580> <l1>update</l>(delta: <2/>)
<581> <b>active_state_changed</b>(current: <l1>LimboState</l>, previous: <l2>LimboState</l>) <l3>🔗</l>
<582> Emitted when the currently active substate is switched to a different substate.
<583> enum <b>UpdateMode</b>: <l1>🔗</l>
<584> <l1>UpdateMode</l> <b>IDLE</b> = <2/>
<585> Update the state machine during the idle process.
<586> <l1>UpdateMode</l> <b>PHYSICS</b> = <2/>
<587> Update the state machine during the physics process.
<588> <l1>UpdateMode</l> <b>MANUAL</b> = <2/>
<589> Manually update the state machine by calling <l1>update</l> from a script.
<590> <l1>LimboState</l> <b>ANYSTATE</b> <l2>🔗</l>
<591> <l1>LimboState</l> <b>anystate</b>()
<592> Useful for defining a transition from any state.
<593> <l1>LimboState</l> <b>initial_state</b> <l2>🔗</l>
<594> <1/> <b>set_initial_state</b>(value: <l2>LimboState</l>)
<595> <l1>LimboState</l> <b>get_initial_state</b>()
<596> The substate that becomes active when the state machine is activated using the <l1>set_active</l> method. If not explicitly set, the first child of the LimboHSM will be considered the initial state.
<597> <l1>UpdateMode</l> <b>update_mode</b> = <2/> <l3>🔗</l>
<598> <1/> <b>set_update_mode</b>(value: <l2>UpdateMode</l>)
<599> <l1>UpdateMode</l> <b>get_update_mode</b>()
<600> Specifies when the state machine should be updated. See <l1>UpdateMode</l>.
<601> <1/> <b>add_transition</b>(from_state: <l2>LimboState</l>, to_state: <l3>LimboState</l>, event: <4/>, guard: <5/> = Callable()) <l6>🔗</l>
<602> Establishes a transition from one state to another when <1/> is dispatched. Both <2/> and <3/> must be immediate children of this <b>LimboHSM</b>.
<603> Optionally, a <1/> function can be specified, which must return a boolean value. If the guard function returns <2/>, the transition will not occur. The guard function is called immediately before the transition is considered. For a state-wide guard function, check out <l3>LimboState.set_guard</l>.
<604> <1/> <b>change_active_state</b>(state: <l2>LimboState</l>) <l3>🔗</l>
<605> Changes the currently active substate to <1/>. If <2/> is already active, it will be exited and reentered.
<606> <1/> must be a child of this <b>LimboHSM</b>.
<607> <l1>LimboState</l> <b>get_active_state</b>() <2/> <l3>🔗</l>
<608> Returns the currently active substate.
<609> <l1>LimboState</l> <b>get_leaf_state</b>() <2/> <l3>🔗</l>
<610> Returns the currently active leaf state within the state machine.
<611> <l1>LimboState</l> <b>get_previous_active_state</b>() <2/> <l3>🔗</l>
<612> Returns the previously active substate.
<613> <1/> <b>has_transition</b>(from_state: <l2>LimboState</l>, event: <3/>) <4/> <l5>🔗</l>
<614> Returns <1/> if there is a transition from <2/> for a given <3/>.
<615> <1/> <b>initialize</b>(agent: <2/>, parent_scope: <l3>Blackboard</l> = null) <l4>🔗</l>
<616> Initiates the state and calls <l1>LimboState._setup</l> for both itself and all substates.
<617> <1/> <b>remove_transition</b>(from_state: <l2>LimboState</l>, event: <3/>) <l4>🔗</l>
<618> Removes a transition from a state associated with specific <1/>.
<619> <1/> <b>set_active</b>(active: <2/>) <l3>🔗</l>
<620> When set to <1/>, switches the state to <l2>initial_state</l> and activates state processing according to <l3>update_mode</l>.
<621> <1/> <b>update</b>(delta: <2/>) <l3>🔗</l>
<622> Calls <l1>LimboState._update</l> on itself and the active substate, with the call cascading down to the leaf state. This method is automatically triggered if <l2>update_mode</l> is not set to <l3>MANUAL</l>.
@@ -0,0 +1,156 @@
<623> BTState
<624> A state node for <l1>LimboHSM</l> that hosts a <l2>BehaviorTree</l>.
<625> BTState is a <l1>LimboState</l> node that manages a <l2>BehaviorTree</l> to provide behavior logic for the state. It instantiates and runs the <l3>BehaviorTree</l> resource, dispatching a state machine event upon <4/> or <5/>. Event names are customizable through <l6>success_event</l> and <l7>failure_event</l>. For further details on state machine events, see <l8>LimboState.dispatch</l>.
<626> <l1>behavior_tree</l>
<627> <l1>failure_event</l>
<628> <l1>monitor_performance</l>
<629> <l1>success_event</l>
<630> <l1>get_bt_instance</l>() <2/>
<631> <l1>set_scene_root_hint</l>(scene_root: <2/>)
<632> <l1>BehaviorTree</l> <b>behavior_tree</b> <l2>🔗</l>
<633> A <l1>BehaviorTree</l> resource that defines state behavior.
<634> <1/> <b>failure_event</b> = <2/> <l3>🔗</l>
<635> <1/> <b>set_failure_event</b>(value: <2/>)
<636> <1/> <b>get_failure_event</b>()
<637> HSM event that will be dispatched when the behavior tree results in <1/>. See <l2>LimboState.dispatch</l>.
<638> <1/> <b>monitor_performance</b> = <2/> <l3>🔗</l>
<639> If <1/>, adds a performance monitor to “Debugger->Monitors” for each instance of this <b>BTState</b> node.
<640> <1/> <b>success_event</b> = <2/> <l3>🔗</l>
<641> <1/> <b>set_success_event</b>(value: <2/>)
<642> <1/> <b>get_success_event</b>()
<643> HSM event that will be dispatched when the behavior tree results in <1/>. See <l2>LimboState.dispatch</l>.
<644> <l1>BTInstance</l> <b>get_bt_instance</b>() <2/> <l3>🔗</l>
<645> <1/> <b>set_scene_root_hint</b>(scene_root: <2/>) <l3>🔗</l>
<646> Sets the <1/> that will be used as the scene root for the newly instantiated behavior tree. Should be called before the state machine is initialized. This is typically useful when creating <b>BTState</b> nodes dynamically from code.
<647> Full class reference is available in the side bar.
<648> BBAabb
<649> <b>Inherits:</b> <l1>BBParam</l>
<650> AABB-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<651> BBArray
<652> Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<653> BBBasis
<654> Basis-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<655> BBBool
<656> Bool-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<657> BBByteArray
<658> ByteArray-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<659> BBColor
<660> Color-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<661> BBColorArray
<662> ColorArray-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<663> BBDictionary
<664> Dictionary-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<665> BBFloat
<666> Float-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<667> BBFloat32Array
<668> PackedFloat32Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<669> BBFloat64Array
<670> PackedFloat64Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<671> BBInt
<672> Integer-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<673> BBInt32Array
<674> PackedInt32Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<675> BBInt64Array
<676> PackedInt64Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<677> BBNode
<678> Node-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<679> Node-type parameter intended for use with <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<680> If the source is a blackboard variable, it allows any type extended from <1/>.
<681> BBPlane
<682> Plane-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<683> BBProjection
<684> Projection-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<685> BBQuaternion
<686> Quaternion-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<687> BBRect2
<688> Rect2-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<689> BBRect2i
<690> Rect2i-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<691> BBString
<692> String-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<693> BBStringArray
<694> StringArray-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<695> BBStringName
<696> StringName-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<697> BBTransform2D
<698> Transform2D-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<699> BBTransform3D
<700> Transform3D-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<701> BBVariant
<702> Variant-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<703> Variant-type parameter intended for use with <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<704> The data type is specified by <l1>type</l>.
<705> <l1>type</l>
<706> Variant.Type <b>type</b> = <1/> <l2>🔗</l>
<707> <1/> <b>set_type</b>(value: Variant.Type)
<708> Variant.Type <b>get_type</b>()
<709> Specified Variant.Type for the parameter value.
<710> BBVector2
<711> Vector2-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<712> BBVector2Array
<713> Vector2Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<714> BBVector2i
<715> Vector2i-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<716> BBVector3
<717> Vector3-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<718> BBVector3Array
<719> Vector3Array-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<720> BBVector3i
<721> Vector3i-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<722> BBVector4
<723> Vector4-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<724> BBVector4i
<725> Vector4i-type parameter for <l1>BehaviorTree</l> tasks. See <l2>BBParam</l>.
<726> BehaviorTree
<727> Contains Behavior Tree data.
<728> Behavior Trees are hierarchical structures used to model and control the behavior of agents in a game (e.g., characters, enemies, entities). They are designed to make it easier to create complex and highly modular behaviors for your games.
<729> Behavior Trees are composed of tasks that represent specific actions or decision-making rules. Tasks can be broadly categorized into two main types: control tasks and leaf tasks. Control tasks determine the execution flow within the tree. They include <l1>BTSequence</l>, <l2>BTSelector</l>, and <l3>BTInvert</l>. Leaf tasks represent specific actions to perform, like moving or attacking, or conditions that need to be checked. The <l4>BTTask</l> class provides the foundation for various building blocks of the Behavior Trees. BT tasks can share data with the help of <l5>Blackboard</l>. See <l6>BTTask.blackboard</l> and <l7>Blackboard</l>.
<730> <b>Note:</b> To create your own actions, extend the <l1>BTAction</l> class.
<731> The BehaviorTree is executed from the root task and follows the rules specified by the control tasks, all the way down to the leaf tasks, which represent the actual actions that the agent should perform or conditions that should be checked. Each task returns a status when it is executed. It can be <1/>, <2/>, or <3/>. These statuses determine how the tree progresses. They are defined in <l4>Status</l>.
<732> Behavior Trees handle conditional logic using condition tasks. These tasks check for specific conditions and return either <1/> or <2/> based on the state of the agent or its environment (e.g., “IsLowOnHealth”, “IsTargetInSight”). Conditions can be used together with <l3>BTSequence</l> and <l4>BTSelector</l> to craft your decision-making logic.
<733> <b>Note</b>: To create your own conditions, extend the <l1>BTCondition</l> class.
<734> Check out the <l1>BTTask</l> class, which provides the foundation for various building blocks of Behavior Trees.
<735> <l1>blackboard_plan</l>
<736> <l1>description</l>
<737> <l1>BehaviorTree</l>
<738> <l1>clone</l>() <2/>
<739> <l1>copy_other</l>(other: <l2>BehaviorTree</l>)
<740> <l1>BTTask</l>
<741> <l1>get_root_task</l>() <2/>
<742> <l1>instantiate</l>(agent: <2/>, blackboard: <l3>Blackboard</l>, instance_owner: <4/>, custom_scene_root: <5/> = null) <6/>
<743> <l1>set_root_task</l>(task: <l2>BTTask</l>)
<744> <b>plan_changed</b>() <l1>🔗</l>
<745> Emitted when the <l1>BlackboardPlan</l> changes.
<746> <l1>BlackboardPlan</l> <b>blackboard_plan</b> <l2>🔗</l>
<747> <1/> <b>description</b> = <2/> <l3>🔗</l>
<748> <1/> <b>set_description</b>(value: <2/>)
<749> <1/> <b>get_description</b>()
<750> User-provided description of the <b>BehaviorTree</b>.
<751> <l1>BehaviorTree</l> <b>clone</b>() <2/> <l3>🔗</l>
<752> Makes a copy of the BehaviorTree resource.
<753> <1/> <b>copy_other</b>(other: <l2>BehaviorTree</l>) <l3>🔗</l>
<754> Become a copy of another behavior tree.
<755> <l1>BTTask</l> <b>get_root_task</b>() <2/> <l3>🔗</l>
<756> Returns the root task of the BehaviorTree resource.
<757> <l1>BTInstance</l> <b>instantiate</b>(agent: <2/>, blackboard: <l3>Blackboard</l>, instance_owner: <4/>, custom_scene_root: <5/> = null) <6/> <l7>🔗</l>
<758> Instantiates the behavior tree and returns <l1>BTInstance</l>. <2/> should be the scene node that will own the behavior tree instance. This is typically a <l3>BTPlayer</l>, <l4>BTState</l>, or a custom player node that controls the behavior tree execution. Make sure to pass a <l5>Blackboard</l> with values populated from <l6>blackboard_plan</l>. See also <l7>BlackboardPlan.populate_blackboard</l> & <l8>BlackboardPlan.create_blackboard</l>.
<759> If <1/> is not <2/>, it will be used as the scene root for the newly instantiated behavior tree; otherwise, the scene root will be set to <3/>. Scene root is essential for <l4>BBNode</l> instances to work properly.
<760> <1/> <b>set_root_task</b>(task: <l2>BTTask</l>) <l3>🔗</l>
<761> Assigns a new root task to the <b>BehaviorTree</b> resource.
<762> BehaviorTreeData
<763> Represents current state of a <l1>BehaviorTree</l> instance.
<764> This class is used by the LimboAI debugger for the serialization and deserialization of <l1>BehaviorTree</l> instance data.
<765> Additionally, it can be used with <l1>BehaviorTreeView</l> to visualize the current state of a <l2>BehaviorTree</l> instance. It is meant to be utilized in custom in-game tools.
<766> <l1>BehaviorTreeData</l>
<767> <l1>create_from_bt_instance</l>(bt_instance: <l2>BTInstance</l>) <3/>
<768> <l1>BehaviorTreeData</l> <b>create_from_bt_instance</b>(bt_instance: <l2>BTInstance</l>) <3/> <l4>🔗</l>
<769> Returns current state of the <1/> encoded as a <b>BehaviorTreeData</b>, suitable for use with <l2>BehaviorTreeView</l>.
<770> Behavior tree instance can be acquired with <l1>BTPlayer.get_bt_instance</l>.
<771> BehaviorTreeView
<772> Visualizes the current state of a <l1>BehaviorTree</l> instance.
<773> Visualizes the current state of a <l1>BehaviorTree</l> instance. See also <l2>BehaviorTreeData</l>.
<774> <l1>update_interval_msec</l>
<775> <l1>clear</l>()
<776> <l1>update_tree</l>(behavior_tree_data: <l2>BehaviorTreeData</l>)
<777> <b>task_selected</b>(type_name: <1/>, script_path: <2/>) <l3>🔗</l>
<778> Emitted when a task item is selected in <b>BehaviorTreeView</b>.
@@ -0,0 +1,164 @@
<779> <1/> <b>update_interval_msec</b> = <2/> <l3>🔗</l>
<780> <1/> <b>set_update_interval_msec</b>(value: <2/>)
<781> <1/> <b>get_update_interval_msec</b>()
<782> Minimum delay between two updates (in milliseconds). Set to higher values for a lower CPU load.
<783> <1/> <b>clear</b>() <l2>🔗</l>
<784> Clears the tree view.
<785> <1/> <b>update_tree</b>(behavior_tree_data: <l2>BehaviorTreeData</l>) <l3>🔗</l>
<786> Updates the representation of a <l1>BehaviorTree</l> instance. See also <l2>BehaviorTreeData</l>.
<787> BlackboardPlan
<788> <l1>prefetch_nodepath_vars</l>
<789> <l1>create_blackboard</l>(prefetch_root: <2/>, parent_scope: <l3>Blackboard</l> = null, prefetch_root_for_base_plan: <4/> = null)
<790> <l1>BlackboardPlan</l>
<791> <l1>get_base_plan</l>() <2/>
<792> <l1>get_parent_scope_plan_provider</l>() <2/>
<793> <l1>is_derived</l>() <2/>
<794> <l1>populate_blackboard</l>(blackboard: <l2>Blackboard</l>, overwrite: <3/>, prefetch_root: <4/>, prefetch_root_for_base_plan: <5/> = null)
<795> <l1>set_base_plan</l>(blackboard_plan: <l2>BlackboardPlan</l>)
<796> <l1>set_parent_scope_plan_provider</l>(callable: <2/>)
<797> <l1>sync_with_base_plan</l>()
<798> <1/> <b>prefetch_nodepath_vars</b> = <2/> <l3>🔗</l>
<799> <1/> <b>set_prefetch_nodepath_vars</b>(value: <2/>)
<800> <1/> <b>is_prefetching_nodepath_vars</b>()
<801> Enables or disables <1/> variable prefetching. If <2/>, <3/> values will be replaced with node instances when the <l4>Blackboard</l> is created.
<802> <l1>Blackboard</l> <b>create_blackboard</b>(prefetch_root: <2/>, parent_scope: <l3>Blackboard</l> = null, prefetch_root_for_base_plan: <4/> = null) <l5>🔗</l>
<803> Constructs a new instance of a <l1>Blackboard</l> using this plan. If <2/> prefetching is enabled, <3/> will be used to retrieve node instances for <4/> variables and substitute their values.
<804> <l1>BlackboardPlan</l> <b>get_base_plan</b>() <2/> <l3>🔗</l>
<805> Returns the base plan. See <l1>is_derived</l>.
<806> <1/> <b>get_parent_scope_plan_provider</b>() <2/> <l3>🔗</l>
<807> Returns the parent scope plan provider - a callable that returns a <b>BlackboardPlan</b>.
<808> <1/> <b>is_derived</b>() <2/> <l3>🔗</l>
<809> Returns <1/> if this plan is derived from another, i.e., the base plan is not <2/>. A derived plan can only contain variables that are present in the base plan, and only variable values can be different.
<810> <1/> <b>populate_blackboard</b>(blackboard: <l2>Blackboard</l>, overwrite: <3/>, prefetch_root: <4/>, prefetch_root_for_base_plan: <5/> = null) <l6>🔗</l>
<811> Populates <1/> with the variables from this plan. If <2/> is <3/>, existing variables with the same names will be overwritten. If <4/> prefetching is enabled, <5/> will be used to retrieve node instances for <6/> variables and substitute their values.
<812> <1/> <b>set_base_plan</b>(blackboard_plan: <l2>BlackboardPlan</l>) <l3>🔗</l>
<813> Sets the base plan. If assigned, this plan will be derived from the base plan.
<814> Use with caution, as it will remove variables not present in the base plan. Only use this for custom tooling.
<815> <1/> <b>set_parent_scope_plan_provider</b>(callable: <2/>) <l3>🔗</l>
<816> Sets the parent scope plan provider - a callable that returns a <b>BlackboardPlan</b>. Used to provide hints in the inspector. When set, mapping feature becomes available.
<817> <1/> <b>sync_with_base_plan</b>() <l2>🔗</l>
<818> Synchronizes this plan with the base plan: removes variables not present in the base plan, and updates type information. Only use this for custom tooling.
<819> BT
<820> <b>Inherited By:</b> <l1>BTTask</l>
<821> Base of <l1>BTTask</l>.
<822> enum <b>Status</b>: <l1>🔗</l>
<823> <l1>Status</l> <b>FRESH</b> = <2/>
<824> Task wasn’t executed yet or it was aborted and reset.
<825> <l1>Status</l> <b>RUNNING</b> = <2/>
<826> Task is being performed and hasn’t finished yet.
<827> <l1>Status</l> <b>FAILURE</b> = <2/>
<828> Task has finished with failure.
<829> <l1>Status</l> <b>SUCCESS</b> = <2/>
<830> Task has finished with success.
<831> BTAction
<832> <b>Inherits:</b> <l1>BTTask</l> <b><</b> <l2>BT</l>
<833> <b>Inherited By:</b> <l1>BTAwaitAnimation</l>, <l2>BTCallMethod</l>, <l3>BTConsolePrint</l>, <l4>BTEvaluateExpression</l>, <l5>BTFail</l>, <l6>BTPauseAnimation</l>, <l7>BTPlayAnimation</l>, <l8>BTRandomWait</l>, <l9>BTSetAgentProperty</l>, <l10>BTSetVar</l>, <l11>BTStopAnimation</l>, <l12>BTWait</l>, <l13>BTWaitTicks</l>
<834> Base class for all <l1>BehaviorTree</l> actions.
<835> Base class for all actions within a <l1>BehaviorTree</l>. You can create your own actions by extending the <b>BTAction</b> class.
<836> Represents a specific behavior or action in a <l1>BehaviorTree</l> that an agent should execute. Actions are the lowest level of the <l2>BehaviorTree</l> hierarchy and are responsible for performing the actual work required to achieve a goal. Actions do not have child tasks.
<837> A single action can perform a task within one or multiple ticks. If it takes more than one tick to complete the task, the action should return <1/> status. When the task is finished, the action returns either <2/> or <3/> to indicate the outcome.
<838> BTAlwaysFail
<839> <b>Inherits:</b> <l1>BTDecorator</l> <b><</b> <l2>BTTask</l> <b><</b> <l3>BT</l>
<840> BT decorator that converts <1/> into <2/>.
<841> BTAlwaysSucceed
<842> BT decorator that converts <1/> into <2/>.
<843> BTAwaitAnimation
<844> <b>Inherits:</b> <l1>BTAction</l> <b><</b> <l2>BTTask</l> <b><</b> <l3>BT</l>
<845> BT action that waits for an animation to finish playing.
<846> BTAwaitAnimation action waits for an animation on the specified <1/> node to finish playing and returns <2/>.
<847> Returns <1/> if the specified animation has finished playing or if the specified animation is not currently playing.
<848> Returns <1/> if the specified animation doesn’t exist or if the action fails to get the <2/> node.
<849> <l1>animation_name</l>
<850> <l1>BBNode</l>
<851> <l1>animation_player</l>
<852> <l1>max_time</l>
<853> <1/> <b>animation_name</b> = <2/> <l3>🔗</l>
<854> <1/> <b>set_animation_name</b>(value: <2/>)
<855> <1/> <b>get_animation_name</b>()
<856> Animation’s key within the <1/> node.
<857> <l1>BBNode</l> <b>animation_player</b> <l2>🔗</l>
<858> <1/> <b>set_animation_player</b>(value: <l2>BBNode</l>)
<859> <l1>BBNode</l> <b>get_animation_player</b>()
<860> Parameter that specifies the <1/> node.
<861> <1/> <b>max_time</b> = <2/> <l3>🔗</l>
<862> <1/> <b>set_max_time</b>(value: <2/>)
<863> <1/> <b>get_max_time</b>()
<864> The maximum duration to wait for the animation to complete (in seconds). If the animation doesn’t finish within this time, BTAwaitAnimation will stop waiting and return <1/>.
<865> BTCallMethod
<866> BT action that calls a method on a specified <1/> or <2/>.
<867> BTCallMethod action calls a <l1>method</l> on the specified <2/> or <3/> instance and returns <4/>.
<868> Returns <1/> if the action encounters an issue during the method execution.
<869> Array[<l1>BBVariant</l>]
<870> <l1>args</l>
<871> <l1>args_include_delta</l>
<872> <l1>method</l>
<873> <l1>node</l>
<874> <l1>result_var</l>
<875> Array[<l1>BBVariant</l>] <b>args</b> = <2/> <l3>🔗</l>
<876> <1/> <b>set_args</b>(value: Array[<l2>BBVariant</l>])
<877> Array[<l1>BBVariant</l>] <b>get_args</b>()
<878> The arguments to be passed when calling the method.
<879> <1/> <b>args_include_delta</b> = <2/> <l3>🔗</l>
<880> <1/> <b>set_include_delta</b>(value: <2/>)
<881> <1/> <b>is_delta_included</b>()
<882> Include delta as a first parameter and shift the position of the rest of the arguments if any.
<883> <1/> <b>method</b> = <2/> <l3>🔗</l>
<884> <1/> <b>set_method</b>(value: <2/>)
<885> <1/> <b>get_method</b>()
<886> The name of the method to be called.
<887> <l1>BBNode</l> <b>node</b> <l2>🔗</l>
<888> <1/> <b>set_node_param</b>(value: <l2>BBNode</l>)
<889> <l1>BBNode</l> <b>get_node_param</b>()
<890> Specifies the <1/> or <2/> instance containing the method to be called.
<891> <1/> <b>result_var</b> = <2/> <l3>🔗</l>
<892> <1/> <b>set_result_var</b>(value: <2/>)
<893> <1/> <b>get_result_var</b>()
<894> if non-empty, assign the result of the method call to the blackboard variable specified by this property.
<895> BTCheckAgentProperty
<896> <b>Inherits:</b> <l1>BTCondition</l> <b><</b> <l2>BTTask</l> <b><</b> <l3>BT</l>
<897> BT condition that checks agent’s property value.
<898> BTCheckAgentProperty examines the agent’s property value and compares it to <l1>value</l>.
<899> Returns <1/> or <2/> based on the <l3>check_type</l>.
<900> <l1>CheckType</l>
<901> <l1>check_type</l>
<902> <l1>property</l>
<903> <l1>BBVariant</l>
<904> <l1>value</l>
<905> <l1>CheckType</l> <b>check_type</b> = <2/> <l3>🔗</l>
<906> <1/> <b>set_check_type</b>(value: <l2>CheckType</l>)
<907> <l1>CheckType</l> <b>get_check_type</b>()
<908> The type of check to be performed.
<909> <1/> <b>property</b> = <2/> <l3>🔗</l>
<910> <1/> <b>set_property</b>(value: <2/>)
<911> <1/> <b>get_property</b>()
<912> Parameter that specifies the agent’s property to be compared.
<913> <l1>BBVariant</l> <b>value</b> <l2>🔗</l>
<914> <1/> <b>set_value</b>(value: <l2>BBVariant</l>)
<915> <l1>BBVariant</l> <b>get_value</b>()
<916> Parameter that specifies the value against which an agent’s property will be compared.
<917> BTCheckTrigger
<918> BT condition that checks a trigger (a boolean variable).
<919> <b>BTCheckTrigger</b> verifies whether the <l1>variable</l> is set to <2/>. If it is, the task switches it to <3/> and returns <4/>. Otherwise, it returns <5/>.
<920> <b>BTCheckTrigger</b> can function as a “gate” within a <l1>BTSequence</l>: when the trigger variable is set to <2/>, it permits the execution of subsequent tasks and then changes the variable to <3/>.
<921> <l1>variable</l>
<922> <1/> <b>variable</b> = <2/> <l3>🔗</l>
<923> A boolean variable on the blackboard used as a trigger. See also <l1>BTTask.blackboard</l>.
<924> If variable’s value is <1/>, <b>BTCheckTrigger</b> will switch it to <2/> and return <3/>.
<925> If variable’s value is <1/>, <b>BTCheckTrigger</b> will return <2/>.
<926> BTCheckVar
<927> BT condition that checks a variable on the blackboard.
<928> BTCheckVar evaluates the <l1>variable</l> against <l2>value</l> and returns <3/> or <4/> based on the <l5>check_type</l>.
<929> <l1>check_type</l>
<930> <l1>value</l>
<931> <l1>variable</l>
<932> <l1>CheckType</l> <b>check_type</b> = <2/> <l3>🔗</l>
<933> <l1>BBVariant</l> <b>value</b> <l2>🔗</l>
<934> A parameter that specifies the value against which the <l1>variable</l> will be compared.
<935> <1/> <b>variable</b> = <2/> <l3>🔗</l>
<936> The name of the variable to check its value.
<937> BTComment
<938> BTComment adds annotations or explanatory notes to a <l1>BehaviorTree</l>.
<939> BTComment is used to add annotations or explanatory notes to a <l1>BehaviorTree</l>.
<940> Comments are not executed as part of the tree and are removed from runtime instances of the <l1>BehaviorTree</l>.
<941> BTComposite
<942> <b>Inherited By:</b> <l1>BTDynamicSelector</l>, <l2>BTDynamicSequence</l>, <l3>BTParallel</l>, <l4>BTProbabilitySelector</l>, <l5>BTRandomSelector</l>, <l6>BTRandomSequence</l>, <l7>BTSelector</l>, <l8>BTSequence</l>
@@ -0,0 +1,131 @@
<943> Base class for BT composite tasks.
<944> Base class for all <l1>BehaviorTree</l> composite tasks. You can create your own composite tasks by extending the BTComposite class.
<945> Composite is a control task within a <l1>BehaviorTree</l> that contains one or more child tasks. It defines the structure and flow of the <l2>BehaviorTree</l> by specifying how child tasks are executed. Composites can be used to group related tasks into a single unit, making it easier to manage and maintain the <l3>BehaviorTree</l>. Examples of <l4>BehaviorTree</l> composites include <l5>BTSelector</l>, <l6>BTSequence</l>, and <l7>BTParallel</l>.
<946> BTCondition
<947> <b>Inherited By:</b> <l1>BTCheckAgentProperty</l>, <l2>BTCheckTrigger</l>, <l3>BTCheckVar</l>
<948> Base class for BT conditions.
<949> Base class for all <l1>BehaviorTree</l> conditions. You can create your own conditions by extending the <b>BTCondition</b> class.
<950> Condition is a task within a <l1>BehaviorTree</l> that checks for a specific condition before executing subsequent tasks. It is often used inside composite tasks to control the execution flow. Conditions are used to verify the state of the environment, check for the presence of an enemy, or evaluate the health status of the agent. The use of condition tasks in a <l2>BehaviorTree</l> can improve system efficiency and prevent unnecessary actions. However, they may not be suitable for complex decision-making processes, and too many condition tasks can make the <l3>BehaviorTree</l> difficult to read.
<951> Conditions typically don’t take multiple ticks to finish and return either <1/> or <2/> immediately.
<952> BTConsolePrint
<953> BT action task that outputs text to the console.
<954> BTConsolePrint action outputs text to the console and returns <1/>. It can include placeholders for format arguments similar to GDScript’s % operator.
<955> <l1>bb_format_parameters</l>
<956> <l1>text</l>
<957> <1/> <b>bb_format_parameters</b> = <2/> <l3>🔗</l>
<958> <1/> <b>set_bb_format_parameters</b>(value: <2/>)
<959> <1/> <b>get_bb_format_parameters</b>()
<960> The values of format parameters are used as format arguments for the text that will be printed.
<961> <b>Note:</b> The returned array is <i>copied</i> and any changes to it will not update the original property value. See <1/> for more details.
<962> <1/> <b>text</b> = <2/> <l3>🔗</l>
<963> <1/> <b>set_text</b>(value: <2/>)
<964> <1/> <b>get_text</b>()
<965> The text to be printed, which can include multiple placeholders to be substituted with format arguments. These placeholders follow the same format as GDScript’s % operator and typically start with ‘%’ followed by a format specifier. For instance: %s for string, %d for integer, %f for real.
<966> BTCooldown
<967> BT decorator that executes its child task only if <l1>duration</l> time has passed since the previous execution.
<968> BTCooldown runs its child task only if <l1>duration</l> time has passed since the last successful child task execution. It will only consider successful executions unless <l2>trigger_on_failure</l> is set to <3/>.
<969> Returns <1/>, if the child task results in <2/>.
<970> Returns <1/>, if the child task results in <2/>, and triggers the cooldown timer.
<971> Returns <1/>, if the child task results in <2/> or if <l3>duration</l> time didn’t pass since the previous execution.
<972> <l1>cooldown_state_var</l>
<973> <l1>duration</l>
<974> <l1>process_pause</l>
<975> <l1>start_cooled</l>
<976> <l1>trigger_on_failure</l>
<977> <1/> <b>cooldown_state_var</b> = <2/> <l3>🔗</l>
<978> <1/> <b>set_cooldown_state_var</b>(value: <2/>)
<979> <1/> <b>get_cooldown_state_var</b>()
<980> A boolean variable used to store the cooldown state in the <l1>Blackboard</l>. If left empty, the variable will be automatically generated and assigned.
<981> If the variable’s value is set to <1/>, it indicates that the cooldown is activated. This feature is useful for checking the cooldown state from other parts of the tree or sharing it among different sections of the <l2>BehaviorTree</l>.
<982> <1/> <b>duration</b> = <2/> <l3>🔗</l>
<983> <1/> <b>set_duration</b>(value: <2/>)
<984> <1/> <b>get_duration</b>()
<985> Time to wait before permitting another child’s execution.
<986> <1/> <b>process_pause</b> = <2/> <l3>🔗</l>
<987> <1/> <b>set_process_pause</b>(value: <2/>)
<988> <1/> <b>get_process_pause</b>()
<989> If <1/>, process cooldown when the <2/> is paused.
<990> <1/> <b>start_cooled</b> = <2/> <l3>🔗</l>
<991> <1/> <b>set_start_cooled</b>(value: <2/>)
<992> <1/> <b>get_start_cooled</b>()
<993> If <1/>, initiate a cooldown as if the child had been executed before the first BT tick.
<994> <1/> <b>trigger_on_failure</b> = <2/> <l3>🔗</l>
<995> <1/> <b>set_trigger_on_failure</b>(value: <2/>)
<996> <1/> <b>get_trigger_on_failure</b>()
<997> If <1/>, the cooldown will be activated if the child task also returns <2/>. Otherwise, the cooldown will only be triggered when the child task returns <3/>.
<998> BTDecorator
<999> <b>Inherited By:</b> <l1>BTAlwaysFail</l>, <l2>BTAlwaysSucceed</l>, <l3>BTCooldown</l>, <l4>BTDelay</l>, <l5>BTForEach</l>, <l6>BTInvert</l>, <l7>BTNewScope</l>, <l8>BTProbability</l>, <l9>BTRepeat</l>, <l10>BTRepeatUntilFailure</l>, <l11>BTRepeatUntilSuccess</l>, <l12>BTRunLimit</l>, <l13>BTTimeLimit</l>
<1000> Base class for BT decorators.
<1001> Base class for all <l1>BehaviorTree</l> decorators. You can create your own decorators by extending the BTDecorator class.
<1002> A decorator is a task within a <l1>BehaviorTree</l> that alters the behavior of its child task. Decorators can have only one child task.
<1003> Decorators can be used to add conditions, limits, or other constraints to the execution of a task. Examples of <l1>BehaviorTree</l> decorators include <l2>BTInvert</l>, <l3>BTRepeat</l>, and <l4>BTCooldown</l>. The use of <l5>BehaviorTree</l> decorators can simplify the design and implementation of complex behaviors by adding additional logic to existing tasks.
<1004> BTDelay
<1005> BT decorator that introduces a delay before executing its child task.
<1006> BTDelay introduces a delay of <l1>seconds</l> before executing its child task. Returns <2/> during the delay period.
<1007> <l1>seconds</l>
<1008> <1/> <b>seconds</b> = <2/> <l3>🔗</l>
<1009> <1/> <b>set_seconds</b>(value: <2/>)
<1010> <1/> <b>get_seconds</b>()
<1011> Delay duration in seconds.
<1012> BTDynamicSelector
<1013> <b>Inherits:</b> <l1>BTComposite</l> <b><</b> <l2>BTTask</l> <b><</b> <l3>BT</l>
<1014> BT composite that executes tasks from scratch every tick until first <1/>.
<1015> BTDynamicSelector executes its child tasks sequentially, from first to last, until any child returns <1/>. Unlike <l2>BTSelector</l>, it will execute tasks from the beginning every tick, reevaluating their statuses. It is quite useful when you want to retry higher-priority behaviors in every tick.
<1016> Returns <1/> if a child task results in <2/>. BTDynamicSelector will remember the last <3/> child, but, unlike <l4>BTSequence</l>, on the next execution tick, it will reexecute preceding tasks and reevaluate their return statuses. If any of the preceding tasks doesn’t result in <5/>, it will abort the remembered <6/> task.
<1017> Returns <1/> if all child tasks result in <2/>.
<1018> Returns <1/> if a child task results in <2/>.
<1019> BTDynamicSequence
<1020> BT composite that executes tasks from scratch every tick as long as they return <1/>.
<1021> BTDynamicSequence executes its child tasks sequentially, from first to last, for as long as they return <1/>. Unlike <l2>BTSequence</l>, it will execute tasks from the beginning every tick, reevaluating their statuses. It is quite useful when you want to recheck conditions preceding a long-running action during each tick and abort the <3/> action when any condition results in <4/>.
<1022> Returns <1/> if a child task results in <2/>. BTDynamicSequence will remember the last <3/> child, but, unlike <l4>BTSequence</l>, on the next execution tick, it will reexecute preceding tasks and reevaluate their return statuses. If any of the preceding tasks doesn’t result in <5/>, it will abort the remembered <6/> task.
<1023> Returns <1/> if a child task results in <2/>.
<1024> Returns <1/> if all child tasks result in <2/>.
<1025> BTEvaluateExpression
<1026> BT action that evaluates an <1/> against a specified <2/> or <3/>.
<1027> BTEvaluateExpression action evaluates an <l1>expression_string</l> on the specified <2/> or <3/> instance and returns <4/> when the <5/> executes successfully.
<1028> Returns <1/> if the action encounters an issue during the <2/> parsing or execution.
<1029> <l1>expression_string</l>
<1030> <l1>input_include_delta</l>
<1031> <l1>input_names</l>
<1032> <l1>input_values</l>
<1033> <l1>node</l>
<1034> <l1>result_var</l>
<1035> Error
<1036> <l1>parse</l>()
<1037> <1/> <b>expression_string</b> = <2/> <l3>🔗</l>
<1038> <1/> <b>set_expression_string</b>(value: <2/>)
<1039> <1/> <b>get_expression_string</b>()
<1040> The expression string to be parsed and executed.
<1041> <b>Warning:</b> Call <l1>parse</l> after updating <l2>expression_string</l> to update the internal <3/> as it won’t be updated automatically.
<1042> <1/> <b>input_include_delta</b> = <2/> <l3>🔗</l>
<1043> <1/> <b>set_input_include_delta</b>(value: <2/>)
<1044> <1/> <b>is_input_delta_included</b>()
<1045> If enabled, the input variable <1/> will be added to <l2>input_names</l> and <l3>input_values</l>.
<1046> <b>Warning:</b> Call <l1>parse</l> after toggling <l2>input_include_delta</l> to update the internal <3/> as it won’t be updated automatically.
<1047> <1/> <b>input_names</b> = <2/> <l3>🔗</l>
<1048> <1/> <b>set_input_names</b>(value: <2/>)
<1049> <1/> <b>get_input_names</b>()
<1050> List of variable names within <l1>expression_string</l> for which the user will provide values for through <l2>input_values</l>.
<1051> <b>Warning:</b> Call <l1>parse</l> after updating <l2>input_names</l> to update the internal <3/> as it won’t be updated automatically.
<1052> Array[<l1>BBVariant</l>] <b>input_values</b> = <2/> <l3>🔗</l>
<1053> <1/> <b>set_input_values</b>(value: Array[<l2>BBVariant</l>])
<1054> Array[<l1>BBVariant</l>] <b>get_input_values</b>()
<1055> List of values for variables specified in <l1>input_names</l>. The values are mapped to the variables by their array index.
<1056> <l1>BBNode</l> <b>node</b> <l2>🔗</l>
<1057> <1/> <b>result_var</b> = <2/> <l3>🔗</l>
<1058> Error <b>parse</b>() <l1>🔗</l>
<1059> Calls Expression.parse considering <l1>input_include_delta</l> and <l2>input_names</l> and returns its error code.
<1060> BTFail
<1061> BT action that always returns <1/>.
<1062> BTForEach
<1063> BT decorator that executes its child task for each element of an <1/>.
<1064> BTForEach executes its child task for each element of an <1/>. During each iteration, the next element is stored in the specified <l2>Blackboard</l> variable.
<1065> Returns <1/> if the child task results in <2/> or if the child task results in <3/> on a non-last iteration.
<1066> Returns <1/> if the child task results in <2/>.
<1067> Returns <1/> if the child task results in <2/> on the last iteration.
<1068> <l1>array_var</l>
<1069> <l1>save_var</l>
<1070> <1/> <b>array_var</b> = <2/> <l3>🔗</l>
<1071> <1/> <b>set_array_var</b>(value: <2/>)
<1072> <1/> <b>get_array_var</b>()
<1073> A variable within the <l1>Blackboard</l> that holds an <2/>, which is used for the iteration process.
@@ -0,0 +1,158 @@
<1074> <1/> <b>save_var</b> = <2/> <l3>🔗</l>
<1075> <1/> <b>set_save_var</b>(value: <2/>)
<1076> <1/> <b>get_save_var</b>()
<1077> A <l1>Blackboard</l> variable used to store an element of the array referenced by <l2>array_var</l>.
<1078> BTInstance
<1079> Represents a runtime instance of a <l1>BehaviorTree</l> resource.
<1080> Can be created using the <l1>BehaviorTree.instantiate</l> method.
<1081> <l1>monitor_performance</l>
<1082> <l1>get_agent</l>() <2/>
<1083> <l1>get_blackboard</l>() <2/>
<1084> <l1>get_last_status</l>() <2/>
<1085> <l1>get_owner_node</l>() <2/>
<1086> <l1>get_root_task</l>() <2/>
<1087> <l1>get_source_bt_path</l>() <2/>
<1088> <l1>is_instance_valid</l>() <2/>
<1089> <l1>register_with_debugger</l>()
<1090> <l1>unregister_with_debugger</l>()
<1091> <l1>update</l>(delta: <2/>)
<1092> <b>freed</b>() <l1>🔗</l>
<1093> Emitted when the behavior tree instance is freed. Used by debugger to unregister.
<1094> <b>updated</b>(status: <1/>) <l2>🔗</l>
<1095> Emitted when the behavior tree instance has finished updating.
<1096> <1/> <b>monitor_performance</b> = <2/> <l3>🔗</l>
<1097> If <1/>, adds a performance monitor for this instance to “Debugger->Monitors” in the editor.
<1098> <1/> <b>get_agent</b>() <2/> <l3>🔗</l>
<1099> Returns the agent of the behavior tree instance.
<1100> <l1>Blackboard</l> <b>get_blackboard</b>() <2/> <l3>🔗</l>
<1101> Returns the blackboard of the behavior tree instance.
<1102> <l1>Status</l> <b>get_last_status</b>() <2/> <l3>🔗</l>
<1103> Returns the execution status of the last update.
<1104> <1/> <b>get_owner_node</b>() <2/> <l3>🔗</l>
<1105> Returns the scene <1/> that owns this behavior tree instance.
<1106> <l1>BTTask</l> <b>get_root_task</b>() <2/> <l3>🔗</l>
<1107> Returns the root task of the behavior tree instance.
<1108> <1/> <b>get_source_bt_path</b>() <2/> <l3>🔗</l>
<1109> Returns the file path to the behavior tree resource that was used to create this instance.
<1110> <1/> <b>is_instance_valid</b>() <2/> <l3>🔗</l>
<1111> Returns <1/> if the behavior tree instance is properly initialized and can be used.
<1112> <1/> <b>register_with_debugger</b>() <l2>🔗</l>
<1113> Registers the behavior tree instance with the debugger.
<1114> <1/> <b>unregister_with_debugger</b>() <l2>🔗</l>
<1115> Unregisters the behavior tree instance from the debugger. This is typically not necessary, as the debugger will automatically unregister the instance when it is freed.
<1116> <l1>Status</l> <b>update</b>(delta: <2/>) <l3>🔗</l>
<1117> Ticks the behavior tree instance and returns its status.
<1118> BTInvert
<1119> BT decorator that transforms <1/> into <2/> and <3/> into <4/>.
<1120> BTInvert transforms <1/> into <2/> and <3/> into <4/>.
<1121> BTNewScope
<1122> <b>Inherited By:</b> <l1>BTSubtree</l>
<1123> BT decorator that creates a new <l1>Blackboard</l> scope.
<1124> BTNewScope creates a new <l1>Blackboard</l> scope during initialization and populates it with data. See <l2>Blackboard</l>.
<1125> Returns the status of the child task execution.
<1126> <l1>blackboard_plan</l>
<1127> <l1>BlackboardPlan</l> <b>blackboard_plan</b> <l2>🔗</l>
<1128> BTParallel
<1129> BT composite that executes all of its child tasks simultaneously.
<1130> BTParallel executes all of its child tasks simultaneously. Note that BTParallel doesn’t involve multithreading. It processes each task sequentially, from first to last, in the same tick before returning a result. If one of the abort criterea is met, any tasks currently <1/> will be terminated, and the result will be either <2/> or <3/>. The <l4>num_failures_required</l> determines when BTParallel fails and <l5>num_successes_required</l> when it succeeds. When both are fullfilled, it gives priority to <l6>num_failures_required</l>.
<1131> If set to <l1>repeat</l>, all child tasks will be re-executed each tick, regardless of whether they previously resulted in <2/> or <3/>.
<1132> Returns <1/> when the required number of child tasks result in <2/>. When <l3>repeat</l> is set to <4/>, if none of the criteria were met and all child tasks resulted in either <5/> or <6/>, BTParallel will return <7/>.
<1133> Returns <1/> when the required number of child tasks result in <2/>.
<1134> Returns <1/> if none of the criterea were fulfilled, and either <l2>repeat</l> is set to <3/> or a child task resulted in <4/>.
<1135> <l1>num_failures_required</l>
<1136> <l1>num_successes_required</l>
<1137> <l1>repeat</l>
<1138> <1/> <b>num_failures_required</b> = <2/> <l3>🔗</l>
<1139> <1/> <b>set_num_failures_required</b>(value: <2/>)
<1140> <1/> <b>get_num_failures_required</b>()
<1141> If the specified number of child tasks return <1/>, BTParallel will also return <2/>.
<1142> <1/> <b>num_successes_required</b> = <2/> <l3>🔗</l>
<1143> <1/> <b>set_num_successes_required</b>(value: <2/>)
<1144> <1/> <b>get_num_successes_required</b>()
<1145> If the specified number of child tasks return <1/>, BTParallel will also return <2/>.
<1146> <1/> <b>repeat</b> = <2/> <l3>🔗</l>
<1147> <1/> <b>set_repeat</b>(value: <2/>)
<1148> <1/> <b>get_repeat</b>()
<1149> When <1/>, the child tasks will be executed again, regardless of whether they previously resulted in a <2/> or <3/>.
<1150> When <1/>, if none of the criteria were met, and all child tasks resulted in a <2/> or <3/>, BTParallel will return <4/>.
<1151> BTPauseAnimation
<1152> BT action that pauses the playback of an animation on the specified <1/> node.
<1153> BTPauseAnimation action pauses the playback of an animation on the specified <1/> node and returns <2/>.
<1154> Returns <1/> if the action fails to get the <2/> node.
<1155> <l1>animation_player</l>
<1156> <l1>BBNode</l> <b>animation_player</b> <l2>🔗</l>
<1157> BTPlayAnimation
<1158> BT action that plays an animation on the specified <1/> node.
<1159> BTPlayAnimation action plays an animation on the specified <1/> node. If the <l2>await_completion</l> is greater than <3/>, the action will wait for the animation to complete, with the maximum waiting time equal to <l4>await_completion</l>.
<1160> Returns <1/> if the animation finishes playing or if the elapsed time exceeds <l2>await_completion</l>. When <l3>await_completion</l> is set to <4/>, BTPlayAnimation doesn’t wait for the animation to finish and immediately returns <5/>.
<1161> Returns <1/> if the animation is still playing and the elapsed time is less than <l2>await_completion</l>.
<1162> Returns <1/> if the action fails to play the requested animation.
<1163> <l1>animation_name</l>
<1164> <l1>animation_player</l>
<1165> <l1>await_completion</l>
<1166> <l1>blend</l>
<1167> <l1>from_end</l>
<1168> <l1>speed</l>
<1169> <1/> <b>animation_name</b> = <2/> <l3>🔗</l>
<1170> Animation’s key within the <1/> node. If empty, BTPlayAnimation will resume the last played animation if the <2/> was previously paused.
<1171> <l1>BBNode</l> <b>animation_player</b> <l2>🔗</l>
<1172> <1/> <b>await_completion</b> = <2/> <l3>🔗</l>
<1173> <1/> <b>set_await_completion</b>(value: <2/>)
<1174> <1/> <b>get_await_completion</b>()
<1175> The maximum duration to wait for the animation to complete (in seconds). If the animation doesn’t finish within this time, BTPlayAnimation will stop waiting and return <1/>.
<1176> <1/> <b>blend</b> = <2/> <l3>🔗</l>
<1177> <1/> <b>set_blend</b>(value: <2/>)
<1178> <1/> <b>get_blend</b>()
<1179> Custom blend time (in seconds). See AnimationPlayer.play.
<1180> <1/> <b>from_end</b> = <2/> <l3>🔗</l>
<1181> <1/> <b>set_from_end</b>(value: <2/>)
<1182> <1/> <b>get_from_end</b>()
<1183> Play animation from the end. Used in combination with negative <l1>speed</l> to play animation in reverse. See AnimationPlayer.play.
<1184> <1/> <b>speed</b> = <2/> <l3>🔗</l>
<1185> <1/> <b>set_speed</b>(value: <2/>)
<1186> <1/> <b>get_speed</b>()
<1187> Custom playback speed scaling ratio. See AnimationPlayer.play.
<1188> BTProbability
<1189> BT decorator that executes its child task with a given probability.
<1190> BTProbability executes its child task with a given probability defined by <l1>run_chance</l>.
<1191> Returns the result of the child task if it was executed; otherwise, it returns <1/>.
<1192> <l1>run_chance</l>
<1193> <1/> <b>run_chance</b> = <2/> <l3>🔗</l>
<1194> <1/> <b>set_run_chance</b>(value: <2/>)
<1195> <1/> <b>get_run_chance</b>()
<1196> Probability that defines how likely the child task will be executed.
<1197> BTProbabilitySelector
<1198> BT composite that chooses a child task to execute based on attached probabilities.
<1199> BTProbabilitySelector chooses a child task to execute based on attached probabilities. It is typically used for decision-making purposes. Probability distribution is calculated based on weights assigned to each child task.
<1200> Returns <1/> when a child task results in <2/>.
<1201> Returns <1/> when a child task results in <2/>.
<1202> The behavior of BTProbabilitySelector when a child task results in <1/> depends on the <l2>abort_on_failure</l> value:
<1203> If <l1>abort_on_failure</l> is <2/>, when a child task results in <3/>, BTProbabilitySelector will normalize the probability distribution over the remaining children and choose a new child task to be executed. If all child tasks fail, the composite will return <4/>.
<1204> If <l1>abort_on_failure</l> is <2/>, when a child task results in <3/>, BTProbabilitySelector will not choose another child task to be executed and will immediately return <4/>.
<1205> <l1>abort_on_failure</l>
<1206> <l1>get_probability</l>(child_idx: <2/>) <3/>
<1207> <l1>get_total_weight</l>() <2/>
<1208> <l1>get_weight</l>(child_idx: <2/>) <3/>
<1209> <l1>has_probability</l>(child_idx: <2/>) <3/>
<1210> <l1>set_probability</l>(child_idx: <2/>, probability: <3/>)
<1211> <l1>set_weight</l>(child_idx: <2/>, weight: <3/>)
<1212> <1/> <b>abort_on_failure</b> = <2/> <l3>🔗</l>
<1213> <1/> <b>set_abort_on_failure</b>(value: <2/>)
<1214> <1/> <b>get_abort_on_failure</b>()
<1215> If <1/>, BTProbabilitySelector will not choose another child to execute and will return <2/> when a child task results in <3/>.
<1216> <1/> <b>get_probability</b>(child_idx: <2/>) <3/> <l4>🔗</l>
<1217> Returns the child task’s selection probability.
<1218> <1/> <b>get_total_weight</b>() <2/> <l3>🔗</l>
<1219> Returns the total weight of all child tasks.
<1220> <1/> <b>get_weight</b>(child_idx: <2/>) <3/> <l4>🔗</l>
<1221> Returns the child task’s weight within the weighted probability selection algorithm.
<1222> <1/> <b>has_probability</b>(child_idx: <2/>) <3/> <l4>🔗</l>
<1223> Returns whether the child task at index <1/> participates within the weighted probability selection algorithm and has a probability assigned to it. Returns <2/> for <l3>BTComment</l> tasks.
<1224> <1/> <b>set_probability</b>(child_idx: <2/>, probability: <3/>) <l4>🔗</l>
<1225> Sets the child task’s weight calculated based on the desired probability.
<1226> <1/> <b>set_weight</b>(child_idx: <2/>, weight: <3/>) <l4>🔗</l>
<1227> Sets the child task’s weight for the weighted probability selection algorithm.
<1228> BTRandomSelector
<1229> BT composite that executes tasks in random order until first <1/>.
<1230> BTRandomSelector executes its child tasks in a random order until any child returns <1/>. If a child task results in <2/>, BTRandomSelector will immediately execute another child task until one of them returns <3/> or all of them result in <4/>.
<1231> Returns <1/> if a child task results in <2/>. BTRandomSelector will remember the execution order and the last child task that returned <3/>, ensuring it resumes from that point in the next tick.
@@ -0,0 +1,159 @@
<1232> BTRandomSequence
<1233> BT composite that executes tasks in random order as long as they return <1/>.
<1234> BTRandomSequence executes its child tasks in a random order as long as they return <1/>. If a child task results in <2/>, BTRandomSequence will immediately execute the next child task until one of them returns <3/> or all of them result in <4/>.
<1235> Returns <1/> if a child task results in <2/>. BTRandomSequence will remember the execution order and the last child task that returned <3/>, ensuring it resumes from that point in the next tick.
<1236> BTRandomWait
<1237> BT action that waits for a randomized duration to elapse and then returns <1/>.
<1238> BTRandomWait action waits for a randomized duration to elapse and then returns <1/>. The duration is randomized within the specified range, defined by <l2>min_duration</l> and <l3>max_duration</l>.
<1239> <l1>max_duration</l>
<1240> <l1>min_duration</l>
<1241> <1/> <b>max_duration</b> = <2/> <l3>🔗</l>
<1242> <1/> <b>set_max_duration</b>(value: <2/>)
<1243> <1/> <b>get_max_duration</b>()
<1244> Maximum duration for the wait.
<1245> <1/> <b>min_duration</b> = <2/> <l3>🔗</l>
<1246> <1/> <b>set_min_duration</b>(value: <2/>)
<1247> <1/> <b>get_min_duration</b>()
<1248> Minimum duration for the wait.
<1249> BTRepeat
<1250> BT decorator that repeats its child a specified number of <l1>times</l>.
<1251> BTRepeat iterates its child task a specified number of times, as defined by <l1>times</l>. If <l2>forever</l> is <3/>, the child’s execution will be repeated indefinitely.
<1252> Returns <1/> if the child task results in <2/>. If <l3>forever</l> is <4/>, BTRepeat will always return <5/>.
<1253> Returns <1/> if the specified number of successful executions is reached. If <l2>abort_on_failure</l> is <3/>, a <4/> status returned by the child is also considered a successful execution.
<1254> Returns <1/> if the child task results in <2/> when <l3>abort_on_failure</l> is <4/>.
<1255> <l1>abort_on_failure</l>
<1256> <l1>forever</l>
<1257> <l1>times</l>
<1258> <1/> <b>abort_on_failure</b> = <2/> <l3>🔗</l>
<1259> If <1/>, <2/> status returned by the child task is also considered as a successful execution.
<1260> <1/> <b>forever</b> = <2/> <l3>🔗</l>
<1261> <1/> <b>set_forever</b>(value: <2/>)
<1262> <1/> <b>get_forever</b>()
<1263> If <1/>, the child’s execution will be repeated indefinitely, always returning <2/>.
<1264> <1/> <b>times</b> = <2/> <l3>🔗</l>
<1265> <1/> <b>set_times</b>(value: <2/>)
<1266> <1/> <b>get_times</b>()
<1267> The number of times to repeat execution of the child task.
<1268> BTRepeatUntilFailure
<1269> BT decorator that repeats its child task until <1/>.
<1270> BTRepeatUntilFailure repeats its child task until it results in <1/>.
<1271> Returns <1/> if the child task results in <2/> or <3/>.
<1272> Returns <1/> if the child task results in <2/>.
<1273> BTRepeatUntilSuccess
<1274> BT decorator that repeats its child task until <1/>.
<1275> BTRepeatUntilSuccess repeats its child task until it results in <1/>.
<1276> Returns <1/> if the child task results in <2/> or <3/>.
<1277> Returns <1/> if the child task results in <2/>.
<1278> BTRunLimit
<1279> BT decorator that restricts the execution of its child a limited number of times.
<1280> BTRunLimit restricts the execution of the child task to a maximum number of times, defined by <l1>run_limit</l>.
<1281> Returns <1/> if the limit on executions is exceeded; otherwise, it returns the status of the child task.
<1282> <l1>CountPolicy</l>
<1283> <l1>count_policy</l>
<1284> <l1>run_limit</l>
<1285> enum <b>CountPolicy</b>: <l1>🔗</l>
<1286> <l1>CountPolicy</l> <b>COUNT_SUCCESSFUL</b> = <2/>
<1287> Count only successful runs towards the limit.
<1288> <l1>CountPolicy</l> <b>COUNT_FAILED</b> = <2/>
<1289> Count only failed runs towards the limit.
<1290> <l1>CountPolicy</l> <b>COUNT_ALL</b> = <2/>
<1291> Count successful and failed runs towards the limit.
<1292> <l1>CountPolicy</l> <b>count_policy</b> = <2/> <l3>🔗</l>
<1293> <1/> <b>set_count_policy</b>(value: <l2>CountPolicy</l>)
<1294> <l1>CountPolicy</l> <b>get_count_policy</b>()
<1295> Which runs should be counted towards the limit: successful, failed, or all?
<1296> <1/> <b>run_limit</b> = <2/> <l3>🔗</l>
<1297> <1/> <b>set_run_limit</b>(value: <2/>)
<1298> <1/> <b>get_run_limit</b>()
<1299> The maximum number of times the child is permitted to be executed.
<1300> BTSelector
<1301> BT composite that sequentially executes tasks until first <1/>.
<1302> BTSelector executes its child tasks sequentially, from first to last, until any child returns <1/>. If a child task results in <2/>, BTSelector will immediately execute the next child task until one of them returns <3/> or all of them result in <4/>. BTSelector and <l5>BTSequence</l> are two of the most common building blocks of behavior trees. Essentially, while <l6>BTSequence</l> is similar to a boolean AND operation, BTSelector is similar to a boolean OR operation. Selectors enable the behavior tree to respond to changes in the environment and trigger transitions between various fallback behaviors.
<1303> Returns <1/> if a child task results in <2/>. BTSelector will remember the last child task that returned <3/>, ensuring it resumes from that point in the next execution tick.
<1304> BTSequence
<1305> BT composite that sequentially executes tasks as long as they return <1/>.
<1306> BTSequence executes its child tasks sequentially, from first to last, as long as they return <1/>. If a child task results in <2/>, BTSequence will immediately execute the next child task until one of them returns <3/> or all of them result in <4/>. BTSequence and <l5>BTSelector</l> are two of the most common building blocks of behavior trees. Essentially, while <l6>BTSelector</l> is similar to a boolean OR operation, BTSequence is similar to a boolean AND operation. Sequences enable the behavior tree to compose complex behaviors from a chain of simpler tasks.
<1307> Returns <1/> if any child task results in <2/>. BTSequence will remember the last child task that returned <3/>, ensuring it resumes from that point in the next execution tick.
<1308> BTSetAgentProperty
<1309> BT action that assigns a value to the specified agent’s property.
<1310> BTSetAgentProperty assigns the specified <l1>value</l> to the agent’s property identified by the <l2>property</l> and returns <3/>. Optionally, it can perform a specific <l4>operation</l> before assignment.
<1311> Returns <1/> if it fails to set the property.
<1312> <l1>Operation</l>
<1313> <l1>operation</l>
<1314> <l1>property</l>
<1315> <l1>value</l>
<1316> <l1>Operation</l> <b>operation</b> = <2/> <l3>🔗</l>
<1317> <1/> <b>set_operation</b>(value: <l2>Operation</l>)
<1318> <l1>Operation</l> <b>get_operation</b>()
<1319> Specifies the operation to be performed before assignment. Operation is executed as follows:
<1320> <1/> <b>property</b> = <2/> <l3>🔗</l>
<1321> Parameter that specifies the agent’s property name.
<1322> <l1>BBVariant</l> <b>value</b> <l2>🔗</l>
<1323> Parameter that specifies the value that will be assigned to agent’s property.
<1324> BTSetVar
<1325> BT action that assigns <l1>value</l> to the <l2>variable</l> and then returns <3/>.
<1326> BTSetVar assigns <l1>value</l> to the <l2>variable</l> and then returns <3/>. Optionally, it can perform a specific <l4>operation</l> before assignment.
<1327> Returns <1/> if it fails to set the <l2>variable</l>.
<1328> <l1>operation</l>
<1329> <l1>value</l>
<1330> <l1>variable</l>
<1331> <l1>Operation</l> <b>operation</b> = <2/> <l3>🔗</l>
<1332> <l1>BBVariant</l> <b>value</b> <l2>🔗</l>
<1333> Parameter that specifies the value to be assigned to the variable.
<1334> <1/> <b>variable</b> = <2/> <l3>🔗</l>
<1335> Name of the variable to which the value will be assigned.
<1336> BTStopAnimation
<1337> BT action that stops the playback of an animation on the specified <1/> node.
<1338> BTStopAnimation action stops the playback of an animation on the specified <1/> node and returns <2/>. If <l3>animation_name</l> is set, it will only stop the playback if the specified animation is currently playing.
<1339> <l1>animation_name</l>
<1340> <l1>animation_player</l>
<1341> <l1>keep_state</l>
<1342> <1/> <b>animation_name</b> = <2/> <l3>🔗</l>
<1343> Animation’s key within the <1/> node. If not empty, BTStopAnimation will only stop the playback if the specified animation is currently playing.
<1344> <l1>BBNode</l> <b>animation_player</b> <l2>🔗</l>
<1345> <1/> <b>keep_state</b> = <2/> <l3>🔗</l>
<1346> <1/> <b>set_keep_state</b>(value: <2/>)
<1347> <1/> <b>get_keep_state</b>()
<1348> If <1/>, the animation state is not updated visually.
<1349> BTSubtree
<1350> <b>Inherits:</b> <l1>BTNewScope</l> <b><</b> <l2>BTDecorator</l> <b><</b> <l3>BTTask</l> <b><</b> <l4>BT</l>
<1351> BT decorator that instantiates and runs a subtree within the larger tree.
<1352> BTSubtree instantiates a <l1>BehaviorTree</l> and includes its root task as a child during initialization, while also creating a new <l2>Blackboard</l> scope.
<1353> Returns the status of the subtree’s execution.
<1354> Subtree blackboard variables can be mapped to the main tree blackboard plan variables. Check out mapping section in the inspector.
<1355> Note: BTSubTree is designed as a simpler loader, and does not support updating <l1>subtree</l> at runtime. A custom subtree decorator is better suited:
<1356> This allows for the <l1>BehaviorTree</l> to load a specific behavior tree from a variable in the blackboard, or from some other location, and use that new behavior tree at runtime.
<1357> <l1>subtree</l>
<1358> <l1>BehaviorTree</l> <b>subtree</b> <l2>🔗</l>
<1359> <1/> <b>set_subtree</b>(value: <l2>BehaviorTree</l>)
<1360> <l1>BehaviorTree</l> <b>get_subtree</b>()
<1361> A <l1>BehaviorTree</l> resource that will be instantiated as a subtree.
<1362> BTTimeLimit
<1363> BT decorator that sets a time limit for its child’s execution.
<1364> BTTimeLimit allocates a limited time for the child’s execution and aborts it, returning <1/> if the <l2>time_limit</l> is exceeded.
<1365> Returns <1/> if the <l2>time_limit</l> is exceeded; otherwise, it returns the status of the child task.
<1366> <l1>time_limit</l>
<1367> <1/> <b>time_limit</b> = <2/> <l3>🔗</l>
<1368> <1/> <b>set_time_limit</b>(value: <2/>)
<1369> <1/> <b>get_time_limit</b>()
<1370> Time allocated for the child task’s execution.
<1371> BTWait
<1372> BT action that waits for a specified <l1>duration</l> to elapse and then returns <2/>.
<1373> <l1>duration</l>
<1374> <1/> <b>duration</b> = <2/> <l3>🔗</l>
<1375> There is currently no description for this property. Please help us by contributing one!
<1376> BTWaitTicks
<1377> BT action that waits for a specified number of ticks to elapse and then returns <1/>.
<1378> <l1>num_ticks</l>
<1379> <1/> <b>num_ticks</b> = <2/> <l3>🔗</l>
<1380> <1/> <b>set_num_ticks</b>(value: <2/>)
<1381> <1/> <b>get_num_ticks</b>()
<1382> The number of ticks to wait before returning <1/>.
<1383> LimboUtility
<1384> Helper functions for LimboAI.
<1385> <l1>decorate_output_var</l>(variable: <2/>) <3/>
<1386> <l1>decorate_var</l>(variable: <2/>) <3/>
<1387> <l1>get_check_operator_string</l>(check: <l2>CheckType</l>) <3/>
<1388> <l1>get_operation_string</l>(operation: <l2>Operation</l>) <3/>
<1389> <l1>get_status_name</l>(status: <2/>) <3/>
<1390> <l1>get_task_icon</l>(class_or_script_path: <2/>) <3/>
@@ -0,0 +1,61 @@
<1391> <l1>perform_check</l>(check: <l2>CheckType</l>, a: <3/>, b: <4/>)
<1392> <l1>perform_operation</l>(operation: <l2>Operation</l>, a: <3/>, b: <4/>)
<1393> enum <b>CheckType</b>: <l1>🔗</l>
<1394> <l1>CheckType</l> <b>CHECK_EQUAL</b> = <2/>
<1395> Equality Check.
<1396> <l1>CheckType</l> <b>CHECK_LESS_THAN</b> = <2/>
<1397> Less Than Check.
<1398> <l1>CheckType</l> <b>CHECK_LESS_THAN_OR_EQUAL</b> = <2/>
<1399> Less Than or Equal To Check.
<1400> <l1>CheckType</l> <b>CHECK_GREATER_THAN</b> = <2/>
<1401> Greater Than Check.
<1402> <l1>CheckType</l> <b>CHECK_GREATER_THAN_OR_EQUAL</b> = <2/>
<1403> Greater Than or Equal To Check
<1404> <l1>CheckType</l> <b>CHECK_NOT_EQUAL</b> = <2/>
<1405> Inequality Check.
<1406> enum <b>Operation</b>: <l1>🔗</l>
<1407> <l1>Operation</l> <b>OPERATION_NONE</b> = <2/>
<1408> No operation.
<1409> <l1>Operation</l> <b>OPERATION_ADDITION</b> = <2/>
<1410> Arithmetic addition.
<1411> <l1>Operation</l> <b>OPERATION_SUBTRACTION</b> = <2/>
<1412> Arithmetic subtraction.
<1413> <l1>Operation</l> <b>OPERATION_MULTIPLICATION</b> = <2/>
<1414> Arithmetic multiplication.
<1415> <l1>Operation</l> <b>OPERATION_DIVISION</b> = <2/>
<1416> Arithmetic division.
<1417> <l1>Operation</l> <b>OPERATION_MODULO</b> = <2/>
<1418> Produces the remainder of an integer division.
<1419> <l1>Operation</l> <b>OPERATION_POWER</b> = <2/>
<1420> Multiply <1/> by itself <2/> times.
<1421> <l1>Operation</l> <b>OPERATION_BIT_SHIFT_LEFT</b> = <2/>
<1422> Bitwise left shift.
<1423> <l1>Operation</l> <b>OPERATION_BIT_SHIFT_RIGHT</b> = <2/>
<1424> Bitwise right shift.
<1425> <l1>Operation</l> <b>OPERATION_BIT_AND</b> = <2/>
<1426> Bitwise AND.
<1427> <l1>Operation</l> <b>OPERATION_BIT_OR</b> = <2/>
<1428> Bitwise OR.
<1429> <l1>Operation</l> <b>OPERATION_BIT_XOR</b> = <2/>
<1430> Bitwise XOR.
<1431> <1/> <b>decorate_output_var</b>(variable: <2/>) <3/> <l4>🔗</l>
<1432> Just like <l1>decorate_var</l>, produces a string with a <l2>Blackboard</l> variable name that is formatted for display, and also adds an additional symbol to indicate that the variable is used as an output.
<1433> <1/> <b>decorate_var</b>(variable: <2/>) <3/> <l4>🔗</l>
<1434> Produces a string with a <l1>Blackboard</l> variable name that is formatted for display or console output.
<1435> <1/> <b>get_check_operator_string</b>(check: <l2>CheckType</l>) <3/> <l4>🔗</l>
<1436> Returns an operator string for a <l1>CheckType</l> enum value. For example, <l2>CHECK_EQUAL</l> returns “==”.
<1437> <1/> <b>get_operation_string</b>(operation: <l2>Operation</l>) <3/> <l4>🔗</l>
<1438> Returns a string representation of an <l1>Operation</l> enum value.
<1439> <1/> <b>get_status_name</b>(status: <2/>) <3/> <l4>🔗</l>
<1440> Returns a name of a <l1>BTTask</l> status code.
<1441> <1/> <b>get_task_icon</b>(class_or_script_path: <2/>) <3/> <l4>🔗</l>
<1442> Returns the icon texture associated with a task based on its class name or script resource path.
<1443> <1/> <b>perform_check</b>(check: <l2>CheckType</l>, a: <3/>, b: <4/>) <l5>🔗</l>
<1444> Performs a <1/> on two values, <2/> and <3/>, and returns <4/> if the check passes.
<1445> <1/> <b>perform_operation</b>(operation: <l2>Operation</l>, a: <3/>, b: <4/>) <l5>🔗</l>
<1446> Performs an <1/> on two values, <2/> and <3/>, and returns the result.
<1447> Getting started
<1448> Behavior Trees
<1449> Hierarchical State MachineS
<1450> Class reference
<1451> LimboAI 1.0 documentation
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
"""Rebuild the Chinese HTML from translated templates."""
import json, re, sys, glob, html as htmllib
from bs4 import BeautifulSoup, Comment, NavigableString
ROOT = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable"
SRC = ROOT + "/index.html"
OUT = ROOT + "/index.zh.html"
I18N = ROOT + "/_i18n"
sys.path.insert(0, I18N)
from serialize import collect # noqa: E402
CHECK_ONLY = "--check" in sys.argv
soup = BeautifulSoup(open(SRC, encoding="utf-8").read(), "lxml")
blocks = collect(soup)
title_node = soup.find("title")
uniq = json.load(open(I18N + "/uniq.json", encoding="utf-8"))["uniq"]
def key_of(tpl, markers):
return tpl + "\x00" + json.dumps(markers, sort_keys=True)
key2uid = {k: v["uid"] for k, v in uniq.items()}
trans = {}
for f in sorted(glob.glob(I18N + "/trans/batch*.json")):
for k, v in json.load(open(f, encoding="utf-8")).items():
trans[int(k)] = v
for b in blocks:
b["uid"] = key2uid.get(key_of(b["tpl"], b["markers"]))
tok_re = re.compile(r"<(l\d+)>|</l\d*>|<([bi])>|</[bi]>|<(\d+)/>")
def norm(t):
"""normalise the tolerated closing-tag variants written by the translator"""
return re.sub(r"</l\d*>", "</l>", t)
def tokens(t):
"""only the tokens that MUST round-trip identically: opaque ids and anchor ids"""
return sorted(m.group(0) for m in re.finditer(r"<(l\d+)>|</l\d*>|<(\d+)/>", t)
if m.group(2) or m.group(1))
def balanced(t):
"""<b>/<i> pairs must be balanced (extra emphasis is allowed)"""
depth = 0
for m in re.finditer(r"<([bi])>|</([bi])>", t):
depth += 1 if m.group(1) else -1
if depth < 0:
return False
return depth == 0
def markers_ok(b, zh):
zh = norm(zh)
return tokens(b["tpl"]) == tokens(zh) and balanced(zh)
untranslated = [b for b in blocks if b["uid"] not in trans]
bad = [b for b in blocks if b["uid"] in trans and not markers_ok(b, trans[b["uid"]])]
print("blocks:", len(blocks),
"| untranslated:", len(untranslated),
"| marker problems:", len(bad))
for b in bad[:20]:
print("--- uid", b["uid"])
print(" en:", b["tpl"][:200])
print(" zh:", trans[b["uid"]][:200])
if CHECK_ONLY:
sys.exit(0)
def rebuild(tpl, markers):
"""turn a translated template back into HTML"""
tpl = norm(tpl)
out, pos = [], 0
for m in tok_re.finditer(tpl):
out.append(htmllib.escape(tpl[pos:m.start()], quote=False))
g = m.group(0)
if g.startswith("<l"):
out.append(markers[g[2:-1]]) # nested anchor: keep as-is
elif g == "</l>":
out.append("</a>")
elif g.startswith("<b>") or g.startswith("<i>"):
out.append("<strong>" if g.startswith("<b") else "<em>")
elif g.startswith("</b") or g.startswith("</i"):
out.append("</strong>" if g.startswith("</b") else "</em>")
else:
out.append(markers[re.search(r"\d+", g).group(0)])
pos = m.end()
out.append(htmllib.escape(tpl[pos:], quote=False))
return "".join(out)
def replace_anchor_inner(marker_html, new_inner):
"""keep the original <a> attributes + inner wrapper, swap the text"""
frag = BeautifulSoup(marker_html, "html.parser")
root = frag.find(True)
node = root
while True:
kids = [k for k in node.children if not isinstance(k, Comment)]
if len(kids) == 1 and getattr(kids[0], "name", None):
node = kids[0]
else:
break
for k in list(node.children):
k.extract()
inner = BeautifulSoup(new_inner, "html.parser")
for k in list(inner.children):
node.append(k.extract())
return str(root)
def build_block_html(tpl, markers):
parts = re.split(r"(<l\d+>.*?</l\d*>)", norm(tpl), flags=re.S)
out = []
for p in parts:
m = re.fullmatch(r"<l(\d+)>(.*?)</l\d*>", p, flags=re.S)
if m:
inner = rebuild(m.group(2), markers)
out.append(replace_anchor_inner(markers[m.group(1)], inner))
else:
out.append(rebuild(p, markers))
return "".join(out)
def set_inner(el, html_str):
el.clear()
frag = BeautifulSoup(html_str, "html.parser")
for k in list(frag.children):
el.append(k.extract())
collect_ids = set(id(b["node"]) for b in blocks if b["node"] is not None)
# a block that *contains* other collected blocks (e.g. <td><p>…) must keep its own
# markup untouched - its text is rebuilt by those nested blocks
containers = set()
for b in blocks:
n = b["node"]
if n is None:
continue
if any(id(d) in collect_ids for d in n.find_all(True)):
containers.add(id(n))
print("container blocks kept as-is:", len(containers))
fixed = 0
for b in blocks:
uid = b["uid"]
zh = trans.get(uid, b["tpl"])
if uid not in trans or not markers_ok(b, zh):
continue
if id(b["node"]) in containers:
continue
headerlinks = [h.extract() for h in b["node"].find_all("a", class_="headerlink")]
html_str = build_block_html(zh, b["markers"])
set_inner(b["node"], html_str)
for h in headerlinks:
b["node"].append(h)
fixed += 1
if title_node is not None and 1451 in trans:
title_node.string = trans[1451]
# ---------------------------------------------------------------- polish pass
ABBR_TITLE = {
"No return value.": "无返回值。",
"This method has no side effects. It doesn't modify any of the instance's member variables.":
"该方法没有副作用,不会修改实例的任何成员变量。",
"This method should typically be overridden by the user to have any effect.":
"该方法通常需要由用户重写才能生效。",
"This method doesn't need an instance to be called, so it can be called directly using the class name.":
"该方法无需实例即可调用,因此可以直接用类名调用。",
}
for el in soup.find_all(attrs={"title": True}):
t = el.get("title")
if t in ABBR_TITLE:
el["title"] = ABBR_TITLE[t]
# breadcrumb
for li in soup.select("li.breadcrumb-item"):
if li.get_text(strip=True) == "LimboAI 1.0 documentation":
li.string = "LimboAI 1.0 文档"
# footer
foot = soup.find("footer")
if foot:
info = foot.find("div", {"role": "contentinfo"})
if info and info.find("p"):
info.find("p").string = "© 2023 年至今 Serhii Snitsaruk 及 LimboAI 贡献者。"
for child in list(foot.children):
if isinstance(child, NavigableString):
s = str(child)
s = s.replace("Built with", "使用 ")
s = s.replace("using a", " 构建,")
s = s.replace("provided by", " 由 ")
child.replace_with(s)
elif getattr(child, "name", None) == "a" and child.get_text(strip=True) == "theme":
child.string = "主题(theme)"
last = foot.contents[-1] if foot.contents else None
if isinstance(last, NavigableString):
last.replace_with(" 提供。\n")
# code comments inside <pre>
CODE_COMMENTS = {
"# Set common target shared between agents in a group:": "# 设置组内各智能体共享的公共目标:",
"# to prevent errors when the instance is freed.": "# 以避免实例被释放时出错。",
'# - Important: Avoid specifying a type for "obj" in GDScript': '# - 重要:在 GDScript 中不要为 "obj" 指定类型',
"# Access common target shared between agents in a group:": "# 访问组内各智能体共享的公共目标:",
'# Check if the "speed" variable exists:': '# 检查 "speed" 变量是否存在:',
'# Get object instance stored in the "object" variable.': '# 获取存储在 "object" 变量中的对象实例。',
'# Get the value of the "speed" variable, with a default value of 100.0 if not found:':
'# 获取 "speed" 变量的值,如果不存在则默认为 100.0:',
'# Handle the "movement_started" event here.': '# 在这里处理 "movement_started" 事件。',
"# It's quite handy when you need to pass some data to the event handler.":
"# 当你需要向事件处理器传递一些数据时,这非常方便。",
'# Set the behavior tree, or use a load("res://ai/trees/...") call':
'# 设置行为树,或使用 load("res://ai/trees/...") 调用',
'# Set the value of the "speed" variable:': '# 设置 "speed" 变量的值:',
"# Use chained methods and delegation to set up states:": "# 使用链式方法与委托来配置状态:",
"# `cargo` can be passed with the event when calling `dispatch()`.":
"# 调用 `dispatch()` 时可以随事件一起传入 `cargo`。",
"# Assume `hsm` is a LimboHSM instance and `bt_state` is a BTState instance":
"# 假设 `hsm` 是一个 LimboHSM 实例,`bt_state` 是一个 BTState 实例",
"# Called each time this task is ticked (aka executed).": "# 每次该任务被 tick(即被执行)时调用。",
"# Called to generate a display name for the task (requires @tool).":
"# 调用它来为任务生成显示名称(需要 @tool)。",
"# Called to generate a display name for the task.": "# 调用它来为任务生成显示名称。",
"# Called to initialize the task.": "# 调用它来初始化任务。",
"# Called when the task is entered.": "# 当任务被进入时调用。",
"# Called when the task is executed.": "# 当任务被执行时调用。",
"# Called when the task is exited.": "# 当任务被退出时调用。",
"# Example: programmatically link a BT blackboard variable to an HSM blackboard variable":
"# 示例:用代码把 BT 黑板变量链接到 HSM 黑板变量",
"# NOTE: variable names do not need to match": "# 注意:变量名不必相同",
"# Strings returned from this method are displayed as warnings in the editor.":
"# 此方法返回的字符串会作为警告显示在编辑器中。",
"# Task parameters.": "# 任务参数。",
"## AgentGroup node: Manages the shared Blackboard for agents in a group.":
"## AgentGroup 节点:管理组内各智能体共享的 Blackboard。",
'## At minimum, you only need to define the "_tick" method.': '## 至少只需要定义 "_tick" 方法。',
"## Called each frame when this state is active.": "## 当该状态处于活动状态时每帧调用。",
"## Called once, when state is initialized.": "## 状态初始化时调用一次。",
"## Called when state is entered.": "## 当状态被进入时调用。",
"## Called when state is exited.": "## 当状态被退出时调用。",
"## Children of this node are assumed to be agents that belong to a common group.":
"## 该节点的子节点被假定为属于同一组的智能体。",
"## InRange condition checks if the agent is within a range of target,":
"## InRange 条件检查智能体是否处于目标的某个范围内,",
"## Note: Each method declaration is optional.": "## 注意:每个方法声明都是可选的。",
"## PlayAnimation: Play an animation on AnimationPlayer, and wait for it to finish.":
"## PlayAnimation:在 AnimationPlayer 上播放动画,并等待其播放完毕。",
"## Returns FAILURE if the node is not found.": "## 如果找不到该节点,返回 FAILURE。",
"## Returns SUCCESS if the agent is within the defined range;":
"## 如果智能体处于定义的范围内,返回 SUCCESS;",
"## Shows or hides a node and returns SUCCESS.": "## 显示或隐藏一个节点并返回 SUCCESS。",
'## This implementation assumes that each agent has a "BTPlayer" node for AI.':
'## 该实现假定每个智能体都有一个用于 AI 的 "BTPlayer" 节点。',
"## defined by distance_min and distance_max.": "## 该范围由 distance_min 和 distance_max 定义。",
"## otherwise, returns FAILURE.": "## 否则返回 FAILURE。",
}
hits = 0
for pre in soup.find_all("pre"):
for txt in pre.find_all(string=True):
s = str(txt)
orig = s
for en, zh in CODE_COMMENTS.items():
if en in s:
s = s.replace(en, zh)
if s != orig:
txt.replace_with(s)
hits += 1
print("code comment spans translated:", hits)
# translation badge in the sidebar
logo_link = soup.select_one(".wy-side-nav-search > a")
if logo_link is not None:
badge = BeautifulSoup(
'<p style="font-size:0.78em;opacity:0.8;margin:0.35em 0 0.2em;">'
'中文汉化版 · 基于官方英文文档翻译,欢迎对照原文阅读</p>', "html.parser")
for k in list(badge.children):
logo_link.insert_after(k.extract())
out = str(soup)
out = out.replace('<html class="writer-html5" lang="en"',
'<html class="writer-html5" lang="zh-CN"', 1)
out = out.replace("<title>LimboAI 1.0 documentation</title>",
"<title>LimboAI 1.0 中文文档</title>")
open(OUT, "w", encoding="utf-8").write(out)
print("rebuilt blocks:", fixed, "->", OUT)
+57
View File
@@ -0,0 +1,57 @@
"""Extract translatable text nodes from the Sphinx single-page HTML dump."""
import json, re, sys
from bs4 import BeautifulSoup, NavigableString, Comment
SRC = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable/index.html"
OUT = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable/_i18n/strings.json"
SKIP_TAGS = {"script", "style", "code", "pre", "kbd", "samp", "var", "tt"}
SKIP_CLASS = {"headerlink", "wy-breadcrumbs-aside", "version-switch", "language-switch"}
html = open(SRC, encoding="utf-8").read()
soup = BeautifulSoup(html, "lxml")
records = []
counter = 0
def parent_skipped(node):
for p in node.parents:
if getattr(p, "name", None) is None:
continue
if p.name in SKIP_TAGS:
return True
cls = p.get("class") or []
if any(c in SKIP_CLASS for c in cls):
return True
return False
def has_letter(t):
return bool(re.search(r"[A-Za-z]", t)) or bool(re.search(r"[\u4e00-\u9fff]", t))
for node in soup.find_all(string=True):
if isinstance(node, Comment):
continue
t = str(node)
if not has_letter(t):
continue
if parent_skipped(node):
continue
# keep trailing/leading spaces for reconstruction
stripped = t.strip()
if not stripped:
continue
counter += 1
records.append({
"id": counter,
"text": t,
"parents": [p.name for p in node.parents if getattr(p, "name", None)][:4],
})
json.dump(records, open(OUT, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
total_chars = sum(len(r["text"]) for r in records)
print("nodes:", len(records), "chars:", total_chars)
from collections import Counter
print(Counter(tuple(r["parents"][:2]) for r in records).most_common(20))
@@ -0,0 +1,83 @@
"""Extract translatable BLOCKS (whole paragraph / list item / heading level units)
from the Sphinx single-page HTML dump, keeping inline markup intact so that
context-aware translation is possible.
Output: _i18n/blocks.json -> list of {id, path, html, text}
Then translator fills _i18n/trans/<n>.json as {"id": "<translated html>"}.
"""
import json, re
from bs4 import BeautifulSoup, NavigableString, Comment
SRC = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable/index.html"
OUT = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable/_i18n/blocks.json"
BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "td", "th", "dt", "dd",
"figcaption", "caption", "li", "blockquote"}
# inline tags that must never be translated / never contain translatable prose
OPAQUE = {"code", "pre", "kbd", "samp", "var", "tt", "img", "script", "style"}
SKIP_CLASS = {"headerlink", "version-switch", "language-switch", "wy-breadcrumbs-aside"}
html = open(SRC, encoding="utf-8").read()
soup = BeautifulSoup(html, "lxml")
blocks = []
def clean_html(node):
"""inner HTML of node with headerlink anchors and comments removed."""
clone = BeautifulSoup(str(node), "lxml")
for tag in clone.find_all(True):
cls = tag.get("class") or []
if tag.name == "a" and "headerlink" in cls:
tag.decompose()
out = clone.decode_contents()
out = re.sub(r"<!--.*?-->", "", out, flags=re.S)
return out.strip()
def is_candidate(node):
if node.name not in BLOCK_TAGS:
return False
# li that directly contains p -> the p is the block, li is container
if node.name == "li" and node.find("p", recursive=False):
return False
for p in node.parents:
if getattr(p, "name", None) in OPAQUE:
return False
return True
seen = set()
for node in soup.find_all(True):
if not is_candidate(node):
continue
if node.name == "blockquote" and node.find(["p", "li"]):
continue # inner p handled separately
h = clean_html(node)
txt = node.get_text(" ", strip=True)
if not re.search(r"[A-Za-z]", txt):
continue
key = (node.name, h)
path = "/".join([p.get("id") or p.name for p in list(node.parents)[:3][::-1] if getattr(p, "name", None)])
blocks.append({"tag": node.name, "path": path, "html": h, "text": txt})
seen.add(key)
# title + sidebar captions / nav labels are handled as generic elements too
extra = []
for t in soup.find_all(["title"]):
extra.append({"tag": "title", "path": "head", "html": t.get_text(), "text": t.get_text()})
for cap in soup.select("p.caption span.caption-text, nav a, .wy-menu a"):
txt = cap.get_text(" ", strip=True)
if not re.search(r"[A-Za-z]", txt):
continue
extra.append({"tag": "nav", "path": "nav", "html": clean_html(cap) or txt, "text": txt})
allb = extra + blocks
json.dump(allb, open(OUT, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
uniq = {}
for b in allb:
uniq.setdefault(b["html"], b)
print("blocks:", len(allb), "unique html:", len(uniq), "unique chars:", sum(len(k) for k in uniq))
from collections import Counter
print(Counter(b["tag"] for b in allb).most_common())
+33
View File
@@ -0,0 +1,33 @@
"""Report text that is still English in the localized page (gap finder)."""
import re, sys
from bs4 import BeautifulSoup, NavigableString, Comment
SRC = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable/index.zh.html"
SKIP = {"script", "style", "pre", "code", "kbd", "samp", "var", "tt"}
soup = BeautifulSoup(open(SRC, encoding="utf-8").read(), "lxml")
rows = []
for node in soup.find_all(string=True):
if isinstance(node, Comment):
continue
t = str(node).strip()
if not re.search(r"[A-Za-z]", t):
continue
if any(getattr(p, "name", None) in SKIP for p in node.parents):
continue
words = re.findall(r"[A-Za-z][A-Za-z'\-]+", t)
long_words = [w for w in words if len(w) > 2 and not w[0].isupper()]
if len(words) >= 4 or len(long_words) >= 2:
parents = "/".join(p.name for p in list(node.parents)[:3])
rows.append((parents, t))
print("leftover english-ish text nodes:", len(rows))
seen = set()
for p, t in rows:
k = t[:60]
if k in seen:
continue
seen.add(k)
print(" [%s] %s" % (p, t[:150]))
if len(seen) > 40:
break
+42
View File
@@ -0,0 +1,42 @@
"""Assign stable uids to unique translatable templates and slice them into batch files."""
import json, re, os
from bs4 import BeautifulSoup
from serialize import collect, SRC
OUTDIR = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable/_i18n"
soup = BeautifulSoup(open(SRC, encoding="utf-8").read(), "lxml")
blocks = collect(soup)
blocks.append({"kind": "title", "tag": "title",
"tpl": soup.find("title").get_text(), "markers": {}, "node": None})
os.makedirs(OUTDIR + "/batches", exist_ok=True)
os.makedirs(OUTDIR + "/trans", exist_ok=True)
def key(b):
return b["tpl"] + "\x00" + json.dumps(b["markers"], sort_keys=True)
uniq = {}
for b in blocks: # document order preserved
k = key(b)
if k not in uniq:
uniq[k] = {"uid": len(uniq) + 1, "tpl": b["tpl"], "markers": b["markers"]}
json.dump({"uniq": uniq},
open(f"{OUTDIR}/uniq.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
CHARS = 12000
batch, size, nb = [], 0, 0
for v in uniq.values():
batch.append("<%d> %s" % (v["uid"], v["tpl"].replace("\n", " ⏎ ")))
size += len(v["tpl"]) + 12
if size >= CHARS:
nb += 1
open(f"{OUTDIR}/batches/batch{nb:02d}.txt", "w", encoding="utf-8").write("\n".join(batch))
batch, size = [], 0
if batch:
nb += 1
open(f"{OUTDIR}/batches/batch{nb:02d}.txt", "w", encoding="utf-8").write("\n".join(batch))
print("unique:", len(uniq), "batches:", nb)

Some files were not shown because too many files have changed in this diff Show More