分离UI
This commit is contained in:
@@ -51,6 +51,59 @@
|
|||||||
GDScript 对动态成员访问极其宽容(`Node` 上调脚本方法、`Node3D.new()` 设脚本属性都不报错),
|
GDScript 对动态成员访问极其宽容(`Node` 上调脚本方法、`Node3D.new()` 设脚本属性都不报错),
|
||||||
所以「编译通过」不等于「语义正确」,逻辑对错还得靠人读。
|
所以「编译通过」不等于「语义正确」,逻辑对错还得靠人读。
|
||||||
|
|
||||||
|
### UI 改为节点配置(用户要求:不要代码生成 UI)
|
||||||
|
|
||||||
|
- 新增 `autoload/net.tscn`(根 `Net` 是 CanvasLayer,layer=20):
|
||||||
|
`Menu`(CenterContainer→PanelContainer→VBox: Title/AddressEdit/HostButton/JoinButton/StatusLabel/HintLabel)
|
||||||
|
+ `Hud`(Control→VBox: StatusLabel/HintLabel)。两个按钮的 `pressed` 在 .tscn 里连接到 `Net`。
|
||||||
|
- `autoload/net.gd` 重写:`extends CanvasLayer`,删掉全部 `_build_*`,改为 `@onready` 节点引用 + 状态更新
|
||||||
|
(`_on_host_button_pressed` / `_on_join_button_pressed` 由场景信号触发)。
|
||||||
|
- **`project.godot` 的 autoload 改成指向场景**:`Net="*res://autoload/net.tscn"`(原来指向 net.gd)。
|
||||||
|
这样 `Net` 这个全局名就是那个 CanvasLayer 根节点,`/root/Net` 路径不变,`main.gd`/`player.gd` 无需改动。
|
||||||
|
- 建场景的做法:`scene_manage(create)` → `batch_execute`(13 个 create_node) → `batch_execute`(29 个 set_property,`undo=false`)
|
||||||
|
→ `signal_manage(connect)` ×2 → `script_attach` → `scene_save`。Godot 自己序列化,比手写 .tscn 可靠。
|
||||||
|
- 验证:双实例 headless 联调通过(主机/客户端各自一个「本地操控」,日志镜像);编辑器内运行零报错。
|
||||||
|
- 文档同步:`联机框架使用指南.md` 文件地图;`联机实战教程-由浅入深.md` 关卡 3 / 关卡 9 改成「在编辑器加节点」写法。
|
||||||
|
|
||||||
|
### 玩家的 UI 也改成节点配置(同一轮完成)
|
||||||
|
|
||||||
|
- `player.tscn` 新增 `Hud (CanvasLayer)` → `HealthLabel (Label)`(位置 16,56)。
|
||||||
|
- `player.gd`:删掉 `_make_health_label()`(原来用代码建 CanvasLayer + Label),
|
||||||
|
改为 `@onready var _hud: CanvasLayer = $Hud` / `@onready var _health_label: Label = $Hud/HealthLabel`;
|
||||||
|
远端分支加 `_hud.visible = false`(否则 N 份血条叠在同一屏幕坐标,显示的是别人的血量);
|
||||||
|
本地分支末尾补 `_refresh_health_label()`(`@onready` 赋值晚于同步写入,那次 setter 会被 null 判断跳过)。
|
||||||
|
- `_report_identity()` 详细自检已恢复。
|
||||||
|
- 确认 `player_sync.tres` 现在有 3 个字段:`.:position`、`Body:rotation`、`.:health`(用户自己加的,正确)。
|
||||||
|
- 文档同步:`联机实战教程-由浅入深.md` 关卡 1 改成「先在 player.tscn 里加 Hud/HealthLabel 节点」;
|
||||||
|
`联机框架使用指南.md` 的 player.tscn 结构图。
|
||||||
|
|
||||||
|
### 反复出现的坑:编辑器把外部写盘的内容覆盖回去
|
||||||
|
|
||||||
|
`net.gd` 用 MCP `script_create` 写成功后,磁盘上又变回了旧版(`extends Node` + `_build_ui` 全在)。
|
||||||
|
原因:编辑器的**脚本编辑器标签页里缓存着旧文本**,资源 reload ≠ 标签页文本刷新,
|
||||||
|
之后一次保存就把旧文本写回去了。而且工具返回的 `reloaded: true` 会让人误以为已经生效。
|
||||||
|
|
||||||
|
**规避办法**:
|
||||||
|
1. 每次写完立刻从磁盘回读校验(Grep/Read 关键行),别信工具的 `reloaded`。
|
||||||
|
2. 改 Godot 项目里的脚本/场景一律走 godot-ai MCP,不要用外部写盘。
|
||||||
|
3. 交付时提醒用户:如果编辑器标签页里还是旧内容,关掉标签页重开(或重启编辑器),
|
||||||
|
并且在确认内容更新之前不要按 Ctrl+S。
|
||||||
|
|
||||||
|
`net.gd` 一共被还原了**两次**(第二次在 17:41~17:43 之间,导致 `git status` 里
|
||||||
|
`autoload/net.gd` 一度没有差异)。第二次修复后连续观察 30 秒无变化才确认落地。
|
||||||
|
排查时用:读文件首行 + 查 `_build_ui` + 看 size/mtime,隔一段时间对比。
|
||||||
|
|
||||||
|
### 仓库状态(供用户查证)
|
||||||
|
|
||||||
|
- `.gitignore` 只忽略 `.godot/` 和 `/android/`,业务文件不会被忽略。
|
||||||
|
- 最后一个提交是 `2ecab99`(17:32「扣血同步」,用户的关卡 1);此后所有改动**都还没提交**,
|
||||||
|
且 `origin/master...master` = `0 0`,即**一个都没推送**。
|
||||||
|
- 未提交清单:`autoload/net.gd`(-121 行)、`player/player.gd`、`player/player.tscn`、
|
||||||
|
`project.godot`、两份 docs、`.workbuddy/memory/...`;**`autoload/net.tscn` 是 untracked(新文件)**。
|
||||||
|
- 用户说「git 上看不到差异」,很可能是因为:① 看的是远端网页(没推送);
|
||||||
|
② GUI 客户端默认不显示 untracked 文件,而 `autoload/net.tscn` 正是新文件。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### godot-ai MCP 使用注意(重要)
|
### godot-ai MCP 使用注意(重要)
|
||||||
|
|
||||||
|
|||||||
+23
-98
@@ -1,10 +1,14 @@
|
|||||||
extends Node
|
extends CanvasLayer
|
||||||
## 联机总入口(Autoload 名:Net)
|
## 联机总入口(Autoload 名:Net,指向 res://autoload/net.tscn)
|
||||||
##
|
##
|
||||||
## 职责边界:只负责「选传输层 + 开局/加入/断开 + 玩家生成回收」。
|
## 职责边界:只负责「选传输层 + 开局/加入/断开 + 玩家生成回收」。
|
||||||
## 玩法代码不直接接触 MultiplayerPeer —— 以后要把 ENet 换成 Steam P2P,
|
## 玩法代码不直接接触 MultiplayerPeer —— 以后要把 ENet 换成 Steam P2P,
|
||||||
## 只需要改本文件的 host_game / join_game 两个函数,RPC 与 Spawner 层不用动。
|
## 只需要改本文件的 host_game / join_game 两个函数,RPC 与 Spawner 层不用动。
|
||||||
##
|
##
|
||||||
|
## 界面是**节点配置**:整个 UI 在 res://autoload/net.tscn 里用节点搭好,
|
||||||
|
## 本脚本只做「改文字、切显隐」这类状态更新,不动态创建任何 Control。
|
||||||
|
## 按钮的 pressed 信号也在 net.tscn 里连接(打开场景 → 选按钮 → 节点面板 → 信号)。
|
||||||
|
##
|
||||||
## 本机双开调试(命令行):
|
## 本机双开调试(命令行):
|
||||||
## godot --path <项目目录> -- --host
|
## godot --path <项目目录> -- --host
|
||||||
## godot --path <项目目录> -- --join 127.0.0.1
|
## godot --path <项目目录> -- --join 127.0.0.1
|
||||||
@@ -16,11 +20,12 @@ const DEFAULT_ADDRESS: String = "127.0.0.1"
|
|||||||
const PLAYER_GROUP: String = "net_player"
|
const PLAYER_GROUP: String = "net_player"
|
||||||
|
|
||||||
var _spawner: MultiplayerSpawner = null
|
var _spawner: MultiplayerSpawner = null
|
||||||
var _menu_root: Control = null
|
|
||||||
var _hud_root: Control = null
|
@onready var _menu: CenterContainer = $Menu
|
||||||
var _address_edit: LineEdit = null
|
@onready var _address_edit: LineEdit = $Menu/Panel/Box/AddressEdit
|
||||||
var _menu_status: Label = null
|
@onready var _menu_status: Label = $Menu/Panel/Box/StatusLabel
|
||||||
var _hud_status: Label = null
|
@onready var _hud: Control = $Hud
|
||||||
|
@onready var _hud_status: Label = $Hud/Box/StatusLabel
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -29,7 +34,7 @@ func _ready() -> void:
|
|||||||
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||||
_build_ui()
|
_show_menu_ui()
|
||||||
call_deferred("_apply_cmdline")
|
call_deferred("_apply_cmdline")
|
||||||
|
|
||||||
|
|
||||||
@@ -150,93 +155,14 @@ func _apply_cmdline() -> void:
|
|||||||
join_game(address)
|
join_game(address)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 界面 ----------------
|
# ---------------- 界面(节点都在 net.tscn 里,这里只管状态) ----------------
|
||||||
|
|
||||||
func _build_ui() -> void:
|
## 两个按钮的 pressed 信号在 net.tscn 里连到这里
|
||||||
var menu_layer := CanvasLayer.new()
|
func _on_host_button_pressed() -> void:
|
||||||
menu_layer.name = "NetMenu"
|
host_game()
|
||||||
menu_layer.layer = 20
|
|
||||||
add_child(menu_layer)
|
|
||||||
_menu_root = _build_menu()
|
|
||||||
menu_layer.add_child(_menu_root)
|
|
||||||
|
|
||||||
var hud_layer := CanvasLayer.new()
|
|
||||||
hud_layer.name = "NetHud"
|
|
||||||
hud_layer.layer = 19
|
|
||||||
add_child(hud_layer)
|
|
||||||
_hud_root = _build_hud()
|
|
||||||
_hud_root.visible = false
|
|
||||||
hud_layer.add_child(_hud_root)
|
|
||||||
|
|
||||||
|
|
||||||
func _build_menu() -> Control:
|
func _on_join_button_pressed() -> void:
|
||||||
var center := CenterContainer.new()
|
|
||||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
|
|
||||||
var panel := PanelContainer.new()
|
|
||||||
center.add_child(panel)
|
|
||||||
|
|
||||||
var box := VBoxContainer.new()
|
|
||||||
box.add_theme_constant_override("separation", 10)
|
|
||||||
panel.add_child(box)
|
|
||||||
|
|
||||||
var title := Label.new()
|
|
||||||
title.text = "联机测试"
|
|
||||||
title.add_theme_font_size_override("font_size", 20)
|
|
||||||
box.add_child(title)
|
|
||||||
|
|
||||||
_address_edit = LineEdit.new()
|
|
||||||
_address_edit.text = DEFAULT_ADDRESS
|
|
||||||
_address_edit.custom_minimum_size = Vector2(240, 0)
|
|
||||||
box.add_child(_address_edit)
|
|
||||||
|
|
||||||
var host_button := Button.new()
|
|
||||||
host_button.text = "创建主机"
|
|
||||||
host_button.pressed.connect(host_game)
|
|
||||||
box.add_child(host_button)
|
|
||||||
|
|
||||||
var join_button := Button.new()
|
|
||||||
join_button.text = "加入主机"
|
|
||||||
join_button.pressed.connect(_on_join_pressed)
|
|
||||||
box.add_child(join_button)
|
|
||||||
|
|
||||||
_menu_status = Label.new()
|
|
||||||
_menu_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
||||||
_menu_status.custom_minimum_size = Vector2(240, 0)
|
|
||||||
box.add_child(_menu_status)
|
|
||||||
|
|
||||||
var hint := Label.new()
|
|
||||||
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
||||||
hint.custom_minimum_size = Vector2(240, 0)
|
|
||||||
hint.modulate = Color(1, 1, 1, 0.7)
|
|
||||||
hint.text = "局域网内填主机的内网 IP;本机双开测试填 %s" % DEFAULT_ADDRESS
|
|
||||||
box.add_child(hint)
|
|
||||||
|
|
||||||
return center
|
|
||||||
|
|
||||||
|
|
||||||
func _build_hud() -> Control:
|
|
||||||
var layer_root := Control.new()
|
|
||||||
layer_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
layer_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
|
|
||||||
var box := VBoxContainer.new()
|
|
||||||
box.position = Vector2(16, 12)
|
|
||||||
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
layer_root.add_child(box)
|
|
||||||
|
|
||||||
_hud_status = Label.new()
|
|
||||||
box.add_child(_hud_status)
|
|
||||||
|
|
||||||
var hint := Label.new()
|
|
||||||
hint.modulate = Color(1, 1, 1, 0.7)
|
|
||||||
hint.text = "F10 断开 · Esc 释放鼠标"
|
|
||||||
box.add_child(hint)
|
|
||||||
|
|
||||||
return layer_root
|
|
||||||
|
|
||||||
|
|
||||||
func _on_join_pressed() -> void:
|
|
||||||
join_game(_address_edit.text)
|
join_game(_address_edit.text)
|
||||||
|
|
||||||
|
|
||||||
@@ -248,19 +174,18 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _show_game_ui() -> void:
|
func _show_game_ui() -> void:
|
||||||
_menu_root.visible = false
|
_menu.visible = false
|
||||||
_hud_root.visible = true
|
_hud.visible = true
|
||||||
_update_hud()
|
_update_hud()
|
||||||
|
|
||||||
|
|
||||||
func _show_menu_ui() -> void:
|
func _show_menu_ui() -> void:
|
||||||
_menu_root.visible = true
|
_menu.visible = true
|
||||||
_hud_root.visible = false
|
_hud.visible = false
|
||||||
|
|
||||||
|
|
||||||
func _set_status(text: String) -> void:
|
func _set_status(text: String) -> void:
|
||||||
if _menu_status != null:
|
_menu_status.text = text
|
||||||
_menu_status.text = text
|
|
||||||
|
|
||||||
|
|
||||||
func _update_hud() -> void:
|
func _update_hud() -> void:
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[gd_scene format=3 uid="uid://l7aug4qrbosl"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://tqratkoyv2up" path="res://autoload/net.gd" id="1_ftux4"]
|
||||||
|
|
||||||
|
[node name="Net" type="CanvasLayer" unique_id=707762690]
|
||||||
|
layer = 20
|
||||||
|
script = ExtResource("1_ftux4")
|
||||||
|
|
||||||
|
[node name="Menu" type="CenterContainer" parent="." unique_id=1952727206]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="Panel" type="PanelContainer" parent="Menu" unique_id=1368615243]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Box" type="VBoxContainer" parent="Menu/Panel" unique_id=230570449]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Menu/Panel/Box" unique_id=435547235]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
text = "联机测试"
|
||||||
|
|
||||||
|
[node name="AddressEdit" type="LineEdit" parent="Menu/Panel/Box" unique_id=1928211362]
|
||||||
|
custom_minimum_size = Vector2(240, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "127.0.0.1"
|
||||||
|
|
||||||
|
[node name="HostButton" type="Button" parent="Menu/Panel/Box" unique_id=2046876747]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "创建主机"
|
||||||
|
|
||||||
|
[node name="JoinButton" type="Button" parent="Menu/Panel/Box" unique_id=440448657]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "加入主机"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="Menu/Panel/Box" unique_id=273872406]
|
||||||
|
custom_minimum_size = Vector2(240, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="HintLabel" type="Label" parent="Menu/Panel/Box" unique_id=1270685438]
|
||||||
|
modulate = Color(1, 1, 1, 0.7)
|
||||||
|
custom_minimum_size = Vector2(240, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "局域网内填主机的内网 IP;本机双开测试填 127.0.0.1"
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="Hud" type="Control" parent="." unique_id=1778234630]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Box" type="VBoxContainer" parent="Hud" unique_id=623926462]
|
||||||
|
layout_mode = 0
|
||||||
|
offset_left = 16.0
|
||||||
|
offset_top = 12.0
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="Hud/Box" unique_id=560394958]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="HintLabel" type="Label" parent="Hud/Box" unique_id=183207570]
|
||||||
|
modulate = Color(1, 1, 1, 0.7)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "F10 断开 · Esc 释放鼠标"
|
||||||
|
|
||||||
|
[connection signal="pressed" from="Menu/Panel/Box/HostButton" to="." method="_on_host_button_pressed"]
|
||||||
|
[connection signal="pressed" from="Menu/Panel/Box/JoinButton" to="." method="_on_join_button_pressed"]
|
||||||
+102
-66
@@ -13,15 +13,18 @@
|
|||||||
| 关卡 | 难度 | 你要实现的 | 新概念 | 改动文件 |
|
| 关卡 | 难度 | 你要实现的 | 新概念 | 改动文件 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 0 | 热身 | 跑起来并看懂日志 | peer / authority | 无 |
|
| 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` |
|
| 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` |
|
| 4 | ★★ | 按 G 让所有端看到挥手光球 | 第一个 RPC | `player.gd` |
|
||||||
| 5 | ★★★ | 按 K 打最近的玩家,对方掉血 | 两段式 RPC + 服务器校验 | `player.gd`、`main.gd` |
|
| 5 | ★★★ | 按 K 打最近的玩家,对方掉血 | 两段式 RPC + 服务器校验 | `player.gd`、`main.gd` |
|
||||||
| 6 | ★★★ | 按 J 发射子弹 | 第二个 MultiplayerSpawner | `main.tscn`、`world/bullets.gd` 等 |
|
| 6 | ★★★ | 按 J 发射子弹 | 第二个 MultiplayerSpawner | `main.tscn`、`world/bullets.gd` 等 |
|
||||||
| 7 | ★★★★ | 子弹命中扣血,且改不掉 | 服务器权威 + 多 Synchronizer | `player.tscn`、`main.gd`、`player.gd` |
|
| 7 | ★★★★ | 子弹命中扣血,且改不掉 | 服务器权威 + 多 Synchronizer | `player.tscn`、`main.gd`、`player.gd` |
|
||||||
| 8 | ★★★★ | 走动的远端玩家不再一跳一跳 | 自己写插值 | `player.gd`、`player_sync.tres` |
|
| 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,这个节点归我管吗?true,我的名字是 1
|
||||||
[观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715
|
[观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715
|
||||||
客户端:[观察] 我是 1334014715,这个节点归我管吗?false,我的名字是 1
|
客户端:[观察] 我是 1334014715,这个节点归我管吗?false,我的名字是 1
|
||||||
[观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715
|
[观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715
|
||||||
```
|
```
|
||||||
|
|
||||||
**你要记住的三件事**:
|
**你要记住的三件事**:
|
||||||
@@ -81,52 +84,84 @@ func _ready() -> void:
|
|||||||
|
|
||||||
**目标**:按 `H` 给自己扣 10 点血,两台机器的血条显示一致。
|
**目标**:按 `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
|
```gdscript
|
||||||
|
@export_group("属性")
|
||||||
## 血量。用 setter 是为了「值一变就刷新 UI」,不管这个值是本地改的还是同步过来的
|
## 血量。用 setter 是为了「值一变就刷新 UI」,不管这个值是本地改的还是同步过来的
|
||||||
@export var health: int = 100:
|
@export var health: int = 100:
|
||||||
set(value):
|
set(value):
|
||||||
health = clampi(value, 0, 100)
|
health = clampi(value, 0, 100)
|
||||||
_refresh_health_label()
|
_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,省得到处写边界判断。
|
`clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。
|
||||||
|
|
||||||
在 `_ready()` 的**本地分支**里(`set_mouse_captured(true)` 之后)加上建血条的代码:
|
**1c. 处理远端玩家**
|
||||||
|
|
||||||
|
血条节点现在**每个玩家实例上都有**(因为它是场景的一部分)。远端玩家不需要显示,所以在 `_ready()` 的远端分支里关掉:
|
||||||
|
|
||||||
```gdscript
|
```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
|
```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:
|
func _refresh_health_label() -> void:
|
||||||
# 远端玩家没有血条,_health_label 是 null,这里直接跳过
|
# 同步可能在 _ready() 之前就把 health 写进来了,那时 _health_label 还是 null,直接跳过
|
||||||
if _health_label == null:
|
if _health_label == null:
|
||||||
return
|
return
|
||||||
_health_label.text = "HP %d / 100" % health
|
_health_label.text = "HP %d / 100" % health
|
||||||
```
|
```
|
||||||
|
|
||||||
> **为什么 setter 和 `_health_label` 要判空?**
|
> **为什么 setter 要判空、`_ready()` 末尾还要再刷一次?**
|
||||||
> 因为同步是在节点刚进场景树时就把值写进来的,可能**早于** `_ready()` 里建血条那一步。
|
> `@onready` 变量是在 `_ready()` 被调用**之前**才赋值的。而同步写入可能来得更早
|
||||||
> 所以 setter 里的东西必须能容忍「UI 还不存在」。
|
> (节点刚进场景树时)。那一次 setter 会因为 `_health_label == null` 被跳过,
|
||||||
|
> 所以本地分支末尾要补一次 `_refresh_health_label()`,把当前值显示出来。
|
||||||
|
|
||||||
### 第 2 步:把 health 加进同步配置
|
### 第 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 端自己的同步字段 → 自动传播。**
|
**新概念**:**本地输入 → 写进 authority 端自己的同步字段 → 自动传播。**
|
||||||
|
|
||||||
@@ -257,30 +292,34 @@ func _apply_body_color() -> void:
|
|||||||
|
|
||||||
### 第 1 步:菜单加一个昵称输入框
|
### 第 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
|
```gdscript
|
||||||
var _name_edit: LineEdit = null
|
|
||||||
## 玩家给自己起的名字,空的话用 peer id
|
## 玩家给自己起的名字,空的话用 peer id
|
||||||
var player_name: String = ""
|
var player_name: String = ""
|
||||||
|
|
||||||
|
@onready var _name_edit: LineEdit = $Menu/Panel/Box/NameEdit
|
||||||
```
|
```
|
||||||
|
|
||||||
在 `_build_menu()` 里,`_address_edit` 那段**下面**插入:
|
> `$Menu/Panel/Box/NameEdit` 就是你在场景里搭出来的节点路径。改完 UI 后如果路径变了(比如移动了节点),这里也要跟着改——否则运行时报 `null` 实例访问错误。
|
||||||
|
|
||||||
```gdscript
|
最后在 `host_game()` 和 `join_game()` 的**开头**各加一行,把输入框的值记下来:
|
||||||
_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()` 的**开头**各加一行,把输入框的值记下来:
|
|
||||||
|
|
||||||
```gdscript
|
```gdscript
|
||||||
player_name = _name_edit.text.strip_edges()
|
player_name = _name_edit.text.strip_edges()
|
||||||
```
|
```
|
||||||
|
|
||||||
> 加在开头是因为:加入成功后菜单会被隐藏,那时就读不到输入框了。
|
> 加在开头是因为:加入成功后菜单会被隐藏,那时再读输入框就不合适了。
|
||||||
|
|
||||||
### 第 2 步:Player 加昵称字段
|
### 第 2 步:Player 加昵称字段
|
||||||
|
|
||||||
@@ -616,10 +655,10 @@ A 按 K
|
|||||||
├─ 发起者是不是 B 节点的 owner?✓
|
├─ 发起者是不是 B 节点的 owner?✓
|
||||||
├─ 距离 ≤ 3 米?✓
|
├─ 距离 ≤ 3 米?✓
|
||||||
└─ world.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
|
||||||
└─ 其他机器:同上,什么都不做
|
└─ 其他机器:同上,什么都不做
|
||||||
```
|
```
|
||||||
|
|
||||||
### 这里埋着一个坑(关卡 7 会修)
|
### 这里埋着一个坑(关卡 7 会修)
|
||||||
@@ -1049,7 +1088,7 @@ func _find_hit() -> PlayerController:
|
|||||||
|
|
||||||
```
|
```
|
||||||
权威端: _net_position ← 真实位置 (每物理帧)
|
权威端: _net_position ← 真实位置 (每物理帧)
|
||||||
↓ 同步
|
↓ 同步
|
||||||
远端: 真实位置 ← 朝 _net_position 平滑逼近 (每渲染帧)
|
远端: 真实位置 ← 朝 _net_position 平滑逼近 (每渲染帧)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1146,40 +1185,37 @@ func _process(delta: float) -> void:
|
|||||||
|
|
||||||
**目标**:左上角实时显示「谁在房间里」,用昵称。
|
**目标**:左上角实时显示「谁在房间里」,用昵称。
|
||||||
|
|
||||||
**改动**:`autoload/net.gd`
|
**改动**:`autoload/net.tscn`、`autoload/net.gd`
|
||||||
|
|
||||||
这一关没有新概念,但把前面所有链路串起来:**spawn → 同步昵称 → UI 显示**。
|
这一关没有新概念,但把前面所有链路串起来:**spawn → 同步昵称 → UI 显示**。
|
||||||
|
|
||||||
### 第 1 步:HUD 加一行列表
|
### 第 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
|
```gdscript
|
||||||
var _player_list: Label = null
|
@onready var _player_list: Label = $Hud/Box/PlayerList
|
||||||
```
|
```
|
||||||
|
|
||||||
`_build_hud()` 里,在 `_hud_status` 后面插入:
|
> **为什么用 Timer 而不是每帧刷新**:字符串拼接 + 排序是很便宜但不必要的开销,
|
||||||
|
> 一秒钟刷两次已经足够。养成「UI 按需刷新」的习惯,比事后优化省事得多。
|
||||||
```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)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 2 步:实现刷新
|
### 第 2 步:实现刷新
|
||||||
|
|
||||||
```gdscript
|
```gdscript
|
||||||
func _refresh_player_list() -> void:
|
func _refresh_player_list() -> void:
|
||||||
if _player_list == null or _hud_root == null or not _hud_root.visible:
|
if not _hud.visible:
|
||||||
return
|
return
|
||||||
# 用 PackedStringArray 而不是 Array[String]:
|
# 用 PackedStringArray 而不是 Array[String]:
|
||||||
# String.join() 要的就是 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)
|
_player_list.text = "房间里 %d 人\n" % lines.size() + "\n".join(lines)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第 3 步:进出房间也刷一下
|
### 第 3 步:进出房间也立刻刷一下
|
||||||
|
|
||||||
在 `_enter_game()` 和 `_leave_game_ui()` 末尾都加:
|
在 `_show_game_ui()` 和 `_show_menu_ui()` 末尾都加:
|
||||||
|
|
||||||
```gdscript
|
```gdscript
|
||||||
_refresh_player_list()
|
_refresh_player_list()
|
||||||
```
|
```
|
||||||
|
|
||||||
这样不用等半秒。
|
这样不用等半秒的定时器。
|
||||||
|
|
||||||
### 验证
|
### 验证
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -70,8 +70,12 @@ RPC 和复制都是靠**节点路径**找到对面对应的节点。所以:
|
|||||||
```
|
```
|
||||||
E:\GD_Projects\agame\
|
E:\GD_Projects\agame\
|
||||||
├── autoload\
|
├── autoload\
|
||||||
│ └── net.gd ← 联机总入口(Autoload 名:Net)
|
│ ├── net.tscn ← 联机总入口(Autoload 名:Net,指向这个场景)
|
||||||
│ 管传输层、开局/加入/断开、玩家生成回收、菜单和 HUD
|
│ │ Net (CanvasLayer) ← 挂 net.gd
|
||||||
|
│ │ ├─ Menu (CenterContainer) ← 主菜单:标题/地址框/两个按钮/状态/提示
|
||||||
|
│ │ └─ Hud (Control) ← 游戏内 HUD:状态 + 操作提示
|
||||||
|
│ └── net.gd ← 管传输层、开局/加入/断开、玩家生成回收;
|
||||||
|
│ UI 是节点配置,这里只改文字、切显隐
|
||||||
├── world\
|
├── world\
|
||||||
│ ├── main.tscn ← 世界根场景
|
│ ├── main.tscn ← 世界根场景
|
||||||
│ │ Main (Node3D)
|
│ │ Main (Node3D)
|
||||||
@@ -85,7 +89,9 @@ E:\GD_Projects\agame\
|
|||||||
│ │ ├─ Collision
|
│ │ ├─ Collision
|
||||||
│ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支
|
│ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支
|
||||||
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
|
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
|
||||||
│ │ └─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
|
│ │ ├─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
|
||||||
|
│ │ └─ Hud (CanvasLayer) ← 本地玩家的血条,远端玩家会被隐藏
|
||||||
|
│ │ └─ HealthLabel (Label)
|
||||||
│ ├── player.gd ← 加了「本地 / 远端」分支
|
│ ├── player.gd ← 加了「本地 / 远端」分支
|
||||||
│ └── player_sync.tres ← 同步配置:到底同步哪几个属性
|
│ └── player_sync.tres ← 同步配置:到底同步哪几个属性
|
||||||
└── tests\
|
└── tests\
|
||||||
|
|||||||
+53
-19
@@ -6,9 +6,12 @@ extends CharacterBody3D
|
|||||||
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
|
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
|
||||||
## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面
|
## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面
|
||||||
## │ └─ Mesh (MeshInstance3D)
|
## │ └─ Mesh (MeshInstance3D)
|
||||||
## └─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转
|
## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转
|
||||||
## └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
|
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
|
||||||
## └─ Camera (Camera3D)
|
## │ └─ Camera (Camera3D)
|
||||||
|
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position 与 Body.rotation
|
||||||
|
## └─ Hud (CanvasLayer) ← 本地玩家的血条(节点配置,不是代码建的)
|
||||||
|
## └─ HealthLabel (Label)
|
||||||
##
|
##
|
||||||
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标
|
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ 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
|
||||||
|
@onready var _hud: CanvasLayer = $Hud
|
||||||
|
@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
|
||||||
@@ -54,23 +60,19 @@ var _pitch: float = 0.0
|
|||||||
var _jump_buffer: int = 0
|
var _jump_buffer: int = 0
|
||||||
## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。
|
## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。
|
||||||
var _is_local: bool = true
|
var _is_local: bool = true
|
||||||
# 血条
|
|
||||||
var _health_label: Label = null
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
print("[观察] 我是 %d,这个节点归我管吗?%s,我的名字是 %s"
|
_report_identity()
|
||||||
% [multiplayer.get_unique_id(), is_multiplayer_authority(), name])
|
|
||||||
# 生成血条
|
|
||||||
_make_health_label()
|
|
||||||
|
|
||||||
_is_local = is_multiplayer_authority()
|
_is_local = is_multiplayer_authority()
|
||||||
if not _is_local:
|
if not _is_local:
|
||||||
# 远端玩家只是一具「回放身体」:
|
# 远端玩家只是一具「回放身体」:
|
||||||
# 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染;
|
# 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染;
|
||||||
|
# 关掉血条 —— 否则 N 份血条叠在同一位置,显示的血量可能是别人的;
|
||||||
# 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。
|
# 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。
|
||||||
# 它的 position 与 Body.rotation 由子节点 Sync 同步过来。
|
# 它的 position 与 Body.rotation 由子节点 Sync 同步过来。
|
||||||
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
|
||||||
@@ -78,6 +80,8 @@ func _ready() -> void:
|
|||||||
_pitch = clampf(spring_arm.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
|
_pitch = clampf(spring_arm.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
|
||||||
spring_arm.rotation.x = _pitch
|
spring_arm.rotation.x = _pitch
|
||||||
set_mouse_captured(true)
|
set_mouse_captured(true)
|
||||||
|
# 同步可能在 _ready() 之前就把 health 写进来了,那时血条还没刷新,这里补一次
|
||||||
|
_refresh_health_label()
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
@@ -148,17 +152,47 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
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:
|
func _refresh_health_label() -> void:
|
||||||
# 远端玩家没有血条,_health_label 是 null,这里直接跳过
|
# 血条节点在 player.tscn 里(Hud/HealthLabel)。
|
||||||
|
# 同步可能在 _ready() 之前就把 health 写进来,那时 _health_label 还是 null,直接跳过。
|
||||||
if _health_label == null:
|
if _health_label == null:
|
||||||
return
|
return
|
||||||
_health_label.text = "HP %d / 100" % health
|
_health_label.text = "HP %d / 100" % health
|
||||||
|
|
||||||
|
|
||||||
|
## 关卡 0 的临时自检:把「我这一端」和「这个节点」分层打印出来。
|
||||||
|
## 联机框架调通后可以整段删掉(连同 _ready() 里的那一行调用)。
|
||||||
|
func _report_identity() -> void:
|
||||||
|
var me: int = multiplayer.get_unique_id()
|
||||||
|
var authority: int = get_multiplayer_authority()
|
||||||
|
|
||||||
|
# 传输层类型:以后换成 Steam 时这里会变成 SteamMultiplayerPeer
|
||||||
|
var transport := "无"
|
||||||
|
if multiplayer.multiplayer_peer != null:
|
||||||
|
transport = multiplayer.multiplayer_peer.get_class()
|
||||||
|
|
||||||
|
# 同步配置:字段为空或未绑定是「远端玩家僵在原地」的头号原因,所以顺手打出来
|
||||||
|
var sync := get_node_or_null("Sync") as MultiplayerSynchronizer
|
||||||
|
var fields := "未绑定"
|
||||||
|
if sync != null and sync.replication_config != null:
|
||||||
|
fields = str(sync.replication_config.get_properties())
|
||||||
|
|
||||||
|
print("""┌─ [节点自检] %s
|
||||||
|
│ 进程级 我是 peer %d | 服务器?%s | 传输 %s | 我知道的 peer:%s
|
||||||
|
│ 节点级 全路径 %s
|
||||||
|
│ 节点名 %s | 归属 peer %d | 归我管?%s
|
||||||
|
│ 同步 Sync 字段 %s
|
||||||
|
└─ 结论 %s""" % [
|
||||||
|
name,
|
||||||
|
me,
|
||||||
|
multiplayer.is_server(),
|
||||||
|
transport,
|
||||||
|
multiplayer.get_peers(),
|
||||||
|
get_path(),
|
||||||
|
name,
|
||||||
|
authority,
|
||||||
|
is_multiplayer_authority(),
|
||||||
|
fields,
|
||||||
|
"这是【我操控】的角色" if is_multiplayer_authority() else "这是【别人】的角色,我只负责回放",
|
||||||
|
])
|
||||||
|
|||||||
+9
-10
@@ -18,16 +18,6 @@ roughness = 0.6
|
|||||||
|
|
||||||
[node name="Player" type="CharacterBody3D" unique_id=869687860]
|
[node name="Player" type="CharacterBody3D" unique_id=869687860]
|
||||||
script = ExtResource("1_oul6g")
|
script = ExtResource("1_oul6g")
|
||||||
health = null
|
|
||||||
walk_speed = null
|
|
||||||
sprint_speed = null
|
|
||||||
acceleration = null
|
|
||||||
air_acceleration = null
|
|
||||||
jump_velocity = null
|
|
||||||
turn_speed = null
|
|
||||||
mouse_sensitivity = null
|
|
||||||
pitch_min_deg = null
|
|
||||||
pitch_max_deg = null
|
|
||||||
|
|
||||||
[node name="Collision" type="CollisionShape3D" parent="." unique_id=215132582]
|
[node name="Collision" type="CollisionShape3D" parent="." unique_id=215132582]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
|
||||||
@@ -53,3 +43,12 @@ 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="Label" parent="Hud" unique_id=348125801]
|
||||||
|
offset_left = 16.0
|
||||||
|
offset_top = 56.0
|
||||||
|
offset_right = 17.0
|
||||||
|
offset_bottom = 79.0
|
||||||
|
text = "HP 100 / 100"
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ config/icon="res://icon.svg"
|
|||||||
|
|
||||||
Steamworks="*uid://w087kpi6dipk"
|
Steamworks="*uid://w087kpi6dipk"
|
||||||
_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd"
|
_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd"
|
||||||
Net="*res://autoload/net.gd"
|
Net="*res://autoload/net.tscn"
|
||||||
|
|
||||||
[debug]
|
[debug]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user