186 lines
6.7 KiB
GDScript
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
|
|
## 角色视觉身高(米)。只用来把世界单位换算成「身长」和「倍身高」两个相对量。
|
|
## 小人国玩法基准:约 0.2 m。
|
|
@export var body_height: float = 0.2
|
|
|
|
@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
|