普通视图

发现新文章,点击刷新页面。
昨天以前未分类

《恶魔轮盘改》项目完整实现详解:架构、流程与双端同步

作者 keyle xiao
2026年6月12日 10:53

归档说明:本文档概括当前项目的整体实现细节,涵盖游戏架构分层、流程控制原理、客户端单机实现、服务端权威实现、联机 FlowStep 同步协议及客户端消费层。内容自洽,可直接作为开发存档阅读,于2026.6.12日留存于博客备份。

目录

  1. 项目总览
  2. 整体架构分层
  3. GameCore 共享逻辑层
  4. 流程控制模块详解
  5. 客户端单机实现
  6. 服务端权威实现
  7. 联机 FlowStep 协议与同步模型
  8. 客户端联机消费层
  9. 双端对照与架构模式总结
  10. 扩展接入与调试验证
  11. 源码文件索引

1. 项目总览

《恶魔轮盘》是一款四人回合制卡牌博弈游戏,支持 Unity 客户端单机(人类 + 本地 AI)与 Unity WebGL / Standalone 联机(权威服务器 + 多人类 + AI 补位)两种运行模式。

技术栈概览:

层次技术职责
Unity 客户端C# + UGUI/IMGUI表现、输入、单机本地逻辑驱动
服务端ASP.NET Core + SignalR Hub登录、大厅、房间、权威对局
共享逻辑GameCore(Assets/Scripts 链接至服务端)规则、流程、技能、道具、AI 决策
协议Protobuf(server/proto/v1/客户端与服务端消息 DTO
持久化MySQL账号、排行榜;房间与对局在内存

核心设计目标:

  1. 规则只写一份FlowController 在 Unity 单机与服务端联机共用,避免双端逻辑漂移。
  2. 流程与表现分离:流程层只发 PresentationCueSpec 和快照,不直接操作 UI。
  3. 联机防作弊:随机数、伤害、道具数量均在服务端 GameSession 内结算;客户端只收 FlowStep 展示。
  4. 可中断协程流程:用 IEnumerator + yield 表达「等玩家决策」,单机用 Unity 协程,服务端用 CoroutineDriver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
flowchart TB
subgraph Client["Unity 客户端"]
GE[GameEntry]
DS[DebugService]
HUD[NormalGameUIInputView / NormalHud]
NET[OnlineSession + OnlineHudCoordinator]
GE -->|单机| DS
GE -->|联机| NET
DS --> HUD
NET --> HUD
end

subgraph Server["ASP.NET 服务端"]
HUB[GameHub SignalR]
GLS[GameLoopService]
SDH[ServerDebugHost]
FP[FlowPublisher]
HUB --> GLS
GLS --> SDH
SDH --> FP
FP --> HUB
end

subgraph Core["GameCore 共享"]
GS[GameSession]
FC[FlowController]
SK[SkillExecutor]
IT[ItemService]
FC --> GS
GS --> SK
GS --> IT
end

DS --> FC
SDH --> FC
HUB <-->|FlowStep / SubmitDecision| NET

2. 整体架构分层

2.1 模块职责边界

模块职责禁止事项
FlowController回合流转、五阶段状态机、响应链轮询、状态栈压栈/出栈不直接画 UI、不读 WebSocket
GameSession聚合玩家列表、枪膛、事件总线、技能执行器、回合指针不决定 UI 如何展示
IDebugHost决策输入、表现 cue 队列、快照推送(双端差异插槽)不修改游戏规则
SkillExecutor / ItemService响应事件、执行主动/被动技能、道具 CRUD不推进回合阶段
AIDecisionMaker / GameDebugAiResolver输出 PlayerDecision不直接改实体状态
FlowPublisher(仅服务端)合并 cue/战报/快照为 FlowStep 广播不含游戏规则
OnlineHudCoordinator(仅联机客户端)revision 排序、表现门禁、权威态/显示投影不跑 FlowController

2.2 服务端进程结构

1
2
3
4
5
6
7
NewRingGame.Server
├── Auth/ 登录、注册、Token 签发与校验
├── Lobby/ 在线玩家、房间列表
├── Room/ 房间生命周期、准备状态、AI 补位、自动开局
├── Game/ GameLoopService、ServerDebugHost、FlowPublisher、MatchStore
├── Connection/ WebSocket 连接映射、断线清理
└── Hubs/GameHub SignalR 实时消息入口

内存热数据(进程内字典,重启丢失):Session Token、ConnectionId→PlayerId、房间实例、进行中对局 MatchInstance(含 GameSession + seed)。MySQL 仅存账号与排行榜等冷数据。

2.3 客户端运行模式

GameEntry 通过三个布尔标志区分状态:

模式IsMatchRunningIsOnlineMatch流程引擎位置
单机正常/Debugtruefalse本地 Session.Flow.RunGameCoroutine()
联机对局truetrue不跑本地 FlowController,只消费 FlowStep
大厅/结算falsefalse

联机入口 BeginOnlineMatch 设置 IsOnlineMatch = true,由 NormalGameUIInputView.EnterOnlineMatch 初始化 HUD 与 OnlineHudCoordinator,等待服务端推送。

2.4 通信方式

  • HTTPS REST:登录(账号不存在则自动注册)、健康检查。
  • SignalR WebSocket(GameHub):大厅、房间、准备、对局 FlowStep 下行、SubmitDecision 上行。
  • Unity WebGL 必须使用 WebSocket(wss),因此服务端选用 SignalR 而非裸 TCP。

连接流程:客户端 POST /api/auth/login 获 Token → ConnectHub?access_token=xxxEnterLobby → 创建/加入房间 → 全员 Ready 后服务端 StartMatch → 广播 GameStarted + 首帧 FlowStep

3. GameCore 共享逻辑层

GameCore 即 Assets/Scripts 下无 UnityEngine 依赖(或 #if HEADLESS 隔离)的纯 C# 逻辑,服务端项目通过 csproj 链接同目录源码编译。

3.1 GameSession:对局会话聚合根

GameSession 持有单局全部可变状态与模块引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class GameSession
{
public Random Random { get; private set; }
public GameEventBus EventBus { get; } = new();
public GameStateStack StateStack { get; } = new();
public SkillExecutor SkillExecutor { get; } = new();
public ItemService ItemService { get; private set; }
public ChamberState Chamber { get; } = new();
public TurnPhase CurrentPhase { get; private set; }
public string TurnOwnerId { get; private set; }
public FlowController Flow { get; private set; }
public IDebugHost Debug { get; }

public GameSession(IDebugHost debug)
{
Debug = debug;
ItemService = new ItemService(SkillExecutor, Random);
AI = new AIDecisionMaker(this);
Flow = new FlowController(this);
}
}

SetupGame(seats, seed) 初始化流程:

  1. 注入 Random(seed)(联机可复现)或本地随机。
  2. 清空事件总线、状态栈、技能注册。
  3. SeatConfig 创建 PlayerEntity(座次、职业、ControlType、开局生命)。
  4. 注册全局被动(狂暴增伤、护身符格挡等)。
  5. 枪膛 Chamber.LoadRandom 装入实弹/空弹序列。
  6. 全员发放开局道具,批次写入 _startingDealBatches 供表现层发牌动画。

回合指针 _currentTurnIndex 按座次 0→1→2→3→0 顺时针流转;AdvanceTurn 跳过已淘汰玩家;仅剩一人存活时 SetGameOver

3.2 核心枚举

TurnPhase(五阶段)

1
TurnStart(0) → MainAction(1) → Judgment(2) → Discard(3) → TurnEnd(4)

FlowStateType(状态栈条目类型)

含义
TurnPhase常规阶段(栈调试描述用)
ShootResponse开枪响应窗口
DyingResponse濒死自救
TraitorChoice血量 1 点转职抉择

MainActionType:Shoot / UseItem / EndAction。

ControlType:Human / AI(断线时人类可切 AI 托管)。

3.3 GameStateStack:分层状态栈

1
2
3
4
5
6
7
8
public class GameStateStack
{
private readonly Stack<FlowStackEntry> _stack = new();
public FlowStackEntry Peek() => _stack.Count > 0 ? _stack.Peek() : null;
public void Push(FlowStackEntry entry) => _stack.Push(entry);
public FlowStackEntry Pop() => _stack.Count > 0 ? _stack.Pop() : null;
public int Count => _stack.Count;
}

规则:栈顶为当前唯一活跃流程;下层挂起保留协程断点;临时流程结算后 Pop 恢复。支持开枪响应内再嵌套濒死等多层场景。

3.4 事件总线与技能

GameEventBus 发布 GameEventType(TurnStart、ShootDeclared、DamageApplied、PlayerDying、ItemAfterUse 等)。角色技能与道具技能统一注册为 SkillExecutor 实例,被动技能订阅事件、主动技能由流程层在决策后调用 ExecuteActive

背叛者转职:玩家血量 1 且未抉择时进入 TraitorChoice 压栈;接受则 ConvertToTraitor 注销全部角色技能,仅保留开枪/道具基础权限。

3.5 PresentationCueRelay:权威表现指令

流程结算点不直接操作 HUD,而是构造 PresentationCueSpec 交给 IDebugHost.QueuePresentationCue

Cue 类型触发场景
Shoot开枪结算(含 bullet_type = FinalBullet)
ItemFly / StealHand / EjectDrink道具使用
HealthDelta / HitImpact血量变化
DealCard发牌(回合开始或被动奖励)
MagicianTrajectory魔术师弹道翻转
HideItemSlot道具消耗前隐藏栏位(供 ItemFly 捕获起点)

CanEmit 条件:session.IsNormalPlayMode && session.Debug != null。Debug 面板模式跳过表现 cue,加速测试。

4. 流程控制模块详解

4.1 模块定位

FlowController 是游戏顶层调度中枢:负责回合流转、五阶段推进、全局状态栈、事件广播、响应链轮询。对标三国杀式「主流程 + 插入响应窗口」机制。

4.2 对局主循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public IEnumerator RunGameCoroutine()
{
yield return PresentStartingDealsIfAny();

while (!_session.IsGameOver)
{
var player = _session.GetCurrentTurnPlayer();
if (player == null) { _session.SetGameOver(); break; }

_session.SetTurnOwner(player);
player.ShootLockedThisTurn = false;

yield return RunTurnCoroutine(player);
if (_session.IsGameOver) break;

_session.AdvanceTurn();
PushAuthoritativeHudSnapshot(FlowBoundaryKind.TurnChange);
}
}

开局 PresentStartingDealsIfAny:取出 _startingDealBatches,经 PresentationCueRelay.EmitDealCards 播发牌动画,WaitActionPresentation 等待结束后再刷新 HUD。

4.3 单回合五阶段

1
2
3
4
5
6
7
8
public IEnumerator RunTurnCoroutine(PlayerEntity player)
{
yield return RunTurnStartPhase(player);
yield return RunMainActionPhase(player);
yield return RunJudgmentPhase(player);
yield return RunDiscardPhase(player);
RunTurnEndPhase(player);
}

各阶段要点:

阶段行为玩家响应
TurnStart强化剂衰减、发回合道具(默认 2 件)、枪膛空则装弹、TurnStart 被动
MainAction循环:开枪 / 用道具 / 结束行动是(主行动决策)
Judgment濒死结算、血量 1 转职扫描是(濒死/转职)
Discard道具超上限则循环弃牌是(弃牌决策)
TurnEndTurnEnd 被动、清理临时标记
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
flowchart TD
A[TurnStart 发道具装弹] --> B[MainAction 主行动循环]
B --> C{操作类型}
C -->|Shoot| D[压栈 ShootResponse]
D --> E[顺时针响应轮询]
E --> F[Pop 栈 + ResolveShoot]
F --> G{目标濒死?}
G -->|是| H[HandlePlayerDying 压栈]
H --> B
G -->|否| B
C -->|UseItem| I[ExecuteUseItem 即时结算]
I --> B
C -->|EndAction| J[Judgment 裁决]
J --> K[Discard 弃牌]
K --> L[TurnEnd]
L --> M[AdvanceTurn]

4.4 主行动决策循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private IEnumerator RunMainActionPhase(PlayerEntity player)
{
_session.Debug.NotifyPhase(_session, TurnPhase.MainAction, player);
var ended = false;
var failedShootAttempts = 0;

while (!ended && player.IsAlive)
{
PlayerDecision decision = null;
yield return _session.Debug.RequestMainActionDecision(player, _session, d => decision = d);
if (decision?.MainAction == null) continue;

switch (decision.MainAction)
{
case MainActionType.Shoot:
yield return ExecuteShoot(player, decision.TargetPlayerId, ...);
yield return WaitActionPresentation();
break;
case MainActionType.UseItem:
yield return ExecuteUseItem(player, decision, ...);
yield return WaitActionPresentation();
break;
case MainActionType.EndAction:
ended = true;
break;
}
}
}

连续无法开枪达到上限(MaxFailedShootAttemptsBeforeAutoEnd)时自动结束主行动。

4.5 开枪完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private IEnumerator ExecuteShoot(PlayerEntity shooter, string targetId, Action<bool> onExecuted)
{
// 校验锁枪、目标、Peek 初始子弹
var ctx = new ShootContext { ShooterId, TargetId, InitialBullet, FinalBullet };

_session.StateStack.Push(new FlowStackEntry
{
StateType = FlowStateType.ShootResponse,
Description = "开枪响应",
Payload = ctx
});

_session.Publish(GameEventType.ShootDeclared);
yield return RunShootResponseWindow(shooter, ctx);
_session.StateStack.Pop();
yield return ResolveShoot(shooter, target, ctx);
}

响应轮询规则RunShootResponseWindow):

  • 起点:当前回合玩家的顺时针下家。
  • 依次询问存活玩家;仅 魔术师且道具数 ≥ MagicianResponseMinItems 时轮询(ShouldPollShootResponse)。
  • 发动「弹道」主动技能则翻转 ctx.FinalBullet,播 MagicianTrajectory cue。

结算规则ResolveShoot):

  • Chamber.PopNext 出弹;实弹则 DamageAboutToApply 事件链 → 可能护身符格挡 → EnhancementItemService.ApplyDamage
  • 锁枪规则:自射空弹不锁;对他人开枪或任意实弹均锁枪(ShootLockedThisTurn = true)。
  • 实弹击杀:背叛者累计 TraitorKills;目标 Health≤0 进入 HandlePlayerDying
  • 结算后 PresentationCueRelay.EmitShootResolve(bullet 用 FinalBullet,非初始 Peek 值)。

