32 KiB
32 KiB
TOHOTOPIA 联机系统技术文档
最后更新: 2026/04/13
目录
1. 系统概述
TOHOTOPIA 是一款 4X 回合制策略游戏,联机系统基于 Steam P2P 构建,采用 房主即服务器(Host-as-Server) 的中心化架构。
核心设计原则
- Action 原子化: 游戏以
Action为最小操作单位推进,每名玩家在自己的回合内执行 N 步 Action 操作 - 房主权威: 所有 Action 必须经房主验证后才广播执行,房主拥有最终游戏状态权威
- 确定性同步: 使用共享随机数种子 (
RandomSeed),保证所有客户端执行相同 Action 后状态一致 - 序列化: 使用
MemoryPack进行高性能二进制序列化
网络模式 (NetMode)
None - 未初始化
Single - 单机模式
Multi - 联机模式
Spectator - 观战模式
2. 网络拓扑结构
┌─────────────┐
│ 房主(Host) │
│ = 服务器+客户端│
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼───┐ ┌─────▼─────┐
│ 成员 A │ │成员 B │ │ 成员 C │
│ (Client) │ │(Client)│ │ (Client) │
└───────────┘ └───────┘ └───────────┘
- 成员 => 房主: 单播 (SendMessage)
- 房主 => 所有成员: 广播 (BroadcastMessage)
- 房主 => 单个成员: 定向发送 (SendMessageToPlayer)
- 成员之间: 不直接通信, 所有消息经房主中转
通信方式
| 方向 | 方法 | 说明 |
|---|---|---|
| 成员 => 房主 | GameNetSender.SendMessage() |
自动获取房主 ID 并发送 |
| 房主 => 全体 | GameNetSender.BroadcastMessage() |
广播给所有已连接的成员 |
| 房主 => 单人 | GameNetSender.SendMessageToPlayer() |
指定 memberId 定向发送 |
| 任意 => 任意 | GameNetSender.SendHeartbeatReply() |
心跳回复(不受房主限制) |
3. 核心类层级与职责
TH1_Logic.Net (接口层/抽象层)
├── ILobby - 房间接口定义
├── LobbyBase - 空实现(非 Steam 平台兜底)
└── LobbyManager - 单例, 持有 ILobby 实例
TH1_Logic.Steam (Steam 实现层)
├── SteamLobbyManager - ILobby 实现, 房间管理
├── SimpleP2P - P2P 连接管理(底层传输)
├── SteamObjectSerializer - 消息定义 & 序列化
├── GameNetSender - 发送端(业务层)
├── GameNetReceiver - 接收端(业务层)
└── SteamGUIMono - 调试 GUI (F3 开关)
RuntimeData (数据层)
├── NetData - 网络数据(玩家映射、Action 序列、随机种子)
├── PlayerConfirmData - 玩家确认状态(Ping、心跳、状态)
├── PlayerConfirmMap - 全体玩家确认状态集合
└── MapData.MapConfig - 对局配置(含联机用 MultiCivs)
平台切换
// LobbyManager.Init()
#if UNITY_EDITOR || STEAM_CHANNEL
Lobby = new SteamLobbyManager(); // Steam 平台
#else
Lobby = new LobbyBase(); // 非 Steam 平台(空实现)
#endif
4. Steam 集成层
4.1 SimpleP2P - P2P 连接管理
单例: SimpleP2P.Instance
负责底层 P2P 连接的建立、维护、消息收发。
| 功能 | 方法 | 说明 |
|---|---|---|
| 初始化 | Initialize() |
注册连接状态变化回调、会话请求回调 |
| 监听 | CreateListenSocket() |
在虚拟端口 1214 创建监听套接字 |
| 连接 | ConnectToPeer(CSteamID) |
主动连接到指定玩家(仅非房主调用) |
| 发送 | SendTo(CSteamID, byte[]) |
通过已建立的连接发送数据 |
| 发送(无连接) | SendToWithOutConnect() |
通过 SteamNetworkingMessages 发送(用于邀请) |
| 广播 | Broadcast(byte[]) |
向所有已连接玩家发送 |
| 轮询 | PollMessages() |
轮询所有连接和 Channel 0 上的消息 |
| 断开 | DisconnectFromPeer(CSteamID) |
断开指定连接 |
| 清理 | Cleanup() |
关闭所有连接和监听套接字 |
连接状态机
ConnectP2P()
│
▼
┌─── Connecting ──────┐
│ │ │
│ ▼ │
│ FindingRoute │
│ │ │
│ ▼ │
│ Connected ◄─────┘
│ │
│ ┌─────┴──────┐
│ ▼ ▼
│ ClosedByPeer ProblemDetected
│ │ │
│ ▼ ▼
└─► None (断开)
关键参数
- 虚拟端口: 1214
- 连接超时: 30 秒
- 最大重试: 3 次
- 最大连接数: 10
- 消息轮询批次: 32 条/连接, 10 条/Channel
消息发送标志
flags = 8 (可靠传输 Reliable)
flags = 0 (不可靠传输 Unreliable)
flags |= 1 (有序传输 NoDelay)
4.2 SteamLobbyManager - 房间管理
实现接口: ILobby
Steam 回调注册
| 回调 | 接收者 | 触发时机 |
|---|---|---|
LobbyCreated_t |
创建者 | CreateLobby 完成 |
GameLobbyJoinRequested_t |
被邀请者 | 接受 Steam 邀请时 |
LobbyEnter_t |
所有成功进入者 | 进入房间成功 |
LobbyChatUpdate_t |
房间内所有人 | 成员进/退/踢/封禁 |
LobbyMatchList_t |
搜索者 | 公开房间搜索结果返回 |
房间属性 (LobbyData)
| Key | 说明 | 示例值 |
|---|---|---|
Game |
游戏标识(防止跨游戏搜索) | "TOHOTOPIA" |
Owner |
房主昵称 | "白哉" |
Version |
游戏版本号 | "1.2.0" |
RoomName |
房间名称 | "白哉的房间" |
RoomCode |
房间码(Base36 编码) | "A1B2C3" |
GameState |
游戏状态 | "0" (菜单) |
disbanded |
解散标记 | "true" |
kick_{memberId} |
踢人标记 | "true" |
加入方式
- Steam 好友邀请 -
InviteFriend()/OpenInviteOverlay() - 房间码加入 -
JoinByRoomCode(code)(Base36 编码的 LobbyID) - 房间 ID 直接加入 -
JoinLobbyById(lobbyId) - 游戏内邀请 -
SendGameInvite()(通过 P2P 发送InviteMessage, 不走 Steam 邀请框) - 公开房间列表搜索 -
SearchPublicLobbies()
房间搜索过滤
// 默认搜索条件
SteamMatchmaking.AddRequestLobbyListDistanceFilter(Worldwide);
SteamMatchmaking.AddRequestLobbyListStringFilter("Game", "TOHOTOPIA", Equal);
SteamMatchmaking.AddRequestLobbyListStringFilter("GameState", "0", Equal); // 仅菜单状态
// 可选: 按名称/房间码过滤
5. 消息协议层
5.1 消息基类与类型
所有网络消息继承自 BaseMessage, 使用 MemoryPackUnion 实现多态序列化。
[MemoryPackable]
[MemoryPackUnion(1, typeof(StringMessage))]
[MemoryPackUnion(2, typeof(GameStartMessage))]
// ... 共 16 种消息类型
public abstract partial class BaseMessage
{
public abstract P2PMsgType MessageType { get; }
}
5.2 消息类型总览
| 枚举值 | 消息类型 | 方向 | 说明 |
|---|---|---|---|
String (1) |
StringMessage |
任意 | 调试用字符串消息 |
GameStart (2) |
GameStartMessage |
房主 => 成员 | 开始游戏, 携带完整 MapData |
ActionConfirm (3) |
ActionConfirmMessage |
成员 => 房主 | 请求执行 Action |
ActionExcute (4) |
ActionExcuteMessage |
房主 => 成员 | 广播 Action 执行 |
TurnEnd (5) |
TurnEndMessage |
成员 => 房主 | 请求结束回合 |
MapConfirm (6) |
MapConfirmMessage |
双向 | 游戏数据心跳校验 |
ForceUpdate (7) |
ForceUpdateMessage |
房主 => 成员 | 强制全量同步 MapData |
ChangeCiv (8) |
ChangeCivMessage |
成员 => 房主 | 修改文明/阵营 |
UpdateLobbyData (9) |
UpdateLobbyDataMessage |
房主 => 成员 | 同步房间配置 |
RequestLobbyData (10) |
RequestLobbyDataMessage |
成员 => 房主 | 请求房间配置 |
Heartbeat (11) |
HeartbeatMessage |
成员 => 房主 | 成员心跳 |
MemberStateSync (12) |
MemberStateSyncMessage |
房主 => 成员 | 房主心跳 + 状态下发 |
RequestForceUpdate (13) |
RequestForceUpdateMessage |
成员 => 房主 | 请求断线重连 |
HeartbeatReply (14) |
HeartbeatReplyMessage |
任意 => 任意 | 心跳回复(用于 Ping 计算) |
ChatMessage (15) |
ChatMessage |
双向 | 聊天消息 |
InviteMessage (16) |
InviteMessage |
任意 => 任意 | 游戏内邀请(不经 P2P 连接) |
5.3 关键消息数据结构
ActionNetData
[MemoryPackable]
public partial class ActionNetData
{
public uint Version; // Action 版本号(递增序列号)
public string MapHash; // 执行前的 MapData 哈希值
public CommonActionParams Param; // Action 参数
public CommonActionId ActionId; // Action 类型标识
}
MapConfirmMessage
public partial class MapConfirmMessage : BaseMessage
{
public ulong MemberId; // 发送者 SteamID
public uint PlayerId; // 游戏内 PlayerId
public int Index; // 当前 Action 序列长度
public ActionNetData ActionData; // 最后一个 ActionNetData (用于比对)
}
6. Action 同步机制
6.1 Action 系统概述
游戏中所有操作以 Action 为原子单位, 由 CommonActionType 枚举定义:
| Action 类型 | 说明 | 主体 |
|---|---|---|
Gain |
收获一次性资源 | Grid |
Build |
建设建筑 | Grid |
StartWonder / BuildWonder |
开启/建造奇观 | Player / Grid |
TrainUnit |
训练单位 | City |
GridMisc |
地格杂项操作 | Grid |
UnitAction |
单位自身行为 | Unit |
CityLevelUpAction |
城市升级 | City |
UnitSkill |
单位技能 | Unit |
LearnTech |
学习科技 | Player |
UnitMove |
单位移动 | Unit |
UnitAttack / UnitAttackAlly / UnitAttackGround |
单位攻击 | Unit |
PlayerAction |
玩家行为(外交等) | Player |
TurnStart / TurnEnd |
回合开始/结束 | Player |
PlayerSurrender |
玩家投降 | Player |
BuyCultureCard |
购买文化卡 | Player |
AIParamControl |
AI 参数控制 | Player |
6.2 Action 标识系统
每个 Action 由 CommonActionId 唯一标识, 组合了 ActionType + 各种子类型枚举。使用 Hash 计算唯一 ID:
public partial class CommonActionId
{
public CommonActionType ActionType;
public WonderTypeEnum WonderType;
public ResourceType ResourceType;
public UnitType UnitType;
// ... 更多子类型字段
public uint Id => ComputeId(); // 哈希唯一标识
}
6.3 Action 参数
public partial class CommonActionParams
{
// 序列化字段(网络传输)
public uint PlayerId, UnitId, CityId, GridId;
public uint TargetUnitId, TargetGridId, TargetPlayerId;
// 运行时引用(MemoryPackIgnore, 接收后通过 RefreshParams 恢复)
[MemoryPackIgnore] public MapData MapData;
[MemoryPackIgnore] public PlayerData PlayerData;
[MemoryPackIgnore] public UnitData UnitData;
// ...
}
接收端通过 RefreshParams() 根据 ID 重新绑定运行时引用。
6.4 房主执行 Action 流程
房主操作 UI
│
▼
ActionLogicBase.CompleteExecute(params)
│
├── 检查 Net.Mode == Multi && IsLobbyOwner
│ │
│ ▼
│ GameNetSender.ActionExecute(id, params) ──► 广播给所有成员
│
├── BeforeExecute(params)
│ └── 记录 ActionNetData 到 Net.Actions 列表
│
├── Execute(params) ──► 实际游戏逻辑
│
└── AfterExecute(params)
└── 触发技能生命周期、刷新外交、更新 UI 等
6.5 成员执行 Action 流程
成员操作 UI
│
▼
ActionLogicBase.CompleteExecute(params)
│
├── 检查 Net.Mode == Multi && !IsLobbyOwner
│ │
│ ▼
│ GameNetSender.ActionConfirm(id, params) ──► 发送给房主
│ (如果是 TurnEnd, 本地不执行, return false)
│
└── 不执行本地 Action(等待房主广播)
... 等待 ...
房主收到 ActionConfirmMessage
│
├── 校验 Version 一致性
├── 校验当前回合玩家一致性
├── RefreshParams() 恢复引用
│
└── action.CompleteExecute(params) ──► 房主本地执行 + 广播
成员收到 ActionExcuteMessage
│
├── 校验 Version 一致性
├── 校验 MapHash (若不一致, 记录差异)
├── RefreshParams() 恢复引用
│
└── action.NetCompleteExecute(params) ──► 成员本地执行(不触发广播)
6.6 回合结束流程
成员点击"结束回合"
│
├── CompleteExecute(TurnEnd action)
│ └── GameNetSender.TurnEndConfirm() ──► 发送给房主
│ └── return false (本地不执行)
│
▼
房主收到 TurnEndMessage
│
└── Main.PlayerLogic.EndPlayerTurn(MapData, playerId)
│
└── 触发 TurnEnd + 下一个 TurnStart Action
│
└── 广播给所有成员
6.7 版本号机制
每个 Action 都携带版本号 (Version), 使用递增序列:
// NetData
public uint GetActionVersion()
{
if (Actions.Count == 0) return 0;
return Actions[^1].Version + 1;
}
- 发送时:
Version = MapData.Net.GetActionVersion() - 接收时: 校验
message.Version == 本地 GetActionVersion() - 不一致时: 触发
SendRequestForceUpdate()请求全量同步
7. 心跳与状态监控
7.1 成员心跳 (成员 => 房主)
HeartbeatMessage {
MemberId: ulong // 发送者 SteamID
State: GameState // 当前游戏状态
}
- 成员定时发送
SendHeartbeat()给房主 - 房主收到后:
- 回复
HeartbeatReply - 更新
PlayerConfirmData.ConfirmTime - 检查成员 GameState 是否异常(如成员在 Menu 而游戏还在进行)
- 回复
7.2 房主状态同步 (房主 => 成员)
MemberStateSyncMessage {
State: GameState // 房主游戏状态
MemberId: ulong // 房主 SteamID
PlayerConfirm: Dictionary<ulong, PlayerConfirmData> // 全体玩家状态
}
- 房主定时广播
SendMemberStateSync() - 成员收到后:
- 回复
HeartbeatReply - 检查自己的状态是否为 OK, 若不是则请求重连
- 检查游戏是否已结束(房主在 Menu 而自己还在游戏)
- 回复
7.3 Ping 计算
// 发送心跳时记录时间
void OnSendHeartbeat() => PingRecordTime = Time.time;
// 收到心跳回复时计算 Ping
void OnReceiveHeartbeatReply() => Ping = Time.time - PingRecordTime;
7.4 玩家确认数据 (PlayerConfirmData)
public partial class PlayerConfirmData
{
public ulong MemberId; // Steam ID
public MemberNetState State; // 当前状态
public float PingRecordTime; // Ping 记录起始时间
public float Ping; // 实际 Ping 值
public float ErrorTime; // 进入错误状态的时间
public float ConfirmTime; // 最后收到心跳的时间
}
成员网络状态 (MemberNetState)
| 状态 | 说明 | 触发条件 |
|---|---|---|
None |
未初始化 | 默认状态 |
OK |
正常 | 心跳校验通过 |
Leaved |
已离开 | 主动退出房间 |
Disconnected |
断线 | 心跳检测成员状态异常 |
Timeout |
超时 | 长时间未收到心跳 |
Error |
数据错误 | Action Hash 校验失败 |
AI 托管判定
// 当玩家离线或错误超过 10 秒, 由 AI 接管操作
public bool IsNeedAI()
{
if (State == MemberNetState.Leaved) return true;
return State != MemberNetState.OK && Time.time - ErrorTime > 10f;
}
8. 断线重连机制
8.1 触发条件
- 版本号超前: 成员收到的 ActionExcute 版本号 > 本地版本号
- 心跳状态异常: 成员发现自己在房主端的状态不是 OK
- MapHash 校验失败: 心跳校验发现 Action 数据不一致
8.2 重连流程
成员检测到需要重连
│
▼
GameNetSender.SendRequestForceUpdate()
├── 发送 RequestForceUpdateMessage 给房主
└── 切换游戏状态为 ForceUpdating(防止重复请求)
▼
房主收到 RequestForceUpdateMessage
│
└── GameNetSender.ForceUpdate(memberId)
└── 发送 ForceUpdateMessage { MapData = Main.MapData } 给该成员
▼
成员收到 ForceUpdateMessage
│
├── Step 1: Main.GameLogic.ChangeState(GameState.Menu) - 结束当前对局
├── Step 2: 关闭所有 UI
├── Step 3: Main.NetResumeMatch(message.MapData) - 使用房主的 MapData 恢复对局
└── (显示断线重连提示 2 秒)
8.3 房主主动全量同步
房主也可以主动触发全量同步:
// 同步给单个成员
GameNetSender.ForceUpdate(memberId);
// 同步给所有成员
GameNetSender.BroadcastForceUpdate();
9. 数据一致性校验
9.1 MapData Hash
使用 MemoryPack 序列化 MapData 并计算 Unity Hash128:
public static string GetMapDataHash(MapData mapData)
{
// 临时移除 Actions 列表(不参与哈希)
var actions = mapData.Net.Actions;
mapData.Net.Actions = null;
byte[] bytes = MemoryPackSerializer.Serialize(mapData);
mapData.Net.Actions = actions;
var hash128 = new Hash128();
hash128.Append(bytes);
return hash128.ToString();
}
9.2 心跳校验 (MapConfirm)
成员 => 房主
// 成员发送校验
MapConfirmMessage {
MemberId,
PlayerId,
Index = Actions.Count, // 本地 Action 序列长度
ActionData = Actions[^1] // 最后一个 Action
}
房主校验逻辑
收到 MapConfirmMessage
│
├── Index > 房主 Actions.Count ?
│ └── 是: 标记该成员为 Error 状态
│
├── Actions[Index-1].IsEqual(message.ActionData) ?
│ └── 否: 标记该成员为 Error 状态
│ └── 是: 标记该成员为 OK 状态
│
└── 输出日志: 本地 hash vs 消息 hash
成员校验逻辑
成员收到房主的 MapConfirmMessage
│
├── Index > 本地 Actions.Count ?
│ └── 是: 请求重连 (SendRequestForceUpdate)
│
└── Actions[Index-1].IsEqual(message.ActionData) ?
└── 否: 请求重连 (SendRequestForceUpdate)
9.3 ActionNetData 判等
public bool IsEqual(ActionNetData other)
{
if (Version != other.Version) return false;
if (MapHash != other.MapHash) return false;
if (ActionId != other.ActionId) return false;
return true;
}
9.4 编译时校验 (CHECK_ACTIONDEFFERENCE)
#if CHECK_ACTIONDEFFERENCE
// 每次 AfterExecute 后序列化一份 MapData 副本
// 下次 BeforeExecute 时与当前 MapData 比较
// 如发现差异, 输出详细日志
#endif
10. 房间生命周期
10.1 创建房间
调用 CreateLobby(maxMembers, isPublic)
│
├── 状态: None => Creating
├── SteamMatchmaking.CreateLobby()
│
▼ 回调: LobbyCreated_t
│
├── 设置 LobbyData (Game, Owner, Version, RoomName, RoomCode, GameState)
├── 设置 Rich Presence
├── 状态: Creating => InLobby
└── 触发 OnLobbyCreatedEvent
10.2 加入房间
调用 JoinLobby(lobbyId)
│
├── 版本号检查(不一致则拒绝)
├── 状态: None => Joining
├── SteamMatchmaking.JoinLobby()
│
▼ 回调: LobbyEnter_t
│
├── 状态: Joining => InLobby
├── 检查是否被踢
├── 如果不是房主: SimpleP2P.ConnectToPeer(房主)
├── 刷新成员列表
└── 触发 OnLobbyEnteredEvent
10.3 开始游戏
房主点击"开始游戏"
│
├── 本地初始化游戏 (包括 MapData, NetData 等)
├── NetData.RefreshPlayerNet() - 建立 SteamID => PlayerId 映射
├── GameNetSender.GameStart() - 广播 GameStartMessage(携带完整 MapData)
├── LobbyManager.SetGameState(1) - 更新房间状态
│
▼ 成员收到 GameStartMessage
│
├── Main.NetStartGame(message.MapData) - 使用房主的 MapData 初始化
└── 关闭大厅 UI, 进入游戏
10.4 离开房间
调用 LeaveLobby()
│
├── 状态: InLobby => Leaving
├── SimpleP2P.DisconnectAll() - 断开所有 P2P 连接
├── SteamMatchmaking.LeaveLobby()
├── 重置状态 (CurrentLobby = Nil, CachedOwner = Nil)
├── 清除 Rich Presence
└── 触发 OnLobbyLeftEvent
10.5 房主变更
房间内成员变化 (LobbyChatUpdate_t)
│
└── CheckOwnerChange()
│
├── 新房主 == 自己 ?
│ └── 是: 接管服务器角色
│
└── 新房主 != 自己 ?
├── SimpleP2P.DisconnectAll()
└── SimpleP2P.ConnectToPeer(新房主)
10.6 踢人机制
Steam 没有直接踢人 API, 通过 LobbyData 标记实现:
// 房主设置踢人标记
SteamMatchmaking.SetLobbyMemberData(CurrentLobby, $"kick_{memberId}", "true");
// 成员定时检查(每 2 秒)
void CheckIfKicked()
{
var kickData = SteamMatchmaking.GetLobbyMemberData(CurrentLobby, SteamUser.GetSteamID(), "kicked");
var specificKick = SteamMatchmaking.GetLobbyData(CurrentLobby, $"kick_{selfId}");
if (kickData == "true" || specificKick == "true") LeaveLobby();
}
11. 关键数据结构
11.1 NetData (联机核心数据, 挂载在 MapData.Net)
[MemoryPackable]
public partial class NetData
{
public NetMode Mode; // 网络模式
public uint CurPlayerId; // 当前操作的玩家 ID
public Dictionary<ulong, uint> Players; // SteamID => PlayerId 映射
public List<ActionNetData> Actions; // 所有行为的有序序列
public int RandomSeed; // 确定性随机种子(房主生成)
[MemoryPackIgnore] public float PlayerStartTime; // 玩家回合开始时间
[MemoryPackIgnore] public float GameStartTime; // 游戏开始时间
}
11.2 MapConfig (对局配置)
[MemoryPackable]
public partial class MapConfig
{
public uint Width, Height; // 地图尺寸
public uint PlayerCount; // 玩家数量
public AIDifficult AIDiff; // AI 难度
public GameMode GameMode; // 游戏模式(征服/完美/创意)
// 联机用
public List<MemberCiv> MultiCivs; // 成员文明选择列表
// 超时控制
public bool IsLimitTime; // 是否限时
public int TimeLimitSeconds; // 限时秒数(默认 180)
}
11.3 PlayerConfirmMap (玩家确认状态集合)
[MemoryPackable]
public partial class PlayerConfirmMap
{
public Dictionary<ulong, PlayerConfirmData> PlayerConfirm;
// 获取或创建(惰性初始化)
public PlayerConfirmData GetPlayerConfirm(ulong memberId);
// 批量更新(收到房主状态同步时)
public void UpdatePlayerConfirm(Dictionary<ulong, PlayerConfirmData> playerConfirm);
}
11.4 LobbyListInfo (房间列表条目)
[MemoryPackable]
public partial class LobbyListInfo
{
public ulong LobbyId;
public ulong OwnerId;
public string OwnerName;
public string RoomName;
public string Version;
public int CurrentPlayers;
public int MaxPlayers;
public int GameState;
}
12. 时序图
12.1 完整 Action 同步时序 (成员操作)
成员 房主 其他成员
│ │ │
│ ActionConfirmMessage │ │
│ ─────────────────────► │ │
│ {Version, MapHash, │ │
│ ActionId, Params} │ │
│ │ │
│ 校验 Version │
│ 校验 Player │
│ RefreshParams │
│ CompleteExecute │
│ │ │
│ │ ActionExcuteMessage │
│ ActionExcuteMessage │ ─────────────────────► │
│ ◄───────────────────── │ {Version, MapHash, │
│ {Version, MapHash, │ ActionId, Params} │
│ ActionId, Params} │ │
│ │ │
│ 校验 Version │ 校验 Version │
│ 校验 MapHash │ 校验 MapHash │
│ RefreshParams │ RefreshParams│
│ NetCompleteExecute │ NetCompleteExecute │
│ │ │
12.2 心跳与校验时序
成员 房主
│ │
│ HeartbeatMessage │
│ ─────────────────────► │
│ {MemberId, GameState} │
│ │
│ 更新 ConfirmTime
│ 检查状态异常
│ │
│ HeartbeatReplyMessage │
│ ◄───────────────────── │
│ {MemberId} │
│ │
│ 计算 Ping │
│ │
│ MemberStateSyncMessage │
│ ◄───────────────────── │
│ {State, PlayerConfirm} │
│ │
│ 检查自身状态 │
│ 如异常 => 请求重连 │
│ │
│ MapConfirmMessage │
│ ─────────────────────► │
│ {Index, ActionData} │
│ │
│ 校验 Action 一致性
│ 更新成员状态
│ │
12.3 断线重连时序
成员 房主
│ │
│ 检测到版本号/Hash不一致 │
│ 或收到异常状态 │
│ │
│ RequestForceUpdateMessage│
│ ─────────────────────► │
│ {MemberId} │
│ │
│ 切换状态:ForceUpdating │
│ │
│ ForceUpdateMessage │
│ ◄───────────────────── │
│ {完整 MapData} │
│ │
│ 结束当前游戏 │
│ 关闭所有 UI │
│ NetResumeMatch(MapData) │
│ 恢复游戏 │
│ │
13. 文件索引
Steam 集成层
| 文件 | 路径 | 说明 |
|---|---|---|
SimpleP2P.cs |
TH1_Logic/Steam/ |
P2P 底层连接管理 |
SteamLobbyManager.cs |
TH1_Logic/Steam/ |
Steam 房间管理 (ILobby 实现) |
SteamObjectSerializer.cs |
TH1_Logic/Steam/ |
P2P 消息类型定义 & 序列化 |
GameNetSender.cs |
TH1_Logic/Steam/ |
网络消息发送端 |
GameNetReceiver.cs |
TH1_Logic/Steam/ |
网络消息接收端 |
SteamGUIMono.cs |
TH1_Logic/Steam/ |
调试 GUI (F3 开关) |
网络抽象层
| 文件 | 路径 | 说明 |
|---|---|---|
ILobby.cs |
TH1_Logic/Net/ |
房间接口定义 |
LobbyManager.cs |
TH1_Logic/Net/ |
房间管理器单例 |
数据层
| 文件 | 路径 | 说明 |
|---|---|---|
NetData.cs |
TH1_Data/ |
网络数据 (模式、玩家映射、Action 序列、随机种子) |
MapData.cs |
TH1_Data/ |
地图数据 (含 MapConfig、Net 等) |
PlayerData.cs |
TH1_Data/ |
玩家数据 |
MatchData.cs |
TH1_Data/ |
对局限制数据 |
GridData.cs |
TH1_Data/ |
地格数据 |
CityData.cs |
TH1_Data/ |
城市数据 |
UnitData.cs |
TH1_Data/ |
单位数据 |
RuntimeData.cs |
TH1_Data/ |
运行时数据 |
Action 逻辑层
| 文件 | 路径 | 说明 |
|---|---|---|
ActionLogic.cs |
TH1_Logic/Action/ |
Action 基类、工厂、参数、ID 系统 |
BuildActionLogic.cs |
TH1_Logic/Action/ |
建设类 Action 逻辑 |
GridMiscActionLogic.cs |
TH1_Logic/Action/ |
地格杂项 Action 逻辑 |
PlayerActionLogic.cs |
TH1_Logic/Action/ |
玩家行为 Action 逻辑(外交、英雄等) |
TrainUnitActionLogic.cs |
TH1_Logic/Action/ |
训练单位 Action 逻辑 |
UnitActionLogic.cs |
TH1_Logic/Action/ |
单位自身行为 Action 逻辑 |
AIActionBase.cs |
TH1_Logic/AI/ |
AI 行为基类 (含 ActionNetData 定义) |
附录 A: 关键单例访问
SimpleP2P.Instance // P2P 连接管理
LobbyManager.Instance.Lobby // 房间接口(ILobby)
GameNetSender.Instance // 消息发送
GameNetReceiver.Instance // 消息接收
Main.MapData // 当前地图数据
Main.MapData.Net // 网络数据(NetData)
Main.Instance.ConfirmMap // 玩家确认状态集合
Main.Instance.MapConfig // 当前对局配置
附录 B: 调试工具
- F3: 开关 Steam 调试 GUI (
SteamGUIMono) - 调试面板内容:
- Steam 初始化状态、用户信息
- 房间状态、成员列表、P2P 连接数
- 房主可踢出成员
- 好友列表 & 邀请
- 文明切换按钮
附录 C: 注意事项
- 房主端口清理: 初始化监听套接字前会强制清理端口 1214 上的旧连接
- 版本控制: 加入房间时校验
Version字段, 版本不一致无法加入 - Steam 隐私限制: Steam 客户端 API 无法获取玩家资料隐私设置, 需 UI 提示
- 嵌套 Action:
ExecuteWithoutFullActionPeriod()用于 Action 内部嵌套调用其他 Action, 不触发网络同步 - AI 托管: 玩家断线超过 10 秒或已离开时, 由 AI 自动接管其操作
- 随机数一致性: 所有 Action 逻辑必须通过
NetData.GetRandom(map)获取随机数, 保证跨端一致