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

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


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

一覧はこちら

UIを改善しよう

さて、太郎のスキルの実装も終わり一区切りついたところで、ユーザーインターフェースを改善したい。

今現在はコンソール画面にダーっと文字が出力されるのだが、ちょっと見づらいのが正直なところだ。普通のゲームはこんなふうに文字が流れて行ったりはしない。固定の画面があって、メッセージだけが数秒間表示されたり、HPの値だけが切り替わったりするだろう。それを実現したい。

まずは動作のイメージを持ってもらいたいので、次のコードを丸ごとコピーしてJuliaのReplに貼り付けてほしい。機構については後ほど説明する。

function 消去(n)
    print("\x1b[2K")
    for i in 1:n
        print("\x1b[1F")
        print("\x1b[2K")
    end
end 

function 画面更新(表示文字列リスト)
    for s in 表示文字列リスト
        println(s)
    end

    sleep(1)

    消去(length(表示文字列リスト))
end 

function main()
    for i in 1:10
        表示文字列リスト = ["太郎 HP$(i*100) MP10", "花子 HP$(i*100) MP10"] 
        画面更新(表示文字列リスト)
    end
end

貼り付けができたら、main()を実行しよう。

通常のコンソール画面と違い、文字が流れていくことなく、数字だけが刻々と変わる様子が見て取れる。まるで魔法のようだ。いったい何故こんなことが出来るのだろうか?

エスケープコード

そもそも、Replでなぜ文字が流れていくかというと、文字を描画するたびに、カーソルが1文字ずつ後ろに移動しているからだ。このカーソル位置、実は制御することができるのだ。そのため、文字を描画した後カーソル位置を戻して再度描画すると上書きすることができる。カーソル位置を動かす以外にも、文字に色をつけたり、文字を消したりと、いろいろなことができる。つまりJuliaのReplだけでカラフルなラブレターを書くことだって可能なのだ。想い人にJuliaのコード片を送りつけるといい。(Juliaをインストールしてもいない人に恋してるだなんて言わないよね?)

先程の例でも、一見するとその場で再描画しているように見えるが、実際には次のようなことを行なっている。

  1. 文字を画面に描画する
  2. 1秒待つ
  3. 「カーソルがある行の文字を消し、カーソル位置を1つ上に戻す」ということを描画した行数分だけ繰り返す
  4. 1に戻る

一連の動作が超高速で行われているので、その場で再描画されているように見えるというわけだ。言うまでもなく、3番のカーソル制御をどうやって行なっているか、というところが焦点になる。

これを実現しているのが、コードの次の部分だ。

function 消去(n)
    print("\x1b[2K")
    for i in 1:n
        print("\x1b[1F")
        print("\x1b[2K")
    end
end 

print("\x1b[2K")print("\x1b[1F") という謎の一文がある。これについて説明しよう。まず、printというのは文字の出力である。私がよく使っているのはprintlnだが、これとの違いは改行コードの有無である。printlnは末尾に改行コードを自動的に付与してくれるのだが、printはそうではない。改行コードなしでの出力だ。