4.6 濒死与转职

濒死HandlePlayerDying):压栈 DyingResponseRequestDyingDecision → 可选奶茶/道具自救 → 仍 ≤0 则清空道具并 Eliminate

转职HandleTraitorChoice):压栈 TraitorChoiceRequestTraitorDecision → 接受则 ConvertToTraitor

主行动中 ExecuteUseItem 后可调用 ProcessImmediateDying 即时处理濒死,不等到 Judgment 阶段。

4.7 弃牌阶段

RunDiscardPhase:当 player.ItemCount > player.GetItemCap() 时循环 RequestDiscardDecision,移除选中道具并记录战报。

4.8 PushAuthoritativeHudSnapshot 条件编译

1
2
3
4
5
6
private void PushAuthoritativeHudSnapshot(FlowBoundaryKind boundary = FlowBoundaryKind.ActionSettle)
{
#if HEADLESS
_session.Debug.PushAuthoritativeSnapshot(_session, boundary);
#endif
}

单机 Unity 编译时不调用(本地 HUD 由 DebugService.RefreshHud 驱动);服务端 HEADLESS 编译时每次行动/阶段/回合边界推送 FlowStep。

4.9 IDebugHost 接口契约

流程层通过以下方法与宿主交互:

方法用途
RequestMainActionDecision主行动:开枪/道具/结束
RequestShootResponseDecision开枪响应(魔术师弹道)
RequestDyingDecision濒死自救
RequestTraitorDecision转职抉择
RequestDiscardDecision超限弃牌
WaitActionPresentation等待表现动画结束
QueuePresentationCue入队表现 spec
PushAuthoritativeSnapshot联机推送权威快照(服务端)
LogEvent战报文本
NotifyPhase / RefreshHud阶段与 HUD 元数据

