血量显示在玩家头顶

This commit is contained in:
2026-09-22 17:51:52 +08:00
parent b11da48e23
commit 414fcafe1d
5 changed files with 61 additions and 35 deletions
+12
View File
@@ -77,6 +77,18 @@
- 文档同步:`联机实战教程-由浅入深.md` 关卡 1 改成「先在 player.tscn 里加 Hud/HealthLabel 节点」; - 文档同步:`联机实战教程-由浅入深.md` 关卡 1 改成「先在 player.tscn 里加 Hud/HealthLabel 节点」;
`联机框架使用指南.md` 的 player.tscn 结构图。 `联机框架使用指南.md` 的 player.tscn 结构图。
### 血条改到玩家头顶(Label3D)
- `player.tscn`:删掉 `Hud (CanvasLayer)`,改为 `HealthLabel (Label3D)` 直接挂在 `Player` 下,
position `(0, 2.05, 0)`、`billboard=1`、`no_depth_test=true`、`font_size=64`、`pixel_size=0.005`(默认值)、`outline_size=8`。
- `player.gd`:`@onready var _health_label: Label3D = $HealthLabel`,删掉 `_hud` 引用与 `_hud.visible = false`。
- **顺带消掉一个坑**:用 `Label3D` 后,血条天然待在各自头顶,不再需要「远端玩家隐藏血条」那一步
(屏幕坐标的 CanvasLayer 才需要,否则 N 份叠在同一屏幕位置)。
现在默认人人可见别人的血量 —— 因为 health 是同步字段。
- 尺寸换算:`Label3D` 的文字世界高度 ≈ `font_size × pixel_size`。64 × 0.005 ≈ 0.32 米。
- 若要与关卡 3 的头顶名牌共存:血量放 `y=2.05`,名牌放 `y=2.3`,不重叠。
- 验证:双实例 headless 联调,两个日志里 `ERROR|Invalid|null` 一条都没有。
### 反复出现的坑:编辑器把外部写盘的内容覆盖回去 ### 反复出现的坑:编辑器把外部写盘的内容覆盖回去
`net.gd` 用 MCP `script_create` 写成功后,磁盘上又变回了旧版(`extends Node` + `_build_ui` 全在)。 `net.gd` 用 MCP `script_create` 写成功后,磁盘上又变回了旧版(`extends Node` + `_build_ui` 全在)。
+37 -18
View File
@@ -93,19 +93,29 @@ func _ready() -> void:
**1a. 在 `player.tscn` 里加血条节点** **1a. 在 `player.tscn` 里加血条节点**
1. 打开 `res://player/player.tscn`。 1. 打开 `res://player/player.tscn`。
2. 选中根节点 `Player`,添加子节点,类型 **`CanvasLayer`**,改名 `Hud`。 2. 选中根节点 `Player`,添加子节点,类型 **`Label3D`**,改名 `HealthLabel`。
> `CanvasLayer` 让节点脱离 3D 世界、按屏幕坐标渲染。`Net` 那个菜单也是这么做的。 > 用 `Label3D` 而不是 `CanvasLayer` + `Label`:`Label3D` 是**3D 世界里的文字**,
3. 选中 `Hud`,添加子节点,类型 `Label`,改名 `HealthLabel`。 > 挂在哪就跟着哪走,所以会自动待在玩家头顶,不需要每帧做屏幕投影。
4. 在 Inspector 里设 `HealthLabel` 的 `Position` = `(16, 56)`,`Text` = `HP 100 / 100`。 3. 在 Inspector 里设:
5. `Ctrl+S` 保存。
| 属性 | 值 | 为什么 |
|---|---|---|
| `Transform → Position` | `(0, 2.05, 0)` | 头顶上方。胶囊高 1.8、中心在 y=0.9,头顶约在 1.8 |
| `Text` | `HP 100 / 100` | 运行时被脚本覆盖,这里只是编辑器预览 |
| `Billboard` | `Enabled` | 永远面朝相机。**不开的话文字会跟着角色转,从背后看是镜像的** |
| `No Depth Test` | 开 | 被别的物体挡住时也能看见 |
| `Font Size` | `64` | |
| `Pixel Size` | `0.005` | 字号→世界单位:64 × 0.005 ≈ 0.32 米高 |
| `Outline Size` | `8` | 描边,远看和杂乱背景上也能读 |
4. `Ctrl+S` 保存。
层级应该是: 层级应该是:
``` ```
Player (CharacterBody3D) Player (CharacterBody3D)
├─ Collision / Body / CameraRig / Sync ├─ Collision / Body / CameraRig / Sync
└─ Hud (CanvasLayer) └─ HealthLabel (Label3D) ← 头顶血条
└─ HealthLabel (Label)
``` ```
**1b. 在 `player.gd` 里加属性和节点引用** **1b. 在 `player.gd` 里加属性和节点引用**
@@ -118,34 +128,40 @@ Player (CharacterBody3D)
health = clampi(value, 0, 100) health = clampi(value, 0, 100)
_refresh_health_label() _refresh_health_label()
@onready var _hud: CanvasLayer = $Hud ## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
@onready var _health_label: Label = $Hud/HealthLabel @onready var _health_label: Label3D = $HealthLabel
``` ```
`clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。 `clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。
**1c. 处理远端玩家** **1c. 远端玩家的血条要不要显示?**
血条节点现在**每个玩家实例上都有**(因为它是场景的一部分)。远端玩家不需要显示,所以在 `_ready()` 的远端分支里关掉: 因为血条挂在**每个玩家节点**上,而 `health` 是同步过来的,所以默认行为是
**每个人的血量都显示在他自己头顶**,你能看到别人还剩多少血。这通常正是想要的效果,不用额外写代码。
> 对比一下两种做法:如果血条是**屏幕坐标**的 `CanvasLayer` + `Label`(像左上角那种),
> 就必须在远端分支里 `hide()` 掉,否则 N 份血条会叠在同一个屏幕位置、显示别人的血量。
> 换成 `Label3D` 之后这个坑自然消失了 —— 这也是「让 UI 待在它该待的世界位置」的好处。
如果你**只想看自己的**,在 `_ready()` 的远端分支里加一行:
```gdscript ```gdscript
func _ready() -> void:
_is_local = is_multiplayer_authority()
if not _is_local: if not _is_local:
camera_rig.queue_free() camera_rig.queue_free()
_hud.visible = false # ← 血条同理:N 份叠在同一位置会显示错的血量 _health_label.visible = false # ← 只在需要「只看自己」时加
set_physics_process(false) set_physics_process(false)
set_process_unhandled_input(false) set_process_unhandled_input(false)
return return
```
本地分支末尾记得补刷一次:
```gdscript
# ...相机初始化 # ...相机初始化
set_mouse_captured(true) set_mouse_captured(true)
_refresh_health_label() # 补刷一次(原因见下面的提示) _refresh_health_label() # 补刷一次(原因见下面的提示)
``` ```
> **这一步千万别漏。** 血条节点的坐标是屏幕坐标,如果没有 `_hud.visible = false`,
> 那么每个玩家(包括别人)都会在左上角画一份血条,**全部重叠在同一位置**,
> 你看到的是最后生成的那个玩家的血量。现象是:主机按 `H` 扣自己的血,屏幕上的数字**纹丝不动**。
**1d. 刷新函数** **1d. 刷新函数**
放在文件底部: 放在文件底部:
@@ -163,6 +179,9 @@ func _refresh_health_label() -> void:
> (节点刚进场景树时)。那一次 setter 会因为 `_health_label == null` 被跳过, > (节点刚进场景树时)。那一次 setter 会因为 `_health_label == null` 被跳过,
> 所以本地分支末尾要补一次 `_refresh_health_label()`,把当前值显示出来。 > 所以本地分支末尾要补一次 `_refresh_health_label()`,把当前值显示出来。
> **和关卡 3 的名牌不冲突**:关卡 3 会在 `(0, 2.3, 0)` 加一个 `Label3D` 名牌,
> 血量放在 `2.05`,名牌放它上面,两个叠不到一起。
### 第 2 步:把 health 加进同步配置 ### 第 2 步:把 health 加进同步配置
打开 `player/player_sync.tres`,在末尾追加三行: 打开 `player/player_sync.tres`,在末尾追加三行:
+1 -2
View File
@@ -90,8 +90,7 @@ E:\GD_Projects\agame\
│ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支 │ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里 │ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
│ │ ├─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向 │ │ ├─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
│ │ └─ Hud (CanvasLayer) ← 本地玩家的血条,远端玩家会被隐藏 │ │ └─ HealthLabel (Label3D) ← 头顶血条(3D 世界里的文字,跟着角色走)
│ │ └─ HealthLabel (Label)
│ ├── player.gd ← 加了「本地 / 远端」分支 │ ├── player.gd ← 加了「本地 / 远端」分支
│ └── player_sync.tres ← 同步配置:到底同步哪几个属性 │ └── player_sync.tres ← 同步配置:到底同步哪几个属性
└── tests\ └── tests\
+5 -8
View File
@@ -10,8 +10,7 @@ extends CharacterBody3D
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障 ## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
## │ └─ Camera (Camera3D) ## │ └─ Camera (Camera3D)
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position 与 Body.rotation ## ├─ Sync (MultiplayerSynchronizer) ← 同步 position 与 Body.rotation
## └─ Hud (CanvasLayer) ← 本地玩家的血条(节点配置,不是代码建的) ## └─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
## └─ HealthLabel (Label)
## ##
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标 ## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标
@@ -50,9 +49,8 @@ const JUMP_BUFFER_FRAMES: int = 6
@onready var body: Node3D = $Body @onready var body: Node3D = $Body
@onready var camera_rig: Node3D = $CameraRig @onready var camera_rig: Node3D = $CameraRig
@onready var spring_arm: SpringArm3D = $CameraRig/SpringArm @onready var spring_arm: SpringArm3D = $CameraRig/SpringArm
## 血条是 player.tscn 里的节点,不是代码建的 UI ## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
@onready var _hud: CanvasLayer = $Hud @onready var _health_label: Label3D = $HealthLabel
@onready var _health_label: Label = $Hud/HealthLabel
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)) var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8))
var _yaw: float = 0.0 var _yaw: float = 0.0
@@ -68,11 +66,10 @@ func _ready() -> void:
if not _is_local: if not _is_local:
# 远端玩家只是一具「回放身体」: # 远端玩家只是一具「回放身体」:
# 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染; # 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染;
# 关掉血条 —— 否则 N 份血条叠在同一位置,显示的血量可能是别人的;
# 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。 # 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。
# 它的 position 与 Body.rotation 由子节点 Sync 同步过来。 # 它的 position 与 Body.rotation 由子节点 Sync 同步过来;
# 头顶血条保留 —— health 同样是同步过来的,这样谁都能看到别人还剩多少血。
camera_rig.queue_free() camera_rig.queue_free()
_hud.visible = false
set_physics_process(false) set_physics_process(false)
set_process_unhandled_input(false) set_process_unhandled_input(false)
return return
+6 -7
View File
@@ -44,11 +44,10 @@ surface_material_override/0 = SubResource("StandardMaterial3D_yw30f")
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210] [node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210]
replication_config = ExtResource("2_rkbax") replication_config = ExtResource("2_rkbax")
[node name="Hud" type="CanvasLayer" parent="." unique_id=425326662] [node name="HealthLabel" type="Label3D" parent="." unique_id=300336561]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.05, 0)
[node name="HealthLabel" type="Label" parent="Hud" unique_id=348125801] billboard = 1
offset_left = 16.0 no_depth_test = true
offset_top = 56.0
offset_right = 17.0
offset_bottom = 79.0
text = "HP 100 / 100" text = "HP 100 / 100"
font_size = 64
outline_size = 8