その次の"x1b[2k"というのが「ANSIエスケープコード」と呼ばれる特別な文字列である。x1bというのがエスケープ文字と呼ばれる特別な文字で、「この後に続くのはただの文字列ではなく『制御コード』ですよ」ということを言っている。続く[の後に続くのが引数で、最後の文字がどのような制御内容かを意味している。

  • Kというのはカーソルがある行の文字を消去すると言う制御内容になり、引数の2は行全体を意味している。合わせると"x1b[2k"は「カーソルがある行全体の文字を消去する」という意味になる。
  • Fというのはカーソルを上の行の先頭に移動させると言う制御内容になり、引数は行数を意味している。合わせると"x2b[1F"は、「カーソルを1行上の行の戦闘に移動させる」という意味になる。

この知識がある上でコードを見るとわかるだろう。clearlines関数では、最初、ループが始まる前に自分のカーソルがある行の文字を消している。その後、引数で指定された行数分だけ上に移動し、行を消している。

エスケープコードを使うと色々なことが出来る。詳しい説明は他のサイトに譲る。私は次のサイトが分かりやすいと思う。

碧色工房

このように、ANSIエスケープコードをつかうことで、コンソール上でより豊かな表現をしたい、というところが今回の主題である。

なお、これらの機能はOSに依存するので、今回のコードが全てのOSで動くのかはわからない。手元の環境で試したところWindows10とmacOSはうまくいった。Linuxでは試していない。(とはいえWindows10でエスケープコードを試したのは3ヶ月ほど前で、その後そのパソコンは壊れてしまったので、現在の手元の環境で再現はできないのだが・・・)

ゲームへの組み込み

この機構をゲームに組み込みたい。イメージとしては、次のような流れで戦闘の表示が進むようにしたい。

  1. 敵との遭遇のメッセージを表示。エンターキーを押すと次に進む。
  2. 1の表示がクリアされ、敵味方の情報(戦況情報)が表示される。エンターキーを押すと次に進む。
  3. ゲームのメイン処理。下記の動きを戦闘終了まで繰り返す。
    1. 戦況情報が表示される。
    2. 選択可能なコマンドが表示される。
    3. コマンドの結果を受けて「○○は△△のダメージを受けた!」などのメッセージが表示される。
  4. 戦闘終了したら結果が表示される。

まあ簡単と言えば簡単だが、それなりに手を入れなければならないのも確かだ。順番に実装していこう。

「1. 敵との遭遇のメッセージを表示。エンターキーを押すと次に進む。」の実装

まずはこの部分だが、これは簡単だ。今できていないのは、エンターキーを押すと次に進む部分だけだ。この動きを実現してくれる便利なツールがJuliaにはある。readline()という関数だ。この関数呼び出しを挿入するだけで、エンターキーを打つまでJuilaの実行を止めてくれる。なので、この関数を埋め込んであげればいいだけではあるのだが、実は現行のコードにreadline()を埋め込んでも、すぐ後の変更でどのみち消すことになるので、一旦次に進もう。

「2. 1の表示がクリアされ、敵味方の情報(戦況情報)が表示される。エンターキーを押すと次に進む。」の実装

ここからが本番だ。文字列の配列を受け取って画面に文字列を表示する、画面更新関数を作ろう。2つのことを行う必要がある。1つ目は「画面の文字列の消去」、2つ目は「文字列を描画し、エンターキー押下まで待つ」だ。まずは素直に作ってみよう。

function 画面更新(現在表示行数, 表示文字列リスト)
    function 消去(行数)
        if 行数 == 0 
            return
        end
        
        print("\x1b[2K") #現在カーソルのある行の文字を消去
        for i in 1:行数+1 #+1はreadlineが作る改行を消すためのもの
            print("\x1b[1F") #カーソルを1行上の先頭に移動
            print("\x1b[2K") #現在カーソルのある行の文字を消去
        end
    end     

    消去(現在表示行数)
    for 文字列 in 表示文字列リスト
        println(文字列)
    end
    readline()
end

これもREPLに貼り付けて、どのように使うものか様子を見てみよう。

julia> 画面更新(0, ["aaa", "bbb"]);画面更新(2, ["ccc"]);画面更新(1, ["ddd", "eee", "fff", "ggg"]);

エンターを押すたびに次のように切り替わっていく。

julia> 画面更新(0, ["aaa", "bbb"]);画面更新(2, ["ccc"]);画面更新(1, ["ddd", "eee", "fff", "ggg"]);
aaa
bbb
julia> 画面更新(0, ["aaa", "bbb"]);画面更新(2, ["ccc"]);画面更新(1, ["ddd", "eee", "fff", "ggg"]);
ccc
julia> 画面更新(0, ["aaa", "bbb"]);画面更新(2, ["ccc"]);画面更新(1, ["ddd", "eee", "fff", "ggg"]);
ddd
eee
fff
ggg

動きは良さそうだが、関数の呼び出し方が不満だ。第一引数に渡すのが「現在画面上に表示されている文字列の行数」であるが、これを指定するのは難しい。今のような例では、直前に指定した表示文字がわかっているので指定可能だが、通常はこうはいかない。どこかに「現在画面上に表示されている文字列の行数」を記憶しておいて、消去のタイミングでその値を参照し、描画のたびにその値を更新する必要がある。

この手の複数の関数が共有したくなる変数は、グローバル変数にしてしまいたくなる。しかし、グローバル変数には多くの問題がつきまとう。グローバル変数はやめておいた方がいい。

引数にするというのが、通常最も良い解決策だが、今回のケースではさほど良い解決策ではない(グローバル変数ほど悪くはないが)。画面更新関数はあらゆる文脈で呼ばれることになるので、あらゆる関数に「現在画面上に表示されている文字列の行数」の変数が引数として渡ることになる。このような設計は脆い設計だ。この変数は抽象度の低い部品だからだ。例えば、画面更新関数の仕様が変更されてこの引数を必要としなくなったら、全ての関数から消して回る必要がある。

ではどうするか。この情報は常に画面更新関数と合わせて管理したいので、クロージャにするのがいいだろう。

#game.jl
function create画面更新関数()
    現在表示行数 = 0

    function 画面更新(表示文字列リスト)
        function 消去(行数)
            if 行数 == 0 
                return
            end
            
            print("\x1b[2K") #現在カーソルのある行の文字を消去
            for i in 1:行数+1 #+1はreadlineが作る改行を消すためのもの
                print("\x1b[1F") #カーソルを1行上の先頭に移動
                print("\x1b[2K") #現在カーソルのある行の文字を消去
            end
        end     

        消去(現在表示行数)
        for 文字列 in 表示文字列リスト
            println(文字列)
        end
        現在表示行数 = length(表示文字列リスト)
        readline()
    end
end

これが我々の叡智の結晶である。「現在表示行数」という変数をクロージャの内部に埋め込み、画面更新のたびに「現在表示行数」も更新している。

さて、これを使って画面描画処理を書き換えていこう。

まず、main関数で「画面更新関数」を作る。これまでprintlnしていたui関連処理は、この関数を受け取って呼び出すように変えていく。

#game.jl
function main(乱数生成器)
    ...
    画面更新関数 = create画面更新関数() #新規行
    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!], 画面更新関数) #引数追加 
    ...
end

また、当面、「ゲームループ」関数以降に興味はないので、コメントアウトしておこう。

#game.jl
function main(乱数生成器)
    ...
    #=
    ゲームループ(プレイヤーs, モンスターs, 乱数生成器)
    ...
    =#
end

画面更新関数が、printlnを行なっているところまで到達できるよう、引数として渡していく。

#game.jl
function モンスター遭遇イベント通知!(リスナーs, 画面更新関数) #引数追加
    for リスナー in リスナーs
        リスナー(画面更新関数) #引数追加
    end
end

そして、ここがメインの対応だ。

#ui.jl
function モンスター遭遇イベントui処理!(画面更新関数) #引数追加
    表示文字列リスト = ["モンスターに遭遇した!", "戦闘開始!"] #printlnしていた内容を配列にした
    画面更新関数(表示文字列リスト)
end

無事、メッセージが表示されただろうか?エンターキーを押すまで待ってくれているだろうか?

モンスターに遭遇した!
戦闘開始!

さらにエンターキーが押されたら戦況を表示してくれるようにしよう。戦況表示関数は、これまでは文字列を返していた、画面更新関数を適用できるように配列を返すようにする。「;」は配列の要素を取り出して連結してくれる。[1] … Continue reading

function 戦況表示(プレイヤーs, モンスターs)
    function 表示(c::Tキャラクター)
       ...
    end

    結果 = ["*****プレイヤー*****";
            [表示(p) for p in プレイヤーs];
            "*****モンスター*****";
            [表示(m) for m in モンスターs];
            "********************"]

    return 結果
end

この結果を表示するようにしよう。

function main(乱数生成器)
    ...
    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!], 画面更新関数)
    
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター1, モンスター2, モンスター3]

    画面更新関数(戦況表示(プレイヤーs, モンスターs)) #追加
    #=
    ゲームループ(プレイヤーs, モンスターs, 乱数生成器)
    ...
    =#
end

これで動かしてみよう。プログラムを起動すると、まず次のような表示がされる。

モンスターに遭遇した!
戦闘開始!

そして、エンターキーを押すと、上の2行が消えて戦況が表示される。

*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ボスドラゴン HP:400 MP:80
ミニドラゴン HP:50 MP:10
ミニドラゴン HP:50 MP:10
********************

成功だ!

「3. ゲームのメイン処理。」の実装

次に進もう。

メインの処理では、下記の動きを戦闘終了まで続ける。

  1. 戦況情報が表示される。
  2. 選択可能なコマンドが表示される。
  3. コマンドの結果を受けて「○○は△△のダメージを受けた!」などのメッセージが表示される。

