🐷

2日でゲームを作ったときに意識したこと・反省点

2024/08/08に公開

はじめに

先日、勝手にunity1weekならぬunity1dayを行い、ゲームを投稿しました。

unity1weekとはunityroomが主催する、1週間でunity製ゲームを作成するイベントです。

https://unityroom.com

これの1日版、すなわちunity1dayをやろう!と、今回はかくひとさんと一緒にゲームを作成させていただきました。

https://github.com/suke-h

ほぼ初となる共同開発ということもあり、とても楽しく勉強になる日々でした(1日の予定でしたが、実際は2日かかりました)。

私は実装担当、かくひとさんにはイラスト・ストーリーの担当をしていただきました。あと残った部分はそれぞれテキトーに補う感じで。

今回は、実装にて意識したことや反省点をまとめてみたいと思います。

ソースコード

https://github.com/qemel/u2d202408-src

2日かかってしまったのでリポジトリ名はu2dです。

作成したゲーム(unityroomリンク)

https://unityroom.com/games/konatsu_enikki

要件

  • 夏をテーマにしたクリッカー・物語ゲーム
    • 比較的静的なゲームだと思うので1日で作れるかなと思った
  • 風鈴を押しまくると風鈴の音が鳴りつつページがめくれて、夏休みの日記を読み進めることが出来る
  • 各ページにて夏の風物詩(Sprite)が登場する
  • 風景等のイラストも適宜差し替える
  • 下画面には絵日記の文章(TextMeshPro)が縦書きで入る

☕ 意識したこと

簡単なテーマを選ぶ

1日で作るので、とにかく簡単なテーマを選ぶことを意識しました。

今回は、できるだけ静的でアニメーション等の動きが少なそうなテーマで挑みました。

やはりどれだけ低く見積もっているつもりでも、人間というものは見栄を張って3日くらいの期間がかかるものを1日でやろうとしがちなので、ここは十分に意識しました。

が、2日かかりました。まだ下げるべきだった

さっさと実装する

当たり前ですが、時間が無さ過ぎるのでさくっと実装することを意識しました。

とにかく最低限動くものを作って、あとはブラッシュアップするというスタンスです。

神駆動開発

さくっとすすめるために、神クラスっぽいクラス(GameManagerもどき)を普通に作って、そのクラスに基本的な処理をがつがつ書いていきました

いうてもそこまでめちゃくちゃ神クラスってレベルにはなりませんでしたが、結果自体は何でもよく、とにかく速く実装できそうな作り方を意識したという感じです。

今回の神はPagePresenterというクラスにして、ページの遷移や画面の更新等を担当しています。

https://github.com/qemel/u2d202408-src/blob/main/Scripts/Presenter/PagePresenter.cs

ページの更新にはObserverパターンを使っています。今回はPageScoreという構造体を作って、それを監視しています(この辺りは後で雑に解説)。

処理を1方向にする

そうはいっても、個人的に最低限守りたいことの1つに「処理を1方向にする」というのがあります。

これによって、確実に循環参照を防ぐことができるし、処理の流れがわかりやすくなります。

今回は、GameEntryPointGameInitializerに始まって初期化処理を行い、そこからPagePresenterが基本的な処理をするような構造になりました。

テキトーなAwake, Startをしない

初期化処理にAwakeを使うと、初期化順でたまに悩まされるので、今回は上のクラスからどんどんInit()を呼び出すような構成にしました。

例えばGameInitializerはこのようになっています。

using u1d202408.Data;
using u1d202408.View;
using UnityEngine;

namespace u1d202408.Presenter
{
    /// <summary>
    ///     ゲーム全体の初期化
    /// </summary>
    public sealed class GameInitializer : MonoBehaviour
    {
        [SerializeField] PagePresenter _pagePresenter;
        [SerializeField] BookView _bookView;
        [SerializeField] PageRequirementSO _pageRequirementSO;
        [SerializeField] PageVisualSO _pageVisualSO;
        [SerializeField] PageAudioSO _pageAudioSO;

        [SerializeField] GameRegistry _gameRegistry;

        public void Initialize() // GameEntryPointから呼ばれる(正直GameEntryPointはいらなかった)
        {
            _bookView.Init();
            _gameRegistry.Init(_pageRequirementSO.Create(), _pageVisualSO.Create(), _pageAudioSO.Create());
            _pagePresenter.Initialize();
        }
    }
}

さらにここの_bookViewBookViewというクラスを持っていて、そのInit()は以下のようになっています。

using u1d202408.Model;
using UnityEngine;

namespace u1d202408.View
{
    public sealed class BookView : MonoBehaviour
    {
        [SerializeField] PageView _pageLeftView;
        [SerializeField] PageView _pageRightView;
        [SerializeField] PageView _nextLeftView;
        [SerializeField] PageView _nextRightView;

