コマンドパターンでCtrl+Zを実装する

執筆者:

カテゴリ:


https://zenn.dev/kd_gamegikenblg/articles/6a5439cd88297c

(9/24)

こんにちは。たこのこです。
上の記事を解読して、お絵描きミニゲームのundo(Ctrl+Z)を実装するまでの過程を書き起こします。
よろしくお願いします。

勉強目的なので検索目的を除いてAIの使用を禁止して頑張ります。

やるぞ~!

undoを実装するゲームの仕様を確認する

undoを実装したいゲームは、現在開発中の『水彩画は水底に沈む』内にあるお絵描きミニゲームです。ゲームの本編で集めた落書きを好きな色でキャンバスに貼り付けてお絵描きできます。

https://store.steampowered.com/app/4389120/_/?l=japanese
(良かったら遊んでください~~~)

このゲームの仕様を考えると、undoで巻き戻す対象となるデータは「キャンバス上のスタンプ」と「パレット色」の2つになりそうです。一応、チュートリアルも状態を持ちますが、undoしても最終的な成果物に影響しない要素なので省きます。

シリアライズとは

(9/25)

記事冒頭でさっそく分からない言葉が出てきました。
シリアライズについて調べてみます。

まず、クラスのインスタンスはプログラムが終了した際にメモリから消し飛ばされます。これを回避する方法の1つとしてのシリアライズがあるとのことです。ずっと使い続けられるよう、バイナリの形などに出力します。

プログラム実行中、メモリに存在するオブジェクトはメモリ上に異なる場所に散らばっていて、これを一列で並んでいるビットに変換することから「シリアライズ(直列化)」と呼ぶみたいです。

undoを実装する2つの方法

記事を読み進めます。

「undoを実装しよう!」となった時、大まかに2種類の実装方法があるとのことです。

1つ目が、操作する度に全てのデータを保存して、undoの度に1つ前を読み込む方法です。動画撮影を続けて逆再生するみたいなイメージです。

2つ目が、操作履歴を記録して、undoで逆算するように巻き戻す方法です。電車で帰宅する際、往路から復路の乗り換え駅を考えるイメージです。

1つ目の方法について、お絵描きミニゲームともなればかなりメモリを食うことが想定されるので避けたいです。一応、差分を記録することで処理負荷の軽減が図れるらしいですが、既に出来上がったミニゲームにundoを実装するワケでもないので、素直に後者の方法を採用することにします。

(なにより後者の方が実装しがいがあって勉強になりそうです)

記事解読を進める

CommandBase

CommandBaseという基底クラスの元で全ての操作を実装するとのことです。データを元の状態に戻すために、全てのコマンドは実行前に戻すための処理も実装する必要があるみたいです。上りの電車があったら下りの電車もないと帰宅できないってことですね。

純粋仮想関数

純粋仮想関数ってなんだっけ?
となったので調べました。

GoogleとAI曰く、基底クラスの中で処理内容を定義せず、宣言だけを行う仮想関数のことだそうです。基底クラスの子クラスは絶対に中身をオーバーライドして実装する必要があります。

純粋仮想関数はC#で言うところの抽象クラスか…と思ったけど全然違って、abstractメソッドを持つクラスのことを抽象クラスと呼ぶので、C++の純粋仮想関数はC#のabstractメソッドと対応している…と言ったほうが正しいです。

CommandManager

(9/26)

記事より引用

// 実行したコマンドを格納するコンテナ
std::list<std::shared_ptr<CommandBase>> m_lCommands;

// コマンドのイテレータ
std::list<std::shared_ptr<CommandBase>>::iterator m_commItr;

コンテナってなんだっけ…配列とかリストの仲間だったような。イテレータはイテレータパターンに使われてて、for文がレベルアップしたもののイメージ…

といったように、ふにゃふにゃな理解でよろしくないのでちゃんと調べます。

コンテナとは、配列やリストを含む、データを管理する仕組みの総称らしいです。配列やリストがバナナやザクロだとしたら、コンテナはフルーツです(?)。

Vector3とかもデータを入れておくための箱なのでコンテナの1種です。コンテナ、思っていたよりも広い概念でした。

イテレータについては、「コンテナの中の現在見ている要素を指すもの」らしいです。これもコンテナみたいに抽象的な概念ですね。

for (int i = 0; i < 3; i++){}

とかのi部分かと思いましたが、イテレータはfor文のソレに留まらず、もっと広い解釈ができるみたいです。コンテナ内の位置を表すためのオブジェクトは、大体イテレータと呼んで差し支えなさそうです。