まずはゲームループのコメントアウトを解除しておこう。

#game.jl
function main(乱数生成器)
    ...
    ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 画面更新関数)
    #=
    if is全滅(モンスターs)
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!])
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!])
    end
    =#
end

さて、メイン処理で厄介なのはコマンド選択の部分である。それ以外は表示だけなのだが、コマンド選択だけは画面上にカーソルが出て、選択肢を選べるのだ。コマンド選択のコードを見てみよう。

#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...
    function RadioMenu作成(選択肢)
        while true
            r = RadioMenu(選択肢, pagesize=4)
            選択index = request("選択してください:", r)

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

これはJuliaのRadioMenuという関数に頼っているのだ。これまではprintlnで文字列描画している部分を単に置き換えれば良かったが、RadioMenuはそう簡単にいかない。RadioMenuはそのまま使いながら、用が済んだらクリアする必要がある。

そのためにはどうすればいいかと言うと、下記のRadioMenu作成関数で、while文を抜けたあたりで、RadioMenuが描画した行数分、画面更新関数の参照している現在表示行数の値を増やしてあげればいいのだ。そうすれば、次に画面描画する際にはRadioMenuが描画した分まで含めて消してくれるはずだ。

#ui.jl
function RadioMenu作成(選択肢)
    while true
        r = RadioMenu(選択肢, pagesize=4)
        選択index = request("選択してください:", r)
        #現在表示行数を増やす

        if 選択index == -1
            println("正しいコマンドを入力してください")
            #現在表示行数を増やす
            continue
        else
            return 選択index
        end
    end
end

問題は、「クロージャが参照している変数の値を外部からどう書き換えるか」というところが問題となる。

#game.jl
function create画面更新関数()
    現在表示行数 = 0 #これを外部から書き換えたい

    function 画面更新(表示文字列リスト)
        #「現在表示行数」の値を参照したり書き換えたりしている
        #この関数がクロージャとして渡されている
    end
end

残念ながら、これはできない。クロージャに閉じ込められている値を外部から書き換えることはできないのだ。

ではどうすればいいのか?同じ変数を参照する別のクロージャも作るようにしておいて、そのクロージャが書き換えてあげればいいのだ。イメージが湧きづらいかもしれないのでコードで示していこう。

複数の関数が関与するクロージャ

create画面更新関数のような実用的なゴツい関数を例に出すとややこしいので、グッと簡単な例にしよう。

createカウンタ」関数は、数字nとそれに関わる幾つかの関数を内部に持ち、関数のタプルをreturnする。

function createカウンタ()
    n = 0
    function 表示()
        println("現在の値:$(n)")
    end
    function 増加()
        n += 1
        println("増加")
    end
    function 減少()
        n -= 1
        println("減少")
    end
    return (表示, 増加, 減少)
end

createカウンタが返す3つの関数は、全て同じ変数を共有している。

julia> カウンタ = createカウンタ()
julia> カウンタ[1]()
現在の値:0
julia> カウンタ[2]()
増加
julia> カウンタ[1]()
現在の値:1
julia> カウンタ[2]()
増加
julia> カウンタ[1]()
現在の値:2
julia> カウンタ[3]()
減少
julia> カウンタ[1]()
現在の値:1
julia> カウンタ[3]()
減少
julia> カウンタ[1]()
現在の値:0

まずここまでで、複数のクロージャが同じ変数を共有できるという感覚を掴んで欲しい。

さて、カウンタ[1]などと言うのは分かりづらい。別々の変数に受けてもいいが、私としては同じ変数を共有している運命共同体として、一つの構造体にしてしまうのがいいように思う。

struct Tカウンタ
    表示
    増加
    減少
end

function createカウンタ()
    n = 0
    function 表示()
        println("現在の値:$(n)")
    end
    function 増加()
        n += 1
        println("増加")
    end
    function 減少()
        n -= 1
        println("減少")
    end
    return Tカウンタ(表示, 増加, 減少)
end

次のように使う。

julia> カウンタ = createカウンタ()
julia> カウンタ.表示()
現在の値:0
julia> カウンタ.増加()
増加
julia> カウンタ.表示()
現在の値:1
julia> カウンタ.増加()
増加
julia> カウンタ.表示()
現在の値:2
julia> カウンタ.減少()
減少
julia> カウンタ.表示()
現在の値:1
julia> カウンタ.減少()
減少
julia> カウンタ.表示()
現在の値:0

あまりJuliaらしくないかもしれない。普通であれば、Tカウンタという構造体はデータだけを持ち、それに対する操作として、「表示」「増加」「減少」がTカウンタを引数に取る関数として定義されるだろう。もちろんそうしてもいい。Julia流にするのであればその方がいいかもしれない。しかし、私があえてこうするのは、そこにニュアンスの違いを感じるからだ。

データと関数の関係というものを考えてみると、なかなか奥深いものがある。

  1. データを変換する関数
    • 主役はデータであり、データを変換したり表示したりする関数たちがある、という立場だ。最も素朴な関数適用のイメージではないだろうか。
  2. 状態を持つ関数
    • 主役は関数であり、関数の状態を管理するデータがある、という立場だ。グローバル変数に依存する関数やクロージャがこれだ。
  3. 多態を実現するためのデータ
    • データのパターンが何種類かあり、その種類に応じて適切な関数が選択されるというものだ。型によるディスパッチのことだ。Holy Traitsパターンが印象深い。この場合、データは関数のためのものではなく、関数を選択するより大きな機構のためのものだ。

これらは排反ではない。1つの関数がこれらの複数の役割を担っていることも多い。[2] … Continue reading今の興味の対象は何か?ということを考えるのが重要だ。

私が画面更新関数やカウンタの例をクロージャで表現したのは、私が興味があるのは画面更新関数やカウンタの振る舞いであって、画面更新関数の現在表示行数やカウンタの現在値ではないからだ。あくまで関数が主役であり、その状態を管理するデータには興味がない。だからクロージャで表現したのだ。

逆に、プレイヤーやモンスターやスキルのようなものは、データそのものに興味があり、攻撃実行関数やダメージ計算処理にはあまり興味がない。これらの関数が適用された結果、データがどう変換されるかというところに興味があるのだ。

