
マイケル
みなさんこんにちは!
マイケルです!
マイケルです!

エレキベア
こんにちクマ〜〜〜

マイケル
本日は一点ご報告です!
我々都会のエレキベアは先週ついに・・・
我々都会のエレキベアは先週ついに・・・

マイケル
ここ最近ずっと作っていた
オセロゲームをリリース
しました!!
オセロゲームをリリース
しました!!

エレキベア
おおお〜〜〜
やったクマ〜〜〜〜〜〜
やったクマ〜〜〜〜〜〜

マイケル
これでゲームアプリは5本目になるわけだけど、
中々綺麗に作れた気がするよ!(面白いかは置いといて・・・)
中々綺麗に作れた気がするよ!(面白いかは置いといて・・・)

エレキベア
(そこは置いたらだめなんじゃ・・・)

マイケル
3ヶ月くらい作っていたせいで、夢の中にまでオセロが出てきた始末・・・
そんな苦しい開発を振り返っていきます!!
興味のある方はお付き合いくださいませ!
そんな苦しい開発を振り返っていきます!!
興味のある方はお付き合いくださいませ!

エレキベア
地獄のオセロ開発クマ〜〜〜

マイケル
なおスクリプトだけにはなりますが、GitHubの方にも挙げていますので、
こちらも是非ご参照ください!!
こちらも是非ご参照ください!!
GitHub – unity-reversi-game-scripts / masarito617

エレキベア
コードも中々の量になったクマね〜〜
工夫・挑戦した点

マイケル
まずは 工夫・挑戦した点 から!
理由もなくこんなオセロゲームを作っていたわけではなく、スキルアップのために様々なことに挑戦しました!
理由もなくこんなオセロゲームを作っていたわけではなく、スキルアップのために様々なことに挑戦しました!

エレキベア
(こんなって言っちゃった・・・)

マイケル
具体的には下記のような点になります!
・DIコンテナの使用と依存関係の整理
・様々なオセロAIの実装
・Cinemachineでのカメラワーク実装
・DOTweenでのアニメーション実装
・UIの効率化と統一
・キャラクターモデルの作成

エレキベア
このオセロ開発でいろんなことを学んだクマね〜〜(しみじみ)

マイケル
一つ一つ見ていくぜ!
DIコンテナの使用と依存関係の整理

マイケル
まずはDIコンテナの使用と依存関係の整理について!
これは開発前から決めていたことで、
・VContainer
・UniTask
・UniRx
といったUnity定番のライブラリを使用して、依存関係を意識しながら開発を進めました。
これは開発前から決めていたことで、
・VContainer
・UniTask
・UniRx
といったUnity定番のライブラリを使用して、依存関係を意識しながら開発を進めました。

エレキベア
確か前回はあえて何もライブラリを使わずに作っていたクマね

マイケル
その通り!
前回は何が何を呼び出しているのかが非常に分かりにくかったけど、DIコンテナ(VContainer)を使用することで依存関係がかなり明確になった気がするよ!
それからサービスクラスに関してもサービスロケータでなくDIコンテナでの登録に置き換えることもできたんだ!
前回は何が何を呼び出しているのかが非常に分かりにくかったけど、DIコンテナ(VContainer)を使用することで依存関係がかなり明確になった気がするよ!
それからサービスクラスに関してもサービスロケータでなくDIコンテナでの登録に置き換えることもできたんだ!
// サービス登録
builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
builder.Register<IAssetsService, AssetsService>(Lifetime.Singleton);
builder.Register<ILogService, LogDebugService>(Lifetime.Singleton);
builder.Register<ITransitionService, TransitionService>(Lifetime.Singleton);
builder.Register<IDialogService, DialogService>(Lifetime.Singleton);
// モバイルの場合、Admobを適用する
if (gameSettings.IsMobilePlatform)
{
builder.Register<IAdmobService, AdmobService>(Lifetime.Singleton);
}
else
{
builder.Register<IAdmobService, AdmobDummyService>(Lifetime.Singleton);
}
↑サービスクラスの登録
エレキベア
DIコンテナを使用することで
コンストラクタ経由での受け取りにすることができるクマね
コンストラクタ経由での受け取りにすることができるクマね

