1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unity WebGLでカードゲーム向けリザルト管理ツールを作ったときの設計と改善の話

1
Posted at

Unity WebGLでカードゲーム向けリザルト管理ツールを作ったときの設計と改善の話

はじめに

Unity WebGLで、カードゲーム「XrossStars」向けの非公式リザルト管理ツールを制作しました。

もともとは、よく一緒に大会へ出るチームメンバー向けに作っていたツールです。
大会に出るたびに、相手のデッキ情報や勝敗をスマートフォンのメモ帳に記録していたのですが、毎回リーダーやACE、タクティクス、勝敗を手入力するのはかなり手間でした。

また、メモが増えていくと「これはいつの大会の記録なのか」「どの対戦で使ったデータなのか」が後から分かりづらくなります。

そこで、大会ごとの使用デッキや対戦履歴を管理できる専用ツールを作ることにしました。

最初はチーム内で使えれば十分だと考えていましたが、実際に使っていく中で「一般ユーザー向けにも需要がありそう」と感じたため、UI/UX、チュートリアル、統計画面、データ保存方法を見直して一般公開しました。

公開後は、約500人ほどのユーザーに利用してもらえる規模になりました。

この記事では、チーム内向けツールを一般公開向けに作り直す中で考えたことと、実装面で特に意識した設計についてまとめます。

※本ツールは非公式のファンツールです。公式とは一切関係ありません。

作ったもの

公開しているツールはこちらです。

主な機能は以下です。

  • 大会ごとの使用デッキ記録
  • 対戦履歴の記録
  • 勝敗、先攻/後攻、相手リーダー、ACE、タクティクスなどの入力
  • 記録した対戦の一覧表示
  • 登録された戦績をもとにした統計表示
  • 大会データを他ユーザー画面で閲覧できる共有機能
  • SNSに大会結果を投稿しやすい記録表示
  • WebGLによるブラウザ公開
  • スマートフォン / PC の両対応
トップ画面 リザルト入力画面 対戦履歴一覧画面 統計画面
トップ画面 リザルト入力画面 対戦履歴一覧画面 統計画面

使用技術

主な技術構成は以下です。

項目 使用技術
クライアント Unity
言語 C#
公開形式 Unity WebGL
非同期処理 UniTask
リアクティブ処理 UniRx
UI uGUI / TextMeshPro
ローカル保存 PlayerPrefs
サーバー側保存 Firebase / Firestore REST API
初期のデータ管理 PlayFab
検討したデータ管理 Google スプレッドシート / GAS
公開先 GitHub Pages

Unityを採用した理由は、自分が一番扱い慣れている技術だったことと、もともと開発していたデッキ管理ツールの基盤を活用できたためです。

公開形式はWebGLを選択しました。
カードゲームの補助ツールは、使い始めるまでの手間が大きいと継続して使われにくいと考えています。
そのため、アプリをインストールしなくてもURLを開くだけで使える形にしたいと思いました。

開発の流れ

開発は大きく2段階に分かれています。

1. チームメンバー向け版

最初は、チームメンバーが使える最低限の機能だけを実装しました。
開発期間は2026年1月〜2月ごろです。

この段階で実装した主な機能は以下です。

  • 大会ごとの使用デッキ管理
  • 大会ごとの対戦履歴記録
  • 対戦相手のデッキ情報記録
  • 勝敗の記録
  • 大会データを他ユーザー画面で閲覧できる共有機能

チーム内向けだったため、多少使い方が分かりにくくても、口頭で説明すれば問題ありませんでした。
ただ、実際に使ってもらう中で、入力のしやすさや記録した後の見返しやすさをもっと改善できると感じました。

2. 一般公開版

一般公開版の開発期間は、2026年4月中旬ごろの約1週間です。

チームメンバー向け版を1〜2ヶ月ほど使った上で、一般公開するには以下の課題があると考えました。

  • 使い方を知っている人しか使いやすくない
  • 記録した後のメリットが少ない
  • PlayFabをそのまま運用する場合、費用面や管理面を考える必要がある

