ホーム » Julia言語で入門するプログラミング(その9)

Julia言語で入門するプログラミング(その9)


「Julia言語で入門するプログラミング」第9回である。未読の方は第1回〜第8回を読んで欲しい。

一覧はこちら

不具合の修正

次のスキルを実装するまえに、前回残した不具合を修正したい。「かばう」で肩代わりした攻撃で戦闘不能になった時のメッセージ順がおかしいというものだ。

ドラゴンの攻撃!
太郎が代わりに攻撃を受ける!
太郎は花子をかばうのをやめた!
太郎は20のダメージを受けた!
太郎の残りHP:0

戦闘不能になったからかばえなくなったので順序が変だし、「かばうのをやめた」のではなく「かばえなくなった」と表示したい。なぜこのような問題が起きたのだろうか?

下記の流れ自体はおかしくない。HPが0になってから、戦闘不能イベント通知が実行されている。

function HP減少!(防御者, ダメージ)
    ...
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        戦闘不能イベント通知!(防御者)
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

問題は、HPが減少した結果はHP減少!関数の外で表示されるが、「かばうのをやめた」という表示は、戦闘不能イベント通知!で呼び出される関数内で表示されていることだ。

事象の発生(例えばHPの減少)とその通知(例えばprintln("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")))が別々の処理に書かれているのでこうなってしまう。事象の発生と通知が離れているところに、別の事象の発生と通知が割り込んできてしまったのだ。事象の発生と通知を極力近くに寄せれば、このような問題は避けられる。

とはいえ、あらゆる箇所にprintlnが入るのもつらい。本来は、HP減少!のような根幹部分の処理と、printlnのような画面表示については分けておきたい。画面表示というのはコロコロ変わるものだからだ。根幹部分の処理は安定している。そのような処理が、画面表示という安定度の低いものに依存してほしくはない。

おや、この話の流れは前回を思い出す。そしてこの問題の解決策もまた、前回の流れを踏襲する。オブザーバーパターンを使おう。そして、根幹部分と画面表示をオブザーバーパターンを使って分離する構造のことを特に「MVCアーキテクチャ」と言うのだ。

MVCアーキテクチャ

MVCとは、Model-View-Contollerの頭文字を取ったものである。アプリケーションの構造を、モデルとビューとコントローラーというものに分けるというものだ。

  • モデルというのはアプリケーションの根幹となる部分のことだ。根幹と言うと曖昧な言い方になるが、後述するビューとコントローラー以外の部分である。
  • ビューというのは画面表示のことである。今のアプリケーションで言うと、printlnで表示される文字列と、RatioMenuで選択するカーソルのことである。
  • コントローラーというのはユーザーの入力を受け付け、モデルへの入力に変換する役割のものである。今のアプリケーションで言うと、RatioMenuで選択するカーソルを元に行動を決定する処理のことである。

ここからわかるように、書いているコードのほとんどはモデルである。ただし、モデルは背後に控えている存在である。我々は画面を通じてアプリケーションと対話する。アプリケーションが起動されると、

  1. まずモデルが初期化される。
  2. ビューがモデルの状態を表示する。
  3. ユーザーはビューの表示を見て、コントローラーに入力する。
  4. コントローラーへの入力はモデルへの指示として変換され、モデルは状態を変える。
  5. 2に戻る

という流れで進んでいく。

重要なのは、

  • ビューはモデルのことを熱心に観察していて、状態を逐一反映する。
  • モデルはビューのことは知らない。

ということである。

そして、このモデルとビューの関係を実現するために、オブザーバーパターンを使っちゃおうよ、というのが「MVCアーキテクチャ」である。MVCアーキテクチャは非常に有名なので、現代的なフレームワークの至る所で使われている。中には今回取り上げるような古典的なMVCパターンから派生進化していることも多いが、まずは基本となるMVCパターンを抑えておけば理解は容易である。

ちなみに、コントローラーは?という点についてだが、ビューとコントローラーの違いは微妙である。モデルはユーザーの指示を受けて状態を変える必要があるが、ユーザーが対面しているのはビューである。なので、入力はまずビューから与えられる。そして、ビューから渡された情報を解釈して、モデルにこう指示したらいいのだな、ということを行うのがコントローラーなのだ。この「ビューから渡された情報の解釈」→「モデルへの指示」が複雑であれば独立したレイヤに分けた方がいいのだが、シンプルなケースだとコントローラに分けるほどでもない。単に、ビューの一部にしてしまっても大きな問題はない。現状のアプリはとてもシンプルなので、当面はモデルとビューの関係にのみ注目する。

オブザーバーパターンでいうところの、どちらがサブジェクトでどちらがオブザーバーかで言うと、モデルがサブジェクト(イベントを発行する人)、ビューがオブザーバー(イベントリスナー)である。

モデルは様々なイベントを発行する。例えば、キャラクターがダメージを受けたら、「ダメージを受けたイベント」を発行する。ビューはそのイベントを受け取り、「ダメージを受けたことを表示する」処理を実行する。これが基本の流れである。

では具体的に実装していこう。

まずは、printlnを使っているところを洗い出す。このうち、ui.jlというファイルはユーザーインターフェースを担う処理なので、ここにあることは問題ない。しかし、それ以外の場所にあるprintlnは排除したい。printlnを使っているところは全てイベントの発行処理に変える。

例えば次のような箇所である。

#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
    if !isnothing(防御者.かばってくれているキャラクター)
    ...
end

これを次のようにする。

#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    攻撃実行イベント通知!(攻撃者, コマンド) #イベント通知に変更
    if !isnothing(防御者.かばってくれているキャラクター)
    ...
end
#キャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    戦闘不能イベントリスナーs
    攻撃実行イベントリスナーs #追加
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [かばう解除!], [かばう解除!], [攻撃実行ui処理!]) #変更
    end
end

#追加
function 攻撃実行イベント通知!(攻撃者, コマンド)
    for リスナー in 攻撃者.攻撃実行イベントリスナーs
        リスナー(攻撃者, コマンド)
    end
end
#ui.jl
function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
end

他の箇所も同様に進めていく。モデルの変化のうちビューに通知する必要のあるものは全てイベントにしていく。

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    攻撃実行イベント通知!(攻撃者, コマンド)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者) #イベントに変更
        防御者 = 防御者.かばってくれているキャラクター
    end
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    HP減少イベント通知!(防御者, 防御者ダメージ)
end
#キャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    攻撃実行イベントリスナーs
    かばう発動イベントリスナーs #追加
    HP減少イベントリスナーs #追加
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, 
        [かばう解除!], [かばう解除!], [攻撃実行ui処理!], [かばう発動ui処理!], [HP減少ui処理!])  #変更
    end
end

function かばう発動イベント通知!(防御者)
    for リスナー in 防御者.かばう発動イベントリスナーs
        リスナー(防御者)
    end
end

function HP減少イベント通知!(防御者, 防御者ダメージ)
    for リスナー in 防御者.HP減少イベントリスナーs
        リスナー(防御者, 防御者ダメージ)
    end
