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

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


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

一覧はこちら

「かばう」の実装

だいぶ寄り道してしまったが、いよいよ「かばう」の実装に入ろう。

まずはテストを書いてみよう。こんなふうになるだろうか。太郎が花子をかばったあとに、ドラゴンが花子に攻撃したところ、花子はHPが減らず、太郎のHPが減っている。

@testset "かばう" begin
    太郎 = createキャラクターHP100()
    花子 = createキャラクターHP100()
    ドラゴン = createモンスターHP200攻撃力20()

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

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

まだテストを書いただけで実装していないので、テストを動かすと想定通り失敗する。では実装していこう。

テストは、createスキルで例外が発生して失敗していたので、まずはそこをカバーしよう。

function createスキル(スキルシンボル)
    ...
    elseif スキルシンボル === :かばう
        return Tかばう()
    ...
end

これで再度実行すると、次は予定通り、花子がダメージを受けて、太郎がダメージを受けていないという理由で失敗する。

さて、Tかばうの実現には3段階必要だ。

  1. 花子を太郎がかばっている状態にするという処理
  2. 花子が受けた攻撃を太郎に差し替える処理
  3. 次の太郎の行動前に花子のかばう状態を解除する処理

順に実装していこう。なお、このタイミングで下記のテスト用補助関数で防御力が1で指定されていたので10に変更している。防御力はデフォルト10の設定なのだ。(第1回参照)

function createキャラクターHP100()
    return Game.Tプレイヤー("", 100, 0, 1, 10, []) #防御力を10に変更
end

花子を太郎がかばっている状態にするという処理

まずは「花子を太郎がかばっている状態」を定義しよう。これは、Tキャラクター共通にフィールドを追加しよう。

mutable struct Tキャラクター共通データ
    ...
    かばってくれているキャラクター
    ...

「庇護者」とかの方がいいかと迷ったが、このくらいの方がわかりやすくていいだろう。長くて鬱陶しければリネームするかもしれない。

内部コンストラクタでは、フィールドの初期値としてnothingを指定しておく。花子をかばうと、このフィールドに花子が代入されることになる。

    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing)  
    end

テストを実行しても、特に状況に変化はない。次に行動実行!(::Tかばう行動, ...)を実装しよう。

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

function かばう実行!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!")
    対象者.かばってくれているキャラクター = 行動者
end

再度テストを実行しても、特に状況に変化はない。ここまでは予定通りだ。

花子が受けた攻撃を太郎に差し替える処理

いよいよかばう行動の本体だ。T通常攻撃でダメージを受けるときに、かばってくれているキャラクターがいれば、防御者を差し替えてしまうという実装だ。

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
    #追加部分
    if !isnothing(防御者.かばってくれているキャラクター)
        println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
        防御者 = 防御者.かばってくれているキャラクター
    end
    #ここまで
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

これで追加したテストが通るようになった。printlnで表示しているメッセージも追加しているので、画面上からも確認しておきたい。太郎のスキルに「かばう」を追加しよう。ついでに本来は太郎のスキルではない「大振り」は外しておこう。

function main()
    ...
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう)])
    ...
end

動かしてみるとわかるが、太郎でスキルを選択しようとするとエラーで失敗する。これはTかばう名前消費MPのフィールドを持たないためだ。追加しておこう。

struct Tかばう <: Tスキル 
    名前
    消費MP
end

function createスキル(スキルシンボル)
    ...
    elseif スキルシンボル === :かばう
        return Tかばう("かばう", 0)
    ...
    end
end

こうして「かばう」が選択できるようになる。

太郎のターン
行動を選択してください:
   攻撃
 > スキル
スキルを選択してください:
   連続攻撃10
 > かばう0

意気揚々と「かばう」を選択すると、衝撃的なメッセージが表示される。

太郎はドラゴンを身を呈して守る構えをとった!

これは裏切りだ。太郎はドラゴンの手先となったのだ。・・・いや、そうではない。これはバグだ。これまでは敵キャラクターが1体で攻撃系の行動しかなかったので、勝手に敵キャラクターが選ばれるようにしたのだ。ここもなんとかしよう。

問題はここだ。

#戦闘.jl
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    コマンド = コマンド選択(行動者)
    return T行動(コマンド, 行動者, モンスターs[1])#ここ!
end

コマンド選択時に対象も選べる必要がある。コマンド選択関数内で対象も選ぶことになるので、コマンド選択関数がT行動を返すようになるだろう。さらに、コマンド選択関数の中で、攻撃対象やかばう対象を指定する必要があるので、プレイヤーsモンスターsを引数に渡すようにする。

#戦闘.jl
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    return コマンド選択(行動者, プレイヤーs, モンスターs)
end
#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...

コマンド選択関数の中身の変更を見ていこう。

まず、選択したコマンドに応じて、敵を選択するのか味方を選択するのかを決める必要がある。そして、敵一覧または味方一覧の中から対象者を選ぶのだ。これを実現するために、get対象リストという関数を作る。get対象リストは、選択された行動内容から、攻撃系の行動であれば敵の一覧を、そうでなければ味方の一覧を表示する。おお、これは行動系統で特徴付けられる性質そのものではないか!!

ui.jl行動系統.jlをincludeし、Holy Traitsパターンを炸裂させよう。

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

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

    function get対象リスト(::Tかばう行動)
        return プレイヤーs
    end

