ホーム » 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

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

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

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

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

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

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

成功だ!

ところで、表示が止まってくれるのはいいのだが、ユーザーの入力を待っているのだということが少しわかりづらい。画面更新が終わったら、”>>>”と出してユーザーの入力を待っていることを明確にしよう。

#game.jl
function create画面更新関数()
        ...
        消去(現在表示行数)
        表示文字列リスト = [表示文字列リスト;">>>"] #変更
        for 文字列 in 表示文字列リスト
        ...
end

表示は次のようになる。

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

「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

描画ツールを作る関数なのにcreate画面更新関数というのはおかしいので、create描画ツールに変更しよう。

#ui.jl
function create描画ツール() #関数名変更
    現在表示行数 = 0
    ...
end

#game.jl
function main(乱数生成器)
    ...
    描画ツール = create描画ツール() #変更
    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!], 描画ツール)
    ...
end

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

#game.jl
function main(乱数生成器)
    ...
    ゲームループ(プレイヤー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を使っている処理を描画ツール.画面更新関数へと置き換えていくことになるが、詳細は明らかに同じような内容の繰り返しなので割愛する。

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

モンスターに遭遇した!
戦闘開始!
*****プレイヤー*****
太郎 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
********************
太郎のターン

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

テストコードのメンテナンス

テストコードのメンテナンスも必要だ。引数に描画ツールが必要になるので渡すようにする。ただし、テストコード側では最終的に描画しないはずなので、nothingで良いはずだ。

#ui_test.jl
@testset "ダメージ < HP" begin
    c = createプレイヤー(HP=100)
    HP減少!(c, 3, nothing) #「描画ツール」の引数追加
    @test c.HP == 97
end

「テストコード側では最終的に描画しない」という仕様は、テスト側のコードではui_stub.jlの関数たちが呼ばれ、それらが何もしないと言うことで実現している。呼ばれるからには、ui_stub.jlにも引数を追加する必要がある。そして何もしない。

#ui_stub.jl
function HP減少ui処理!(防御者, 防御者ダメージ, 描画ツール) end #引数追加

このようなことを愚直に繰り返していく。その他、下記の1関数は追加が必要だった。

#ui_stub.jl
function 攻撃実行ui処理!(行動者, スキル::T通常攻撃, 描画ツール) end #関数追加

以上でテストコードの対応は完了するはずだ。テスト結果がオールグリーンになることを確認しておこう。

「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でカラフルに点滅するクリスマスツリーを書いてみました

コード

今回のコードは以下のURLで確認できる。

https://github.com/muuumin-soft/julia-intro-prog/tree/main/rpg12

続き

第13回

References

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