end
#ui.jl
function かばう発動ui処理!(防御者)
    println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
end

function HP減少ui処理!(防御者, 防御者ダメージ)
    println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

このようにして、モデルとビューを分けていく。要領は掴めたと思うので、あとは省略する。コードの全体像は記事の最後を確認して欲しいが、1点だけ重要な点をお伝えしておこう。

関数の代入時の取り扱い

攻撃実行!関数では、T通常攻撃の時とTスキルのとき、同じ攻撃実行イベント通知!を発行したくなるはずだ。

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    攻撃実行イベント通知!(攻撃者, コマンド)
    ...
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    攻撃実行イベント通知!(攻撃者, スキル)
    ...
end

この場合、当然同じイベントリスナー攻撃実行イベントリスナーsへ、イベントを通知する。リスナーとして登録されるのは攻撃実行時のui処理だが、このときT通常攻撃のときとTスキルの時で別々の処理が行われて欲しい。

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
end

function 攻撃実行ui処理!(行動者, スキル::Tスキル)
    println("----------")
    println("$(行動者.名前)の$(スキル.名前)!")
end

これをどのように登録すればいいのだろうか?という疑問である。普通に攻撃実行ui処理!関数を呼び出しなら、型によるディスパッチがかかって、適切なものが呼び出される。ところが、今回は、リスナーとして関数を登録し、その関数をイベント発行時に呼び出す、ということをやりたいのだ。

このような時、どうすればいいのだろうか?どちらの関数を、イベントリスナーに登録すればいいのだろうか?

実は、何も考えなくていいのである。ただ素直に、攻撃実行ui処理!をイベントリスナーとして登録すれば良いだけである。イベントは次のようにリスナーへ通知される。

function 攻撃実行イベント通知!(攻撃者, コマンド)
    for リスナー in 攻撃者.攻撃実行イベントリスナーs
        リスナー(攻撃者, コマンド)
    end
end

配列に入っている関数を取り出して、引数を適用している。このとき、直接関数呼び出しを行った時と同様に、型によるディスパッチ処理が効くのである。

前回私は、関数は名前のついたラムダ式のようなものだと書いた。そして、ラムダ式を変数に代入したり配列の要素にできるのと同様に、関数も変数に代入したり配列の要素にできると書いた。それをもとに、何らかの関数の実体が配列の要素となっているようなイメージを抱くことになってしまったかもしれない。これは説明が悪かった。私自身が勘違いしていたのだ。申し訳ない。

実際には変数に代入されたり配列の要素になるのは、関数の実体ではなく関数名の情報である、ということの方がJuliaの取り扱いのイメージに近い。

例を出そう。一度変数に代入した関数を、後から定義を変更している。

julia> f(x) = 2x
julia> a = f
julia> a(2)
4
julia> f(x) = 3x
julia> a(2)
6

もしも、af(x) = 2xの本体であるx -> 2xを保持していたら、最後のa(2)でも4という結果が返るはずである。しかし、実際には6が返っている。これは、aが保持しているのが関数の実体ではなく、関数名であることを示唆している。

Juliaでのオブザーバーパターンでは「どの名前のついた関数を呼び出しますか?」ということだけをイベントリスナーとして登録しておき、実際に呼び出される関数は、イベント通知での実行時に決まるのだ。また、その際には通常の関数呼び出しと同様に型によるディスパッチが効く。一度誤ったイメージをお伝えしてしまい恐縮だが、これが正しいイメージとなる。

不具合の修正

もともと問題視していた、「かばう」で肩代わりした攻撃で戦闘不能になった時のメッセージ順がおかしいという問題は、HP減少!関数の中で、HPが減ったまさにその直後にHP減少イベント通知!を発行するようにしたことで解消する。

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end

    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        HP減少イベント通知!(防御者, ダメージ)
        戦闘不能イベント通知!(防御者)
    else
        防御者.HP = 防御者.HP - ダメージ
        HP減少イベント通知!(防御者, ダメージ)
    end
end

さらに、戦闘不能になったときに、「かばうのをやめた」のではなく「かばえなくなった」と変更する対応を入れよう。次の関数に引数を追加して、if文で分岐しよう。

function かばう解除ui処理!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
end

かばう解除トリガという引数を追加した。

function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
        println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
    elseif かばう解除トリガ === :戦闘不能
        println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

芋づる式に、呼び出し元にもかばう解除トリガ追加していく。

function かばう解除イベント通知!(行動者, 防御者, かばう解除トリガ)
    for リスナー in 防御者.かばう解除イベントリスナーs
        リスナー(行動者, 防御者, かばう解除トリガ)
    end
end
function かばう解除!(行動者, かばう解除トリガ)
        ...
        かばう解除イベント通知!(行動者, 対象者, かばう解除トリガ)
        ...
end

ところで、次のところで悩んでしまう。これらの処理の中で、かばう解除トリガをリスナーへの引数で必要があるのだが、それがちょっと嫌だ。

#戦闘.jl
function 行動前処理イベント通知!(行動者::Tキャラクター) #「かばう解除トリガ」を追加する?
    for リスナー in 行動者.行動前処理イベントリスナーs
        リスナー(行動者) #「かばう解除トリガ」を追加する?
    end
end
#キャラクター.jl
function 戦闘不能イベント通知!(防御者::Tキャラクター) #「かばう解除トリガ」を追加する?
    for リスナー in 防御者.戦闘不能イベントリスナーs
        リスナー(防御者) #「かばう解除トリガ」を追加する?
    end
end

今後、行動前処理イベントや戦闘不能イベントのリスナーとして色々なものが追加される可能性があるが、それらがかばう解除トリガを必要とする可能性は低い。受け取っても無視するように作りたくはない。イベントリスナー間は独立していて欲しいのに、無用な引数を受け取り、無視するという、妙な依存関係が生まれてしまう。

そこで一つギミックを導入しよう。「関数を返す関数」を作るのだ。

クロージャ

次のgetかばう解除ui処理!を見てほしい。この関数は、かばう解除トリガを受け取り、受け取った値に対して、内部で定義したかばう解除!関数を返す。(もとのかばう解除!は消した。)

function getかばう解除!(かばう解除トリガ)
    return function かばう解除!(行動者)
        if !isnothing(行動者.かばっているキャラクター)
            対象者 = 行動者.かばっているキャラクター
            かばう解除イベント通知!(行動者, 対象者, かばう解除トリガ)
            行動者.かばっているキャラクター = nothing                    
            対象者.かばってくれているキャラクター = nothing
            #事後条件
            かばうデータ整合性チェック(行動者)
            かばうデータ整合性チェック(対象者)
        end
    end
end

こうして定義したgetかばう解除!を、getかばう解除!(:戦闘不能時)と呼び出すと、関数がreturnされる。その関数というのは、書くとすれば次のようなものだ。(便宜的にかばう解除!という関数名を表示したが、この関数名が表に出てくることはない。)