get対象リストには、モンスターsプレイヤーsの引数がないのに、返り値として返している。これは、その外側のコマンド選択関数の引数を返しているのだ。一見すると妙なことが起きているように見えるが、外側のスコープの変数にアクセスできること自体はそんなにおかしなことではない。

julia> for i in 1:3
           function test()
               return 2i
           end
           println(test())
       end
2
4
6

そして、実際に選択したスキルから対象リストを使って画面に表示・選択できるようにしているのが下記の部分だ。まあ、そんなに解説するところはないだろう。通常攻撃の方の分岐も、以下同文という感じだ。

選択スキル = 行動者.スキルs[選択index]
対象リスト = get対象リスト(選択スキル)
if length(対象リスト) == 1
    return T行動(選択スキル, 行動者, 対象リスト[1])
else
    選択肢 = RadioMenu([s.名前 for s in 対象リスト], pagesize=4)
    選択index = request("誰を対象にしますか?:", 選択肢)
    if 選択index == -1
        println("正しいコマンドを入力してください")
        continue
    end
    対象者 = 対象リスト[選択index]
    return T行動(選択スキル, 行動者, 対象者)
end

まとめると、次のような感じになる。

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

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

    function get対象リスト(::Tかばう行動)
        return プレイヤーs
    end

    while true
        選択肢 = RadioMenu(["攻撃", "スキル"], pagesize=4)
        選択index = request("行動を選択してください:", 選択肢)

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

        if 選択index == 1
            対象リスト = get対象リスト(T通常攻撃())
            if length(対象リスト) == 1
                return T行動(T通常攻撃(), 行動者, 対象リスト[1])
            else
                選択肢 = RadioMenu([s.名前 for s in 対象リスト], pagesize=4)
                選択index = request("誰を対象にしますか?:", 選択肢)
                if 選択index == -1
                    println("正しいコマンドを入力してください")
                    continue
                end
                対象者 = 対象リスト[選択index]
                return T行動(T通常攻撃(), 行動者, 対象者)
            end
        elseif 選択index == 2
            選択肢 = RadioMenu([s.名前 * string(s.消費MP) for s in 行動者.スキルs], pagesize=4)
            選択index = request("スキルを選択してください:", 選択肢)
            if 選択index == -1
                println("正しいコマンドを入力してください")
                continue
            end
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end

            選択スキル = 行動者.スキルs[選択index]
            対象リスト = get対象リスト(選択スキル)
            if length(対象リスト) == 1
                return T行動(選択スキル, 行動者, 対象リスト[1])
            else
                選択肢 = RadioMenu([s.名前 for s in 対象リスト], pagesize=4)
                選択index = request("誰を対象にしますか?:", 選択肢)
                if 選択index == -1
                    println("正しいコマンドを入力してください")
                    continue
                end
                対象者 = 対象リスト[選択index]
                return T行動(選択スキル, 行動者, 対象者)
            end
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

うーん、正しく動きはするものの、汚いコードだ。ここらでリファクタリングしよう。

リファクタリング

配列から選択肢を作り、RadioMenuに表示し、選択されたインデックスを返却するところを共通化しておこう。

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

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

これで少しマシになった。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...
    while true
        選択index = RadioMenu作成(["攻撃", "スキル"])
        if 選択index == 1
            対象リスト = get対象リスト(T通常攻撃())
            if length(対象リスト) == 1
                return T行動(T通常攻撃(), 行動者, 対象リスト[1])
            else
                選択index = RadioMenu作成([s.名前 for s in 対象リスト])
                対象者 = 対象リスト[選択index]
                return T行動(T通常攻撃(), 行動者, 対象者)
            end
        elseif 選択index == 2
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end
            選択スキル = 行動者.スキルs[選択index]

            対象リスト = get対象リスト(選択スキル)
            if length(対象リスト) == 1
                return T行動(選択スキル, 行動者, 対象リスト[1])
            else
                選択index = RadioMenu作成([s.名前 for s in 対象リスト])
                対象者 = 対象リスト[選択index]
                return T行動(選択スキル, 行動者, 対象者)
            end
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

しれっと「行動を選択してください」「スキルを選択してください」などのメッセージを、単に「選択してください」に統一している。引数で受け取れるようにしてもいいが、そこまでの価値もないと判断した。おかげでシンプルな実装になっている。提供する価値が同じなのであれば、仕様をシンプルにしていくための交渉も大切なことだ。

さて、もう一息頑張ろう。敵または味方のリストを表示して、行動対象を選択するところを共通化しよう。行動対象決定という関数名にしようと思ったが、行動対象を決定した上で、T行動型のデータを作ってreturnしているので、名前と実態がそぐわない。どうするか悩んだが、次のような長ったらしい名前にすることにした。文脈に依存しないUtility的な関数であればあまり長い名前は鬱陶しいのだが、このように文脈に依存する名前は長くても構わないだろう。

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

次のようにスッキリした。

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

あとは選択indexという変数を改善しよう。== 1という使われ方が嫌だ。行動者.スキルs[選択index]も2箇所に書かれているのでまとめると、次のようになる。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ....
    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)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs) #追加
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

かなり基盤のループに部分に手を入れることになるため、「かばう」という固有の機能のためにそこまでしていいのか?という思いもあるが、行動の前に行動前処理が呼ばれる、というくらいなら基盤の拡張として自然なので、これでよしとしよう。

こうして方針が決まったので、テストコードは次のようにかける。「かばう」が解除された後は、花子のHPが減り、太郎のHPが減らないことを確認している。

