Closed53

C#ゲーム設計メモ

けめるけめる

方針

  • 継承はしんどい
    • 継承すると元のクラスを追うのがしんどい
    • LSP違反をあまりにも起こしやすい
  • 継承したくなっても基本的にはinterfaceやコンポジションを多用するべき?
けめるけめる
  • interfaceでの抽象化の仕方
    • is-a, has-a, can-do関係をしっかりと整理するのが大事
      • 基本的にはinterfaceはcan-do関係で作るのがいい?
        • IPlayerとかじゃなくて、IDamageApplicableにするとか
        • 本質を切り抜いて抽象化し、LSP違反を避ける
けめるけめる
  • has-a関係ならコンポジションを活用する
    • クラスを分けて機能を分離する(SRP)
    • 意外と分けるのが難しかったりするのでここの勘所がイマイチ分からない…
    • 委譲も検討できる?
けめるけめる
  • データ構造の構築にはコンポジション
  • ふるまいの定義にはinterface
けめるけめる

神クラスについて

  • ゲームを設計している以上、何らかの神なるクラスができるのは避けづらい
  • GameLoopを管理するクラスはどうしても必要で、すべてをStateに分けて使うのは難しい

できるだけ頑張りたい例

  • ターン制のカードゲームを考える(Slay the Spire的な1人用のもの)
    • イベントをStateEventのEnumで発行・購読して疎結合でクラスが動作するという設計を考える
    • StateEventはBatlleBegins, PlayerTurnBegins, PlayerTurnEnds, EnemyTurnBegins, EnemyTurnEnds
    • StateEventを適切なタイミングで発行するPublisherが必要

このくらいで神クラスレベルの存在は消せる…?

けめるけめる
  • 小規模な開発(1weekのゲームジャム等)なら、神前提の開発(神駆動開発)もありな気がする
  • GameLoopみたいなクラスにasync / awaitを多用したコードを書きなぐることで解決する
public async UniTask Start()
{
    var deck = _deck; //DIしてるやつ
    var player = _player; //DIしてるやつ
    var enemies = _enemySet; //DIしてるやつ
    await 演出;
    deck.Draw(3);
    
    var playerWin = enemies.ForEach(x => x.IsAlive == false);
    var playerLose = player.IsAlive == false;
    while(playerWin == false && playerLose == false)
    {
        // 自ターン
        await UniTask.WaitUntil(() => _turnEndButton.Pressed);
        await ターンエンド演出;
        
        // 敵ターン
        foreach(var enemy in enemies)
        {
            enemy.TakeTurn(player);
            await 効果演出;
        }
        
        await ターンエンド演出;
    }
    
    if(playerWin)
    {
        // 勝利演出
    }
    else
    {
        // ゲームオーバー
    }
}
  • イベントやら演出やらがごちゃごちゃになるのは1週間程度ならそこまで問題にならない
けめるけめる

神駆動開発のメリット

  • 神の疑似コードがそのまま開発の指標になる
    • クソデカUseCase
    • 迷わずに手が進みやすい
  • デバッグがしやすい
    • 任意のawait部分をコメントアウトするだけで簡単に不要な部分を排除してデバッグすることができる
けめるけめる

図の書き方

シーケンス図を書いて、難しい部分はユースケースとかに分けて細分化しながら考えるのがよさそう

クラス図はそのあとに書くほうが幾分見通しがいいと思う

けめるけめる

ValueObject

そもそもこの辺はほとんど理解してない

基本的にVOは同一性が内部の値によって担保されるimmutableなものとして説明されることが多いけど、ここでは単なる値のラッパーとしてのVOを指す。として…

https://neue.cc/2020/12/15_597.html

この辺の記事にもある通り、ちゃんとお作法に則ってVOを作るのはめちゃしんどいし、いかんせん.Valueが非本質的になるというデメリットが存在する。

今のUnityC#9ではrecordを利用して似たような機能を実装できるけど、こっちは本当にimmutableとして実装することが前提だと思うので、あんまりよくない使い方になるし、結局.Valueの呪いから逃げられない

けめるけめる

この辺もうちょっと考えたいけど、そもそもVOなんて使わなくてもゲームは作れるし、プリミティブ型の取り違えとかいうヒューマンエラーなんて(少なくとも個人開発の域だと)そんなない気がするので、VOは使わずに作るっていうのも全然ありな気がする。

public readonly struct Hp : IEquatable<Hp>, IComparable<Hp>
{
    public int Value { get; }

    //ctorなど...
}

よりも、

public class Player
{
     public int Hp { get; private set; }

    // Hpのプロパティ処理など...   
}

みたいなので十分なのではという