5. 客户端单机实现

5.1 启动链路

1
2
3
4
5
6
7
8
// GameEntry.Start
Session = new GameSession(new DebugService());

// GameEntry.RunMatchCoroutine
Session.IsNormalPlayMode = LastPlayMode != PlayModeKind.Debug;
Session.HideOpponentHandItems = LastPlayMode == PlayModeKind.Master;
Session.SetupGame(seats, null);
yield return Session.Flow.RunGameCoroutine();

DebugService 实现 IDebugHost:承担 IMGUI Debug 面板或 NormalHud 的决策 UI、本地 cue 协程队列、战报日志。

5.2 决策路径

DebugService.RequestDecision 分支:

  1. AI 或托管AiDelegation.ShouldAutoDecide)→ ExecuteAiDecisionAIDecisionMaker 立即返回。
  2. 人类WaitHumanDecision:设置 _waitingHuman = true,UI 展示选项;玩家调用 SubmitHumanDecision(decision) 后协程继续。

Debug 模式支持控制台指令(shoot player2 | use item1 | end);正常模式由 NormalGameUIInputView 渲染按钮与道具栏。

5.3 表现播放

DebugService.QueuePresentationCue

  1. PresentationCuePlayback.TryBuildFromSpec 将 spec 转为 Unity 协程。
  2. 入队 _presentationCueQueue
  3. WaitActionPresentation 顺序 Dequeue 并 yield return 每个 cue 协程。
  4. 播放完毕后 FlushPendingHealthDeltas 等收尾。

单机无 revision 概念;流程 yield 等待本地动画自然结束后再开下一决策窗。

1
2
3
4
5
6
7
8
9
flowchart LR
FC[FlowController] --> PCR[PresentationCueRelay]
FC -->|Request*Decision| DS[DebugService]
PCR -->|QueuePresentationCue| DS
DS --> PCP[PresentationCuePlayback]
PCP --> FX[NormalHudPresentationFx]
DS --> HUD[NormalGameUIInputView]
HUD -->|SubmitHumanDecision| DS
DS --> FC

5.4 玩法模式差异

PlayModeKind表现手牌可见性
SimpleNormalHud,无 Debug 面板他人手牌可见
MasterNormalHudHideOpponentHandItems=true,他人道具栏显示卡背
Debug完整 Debug IMGUI + 可选 AI 托管全信息

6. 服务端权威实现

6.1 对局启动:GameLoopService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void StartMatch(RoomModel room, GameStarted started)
{
var debugHost = new ServerDebugHost();
var session = new GameSession(debugHost)
{
IsNormalPlayMode = true,
HideOpponentHandItems = room.PlayMode == PlayMode.Master
};
var seatConfigs = RoomMatchSeatBuilder.Build(room, _connections, _matchOptions);
session.SetupGame(seatConfigs, started.Seed);

var match = new MatchInstance { MatchId, RoomId, Session, DebugHost, Seed, ... };
var publisher = new FlowPublisher(match, buildSnapshot, BroadcastFlowStepAsync);
match.FlowPublisher = publisher;
debugHost.FlowPublisher = publisher;

_matches.Add(match);
_ = RunMatchAsync(match);
}

RunMatchAsync

1
2
3
4
5
6
await match.FlowPublisher.PublishMatchStartAsync();
await CoroutineDriver.RunAsync(
match.Session.Flow.RunGameCoroutine(),
onIdle: null,
match.Cancellation.Token);
await FinishMatchAsync(match); // 排行榜计分 + GameOver 广播

6.2 MatchInstance 与 MatchStore

MatchInstance 持有:GameSessionServerDebugHostFlowPublisherRoomModelSeedCancellationTokenSource

PresentationSync 锁 + PresentationPipeline Task 链:串行化 FlowStep 广播,避免并发 Commit 乱序。

MatchStore 维护 MatchId / PlayerId / RoomId 三向索引,供 Hub 路由决策与断线处理。

6.3 ServerDebugHost:决策与推送

AI 即时决策

1
2
3
4
5
6
7
8
if (player.Control == ControlType.AI || AiDelegation.ShouldAutoDecide(player))
{
var ai = GameDebugAiResolver.Resolve(session, player, kind, shoot);
if (session.IsNormalPlayMode)
yield return CoroutineDelay.Seconds(AiThinkPauseSeconds); // 0.8s
onComplete(ai);
yield break;
}

人类等待 WebSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BeginWait(player, session, kind, shoot);  // 设置 PendingDecision
_ = FlowPublisher?.PublishDecisionOpenAsync();

