「Julia言語で入門するプログラミング」第8回である。未読の方は第1回〜第7回を読んで欲しい。
Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ
Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ
Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ
Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ
Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ
Julia言語で入門するプログラミング(その6) - SE教育パパむううみんのブログ
Julia言語で入門するプログラミング(その7) - SE教育パパむううみんのブログ
前回の仕上げ
まず前回の仕上げを行おう。パラメトリック型などいろいろな案を提示したが、Holy Traitsパターンを使うようにする。「行動系統.jl」というファイルを新たに作り、下記のようにする。
#行動系統.jl struct T攻撃系行動 end struct Tかばう行動 end 行動系統(::T通常攻撃) = T攻撃系行動() 行動系統(::T攻撃スキル) = T攻撃系行動() 行動系統(::Tかばう) = Tかばう行動()
「戦闘.jl」は次のようになる。
#戦闘.jl include("行動系統.jl") struct T行動 コマンド::T行動内容 行動者::Tキャラクター 対象者::Tキャラクター end ... function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs) return T行動(T通常攻撃(), 行動者, rand(行動可能な奴ら(プレイヤーs))) end function 行動実行!(行動::T行動) 行動実行!(行動系統(行動.コマンド), 行動) end function 行動実行!(::T攻撃系行動, 行動::T行動) 攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド) MP減少!(行動.行動者, 行動.コマンド) end function 行動実行!(::Tかばう行動, 行動::T行動) #かばう実行!(行動.行動者, 行動.対象者) #未実装 end ...
終わってしまえばこぢんまりした修正である。
「かばう」の実装
だいぶ寄り道してしまったが、いよいよ「かばう」の実装に入ろう。
まずはテストを書いてみよう。こんなふうになるだろうか。太郎が花子をかばったあとに、ドラゴンが花子に攻撃したところ、花子は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で指定されていたので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
こうして「かばう」が選択できるようになる。
太郎のターン 行動を選択してください: 攻撃 > スキル スキルを選択してください: 連続攻撃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 p
とreturn 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
普通の関数のように複数行にわたる定義も可能で、その場合には、begin
〜end
でくるむ。
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 かばう解除!(行動者) if !isnothing(行動者.かばっているキャラクター) 対象者 = 行動者.かばっているキャラクター println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!") 行動者.かばっているキャラクター = nothing 対象者.かばってくれているキャラクター = nothing #事後条件 かばうデータ整合性チェック(行動者) かばうデータ整合性チェック(対象者) end end
これでテストが通るようになるはずだ。
ここまでが下準備で、いよいよ戦闘不能になったら「かばう」が解除されるケースだ。再度、"戦闘不能になったらかばう解除"をテストケースに加えよう。
@testset "戦闘不能になったらかばう解除" begin 太郎 = createプレイヤー(名前="太郎", HP=30) 花子 = createプレイヤー(名前="花子", HP=100) ドラゴン = createモンスター(HP=200, 攻撃力=20) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) @test 花子.かばってくれているキャラクター == 太郎 ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 100 @test 太郎.HP == 10 @test 花子.かばってくれているキャラクター == 太郎 ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 100 @test 太郎.HP == 0 ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 80 @test 太郎.HP == 0 end
このテストは失敗する。正しく動かすために、次のように対応しよう。
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固有の文法はほとんど説明しなかった。その代わり説明した「ラムダ式」「第一級オブジェクトとしての関数」はさまざまな言語で採用されている汎用的な機能なので、ぜひきちんと身につけたいところだ。
次回も引き続きさまざまなスキルを実装していこう。
コード
ここまでのコードを掲載しておこう。本文では触れなかったが、ちょこちょこ関数の定義をあるファイルから別のファイルに変更していたりする。
#game_exec.jl include("game.jl") Game.main()
#game.jl module Game using Random import REPL using REPL.TerminalMenus include("キャラクター.jl") include("戦闘.jl") include("ui.jl") function main() モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)]) プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [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スキル(:連続攻撃)]) println("モンスターに遭遇した!") println("戦闘開始!") ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター]) if モンスター.HP == 0 println("戦闘に勝利した!") else println("戦闘に敗北した・・・") end end end
#ui.jl include("スキル.jl") include("行動系統.jl") function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs) function get対象リスト(スキル::T行動内容) get対象リスト(行動系統(スキル)) end function get対象リスト(::T攻撃系行動) return モンスターs end function get対象リスト(::Tかばう行動) return filter(p -> p != 行動者 && isnothing(p.かばってくれているキャラクター), プレイヤーs) end function RadioMenu作成(選択肢) while true r = RadioMenu(選択肢, pagesize=4) 選択index = request("選択してください:", r) if 選択index == -1 println("正しいコマンドを入力してください") continue else return 選択index end end end function 行動対象を選択し行動を決定(行動内容::T行動内容) 対象リスト = get対象リスト(行動内容) if length(対象リスト) == 1 return T行動(行動内容, 行動者, 対象リスト[1]) else 選択index = RadioMenu作成([s.名前 for s in 対象リスト]) 対象者 = 対象リスト[選択index] return T行動(行動内容, 行動者, 対象者) end end while true 選択肢 = ["攻撃", "スキル"] 選択index = RadioMenu作成(選択肢) 選択 = 選択肢[選択index] if 選択 == "攻撃" return 行動対象を選択し行動を決定(T通常攻撃()) elseif 選択 == "スキル" 選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs]) 選択スキル = 行動者.スキルs[選択index] if 行動者.MP < 選択スキル.消費MP println("MPが足りません") continue end return 行動対象を選択し行動を決定(選択スキル) else throw(DomainError("行動選択でありえない選択肢が選ばれています")) end end end function 戦況表示(プレイヤーs, モンスターs) 結果 = [] push!(結果, "*****プレイヤー*****") for p in プレイヤーs push!(結果, "$(p.名前) HP:$(p.HP) MP:$(p.MP)") end push!(結果, "*****モンスター*****") for m in モンスターs push!(結果, "$(m.名前) HP:$(m.HP) MP:$(m.MP)") end push!(結果, "********************") return join(結果, "\n") end
#キャラクター.jl include("スキル.jl") mutable struct Tキャラクター共通データ 名前 HP MP 攻撃力 防御力 スキル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, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [かばう解除!], [かばう解除!]) end end abstract type Tキャラクター end mutable struct Tプレイヤー <: Tキャラクター _キャラクター共通データ::Tキャラクター共通データ end function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs) return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs)) end function Base.getproperty(obj::Tキャラクター, sym::Symbol) if sym in fieldnames(Tキャラクター共通データ) return Base.getproperty(obj._キャラクター共通データ, sym) end return Base.getfield(obj, sym) end function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val) if sym in fieldnames(Tキャラクター共通データ) return Base.setproperty!(obj._キャラクター共通データ, sym, val) end return Base.setfield!(obj, sym, val) end mutable struct Tモンスター <: Tキャラクター _キャラクター共通データ::Tキャラクター共通データ end function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs) return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs)) end function 戦闘不能処理イベント通知!(防御者::Tキャラクター) for リスナー in 防御者.戦闘不能イベントリスナーs リスナー(防御者) end end function HP減少!(防御者, ダメージ) if ダメージ < 0 throw(DomainError("ダメージがマイナスです")) end if 防御者.HP - ダメージ < 0 防御者.HP = 0 戦闘不能処理イベント通知!(防御者) else 防御者.HP = 防御者.HP - ダメージ end end function MP減少!(行動者, コマンド::T通常攻撃) end function MP減少!(行動者, コマンド::Tスキル) 消費MP = コマンド.消費MP if 消費MP < 0 throw(DomainError("消費MPがマイナスです")) 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
#スキル.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 function createスキル(スキルシンボル) if スキルシンボル === :大振り return T攻撃スキル("大振り", 2, 0.4, 0) elseif スキルシンボル === :連続攻撃 return T攻撃スキル("連続攻撃", 0.5, 1, 10, 2, 5) 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 かばう実行!(行動者, 対象者) println("----------") println("$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!") 行動者.かばっているキャラクター = 対象者 対象者.かばってくれているキャラクター = 行動者 #事後条件 かばうデータ整合性チェック(行動者) かばうデータ整合性チェック(対象者) end function かばう解除!(行動者) if !isnothing(行動者.かばっているキャラクター) 対象者 = 行動者.かばっているキャラクター println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!") 行動者.かばっているキャラクター = nothing 対象者.かばってくれているキャラクター = nothing #事後条件 かばうデータ整合性チェック(行動者) かばうデータ整合性チェック(対象者) end end
#戦闘.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通常攻撃) println("----------") println("$(攻撃者.名前)の攻撃!") if !isnothing(防御者.かばってくれているキャラクター) println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!") 防御者 = 防御者.かばってくれているキャラクター end 防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力) HP減少!(防御者, 防御者ダメージ) println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!") println("$(防御者.名前)の残りHP:$(防御者.HP)") end 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 function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs) println(戦況表示(プレイヤーs, モンスターs)) println("$(行動者.名前)のターン") 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行動) かばう実行!(行動.行動者, 行動.対象者) end function 行動前処理イベント通知!(行動者::Tキャラクター) for リスナー in 行動者.行動前処理イベントリスナーs リスナー(行動者) end end function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs) 行動前処理イベント通知!(行動者) end function is全滅(キャラクターs) return all(p.HP == 0 for p in キャラクターs) end function is戦闘終了(プレイヤーs, モンスターs) return is全滅(プレイヤーs) || is全滅(モンスターs) end function 行動順決定(プレイヤーs, モンスターs) 行動順 = vcat(プレイヤーs, モンスターs) return shuffle(行動順) end function ゲームループ(プレイヤーs, モンスターs) while true for 行動者 in 行動順決定(プレイヤーs, モンスターs) if is行動可能(行動者) 行動前処理!(行動者, プレイヤーs, モンスターs) 行動 = 行動決定(行動者, プレイヤーs, モンスターs) 行動実行!(行動) if is戦闘終了(プレイヤーs, モンスターs) return end end end end end
#行動系統.jl struct T攻撃系行動 end struct Tかばう行動 end 行動系統(::T通常攻撃) = T攻撃系行動() 行動系統(::T攻撃スキル) = T攻撃系行動() 行動系統(::Tかばう) = Tかばう行動()
以下はテストコードだ。
include("game.jl") using Test function createプレイヤー(;名前="太郎", HP=100, MP=20, 攻撃力=10, 防御力=10, スキルs=[]) return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs) end function createモンスター(;名前="ドラゴン", HP=400, MP=80, 攻撃力=20, 防御力=10, スキルs=[]) return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs) end @testset "HP減少" begin @testset "ダメージ < HP" begin c = createプレイヤー(HP=100) Game.HP減少!(c, 3) #3のダメージ @test c.HP == 97 end @testset "複数回ダメージ" begin c = createプレイヤー(HP=100) Game.HP減少!(c, 3) #3のダメージ @test c.HP == 97 Game.HP減少!(c, 3) #3のダメージ @test c.HP == 94 end @testset "ダメージ > HP" begin c = createプレイヤー(HP=100) Game.HP減少!(c, 101) #101のダメージ @test c.HP == 0 end @testset "ダメージ = HP" begin c = createプレイヤー(HP=100) Game.HP減少!(c, 100) #100のダメージ @test c.HP == 0 end end @testset "行動実行!" begin @testset "通常攻撃" begin p = createプレイヤー(HP=100, 攻撃力=10) m = createモンスター(HP=200, 攻撃力=20) プレイヤーからモンスターへ攻撃 = Game.T行動(Game.T通常攻撃(), p, m) Game.行動実行!(プレイヤーからモンスターへ攻撃) @test p.HP == 100 @test m.HP == 190 モンスターからプレイヤーへ攻撃 = Game.T行動(Game.T通常攻撃(), m, p) Game.行動実行!(モンスターからプレイヤーへ攻撃) @test p.HP == 80 @test m.HP == 190 end @testset "大振り攻撃" begin p = createプレイヤー(HP=100, 攻撃力=10) m = createモンスター(HP=200, 攻撃力=20) プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:大振り), p, m) Game.行動実行!(プレイヤーからモンスターへ攻撃) @test p.HP == 100 @test m.HP == 180 || m.HP == 200 モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:大振り), m, p) Game.行動実行!(モンスターからプレイヤーへ攻撃) @test p.HP == 100 || p.HP == 60 @test m.HP == 180 || m.HP == 200 end @testset "連続攻撃" begin プレイヤーHP = 100 プレイヤー攻撃力 = 10 p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力) モンスターHP = 200 モンスター攻撃力 = 20 m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力) プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), p, m) Game.行動実行!(プレイヤーからモンスターへ攻撃) @test p.HP == プレイヤーHP プレイヤー与ダメージ = round(Int, プレイヤー攻撃力/2) @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), m, p) Game.行動実行!(モンスターからプレイヤーへ攻撃) モンスター与ダメージ = round(Int, モンスター攻撃力/2) @test プレイヤーHP - モンスター与ダメージ * 5 ≤ p.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 end @testset "かばう" begin @testset "かばう実行データチェック" begin 太郎 = createプレイヤー(名前="太郎", HP=100) 花子 = createプレイヤー(名前="花子", HP=100) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) @test 太郎.かばっているキャラクター == 花子 @test 花子.かばってくれているキャラクター == 太郎 end @testset "かばう解除データチェック" begin 太郎 = createプレイヤー(名前="太郎", HP=100) 花子 = createプレイヤー(名前="花子", HP=100) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) Game.行動前処理!(太郎, [花子], []) #「かばう」が解除される @test isnothing(太郎.かばっているキャラクター) @test isnothing(花子.かばってくれているキャラクター) end @testset "通常攻撃" begin 太郎 = createプレイヤー(名前="太郎", HP=100) 花子 = createプレイヤー(名前="花子", HP=100) ドラゴン = createモンスター(HP=200, 攻撃力=20) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 100 @test 太郎.HP == 80 Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 80 @test 太郎.HP == 80 end @testset "連続攻撃" begin プレイヤーHP = 100 太郎 = createプレイヤー(名前="太郎", HP=プレイヤーHP) 花子 = createプレイヤー(名前="花子", HP=プレイヤーHP) モンスター攻撃力 = 20 ドラゴン = createモンスター(攻撃力=モンスター攻撃力) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) ドラゴンから花子へ連続攻撃 = Game.T行動(Game.createスキル(:連続攻撃), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ連続攻撃) @test 花子.HP == プレイヤーHP モンスター与ダメージ = round(Int, モンスター攻撃力/2) @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される #@test 太郎.行動前処理イベントリスナーs == [] #@test 太郎.戦闘不能イベントリスナーs == [] Game.行動実行!(ドラゴンから花子へ連続攻撃) @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 花子.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 end @testset "花子を太郎がかばい、太郎を遠藤君がかばっているとき、太郎がダメージを受ける" begin 太郎 = createプレイヤー(名前="太郎", HP=100) 花子 = createプレイヤー(名前="花子", HP=100) 遠藤君 = createプレイヤー(名前="遠藤君", HP=100) ドラゴン = createモンスター(HP=200, 攻撃力=20) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) 遠藤君が太郎をかばう = Game.T行動(Game.createスキル(:かばう), 遠藤君, 太郎) Game.行動実行!(遠藤君が太郎をかばう) ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 100 @test 太郎.HP == 80 @test 遠藤君.HP == 100 end @testset "戦闘不能になったらかばう解除" begin 太郎 = createプレイヤー(名前="太郎", HP=30) 花子 = createプレイヤー(名前="花子", HP=100) ドラゴン = createモンスター(HP=200, 攻撃力=20) 太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子) Game.行動実行!(太郎が花子をかばう) @test 花子.かばってくれているキャラクター == 太郎 ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 100 @test 太郎.HP == 10 @test 花子.かばってくれているキャラクター == 太郎 ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 100 @test 太郎.HP == 0 ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子) Game.行動実行!(ドラゴンから花子へ攻撃) @test 花子.HP == 80 @test 太郎.HP == 0 end end end @testset "is戦闘終了" begin @testset begin @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=1)]) == false @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0)]) == true @test Game.is戦闘終了([createプレイヤー(HP=0)], [createモンスター(HP=1)]) == true @test Game.is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=1)], [createモンスター(HP=1)]) == false @test Game.is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=0)], [createモンスター(HP=1)]) == true @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=1)]) == false @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=0)]) == true end end function is全て相異なる(配列) if length(配列) == 1 return true elseif length(配列) == 2 return 配列[1] != 配列[2] else 先頭 = 配列[1] 残り = 配列[2:end] return !(先頭 in 残り) && is全て相異なる(残り) end end @testset "is全て相異なる" begin #要素数1 @test is全て相異なる([1]) == true #要素数2 @test is全て相異なる([1, 2]) == true @test is全て相異なる([1, 1]) == false #要素数3 @test is全て相異なる([1, 1, 1]) == false @test is全て相異なる([1, 1, 2]) == false @test is全て相異なる([1, 2, 1]) == false @test is全て相異なる([2, 1, 1]) == false @test is全て相異なる([1, 2, 3]) == true @test is全て相異なる([2, 1, 3]) == true @test is全て相異なる([3, 2, 1]) == true end @testset "行動順決定" begin p1 = createプレイヤー() m1 = createモンスター() @testset "1vs1" begin 行動順 = Game.行動順決定([p1], [m1]) @test length(行動順) == 2 end p2 = createプレイヤー() @testset "2vs1" begin 行動順 = Game.行動順決定([p1, p2], [m1]) @test length(行動順) == 3 end m2 = createモンスター() @testset "1vs2" begin 行動順 = Game.行動順決定([p1], [m1, m2]) @test length(行動順) == 3 end @testset "2vs2" begin 行動順 = Game.行動順決定([p1, p2], [m1, m2]) @test length(行動順) == 4 end end @testset "is戦闘終了" begin @testset "1vs1 両者生存" begin p = createプレイヤー(HP=1) m = createモンスター(HP=1) @test Game.is戦闘終了([p], [m]) == false end @testset "1vs1 プレイヤー死亡" begin p = createプレイヤー(HP=0) m = createモンスター(HP=1) @test Game.is戦闘終了([p], [m]) == true end end @testset "is行動可能" begin p = createプレイヤー(HP=1) @test Game.is行動可能(p) == true p = createプレイヤー(HP=0) @test Game.is行動可能(p) == false m = createモンスター(HP=1) @test Game.is行動可能(m) == true m = createモンスター(HP=0) @test Game.is行動可能(m) == false end @testset "行動可能な奴ら" begin p1 = createプレイヤー(HP=1) @test Game.行動可能な奴ら([p1]) == [p1] p2 = createプレイヤー(HP=0) @test Game.行動可能な奴ら([p1, p2]) == [p1] p3 = createプレイヤー(HP=1) @test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3] m1 = createモンスター(HP=1) @test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1] m2 = createモンスター(HP=0) @test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1] m3 = createモンスター(HP=1) @test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3] end @testset "戦況表示" begin モンスター = Game.Tモンスター("ドラゴン", 400, 80, 40, 10, []) プレイヤー1 = Game.Tプレイヤー("太郎", 100, 20, 10, 10, []) プレイヤー2 = Game.Tプレイヤー("花子", 100, 20, 10, 10, []) プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 20, 10, 10, []) プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 20, 10, 10, []) プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4] モンスターs = [モンスター] @test Game.戦況表示(プレイヤーs, モンスターs) == """ *****プレイヤー***** 太郎 HP:100 MP:20 花子 HP:100 MP:20 遠藤君 HP:100 MP:20 高橋先生 HP:100 MP:20 *****モンスター***** ドラゴン HP:400 MP:80 ********************""" end