跳转到内容

30. 做一个多人小游戏

约 90 分钟 难度:进阶 动手章

第 26 到 29 章的工具到位了。这一章把它们拼成一个能拉朋友进来玩的项目。

游戏规则简单到一句话能说清:场地里 4 个按钮,每次只有一个发光,发光的按钮被按一下,按的人得 1 分,另一个按钮再发光。计分板实时显示每位玩家的分数。

短到一个晚上能做完,但需要的同步技巧覆盖第六部的全部内容:

  • 同步变量:当前发光是哪个按钮、每位玩家的分数。
  • 所有权转移:按下按钮时把 Manager 的所有权抢过来,再改全局状态。
  • 网络事件:「重置游戏」要让所有客户端看到一次特效。
  • Late Joiner:第三个玩家中途加入,看到当前发光按钮和现存比分。
  • OnPlayerJoined / OnPlayerLeft:自动给新玩家分配计分板槽位、玩家走了清空槽位。

读完这一章如果还有精力,几条进阶扩展放在最后,可以接着发挥。

整个游戏由四块组成:

TapTheLightGame
├─ GameManager (空 GameObject + GameManager.cs,跑游戏全局状态)
├─ Button_0 (Cube + GameButton.cs + Light + Box Collider)
├─ Button_1 (同上)
├─ Button_2 (同上)
├─ Button_3 (同上)
├─ ResetButton (Cube + ResetButton.cs)
└─ Scoreboard (TextMeshPro,显示当前分数)

GameManager 是中心:所有同步状态都在这一个 UdonSharpBehaviour 里。4 个按钮各自只是「按下时通知 Manager」的薄壳,自己不存任何同步状态。这样一个项目里只有一份同步状态需要操心。

打开第二部那间小屋(或者新建一个空场景)。在地面上放 4 个 Cube 当按钮:

Button_0 Position -2, 1.0, 0 Scale 0.5, 0.3, 0.5
Button_1 Position -0.7, 1.0, 0 Scale 0.5, 0.3, 0.5
Button_2 Position 0.7, 1.0, 0 Scale 0.5, 0.3, 0.5
Button_3 Position 2, 1.0, 0 Scale 0.5, 0.3, 0.5

每个 Button 上做这几件事:

  1. 自带的 Box Collider 保留,Is Trigger
  2. Add Component → 搜 Light,选 Point LightRange = 2Intensity = 0(默认是灭的),Color 可以挑一个鲜艳的(每个按钮不同色更直观)。
  3. (可选)做一个亮色材质让按钮在灭着时也看得见。

新建空 GameObject GameManager,位置随意(不参与渲染,可以放在 0, 0, 0 或埋到地下)。

新建空 GameObject ResetButton,位置摆在场地一角:

ResetButton Position 0, 1.0, 2 Scale 0.4, 0.4, 0.4

加一个 Box Collider、一个亮色材质方便看见。

新建一个 CanvasWorld Space,在它下面加 TextMeshPro - Text,命名 Scoreboard。位置摆在场地后方墙上、字号调到能看清。这一章 UI 的细节不展开,第七部会讲,先能显示文字就行。

Assets/Scripts/ 下新建 GameManager.cs