ところで、イテレータのサンプルコードを見ましたが、要素間の移動を++itrみたく表現していました。

こうした、イテレータパターンを用いることで、配列やリストを使いたい人がデータ構造を理解している必要がなくなるとのことです。これは便利ですね。コードの中身を忘れた私や他の方々に頭を使わせないコードの実装に役立ちそうです。

コマンドを格納するコンテナ?

記事より引用

// 実行したコマンドを格納するコンテナ
std::list<std::shared_ptr<CommandBase>> m_lCommands;

// コマンドのイテレータ
std::list<std::shared_ptr<CommandBase>>::iterator m_commItr;

コマンドを格納するコンテナって何でしょうか。額面通りに受け取れば「インスタンス化したクラスを指すポインタのリスト」だとは思います。

よくよく考えてみると、クラスをインスタンス化するって、メモリ上でどんな処理が行われているのかよく把握していない気がします。

と思いましたが、フィールド用のメモリが確保するくらいですかね。そんなに難しい話でもありませんでした。

さらに振りかかる意味わからん構文

(9/27)

m_lCommands.emplace_back(std::make_shared<EmptyCommand>());

m_lCommandsって何を意味しているのでしょうか。意図の分からない命名をそのままにしておくのはモヤモヤするので明らかにしたいです。

先頭にIとつけるのはInterfaceの継承まわりでよく見る気がします。継承先だということを言いたいのでしょうか?

そしてm_はなんでしょうか?

manager、message、member…

C++の命名分からんです。
素直に相談します。

曰く、m_lCommandsは「memberであるlistのCommands」らしいです。

よく見たら大文字の「I」じゃなくて小文字の「l」でした。キャメルケースではなくスネークケースなので先頭の小文字が許されてるんですね。普段しない書き方だったので見間違いました。

そういえばC#でもフィールドを_hpとか_coinとか書くケースを見ますが、これと似たような話のように思います。

さらに振りかかる意味わからん構文2

(9/28)

m_lCommands.emplace_back(std::make_shared<EmptyCommand>());

というか、全体的にC++の記法や標準搭載の機能を知らなくて困っています。

class B :public A

は基底クラスAを子クラスBが継承してる

std::hogehoge

はstdという名前空間のhogehogeを引っ張ってきている

くらいは分かります。

m_lCommands.emplace_back(std::make_shared<EmptyCommand>());

なんて長文になると「???」です。

要素要素に分解して考えます。

m_lCommands.emplace_back(std...)

これは、m_lCommandsに含まれるemplace_backというメンバ関数を呼び出していて、その際に引数としてstd::make_shared<EmptyCommand>()を渡していますね。

emplace_backに渡している上の引数はstdライブラリのmake_shared<EmptyCommand>()で…

ところで、「hogehoge<>()」の山括弧ってどんな意味でしたっけ。MonobehaviourのGetComponentによく出てくるイメージです。

調べてみました。山括弧はC+のテンプレート機能を使う際の、型を指定するためのものらしいです。

テンプレート機能って何…?

もっと調べました。テンプレート機能は、型を抽象化して、同じ処理を別の型でも共通して使い回せるようにする仕組みらしいです。

多態性の話に似ていると思いました。intやfloatといった異なる型でも動かせる処理を作りたいときに有用そうです。

ちなみに、emplace_back()はリストなどのコンテナに標準搭載されている機能の1つらしく、一番最後に要素を作りたいときに呼び出すらしいです。

では、std::make_shared<>() のmake_sharedって何でしょうか。これが最後の謎です。EmptyCommandインスタンスを指した、共有されるポインタを作る…共有?

調べました。これは、EmptyCommandを指すポインタの寿命を自動管理してくれる、スマートポインタのうち「共有型ポインタ」と呼ばれるものらしいです。

知らない言葉を調べたら知らない言葉が出てきました。スマートポインタって何ですか。

さらに調べました。スマートポインタは、動的に確保したメモリなどのリソースを自動で解放してくれるポインタ機能のことらしいです。

ここでC言語のことを思い出しました。動的に作ったメモリって、適切なタイミングで解放しないとメモリリークが起こって、動作重くなったりバグったり大変しますよね。スマートポインタはここらへんの処理をスコープを抜けるときとか不要になったタイミングで自動的に行ってくれるそうです。

へぇ〜!
便利だな〜
C++すごい

そして、スマートポインタにもいくつか種類があって、std::unique_ptr、std::shared_ptr、std::weak_ptrと、3つあるらしいです。

