Files
agame/ui/debug_panel.gd
T
2026-10-10 16:07:53 +08:00

186 lines
6.7 KiB
GDScript

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