マイケル
DIコンテナやVContainerについての詳細は、下記の記事をご参照ください!

マイケル
それからもう一つ、UniRxのReactivePropertyを使用することで
UI側からオブジェクトの状態を監視するようにしたのも、実装しやすかったように感じます!
ざっくりですが各パッケージの関係性は下記のようになっています。
UI側からオブジェクトの状態を監視するようにしたのも、実装しやすかったように感じます!
ざっくりですが各パッケージの関係性は下記のようになっています。


エレキベア
前回はUI周りの呼び出しも複雑になっていたクマからね〜〜

マイケル
具体的な実装は下記のようになっています!
ReactivePropertyで定義した変数を監視することで、UIの更新を行なっています。
ReactivePropertyで定義した変数を監視することで、UIの更新を行なっています。
/// <summary>
/// ストーン管理クラス
/// </summary>
public class StoneManager
{
・・・略・・・
/// <summary>
/// 各色ごとのストーンの数
/// </summary>
private readonly ReactiveProperty<int> _whiteStoneCount = new(0);
private readonly ReactiveProperty<int> _blackStoneCount = new(0);
public IReadOnlyReactiveProperty<int> WhiteStoneCount => _whiteStoneCount;
public IReadOnlyReactiveProperty<int> BlackStoneCount => _blackStoneCount;
↑ReactivePropertyの変数を定義 /// <summary>
/// ステート監視設定
/// </summary>
private void OnSetStateSubscribe()
{
・・・略・・・
// ストーン数表示
_stoneManager
.WhiteStoneCount
.Subscribe(count => gameInfoView.SetWhiteCount(count))
.AddTo(this);
_stoneManager
.BlackStoneCount
.Subscribe(count => gameInfoView.SetBlackCount(count))
.AddTo(this);
// ゲーム状態による切替
_gameManager
.State
.Subscribe(x =>
{
switch (x)
{
// 開始表示
case GameState.Play:
OnShowStart();
break;
// 結果表示
case GameState.Result:
OnShowResult();
break;
}
}).AddTo(this);
}
↑定義した変数を監視する
エレキベア
これならUIを取り外すのも簡単そうクマね
様々なオセロAIの実装

マイケル
そして次は何と言ってもオセロAIの実装です!
これはもう・・・定番なアルゴリズムから強化学習までいろんなものを作りましたよ!
詳しくは下記記事をご覧ください!!
これはもう・・・定番なアルゴリズムから強化学習までいろんなものを作りましたよ!
詳しくは下記記事をご覧ください!!

エレキベア
いや〜〜〜感慨深いクマ〜〜〜〜

マイケル
これらを組み合わせて最終的には 5(+3)キャラ分のAIを作成 しました。
こうしてみるとかなり賑やかになりましたね!
こうしてみるとかなり賑やかになりましたね!


マイケル
詳細な内容は各記事に書いてあるので省きますが、下記のように感情によってアルゴリズムを切り替えているのがミソですね!
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Reversi.Stones.Stone;
namespace Reversi.Players.AI
{
/// <summary>
/// エレキベア
/// </summary>
public class ElekiBearPlayer : Player
{
public ElekiBearPlayer(StoneState myStoneState, Action<StoneState, int, int> putStoneAction) : base(myStoneState, putStoneAction)
{
}
protected override void StartThink()
{
StartThinkAsync(CancellationTokenSource.Token);
}
/// <summary>
/// 選択するストーンを考える
/// </summary>
private async void StartThinkAsync(CancellationToken token)
{
// 考える時間
await WaitSelectTime(200, token);
// 早すぎると上手くいかないので1フレームは待つ
await UniTask.DelayFrame(1, cancellationToken: token);
// 感情によって選択手法を変える
switch (Emotion)
{
// 通常、焦り:MiniMax
case PlayerEmotion.Normal:
case PlayerEmotion.Heat:
SelectStoneIndex = await AIAlgorithm.SearchMultiThreadNegaAlphaStoneAsync(StoneStates, MyStoneState, 3, true, token);
break;
// 悲しい:ランダム
case PlayerEmotion.Sad:
SelectStoneIndex = AIAlgorithm.GetRandomStoneIndex(StoneStates, MyStoneState);
break;
}
}
}
}
↑エレキベアのAI実装例
エレキベア
もう少し賢く作ってほしかったクマ・・・
Cinemachineでのカメラワーク実装

マイケル
それから今回はCinemachineも使用していて、
カメラワークの実装にも挑戦してみました!
例えばタイトル画面のボードをいろんな角度から切り替えて写している箇所ですね!
カメラワークの実装にも挑戦してみました!
例えばタイトル画面のボードをいろんな角度から切り替えて写している箇所ですね!


エレキベア
何かそれっぽいクマ〜〜

マイケル
これは下記のように一定時間ごとに各カメラのPriorityを切り替えることで表現しています。
/// <summary>
/// アニメーション開始処理
/// </summary>
/// <param name="token"></param>
private async void StartAnimationAsync(CancellationToken token)
{
_isPlayAnimation = true;
virtualCameraRound.Priority = 1;
// 順番にカメラを切り替える
while (_isPlayAnimation)
{
// Round
dollyCartRound.m_Position = 0.0f;
await UniTask.Delay(500, cancellationToken: token);
SetAllPriority(-1);
virtualCameraRound.Priority = 1;
dollyCartRound.m_Speed = 0.3f;
await UniTask.Delay(3000, cancellationToken: token);
// Dolly
dollyCartDolly.m_Position = 0.0f;
await UniTask.Delay(500, cancellationToken: token);
SetAllPriority(-1);
virtualCameraDolly.Priority = 1;
await UniTask.Delay(3000, cancellationToken: token);
// Up
dollyCartUp.m_Position = 0.0f;
await UniTask.Delay(500, cancellationToken: token);
SetAllPriority(-1);
virtualCameraUp.Priority = 1;
await UniTask.Delay(3000, cancellationToken: token);
}
}
↑タイトル画面のカメラワーク
マイケル
Cinemachineの使い方については、下記記事にまとめてありますので
こちらもよければご参照ください!
こちらもよければご参照ください!

エレキベア
Cinemachineは使いやすくて楽しかったクマね
DOTweenでのアニメーション実装

マイケル
そして次はDOTweenアニメーション!
これは前回も使用していましたが、今回も思いつきでいろんな箇所のアニメーションを実装しています。
これは前回も使用していましたが、今回も思いつきでいろんな箇所のアニメーションを実装しています。

マイケル
まずはタイトルロゴのアニメーション!
これは白背景の上に黒い円を配置して、ランダムでスケールを変更することで表現しています。
これは白背景の上に黒い円を配置して、ランダムでスケールを変更することで表現しています。

[SerializeField] private Image whiteBackImage;
[SerializeField] private Image frontTextImage;
[SerializeField] private Image backThunderImage;
[SerializeField] private List<Image> backCircleImageList;
private List<Tween> _loopTweenList;
private static readonly float MinCircleImageScale = 0.95f;
private static readonly float MaxCircleImageScale = 1.06f;
private static readonly Vector3 ThunderRotateVector3 = new Vector3(0.0f, 0.0f, 8.0f);
private void Awake()
{
// Alpha値を上げるSequenceを作成
var appearSequence = DOTween.Sequence();
appearSequence.AppendCallback(() => backThunderImage.gameObject.SetActive(false)); // 最初はイナズマ非表示
appearSequence.Append(CreateToAlphaTween(whiteBackImage, 1.0f));
appearSequence.Join(CreateToAlphaTween(frontTextImage, 1.0f));
backCircleImageList.ForEach(backCircleImage =>
{
appearSequence.Join(CreateToAlphaTween(backCircleImage, 1.0f));
});
// イナズマを遅れてひょいと出す
var thunderAppearSequence = DOTween.Sequence();
thunderAppearSequence.AppendInterval(0.6f);
thunderAppearSequence.AppendCallback(() =>
{
backThunderImage.transform.localEulerAngles = ThunderRotateVector3;
backThunderImage.gameObject.SetActive(true);
});
thunderAppearSequence.Join(CreateToAlphaTween(backThunderImage, 0.5f));
thunderAppearSequence.Join(backThunderImage.transform.DOLocalRotate(Vector3.zero, 0.5f));
appearSequence.Join(thunderAppearSequence);
// 大きさを徐々に変えるループアニメーションを作成
_loopTweenList = new List<Tween>();
foreach (var backCircleImage in backCircleImageList)
{
// 間隔と開始時間を適当にばらまく
var randomDelayTime = Random.Range(0.0f, 0.5f);
var randomDurationTime = Random.Range(1.0f, 1.5f);
backCircleImage.transform.localScale = Vector3.one * MinCircleImageScale;
_loopTweenList.Add(
backCircleImage.transform.DOScale(MaxCircleImageScale, randomDurationTime)
.SetDelay(randomDelayTime).SetLoops(-1, LoopType.Yoyo));
}
// イナズマのループアニメーション
var thunderLoopSequence = DOTween.Sequence();
thunderLoopSequence.AppendInterval(0.8f);
thunderLoopSequence.Append(backThunderImage.transform.DOLocalRotate(ThunderRotateVector3, 0.2f));
thunderLoopSequence.Append(backThunderImage.transform.DOLocalRotate(Vector3.zero, 0.2f));
thunderLoopSequence.SetLoops(-1, LoopType.Restart);
thunderLoopSequence.Pause();
DOTween.Sequence().AppendCallback(() => thunderLoopSequence.Restart()).SetDelay(1.0f);
_loopTweenList.Add(thunderLoopSequence);
}
↑円とイナズマをアニメーションさせている
エレキベア
長いクマ・・・

マイケル
それから下記のようなメッセージ表示にも使用しています。
今回初めて DOTween.To や DOTween.ToAlpha を使用してみましたが、これらも中々汎用性が高そうだなと思いました。
今回初めて DOTween.To や DOTween.ToAlpha を使用してみましたが、これらも中々汎用性が高そうだなと思いました。

public void ShowMessage(string text, UnityAction callback, float displayDelayTime = 0.0f)
{
// テキスト設定
messageText.SetTextForDynamic(text);
// 背景がスライドしてテキストが浮かび上がる
var sequence = DOTween.Sequence();
sequence.AppendInterval(displayDelayTime);
sequence.AppendCallback(() =>
{
// 最初は非表示
messageBackgroundImage.fillAmount = 0.0f;
var c = messageText.color;
c.a = 0.0f;
messageText.color = c;
// オブジェクトを表示する
SetActiveMessageArea(true);
});
sequence.Append(DOTween.To(
() => messageBackgroundImage.fillAmount,
(x) => messageBackgroundImage.fillAmount = x,
1.0f,
0.5f
));
sequence.Append(DOTween.ToAlpha(
() => messageText.color,
color => messageText.color = color,
1.0f,
0.2f
));
// コールバックが設定されている場合
if (callback != null)
{
sequence.AppendInterval(1.0f);
sequence.AppendCallback(() =>
{
callback();
});
}
}
↑アルファ値や特定の変数を変化させている
エレキベア
任意の変数を変化させられるのは便利クマね

マイケル
そして最後は石がひっくり返るアニメーション!
これは挟んだ方向に対して順番に回転する演出を加えています!
これは挟んだ方向に対して順番に回転する演出を加えています!

/// <summary>
/// ひっくり返すアニメーション
/// </summary>
/// <param name="putStoneIndex"></param>
/// <param name="callback"></param>
private void StartTurnAnimation(StoneIndex putStoneIndex, Action callback)
{
// アニメーションさせない場合
if (!IsDisplayAnimation)
{
callback();
return;
}
// 色が変わる場合
var putVec = Index - putStoneIndex; // 置いた位置からのベクトル
var waitTime = putVec.GetLength() * 0.08f; // アニメーションを遅らせる時間(遠いほど開始を遅らせる)
var q = Quaternion.AngleAxis(180.0f, new Vector3(-putVec.Z, 0.0f, putVec.X)); // 90度回転させたクォータニオン(-Z,Xで指定)
// ひっくり返るアニメーションを実行
var sequence = DOTween.Sequence();
sequence.AppendInterval(waitTime);
sequence.Append(stoneObject.transform.DOLocalRotateQuaternion(q, 0.5f).SetRelative().SetEase(Ease.OutQuart));
sequence.Join(DOTween.Sequence()
.Append(stoneObject.transform.DOLocalMoveY(1, 0.25f).SetRelative().SetEase(Ease.OutQuart))
.Append(stoneObject.transform.DOLocalMoveY(-1, 0.2f).SetRelative().SetEase(Ease.InOutBounce)));
sequence.OnComplete(() =>
{
callback();
});
}

エレキベア
これは確かに気持ちいいかもしれないクマ

マイケル
DOTweenはマジで楽しいからこれからもバンバン使っていこう!
UIを極力シンプルに統一

マイケル
そしてここからは技術的な話とは少し離れますが、
UIに関して下記のProcedural UI Imageを使用することで全体のイメージを統一、素材作成の手間を軽減しました。
UIに関して下記のProcedural UI Imageを使用することで全体のイメージを統一、素材作成の手間を軽減しました。

【Unity】「Procedural UI Image」を使ってシンプルなUIを爆速で作成する
マイケルあ〜〜〜UIの素材をいちいち作るの面倒くさいな〜〜〜でもUnity標準のUIはダサいしな〜〜〜〜マイケル・・・そんな場面はないでしょうか??今回はシンプルなUIを爆速で作れる「Procedural UI...
↑Procedural UI Imageの紹介記事

マイケル
これが中々シンプルで使い勝手がいいんですよね〜!
具体的には下記のようなレイアウトになります。
具体的には下記のようなレイアウトになります。




エレキベア
確かに統一感あって悪くないクマね〜〜
むしろエンジニアが下手に作るよりは全然いいクマ・・・
むしろエンジニアが下手に作るよりは全然いいクマ・・・

マイケル
ちょこっとしたゲームを作るには丁度いいね!
(オインクゲームズさんのUIに影響を受けてるのは内緒です)
(オインクゲームズさんのUIに影響を受けてるのは内緒です)

エレキベア
言っちゃったクマ・・・
キャラクターモデルの作成

マイケル
そして最後に、今回はBlenderでキャラモデルを作ることにも挑戦しました!
全7キャラ分のモデルについて全て手作業で作っています!
全7キャラ分のモデルについて全て手作業で作っています!



エレキベア
ついにゲームに出演する時がきたクマか・・・

マイケル
流用できるから今後も活用していくつもりだよ!
そして収穫として大きかったのは、しっかり記事にBlenderの使い方をまとめることができたことです!
前回は面倒くさすぎてメモも残しておらず、結果しばらくしたら忘れてしまっていたのですが、今回はメモしたから大丈夫なはず!!
そして収穫として大きかったのは、しっかり記事にBlenderの使い方をまとめることができたことです!
前回は面倒くさすぎてメモも残しておらず、結果しばらくしたら忘れてしまっていたのですが、今回はメモしたから大丈夫なはず!!

エレキベア
細かく残して偉いクマ〜〜〜
これでモデル作成も困ることはないクマね
これでモデル作成も困ることはないクマね
反省点

マイケル
たくさんのことを学んだオセロ開発でしたが、当然反省点もあります・・・。
こちらもいくつか振り返っていきましょう!
こちらもいくつか振り返っていきましょう!
3D描画が少し重い

マイケル
一つ目は描画処理が割と重たいことです。
URPに移行したりテクスチャサイズ、マテリアルを削減したり等の対処は行いましたが、
それでも古い端末だと表示がカクつく場面が多々ある状態です・・・。
URPに移行したりテクスチャサイズ、マテリアルを削減したり等の対処は行いましたが、
それでも古い端末だと表示がカクつく場面が多々ある状態です・・・。


エレキベア
これは中々難しいクマね・・・
なるべく古い端末でも動くようにしたいところクマが・・・
なるべく古い端末でも動くようにしたいところクマが・・・

マイケル
この辺はこれから描画周りを勉強していく予定なので、
少しずつ改善していけたらと思います!
少しずつ改善していけたらと思います!
リリース直前のWebGL対応で一苦労

マイケル
そして次はリリース直前のWebGL対応で一苦労したことです!
実際にどのような対応を行ったのかは下記にまとめてあります。
実際にどのような対応を行ったのかは下記にまとめてあります。

マイケル
不具合が出ること自体は仕方ないのですが、問題はリリース直前でWebGL確認を行ったことです。
もう少し早い段階で確認しておけばよかったなと反省しています・・・。
もう少し早い段階で確認しておけばよかったなと反省しています・・・。

エレキベア
リリース前の不具合は萎えるクマね・・・
非同期処理のキャンセル処理は忘れずに

マイケル
次はコーディングの話になるのですが、UniTaskやDOTweenで処理を行なった際に、キャンセル処理を忘れてしまった箇所がいくつかありました。
終盤にまとめて対処しましたが、これは実装時にキャンセル処理も合わせて書くよう意識した方がいいなと思いました。
終盤にまとめて対処しましたが、これは実装時にキャンセル処理も合わせて書くよう意識した方がいいなと思いました。
private async void StartGameAsync(CancellationToken token)
{
// 一定時間待機
await UniTask.Delay(2300, cancellationToken: token);
// ゲーム開始
Owner._playerManager.StartGame();
Owner._playerManager.StartTurn();
_isGameStart = true;
}
↑キャンセル処理は忘れず書きましょう
エレキベア
まあ確かに面倒くさいクマ・・・
開発者都合なところも多い

マイケル
そして最後に重大な問題ですが、
そもそもオセロで3Dにする必要はあったのか? という点です。
そもそもオセロで3Dにする必要はあったのか? という点です。

エレキベア
全てをぶち壊す問題がきたクマ・・・

マイケル
モデル作成したかったり3Dを扱いたいという理由で3Dに振り切りましたが、
オセロをすることがメインであることを考えると描画負荷も考慮して2Dでもよかったのでは?と思います・・・
オセロをすることがメインであることを考えると描画負荷も考慮して2Dでもよかったのでは?と思います・・・

エレキベア
開発者都合が入ってしまったクマね

マイケル
この辺りは企画時に考えるべきことなので、次回はしっかりゲームの仕様や需要を考えた上で作ろうと思います!!
おわりに

マイケル
というわけでオセロゲーム開発の振り返りでした!
どうだったかな??
どうだったかな??

エレキベア
割と長い時間をかけたから疲れたクマ〜〜〜

マイケル
俺も正直完成した時は「やっと解放される・・・」という嬉しさの方が勝っていたよ・・・

マイケル
でもやっぱりすごく楽しかったしよくやり切ったと思う!!
早く次のゲームも作りたい!!!
早く次のゲームも作りたい!!!

エレキベア
ゲーム開発はやはり奥が深いクマね

マイケル
アセット周りや入力周りはまだレガシーな状態で作ってしまったから、
今後は描画周りの学習に加えて
・Addressable Asset System
・InputSystem
あたりも触っていきたいな!
今後は描画周りの学習に加えて
・Addressable Asset System
・InputSystem
あたりも触っていきたいな!

エレキベア
今回もとにかくおつかれさまクマ
次回もやったるクマ〜〜〜〜
次回もやったるクマ〜〜〜〜

マイケル
それでは今日はこの辺で!!
アデューーー!!
アデューーー!!

エレキベア
クマ〜〜〜
【Unity】「ゆるいオセロ」をリリース!工夫点や反省点をざっと振り返る 〜完〜
↑よければ遊んでください!!!!
コメント