@testset "かばう" begin
    ...
    太郎が花子をかばう = 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 

無事テストが失敗したことを確認して、実装に入ろう。

メインのループ部分に、行動前処理!関数の呼び出しを追加する。

#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs)
            ...
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs) #追加
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                 ...
            end
            ...
end

行動前処理!関数の具体的な中身は次のようなものだ。今後は「かばう」以外にも色々入ってくるだろうか、今は「かばう」だけだ。

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    isかばっている, 対象 = is誰かをかばっている(行動者, プレイヤーs, モンスターs)
    if isかばっている
        かばう解除!(行動者, 対象)
    end
end

is誰かをかばっている関数は次のようなものだ。

function is誰かをかばっている(行動者::Tキャラクター, プレイヤーs, モンスターs)
    全キャラクターs = vcat(プレイヤーs, モンスターs)
    for p in 全キャラクターs
        if p.かばってくれているキャラクター == 行動者
            return (true, p)
        end
    end
    return (false, nothing)
end

特徴的なのはreturn (true, p)のようにしているところだ。丸括弧で囲まれたデータのことを「タプル」と言う。タプルとは、複数の値をひとまとまりとして取り扱うことのできるデータ構造だ。配列は同じ種類のデータがたくさんあるときに使うが、タプルは構造体に似て、ざまざまな種類のデータをひとまとまりで扱うときに使う。配列は要素数が無数にあることが半ば前提のようなデータ構造だが、タプルは片手で数えられるくらいの要素数であることがほとんどだ。

単に、return preturn nothingのようにして、返り値がnothingかどうかで判定しても良かったのだが、nothingというのはあくまでnothingであり、それを「誰もかばっていない」という意味にするのはあまり好きではない。「誰かをかばっているかどうか」「それは誰か」に分けて表現するためにタプルを使った。なお、タプルにせずに単に次のように書いてもいい。

function is誰かをかばっている(行動者::Tキャラクター, プレイヤーs, モンスターs)
    ...
            return true, p
    ...
    return false, nothing
end

最後がかばう解除!である。

function かばう解除!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
    対象者.かばってくれているキャラクター = nothing
end

ここも本当は、(false, nothing)のようにしたいのだが、しっくりくるフィールド名が思い浮かばない。別途、is誰かにかばわれているというフィールドを複数作るほどでもないしな、という妥協の産物だ。

これでテストが通るはずだ。また、テストだけでなく実際に動かしてみよう。そのうち太郎が花子をかばう様子を確認できるだろう。面倒だったらドラゴンが常に花子を狙うようにするといい。

細かい不具合

動かしているうちに、ちょっと細かい不具合や残件が気になってくるだろう。

  • 自分をかばうことができる問題
    • かばうの対象に自分を選んでもターンを消費するだけで嬉しくない。自分自身は選べないようにしよう。
  • スキル攻撃時のかばう処理
    • 今のところ通常攻撃にしか「かばう」で攻撃を受ける対象を差し替える処理を入れていないので、スキル攻撃にも対応するようにしよう。
  • かばってくれている人をさらに別の人がかばってくれているとき
    • 花子を太郎がかばい、太郎を遠藤君がかばっているとき、花子が攻撃を受けたらダメージを受けるのは太郎だろうか、遠藤君だろうか?うーん、わからない。ドラゴンの爪が今まさに花子に襲いかかるとき、横から太郎がサッと前に飛び出し、身代わりで攻撃を受けるのはかっこいい。しかし、そこにさらにサッと遠藤君が飛び出してきて太郎をかばうと、ちょっとギャグみたいだ。太郎の面目は丸潰れだし、花子だって誰にお礼のチッスをかませばいいか困ってしまうだろう。これは良くない。直接かばってくれている人が身代わりになるようにしておこう。
  • 同じ人を二人がかばったとき
    • 花子を太郎と遠藤君の両方がかばったとき、どちらが花子への攻撃を受けるかどうか。これもよくわからない。強いて考えれば、かばうからには、かばいやすい位置にいるのだろうから、先客がいたらダメということにしておくのがいいだろう。
  • かばう人が戦闘不能になったとき
    • HPが0になっても延々とかばい続けてしまってはいけないので、HPが0になったら「かばう」が解除されるようにしよう。

自分をかばうことができる問題への対応

自分をかばうことができる問題に対応するために、Tかばう行動で対象リストを出すときに、行動者を対象から除外するようにしたい。次のように書く。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者, プレイヤーs)
end

filterというのは、指定された配列から、与えられた条件を満たす要素のみを抽出した配列を作る関数だ。第一引数が条件p -> p != 行動者であり、第二引数が配列プレイヤーsだ。

このp -> p != 行動者とはなんだろうか?これは「ラムダ式」と呼ばれるものだ。

ラムダ式

ラムダ式とは、匿名関数と呼ばれることもある。匿名関数というからには、名前のついていない関数である。

まず、普通の関数には名前がついている。例えば次の関数はdoubleという名前で定義されている。その中身では、xという引数を2xにする、という処理が行われている。

function double(x)
  return 2x
end

ところでこの関数定義を、「xという引数を2xにする、という処理に対して、doubleと名付けた」というように見ることもできる。

このとき、「xという引数を2xにする」という処理そのものを表現するときに使うのが「ラムダ式」だ。この場合、x -> 2xと表現される事になる。読み方は簡単で、矢印の左が引数、右が処理内容である。

ラムダ式は関数と同じように扱うことができる。例えば次のように書くと、「x -> 2xという(匿名の)関数に、引数5を作用させている」ということになり、結果10となる。

julia> (x -> 2x)(5)
10

ラムダ式は変数に代入することもできる。

julia> double = x -> 2x
julia> double(5)
10

double(5)は、変数に保持しているラムダ式に引数を渡しているのだが、あたかも関数呼び出しのように見える。ラムダ式を変数に代入するというのは、匿名関数に名前をつけるということであり、実質的に関数の定義をするのと同等である。

実際、「関数定義とは、ラムダ式で定義された本体に名前をつけるという意味である」という意味づけを明確にしている言語もあるくらいである。(興味のある方はSchemeという言語を調べてみよう)

なお、引数が複数になったときのラムダ式は次のように書ける。

julia> (x, y) -> x + y

もちろん、変数に代入することもできる。

julia> add = (x, y) -> x + y
julia> add(5, 10)
15

普通の関数のように複数行にわたる定義も可能で、その場合には、beginendでくるむ。

julia> add = (x, y) -> begin
                         println("x=$x")
                         println("y=$y")
                         println("x+y=$(x+y)")
                       end
julia> add(5, 10)
x=5
y=10
x+y=15

元の問題に戻ろう。次のようにしたのだった。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者, プレイヤーs)
end

これは「プレイヤーs配列の各要素に対して、『pを引数に受け取って、p行動者が等しくないときにtrueを返す関数』を適用して、trueになった要素だけを抽出する」という意味になっている。なお、pという変数名にはプレイヤーの意味を込めた。私はラムダ式の変数名に長い名前をつけることを好まない。ラムダ式は短く簡潔に書くのがメリットだからだ。

これで、「かばう」の対象に自分自身が出てくることは無くなった。

スキル攻撃時のかばう処理への対応

これは簡単だ。まずはテストを作ろう。

@testset "かばう" begin
    ...
    @testset "連続攻撃" begin
        太郎 = createキャラクターHP100()
        花子 = createキャラクターHP100()
        ドラゴン = createモンスターHP200攻撃力20()

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

        ドラゴンから花子へ連続攻撃 = Game.T行動(Game.createスキル(:連続攻撃), ドラゴン, 花子)
        Game.行動実行!(ドラゴンから花子へ連続攻撃)
        @test 花子.HP == 100
        @test 100 - 10 * 5 <= 太郎.HP <= 100 - 10 * 2

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

        Game.行動実行!(ドラゴンから花子へ連続攻撃)
        @test 100 - 10 * 5 <= 花子.HP <= 100 - 10 * 2
        @test 100 - 10 * 5 <= 太郎.HP <= 100 - 10 * 2
    end
end 

これは期待通り失敗する。

実装は、T通常攻撃と同じようにしよう。

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)の$(スキル.名前)!")
    ### 追加
    if !isnothing(防御者.かばってくれているキャラクター)
        println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
        防御者 = 防御者.かばってくれているキャラクター
    end
    ###
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        if rand() < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

これでテストが通る。テストはOKだが、実際に動かして確認もしておきたい。このためには、ドラゴンがスキルを使えるようにする必要があるので、その実装をしておこう。ドラゴンは選択可能な行動からランダムで1つ行動を選ぶ。残MPが残っているうちは、通常攻撃とスキルを織り交ぜて使ってきて、残MPがなくなると通常攻撃ばかりになる。

変更するのは次の関数だ。モンスターが行動するときには通常攻撃固定になっている。

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動(T通常攻撃(), 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

次のように修正しよう。get選択可能行動内容関数は、そのキャラクターの選択可能な行動内容の配列を返す。ここでもラムダ式が活躍していることを確認しておこう。

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 main()
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)])
    ...

これでドラゴンが連続攻撃を使ってくるようになる。連続攻撃に対しても「かばう」が発動することを確認しておこう。

キーワード引数とオプショナル引数

ところでテストの期待値の書き方がやや苦しい。

p = createプレイヤーHP100攻撃力10()
m = createモンスターHP200攻撃力20()
...
@test 100 - 10 * 5 <= p.HP <= 100 - 10 * 2

テストコードは明快さが大切なので、即値が入っていても必ずしも悪くはないのだが、ちょっとわかりづらい。100とか10とかいう値は初期設定したパラメータに依存するのだ。

次のように変更しよう。

@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
    @test モンスターHP - プレイヤー攻撃力/2 * 5 ≤ m.HP ≤ モンスターHP - プレイヤー攻撃力/2 * 2 

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

詳細はあまり重要ではなく、即値から変数にしているだけだが、「キーワード引数」という新たなテクニックを使っている。次の部分だ。

...
p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力)
...
m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力)
...

通常、関数の引数は並び順に従って指定する必要があるが、キーワード引数を使うと、仮引数の名前を指定して値を渡すことができる。

キーワード引数は次のような文法で記述する。引数のリストは通常カンマで区切るが、セミコロンで区切った後の引数がキーワード引数となる。次の関数はキーワード引数のみ取るので、セミコロンから始まっている。

function createプレイヤー(;HP, 攻撃力)
    return Game.Tプレイヤー("", HP, 0, 攻撃力, 10, [])
end

キーワード引数を使うことで、値がずらずら並んでいるよりも、どの変数になんの値を設定したのか分かりやすくなることがある。

