Compare commits

..
2 Commits
Author SHA1 Message Date
moyu 7934537793 Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-12 14:43:55 +08:00
moyu 93783b1bd9 debug和修复 2026-10-10 16:07:53 +08:00
11 changed files with 440 additions and 13 deletions
+220
View File
@@ -0,0 +1,220 @@
# 2026-10-10
## 新增右上角调试面板(DebugPanel)
**需求**:右上角显示玩家移动速度、相机高度等信息。
**新增文件**
- `ui/debug_panel.gd`(`class_name DebugPanel extends Control`)—— 单节点 `_draw()` 实现,
不用 Label 节点。理由:要自己控制两列对齐(标签左对齐、数值左对齐、列间距固定),
不必去猜字体度量。总刷屏用 `draw_rect` 画底板。
**显示内容**(10 Hz 刷新,`refresh_interval` 可调)
| 行 | 内容 |
|---|---|
| FPS | `Engine.get_frames_per_second()` |
| 移动速度 | 水平速度 m/s + 身长/秒 |
| 垂直速度 | `velocity.y` |
| 相机高度 | 相机相对脚底的世界高度 m + 倍身高 |
| 状态 | 站立/蹲下/疾跑 · 地面/空中 |
| 位置 | 世界 X, Z |
| 手持 | 拖拽中 / 可抓取 / — |
**关键设计点**
- 相机高度用 `_camera.global_position.y - _player.global_position.y`(世界 y 相减),
**不是**读 `CameraRig.position.y` —— 后者是本地坐标还要乘玩家缩放,且相减法对
「蹲伏过渡中途」这种非稳态也直接成立。
- `body_height`(默认 0.15)只用于换算「身长/秒」和「倍身高」,**和 `player.tscn` 的
`Player.scale` 绑定,改角色尺寸时要同步改**。
- `mouse_filter = IGNORE` 在 `_ready()` 里显式设置(见下方 MCP 限制),
否则这个全屏 Control 会吃掉所有鼠标事件,鼠标视角和拖拽全会废掉。
- `visible` 默认 false,`setup()` 里才置 true —— 避免远端玩家的「回放身体」
闪出一块「未接线」面板。
- F3 切换显隐(`toggle_key`)。`Key` 类型可以直接 `@export`。
- 挂在 `CameraRig/HUD` 下(和准心同级)→ 远端玩家的 HUD 随 `camera_rig.queue_free()` 一起释放,
天然只有本地玩家显示。
**改动文件**
- `character/player/player.tscn`:`HUD` 下新增 `DebugPanel (Control)`,
锚点 Full Rect(`anchor_right`/`anchor_bottom` = 1.0),挂 `ui/debug_panel.gd`。
- `character/player/player.gd`:加 `@onready var debug_panel: DebugPanel = $CameraRig/HUD/DebugPanel`
和 `_ready()` 里 `debug_panel.setup(self, motor, pickup, camera)`。
- `character/player/player_pickup.gd`:新增公开 getter `is_hovering()`(配 `has_object()`)。
---
## ⚠ 本次踩到的 Godot MCP 限制(重要,可复用)
### 1. 编辑器在 play 模式时 MCP 拒绝一切写入
`EDITOR_NOT_READY / EDITOR_PLAYING`。必须先 `project_manage(op="stop")`。
(本次用户选择「我来停」,停完 `editor_manage(op="state")` 确认 `readiness: ready`。)
### 2. MCP 写不了整型属性
`node_set_property` 传整数会变成 String → `WRONG_TYPE: Cannot write String to a int property`。
**浮点/字符串正常**。受影响的:`mouse_filter`、`grow_horizontal`、`anchors_preset`。
→ 绕过办法:
- 锚点:改设 `anchor_right` / `anchor_bottom` = **1.0**(浮点可写);编辑器保存时会自动补
`anchors_preset = 15` + `layout_mode = 3`,效果与准心节点完全一致。
- 整型语义(如 `mouse_filter`):**写进脚本里**(`_ready()` 里设),别依赖场景属性。
### 3. 假 parse error(本项目的顽固问题,第二次遇到)
`script_create` / `script_patch` / `filesystem_manage(write_text)` 的 `diagnostics` 会对
**完全合法的文件**报 `gdscript_reload_failed, error_code 43 (ERR_PARSE_ERROR)`,
`line` 是 `fallback_line: true`(≈ 文件总行数,不可信)。
本次 `ui/debug_panel.gd`、`player_pickup.gd` 都被这样误报过,而真实项目上
`--check-only` 全部通过。**判定:这是编辑器热重载产物,不是语法错误。**
教训:**MCP 的 diagnostics 只能当提示,不能用它判定脚本坏了。**
### 4. 可靠的脚本语法校验方法(离线,不污染项目)
Godot 可执行文件路径:
`D:\_yvmou\Godot\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
(从 `Get-Process | Where ProcessName -like "*godot*" | Select Path` 拿到)
直接对真实项目跑(安全,`--check-only` 解析完即退):
```bash
"$GODOT" --headless --path "E:/Godot/agame" --check-only --script "res://ui/debug_panel.gd"
```
**输出为空 = 通过**。
沙箱法(想彻底不碰项目时):临时目录建最小 `project.godot`,拷入脚本及其依赖,
先 `--editor --quit` 生成 `.godot/global_script_class_cache.cfg`(否则会误报
「Could not find type」——注意**类型解析失败也是报 Parse Error**),再 `--check-only`。
### 5. 字体(中文能否渲染)
实测:`ThemeDB.fallback_font` **就是**默认主题字体(`FontFile` / "Open Sans SemiBold"),
`allow_system_fallback = true`,系统有 Microsoft YaHei/SimSun。
注意 `has_char("点")` 返回 **false**(`has_char` 不查系统回退链),但排版时回退生效:
「移动速度相机高度」8 字量出 128px = 16px/字,字形确实取到了。
→ **结论:`_draw()` + `ThemeDB.fallback_font` 画中文是可行的,不必额外挂字体资源。**
### 6. 其他
- `custom_manage(op="list")` 可列出第三方 addon 注册的自定义工具。
- `editor_manage(op="state")` 能看 play 状态;`logs_read(source="editor")` 只看得到
脚本 reload 的 warning,拿不到 GDScript 的详细 parse 报错文本(stderr 到编辑器控制台)。
- 用户 `world/main2.tscn` 是编辑器常驻场景,用完要**还原回去**(本次已还原)。
---
## `player_sync.tres` 为什么在 Godot 里「不可编辑」
- `SceneReplicationConfig` **没有任何 `@export` 属性**,类上只有 `add_property` / `remove_property` /
`property_set_*` 这些**方法** → 通用 Inspector 渲染出来是空的,所以双击 `.tres` 看起来「不能编辑」。
- 这不是错觉:Godot 4.7 二进制里有 `ReplicationEditor` / `Replication Dock` / `Remove Property` /
`Replication Mode`(下拉枚举 `Never,Always,On Change`)/ `ReplicationInterface`,
但**没有** `SceneReplicationConfigEditor` —— 即不存在独立的资源编辑器。
- **正确改法**:选中场景里的 `Sync` 节点(`MultiplayerSynchronizer`)→ Inspector 的 Replication 区
→ 添加/删除属性、勾 Spawn、选 Replication Mode → 保存场景(外部 .tres 会被一并写盘)。
- 运行时可在调试器里看同步情况(`ReplicationInterface` / `ReplicationProfiler`)。
### 顺带发现:`.:health` 是失效路径
`character/player/player.gd` 上**没有** `health`(`health` 只存在于 `demo/` 那套
`agent_base.gd` + `Health` 节点)。同类「路径过期」问题已第三次出现
(前两次是 `Body/AnimationPlayer:anim_state` / `anim_speed`)。
当前 5 条里 4 条有效:`.:position`、`Body:rotation`、`AnimationPlayer:anim_state`、`AnimationPlayer:anim_speed`。
原 5 条全是 `replication_mode = 1`(Always,每处理帧持续发)。`anim_state` / `anim_speed`
是低频变化量,改用 `On Change`(2,可靠传输、变化才发)更省带宽。
### 已处理(用户确认后动手)
`player_sync.tres` 现为 4 条:
| # | 路径 | spawn | mode |
|---|---|---|---|
| 0 | `.:position` | true | 1 Always |
| 1 | `Body:rotation` | true | 1 Always |
| 2 | `AnimationPlayer:anim_state` | true | 2 On Change |
| 3 | `AnimationPlayer:anim_speed` | true | 2 On Change |
- **改法**:MCP `filesystem_manage(write_text)` 直接写 .tres 文本 → `scan()` → `reimport()`。
这个资源**没有编辑器 UI,也不存在「编辑器打开着」的竞态**,所以写文本是安全的
(与 .tscn 的情况不同)。`reimport` 对 .tres 只会刷新文件条目(`skipped_non_imported`)。
- **验证手段(值得复用)**:把手写的 `.tres` 拷进最小沙箱工程,用
`--headless --path <沙箱> --script res://check.gd` 跑一段 `extends SceneTree` 的脚本,
`load()` 它并打印 `get_properties()` / `property_get_spawn()` /
`property_get_replication_mode()` —— 这是**唯一能验证手写 .tres 真能解析**的办法。
实测输出:4 条,mode = 1,1,2,2 ✓
- `resource_manage(op="load")` 对 `SceneReplicationConfig` 只返回 3 个属性
(`resource_local_to_scene` / `resource_path` / `resource_name`),
`op="inspect"` 直接拒绝(不在它支持的原生资源族里)。
- `Always`(1) 用不可靠传输、每帧持续发;`On Change`(2) 用可靠传输、值变了才发。
连续变化的量(position / rotation)用 `Always` 是对的,低频量才换 `On Change`。
### 决定:保持外部 .tres,不内嵌(用户明确选择「不改」)
调研过三条替代路,全部放弃,**以后不必再提**:
- **A 内嵌 `sub_resource`**:功能零损失、可少一个文件,但给配置加条目要调 `add_property()`
—— 那是**方法不是属性**,MCP 只能读写属性(`custom_manage` 也返回 0 个自定义工具),
最后 4 行只能用户在 Replication 面板手点。不值得为省一个文件付这个手工成本。
- **B 脚本里构造**(`MultiplayerSynchronizer` 上挂脚本,`_ready()` 里 new 一个 config 再赋给
`replication_config`):会让编辑器 Replication 面板与实际同步内容**脱节**,
且运行时赋值能否赶在同步器开始工作前生效**未实测**。放弃。
- **C 弃用 Synchronizer 改 `@rpc`**:`world/main.tscn` 用 `MultiplayerSpawner` + `main.gd` 的
`spawn_function` 生成玩家,「新 peer 拿到当前动画状态」是 `spawn = true` 白送的;
换 RPC 要自己补初始状态 / 节流 / 丢包策略。为 4 个值不划算。
- 根本原因:`.tres` 的"缺点"只是不能双击打开,而**编辑入口一直是 Sync 节点的 Replication
面板**(换成 A 也是同一个面板)。
### `/Player/Sync` 节点实测值
`root_path = ".."`(默认值 = 父节点 Player,这就是 `.:position` 指 Player 的原因)、
`delta_interval = 0.0`、`public_visibility = true`、`visibility_update_mode = 0`。
`replication_config` **不在** `node_get_properties` 可读属性里(被 ReplicationEditor 面板接管)。
### 补充:外部 .tres 改动的落盘机制(用户追问「tres 不是不可编辑吗」)
- 准确说法:`player_sync.tres` **没有自己的编辑界面**,但**内容可以改**,入口是 Sync 节点的
Replication 面板。这是两件事,别混。
- 根因链:`SceneReplicationConfig` 没有任何 `@export` 属性(只有方法)→ 通用 Inspector 渲染不出
可编辑项 → Godot 只好把专用 UI(`ReplicationEditor`)挂在**使用它的节点**
(`MultiplayerSynchronizer`)上。实测证据:`resource_manage(op="load")` 只回 3 个元数据属性
(`resource_local_to_scene` / `resource_path` / `resource_name`)—— 这就是你双击它看到的全部内容。
- **编辑器加载的那份 .tres 实例就是项目文件本身**,所以面板里改它会**写回 `.tres`**,重启后仍在
(与 `duplicate()` 出来的副本不同 —— 这既是它"生效"的原因,也是 Godot 著名的「资源被意外共享
修改」陷阱的同一机制)。改完记得保存场景。
- 真正做不到的只有一件事:**手改文本时没有校验**。NodePath 写错编辑器不会拦 ——
`.:health`、`Body/AnimationPlayer:anim_state` 两次事故都源于此。要改路径请走面板下拉选,
别手打。
### 项目事实:资源类的三种来源(排查中确认)
- `demo/ai/trees/*.tres` 的类型是 `BehaviorTree` / `BlackboardPlan`,**但这两个类不在任何 .gd 里** ——
`addons/limboai/` 只有 `bin/*.so`(GDExtension,C++ 编译产物),一个 `.gd` 都没有。
所以它们是**插件用 C++ 注册的 Resource 类**,用起来和引擎内置类没区别(LimboAI 的 C++ 版)。
- 项目里目前**没有任何自己写的 `extends Resource` 脚本**(排除 addons)。
已存在的 `.tres` 类型:`StandardMaterial3D`(素材包材质)、`SceneReplicationConfig`(player_sync)、
`BehaviorTree`(limboai)。
- 没有二进制 `.res`。
- 判据:**`@export` 就是「能不能在 Inspector 里编辑」的分水岭**。`SceneReplicationConfig` 一个
`@export` 都没有 → 打开是空的;自定义 Resource 有 → 双击就能编辑。
### 调试器面板截图排查(15:58 用户贴图)
**三条警告**都是历史遗留、与近期改动无关,来自项目加载时的 `GDScript::reload` 全量重载:
- `face_painter.gd:153` 整数除法 —— **不是 bug**。`_stamp_d` 恒为偶数(第 138 行 `d = r * 2`),
整除不丢精度。要消警告用 `@warning_ignore("integer_division")` + 注释说明,**不要**改成浮点。
- `face_rig.gd:190` 局部变量 `name` 遮蔽 `Node.name` → 改名(如 `entry_name`)即可。
- `paint_panel.gd:69` 局部变量 `scale` 遮蔽 `Control.scale` → 改名(如 `factor`)即可。
**那条错误 `net.gd:66 host_game(): Couldn't create an ENet host.` 也不是 bug**:
日志证据显示当时**两个实例同时在跑** —— 主机(peer 1)已占住 7000 端口,第二个实例点「创建主机」
必然失败(这是预期路径),随后它点「加入」**成功连上**(日志里有完整握手 + 双方各自生成角色)。
功能正常,唯一可改进的是错误消息不够人话。
排查要点:**`user://logs/godot.log` 是「按项目」共享的,多实例会并发写同一个文件**,
所以日志行会交错(截图里 `enet_connection.cp` 被从中间截断就是并发写的痕迹)。
判据:`[Net] 本端 1 …` 和 `[Net] 本端 1484785418 …` 同时出现 = 两个进程。
### ⚠ 两条重要的方法论修正(都靠实测证伪)
**1. 之前说的「假 parse error」成因找到了 —— 不是假错,是热重载场景内的脚本。**
`script_patch`/`script_create` 返回 `gdscript_reload_failed (error_code 43)` 的文件,
全部都是**挂在「当前编辑器打开的那个场景」里的节点上的脚本**:
`debug_panel.gd`(HUD/DebugPanel)、`player_pickup.gd`(/Player/Pickup)、
`face_painter.gd`、`face_rig.gd`(FaceRig) —— 当时开着的正是 `player.tscn`。
而 `paint_panel.gd`(在 paint_panel.tscn 里)、`net.gd`(autoload,net.tscn 没开)
**都返回 `diagnostics: []` 干净结果**。
结论:场景里有活实例时热重载会被判定失败,**文件本身没问题**。
想拿干净诊断:先把编辑器切到一个**不含该脚本实例**的场景,或直接用下面的离线校验。
**2. `--check-only` 只报错、不报警告!**
实测探针(`var name := "hello"` + `5 / 2`,两条确定会触发的警告)→ `--check-only` **零输出**。
所以「无输出」只能证明**没有语法/分析错误**,**不能**作为「警告已消除」的证据。
要确认警告消掉了,必须看编辑器的 Errors 面板(重启编辑器或重新加载项目)。
本次三条警告的处理依据是「遮蔽变量已改名 + 所有引用点都改到」(`face_rig.gd` 的 `name`
在 190/191/192/202/208 共 5 处,**漏改后两处会静默解析成 `Node.name`、不报错但值全错**,
已全部改名 `entry_name`)。
+5 -1
View File
@@ -65,7 +65,11 @@ func host_game() -> void:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(PORT, MAX_PLAYERS)
if err != OK:
_set_status("创建主机失败:%s" % error_string(err))
# ENet 对「端口已被占用」和其他建站失败返回**同一个错误码**(ERR_CANT_CREATE,实测为 20),
# 所以这里没法按错误码分流,直接把最常见的原因写进提示 ——
# 同机双开调试时,先启动的那个实例已经是主机了,第二个必然抢不到端口。
_set_status("创建主机失败:端口 %d 无法绑定,可能已被占用(已有实例在当主机?)" % PORT)
print("[Net] 创建主机失败:%s(端口 %d)" % [error_string(err), PORT])
return
multiplayer.multiplayer_peer = peer
_show_game_ui()
+3
View File
@@ -150,6 +150,9 @@ func _rebuild_stamp() -> void:
## 在 pos 处盖一个印章。越界像素直接跳过,所以画到边上也不会出错。
func _stamp_at(pos: Vector2) -> void:
var d := _stamp_d
# d 恒为偶数(上方是按 r * 2 算出来的),这里的整除不会丢精度。
# 所以只抑制警告、不改算法 —— 换成浮点反而会引入半像素偏移。
@warning_ignore("integer_division")
var r := d / 2
var cx := int(round(pos.x)) - r
var cy := int(round(pos.y)) - r
+5 -5
View File
@@ -187,9 +187,9 @@ func save_drawing_as_expression(display_name: String) -> Dictionary:
return {}
var rows := _read_library_index()
var name := display_name.strip_edges()
if name.is_empty():
name = "我的表情 %d" % (rows.size() + 1)
var entry_name := display_name.strip_edges()
if entry_name.is_empty():
entry_name = "我的表情 %d" % (rows.size() + 1)
var id := "user:%d" % Time.get_ticks_usec()
var filename := id.replace(":", "_") + ".png"
var file := FileAccess.open("%s/%s" % [LIBRARY_DIR, filename], FileAccess.WRITE)
@@ -199,13 +199,13 @@ func save_drawing_as_expression(display_name: String) -> Dictionary:
file.store_buffer(painter.to_png_bytes())
file.close()
rows.append({"id": id, "name": name, "file": filename})
rows.append({"id": id, "name": entry_name, "file": filename})
if not _write_library_index(rows):
return {}
if not select_expression(id):
return {}
save()
return {"id": id, "name": name}
return {"id": id, "name": entry_name}
func _read_library_index() -> Array:
+4
View File
@@ -16,6 +16,8 @@ extends CharacterBody3D
@onready var camera: Camera3D = $CameraRig/Camera
## 准心(挂在 CameraRig/HUD 下)。远端玩家的 HUD 会随 camera_rig 一起被释放。
@onready var crosshair: Crosshair = $CameraRig/HUD/Crosshair
## 右上角调试面板。同样挂在 HUD 下,所以远端玩家不会显示它。
@onready var debug_panel: DebugPanel = $CameraRig/HUD/DebugPanel
@onready var animation_player: PlayerAnimator = $AnimationPlayer
@onready var look: PlayerLook = $Look
@onready var motor: PlayerMotor = $Motor
@@ -56,6 +58,8 @@ func _ready() -> void:
)
# 准心接线:拾取组件一发现交互状态变化,就通知准心换外观(点 / 手 / 抓)。
pickup.interact_state_changed.connect(_on_pickup_interact_state_changed)
# 调试面板接线:把要读的组件注入进去,面板自己不 get_node。
debug_panel.setup(self, motor, pickup, camera)
set_mouse_captured(true)
+8
View File
@@ -10,6 +10,7 @@
[ext_resource type="AnimationLibrary" uid="uid://ddnmtmswkjx6g" path="res://character/player/assets/animations/fall.glb" id="4_hr31g"]
[ext_resource type="Texture2D" uid="uid://c67qe66m2aft4" path="res://character/assets/grab.png" id="4_ywhvj"]
[ext_resource type="AnimationLibrary" uid="uid://ddkl5cqdd5usx" path="res://character/player/assets/animations/idle.glb" id="5_lqp71"]
[ext_resource type="Script" uid="uid://d2rmc8p4g4dt6" path="res://ui/debug_panel.gd" id="5_ywhvj"]
[ext_resource type="AnimationLibrary" uid="uid://esw60hwb2cty" path="res://character/player/assets/animations/jump.glb" id="6_vuhga"]
[ext_resource type="AnimationLibrary" uid="uid://rqhclfyiingo" path="res://character/player/assets/animations/run.glb" id="7_muf2e"]
[ext_resource type="AnimationLibrary" uid="uid://dy8a178vlbsof" path="res://character/player/assets/animations/sneak.glb" id="8_jo6ux"]
@@ -61,6 +62,13 @@ script = ExtResource("2_vuhga")
hover_texture = ExtResource("3_i6sc0")
hold_texture = ExtResource("4_ywhvj")
[node name="DebugPanel" type="Control" parent="CameraRig/HUD" unique_id=1328176640]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("5_ywhvj")
[node name="Body" type="Node3D" parent="." unique_id=394283421]
[node name="mouse" parent="Body" unique_id=1995806863 instance=ExtResource("2_wu8e1")]
+5
View File
@@ -140,3 +140,8 @@ func _throw() -> void:
## 当前是否抓着东西。调试用,也方便以后加「搬运时不能开枪」这类规则。
func has_object() -> bool:
return is_instance_valid(_current)
## 当前准心是否指着可拖拽的刚体(还没抓)。调试用。
func is_hovering() -> bool:
return _hovering
+2 -5
View File
@@ -7,12 +7,9 @@ properties/0/replication_mode = 1
properties/1/path = NodePath("Body:rotation")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:health")
properties/2/path = NodePath("AnimationPlayer:anim_state")
properties/2/spawn = true
properties/2/replication_mode = 1
properties/3/path = NodePath("AnimationPlayer:anim_state")
properties/3/path = NodePath("AnimationPlayer:anim_speed")
properties/3/spawn = true
properties/3/replication_mode = 1
properties/4/path = NodePath("AnimationPlayer:anim_speed")
properties/4/spawn = true
properties/4/replication_mode = 1
+185
View File
@@ -0,0 +1,185 @@
class_name DebugPanel
extends Control
## 调试面板:屏幕右上角实时显示玩家的运行状态(移动速度、相机高度、状态等)。
##
## 纯显示 —— 不参与任何游戏逻辑,也不吃输入(mouse_filter = IGNORE)。
## 数据全都来自 Player 通过 setup() 注入的引用,面板自己不 get_node,
## 所以它和玩家的场景结构解耦,以后想挪到别处只改接线。
##
## 挂在 CameraRig/HUD 下(和准心同级):远端玩家的 HUD 会随 camera_rig 一起被释放,
## 所以只有本地玩家会看到这块面板。
## 数值刷新间隔(秒)。调试信息不需要每帧重建字符串 ——
## 每帧拼字符串会持续产生垃圾,而 10 Hz 对肉眼已经是连续的。
@export var refresh_interval: float = 0.1
## 角色视觉身高(米)。只用来把世界单位换算成「身长」和「倍身高」两个相对量。
## 它和 player.tscn 里 Player.scale 是绑定的,改角色尺寸时要一起改。
@export var body_height: float = 0.15
@export_group("外观")
## 距屏幕右上角的边距(像素)
@export var edge_margin: Vector2 = Vector2(14.0, 14.0)
## 底板内边距(像素)
@export var padding: Vector2 = Vector2(10.0, 8.0)
## 标签列与数值列之间的间隔(像素)
@export var gutter: float = 14.0
## 行距:在字体行高之外额外增加的像素
@export var line_spacing: float = 3.0
## 字号。<= 0 时用引擎默认字号
@export var font_size: int = 13
## 底板颜色。把 a 设为 0 就没有底板
@export var background_color: Color = Color(0.05, 0.05, 0.07, 0.62)
## 标签色 / 数值色。标签暗一些、数值亮一些,扫一眼就能定位到数
@export var label_color: Color = Color(1.0, 1.0, 1.0, 0.55)
@export var value_color: Color = Color(1.0, 1.0, 1.0, 0.95)
@export_group("交互")
## 切换显隐的按键。F3 是调试面板的惯例键,本项目没占用。
@export var toggle_key: Key = KEY_F3
## 已格式化的两列文本:_rebuild() 填,_draw() 画。
var _labels: PackedStringArray = PackedStringArray()
var _values: PackedStringArray = PackedStringArray()
var _accum: float = 0.0
var _player: CharacterBody3D
var _motor: PlayerMotor
var _pickup: PlayerPickup
var _camera: Camera3D
## 由 Player 在 _ready() 注入依赖。
func setup(player: CharacterBody3D, motor: PlayerMotor, pickup: PlayerPickup, camera: Camera3D) -> void:
_player = player
_motor = motor
_pickup = pickup
_camera = camera
# 先填一次,免得注入到第一次刷新之间有一段空白
_rebuild()
queue_redraw()
# 注入完成才显示(_ready() 里默认是关的)。
visible = true
func _ready() -> void:
# 保险:这是个铺满全屏的 Control,一旦吃掉鼠标事件,鼠标视角和拖拽就全废了。
# 显式设成 IGNORE,不依赖场景里的配置。
mouse_filter = Control.MOUSE_FILTER_IGNORE
# 默认不显示,等 setup() 注入完成再亮。
# 这样除本地玩家之外的那些「回放身体」不会闪出一块空面板。
visible = false
func _process(delta: float) -> void:
if not visible:
return
_accum += delta
if _accum < refresh_interval:
return
_accum = 0.0
_rebuild()
queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == toggle_key:
visible = not visible
if visible:
# 刚显示出来就直接刷一次,不用等下一个周期
_accum = refresh_interval
func _notification(what: int) -> void:
# 这是铺满全屏的 Control:窗口一变形,右上角的位置就跟着变,必须重算。
if what == NOTIFICATION_RESIZED:
queue_redraw()
## 采集数据并格式化成两列文本。
func _rebuild() -> void:
_labels.clear()
_values.clear()
if _player == null:
_add("DebugPanel", "未接线")
return
var safe_height := maxf(body_height, 0.0001)
var velocity := _player.velocity
var horizontal := Vector2(velocity.x, velocity.z).length()
var camera_height := _camera_height()
_add("FPS", str(Engine.get_frames_per_second()))
_add("移动速度", "%.2f m/s %.2f 身长/s" % [horizontal, horizontal / safe_height])
_add("垂直速度", "%.2f m/s" % velocity.y)
_add("相机高度", "%.3f m %.2f 身高" % [camera_height, camera_height / safe_height])
_add("状态", _state_text())
_add("位置", "%.1f, %.1f" % [_player.global_position.x, _player.global_position.z])
_add("手持", _hold_text())
## 相机相对脚底的高度(米)。
## 用世界 y 相减而不是读 CameraRig.position.y —— 后者是本地坐标(还得乘玩家缩放),
## 而相减对「蹲伏过渡中途」这种非稳态也直接成立。
func _camera_height() -> float:
if _camera == null:
return 0.0
return _camera.global_position.y - _player.global_position.y
func _state_text() -> String:
var motion := "站立"
if _motor != null:
if _motor.crouching:
motion = "蹲下"
elif _motor.sprinting:
motion = "疾跑"
elif Vector2(_player.velocity.x, _player.velocity.z).length() > 0.02:
motion = "行走"
return "%s · %s" % [motion, "地面" if _player.is_on_floor() else "空中"]
func _hold_text() -> String:
if _pickup == null:
return "—"
if _pickup.has_object():
return "拖拽中"
if _pickup.is_hovering():
return "可抓取"
return "—"
func _add(label: String, value: String) -> void:
_labels.append(label)
_values.append(value)
## 画在右上角。用 _draw() 而不是一组 Label 节点,是为了自己控制两列对齐 ——
## 标签左对齐、数值左对齐且两列间距固定,不必去猜字体度量。
func _draw() -> void:
if _labels.is_empty():
return
var font := ThemeDB.fallback_font
var fs := font_size if font_size > 0 else ThemeDB.fallback_font_size
var line_h := font.get_height(fs) + line_spacing
# 两列各取最大宽度,底板就贴合内容,不会留一块空的。
var label_w := 0.0
var value_w := 0.0
for i in _labels.size():
label_w = maxf(label_w, font.get_string_size(_labels[i], HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x)
value_w = maxf(value_w, font.get_string_size(_values[i], HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x)
var box := Vector2(label_w + gutter + value_w, line_h * _labels.size()) + padding * 2.0
# 贴右上角:整块从屏幕右边缘往左量。
var origin := Vector2(size.x - edge_margin.x - box.x, edge_margin.y)
if background_color.a > 0.0:
draw_rect(Rect2(origin, box), background_color, true)
var x := origin.x + padding.x
var value_x := x + label_w + gutter
var y := origin.y + padding.y + font.get_ascent(fs)
for i in _labels.size():
draw_string(font, Vector2(x, y), _labels[i], HORIZONTAL_ALIGNMENT_LEFT, -1.0, fs, label_color)
draw_string(font, Vector2(value_x, y), _values[i], HORIZONTAL_ALIGNMENT_LEFT, -1.0, fs, value_color)
y += line_h
+1
View File
@@ -0,0 +1 @@
uid://d2rmc8p4g4dt6
+2 -2
View File
@@ -66,8 +66,8 @@ func open(rig: FaceRig, start_blank: bool = false) -> void:
## 屏幕坐标 → 画布坐标。显示尺寸和画布尺寸不一致时按比例缩
func _canvas_pos(event: InputEventMouse) -> Vector2:
var scale := float(_rig.painter.canvas_size) / maxf(1.0, _canvas_rect.size.x)
return event.position * scale
var scale_factor := float(_rig.painter.canvas_size) / maxf(1.0, _canvas_rect.size.x)
return event.position * scale_factor
func _on_canvas_input(event: InputEvent) -> void: