MagicOnionでローカルでのゲームっぽいものを作る【Unity,gRPC,MagicOnion】

仕事でgRPCを触る機会があったので、せっかくだったら個人でもゲームっぽいものを作りたいということで n回目のMagicOnionに挑戦しました
(過去に二回ほど環境構築やらでつまづいています)

記事を書いていて特に書くことがないので、メモ程度になりました

Demo

ローカルPC内で複数のアプリから任意のルームへの参加して移動およびチャット機能の実装をしたデモ動画になります。
有料セットなどを抜いたRepositoryはこちらになるので、コードはRepositoryからご確認ください。

youtu.be

環境

  • Unity 2020.3.25f1

使用したアセット

SDトーコちゃん

実装部分

環境構築やサーバーとクライアントとの基本のデータ送信部分は参考サイトをそのままなので、サイトを見てください。

任意のPrefabを生成する

参考サイトでは基本オブジェクトの生成のみしかなかったので、せっかくなので好きなアバターを生成したいので簡単に以下のように変更しています。

  1. ゲームシーンに切り替わるまでサーバーとの通信を待機して、切り替わったら接続と同時にPrefab情報を登録。(*1)
  2. ClientHubからで登録されたPrefabを使ってプレイヤーモデルを生成
  3. サーバーにはPrefabのposition,rotationのみを送信(今回はモデルの種類は一つのため)

(*1)

_gamingHubClient = new GamingHubClient
{
    CharacterGameObject = otherPlayerPrefab
};
await _gamingHubClient.ConnectAsync(_channel);

(*2)

        void IGamingHubReceiver.OnJoin(Player player, string playerName)
        {
            var playerObject = Object.Instantiate(CharacterGameObject);

            if (player.Name == playerName)
            {
                _myObject = playerObject;
            }

            playerObject.GetComponent<CharacterHandler>().SetName(player.Name);

            playerObject.name = player.Name;
            playerObject.transform.SetPositionAndRotation(player.Position, player.Rotation);
            _players[player.Name] = playerObject;
        }

参考サイト

qiita.com

qiita.com

zenn.dev

zenn.dev

UnityでのURLのPingの非同期確認方法【Unity,C#】

はじめに

特定のIPアドレスがとっているかどうかを接続する前に、確認したいときがあります。 C# での同期的な処理やUnityのPingを使った方法(timeout時間は指定できない?)は、以下のようにできるみたいでした。

santerabyte.com

qiita.com

非同期でtimeout時間を指定した場合がすぐにわからなったのでまとめておきます

実装

参考サイト docs.microsoft.com

Ping には Unityのクラスを使用せずに、System.Net.NetworkInformation.Ping を使用しています。    Unity側のPingで timeoutを指定して、非同期にする方法がわからず...

var ping = new Ping();
response = await ping.SendPingAsync(Url, TimeOut);

オフラインゲームのデータをGitHubに半永久的に保存する

序論

ひと昔ではオフラインゲーム(フリーゲーム)が流行っていました.
いまではオンラインゲームが主流となりゲームデータはクラウド上に保存され,SNSがあればどこからでもどの端末からでもセーブした段階からはじめることができるようになりました(*1). しかし,オフラインゲームはオンラインに保存されないため数年後に同じゲームをやりたいとなると過去のセーブデータを探さなくていけません.数年前のデータは基本保存されていない・どこにあるかわからないことが普通です.

(*1) : ログインしていない期間が長いため運営側でのアカウント削除やサービス終了は起こりうる

目的

今回はデータがどこにいったのかわからない・そもそも数年前のデータなんてないという事態をさけるために, GitHub を使った半永久的なデータ保存方法をメモしておきます
(ゲーム以外にも卒論の資料作成や小説の文章管理などさまざまなところにも活用できるかと思います)

対象者

  • Git,GitHubを触ったことがある人
  • オフラインゲームをやっていてセーブデータがなくて同じゲームを何回もやり直している人
  • 以下の環境の方
    • Windows OS
    • Gitがインストール済み

GitHubへbatファイルで一定時間ごとに保存する

今回は GitHubに手作業で commit・pushすることすらめんどくさいので,batファイルを使用して自動化してしまいます

Batファイルの作成

Batファイルについては以下を参考にしてください

e-words.jp

  1. はじめに 自動的に実行するためにBatファイルを作成します

右クリックからTextを選んでTextファイルを作成します.

f:id:ayousanz:20211213202743p:plain

2 . Textファイルの中身を以下のコードをコピペします

:loop

git add *
git commit -m "save"
git push origin main