さらにもう1つ、「オプショナル引数」というものも紹介しておこう。これは、省略可能な引数のことである。オプショナル引数は、デフォルトで何らかの値が設定されるようになっている。呼び出し時に明示的に指定されたらその値になるが、省略したらデフォルト値が設定されるようになっている。

次のように、関数の仮引数に=100のように設定することで、その引数の指定が省略されたときデフォルト値を設定することができる。

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

なお、キーワード引数もオプショナル引数も、通常の引数の後ろに設定する必要がある。

これまで、テスト用のcreateプレイヤーHP100のような関数は、パラメータになんの値を設定しているかを分かりやすくするために、このような名前にしていた。しかし、次のようにキーワード引数とオプショナル引数を組み合わせると、任意の引数だけキーワード指定で渡し、それ以外の引数は省略してデフォルト値を設定することが可能になる。

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

こうすると次のように、必要なパラメータだけ明示的に指定でき、それ以外のパラメータは無難な値に設定される、というようにできるのだ。

createプレイヤー(名前="花子", HP=100)

こちらの方が好ましいので、このようにしておこう。置き換え自体は単調な作業なので割愛する。

かばってくれている人をさらに別の人がかばってくれているときの対応

これに関しては、おそらく今のままの実装で大丈夫なのだが、確認しておこう。次のようなテストを書く。

@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

これは問題なく成功する。

同じ人を二人がかばったときへの対応

すでに誰かにかばわれている人は選択不可能にしよう。次の関数に手を加えることになる。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者, プレイヤーs)
end

プレイヤーが誰かにかばわれていたら対象から除外するので、次のようにしよう。

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

遠藤君が「かばう」を使えるようにしておいてから、起動して動きを確認しておこう。

function main()
    ...
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:かばう)])
    ...
end

かばう人が戦闘不能になったとき

最後に、かばう人が戦闘不能になったときの対応である。これができたら「かばう」の実装は完了だ。

最初にテストコードを書こう。太郎のHPが0になったら、その後は花子がダメージを受けることになる。

@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

まずは素直に実装してみよう。かばう人が戦闘不能になったとき、その人が次のターンの経過を迎えた時と同じく「かばう」を解除したい。HP減少!関数に、HPが0になったときの分岐があるので、そこで実装することになるだろう。

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        #ここに「かばう」を解除する処理を入れる。
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

防御者が、かばっていた対象の人のフィールドをいじる必要がある。そのため、この関数に、プレイヤーsモンスターsを引っ張ってくる必要がある。そしてそれらのキャラクターでループし、自分がかばっている人だったらフィールドを解除しようという目論見だ。

しかし、それはなんだか嫌だ。ダメージを受けた人のHPを減少させる処理の引数に、なぜ他のキャラクターの情報が必要なのだろうか?確かに、「かばう」の仕様として必要と言われたらその通りだが、違和感はある。

問題は、「かばっている人」が誰をかばっているかを知らないというところにある。なので、全キャラクターの情報を引っ張ってくる必要があるのだった。それ自体が違和感のある話ではある。キャラクターが「誰をかばっているか」を知ることができるようにしよう。

それでは、現状の「誰にかばってもらっているか」という情報はどうなるのだろうか?これは消すべきだろうか?消したらどうなるかというと、花子が太郎にかばってもらっているときも、花子から直接その情報を取得できず、全キャラクターから「花子をかばっている人」を見つけて攻撃を受ける対象を差し替える必要があるというわけだ。まあ別にいいのだが、そんなに躍起になって消す必要があるのだろうか?花子が「自分をかばってくれているのが太郎である」と知っていたっていいではないか。

すると、結局両方のフィールドを残せばいいという話になるが、そうすると、「太郎が花子をかばっている」「花子が太郎にかばってもらっている」という本質的に等価な情報を別々に管理する必要が出てくる。それはそれでデータ更新時に片方だけを更新して片方更新漏れするなどの不具合につながる恐れがある。それも嫌だ。

となると、「太郎が花子をかばっている」という情報をキャラクターから独立に切り出し、その情報を太郎も花子も参照するということになるだろうか?これが一番正攻法な気もするが、その機構まで組み込むのは、ちょっと過剰な気がするなあ・・・。

いろいろ悩むところだが、今回は「かばっている人」「かばってくれている人」をそれぞれ別のフィールドで管理するという案で進めてみよう。データの二重管理のデメリットはあるが、更新箇所も多くないのと、きちんとデータのチェックを入れてあげれば対処可能だろう。

というわけで、先程の”戦闘不能になったらかばう解除”のテストコードは一旦コメントアウトでもしておいて、先にデータの持ち方を変更するようにしよう。

かばっているキャラクターというフィールドを追加しよう。

mutable struct Tキャラクター共通データ
    ...
    かばっているキャラクター #追加
    かばってくれているキャラクター
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing)  #ここにも初期値(nothing)の追加を忘れずに
    ...

ここにも追加を忘れないようにしよう。

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs, :かばっているキャラクター,  :かばってくれているキャラクター]  #追加
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs, :かばっているキャラクター, :かばってくれているキャラクター] #追加
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

毎度追加するのも面倒だ。実はJuliaには構造体のフィールド名を取得するいい関数がある。fieldnamesという関数だ。これを使って、Tキャラクター共通データに保持しているフィールドを取得するようにしよう。

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

