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