ただ、hpはPlayerのみじゃなくて的にも実装することが多いので、そこはちょっと考えどころではある。再利用性が下がるという意味で

けめるけめる

最近はrecord structが優秀だと感じているので、全部これでいいのではという気になっている

けめるけめる

PubSubパターンのデメリット

  • 処理を追いづらくなる場合がある
  • 普通にクラス参照したほうがわかりやすい場合もある

本質的にメッセージによるやり取りで十分で、それは毎フレーム起こすようなものでない場合にメッセージを利用したい

値の変化が重要な場合は若干Observableとかに軍配が上がるかも

けめるけめる

エントリポイントの意識

エントリポイント…自分から動くクラス

そもそもMonoクラスは大体エントリポイントなことを意識すべき

自分からイベント関数を使って自分自身や他のMonoを動かすことができる

けめるけめる

Monoのイベント関数は全て「自分がこのときどうする」を意識して書くもの

逆に言うと、原理的には自分から動かないようなクラスはMonoにする必要がない

けめるけめる

裏を返せば、わざわざ自分から動くようなものをPureC#で書く意味はない

けめるけめる

例えば、敵キャラを実装する際、わざわざEnemyControllerみたいなクラスがEnemyViewのAPIを叩くようにする、というのはやり過ぎな気がしてきている

けめるけめる

それなら普通にEnemyのMonoクラスが自分自身を動かせばいい

けめるけめる

ただ、そこに含まれるHpなどのステータスや、攻撃のための時間等のパラメーターといったものは、自分から動くというイメージがないのでちゃんとクラスとして分けたほうがいい感

けめるけめる

On系のメソッドやイベントシステムは、全部自分がどうするかをイメージして書くもので、これをHp等のステータスに使用すると可読性が下がる

完全に自分ができることを自分でやらせると、クラスの依存をする意味がそもそも無くなる

自分から動くやつが、自分では動けずにpublicなAPI公開してるだけのクラスだけに依存するのがベストで、できるだけイベントシステムに頼らないほうがいい気がする

けめるけめる

StSライクカードゲームの例で考えてみる

カードが自分からできることはあまりなく、初期化、使用時の発動効果くらいだと思う

要は、その上にカードを使うPlayerクラスがいて、これがカードのAPIを発動するのが基本になりそう

けめるけめる

カードのパラメーターなどは、さらにPureC#に分離したほうがいい(別にイベント関数使わないと決めるならMonoでもいい気はするけど)

また、敵は明らかに自分から意思を持って動くイメージなので基本の部分はMonoに書く

けめるけめる

上に明らかなエントリポイントがいる以上、Cardはエントリポイントとして作らないほうがいいかもしれない

その際は、メソッド名はUse()などになるだろう

けめるけめる

そう考えると、On系の関数を外から実行するのは変な気がする

OnUseとするなら、Useされたカード側が自立して動くようにした方がエントリポイントの共有概念としてわかりやすい

けめるけめる

まあ、これはあくまで理想論で、実際はエントリポイントの親子がひしめいて、状態がわけ分からなくなることも多い

親子構造の中で何が一番エントリポイント味あるかを考えるのは結構良さそう

けめるけめる

エントリポイントその2

こないだは「自分から動くやつ」と「自分では動かないやつ」、つまりエントリポイント性からクラスを区別してたけど、そうではなくて、「命令を下すやつ」と「命令を受けるやつ」でクラスを区別・定義した方が分かりやすい気がする

この定義だと、命令を受ける奴は必ずしもOn()系のメソッドを実装してはいけないわけではなく、他クラスとのやり取りを無くせばいいだけだと分かる

けめるけめる

つまり、この定義だと、メッセージングシステムのSubscribe等は「命令を受ける」扱いになり、(その中のメソッドで他クラスに影響しない限り)利用できる。

エントリポイントでないクラスはSubscribeするメソッドが書けないので、利用できない。

けめるけめる

逆に、命令を下す側はメッセージングのPublishを利用できる
Subscribeは命令を受ける側なので利用できない

けめるけめる

こう考えると、この設計においてPublishとSubscribeを同時に行うクラスはあってはならないと分かる
メッセージの方法が、そのまま設計の指針になる

けめるけめる

理想の設計

データと機能、Viewの表示を分離

データは不安定な情報で、寿命がある可能性が高い

機能は、極まると純粋関数なので、安定的に存在できる

けめるけめる

全てのロジックはデータのやり取りとして記述

Monoクラスでやり取りせず、なるべく定義されたデータをいじる形にしたい

その結果として、何かしらの形でデータを見た目と同期する機能が必要(MVPなど)

けめるけめる

いちいち2クラス(Model, Viewなど)を呼んでたら、大したメリットにならない