この課題を解決するために、一般公開版では主に以下を作り直しました。

  • UI/UXの見直し
  • 初回チュートリアルの追加
  • 統計画面の追加
  • 対戦履歴一覧の追加
  • データ保存方法の見直し
  • WebGL向けの動作確認と軽量化

一般公開に向けて考えたこと

UI/UXを作り直す

チーム内向け版は、使い方を知っている人が操作する前提でした。

しかし、一般公開では初めて触る人が説明なしで使える必要があります。

そのため、以下のような点を見直しました。

  • 初めて使う人でも流れが分かる画面構成にする
  • 入力の順番が自然になるようにする
  • ボタンの配置や大きさを見直す
  • スマートフォンでも押しやすいUIにする
  • 入力済み/未入力の状態を分かりやすくする
  • 画面遷移を整理する
  • 重要な説明を見落としにくい位置に置く

自分だけで判断すると、どうしても「作った本人だから分かるUI」になりやすいです。
そのため、チームメンバーに実際に触ってもらい、分かりづらい部分を何度も修正しました。

記録した後のメリットを作る

リザルト管理ツールは、入力作業が発生するツールです。
そのため、記録した後にメリットがないと継続して使われにくくなります。

チームメンバー向け版では、対戦履歴を記録することはできましたが、記録したデータをもとにした統計表示が少ない状態でした。

そこで、一般公開版では統計画面を追加しました。

表示している情報の例です。

  • デッキ構成ごとの使用率
  • デッキ構成ごとの勝率
  • リーダーごとの使用率 / 勝率
  • ACEの傾向
  • タクティクスの傾向
  • 先攻 / 後攻ごとの勝率
  • 直近2週間 / 全期間の切り替え

ただ数値を並べるだけではなく、ユーザーが「記録してよかった」と感じられる画面になることを意識しました。

データ保存方法を見直す

チームメンバー向け版では、データ管理にPlayFabを使用していました。

数人規模で使う分には問題ありませんでしたが、一般公開する場合はユーザー数が増える可能性があります。
PlayFabを開発モードから運用モードへ移行すると費用が発生するため、一般公開版ではデータ管理方法を改めて考えました。

最初は、Google スプレッドシートにGASを使って追記していく方法も検討しました。
スプレッドシートであれば、表形式で確認しやすく、後から管理しやすいメリットがあります。

ただ、数人規模なら問題なくても、数十人以上が使う場合、同時書き込みや処理負荷によって記録漏れが発生する可能性を懸念しました。

そこで、一般公開版ではFirebase / Firestoreを使用する方針に変更しました。

Firebaseを選んだ理由は以下です。

  • ユーザーデータを管理しやすい
  • 対戦データを保存しやすい
  • 一般公開時のスケールを見込みやすい
  • 必要に応じてスプレッドシート側にデータを取り出して確認できる
  • 見やすい管理と安定した保存の両方を取りやすい

この部分は、作りやすさだけでなく、公開後の運用も含めて判断する必要がありました。

全体設計

一般公開版では、ローカル保存とサーバー送信を分ける設計にしました。

大まかな流れは以下です。

ユーザーが大会・対戦結果を入力
        ↓
TournamentLog / MatchLog として保持
        ↓
PlayerPrefs にローカル保存
        ↓
Firestore 送信用に dirty 登録
        ↓
未送信・大会完了・一定時間無操作などのタイミングで Firestore へ送信
        ↓
保存されたデータをもとに統計を計算

WebGLでは通信失敗やタブ終了が起こる可能性があります。
そのため、ユーザーが入力した記録をサーバー送信だけに依存させず、まずローカルに保存するようにしました。

Firestoreへの送信に失敗しても、次回起動時に未送信データを復元して再送できる構成にしています。

データ構造

大会単位のデータは TournamentLog、1試合ごとのデータは MatchLog として管理しています。

記事用に主要な部分だけ抜粋すると、以下のような形です。

[Serializable]
public class TournamentLog
{
    public string id;
    public string date;
    public string tournamentName;

    public int[] myDeckLeader = new int[4];
    public List<int> myAceIds = new();
    public List<MatchLog> matches = new();