大体のケースでは、データがどう変換されるかということの方が重要な問題であることが多い。その証拠に、ほとんど全ての言語は関数という機能を持っているが、クロージャを持っている言語はそれよりもずっと少ない。Juliaを使っていてもそうだ。大抵の場合は、データがあり、それを変換する関数があるという世界観でうまくいく。しかし時にはクロージャを使いたくなることもあるのだ。

複数のクロージャ返すcreate画面更新関数

少し脱線してしまった。話を戻そう。

まず、create画面更新関数に、「現在表示行数加算」という関数を追加しよう。この関数を呼び出すと、現在表示行数を増やすことができる。

そして、画面更新関数と現在表示行数加算関数を描画ツールという構造体に入れよう。これを、create画面更新関数の結果としている。

#ui.jl
struct 描画ツール
    画面更新関数
    現在表示行数加算関数
end

...

function create画面更新関数()
    現在表示行数 = 0

    function 画面更新(表示文字列リスト)
        ...
    end

    function 現在表示行数加算(行数)
        現在表示行数 += 行数
    end

    return (画面更新, 現在表示行数加算)
end

これまで画面更新関数を取り扱っていたところは、描画ツールに置き換えられることになる。

#game.jl
function main(乱数生成器)
    ...
    描画ツール = create画面更新関数() #変更
    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!], 描画ツール) #変更
    ...    
    描画ツール.画面更新関数(戦況表示(プレイヤーs, モンスターs) #変更
    ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 描画ツール) #変更
    ...
end

function モンスター遭遇イベント通知!(リスナーs, 描画ツール) #変更
    for リスナー in リスナーs
        リスナー(描画ツール) #変更
    end
end

#ui.jl
function モンスター遭遇イベントui処理!(描画ツール) #変更
    表示文字列リスト = ["モンスターに遭遇した!", "戦闘開始!"]
    描画ツール.画面更新関数(表示文字列リスト) #変更
end

#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 描画ツール) #変更
   ...
end

ゲームループの中で、コマンド選択をするところがあるので、そこにも描画ツールを渡してあげよう。ついでに行動決定イベント通知にも渡すようにする。

#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 描画ツール)
                ...
                行動 = 行動決定(行動者, プレイヤーs, モンスターs, 描画ツール)
                ...
end

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

#キャラクター.jl
function 行動決定イベント通知!(行動者::Tプレイヤー, プレイヤーs, モンスターs, 描画ツール)
    for リスナー in 行動者.行動決定イベントリスナーs
        リスナー(行動者, プレイヤーs, モンスターs, 描画ツール)
    end
end

#ui.jl
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs, 描画ツール)
    戦況表示(プレイヤーs, モンスターs, 描画ツール)
    描画ツール.画面更新関数(["$(行動者.名前)のターン"])
end

Tモンスター側の行動決定関数は描画ツールは使わないが、引数に追加は必要なので渡しておこう。

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

コマンド選択の中身が本題だ。少しRatioMenuについておさらいしておこう。

RatioMenuで選択肢を表示する時、次のように「選択してください」という文言が来て、その後に選択肢の数だけ行が描画される。

選択してください:
 > ボスドラゴン
   ミニドラゴン
   ミニドラゴン

選択肢がある程度より多くなるとカーソルを出してくれるのがRadioMenuのいいところだ。pagesizeよりも選択肢の数が少ない時と多い時で後々消去すべき行数が異なるということだ。

julia> r = RadioMenu(["ドラゴン1", "ドラゴン2", "ドラゴン3", "ドラゴン4", "ドラゴン5", "ドラゴン6","ドラゴン7"], pagesize=4)

julia> request("選択してください", r)
選択してください
^  ドラゴン3
 > ドラゴン4
   ドラゴン5
v  ドラゴン6

これを踏まえて、コマンド選択関数を次のように変更しよう。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs, 描画ツール)
    ...
    #補助関数を追加
    function 表示行数取得(選択肢)
        if length(選択肢) < 描画ツール.ページサイズ
            return length(選択肢)
        else
            return 描画ツール.ページサイズ
        end
    end

    function RadioMenu作成(選択肢)
        while true
            r = RadioMenu(選択肢, pagesize=描画ツール.ページサイズ)
            選択index = request("選択してください:", r)
            
            画面表示行数 = 表示行数取得(選択肢, 描画ツール.ページサイズ) + 1 #1は"選択してください:"の行
            描画ツール.現在表示行数加算関数(画面表示行数) #これでRadioMenuでの表示がクリアされる

            if 選択index == -1
                println("正しいコマンドを入力してください")
                描画ツール.現在表示行数加算関数(1) #ここにも追加
                continue
            else
                return 選択index
            end
        end
    end
    ...
end

pagesizeに設置している4という数字も必要になるので、描画ツールに定義するようにしている。

struct 描画ツール
    画面更新関数
    現在表示行数加算関数
    ページサイズ #追加
end

function create画面更新関数()
    ...
    ページサイズ = 4 #追加
    return 描画ツール(画面更新, 現在表示行数加算, ページサイズ) #引数追加
end

行動に対する描画

さて、コマンド選択ができたら、あとは表示だけなので定型作業のはずだ。printlnを使っているところを、画面更新関数に置き換えていく。

ゲームループ関数に、描画ツールの引数を追加していこう。

function ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 描画ツール) #引数追加
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs, 描画ツール) #引数追加
                行動実行!(行動, 乱数生成器, 描画ツール) #引数追加
                行動後処理!(行動者, プレイヤーs, モンスターs)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

行動決定関数には先ほど引数に描画ツールを追加したので、次は行動実行関数だ。ひたすら引数追加だ。

function 行動実行!(行動::T行動, 乱数生成器, 描画ツール)
    行動実行!(行動系統(行動.コマンド), 行動, 乱数生成器, 描画ツール)
end

function 行動実行!(::T攻撃系行動, 行動::T行動, 乱数生成器, 描画ツール) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド, 乱数生成器, 描画ツール)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::T回復系行動, 行動::T行動, 乱数生成器, 描画ツール) 
    回復実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動, 乱数生成器, 描画ツール) 
    かばう実行!(行動.行動者, 行動.対象者)
end

function 行動実行!(::T刃に毒を塗る行動, 行動::T行動, 乱数生成器, 描画ツール) 
    刃に毒を塗る実行!(行動.対象者)
    MP減少!(行動.行動者, 行動.コマンド)
end

攻撃実行関数に引数を追加する。

