C#ゲーム設計メモ
方針
- 継承はしんどい
- 継承すると元のクラスを追うのがしんどい
- LSP違反をあまりにも起こしやすい
- 継承したくなっても基本的にはinterfaceやコンポジションを多用するべき?
- interfaceでの抽象化の仕方
- is-a, has-a, can-do関係をしっかりと整理するのが大事
-
基本的にはinterfaceはcan-do関係で作るのがいい?
- IPlayerとかじゃなくて、IDamageApplicableにするとか
- 本質を切り抜いて抽象化し、LSP違反を避ける
-
基本的にはinterfaceはcan-do関係で作るのがいい?
- is-a, has-a, can-do関係をしっかりと整理するのが大事
- 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を指す。として…
この辺の記事にもある通り、ちゃんとお作法に則って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()系のメソッドを実装してはいけないわけではなく、他クラスとのやり取りを無くせばいいだけだと分かる
理想の設計
データと機能、Viewの表示を分離
データは不安定な情報で、寿命がある可能性が高い
機能は、極まると純粋関数なので、安定的に存在できる
ツイッター
DIを全面的に使うことを一旦諦めていいと思った
自分みたいな規模の個人開発ではそんなに大きなメリットもないし、むしろクラスが多くなって疲れるデメリットも生じてくる
ゲームの挙動を全てコマンドとして考える
ゲームのイベントや動作をコマンドとして考える手法がある
例えば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層の実装
- 実装がまるっきり変わるケースも多いので、そういう時はクラスごと作り直したほうがいいケースが多い
データ構造を加工して返してやるみたいなイメージが刺さる部分はかなり存在する
簡易なキャラコントローラーならinputをまとめた構造体を元にvelocityを返す関数を定義してやるだけでいいかもしれない
シングルトンのやってることって間逆な気がする
シングルトンにみんなアクセスするようなコードを書いたらもうそれはManageできてないことが多い