增加保护

This commit is contained in:
wuwenbo 2026-04-13 14:25:36 +08:00
parent 922ddaba9a
commit 12a84aec53
12 changed files with 1040 additions and 564 deletions

999
MD/Multiplayer_System.md Normal file
View File

@ -0,0 +1,999 @@
# TOHOTOPIA 联机系统技术文档
> 最后更新: 2026/04/13
---
## 目录
1. [系统概述](#1-系统概述)
2. [网络拓扑结构](#2-网络拓扑结构)
3. [核心类层级与职责](#3-核心类层级与职责)
4. [Steam 集成层](#4-steam-集成层)
5. [消息协议层](#5-消息协议层)
6. [Action 同步机制](#6-action-同步机制)
7. [心跳与状态监控](#7-心跳与状态监控)
8. [断线重连机制](#8-断线重连机制)
9. [数据一致性校验](#9-数据一致性校验)
10. [房间生命周期](#10-房间生命周期)
11. [关键数据结构](#11-关键数据结构)
12. [时序图](#12-时序图)
13. [文件索引](#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)
```
### 平台切换
```csharp
// 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"` |
#### 加入方式
1. **Steam 好友邀请** - `InviteFriend()` / `OpenInviteOverlay()`
2. **房间码加入** - `JoinByRoomCode(code)` (Base36 编码的 LobbyID)
3. **房间 ID 直接加入** - `JoinLobbyById(lobbyId)`
4. **游戏内邀请** - `SendGameInvite()` (通过 P2P 发送 `InviteMessage`, 不走 Steam 邀请框)
5. **公开房间列表搜索** - `SearchPublicLobbies()`
#### 房间搜索过滤
```csharp
// 默认搜索条件
SteamMatchmaking.AddRequestLobbyListDistanceFilter(Worldwide);
SteamMatchmaking.AddRequestLobbyListStringFilter("Game", "TOHOTOPIA", Equal);
SteamMatchmaking.AddRequestLobbyListStringFilter("GameState", "0", Equal); // 仅菜单状态
// 可选: 按名称/房间码过滤
```
---
## 5. 消息协议层
### 5.1 消息基类与类型
所有网络消息继承自 `BaseMessage`, 使用 `MemoryPackUnion` 实现多态序列化。
```csharp
[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
```csharp
[MemoryPackable]
public partial class ActionNetData
{
public uint Version; // Action 版本号(递增序列号)
public string MapHash; // 执行前的 MapData 哈希值
public CommonActionParams Param; // Action 参数
public CommonActionId ActionId; // Action 类型标识
}
```
#### MapConfirmMessage
```csharp
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:
```csharp
public partial class CommonActionId
{
public CommonActionType ActionType;
public WonderTypeEnum WonderType;
public ResourceType ResourceType;
public UnitType UnitType;
// ... 更多子类型字段
public uint Id => ComputeId(); // 哈希唯一标识
}
```
### 6.3 Action 参数
```csharp
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`), 使用递增序列:
```csharp
// 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()` 给房主
- 房主收到后:
1. 回复 `HeartbeatReply`
2. 更新 `PlayerConfirmData.ConfirmTime`
3. 检查成员 GameState 是否异常(如成员在 Menu 而游戏还在进行)
### 7.2 房主状态同步 (房主 => 成员)
```
MemberStateSyncMessage {
State: GameState // 房主游戏状态
MemberId: ulong // 房主 SteamID
PlayerConfirm: Dictionary<ulong, PlayerConfirmData> // 全体玩家状态
}
```
- 房主定时广播 `SendMemberStateSync()`
- 成员收到后:
1. 回复 `HeartbeatReply`
2. 检查自己的状态是否为 OK, 若不是则请求重连
3. 检查游戏是否已结束(房主在 Menu 而自己还在游戏)
### 7.3 Ping 计算
```csharp
// 发送心跳时记录时间
void OnSendHeartbeat() => PingRecordTime = Time.time;
// 收到心跳回复时计算 Ping
void OnReceiveHeartbeatReply() => Ping = Time.time - PingRecordTime;
```
### 7.4 玩家确认数据 (`PlayerConfirmData`)
```csharp
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 托管判定
```csharp
// 当玩家离线或错误超过 10 秒, 由 AI 接管操作
public bool IsNeedAI()
{
if (State == MemberNetState.Leaved) return true;
return State != MemberNetState.OK && Time.time - ErrorTime > 10f;
}
```
---
## 8. 断线重连机制
### 8.1 触发条件
1. **版本号超前**: 成员收到的 ActionExcute 版本号 > 本地版本号
2. **心跳状态异常**: 成员发现自己在房主端的状态不是 OK
3. **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 房主主动全量同步
房主也可以主动触发全量同步:
```csharp
// 同步给单个成员
GameNetSender.ForceUpdate(memberId);
// 同步给所有成员
GameNetSender.BroadcastForceUpdate();
```
---
## 9. 数据一致性校验
### 9.1 MapData Hash
使用 MemoryPack 序列化 MapData 并计算 Unity `Hash128`:
```csharp
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)
#### 成员 => 房主
```csharp
// 成员发送校验
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 判等
```csharp
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 标记实现:
```csharp
// 房主设置踢人标记
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)
```csharp
[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 (对局配置)
```csharp
[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 (玩家确认状态集合)
```csharp
[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 (房间列表条目)
```csharp
[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: 关键单例访问
```csharp
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: 注意事项
1. **房主端口清理**: 初始化监听套接字前会强制清理端口 1214 上的旧连接
2. **版本控制**: 加入房间时校验 `Version` 字段, 版本不一致无法加入
3. **Steam 隐私限制**: Steam 客户端 API 无法获取玩家资料隐私设置, 需 UI 提示
4. **嵌套 Action**: `ExecuteWithoutFullActionPeriod()` 用于 Action 内部嵌套调用其他 Action, 不触发网络同步
5. **AI 托管**: 玩家断线超过 10 秒或已离开时, 由 AI 自动接管其操作
6. **随机数一致性**: 所有 Action 逻辑必须通过 `NetData.GetRandom(map)` 获取随机数, 保证跨端一致

View File

@ -79,7 +79,7 @@ namespace TH1_Core.Managers
return;
}
}
if (task == null)
{
Debug.LogError("试图添加一个空的任务!");
@ -87,7 +87,8 @@ namespace TH1_Core.Managers
}
//如果当前不是我的回合并且进来了一个UISequencerTask那么要先缓存直到我的回合再显示出来
if (Main.MapData.CurPlayer != Main.MapData.PlayerMap.SelfPlayerData && (task as UISequencerTask) != null)
if (Main.MapData?.CurPlayer != null && Main.MapData?.PlayerMap?.SelfPlayerData != null &&
Main.MapData.CurPlayer != Main.MapData.PlayerMap.SelfPlayerData && (task as UISequencerTask) != null)
{
_taskNotCurPlayList.Enqueue(task);
}
@ -147,7 +148,8 @@ namespace TH1_Core.Managers
public static void CheckNotCurQueue()
{
if (Main.MapData?.CurPlayer == null || Main.MapData?.PlayerMap?.SelfPlayerData == null) return;
if (Main.MapData.CurPlayer != Main.MapData.PlayerMap.SelfPlayerData)return;
while(_taskNotCurPlayList.Count > 0)
_taskQueue.Enqueue(_taskNotCurPlayList.Dequeue());

View File

@ -1840,6 +1840,9 @@ namespace Logic.Action
protected override bool Execute(CommonActionParams actionParams)
{
//Step #0 鲁棒性保护
if (actionParams?.UnitData == null || actionParams.TargetUnitData == null || actionParams.MapData == null) return false;
//Step #0 为anim做数据准备
GridData originGrid = null;
GridData targetGrid = null;
@ -1859,10 +1862,13 @@ namespace Logic.Action
originCity = actionParams.UnitData.City(Main.MapData);
targetCity = actionParams.TargetUnitData.City(Main.MapData);
originPlayer = actionParams.UnitData.Player(Main.MapData);
MapRenderer.Instance.ROUnitMap.TryGetValue(actionParams.UnitData.Id, out originUnitRenderer);
MapRenderer.Instance.ROUnitMap.TryGetValue(actionParams.TargetUnitData.Id, out targetUnitRenderer);
originUnitProjectileType = actionParams.UnitData.GetProjectileType(actionParams.MapData,targetGrid);
targetUnitProjectileType = actionParams.TargetUnitData.GetProjectileType(actionParams.MapData,targetGrid);
MapRenderer.Instance?.ROUnitMap?.TryGetValue(actionParams.UnitData.Id, out originUnitRenderer);
MapRenderer.Instance?.ROUnitMap?.TryGetValue(actionParams.TargetUnitData.Id, out targetUnitRenderer);
if (targetGrid != null)
{
originUnitProjectileType = actionParams.UnitData.GetProjectileType(actionParams.MapData,targetGrid);
targetUnitProjectileType = actionParams.TargetUnitData.GetProjectileType(actionParams.MapData,targetGrid);
}
}
//Step #2 处理逻辑计算提前存储anim使用到的数据
@ -1882,8 +1888,9 @@ namespace Logic.Action
bool pushed = originGridAfter != null && originGridAfter.Id != originGrid.Id && originGridAfter.Id == targetGrid.Id;
//如果在视野,播放动画
if (Main.MapData.PlayerMap.SelfPlayerData.Sight.CheckIsInSight(originGrid.Id) ||
Main.MapData.PlayerMap.SelfPlayerData.Sight.CheckIsInSight(targetGrid.Id))
if (Main.MapData?.PlayerMap?.SelfPlayerData?.Sight != null &&
(Main.MapData.PlayerMap.SelfPlayerData.Sight.CheckIsInSight(originGrid.Id) ||
Main.MapData.PlayerMap.SelfPlayerData.Sight.CheckIsInSight(targetGrid.Id)))
{
//如果发生了推动,先播放双方的移动动画,再播放攻击动画
//推击保证不会击杀target所以targetGridAfter!=null可以正确区分推击和MoveKill

View File

@ -233,7 +233,9 @@ namespace Logic
public void OnDisconnectToHost()
{
if (_curState == GameState.Menu) return;
if (Main.MapData?.Net == null) return;
if (Main.MapData.Net.Mode != NetMode.Multi) return;
if (!LobbyManager.Instance.Lobby.IsInitialized()) return;
if (LobbyManager.Instance.Lobby.IsLobbyOwner()) return;
EventManager.Publish(new ExecuteUIBottomBottomBarQuit());
}

View File

@ -39,15 +39,16 @@ namespace Logic.Skill
public override void OnDamaged(MapData mapData, SettlementInfo info)
{
if (info == null || !info.IsKill || info.DamageTargetGrid == null || info.DamageTargetCity == null) return;
if (mapData == null) return;
if (info == null || !info.IsKill || info.DamageTarget == null || info.DamageTargetGrid == null || info.DamageTargetCity == null) return;
var tmpUnitFullType = info.DamageTarget.UnitFullType;
UpdateFullType(info.DamageTarget);
//如果要复活的位置上有地方单位先将对方passiveMove开
if (info.DamageTargetGrid.RealUnit(mapData, out var unit))
Main.UnitLogic.PassiveMoveAway(mapData,unit);
mapData.AddUnitData(info.DamageTargetGrid.Id, info.DamageTargetCity.Id, FullType,out var newUnit);
newUnit.OriginUnitFullType = tmpUnitFullType;
if (newUnit != null)
newUnit.OriginUnitFullType = tmpUnitFullType;
}
}
}

View File

@ -1,134 +0,0 @@
/*
* @Author:
* @Description:
* @Date: 20260310 14:03:29
* @Modify:
*/
using System;
using Logic.CrashSight;
using Steamworks;
using UnityEngine;
namespace TH1_Logic.Tools
{
public static class SteamCloudStorage
{
/// <summary>
/// 是否可用Steamworks 已初始化且云存储已启用)
/// </summary>
public static bool IsAvailable()
{
try
{
return SteamManager.Initialized && SteamRemoteStorage.IsCloudEnabledForAccount()
&& SteamRemoteStorage.IsCloudEnabledForApp();
}
catch
{
return false;
}
}
/// <summary>
/// 写入文件到 Steam Cloud
/// </summary>
/// <param name="fileName">虚拟文件名,如 "achievement.json"</param>
/// <param name="data">UTF-8 字节数据</param>
/// <returns>是否成功</returns>
public static bool WriteFile(string fileName, byte[] data)
{
if (!IsAvailable()) return false;
try
{
bool success = SteamRemoteStorage.FileWrite(fileName, data, data.Length);
if (!success)
{
LogSystem.LogError($"[SteamCloud] 写入失败: {fileName}");
}
return success;
}
catch (Exception e)
{
LogSystem.LogError($"[SteamCloud] 写入异常: {fileName} | {e.Message}");
return false;
}
}
/// <summary>
/// 从 Steam Cloud 读取文件
/// </summary>
/// <param name="fileName">虚拟文件名</param>
/// <returns>文件内容字节数组,失败返回 null</returns>
public static byte[] ReadFile(string fileName)
{
if (!IsAvailable()) return null;
try
{
if (!SteamRemoteStorage.FileExists(fileName)) return null;
int size = SteamRemoteStorage.GetFileSize(fileName);
if (size <= 0) return null;
byte[] buffer = new byte[size];
int readBytes = SteamRemoteStorage.FileRead(fileName, buffer, size);
if (readBytes <= 0) return null;
// 实际读取长度可能小于 buffer 大小
if (readBytes < size)
{
byte[] trimmed = new byte[readBytes];
Array.Copy(buffer, trimmed, readBytes);
return trimmed;
}
return buffer;
}
catch (Exception e)
{
LogSystem.LogError($"[SteamCloud] 读取异常: {fileName} | {e.Message}");
return null;
}
}
/// <summary>
/// 删除 Steam Cloud 上的文件
/// </summary>
public static bool DeleteFile(string fileName)
{
if (!IsAvailable()) return false;
try
{
return SteamRemoteStorage.FileDelete(fileName);
}
catch (Exception e)
{
LogSystem.LogError($"[SteamCloud] 删除异常: {fileName} | {e.Message}");
return false;
}
}
/// <summary>
/// 检查文件是否存在于 Steam Cloud
/// </summary>
public static bool FileExists(string fileName)
{
if (!IsAvailable()) return false;
try
{
return SteamRemoteStorage.FileExists(fileName);
}
catch
{
return false;
}
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 9a96892e098a418ba18da3d76258893b
timeCreated: 1773123973

View File

@ -1,399 +0,0 @@
// using System.Collections.Generic;
// using System.IO;
// using UnityEngine;
// using Steamworks;
// using Logic.CrashSight;
// using TH1_Logic.Net;
//
//
// namespace TH1_Logic.Steam
// {
// public class TH1SteamManager
// {
// public static TH1SteamManager Instance { get; } = new TH1SteamManager();
//
// [Header("Steam 连接状态")]
// public bool IsSteamInitialized = false;
// public bool IsLoggedIn = false;
// public string CurrentUserName = "";
// public CSteamID CurrentUserID;
//
// [Header("房间测试")]
// private int _maxLobbyMembers = 4;
// private SteamLobbyManager _lobby;
// private GameObject _guiObj;
//
//
// // 初始化Steam
// public void Init()
// {
// LogSystem.LogInfo("开始初始化Steam...");
// LogSystem.LogInfo($"启动方式检测: {(SteamAPI.RestartAppIfNecessary(new Steamworks.AppId_t(480)) ? "需要通过Steam启动" : "直接启动或已通过Steam启动")}");
//
// // 检查Steam是否运行
// if (!SteamAPI.IsSteamRunning())
// {
// LogSystem.LogError("Steam客户端未运行请先启动Steam。");
// return;
// }
//
// // 检查steam_appid.txt文件状态
// CheckSteamAppIdFile();
//
// // 初始化Steam API
// IsSteamInitialized = SteamAPI.Init();
//
// if (!IsSteamInitialized)
// {
// LogSystem.LogError("Steam API初始化失败");
// LogSystem.LogError("可能的原因:");
// LogSystem.LogError("1. Steam客户端未运行");
// LogSystem.LogError("2. steam_appid.txt文件配置错误开发环境");
// LogSystem.LogError("3. 没有以Steam方式启动应用开发环境");
// LogSystem.LogError("4. Steam App ID不匹配");
// return;
// }
// LogSystem.LogInfo("Steam API初始化成功");
//
// // 检查用户登录状态
// try
// {
// CheckUserLoginStatus();
// }
// catch (System.Exception e)
// {
// LogSystem.LogError($"检查用户登录状态异常: {e.Message}");
// }
//
// // 显示启动信息
// DisplayLaunchInfo();
//
// // 初始化Steam房间管理器
// try
// {
// InitializeSteamLobbyManager();
// }
// catch (System.Exception e)
// {
// LogSystem.LogError($"Steam房间管理器初始化异常: {e.Message}");
// }
//
// LogSystem.LogInfo("Steam测试环境初始化完成");
//
// // 构建 GUI 输入 Mono
// _guiObj = new GameObject();
// _guiObj.name = "SteamGUI";
// _guiObj.AddComponent<SteamGUIMono>();
// _guiObj.SetActive(true);
// }
//
// // 定时更新
// public void Update()
// {
// if (!IsSteamInitialized || !IsLoggedIn) return;
//
// // 更新Steam回调
// SteamAPI.RunCallbacks();
//
// // 更新P2P消息
// _lobby.Update();
// }
//
// /// <summary>
// /// 检查steam_appid.txt文件状态
// /// </summary>
// private void CheckSteamAppIdFile()
// {
// string steamAppIdPath = Path.Combine(Directory.GetCurrentDirectory(), "steam_appid.txt");
//
// if (File.Exists(steamAppIdPath))
// {
// try
// {
// string content = File.ReadAllText(steamAppIdPath).Trim();
// LogSystem.LogInfo($"发现steam_appid.txt文件App ID: {content}");
// }
// catch (System.Exception e)
// {
// LogSystem.LogWarning($"读取steam_appid.txt失败: {e.Message}");
// }
// }
// else
// {
// LogSystem.LogInfo("未发现steam_appid.txt文件 - 这在Steam平台发布时是正常的");
// }
// }
//
// /// <summary>
// /// 显示启动信息
// /// </summary>
// private void DisplayLaunchInfo()
// {
// if (!IsSteamInitialized) return;
//
// // 获取当前App ID
// var currentAppId = SteamUtils.GetAppID();
// LogSystem.LogInfo($"当前Steam App ID: {currentAppId}");
//
// // 检查启动方式
// bool launchedViaSteam = SteamApps.BIsSubscribedApp(currentAppId);
// LogSystem.LogInfo($"通过Steam启动: {(launchedViaSteam ? "是" : "否")}");
//
// // 显示Steam环境信息
// LogSystem.LogInfo($"Steam语言: {SteamApps.GetCurrentGameLanguage()}");
// LogSystem.LogInfo($"Steam服务器连接: {(SteamUser.BLoggedOn() ? "已连接" : "未连接")}");
//
// // 检查DLC和订阅状态
// if (currentAppId.m_AppId == 480) // Spacewar测试应用
// {
// LogSystem.LogInfo("当前使用Spacewar测试应用 - 适用于开发测试");
// }
// else
// {
// LogSystem.LogInfo($"当前使用正式应用ID: {currentAppId.m_AppId}");
// }
// }
//
// /// <summary>
// /// 检查用户登录状态
// /// </summary>
// private void CheckUserLoginStatus()
// {
// if (!IsSteamInitialized) return;
//
// IsLoggedIn = SteamUser.BLoggedOn();
//
// if (IsLoggedIn)
// {
// CurrentUserID = SteamUser.GetSteamID();
// CurrentUserName = SteamFriends.GetPersonaName();
// LogSystem.LogInfo($"Steam用户已登录: {CurrentUserName} ({CurrentUserID})");
// }
// else
// {
// LogSystem.LogWarning("Steam用户未登录");
// }
// }
//
// /// <summary>
// /// 初始化Steam房间管理器
// /// </summary>
// private void InitializeSteamLobbyManager()
// {
// if (!IsSteamInitialized || !IsLoggedIn) return;
// try
// {
// _lobby ??= new SteamLobbyManager();
// // 初始化房间管理器
// _lobby.InitCallbacks();
//
// // 订阅事件
// _lobby.OnLobbyCreatedEvent += OnLobbyCreated;
// _lobby.OnLobbyEnteredEvent += OnLobbyEntered;
// _lobby.OnLobbyLeftEvent += OnLobbyLeft;
// _lobby.OnMemberJoinedEvent += OnMemberJoined;
// _lobby.OnMemberLeftEvent += OnMemberLeft;
// _lobby.OnHostChangedEvent += OnHostChanged;
// _lobby.OnLobbyErrorEvent += OnLobbyError;
//
// // 订阅P2P事件
// SimpleP2P.Instance.OnPeerConnectedEvent += OnP2PPeerConnected;
// SimpleP2P.Instance.OnPeerDisconnectedEvent += OnP2PPeerDisconnected;
// SimpleP2P.Instance.OnMessageReceivedEvent += OnP2PMessageReceived;
// SimpleP2P.Instance.OnConnectionErrorEvent += OnP2PConnectionError;
//
// LogSystem.LogInfo("Steam房间管理器初始化成功");
// LobbyManager.Instance.Lobby = _lobby;
// }
// catch (System.Exception e)
// {
// LogSystem.LogError($"Steam房间管理器初始化失败: {e.Message}");
// }
// }
//
// private void OnDestroy()
// {
// if (IsSteamInitialized)
// {
// _lobby.Cleanup();
// SteamAPI.Shutdown();
// LogSystem.LogInfo("Steam API已关闭");
// }
// }
//
// #region 房间事件处理
//
// private void OnLobbyCreated(CSteamID lobbyId)
// {
// LogSystem.LogInfo($"[测试] 房间创建成功: {lobbyId}");
// }
//
// private void OnLobbyEntered(CSteamID lobbyId)
// {
// LogSystem.LogInfo($"[测试] 进入房间: {lobbyId}");
// LogSystem.LogInfo($"[测试] 房间成员数: {_lobby.GetMemberCount()}/{_lobby.GetMemberLimit()}");
// }
//
// private void OnLobbyLeft()
// {
// LogSystem.LogInfo("[测试] 离开房间");
// }
//
// private void OnMemberJoined(CSteamID memberId)
// {
// string memberName = SteamFriends.GetFriendPersonaName(memberId);
// LogSystem.LogInfo($"[测试] 成员加入: {memberName} ({memberId})");
// }
//
// private void OnMemberLeft(CSteamID memberId)
// {
// string memberName = SteamFriends.GetFriendPersonaName(memberId);
// LogSystem.LogInfo($"[测试] 成员离开: {memberName} ({memberId})");
// }
//
// private void OnHostChanged(CSteamID oldHost, CSteamID newHost)
// {
// string oldHostName = SteamFriends.GetFriendPersonaName(oldHost);
// string newHostName = SteamFriends.GetFriendPersonaName(newHost);
// LogSystem.LogInfo($"[测试] 房主变更: {oldHostName} -> {newHostName}");
// }
//
// private void OnLobbyError(string error)
// {
// LogSystem.LogError($"[测试] 房间错误: {error}");
// }
//
// #endregion
//
// #region P2P事件处理
//
// private void OnP2PPeerConnected(CSteamID peerId)
// {
// string peerName = SteamFriends.GetFriendPersonaName(peerId);
// LogSystem.LogInfo($"[测试] P2P连接建立: {peerName} ({peerId})");
// }
//
// private void OnP2PPeerDisconnected(CSteamID peerId)
// {
// string peerName = SteamFriends.GetFriendPersonaName(peerId);
// LogSystem.LogInfo($"[测试] P2P连接断开: {peerName} ({peerId})");
// }
//
// private void OnP2PMessageReceived(CSteamID senderId, byte[] data)
// {
// string senderName = SteamFriends.GetFriendPersonaName(senderId);
// string message = System.Text.Encoding.UTF8.GetString(data);
// LogSystem.LogInfo($"[测试] 收到P2P消息 from {senderName}: {message}");
// }
//
// private void OnP2PConnectionError(string error)
// {
// LogSystem.LogError($"[测试] P2P连接错误: {error}");
// }
//
// #endregion
//
// #region 测试方法
//
// // 创建测试房间
// public void CreateLobby()
// {
// if (!CanPerformLobbyAction()) return;
//
// LogSystem.LogInfo("[测试] 创建房间...");
// _lobby.CreateFriendsLobby(_maxLobbyMembers);
// }
//
// // 离开房间
// public void LeaveLobby()
// {
// if (!_lobby.IsInLobby())
// {
// LogSystem.LogWarning("[测试] 当前不在房间中");
// return;
// }
//
// LogSystem.LogInfo("[测试] 离开房间...");
// _lobby.LeaveLobby();
// }
//
// // 解散房间
// public void DisbandLobby()
// {
// if (!_lobby.IsInLobby())
// {
// LogSystem.LogWarning("[测试] 当前不在房间中");
// return;
// }
//
// if (!_lobby.IsLobbyOwner())
// {
// LogSystem.LogWarning("[测试] 只有房主可以解散房间");
// return;
// }
//
// LogSystem.LogInfo("[测试] 解散房间...");
// _lobby.DisbandLobby();
// }
//
// // 发送测试消息
// public void SendMessage()
// {
// if (!_lobby.IsInLobby())
// {
// LogSystem.LogWarning("[测试] 当前不在房间中,无法发送消息");
// return;
// }
//
// string testMessage = $"测试消息 from {CurrentUserName} at {System.DateTime.Now:HH:mm:ss}";
// GameNetSender.Instance.BroadcastString(testMessage);
// LogSystem.LogInfo($"[测试] 广播消息: {testMessage}");
// }
//
// // 显示在线好友
// public void ShowOnlineFriends()
// {
// if (!CanPerformLobbyAction()) return;
//
// var friends = _lobby.GetOnlineFriends();
// LogSystem.LogInfo($"[测试] 在线好友数量: {friends.Count}");
//
// foreach (var friend in friends)
// {
// LogSystem.LogInfo($"[测试] 好友: {friend.name} ({friend.id})");
// }
// }
//
// // 打开邀请界面
// public void OpenInviteOverlay()
// {
// if (!_lobby.IsInLobby())
// {
// LogSystem.LogWarning("[测试] 当前不在房间中,无法邀请好友");
// return;
// }
//
// LogSystem.LogInfo("[测试] 打开Steam邀请界面...");
// _lobby.OpenInviteOverlay();
// }
//
// private bool CanPerformLobbyAction()
// {
// if (!IsSteamInitialized)
// {
// LogSystem.LogError("[测试] Steam未初始化");
// return false;
// }
//
// if (!IsLoggedIn)
// {
// LogSystem.LogError("[测试] Steam用户未登录");
// return false;
// }
//
// return true;
// }
//
// #endregion
// }
// }

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 6d8d18aba4f448e095c425fe5a24a1c8
timeCreated: 1756803052

View File

@ -456,6 +456,8 @@ namespace TH1Renderer
private void UpdateRoad()
{
if (_gridData == null || _road == null || _mapData == null) return;
//如果当前的feature没有road
if (_gridData.Feature != TerrainFeature.Road)
{
@ -470,31 +472,31 @@ namespace TH1Renderer
return;
}
//TODO 最好把dir做成enum
for (int i = 1; i <= 9; i++)
{
int dirForHuman = i;
int dirForComputer = dirForHuman - 1;
var t = _road.transform.Find($"Road{dirForHuman}");
if (!_mapData.CheckIfNearByGridRoadCanConnenct(_gridData, dirForComputer))
if (!_mapData.CheckIfNearByGridRoadCanConnenct(_gridData, dirForComputer))
{
if (t != null)
GameObject.Destroy(t.gameObject);
continue;
}
if (t == null)
{
GameObject road = new GameObject($"Road{dirForHuman}");
SpriteRenderer sr = road.AddComponent<SpriteRenderer>();
ResourceCache.Instance.SpriteCache.Roads.TryGetValue((_gridData.Terrain == TerrainType.Land ? "Road" : "WaterRoad") + dirForComputer,out var tmpSprite);
ResourceCache.Instance?.SpriteCache?.Roads?.TryGetValue((_gridData.Terrain == TerrainType.Land ? "Road" : "WaterRoad") + dirForComputer,out var tmpSprite);
sr.sprite = tmpSprite;
road.transform.parent = _road.transform;
road.transform.localPosition = new Vector3(0, 0, 0);
}
}
}

View File

@ -231,8 +231,8 @@ namespace TH1Renderer
Sprite = param.Sprite;
VFXObject = param.VFXObject;
IsPlaying = false;
_renderer = VFXObject.GetComponent<SpriteRenderer>();
_transfrom = VFXObject.GetComponent<Transform>();
_renderer = VFXObject != null ? VFXObject.GetComponent<SpriteRenderer>() : null;
_transfrom = VFXObject != null ? VFXObject.GetComponent<Transform>() : null;
}
public virtual void SetPlay(GridVFXPlayType playType = GridVFXPlayType.PlayOnce)
@ -246,10 +246,11 @@ namespace TH1Renderer
public virtual void Play(GridVFXPlayType playType = GridVFXPlayType.PlayOnce)
{
if (VFXObject == null) return;
VFXObject.SetActive(true);
IsPlaying = true;
var animancer = VFXObject.GetComponent<AnimancerComponent>();
if (VFXAnim != null)
if (VFXAnim != null && animancer != null)
{
animancer.Play(VFXAnim);
//这里还要处理音频 TODO 将来配表化
@ -264,11 +265,10 @@ namespace TH1Renderer
if(playType is GridVFXPlayType.PlayOnce)
Timer.Instance.TimerRegister(VFXObject, () =>
{
//TODO 这里可能为null但不知道为什么 将来要排雷
VFXObject?.SetActive(false);
IsPlaying = false;
}, VFXAnim.length,"GridVFXRenderer_" + Type);
}
}
@ -285,7 +285,7 @@ namespace TH1Renderer
public virtual void Stop()
{
IsPlaying = false;
VFXObject.SetActive(false);
VFXObject?.SetActive(false);
}
public virtual void Update()
@ -480,7 +480,7 @@ namespace TH1Renderer
public override void Play(GridVFXPlayType playType = GridVFXPlayType.PlayOnce)
{
IsPlaying = true;
VFXObject.SetActive(true);
VFXObject?.SetActive(true);
}
}

View File

@ -117,7 +117,9 @@ namespace TH1_UI.View.Info
{
if (Pool.Count < 5 ) return;
if (Main.MapData?.PlayerMap?.SelfPlayerData == null) return;
var player = Main.MapData.PlayerMap.SelfPlayerData;
if (Table.Instance?.UnitTypeDataAssets == null) return;
//Step #1 先设置Pool的对象图样
bool firstSelect = true;