#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃, 乱数生成器, 描画ツール)
    攻撃実行イベント通知!(攻撃者, コマンド, 描画ツール)
    ...
    HP減少!(防御者, 防御者ダメージ, 描画ツール)
    if 防御者ダメージ > 0
        状態異常付与判定!(攻撃者, 防御者, 乱数生成器, 描画ツール)
    end
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル, 乱数生成器, 描画ツール)
    攻撃実行イベント通知!(攻撃者, スキル, 描画ツール)
    ...
    for _ in 1:攻撃回数
            ...
            HP減少!(防御者, 防御者ダメージ, 描画ツール)
            if 防御者ダメージ > 0
                状態異常付与判定!(攻撃者, 防御者, 乱数生成器, 描画ツール)
            end
            ...
    end
end

攻撃実行イベント通知!にも引数を追加する。

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

これでui処理!にまで引数がわたることになるので、その対応を入れる。

#ui.jl
function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃, 描画ツール)
    表示文字列リスト = ["$(攻撃者.名前)の攻撃!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

ゲームループ関数に一通り引数を追加したら、次のようになっていることだろう。

function ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 描画ツール)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs, 描画ツール) #引数追加
                行動 = 行動決定(行動者, プレイヤーs, モンスターs, 描画ツール) #引数追加
                行動実行!(行動, 乱数生成器, 描画ツール) #引数追加
                行動後処理!(行動者, プレイヤーs, モンスターs, 描画ツール) #引数追加
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

呼び出し先も引数を追加して、printlnを使っている処理を描画ツール.画面更新関数へと置き換えていくことになるが、詳細は明らかに同じような内容の繰り返しなので割愛する。ただ一つだけ注意が必要なのが行動決定ui処理だ。他の処理は文字列からリストを作っているだけだが、これだけは戦況表示がつくる配列に追加しているので、表示文字列リストの作り方が特殊だ。

function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs, 描画ツール)
    表示文字列リスト = [戦況表示(プレイヤーs, モンスターs)
                    ;"$(行動者.名前)のターン"]
    描画ツール.画面更新関数(表示文字列リスト)
end

ひととおり対応したら、エンターキーを押すたびに次のように切り替わっていくだろう。

モンスターに遭遇した!
戦闘開始!
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ボスドラゴン HP:400 MP:80
ミニドラゴン HP:50 MP:10
ミニドラゴン HP:50 MP:10
********************
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ボスドラゴン HP:400 MP:80
ミニドラゴン HP:50 MP:10
ミニドラゴン HP:50 MP:10
********************
高橋先生のターン
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ボスドラゴン HP:400 MP:80
ミニドラゴン HP:50 MP:10
ミニドラゴン HP:50 MP:10
********************
高橋先生のターン

選択してください:
 > 攻撃
   スキル
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ボスドラゴン HP:400 MP:80
ミニドラゴン HP:50 MP:10
ミニドラゴン HP:50 MP:10
********************
高橋先生のターン

選択してください:
 > 攻撃
   スキル
選択してください:
 > ボスドラゴン
   ミニドラゴン
   ミニドラゴン
高橋先生の攻撃!
ボスドラゴンは10のダメージを受けた!
ボスドラゴンの残りHP:390
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ボスドラゴン HP:390 MP:80
ミニドラゴン HP:50 MP:10
ミニドラゴン HP:50 MP:10
********************
太郎のターン

無事、メッセージが切り替わってくれている!まだまだ改善の余地はあるが、当面やりたいことはかなりの部分実現できた。

「4. 戦闘終了したら結果が表示される。」の実装

最後の仕上げだ。コメントアウトされていた下記の部分を実装しよう。もはや説明不要だろう。

#game.jl
function main(乱数生成器)
    ...
    if is全滅(モンスターs)
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!], 描画ツール)
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!], 描画ツール)
    end
end

と思ったが、これが仕上げなので、どうせならちゃんと書いておこう。

#game.jl
function 戦闘勝利イベント通知!(リスナーs, 描画ツール)
    for リスナー in リスナーs
        リスナー(描画ツール)
    end
end

function 戦闘敗北イベント通知!(リスナーs, 描画ツール)
    for リスナー in リスナーs
        リスナー(描画ツール)
    end
end
#ui.jl
function 戦闘勝利イベントui処理!(描画ツール)
    表示文字列リスト = ["戦闘に勝利した!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 戦闘敗北イベントui処理!(描画ツール)
    表示文字列リスト = ["戦闘に敗北した・・・"]
    描画ツール.画面更新関数(表示文字列リスト)
end

できた!無事戦闘が終了するところまで見届けておこう。

第12回の終わりに

今回は画面表示の改善に取り組んでみた。引数を追加しまくって大変だった。まだ改善したいポイントも多いが、ひとまず区切りがいいのでここまでにする。本当は最初のムービーのように、滑らかに更新したいのだ。

心残りはテスト側のコードのメンテナンスだ。今気づいた。完全に忘れていた。画面表示なので仕様に影響はないが、引数が食い違うからコケまくるだろう。次までに直すので許して欲しい。

さて、本気でUIに凝るのであれば、HTML+JavaScriptにて実現するのがいいと思う。Julia側でモデルを管理するサービスをWeb APIとして作り、その結果をHTMLとJavaScript でブラウザに描画するイメージだ。JuliaにはWebフレームワークもあるのでそういったことも可能なはずだ。TypeScriptとかCoffeeScriptのノリでJavaScriptにトランスコンパイルされるJuliaScriptみたいな言語を作ってくれたら、全てJulia(のサブセット)になって嬉しい。誰か作って欲しい。

参考文献

Julia のREPL上でカラフルな出力をする(お遊び)

Juliaでカラフルに点滅するクリスマスツリーを書いてみました

コード

ここまでのコードを貼っておこう。

#game_exec.jl
module Game

include("乱数.jl")
include("ui.jl")
include("game.jl")

main(get乱数生成器())

end
#game.jl
import REPL
using REPL.TerminalMenus

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

struct 描画ツール
    画面更新関数
    現在表示行数加算関数
    ページサイズ
end

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

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

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

function create画面更新関数()
    現在表示行数 = 0

    function 画面更新(表示文字列リスト)
        function 消去(行数)
            if 行数 == 0 
                return
            end
            
            print("\x1b[2K") #現在カーソルのある行の文字を消去
            for i in 1:行数+1 #+1はreadlineが作る改行を消すためのもの
                print("\x1b[1F") #カーソルを1行上の先頭に移動
                print("\x1b[2K") #現在カーソルのある行の文字を消去
            end
        end     

        消去(現在表示行数)
        for 文字列 in 表示文字列リスト
            println(文字列)
        end
        現在表示行数 = length(表示文字列リスト)
        readline()
    end

    function 現在表示行数加算(行数)
        現在表示行数 += 行数
    end
    
    ページサイズ = 4
    return 描画ツール(画面更新, 現在表示行数加算, ページサイズ)
end

function main(乱数生成器)
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), 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スキル(:連続攻撃)])

    モンスター1 = Tモンスター("ボスドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)], true)
    モンスター2 = Tモンスター("ミニドラゴン", 50, 10, 5, 10, [createスキル(:連続攻撃)], false)
    モンスター3 = Tモンスター("ミニドラゴン", 50, 10, 5, 10, [createスキル(:連続攻撃)], false)

    描画ツール = create画面更新関数()

    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!], 描画ツール)
    
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター1, モンスター2, モンスター3]
    
    描画ツール.画面更新関数(戦況表示(プレイヤーs, モンスターs))

    ゲームループ(プレイヤーs, モンスターs, 乱数生成器, 描画ツール)

    if is全滅(モンスターs)
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!], 描画ツール)
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!], 描画ツール)
    end