uniqueポインタは、1つのオブジェクトに対して単一の所有権を持てて、他のポインタにオブジェクトの参照を共有させないらしいです。

sharedポインタは、1つのオブジェクトを複数のポインタで共有できるポインタで、内部で何個のポインタから使われているかを管理してくれているみたいです。

weakポインタは、所有権なしで参照するポインタらしいです。sharedポインタ同士の循環参照(お互いを見合って解放できなくなる現象)を防ぐために使われるとのこと。

循環参照…?
循環参照がピンと来ないので調べました。

スマートポインタと、指されているオブジェクトは運命共同体なので、使われなくなったオブジェクトはスマートポインタによって勝手に破棄されるのが常ですが、あるオブジェクトの中で、自分自身を所有するスマートポインタを持つ他のオブジェクトを所有するスマートポインタを持っていると、使われなくなったオブジェクト同士がお互いを所有し合うので、メモリが解放されなくなるみたいです。

窓際社員同士が「俺達まだ現役だよなぁ?!」って結託し合っているようなものです。これはマズいですね。

これを回避するためのweakポインタです。sharedポインタを使って参照を作る中にweakポインタを挟むことで、使われなくなったオブジェクトは順当に破棄されるようになります。weakポインタは誰の所有権も持たないので、循環参照の輪も途切れるみたいです。

完全に理解した

(10/1)

記事を一通り読んで、分からない言葉や概念は0にできたと思うので、実際に手を動かして実装してみます。

1時間後…

実装できました。

この記事はC++の話をしていて、UnityではC#を扱っていたので、言語仕様の違いを把握する際に苦労しました。

該当記事のサンプルコードと同じように記述するべく、C++のイテレータにあたるらしい、C#のIEnumeratorを使うことにしたのですが、このインターフェースには前の要素に戻るメソッドが用意されていないことに気づきました。–iterみたく記述できません。

なんで?
と思ったので調べました。

IEnumeratorはメモリ上に存在しないデータ(例えば、受信しながら1行ずつ処理するデータ)を扱うこともあるらしく、1つ前の要素を参照する機能を付けると、メモリにデータを全て記録する必要が生まれてしまうみたいです。

そうなんですね。

また、遅延評価と呼ばれる仕組みが用いられていることも関係しているらしいです。

なんですか遅延評価って。
これも調べました。

あるデータが必要になるまでその計算を先送りにする情報処理におけるルールらしいです。これによって、プログラム全体を通した処理時間を短縮できるようになったり、無限に続くリストといった大きなデータも扱えるようになったりするみたいです。

へぇ〜。

話が逸れまくってるので、そろそろ元に戻します。

とにかく、C++のイテレータ部分については、リストを作ってインデックスで管理したほうがC#では見やすいし書きやすいと判断しました。

イテレータまわりをけっこう理解できて、コマンドパターンもクールに記述できると思ったのですが、結局はよく見るint型でのインデックスに落ち着きました。分かりやすくて書きやすいコードに越したことはないと思うので、これで良いとは思うのですが…

動かしてみる

さっそく、作ったコマンドパターンをお絵描きミニゲームに適当してみました。

スタンプを置いたり、色を作ったり、使う色を変えたりする操作を担当しているクラスが置き換えの対象です。コイツらにIBaseCommandを適用し、DoとUndoを実装しました。

その上で、CommandManagerを介して各クラスのDoとUndoを実行&記録することで、それぞれの操作が可逆的になり、どんな状態からでも一番最初まで巻き戻すことができるってワケです。

すげぇ!

おしまい

移動時間中にちょびちょび解読を進めていたので、実装を終えるまでに1週間かかりました。ただ、スキマ時間に勉強の習慣を継続できたのは高評価です。今後も続けていきたいです。

あえて違う言語の記事を見て実装方法を学ぶというのはアリかもしれません。言語仕様の違いを体感できますし、「この言語のこの仕様は、この言語でどうやって実装しようか」などと、普段使っていない頭を使うきっかけになると思いました。

今になって記事を振り返ってみると、知らない言葉や概念を見つけ次第、AIと壁打ちしたり調べていたりしたので、だいぶ道草の多い文章になったと思います。時間はかかりましたが、普段スルーしていた部分を明らかにできたので、プログラミング言語に対する理解度が深まりました。

今後も、早く形にしたいという気持ちを少しだけ抑えて、理解していない言葉を見つけたら入念に調べるようにしていきたいです。

以上です。

お疲れ様でした。

コメント

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です