もっと委譲の階層が深くなり、同名のフィールドが委譲の階層の中の構造体にあちこち出てきたりしたら、このような単純な判定では失敗するかもしれないが、そのときはその時で考えよう。

テストを作っておこう。

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

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

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

テストが失敗することを確認したら、かばう実行!かばっているキャラクターを設定しよう。さらに、不整合なデータとなっていないかのチェックを作っておこう。

function かばう実行!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!")
    行動者.かばっているキャラクター = 対象者
    対象者.かばってくれているキャラクター = 行動者

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

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

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

「かばう」が解除される方も確認しておこう。

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

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

    Game.行動前処理!(太郎, [花子], []) #「かばう」が解除される
    @test isnothing(太郎.かばっているキャラクター)
    @test isnothing(花子.かばってくれているキャラクター)
end

白状しておくと、あまりいいテストケースではない。今チェックしているのは、Tキャラクターの内部データのような性質のものだ。外部に見せる振る舞いはチェックしておくべきだが、内部データはあまりテストを作るべきではない。外部への振る舞いを変えない限りは、内部データは好き勝手に変更していい、というのが本来のあり方だ。内部データのテストを作ると、内部データを変えるたびにテストが壊れてしまうのだ。とはいえ、今のチェックしたい内容は、外部からの振る舞いとしては見えない部分で、しかしデータの不整合というのも起こしたくないので、折衷案的な立場のテストだ。

話を戻して、追加したテストが失敗することを確認してから、次の修正を加えよう。これまでかばう解除!のためには、行動者対象者を渡していたが、行動者から直接取得できるようになっている。

#function かばう解除!(行動者, 対象者)
#    println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
#    対象者.かばってくれているキャラクター = nothing
#end

function かばう解除!(行動者)
    if !isnothing(行動者.かばっているキャラクター)
        対象者 = 行動者.かばっているキャラクター
        println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
        行動者.かばっているキャラクター = nothing                    
        対象者.かばってくれているキャラクター = nothing
        #事後条件
        かばうデータ整合性チェック(行動者)
        かばうデータ整合性チェック(対象者)
    end
end

かばう解除! 関数の内部でかばっているキャラクターがいるかどうか、などの面倒をみるようにしたので、行動前処理! 関数の仕事は楽になる。

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    #isかばっている, 対象 = is誰かをかばっている(行動者, プレイヤーs, モンスターs)
    #if isかばっている
    #    かばう解除!(行動者, 対象)
    #end
    かばう解除!(行動者)
end

これでテストが通るようになるはずだ。

ここまでが下準備で、いよいよ戦闘不能になったら「かばう」が解除されるケースだ。再度、”戦闘不能になったらかばう解除”をテストケースに加えよう。

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

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

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

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

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

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

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

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

このテストは失敗する。正しく動かすために、次のように対応しよう。

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end
    if 防御者.HP - ダメージ ≤ 0
        防御者.HP = 0
        かばう解除!(防御者) #追加
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

テストは無事に通ったことだろう。

少し回り道をしたが、データの持ち方を工夫することで、HP減少!に対しておかしな引数を追加することなく対応することができた。

ちなみに、この変更に伴い、次の関数が不要になっているので消している。

function is誰かをかばっている(行動者::Tキャラクター, プレイヤーs, モンスターs)
    全キャラクターs = vcat(プレイヤーs, モンスターs)
    for p in 全キャラクターs
        if p.かばってくれているキャラクター == 行動者
            return true, p
        end
    end
    return false, nothing
end

また、現状で実は微妙なバグがある。戦闘不能になった際のメッセージにが、「太郎が代わりに攻撃を受ける!」「太郎は花子をかばうのをやめた!」「太郎は20のダメージを受けた!」「太郎の残りHP:0」となっているのだ。「太郎は花子をかばうのをやめた!」を最後に持ってきたいし、そもそもやめたのではなく戦闘不能でかばえないので、適切なメッセージにしたい。

しかし、これは次回以降で解決しよう。最後にもう一つ語っておきたいところがあるのだ。

欲張りセット

機能的には無事実装できたのだが、実はここまでの方針には不満がある。

「かばう」が解除される時の仕様をまとめると次のようになる。

  • 「かばう」の実行者が次の行動をするときか、戦闘不能になったときに、「かばう」が解除される。

問題は、この知識に対応する実装が、別々の場所に散らばり埋もれてしまっていることだ。この仕様について知っている人はいい。勘を働かせて、どこで解除処理が実装されているかを突き止めることができるだろう。しかし、知らない人はそうはいかない。あちこちに埋め込まれたコード片から仕様を推測するのは大変な苦行だ。

またそれとは別の観点として、「かばう」という個別のスキル固有の要件が、「HPを減少させる」という、このアプリケーションの根幹の処理に混ざり込んでしまっているという点がある。

アプリケーションには、変更がしばしば入る部分と、変更があまり入らない部分がある。このことを「安定度」という言葉で表現することがある。アプリケーションの根幹に当たる部分は、安定度が高くなければならない。

「かばう」に関する仕様はアプリケーションの根幹とは言えない。「かばう」なしでもゲームは成立する。「かばう」は枝葉である。枝葉の仕様はよく変わる。何か枝葉の仕様変更があるたびに、根幹の部分に手を入れる必要がある設計はまずい設計だ。そして、HPを減少させる処理は明らかに根幹の部分だ。HPが減少しなければゲームは成立しない。