end
#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    名前
    HP
    最大HP
    MP
    攻撃力
    防御力
    状態異常s
    物理攻撃時状態異常付与確率
    スキルs
    かばっているキャラクター
    かばってくれているキャラクター
    行動前処理イベントリスナーs
    行動後処理イベントリスナーs
    戦闘不能イベントリスナーs
    攻撃実行イベントリスナーs
    回復実行イベントリスナーs
    状態異常付与イベントリスナーs
    かばう実行イベントリスナーs
    かばう発動イベントリスナーs
    かばう解除イベントリスナーs
    HP減少イベントリスナーs
    HP回復イベントリスナーs
    攻撃失敗イベントリスナーs
    行動決定イベントリスナー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, 攻撃力, 防御力, Set(), Dict(), スキルs, nothing, nothing, 
            [getかばう解除!(:行動前処理)], [毒ダメージ発生!], [getかばう解除!(:戦闘不能)], 
            [攻撃実行ui処理!], [回復実行ui処理!], [状態異常付与ui処理!],
            [かばう実行ui処理!], [かばう発動ui処理!], [かばう解除ui処理!], 
            [HP減少ui処理!], [HP回復ui処理!], [攻撃失敗ui処理!], [行動決定ui処理!],
            [刃に毒を塗る実行ui処理!], [毒ダメージ発生ui処理!])
    end
end

abstract type Tキャラクター end

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

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
    isボス