while (_waiting)
{
if (超时 DecisionTimeoutMs) // 30s
{
_resolvedDecision = GameDebugAiResolver.Resolve(...);
_ = FlowPublisher?.PublishDecisionCloseAsync();
break;
}
yield return null;
}
onComplete(_resolvedDecision);

TrySubmitDecision(Hub 调用链:GameLoopService.TrySubmitDecision):

  • 校验 PendingDecision.Scene 与 payload 一致。
  • 校验 player_id 为当前等待者。
  • 主行动用道具时服务端预校验 MainActionRules.CanUseItem
  • 写入 _resolvedDecision_waiting = falsePublishDecisionCloseAsync

表现等待

1
2
3
4
5
6
public IEnumerator WaitActionPresentation(GameSession session)
{
var holdMs = FlowPublisher.LastPresentationHoldMs;
if (holdMs <= 0) yield break;
yield return CoroutineDelay.Seconds(holdMs / 1000f);
}

PushAuthoritativeSnapshot 映射 boundary 到 Publish 方法:

FlowBoundaryKindPublish 方法
ActionSettlePublishActionSettleAsync(含 hold)
TurnChangePublishTurnChangeAsync
PhaseChangePublishPhaseChangeAsync
DecisionOpenPublishDecisionOpenAsync
DecisionClosePublishDecisionCloseAsync
MatchStartPublishMatchStartAsync

6.4 FlowPublisher:revision 合并广播

核心约束:同一逻辑步内,先 BufferCue / BufferEvent,再 Publish,保证 cue 与 snapshot 同一 revision

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private Task PublishAsync(ProtoFlowBoundary boundary, bool includeHold)
{
_revision++;
var holdMs = includeHold ? PresentationHoldCalculator.ComputeMs(_bufferedCueSpecs) : 0;
LastPresentationHoldMs = holdMs;

var step = new FlowStep
{
Revision = _revision,
Boundary = boundary,
PresentationHoldMs = holdMs
};
// 填充 cues(带 flow_revision、cue_index)
// 填充 events(战报 GameEventLine)
// 填充 snapshot(GameProtoMapper.ToSnapshot,per-viewer 大师模式掩码)

_bufferedCueSpecs.Clear();
_bufferedEvents.Clear();
// PresentationPipeline 串行 await _broadcast
return _match.PresentationPipeline;
}

BuildResyncStep(viewerPlayerId):断线重连用,不递增 revision,boundary = MATCH_RESYNC。

大师模式(Master):BroadcastFlowStepPerViewerAsync 对每个真实玩家单独克隆 Step,掩码他人手牌事件文本与 cue,重建 per-viewer snapshot。

6.5 CoroutineDriver:无 Unity 协程运行时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static async Task RunAsync(IEnumerator root, Func<Task>? onIdle, CancellationToken ct)
{
var stack = new Stack<IEnumerator>();
stack.Push(root);
while (stack.Count > 0)
{
if (current.Current is IEnumerator nested) { stack.Push(nested); continue; }
if (current.Current is CoroutineDelay delay)
{
await Task.Delay(delay.Milliseconds, ct);
continue;
}
await Task.Delay(100, ct); // yield return null → 100ms,供 WS 决策与超时检测
}
}

6.6 断线、重连、超时

事件服务端行为
人类断线HandleDisconnectentity.Control = AI,广播最新 snapshot
重连ResyncPlayerAsync:恢复 Human 控制,单播 MatchResynced(含完整 FlowStep + RoomUpdated)
决策 30s 超时AI 代打,LogEvent [AI] 决策超时,PublishDecisionClose
对局异常不广播 GameOver(避免多人存活误弹结算),Remove match,房间回 Waiting

6.7 PresentationHoldCalculator(双端共用)

Cue 类型估算时长
Shoot2800ms
ItemFly1200ms
StealHand1600ms
EjectDrink1400ms
HealthDelta / HitImpact1150ms
MagicianTrajectory2000ms
DealCard800ms × 张数
HideItemSlot50ms
合计上下限400ms ~ 6000ms

7. 联机 FlowStep 协议与同步模型

7.1 设计优先级

优先级层级职责
1流程流转回合/阶段/决策权何时转移;服务端 gate 下一决策
2状态同步血量、道具、枪膛、PendingDecision 等权威 HUD 数据
3特效推送开枪、道具飞行、回血/掉血飘字等纯表现

核心原则:同一逻辑步使用同一个 revision,cue 与 snapshot 捆绑下发,客户端按序处理。

7.2 FlowStep 消息结构

1
2
3
4
5
6
7
8
message FlowStep {
int64 revision = 1;
FlowBoundaryKind boundary = 2;
GameStateSnapshot snapshot = 3;
repeated PresentationCue cues = 4;
repeated GameEventLine events = 5;
int32 presentation_hold_ms = 6;
}

GameStateSnapshot 主要字段:

字段含义
match_id / turn_owner_id / phase对局与回合上下文
flow_state状态栈顶描述(调试/Intel)
chamber枪膛剩余、已知下一发、已消耗序列
players[]各玩家血量、道具栏、ControlType、shoot_locked 等
pending_decision当前等待决策的玩家、scene、timeout、wait_started_at
revision / boundary与 FlowStep 对齐

PlayerDecisionPayload 上行字段:scene、main_action、target_player_id、item_instance_id、use_shoot_response_skill、use_dying_save、accept_traitor_conversion、items_to_discard 等。

Hub 消息种类:HUB_MESSAGE_KIND_FLOW_STEP = 111;重连 MatchResynced 含完整 FlowStep state(boundary = MATCH_RESYNC)。

旧版分通道 game_state_snapshot / presentation_cue / game_event 仍保留解析,FlowStep 启用后对局内应忽略(OnlineSession 丢弃 stale legacy 消息)。

7.3 FlowBoundaryKind 与客户端行为

boundary含义客户端行为
ACTION_SETTLE一次行动结算完成播 cues → 应用 snapshot;服务端按 hold 等待
TURN_CHANGE回合切换Abort 积压特效 → 全量刷新 HUD
PHASE_CHANGE阶段切换同上
DECISION_OPEN打开决策窗应用 snapshot + 展示决策 UI;不 abort 特效
DECISION_CLOSE提交/关闭决策关闭决策 UI;等后续 ACTION_SETTLE
MATCH_START对局开始Abort + 初始化 HUD
MATCH_RESYNC断线重连Abort + 直接应用 resync snapshot

7.4 服务端推送流水线

1
2
3
4
5
FlowController 结算点
├─ PresentationCueRelay.Emit* → ServerDebugHost.QueuePresentationCue → FlowPublisher.BufferCue
├─ LogEvent → FlowPublisher.BufferEvent
└─ PushAuthoritativeSnapshot → FlowPublisher.Publish*Async
└─ 合并 → FlowStep → SignalR 广播

决策推送:人类进入等待发 DECISION_OPEN(含 pending_decision);提交或超时发 DECISION_CLOSE。不再每 300ms 轮询推全量快照。