GameManager.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon.Common.Interfaces;
using TMPro;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class GameManager : UdonSharpBehaviour
{
private const int MAX_PLAYERS = 16;
[Header("场上的 4 个按钮")]
public GameButton[] buttons;
[Header("计分板 UI")]
public TextMeshProUGUI scoreboardText;
// 当前发光按钮的下标。-1 表示没有按钮发光(重置后到第一次激活之间)
[UdonSynced]
private int activeButtonIndex = -1;
// 玩家槽位:parallel arrays
[UdonSynced]
private int[] playerIds = new int[MAX_PLAYERS];
[UdonSynced]
private int[] scores = new int[MAX_PLAYERS];
private void Start()
{
// 第一个进实例的玩家自动是 Owner,由她启动第一轮
if (Networking.IsOwner(gameObject))
{
ActivateRandomButton();
RequestSerialization();
}
UpdateUI();
}
public override void OnDeserialization()
{
// 收到状态更新:重画 UI 和按钮发光状态
UpdateUI();
}
public override void OnPlayerJoined(VRCPlayerApi player)
{
if (!Networking.IsOwner(gameObject)) return;
int slot = FindSlot(player.playerId);
if (slot >= 0) return; // 已经注册过
slot = FindEmptySlot();
if (slot < 0) return; // 满了
playerIds[slot] = player.playerId;
scores[slot] = 0;
RequestSerialization();
}
public override void OnPlayerLeft(VRCPlayerApi player)
{
if (!Networking.IsOwner(gameObject)) return;
int slot = FindSlot(player.playerId);
if (slot < 0) return;
playerIds[slot] = 0;
scores[slot] = 0;
RequestSerialization();
}
// 由 GameButton 在玩家按对了发光按钮时调用
public void OnButtonHit(int buttonIndex)
{
if (buttonIndex != activeButtonIndex) return; // 没按到发光的,忽略
// 抢所有权
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
// 给本机玩家加 1 分
int slot = FindSlot(Networking.LocalPlayer.playerId);
if (slot < 0)
{
slot = FindEmptySlot();
if (slot >= 0)
{
playerIds[slot] = Networking.LocalPlayer.playerId;
}
}
if (slot >= 0)
{
scores[slot]++;
}
// 选下一个发光按钮
ActivateRandomButton();
RequestSerialization();
UpdateUI();
}
// 由 ResetButton 调用
public void ResetGame()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
for (int i = 0; i < scores.Length; i++)
{
scores[i] = 0;
}
ActivateRandomButton();
RequestSerialization();
UpdateUI();
// 让所有人看一次重置特效(事件,不是状态)
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(PlayResetEffect));
}
public void PlayResetEffect()
{
// 这一段在所有客户端都跑一次。下面留个简单实现,第二步可以扩展成粒子或音效
Debug.Log("[Game] Reset!");
}
// ---- 内部工具方法 ----
private void ActivateRandomButton()
{
if (buttons == null || buttons.Length == 0) return;
int next = Random.Range(0, buttons.Length);
// 尽量不选当前那个,让玩家有移动感
if (next == activeButtonIndex && buttons.Length > 1)
{
next = (next + 1) % buttons.Length;
}
activeButtonIndex = next;
}
private int FindSlot(int playerId)
{
for (int i = 0; i < playerIds.Length; i++)
{
if (playerIds[i] == playerId) return i;
}
return -1;
}
private int FindEmptySlot()
{
for (int i = 0; i < playerIds.Length; i++)
{
if (playerIds[i] == 0) return i;
}
return -1;
}
private void UpdateUI()
{
// 更新按钮发光状态
if (buttons != null)
{
for (int i = 0; i < buttons.Length; i++)
{
if (buttons[i] != null)
{
buttons[i].SetGlowing(i == activeButtonIndex);
}
}
}
// 更新计分板文字
if (scoreboardText == null) return;
string text = "<size=120%>抢分计分板</size>\n\n";
for (int i = 0; i < playerIds.Length; i++)
{
if (playerIds[i] == 0) continue;
VRCPlayerApi p = VRCPlayerApi.GetPlayerById(playerIds[i]);
string name = p != null ? p.displayName : "(离线)";
text += $"{name}{scores[i]}\n";
}
scoreboardText.text = text;
}
}

值得停下来看几件事:

  • 类标 [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]:所有同步都走手动模式。
  • playerIdsscores 是两个并行数组,都标 [UdonSynced]。第 28 章说过 UdonSharp 同步支持基础类型的数组。
  • OnPlayerJoined 只让 Owner 处理。其他客户端跑 OnPlayerJoined 也会进入这个方法但被第一行 if (!IsOwner) return 挡住,不会做任何事。
  • OnButtonHit 写成 public:它会被另一个脚本(GameButton)通过本地引用直接调用。第 28 章见过:本地脚本之间互相调方法不需要 SendCustomEvent,直接调就行。
  • ActivateRandomButton 里那个「不选当前的」防止连续两下按同一个按钮,让玩家感觉「光在跳」。
  • UpdateUI 里通过 VRCPlayerApi.GetPlayerById 把 playerId 翻译成显示名。这个 API 找不到对应玩家时返回 null,要判一下。

Assets/Scripts/ 下新建 GameButton.cs