function かばう解除!(行動者)
        ...
        かばう解除イベント通知!(行動者, 対象者, :戦闘不能時)
        ...
end

厳密には、上記のように:戦闘不能という値が埋め込まれる区分けではなく、:戦闘不能時という値が入った変数への参照を保持し続けている。が、この値が変更されるということは当面考えられないので、上記のように固定で埋め込まれていると考えても差し支えない。

あとは、リスナー登録の時にどちらのシンボルを与えるかを指定しておけば、その時点でパターンを指定できるので、リスナー実行時にどちらのパターンかという情報を引数として渡す必要がなくなるのだ。

mutable struct Tキャラクター共通データ
    ...
            [getかばう解除!(:行動前処理)], [getかばう解除!(:戦闘不能)], [攻撃実行ui処理!], [回復実行ui処理!], 
    ...
end

Juliaは関数が第一級オブジェクトなので、「関数を返す関数」というものも定義できる。そして、内部の関数は、その外側の変数(包んでいる関数の引数やローカル変数)を参照できる。このように、外部の変数の情報を保持した関数のことを「クロージャ」という。日本語訳は「関数閉包」だが、日本語でも「クロージャ」と呼ぶことの方が多い。

クロージャは定義を読んでもよくわからず、使っていくうちに慣れていくタイプの概念なので、今の説明を聞いてよくわからなくても問題はない。今後もちょこちょこ使用すると思うので、その中で慣れていこう。

さて、長くなったが、一通りの置き換えが終わった。今は描画処理がprintlnと簡単だったのでメリットが分かりづらいが、今後、画面の描画をもっとリッチにしたいと思った時に、描画関連ロジックはもっと複雑になっていくだろう。そのようなときにも、モデル部分のロジックは手を加えず、ビューを差し替えるだけでよりリッチな画面に変更していくことができるのだ。

もちろん、MVCパターンもいい面ばかりではない。直接の関数呼び出しと比べて、処理の流れは分かりづらくなる。これはオブザーバーパターンと同じである。モデルとビューの分離の手段として、オブザーバーパターンを使っているのだから当然ではある。

MVCパターンの限界

さて、悪いところとは別に、MVCパターンの限界というものもある。MVCパターンのミソは、モデルがビューのことを知らないふりしていられるというところにある。モデルは一方的にイベント通知を発行し、ビューはそれを受け取りせっせと描画を行う。ただ、これはあくまで「知らないふりをする」だけだ。実際にはモデルは完全にビューのことを無視することはできない。

例えば、ビューがどのような情報を表示したいのか、という情報を知らなければ、モデルはイベント通知にその情報を含めることはできない。ビューが表示したい情報を増やせば、当然モデルもそれに合わせて情報を送り込んであげる必要がある。モデルがビューのことを知らないというのは幻想なのだ。

また別の観点として、処理実行順の問題もある。モデルはイベント通知の発行後、「ビューのことなど知らん」と次の処理に進めるわけではない。実際には、モデルは自分のイベントリスナーに声をかけて、イベントリスナーの処理が終わるのを待って、それから次の処理に移るのである。モデルは概念的にはビューのことを知らないふりしてはいるが、実際には実行処理の順序という意味でビューの処理の影響を直接的に受ける。通常、これが大きな問題になることはないが、画面の描画に時間がかかるときに問題になるケースもある。

また、そのような実装レベルの話ではなく、より上位の仕様のレベルで相性が悪いこともある。一つ例を紹介したい。有名なゲームタイトルに、ファイナルファンタジーシリーズがある。このシリーズの第9作のファイナルファンタジーⅨ(以下FF9)は、「アクティブタイムバトル」と呼ばれる戦闘システムを採用している(FF9に限るわけではないが)。普通のゲームでは、コマンドの選択中にはゲーム中の時間は止まっているものだが、「アクティブタイムバトル」システムではそのようなときにもゲーム中の時間が止まらないのだ。

デデデン、デデン、デデン、デデンデデン、と音楽が流れる。戦闘が始まる。バーンと味方キャラクターと敵キャラクターが画面で相対する。味方キャラクターと敵キャラクターともにゲージが溜まっていく。ゲージがマックスになったら行動できるのだ。ジタンというキャラクターのゲージが溜まり、ピコーンという軽快な音を立てる。ジタンの行動を選択する。うーん、攻撃をしようか、それとも盗むコマンドで敵からアイテムを奪おうか。迷っている間にも敵キャラクターのゲージは溜まっていく。うかうかしていられない、ええい、攻撃だ。とまあこんな感じの手に汗握る戦闘システムである。

ところでFF9ではこのアクティブタイムバトルが非常に徹底されている。ゲーム内の時間経過は、画面描画とは完全に独立している。例えば、魔法を使うと数秒間、魔法のエフェクトが画面に表示される。その間にもゲーム内の時間が経過し、ゲージは動く。ゲームが終盤になるにつれ、魔法のエフェクトはどんどん派手になっていき、描画時間も長時間に及ぶ。最も長いエフェクトの攻撃だと、なんと一分間にわたりアニメーションが流れるのだ。もちろんその一分間の間にも、ゲーム内の時間は動き続ける。

ところで、このゲームには、一定時間ごとに少しずつHPが回復する「リジェネ」という魔法がある。リジェネはゲーム内の時間経過に合わせてHPを回復する。そのため、一分にも及ぶアニメーションの間も定期的にHPを回復し続ける。すると、リジェネをかけておくと、なんと派手な攻撃を一発打つだけで、攻撃が終わるころにはHPまで全回復するのである。まさに攻撃は最大の防御なりといったところである。どこまで事前にゲームデザイナーが意図したかどうかはわからないが、とにかくそのような動きになっているのだ。

この仕様を実現しようと思うと、先程のような、「モデルがイベントを発行した後、画面の描画を終わらせて、その後にモデルの処理を再開する」というような動きではダメだ。モデルの実行と画面の実行は独立して動く必要がある。単純なMVCパターン(というかオブザーバーパターン)ではこの仕様は実現できない。これを解消するには、「メッセージキューイング」などの別の手法を使うか、あるいは複数スレッドを立ててモデルの処理と画面の処理を本当に並列にするなど、なんらかの手段をとる必要がある。

また、逆に、画面の状態を監視しながらモデルの動きを決めたい、という要件が入ってきたらどうなるか。FF9の例で言うと、やはり先程のような事象は困ると言うことで、魔法のエフェクトの描画中は、ゲーム内の時間経過を止めたいと考えるとする。ただし完全に止めたいわけでもなく、臨場感を出すためにゲージだけは動かしたい、一方でゲームバランスのためにリジェネの効果は止めたい、と言われたとする。そうなると、モデルなんらかの手段で、今の画面の情報というものを取得する必要があり、これは単純なMVCパターンでは対応できない。

このようなわけで、MVCパターンというのは決して完璧なパターンというわけでもなく(まあそんなパターンは存在しないのだが)、それを補うための派生版のパターンも存在する。いきなり派生版に出会うと戸惑うかもしれないが、まずは基本型として今回説明したものを理解しておくというのは重要である。