7.5 联机对局配置(OnlineMatchOptions)

配置默认说明
AiFillMax3AllowAiFill 开局最多补位 AI 数
AiStartingHealth2AI 开局生命(人类仍为 3)

固定四角座位(seatIndex 0–3),UI 旋转使本机永远在左上;空座隐藏即可。

8. 客户端联机消费层

联机客户端不运行 FlowController,职责是:按 revision 顺序接收 FlowStep → 更新权威态 → 播放 cue → 提交显示投影 → 在 DECISION_OPEN 时展示决策 UI → 上行 SubmitDecision。

8.1 组件链路

1
2
3
4
5
6
7
8
9
10
11
12
13
OnlineSession(Hub 消息入口)
└─ OnlineHudCoordinator(统一协调)
├─ OnlineFlowOrchestrator(revision 排序 + Presenting 门禁)
├─ OnlineHudState(权威态 vs 显示投影)
└─ NormalGameUIInputView.DispatchOnlineFlowStep
├─ 硬边界? → AbortQueuedPresentation + 清 gate
├─ BeginFlowStep + EnqueueCue(HideItemSlot 前捕获飞行起点)
├─ ApplyOnlineSnapshotData(写权威态)
├─ DecisionOpen/Close → 立即刷新决策 UI
├─ events → OnlineBattleLogStore 战报
└─ 表现 idle → CommitDisplay → NotifyPresentationIdle
└─ OnlineMatchPresentationPlayer(cue 队列 + hold 计时)
└─ PresentationCuePlayback → NormalHudPresentationFx

8.2 OnlineFlowOrchestrator

  • Enqueue(step, dispatch):revision ≤ lastApplied 丢弃;乱序入 SortedList 缓冲。
  • dispatch 返回 true → 设置 _presentationGated,暂停后续派发。
  • NotifyPresentationIdle → 清 gate,继续 TryDispatchSequential
  • ApplyResync / FastForwardToRevision:重连或追帧用。

8.3 OnlineHudState:双态模型

状态变量用途
权威态_authoritative + _authoritativePlayers决策合法性、Intel、PendingDecision
显示投影_display实际渲染 HUD 的快照
待提交_pendingFullCommit表现 busy 时排队,idle 后 CommitDisplay

表现播放中:权威态立即更新,显示投影延后,避免动画未播完道具栏已刷新导致穿帮。

OnlineHudCoordinator.ShouldDeferHudCommit 统一判定:决策边界不延后;硬边界不延后;表现 busy / orchestrator gated / 有待提交时延后。

8.4 客户端消费状态机

状态行为
IdleOrchestrator 可派发下一 revision
Presenting播放 cue 和/或 hold 计时;门禁后续 Step;权威态更新,显示排队
Applying表现 idle → CommitDisplay → NotifyPresentationIdle

硬边界(TurnChange / PhaseChange / MatchStart / MatchResync)调用 AbortQueuedPresentation,清 gate,立即全量刷新。

8.5 DispatchOnlineFlowStep 处理顺序

  1. 判定 ShouldAbortPresentation → abort + 清 pending commit。
  2. BeginFlowStep:HideItemSlot cue 前 TryCaptureItemFlyStart
  3. Cue 入队 OnlineMatchPresentationPlayer
  4. SetAuthoritative(snapshot) 写权威态。
  5. DecisionOpen/Close 边界立即刷新决策 UI(不能等表现 idle,否则「提交中」状态卡住)。
  6. events 追加战报。
  7. 按规则 QueueFullCommit 或立即 CommitDisplay。

8.6 道具栏与 ItemFly 策略

  • 快照 players[].items 为权威数量。
  • 处理顺序:先 cue 入队,再写权威态;表现 busy 时不重建道具栏 DOM。
  • _onlinePresentationHiddenItems 仅本机 optimistic hide;观察他人以 snapshot 为准。
  • 发牌 pendingReveal 有 12s fallback(TryFinalizeOpeningDealPresentation)。

8.7 开枪特效规则

  • 权威子弹类型:PresentationCue.bullet_type = ctx.FinalBullet(魔术师翻转后的最终子弹)。
  • 同一 FlowStep 内 Shoot cue 入队成功后,禁止用战报 regex 二次播放。
  • 实弹 + 护身符:Shoot 仍播实弹动画,另播护身符反馈 cue,不播 HealthDelta。

8.8 重连 MatchResynced

1
2
3
4
Hub 收到 MatchResynced
→ QueueOnlineResync(EnterOnlineMatch 前暂存)
→ PrepareOnlineResync:Abort 表现 + BattleLog.ReplaceFromFlowStep + ApplyResync
→ EnterOnlineMatch 时若有 pending:从 state.events 重建战报

服务端 ResyncPlayerAsync 同时恢复 ControlType.HumanPublishSnapshotAsync 广播给他人。

8.9 双通道兼容

通道入口FlowStep 启用后
FlowStepFlowStepReceived主路径
legacy snapshotSnapshotReceived表现 busy 时 partial;校验 IsStaleLegacySnapshot
legacy cue/eventPresentationCueReceivedOnlineSession 丢弃,防重复

OnlineSession.UsesFlowStepChannel:收到首帧 FlowStep 后切换,丢弃 stale legacy。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sequenceDiagram
participant FC as FlowController
participant SDH as ServerDebugHost
participant FP as FlowPublisher
participant Hub as SignalR
participant OS as OnlineSession
participant OHC as OnlineHudCoordinator
participant HUD as NormalGameUIInputView

FC->>SDH: RequestMainActionDecision
SDH->>FP: PublishDecisionOpen
FP->>Hub: FlowStep rev=N boundary=DECISION_OPEN
Hub->>OS: FlowStepReceived
OS->>OHC: EnqueueFlowStep
OHC->>HUD: 展示决策 UI
HUD->>Hub: SubmitDecision
Hub->>SDH: TrySubmitDecision
SDH->>FP: PublishDecisionClose
FC->>FC: ExecuteShoot
FC->>SDH: QueuePresentationCue + PushSnapshot
SDH->>FP: PublishActionSettle hold=2800ms
FP->>Hub: FlowStep rev=N+1 cues+snapshot
Hub->>OHC: EnqueueFlowStep
OHC->>HUD: 播放开枪 + 应用权威态
FC->>SDH: WaitActionPresentation 2.8s
FC->>SDH: RequestMainActionDecision

9. 双端对照与架构模式总结

9.1 实现对照表

维度客户端单机服务端联机客户端联机
流程引擎本地 FlowController同一份 FlowController不运行
宿主DebugServiceServerDebugHostOnlineHudCoordinator
决策IMGUI/NormalHudWS SubmitDecision同左,经 Hub 上行
AIAIDecisionMakerGameDebugAiResolver无(服务端算)
表现本地 cue 协程cue 进 FlowStepOnlineMatchPresentationPlayer
快照RefreshHud 本地FlowPublisher → protoApplyOnlineSnapshotData
随机数本地 Randomseed 注入不使用本地 Random
协程Unity StartCoroutineCoroutineDriverUnity 仅播表现