GameButton.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
public class GameButton : UdonSharpBehaviour
{
public GameManager manager;
public Light buttonLight;
public int buttonIndex;
private bool isGlowing = false;
public void SetGlowing(bool glowing)
{
isGlowing = glowing;
if (buttonLight != null)
{
buttonLight.intensity = glowing ? 4f : 0f;
}
}
public override void Interact()
{
if (!isGlowing) return; // 只有发光的按钮接受按下
if (manager == null) return;
manager.OnButtonHit(buttonIndex);
}
}

很短:

  • [UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)] 明示这个脚本不存任何同步状态。isGlowing 是本地缓存,由 Manager 在 UpdateUI 里调 SetGlowing 更新。
  • 注意不要写成 BehaviourSyncMode.None:那是「完全禁用网络」,连 SendCustomNetworkEvent 都不能用。NoVariableSync 只禁同步变量,本地脚本之间互相调方法、被 Manager 通过引用调用都正常(第 28 章末尾那段 caution 讲过)。
  • Interact 检查 isGlowing:按了不发光的按钮直接忽略。VRChat 的 Interaction Text 可以在 Inspector 里填空字符串让按钮看起来「不能按」,但这里更简单的做法是脚本内部判一下。
  • 调用 manager.OnButtonHit(buttonIndex):把「按到了」这件事告诉 Manager,让 Manager 处理同步状态。

GameButton.cs 挂到每个按钮 GameObject 上:

字段Button_0Button_1Button_2Button_3
Manager拖 GameManager 物体同左同左同左
Button Light拖本物体的 Light
Button Index0123
Interaction Text

GameManager,把 4 个按钮拖进 Buttons 数组:Inspector 里 ButtonsSize = 4 → 4 个槽分别拖 Button_0Button_3。再把 Scoreboard 那块 TextMeshPro 拖进 Scoreboard Text 槽。