end
#ui_stub.jl
include("スキル.jl")
include("Tキャラクター.jl")

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃) end
function 攻撃実行ui処理!(行動者, スキル::Tスキル) end
function 回復実行ui処理!(行動者, スキル::Tスキル) end
function スキル実行ui処理!(行動者, スキル::Tスキル) end
function かばう実行ui処理!(行動者, 対象者) end
function かばう発動ui処理!(防御者) end
function かばう解除ui処理_行動前処理!(行動者, 対象者) end
function かばう解除ui処理_戦闘不能!(行動者, 対象者) end
function HP減少ui処理!(防御者, 防御者ダメージ) end
function HP回復ui処理!(対象者, 回復量) end
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs) end
function 攻撃失敗ui処理!() end
function 刃に毒を塗る実行ui処理!(対象者) end
function 状態異常付与ui処理!(対象者, 状態異常) end
function 毒ダメージ発生ui処理!(対象者) end
#ui.jl
include("スキル.jl")
include("行動系統.jl")
include("Tキャラクター.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 get対象リスト(::T刃に毒を塗る行動)
        return [行動者]
    end

    function 表示行数取得(選択肢, ページサイズ)
        選択肢数 = length(選択肢)
        return 選択肢数 < ページサイズ ? 選択肢数 : ページサイズ
    end

    function RadioMenu作成(選択肢)
        while true
            r = RadioMenu(選択肢, pagesize=描画ツール.ページサイズ)
            選択index = request("選択してください:", r)
            
            画面表示行数 = 表示行数取得(選択肢, 描画ツール.ページサイズ) + 1 #1は"選択してください:"の行
            描画ツール.現在表示行数加算関数(画面表示行数)

            if 選択index == -1
                println("正しいコマンドを入力してください")
                描画ツール.現在表示行数加算関数(1)
                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が足りません")
                描画ツール.現在表示行数加算関数(1)
                continue
            end
            return 行動対象を選択し行動を決定(選択スキル)
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

function 戦況表示(プレイヤーs, モンスターs)
    function 表示(c::Tキャラクター)
        s = "$(c.名前) HP:$(c.HP) MP:$(c.MP)"

        状態異常s = c.状態異常s
        if length(状態異常s) > 0
            s *= " " * join(["$(j)" for j in 状態異常s], " ")
        end

        付与s = keys(c.物理攻撃時状態異常付与確率)
        if length(付与s) > 0
            s *= " " * join(["$(f)付与" for f in 付与s], " ")
        end
        return s
    end

    結果 = ["*****プレイヤー*****";
            [表示(p) for p in プレイヤーs];
            "*****モンスター*****";
            [表示(m) for m in モンスターs];
            "********************"]

    return 結果
end

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃, 描画ツール)
    表示文字列リスト = ["$(攻撃者.名前)の攻撃!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

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

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

function スキル実行ui処理!(行動者, スキル::Tスキル, 描画ツール)
    表示文字列リスト = ["$(行動者.名前)の$(スキル.名前)!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function かばう実行ui処理!(行動者, 対象者, 描画ツール)
    表示文字列リスト = ["$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function かばう発動ui処理!(防御者, 描画ツール)
    表示文字列リスト = ["$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function かばう解除ui処理_行動前処理!(行動者, 対象者, 描画ツール)
    表示文字列リスト = ["$(行動者.名前)は$(対象者.名前)をかばうのをやめた!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function かばう解除ui処理_戦闘不能!(行動者, 対象者, 描画ツール)
    表示文字列リスト = ["$(行動者.名前)は$(対象者.名前)をかばえなくなった!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function HP減少ui処理!(防御者, 防御者ダメージ, 描画ツール)
    表示文字列リスト = ["$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!"
                    ,"$(防御者.名前)の残りHP:$(防御者.HP)"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function HP回復ui処理!(対象者, 回復量, 描画ツール)
    表示文字列リスト = ["$(対象者.名前)のHPが$(回復量)回復した!"
                    ,"$(対象者.名前)の残りHP:$(対象者.HP)"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs, 描画ツール)
    表示文字列リスト = [戦況表示(プレイヤーs, モンスターs)
                    ;"$(行動者.名前)のターン"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 攻撃失敗ui処理!(描画ツール)
    表示文字列リスト = ["攻撃は失敗した・・・"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function モンスター遭遇イベントui処理!(描画ツール)
    表示文字列リスト = ["モンスターに遭遇した!", "戦闘開始!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 戦闘勝利イベントui処理!(描画ツール)
    表示文字列リスト = ["戦闘に勝利した!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 戦闘敗北イベントui処理!(描画ツール)
    表示文字列リスト = ["戦闘に敗北した・・・"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 刃に毒を塗る実行ui処理!(対象者, 描画ツール)
    表示文字列リスト = ["$(対象者.名前)は刃に毒を塗った!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 状態異常付与ui処理!(対象者, 状態異常, 描画ツール)
    表示文字列リスト = ["$(対象者.名前)は$(状態異常)状態になった!"]
    描画ツール.画面更新関数(表示文字列リスト)
end

function 毒ダメージ発生ui処理!(対象者, 描画ツール)
    表示文字列リスト = ["$(対象者.名前)は毒に苦しんでいる!"]
    描画ツール.画面更新関数(表示文字列リスト)
end
#キャラクター.jl
include("Tキャラクター.jl")
include("スキル.jl")

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

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs, isボス)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs), isボス)    
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

function 刃に毒を塗る実行イベント通知!(対象者, 描画ツール)
    for リスナー in 対象者.刃に毒を塗る実行イベントリスナーs
        リスナー(対象者, 描画ツール)
    end
end

function 状態異常付与イベント通知!(対象者, 状態異常, 描画ツール)
    for リスナー in 対象者.状態異常付与イベントリスナーs
        リスナー(対象者, 状態異常, 描画ツール)
    end
end

function 毒ダメージ発生イベント通知!(対象者, 描画ツール)
    for リスナー in 対象者.毒ダメージ発生イベントリスナーs
        リスナー(対象者, 描画ツール)
    end
end
#スキル.jl
include("Tキャラクター.jl")
include("モンスターヒエラルキー.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

struct T刃に毒を塗る <: Tスキル 
    名前
    消費MP
end

function T刃に毒を塗る() 
    return T刃に毒を塗る("刃に毒を塗る", 5)
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)
    elseif スキルシンボル === :刃に毒を塗る
        return T刃に毒を塗る()
    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かばう解除!(かばう解除トリガ)
    if !(かばう解除トリガ in [:行動前処理, :戦闘不能]) 
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end

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

#関数名が紛らわしいが、「かばうを解除する時にメッセージを出し分けたい」という
#モデルの処理なのでUI層ではなくモデル層に定義
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ, 描画ツール)
    if かばう解除トリガ === :行動前処理
        かばう解除ui処理_行動前処理!(行動者, 対象者, 描画ツール)
    elseif かばう解除トリガ === :戦闘不能
        かばう解除ui処理_戦闘不能!(行動者, 対象者, 描画ツール)
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

function 刃に毒を塗る実行!(対象者, 描画ツール)
    刃に毒を塗る実行イベント通知!(対象者, 描画ツール)
    対象者.物理攻撃時状態異常付与確率[:毒] = 0.25
end

function 毒ダメージ発生!(対象者, 描画ツール)
    if :毒 in 対象者.状態異常s
        毒ダメージ発生イベント通知!(対象者, 描画ツール)
        毒ダメージ = 毒ダメージ計算(対象者) 
        HP減少!(対象者, 毒ダメージ, 描画ツール)
    end
end

function 毒ダメージ計算(対象者)
    係数(::Tプレイヤー) = 0.2
    係数(対象者::Tモンスター) = 係数(モンスターヒエラルキー(対象者))
    係数(::Tボスモンスター) = 0.2
    係数(::Tザコモンスター) = 0.5

    round(Int, 対象者.最大HP * 係数(対象者), RoundDown) 
end
#モンスターヒエラルキー.jl
struct Tボスモンスター end
struct Tザコモンスター end

function モンスターヒエラルキー(モンスター::Tモンスター) 
    if モンスター.isボス
        return Tボスモンスター()
    else
        return Tザコモンスター()
    end
end
#行動系統.jl
struct T攻撃系行動 end
struct T回復系行動 end
struct Tかばう行動 end
struct T刃に毒を塗る行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::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減少!(防御者, 防御者ダメージ, 描画ツール)
    if 防御者ダメージ > 0
        状態異常付与判定!(攻撃者, 防御者, 乱数生成器, 描画ツール)
    end
end

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

function 状態異常付与判定!(攻撃者, 防御者, 乱数生成器, 描画ツール)
    状態異常付与確率 = 攻撃者.物理攻撃時状態異常付与確率
    for (状態異常, 付与確率) in 状態異常付与確率
        乱数 = 乱数生成器()
        if 乱数 < 付与確率
            状態異常付与!(防御者, 状態異常, 描画ツール)
            物理攻撃時状態異常付与解除!(攻撃者, 状態異常)
        end
    end
end

function 状態異常付与!(対象者, 状態異常, 描画ツール)
    push!(対象者.状態異常s, 状態異常)
    状態異常付与イベント通知!(対象者, 状態異常, 描画ツール)
end

function 物理攻撃時状態異常付与解除!(攻撃者, 状態異常)
    delete!(攻撃者.物理攻撃時状態異常付与確率, 状態異常)
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刃に毒を塗る行動, 行動::T行動, 乱数生成器, 描画ツール) 
    刃に毒を塗る実行!(行動.対象者, 描画ツール)
    MP減少!(行動.行動者, 行動.コマンド)
end

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

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

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs, 描画ツール)
    行動前処理イベント通知!(行動者, 描画ツール)
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, 描画ツール)
                行動実行!(行動, 乱数生成器, 描画ツール)
                行動後処理!(行動者, プレイヤーs, モンスターs, 描画ツール)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end
#乱数.jl
using Random

function get乱数生成器()
    return function exec()
        return rand()
    end
end

function get乱数生成器stub()
    return get乱数生成器stub([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])
end

function get乱数生成器stub(指定数列)
    if length(指定数列) == 0
        throw(DomainError("指定数列として1つ以上の要素を含むようにしてください"))
    end
    function 数列生成(c::Channel)
        i = 1
        while (true)
            put!(c, 指定数列[i])
            i += 1
            if length(指定数列) < i
                i = 1
            end
        end
    end
    chnl = Channel(数列生成);
    
    return function exec()
        return take!(chnl)
    end
end

テストコード

#game_test.jl
module GameTest

include("ui_stub.jl")
include("乱数.jl")
include("game.jl")

using Test

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

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

@testset "HP減少" begin

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

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

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

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

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

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

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

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

        乱数生成器 = get乱数生成器stub()

        プレイヤーからモンスターへ攻撃 = T行動(createスキル(:大振り), p, m)
        for i in 1:4 #40%の確率でヒット
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test p.HP == 1000
            @test m.HP == 2000 - i * 20
        end
        for i in 1:6 #60%の確率で外れる
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test p.HP == 1000
            @test m.HP == 1920
        end

        モンスターからプレイヤーへ攻撃 = T行動(createスキル(:大振り), m, p)
        for i in 1:4 #40%の確率でヒット
            行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
            @test p.HP == 1000 - i * 40
            @test m.HP == 1920
        end
        for i in 1:6 #60%の確率で外れる
            行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
            @test p.HP == 840
            @test m.HP == 1920
        end
    end 

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

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

        モンスターからプレイヤーへ攻撃 = T行動(createスキル(:連続攻撃), m, p)
        行動実行!(モンスターからプレイヤーへ攻撃, get乱数生成器())
        モンスター与ダメージ = 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)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

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

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

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

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

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

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

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

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

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

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

            行動実行!(ドラゴンから花子へ連続攻撃, get乱数生成器())
            @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)

            太郎が花子をかばう = T行動(createスキル(:かばう), 太郎, 花子)
            行動実行!(太郎が花子をかばう, get乱数生成器())

            遠藤君が太郎をかばう = T行動(createスキル(:かばう), 遠藤君, 太郎)
            行動実行!(遠藤君が太郎をかばう, get乱数生成器())

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

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

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

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())
            
            @test 花子.HP == 100
            @test 太郎.HP == 10
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

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

            ドラゴンから花子へ攻撃 = T行動(T通常攻撃(), ドラゴン, 花子)
            行動実行!(ドラゴンから花子へ攻撃, get乱数生成器())

            @test 花子.HP == 80
            @test 太郎.HP == 0
        end
        @testset "想定外のシンボルでは例外が発生" begin
            p1 = createプレイヤー()
            p2 = createプレイヤー()
            @test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
            @test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
            @test_throws DomainError かばう解除ui処理!(p1, p2, :想定外シンボル)
        end
    end 

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

    @testset "刃に毒を塗る" begin
        @testset "刃に毒を塗る実行" begin
            p = createプレイヤー()
            刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
            行動実行!(刃に毒を塗る, get乱数生成器())
            @test p.物理攻撃時状態異常付与確率[:毒] == 0.25    
        end
        @testset "刃に毒を塗ってから通常攻撃実行" begin
            @testset "成功" begin
                p = createプレイヤー()
                刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
                行動実行!(刃に毒を塗る, get乱数生成器())

                m = createモンスター()
                プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
                乱数生成器 = get乱数生成器stub([0.2]) #25%の確率で状態異常付与なので成功する想定
                行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
                @test :毒 in m.状態異常s
            end

            @testset "失敗" begin
                p = createプレイヤー()
                刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
                行動実行!(刃に毒を塗る, get乱数生成器())

                m = createモンスター()
                プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
                乱数生成器 = get乱数生成器stub([0.3]) #25%の確率で状態異常付与なので失敗する想定
                行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
                @test isempty(m.状態異常s)
            end
        end
        @testset "刃に毒を塗ってから通常攻撃実行するもダメージ0" begin
            p = createプレイヤー(攻撃力=0)
            刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
            行動実行!(刃に毒を塗る, get乱数生成器())
    
            乱数生成器 = get乱数生成器stub()
            for i in 1:10 #ダメージ0なので毒にできない
                m = createモンスター()
                プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
                行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
                @test isempty(m.状態異常s)
            end
        end
        @testset "刃に毒を塗ってからスキル攻撃実行" begin
            p = createプレイヤー()
            刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
            行動実行!(刃に毒を塗る, get乱数生成器())
    
            乱数生成器 = get乱数生成器stub()
            m = createモンスター()
            プレイヤーからモンスターへ攻撃 = T行動(createスキル(:連続攻撃), p, m)
            行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
            @test :毒 in m.状態異常s
        end
    end 

    @testset "毒ダメージ" begin
        @testset "ボス敵:20%ダメージ" begin
            m = createモンスター(HP=100, isボス=true)
            状態異常付与!(m, :毒)
            行動後処理!(m, nothing, nothing)
            @test m.HP == 100 - 20
        end
        @testset "ザコ敵:50%ダメージ" begin
            m = createモンスター(HP=100, isボス=false)
            状態異常付与!(m, :毒)
            行動後処理!(m, nothing, nothing)
            @test m.HP == 100 - 50
        end
        @testset "プレイヤー:20%ダメージ" begin
            p = createプレイヤー(HP=100)
            状態異常付与!(p, :毒)
            行動後処理!(p, nothing, nothing)
            @test p.HP == 100 - 20
        end
    end 
end

@testset "is戦闘終了" begin
    @testset begin
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=1)]) == false
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0)]) == true
        @test is戦闘終了([createプレイヤー(HP=0)], [createモンスター(HP=1)]) == true
        @test is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=1)], [createモンスター(HP=1)]) == false
        @test is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=0)], [createモンスター(HP=1)]) == true
        @test is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=1)]) == false
        @test 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
        行動順 = 行動順決定([p1], [m1])
        @test length(行動順) == 2
    end

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

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

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

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

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


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

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

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

end

References

References
1 もう少し正確に言うと、(数学の)行列の垂直方向への連結を意味する。配列は列ベクトルとして取り扱われるため、垂直方向への連結は、実質的に配列の連結を意味する。
2 特に2と3を一緒こたに実現しているのが「オブジェクト指向」というパラダイムだ。オブジェクト指向が熱狂をもって迎え入れられたことにはこのあたりの事情が関係していると思う。オブジェクト指向を受け入れるだけで、1しかなかった世界に、突然2と3の恩恵を受けることができるのだ。