Compare commits
4
Commits
020ab88606
...
93222895a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93222895a5 | ||
|
|
e71276050c | ||
|
|
cac2f7d87b | ||
|
|
db60e35766 |
@@ -0,0 +1,60 @@
|
|||||||
|
# 2026-09-22
|
||||||
|
|
||||||
|
## 给 Godot 项目 agame 接上局域网联机(ENet 基础版)
|
||||||
|
|
||||||
|
全流程通过 `godot-ai` MCP 操作编辑器完成,`E:\GD_Projects\agame` 项目。
|
||||||
|
|
||||||
|
### 落地结果
|
||||||
|
|
||||||
|
- 新增 `autoload/net.gd`(Autoload 名 `Net`):传输层 + 开局/加入/断开 + 玩家生成回收 + 主菜单/HUD(代码建 UI,无 .tscn)。
|
||||||
|
- 新增 `world/main.gd`:把 `player.tscn` 注册给 `MultiplayerSpawner.spawn_function`,`spawn_function` 里 `set_multiplayer_authority(peer_id, true)` + `add_to_group("net_player")`。
|
||||||
|
- 新增 `player/player_sync.tres`(`SceneReplicationConfig`),同步字段:`.:position`、`Body:rotation`,`replication_mode = 1`(ALWAYS)。
|
||||||
|
- `player/player.tscn`:根下加 `Sync`(`MultiplayerSynchronizer`,`root_path` 保持默认 `..`),绑定上面那个 .tres。
|
||||||
|
- `world/main.tscn`:删掉硬编码的 Player 实例,改挂 `MultiplayerSpawner`(`spawn_path = ..`)。
|
||||||
|
- `player/player.gd`:加 `_is_local`;远端玩家 `camera_rig.queue_free()` + 关物理 + 关 unhandled input。
|
||||||
|
- 新增 `tests/test_net_replication.gd`(`McpTestSuite`),3 个用例 12 条断言全绿,钉住同步配置不被静默写坏。
|
||||||
|
|
||||||
|
### 关键结论 / 踩坑
|
||||||
|
|
||||||
|
- **节点 authority 不继承**:必须 `set_multiplayer_authority(id, true)`,否则子节点 `MultiplayerSynchronizer` 仍是服务器 authority,同步不发。
|
||||||
|
- **远端玩家必须拆相机并关物理**,否则多台 Camera3D 抢渲染、双重模拟导致抖动。
|
||||||
|
- 去中心化 authority 模型跑通(host=1 本地操控 / client 远端回放,client 侧镜像)+ ENet 中继正常。
|
||||||
|
- 命令行调试入口:`Net._apply_cmdline()` 支持 `-- --host` / `-- --join <ip>`。
|
||||||
|
- ~~项目已存在的无关报错:`res://addons/ziva_agent/ziva_agent.gdextension` 文件缺失,每次启动都刷 3 行 ERROR。~~ **已修复**:
|
||||||
|
插件目录已删除,但 `.godot/extension_list.cfg` 里残留了一行 `res://addons/ziva_agent/ziva_agent.gdextension`。
|
||||||
|
`filesystem_manage(op="scan")` 不会清掉它(Godot 只在运行中观测到 .gdextension 增删时才重写该文件),
|
||||||
|
直接删掉那一行即可。已用 headless 启动验证:3 行 ERROR 消失。
|
||||||
|
- AppID 仍是 480(Spacewar 测试,Steam 客户端未装 → `status:1`),换 Steam P2P 时只需改 `Net.host_game/join_game`。
|
||||||
|
|
||||||
|
### 输出文档
|
||||||
|
|
||||||
|
- 新增 `docs/联机框架使用指南.md`:面向零联机基础的人,含概念(peer/authority/复制 vs RPC)、文件地图、
|
||||||
|
三种跑法、四个「新功能怎么同步」配方、Steam P2P 迁移(直连 SteamID 版 + 大厅版)、调试与已知限制。
|
||||||
|
- **Steam API 签名已用探针脚本让 GDScript 分析器逐个校验**(本项目装的是 camelCase 风格 GodotSteam):
|
||||||
|
- `Steam.createLobby(type, max_members)` **返回 void**,结果走 `lobby_created(connect_result, lobby_id)` 信号
|
||||||
|
- `Steam.joinLobby(lobby_id)` → `lobby_joined(lobby_id, permissions, locked, response)` 信号
|
||||||
|
- `Steam.requestLobbyList()` → `lobby_match_list(lobbies: Array)` 信号;成功码 = 1
|
||||||
|
- `Steam.getSteamID()`;`setLobbyData/getLobbyData/getLobbyOwner/getNumLobbyMembers/getLobbyMemberByIndex`
|
||||||
|
- `SteamMultiplayerPeer`:`create_host(port=0)`、`create_client(steam_id, port=0)`、`host_with_lobby(lobby_id)`、
|
||||||
|
`connect_to_lobby(lobby_id)`、`add_peer(steam_id, port=0)`、`set_no_delay/set_no_nagle/set_debug_level`
|
||||||
|
- 校验手法(可复用):写临时 .gd 调用候选 API,`script_create` 返回的 diagnostics 会指出「方法不存在」「返回 void 不能赋值」等,比查文档更快更准。
|
||||||
|
- 新增 `docs/联机实战教程-由浅入深.md`:九关递进教程(血量同步 → 颜色认领 → 头顶名牌 → 挥手 RPC → 两段式攻击 →
|
||||||
|
子弹 Spawner → 服务器权威血量 → 远端插值 → 玩家列表)。按键约定 H/G/K/J。
|
||||||
|
- 教程里引入的两个工程改动(后续实现时要跟着做):`player.gd` 加 `class_name PlayerController`、
|
||||||
|
`main.gd` 加 `class_name GameWorld`、`bullet.gd` 加 `class_name Bullet`、`bullets.gd` 加 `class_name BulletPool`,
|
||||||
|
这样跨脚本调用才有编译期检查。
|
||||||
|
- **写教程时抓到一个真 bug**:`parent.broadcast_hit(...)` 是本地调用,伤害传不到对方机器,必须
|
||||||
|
`world.broadcast_hit.rpc(...)`。GDScript 对漏 `.rpc()` 不报任何错,这是最阴的一类联机 bug。
|
||||||
|
- **第二个发现**:`@rpc("authority")` 的检查是「发起方 peer id == 被调节点 authority」,所以服务器不能
|
||||||
|
调用别人玩家节点上的 authority 模式 RPC。广播函数必须放在 authority 为 1 的节点上(世界根节点 Main 天然满足)。
|
||||||
|
- 教程全部代码用「拼成一个文件做整体编译」的方式验证过:`diagnostics: []` 零错误。
|
||||||
|
GDScript 对动态成员访问极其宽容(`Node` 上调脚本方法、`Node3D.new()` 设脚本属性都不报错),
|
||||||
|
所以「编译通过」不等于「语义正确」,逻辑对错还得靠人读。
|
||||||
|
|
||||||
|
|
||||||
|
### godot-ai MCP 使用注意(重要)
|
||||||
|
|
||||||
|
- **autoload 的全局标识符在「正在运行的编辑器会话」里不可见**:新加的 autoload,脚本里直接写 `Net.xxx` 会报 `Identifier not found: Net`。改用 `get_node_or_null("/root/Net")`。
|
||||||
|
- Godot 在 Windows 上**不往重定向的 stdout 写日志**(`_console.exe` 更糟,它自己开控制台)。抓输出必须用引擎参数 `--log-file <path>`。
|
||||||
|
- `game_manage.get_ui_elements` / `get_scene_tree` 看不到 `/root` 下的 autoload 节点,只认当前场景树。
|
||||||
|
- 验证资源是否真被解析:写 `res://tests/test_*.gd`(继承 `McpTestSuite`,方法名 `test_*`,用 `assert_true/assert_eq/assert_gt`),再 `test_run`。
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
extends Node
|
||||||
|
## 联机总入口(Autoload 名:Net)
|
||||||
|
##
|
||||||
|
## 职责边界:只负责「选传输层 + 开局/加入/断开 + 玩家生成回收」。
|
||||||
|
## 玩法代码不直接接触 MultiplayerPeer —— 以后要把 ENet 换成 Steam P2P,
|
||||||
|
## 只需要改本文件的 host_game / join_game 两个函数,RPC 与 Spawner 层不用动。
|
||||||
|
##
|
||||||
|
## 本机双开调试(命令行):
|
||||||
|
## godot --path <项目目录> -- --host
|
||||||
|
## godot --path <项目目录> -- --join 127.0.0.1
|
||||||
|
|
||||||
|
const PORT: int = 7000
|
||||||
|
const MAX_PLAYERS: int = 8
|
||||||
|
const DEFAULT_ADDRESS: String = "127.0.0.1"
|
||||||
|
## 所有玩家节点都会加入这个组,方便整批清场
|
||||||
|
const PLAYER_GROUP: String = "net_player"
|
||||||
|
|
||||||
|
var _spawner: MultiplayerSpawner = null
|
||||||
|
var _menu_root: Control = null
|
||||||
|
var _hud_root: Control = null
|
||||||
|
var _address_edit: LineEdit = null
|
||||||
|
var _menu_status: Label = null
|
||||||
|
var _hud_status: Label = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||||
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||||
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||||
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||||
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||||
|
_build_ui()
|
||||||
|
call_deferred("_apply_cmdline")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 对外接口 ----------------
|
||||||
|
|
||||||
|
## 世界根节点在 _ready 里调用,把 MultiplayerSpawner 托管给 Net。
|
||||||
|
func bind_spawner(spawner: MultiplayerSpawner) -> void:
|
||||||
|
_spawner = spawner
|
||||||
|
|
||||||
|
|
||||||
|
## 创建主机。ENet 是客户端-服务器模型,主机同时是服务器与玩家。
|
||||||
|
func host_game() -> void:
|
||||||
|
var peer := ENetMultiplayerPeer.new()
|
||||||
|
var err := peer.create_server(PORT, MAX_PLAYERS)
|
||||||
|
if err != OK:
|
||||||
|
_set_status("创建主机失败:%s" % error_string(err))
|
||||||
|
return
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
_show_game_ui()
|
||||||
|
print("[Net] 主机已启动,端口 %d" % PORT)
|
||||||
|
# 服务器自己也要一个角色。延后一帧,等各端场景就绪。
|
||||||
|
call_deferred("_spawn_player", 1)
|
||||||
|
|
||||||
|
|
||||||
|
## 加入主机。address 为主机在局域网内的内网 IP。
|
||||||
|
func join_game(address: String = DEFAULT_ADDRESS) -> void:
|
||||||
|
var target := address.strip_edges()
|
||||||
|
if target.is_empty():
|
||||||
|
target = DEFAULT_ADDRESS
|
||||||
|
var peer := ENetMultiplayerPeer.new()
|
||||||
|
var err := peer.create_client(target, PORT)
|
||||||
|
if err != OK:
|
||||||
|
_set_status("连接失败:%s" % error_string(err))
|
||||||
|
return
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
_set_status("正在连接 %s:%d …" % [target, PORT])
|
||||||
|
print("[Net] 正在连接 %s:%d" % [target, PORT])
|
||||||
|
|
||||||
|
|
||||||
|
## 断开并清场,回到主菜单。
|
||||||
|
func leave_game() -> void:
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
_clear_players()
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
_show_menu_ui()
|
||||||
|
_set_status("已断开")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 网络事件 ----------------
|
||||||
|
|
||||||
|
func _on_peer_connected(id: int) -> void:
|
||||||
|
# 只有服务器生成角色,客户端靠 MultiplayerSpawner 自动收到同一份。
|
||||||
|
if multiplayer.is_server():
|
||||||
|
call_deferred("_spawn_player", id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_peer_disconnected(id: int) -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
_despawn_player(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connected_to_server() -> void:
|
||||||
|
_show_game_ui()
|
||||||
|
print("[Net] 已连接,等待主机生成角色")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connection_failed() -> void:
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
_set_status("连接失败:主机无响应")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_server_disconnected() -> void:
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
_clear_players()
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
_show_menu_ui()
|
||||||
|
_set_status("主机已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 玩家生成与回收 ----------------
|
||||||
|
|
||||||
|
func _spawn_player(peer_id: int) -> void:
|
||||||
|
if _spawner == null or not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var parent := _spawner.get_node_or_null(_spawner.spawn_path)
|
||||||
|
if parent == null or parent.has_node(str(peer_id)):
|
||||||
|
return
|
||||||
|
_spawner.spawn(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _despawn_player(peer_id: int) -> void:
|
||||||
|
if _spawner == null:
|
||||||
|
return
|
||||||
|
var parent := _spawner.get_node_or_null(_spawner.spawn_path)
|
||||||
|
if parent == null:
|
||||||
|
return
|
||||||
|
var node := parent.get_node_or_null(str(peer_id))
|
||||||
|
if node != null:
|
||||||
|
node.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_players() -> void:
|
||||||
|
for node in get_tree().get_nodes_in_group(PLAYER_GROUP):
|
||||||
|
node.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 命令行调试入口 ----------------
|
||||||
|
|
||||||
|
func _apply_cmdline() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
if args.has("--host"):
|
||||||
|
host_game()
|
||||||
|
elif args.has("--join"):
|
||||||
|
var index := args.find("--join")
|
||||||
|
var address := DEFAULT_ADDRESS
|
||||||
|
if index + 1 < args.size():
|
||||||
|
address = args[index + 1]
|
||||||
|
join_game(address)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 界面 ----------------
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
var menu_layer := CanvasLayer.new()
|
||||||
|
menu_layer.name = "NetMenu"
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo \
|
||||||
|
and event.keycode == KEY_F10 and multiplayer.multiplayer_peer != null:
|
||||||
|
leave_game()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _show_game_ui() -> void:
|
||||||
|
_menu_root.visible = false
|
||||||
|
_hud_root.visible = true
|
||||||
|
_update_hud()
|
||||||
|
|
||||||
|
|
||||||
|
func _show_menu_ui() -> void:
|
||||||
|
_menu_root.visible = true
|
||||||
|
_hud_root.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _set_status(text: String) -> void:
|
||||||
|
if _menu_status != null:
|
||||||
|
_menu_status.text = text
|
||||||
|
|
||||||
|
|
||||||
|
func _update_hud() -> void:
|
||||||
|
var role := "主机" if multiplayer.is_server() else "客户端"
|
||||||
|
var players := multiplayer.get_peers().size() + 1
|
||||||
|
_hud_status.text = "联机中 · %s · 玩家 %d" % [role, players]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://tqratkoyv2up
|
||||||
+1259
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,616 @@
|
|||||||
|
# 联机框架使用指南
|
||||||
|
|
||||||
|
面向「完全没接触过联机」的开发者的入门文档。
|
||||||
|
读完你应该能做到三件事:跑起联机、给自己新做的功能加上同步、把传输层换成 Steam P2P。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话总览
|
||||||
|
|
||||||
|
这个框架把玩家角色拆成两种身份:**一个是「我在操作的角色」,其余都是「别人在操作的角色」**。
|
||||||
|
|
||||||
|
- 我在操作的那个:读键盘鼠标、跑物理、开相机。
|
||||||
|
- 别人的那些:什么都不算,只是把「对面发过来的位置和朝向」照着摆一遍。
|
||||||
|
|
||||||
|
联机要做的全部事情,就是让每台机器对**同一份状态**达成一致。其余都是细节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 先搞懂四个概念(不用懂网络)
|
||||||
|
|
||||||
|
### 1.1 对等端(peer)与 peer id
|
||||||
|
|
||||||
|
联机里的每一台机器叫一个 **peer**,各自有一个数字编号 `peer id`。
|
||||||
|
本项目用的是 ENet(客户端-服务器模型),所以:
|
||||||
|
|
||||||
|
| 角色 | peer id | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主机(host) | **1** | 同时是「服务器」和「一个玩家」 |
|
||||||
|
| 第 1 个加入的人 | 随机大数(如 1334014715) | |
|
||||||
|
| 第 2 个加入的人 | 另一个随机大数 | |
|
||||||
|
|
||||||
|
> 注意:ENet 不是「人人平等」的 P2P,它是**主机当服务器**。主机退出 = 所有人掉线。
|
||||||
|
> Steam 那套才是真正的 P2P(第 5 章),但上层代码完全一样。
|
||||||
|
|
||||||
|
### 1.2 authority(中文:权威方)
|
||||||
|
|
||||||
|
**每个节点都有一个 authority,表示「谁有权说了算」。** 默认是 1(服务器)。
|
||||||
|
|
||||||
|
- `is_multiplayer_authority()` 返回 `true` → 这个节点归我管,我可以改它。
|
||||||
|
- 返回 `false` → 这个节点是别人的镜像,**我改了也会被对方的下一次同步覆盖回去**。
|
||||||
|
|
||||||
|
这是全篇最重要的一条规则。你新增任何同步逻辑前,都要先问:
|
||||||
|
|
||||||
|
> **这个值该由谁来改?**
|
||||||
|
|
||||||
|
### 1.3 复制(replication)与 RPC
|
||||||
|
|
||||||
|
两种同步手段,分工明确:
|
||||||
|
|
||||||
|
| | 复制(Replication) | RPC(远程过程调用) |
|
||||||
|
|---|---|---|
|
||||||
|
| 适合 | **持续变化的状态**:位置、朝向、血量、姓名 | **一次性事件**:开了一枪、捡到东西、发一句话 |
|
||||||
|
| 机制 | 引擎按固定频率自动把属性值发出去 | 你主动调用一个函数,它在别的机器上跑 |
|
||||||
|
| 丢包 | 丢了无所谓,下一次同步会补上最新值 | 用 `reliable` 保证必达 |
|
||||||
|
| 本项目用在哪 | `Player/Sync`(位置 + 朝向) | 目前还没用,你加功能时才会用到 |
|
||||||
|
|
||||||
|
一句话记法:**状态用复制,事件用 RPC。**
|
||||||
|
|
||||||
|
### 1.4 节点路径必须一致
|
||||||
|
|
||||||
|
RPC 和复制都是靠**节点路径**找到对面对应的节点。所以:
|
||||||
|
|
||||||
|
- 玩家节点在各端的名字必须一样(本项目用 `str(peer_id)` 当名字)。
|
||||||
|
- 别在运行时给节点随机改名,否则对端会报 `Node not found`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 框架由哪些文件组成
|
||||||
|
|
||||||
|
```
|
||||||
|
E:\GD_Projects\agame\
|
||||||
|
├── autoload\
|
||||||
|
│ └── net.gd ← 联机总入口(Autoload 名:Net)
|
||||||
|
│ 管传输层、开局/加入/断开、玩家生成回收、菜单和 HUD
|
||||||
|
├── world\
|
||||||
|
│ ├── main.tscn ← 世界根场景
|
||||||
|
│ │ Main (Node3D)
|
||||||
|
│ │ ├─ Ground / Sun / Environment
|
||||||
|
│ │ └─ MultiplayerSpawner ← 玩家由它动态生成,场景里不再硬编码 Player
|
||||||
|
│ └── main.gd ← 只做一件事:告诉 Spawner「生成玩家时用 player.tscn,
|
||||||
|
│ 并把那个玩家的 authority 设成对应的 peer id」
|
||||||
|
├── player\
|
||||||
|
│ ├── player.tscn ← 玩家场景
|
||||||
|
│ │ Player (CharacterBody3D)
|
||||||
|
│ │ ├─ Collision
|
||||||
|
│ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支
|
||||||
|
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
|
||||||
|
│ │ └─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
|
||||||
|
│ ├── player.gd ← 加了「本地 / 远端」分支
|
||||||
|
│ └── player_sync.tres ← 同步配置:到底同步哪几个属性
|
||||||
|
└── tests\
|
||||||
|
└── test_net_replication.gd ← 回归测试,防止同步配置被静默改坏
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据流(创建主机 → 有人加入)**
|
||||||
|
|
||||||
|
```
|
||||||
|
主机 客户端
|
||||||
|
│ 点「创建主机」 │ 点「加入主机」
|
||||||
|
│ ENetMultiplayerPeer.create_server │ ENetMultiplayerPeer.create_client
|
||||||
|
│ ↓ │ ↓
|
||||||
|
│ multiplayer.multiplayer_peer = peer ←── 同一个多人系统,只是 peer 不同
|
||||||
|
│ ↓ │
|
||||||
|
│ 服务器收到 peer_connected(id) │
|
||||||
|
│ Net._spawn_player(id) │
|
||||||
|
│ ↓ │
|
||||||
|
│ MultiplayerSpawner.spawn(id) ─────→ 客户端收到「生成一个玩家」的指令
|
||||||
|
│ ↓ │ ↓
|
||||||
|
│ 各端都执行 spawn_function │ 各端都执行 spawn_function
|
||||||
|
│ ↓ │ ↓
|
||||||
|
│ 设 authority = id(递归到子节点) │ 设 authority = id(递归到子节点)
|
||||||
|
│ ↓ │ ↓
|
||||||
|
│ id≠我 → 远端回放 │ id=我 → 本地操控(读输入、开相机)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 把联机跑起来
|
||||||
|
|
||||||
|
### 3.1 方式一:编辑器里双开(最方便)
|
||||||
|
|
||||||
|
Godot 编辑器菜单 **调试 → 运行多个实例 → 2 个实例**(Debug → Run Multiple Instances),然后:
|
||||||
|
|
||||||
|
1. 第一个窗口点「创建主机」。
|
||||||
|
2. 第二个窗口地址栏填 `127.0.0.1`,点「加入主机」。
|
||||||
|
3. 两个窗口里都应该看到两个胶囊体,鼠标各自控制自己的那个。
|
||||||
|
|
||||||
|
### 3.2 方式二:命令行双开
|
||||||
|
|
||||||
|
我在 `net.gd` 里加了命令行参数入口,方便自动化测试:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 假设 Godot 在 E:\Godot_v4.7.2-stable_mono_win64\
|
||||||
|
$g = 'E:\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe'
|
||||||
|
|
||||||
|
# 主机
|
||||||
|
Start-Process $g -ArgumentList @('--path','E:\GD_Projects\agame','--','--host')
|
||||||
|
# 加入
|
||||||
|
Start-Process $g -ArgumentList @('--path','E:\GD_Projects\agame','--','--join','127.0.0.1')
|
||||||
|
```
|
||||||
|
|
||||||
|
想抓日志(**Godot 在 Windows 上不往重定向的 stdout 写日志,必须用 `--log-file`**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Start-Process $g -ArgumentList @(
|
||||||
|
'--headless','--log-file','C:\temp\host.log',
|
||||||
|
'--path','E:\GD_Projects\agame','--','--host'
|
||||||
|
) -PassThru
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 方式三:局域网真机
|
||||||
|
|
||||||
|
1. 主机那台机器查自己的内网 IP(`ipconfig`,看 `192.168.x.x` 或 `10.x.x.x`)。
|
||||||
|
2. 客户端在地址栏填这个 IP,点加入。
|
||||||
|
3. 连不上先查 Windows 防火墙是否拦了游戏进程,以及端口 `7000`(`Net.PORT`)是否放行。
|
||||||
|
|
||||||
|
### 3.4 一切正常时你该看到什么
|
||||||
|
|
||||||
|
```
|
||||||
|
主机日志 客户端日志
|
||||||
|
[Net] 主机已启动,端口 7000 [Net] 正在连接 127.0.0.1:7000
|
||||||
|
[Net] 本端 1 生成玩家 1(本地操控) [Net] 已连接,等待主机生成角色
|
||||||
|
[Net] 本端 1 生成玩家 1334014715(远端回放) [Net] 本端 1334014715 生成玩家 1(远端回放)
|
||||||
|
[Net] 本端 1334014715 生成玩家 1334014715(本地操控)
|
||||||
|
```
|
||||||
|
|
||||||
|
两边日志**互为镜像**,而且**每一端恰好有一个「本地操控」**——这就是联机正常的标志。
|
||||||
|
|
||||||
|
游戏里:`F10` 断开,`Esc` 释放鼠标。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 日常开发:我新做的功能怎么同步?
|
||||||
|
|
||||||
|
### 4.0 先做决策
|
||||||
|
|
||||||
|
拿到一个需求,先按下表判断,不要一上来就写 `@rpc`。
|
||||||
|
|
||||||
|
| 你的需求 | 用什么 | 去哪看 |
|
||||||
|
|---|---|---|
|
||||||
|
| 血量、蓝量、名字、皮肤 id、瞄准方向这类**状态** | 复制 | 配方 A(4.1) |
|
||||||
|
| 开火、拾取、受击、播放某个动画、聊天这类**一次性的动作** | RPC | 配方 B(4.2) |
|
||||||
|
| 子弹、掉落物、怪物这类**运行时才出现/消失的东西** | 第二个 MultiplayerSpawner | 配方 C(4.3) |
|
||||||
|
| 刷怪规则、计分、房间设置这类**必须由一个人说了算的东西** | 服务器权威 + RPC | 配方 D(4.4) |
|
||||||
|
|
||||||
|
### 4.1 配方 A:新增一个持续同步的状态
|
||||||
|
|
||||||
|
**例子:给玩家加血量 `health`。**
|
||||||
|
|
||||||
|
**第 1 步**,在 `player/player.gd` 里声明变量。加 `@export` 是为了能在 Inspector 里看到,同步本身不要求:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
@export var health: int = 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**第 2 步**,把这个属性加进同步配置 `player/player_sync.tres`。两种改法,任选:
|
||||||
|
|
||||||
|
- **图形化**:打开 `player.tscn` → 选中 `Sync` 节点 → Inspector 里 `Replication Config` 点开那个 `.tres` → **Add property** → 选 `health` → 勾上 **Spawn** → **Replication Mode** 选 `Always`。
|
||||||
|
- **直接改文本**,在文件末尾追加三行(编号接着往下排):
|
||||||
|
|
||||||
|
```
|
||||||
|
properties/2/path = NodePath(".:health")
|
||||||
|
properties/2/spawn = true
|
||||||
|
properties/2/replication_mode = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
三个字段的含义:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `path` | 属性路径,相对 `Sync.root_path`(本项目是 `..`,也就是 `Player` 本级)。`.:health` = Player 自己的 health;`Body:rotation` = Player/Body 的 rotation |
|
||||||
|
| `spawn` | 生成时是否把这个值一起带过去。**必须 true**,否则新加入的人看到的是代码里的默认值而不是当前值 |
|
||||||
|
| `replication_mode` | `0` = 从不同步,`1` = 每帧同步,`2` = 值变了才同步 |
|
||||||
|
|
||||||
|
**第 3 步**,改血量时,**只能在权威端改**:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
if is_multiplayer_authority():
|
||||||
|
health -= 10
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你在非权威端直接 `health -= 10`,效果会闪一下又被对端的旧值覆盖回来。
|
||||||
|
|
||||||
|
> `replication_mode = 2`(只在变化时同步)能省流量,但要注意它靠对比上一帧的值判断,对浮点数频繁微变的属性效果不好。
|
||||||
|
|
||||||
|
### 4.2 配方 B:新增一个一次性事件(RPC)
|
||||||
|
|
||||||
|
RPC 的本质是「调用一个函数,它在别的机器上也会跑一遍」。Godot 用注解 `@rpc` 标记哪些函数可以被远程调用。
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
## 三项参数的含义:
|
||||||
|
## 1. 谁可以调用: "authority"(默认,只有权威方能调)| "any_peer"(谁都能调)
|
||||||
|
## 2. 本地是否也执行:"call_local"(本地也跑)| "call_remote"(只跑远程,默认)
|
||||||
|
## 3. 可靠性与顺序:"reliable"(必达,默认)| "unreliable"(丢了就算)
|
||||||
|
## | "unreliable_ordered"(只保留最新的,位置类数据用这个)
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func request_shoot(target_position: Vector3) -> void:
|
||||||
|
# 请求会同时到达所有人,但只有权威端需要处理,其余端直接丢掉
|
||||||
|
if not is_multiplayer_authority():
|
||||||
|
return
|
||||||
|
# 校验放这里:冷却、弹药、距离、视线……不通过就直接 return
|
||||||
|
if _cooldown > 0.0:
|
||||||
|
return
|
||||||
|
_cooldown = 0.5
|
||||||
|
# 校验通过,由权威端广播结果
|
||||||
|
_broadcast_shoot.rpc(target_position)
|
||||||
|
|
||||||
|
|
||||||
|
## "authority" + "call_local" 是最常用的组合:
|
||||||
|
## 只有权威端能发起,且发起方自己也执行一遍(保证本地表现和远程一致)
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _broadcast_shoot(target_position: Vector3) -> void:
|
||||||
|
spawn_muzzle_flash()
|
||||||
|
play_shoot_sound()
|
||||||
|
```
|
||||||
|
|
||||||
|
**怎么调用**:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
_broadcast_shoot.rpc(target_position) # 发给所有 peer(本地按 call_local 决定跑不跑)
|
||||||
|
_broadcast_shoot.rpc_id(some_peer_id, pos) # 只发给指定 peer
|
||||||
|
```
|
||||||
|
|
||||||
|
**三个必须知道的坑**:
|
||||||
|
|
||||||
|
1. **路径一致性**:`@rpc` 函数所在节点的路径必须在所有端完全相同。所以别给节点随机改名。
|
||||||
|
2. **别用 `@rpc` 传节点引用**。传 `Vector3`、`int`、`String`、`Array`、`Dictionary` 这类纯数据。要传「哪个对象」就传它的 `name` 或你自己编的 id,对端自己查。
|
||||||
|
3. **请求(request)和结果(broadcast)要分成两个函数**。别让「客户端直接执行动作」——那样人人都能作弊。本项目现在的玩家位置是客户端权威的(简单但不安全),如果你要做多人对战,参考配方 D 把关键逻辑搬到服务器。
|
||||||
|
|
||||||
|
### 4.3 配方 C:同步运行时生成/销毁的物体
|
||||||
|
|
||||||
|
**一个 `MultiplayerSpawner` 只管一种节点。** 要加子弹,就再加一个 spawner。
|
||||||
|
|
||||||
|
**第 1 步**,在 `main.tscn` 里找个容器当子弹的父节点(也可以直接用 `Main`),挂一个新的 `MultiplayerSpawner`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Main
|
||||||
|
├─ MultiplayerSpawner spawn_path = .. → 生成玩家
|
||||||
|
├─ Bullets (Node3D) ← 新建,用来收纳子弹
|
||||||
|
│ └─ BulletSpawner (MultiplayerSpawner) spawn_path = .. → 生成子弹
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`spawn_path` 要指向**子弹的父节点**(相对 spawner 自己)。路径在每台机器上都一样,这是硬要求。
|
||||||
|
|
||||||
|
**第 2 步**,写一个类似 `world/main.gd` 的脚本给子弹容器,注册生成函数:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends Node3D
|
||||||
|
|
||||||
|
const BULLET_SCENE: PackedScene = preload("res://combat/bullet.tscn")
|
||||||
|
|
||||||
|
@onready var spawner: MultiplayerSpawner = $BulletSpawner
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
spawner.spawn_function = _create_bullet
|
||||||
|
|
||||||
|
|
||||||
|
## 这个函数会在每一端各跑一次,参数 data 由 spawn() 的调用方决定
|
||||||
|
func _create_bullet(data: Dictionary) -> Node:
|
||||||
|
var bullet := BULLET_SCENE.instantiate()
|
||||||
|
bullet.name = str(data["id"]) # 名字必须各端一致,用你自己编的唯一 id
|
||||||
|
bullet.global_position = data["position"]
|
||||||
|
bullet.direction = data["direction"]
|
||||||
|
return bullet
|
||||||
|
|
||||||
|
|
||||||
|
## 只允许权威端/服务器调用
|
||||||
|
func fire(position: Vector3, direction: Vector3) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
spawner.spawn({"id": _next_id(), "position": position, "direction": direction})
|
||||||
|
```
|
||||||
|
|
||||||
|
**第 3 步**,销毁就是 `queue_free()`——spawner 会自动把「销毁」这件事同步给其他端:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
bullet.queue_free()
|
||||||
|
```
|
||||||
|
|
||||||
|
> **别在多台机器上各自生成**。只有服务器(或该物体的权威端)能调 `spawn()`,其他人通过同步收到。
|
||||||
|
|
||||||
|
### 4.4 配方 D:把关键逻辑交给服务器
|
||||||
|
|
||||||
|
现在的模型是「每个人管自己」,好处是简单、无延迟;坏处是**玩家能改自己的客户端作弊**(比如改速度、瞬移)。
|
||||||
|
|
||||||
|
哪类必须搬给服务器:涉及胜负、资源、其他玩家的判定。
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
## 客户端:我要捡这个箱子
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func request_pickup(item_name: String) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var node := get_node_or_null("../Items/" + item_name)
|
||||||
|
if node == null or node.claimed:
|
||||||
|
return # 服务器校验:不存在或已被捡走
|
||||||
|
node.claimed = true # 服务器记状态
|
||||||
|
_play_pickup.rpc(item_name) # 再广播给所有人
|
||||||
|
|
||||||
|
|
||||||
|
## 服务器 → 所有人:这个箱子没了
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _play_pickup(item_name: String) -> void:
|
||||||
|
var node := get_node_or_null("../Items/" + item_name)
|
||||||
|
if node != null:
|
||||||
|
node.queue_free()
|
||||||
|
```
|
||||||
|
|
||||||
|
常用的判断函数:
|
||||||
|
|
||||||
|
| 函数 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `multiplayer.is_server()` | 我是不是服务器(主机是,客户端不是) |
|
||||||
|
| `multiplayer.get_unique_id()` | 我的 peer id |
|
||||||
|
| `multiplayer.get_peers()` | 我知道的其他 peer id 列表 |
|
||||||
|
| `is_multiplayer_authority()` | 当前节点归不归我管 |
|
||||||
|
|
||||||
|
### 4.5 想让别的玩家看到我的模型/动画
|
||||||
|
|
||||||
|
好消息:**不需要任何额外工作**。`player.tscn` 是同一份场景,你只要把模型挂到 `Body` 节点下面,所有端都会看到:
|
||||||
|
|
||||||
|
```
|
||||||
|
Player
|
||||||
|
└─ Body ← 换角色模型就挂这里;它是一个 Node3D,本体 Player 不跟着转
|
||||||
|
└─ Mesh / 骨骼模型
|
||||||
|
```
|
||||||
|
|
||||||
|
要注意的是**驱动方式**:
|
||||||
|
|
||||||
|
- 「转身」现在同步的是 `Body:rotation`(视觉层的朝向),**不是** `Player:rotation`。因为 `player.gd` 里本体是不旋转的(只把 `Body` 转过去)。同步错了这一项,远端玩家的朝向就会一直是初始值。
|
||||||
|
- 远端玩家的动画**不能靠输入驱动**(远端玩家读不到键盘)。正确做法:把「状态」同步出去(比如 `@export var move_speed: float`),动画机只读这个状态。
|
||||||
|
|
||||||
|
### 4.6 五个必踩的坑(收藏这一段)
|
||||||
|
|
||||||
|
| 现象 | 原因 | 修法 |
|
||||||
|
|---|---|---|
|
||||||
|
| 远端玩家僵在原地不动 | 同步配置没生效(`.tres` 键名写错 / `replication_mode` 是 0 / 没绑定) | 跑 `tests/test_net_replication.gd` |
|
||||||
|
| 远端玩家位置一改就弹回去 | 你在非权威端改了被同步的属性 | 加 `if is_multiplayer_authority():` 判断 |
|
||||||
|
| 画面里有好几个视角在打架 | 多台 `Camera3D` 抢渲染 | 远端玩家必须 `camera_rig.queue_free()` |
|
||||||
|
| 远端玩家抽搐、漂移、穿地 | 两端都在跑 `move_and_slide()` | 远端玩家 `set_physics_process(false)` |
|
||||||
|
| 报 `Node not found` / 同步完全不发 | 节点路径各端不一致 | 节点名用 `str(peer_id)` 这类确定性值,别随机命名 |
|
||||||
|
|
||||||
|
另外有一条**最阴的**:**节点 authority 不会自动继承给子节点**。所以 `set_multiplayer_authority()` 必须传第二个参数 `true`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
player.set_multiplayer_authority(peer_id, true) # true = 递归设置所有子节点
|
||||||
|
```
|
||||||
|
|
||||||
|
漏了这个 `true`,`MultiplayerSynchronizer`(子节点)仍然认为自己是服务器的,同步**一条都不会发,而且不报任何错**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 改成 Steam P2P
|
||||||
|
|
||||||
|
### 5.0 好消息:只需要改一个文件
|
||||||
|
|
||||||
|
`Net` 之外的所有文件(RPC、Spawner、Synchronizer、`player.gd`、`main.gd`)**一个字都不用动**。
|
||||||
|
因为它们操作的都是引擎级的抽象(`MultiplayerAPI`),跟底层用的是 ENet 还是 Steam 无关。
|
||||||
|
|
||||||
|
要改的只有 `autoload/net.gd` 里的 `host_game()` 和 `join_game()` 两个函数。
|
||||||
|
|
||||||
|
### 5.1 准备工作
|
||||||
|
|
||||||
|
1. **装 Steam 客户端并登录**。不登录,`steamInitEx` 会返回 `{ "status": 1, ... }`。
|
||||||
|
2. **拿到真实 AppID**。当前代码里是 `480`,这是 Valve 的 Spacewar 测试 AppID,只能本机/好友小范围测试,大厅搜索、邀请等功能受限。把 `autoload/steamworks.gd` 第一行的 `steam_app_id` 改成你自己的 AppID。
|
||||||
|
3. **在项目根目录放一个 `steam_appid.txt`**(不是 `res://` 里面,是 `.godot` 同级的真实路径),内容就是 AppID 数字。
|
||||||
|
> 这个是 Steamworks SDK 的调试机制,只在编辑器/本地调试时需要;正式打包发布**不要**带上它。
|
||||||
|
4. 启动游戏看日志,确认出现 `Did Steam initialize?: { "status": 0, ... }`(`0` = 成功)。
|
||||||
|
|
||||||
|
`status` 的含义:`0` OK、`1` 找不到 Steam 客户端、`2` 没有 AppID、`3` 版本不匹配。
|
||||||
|
|
||||||
|
### 5.2 最小改法:SteamID 直连(约 20 行)
|
||||||
|
|
||||||
|
跟现在的 ENet 版几乎一模一样——地址栏从「IP」变成「主机的 SteamID」。
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
## 创建主机
|
||||||
|
func host_game() -> void:
|
||||||
|
var peer := SteamMultiplayerPeer.new()
|
||||||
|
peer.set_no_delay(true) # 实时游戏必备,关掉 Nagle 攒包
|
||||||
|
var err := peer.create_host(0) # 参数是虚拟端口,0 = 默认
|
||||||
|
if err != OK:
|
||||||
|
_set_status("创建主机失败:%s" % error_string(err))
|
||||||
|
return
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
_show_game_ui()
|
||||||
|
print("[Net] 主机已启动。你的 SteamID = %d,把它发给队友" % Steam.getSteamID())
|
||||||
|
call_deferred("_spawn_player", 1)
|
||||||
|
|
||||||
|
|
||||||
|
## 加入主机。这里的 address 传的是主机的 SteamID(纯数字)
|
||||||
|
func join_game(address: String = "") -> void:
|
||||||
|
var target := address.strip_edges()
|
||||||
|
if not target.is_valid_int():
|
||||||
|
_set_status("请填主机的 SteamID(纯数字)")
|
||||||
|
return
|
||||||
|
var peer := SteamMultiplayerPeer.new()
|
||||||
|
peer.set_no_delay(true)
|
||||||
|
var err := peer.create_client(int(target), 0)
|
||||||
|
if err != OK:
|
||||||
|
_set_status("连接失败:%s" % error_string(err))
|
||||||
|
return
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
_set_status("正在连接 SteamID %s …" % target)
|
||||||
|
```
|
||||||
|
|
||||||
|
配套的小调整:
|
||||||
|
- 菜单里的提示文字改成「填主机的 SteamID」,地址栏默认值 `127.0.0.1` 改成空。
|
||||||
|
- 主机的 SteamID 建议显示在 HUD 上,方便念给队友。
|
||||||
|
|
||||||
|
**代价**:得手动交换 SteamID(截图、复制粘贴)。想让玩家自己搜房间,用 5.3。
|
||||||
|
|
||||||
|
### 5.3 完整改法:Steam 大厅(官方推荐)
|
||||||
|
|
||||||
|
思路:**先用 Steam 大厅找人,再把大厅 id 交给 `SteamMultiplayerPeer`**。
|
||||||
|
|
||||||
|
```
|
||||||
|
主机 客户端
|
||||||
|
createLobby(PUBLIC, 4) requestLobbyList() → lobby_match_list
|
||||||
|
↓ 信号 lobby_created ↓ 从列表里挑一个
|
||||||
|
host_with_lobby(lobby_id) joinLobby(lobby_id)
|
||||||
|
↓ ↓ 信号 lobby_joined
|
||||||
|
multiplayer.multiplayer_peer = peer connect_to_lobby(lobby_id)
|
||||||
|
↓
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
```
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# ---------------- 主机:建大厅 ----------------
|
||||||
|
|
||||||
|
func host_game_lobby(max_members: int = 4) -> void:
|
||||||
|
Steam.lobby_created.connect(_on_lobby_created, CONNECT_ONE_SHOT)
|
||||||
|
Steam.createLobby(Steam.LOBBY_TYPE_PUBLIC, max_members) # 人数上限由大厅决定
|
||||||
|
|
||||||
|
|
||||||
|
func _on_lobby_created(connect_result: int, lobby_id: int) -> void:
|
||||||
|
if connect_result != 1: # 1 = 成功
|
||||||
|
_set_status("创建大厅失败:%d" % connect_result)
|
||||||
|
return
|
||||||
|
Steam.setLobbyData(lobby_id, "game", "agame") # 给大厅打标签,方便筛选
|
||||||
|
print("[Net] 大厅已创建:%d" % lobby_id)
|
||||||
|
|
||||||
|
var peer := SteamMultiplayerPeer.new()
|
||||||
|
peer.set_no_delay(true)
|
||||||
|
peer.host_with_lobby(lobby_id) # 大厅绑到 peer 上
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
_show_game_ui()
|
||||||
|
call_deferred("_spawn_player", 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 客户端:找大厅 + 加入 ----------------
|
||||||
|
|
||||||
|
func refresh_lobby_list() -> void:
|
||||||
|
Steam.lobby_match_list.connect(_on_lobby_match_list, CONNECT_ONE_SHOT)
|
||||||
|
Steam.requestLobbyList()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_lobby_match_list(lobbies: Array) -> void:
|
||||||
|
# lobbies 是 lobby id 的数组,拿去做列表 UI
|
||||||
|
for lobby_id in lobbies:
|
||||||
|
print("大厅 %s,人数 %d" % [lobby_id, Steam.getNumLobbyMembers(lobby_id)])
|
||||||
|
|
||||||
|
|
||||||
|
func join_game_lobby(lobby_id: int) -> void:
|
||||||
|
Steam.lobby_joined.connect(_on_lobby_joined)
|
||||||
|
Steam.joinLobby(lobby_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_lobby_joined(lobby_id: int, _permissions: int, _locked: bool, response: int) -> void:
|
||||||
|
if response != 1: # 1 = 成功
|
||||||
|
_set_status("加入大厅失败:%d" % response)
|
||||||
|
return
|
||||||
|
var peer := SteamMultiplayerPeer.new()
|
||||||
|
peer.set_no_delay(true)
|
||||||
|
peer.connect_to_lobby(lobby_id)
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
_show_game_ui()
|
||||||
|
```
|
||||||
|
|
||||||
|
上面用到的 Steam 接口(已在本项目安装的 GodotSteam 版本上逐个校验过签名,照抄即可):
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `Steam.createLobby(type, max_members)` | 建大厅。**返回值是 void**,结果通过 `lobby_created` 信号回来 |
|
||||||
|
| `Steam.lobby_created(connect_result, lobby_id)` | 信号,`connect_result == 1` 为成功 |
|
||||||
|
| `Steam.joinLobby(lobby_id)` | 加入大厅,结果通过 `lobby_joined` 信号回来 |
|
||||||
|
| `Steam.lobby_joined(lobby_id, permissions, locked, response)` | 信号,`response == 1` 为成功 |
|
||||||
|
| `Steam.requestLobbyList()` | 请求大厅列表,结果通过 `lobby_match_list(lobbies: Array)` 信号回来 |
|
||||||
|
| `Steam.setLobbyData` / `getLobbyData` / `getLobbyOwner` / `getNumLobbyMembers` / `getLobbyMemberByIndex` | 读写大厅信息 |
|
||||||
|
| `Steam.getSteamID()` | 取自己的 SteamID |
|
||||||
|
| `SteamMultiplayerPeer.create_host(port=0)` | 当主机 |
|
||||||
|
| `SteamMultiplayerPeer.create_client(steam_id, port=0)` | 按 SteamID 直连 |
|
||||||
|
| `SteamMultiplayerPeer.host_with_lobby(lobby_id)` | 当主机并绑定大厅 |
|
||||||
|
| `SteamMultiplayerPeer.connect_to_lobby(lobby_id)` | 按大厅 id 加入 |
|
||||||
|
| `peer.set_no_delay(true)` | 关掉攒包延迟,实时游戏必须开 |
|
||||||
|
|
||||||
|
> `lobby_id` 是 Steam 给的 64 位整数,显示给玩家时可以转成字符串再转回来,别自己改类型。
|
||||||
|
|
||||||
|
### 5.4 换成 Steam 之后要注意
|
||||||
|
|
||||||
|
- **主机仍然是服务器**。Steam P2P 只是把「怎么找到对方」换掉了,连接拓扑没变(仍然是主机=服务器、客户端之间的流量由主机中继)。所以「主机退出 = 全员掉线」这一点不变。
|
||||||
|
- **握手是异步的**。点加入后要等 1~3 秒才会真正建连;期间 `multiplayer.connected_to_server` 信号才触发。UI 上要有「连接中」状态。
|
||||||
|
- **不要用 SteamID 当玩家名字**,一长串数字没法看。加一个同步字段传昵称:`@export var nickname: String`,加入 `player_sync.tres` 同步即可。
|
||||||
|
- **AppID 480 的坑**:第三方账号没有 Spacewar 的话,连不上或大厅搜不到。本机双开测试没问题。
|
||||||
|
- **打包发布**:删掉 `steam_appid.txt`,AppID 写死在代码/配置里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 调试工具箱
|
||||||
|
|
||||||
|
| 手段 | 怎么用 |
|
||||||
|
|---|---|
|
||||||
|
| 看日志 | 编辑器里跑 → Output 面板;命令行跑 → 加 `--log-file 路径`(Windows 上重定向 stdout 抓不到输出,这是 Godot 的已知行为) |
|
||||||
|
| 看同步有没有生效 | 在 `_ready()` 里 `print(is_multiplayer_authority())`,每端应该只有一个是 `true` |
|
||||||
|
| 看玩家有没有生成 | 装了这个项目有 godot-ai 插件,可以让 AI 用 `game_manage.get_scene_tree` 看运行时节点树(注意:它看不到 autoload 下的 UI) |
|
||||||
|
| 防配置被改坏 | `tests/test_net_replication.gd` 会验证 `player_sync.tres` 真的有同步字段且 `replication_mode == Always`。**这个配置写错不会报错,只会让角色僵在原地**,所以值得用测试钉住 |
|
||||||
|
| 主动断言 | 也可以让它跑一遍:让 AI 执行 `test_run`,或者自己写更多 `res://tests/test_*.gd`(继承 `McpTestSuite`,方法名以 `test_` 开头,用 `assert_eq` 之类) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 已知限制 / 下一步可以做什么
|
||||||
|
|
||||||
|
现在的实现是**能跑通、够用、但不适合正式对战**的基础版。按重要性排:
|
||||||
|
|
||||||
|
1. **没有插值,远端玩家会一跳一跳**。
|
||||||
|
同步是按固定节奏发位置,两端节奏不一致就会卡顿。修法二选一:
|
||||||
|
- 简单:把 `Sync` 的 `Replication Interval`(`replication_interval`)调小,或保持默认每帧发。
|
||||||
|
- 正规:自己写插值——远端玩家在 `_process` 里朝「最新收到的位置」做 `lerp` 平滑,而不是直接赋值。
|
||||||
|
|
||||||
|
2. **没有服务器校验,玩家可以作弊**。
|
||||||
|
现在位置是客户端权威的,改客户端就能瞬移。要防作弊得走配方 D:客户端只发输入,服务器算位置再同步回来。
|
||||||
|
|
||||||
|
3. **没有断线重连**。目前主机掉线 = 其他人全部回主菜单。要做重连需要改成「场景常驻 + 玩家按 session 恢复」。
|
||||||
|
|
||||||
|
4. **没有大厅/房间列表**(ENet 版)。换 Steam 后自带。
|
||||||
|
|
||||||
|
5. **人数上限是 8**(`Net.MAX_PLAYERS`)。ENet 提升这个数字要同时考虑带宽;Steam 版上限由大厅的 `max_members` 决定。
|
||||||
|
|
||||||
|
6. **没有玩家昵称**。节点名现在就是 `str(peer_id)`,UI 上显示的是数字。按 5.4 的建议加同步字段即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:一页速查
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# 传输层(只在 net.gd 里出现)
|
||||||
|
var peer := ENetMultiplayerPeer.new()
|
||||||
|
peer.create_server(7000, 8) # 主机
|
||||||
|
peer.create_client("192.168.1.5", 7000) # 客户端
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
|
||||||
|
# 判断我是谁
|
||||||
|
multiplayer.is_server() # 我是不是服务器
|
||||||
|
multiplayer.get_unique_id() # 我的 peer id
|
||||||
|
multiplayer.get_peers() # 其他 peer 的 id 列表
|
||||||
|
is_multiplayer_authority() # 当前节点归不归我管
|
||||||
|
|
||||||
|
# 状态同步(改 player_sync.tres)
|
||||||
|
properties/N/path = NodePath(".:health")
|
||||||
|
properties/N/spawn = true
|
||||||
|
properties/N/replication_mode = 1 # 0 从不同步 1 每帧 2 变化时
|
||||||
|
|
||||||
|
# 事件同步(RPC)
|
||||||
|
@rpc("any_peer", "reliable") func request_xxx(...) -> void: # 谁都能发起
|
||||||
|
@rpc("authority", "call_local", "reliable") func do_xxx(...) -> void: # 只有权威方发起 + 本地也跑
|
||||||
|
rpc(...) / rpc_id(peer_id, ...)
|
||||||
|
|
||||||
|
# 动态生成/销毁
|
||||||
|
spawner.spawn(data) # 只能在服务器/权威端调用
|
||||||
|
node.queue_free() # 销毁自动同步
|
||||||
|
|
||||||
|
# 授权(第二参数漏了就静默失效)
|
||||||
|
player.set_multiplayer_authority(peer_id, true)
|
||||||
|
```
|
||||||
@@ -45,9 +45,21 @@ var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_grav
|
|||||||
var _yaw: float = 0.0
|
var _yaw: float = 0.0
|
||||||
var _pitch: float = 0.0
|
var _pitch: float = 0.0
|
||||||
var _jump_buffer: int = 0
|
var _jump_buffer: int = 0
|
||||||
|
## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。
|
||||||
|
var _is_local: bool = true
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_is_local = is_multiplayer_authority()
|
||||||
|
if not _is_local:
|
||||||
|
# 远端玩家只是一具「回放身体」:
|
||||||
|
# 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染;
|
||||||
|
# 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。
|
||||||
|
# 它的 position 与 Body.rotation 由子节点 Sync 同步过来。
|
||||||
|
camera_rig.queue_free()
|
||||||
|
set_physics_process(false)
|
||||||
|
set_process_unhandled_input(false)
|
||||||
|
return
|
||||||
_yaw = camera_rig.rotation.y
|
_yaw = camera_rig.rotation.y
|
||||||
_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
|
||||||
@@ -77,6 +89,8 @@ func set_mouse_captured(captured: bool) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
|
if not _is_local:
|
||||||
|
return
|
||||||
# --- 重力 ---
|
# --- 重力 ---
|
||||||
if is_on_floor():
|
if is_on_floor():
|
||||||
if velocity.y < 0.0:
|
if velocity.y < 0.0:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://bekuuw153mv03"]
|
[gd_scene format=3 uid="uid://bekuuw153mv03"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://f5ptp3is4agf" path="res://player/player.gd" id="1_oul6g"]
|
[ext_resource type="Script" uid="uid://f5ptp3is4agf" path="res://player/player.gd" id="1_oul6g"]
|
||||||
|
[ext_resource type="SceneReplicationConfig" path="res://player/player_sync.tres" id="2_rkbax"]
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
|
||||||
radius = 0.4
|
radius = 0.4
|
||||||
@@ -39,3 +40,6 @@ fov = 82.2
|
|||||||
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)
|
||||||
mesh = SubResource("CapsuleMesh_g1dw6")
|
mesh = SubResource("CapsuleMesh_g1dw6")
|
||||||
surface_material_override/0 = SubResource("StandardMaterial3D_yw30f")
|
surface_material_override/0 = SubResource("StandardMaterial3D_yw30f")
|
||||||
|
|
||||||
|
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210]
|
||||||
|
replication_config = ExtResource("2_rkbax")
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_resource type="SceneReplicationConfig" format=3]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
properties/0/path = NodePath(".:position")
|
||||||
|
properties/0/spawn = true
|
||||||
|
properties/0/replication_mode = 1
|
||||||
|
properties/1/path = NodePath("Body:rotation")
|
||||||
|
properties/1/spawn = true
|
||||||
|
properties/1/replication_mode = 1
|
||||||
@@ -19,6 +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"
|
||||||
|
|
||||||
[debug]
|
[debug]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@tool
|
||||||
|
extends McpTestSuite
|
||||||
|
## 回归测试:Player 的同步配置必须真的被解析成条目。
|
||||||
|
##
|
||||||
|
## 这是一处典型的「静默失效」:如果 .tres 里的键名写错,或 replication_mode
|
||||||
|
## 落到 NEVER,远端玩家会僵在原地不动,而且不打任何错误。用测试钉住它。
|
||||||
|
|
||||||
|
const CONFIG_PATH := "res://player/player_sync.tres"
|
||||||
|
const PLAYER_SCENE_PATH := "res://player/player.tscn"
|
||||||
|
|
||||||
|
|
||||||
|
func suite_name() -> String:
|
||||||
|
return "net"
|
||||||
|
|
||||||
|
|
||||||
|
func test_config_has_expected_paths() -> void:
|
||||||
|
var config := load(CONFIG_PATH) as SceneReplicationConfig
|
||||||
|
assert_true(config != null, "player_sync.tres 未能加载为 SceneReplicationConfig")
|
||||||
|
if config == null:
|
||||||
|
return
|
||||||
|
var paths := config.get_properties()
|
||||||
|
assert_eq(paths.size(), 2, "同步字段数量不对:%s" % [paths])
|
||||||
|
assert_true(config.has_property(^".:position"), "缺少 .:position")
|
||||||
|
assert_true(config.has_property(^"Body:rotation"), "缺少 Body:rotation")
|
||||||
|
|
||||||
|
|
||||||
|
func test_config_replication_mode_is_always() -> void:
|
||||||
|
var config := load(CONFIG_PATH) as SceneReplicationConfig
|
||||||
|
assert_true(config != null, "player_sync.tres 未能加载")
|
||||||
|
if config == null:
|
||||||
|
return
|
||||||
|
var paths := config.get_properties()
|
||||||
|
assert_gt(paths.size(), 0, "同步字段为空,远端玩家不会移动")
|
||||||
|
for path in paths:
|
||||||
|
assert_eq(
|
||||||
|
config.property_get_replication_mode(path),
|
||||||
|
SceneReplicationConfig.REPLICATION_MODE_ALWAYS,
|
||||||
|
"%s 的 replication_mode 不是 ALWAYS" % path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func test_player_scene_wires_synchronizer() -> void:
|
||||||
|
var packed := load(PLAYER_SCENE_PATH) as PackedScene
|
||||||
|
assert_true(packed != null, "player.tscn 未能加载")
|
||||||
|
if packed == null:
|
||||||
|
return
|
||||||
|
var player := packed.instantiate()
|
||||||
|
track(player)
|
||||||
|
var sync := player.get_node_or_null("Sync") as MultiplayerSynchronizer
|
||||||
|
assert_true(sync != null, "Player 下缺少 Sync 节点")
|
||||||
|
if sync == null:
|
||||||
|
return
|
||||||
|
assert_true(sync.replication_config != null, "Sync 没有绑定 SceneReplicationConfig")
|
||||||
|
assert_eq(sync.root_path, ^"..", "Sync.root_path 应为 .. (相对 Player)")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://nvx3ojicfxh5
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
extends Node3D
|
||||||
|
## 世界根节点。
|
||||||
|
##
|
||||||
|
## 只做一件事:把玩家场景注册给 MultiplayerSpawner,并把 spawner 托管给 Net 单例。
|
||||||
|
## 玩家的生成/回收时机完全由 Net 决定,本文件不主动调用 spawn。
|
||||||
|
|
||||||
|
const PLAYER_SCENE: PackedScene = preload("res://player/player.tscn")
|
||||||
|
const PLAYER_GROUP: String = "net_player"
|
||||||
|
## 用节点路径而不是全局标识符访问单例:
|
||||||
|
## autoload 的全局名只在引擎启动时注册,这样写不依赖注册时机。
|
||||||
|
const NET_PATH: String = "/root/Net"
|
||||||
|
|
||||||
|
@onready var spawner: MultiplayerSpawner = $MultiplayerSpawner
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
spawner.spawn_function = _create_player
|
||||||
|
var net := get_node_or_null(NET_PATH)
|
||||||
|
if net != null:
|
||||||
|
net.bind_spawner(spawner)
|
||||||
|
else:
|
||||||
|
push_error("Net 单例未注册,联机不可用。请确认 project.godot 的 [autoload] 里有 Net")
|
||||||
|
|
||||||
|
|
||||||
|
## MultiplayerSpawner 会在**每一端**都调用这个函数,因此 authority 必须在这里设置:
|
||||||
|
## 这样「谁是本地玩家」在各端结论一致,玩家脚本里的 is_multiplayer_authority() 才可靠。
|
||||||
|
## recursive=true 很关键 —— 节点 authority 不自动继承,Sync 子节点必须一起设。
|
||||||
|
func _create_player(peer_id: int) -> Node:
|
||||||
|
var player := PLAYER_SCENE.instantiate()
|
||||||
|
player.name = str(peer_id)
|
||||||
|
player.add_to_group(PLAYER_GROUP)
|
||||||
|
player.set_multiplayer_authority(peer_id, true)
|
||||||
|
var here := multiplayer.get_unique_id()
|
||||||
|
print("[Net] 本端 %d 生成玩家 %d%s" % [here, peer_id, "(本地操控)" if peer_id == here else "(远端回放)"])
|
||||||
|
return player
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://11rbhffe5dhv
|
||||||
+6
-5
@@ -1,7 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://bp8l1uwgshc86"]
|
[gd_scene format=3 uid="uid://bp8l1uwgshc86"]
|
||||||
|
|
||||||
[ext_resource type="PackedScene" uid="uid://bekuuw153mv03" path="res://player/player.tscn" id="1_gjxf3"]
|
[ext_resource type="Script" uid="uid://11rbhffe5dhv" path="res://world/main.gd" id="1_hyim0"]
|
||||||
[ext_resource type="Sky" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"]
|
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"]
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"]
|
||||||
size = Vector3(40, 0.5, 40)
|
size = Vector3(40, 0.5, 40)
|
||||||
@@ -22,6 +22,7 @@ ambient_light_source = 3
|
|||||||
reflected_light_source = 2
|
reflected_light_source = 2
|
||||||
|
|
||||||
[node name="Main" type="Node3D" unique_id=58155864]
|
[node name="Main" type="Node3D" unique_id=58155864]
|
||||||
|
script = ExtResource("1_hyim0")
|
||||||
|
|
||||||
[node name="Ground" type="StaticBody3D" parent="." unique_id=305091665]
|
[node name="Ground" type="StaticBody3D" parent="." unique_id=305091665]
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ shadow_enabled = true
|
|||||||
directional_shadow_blend_splits = true
|
directional_shadow_blend_splits = true
|
||||||
directional_shadow_max_distance = 40.0
|
directional_shadow_max_distance = 40.0
|
||||||
|
|
||||||
[node name="Player" parent="." unique_id=869687860 instance=ExtResource("1_gjxf3")]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0)
|
|
||||||
|
|
||||||
[node name="Environment" type="WorldEnvironment" parent="." unique_id=794103784]
|
[node name="Environment" type="WorldEnvironment" parent="." unique_id=794103784]
|
||||||
environment = SubResource("Environment_gu74f")
|
environment = SubResource("Environment_gu74f")
|
||||||
|
|
||||||
|
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1410622657]
|
||||||
|
spawn_path = NodePath("..")
|
||||||
|
|||||||
@@ -4,6 +4,45 @@
|
|||||||
|
|
||||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_cartoon"]
|
[sub_resource type="ShaderMaterial" id="ShaderMaterial_cartoon"]
|
||||||
shader = ExtResource("1_cartoon")
|
shader = ExtResource("1_cartoon")
|
||||||
|
shader_parameter/zenith_color = Color(0.09, 0.3, 0.78, 1)
|
||||||
|
shader_parameter/mid_color = Color(0.28, 0.6, 0.95, 1)
|
||||||
|
shader_parameter/horizon_color = Color(0.78, 0.88, 1, 1)
|
||||||
|
shader_parameter/ground_color = Color(0.29, 0.329, 0.384, 1)
|
||||||
|
shader_parameter/gradient_bias = 1.4
|
||||||
|
shader_parameter/band_count = 0.0
|
||||||
|
shader_parameter/band_sharpness = 0.9
|
||||||
|
shader_parameter/follow_scene_light = 1.0
|
||||||
|
shader_parameter/sun_direction = Vector3(0.35, 0.55, 0.35)
|
||||||
|
shader_parameter/sun_core_color = Color(1, 0.976, 0.878, 1)
|
||||||
|
shader_parameter/sun_halo_color = Color(1, 0.827, 0.478, 1)
|
||||||
|
shader_parameter/sun_disk_size = 0.035
|
||||||
|
shader_parameter/sun_disk_softness = 0.2
|
||||||
|
shader_parameter/sun_halo_size = 0.55
|
||||||
|
shader_parameter/sun_halo_strength = 0.65
|
||||||
|
shader_parameter/sun_bloom_strength = 0.35
|
||||||
|
shader_parameter/sun_intensity = 1.7
|
||||||
|
shader_parameter/star_amount = 0.0
|
||||||
|
shader_parameter/star_density = 60.0
|
||||||
|
shader_parameter/star_size = 0.05
|
||||||
|
shader_parameter/star_twinkle = 0.65
|
||||||
|
shader_parameter/star_color = Color(0.85, 0.9, 1, 1)
|
||||||
|
shader_parameter/cloud_color = Color(1, 1, 1, 1)
|
||||||
|
shader_parameter/cloud_shadow_color = Color(0.45, 0.62, 0.88, 1)
|
||||||
|
shader_parameter/cloud_rim_color = Color(1, 0.93, 0.76, 1)
|
||||||
|
shader_parameter/cloud_coverage = 0.55
|
||||||
|
shader_parameter/cloud_scale = 4.0
|
||||||
|
shader_parameter/cloud_plane_offset = 0.6
|
||||||
|
shader_parameter/cloud_horizon_fade = 0.08
|
||||||
|
shader_parameter/cloud_edge = 0.16
|
||||||
|
shader_parameter/cloud_warp = 0.3
|
||||||
|
shader_parameter/cloud_speed = 0.01
|
||||||
|
shader_parameter/cloud_direction = Vector2(1, 0.25)
|
||||||
|
shader_parameter/cloud_shading = 0.55
|
||||||
|
shader_parameter/cloud_rim = 0.4
|
||||||
|
shader_parameter/cloud_opacity = 1.0
|
||||||
|
shader_parameter/haze_color = Color(0.8, 0.89, 1, 1)
|
||||||
|
shader_parameter/haze_strength = 0.35
|
||||||
|
shader_parameter/haze_height = 0.09
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
sky_material = SubResource("ShaderMaterial_cartoon")
|
sky_material = SubResource("ShaderMaterial_cartoon")
|
||||||
|
|||||||
Reference in New Issue
Block a user