        public void Init()
        {
            _pageLeftView.Init();
            _pageRightView.Init();
            _nextLeftView.Init();
            _nextRightView.Init();
        }
    }
}

こんな感じで初期化の順番を明確化することで、初期化順で悩まされることを無くそう!という試みです。

結果的には今回の要件だと、これは自己満でしかなかったです。(初期化順で悩まされるようなゲーム性してなかった...)

🧱 コードの雑解説

ValueObject

ValueObjectは、値によって比較されるようなオブジェクトです。主に以下の目的で作成されます。

  • 不変性を保つ
    • 一度作成されたら中身を変更できないので
  • 意味を絞る
    • int pageNumberint clickCountは別のものだけど、int型だと区別がつかないから間違った方に値を入れちゃうかもしれない
    • そういうときにPageNumberClickCountというValueObjectを作ると、間違えようがなくなる(コンパイルエラーになるから)
  • 不正を減らす
    • コンストラクタで不正な値を弾くことができる

今回のPageScoreはページのスコアを表す構造体で、中身にページを表すint型の値を持っています。

本当はrecord struct型を使いたいところですが、Unityではまだ正式に使えないのでstructを使い、IEquatableを実装しています。

https://github.com/qemel/u2d202408-src/blob/main/Scripts/Model/PageNumber.cs

Observerパターン

IEquatableを実装したオブジェクトはReactivePropertyに使うことが出来ます。

今回は神クラスことPagePresenterにてこういった値の管理をしています。

_gameRegistry
    .CurrentPageScore
    .Subscribe(
        _ =>
        {
            _pageScoreGaugeUIView.SetFillAmount(_gameRegistry.RequirementRate);

            if (_gameRegistry.FilledRequirement) _gameRegistry.GoToNextPage();
        }
    )
    .AddTo(gameObject);

ゲームの状態を管理するRegistry

状態というのはプログラムの天敵です。状態があちこちで変わると意味が分からなくなっちゃいます。

今回はゲームの主要な状態をGameRegistryという1クラスにまとめました。状態版の神クラスです。

using System;
using R3;
using u1d202408.Model;
using UnityEngine;

namespace u1d202408.Presenter
{
    public sealed class GameRegistry : MonoBehaviour
    {
        readonly ReactiveProperty<PageNumber> _currentPageNumber = new(new PageNumber(0));
        readonly ReactiveProperty<PageScore> _currentPageScore = new(new PageScore(0));

        /// <summary>
        ///     最初に設定する
        /// </summary>
        PageAudioRepository _pageAudioRepository;
        PageRequirementRepository _pageRequirementRepository;
        PageVisualRepository _pageVisualRepository;

        /// <summary>
        ///     現在のページ番号
        /// </summary>
        public ReadOnlyReactiveProperty<PageNumber> CurrentPageNumber => _currentPageNumber;

        public PageNumber MaxAchievedPageNumber { get; private set; } = new(0);

        /// <summary>
        ///     現在のページの遷移要件
        /// </summary>
        public PageScoreRequirement CurrentPageScoreRequirement
        {
            get
            {
                var requirement = _pageRequirementRepository.Get(_currentPageNumber.CurrentValue);
                if (requirement == new PageScoreRequirement(0))
                    throw new Exception($"PageScoreRequirement is not set. PageNumber: {_currentPageNumber.Value}");

                return requirement;
            }
        }

        /// <summary>
        ///     現在のページのスコア
        /// </summary>
        public ReadOnlyReactiveProperty<PageScore> CurrentPageScore => _currentPageScore;

        public PageAudio CurrentPageAudio => _pageAudioRepository.Get(_currentPageNumber.CurrentValue);

        public PageVisual CurrentPageVisual => _pageVisualRepository.Get(_currentPageNumber.Value);
        public PageVisual NextPageVisual => _pageVisualRepository.Get(_currentPageNumber.Value.Next());

        /// <summary>
        ///     条件達成しているか
        /// </summary>
        public bool FilledRequirement => CurrentPageScore.CurrentValue.Value >=
                                         CurrentPageScoreRequirement.Value;

        /// <summary>
        ///     どのくらい条件を満たしているか
        /// </summary>
        public float RequirementRate => (float)CurrentPageScore.CurrentValue.Value /
                                        CurrentPageScoreRequirement.Value;

        /// <summary>
        ///     最後のページ番号にいるか
        /// </summary>
        public bool IsInLastPage => _currentPageNumber.Value.Value + 1 == _pageRequirementRepository.Count();

        /// <summary>
        ///     最初のページ番号にいるか
        /// </summary>
        public bool IsInFirstPage => _currentPageNumber.Value.Value == 0;