さて、すこし脱線してしまったが、これでモデルとビューを分離することができた。これでモデルに影響を与えることなく画面を改善していくことができる。なので今から2万年ほどかけてFF9みたいな素敵な戦闘画面を作り込んでいってもいいが、ちょっと大変そうなので、それよりは本筋のスキルの実装に戻ることにしよう。

「ヒール」の実装

次は「ヒール」のスキルを実装しよう。

  • ヒール
  • HPを回復する。

HPはどのくらい回復すればいいだろうか?最大HPの50%回復するようにしよう。端数が出たら切り捨てる。最大HPが99のキャラクターがいたら、回復量は49になる。

今度は回復系の行動の実装である。全く新しい処理なので、それなりに大変だろう。

実装に取り掛かる前に、いつもながらテストから書こう。テストはこんなふうになるだろう。

#game_test.jl
@testset "ヒール" begin
    @testset "偶数:最大HP以内" begin
        p = createプレイヤー(HP=100)
        Game.HP減少!(p, 51)
        ヒールで回復 = Game.T行動(Game.createスキル(:ヒール), p, p)
        Game.行動実行!(ヒールで回復)
        @test p.HP == 100 - 51 + 50                
    end
    @testset "奇数:最大HP以内" begin
        p = createプレイヤー(HP=99)
        Game.HP減少!(p, 51)
        ヒールで回復 = Game.T行動(Game.createスキル(:ヒール), p, p)
        Game.行動実行!(ヒールで回復)
        @test p.HP == 99 - 51 + 49             
    end
    @testset "最大HPまで" begin
        p = createプレイヤー(HP=100)
        Game.HP減少!(p, 49)
        ヒールで回復 = Game.T行動(Game.createスキル(:ヒール), p, p)
        Game.行動実行!(ヒールで回復)
        @test p.HP == 100                
    end
end 

最大HPの50%を回復するからには、最大HPというフィールドが必要である。フィールドを追加して、コンストラクタに手を加える。最大HPはコンストラクタで渡されたHPと同じ値を設定するようにしている。

#キャラクター.jl
mutable struct Tキャラクター共通データ
    名前
    HP
    最大HP #追加
    MP
    ...
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, 
    ...
end

回復スキルのための構造体として、T回復スキルを作ろう。フィールドに、名前回復割合消費MPを持つ。命中率をフィールドに持つのはやめた。私は回復行動が外れるとイライラするのだ。

