技術記事

技術的な内容を記事としてまとめます。

  • コマンドパターンで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と壁打ちしたり調べていたりしたので、だいぶ道草の多い文章になったと思います。時間はかかりましたが、普段スルーしていた部分を明らかにできたので、プログラミング言語に対する理解度が深まりました。

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

    以上です。

    お疲れ様でした。

  • ビジュアルノベルのプレイ履歴を収集して、プレイヤーの行動を予測できるようになりたい

    ビジュアルノベルのプレイ履歴を収集して、プレイヤーの行動を予測できるようになりたい

    はじめに

    こんにちは。
    たこのこです。

    先日、サークル員と開発したビジュアルノベルゲーム『水彩画は水底に沈む』を東京ゲームダンジョン13に出展してきました。

    その様子は別の記事にまとめています。

    本稿では、本イベントで収集したデータまわりをまとめました。導線まわりの知見が得られるような記事になっていれば幸いです。


    調査の目的

    本調査では、実際の操作履歴からプレイヤーの行動傾向を分析することで、「どうやって操作すれば良いのか分からない」「ゲームを進める方法が分からなくなった」といった、導線の弱さから起こるゲーム体験の停滞を前もって対策できるようにします。

    (こういう調査報告な文章を書く方法を調べたところ、調査の目的を書くと分かりやすくて良いとのことでしたので真似しました)


    どんなゲームを展示したのか?

    『水彩画は水底に沈む』は、ポイント&クリックアドベンチャーゲームです。背景の描かれたイラストが提示され、該当箇所にカーソルを合わせてクリックすることで、テキストが表示されたり、音が鳴ったり、物語が進展したりするなど、ゲームから反応が返ってきます。

    このゲームシステムに加えて、本作では快適にゲームを進められるよう、クリック可能な箇所にカーソルが重なった際、クリック可能である旨を示すテキストと効果音が提示されます。

    ゲームの説明は以上です。具体的なゲームの内容に興味のある方は、Steamのストアページに詳しく書かれているので確認してください。

    https://store.steampowered.com/app/4389120/_/?l=japanese


    データ分析手法

    2026年8月8日に東京都立産業貿易センターにて開催された「東京ゲームダンジョン13」の参加者の内65名に、本作の試遊を依頼し、その操作履歴を記録しました。

    この記録作業はゲーム側で自動実行され、クリックの座標やイベント呼び出しの時刻が記録されました。

    本稿では、収集したデータを加工し、メッセージが提示されてから次にクリックするまでの経過時間や、シーンごとのクリック箇所の分布、試遊時間を棒グラフやヒートマップなどの図表にまとめました。

    なお、データを加工する過程で、ゲームの一時停止中に行われた操作や、同じ箇所の連打といった外れ値の操作履歴は除外しています。


    結果

    各イラストのクリック箇所を可視化したものを記載します。

    次にプレイ時間です。

    クリック回数です。セリフを次に進めるためのクリックやUIを操作するためのクリックは省いています。

    最後は、セリフが提示されてからクリックでセリフが進められるまでにかけた時間の平均です。


    クリック位置について思ったこと

    クリックされやすい物品の傾向

    • 画面の中央付近にあることが多い
    • どちらかと言えば画面の上側にある
    • 周囲よりも濃い色で描かれていることが多い
    • 周囲と区別されて描かれていることが多い
    • 人の顔が含まれている

    これは今後に使えそうです。

    教室のイラストは分かりやすくクリックできそうなアイテムが置かれていたので、クリックされる場所も物品に集中しています。

    対して、階段のイラストでは、窓や階段といった大きい物品が配置されているので、クリックされる箇所が分散している傾向を見て取れます。

    ただ、階段のイラストに限らず、他のイラストでも想像以上にクリック位置が分散しているなと感じました。

    そこで、クリック回数の多いデータを個別に確認したところ、手当たり次第にクリックしてゲームを進めているケースを確認できました。

    画面上をなぞるようにしてカーソルを動かし、クリック可能な箇所を探す遊び方を想定していたので、これは改善の余地がありそうです。何もない所をクリックしたらエラー音が鳴るとか、カーソルの乗ったクリック可能な箇所がハイライトされるとか…

    そういえば、何もない所がめちゃくちゃクリックされているように見えますが、これはチュートリアルを閉じたり画面遷移をしたりするためのクリック履歴がデータに含まれていたためです。

    机の脚部分だったりキャンバスの右あたりです。

    (図示する前にデータから省いておけばよかった…)


    プレイ時間について思ったこと

    プレイ時間の平均値は442秒で標準偏差103秒でした。6~7分に31人(約54%) が集中していて、そこそこプレイ時間がまとまっています。多くの人に想定通りの流れでゲームを遊んでもらえたと考えます。

    ただ、中には3分から4分と短い時間で試遊版のエンディングを迎えたプレイヤーや、11分から12分と長い時間をかけたプレイヤーも含まれています。

    そこで、3分から4分でクリアした方のデータを個別に確認したところ、セリフを読むスピードが0.4秒から0.5秒と比較的速い部類でした。また、11分から12分の方はその逆で、約2秒かけてセリフを読んでいました。

    このことから、プレイヤーの物語への興味関心がプレイ時間に影響していそうだな、と考えましたが、試遊版は特定箇所をクリックすることでエンディングを迎える条件が満たされるので、意図せず試遊版を終了してしまったケースが考えられそうです。

    「もっと物語を読みたかった」といった不都合を防ぐためにも、不可逆的な物語の進展が起こる直前に「進みますか?→YES/NO」といった選択肢を設けても良さそうです。


    セリフを読む速度について思ったこと

    セリフが表示されてから次のクリックまでの時間の平均値は約0.84秒、標準偏差は0.35秒でした。標準偏差が平均の42%程度あるので、プレイヤーのセリフを読む速度にはそこそこ分散している、という結果になりました。

    なので、文章中心のノベルゲームともなれば、プレイ時間はかなりの幅が生まれそうです。「文字を読む速度って人によって違うだろうな」と思っていましたが、こうしてグラフにしてみると実感が湧きます。

    補足ですが、本作では、長くても20文字くらいのセリフがパッと表示されるようになっています。セリフも1行ずつ表示されます。

    セリフの文字数と読む速度の関係を図示しても面白そうです。
    キリがないのでまた今度…


    おしまい

    データ集めて加工するの楽しい…

    楽しいだけでなく、意外な傾向を読み取れたり、有効なゲームの改善策を編み出せたり、良いことしかないので、今後作るゲームはデータの自動収集を標準搭載することにします。

    なんなら、オンライン上でデータを収集しても良さそうです。ただ、実装方法がピンとこないので、通信周りの技術にも強くなりたいです。

    プレイヤーの年齢層や性別、これまでのゲームプレイ履歴だったりも一緒に収集して分析したいところですが、ガッツリ個人情報なので扱いに困ります。機密情報保持のようなものを用意する必要があるのでしょうか?法に触れそうなので慎重に行きたいです。

    あと、外れ値を省くのにかなり手間がかかったので、加工段階をもっと効率化したいなと思いました。そもそものデータ形式が扱いにくかったので、プレイ履歴の収集段階で工夫したいです。

    以上です。

    ゲームダンジョン13は、展示イベントの展示側として参加した点でも良い経験になりましたが、こうしてデータを集められた点でも有意義だったと感じました。思った以上に収穫があったので、今後のゲーム開発に生かしたいです。