    public bool isSynced = false;
}

[Serializable]
public class MatchLog
{
    public int[] opponentLeader = new int[4];
    public List<int> opponentAceIds = new();

    public bool isFirst;

    public int[] myTactics = new int[3];
    public int[] oppTactics = new int[3];

    // BO3用:1R勝利=bit0, 2R勝利=bit1, 3R勝利=bit2
    public byte winBits;

    public string note;
}

大会全体の情報は TournamentLog に持たせ、1試合ごとの相手情報や勝敗は MatchLog に持たせています。

BO3の勝敗は winBits で管理しています。
単純に bool isWin だけを持つのではなく、ラウンドごとの勝敗をbitで持つことで、後から「1ラウンド目の勝率」や「勝ち進行/負け進行」のような集計にも使えるようにしています。

public bool IsWin()
{
    int winCount = 0;

    for (var i = 0; i < 3; i++)
    {
        if (((winBits >> i) & 1) == 1)
        {
            winCount++;
        }
    }

    return winCount >= 2;
}

入力負荷とデータ品質を両立するために qualityScore を持たせる

リザルト管理ツールで難しいのは、入力負荷とデータ品質のバランスです。

統計の精度を上げるには、相手リーダー、相手ACE、自分のタクティクス、相手のタクティクスなど、できるだけ多くの情報が必要です。
しかし、最初からすべてを必須にすると、1試合ごとの入力が重くなり、継続して使われにくくなります。

そこで、試合データに qualityScore を持たせる設計にしました。

/// <summary>
/// データ品質スコア(0〜4)。
/// 0=相手リーダーなし
/// 1=相手リーダーのみ
/// 2=+相手ACE
/// 3=+自タクティクス
/// 4=全揃い
/// </summary>
public int GetQualityScore()
{
    if (!HasOpponentLeader()) return 0;

    int score = 1;

    if (opponentAceIds != null && opponentAceIds.Count >= 1)
        score++;

    if (myTactics != null && myTactics.Length >= 2
        && myTactics[0] != -1 && myTactics[1] != -1)
        score++;

    if (score == 3 && oppTactics != null && oppTactics.Length >= 2
        && oppTactics[0] != -1 && oppTactics[1] != -1)
        score++;

    return score;
}

詳細な統計では、基本的に qualityScore == 4 のデータを対象にしています。

これにより、ユーザーは最低限の記録から始められる一方で、情報が揃っているデータは信頼性の高い統計に使えるようになります。

入力しやすさと統計の信頼性を同じ条件で無理に解決しようとせず、データの充実度に応じて使い道を分けたのが、この設計のポイントです。

ローカル保存は PlayerPrefs + 圧縮文字列

入力した大会ログは、まずローカルに保存します。

ただし、TournamentLog をそのままJSONで保存すると文字列が長くなりやすいため、保存用に圧縮文字列へ変換しています。

実装では TournamentLogCompact を用意し、以下のような方針にしました。

  • 必要な値をバイナリで書き込む
  • 可変長整数で数値を短く保存する
  • Base64URL形式に変換する
  • CRC32を付けて、壊れたデータを検出できるようにする
  • Versionを持たせて、後からフォーマット変更できるようにする
public void AddTournamentLog(TournamentLog log)
{
    if (log == null) return;

    var encoded = TournamentLogCompact.Encode(log);
    if (string.IsNullOrEmpty(encoded)) return;

    TournamentLogs.Add(encoded);
}

public List<TournamentLog> GetDecodedTournamentLogs()
{
    var result = new List<TournamentLog>();
    if (TournamentLogs == null) return result;

    foreach (var s in TournamentLogs)
    {
        if (string.IsNullOrEmpty(s)) continue;

        var log = TournamentLogCompact.DecodeSafe(s);
        if (log == null) continue;

        result.Add(log);
    }

    return result;
}

DecodeSafe では、壊れているデータを読み込まずに無視するようにしています。

個人開発のツールではありますが、ユーザーの記録データを扱う以上、壊れたデータで画面全体が止まらないようにすることを意識しました。