#スキル.jl
struct T回復スキル <: Tスキル
    名前
    回復割合
    消費MP
    T回復スキル(名前, 回復割合, 消費MP) = begin
        if !(0 ≤ 回復割合 ≤ 1)
            throw(DomainError("回復割合は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        new(名前, 回復割合, 消費MP)
    end
end

50%の回復は強力なので、乱発できては面白くない。消費MPは少し高めに10に設定しよう。

#スキル.jl
function createスキル(スキルシンボル)
    ...
    elseif スキルシンボル === :ヒール
        return T回復スキル("ヒール", 0.5, 10)
    ...
end

回復系スキルは、味方の一覧から対象を選択できるようにする。行動系統回復系行動を加え、コマンド選択時にプレイヤーsが対象リストとして返されるようにしよう。

#行動系統.jl
struct T攻撃系行動 end
struct T回復系行動 end #追加
struct Tかばう行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::T回復スキル) = T回復系行動() #追加
行動系統(::Tかばう) = Tかばう行動()
#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...
    #追加
    function get対象リスト(::T回復系行動)
        return プレイヤーs
    end
    ...

戦闘時に回復系行動を実行できるようにしよう。

#戦闘.jl
function 行動実行!(::T回復系行動, 行動::T行動) 
    回復実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

回復実行!関数は次のようになる。

#戦闘.jl
function 回復実行!(行動者, 対象者, スキル::Tスキル)
    回復実行イベント通知!(行動者, スキル)
    回復量= 回復量計算(対象者.最大HP, スキル.回復割合)
    HP回復!(対象者, 回復量)
end

function 回復量計算(最大HP, 回復割合)
    if 最大HP < 0
        throw(DomainError("最大HPが負の値になっています"))
    end
    if 回復割合 < 0 || 1 < 回復割合 
        throw(DomainError("回復割合が0から1の範囲を超えています"))
    end
    return round(Int, 最大HP * 回復割合, RoundDown) 
end

回復実行イベント通知!はどのような処理になるだろうか?類似である攻撃実行イベント通知!は結局のところ、攻撃実行ui処理!が呼ばれ、その中身は次のようにスキルの名前が表示されるだけである。

function 攻撃実行ui処理!(攻撃者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)の$(スキル.名前)!")
end

回復時もこれと同じ処理で良いので、この関数を回復実行イベント通知!のリスナーとして登録しよう。しかし、回復実行イベント通知!のリスナーに、攻撃実行ui処理!が来るのは気持ちが悪い。そもそも、攻撃実行ui処理!は、単に行動するキャラクターの名前と、実行するスキルの名前を表示するだけである。もっと中立的な名前に変更すべきだ。

#関数名等の変更
function スキル実行ui処理!(行動者, スキル::Tスキル)
    println("----------")
    println("$(行動者.名前)の$(スキル.名前)!")
end

この関数を、攻撃実行イベント、新しく作る回復実行イベントのイベントリスナーとして登録しよう。

#キャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    回復実行イベントリスナーs
    ...
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, 
            [かばう解除!], [かばう解除!], [スキル実行ui処理!], [スキル実行ui処理!], 
        ...
    end
end

ただ、そうなると困ったことになる。T通常攻撃T攻撃スキルのどちらもが、攻撃実行イベント通知!を発行する。T攻撃スキルの時には、スキル実行ui処理が呼ばれていいが、T通常攻撃のときには、これまで通りの攻撃実行ui処理!が呼ばれて欲しい。そうすると結局、イベントリスナーとして登録するのは攻撃実行ui処理!とせざるを得ない。攻撃実行ui処理!Tスキルを引数に呼ばれた時だけ、スキル実行ui処理!が呼ばれるようにしよう。

#ui.jl
function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
end

function 攻撃実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function 回復実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function スキル実行ui処理!(行動者, スキル::Tスキル)
    println("----------")
    println("$(行動者.名前)の$(スキル.名前)!")
end

イベントリスナーは次のように登録する。

#キャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    攻撃実行イベントリスナーs
    回復実行イベントリスナーs
    ...
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, 
            [かばう解除!], [かばう解除!], [攻撃実行ui処理!], [回復実行ui処理!],
        ...
    end
end

これで、リスナーを登録するところは完成だ。

次は、リスナーへの通知を作る。回復実行イベント通知!処理を作ろう。

#キャラクター.jl
function 回復実行イベント通知!(行動者, コマンド)
    for リスナー in 行動者.回復実行イベントリスナーs
        リスナー(行動者, コマンド)
    end
end

HP回復処理自体は次のようになる。

#キャラクター.jl
function HP回復!(対象者, 回復量)
    if 回復量 < 0
        throw(DomainError("回復量がマイナスです"))
    end

    HP減少量 = 対象者.最大HP - 対象者.HP
    実際回復量 = HP減少量 > 回復量 ? 回復量 : HP減少量

    HP回復イベント通知!(対象者, 実際回復量)
    対象者.HP += 実際回復量
end

プチ文法事項として、実際回復量 = HP減少量 > 回復量 ? 回復量 : HP減少量という登場している。これは三項演算子と呼ばれる機能で、簡易的なif文のようなものだ。条件 ? 真の時の値 : 偽の時の値という書き方をする。条件が真であればコロンの前の値が、偽であればコロンの後の値が返る。

また、対象者.HP += 実際回復量という文も登場している。これは、対象者.HP = 対象者.HP + 実際回復量と同じになる。

ついでに、HP減少!処理も似たような形に変更しておこう。元の処理では、HP減少イベント通知!HP == 0の分岐とそうでない分岐の両方にあり、少し気持ちが悪かった。

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end

    実際ダメージ = ダメージ < 防御者.HP ? ダメージ : 防御者.HP
    防御者.HP -= 実際ダメージ
    HP減少イベント通知!(防御者, ダメージ)

    if 防御者.HP == 0
        戦闘不能イベント通知!(防御者)
    end
end

画面通知の部分も他と同じように作ろう。

#キャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    HP減少イベントリスナーs
    HP回復イベントリスナーs #追加
    ...
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        new(名前, HP, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, 
            ...
            [HP減少ui処理!], [HP回復ui処理!], [攻撃失敗ui処理!], [行動決定ui処理!])
    ...
end

function HP回復イベント通知!(対象者, 回復量)
    for リスナー in 対象者.HP回復イベントリスナーs
        リスナー(対象者, 回復量)
    end
end
#ui.jl
function HP回復ui処理!(対象者, 回復量)
    println("$(対象者.名前)のHPが$(回復量)回復した!")
    println("$(対象者.名前)の残りHP:$(対象者.HP)")
end

これでテストが通るはずだ。さらに、実際に画面から動かしても正しく動く。

*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:60 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ドラゴン HP:370 MP:80
********************
太郎のターン
選択してください:
   攻撃
 > スキル
選択してください:
   連続攻撃10
   かばう0
 > ヒール10
選択してください:
   太郎
 > 花子
   遠藤君
   高橋先生
----------
太郎のヒール!
花子のHPが40回復した!
花子の残りHP:100

成功だ!

回復スキルの実装は、思いのほか簡単に終わった。既存の処理にあまり手を加えることなく、ほとんど新しい処理を付け加えるだけでよかった、というような印象ではないだろうか。単調で退屈なくらいだったかもしれない。Juliaは多重ディスパッチが強力なので、新たな分類のデータを追加したときに、専用の関数を付け加えるだけで事足りてしまうのだ。このような性質を「加法性がある」とか「加法的である」と言ったりする。この性質は非常に重要だ。

今回のまとめ

今回はちょっとギミック寄りの回だった。

  • MVCパターン
  • クロージャ
  • 「ヒール」の実装

ちょっと細かい説明が続き過ぎたかもしれないが、ともあれ1スキル実装できたのはよかった。次回以降も引き続き頑張ってスキルを実装していこう。

次回予告

次のスキルは「刃に毒を塗る」だが、これはまた大変そうだ。刃に毒を塗ったら、次の攻撃で相手を毒状態にすることができる。そうして、毒状態になると毎ターンダメージを受けるのだ。いろいろと新しい処理を書いていく必要がありそうだ。

また、これは次回になるかそれ以降になるかわからないが、今回せっかく苦労してMVCにしたのだから、ビューの差し替えというものを実演したい。2万年かけてFF9の画面を作りたいわけではない。気にしているのは自動テストだ。

自動テストの実行時、結果の表示の合間に、コマンドラインへの表示が行われている。これを表示しないようにすることはできないだろうか?テストの結果が見えづらいからだ。そのため普通にアプリを起動した時の画面からは、文字が表示されるようにしたいが、自動テストの時には表示されないようにしたい。これはある意味、通常起動の時と、自動テストの時とでビューを差し替えているということに等しい。通常起動の時には本物のビューを、自動テストの時には偽物のビューを使う。このように、自動テスト時に、テストに都合の良いようにすげかえる偽物のことを「モック」とか「スタブ」とか呼ぶことがある。この手法を使ってみたい。

続き

第10回

コード

今回のコードを載せておこう。Tキャラクター構造体が大きくなり過ぎているので、近いうちに手を加えたい。

#game_exec.jl
include("game.jl")

Game.main()
#game.jl
module Game

using Random
import REPL
using REPL.TerminalMenus

include("キャラクター.jl")
include("戦闘.jl")
include("ui.jl")

function モンスター遭遇イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end

function 戦闘勝利イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end

function 戦闘敗北イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end


function main()
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)])
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう), createスキル(:ヒール)])
    プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:かばう)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [createスキル(:ヒール), createスキル(:連続攻撃)])

    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!])

    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])

    if モンスター.HP == 0
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!])
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!])
    end
end

