はじめに
スコフィごいた Scofi-Goita は、石川県能登地方の伝統ボードゲーム「ごいた」をVRChat上で遊べるようにしたワールドです。駒はスコフィ(1058mart制作アバター)。 ごいたは4人・2チームで遊ぶゲームで、将棋の駒と同じ形をした32枚を使い、「攻め」「受け」「なし」を繰り返していち早く手駒をなくすことを目指します。
この記事ではソースコードの中でも同期変数の圧縮・展開まわりに絞って、技術的な話をしようと思います。
少し裏話になりますが、このワールドはもともと「ちゃんと作ろう」という気持ちで始まったわけではなくて、「暇だしごいたのギミックでも書いてみるか」くらいの軽いノリで書き始めた実験コードでした。ゲームを完成させることよりも「同期変数をどこまで削れるか」という課題の方が面白くなってしまって、必要以上に凝った圧縮処理を書き込んでいました。
そんな中で、
- 「駒がスコフィだったら絶対かわいい」という声が上がり
- 試しにやってみたら本当にハマって
- その様子がスコフィの作者であるinugoyaさんに見つかり
- これはちゃんとしたワールドにしないと……となり
という、まったく予期しない方向転換が起きました。
結果として、暇つぶしで作った過剰に圧縮された同期ロジックが、そのまま本番ワールドへ引き継がれることになりました。
「せっかくこの圧縮ロジックがあるなら、LateJoiner にも完全同期できるし帯域も節約できる、このまま使えばいいじゃないか」という判断でそのまま採用した結果、実験的な実装が設計の根幹になってしまいました。
こうして、同期変数にゲーム状態を極限まで詰め込み、FieldChangeCallback で受信したら展開して状態を復元するという構造が生まれました。ただ当然ながら、本来そこまで複雑にする必要のない部分まで圧縮してしまったので、バグが紛れ込みやすい作りになってしまいました。ワールド公開後や大会のときにいくつか不具合を起こしてしまい、プレイヤーの皆さんにご迷惑をおかけしました。本当に申し訳なかったです。
技術的には我ながら面白いと思っているので、ここで紹介します。
なぜ「圧縮」が必要なのか
VRChatでゲームの状態(誰がどの駒を持っているか、盤面の様子、スコアなど)を参加者全員に届けるには、その情報をネットワーク越しに送信する必要があります。
UdonSharpで BehaviourSyncMode.Manual を使う場合、RequestSerialization() を呼ぶことで同期対象のフィールドが送信されます。送信データが大きいと送信レートが制限されるため(参考: Networking Specs & Tricks | VRChat Creation)、なるべく少ない同期変数に多くの情報を詰め込むのが重要です。
スコフィごいたでは、
- 複数卓を並べる構成でも同期負荷を抑えたい
- LateJoiner にも完全なゲーム状態を渡したい
という要件があって、手駒・盤面・スコア・ターン情報といったゲーム状態をすべて同期変数に持たせる必要がありました。
そこで「1つの変数にどれだけ情報を詰め込めるか」という方向で、ビット演算や進数変換を使った圧縮方式を採用しています。
同期変数は以下の16個です(参加プレイヤー名の文字列を除く)。
[UdonSynced, FieldChangeCallback(nameof(P1))] private uint _p1; [UdonSynced, FieldChangeCallback(nameof(P2))] private uint _p2; [UdonSynced, FieldChangeCallback(nameof(P3))] private uint _p3; [UdonSynced, FieldChangeCallback(nameof(P4))] private uint _p4; [UdonSynced, FieldChangeCallback(nameof(B1))] private uint _b1 = 1212696648u; [UdonSynced, FieldChangeCallback(nameof(B2))] private uint _b2 = 1212696648u; [UdonSynced, FieldChangeCallback(nameof(B3))] private uint _b3 = 1212696648u; [UdonSynced, FieldChangeCallback(nameof(B4))] private uint _b4 = 1212696648u; [UdonSynced, FieldChangeCallback(nameof(NextGameBits))] private byte _nextGameBits = 0; [UdonSynced, FieldChangeCallback(nameof(ContinueBits))] private byte _continueBits = 0; [UdonSynced, FieldChangeCallback(nameof(Turn))] private byte _turn = 0; [UdonSynced, FieldChangeCallback(nameof(Both5Si))] private bool _both5Si = false; [UdonSynced, FieldChangeCallback(nameof(Score))] private int _score = 0; [UdonSynced, FieldChangeCallback(nameof(PickupMode))] private bool _pickupMode = false; [UdonSynced, FieldChangeCallback(nameof(TabletFixedStates))] private byte _tabletFixedStates = 0;
これらの変数でごいた全体のゲーム状態を表現しています。それぞれどういう圧縮をしているか、順番に見ていきます。
圧縮① P1~P4(uint × 4)— ビットマップで駒の配布を表現
各プレイヤーの手駒は uint(32ビット整数)1つで表します。
ごいたのコマは全部で32枚(「王」×1、「玉」×1、「飛」×2、「角」×2、「金」×4、「銀」×4、「馬」×4、「香」×4、「し」×10)で、各コマに0~31のインデックスが振ってあります。
「ビット」は0か1の2値しか取れない情報の最小単位で、uint は32個のビットを横に並べた整数型です。駒が32枚でビットも32個、ちょうど同じ数なので「第i番ビットが1 ⟺ i番の駒を持っている」というON/OFFとして使えます。
エンコード側はこうなっています。
// OwnerShuffle内(Owner側が駒をシャッフルして配布)
for (int i = 0; i < 8; i++)
{
p1 |= 1u << shufflePieces[i << 2]; // 0,4,8,...番目の駒 → P1
p2 |= 1u << shufflePieces[i << 2 | 1]; // 1,5,9,...番目の駒 → P2
p3 |= 1u << shufflePieces[i << 2 | 2]; // 2,6,10,...番目の駒 → P3
p4 |= 1u << shufflePieces[i << 2 | 3]; // 3,7,11,...番目の駒 → P4
}
i << 2 は i * 4、i << 2 | 1 は i * 4 + 1 と同じです。
デコード側は各ビットを走査して、1 が立っている位置から駒インデックスを復元します。
// SyncPieces内
for (int i = 0; i < 32; i++)
{
if ((P1 >> i & 1) == 1) playerPieces[0][index1++] = i;
if ((P2 >> i & 1) == 1) playerPieces[1][index2++] = i;
if ((P3 >> i & 1) == 1) playerPieces[2][index3++] = i;
if ((P4 >> i & 1) == 1) playerPieces[3][index4++] = i;
}
配布完了の判定は、4人分の OR が 0xFFFFFFFF(全駒が誰かの手に)かつ AND が 0(同じ駒を2人が持っていない)であることを確認します。
if ((P1 & P2 & P3 & P4) == 0u && (P1 | P2 | P3 | P4) == 0xFF_FF_FF_FFu)
32枚の駒と32ビットを1対1対応させた、シンプルなビットマップ設計です。
圧縮② B1~B4(uint × 4)— 乗算パックで盤面を表現
盤面は32マス(4プレイヤー × 8手)あり、4つの uint(B1~B4)で管理しています。1つの uint に1プレイヤー分8マス(= 4ペア)を収める作りです。
各マスの内容は「受け駒スロット番号(-8~+7、マイナスは伏せ駒)」と「攻め駒スロット番号(0~8)」のペアです。ごいたのルールで補足すると、受け駒はそのターンで受けた駒のことです。全員が「なし」を宣言した場合、親は手駒から1枚を裏向きに出して受けとするのですが、この伏せ駒がマイナス値として記録されます。攻め駒は常に表向きなので非負値です。
エンコード式はこうなっています。
1バイト = (受け駒スロット + 8) × 9 + 攻め駒スロット
受け駒スロットは16通り(-8~+7)、攻め駒スロットは9通り(0~8)なので 16 × 9 = 144 で1バイト(256)に収まります。4ペア分を uint に詰めます。
10進数の「23 = 2×10 + 3」と同じ発想ですが、上位は0〜15の16通り、下位は0〜8の9通りと、桁ごとの基数が揃っていません。全桁が同じ基数で揃う純粋な進数表現ではなく、「16通りと9通りの組み合わせを1バイトに収める」というそのままの設計です。
デコード側はこうです。
// SyncBoard1内(SyncBoard2〜4も同じ構造)
uint b = B1;
for (int i = 0; i < 4; i++)
{
int t = (int)(b & 0xFF); // 下位1バイトを取り出す
board[0][i << 1] = t / 9 - 8; // 受け駒スロット(-8〜+7、マイナスは伏せ駒)
board[0][i << 1 | 1] = t % 9; // 攻め駒スロット(0〜8)
b >>= 8; // 次の1バイトへ
}
i << 1 は i * 2、i << 1 | 1 は i * 2 + 1 と同じです。
初期値 boardInitValue = 1212696648u はゲーム開始前の盤面が空の状態を表しています。空マスは「受け: 0(伏せ駒なし), 攻め: 0」なので (0+8)×9+0 = 72 になります。
// (((8*9<<8)+8*9<<8)+8*9<<8)+8*9 = 1212696648u
各バイトが72(= 8×9)で初期化されているわけです。この定数を使って B1 == boardInitValue で盤面が初期状態かどうか判定できます。
圧縮③ Turn(byte)— 1バイトに4つの意味
Turn は1バイト(8ビット)ですが、複数の状態を同時に持っています。8個のビットを「2+2+2+2」の4グループに分けて、それぞれ別の情報として使う設計です。1枚のメモを4つの欄に区切るイメージですね。
// ビット構成 // [7][6][5][4][3][2][1][0] // ↑ ↑ ↑ ↑ └──┘ └──┘ // | | | | parent turn // | | └──┴── 次ゲーム準備フラグ(チーム1・2) // └──┴──────── ゲーム終了フラグ(チーム1・2)
- bits 0-1: 現在のターンプレイヤー (0〜3)
- bits 2-3: 攻め側(親)のプレイヤー (0〜3)
- bits 4-5: 「次のゲーム準備中」フラグ(ビット4=チーム1、ビット5=チーム2)
- bits 6-7: 「ゲーム終了」フラグ(ビット6=チーム1、ビット7=チーム2)
デコードはこうなっています。
// DelayChangeTurn内 int parent = Turn >> 2 & 0b00000011; // bits 2-3: 攻め側プレイヤー int turn = Turn & 0b00000011; // bits 0-1: 現在のターン
if ((Turn & 0b11000000) != 0) FinishGame(); // bits 6-7 のどちらかが1 → ゲーム終了 if ((Turn & 0b00110000) != 0) PrepareNextGame(); // bits 4-5 のどちらかが1 → 次ゲームへ
エンコード例(ゲーム終了時):
// roundWinner = 上がったプレイヤーの番号(0〜3) // 上がった人が次の親かつ次のターン先頭になるので bits 2-3 と bits 0-1 に同じ値 // チーム1勝利(チーム1=ビット6) Turn = (byte)((score1 >= 150 ? 0b01010000 : 0b00010000) | roundWinner << 2 | roundWinner); // チーム2勝利(チーム2=ビット7) Turn = (byte)((score2 >= 150 ? 0b10100000 : 0b00100000) | roundWinner << 2 | roundWinner);
ターン・攻め側・準備・終了の4種類を1バイトに詰め込んでいます。
圧縮④ Score(int)— 2チームのスコアを1変数に
ごいたは150点を先取したチームが勝ちです。2チームのスコアを1変数に収めるなら16ビットで足りそうに見えますが、ごいたには「5し」という特殊ルールがあります。同チームの2人がそれぞれ「5し」を達成すると150点が一気に加算されます。140点保有時にこれが起きると 140 + 150 = 290点になるので、1チームのスコアは最大290点まで表現できる必要があります。
290を表すには9ビット必要です(29 = 512 > 290)。2チーム分で18ビット以上必要なので16ビット整数では足りません。そこで int(32ビット)を使います。
実際には290より大きくて2のべき乗にきりがよい 512(= 29)を基数にして詰め込んでいます。
// エンコード Score = (score1 << 9) | score2; // デコード score1 = Score >> 9; score2 = Score & 0x1FF; // 0x1FF = 511 = (1<<9)-1
score1 を上位9ビット、score2 を下位9ビットに詰め込む形です。たとえば score1=10・score2=30 なら (10 << 9) | 30 = 5150。取り出すときは 5150 >> 9 = 10、5150 & 0x1FF = 30 で戻ります。
圧縮⑤ ContinueBits(byte)— 2種類の準備フラグを1バイトに
ContinueBits は1バイトで「全員の続行OK」と「5し宣言」の2種類のフラグを管理しています。
- 下位4ビット(bits 0-3): 各プレイヤーの「ゲーム続行OK」フラグ
- 上位4ビット(bits 4-7): 各プレイヤーの「5し宣言」フラグ
// 全員続行OKか判定
if ((ContinueBits & 0b00001111) == 0b00001111) { /* 全員続行 → ゲーム開始 */ }
// プレイヤーiの5し宣言を参照
if ((ContinueBits >> i + 4 & 1) == 1) { /* プレイヤーiが5し宣言 */ }
圧縮⑥ NextGameBits(byte)— 次ゲームへの準備完了フラグ
NextGameBits の下位4ビット(bits 0-3)はそれぞれ4プレイヤーの「次のゲームへ進む」ボタン押下フラグです。
NextGameBits = (byte)(NextGameBits | (1 << player - 1)); // プレイヤーが押した
if ((NextGameBits & 0b00001111) == 0b1111) { /* 全員準備完了 */ }
FieldChangeCallback — 圧縮変数をリアクティブに展開するパターン
すべての同期変数に [FieldChangeCallback] 属性が付いています。
[UdonSynced, FieldChangeCallback(nameof(Turn))]
private byte _turn = 0;
public byte Turn
{
get => _turn;
set
{
_turn = value;
ChangeTurn(); // ← ネットワーク同期時に自動で呼ばれる
}
}
この属性を使うと、ネットワーク越しに _turn が更新されたとき、setter が自動で呼び出されます。setter の中で ChangeTurn() を呼んでいるので、圧縮された byte が届いた瞬間に展開してUI更新まで走ります。
[FieldChangeCallback] は「値が書き換えられたら指定した処理を自動呼び出し」する仕組みなので、受信側は自分で処理を呼び出す必要がなく、VRChatのSDKが面倒を見てくれます。
Turnが変わる →ChangeTurn()→ ビット分解 → ターン表示・ゲーム状態遷移B1が変わる →SyncBoard1()→ 乗算パックデコード → 盤面メッシュ更新Scoreが変わる →ChangeScore()→Score >> 9Score & 0x1FF→ スコア表示更新P1〜P4が変わる →SyncPieces()→ ビット走査 → 手駒ボタン更新
圧縮したまま送って、受信したら自動展開する。これが実装の中心となるパターンです。
まとめ
同期変数設計をまとめると次の通りです。
| 変数 | 型 | 圧縮方式 | 格納情報 |
|---|---|---|---|
| P1〜P4 | uint × 4 | ビットマップ(1bit = 1駒) | 4人の手駒配布(32枚) |
| B1〜B4 | uint × 4 | 乗算パック(1byte = 受け+攻めペア) | 盤面32マスの状態 |
| Turn | byte | ビット分割(2+2+2+2bits) | ターン・攻め側・準備・終了フラグ |
| Score | int | ビットシフトパック(×512) | 2チームのスコア |
| ContinueBits | byte | ビット分割(4+4bits) | 続行OK + 5し宣言フラグ |
| NextGameBits | byte | ビットフラグ(4bits) | 次ゲーム準備完了フラグ |
「同期変数をどこまで削れるか」という実験が起点とはいえ、複数卓を並べながらLateJoinerにも完全な状態を渡すという要件に対しては、圧縮は単なるこだわりではなく妥当な選択になっていました。FieldChangeCallback で「受信したら自動展開する」パターンも、今見るとなかなかきれいにまとまっていると思います。
ただ、「自分が面白くて書いたコード」と「人が遊ぶゲームのコード」は別物で、その区別を最初からつけられていなかったのは素直に反省しています。圧縮率より読みやすさを取るべき場所はあったし、実際そのツケは後から返ってきました。
それでも、このようなご縁がなければ、このコードは誰にも見られずにどこかに消えていたはずです。過剰な圧縮も、不具合の記憶も、全部ひっくるめてスコフィごいたというゲームになっています。もうちょっとちゃんと書けとは今も思いますが。