Firestore送信は dirty 管理で遅延同期する

Firestoreへの送信は、入力のたびに毎回即時送信するのではなく、dirty管理で遅延同期しています。

理由は以下です。

  • 入力のたびに送信すると通信回数が増える
  • 編集途中の不完全なデータを何度も送ってしまう
  • 通信失敗時にユーザーの記録が消えないようにしたい
  • Firestoreへの不要な書き込みを減らしたい

実装では、_dirtyIds_immediateIds の2種類のキューを持たせています。

private readonly HashSet<string> _dirtyIds = new();
private readonly HashSet<string> _immediateIds = new();
private readonly Dictionary<string, string> _lastSentEncoded = new();

private float _lastEditTime = float.MinValue;
private bool _isSending = false;

編集時は MarkDirty を呼びます。

public void MarkDirty(TournamentLog log)
{
    if (log == null || string.IsNullOrEmpty(log.id)) return;

    if (_lastSentEncoded.ContainsKey(log.id))
    {
        // 送信済みログの再編集 → 一定時間無操作後に送信
        _dirtyIds.Add(log.id);
        _lastEditTime = Time.realtimeSinceStartup;
    }
    else
    {
        // 未送信ログ → 即時送信キューへ
        _immediateIds.Add(log.id);
    }
}

Update では、即時送信キューがある場合は送信し、送信済みログの再編集は一定時間無操作になったタイミングで送信します。

private void Update()
{
    if (_isSending) return;

    if (_immediateIds.Count > 0)
    {
        SyncAllDirtyAsync(force: false).Forget();
        return;
    }

    if (_dirtyIds.Count == 0) return;

    var idle = Time.realtimeSinceStartup - _lastEditTime;
    if (idle >= config.idleThresholdSeconds)
    {
        SyncAllDirtyAsync(force: false).Forget();
    }
}

また、前回送信した内容と同じ場合はFirestoreへの書き込みをスキップするようにしました。

var encoded = TournamentLogCompact.Encode(postLog);
if (_lastSentEncoded.TryGetValue(log.id, out var last) && last == encoded)
{
    _dirtyIds.Remove(log.id);
    _immediateIds.Remove(log.id);
    return true;
}

この仕組みにより、ユーザー体験としてはローカル保存で即反映しつつ、Firestoreへの送信回数を抑える構成にしています。

Firestore REST API を UnityWebRequest で叩く

Firestoreへの書き込みは、Firebase SDKに寄せず、UnityWebRequestでREST APIを叩く形にしました。

public async UniTask<bool> SetTournamentAsync(TournamentLog log, CancellationToken ct)
{
    var idToken = await _auth.GetValidIdTokenAsync(ct);
    if (string.IsNullOrEmpty(idToken)) return false;

    var url = BuildDocumentUrl(log.id);
    var json = FirestoreDocumentBuilder.Build(log);
    var body = Encoding.UTF8.GetBytes(json);

    using var req = new UnityWebRequest(url, "PATCH")
    {
        uploadHandler = new UploadHandlerRaw(body)
        {
            contentType = "application/json"
        },
        downloadHandler = new DownloadHandlerBuffer()
    };

    req.SetRequestHeader("Authorization", $"Bearer {idToken}");

    try
    {
        await req.SendWebRequest().ToUniTask(cancellationToken: ct);
    }
    catch (UnityWebRequestException)
    {
        // HTTPエラーは req.result 側で処理する
    }

    return req.result == UnityWebRequest.Result.Success;
}

ドキュメントIDには TournamentLog.id を使い、PATCH で保存しています。
そのため、同じIDの大会ログがすでに存在する場合は上書きされ、重複登録されにくい構成になります。

Firestore REST APIでは、値に stringValueintegerValue などの型タグが必要です。
そのため、TournamentLog からFirestore用JSONを組み立てる専用クラスを用意しました。

また、試合ごとの qualityScore もFirestoreへ保存しています。
これにより、後から集計するときに「詳細な情報が揃っている試合だけを対象にする」という条件を扱いやすくしています。