end
#ui.jl
include("スキル.jl")
include("行動系統.jl")

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    function get対象リスト(スキル::T行動内容)
        get対象リスト(行動系統(スキル))
    end

    function get対象リスト(::T攻撃系行動)
        return モンスターs
    end

    function get対象リスト(::T回復系行動)
        return プレイヤーs
    end

    function get対象リスト(::Tかばう行動)
        return filter(p -> p != 行動者 && isnothing(p.かばってくれているキャラクター), プレイヤーs)
    end

    function RadioMenu作成(選択肢)
        while true
            r = RadioMenu(選択肢, pagesize=4)
            選択index = request("選択してください:", r)

            if 選択index == -1
                println("正しいコマンドを入力してください")
                continue
            else
                return 選択index
            end
        end
    end

    function 行動対象を選択し行動を決定(行動内容::T行動内容)
        対象リスト = get対象リスト(行動内容)
        if length(対象リスト) == 1
            return T行動(行動内容, 行動者, 対象リスト[1])
        else
            選択index = RadioMenu作成([s.名前 for s in 対象リスト])
            対象者 = 対象リスト[選択index]
            return T行動(行動内容, 行動者, 対象者)
        end
    end

    while true
        選択肢 = ["攻撃", "スキル"]
        選択index = RadioMenu作成(選択肢)
        選択 = 選択肢[選択index]
        if 選択 == "攻撃"
            return 行動対象を選択し行動を決定(T通常攻撃())
        elseif 選択 == "スキル"
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            選択スキル = 行動者.スキルs[選択index]
            if 行動者.MP < 選択スキル.消費MP 
                println("MPが足りません")
                continue
            end
            return 行動対象を選択し行動を決定(選択スキル)
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP) MP:$(p.MP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP) MP:$(m.MP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
end

function 攻撃実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function 回復実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function スキル実行ui処理!(行動者, スキル::Tスキル)
    println("----------")
    println("$(行動者.名前)の$(スキル.名前)!")
end

function かばう実行ui処理!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!")
end

function かばう発動ui処理!(防御者)
    println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
end

function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
        println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
    elseif かばう解除トリガ === :戦闘不能
        println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

function HP減少ui処理!(防御者, 防御者ダメージ)
    println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

function HP回復ui処理!(対象者, 回復量)
    println("$(対象者.名前)のHPが$(回復量)回復した!")
    println("$(対象者.名前)の残りHP:$(対象者.HP)")
end

function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
end

function 攻撃失敗ui処理!()
    println("攻撃は失敗した・・・")
end

function モンスター遭遇イベントui処理!()
    println("モンスターに遭遇した!")
    println("戦闘開始!")
end

function 戦闘勝利イベントui処理!()
    println("戦闘に勝利した!")
end

function 戦闘敗北イベントui処理!()
    println("戦闘に敗北した・・・")
end
#キャラクター.jl
include("スキル.jl")

mutable struct Tキャラクター共通データ
    名前
    HP
    最大HP
    MP
    攻撃力
    防御力
    スキルs
    かばっているキャラクター
    かばってくれているキャラクター
    行動前処理イベントリスナーs
    戦闘不能イベントリスナーs
    攻撃実行イベントリスナーs
    回復実行イベントリスナーs
    かばう実行イベントリスナーs
    かばう発動イベントリスナーs
    かばう解除イベントリスナーs
    HP減少イベントリスナーs
    HP回復イベントリスナーs
    攻撃失敗イベントリスナーs
    行動決定イベントリスナーs
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        if HP < 0
            throw(DomainError("HPが負の値になっています"))
        end
        if MP < 0
            throw(DomainError("MPが負の値になっています"))
        end        
        if 攻撃力 < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 防御力 ≤ 0
            throw(DomainError("防御力が0または負の値になっています"))
        end 
        new(名前, HP, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, 
            [getかばう解除!(:行動前処理)], [getかばう解除!(:戦闘不能)], [攻撃実行ui処理!], [回復実行ui処理!], 
            [かばう実行ui処理!], [かばう発動ui処理!], [かばう解除ui処理!], 
            [HP減少ui処理!], [HP回復ui処理!], [攻撃失敗ui処理!], [行動決定ui処理!])
    end
end

abstract type Tキャラクター end

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function 戦闘不能イベント通知!(防御者::Tキャラクター)
    for リスナー in 防御者.戦闘不能イベントリスナーs
        リスナー(防御者)
    end
end

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end

    実際ダメージ = ダメージ < 防御者.HP ? ダメージ : 防御者.HP
    防御者.HP -= 実際ダメージ
    HP減少イベント通知!(防御者, ダメージ)

    if 防御者.HP == 0
        戦闘不能イベント通知!(防御者)
    end
end

function HP回復!(対象者, 回復量)
    if 回復量 < 0
        throw(DomainError("回復量がマイナスです"))
    end

    HP減少量 = 対象者.最大HP - 対象者.HP
    実際回復量 = HP減少量 > 回復量 ? 回復量 : HP減少量

    対象者.HP += 実際回復量
    HP回復イベント通知!(対象者, 実際回復量)
end


function MP減少!(行動者, コマンド::T通常攻撃)
end

function MP減少!(行動者, コマンド::Tスキル)
    消費MP = コマンド.消費MP
    if 消費MP < 0
        throw(DomainError("ダメージがマイナスです"))
    end    
    if 行動者.MP - 消費MP < 0
        行動者.MP = 0
    else
        行動者.MP = 行動者.MP - 消費MP
    end
end

function is行動可能(キャラクター)
    if キャラクター.HP < 0
        throw(DomainError("キャラクターのHPが負です"))
    end
    return キャラクター.HP > 0
end

function 行動可能な奴ら(キャラクターs)
    return [c for c in キャラクターs if is行動可能(c)]
end

function 攻撃実行イベント通知!(攻撃者, コマンド)
    for リスナー in 攻撃者.攻撃実行イベントリスナーs
        リスナー(攻撃者, コマンド)
    end
end

function 回復実行イベント通知!(行動者, コマンド)
    for リスナー in 行動者.回復実行イベントリスナーs
        リスナー(行動者, コマンド)
    end
end

function 攻撃失敗イベント通知!(攻撃者)
    for リスナー in 攻撃者.攻撃失敗イベントリスナーs
        リスナー()
    end
end

function かばう実行イベント通知!(行動者, 対象者)
    for リスナー in 行動者.かばう実行イベントリスナーs
        リスナー(行動者, 対象者)
    end
end

function かばう発動イベント通知!(防御者)
    for リスナー in 防御者.かばう発動イベントリスナーs
        リスナー(防御者)
    end
end

function かばう解除イベント通知!(行動者, 防御者, かばう解除トリガ)
    for リスナー in 防御者.かばう解除イベントリスナーs
        リスナー(行動者, 防御者, かばう解除トリガ)
    end
end

function HP減少イベント通知!(防御者, 防御者ダメージ)
    for リスナー in 防御者.HP減少イベントリスナーs
        リスナー(防御者, 防御者ダメージ)
    end
end

function HP回復イベント通知!(対象者, 回復量)
    for リスナー in 対象者.HP回復イベントリスナーs
        リスナー(対象者, 回復量)
    end
end

function 行動決定イベント通知!(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    for リスナー in 行動者.行動決定イベントリスナーs
        リスナー(行動者, プレイヤーs, モンスターs)
    end
end
#スキル.jl
abstract type T行動内容 end 
abstract type Tスキル <: T行動内容 end 
struct T通常攻撃 <: T行動内容 end
struct Tかばう <: Tスキル 
    名前
    消費MP
end

function Tかばう() 
    return Tかばう("かばう", 0)
end

struct T攻撃スキル <: Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    T攻撃スキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
        if 威力 < 0
            throw(DomainError("威力が負の値になっています"))
        end
        if !(0 ≤ 命中率 ≤ 1)
            throw(DomainError("命中率は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 攻撃回数min < 0
            throw(DomainError("攻撃回数minが負の値になっています"))
        end 
        if 攻撃回数max < 0
            throw(DomainError("攻撃回数maxが負の値になっています"))
        end 
        if 攻撃回数max < 攻撃回数min 
            throw(DomainError("攻撃回数maxが攻撃回数minより小さくなっています"))
        end 
        new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
    end
end

function T攻撃スキル(名前, 威力, 命中率, 消費MP) 
    return T攻撃スキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

struct T回復スキル <: Tスキル
    名前
    回復割合
    消費MP
    T回復スキル(名前, 回復割合, 消費MP) = begin
        if !(0 ≤ 回復割合 ≤ 1)
            throw(DomainError("回復割合は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        new(名前, 回復割合, 消費MP)
    end
end


function createスキル(スキルシンボル)
    if スキルシンボル === :大振り
        return T攻撃スキル("大振り", 2, 0.4, 0)
    elseif スキルシンボル === :連続攻撃
        return T攻撃スキル("連続攻撃", 0.5, 1, 10, 2, 5)
    elseif スキルシンボル === :かばう
        return Tかばう()
    elseif スキルシンボル === :ヒール
        return T回復スキル("ヒール", 0.5, 10)
    else
        Throw(DomainError("未定義のスキルが指定されました"))
    end
end

function かばうデータ整合性チェック(キャラクター)
    if !isnothing(キャラクター.かばっているキャラクター)
        if (キャラクター.かばっているキャラクター.かばってくれているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end

    if !isnothing(キャラクター.かばってくれているキャラクター)
        if (キャラクター.かばってくれているキャラクター.かばっているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end
end

function かばう実行!(行動者, 対象者)
    かばう実行イベント通知!(行動者, 対象者)
    行動者.かばっているキャラクター = 対象者
    対象者.かばってくれているキャラクター = 行動者

    #事後条件
    かばうデータ整合性チェック(行動者)
    かばうデータ整合性チェック(対象者)
end

function getかばう解除!(かばう解除トリガ)
    return function かばう解除!(行動者)
        if !isnothing(行動者.かばっているキャラクター)
            対象者 = 行動者.かばっているキャラクター
            かばう解除イベント通知!(行動者, 対象者, かばう解除トリガ)
            行動者.かばっているキャラクター = nothing                    
            対象者.かばってくれているキャラクター = nothing
            #事後条件
            かばうデータ整合性チェック(行動者)
            かばうデータ整合性チェック(対象者)
        end
    end
end
#行動系統.jl
struct T攻撃系行動 end
struct T回復系行動 end
struct Tかばう行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::T回復スキル) = T回復系行動()
行動系統(::Tかばう) = Tかばう行動()
#戦闘.jl
include("行動系統.jl")

struct T行動
    コマンド::T行動内容
    行動者::Tキャラクター
    対象者::Tキャラクター
end

function ダメージ計算(攻撃力, 防御力)
    if 攻撃力 < 0
        throw(DomainError("攻撃力が負の値になっています"))
    end
    if 防御力 ≤ 0
        throw(DomainError("防御力が0または負の値になっています"))
    end
    return round(Int, 10 * 攻撃力/防御力)
end

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    攻撃実行イベント通知!(攻撃者, コマンド)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者)
        防御者 = 防御者.かばってくれているキャラクター
    end
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    攻撃実行イベント通知!(攻撃者, スキル)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者)
        防御者 = 防御者.かばってくれているキャラクター
    end
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        if rand() < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
        else
            攻撃失敗イベント通知!(攻撃者)
        end
    end
end

function 回復量計算(最大HP, 回復割合)
    if 最大HP < 0
        throw(DomainError("最大HPが負の値になっています"))
    end
    if 回復割合 < 0 || 1 < 回復割合 
        throw(DomainError("回復割合が0から1の範囲を超えています"))
    end
    return round(Int, 最大HP * 回復割合, RoundDown) 
end


function 回復実行!(行動者, 対象者, スキル::T回復スキル)
    回復実行イベント通知!(行動者, スキル)
    回復量 = 回復量計算(対象者.最大HP, スキル.回復割合)
    HP回復!(対象者, 回復量)
end

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    行動決定イベント通知!(行動者, プレイヤーs, モンスターs)
    return コマンド選択(行動者, プレイヤーs, モンスターs)
end

function get選択可能行動内容(行動者::Tキャラクター)
    選択可能行動内容 = T行動内容[]
    push!(選択可能行動内容, T通常攻撃())
    選択可能スキル = filter(s -> s.消費MP ≤ 行動者.MP, 行動者.スキルs)
    append!(選択可能行動内容, 選択可能スキル)
    return 選択可能行動内容
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    選択可能行動内容 = get選択可能行動内容(行動者)
    行動内容 = rand(選択可能行動内容)
    return T行動(行動内容, 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

function 行動実行!(行動::T行動)
    行動実行!(行動系統(行動.コマンド), 行動)
end

function 行動実行!(::T攻撃系行動, 行動::T行動) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::T回復系行動, 行動::T行動) 
    回復実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動) 
    かばう実行!(行動.行動者, 行動.対象者)
end

function 行動前処理イベント通知!(行動者::Tキャラクター)
    for リスナー in 行動者.行動前処理イベントリスナーs
        リスナー(行動者)        
    end
end

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    行動前処理イベント通知!(行動者)
end

function  is全滅(キャラクターs)
    return all(p.HP == 0 for p in キャラクターs)
end

function is戦闘終了(プレイヤーs, モンスターs)
    return is全滅(プレイヤーs) || is全滅(モンスターs)
end

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs)
    return shuffle(行動順)
end

function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

以下はテストコードだ。

#game_test.jl
include("game.jl")

using Test

function createプレイヤー(;名前="太郎", HP=100, MP=20, 攻撃力=10, 防御力=10, スキルs=[])
    return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

function createモンスター(;名前="ドラゴン", HP=400, MP=80, 攻撃力=20, 防御力=10, スキルs=[])
    return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

@testset "HP減少" begin

    @testset "ダメージ < HP" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end

    @testset "複数回ダメージ" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 94
    end   

    @testset "ダメージ > HP" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 101) #101のダメージ
        @test c.HP == 0
    end

    @testset "ダメージ = HP" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 100) #100のダメージ
        @test c.HP == 0
    end    
end

@testset "行動実行!" begin
    @testset "通常攻撃" begin
        p = createプレイヤー(HP=100, 攻撃力=10)
        m = createモンスター(HP=200, 攻撃力=20)

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.T通常攻撃(), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 190

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.T通常攻撃(), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 80
        @test m.HP == 190
    end    

    @testset "大振り攻撃" begin
        p = createプレイヤー(HP=100, 攻撃力=10)
        m = createモンスター(HP=200, 攻撃力=20)

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:大振り), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 180 || m.HP == 200

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:大振り), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 100 || p.HP == 60
        @test m.HP == 180 || m.HP == 200
    end 

    @testset "連続攻撃" begin
        プレイヤーHP = 100
        プレイヤー攻撃力 = 10
        p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力)
        モンスターHP = 200
        モンスター攻撃力 = 20
        m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力)

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == プレイヤーHP
        プレイヤー与ダメージ = round(Int, プレイヤー攻撃力/2)
        @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        モンスター与ダメージ = round(Int, モンスター攻撃力/2)
        @test プレイヤーHP - モンスター与ダメージ * 5 ≤ p.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 
        @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 
    end 

    @testset "かばう" begin
        @testset "かばう実行データチェック" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            @test 太郎.かばっているキャラクター == 花子
            @test 花子.かばってくれているキャラクター == 太郎
        end
        @testset "かばう解除データチェック" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            Game.行動前処理!(太郎, [花子], []) #「かばう」が解除される
            @test isnothing(太郎.かばっているキャラクター)
            @test isnothing(花子.かばってくれているキャラクター)
        end
        @testset "通常攻撃" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)
            @test 花子.HP == 100
            @test 太郎.HP == 80 

            Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

            Game.行動実行!(ドラゴンから花子へ攻撃)
            @test 花子.HP == 80
            @test 太郎.HP == 80 
        end
        @testset "連続攻撃" begin
            プレイヤーHP = 100
            太郎 = createプレイヤー(名前="太郎", HP=プレイヤーHP)
            花子 = createプレイヤー(名前="花子", HP=プレイヤーHP)
            モンスター攻撃力 = 20
            ドラゴン = createモンスター(攻撃力=モンスター攻撃力)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            ドラゴンから花子へ連続攻撃 = Game.T行動(Game.createスキル(:連続攻撃), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ連続攻撃)
            @test 花子.HP == プレイヤーHP
            モンスター与ダメージ = round(Int, モンスター攻撃力/2)
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2

            Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

            Game.行動実行!(ドラゴンから花子へ連続攻撃)
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 花子.HP ≤ プレイヤーHP - モンスター与ダメージ * 2
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2
        end
        @testset "花子を太郎がかばい、太郎を遠藤君がかばっているとき、太郎がダメージを受ける" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)
            遠藤君 = createプレイヤー(名前="遠藤君", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            遠藤君が太郎をかばう = Game.T行動(Game.createスキル(:かばう), 遠藤君, 太郎)
            Game.行動実行!(遠藤君が太郎をかばう)

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 100
            @test 太郎.HP == 80 
            @test 遠藤君.HP == 100
        end
        @testset "戦闘不能になったらかばう解除" begin
            太郎 = createプレイヤー(名前="太郎", HP=30)
            花子 = createプレイヤー(名前="花子", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 100
            @test 太郎.HP == 10
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 100
            @test 太郎.HP == 0

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 80
            @test 太郎.HP == 0
        end
    end 

    @testset "ヒール" begin
        @testset "偶数:最大HP以内" begin
            p = createプレイヤー(HP=100)
            Game.HP減少!(p, 51)
            ヒールで回復 = Game.T行動(Game.createスキル(:ヒール), p, p)
            Game.行動実行!(ヒールで回復)
            @test p.HP == 100 - 51 + 50                
        end
        @testset "奇数:最大HP以内" begin
            p = createプレイヤー(HP=99)
            Game.HP減少!(p, 51)
            ヒールで回復 = Game.T行動(Game.createスキル(:ヒール), p, p)
            Game.行動実行!(ヒールで回復)
            @test p.HP == 99 - 51 + 49             
        end
        @testset "最大HPまで" begin
            p = createプレイヤー(HP=100)
            Game.HP減少!(p, 49)
            ヒールで回復 = Game.T行動(Game.createスキル(:ヒール), p, p)
            Game.行動実行!(ヒールで回復)
            @test p.HP == 100                
        end
    end 
end

@testset "is戦闘終了" begin
    @testset begin
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=1)]) == false
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0)]) == true
        @test Game.is戦闘終了([createプレイヤー(HP=0)], [createモンスター(HP=1)]) == true
        @test Game.is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=1)], [createモンスター(HP=1)]) == false
        @test Game.is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=0)], [createモンスター(HP=1)]) == true
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=1)]) == false
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=0)]) == true
    end
