闭环 4 · 最小可玩版本
这一章解决:把第 40 章的 ADR 落成闭环 4。先不用完整 NPC 和对象池,只用训练目标跑通一局合作防守的最小版本。
先看一下
前面三次闭环已经证明了 PlayerObject + GameState + requestId + stateVersion 的骨架能工作。第八部要做完整项目,第一步先把项目的「一局」跑通:进入大厅、准备、开局、清目标、结算、重开。怪物 AI 放到下一章。
本闭环修的是第 40 章之前的空缺:ADR 已经写清状态归属,但还没有一个能按这份 ADR 运行的最小世界。
这一章会拿到什么
- 一条完整闭环:
LOBBY → COUNTDOWN → INGAME → RESULT → LOBBY PlayerLobbyState的项目增量字段:HP、个人分、命中目标请求GameState的项目增量字段:matchId、波次、目标清理 bitmask、结算原因- 一个临时
TrainingTarget,代替第 42 章的 NPC 与对象池 - 闭环 4 的测试矩阵
依赖前面
- 第 40 章的 ADR
- 闭环 3 的
stateVersion+ GameState 端冷却 + 玩家离开清理 - 第 16 章的
BeginMatch/EndMatch/RestartMatch - 第 17 章的个人分 / 队伍分 / 总分三层
- 第 29 章的本地预演边界
本闭环新增什么,故意不做什么
Section titled “本闭环新增什么,故意不做什么”闭环 4 新增的是项目形态,架构仍复用闭环 3。它把闭环 3 的「按 Score 加分」改成「打掉训练目标得分」,并加上波次、HP、结算原因这些合作防守世界需要的字段。
| 项目 | 闭环 3 | 闭环 4 |
|---|---|---|
| 玩法对象 | Score 按钮 | 训练目标 TrainingTarget |
| 阶段 | LOBBY / INGAME / RESULT | LOBBY / COUNTDOWN / INGAME / RESULT |
| 分数 | totalScore | teamScore + totalScore + personalScore |
| 目标进度 | 无 | targetClearedMask + enemyAliveCount |
| 玩家状态 | ready / request | ready / team / role / HP / personalScore / request |
| 结算 | 到分或到时 | 清完目标、时间到、全部倒地 |
本闭环故意不接第 42 章的对象池,不接第 43 章的完整体感优化,也不接 Vol.5 的长期奖励。训练目标是替身:它只负责把「玩家打中了目标」这个请求送到 GameState。第 42 章会把它换成池化敌人。
场景里要摆什么
Section titled “场景里要摆什么”在闭环 3 场景基础上加三类对象。
| 对象 | 数量 | 作用 |
|---|---|---|
GameState | 1 | 继续承载全局阶段、分数、波次、结算 |
| PlayerObject prefab | 每位玩家 1 份 | 继续承载 ready、team、role、HP、个人分、请求字段 |
TrainingTarget | 5 个左右 | 暂时代替敌人,被命中后向本地 PlayerObject 发请求 |
TrainingTarget 不需要 VRCObjectPool。它们直接放在场景里,由 GameState 的 targetClearedMask 决定显示或隐藏。迟入玩家加入时,读到 targetClearedMask 后,本地把已经清掉的目标隐藏即可。
建议对象层级:
GameRoot├─ GameState├─ UI│ ├─ PhaseLabel│ ├─ ScoreLabel│ ├─ WaveLabel│ ├─ HpLabel│ └─ ResultPanel├─ Buttons│ ├─ ReadyButton│ ├─ StartButton│ └─ RestartButton└─ TrainingTargets ├─ Target_00 ├─ Target_01 ├─ Target_02 ├─ Target_03 └─ Target_04本章只要求这些对象能跑通逻辑,不要求最终美术。第八部后面的 NPC、对象池、体感优化和上线迭代都建立在这条最小场景上。
PlayerObject 增量:把个人状态补齐
Section titled “PlayerObject 增量:把个人状态补齐”沿用闭环 3 的 PlayerLobbyState。本章加几个项目字段和两个请求类型。
public const byte REQ_HIT_TARGET = 5;public const byte REQ_PLAYER_DOWN = 6;
[UdonSynced] private byte roleId;[UdonSynced] private int currentHP = 100;[UdonSynced] private bool isDowned;[UdonSynced] private int personalScore;
public byte RoleId => roleId;public int CurrentHP => currentHP;public bool IsDowned => isDowned;public int PersonalScore => personalScore;teamId 在闭环 2 已经预留,本章开始正式使用。合作防守最小版本可以先把所有玩家放进 teamId = 0,仍然保留字段,是为了第 46 章改造成 PvP 一对多时不改底层结构。
命中目标通过请求字段发给 GameState。payload 暂时只放目标编号。
public void RequestHitTarget(int targetId){ IssueRequest(REQ_HIT_TARGET, targetId);}个人分作为 per-player 显示字段,由玩家自己的 PlayerObject Owner 落地。GameState 裁决命中成立后,向该玩家的 PlayerObject Owner 发批准回执;结算用的最终排名和 MVP 仍由 GameState 汇总快照决定。这样写是因为官方同步变量只能由对象 Owner 修改,不能把 PlayerObject 的个人分当作可信奖励来源。
[NetworkCallable]public void OnPersonalScoreAdded(int delta){ if (!Networking.IsOwner(gameObject)) return; personalScore += delta; RequestSerialization();}开局和重开时,玩家对象自己清本局字段。
[NetworkCallable]public void OnProjectMatchBegin(){ if (!Networking.IsOwner(gameObject)) return;
currentHP = 100; isDowned = false; personalScore = 0; RequestSerialization();}
[NetworkCallable]public void OnProjectBackToLobby(){ if (!Networking.IsOwner(gameObject)) return;
isReady = false; currentHP = 100; isDowned = false; personalScore = 0; RequestSerialization();}这里没有清 requestId。第 16 章已经定过规则:请求流字段永远不清。requestId 单调递增,GameState 用 lastProcessedRequestId 去重。
GameState 增量:把一局合作防守写成同步字段
Section titled “GameState 增量:把一局合作防守写成同步字段”GameState 继续使用 Manual sync。闭环 3 的 stateVersion、lastProcessedRequestId[]、Owner 候选队列保留。
public const byte PHASE_LOBBY = 0;public const byte PHASE_COUNTDOWN = 1;public const byte PHASE_INGAME = 2;public const byte PHASE_RESULT = 3;
public const byte RESULT_NONE = 0;public const byte RESULT_ALL_TARGETS_CLEARED = 1;public const byte RESULT_TIME_UP = 2;public const byte RESULT_ALL_PLAYERS_DOWN = 3;
[UdonSynced] private int matchId;[UdonSynced] private byte phase;[UdonSynced] private int stateVersion;
[UdonSynced] private int waveIndex;[UdonSynced] private double phaseStartServerTime;[UdonSynced] private double waveStartServerTime;[UdonSynced] private int enemyAliveCount;[UdonSynced] private int targetClearedMask;
[UdonSynced] private int totalScore;[UdonSynced] private int[] teamScore = new int[4];[UdonSynced] private byte resultReason;[UdonSynced] private int resultMvpPlayerId = -1;targetClearedMask 是本闭环的小技巧。5 个训练目标对应 bit 0 到 bit 4。目标 2 被清掉时,写 targetClearedMask |= 1 << 2。迟入玩家拿到这个整数,就能隐藏已清掉的目标。
目标数量先用 Inspector 参数。
[Header("Project MVP")]public int targetCount = 5;public int scorePerTarget = 1;public float countdownSeconds = 3f;public float matchDuration = 90f;Manual sync 单次约 280 KB,本闭环这些字段远低于这个量级。真正需要控制的是发送频率:训练目标被清掉、阶段变化、结算时才 RequestSerialization()。空 tick 不发包。
阶段入口:倒计时、开局、结算、重开
Section titled “阶段入口:倒计时、开局、结算、重开”Start 请求不直接进入 INGAME,而是进入 COUNTDOWN。这样 UI 有一段统一的「准备进入比赛」时间,也给 PlayerObject 广播清场留出位置。
private void HandleStart(VRCPlayerApi who){ if (phase != PHASE_LOBBY) return; if (!AllPlayersReady()) return;
phase = PHASE_COUNTDOWN; phaseStartServerTime = Networking.GetServerTimeInSeconds(); RequestSerialization(); ApplyState();}Update 里只让 GameState Owner 推进阶段。
private void Update(){ UpdateLocalClockUi();
if (!Networking.IsOwner(gameObject)) return;
if (phase == PHASE_COUNTDOWN) { double elapsed = Networking.CalculateServerDeltaTime( Networking.GetServerTimeInSeconds(), phaseStartServerTime); if (elapsed >= countdownSeconds) BeginProjectMatch(); return; }
if (phase == PHASE_INGAME) { TickProjectMatch(); }}BeginProjectMatch 做本局初始化。它对应第 16 章的 BeginMatch。
private void BeginProjectMatch(){ if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_COUNTDOWN) return;
matchId += 1; phase = PHASE_INGAME; resultReason = RESULT_NONE; resultMvpPlayerId = -1;
waveIndex = 1; phaseStartServerTime = Networking.GetServerTimeInSeconds(); waveStartServerTime = phaseStartServerTime;
targetClearedMask = 0; enemyAliveCount = targetCount; totalScore = 0; ClearTeamScores();
BroadcastProjectMatchBegin();
RequestSerialization(); ApplyState();}TickProjectMatch 只做两个判断:目标清完,或时间到。全部玩家倒地的判断先留接口,第 43 章接体感和受击反馈时再补完整。
private void TickProjectMatch(){ double elapsed = Networking.CalculateServerDeltaTime( Networking.GetServerTimeInSeconds(), phaseStartServerTime);
if (enemyAliveCount <= 0) { EndProjectMatch(RESULT_ALL_TARGETS_CLEARED); return; }
if (elapsed >= matchDuration) { EndProjectMatch(RESULT_TIME_UP); return; }}EndProjectMatch 固化结算。MVP 从当前房间玩家的 PlayerObject 读取个人分。
private void EndProjectMatch(byte reason){ if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_INGAME) return;
phase = PHASE_RESULT; resultReason = reason; resultMvpPlayerId = ComputeMvpPlayerId();
RequestSerialization(); ApplyState();}RestartProjectMatch 回到大厅,保留队伍和角色,清 ready、HP、个人分和全局进度。
private void RestartProjectMatch(){ if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_RESULT) return;
phase = PHASE_LOBBY; resultReason = RESULT_NONE; resultMvpPlayerId = -1; waveIndex = 0; enemyAliveCount = 0; targetClearedMask = 0; totalScore = 0; ClearTeamScores();
BroadcastProjectBackToLobby();
RequestSerialization(); ApplyState();}命中目标:训练目标只发请求,GameState 裁决
Section titled “命中目标:训练目标只发请求,GameState 裁决”TrainingTarget 不直接加分。它只找到本地玩家的 PlayerObject,然后发 REQ_HIT_TARGET。
using UdonSharp;using UnityEngine;using VRC.SDKBase;
public class TrainingTarget : UdonSharpBehaviour{ public int targetId; public GameState gameState;
public override void Interact() { var pls = FindLocalPLS(); if (pls == null) return; pls.RequestHitTarget(targetId); }
private PlayerLobbyState FindLocalPLS() { var p = Networking.LocalPlayer; if (p == null || !p.IsValid()) return null;
var objs = Networking.GetPlayerObjects(p); if (objs == null) return null;
for (int i = 0; i < objs.Length; i++) { if (objs[i] == null) continue; var pls = objs[i].GetComponentInChildren<PlayerLobbyState>(); if (pls != null) return pls; } return null; }}GameState.ProcessRequest 增加一条分支。
private void ProcessRequest(VRCPlayerApi who, PlayerLobbyState pls){ byte type = pls.RequestType;
if (type == PlayerLobbyState.REQ_START) HandleStart(who); else if (type == PlayerLobbyState.REQ_HIT_TARGET) HandleHitTarget(who, pls.RequestPayload); else if (type == PlayerLobbyState.REQ_RESTART) RestartProjectMatch();}HandleHitTarget 是本闭环的裁决点。
private void HandleHitTarget(VRCPlayerApi who, int targetId){ if (phase != PHASE_INGAME) return; if (targetId < 0 || targetId >= targetCount) return;
int bit = 1 << targetId; if ((targetClearedMask & bit) != 0) return;
targetClearedMask |= bit; enemyAliveCount = Mathf.Max(0, enemyAliveCount - 1);
int team = GetPlayerTeam(who); totalScore += scorePerTarget; if (team >= 0 && team < teamScore.Length) { teamScore[team] += scorePerTarget; }
var pls = GetPLS(who); if (pls != null) { pls.SendCustomNetworkEvent( VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(PlayerLobbyState.OnPersonalScoreAdded), scorePerTarget); }
RequestSerialization(); ApplyState();}重复命中同一目标会被 targetClearedMask 拦住。玩家连点、网络重发、Owner 接管后重扫请求,都不会让同一个目标重复加分。
让目标按同步状态显示和隐藏
Section titled “让目标按同步状态显示和隐藏”每个 TrainingTarget 可以在 OnDeserialization 后或本地 UI 刷新时读取 GameState。
public void RefreshVisible(){ if (gameState == null) return; bool cleared = gameState.IsTargetCleared(targetId); gameObject.SetActive(!cleared);}GameState 暴露只读查询。
public bool IsTargetCleared(int targetId){ if (targetId < 0 || targetId >= targetCount) return true; int bit = 1 << targetId; return (targetClearedMask & bit) != 0;}实际项目里可以由 GameState.ApplyState() 遍历 TrainingTarget[] 调 RefreshVisible()。本闭环保持简单:GameState 是唯一读同步字段的地方,训练目标只接受刷新。
UI 只读同步状态
Section titled “UI 只读同步状态”闭环 4 的 UI 先做五个文本:阶段、倒计时、波次、分数、结果。
private void ApplyState(){ if (phaseLabel != null) phaseLabel.text = PhaseToText(phase); if (scoreLabel != null) scoreLabel.text = totalScore.ToString(); if (waveLabel != null) waveLabel.text = waveIndex <= 0 ? "-" : waveIndex.ToString();
if (resultLabel != null) { resultLabel.text = ResultToText(resultReason); }
RefreshTargets();}倒计时和比赛剩余时间可以本地计算,因为基准时间来自同步字段。
private void UpdateLocalClockUi(){ if (timerLabel == null) return;
double now = Networking.GetServerTimeInSeconds(); if (phase == PHASE_COUNTDOWN) { double elapsed = Networking.CalculateServerDeltaTime(now, phaseStartServerTime); timerLabel.text = Mathf.Max(0f, countdownSeconds - (float)elapsed).ToString("F1"); return; }
if (phase == PHASE_INGAME) { double elapsed = Networking.CalculateServerDeltaTime(now, phaseStartServerTime); timerLabel.text = Mathf.Max(0f, matchDuration - (float)elapsed).ToString("F1"); return; }
timerLabel.text = "-";}UI 不写状态。Ready、Start、Restart、Target 交互都回到 PlayerObject 请求或 GameState 的阶段入口。
迟入恢复:闭环 4 要看到四样东西
Section titled “迟入恢复:闭环 4 要看到四样东西”迟入玩家进入 INGAME 时,能从当前同步字段恢复四样东西。
| 内容 | 恢复来源 |
|---|---|
| 当前阶段和剩余时间 | phase + phaseStartServerTime |
| 当前第几波 | waveIndex |
| 还剩几个目标 | enemyAliveCount |
| 哪些训练目标已经消失 | targetClearedMask |
Network Event 不参与恢复。命中音效、击中特效、按钮按下态都可以丢。迟入玩家只需要看到当前目标状态和结算结果。
RESULT 阶段迟入时,玩家直接读 resultReason、totalScore、teamScore[]、resultMvpPlayerId。他自己的 PlayerObject 是新生成的,personalScore=0,这和第 17 章的边界一致。
闭环 4 留给后续章节的缺口
Section titled “闭环 4 留给后续章节的缺口”闭环 4 到这里已经能跑完一局,但它仍然是最小版本。
| 缺口 | 为什么现在不做 | 后续章节 |
|---|---|---|
| 真实 NPC | 会引入 AI、移动、目标选择和 Owner 分配 | 第 42 章 |
| 对象池 | 训练目标是场景常驻对象,不需要生成 / 归还 | 第 42 章 |
| 完整体感预演 | 本闭环先保证结果正确,按目标只走请求 | 第 43 章 |
| 日志与测试归档 | 现在只做章节测试矩阵 | 第 44 章 |
| 平衡性和长期奖励 | MVP 只给最小分数,不接持久化 | 第 45 章 |
这张表就是闭环 4 的边界。后续每章都只改一个方向,避免完整项目一下子膨胀成不可调试的状态。
挑一条试。
- 把
targetCount从 5 改成 8。targetClearedMask还能直接工作;如果改成 40,会在哪里失效? - 把
scorePerTarget改成按目标不同给分。这个分值应该放在TrainingTarget上,还是放在GameState的配置数组里? - 把
COUNTDOWN阶段删掉,Start 后直接BeginProjectMatch()。测试时观察 UI、PlayerObject 清场和远端同步有没有更容易出现中间态。
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 2 人进入,分别 Ready | Ready 状态独立同步,Start 前不会进入比赛 | … |
| 2. 全员 Ready 后按 Start | 进入 COUNTDOWN,倒计时结束后进入 INGAME,matchId +1 | … |
| 3. A 命中目标 0 | targetClearedMask bit 0 置位,目标 0 隐藏,enemyAliveCount -1,分数增加 | … |
| 4. A 连点目标 0 | 同一目标不会重复加分,enemyAliveCount 不会继续减少 | … |
| 5. B 命中剩余所有目标 | enemyAliveCount=0 后进入 RESULT,resultReason=RESULT_ALL_TARGETS_CLEARED | … |
| 6. INGAME 中迟入 C | C 看到已清目标隐藏,剩余目标可见,分数和剩余时间正确 | … |
7. GameState Owner 在 INGAME 离开 | 新 Owner 接管后仍能继续命中剩余目标并结算 | … |
| 8. RESULT 按 Restart | 回到 LOBBY,目标全部恢复,PlayerObject 的 HP / personalScore / ready 清干净 | … |
| 9. 连续跑 3 局 | matchId 单调递增,上一局目标状态和分数不串到下一局 | … |
- 能说明闭环 4 为什么用训练目标代替 NPC
- 能解释
targetClearedMask如何帮助迟入恢复 - 能说出 PlayerObject 新增字段各自的归属理由
- 能说明
GameState为什么只保存目标聚合和结算字段 - 能指出闭环 4 留给第 42、43、44 章的缺口
- 第 40 章 · 从玩法反推架构决策记录:闭环 4 的状态归属来源。
- 闭环 3 · 加上命令版本号的工程化一局:本闭环复用的
stateVersion、冷却和清理基础。 - 第 16 章 · 开局与重开:
BeginMatch、EndMatch、RestartMatch的事务边界。 - 第 17 章 · 计分、奖励与结算:个人分、队伍分、总分三层。
- 第 26 章 · 轻实时范式:合作防守世界的 tick 与聚合状态。
- 第 36 章 · 对象池与生成系统:第 42 章替换训练目标时要回查的对象池边界。