統計ロジックは UI から分離する

統計画面では、ローカルに保存された大会ログから、デッキ構成やリーダー、ACE、タクティクスなどの傾向を計算しています。

ここで意識したのは、集計ロジックを MonoBehaviour に寄せすぎないことです。

統計画面は、後から表示項目や条件が増えやすい部分です。
そのため、UI側に集計処理を書きすぎると、後から変更しづらくなると考えました。

実装では、集計処理を LocalTournamentStatsService に寄せ、UI側は条件を渡してViewModelを受け取る形にしています。

public static class LocalTournamentStatsService
{
    public static LocalStatsScreenViewModel Calculate(
        List<TournamentLog> allLogs,
        LocalStatsFilterCondition condition)
    {
        var filtered = FilterLogs(allLogs, condition);

        return new LocalStatsScreenViewModel
        {
            FirstTurnMeta = BuildMeta(filtered, condition, isFirst: true, isWin: null),
            SecondTurnMeta = BuildMeta(filtered, condition, isFirst: false, isWin: null),
            TurnWinRate = BuildTurnWinRate(filtered, condition),
        };
    }
}

記事用に短くしていますが、実際には勝ち進行/負け進行別の集計などもここで生成しています。

UI側は、受け取ったViewModelを表示するだけに近い形にしました。

private void Refresh()
{
    var logs = LocalTournamentLogProvider.GetAll();
    var vm = LocalTournamentStatsService.Calculate(logs, _condition);

    RefreshMetaList(vm.FirstTurnMeta);
    RefreshTurnWinRate(vm.TurnWinRate);
}

この形にしたことで、以下のメリットがありました。

  • 集計ロジックを追いやすい
  • UI変更の影響が集計処理に出にくい
  • 後から統計項目を増やしやすい
  • 表示だけ差し替えたいときに対応しやすい

個人開発では画面側に処理を書きすぎてしまいがちですが、統計のようにロジックが重い部分は早めに分けておくと後から楽になると感じました。

フル構成の統計キーを作る

このツールでは、単体カードだけでなく、リーダー4枚、ACE、タクティクスの組み合わせを見たい場面があります。

そのため、フル構成を文字列キーに変換して集計しています。

private static string BuildKey(int[] leaders, List<int> aces, int[] tactics)
{
    if (leaders == null || leaders.Length < 4) return null;
    foreach (var id in leaders) if (id < 0) return null;

    if (tactics == null || tactics.Length < 3) return null;
    foreach (var id in tactics) if (id < 0) return null;

    var ls = (int[])leaders.Clone();
    Array.Sort(ls);

    var ac = aces != null
        ? aces.OrderBy(x => x).ToList()
        : new List<int>();

    string lKey = string.Join("_", ls);
    string aKey = string.Join("_", ac);
    string tKey = $"{tactics[0]}_{tactics[1]}_{tactics[2]}";

    return $"L:{lKey}|A:{aKey}|T:{tKey}";
}

リーダーとACEは順不同として扱いたかったため、ソートしてからキー化しています。
一方で、タクティクスはラウンドごとの順番に意味があるため、順番込みでキーにしています。

このように、カードごとに「順番を無視するか」「順番を含めるか」を分けることで、見たい統計に合わせた集計ができるようにしました。

WebGL公開で苦労したこと

Unity WebGLで公開する上では、通常のPC向けビルドとは違う問題がいくつかありました。

特に苦労したのは以下です。

  • WebGL向けの軽量化
  • WebGLでは動かない、または挙動が変わる処理への対応
  • PlayerPrefsの保存挙動

WebGL向けの軽量化

WebGLでは、通常のビルド以上にファイルサイズや読み込み時間を意識する必要があります。
不要なアセットを含めないことや、ビルドサイズをできるだけ抑えることを意識しました。

WebGLでは動かない、または挙動が変わる処理への対応

通常のUnity実行環境では問題なく動いていても、WebGLに出すと想定通り動かない処理がありました。

