跳转到内容

闭环 4 · 最小可玩版本

约 9 分钟 难度:5

这一章解决:把第 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 / RESULTLOBBY / COUNTDOWN / INGAME / RESULT
分数totalScoreteamScore + totalScore + personalScore
目标进度targetClearedMask + enemyAliveCount
玩家状态ready / requestready / team / role / HP / personalScore / request
结算到分或到时清完目标、时间到、全部倒地

本闭环故意不接第 42 章的对象池,不接第 43 章的完整体感优化,也不接 Vol.5 的长期奖励。训练目标是替身:它只负责把「玩家打中了目标」这个请求送到 GameState。第 42 章会把它换成池化敌人。


在闭环 3 场景基础上加三类对象。

对象数量作用
GameState1继续承载全局阶段、分数、波次、结算
PlayerObject prefab每位玩家 1 份继续承载 ready、team、role、HP、个人分、请求字段
TrainingTarget5 个左右暂时代替敌人,被命中后向本地 PlayerObject 发请求

TrainingTarget 不需要 VRCObjectPool。它们直接放在场景里,由 GameStatetargetClearedMask 决定显示或隐藏。迟入玩家加入时,读到 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 一对多时不改底层结构。

命中目标通过请求字段发给 GameStatepayload 暂时只放目标编号。

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 单调递增,GameStatelastProcessedRequestId 去重。


GameState 增量:把一局合作防守写成同步字段

Section titled “GameState 增量:把一局合作防守写成同步字段”

GameState 继续使用 Manual sync。闭环 3 的 stateVersionlastProcessedRequestId[]、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 接管后重扫请求,都不会让同一个目标重复加分。


每个 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 是唯一读同步字段的地方,训练目标只接受刷新。


闭环 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 阶段迟入时,玩家直接读 resultReasontotalScoreteamScore[]resultMvpPlayerId。他自己的 PlayerObject 是新生成的,personalScore=0,这和第 17 章的边界一致。


闭环 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 人进入,分别 ReadyReady 状态独立同步,Start 前不会进入比赛
2. 全员 Ready 后按 Start进入 COUNTDOWN,倒计时结束后进入 INGAME,matchId +1
3. A 命中目标 0targetClearedMask bit 0 置位,目标 0 隐藏,enemyAliveCount -1,分数增加
4. A 连点目标 0同一目标不会重复加分,enemyAliveCount 不会继续减少
5. B 命中剩余所有目标enemyAliveCount=0 后进入 RESULT,resultReason=RESULT_ALL_TARGETS_CLEARED
6. INGAME 中迟入 CC 看到已清目标隐藏,剩余目标可见,分数和剩余时间正确
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 章的缺口