我々は難題に直面している。HPが減少する処理で「かばう」を解除できるようにしたい。一方「かばう」の解除をHPの減少処理とは独立させたい。この欲張りセットを注文してもいいのだろうか?

いいのだ。欲張ってよろしい。この問題は実はよく知られた解決策がある。「オブザーバーパターン」と呼ばれる設計技法である。

オブザーバーパターン

オブザーバーパターンとは、デザインパターンと呼ばれる設計技法の一つである。オブザーバーとは観察者という意味である。オブザーバーパターンの説明をする前に、まずは普通のプログラムについて考えよう。

通常のプログラムでは、ある処理の中で別の処理を呼び出していく。処理Aが処理Bを呼び出すとしよう。処理Bは処理Aに叩き起こされるのである。この場合、処理Aは処理Bのことが気になって仕方がないのである。処理Aはお母さんのように処理Bに気を配っている。処理Aは処理Bにちゃんと仕事をしてほしいのである。処理Aがうまく仕事をできるかは処理Bにかかっているからである。処理Aは処理Bに頼り切っているのである。

一方、処理Bは処理Aのことをそんなに気にかけてはいないのである。引数が渡されるからしょーがなく返り値を返してやってるだけで、できれば寝ていたいのである。処理Bは自分の仕事が終わったら処理Aが仕事をきちんと終えられるかは別段気にしないのである。処理Aが自分の返り値をきちんと受け取ったかどうかすら、処理Bには関係ないのである。処理Bは処理Aがどうなろうと知ったこっちゃないのである。

このような状況を指して、「処理Aは処理Bに依存している」と表現する。処理Bが気持ちよく仕事ができるように、処理Aが面倒を見てやらなければならないのである。「かわいいかわいい処理Bちゃん、おつかいに行ってくださいな。帰ったらケーキと紅茶を用意しておきますからね。」

オブザーバーパターンはこれとは全く異なる関係にある。処理Aはステージで踊るアイドルで、処理Bは観客席に座るファンである。処理Aは処理Bのことなど知らない。処理Bは処理Aの一挙手一投足観察している。処理Aが歌を歌うと、処理Bは耳を澄ませる。処理Aがマイクを観客席に向けると、処理Bは声を枯らして熱唱する。処理Aが公演スケジュールを発表すると、処理Bは有給休暇を申請する。処理Aが結婚を発表すると、処理Bは藁人形と五寸釘を片手に丑の刻参りを始める。

もちろん、処理Bが別のアイドルに鞍替えすることもできる。それは処理Bの勝手である。処理Bは自分の好きなように観察対象を決めるし、処理Aは誰に観察されているかを意識することはない。処理Aはただ、〇〇を行ったというシグナルを発行する。処理Bはそのシグナルが発行されると、それに応じた処理を好き勝手に行う。

このように処理間の依存関係を切り離すことをオブザーバーパターンは目指す。一般的に、ソフトウェアの個々の処理は疎結合であることが望ましい。

  • 用語の整理

オブザーバーパターンは用語が少しややこしい。整理しておこう。

まず、イベントを発行する人を「サブジェクト」という。今回だと「キャラクター」が相当する。サブジェクトはさまざまな「イベント」を発行する。「キャラクターが行動するイベント」と「キャラクターのHPが減少してゼロになるイベント」が今回発行されるイベントだ。

次に、オブザーバーというのは観察者のことで、サブジェクトのことを観察する。ただ、実際にはオブザーバーは観察するというよりは、サブジェクトに対して、「イベントが発行されたら僕に教えてください」と事前に登録しているのだ。そして、サブジェクトは登録されたオブザーバーに対してイベントの通知をおこなう。サブジェクトは、オブザーバーを登録するリストを持っているのだ。

そういった関係から、サブジェクトは自分のイベントを聞く人を知っているということで、「イベントリスナー」という名前でデータを保持することが多い。オブザーバーパターンはオブザーバーが主役かと思いきや、ソースコードにはリスナーというものが出てきて混乱しがちなので注意しよう。

「オブザーバーは、サブジェクトのイベントリスナーとして登録され、サブジェクトはイベントの発行をイベントリスナーに通知する」ということである。

大丈夫だろうか?では、具体的にオブザーバーパターンを実装してみよう。

  • オブザーバーを登録できるようにする

キャラクターは自分自身を観察してくれる人を募集している。行動を行う前や、戦闘不能になったとき、それぞれのイベントに対するリスナーを登録できるフィールドを用意する。それぞれは単なる配列だ。リスナーは複数登録できるのでフィールド名にリスナーsとsをつけている。

mutable struct Tキャラクター共通データ
    ...
    行動前処理イベントリスナーs
    戦闘不能イベントリスナーs
    ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [], [])  
    ...
end
  • リスナー登録する

リスナーとしては何を登録すればいいだろうか?リスナーには何が求められるのだろうか?リスナーは何ができる必要があるだろうか?

リスナーは、「かばう」を解除できる必要がある。つまり関数である。イベントを受け取ったら、該当キャラクターの「かばう」を解除できる関数がリスナーとして登録されていれば、それを実行すれば「かばう」が解除されるのだ。

つまり、かばう解除!関数のようなものがふさわしい。

function かばう解除!(行動者)
    if !isnothing(行動者.かばっているキャラクター)
        対象者 = 行動者.かばっているキャラクター
        println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
        行動者.かばっているキャラクター = nothing                    
        対象者.かばってくれているキャラクター = nothing
        #事後条件
        かばうデータ整合性チェック(行動者)
        かばうデータ整合性チェック(対象者)
    end