ResetButton.cs
using UdonSharp;
using UnityEngine;
[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
public class ResetButton : UdonSharpBehaviour
{
public GameManager manager;
public override void Interact()
{
if (manager == null) return;
manager.ResetGame();
}
}

挂到 ResetButton 物体上,把 GameManager 拖进 Manager 字段。Interaction Text 写「重置」。

先单人测一遍,确认本地逻辑通畅。

Build & Test,进世界。Start 时 Owner 是自己,应该能看到一个按钮发光。走过去按一下:

  • 发光按钮的灯熄灭。
  • 另一个按钮发光。
  • 计分板上自己的名字 + 分数 1 出现。

按几下,分数累加。按 ResetButton,分数归零、按钮换一个发光。

如果某一步不对,按这条顺序自查:

  1. 计分板没显示自己 → OnPlayerJoined 没跑:检查 GameManager 是不是挂到了一个网络化 GameObject(默认所有挂 UdonBehaviour 的 GameObject 都满足)。
  2. 按了按钮没反应 → 看 Console 红字,多半是引用槽没拖(manager 是 None、Buttons 数组 Size 是 0)。
  3. 按了按钮分数加了但 UI 没变 → UpdateUI 没被调:检查 OnButtonHit 末尾有没有 UpdateUI() 那一行。

第六步:双人 Build & Publish 验证同步

Section titled “第六步:双人 Build & Publish 验证同步”

单人通过后 Build & Publish 上传,开 Friends 实例。两个客户端同时进。

预期表现:

  • 两个屏幕看到同一个按钮发光(Manager 同步过的 activeButtonIndex)。
  • Alpha 抢先按下,两边都看到分数变化(Alpha +1)。
  • 下一个按钮发光的位置两边一致。
  • 计分板上 Alpha 和 Beta 都列出来。

如果 Alpha 看到自己分数 +1 但 Beta 看到自己分数也 +1,是 playerIds 用错了:自查 OnButtonHitNetworking.LocalPlayer.playerId 这一行。每个客户端跑这段代码时,Networking.LocalPlayer 应该是按按钮的那个人。

让 Charlie 加入这个实例(开第三个客户端,或拉一位朋友)。

Charlie 进来后应该在短暂同步后看到:

  • 当前发光的按钮(不需要等下一次切换)。
  • Alpha 和 Beta 的当前分数。
  • 自己的名字出现在计分板上。

第三条值得说明。回顾 OnPlayerJoined 的逻辑:Charlie 加入时所有客户端的 OnPlayerJoined 都会被调用,但只有 Owner 那台会真的写 playerIds[slot] = Charlie.playerId 并调用 RequestSerialization()

Charlie 加入瞬间,她自己的客户端会先收到已有同步状态的快照,UI 可能先显示已有玩家分数;随后 Owner 处理 OnPlayerJoined、把 Charlie 加进 playerIds 并序列化,Charlie 的名字再出现在计分板上。这个过程通常只晚一拍,不需要等下一次按按钮。

如果 Charlie 自己就是 Owner(例如 Alpha 和 Beta 都已经离开,Charlie 进入时成为当前 Owner),她自己的客户端会负责写入槽位并广播。

让 Alpha 离开实例。她原本是 Manager 的 Owner。

VRChat 会自动把所有权改派给仍在实例中的玩家(常见是当前 Master / 最早加入者;不要把具体顺序写进玩法规则)。所有客户端的 GameManager 上都会触发 OnOwnershipTransferred。这之后所有「只让 Owner 跑」的逻辑(OnPlayerJoinedResetGameOnButtonHit 里的 RequestSerialization)会在新的 Owner 客户端执行。

游戏不应该中断:Alpha 离开瞬间她的分数留在 playerIds[slot] 里几秒,直到新的 Owner 客户端的 OnPlayerLeft(Alpha) 跑完才被清掉。这件事多数情况下没问题。

边界情况要小心:如果 Alpha 离开时她手里还有「等待广播」的状态变化(比如 RequestSerialization 调了但还没真的发出去),那个变化会丢。这是分布式系统的常态,不用过分追求完美一致。

游戏雏形跑起来后,几个方向可以扩展:

  • 加倒计时:用 Networking.GetServerTimeInSeconds() 同步一个游戏开始时间戳,所有客户端可以本地计算「剩余秒数」。倒计时归零时 Owner 触发 ResetGame。这个 API 返回的是服务器时间,所有客户端拿到的是同一个值,不需要同步。
  • 同步特效:在 PlayResetEffect 里实际播一段粒子和音效。这一章预留了空函数,每个客户端各自播一次自己本地的粒子,效果就是全场同时炸开。
  • 多档难度:加一个 [UdonSynced] int difficulty 字段,通过额外的「难度按钮」让任意玩家切换。难度高的话 ActivateRandomButton 后立刻安排一个 SendCustomEventDelayedSeconds 在 X 秒后强制切换(玩家没按到也会丢机会)。

第三个变体能让游戏从「比手快」变成「能不能撑住节奏」,玩感大不一样。改难度按钮的时候会发现「有些字段任何人都能改」(比如难度)跟「有些字段只有 Owner 能改」(比如分数)混合起来需要小心安排,让别的玩家也能按按钮先抢所有权再改是简单做法。

把第 26 到 30 章学的东西拼一起看,一个 VRChat 多人系统通常长这样:

┌──────────────────────────────────────┐
│ Manager UdonSharpBehaviour │
│ ─────────────── │
│ [UdonBehaviourSyncMode(Manual)] │
│ │
│ [UdonSynced] 全局状态 1, 2, 3 ... │
│ │
│ Owner 改字段 → RequestSerialization │
│ 其他客户端 → OnDeserialization │
│ │
│ OnPlayerJoined / OnPlayerLeft │
│ 只让 Owner 处理玩家槽位 │
│ │
│ 对外公开方法: │
│ - OnButtonHit(int) │
│ - ResetGame() │
│ ... │
└──────────────────────────────────────┘
↑ ↑
│ │
┌───┴────┐ ┌────┴────┐
│ Button │ │ Reset │
│NoVarSync│ │NoVarSync│
└────────┘ └─────────┘
不存同步状态 不存同步状态
只调 Manager 只调 Manager
公开方法 公开方法

中心一份同步状态、若干薄壳交互组件、一个状态变化的统一入口。这套形状在四人桌游、抢点游戏、多人解谜、社交派对小游戏里反复出现。换皮换玩法,骨架不变。

第六部到这里走完。回到第四部那段创作者视角说过的话:「会回应人的房间」加上「能让所有人看到同一件事」,VRChat 多人世界的两块基本能力就到位了。剩下的更大的话题(空间体验设计、性能、发布维护)是把这个能跑的多人世界做扎实做大。