powershell sleep 300

goto :loop
  1. テキストを任意の名前にして,Batファイルとして保存する

ファイルの内容にコードを入れたら,「名前をつけて保存」からBatファイルとして保存します

ファイル名の一番後ろに .bat と入れます. またファイルの種類は「すべての種類」を選択します

f:id:ayousanz:20211213203323p:plain

Batファイルの動作確認

生成したファイルをダブルクリックして,実行すると以下のような画面が出てくれば成功です.

f:id:ayousanz:20211213203556p:plain

GAS経由でGitHub IssueをGoogle Sheetへ自動更新【GoogleAppsScript】【GitHub】

プロジェクト管理をするときにいろいろ選択肢があるのかで,個人プロジェクトや同人チームだとGoogle スプレッドシートを使うことが多々あるかと思います. GitHubのissueと当時に使用するときに両方更新するのはめんどくさいので,issue側を使用してシートは確認用で自動的に更新されるようにしました (実際に所属しているチームで使用しています)

導入前のタスク管理方法と過去の状態

以下のチーム(二人や一人のところもあるのでチームとは呼べない部分もありますが,ここではチームをします)があります.

  • Unityでのゲーム実装
  • ゲームの企画と仕様
  • ゲームで使用するイラスト
  • 運営上のタスク

これらの全体のタスクを可視化して,進捗を確認するためにすべてをGoogle スプレッドシートで管理を行っていましたが,開発側はGitHubを使用しているためissueを使いタスクのcloseなどを自動化したいという要望がありました. そこでGitHubのissueをメインで使用して,スプレッドシートを確認用だけに変更する方針にしました.

Demo

実際のGooleスプレッドシート側の画像になります.ぼかしていますが,タスク名・タスク内容・担当・issueへのリンクを表示しています.

GitHubからissueの取得

GitHubのアクセストークンの取得方法

まずはGASからGitHubにアクセスしたいので,GitHubのアクセストークンを発行します.

  1. GitHubの右上のところからSettingsを開きます.

2 . サイドメニューの Deleloper settings を開きます.

3 . Personal sccess token から Generate new token で新規にトークンを作成します

生成時の設定は repo に入れてます. 名前は任意のものを入れて,日程は使用する期間を設定します.

実装内容

上記でアクセストークンを取得することができたため,以下のスクリプトで任意のRepositoryのissueを取得することができるようになります. issueのステータスは,以下の3つを指定できますので使用する状況に合わせて変更していきます

状態 パラメータ
全て取得 all
開いているもの open
閉じているもの closed

(以下のコードはopenで指定しています)

参考サイト(GitHubのissueのAPIドキュメント)

https://docs.github.com/en/rest/reference/issues

function GetOpenIssues() {
  var url = 'https://api.github.com/repos/GitHub Your Name/RepositoryName/issues?state=open&per_page=100';
  var accessToken = 'your accessToken';
  var headers = {
    'Authorization': 'token '+ accessToken
  };

  var options = {
    'method': 'GET',    // GETやPUTなどを指定します。Qiita APIによって異なります
    'headers': headers,  // 上で作成されたアクセストークンを含むヘッダ情報が入ります
  };
  var response = UrlFetchApp.fetch(url, options).getContentText();
  var data = JSON.parse(response);
  return data;
}

Googleスプレッドシートの更新

スプレッドシートのIDの取得

まず更新シートのIDを取得します.

実装内容

取得したスプレッドシートのIDからデータの更新を行っていきます. 先ほどGitHubのissueから一覧を取得したので,これらの一覧データを引数に入れてスプレッドシート内のデータを更新しています.

function UpdateGSheet(issueDataList) {
  var sheetId = 'your sheet id';
  var sheetName = 'クライアント';
  var ss = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);

  var issueObjectList = []
  var issueSize = Object.keys(issueDataList).length;
  console.log("issue size:" + issueSize);
  for(var index = 0;index < issueSize ;index++){
    var d = issueDataList[index];
    var issueObject = UpdateIssue(d);
    issueObjectList.push(issueObject);
  }
  var issueInfoSize = Object.keys(issueObjectList[0]).length;
  console.log(issueSize + "," + issueInfoSize)

  // シートをクリア
  ClearBeforeSheetInfo(ss);

  ss.getRange(2,1,issueSize,issueInfoSize).setValues(issueObjectList);
}

function ClearBeforeSheetInfo(sheet){
  var range = sheet.getRange("A2:X100");
  range.clear({contentsOnly: true});
}