end

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        先頭 = 配列[1]
        残り = 配列[2:end]
        return !(先頭 in 残り) && is全て相異なる(残り) 
    end
end

@testset "is全て相異なる" begin
    #要素数1
    @test is全て相異なる([1]) == true
    #要素数2
    @test is全て相異なる([1, 2]) == true
    @test is全て相異なる([1, 1]) == false
    #要素数3
    @test is全て相異なる([1, 1, 1]) == false    
    @test is全て相異なる([1, 1, 2]) == false
    @test is全て相異なる([1, 2, 1]) == false
    @test is全て相異なる([2, 1, 1]) == false    
    @test is全て相異なる([1, 2, 3]) == true
    @test is全て相異なる([2, 1, 3]) == true
    @test is全て相異なる([3, 2, 1]) == true
end

@testset "行動順決定" begin
    p1 = createプレイヤー()
    m1 = createモンスター()

    @testset "1vs1" begin
        行動順 = Game.行動順決定([p1], [m1])
        @test length(行動順) == 2
    end

    p2 = createプレイヤー()
    @testset "2vs1" begin
        行動順 = Game.行動順決定([p1, p2], [m1])
        @test length(行動順) == 3
    end

    m2 = createモンスター()
    @testset "1vs2" begin
        行動順 = Game.行動順決定([p1], [m1, m2])
        @test length(行動順) == 3
    end

    @testset "2vs2" begin
        行動順 = Game.行動順決定([p1, p2], [m1, m2])
        @test length(行動順) == 4
    end