依存関係の本質がほとんど変わらない

けめるけめる

ただ、あくまで理想論
実際は仕様変更とかしまくるのでほぼ無理

けめるけめる

ツイッター

DIを全面的に使うことを一旦諦めていいと思った

自分みたいな規模の個人開発ではそんなに大きなメリットもないし、むしろクラスが多くなって疲れるデメリットも生じてくる

けめるけめる

MonoクラスのAPI使うの防止とか、エントリポイント防止とか、そのくらいの理由でDIコンテナするくらいなら自分ルール守ればいいやん感はちょっとある

けめるけめる

いちいち依存関係の整理でワンクッション時間がかかるのは個人的、個人開発的にはちょっとだるい

エントリポイントさえ悪用しなければMonoのデメリットなんてそんなにないんだから積極的にMono作ってよいかも

けめるけめる

その上で、GetConponentとか、そのへんのAPIに注意すればいいだけの話かもしれない

けめるけめる

ただ、EditModeテストできないのはまあまあ大きいデメリットではある

けめるけめる

ゲームの挙動を全てコマンドとして考える

ゲームのイベントや動作をコマンドとして考える手法がある

例えばBackpackBattlesみたいなゲームを考えてみると、

  • アイテムの選択
  • アイテムの配置位置、向きの決定
  • アイテムを実際に配置

というメインの流れがある

けめるけめる

これに加えて実際は、いろいろなイベントが並行して起こっている

  • アイテムの選択前のホバー時はアイテムの効果をUI表示
  • アイテムの配置前に置けるかどうかを示すインジケータの表示
  • 置いたあとのアイテムの相互作用を示すインジケータの表示
  • 置けなかった場合物理に従って落とす
けめるけめる

この中で、完全にステートとして分離できそうなのもあれば、そうじゃないのもあるのがゲームの大変なところ

けめるけめる

例えば「アイテムの選択前のホバー時はアイテムの効果をUI表示」、「置けなかった場合物理に従って落とす 」はステートとして分離可能だが、「アイテムの配置前に置けるかどうかを示すインジケータの表示」はステートをまたがりそうだし、「置いたあとのアイテムの相互作用を示すインジケータの表示」はUIの表示とロジックが複雑に絡んでで厄介そう

けめるけめる

処理の方向性がかなり大事だと思う今日このごろ

方向性を考えるとメッセージ等も自然と使い方が限られてきてわかりやすい

けめるけめる

Assembly切るメリット

  • コンパイルの無駄が減る
  • 処理の方向性を強制できる
  • internal使える
    • internalあると集約もしやすい
けめるけめる

パターンの本質を考える

  • MVP
    • 循環参照をなくす
    • マッピングによってModel, Viewの連携を自動化する
  • DI(Inversion)
    • 変更に耐性をつける
    • 処理の方向を整える
けめるけめる

MとVの分離はまだ難しくて悩む

MがたくさんIdのあるEnemyのようなものの場合、生成したMにどうやってアクセスするかが悩ましい

  • P作る
  • Uidとそれに対応したRegistry作る

基本的にはたぶんこの二択なんだけど、P作ると本格的にMVPになってしまってインゲームだと難しいし、Uidは単純に手間が増えて、しかも動的な追加削除が多いと複雑に感じる

けめるけめる

一応VがMを持つという方法もあるけど、個人的にはちょっと気持ち悪さがある

やっぱ安牌はMVPっぽくやっちゃうことなのかな…
特にGUIチックな操作が多いゲームならそれでいいのかもしれない

けめるけめる

interfaceの使い方

本質的には、「変化するもの」に対して切る
変化するもの一覧

  • 抽象的なメソッドの実装部分
  • テストしたいから取りあえず作ったクラス
  • View層の実装
      - 実装がまるっきり変わるケースも多いので、そういう時はクラスごと作り直したほうがいいケースが多い
けめるけめる

あんまり継承の代わりとして使わないほうがいいかも
つまり、「継承よりマシなやつ」という認識だけで使うのは微妙

EnemyBaseがIEnemyになってもいうほどそんなに嬉しくはない

けめるけめる

ましてデフォルト実装しだしたら、そんなに継承と変わらないから尚更

けめるけめる

デフォルト実装は、なにもしない場合が多いときだけ検討の価値アリという印象

けめるけめる

データ構造を加工して返してやるみたいなイメージが刺さる部分はかなり存在する

簡易なキャラコントローラーならinputをまとめた構造体を元にvelocityを返す関数を定義してやるだけでいいかもしれない

けめるけめる

シングルトンのやってることって間逆な気がする

シングルトンにみんなアクセスするようなコードを書いたらもうそれはManageできてないことが多い

このスクラップは11日前にクローズされました