function UpdateIssue(issueData){
  var assign = issueData['assignee']
  var labelName = issueData['labels']

  var issueList = []
  if(assign == null){
    issueList = [issueData['number'],issueData['title'],"修正中","指定なし",issueData['url']]
  }else{
    issueList = [issueData['number'],issueData['title'],"修正中",GetUserName(assign['login']),issueData['url']]
  }
  return issueList;
}

一時間ごとに自動更新を行う.

手動でissueの更新をスプレッドシートに更新していたのでは意味がないので,GASのトリガーで一定時間ごとにスクリプトを実行するように設定します.

トリガーの設定画面

unity1week「ちゅう」のゲームで実装したイベントアニメーション【unity1week,Unity】

今回プロなろというグループでチーム開発を行いました!

ジャンプキングに似たやりこみのあるゲームになっています

Repositoryは公開しているので,細かい実装はRepositoryを参照ください

https://github.com/ayutaz/u1k-pronarogithub.com

(注) : 実装説明により一部ゲームのネタバレ等含みます

開発環境

  • Unity 2020.3.17f1
  • Github(主にCI/CD用)

使用アセット(関連するもののに記載)

UPM

Asset Store

イベントアニメーション

各画面のイベントアニメーション

各画面にて以下のようなゲームイベントが存在します

これらを実装するために,以下を採用しました

タイトルの背景アニメーション

ゲーム開始時のアニメーション(ゲーム画面)

ゲームクリア時のアニメーション(ゲーム画面)

クリア画面の背景アニメーション

ゲームオーバー画面のアニメーション

実装方法

こちら基本的には DOTweenを 以下の方法で使用してイベントアニメーションを再生しています

  1. Sequenceを生成して,任意のところで再生
  2. trasform.DO~をUniTask型に変換して,awaitで前の処理が終わるまで待機

タイトルは Sequenceで実装しているので,以下のように実装しました

_sequence = DOTween.Sequence()
                .Append(transform.DOJump(new Vector3(3.65f, _firstYPosition, 0f), 0.3f, 10, 10f))
                .AppendCallback(() => characterDialogue.SetView(true).Forget())
                .AppendCallback(() => _rigidbody2D.bodyType = RigidbodyType2D.Dynamic)
                .Append(transform.DORotate(new Vector3(0f, 0f, 360f), 5f, RotateMode.FastBeyond360))
                .AppendCallback(() =>
                {
                    characterDialogue.SetView(false).Forget();
                    //ループ対応
                    Reset();
                });

            _sequence.Restart();

ゲームの開始時のアニメーションはDOTweenをawaitしています

        public async UniTask StartEvent()
        {
            await FallHole();
            await SetStartPosition();
            await Conversation();
        }
        
        private async UniTask FallHole()
        {
            player.DORotate(new Vector3(0f, 0f, 360f + 180f), 5f, RotateMode.FastBeyond360);
            await player.DOMoveY(-911f, 5f).ToAwaiter();
        }

        private async UniTask SetStartPosition()
        {
            player.DOJump(new Vector3(-45f, -900f, 0f), 200f, 1, 3f);
            await player.DORotate(new Vector3(0f, 0f, -180f), 3f).SetRelative(true).ToAwaiter();
        }

        private async UniTask Conversation()
        {
            await playerCharacterDialogue.SetView(true);
            playerCharacterDialogue.SetContent(firstConversationContent[0]);
            await UniTask.Delay(TimeSpan.FromSeconds(WaitBetweenConversationTime));
            playerCharacterDialogue.SetContent(firstConversationContent[1]);
            await UniTask.Delay(TimeSpan.FromSeconds(WaitBetweenConversationTime));
            await playerCharacterDialogue.SetView(false);
        }

ゲームのクリア情報のツイート機能

こちらの記事を参考に実装しました

画像のツイートは,できない時とできるときがあり調査時間がなかったため文字のみになりました

unity-senpai.hatenablog.com

クリア時間のランキング機能

unity roomのランキングはnaichiさんが公開されているものを使用させていただきました

時間は 〇分〇秒で表示したかったのでフォーマットを mm:ss に設定しています

blog.naichilab.com

開発運用(CI/CD)

今回の制作ではGithub Actionsを使って,テストとビルドを自動化しました

前に記事を書いたので,そちらを参考にしてみてください

https://ayousanz.hatenadiary.jp/entry/2021/05/27/%E5%80%8B%E4%BA%BA%E9%96%8B%E7%99%BA%E3%81%A7Github_Actions%E3%81%A7Unity%E3%81%AECI/CD%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%9F_%E3%80%90Unity%2CGithub_Actions%28game-ci%29%E3%80%91ayousanz.hatenadiary.jp