        public void Init(
            PageRequirementRepository repository, PageVisualRepository visualRepository,
            PageAudioRepository audioRepository
        )
        {
            if (_pageRequirementRepository != null) throw new ArgumentNullException(nameof(repository));
            if (_pageVisualRepository != null) throw new ArgumentNullException(nameof(visualRepository));
            if (_pageAudioRepository != null) throw new ArgumentNullException(nameof(audioRepository));

            _pageRequirementRepository = repository;
            _pageVisualRepository = visualRepository;
            _pageAudioRepository = audioRepository;

            _currentPageNumber.Subscribe(
                                  pageNumber =>
                                  {
                                      MaxAchievedPageNumber =
                                          new PageNumber(Mathf.Max(MaxAchievedPageNumber.Value, pageNumber.Value));
                                  }
                              )
                              .AddTo(gameObject);

            _currentPageNumber.AddTo(gameObject);
            _currentPageScore.AddTo(gameObject);
        }

        public void Set(PageNumber pageNumber)
        {
            _currentPageNumber.Value = pageNumber;
        }

        public void SetNextPageNumber()
        {
            _currentPageNumber.Value = _currentPageNumber.Value.Next();
        }

        public void Set(PageScore pageScore)
        {
            _currentPageScore.Value = pageScore;
        }

        public void Add(PageScore pageScore)
        {
            _currentPageScore.Value += pageScore;
        }

        public void GoToNextPage()
        {
            if (IsInLastPage) return;
            SetNextPageNumber();
            Set(new PageScore(0));
        }

        public void GoToPreviousPage()
        {
            if (IsInFirstPage) return;
            _currentPageNumber.Value = _currentPageNumber.Value.Previous();
            Set(new PageScore(0));
        }
    }
}

最後の方はこの中にもR3(UniRx)のSubscribe()メソッドが入っており結構めちゃくちゃなことしてますが、最初はただただ状態管理するための箱として使っていました。

最後は時間が無かったのです。

初期値を管理するRepository

ゲーム実行前に既に決まっているような初期設定はRepositoryとして作成しました。これをRegistryが初期値として持っています。

多分、Registryが直接Repositoryを持っているのはよくないですが、時間が無かったのでこのような形になりました。

using System.Collections.Generic;

namespace u1d202408.Model
{
    /// <summary>
    ///     ページごとのスコア(風鈴をクリックする回数)要件
    /// </summary>
    public sealed class PageRequirementRepository
    {
        readonly Dictionary<PageNumber, PageScoreRequirement> _pageScoreRequirements = new();

        public PageRequirementRepository(List<int> pageScoreRequirements)
        {
            for (var i = 0; i < pageScoreRequirements.Count; i++)
            {
                _pageScoreRequirements.Add(new PageNumber(i), new PageScoreRequirement(pageScoreRequirements[i]));
            }
        }

        public PageScoreRequirement Get(PageNumber pageNumber)
        {
            return _pageScoreRequirements[pageNumber];
        }

        public int Count()
        {
            return _pageScoreRequirements.Count;
        }
    }
}

🧊 反省ポイント

ページをめくる処理

ページをめくる処理については、最初はページを見開き1単位でクラス化してしまったため、ページめくるようなアニメーション処理ができなかったのがあまりにも致命的でした。

もっと要件についてちゃんと考えて、ページを1ページ1ページに分けておくべきでした。後から考えたら、ページをめくるのだからページを1ページ1ページに分けておくのが当たり前だったのですが、これを最初に考えていなかったのが反省点です。

ただ、これ自体は途中で修正はしたのですが、結局ページめくり処理の実装には至りませんでした。

というのも、ページを構成するSpriteやTMP_Textは表示順がOrder in Layerで管理されているため、ページを3D空間上でめくるときにどうしても表示順がおかしくなってしまい、これを解決するのが不可能と判断したからです。

なんか普通にできる方法がある気がしているのですが、時間が無かったので断念しました。

ちょっと丁寧すぎた

1日で作ると決めていたので、もうちょっと乱暴に実装してもよかったかなと思いました。

特にInitの処理周りは、もうちょっとテキトーにやってもよかったように感じます。
ValueObjectも多少冗長でした。

この辺りも要件の見通しに繋がってくる反省ポイントになります。要件定義はやっぱり大事ですね。

まとめ

とにかく共同開発って面白いなと思いました。自分が実装をしていると、どんどん素敵なイラストやアイデアが勝手に増えてきて、ゲームを複数人で作ることの意味を強く感じました。

実装については、個人的にはかなり上出来でしたが反省点も少しありました。とにかく要件定義の大切さを思い知らされました

次はunity1weekに参加するので、またそのときには要件をしっかりと見つめなおすようにしたいなと思います!

Discussion