主题
玩家会话状态语义
本文面向框架用户,说明登录态、游戏连接、主动退出、断线、顶号、重连和随进随出重新加入之间的区别。它重点回答两个问题:
- 玩家到底还算不算在当前游戏局里?
- 玩法代码应该用哪些事件和状态判断玩家是暂时离线、已经离开,还是重新连回来了?
最重要的原则是:断线不等于退出游戏,退出登录也不等于退出游戏。 只有主动退出、放弃重连、被踢、游戏结束或平台明确移除局内归属时,玩家才应被当作已经离开本局。
四层状态
很多混淆来自把不同层的状态合成了一个“玩家不在了”。实际运行时至少有四层:
| 层级 | 说明 | 谁主要关心 | 玩法代码能否直接判断 |
|---|---|---|---|
| 客户端登录态 | 当前客户端是否仍持有平台账号登录。顶号会让旧客户端登录失效。 | 客户端、平台登录系统 | 通常不能。玩法服务器只会看到后续连接变化。 |
| 游戏连接态 | 客户端和 Host 的网络连接是否还活着。网络超时、客户端崩溃、顶号后的旧连接关闭都会影响它。 | Host、玩法服务器 | 可以,通过玩家连接/断线事件和 PlayerStatus 判断。 |
| 平台局内归属 | Lobby 是否认为该用户仍属于某个 game session。这决定下次是否能走重连路径。 | Lobby、重连流程 | 间接可见。玩法不要自己维护平台归属。 |
| 玩法槽位状态 | 当前 Player 槽位是否 Connected、Disconnected、Left,以及槽位上的单位、视野、资源是否还保留。 | gameplay / framework 用户 | 可以,通过 Player.IsOnline、EventPlayerStatusChanged、玩家事件判断。 |
同一个用户可以出现这样的组合:
text
客户端登录态:旧客户端已失效
游戏连接态:旧连接断开
平台局内归属:仍保留,可重连
玩法槽位状态:Disconnected这就是顶号、崩溃、网络断开后最容易误判的场景:旧客户端不再在线,但玩家并没有主动退出游戏。
状态语义速查
| 场景 | 玩法层通常看到 | 平台是否保留本局归属 | 下次是否走重连 | 说明 |
|---|---|---|---|---|
| 短暂网络抖动,未达到底层超时 | 通常无事件 | 保留 | 不需要重连 | 传输层可能自行恢复,玩法层不一定感知。 |
| 网络断开、客户端崩溃、进程被杀 | EventPlayerUserDisconnected,状态变为 Disconnected | 保留 | 是 | 这是“暂时离线”。不要当作主动退出。 |
| 账号在其他地方登录 | 旧客户端登录态失效;只有当 Host 侧游戏连接也断开、超时,或走 Entrance proxy 的 USER_DISCONNECT 链路时,玩法层才会收到 EventPlayerUserDisconnected | 保留 | 是 | 顶号不是主动退出游戏,也没有一个固定的“顶号后 N 秒必定断线”语义。旧客户端自身不会继续重连,但新登录端仍可能看到可重连局。 |
| 用户主动退出游戏 | EventPlayerUserActivelyQuit,状态变为 Left | 移除 | 否 | 这是明确离局。下次不应弹重连对话框。 |
| 用户取消/放弃重连 | EventPlayerUserAbandonReconnect,状态变为 Left | 移除 | 否 | 玩家明确表示不回到这局。 |
服务端 Player.Kick(...) | 客户端收到踢出提示;平台移除局内归属 | 移除 | 否 | 如果需要记录业务原因,应在调用 Kick 的项目逻辑中记录。公开断线事件不携带 kick reason。 |
| 游戏结束 | Game.OnGameEnd / 游戏结束流程 | 移除 | 否 | 游戏局结束后不再重连。 |
| 随进随出局主动退出后再加入 | 先离开旧归属,再通过开放局加入接口进入 | 退出后移除;再次加入时重新注册 | 否,走加入 | 这是“重新加入同一局”,不是“重连旧身份”。 |
重连对话框什么时候出现
重连对话框依赖平台仍然保存“这个用户属于某个未结束游戏局”的信息。下面这些情况会保留归属,因此下次进入大厅或启动流程时可能出现重连入口:
- 网络断开或客户端崩溃。
- 顶号导致旧客户端退出登录后,旧游戏连接也断开、超时,或通过 Entrance proxy 链路通知到 Host。
- 客户端进程被系统杀掉,Host 后续判定断线。
下面这些情况会移除归属,因此不应再弹重连对话框:
- 用户主动退出游戏。
- 用户在重连提示中选择放弃或取消重连。
- 服务端踢出玩家。
- 游戏局已经结束。
对随进随出局要特别区分:
- 重连:回到自己没有主动离开的旧槽位和旧局内状态。
- 重新加入:通过开放局/中途加入入口再次进入一个仍然存在的游戏局。
重新加入可以进到“原本那局”,但它不是重连路径。项目代码应把它当作一次新的进入流程处理。开放局加入 API 见 开放局加入系统。
时间判定
这些时间来自当前平台和 Host 行为,实际线上值以部署配置和地图配置为准。玩法代码不应把它们写死成业务规则。
| 时间 | 当前语义 | 默认值或来源 | 玩法侧建议 |
|---|---|---|---|
| 普通连接无包超时 | Host 多久没收到客户端包后先把 KCP 连接标为 paused / Unresponsive | Host 网络配置 base_config.netpeer_time_out_seconds,代码默认值为 10 秒,调试/部署可改 | 这一步在 WasiCore 里表现为 EventPlayerConnectionUnresponsive / PlayerStatus.Unresponsive / Player.IsConnectionUnresponsive。 |
| paused 连接超时 | KCP 连接已进入 paused 状态后,Host 最多继续保留多久 | 地图配置 connection_timeout_when_paused,默认 120 秒 | 超过这段时间仍未恢复,才进入真正断线流程,并触发 EventPlayerUserDisconnected。 |
| 全员离线后结束游戏局 | 所有真人玩家都离线一段时间后,普通游戏局可被 Host 结束 | 地图配置 end_game_when_offline_time,默认 300 秒;常驻局不按此自然结束 | 这是游戏局回收条件,不是单个玩家从 Disconnected 变 Left 的时间。 |
一名玩家断线后,并不会因为“过了 N 秒”自动在玩法层变成主动退出。是否清理他的局内资源,应该由项目自己的玩法规则决定;但如果你清理了实体、队伍席位或临时资源,就要确保重连回来时能恢复或给出明确的不可恢复处理。
paused 的底层判定标准是连接状态,不是玩家主观上“离开键盘”。KCP 连接在两种情况下会进入 paused:客户端显式发送 CE1PAUSE / CE1PAUSELOADING,例如应用切后台或少数仍调用真实 Connection.Pause() 的加载流程;或者 Host 在普通连接无包超时窗口内收不到包,当前默认约 10 秒后先把连接自动标为 paused,而不是立刻断线。进入 paused 后,Host 会触发 connection pause 流程;WasiCore 当前公开语义是 EventPlayerConnectionUnresponsive 和 PlayerStatus.Unresponsive。此时不会触发 EventPlayerUserDisconnected。只要后续收到任意有效 KCP 业务包,就会恢复为 connected,并映射为 EventPlayerConnectionRestored / PlayerStatus.Connected。只有 paused 状态继续超过 connection_timeout_when_paused,Host 才会真正断开连接并进入玩家断线流程。
顶号本身也不是一个 gameplay 断线计时器。当前实现里,旧 Entrance channel 会被通知“账号在其他地方登录”并关闭;如果该游戏连接是通过 Entrance proxy 转发给 Host,Entrance 注销会广播 USER_DISCONNECT,Host 收到后会很快进入玩家断线流程。这个路径主要受服务端调度和网络往返影响,Entrance 侧还安排了 3 秒后的兜底关闭。若当前游戏是客户端直连 Host 的连接,顶号不会直接等价于 Host 连接断开;只有旧客户端实际关闭游戏连接、进程退出、网络不再发包并达到 Host 网络超时,玩法层才会收到断线事件。
玩法中如何判断
服务端优先使用玩家事件和 PlayerStatus。常用事件见 玩家系统。
csharp
#if SERVER
using GameCore.Event;
using GameCore.GameSystem;
using GameCore.PlayerAndUsers;
using GameCore.PlayerAndUsers.Enum;
using Events;
using System.Collections.Generic;
public static class PlayerSessionStateExample : IGameClass
{
private static readonly HashSet<long> DisconnectedUsers = new();
public static void OnRegisterGameClass()
{
Game.OnGameTriggerInitialization += RegisterTriggers;
}
private static void RegisterTriggers()
{
new Trigger<EventPlayerUserDisconnected>(OnDisconnected).Register(Game.Instance);
new Trigger<EventPlayerUserConnected>(OnConnected).Register(Game.Instance);
new Trigger<EventPlayerConnectionUnresponsive>(OnConnectionUnresponsive).Register(Game.Instance);
new Trigger<EventPlayerConnectionRestored>(OnConnectionRestored).Register(Game.Instance);
new Trigger<EventPlayerUserActivelyQuit>(OnActivelyQuit).Register(Game.Instance);
new Trigger<EventPlayerUserAbandonReconnect>(OnAbandonReconnect).Register(Game.Instance);
new Trigger<EventPlayerStatusChanged>(OnStatusChanged).Register(Game.Instance);
}
private static void OnDisconnected(EventPlayerUserDisconnected e)
{
DisconnectedUsers.Add(e.User.UserId);
Game.Logger.LogInformation(
"玩家暂时断线: Player={PlayerId}, User={UserId}",
e.Player.Id,
e.User.UserId);
// 推荐:暂停只依赖在线玩家的逻辑、保留可恢复状态。
// 不推荐:立刻删除存档、结算失败、销毁不可恢复的关键对象。
}
private static void OnConnected(EventPlayerUserConnected e)
{
var wasDisconnected = DisconnectedUsers.Remove(e.User.UserId);
Game.Logger.LogInformation(
"玩家连接: Player={PlayerId}, User={UserId}, WasDisconnected={WasDisconnected}",
e.Player.Id,
e.User.UserId,
wasDisconnected);
// 当前公开事件不直接暴露底层 isReconnect。
// 如果需要业务上的“重连回来”,可用此前是否记录为断线来判断。
}
private static void OnConnectionUnresponsive(EventPlayerConnectionUnresponsive e)
{
Game.Logger.LogInformation(
"玩家连接进入 paused/Unresponsive: Player={PlayerId}, User={UserId}",
e.Player.Id,
e.User.UserId);
// 注意:这是底层连接 paused,不是真正的“用户挂机”。
// 可能来自切后台、加载流程,或约 10 秒无包后的自动 pause。
// 它不是主动退出,也不是正式断线。
}
private static void OnConnectionRestored(EventPlayerConnectionRestored e)
{
Game.Logger.LogInformation(
"玩家连接从 paused/Unresponsive 恢复: Player={PlayerId}, User={UserId}",
e.Player.Id,
e.User.UserId);
}
private static void OnActivelyQuit(EventPlayerUserActivelyQuit e)
{
DisconnectedUsers.Remove(e.User.UserId);
MarkUserLeft(e.Player, e.User.UserId, "actively quit");
}
private static void OnAbandonReconnect(EventPlayerUserAbandonReconnect e)
{
DisconnectedUsers.Remove(e.User.UserId);
MarkUserLeft(e.Player, e.User.UserId, "abandon reconnect");
}
private static void OnStatusChanged(EventPlayerStatusChanged e)
{
Game.Logger.LogInformation(
"玩家状态变化: Player={PlayerId}, {OldStatus} -> {Status}",
e.Player.Id,
e.OldStatus,
e.Status);
}
private static void MarkUserLeft(Player player, long userId, string reason)
{
Game.Logger.LogInformation(
"玩家离开本局: Player={PlayerId}, User={UserId}, Reason={Reason}",
player.Id,
userId,
reason);
// 这里再做最终离局清理、结算或释放不可重连资源。
}
}
#endif如果只需要判断当前是否在线,可以在服务端读:
csharp
#if SERVER
if (!player.IsOnline)
{
// Disconnected 或 Left 都可能不在线。
// 若需要区分,监听 EventPlayerStatusChanged,或读取 player.SlotController.Status。
}
#endif如果需要判断“当前是否处于底层 connection paused / 暂时失联”,现有公开 API 可直接读 player.IsConnectionUnresponsive,或者读 PlayerStatus.Unresponsive:
csharp
#if SERVER
if (player.IsConnectionUnresponsive)
{
// 当前 Host 连接处于 paused / 暂时失联状态。
// 注意:player.IsOnline 仍会把 Unresponsive 视为在线。
}
#endif设计建议
- 把
EventPlayerUserDisconnected当作“暂时失联”,不要直接当作战败、退出或结算完成。 - 主动退出、放弃重连、踢出、游戏结束才适合做最终离局清理。
- 需要保存 CloudData 时,使用
User.UserId,不要用Player.Id。Player.Id是本局槽位,不是持久用户标识。 - 断线期间应避免继续给该玩家发送必须即时确认的客户端消息;
PlayerController.SendMessage只会向Connected状态的真人玩家发送。 PlayerStatus.Unresponsive当前对应底层 connection paused / 暂时失联。它仍被Player.IsOnline视为在线,但PlayerController.SendMessage不会向Unresponsive状态发送。- 顶号和网络断开在公开玩法事件里都表现为断线。项目不要依赖公开事件区分二者。
- 如果项目允许玩家主动切换到另一个游戏局,并且旧局还会周期保存用户状态,阅读 同用户游戏局切换清理,避免旧局晚到写入覆盖新局状态。
- 如果项目需要玩家全部离线后服务器继续运行,使用 常驻游戏局,不要依赖普通离线保活时间。
用户文案建议
面向玩家时,尽量避免把平台技术状态直接暴露出来:
| 技术状态 | 推荐文案方向 | 避免 |
|---|---|---|
| 顶号 | “账号已在其他设备登录,本设备已退出登录。” | “你已退出当前游戏。” |
| 断线可重连 | “检测到上一局仍在进行,是否回到游戏?” | “你刚刚退出了游戏,是否重进?” |
| 主动退出后开放局再加入 | “加入游戏” | “重连游戏” |
| 放弃重连 | “放弃后本局将不再保留重连入口。” | “稍后仍可恢复原局。” |