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
func _ready() -> void:
_is_local = is_multiplayer_authority()
_make_nameplate() # ← 名牌是所有玩家都要显示的东西
_make_nameplate() # ← 名牌是所有玩家都要显示的东西,要放在 is_local 判断之前
if not _is_local:
camera_rig.queue_free()
...
set_physics_process(false)
set_process_unhandled_input(false)
return
# 只有自己给自己起名
nickname = get_node_or_null("/root/Net").player_name if ...
```
最后那行写清楚点,用工具函数更好:
```gdscript
# 下面几行是前面几关攒下来的,只有本地玩家会执行
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:
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 net.player_name
return typed
```
> **为什么用 `/root/Net` 而不是直接写 `Net`?**
@@ -614,7 +615,7 @@ A 按 K
├─ is_server() ✓
├─ 发起者是不是 B 节点的 owner?✓
├─ 距离 ≤ 3 米?✓
└─ parent.broadcast_hit.rpc("B", 10) ← 广播给所有人
└─ world.broadcast_hit.rpc("B", 10) ← 广播给所有人
│
├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做
├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10
@@ -922,22 +923,38 @@ func _create_player(peer_id: int) -> Node:
### 第 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
@rpc("authority", "call_local", "reliable")
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:
return
# 服务器直接改。因为 health 的权威端就是服务器,
# 这个值会自动同步给所有人,客户端改了也会被覆盖回来
target.health = target.health - amount
# 服务器直接扣。玩家自己的机器现在改不动这个值了
target.apply_server_damage(amount)
```
注意这里是 `target.health = ...`,**不是** `target.receive_damage(...)`。现在所有权在服务器手上,服务器说了算。
**这一关的全部意义就是判断条件从 `_is_local` 变成了 `is_server()`:**
| | 关卡 5 | 关卡 7 |
|---|---|---|
| `health` 的权威端 | 玩家自己 | 服务器 |
| 谁执行扣血 | 受害者的客户端 | 服务器 |
| 客户端能偷偷改血量吗 | 能(作弊) | 不能(会被服务器覆盖) |
| 谁来判定命中 | 攻击方的客户端 | 服务器 |
### 第 6 步:命中判定(子弹打中人)
@@ -956,8 +973,8 @@ func _physics_process(delta: float) -> void:
if multiplayer.is_server():
var victim := _find_hit()
if victim != null:
var world := get_parent().get_parent() # Bullets → Main
if world != null and world.has_method("broadcast_hit"):
var world := get_tree().current_scene as GameWorld
if world != null:
world.broadcast_hit.rpc(victim.name, DAMAGE)
queue_free()
return
@@ -966,19 +983,22 @@ func _physics_process(delta: float) -> void:
queue_free()
func _find_hit() -> Node3D:
func _find_hit() -> PlayerController:
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 # 别打自己
# 用「到角色胶囊中心」的距离粗略判定,够用且不受碰撞层影响
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:
return node
return candidate
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:
if _player_list == null or _hud_root == null or not _hud_root.visible:
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):
# 名字是同步过来的字段,所以这里读到的是「那个玩家自己起的名字」
var display: String = str(node.get("nickname"))
# 名字是同步过来的字段,所以这里读到的是「那个玩家自己起的名字」。
# 用 get() 而不是 node.nickname:node 是 Node 类型,静态访问拿不到脚本变量
var display := str(node.get("nickname"))
if display.is_empty():
display = "玩家 " + str(node.name)
lines.append("· %s(peer %s)" % [display, node.name])