end

「太郎」が行動する時、あるいは戦闘不能になる時、「太郎」を引数にかばう解除!関数が実行されるようにしたい。どうやってそんなことができるようになるのだろうか?

さて、ここで思い出して欲しいのが先ほど学んだラムダ式である。ラムダ式は次のように変数に代入することができた。

julia> double = x -> 2x
julia> double(5)
10

そして思い出して欲しい。関数とは名前のついたラムダ式なのだった。であれば、関数もラムダ式のごとく、変数に代入したり、配列に入れたりできていいはずだ。そして実際、それができるのだ。

julia> function test(x)
         return 2x
       end
test (generic function with 1 method)

julia> a = test
test (generic function with 1 method)

julia> a(5)
10

a = testのように、関数名を引数なしで呼び出すと、関数そのものを取り扱うことができる。

ここまでくればもう私の言いたいことがわかるだろう。かばう解除!関数を、イベントリスナーの配列に登録すればいいのだ。

全てのキャラクターに対してかばう解除!関数を登録しておく。コンストラクタで指定しておこう。

mutable struct Tキャラクター共通データ
    ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [かばう解除!], [かばう解除!])  
    end
end
  • イベントを通知する

それぞれのイベントが発生したときにリスナーに通知するようにしよう。先程行動前処理!HP減少!に書いた、かばう解除!の処理の呼び出しは消し、代わりにイベント通知の関数を呼び出すようにしよう。

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

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

さらに、それぞれの通知処理の中でリスナーへ通知しよう。

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

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

リスナーという変数には、関数が入っている。そして、その関数に引数を与えて呼び出していることで実行している。このようにして、かばう解除!処理が間接的に呼び出されることになる。

こうして、該当のイベントが発生するたびにかばう解除!関数が呼ばれることになった。かばう解除関数は、誰もかばっていない場合には空振りするようになっているので心配は無用だ。

テストを実行して問題なく完了することを確認しておこう。

オブザーバーパターンのまとめ

オブザーバーパターンは数あるデザインパターンの中でも非常に有用なものの一つである。この考え方は、現代的なGUIフレームワークなどに標準的に採用されており、一部の言語では言語機能としてこの仕組みが採用されていることもある。(C#のeventなど)

オブザーバーパターンにはメリットもデメリットもある。メリットは、処理呼び出しの依存関係を切り離すことができることだ。デメリットは、処理を追いかけるのが難しくなることだ。疎結合は一般的にはいいことで、オブザーバーパターンは疎結合を実現する手段ではあるが、ありとあらゆる箇所をオブザーバーパターンで表現することは感心しない。

例えば、「かばう」を実行したらかばっているキャラクターに対象を設定する、という処理だって、オブザーバーパターンで書くことはできる。しかし、この場合はデメリットしかない。「かばう」を実行したら誰かをかばっている状態にする、というのは極めて当たり前のことで、ここを抽象化しても嬉しくない。単に遠回りなだけだ。使い所を見極めるのが重要だ。

とはいえ、そういったことが実感できるのは、実際に使ってみた時だけだ。このような趣味のアプリであれば、最初は身につけた知識やテクニックを過剰なまでに使ってみるといいだろう。パターンを適用可能な領域をギリギリまで攻めてみよう。どうせ困るのは自分だけだし、困るといったってたいして困らないのだ。そうして適用すべき領域、適用すべきでない領域を見極めていくのだ。

第一級オブジェクト

ところでこのオブザーバーパターン、調べてみると複雑なクラス図が出てきたりして難しいことに気づくだろう。それを見たことのある人は、今回の実装が拍子抜けするほど簡単なことに驚くかもしれない。

今回見たように、関数を数値や文字列や構造体と同様に、変数に代入したり、引数に渡したり、という扱いができるという言語仕様を指して、「関数が第一級オブジェクトである」と表現することがある。今回の実装が簡単だったのは、この「関数を自由に受け渡しする」という機能によるところが大きい。(まあ普通に紹介されるオブザーバーパターンと比べて、「リスナーの削除」のような処理を作っていないというところもあるが。)

オブザーバーパターンという単語は、いわゆるGoFのデザインパターンという本で有名になったのだが、当時のメインストリームにあった言語では、「関数を第一級オブジェクトとして扱う」という機能がないことが多かった。GoFのデザインパターンはC++で書かれているので、実際にはC言語から受け継いだ関数ポインタという機能があるのだが、Javaにはない。そして、そのような機能がないために、関数を渡したいだけの場面でもクラスを定義したり、複雑なクラスの継承関係などを設定する必要があり、ややこしいのだ。

現代的な言語仕様から見ると、足りない機能を補うために妙にややこしいことをやっているだけに見えて「古臭い」となどと言われることもあるが、本質的には重要なことを述べていることが多いので、今でも学ぶところの多い題材だと私は思う。このシリーズでも他にも紹介していけたらと思う。

第8回の終わりに

かなり時間がかかってしまったが、「かばう」処理が実装できた。今回はJulia固有の文法はほとんど説明しなかった。その代わり説明した「ラムダ式」「第一級オブジェクトとしての関数」はさまざまな言語で採用されている汎用的な機能なので、ぜひきちんと身につけたいところだ。

次回も引き続きさまざまなスキルを実装していこう。

コード

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

続き

第9回