分离UI
This commit is contained in:
+102
-66
@@ -13,15 +13,18 @@
|
||||
| 关卡 | 难度 | 你要实现的 | 新概念 | 改动文件 |
|
||||
|---|---|---|---|---|
|
||||
| 0 | 热身 | 跑起来并看懂日志 | peer / authority | 无 |
|
||||
| 1 | ★ | 自己按 H 扣血,两端血条一致 | 属性复制 | `player.gd`、`player_sync.tres` |
|
||||
| 1 | ★ | 自己按 H 扣血,两端血条一致 | 属性复制 | `player.tscn`、`player.gd`、`player_sync.tres` |
|
||||
| 2 | ★ | 每个玩家一个随机颜色 | authority 归属 | `player.gd`、`player_sync.tres` |
|
||||
| 3 | ★★ | 头顶显示自己的名牌 | 本地输入如何进网络 | `net.gd`、`player.gd`、`player_sync.tres` |
|
||||
| 3 | ★★ | 头顶显示自己的名牌 | 本地输入如何进网络 | `net.tscn`、`net.gd`、`player.gd`、`player_sync.tres` |
|
||||
| 4 | ★★ | 按 G 让所有端看到挥手光球 | 第一个 RPC | `player.gd` |
|
||||
| 5 | ★★★ | 按 K 打最近的玩家,对方掉血 | 两段式 RPC + 服务器校验 | `player.gd`、`main.gd` |
|
||||
| 6 | ★★★ | 按 J 发射子弹 | 第二个 MultiplayerSpawner | `main.tscn`、`world/bullets.gd` 等 |
|
||||
| 7 | ★★★★ | 子弹命中扣血,且改不掉 | 服务器权威 + 多 Synchronizer | `player.tscn`、`main.gd`、`player.gd` |
|
||||
| 8 | ★★★★ | 走动的远端玩家不再一跳一跳 | 自己写插值 | `player.gd`、`player_sync.tres` |
|
||||
| 9 | ★★★ | 左上角显示所有玩家名字 | 把整条链路串起来 | `net.gd` |
|
||||
| 9 | ★★★ | 左上角显示所有玩家名字 | 把整条链路串起来 | `net.tscn`、`net.gd` |
|
||||
|
||||
> **UI 一律用节点配置,不用代码创建。** 联机菜单和 HUD 都在 `res://autoload/net.tscn` 里搭好,
|
||||
> 涉及 UI 的关卡(3、9)都是「在编辑器里加节点 + 连信号」,脚本里只写 `@onready` 引用和状态更新。
|
||||
|
||||
**按键占用表**(教程自己定的,别和已有的冲突):
|
||||
|
||||
@@ -62,9 +65,9 @@ func _ready() -> void:
|
||||
|
||||
```
|
||||
主机: [观察] 我是 1,这个节点归我管吗?true,我的名字是 1
|
||||
[观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715
|
||||
[观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715
|
||||
客户端:[观察] 我是 1334014715,这个节点归我管吗?false,我的名字是 1
|
||||
[观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715
|
||||
[观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715
|
||||
```
|
||||
|
||||
**你要记住的三件事**:
|
||||
@@ -81,52 +84,84 @@ func _ready() -> void:
|
||||
|
||||
**目标**:按 `H` 给自己扣 10 点血,两台机器的血条显示一致。
|
||||
|
||||
**改动**:`player/player.gd`、`player/player_sync.tres`
|
||||
**改动**:`player/player.tscn`、`player/player.gd`、`player/player_sync.tres`
|
||||
|
||||
### 第 1 步:加属性 + 一个只给本地玩家看的血条
|
||||
### 第 1 步:加属性 + 血条(节点配置)
|
||||
|
||||
在 `player.gd` 顶部的导出变量区(`@export_group("移动")` 前面)加:
|
||||
**先说 UI 的规矩**:本项目所有 UI 都用**节点配置**,不在代码里 `Label.new()`。所以血条要先在编辑器里搭出来。
|
||||
|
||||
**1a. 在 `player.tscn` 里加血条节点**
|
||||
|
||||
1. 打开 `res://player/player.tscn`。
|
||||
2. 选中根节点 `Player`,添加子节点,类型 **`CanvasLayer`**,改名 `Hud`。
|
||||
> `CanvasLayer` 让节点脱离 3D 世界、按屏幕坐标渲染。`Net` 那个菜单也是这么做的。
|
||||
3. 选中 `Hud`,添加子节点,类型 `Label`,改名 `HealthLabel`。
|
||||
4. 在 Inspector 里设 `HealthLabel` 的 `Position` = `(16, 56)`,`Text` = `HP 100 / 100`。
|
||||
5. `Ctrl+S` 保存。
|
||||
|
||||
层级应该是:
|
||||
|
||||
```
|
||||
Player (CharacterBody3D)
|
||||
├─ Collision / Body / CameraRig / Sync
|
||||
└─ Hud (CanvasLayer)
|
||||
└─ HealthLabel (Label)
|
||||
```
|
||||
|
||||
**1b. 在 `player.gd` 里加属性和节点引用**
|
||||
|
||||
```gdscript
|
||||
@export_group("属性")
|
||||
## 血量。用 setter 是为了「值一变就刷新 UI」,不管这个值是本地改的还是同步过来的
|
||||
@export var health: int = 100:
|
||||
set(value):
|
||||
health = clampi(value, 0, 100)
|
||||
_refresh_health_label()
|
||||
|
||||
var _health_label: Label = null
|
||||
@onready var _hud: CanvasLayer = $Hud
|
||||
@onready var _health_label: Label = $Hud/HealthLabel
|
||||
```
|
||||
|
||||
`clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。
|
||||
|
||||
在 `_ready()` 的**本地分支**里(`set_mouse_captured(true)` 之后)加上建血条的代码:
|
||||
**1c. 处理远端玩家**
|
||||
|
||||
血条节点现在**每个玩家实例上都有**(因为它是场景的一部分)。远端玩家不需要显示,所以在 `_ready()` 的远端分支里关掉:
|
||||
|
||||
```gdscript
|
||||
_make_health_label()
|
||||
func _ready() -> void:
|
||||
_is_local = is_multiplayer_authority()
|
||||
if not _is_local:
|
||||
camera_rig.queue_free()
|
||||
_hud.visible = false # ← 血条同理:N 份叠在同一位置会显示错的血量
|
||||
set_physics_process(false)
|
||||
set_process_unhandled_input(false)
|
||||
return
|
||||
# ...相机初始化
|
||||
set_mouse_captured(true)
|
||||
_refresh_health_label() # 补刷一次(原因见下面的提示)
|
||||
```
|
||||
|
||||
然后在文件底部加这三个函数:
|
||||
> **这一步千万别漏。** 血条节点的坐标是屏幕坐标,如果没有 `_hud.visible = false`,
|
||||
> 那么每个玩家(包括别人)都会在左上角画一份血条,**全部重叠在同一位置**,
|
||||
> 你看到的是最后生成的那个玩家的血量。现象是:主机按 `H` 扣自己的血,屏幕上的数字**纹丝不动**。
|
||||
|
||||
**1d. 刷新函数**
|
||||
|
||||
放在文件底部:
|
||||
|
||||
```gdscript
|
||||
func _make_health_label() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
_health_label = Label.new()
|
||||
_health_label.position = Vector2(16, 56)
|
||||
layer.add_child(_health_label)
|
||||
add_child(layer)
|
||||
_refresh_health_label()
|
||||
|
||||
|
||||
func _refresh_health_label() -> void:
|
||||
# 远端玩家没有血条,_health_label 是 null,这里直接跳过
|
||||
# 同步可能在 _ready() 之前就把 health 写进来了,那时 _health_label 还是 null,直接跳过
|
||||
if _health_label == null:
|
||||
return
|
||||
_health_label.text = "HP %d / 100" % health
|
||||
```
|
||||
|
||||
> **为什么 setter 和 `_health_label` 要判空?**
|
||||
> 因为同步是在节点刚进场景树时就把值写进来的,可能**早于** `_ready()` 里建血条那一步。
|
||||
> 所以 setter 里的东西必须能容忍「UI 还不存在」。
|
||||
> **为什么 setter 要判空、`_ready()` 末尾还要再刷一次?**
|
||||
> `@onready` 变量是在 `_ready()` 被调用**之前**才赋值的。而同步写入可能来得更早
|
||||
> (节点刚进场景树时)。那一次 setter 会因为 `_health_label == null` 被跳过,
|
||||
> 所以本地分支末尾要补一次 `_refresh_health_label()`,把当前值显示出来。
|
||||
|
||||
### 第 2 步:把 health 加进同步配置
|
||||
|
||||
@@ -249,7 +284,7 @@ func _apply_body_color() -> void:
|
||||
|
||||
**目标**:菜单里填一个昵称,进入游戏后每个玩家头顶显示自己的名字。
|
||||
|
||||
**改动**:`autoload/net.gd`、`player/player.gd`、`player/player_sync.tres`
|
||||
**改动**:`autoload/net.tscn`、`autoload/net.gd`、`player/player.gd`、`player/player_sync.tres`
|
||||
|
||||
**新概念**:**本地输入 → 写进 authority 端自己的同步字段 → 自动传播。**
|
||||
|
||||
@@ -257,30 +292,34 @@ func _apply_body_color() -> void:
|
||||
|
||||
### 第 1 步:菜单加一个昵称输入框
|
||||
|
||||
`net.gd` 里加一个变量(放在 `var _address_edit` 旁边):
|
||||
UI 是**节点配置**(在 `res://autoload/net.tscn` 里搭好的),所以这一步不写创建控件的代码,是在编辑器里加一个节点。
|
||||
|
||||
1. 在文件系统面板双击打开 `res://autoload/net.tscn`。
|
||||
2. 选中 `Menu/Panel/Box/AddressEdit`(地址输入框),在场景菜单里选 **添加子节点**,类型 `LineEdit`,改名 `NameEdit`。
|
||||
3. 在 Inspector 里设:
|
||||
- `Placeholder Text` = `你的名字`
|
||||
- `Custom Minimum Size` = `240 × 0`(和上面的地址框对齐)
|
||||
4. 拖动节点,把它排到 `AddressEdit` **下面**(同级节点的顺序就是显示顺序)。
|
||||
5. `Ctrl+S` 保存场景。
|
||||
|
||||
然后在 `net.gd` 里加引用和状态变量:
|
||||
|
||||
```gdscript
|
||||
var _name_edit: LineEdit = null
|
||||
## 玩家给自己起的名字,空的话用 peer id
|
||||
var player_name: String = ""
|
||||
|
||||
@onready var _name_edit: LineEdit = $Menu/Panel/Box/NameEdit
|
||||
```
|
||||
|
||||
在 `_build_menu()` 里,`_address_edit` 那段**下面**插入:
|
||||
> `$Menu/Panel/Box/NameEdit` 就是你在场景里搭出来的节点路径。改完 UI 后如果路径变了(比如移动了节点),这里也要跟着改——否则运行时报 `null` 实例访问错误。
|
||||
|
||||
```gdscript
|
||||
_name_edit = LineEdit.new()
|
||||
_name_edit.placeholder_text = "你的名字"
|
||||
_name_edit.custom_minimum_size = Vector2(240, 0)
|
||||
box.add_child(_name_edit)
|
||||
```
|
||||
|
||||
然后在 `host_game()` 和 `join_game()` 的**开头**各加一行,把输入框的值记下来:
|
||||
最后在 `host_game()` 和 `join_game()` 的**开头**各加一行,把输入框的值记下来:
|
||||
|
||||
```gdscript
|
||||
player_name = _name_edit.text.strip_edges()
|
||||
```
|
||||
|
||||
> 加在开头是因为:加入成功后菜单会被隐藏,那时就读不到输入框了。
|
||||
> 加在开头是因为:加入成功后菜单会被隐藏,那时再读输入框就不合适了。
|
||||
|
||||
### 第 2 步:Player 加昵称字段
|
||||
|
||||
@@ -616,10 +655,10 @@ A 按 K
|
||||
├─ 发起者是不是 B 节点的 owner?✓
|
||||
├─ 距离 ≤ 3 米?✓
|
||||
└─ world.broadcast_hit.rpc("B", 10) ← 广播给所有人
|
||||
│
|
||||
├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做
|
||||
├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10
|
||||
└─ 其他机器:同上,什么都不做
|
||||
│
|
||||
├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做
|
||||
├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10
|
||||
└─ 其他机器:同上,什么都不做
|
||||
```
|
||||
|
||||
### 这里埋着一个坑(关卡 7 会修)
|
||||
@@ -1049,7 +1088,7 @@ func _find_hit() -> PlayerController:
|
||||
|
||||
```
|
||||
权威端: _net_position ← 真实位置 (每物理帧)
|
||||
↓ 同步
|
||||
↓ 同步
|
||||
远端: 真实位置 ← 朝 _net_position 平滑逼近 (每渲染帧)
|
||||
```
|
||||
|
||||
@@ -1146,40 +1185,37 @@ func _process(delta: float) -> void:
|
||||
|
||||
**目标**:左上角实时显示「谁在房间里」,用昵称。
|
||||
|
||||
**改动**:`autoload/net.gd`
|
||||
**改动**:`autoload/net.tscn`、`autoload/net.gd`
|
||||
|
||||
这一关没有新概念,但把前面所有链路串起来:**spawn → 同步昵称 → UI 显示**。
|
||||
|
||||
### 第 1 步:HUD 加一行列表
|
||||
|
||||
`net.gd` 里加变量:
|
||||
还是在 `res://autoload/net.tscn` 里加节点:
|
||||
|
||||
1. 打开 `res://autoload/net.tscn`。
|
||||
2. 选中 `Hud/Box/StatusLabel`,添加子节点,类型 `Label`,改名 `PlayerList`。
|
||||
3. 把它排到 `StatusLabel` **下面**。
|
||||
4. 再给 `Hud/Box` 加一个子节点,类型 `Timer`:
|
||||
- `Wait Time` = `0.5`
|
||||
- `Autostart` = `true`
|
||||
5. 在 Inspector 的 **节点 → 信号** 面板里,把 `Hud/Box/Timer` 的 `timeout` 连到 `Net` 的 `_refresh_player_list`。
|
||||
6. `Ctrl+S` 保存。
|
||||
|
||||
然后在 `net.gd` 里加引用(**不用再写 `Timer.new()` / `Label.new()`**):
|
||||
|
||||
```gdscript
|
||||
var _player_list: Label = null
|
||||
@onready var _player_list: Label = $Hud/Box/PlayerList
|
||||
```
|
||||
|
||||
`_build_hud()` 里,在 `_hud_status` 后面插入:
|
||||
|
||||
```gdscript
|
||||
_player_list = Label.new()
|
||||
box.add_child(_player_list)
|
||||
```
|
||||
|
||||
再在 `_build_hud()` 末尾加一个定时刷新(别每帧拼字符串):
|
||||
|
||||
```gdscript
|
||||
var timer := Timer.new()
|
||||
timer.wait_time = 0.5
|
||||
timer.autostart = true
|
||||
timer.timeout.connect(_refresh_player_list)
|
||||
add_child(timer)
|
||||
```
|
||||
> **为什么用 Timer 而不是每帧刷新**:字符串拼接 + 排序是很便宜但不必要的开销,
|
||||
> 一秒钟刷两次已经足够。养成「UI 按需刷新」的习惯,比事后优化省事得多。
|
||||
|
||||
### 第 2 步:实现刷新
|
||||
|
||||
```gdscript
|
||||
func _refresh_player_list() -> void:
|
||||
if _player_list == null or _hud_root == null or not _hud_root.visible:
|
||||
if not _hud.visible:
|
||||
return
|
||||
# 用 PackedStringArray 而不是 Array[String]:
|
||||
# String.join() 要的就是 PackedStringArray,传 Array[String] 会在运行时转换失败
|
||||
@@ -1195,15 +1231,15 @@ func _refresh_player_list() -> void:
|
||||
_player_list.text = "房间里 %d 人\n" % lines.size() + "\n".join(lines)
|
||||
```
|
||||
|
||||
### 第 3 步:进出房间也刷一下
|
||||
### 第 3 步:进出房间也立刻刷一下
|
||||
|
||||
在 `_enter_game()` 和 `_leave_game_ui()` 末尾都加:
|
||||
在 `_show_game_ui()` 和 `_show_menu_ui()` 末尾都加:
|
||||
|
||||
```gdscript
|
||||
_refresh_player_list()
|
||||
```
|
||||
|
||||
这样不用等半秒。
|
||||
这样不用等半秒的定时器。
|
||||
|
||||
### 验证
|
||||
|
||||
|
||||
+9
-3
@@ -70,8 +70,12 @@ RPC 和复制都是靠**节点路径**找到对面对应的节点。所以:
|
||||
```
|
||||
E:\GD_Projects\agame\
|
||||
├── autoload\
|
||||
│ └── net.gd ← 联机总入口(Autoload 名:Net)
|
||||
│ 管传输层、开局/加入/断开、玩家生成回收、菜单和 HUD
|
||||
│ ├── net.tscn ← 联机总入口(Autoload 名:Net,指向这个场景)
|
||||
│ │ Net (CanvasLayer) ← 挂 net.gd
|
||||
│ │ ├─ Menu (CenterContainer) ← 主菜单:标题/地址框/两个按钮/状态/提示
|
||||
│ │ └─ Hud (Control) ← 游戏内 HUD:状态 + 操作提示
|
||||
│ └── net.gd ← 管传输层、开局/加入/断开、玩家生成回收;
|
||||
│ UI 是节点配置,这里只改文字、切显隐
|
||||
├── world\
|
||||
│ ├── main.tscn ← 世界根场景
|
||||
│ │ Main (Node3D)
|
||||
@@ -85,7 +89,9 @@ E:\GD_Projects\agame\
|
||||
│ │ ├─ Collision
|
||||
│ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支
|
||||
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
|
||||
│ │ └─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
|
||||
│ │ ├─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
|
||||
│ │ └─ Hud (CanvasLayer) ← 本地玩家的血条,远端玩家会被隐藏
|
||||
│ │ └─ HealthLabel (Label)
|
||||
│ ├── player.gd ← 加了「本地 / 远端」分支
|
||||
│ └── player_sync.tres ← 同步配置:到底同步哪几个属性
|
||||
└── tests\
|
||||
|
||||
Reference in New Issue
Block a user