🍳

Unity1週間ゲームジャム「かわる」の反省会 - ほぼ自分用編

2024/03/30に公開

はじめに

unityroomが開催している1週間ゲームジャムである「Unity1週間ゲームジャム(unity 1 week, u1w)」に参加しました。

お題に沿ったUnityのゲーム作品を1週間で作るという体で遅刻もOKな、比較的ゆるめのイベントです。私は12日かけて提出しました。

https://unityroom.com/unity1weeks

今回はそこでのゲーム制作について、主に設計周りから反省点を並べていきたいと思います。

要件

今回作成したゲームの要件について簡単に俯瞰しておきます。

基本ルール

  • プレイヤーが画面端に行くと、反対色になって逆側から出てくる
  • 逆側から出てきた反対色のプレイヤーは、その色に沿ったコライダー(当たり判定)に沿って移動できる
  • ゴールに触れたらレベルクリア、次のレベルへ
  • 同じ色が重なったらゲームオーバー、リトライ処理

追加要素

  • 触れると重力を反転するアイテム
  • カメラを移動させてその視界に沿って画面端の処理をできるようにする機能(文章で書いても意味不!!!)
    • 要は、見えてる世界が真実になる的な、そんな感じ
    • もともと画面端に壁があるステージも、カメラを移動して壁が無いように見せれば、その通りに処理されるみたいな感じ

使用ライブラリ

ライブラリ名 概要
R3 UniRxの後継。Input等の発行・購読に利用。
UniTask async/await使いたかったら必須のやつ。
VContainer DIコンテナ。楽するために導入。
MessagePipe 疎結合たのしーーーするためのメッセージライブラリ。DIコンテナ必須。
LucidAudio Audioの再生・破棄等が気軽にできるライブラリ。
Alchemy インスペクタ拡張。
LitMotion Tweenライブラリ。書きやすい。

リンク

ゲーム

https://unityroom.com/games/worb

ソースコード

https://github.com/Noth827/-u1w-white-or-black?tab=readme-ov-file

反省点

設計を無視したら最悪になった

最初はこのゲームを簡単なものと見積もり、「設計を一旦無視したらどうなるのか試してみよう」とテキトーにコードを書いていました、が。

3日くらいで普通に拡張性ゼロのコードになってしまい、これは最悪でした。そもそも想定の5倍くらいは実装のムズイゲームだったので、あまりにも舐めていたというしかないです。

「設計一旦無視したらどうなるのか試してみよう」の答えとしては、「どうも何もヤバイ」です。もうこんな実験みたいな体で横着はしません。ここに誓います。

グリッドベースかfloatベースかの見積もりが甘かった

最初、ステージのサイズを管理するクラスはこんな感じでした。プレイヤーの移動がVector2なので、それに合わせてfloat, Vector2で書きました。

StageField
using UnityEngine;

/// <summary>
/// ステージ全体の情報
/// </summary>
public sealed class StageField
{
    public float TopBound { get; private set; }
    public float BottomBound { get; private set; }
    public float LeftBound { get; private set; }
    public float RightBound { get; private set; }

    public StageField(float topBound, float bottomBound, float leftBound, float rightBound)
    {
        TopBound = topBound;
        BottomBound = bottomBound;
        LeftBound = leftBound;
        RightBound = rightBound;
    }

    // カメラ移動時に使う
    public void Move(Vector2 move)
    {
        TopBound += move.y;
        BottomBound += move.y;
        LeftBound += move.x;
        RightBound += move.x;
    }

    public bool IsInBounds(float x, float y)
    {
        return x > LeftBound && x < RightBound && y > BottomBound && y < TopBound;
    }

    public bool IsInBounds(Vector2 position)
    {
        return IsInBounds(position.x, position.y);
    }
}

ですが、ステージ自体はグリッドベース(なんならTileMapでやろうと思ってたくらい)で十分だったので、ここはもっと違和感を覚えるべきでした。

座標が1つズレている等のバグがなかなか治らなかった原因がこの辺の記述をあいまいにしてたからなので、もっとさっさと考え直すべきでした。

この場合は、intVector2Intに型を修正することによってバグが分かりやすくなりました。

ブロックの判定に使うコードなのにカメラ端が端点になっているのが直感的にあまりにも分かりづらかった…

識別で困ったらID作ってRepository/Registryに!

カメラ移動を実装するには画面外の壁を動的に生成・破棄する必要があると分かり、壁オブジェクトを管理する必要が出てきました。

最初はWall.transfrom.positionを一々見る感じで壁オブジェクトを識別しようかと思いましたが、それだとパフォーマンス的にも微妙だし、ちょっとわかりづらいなと感じました。

ちょっとわかりづらい
private List<Wall> _walls = new();

/*...*/

private void Foo()
{
    var targetPosition = /*...*/
    if (_walls.Find(x => x.transfrom.position == targetPosition))
    {
        // 処理...
    }
}