9.2 三种架构模式组合

  1. 分层状态机 + 状态栈:主回合五阶段 + 开枪/濒死/转职插入流程。
  2. 协程脚本化工作流yield return Request*Decision 表达异步人机交互;服务端 CoroutineDriver 移植。
  3. 权威服务器 + 捆绑同步:逻辑步 = FlowStep(revision, boundary, snapshot, cues, events, hold)。

9.3 联机客户端不跑 FlowController 的原因

  1. 防作弊:Random、伤害、道具必须以服务端为准。
  2. 单点真相:避免客户端先算再上报的冲突。
  3. 表现可慢不可错:动画可延长,revision 严格单调。

10. 扩展接入与调试验证

10.1 新增动作接入步骤

  1. 流程层(GameCore):在 FlowController 结算点调用 PresentationCueRelay.Emit*;新决策场景扩展 IDebugHost.Request*Decision
  2. 表现时长PresentationCueKind + PresentationHoldCalculator.EstimateCueMs
  3. 协议:proto PresentationCueKind + PresentationCueMapper.ToProto
  4. Unity 表现PresentationCuePlayback.TryBuildFromSpec 新分支。
  5. 不要单独广播 snapshot/cue/event;统一 PushAuthoritativeSnapshot 一次 Commit。

10.2 调试要点

  • 观察 FlowStep.revision 是否严格单调递增。
  • 特效丢失:检查 cue 是否为空、TryBuildFromSpec 是否 false。
  • 道具数量错乱:检查 snapshot.items 与是否误用 hidden 集合过滤他人。
  • 服务端:确认 BufferCue 在 Publish 之前完成。

10.3 自动化测试

客户端 FlowSync 单元测试(无需启动服务端):

1
2
cd server/tools/ProtoSmokeTest
dotnet run -- --flow-sync-unit

覆盖:OnlineFlowOrchestrator 排序/门禁、OnlineHudState 显示延后、OnlineFlowSync 规则。

联机对局时序冒烟(需 API + 自动对局):

1
2
cd server/tools/ProtoSmokeTest
dotnet run -- --match-flow --minutes 8 http://127.0.0.1:8080 用户名 密码

校验:revision 连续、bundle 内 snapshot/cue revision 对齐、ActionSettle 含 cue 时 hold>0、无 legacy 分通道消息。

Proto 代码生成:

1
cd server && ./scripts/generate-proto.sh

11. 源码文件索引

GameCore(Assets/Scripts,服务端链接编译)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Flow/FlowController.cs           流程主控:五阶段、响应链、压栈
Flow/GameSession.cs 对局会话聚合
Flow/PresentationCueRelay.cs 权威表现 cue 发射
Flow/PresentationHoldCalculator.cs 表现时长估算
Flow/FlowBoundaryKind.cs 边界枚举(与 proto 对齐)
Core/GameStateStack.cs 分层状态栈
Core/GameEnums.cs TurnPhase, FlowStateType, MainActionType
Core/GameEventBus.cs 全局事件总线
Debug/IDebugHost.cs 双端宿主接口
Debug/DebugService.cs Unity 单机宿主
Character/PlayerEntity.cs 玩家实体
Item/ItemService.cs 道具服务
Skill/SkillExecutor.cs 技能执行
AI/AIDecisionMaker.cs 单机 AI 决策
Game/GameEntry.cs 单机/联机入口

服务端(server/src/NewRingGame.Server)

1
2
3
4
5
6
7
8
9
Services/Game/GameLoopService.cs     对局启动、决策路由、断线重连
Services/Game/ServerDebugHost.cs IDebugHost 服务端实现
Services/Game/FlowPublisher.cs FlowStep 合并广播
Services/Game/CoroutineDriver.cs 无 Unity 协程驱动
Services/Game/GameProtoMapper.cs GameSession → proto snapshot
Services/Game/GameDecisionMapper.cs proto ↔ PlayerDecision
Services/Game/MatchModels.cs MatchInstance、MatchStore
Services/Game/MasterModePrivacy.cs 大师模式掩码
Hubs/GameHub.cs SignalR 入口

协议

1
2
3
server/proto/v1/game.proto           FlowStep、GameStateSnapshot、PlayerDecisionPayload
server/proto/v1/enums.proto FlowBoundaryKind、HubMessageKind
server/proto/v1/room.proto 房间与座位

联机客户端(Assets/Scripts/Network + UI)

1
2
3
4
5
6
7
8
9
10
11
12
Network/OnlineSession.cs             Hub 连接、消息分发
Network/OnlineHudCoordinator.cs 协调 Orchestrator + HudState
Network/OnlineFlowOrchestrator.cs revision 排序与门禁
Network/OnlineHudState.cs 权威态/显示投影双态
Network/OnlineFlowSync.cs 边界判定与延后规则
Network/OnlineFlowChannel.cs FlowStep/legacy 通道切换
Network/OnlineBattleLogStore.cs 战报存储
Network/GameHubClient.cs SignalR 客户端
UI/NormalGameUIInputView.Online.cs 联机 FlowStep 派发、决策 UI
UI/OnlineMatchPresentationPlayer.cs cue 队列与 hold
UI/PresentationCuePlayback.cs cue spec → 动画协程
UI/NormalHudPresentationFx.cs HUD 特效实现

文档版本:GameCore 共用 FlowController + FlowStep 联机方案。归档日期:2026-06。

.net core服务发布经验总结

作者 keyle xiao
2026年6月12日 10:13

项目:NewRingGame(Unity WebGL + .NET 7 API)
环境:CentOS 7、宝塔面板、同机部署
WebGL:https://mini.vrast.cn/
API:https://server.vrast.cn
部署目录:/www/wwwroot/ringgame

一、背景

《恶魔轮盘》联机版采用 Unity WebGL 客户端 + .NET 7 服务端 架构。首次上生产时,WebGL 与 API 部署在同一台 CentOS 7 服务器,通过宝塔面板管理 Nginx、进程与 FTP。

联调过程中,HTTP 登录接口已经可用,但游戏界面长期停在 「连接 Hub…」,无法进入在线大厅。本文记录从部署到联机打通的完整排查过程与最终解法。

二、部署阶段:服务器与环境

2.1 .NET 7 运行时(CentOS 7)

CentOS 7 官方源没有 .NET 7 包,不能照搬 Ubuntu 的 apt install

做法: 使用微软安装脚本安装 ASP.NET Core 7 运行时:

1
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --runtime aspnetcore --channel 7.0

发布包中附带 install-dotnet-runtime.shrun-prod.sh,便于在宝塔里一键启动。

2.2 宝塔启动命令写法

错误示例: 把环境变量写在整条命令最前面,宝塔进程守护可能解析失败。

1
ASPNETCORE_ENVIRONMENT=Production nohup dotnet ...