設計

1週間の期間ですが,設計をある程度意識して実装をしています

UniRx,UniTaskを使ってMV(R)Pで実装をしています

各シーンのクラス図は以下のようになっています

タイトルシーン

ゲームシーン

ゲームクリアシーン

ゲームオーバーシーン

またフォルダは一応(めっちゃあいまいですが)ドメインごとにわけました

コンピュータサイエンスやその他最近の技術を学べる/活用できるサイト【Unity,Blender,Python ...】

コンピュータサイエンス全般

コンピュータサイエンスについての無料公開をまとめたリポジトリ

github.com

ゲームによく使われるアルゴリズムがまとまっているサイト

www.redblobgames.com

gRPCについて

github.com

Backendless(BaaS)とUnityを使ったオンラインショップの画面の作成【Unity,SaaS】

今回はゲームコンテストで作ろうと思っていたオンラインゲームのショップ画面をプロトタイプで作成してみました (たぶんコンテストにはこの要素はいれないです)

Demo

開発環境

  • Unity 2019.4.28f1
  • Backendless(BaaS)

使用したアセット

あまり関係ないですが,内部で以下も使用しています

backendlessとは?

(ほかのSaaSと同じなので,SaaSの説明を引用)

SaaS」(Software as a Service:「サース」または「サーズ」)とは、ソフトウェアを利用者(クライアント)側に導入するのではなく、提供者(サーバー)側で稼働しているソフトウェアを、インターネット等のネットワーク経由で、利用者がサービスとして利用する状況を指します。

引用元

SaaSとは | クラウド・データセンター用語集/IDCフロンティア

参考にした参考書

booth.pm

公式ドキュメント

backendless.com

実装

実装した機能としては以下です

  • Moqデータとしてコード側で一定するのデータをDBに追加する関数
  • 画面上からアイテムの購入(図1)
  • 購入したアイテムの削除

図1

各種非同期部分の書きかたと改善作成

非同期部分の書き方はtwitter で KOGAさんにアドバイスいただきました

詳細は以下のtweetのスレをご確認ください (今回はアドバイスをすべて反映できていません)

クラス図

スクリプト

DBHandler

using System;
using System.Collections.Generic;
using _OnlineShop.Scripts;
using UnityEngine;
using BackendlessAPI;
using BackendlessAPI.Persistence;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEditor;
using Random = UnityEngine.Random;

public class DBHandler : IDBHandler,IDisposable
{
    private readonly CompositeDisposable _compositeDisposable = new CompositeDisposable();

    public async void DeleteItemData(string tableName,Dictionary<string, object> itemInfo)
    {
        var savedContact = await Backendless.Persistence.Of(tableName).SaveAsync(itemInfo);
        await Backendless.Persistence.Of(tableName).RemoveAsync(savedContact);
    }

    //データの新規追加(同じ名前があっても新規に追加される)
    public async void AddItemData(string itemName,int itemCount,int itemPrice)
    {
        var dict = new Dictionary<string, object>
        {
            {"itemName",itemName},
            {"itemCount",itemCount},
            {"itemPrice",itemPrice}
        };
        var result = await Backendless.Data.Of("ShopList").SaveAsync(dict);
        Debug.Log($"result:{result}");
    }

    public async void UpdateItemData(string itemGroup,string itemName,int itemCount,int itemPrice)
    {
        var result = await Backendless.Data.Of(itemGroup).FindFirstAsync();
        result[itemName] = 30;
        await Backendless.Data.Of("Person").SaveAsync(result);
    }

    public async UniTask<IList<Dictionary<string, object>>> GetTableData(string tableName)
    {
        var query = DataQueryBuilder.Create();
        query.AddSortBy("created desc");
        query.SetRelationsDepth( 0 );
        return await Backendless.Data.Of(tableName).FindAsync(query).AsUniTask();
    }

    public void AddMoqData(string tableName)
    {
        for (var index = 1; index <= 6; index++)
        {
            AddItem(index);
        }
        async void AddItem(int itemNo)
        {
            var itemName = $"{tableName}" + itemNo;
            var dict = new Dictionary<string, object>
            {
                {"itemName",itemName},
                {"itemCount",Random.Range(0,30) * 5},
                {"itemPrice",Random.Range(2,100) * 10}
            };
            await Backendless.Data.Of(tableName).SaveAsync(dict);
        }
    }
    