そこで、個別にIDを用意し、Dictionaryを作って処理しました。これならアクセスの計算量はO(N)からO(1)に減りますし、コードの意味も分かりやすいです。

よさそう
private Dictionary<WallId, Wall> _walls = new();

/*...*/

private void Foo()
{
    var targetPosition = /*...*/
    _walls.TryGetValue(new WallId(position), out var wall);
}

ちなみに、DictionaryのKeyにあたるオブジェクトにはIEquatableを実装しておきましょう。
もしくは標準で実装できるrecordなどを使うのもいいでしょう(Unityでrecord使うのちょっと面倒なので使ってませんが)。

WallId
public readonly struct WallId : IEquatable<WallId>
{
    private readonly Vector2Int _value;

    public WallId(Vector2 value)
    {
        _value = value;
    }

    /*...[Riderで自動生成したIEquatableの実装コード]...*/
}

そしてDictionaryのアクセスを認めるだけのRepositoryクラスを作ればなおよかった気がします。 実際には処理系を中に色々書いちゃったし、このクラス参照してDictionary見てるだけのクラスとかある。

あと、こういうとき、Repositoryよりしっくりくる名前が欲しいけど分からない。

ViewはView, ModelはModel

できるだけViewとModelは分けて書くように意識しましたが、それでもまだまだだなと思いました。

例えば、カメラの移動量を制御するのにCameraMaxMovementなるクラスを最後らへんに作ったのですが、もうちょい早めに気づいてもよかった気がします。

最初はCameraMovementというMonoBehaviour継承クラスにフィールドとして置いていたので、他クラスで使えない状態でした。

しょうもないboolの状態定義が多かった

一度だけ発火するようなものに対してprivate bool _isDone;みたいな状態を定義して書くことが多かったので、これの解決法を考えたいです。

例えば、以下のコードならこう書きかえられることが分かりました。状態が減って分かりやすい。

before
private bool _isDone;

private void Start()
{
    _inputProvider
        .RetryPressedTime
        .DistinctUntilChanged()
        .Subscribe(time =>
        {
            SetRetryTime(time);
            SetActive(time > 0);
            if (time >= _retryTime)
            {
                if (_isDone) return;
                _publisher.Publish(new GameStateEvent(GameState.Retry));
                _isDone = true;
            }
        }).AddTo(this);
}
after
private void Start()
{
    var disposable = new SingleAssignmentDisposable();
    disposable.Disposable =_inputProvider
        .RetryPressedTime
        .DistinctUntilChanged()
        .Subscribe(time =>
        {
            SetRetryTime(time);
            SetActive(time > 0);
            if (time >= _retryTime)
            {
                _publisher.Publish(new GameStateEvent(GameState.Retry));
                disposable.Dispose(); // 購読の解除
            }
        });
}

参考:

https://qiita.com/yaegaki/items/bea845df0b011d515afb

レベル設計が面倒だった

レベルをUnity上で作成するとき、テンプレートとなるオブジェクトを早めに作っておきたかったのですが、イマイチ上手くいかず、この辺どうすればいいのかなと悩みました。

VContainerのConfigureのSerializeFieldに突っ込む作業を毎レベルごとにやらないといけなかったのは、単純にシーン設計ミスったからだなーと今になって思いました。

理想はVContainerの設定をするLifetimeScopeとレベルのGameObjectだけをヒエラルキー上に配置し、あとはUIやらなんやらは自動生成という感じなのかなーとか、色々と考え中です。

あと、不要なオブジェクトを選択して誤って消したり移動したりしてしまうケースが多々あったのですが、編集をロックする方法があるようです。

https://tsubakit1.hateblo.jp/entry/20140422/1398177224

仕様の無知

Collider, Tilemap辺りをあまり知らないまま使ったので、めちゃめちゃ判定の実装等で沼りました。勉強必須だなと感じました…。

例えば

  • CompositeCollider2DはデフォルトでOutlineの設定になっており、境界線でしかColliderの判定がされないので、OverlayPoint2Dを使っても上手くいかない。OverlayBox2Dも境界線に触れてないと判定されないので注意。
  • TileBaseからそのGameObjectを取得することは多分出来ない。Tileを使って動的な生成・破棄をする。
  • LayerMaskの作り方(ビットを使ったり参照したり)

などで3日くらい沼りました。なんかその時は体調も悪かった。

あとはVContainerもちょっと仕様が分からず結構時間がとられました。それでもDIコンテナ使うと楽なのでトータルだとプラスな気もしますが。

Factoryの作り方がイマイチ分からなかったので記事にしました。

https://zenn.dev/qemel/articles/6dcafbc4ff3987

おわりに

今回もu1wでは色々勉強になりました。

ゲーム制作したくてそこそこ暇な人はぜひ参加しよう!!

次回はライブラリの説明や良かった部分について振り返ります。

Discussion