推荐:

  • 宝塔「启动文件」填:/www/wwwroot/ringgame/run-prod.sh
  • 或 systemd:ExecStart=/usr/bin/dotnet /www/wwwroot/ringgame/NewRingGame.Server.dll
  • 环境变量在 run-prod.sh 或 systemd Environment= 中设置

2.3 FTP 上传 553 Permission denied

宝塔站点目录属主多为 www:www,权限 drwxr-xr-x,FTP 用户不在 www 组时无法写入。

做法:

1
2
3
4
chmod 775 /www/wwwroot/ringgame
chown -R www:www /www/wwwroot/ringgame
# 将 FTP 用户加入 www 组
usermod -aG www <ftp用户名>

GM 面板发布默认 FTP 主机 server.vrast.cn,远端目录 /www/wwwroot/ringgame

2.4 MySQL SSL 握手失败

宝塔本地 MySQL 连接时,若未配置 SSL,.NET 驱动可能报 SSL 相关错误。

做法: 连接串增加:

1
SslMode=None;

三、服务端代码与配置

3.1 反向代理与 CORS

Nginx 终止 HTTPS 后,Kestrel 收到的是 HTTP 请求,需正确识别客户端 IP 与原始协议。

Program.cs 已配置:

  • UseForwardedHeaders():识别 X-Forwarded-ForX-Forwarded-Proto
  • 生产环境启用 CORS,允许 WebGL 站点跨域带凭证访问

3.2 客户端网络配置

Assets/Resources/Network/NetworkSettings.json 生产段示例:

1
2
3
4
5
6
7
{
"release": {
"host": "server.vrast.cn",
"port": 443,
"useTls": true
}
}

派生地址:

  • API:https://server.vrast.cn/api
  • Hub:wss://server.vrast.cn/hub/game?access_token={token}

3.3 登录与 Hub 鉴权流程

  1. POST /api/auth/login → 返回 JWT token
  2. WebSocket 连接 /hub/game?access_token=...
  3. GameHub.OnConnectedAsync 校验 token,失败则 Context.Abort()

因此:登录 200 只说明 HTTP 正常;卡在「连接 Hub…」说明 WebSocket 阶段有问题。

四、联机故障:「连接 Hub…」

4.1 现象

项目状态
GET /health200
POST /api/auth/login200,有 token
CORS 预检正常,Access-Control-Allow-Origin: https://mini.vrast.cn
游戏界面停在「连接 Hub…」
DevTools → Network → Socket无连接,或 Unity 侧无后续日志

Console 可见 [GameHub] 连接 wss://server.vrast.cn/hub/game,但无「握手成功」或明确报错。

4.2 分层排查

第一层:服务端 HTTP 是否正常

1
2
3
4
curl -s https://server.vrast.cn/health
curl -s -X POST https://server.vrast.cn/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"secret123"}'

第二层:浏览器原生 WebSocket 是否正常

https://mini.vrast.cn/ 控制台执行(替换真实 token):

1
2
3
4
const ws = new WebSocket("wss://server.vrast.cn/hub/game?access_token=你的token");
ws.onopen = () => console.log("WS 成功");
ws.onerror = (e) => console.log("WS 失败", e);
ws.onclose = (e) => console.log("WS 关闭", e.code);

若输出 「WS 成功」,则 Nginx 反代与 Hub 均已正常,问题在 Unity WebGL 客户端

第三层:Unity 客户端

WebGL 上 System.Net.WebSockets.ClientWebSocket 不可靠;配合 ConfigureAwait(false)Task.Run 会导致异步回调无法继续,界面永久停在「连接 Hub…」。

五、Nginx 配置(宝塔)

5.1 全局:WebSocket Upgrade 映射