    #if UNITY_EDITOR
    void HandleOnPlayModeChanged( PlayModeStateChange stateChange )
    {
        // This method is run whenever the playmode state is changed.
        if ( stateChange == PlayModeStateChange.ExitingEditMode || stateChange == PlayModeStateChange.ExitingPlayMode )
        {
            Backendless.RT.Disconnect();
        }
    }
    #endif
    public DBHandler()
    {
#if UNITY_EDITOR
        EditorApplication.playModeStateChanged += HandleOnPlayModeChanged;
#endif
    }

    public void Dispose()
    {
        _compositeDisposable?.Dispose();
    }
}

GameLifetimeScope

using _OnlineShop.Scripts;
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class GameLifetimeScope : LifetimeScope
{
    [SerializeField] private ShopShopView shopShopView;

    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponent(shopShopView).AsImplementedInterfaces();
        builder.Register<DBHandler>(Lifetime.Singleton).AsImplementedInterfaces();
        builder.RegisterEntryPoint<GameManager>();
    }
}

GameManager

using System;
using System.Linq;
using _OnlineShop.Scripts;
using UniRx;
using UnityEngine;
using VContainer.Unity;

public class GameManager : IStartable,IDisposable
{
    private readonly IDBHandler _dbHandler;
    private readonly IShopView _shopView;
    private readonly CompositeDisposable _compositeDisposable = new CompositeDisposable();
    private GameManager(IDBHandler dbHandler,IShopView shopView)
    {
        _dbHandler = dbHandler;
        _shopView = shopView;
    }

    public async void Start()
    {
        // _dbHandler.AddMoqData("Food");
        var d = await _dbHandler.GetTableData("Food");
        foreach (var (item,index) in d.Select((item,index) => (item,index)))
        {
            _shopView.SetText(item["itemName"].ToString(),item["itemCount"].ToString(),item["itemPrice"].ToString(),index);
        }

        _shopView.BuyItemObservable().Subscribe(value =>
        {
            _shopView.SetBuy(true,value);
            Debug.Log($"food:{value}を購入");
            _dbHandler.DeleteItemData("Food",d[value]);
        }).AddTo(_compositeDisposable);
    }

    public void Dispose()
    {
        _compositeDisposable?.Dispose();
    }
}

Item

using System;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace _OnlineShop.Scripts
{
    public class Item : MonoBehaviour
    {
        [SerializeField] private Text itemNameText;
        [SerializeField] private Text itemCountText;
        [SerializeField] private Text itemPriceText;
        [SerializeField] private Image itemIcon;
        [SerializeField] private Button buyButton;
        [SerializeField] private Image backgroundImage;

        public IObservable<Unit> OnClickBuyButton()
        {
            return buyButton.OnClickAsObservable();
        }

        public void SetText(string itemName, int itemCount, int itemPrice,int iconNo)
        {
            itemNameText.text = itemName;
            itemCountText.text = $"{itemCount}個";
            itemPriceText.text = $"${itemPrice}";
            itemIcon.sprite = GetIcon(iconNo);
        }

        private static Sprite GetIcon(int iconNo)
        {
            return Resources.Load<Sprite>($"Food/Icon{iconNo}");
        }

        public void SetBuy(bool isBuy)
        {
            backgroundImage.color = new Color(115, 115, 115);
            buyButton.interactable = false;
        }
    }
}

ShopShopView

using System;
using System.Collections.Generic;
using System.Linq;
using UniRx;
using UnityEngine;

namespace _OnlineShop.Scripts
{
    public class ShopShopView : MonoBehaviour,IShopView
    {
        [SerializeField] private List<Item> itemList = new List<Item>();

        private readonly Subject<int> _buySubject = new Subject<int>();
        
        private void Awake()
        {
            foreach (var (item,index) in itemList.Select((item,index) => (item,index)))
            {
                item.OnClickBuyButton().Subscribe(_ =>
                {
                    _buySubject.OnNext(index);
                }).AddTo(this);
            }
        }

        public void SetView(bool isView)
        {
            gameObject.SetActive(isView);
        }

        public void SetText(string itemName, string itemCount, string itemPrice,int itemNo)
        {
            if (itemNo < itemList.Count)
            {
                itemList[itemNo].SetText(itemName,int.Parse(itemCount), int.Parse(itemPrice),itemNo+1);
            }
        }

        public void SetBuy(bool isBuy,int itemNo)
        {
            itemList[itemNo].SetBuy(isBuy);
        }

        public IObservable<int> BuyItemObservable()
        {
            return _buySubject;
        }
    }
}