end

@testset "is戦闘終了" begin
    @testset "1vs1 両者生存" begin
        p = createプレイヤー(HP=1)
        m = createモンスター(HP=1)
        @test Game.is戦闘終了([p], [m]) == false
    end

    @testset "1vs1 プレイヤー死亡" begin
        p = createプレイヤー(HP=0)
        m = createモンスター(HP=1)
        @test Game.is戦闘終了([p], [m]) == true
    end
end


@testset "is行動可能" begin
    p = createプレイヤー(HP=1)
    @test Game.is行動可能(p) == true
    p = createプレイヤー(HP=0)
    @test Game.is行動可能(p) == false
    m = createモンスター(HP=1)
    @test Game.is行動可能(m) == true
    m = createモンスター(HP=0)
    @test Game.is行動可能(m) == false
end

@testset "行動可能な奴ら" begin
    p1 = createプレイヤー(HP=1)
    @test Game.行動可能な奴ら([p1]) == [p1]
    p2 = createプレイヤー(HP=0)
    @test Game.行動可能な奴ら([p1, p2]) == [p1]
    p3 = createプレイヤー(HP=1)
    @test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]

    m1 = createモンスター(HP=1)
    @test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
    m2 = createモンスター(HP=0)
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
    m3 = createモンスター(HP=1)
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end

@testset "戦況表示" begin
    モンスター = Game.Tモンスター("ドラゴン", 400, 80, 40, 10, [])
    プレイヤー1 = Game.Tプレイヤー("太郎", 100, 20, 10, 10, [])
    プレイヤー2 = Game.Tプレイヤー("花子", 100, 20, 10, 10, [])
    プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 20, 10, 10, [])
    プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 20, 10, 10, [])
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター]

    @test Game.戦況表示(プレイヤーs, モンスターs) == 
    """
    *****プレイヤー*****
    太郎 HP:100 MP:20
    花子 HP:100 MP:20
    遠藤君 HP:100 MP:20
    高橋先生 HP:100 MP:20
    *****モンスター*****
    ドラゴン HP:400 MP:80
    ********************"""
end