宝塔 → 软件商店 → Nginx → 配置修改,在 http { 内、server { 前加入:

1
2
3
4
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

5.2 站点:server.vrast.cn

原配置主要问题:

问题原值修正
Host 头127.0.0.1:$server_port$host
转发协议缺失X-Forwarded-Proto $scheme
Connection固定 "upgrade"$connection_upgrade
Hub 路径与普通 API 混用单独 location /hub/
发送超时30s86400s(长连接)

/hub/ 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
location /hub/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}

5.3 宝塔保存配置的坑

直接删改 SSL / 错误页注释块会导致 「配置文件保存失败」。必须保留:

  • #SSL-START 内的 #error_page 404/404.html;
  • #ERROR-PAGE-START#ERROR-PAGE-END 整段
  • #CERT-APPLY-CHECK--START/END
  • #HTTP_TO_HTTPS_START/END

只改 location 反代部分,不要动上述标记块。

保存后执行:

1
nginx -t && nginx -s reload

六、WebGL 客户端修复

6.1 问题根因

问题说明
ConfigureAwait(false)WebGL 无线程池,continuation 无法执行
Task.RunWebGL 不支持,接收循环无法启动
ClientWebSocket浏览器测试 WS 成功,Unity 侧仍挂起

6.2 解决方案

WebGL 改用 浏览器原生 WebSocket(jslib):

文件作用
Assets/Plugins/WebGL/BrowserWebSocket.jslibJS 层 WebSocket 封装
Assets/Scripts/Network/BrowserWebSocketTransport.csC# 桥接
Assets/Scripts/Network/SignalRWebSocketClient.csWebGL 自动选择传输层

其他改动:

  • GameHubClient:20 秒连接超时
  • NetworkConfig:443 端口省略 :443wss://server.vrast.cn/hub/game
  • 日志使用 UnityEngine.Debug.Log,避免与 DemonRoulette.Debug 命名空间冲突

6.3 成功标志

Console 应出现:

1
2
[GameHub] 连接 wss://server.vrast.cn/hub/game
[SignalR] 握手成功

随后进入在线大厅(房间列表)。

6.4 jslib 运行时错误:DemonRouletteWs is not defined

登录后 Hub 连接阶段,浏览器弹出 Unity 运行时错误,Console 完整堆栈类似:

1
2
3
4
5
Uncaught ReferenceError: DemonRouletteWs is not defined
at _DemonRoulette_WsCreate (WebGL-Staging.framework.js.br:10:26428)
at WebGL-Staging.wasm.br:0x15d59f
at invoke_ii (WebGL-Staging.framework.js.br:10:394006)
...

原因: 第一版 BrowserWebSocket.jslibmergeInto 外部声明了全局变量:

1
2
3
4
5
6
7
8
9
// ❌ 错误写法:打包后该变量不会进入 framework.js 作用域
var DemonRouletteWs = { nextId: 1, sockets: {} };

mergeInto(LibraryManager.library, {
DemonRoulette_WsCreate: function (urlPtr) {
var id = DemonRouletteWs.nextId++; // 运行时报 ReferenceError
...
}
});

Unity / Emscripten 只会把 mergeInto(LibraryManager.library, ...) 内的符号打进 framework.js。外部 var 在 WebGL 运行时不可见,C# 通过 [DllImport("__Internal")] 调用 _DemonRoulette_WsCreate 时就会崩溃。

正确写法: 使用 Emscripten 的 $ 依赖语法保存状态,并 autoAddDeps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 正确写法:Assets/Plugins/WebGL/BrowserWebSocket.jslib
var DemonRouletteBrowserWsPlugin = {
$drrWsState: { nextId: 1, sockets: {} },

DemonRoulette_WsCreate__deps: ["$drrWsState"],
DemonRoulette_WsCreate: function (urlPtr) {
var id = drrWsState.nextId++;
drrWsState.sockets[id] = entry;
...
},
// 其余函数同样声明 __deps: ["$drrWsState"]
};

mergeInto(LibraryManager.library, DemonRouletteBrowserWsPlugin);
autoAddDeps(DemonRouletteBrowserWsPlugin, "$drrWsState");

如何确认已部署新包:

  1. 在 DevTools → Sources 或 Network 里打开 WebGL-Staging.framework.js(解压 .br 后)
  2. 搜索 DemonRouletteWs不应存在
  3. 搜索 drrWsState应存在

若仍能看到 DemonRouletteWs,说明上传的是旧 Build,或 Service Worker / IndexedDB 缓存了旧 framework.js(URL 带 ?v=legacy.1.1.1... 时尤其容易误判「已更新」)。

6.5 发布注意

  1. Release Build 重新打 WebGL 包并上传 mini.vrast.cn
  2. 强刷或清缓存:Service Worker 可能缓存旧包(项目内有 WebCacheRepair
  3. DevTools → Application → Service Workers → Unregister(如有必要)

七、经验清单(Checklist)

服务端

  • API 监听 127.0.0.1:8080,Nginx 反代到 443
  • curl /healthcurl login 均正常
  • MySQL 连接串含 SslMode=None(宝塔本地库)
  • ForwardedHeaders、CORS 已启用

Nginx

  • 全局 map $http_upgrade $connection_upgrade
  • Host$host,非 127.0.0.1
  • X-Forwarded-Proto $scheme
  • /hub/ 单独 location,proxy_buffering off
  • 宝塔 SSL/ERROR-PAGE 注释块未删

客户端

  • NetworkSettings.json release 段指向生产域名
  • WebGL 使用 BrowserWebSocket.jslib(非纯 ClientWebSocket)
  • 浏览器原生 WS 测试通过后再查 Unity 包版本
  • 上传后清 SW / 站点缓存

八、相关路径速查

用途路径/地址
WebGL 站点https://mini.vrast.cn/
API / Hubhttps://server.vrast.cn
服务端部署/www/wwwroot/ringgame
GM 发布面板server/scripts/dev-manager/app.py
网络配置Assets/Resources/Network/NetworkSettings.json
Hub 服务端server/src/NewRingGame.Server/Hubs/GameHub.cs
登录 UIAssets/Scripts/UI/OnlineNetworkUiBinder.cs

九、小结

本次联机打通,问题并不在「登录接口」,而在 Hub WebSocket 全链路

  1. Nginx 需正确转发 WebSocket(Host、Upgrade、Forwarded-Proto、/hub/ 独立规则)
  2. 服务端 在反代后需识别 HTTPS 与真实 IP
  3. WebGL 客户端 不能使用桌面端惯用的 ClientWebSocket + ConfigureAwait(false),必须走浏览器原生 WebSocket

排查顺序建议:HTTP 健康检查 → 登录 API → 浏览器 WS 测试 → Unity 包与缓存。当浏览器 new WebSocket(...) 已成功而 Unity 仍卡住时,应优先怀疑 WebGL 网络实现,而非继续改 Nginx。

归档日期:2026-06-06

补码陷阱

作者 keyle xiao
2025年10月22日 11:10

为什么这个Bug如此狡猾?

直觉背叛:我们直觉上认为 -A 就是 “负的A”,但在位运算中它变成了”A的补码”
测试遗漏:我们测试了正数情况,但对负数的测试不够充分
认知偏差:我们都”知道”补码,但真正用到时却忘了它的存在

你发现下面代码中的问题了吗 ?

1
2
3
4
5
6
7
8
9
// 伪代码:用负数表示反选,正数表示正常选择
bool ShouldSelect(int value, int mask)
{
if (-value & mask == Mathf.Abs(-value))
{
return -value > 0;
}
return false;
}

在C#中,A & B-A & B 的区别主要在于对负数处理的不同。让我通过具体案例来说明:

案例演示

1
2
3
4
5
6
7
8
9
10
int A = 5;    // 二进制: 0000 0101
int B = 3; // 二进制: 0000 0011

// 情况1: A & B
int result1 = A & B; // 5 & 3
Console.WriteLine($"A & B = {result1}"); // 输出: 1

// 情况2: -A & B
int result2 = -A & B; // -5 & 3
Console.WriteLine($"-A & B = {result2}"); // 输出: 3

二进制分析:

1
2
3
4
5
6
7
A = 5:  0000 0101
B = 3: 0000 0011
A & B: 0000 0001 = 1

-A = -5: 1111 1011 (补码表示)
B = 3: 0000 0011
-A & B: 0000 0011 = 3

更多案例

1
2
3
4
5
6
7
8
9
10
11
12
// 案例2
int A2 = 10; // 1010
int B2 = 6; // 0110

Console.WriteLine($"{A2} & {B2} = {A2 & B2}"); // 输出: 2
Console.WriteLine($"-{A2} & {B2} = {-A2 & B2}"); // 输出: 6

// 案例3 - 负数和负数
int A3 = -3;
int B3 = -5;

Console.WriteLine($"{A3} & {B3} = {A3 & B3}"); // 输出: -7

背后的意义

1. 补码表示法

  • 在C#中,整数使用二进制补码表示
  • 正数的补码是其本身
  • 负数的补码 = 对应正数的二进制取反 + 1

2. 运算本质

  • A & B:对两个数的实际二进制位进行按位与
  • -A & B:先计算A的补码(得到-A),再与B进行按位与

3. 实际应用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 标志位检查
[Flags]
enum Permissions
{
Read = 1, // 0001
Write = 2, // 0010
Execute = 4 // 0100
}

// 检查权限
Permissions userPermissions = Permissions.Read | Permissions.Write;

// 正确的方式:直接与操作
bool canRead = (userPermissions & Permissions.Read) != 0; // true

// 错误的方式:使用负数(无意义)
bool wrongCheck = (-(int)userPermissions & (int)Permissions.Read) != 0; // 结果不可预测

4. 重要注意事项

1
2
3
4
// 注意:-A & B 不等于 -(A & B)
int A = 5, B = 3;
Console.WriteLine($"-A & B = {-A & B}"); // 3
Console.WriteLine($"-(A & B) = {-(A & B)}"); // -1

总结

  • A & B 是直接的按位与运算,结果可预测
  • -A & B 涉及补码转换,结果取决于负数的二进制表示
  • 在实际编程中,应避免对负数进行无意义的位运算,除非明确理解补码机制
  • 位运算通常用于标志位、掩码操作等场景,应保持操作数的明确性和可读性
❌
❌