特に、ブラウザ上で動作する都合上、以下のような部分はPC向けビルドとは違う意識が必要でした。

  • 通信処理のタイミング
  • タブを閉じる、フォーカスが外れるなどのライフサイクル
  • スマートフォンブラウザでの入力欄の挙動
  • iOS / Android / PCブラウザごとの表示差

そのため、Editor上やPC向けビルドでの確認だけではなく、実際にWebGLとして公開した状態で動作確認する必要がありました。

PlayerPrefsの保存挙動

このツールでは、入力した大会ログをまず PlayerPrefs に保存しています。

WebGLではブラウザ上のストレージに保存されるため、ユーザーの環境やブラウザ設定によって保存状態の扱いが変わる可能性があります。
また、通信に失敗した場合でもユーザーの記録を残したかったため、サーバー送信だけに依存しない構成にしました。

そのため、ローカル保存とFirestore送信を分け、まず PlayerPrefs に保存してから、未送信データをFirestoreへ同期する流れにしています。

この設計にしたことで、通信失敗や一時的な離脱があっても、入力データをできるだけ守れるようにしました。

作ってみて学んだこと

今回の開発で特に学びが大きかったのは、以下の3つです。

1. チーム内ツールと一般公開ツールは別物だった

チーム内であれば、使い方を直接説明できます。
多少分かりにくいUIでも、前提知識があるため使えてしまいます。

一方で、一般公開では、初めて触る人が説明なしで使える必要があります。

この違いを意識して、UI/UX、チュートリアル、注意書き、データ管理方法を見直す必要がありました。

2. 使われるツールは、機能だけでは決まらない

最初は「戦績を記録できれば便利だろう」と考えていました。

しかし、実際に使ってもらうには、機能があるだけでは足りませんでした。

  • 最初に何をすればいいか分かる
  • 入力が面倒すぎない
  • 記録した結果を見る楽しさがある
  • スマートフォンでも使いやすい
  • 大会後に共有しやすい
  • 注意点が分かりやすい

こういった要素がそろって、初めて継続して使ってもらえるツールになると感じました。

3. 技術選定は、作りやすさだけでなく運用まで考える必要がある

チーム内向けであれば、PlayFabを使った構成でも問題ありませんでした。

しかし、一般公開する場合は、費用、管理方法、データの見やすさ、スケールしたときの安定性も考える必要があります。

GASとスプレッドシートも管理しやすい選択肢でしたが、記録漏れや処理負荷を考えると不安がありました。

最終的にFirebaseを採用したことで、ユーザー管理とデータ管理の両方を扱いやすくなり、一般公開向けの構成に近づけることができました。

今後改善したいこと

今後の改善としては、以下のようなことを考えています。

  • 入力の手間をさらに減らす
  • 統計画面の見やすさを上げる
  • データの信頼性を高める
  • WebGLの読み込み速度を改善する

特に、入力の手間と統計の見やすさは、リザルト管理ツールの使いやすさに直結する部分なので、今後も改善していきたいです。

まとめ

Unity WebGLで、カードゲーム向けの非公式リザルト管理ツールを作り、一般ユーザー向けに公開しました。

もともとはチームメンバー向けの小さなツールでしたが、UI/UXやチュートリアルを見直し、統計機能や対戦履歴一覧を追加し、データ管理方法もPlayFabからFirebase中心の構成へ変更しました。

その結果、約500人に利用してもらえるツールになりました。

今回の開発を通して、以下のことを学びました。

  • チーム内ツールと一般公開ツールでは、必要な設計が大きく違う
  • ユーザーに使ってもらうには、機能だけでなく導線が重要
  • 記録ツールでは、入力負荷とデータ品質のバランスが重要
  • ローカル保存とサーバー送信を分けることで、通信失敗に強くできる
  • Unity WebGL公開では、ビルド後の配信環境やブラウザ差分も考える必要がある
  • 技術選定は、開発しやすさだけでなく運用や費用まで含めて考える必要がある
  • 個人開発でも、実際に使われるものを作る経験は大きな学びになる

今後も、ゲームを遊ぶ人にとって便利で、継続して使いやすいツールになるように改善していきたいです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?