「Julia言語で入門するプログラミング」第5回である。未読の方は第1回〜第4回を読んで欲しい。
一覧はこちら
当面の目標
キャラクターが複数になり、仕様が大幅に変わったので、実装を大きく変更する必要がある。今回は大変な改造になりそうだ。ふんどしを締めてかかる必要がある。
まずは味方キャラクターを4人、敵キャラクターをドラゴンにしてみよう。当面の目標は、この状態で今までのように動くことにする。つまり下記のような状態を目指す。
- 味方キャラクターには行動の指示を与えることができる(通常攻撃か大振り)
- 敵キャラクターは通常攻撃固定
- 敵か味方が全滅したら戦闘終了
キャラクターごとの個性を出すのはその後だ。
ふんどしは締めただろうか?では始めよう。
改造
味方も敵も初期生成した後は配列に入れることにしよう。いったん、敵モンスターは1体だけだが、今後複数にする予定なので今のうちに配列にしている。こんなふうになる。
function main()
モンスター = Tモンスター("ドラゴン", 400, 40, 10)
プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10)
プレイヤー2 = Tプレイヤー("花子", 100, 10, 10)
プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10)
プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10)
println("モンスターに遭遇した!")
println("戦闘開始!")
ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])
if モンスター.HP == 0
println("戦闘に勝利した!")
else
println("戦闘に敗北した・・・")
end
end
もちろん、ゲームループ
関数は影響を受ける。次のように引数に配列を受け取る。複数形の意味を込めてプレイヤーs
、モンスターs
とした。プレイヤーとモンスターが複数になったことで、行動順決定
関数や行動の選択方式、戦闘終了条件防御者.HP == 0
を変える必要がある。
変更前はこのような形だ。
#変更前
function ゲームループ(プレイヤー, モンスター)
while true
for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
攻撃者, 防御者 = 攻防
行動実行!(攻撃者, 防御者)
if 防御者.HP == 0
return
end
end
end
end
これがこのように変わる予定だ。
#変更後
function ゲームループ(プレイヤーs, モンスターs)
while true
for 行動者 in 行動順決定(プレイヤーs, モンスターs)
if is行動可能(行動者)
行動 = 行動決定(行動者, プレイヤーs, モンスターs)
行動実行!(行動)
if is戦闘終了(プレイヤーs, モンスターs)
return
end
end
end
end
end
まず、行動順の決定に関しては、これまでは乱数の値に従って、プレイヤー先攻とモンスター先攻が決まり、自動的に攻撃側と防御側の組み合わせも決まった。今回からキャラクターが複数になるので変更が入る。行動するキャラクターは敵味方全体を完全にシャッフルする形で決め、その後キャラクターの行動次第で攻撃対象が決まるようにしよう。
行動順決定関数
行動順決定
関数は、元々はプレイヤーとモンスターをとり、乱数の結果に応じて行動順の配列を変える関数だった。今回の変更も基本的な考え方は変わらない。プレイヤーの配列とモンスターの配列を引数に取り、シャッフルして1つの配列にして返す。ただし、乱数を引数にとるのはやめにする。
これもテスト駆動で作ってみよう。最初のテストはこんな感じだ。一対一の時には、どちらが先攻かはわからないが、とにかく要素が2つの配列になることは確かだろう。
@testset "行動順決定" begin
function createプレイヤー()
return Game.Tプレイヤー("", 0, 0, 0)
end
function createモンスター()
return Game.Tモンスター("", 0, 0, 0)
end
p1 = createプレイヤー()
m1 = createモンスター()
@testset "1vs1" begin
行動順 = Game.行動順決定([p1], [m1])
@test length(行動順) == 2
end
end
このテストを通す、最もシンプルな実装はこんなところだろう。相変わらず最初は恐ろしく適当だ。
function 行動順決定(プレイヤーs, モンスターs)
return [0, 0]
end
テストケースを増やそう。次のケースを追加する。もちろん失敗する。
p2 = createプレイヤー()
@testset "2vs1" begin
行動順 = Game.行動順決定([p1, p2], [m1])
@test length(行動順) == 3
end
関数を次のように手直ししよう。append!
というのは、第一引数の配列の末尾に第二引数の配列の「中身」を追加する関数だ。push!
は、第一引数の配列の末尾に第二引数「そのもの」を追加するので少し違う。これでテストは通る。
function 行動順決定(プレイヤーs, モンスターs)
行動順 = []
append!(行動順, プレイヤーs)
push!(行動順, 0)
return 行動順
end
さらに次のケースを追加する。このケースも失敗する。
m2 = createモンスター()
@testset "1vs2" begin
行動順 = Game.行動順決定([p1], [m1, m2])
@test length(行動順) == 3
end
モンスター側もプレイヤー側と同じくappend!
で配列を追加するようにしよう。テストが通るはずだ。
function 行動順決定(プレイヤーs, モンスターs)
行動順 = []
append!(行動順, プレイヤーs)
append!(行動順, モンスターs) #変更
return 行動順
end
ここまでappend!
を使ってきたが、複数の配列を連結させるだけであれば、次のようにした方が意図が明確になる。
function 行動順決定(プレイヤーs, モンスターs)
行動順 = vcat(プレイヤーs, モンスターs) #[プレイヤーs; モンスターs]と書いてもいい
return 行動順
end
最後にダメ押しの二対二のテストケースだが、このケースは通る。
@testset "2vs2" begin
行動順 = Game.行動順決定([p1, p2], [m1, m2])
@test length(行動順) == 4
end
さて、ここまでは全くシャッフルをしていないので、ここからはシャッフルする処理を入れる必要がある。
Juliaには配列をランダムにシャッフルしてくれるshuffleという関数があるのでそれを使おう。
using Random
...
function 行動順決定(プレイヤーs, モンスターs)
行動順 = vcat(プレイヤーs, モンスターs)
return shuffle(行動順)
end
ただし、少し問題がある。シャッフルの結果は自動テストで結果を期待することはできない。渡す配列はきれいな並びにしておいて、シャッフル後にぐちゃぐちゃになっていることを期待値にする、というのもあまり良くはない。シャッフルの結果、元の配列と全く同じになる可能性も、僅かながらあるからだ。自動テストは何百回、何千回も動かされる可能性があるので、「大体うまくいくんですけどね」というのはイマイチだ。テストが失敗した都度、ただの偶然なのか追及すべき不具合なのかを切り分けるのは大変だ。だから、シャッフルされたかどうかは目視で確認するしかない。
ただ、せめてもの追加ケースとして、結果の行動順が同一の要素を含まれていないことを確認するケースは入れておきたい。何かの実装ミスで、太郎と花子とドラゴンの行動順をシャッフルした結果がドラゴン、太郎、ドラゴン、のようにはならないことを確認したい。これはちょっと重たいトピックとなるので、後ほど実装するようにしよう。
さて、行動順決定関数を変更したのでメインロジックも変えておこう。
function ゲームループ(プレイヤー, モンスター) #変更
while true
for 行動者 in 行動順決定(プレイヤーs, モンスターs) #変更
攻撃者, 防御者 = 攻防
行動実行!(攻撃者, 防御者)
if 防御者.HP == 0
return
end
end
end
end
今の時点では中途半端な修正なので動かない。行動者が行動を決定する際に、行動の内容と対象者を選べるようにしたい。これは今は行動実行
関数で行っているが、これは既に決まっている防御者を受け取るインターフェースになっている。全面的に見直す必要がある。(まあ言うほど大した内容ではないが)
行動決定関数
行動者が決まったら、どんな行動を行うか決定する必要がある。そこで、行動決定
という関数を作ろうと思う。行動決定
関数は「行動」を返り値にする。ここで「行動」とはどのようなイメージになるだろうか。「誰か」が「誰か」に「何か」をするのが行動だ。例えば、「太郎」が「ドラゴン」に「大振り攻撃」をしたり、「高橋先生」が「花子」を「回復」したりするのだ。これを表現するためのデータ構造を作ろう。
struct T行動
コマンド
行動者
対象者
end
コマンド
フィールドは、画面から入力された”1″とか”2″とかになる。そのうち多彩な技が出てくるにつれてコマンド
自体も複雑な構造体になっていくと思うが、今はそれだけのデータだ。
行動決定
関数はT行動
構造体を返す。既存の処理を切りはりして作ったもので、次のようになる。画面入力が絡むので自動テストを作れないのが残念だ。
プレイヤーの時には、コマンドは選択するがモンスターはドラゴン1体だけなので、対象はモンスターs[1]
固定だ。
モンスターの時には、コマンドは”1″固定だが、対象のプレイヤーはランダムに決まるようにしている。rand
関数の引数に配列を与えると、ランダムにどれかの要素を抽出してくれる。
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
println("$(行動者.名前)のターン")
コマンド = コマンド選択()
return T行動(コマンド, 行動者, モンスターs[1])
end
function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
return T行動("1", 行動者, rand(プレイヤーs))
end
決定された行動を実行する関数も作ろう。今は攻撃系の行動しかないので、攻撃実行!
関数にそのまま流す。
function 行動実行!(行動)
攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
end
この関数はテスト可能だ。テストコードを作っておこう。
#julia_test.jl
@testset "行動実行!" begin
function createプレイヤーHP100攻撃力10()
return Game.Tプレイヤー("", 100, 10, 10)
end
function createモンスターHP200攻撃力20()
return Game.Tモンスター("", 200, 20, 10)
end
@testset "通常攻撃" begin
p = createプレイヤーHP100攻撃力10()
m = createモンスターHP200攻撃力20()
プレイヤーからモンスターへ攻撃 = Game.T行動("1", p, m)
Game.行動実行!(プレイヤーからモンスターへ攻撃)
@test p.HP == 100
@test m.HP == 190
モンスターからプレイヤーへ攻撃 = Game.T行動("1", m, p)
Game.行動実行!(モンスターからプレイヤーへ攻撃)
@test p.HP == 80
@test m.HP == 190
end
@testset "大振り攻撃" begin
p = createプレイヤーHP100攻撃力10()
m = createモンスターHP200攻撃力20()
プレイヤーからモンスターへ攻撃 = Game.T行動("2", p, m)
Game.行動実行!(プレイヤーからモンスターへ攻撃)
@test p.HP == 100
@test m.HP == 180 || m.HP == 200
モンスターからプレイヤーへ攻撃 = Game.T行動("2", m, p)
Game.行動実行!(モンスターからプレイヤーへ攻撃)
@test p.HP == 100 || p.HP == 60
@test m.HP == 180 || m.HP == 200
end
end
次のようにメインロジックへ組み込もう。まだ動かないが、後一息だ。
function ゲームループ(プレイヤー, モンスター)
while true
for 行動者 in 行動順決定(プレイヤーs, モンスターs)
行動 = 行動決定(行動者, プレイヤーs, モンスターs) #変更
行動実行!(行動) #変更
if 防御者.HP == 0
return
end
end
end
end
戦闘終了条件
戦闘終了条件を変更しよう。今は防御者.HP == 0
となっている部分だ。プレイヤーs
とモンスターs
のどちらかが全滅したら終了だ。これを判定する関数is戦闘終了
を書こう。
やりたいのは、プレイヤーs
またはモンスターs
のいずれかの配列の全ての要素のHPが0になっていることだ。テストコードは下記のようになる。
@testset "is戦闘終了" begin
function createプレイヤーHP0()
return Game.Tプレイヤー("", 0, 0, 0)
end
function createプレイヤーHP1()
return Game.Tプレイヤー("", 1, 0, 0)
end
function createモンスターHP0()
return Game.Tモンスター("", 0, 0, 0)
end
function createモンスターHP1()
return Game.Tモンスター("", 1, 0, 0)
end
@testset begin
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP1()]) == false
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0()]) == true
@test Game.is戦闘終了([createプレイヤーHP0()], [createモンスターHP1()]) == true
@test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP1()], [createモンスターHP1()]) == false
@test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP0()], [createモンスターHP1()]) == true
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP1()]) == false
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP0()]) == true
end
end
今回はそれよりも実装に興味がある。例えば、プレイヤーs
配列の要素が全てHP=0である時にtrueを返す関数を作るにはどうすればいいだろうか?
例えばこのように書けるだろう。
function is全滅(プレイヤーs)
for p in プレイヤーs
if p.HP != 0
return false
end
end
return true
end
悪くはない。しかし、ちょっとかっこいいテクニックがあるのだ。
内包表記
内包表記というのは最初に出会うと戸惑うかもしれないが、使い慣れると病みつきになる構文だ。超メジャーというほどではないが、HaskellやPythonなどの言語にも登場する便利な機能だ。それらの言語ではリスト内包表記と呼ばれている。知っておいて損はない。内包表記とは配列から配列への変換を行う際の簡潔な表記を提供する。
発想はこうだ。「うーん、このプレイヤーs = [太郎, 花子, 遠藤君, 高橋先生]
という配列から、HPが0かどうかの判定をして、結果を[true, true, false, true]
みたいに取得できないかなあ」という感じだ。
具体的には次のように書く。
[p.HP == 0 for p in プレイヤーs]
これは後ろから読むと理解しやすい。すなわち、「プレイヤーs
の要素をp
と名付け、p.HP == 0
という式を適用した結果の配列を作りなさい」と読む。
そうすると、プレイヤーs
の要素のHP値に応じて、true/falseが定まる。あとは、Juliaにはall
という関数があり、これは配列が全てtrueの時のみtrueとなる関数である。
julia> all([true, true])
true
julia> all([true, false])
false
結果、内包表記を使うと次のようにシンプルに表現することができる。なお、プレイヤーにもモンスターにも適用できるように、仮引数の名前はキャラクターs
に変更した。
function is全滅(キャラクターs)
return all([p.HP == 0 for p in キャラクターs])
end
戦闘終了条件は、プレイヤーs
とモンスターs
のどちらかが全滅することなので、次のように書ける。
function is戦闘終了(プレイヤーs, モンスターs)
return is全滅(プレイヤーs) || is全滅(モンスターs)
end
この関数をメインロジックの戦闘終了条件として使おう。
function ゲームループ(プレイヤーs, モンスターs)
while true
for 行動者 in 行動順決定(プレイヤーs, モンスターs)
if is行動可能(行動者)
行動 = 行動決定(行動者, プレイヤーs, モンスターs)
行動実行!(行動)
if is戦闘終了(プレイヤーs, モンスターs) #変更
return
end
end
end
end
end
内包表記は特に新しい何かを提供するわけではない。ただ簡潔に記述できるという点だけがメリットだ。そして、簡潔に記述できるというのはとても大切なことなのだ。
仕上げ
これで動く形になった。しかし、これにはまだバグがある。動かしてみるとわかるが、HPを0にされたキャラクターが平気で動いているのだ。これは生命に対する冒涜だ。行動可能な時にだけ行動できるようにしよう。
#game_test.jl
@testset "is行動可能" begin
p = createプレイヤーHP1()
@test Game.is行動可能(p) == true
p = createプレイヤーHP0()
@test Game.is行動可能(p) == false
m = createモンスターHP1()
@test Game.is行動可能(m) == true
m = createモンスターHP0()
@test Game.is行動可能(m) == false
end
#game.jl
function is行動可能(キャラクター)
return キャラクター.HP != 0
end
これを使って、次のように条件分岐を入れると出来上がりだ。
#game.jl
function ゲームループ(プレイヤーs, モンスターs)
while true
for 行動者 in 行動順決定(プレイヤーs, モンスターs)
if is行動可能(行動者) #追加
行動 = 行動決定(行動者, プレイヤーs, モンスターs)
行動実行!(行動)
if is戦闘終了(プレイヤーs, モンスターs)
return
end
end
end
end
end
もう一つ変更が必要なのが、モンスターが攻撃対象のプレイヤーを選ぶとき、HP=0のキャラクターを対象にする可能性があるところだ。
function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
return T行動("1", 行動者, rand(プレイヤーs)) #全てのプレイヤーを対象にしている
end
これを避けるために、行動可能な奴ら
という関数を作ろう。これは受け取ったキャラクターの配列から、HPが0でないキャラクターだけを抽出する関数だ。
テストコードはこうなる。
#game_test.jl
@testset "行動可能な奴ら" begin
p1 = createプレイヤーHP1()
@test Game.行動可能な奴ら([p1]) == [p1]
p2 = createプレイヤーHP0()
@test Game.行動可能な奴ら([p1, p2]) == [p1]
p3 = createプレイヤーHP1()
@test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]
m1 = createモンスターHP1()
@test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
m2 = createモンスターHP0()
@test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
m3 = createモンスターHP1()
@test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end
実装はこのようになる。
#game.jl
function 行動可能な奴ら(キャラクターs)
return [c for c in キャラクターs if is行動可能(c)]
end
if
というキーワードが内包表記中に出てきた。ifの条件を満たした要素のみを対象に抽出するという作用をする。そのため、elseなどは存在しない。
これを使うと、次のように修正される。
function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
return T行動("1", 行動者, rand(行動可能な奴ら(プレイヤーs)))
end
練習問題
配列lst = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
を考える。
- 問題1
lst
の各要素を2乗した配列[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
を内包表記で生成しよう。
- 問題2
lst
の各要素のうち、偶数の要素のみ残して2乗した配列[4, 16, 36, 64, 100]
を内包表記で生成しよう。
- 問題3
lst
の各要素のうち、偶数の要素を2乗し、奇数の要素はそのままにした配列[1, 4, 3, 16, 5, 36, 7, 64, 9, 100]
を内包表記で生成しよう。
戦況の可視化
さて、動かしてみるとバグはないのだが、いまいち困るとこがある。今の戦況がわかりづらいのだ。今誰がどのくらいの残HPなのか見えづらい。これを改善しよう。
こんな関数を作る。最終的に画面に表示する情報ではあるが、いつものprintln文ではなく文字列を返すようにした。これで自動テストが可能になるのと、println文があまり色々な場所に散らばっているとわかりづらいためだ。
function 戦況表示(プレイヤーs, モンスターs)
結果 = []
push!(結果, "*****プレイヤー*****")
for p in プレイヤーs
push!(結果, "$(p.名前) HP:$(p.HP)")
end
push!(結果, "*****モンスター*****")
for m in モンスターs
push!(結果, "$(m.名前) HP:$(m.HP)")
end
push!(結果, "********************")
return join(結果, "\n")
end
まあそんなに見るべきところはないが、join
という関数は知っておいた方がいいだろう。配列に入れた文字列を、何かの区切り文字を連結したい時に使う。今回は改行コードで区切っている。Juliaに限らずいろんな言語で提供されている関数だ。これを使わずにループ処理でやろうとすると、一見簡単に思えて先頭の要素か末尾の要素でごちゃっとした処理を書く必要があり、ダサいのだ。ちなみにJuliaのjoin関数は、最後の区切り要素だけ特別に指定することもできる。これはあまり他の言語では見たことがないが、英語では例を列挙する時にA, B and Cのように、最後だけandにしたりするためらしい。
この処理は文字列を返すようにしているので、自動テスト可能だ。
@testset "戦況表示" begin
モンスター = Game.Tモンスター("ドラゴン", 400, 40, 10)
プレイヤー1 = Game.Tプレイヤー("太郎", 100, 10, 10)
プレイヤー2 = Game.Tプレイヤー("花子", 100, 10, 10)
プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 10, 10)
プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 10, 10)
プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
モンスターs = [モンスター]
@test Game.戦況表示(プレイヤーs, モンスターs) ==
"""
*****プレイヤー*****
太郎 HP:100
花子 HP:100
遠藤君 HP:100
高橋先生 HP:100
*****モンスター*****
ドラゴン HP:400
********************"""
end
期待値の部分で「ヒアドキュメント」と呼ばれる特別な文字列の作り方をしている。"""
とダブルクォーテーション3つで囲まれた文字列だ。ヒアドキュメントもいくつかの言語で採用されている。このように改行を含んだ文字列を取り扱いやすくするためのものだ。言語によって微妙な差異のある機能だが、インデントを賢く取り扱ってくれたりする。普通にダブルクォーテーションで囲むだけだと、先頭のインデントの空白部分も文字列に含めてしまう。ヒアドキュメントにすると、1行目のインデントに合わせて2行目以降の空白を調整してくれるのだ。
これを使って、行動決定
関数で表示するようにしよう。
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
println(戦況表示(プレイヤーs, モンスターs))
println("$(行動者.名前)のターン")
コマンド = コマンド選択()
return T行動(コマンド, 行動者, モンスターs[1])
end
こんなふうになる。
*****プレイヤー*****
太郎 HP:60
花子 HP:100
遠藤君 HP:100
高橋先生 HP:100
*****モンスター*****
ドラゴン HP:330
********************
太郎のターン
[1]攻撃[2]大振り: 1
プレイヤー側とモンスター側のHP表示がされて、状況がわかりやすくなった。
スキルを充実させよう
突然自己啓発本のようなフレーズだが、貴方のスキルの話ではない。ゲームのキャラクターの話だ。貴方のスキルは私の記事を読むことでバリバリに伸びているので心配する必要はない。
今は通常攻撃と大振りしかないが、もっと色々増やしたいところだ。
- 太郎
- 連続攻撃
- 2〜5回の連続攻撃を行う。1回当たりのダメージは半減する。
- かばう
- 一定期間、指定した相手が受ける攻撃を代わりに受ける。
- ヒール
- HPを回復する。
- 刃に毒を塗る
- 攻撃がヒットしたら一定確率で毒を与えることができるようになる。
- 連続攻撃
- 花子
- ファイア
- 敵に炎属性の攻撃。
- アイス
- 敵に氷属性の攻撃。
- ドレイン
- 敵のHPを吸収する。
- 集中
- 魔法攻撃の威力を上昇させる。
- ファイア
- 遠藤君
- 大振り
- 命中率は低いが通常の2倍の威力の攻撃を行う。
- かばう
- 一定期間、指定した相手が受ける攻撃を代わりに受ける。
- 捨て身
- 自分のHPと引き換えに相手に大ダメージを与える
- 大振り
- 高橋先生
- ヒール
- HPを回復する。
- バリア
- 指定した相手が受けるダメージを軽減させる。
- 濃霧
- 一定期間敵味方全ての攻撃の命中率を下げる。
- 金縛り
- 指定した相手を一定期間行動不能にする。
- ヒール
これは大変そうだ。単純に攻撃の威力を上げるようなものは少なくて、ちょっとずつ実装の工夫が必要になりそうなものが多い。ドラゴンの討伐に有効そうで、さらに実装の負担の大きそうなものを頑張って考えてみたのだ。まあ、単純な実装のスキルを量産しても面白くないだろう。このように課題の設定の自由度が高いのもゲームプログラムを題材に選んだ理由の一つだ。果たしてこれらをきれいに実装できるだろうかという点は一抹の不安を覚えないでもないが、きっとなんとかなるだろう。
これらのスキルは順番に実装していくつもりだが、その前にやり残した課題をやっておこう。行動順のシャッフルをした時に、重複が存在しないことの確認のテストケースだ。
再帰
今から作るのは、「与えられた配列の要素が全て異なればtrue、そうでなければfalseを返す関数」だ。関数名はis全て相異なる
にしよう。実は同じことをしてくれる関数がJuliaには標準で用意されているのだが、それとは別に実装してみよう。標準ライブラリにある関数を無視してあえて自作するというのは、批判の多い行為だ。「車輪の再発明をするな」というやつだ。ただ、これは仕事や研究で使う製品レベルのコードならその通りだが、今みたいな趣味や練習の場ではあまり気にしなくていい。どんどん再発明してしまおう。そして標準ライブラリのコードを比較してみよう。きっと、思わぬ発見があるはずだ。Juliaの標準ライブラリのコードは公式サイトの「Documentation」から確認できる。使用例のところに「source」というボタンがあるので押すとGitHubのページに飛ぶのだ。驚くほど親切だ。
今から2通りのやり方でこれを実装する。1つ目は普通のループ処理で、2つ目はこのセクションのタイトルになっている「再帰」処理である。
ループで書いてみよう
まずはループを使って書いてみよう。次の処理が、もっともシンプルなテストケースとその実装だ。要素が1つなら重複はないのでtrue
になる。
@testset "is全て相異なる" begin
@test is全て相異なる([1]) == true
end
function is全て相異なる(行動順)
return true
end
次に、要素数が2つのケースを追加する。
@testset "is全て相異なる" begin
...
@test is全て相異なる([1, 2]) == true
@test is全て相異なる([1, 1]) == false
end
最もシンプルな実装というとこんな感じだろうか。
function is全て相異なる(配列)
if length(配列) == 1
return true
else
return 配列[1] != 配列[2]
end
end
要素数が3つのケースはどうだろうか。テストケースはこんな感じだ。
@testset "is全て相異なる" begin
...
#要素数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
実装はどうだろうか。
- 1つ目の要素に対して考えると、2つ目の要素と3つ目の要素が1つ目の要素と異なる必要がある。
- 2つ目の要素に対して考えると、1つ目の要素と3つ目の要素が2つ目の要素と異なる必要がある。ただし、2つ目の要素と1つ目の要素の比較は1でやっているので、3つ目の要素とだけ比較すれば良い。
- 3つ目の要素に対して考えると、1つ目の要素と2つ目の要素が3つ目の要素と異なる必要がある。ただし、2つ目の要素と1つ目の要素の比較は1と2でやっているので、特に何もする必要はない。
そう考えて一般化すると、「i番目の要素が、i+1番目以降の要素に含まれないこと」を配列の全要素について確認できればいいことになる。こんな実装になるだろう。
function is全て相異なる(配列)
if length(配列) == 1
return true
elseif length(配列) == 2
return 配列[1] != 配列[2]
else
for i in 1:length(配列)
着目要素 = 配列[i]
残り = 配列[i+1:end]
if 着目要素 in 残り
return false
end
end
return true
end
end
これでテストは通る。ところで、要素が1つのケースと2つのケースでの分岐はいるのだろうか?外してみよう。
function is全て相異なる(配列)
for i in 1:length(配列)
着目要素 = 配列[i]
残り = 配列[i+1:end]
if 着目要素 in 残り
return false
end
end
return true
end
これでもテストは通る。
結局、上のケースが最もシンプルな実装と言えそうだ。
再帰処理で書いてみよう
次は再帰処理である。再帰処理とは、自分自身を呼び出す処理のことである。これは実物を見てもらった方が速いだろう。
要素数1のケースと2のケースまでの進め方は同じである。要素数が2までのケースのみ考慮した実装は次のようになる。
function is全て相異なる(配列)
if length(配列) == 1
return true
else
return 配列[1] != 配列[2]
end
end
要素数3のケースを考える時の考え方がガラッと異なる。再帰処理を使う場合にはこう考えるのである。
配列を先頭の要素と残りの要素に分けて考えてみる。先頭の要素が残りの要素に含まれないこと、これが1つ目の条件だ。そして、残りの要素について、is全て相異なる
が満たされること、これが2つ目の条件だ。この2つが満たされたら、結局すべての要素について、is全て相異なる
と言える。
この考え方を表現したのが次のコードだ。is全て相異なる
という処理の定義の中で、自分自身を呼び出している。このような処理を再帰処理と呼ぶ。
function is全て相異なる(配列)
if length(配列) == 1
return true
elseif length(配列) == 2
return 配列[1] != 配列[2]
else
先頭 = 配列[1]
残り = 配列[2:end]
return !(先頭 in 残り) && is全て相異なる(残り)
end
end
このコードは典型的な再帰処理の例になっている。問題のサイズが小さく、結果を自明に表現できるケースでは値(結果)を返し、そうでなければ問題のサイズを縮小する。数学的帰納法と似たようなイメージである。(ただし、length(配列) == 1
のケースと、length(配列) == 2
のケースの意味の違いには注意!前者は要素数1の配列が入ってきた時の結果を定義しているだけだ。後者は要素数2の配列の結果を定義しているだけでなく、要素数3以上のケースで再帰処理が到達する到達点になっている。要素数1以外のケースから要素数1の結果に到達することはない。)
ループと再帰の比較
ループと再帰を見比べると、何となく考え方の違いが見えてくる。実際のところ、今回出したループ処理と再帰処理はやっていることはほとんど同じなのだが、コードの字面として受ける印象が異なるだろう。
- ループで書いた処理は、入力された配列をどうチェックすれば指定された条件が満たされるか、という処理になっている。
- 再帰で書いた処理は、入力された配列がどのような性質を持っていれば指定された条件が満たされるか、という処理になっている。
このような性質の違いを、「手続き的」「宣言的」という言葉で表現しても良い。通常、手続き的な処理よりも宣言的な処理の方がわかりやすい。人間は、動的にグリグリと変化する状況をシミュレートするのがあまり得意ではないからだ。手続的な表現ではまさにそれが求められる。一方、宣言的な表現は、静的な関係の表明なので脳にあまり負荷をかけずに理解できる。
とはいえ、手続的な表現と宣言的な表現に何か明確な境界があるわけでもない。どんな書き方であれ、高級言語である時点でアセンブラに比べれば圧倒的に宣言的だ。x = 1
と宣言するだけで、メモリ上のどこかにx
というシンボルを持つ変数と、1
という値を意味するビット列が確保され、それらの結び付けを行ってくれるのだ。明らかに多くの手続きを、言語処理系が請け負って代行してくれているおかげで、我々は極めて快適にプログラムを書くことができる。プログラミング言語の進化とは手続き型から宣言型への進化とも言えるのだ。
勘違いしてはいけないが、ループで書いたから手続き的、再帰で書いたから宣言的というわけではない。手続的な発想、再帰的な発想がまずあり、それを表現する上で、手続き的な発想をする時にはループ処理で実装する方が親和性が高く、宣言的な発想をする時には再帰処理で実装する方が親和性が高いという話だ。
なお、原理的には、再帰処理とループ処理は互いに書き換え可能である。そもそも関数型と呼ばれるプログラミング言語には、ループという構文自体がないこともある。必要であれば再帰処理で実現可能だからだ。
このように書くと、ループなどいらねえと捨ててしまって、何だって再帰処理で書きたくなってくるかもしれない。しかし、再帰処理には注意点があるのだ。
スタックオーバーフロー
再帰処理とスタックオーバーフローは切っても切り離せない関係だ。ちょっと込み入った話になるが、説明しよう。
スタックというのは、プログラムが管理する記憶領域のうちの1つだ。プログラムの実行中、最終的な結果を出す前に一時的にデータを置くための場所として利用される。ローカル変数の値であるとか、関数呼び出しの際の引数の情報であるとか、そういった情報だ。細かい話はおいておくとして、関数呼び出しするとスタックという領域が確保される(使われる)ということを理解して欲しい。ちなみにスタック領域を確保することを、スタックに積むと表現したりする。
関数呼び出しがあったらスタックが確保されるとしても通常は問題にはならない。関数を抜けるとスタックは解放され、再度利用することができるようになるからだ。そのため、通常、確保されるスタックのサイズはそう大きくはならない。しかし、再帰処理の厄介なところは、その関数の計算が完了するまでスタックを食い潰し続けるというところだ。
is全て相異なる
関数の評価がどうなされるかを見てみよう。
is全て相異なる([1, 2, 3, 4, 5])
#↓
!(1 in [2, 3, 4, 5]) && is全て相異なる([2, 3, 4, 5])
#↓
true && is全て相異なる([2, 3, 4, 5])
#↓
true && (!(2 in [3, 4, 5]) && is全て相異なる([3, 4, 5]))
#↓
true && (true && is全て相異なる([3, 4, 5]))
#↓
true && (true && (!(3 in [4, 5]) && is全て相異なる([4, 5])))
#↓
true && (true && (true && is全て相異なる([4, 5])))
#↓
true && (true && (true && 4 != 5))
#↓
true && (true && (true && true))
#↓
true && (true && true)
#↓
true && true
#↓
true
ポイントは、再帰関数で値を返すことのできるケースに到達するまで、オリジナルの結果が確定しないことだ。そのため、言語処理系は計算の途中で現れた結果(!(1 in [2, 3, 4, 5]) = true
など)をメモリ上のどこかに保持しておく必要がある。これは通常スタックで保持され、再帰呼び出しのたびにその数が増える。そのため要素数が大きくなると、スタックの上限を超えてしまうことがあり得るのだ。スタック上限を超えてしまうことをスタックオーバーフローと呼び、プログラムの実行は強制的に中断される。
下記のループ版のコードであればそのような心配はない。for文の繰り返し回数がどこまで大きくなろうとも、保持すべき変数の数は一定だからだ。
function is全て相異なる(配列)
for i in 1:length(配列)
着目要素 = 配列[i]
残り = 配列[i+1:end]
if 着目要素 in 残り
return false
end
end
return true
end
スタックオーバーフローは再帰関数固有の問題ではない。通常の関数呼び出しでも起こりうる。ただし、そのためには、内部で関数呼び出しを異常な深さで行う必要がある。関数Aの内部でA1を呼び出し、その次にA2を呼び出し、その次にA3を呼び出し、、、というものであれば100万回続いたってスタックオーバーフローは発生しない。それぞれの関数の終わりでスタックは解放されているからだ。単に回数が問題ではない。深さが問題なのだ。関数Aの内部で関数A1を呼び出し、A1の内部でA2を呼び出し、A2の内部でA3を呼び出し、、、という階層である必要があるのだ。これが数万程度のオーダーでなければ発生しないのだ。なかなか起こりそうにないことがわかるだろう。
末尾再帰
再帰関数にはスタックオーバーフローという弱点があることが分かったが、それではループ構文を持たない関数型言語はどう対処しているのだろうか?
ここで登場するのが「末尾再帰」というキーワードだ。
末尾再帰とは、再帰関数での再帰呼び出し処理が、その関数での最後の処理になっていることを言う。次の処理は字面的にはis相異なる(残り)
が最後の処理になっているように見えるが、実際にはこの結果を元にした&&
演算子の処理が残っているので、これは末尾再帰ではない。
function is全て相異なる(配列)
...
return !(先頭 in 残り) && is全て相異なる(残り)
...
end
末尾呼び出しとして書き換えると、次のようになる。
function is全て相異なる(配列)
function iter(配列, 結果)
if length(配列) == 1
return true
elseif length(配列) == 2
return (配列[1] != 配列[2]) && 結果
else
先頭 = 配列[1]
残り = 配列[2:end]
return iter(残り, !(先頭 in 残り) && 結果)
end
end
return iter(配列, true)
end
関数内関数で定義しているが、そこはどうでもよく、重要なのは次の部分である。
function iter(配列, 結果)
...
return iter(残り, !(先頭 in 残り) && 結果)
...
end
iter
という関数が自分自身を呼び出しており、その呼び出し結果を使った演算は存在しない。iterというのは繰り返し処理を意味するiterateの略で、そんなに深い意味があるわけでもない。末尾再帰では演算処理を保持する変数を引数にすることが多いため、関数内関数で実装することが多く、私は末尾再帰形式の時にはよくこの書き方にする。
末尾再帰はなぜスタックを食い潰さないのだろうか?次のイメージを見てほしい。
iter([1, 2, 3, 4, 5], true)
#↓
iter([2, 3, 4, 5], !(1 in [2, 3, 4, 5]) && true)
#↓
iter([2, 3, 4, 5], true && true)
#↓
iter([2, 3, 4, 5], true)
#↓
iter([3, 4, 5], !(2 in [3, 4, 5]) && true)
#↓
iter([3, 4, 5], true && true)
#↓
iter([3, 4, 5], true)
#↓
iter([4, 5], !(3 in [4, 5]) && true)
#↓
iter([4, 5], true && true)
#↓
iter([4, 5], true)
#↓
(4 != 5) && true
#↓
true && true
#↓
true
末尾呼び出し形式にすると、関数呼び出しの後に行う演算が存在しないので、そのために必要な情報をスタックに積む必要がなくなる。そのため、末尾再帰形式にすると、「原理的には」スタックオーバーフローを回避することができる。
ここであえて、原理的には、と強調したのには理由がある。それは、Juliaではこの手法を使ってもスタックオーバーフローを回避できないからだ。
なぜなら末尾再帰にして各再帰処理の結果をスタックに保持しないようにできたとしても、関数呼び出しそのものでスタックを使ってしまうからだ。上の例で言うと、iter
を呼び出すたびに、それが末尾再帰形式であっても、iter
という関数呼び出しの履歴がスタックに積まれ、結局スタックを食い潰してしまうのだ。
ループを排して再帰呼び出し一本で勝負する言語にとって、これは重大な問題となるが、その点は心配ご無用。さらなる工夫が施されている。そのような言語では、末尾再帰の関数呼び出しの時には、内部でループ処理に変換してくれるのだ。ループ処理であればスタックを食い潰す心配はなくなる。この工夫のことを「末尾再帰の最適化」というように呼ぶ。残念ながらJuliaは末尾再帰の最適化はサポートしていない。
結果的にJuliaでは、再帰関数を書く時にスタックオーバーフローを絶対に回避できる方法というものは存在しない。そのため、再帰関数を使う際には、事前に想定される規模の入力でスタックオーバーフローが起きないかを確認しておく必要がある。また、スタックオーバーフローが起きてしまうのであればループ処理として実装する必要がある。
再帰処理はある種の関数を非常に簡潔に記述できる強力な武器だ。是非いろいろな関数を再帰処理で書いてみて欲しい。ここで述べたようなリスクは頭に入れておく必要はあるが、使うべき時には使えるようになっておこう。
ちなみに、このis全て相異なる
関数だが、Juliaではallunique
という関数で提供されている。その実装はというと、なんとループも再帰も使っていないのだ。何を行なっているか是非確認してみよう。「Examples」の枠内にカーソルを持っていくと、右下に「source」ボタンが出てくる。
https://docs.julialang.org/en/v1/base/collections/#Base.allunique
練習問題
次の関数をループ処理、再帰処理、末尾再帰処理で実装しよう。指定されたテストケースを通過すること。なお、配列を引数の取る場合、配列は空ではなく、また、要素が全て数値であることは前提にして良い。
- 与えられた自然数の階乗を求める関数
using Test
@testset "階乗" begin
@test 階乗_ループ(1) == 1
@test 階乗_再帰(1) == 1
@test 階乗_末尾再帰(1) == 1
@test 階乗_ループ(4) == 24
@test 階乗_再帰(4) == 24
@test 階乗_末尾再帰(4) == 24
end
- 与えられた配列の和を求める関数
using Test
@testset "総和" begin
@test 総和_ループ([1]) == 1
@test 総和_再帰([1]) == 1
@test 総和_末尾再帰([1]) == 1
@test 総和_ループ([1, 2, 3, 4, 5]) == 15
@test 総和_再帰([1, 2, 3, 4, 5]) == 15
@test 総和_末尾再帰([1, 2, 3, 4, 5]) == 15
end
- 与えられた配列の最大値を求める関数。2つの数の比較に、Juliaの
max
関数は使用して良い。
using Test
@testset "最大" begin
@test 最大_ループ([1]) == 1
@test 最大_再帰([1]) == 1
@test 最大_末尾再帰([1]) == 1
@test 最大_ループ([2, 1]) == 2
@test 最大_再帰([2, 1]) == 2
@test 最大_末尾再帰([2, 1]) == 2
@test 最大_ループ([2, 1, 3]) == 3
@test 最大_再帰([2, 1, 3]) == 3
@test 最大_末尾再帰([2, 1, 3]) == 3
end
- 与えられた配列の平均値を求める関数。
using Test
@testset "平均" begin
@test isapprox(平均_ループ([1]), 1.0)
@test isapprox(平均_再帰([1]), 1.0)
@test isapprox(平均_末尾再帰([1]), 1.0)
@test isapprox(平均_ループ([2, 1]), 1.5)
@test isapprox(平均_再帰([2, 1]), 1.5)
@test isapprox(平均_末尾再帰([2, 1]), 1.5)
@test isapprox(平均_ループ([2, 1, 3]), 2.0)
@test isapprox(平均_再帰([2, 1, 3]), 2.0)
@test isapprox(平均_末尾再帰([2, 1, 3]), 2.0)
end
第5回の終わりに
前回の終わりにモンスターごとの型をつくったり、型の階層構造について説明すると言いながら、全くその部分は着手できなった。まさかこのタイミングで再帰処理について語ることになるとは思ってもみなかったのだ。私は行き当たりばったりなのだ。本当に申し訳ありません。心よりお詫び申し上げます。メンゴメンゴ。
モンスターごとの型は次回ではおそらく無理だと思う。というか、しばらくはひたすら色々なスキルを実装することに注力することになりそうだ。まあスキルも型を作っていく予定なので、きっと型の階層関係については説明する機会があるだろう。
練習問題の解答
内包表記
配列lst = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
を考える。
- 問題1
lst
の各要素を2乗した配列[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
を内包表記で生成しよう。
- 解答
[e^2 for e in lst]
- 問題2
lst
の各要素のうち、偶数の要素のみ残して2乗した配列[4, 16, 36, 64, 100]
を内包表記で生成しよう。
- 解答
[e^2 for e in lst if e%2 == 0]
- 問題3
lst
の各要素のうち、偶数の要素を2乗し、奇数の要素はそのままにした配列[1, 4, 3, 16, 5, 36, 7, 64, 9, 100]
を内包表記で生成しよう。
- 解答
[if e%2 == 0 e^2 else e end for e in lst]
再帰処理
次の関数をループ処理、再帰処理、末尾再帰処理で実装しよう。配列の要素が全て数値であることは前提にして良い。
次の関数をループ処理、再帰処理、末尾再帰処理で実装しよう。指定されたテストケースを通過すること。なお、配列を引数の取る場合、配列は空ではなく、また、要素が全て数値であることは前提にして良い。
- 与えられた自然数の階乗を求める関数
function 階乗_ループ(n)
s = 1
while (n >= 1)
s *= n
n -= 1
end
return s
end
function 階乗_再帰(n)
if n == 1
return 1
else
return n * 階乗_再帰(n - 1)
end
end
function 階乗_末尾再帰(n)
function iter(n, s)
if n == 1
return s
else
return iter(n - 1, s * n)
end
end
return iter(n, 1)
end
これは再帰処理は配列以外にも適用できることを示したいための問題だった。
- 与えられた配列の和を求める関数
function 総和_ループ(arr)
s = 0
for e in arr
s += e
end
return s
end
function 総和_再帰(arr)
if length(arr) == 1
return arr[1]
else
return arr[1] + 総和_再帰(arr[2:end])
end
end
function 総和_末尾再帰(arr)
function iter(arr, result)
if length(arr) == 1
return result + arr[1]
else
return iter(arr[2:end], result + arr[1])
end
end
return iter(arr, 0)
end
- 与えられた配列の最大値を求める関数。2つの数の比較に、Juliaの
max
関数は使用して良い。
function 最大_ループ(arr)
これまでの最大値 = arr[1]
for e in arr[2:end]
if e > これまでの最大値
これまでの最大値 = max(これまでの最大値, e)
end
end
return これまでの最大値
end
function 最大_再帰(arr)
if length(arr) == 1
return arr[1]
elseif length(arr) == 2
return max(arr[1], arr[2])
else
return max(arr[1], 最大_再帰(arr[2:end]))
end
end
function 最大_末尾再帰(arr)
function iter(arr, これまでの最大値)
if length(arr) == 0
return これまでの最大値
elseif length(arr) == 1
return max(arr[1], これまでの最大値)
else
return iter(arr[2:end], max(arr[1], これまでの最大値))
end
end
return iter(arr[2:end], arr[1])
end
これなどは再帰処理で書くのが最も自然に思える問題だ。ループ処理は、いかにも手続き的な感じが出ている。
- 与えられた配列の平均値を求める関数。
function 平均_ループ(arr)
合計 = 0
要素数 = 0
for e in arr
合計 += e
要素数 += 1
end
return 合計/要素数
end
function 平均_再帰(arr)
function iter(arr, 合計, 要素数)
if length(arr) == 0
return 合計/要素数
else
return iter(arr[2:end], 合計 + arr[1], 要素数 + 1)
end
end
return iter(arr, 0, 0)
end
function 平均_末尾再帰(arr)
function iter(arr, 合計, 要素数)
if length(arr) == 0
return 合計/要素数
else
return iter(arr[2:end], 合計 + arr[1], 要素数 + 1)
end
end
return iter(arr, 0, 0)
end
これは少しいじわる問題だったかも知れない。再帰処理と末尾再帰処理が同じ答えになる。平均値を求める問題というのは、小さな問題に分割できないからだ。与えられた配列を分割してそれぞれの平均値を取る、というようなことをしても、全体の平均値を求める役には立たない。このようなケースでは再帰処理にできたとしても、ループで変化させている値を引数として渡して変化させることにしかならない。それであればループで処理するのが自然である。
続き
ここまでの実装
最後にここまでのコードを載せておこう。game.jlとgame_test.jlがごちゃごちゃしてきている、そろそろファイルを分割するかも知れない。
#game.jl
module Game
using Random
mutable struct Tプレイヤー
名前
HP
攻撃力
防御力
end
mutable struct Tモンスター
名前
HP
攻撃力
防御力
end
struct T行動
コマンド
行動者
対象者
end
function ダメージ計算(攻撃力, 防御力)
return round(Int, 10 * 攻撃力/防御力)
end
function HP減少!(防御者, ダメージ)
if 防御者.HP - ダメージ < 0
防御者.HP = 0
else
防御者.HP = 防御者.HP - ダメージ
end
end
function 攻撃実行!(攻撃者, 防御者, コマンド)
println("----------")
if コマンド == "1"
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
HP減少!(防御者, 防御者ダメージ)
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
elseif コマンド == "2"
println("$(攻撃者.名前)の大振り!")
if rand() < 0.4
防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
HP減少!(防御者, 防御者ダメージ)
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
else
println("攻撃は失敗した・・・")
end
end
end
function is行動可能(キャラクター)
return キャラクター.HP != 0
end
function 行動可能な奴ら(キャラクターs)
return [c for c in キャラクターs if is行動可能(c)]
end
function 行動順決定(プレイヤーs, モンスターs)
行動順 = vcat(プレイヤーs, モンスターs)
return shuffle(行動順)
end
function コマンド選択()
function isValidコマンド(コマンド)
return コマンド in ["1", "2"]
end
while true
コマンド = Base.prompt("[1]攻撃[2]大振り")
if isValidコマンド(コマンド)
return コマンド
else
println("正しいコマンドを入力してください")
end
end
end
function 戦況表示(プレイヤーs, モンスターs)
結果 = []
push!(結果, "*****プレイヤー*****")
for p in プレイヤーs
push!(結果, "$(p.名前) HP:$(p.HP)")
end
push!(結果, "*****モンスター*****")
for m in モンスターs
push!(結果, "$(m.名前) HP:$(m.HP)")
end
push!(結果, "********************")
return join(結果, "\n")
end
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
println(戦況表示(プレイヤーs, モンスターs))
println("$(行動者.名前)のターン")
コマンド = コマンド選択()
return T行動(コマンド, 行動者, モンスターs[1])
end
function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
return T行動("1", 行動者, rand(行動可能な奴ら(プレイヤーs)))
end
function 行動実行!(行動)
攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
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)
while true
for 行動者 in 行動順決定(プレイヤーs, モンスターs)
if is行動可能(行動者)
行動 = 行動決定(行動者, プレイヤーs, モンスターs)
行動実行!(行動)
if is戦闘終了(プレイヤーs, モンスターs)
return
end
end
end
end
end
function main()
モンスター = Tモンスター("ドラゴン", 400, 40, 10)
プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10)
プレイヤー2 = Tプレイヤー("花子", 100, 10, 10)
プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10)
プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10)
println("モンスターに遭遇した!")
println("戦闘開始!")
ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])
if モンスター.HP == 0
println("戦闘に勝利した!")
else
println("戦闘に敗北した・・・")
end
end
end
#game_test.jl
include("game.jl")
using Test
function createキャラクターHP100()
return Game.Tプレイヤー("", 100, 0, 0)
end
function createプレイヤーHP100攻撃力10()
return Game.Tプレイヤー("", 100, 10, 10)
end
function createモンスターHP200攻撃力20()
return Game.Tモンスター("", 200, 20, 10)
end
function createプレイヤーHP0()
return Game.Tプレイヤー("", 0, 0, 0)
end
function createプレイヤーHP1()
return Game.Tプレイヤー("", 1, 0, 0)
end
function createモンスターHP0()
return Game.Tモンスター("", 0, 0, 0)
end
function createモンスターHP1()
return Game.Tモンスター("", 1, 0, 0)
end
function createプレイヤー()
return Game.Tプレイヤー("", 0, 0, 0)
end
function createモンスター()
return Game.Tモンスター("", 0, 0, 0)
end
@testset "HP減少" begin
@testset "ダメージ < HP" begin
c = createキャラクターHP100()
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 97
end
@testset "複数回ダメージ" begin
c = createキャラクターHP100()
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 97
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 94
end
@testset "ダメージ > HP" begin
c = createキャラクターHP100()
Game.HP減少!(c, 101) #101のダメージ
@test c.HP == 0
end
@testset "ダメージ = HP" begin
c = createキャラクターHP100()
Game.HP減少!(c, 100) #100のダメージ
@test c.HP == 0
end
end
@testset "行動実行!" begin
@testset "通常攻撃" begin
p = createプレイヤーHP100攻撃力10()
m = createモンスターHP200攻撃力20()
プレイヤーからモンスターへ攻撃 = Game.T行動("1", p, m)
Game.行動実行!(プレイヤーからモンスターへ攻撃)
@test p.HP == 100
@test m.HP == 190
モンスターからプレイヤーへ攻撃 = Game.T行動("1", m, p)
Game.行動実行!(モンスターからプレイヤーへ攻撃)
@test p.HP == 80
@test m.HP == 190
end
@testset "大振り攻撃" begin
p = createプレイヤーHP100攻撃力10()
m = createモンスターHP200攻撃力20()
プレイヤーからモンスターへ攻撃 = Game.T行動("2", p, m)
Game.行動実行!(プレイヤーからモンスターへ攻撃)
@test p.HP == 100
@test m.HP == 180 || m.HP == 200
モンスターからプレイヤーへ攻撃 = Game.T行動("2", m, p)
Game.行動実行!(モンスターからプレイヤーへ攻撃)
@test p.HP == 100 || p.HP == 60
@test m.HP == 180 || m.HP == 200
end
end
@testset "is戦闘終了" begin
@testset begin
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP1()]) == false
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0()]) == true
@test Game.is戦闘終了([createプレイヤーHP0()], [createモンスターHP1()]) == true
@test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP1()], [createモンスターHP1()]) == false
@test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP0()], [createモンスターHP1()]) == true
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP1()]) == false
@test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP0()]) == 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
function createプレイヤーHP(HP)
return Game.Tプレイヤー("", HP, 0, 0)
end
function createモンスターHP(HP)
return Game.Tモンスター("", HP, 0, 0)
end
@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プレイヤーHP1()
@test Game.is行動可能(p) == true
p = createプレイヤーHP0()
@test Game.is行動可能(p) == false
m = createモンスターHP1()
@test Game.is行動可能(m) == true
m = createモンスターHP0()
@test Game.is行動可能(m) == false
end
@testset "行動可能な奴ら" begin
p1 = createプレイヤーHP1()
@test Game.行動可能な奴ら([p1]) == [p1]
p2 = createプレイヤーHP0()
@test Game.行動可能な奴ら([p1, p2]) == [p1]
p3 = createプレイヤーHP1()
@test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]
m1 = createモンスターHP1()
@test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
m2 = createモンスターHP0()
@test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
m3 = createモンスターHP1()
@test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end
@testset "戦況表示" begin
モンスター = Game.Tモンスター("ドラゴン", 400, 40, 10)
プレイヤー1 = Game.Tプレイヤー("太郎", 100, 10, 10)
プレイヤー2 = Game.Tプレイヤー("花子", 100, 10, 10)
プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 10, 10)
プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 10, 10)
プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
モンスターs = [モンスター]
@test Game.戦況表示(プレイヤーs, モンスターs) ==
"""
*****プレイヤー*****
太郎 HP:100
花子 HP:100
遠藤君 HP:100
高橋先生 HP:100
*****モンスター*****
ドラゴン HP:400
********************"""
end
game_exec.jl
include("game.jl")
Game.main()