Update 联机实战教程-由浅入深.md

This commit is contained in:
2026-09-22 16:59:13 +08:00
parent cac2f7d87b
commit e71276050c
+55 -32
View File
@@ -326,27 +326,28 @@ func _refresh_nameplate() -> void:
```gdscript ```gdscript
func _ready() -> void: func _ready() -> void:
_is_local = is_multiplayer_authority() _is_local = is_multiplayer_authority()
_make_nameplate() # ← 名牌是所有玩家都要显示的东西 _make_nameplate() # ← 名牌是所有玩家都要显示的东西,要放在 is_local 判断之前
if not _is_local: if not _is_local:
camera_rig.queue_free() camera_rig.queue_free()
... set_physics_process(false)
set_process_unhandled_input(false)
return return
# 只有自己给自己起名 # 下面几行是前面几关攒下来的,只有本地玩家会执行
nickname = get_node_or_null("/root/Net").player_name if ...
```
最后那行写清楚点,用工具函数更好:
```gdscript
nickname = _read_player_name() nickname = _read_player_name()
``` _make_health_label()
body_color = Color.from_hsv(randf(), 0.65, 0.9)
set_mouse_captured(true)
```gdscript
## 从 Net 单例读菜单里填的名字。用节点路径访问,不依赖 autoload 全局名
func _read_player_name() -> String: func _read_player_name() -> String:
var net := get_node_or_null("/root/Net") var net := get_node_or_null("/root/Net")
if net == null or net.player_name.is_empty(): if net == null:
return "玩家 " + name
var typed := str(net.get("player_name"))
if typed.is_empty():
return "玩家 " + name # 没填就用节点名(= peer id) return "玩家 " + name # 没填就用节点名(= peer id)
return net.player_name return typed
``` ```
> **为什么用 `/root/Net` 而不是直接写 `Net`?** > **为什么用 `/root/Net` 而不是直接写 `Net`?**
@@ -614,7 +615,7 @@ A 按 K
├─ is_server() ✓ ├─ is_server() ✓
├─ 发起者是不是 B 节点的 owner?✓ ├─ 发起者是不是 B 节点的 owner?✓
├─ 距离 ≤ 3 米?✓ ├─ 距离 ≤ 3 米?✓
└─ parent.broadcast_hit.rpc("B", 10) ← 广播给所有人 └─ world.broadcast_hit.rpc("B", 10) ← 广播给所有人
│ │
├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做 ├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做
├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10 ├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10
@@ -922,22 +923,38 @@ func _create_player(peer_id: int) -> Node:
### 第 5 步:服务器直接改血量 ### 第 5 步:服务器直接改血量
现在 `health` 的权威端是服务器了,所以服务器可以直接写它,不需要再让「受害者自己执行」。 现在 `health` 的权威端是服务器了,服务器写它的值就会自动同步给所有人,客户端改了也会被覆盖回来。
把 `player.gd` 里的 `receive_damage()` 删掉,并修改 `main.gd` 的广播: 在 `player.gd` 里,把关卡 5 写的 `receive_damage()` **换成**这个——判断条件从「我是不是本地玩家」变成了「我是不是服务器」:
```gdscript
## 扣血入口。现在只有服务器会调它 —— health 的权威端就是服务器
func apply_server_damage(amount: int) -> void:
if not multiplayer.is_server():
return
health = maxi(0, health - amount)
```
`world/main.gd` 的广播相应改成:
```gdscript ```gdscript
@rpc("authority", "call_local", "reliable") @rpc("authority", "call_local", "reliable")
func broadcast_hit(target_name: String, amount: int) -> void: func broadcast_hit(target_name: String, amount: int) -> void:
var target := get_node_or_null(target_name) var target := get_node_or_null(target_name) as PlayerController
if target == null: if target == null:
return return
# 服务器直接改。因为 health 的权威端就是服务器, # 服务器直接扣。玩家自己的机器现在改不动这个值了
# 这个值会自动同步给所有人,客户端改了也会被覆盖回来 target.apply_server_damage(amount)
target.health = target.health - amount
``` ```
注意这里是 `target.health = ...`,**不是** `target.receive_damage(...)`。现在所有权在服务器手上,服务器说了算。 **这一关的全部意义就是判断条件从 `_is_local` 变成了 `is_server()`:**
| | 关卡 5 | 关卡 7 |
|---|---|---|
| `health` 的权威端 | 玩家自己 | 服务器 |
| 谁执行扣血 | 受害者的客户端 | 服务器 |
| 客户端能偷偷改血量吗 | 能(作弊) | 不能(会被服务器覆盖) |
| 谁来判定命中 | 攻击方的客户端 | 服务器 |
### 第 6 步:命中判定(子弹打中人) ### 第 6 步:命中判定(子弹打中人)
@@ -956,8 +973,8 @@ func _physics_process(delta: float) -> void:
if multiplayer.is_server(): if multiplayer.is_server():
var victim := _find_hit() var victim := _find_hit()
if victim != null: if victim != null:
var world := get_parent().get_parent() # Bullets → Main var world := get_tree().current_scene as GameWorld
if world != null and world.has_method("broadcast_hit"): if world != null:
world.broadcast_hit.rpc(victim.name, DAMAGE) world.broadcast_hit.rpc(victim.name, DAMAGE)
queue_free() queue_free()
return return
@@ -966,19 +983,22 @@ func _physics_process(delta: float) -> void:
queue_free() queue_free()
func _find_hit() -> Node3D: func _find_hit() -> PlayerController:
for node in get_tree().get_nodes_in_group("net_player"): for node in get_tree().get_nodes_in_group("net_player"):
if node.name == str(owner_peer): var candidate := node as PlayerController
if candidate == null:
continue
if candidate.name == str(owner_peer):
continue # 别打自己 continue # 别打自己
# 用「到角色胶囊中心」的距离粗略判定,够用且不受碰撞层影响 # 用「到角色胶囊中心」的距离粗略判定,够用且不受碰撞层影响
var center: Vector3 = node.global_position + Vector3(0, 0.9, 0) var center: Vector3 = candidate.global_position + Vector3(0, 0.9, 0)
if center.distance_to(global_position) < HIT_RADIUS: if center.distance_to(global_position) < HIT_RADIUS:
return node return candidate
return null return null
``` ```
> 用距离判定而不是物理碰撞,是因为子弹和玩家都不需要额外的碰撞层配置, > `get_tree().current_scene` 就是主场景的根节点(本项目的 `Main`),比 `get_parent().get_parent()` 更稳,
> 而且**判定只发生在服务器**,天然避开了「各端物理结果不同步」的问题。 > 少一个「层级一变就断」的隐患。
### 验证 ### 验证
@@ -1161,10 +1181,13 @@ var _player_list: Label = null
func _refresh_player_list() -> void: func _refresh_player_list() -> void:
if _player_list == null or _hud_root == null or not _hud_root.visible: if _player_list == null or _hud_root == null or not _hud_root.visible:
return return
var lines: Array[String] = [] # 用 PackedStringArray 而不是 Array[String]:
# String.join() 要的就是 PackedStringArray,传 Array[String] 会在运行时转换失败
var lines := PackedStringArray()
for node in get_tree().get_nodes_in_group(PLAYER_GROUP): for node in get_tree().get_nodes_in_group(PLAYER_GROUP):
# 名字是同步过来的字段,所以这里读到的是「那个玩家自己起的名字」 # 名字是同步过来的字段,所以这里读到的是「那个玩家自己起的名字」。
var display: String = str(node.get("nickname")) # 用 get() 而不是 node.nickname:node 是 Node 类型,静态访问拿不到脚本变量
var display := str(node.get("nickname"))
if display.is_empty(): if display.is_empty():
display = "玩家 " + str(node.name) display = "玩家 " + str(node.name)
lines.append("· %s(peer %s)" % [display, node.name]) lines.append("· %s(peer %s)" % [display, node.name])