diff --git a/docs/联机实战教程-由浅入深.md b/docs/联机实战教程-由浅入深.md index 855cfe7..a09e61d 100644 --- a/docs/联机实战教程-由浅入深.md +++ b/docs/联机实战教程-由浅入深.md @@ -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])