「Julia言語で入門するプログラミング」第4回である。未読の方は第1回〜第3回を読んで欲しい。
一覧はこちら
標準入力
前回の終わりで「さすがに次回は命令くらいは下せるようにはしたいと思っている」と言ったので早速実行しよう。自分のあまりの手際の良さに惚れ惚れするばかりだ。
まずは、プレイヤーキャラクターに命令を与えられるようにしたい。命令を与えられるからには行動に選択肢を増やそう。これまではただの攻撃しかなかったが、それに加えて「大振り」という攻撃方法を追加するようにしよう。これは、通常の2倍のダメージを与えられるのだが、大振りになるぶん命中率が下がり、60%の確率で外れてしまうという攻撃だ。期待値としては通常攻撃よりも少し悪いのだが、ちまちまと回復してくる敵だと一撃で仕留められるメリットがある。プレイヤーの行動時には、通常の攻撃か大振りかを選べるようにしたい。
そうすると、プログラムに対して何か入力をする必要が出てくる。プログラムに何かを入力する代表的な方法として、標準入力というものがある。これは、ざっくりいうとキーボードのことだ。プログラムに対する入力としては、ファイルの内容であるとか、USBケーブルからの信号であるとかも考えられるが、ひとまず最も標準的な入力はキーボードであろうということでこうなっている。
今からプログラムで勇者に命令を下すにあたっては、これに倣ってキーボードからコマンドを入力する。もちろんお好みでマイコンと物理スイッチを買ってきて、USBポートからモールス信号を流し込めるようにしても良いが、この記事では取り扱わない。私が教えて欲しいくらいだ。
最初に準備として、前回作ったテストコードは捨ててしまおう。前回までは3ターン殴り合うだけだったので、全ての実行パターンを網羅できた。だが、今回から、敵味方共に行動のバリエーションが増える。そのため、乱数の要素を除いたとしても、動作の網羅をすることは事実上不可能になる。前回一枚岩のコードからそれぞれの役割を持つ関数などに分離できたので、それらの関数単位での自動テストは今後増やしていく予定だが、main処理を通してのテストはひとまず忘れることにする。
main関数の偽乱数列
、結果
引数をなくし、テストコードもなくし、偽乱数列の要素を使っていたところはrand()
関数に置き換えよう。結果、次のようなコードが今回のスタート地点になる。
using Test
mutable struct キャラクター
名前
HP
攻撃力
防御力
end
function ダメージ計算(攻撃力, 防御力)
return round(Int, 10 * 攻撃力/防御力)
end
function 攻撃実行!(攻撃者, 防御者)
println("----------")
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
end
function 行動順決定(プレイヤー, モンスター, 乱数)
if 乱数 < 0.5
return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
else
return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
end
end
function ゲームループ(プレイヤー, モンスター)
while true
for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
return
end
end
end
end
function main()
モンスター = キャラクター("モンスター", 30, 10, 10)
プレイヤー = キャラクター("勇者", 30, 10, 10)
println("モンスターに遭遇した!")
println("戦闘開始!")
ゲームループ(プレイヤー, モンスター)
if モンスター.HP == 0
println("戦闘に勝利した!")
else
println("戦闘に敗北した・・・")
end
end
main()
ではスタートだ。まずは、行動するキャラクターが勇者の時に、コマンド入力を促すようにしてみよう。そのためには、まずキャラクターがプレイヤーなのかモンスターなのかを判別できる必要がある。構造体にフィールドを一つ追加しよう。
mutable struct キャラクター
名前
HP
攻撃力
防御力
isプレイヤー #追加
end
今回追加したのは、isプレイヤー
というフィールドである。これは真偽値であり、プレイヤーの時に真に、モンスターの時に偽になる。ちょっと違和感のあるフィールド名かもしれない。「プレイヤーフラグ」などの名前にしたくなるかもしれないところだ。フラグというのは、何かの条件が成立した時にON/OFFさせる真偽値を表すために使われることが多い言葉だ。しかし、〇〇フラグという言葉は良くないことがある。名前の付け方によっては、どのような状態を指すのか分かりづらいことがあるためだ。
例えば、キャラクターが「死亡フラグ」という名称のフィールドを持っていたとして、そのキャラクターが死亡している状況なのか、そのキャラクターが死亡することが確定している状況なのか分かりづらい。「死亡済み」とか「死亡確定」とか、もっと明快な名称をつけるべきなのだ。そう言った名前にすると、真偽値であることが明確になり、フラグという名前は余計なものになる。フラグという言葉をつけなければおさまりが悪いのであれば、それは前段の言葉が不十分なのだ。逆にフラグとつけると、前段の言葉が不十分であっても、何となくそれなりの名前に見えてしまうのだ。
そのようなわけでフラグはあえて使わない。今回は「プレイヤーフラグ」と名付けてもおそらく十分明快ではあるのだが、あえてそうする。なお、通常こう言った時には普通の英語ベースのプログラムだと、「is○○」のような名前にする。「isプレイヤー」のようなフィールド名と、「プレイヤーである」のようなフィールド名のどちらが読みやすいか迷ったが、両方試した結果、「isプレイヤー」を採用することにした。英語と日本語が混ざって気持ち悪いかと思ったが意外とそうでもなく、むしろプログラミングのイディオム的でわかりやすい。
さて、キャラクターがプレイヤーだった場合、ユーザーに入力を促し、受け取った入力を保持する仕組みが必要になる。これを行ってくれるのが、Base.prompt
関数である。この関数は引数に指定した文字列をユーザーに提示し、入力された文字列を返り値とする関数だ。
このように入力してみる。
julia> 入力 = Base.prompt("キー入力してエンターを押してください")
すると、下記のようになり、ユーザー入力を待つ。
julia> 入力 = Base.prompt("キー入力してエンターを押してください")
キー入力してエンターを押してください:
そのうえで、テスト
と入力すると、その内容が返り値の変数に保存される。
julia> 入力 = Base.prompt("キー入力してエンターを押してください")
キー入力してエンターを押してください: テスト
"テスト"
julia> 入力
"テスト"
これを使おう。次のように、攻撃実行!
の前にif文を入れよう。そして、キャラクターがプレイヤーであれば、入力を促す。1を選んだら通常攻撃、2を選んだら大振りになるようにしている。受け取ったコマンドは攻撃実行!
関数に渡す。モンスターのターンでは通常攻撃を示す1固定で呼び出す。
if 攻撃者.isプレイヤー
println("勇者のターン")
コマンド = Base.prompt("[1]攻撃[2]大振り")
攻撃実行!(攻撃者, 防御者, コマンド)
else
攻撃実行!(攻撃者, 防御者, "1")
end
動作の違いは攻撃実行!
関数で表現する。
function 攻撃実行!(攻撃者, 防御者, コマンド)
println("----------")
if コマンド == "1"
println("$(攻撃者.名前)の攻撃!")
elseif コマンド == "2"
println("$(攻撃者.名前)の大振り!")
end
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
end
ここではまだ、大振りの攻撃が威力2倍で命中率40%にする対応入れていない。ただ、メッセージだけ「大振り」となるようにした。これで動かしてみよう。
ただ、ここで注意がある。これまではVisual Studio Codeの上でJuliaプログラムを実行されていたと思うが、どうやら現在最新のVisual Studio CodeのJulia Extension v1.0.10にはバグがあり、Visual Studo Codeのターミナルから標準入力をうまく読み取ってくれないのだ。そのため、書き上げたコードはREPLに読み込ませるようにしよう。
Juliaでファイルに書かれたコードをREPL実行するには、include
というものが使う。これは指定されたファイルの中身をべちゃっと貼り付けたのと同じ動きをする。REPLでinclude(ファイルパス)
とすると、そのファイルを実行できる。
ファイルパスを取得するには、Visual Studio Codeで、上部のファイル名が出ているタブで右クリックして「Copy Path」という選択肢を選ぶ。そうすると、クリップボードにコピーされる。macの場合はそのまま貼り付ければ良いのだが、Windowsの場合は注意点がある。Windowsのファイルパスはバックスラッシュ\
記号(もしくは¥
記号)で表現されるが、このままではJuliaでは上手くパスと解釈されない。スラッシュに変換するか、\
記号を2つ重ねにしよう。
#悪い例
julia> include("C:\src\julia\game\rpg4.jl")
#良い例
julia> include("C:\\src\\julia\\game\\rpg4.jl")
#良い例
julia> include("C:/src/julia/game/rpg4.jl")
これで動かしてみると、REPLのプロンプトで、コマンド入力を促される。あとは好きに命令を下そう。
julia> include("あなたのファイルのパス")
モンスターに遭遇した!
戦闘開始!
[1]攻撃[2]大振り: 2
----------
勇者の大振り!
モンスターは10のダメージを受けた!
モンスターの残りHP:20
確かに動いている。
もう1つだけ作業をしよう。構造体を定義したあと、一度定義した構造体を変更した時、Juliaがエラーを出すことはなかっただろうか?「ERROR: invalid redefinition of〜」というやつだ。これまではVisual Studio Codeのターミナルでゴミ箱ボタンを押せばVisual Studo CodeのREPLが消えて、再度実行すればよかった。今後は、REPLを再起動する必要があり面倒だ。少し細工をしよう。
ファイルの先頭にmodule Game
を書き、最後のmain()
の直前に end
を入れる。さらに、main()
の呼び出しを、Game.main()
に変える。こんなふうになる。
#先頭
module Game
using Test
...
#末尾
println("戦闘に敗北した・・・")
end
end
end
Game.main()
module
についてはあまり深入りしない。大規模なアプリケーションでは大事になってくるのだが、今の段階ではあまり気にするものでもない。こうすると、構造体の定義を変えてもエラーにならないので使っただけで、今のところ特に本質的なものではない。
話を戻すと、これでようやくコマンドを与えて動作を変えられるようになった。ついでに、大振り攻撃を威力2倍で命中率40%にしてみよう。とりあえず安直に作ったのが次のコードだ。醜い重複にムズムズしてくるが、あとで整理する。
function 攻撃実行!(攻撃者, 防御者, コマンド)
println("----------")
if コマンド == "1"
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
elseif コマンド == "2"
if rand() < 0.4 #40%の確率で攻撃成功。
println("$(攻撃者.名前)の大振り!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力) #威力2倍
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
else
println("攻撃は失敗した・・・")
end
end
end
動かしていると、困ったことが起きたことに気づくだろう。モンスターのHPが10残っていて、大振りで20のダメージを与えた時、モンスターのHPが-10になってしまうのだ。ゲームの終了条件は防御者.HP == 0
になっているので、この判定をすり抜けてしまう。HPがマイナスにならないようにしよう。
ここで、防御者のHPを計算する関数を作るのだが、ちょっとテスト駆動開発というやり方で作ってみよう。
テスト駆動開発
テスト駆動開発は一風変わった開発方法だ。普通はコードを書いてからテストをするのだが、このやり方はテストを書いてからコードを作る。妙なやり方に思えるかもしれないが、これがなかなか癖になるのだ。慣れるとむしろコードから先に書くやり方だと落ち着かなくなってくる。
テスト駆動開発のサイクルはこうだ。
- まず最初にテストコードを書く。
- 何もないところにテストコードだけ書くので必ず失敗するはずだ。失敗することを確認する。
- 次にテストが通る最低限のコードを書く。
- テストが通ることを確認する。1に戻って新たなテストを書く。
思いつく限りのテストを書いたら完了だ。何故こんなやり方をするのだろうか?理由はいくつかある。
- 関数のインターフェースが自然になる。
- 関数を作る時に実装から入ってしまうと、既存の部品に合わせたインターフェース(引数や返り値)になってしまうことがある。それがその関数にとって自然なインターフェースであれば良いのだが、そうならないこともある。テストから書く = 関数の呼び出し方を決める、ということなので、その関数にとって最も自然なインターフェースをまず考えることになる。
- 不必要に複雑な実装にならない。
- テストが通る最低限のコードを書くというところがミソだ。コードを書いていると、こんな拡張性はいるかな、あんなことは考慮しておいたほうがいいかな、と考えすぎて無駄に複雑な設計になってしまうことがある。テストが通る最低限のコードを書くことは、設計が複雑になりすぎない重要な基準になる。もちろん、テストが足りなければ単に欠陥のあるコードなので、テストは十分に用意しておく必要がある。
- テストが後回しにされない。
- コードは実装があれば動く。テストがなくても動く。だから、気を抜くとテストは作られなくなってしまう。
- テスト可能な関数になる。
- 第一回の終盤で話したが、自動テスト可能なコードというのは自然には出来上がらない。途中からテスト可能なコードに作り替えるのは大変だ。最初から作っておけば無駄な手戻りを防ぐことができる。
- テストケースが関数の仕様となる。
- テストコードを見ることで、どのような使い方が想定された関数かがわかるようになる。動作保証されているサンプルコードとなるのだ。
このように、メリットの多い手法なので、ぜひ身につけていきたい。
準備
テストコードを作り始める前に少しだけ準備しよう。これまでは1つのファイルの中に、全てのコードが含まれていたが、ここからは3つのファイルに分けることにする。ゲームの部品を書くメインのファイル、通常の実行で呼び出すファイル、テストの実行で呼び出すファイルだ。
ほとんどのコードは1番目のファイルに含まれる。ここまで書いた中で、Game.main()
以外は全てここに入れよう。これをgame.jl
というファイル名にしよう。なお、using Test
は消した。テストは別のファイルに書くからだ。
#game.jl
module Game
mutable struct キャラクター
名前
HP
攻撃力
防御力
isプレイヤー
end
function ダメージ計算(攻撃力, 防御力)
return round(Int, 10 * 攻撃力/防御力)
end
function 攻撃実行!(攻撃者, 防御者, コマンド)
println("----------")
if コマンド == "1"
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
elseif コマンド == "2"
println("$(攻撃者.名前)の大振り!")
if rand() < 0.4
防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
else
println("攻撃は失敗した・・・")
end
end
end
function 行動順決定(プレイヤー, モンスター, 乱数)
if 乱数 < 0.5
return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
else
return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
end
end
function ゲームループ(プレイヤー, モンスター)
while true
for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
攻撃者, 防御者 = 攻防
if 攻撃者.isプレイヤー
println("勇者のターン")
コマンド = Base.prompt("[1]攻撃[2]大振り")
攻撃実行!(攻撃者, 防御者, コマンド)
else
攻撃実行!(攻撃者, 防御者, "1")
end
if 防御者.HP == 0
return
end
end
end
end
function main()
モンスター = キャラクター("モンスター", 30, 10, 10, false)
プレイヤー = キャラクター("勇者", 30, 10, 10, true)
println("モンスターに遭遇した!")
println("戦闘開始!")
ゲームループ(プレイヤー, モンスター)
if モンスター.HP == 0
println("戦闘に勝利した!")
else
println("戦闘に敗北した・・・")
end
end
end
次に、同じフォルダにgame_exec.jl
というファイルを作ろう。中身はたったこれだけだ。include文でgame.jl
の中身をベチャッと貼り付け、main
関数を呼んでいるだけだ。普通にゲームを実行する時にはこちらを呼び出す。execはexecuteという英語の略だ。
#game_exec.jl
include("game.jl")
Game.main()
最後に、同じフォルダにgame_test.jl
というファイルを作ろう。こちらを呼び出すとテストが実行される。ここにテストコードを追加していこう。
#game_test.jl
include("game.jl")
using Test
これらは同じフォルダに作るようにしているが、変えたければ別のフォルダに分けても良い。その場合は、include
の中身を相対パスなり絶対パスで指定しよう。これで準備は完了だ。
失敗するテストの作成
最初にテストケースを作る。関数名はHP減少!
としよう。引数の状態を変更する関数なので、!
を忘れないようにしよう。忘れても動作に支障はないのだが、使う人がびっくりしてしまう。
適当なキャラクターを作成し、ダメージを与え、残HPを計算しよう。
#game_test.jl
include("game.jl")
using Test
@testset "HP減少" begin
@testset "ダメージ < HP" begin
c = Game.キャラクター("", 100, 0, 0, true) #HP100のキャラクター
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 97
end
end
game.jl
ファイルの内部をmodule Game
で囲ったので、Game.キャラクター
、Game.HP減少!
のように書く必要が出ている。module
とは、アプリケーション内に仕切りを作る機能なのだ。moduleの外部から内部の関数や変数にアクセスするには、モジュール名.関数名
のようにする必要がある。
これを実行すると、次のようなエラーになる。
ダメージ < HP: Error During Test at game_test.jl:6
Got exception outside of a @test
UndefVarError: HP減少! not defined
HP減少!
なんて関数は無いと怒られているのだ。期待通りだ。しかし、いったいこんなことをして何の意味があるのだろうか?これは、作った自動テストが正しく動作することを確認しているのだ。たまにだが、テストの作り方を間違えて、常にOKを返すテストを作ってしまったりする。失敗することを確認するようにしていると、そんなミスを防ぐことができる。
最低限のコードの作成
次にテストケースを通過させる最低限のコードを書く。game.jl
のどこかに次の関数を追加しよう。
#game.jl
function HP減少!(防御者, ダメージ)
防御者.HP = 97
end
97固定値だって!?良識溢れるプログラマの皆様からすると卒倒するようなコードかもしれない。神をも恐れぬ蛮行である。しかし、「テストケースを通過させる最低限のコード」という要件は満たしている。最低限というよりは最低なコードという方が適切かもしれないが、ともあれ、テストは通る。気にしない気にしない。万事OKだ。
リファクタリング
次のテストケースに移る前に、ちょっとコードを読みやすくしよう。
Game.キャラクター("", 100, 0, 0, true) #HP100のキャラクター
という部分がダサいのだ。HP100のキャラクターを作りたいだけなのに、余計な引数が目立っており、コメントが無いと良くわからない。テストコードに使用する関数を作ろう。
#game_test.jl
@testset "HP減少" begin
function createキャラクターHP100()
return Game.キャラクター("", 100, 0, 0, true)
end
こんな関数は本体側のコードに入れるようなものでは無いのでテスト側の関数とした。関数名に値がベタ書きで入っているなんて本体側のコードでは考えられないが、テストコードなら私はありだと思う。テストコードはなるべく単純明快であるべきで、そのためなら汎用性は少しくらい犠牲にしても良い。ちなみにcreateとは生成するという意味の英語で、構造体などを作る時に慣例的に使われる。
この共通関数を呼び出すように変更したら、次のようになる。
@testset "ダメージ < HP" begin
c = createキャラクターHP100()
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 97
end
もちろんテストが通ることは確認しておこう。
テストケース量産
テストケースを増やそう。要領は分かったと思うのでここからはテンポ良くいこう。次のテストケースは何度も攻撃されたら都度HPが減っていくケースにしよう。
@testset "複数回ダメージ" begin
c = createキャラクターHP100()
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 97
Game.HP減少!(c, 3) #3のダメージ
@test c.HP == 94
end
当たり前だがテストは通らない。ひどいコードを書いたバチが当たったのだ。
複数回ダメージ: Test Failed at game_test.jl:21
Expression: c.HP == 94
Evaluated: 97 == 94
修正が必要だ。テストを通す最低限の実装というと、このくらいになるだろう。
function HP減少!(防御者, ダメージ)
防御者.HP = 防御者.HP - ダメージ
end
これでテストは通る。
Test Summary: | Pass Total
HP減少 | 3 3
次はHPより大きいダメージを与えられた時にHPがマイナスにならず0になるテストケースを作ろう。
#game_test.jl
@testset "ダメージ > HP" begin
c = createキャラクターHP100()
Game.HP減少!(c, 101) #101のダメージ
@test c.HP == 0
end
これは期待通りエラーになる。順調だ。
ダメージ > HP: Test Failed at game_test.jl:27
Expression: c.HP == 0
Evaluated: -1 == 0
テストが通るように修正しよう。
#game.jl
function HP減少!(防御者, ダメージ)
if 防御者.HP - ダメージ < 0
防御者.HP = 0
else
防御者.HP = 防御者.HP - ダメージ
end
end
これでテストが通る。
Test Summary: | Pass Total
HP減少 | 4 4
こんなコードを書いたら境界値が気になってくるものだ。HPとダメージが全く同じ値の時に正しく動いてくれるだろうか?大丈夫な気もするが、テストケースを追加しよう。
#game_test.jl
@testset "ダメージ = HP" begin
c = createキャラクターHP100()
Game.HP減少!(c, 100) #100のダメージ
@test c.HP == 0
end
これは問題なく通る。ひとまずこのくらいにしておこう。本当は、マイナスのダメージが入ってきたときのことも考えたいのだが、ちょっと後回しにする。
この関数を、HPを減らしている処理に差し替える。
if コマンド == "1"
...
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
HP減少!(防御者, 防御者ダメージ) #差し替え
...
elseif コマンド == "2"
...
if rand() < 0.4
防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
HP減少!(防御者, 防御者ダメージ) #差し替え
...
end
これでHPがマイナスになることはなくなるはずだ。
エラーハンドリング
コマンドを与えることはできるようになったが、危うい点が残っている。下記のコマンド入力箇所で、ユーザーが有効なコマンドを指定してくれたら良いが、変な入力をされたらどうなるだろうか?どうもこうも、今は完全に無視されるだけだが、できればユーザーに無効な入力をされたことを通知し、再度正しい入力をしてもらいたい。
コマンド = Base.prompt("[1]攻撃[2]大振り")
攻撃実行!(攻撃者, 防御者, コマンド)
外部から入力されたデータをチェックすることは重要だ。不正なデータを通知することはユーザーフレンドリーでもあるし、チェック処理の後続の処理はデータが汚いことを気にする必要がなくなる。
入力されたコマンドをそのまま次の処理に投げつけるのではなく、いったん内容を確認してみるようにしよう。
要は、入力されたコマンドが1、2であればOK、そうでなければNGなのだ。コマンドが1、2でない限りは再入力を求める処理なので、while文を使うのが適当だろう。次のような感じになる。in
というのは関数だ。a in b
でa
がb
に含まれることを表す。∈
という記号を使うこともできる。これは数学などで出てくるが、ある要素が集合に含まれることを示す記号だ。ちょっと数学チックなので、この記事では使わないが、知っていたらすっきり記述できて良い。何よりも、対となる∉
という記号があるのが良い。in
にはそのようなものがなく、書くとすれば!(a in b)
である。これをa ∉ b
と書けるのだ。
while true
コマンド = Base.prompt("[1]攻撃[2]大振り")
if コマンド in ["1", "2"]
break
else
println("正しいコマンドを入力してください")
end
end
どうせだからこの処理は関数化しよう。
function コマンド選択()
while true
コマンド = Base.prompt("[1]攻撃[2]大振り")
if コマンド in ["1", "2"]
return コマンド
else
println("正しいコマンドを入力してください")
end
end
end
この関数をREPLに貼り付けると1か2を入力しない限りは延々と正しいコマンドの入力を求められることがわかるだろう。さらに、コマンド in ["1", "2"]
という処理も関数化したい。コマンドが1か2であるということは、それ即ち妥当なコマンドであるということだ。その意図を明確にした関数名をつけよう。
とはいえ、この関数は広く使われるものでもなく、コマンド選択関数の中だけで使われる補助的な関数になるので、定義も関数内にしてしまおう。Validというのは英語で妥当なという意味だ。入力値が妥当であることのチェックをバリデーションと呼んだりする。isvalidというのは、妥当性をチェックする関数の慣用句になっているのでそれに倣ってisvalidコマンド
という名前にした。
function コマンド選択()
function isvalidコマンド(コマンド)
return コマンド in ["1", "2"]
end
while true
コマンド = Base.prompt("[1]攻撃[2]大振り")
if isvalidコマンド(コマンド)
return コマンド
else
println("正しいコマンドを入力してください")
end
end
end
この関数のように、物事が上手くいかないケースの取り扱いを「エラーハンドリング」という。今回ように、上手くいったかどうかを返り値で判定し、条件分岐でエラー時の処理と正常時の処理を分けるのが最も一般的だ。これ以外の代表的な方法として、「例外処理」と呼ばれるものがある。第2回でもちょろっと触れた。ここで例外処理について語りたくなってくるのだが、またゲーム開発の進捗が止まってしまうので我慢する。第3回では細かい話をしすぎた気がして反省しているのだ。例外処理は次回か次々回くらいにとっておくことにしよう。
この関数を使うと、次のように書ける。
if 攻撃者.isプレイヤー
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
練習問題
- 問題1
コマンド選択
関数はテスト駆動では作らなかった。これはなぜかわかるだろうか?
個性的なキャラクター
勇者に複数の命令を下せるようになったのはいいし、着々とプログラミング作法を学べているのも良いのだが、肝心のゲームがいまいち盛り上がらない。私が盛り上がらないだけで皆さんが盛り上がってくれていればそれでも良いのだが、あまりそんな気もしない。
理由はいくつか思いつく。まずリソース管理の概念がない。RPGの醍醐味の一つが、限られたリソースをどう配分して課題をクリアするか、というところにある。HP、MP、アイテムの制約がある中でダンジョンに潜ったり強敵を倒したりする。今は一戦限りで終わってしまうので、駆け引きというものがない。ダンジョンを作るのは大変だが、せめて雑魚敵と何連戦かした上にボス敵と戦うような構成にしたい。
キャラクターに個性がないのも問題だ。味方も敵も一人きりで、敵に至っては「モンスター」だ。もっとスライムやらドラゴンやらゾンビやら、個性豊かなキャラクターがいなくてはいけない。味方だって戦士とか魔法使いとかそういうのが欲しい。
他にも足りないものはいくらでもありそうだが、とりあえずこの辺りを解決しなければ話にならない。どちらから解決するかであるが、ここは後者から解決することにしよう。リソース管理については、一戦限りの戦闘であっても敵が強くて長期戦になればリソース管理の余地が生まれる。
そのようなわけで、これから我々のゲームのプレイヤーキャラクターとなる、主人公と愉快な仲間たちを紹介しよう。これまでの勇者君には一旦退場いただき、これからは彼らを操りモンスターと戦っていくことになる。
- 太郎
- 主人公格のキャラクターである。名字は山田。日本人としては極めて親近感のある名前だと思う。万能型の器用なキャラクターである。
- 花子
- ヒロイン的なキャラクターである。名字は田中。太郎とは幼なじみという設定にしておこう。魔法使い型のキャラクターである。
- 遠藤君
- 頼りになる友人役のキャラクターである。名前は哲也。戦士型のキャラクターである。ガキ大将的性格で気性が激しいので、みんなから「君」づけで呼ばれている。太郎も例外ではない。物語中盤で彼らの絆が深まるイベントを用意してあげると良いだろう。「太郎」「遠藤」と呼び合うシーンは涙無くして見ることはできない。厚手のハンカチを用意しておこう。
- 高橋先生
- お目付役の女の先生である。国語の先生なので特に戦闘に秀でているわけではない。支援型のキャラクターである。
こんなところだろう。枯れ木も山の賑わいとばかりに設定を盛り込んでみた。この設定を活かせるかは限りなく未知数だ。
敵も「モンスター」ではつまらないので具体的なキャラクターにしよう。かといってゴブリンやスライムのような弱そうなのと長期戦を強いられるのも格好がつかない。もっと強そうな敵にしよう。
- ドラゴン
- 口から炎を吐き、鋭い爪と固い鱗を持つ強力なモンスターだ。爬虫類なので体温が下がると活動が鈍くなるぞ。
- ケルベロス
- 頭が3つある魔犬。普段は地獄の番犬をしている。弱ると遠吠えで仲間を呼ぶので、ある程度ダメージを与えたら一気に倒してしまうべし。
- 青銅魔人
- 魔法の力で動く金属の巨大人形。とにかく頑丈なのでまともにダメージは与えられない。しばらくするとエネルギー切れで動かなくなるので、攻撃を受けないように時間稼ぎしよう。
どれも強そうだし、個性もあって良い感じだ。
実装の方針
さて、このような個性豊かな面々の特徴をどうコードに落とし込めば良いだろうか。
単に攻撃力が高いとか、防御力が高いという話であれば、能力値を調整すれば良い。しかし、体温低下とか、エネルギー切れになるとかはどうだろうか。そのような状態を管理するフィールドをキャラクター
構造体に追加していくというのは1つの手だ。モンスターの種類を表すフィールドも必要になるだろう。
モンスターの攻撃実行!
関数内で、こんな処理を書くことになるだろうか。
if モンスター.種類 == "ドラゴン"
if モンスター.体温低下度 > 3
println("ドラゴンは体温が低下し動けない")
...
end
elseif モンスター.種類 == "青銅魔人"
if モンスター.残エネルギー == 0
println("青銅魔人はエネルギー切れとなった")
...
end
else if ...
end
これでももちろん動く。しかし、どうにもif文がごちゃごちゃしている感じは拭えない。Juliaにはもっと良いやり方があるのだ。これからそれを紹介しよう。そのためにはまず、「型」という概念を理解する必要がある。
型
Juliaでは、というか、プログラミング言語の世界では、データは「型」という概念を持つ。「型」とは、そのデータがどのような種類のデータであるかを表す。 1
というのは整数という型のデータだ。1.0
というのは小数という型のデータだ。だから、1
と1.0
は違うものなのだ。
「馬鹿な!」と、あなたは叫ぶかもしれない。
「そんな無法は許されない!!」そう叫んで、私の手からキーボードをむしり取るかもしれない。
「Juliaに聞いてやろうじゃないか!!!」キーボードが壊れるほどの勢いで、REPLに入力するかもしれない。
julia> 1 == 1.0
true
「それ見たことか!同じじゃないか!!いい加減なことを言うな!!!」興奮のあまり、奪ったキーボードで私の頭をぶっ叩くのかもしれない。無法はどっちだ。
少し落ち着いて欲しい。落ち着いて聞いて欲しい。今から2つ大事なことを言う。
- プログラミング言語の世界では通常、整数と小数が区別されることは事実だ。しかし、それは「整数と小数が違う振る舞いをする」ということとはイコールではない。整数と小数に別の型を与えながらも、整数と小数が同じような振る舞いにすることは可能だ。
- Juliaでは整数と小数が違う振る舞いをすることがある。
整数と小数の違いについては、深くは立ち入らない。ここでは次の例だけ示そう。
julia> 1 === 1.0
false
第一回で言及を避けた、===
演算子である。これは二つのデータが完全に同一である時にtrueとなる。どういうことだろうか?
コンピュータのデータは0と1だけで表現されると聞いたことはあるだろうか。データはコンピュータのメモリ上のどこかに保持されるわけだが、その際我々が慣れ親しんだ、5
だとか0.2
だとかa
みたいな形では保存されない。このようなデータに変換というか、解釈できるような0と1の並べ方のルールが決まっており、コンピュータの内部ではあくまでそのルールに沿った0と1が並んでいるだけなのだ。このような0と1の数字それぞれのことをビットという。
Juliaではデータがどのようなビットの並びで表現されるかを見る関数が用意されている。bitstring
関数だ。これで数字がどのようなビット列として表現されるか見てみよう。0や1が連続して並ぶ部分は非常に長いので..
で省略した。
julia> bitstring(0)
"0..000000"
julia> bitstring(1)
"0..000001"
julia> bitstring(2)
"0..000010"
julia> bitstring(3)
"0..000011"
julia> bitstring(4)
"0..000100"
なんとなく理解できるだろう。普通の数字と同様に、下の桁から順に繰り上がっていく。正確に理解するには二進数というキーワードで調べてみよう。
これと比較して小数はどうなるか。
julia> bitstring(0.0)
"00000000000..0"
julia> bitstring(1.0)
"00111111111100..0"
julia> bitstring(1.1)
"0011111111110001100110011001100110011001100110011001100110011010"
julia> bitstring(1.9)
"0011111111111110011001100110011001100110011001100110011001100110"
julia> bitstring(2.0)
"010000000000..0"
小数は整数と比べて一見してルールが分かりづらい。小数も整数と同じく二進数なのだが、ルールはずっと複雑になる。
話を戻して、1 === 1.0
だが、これは二つのデータが完全に同一である時にtrueとなると言った。数値のような不変オブジェクトでは、ビットの並びが同じ時に完全に同一と判断される。見ての通り、1と1.0は、コンピュータの内部では明確に違うビット列になる。そのため、1 === 1.0
はfalseになるのだ。ちなみに、可変なオブジェクトの場合には、仮に全く同じメモリの並びであっても===
はtrueとならず、完全に同じメモリ番地のデータの時に限りtrue
となる。
また、ここからわかるように、同じビットの並びであっても、それを整数と解釈するか小数と解釈するかで違う値を意味することになる。
このように、データは「ビットの並び」と、「ビットの並びの解釈」の2つの要素が組み合わさることで初めて正しく取り扱うことができるようになる。この「ビットの並びの解釈」の役割を担うのが「型」という存在だ。Juilaはデフォルトで整数、小数、文字、文字列、配列などの型を提供している。これらはプリミティブ型と呼ばれる最も基本的なデータ型である。
そして、それを組み合わせることで構造体などより複雑な型を定義することができるようになる。構造体を定義するというのは、新しい型を定義しているということになるのだ。
ところで、「型」は英語では”type”という単語だ。というか、順序からすると”type”に「型」という訳語が割り当てられたのだが、「型」という単語はイメージが湧きづらい。私は”type”の訳語としては「種類」の方が分かりやすかったのではないかと思う。まあ、「種類」とすると身近な単語すぎて逆に分かりづらかったり誤解を招く可能性があったのかもしれないが。
型による演算の選択
データと型は分かち難く結びついた概念だ。型はさらに、演算とも深く関わっている。
例えば、*
という演算のことを考えよう。ご存知の通りこの記号は、数値であれば掛け算、文字列であれば文字列を連結させるという演算を担っている。Juliaはa * b
という式がある時に、a
とb
がどのような型のデータであるかにより、実際に行う処理を変えている。
Juliaは、*
という記号の演算をたくさん知っている。そして、入ってきたデータの型に応じて、たくさんある*
演算のうち適切なものを選択することができる。これはJuliaが勝手に行うことであり、我々はこのことを意識しなくても良いようになっている。
そして、我々はこの仕組みをもっと積極的に使うこともできる。全く同名の関数を複数定義する。しかし、これらの関数の引数の型を変えておく。関数呼び出しの際、どちらの関数を呼び出すかを指定する必要はない。Juliaがうまくやってくるれるのだ。早速やってみよう。
今、次のように、データがプレイヤーかモンスターかで分けている処理がある。
if 攻撃者.isプレイヤー
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
else
攻撃実行!(攻撃者, 防御者, "1")
end
これは、次のように変形できる。これは単に関数に抽出しただけだ。
function 行動実行_プレイヤー!(攻撃者, 防御者)
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
end
function 行動実行_モンスター!(攻撃者, 防御者)
攻撃実行!(攻撃者, 防御者, "1")
end
if 攻撃者.isプレイヤー
行動実行_プレイヤー!(攻撃者, 防御者)
else
行動実行_モンスター!(攻撃者, 防御者)
end
このif分岐はもちろん我々プログラマが書いた分岐だ。データの種類に応じて実行されるべき処理を、明示的に選択している。しかし、型の仕組みをうまく使えば、適切な処理が自動で選択されるようにできるのだ。
まず、プレイヤーとモンスターの型を別々に定義する。ここで、頭に「T」をつけた。これは変数名に「プレイヤー」「モンスター」を使いたいがための苦肉の策だ。「T」にはType(型)の意味を込めた。キャラクター
は不要なので消した。また、isプレイヤー
も新しい型には不要だ。
mutable struct Tプレイヤー
名前
HP
攻撃力
防御力
end
mutable struct Tモンスター
名前
HP
攻撃力
防御力
end
#=
mutable struct キャラクター
名前
HP
攻撃力
防御力
isプレイヤー
end
=#
そして、main
関数での変数の宣言を変更する。
function main()
#モンスター = キャラクター("モンスター", 30, 10, 10, false)
#プレイヤー = キャラクター("勇者", 30, 10, 10, true)
モンスター = Tモンスター("モンスター", 30, 10, 10)
プレイヤー = Tプレイヤー("勇者", 30, 10, 10)
ここからが重要だ。行動実行!
という名前の関数を2つ定義する。そして、どちらの型の時に呼び出して欲しいかを、引数で指定する。(仮引数::型名)
の形だ。
次のようにすることで、攻撃者
変数がTプレイヤー
なのかTモンスター
なのかに応じて、どちらの関数が呼び出されるかが決まる。
#=
function 行動実行_プレイヤー!(攻撃者, 防御者)
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
end
=#
function 行動実行!(攻撃者::Tプレイヤー, 防御者)
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
end
#=
function 行動実行_モンスター!(攻撃者, 防御者)
攻撃実行!(攻撃者, 防御者, "1")
end
=#
function 行動実行!(攻撃者::Tモンスター, 防御者)
攻撃実行!(攻撃者, 防御者, "1")
end
呼び出し元は、ただ呼び出したい処理を指定するだけで良い。あとは引数の型の情報をもとに、Juliaがうまいことやってくれる。
#=
if 攻撃者.isプレイヤー
行動実行_プレイヤー!(攻撃者, 防御者)
else
行動実行_モンスター!(攻撃者, 防御者)
end
=#
行動実行!(攻撃者, 防御者)
差異を表示するためにコメントアウトでコードを残していたが、不要なコードは消してしまうと、次のようになる。isプレイヤー
でのif文が消えたことがわかるだろう。
function 行動実行!(攻撃者::Tプレイヤー, 防御者)
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
end
function 行動実行!(攻撃者::Tモンスター, 防御者)
攻撃実行!(攻撃者, 防御者, "1")
end
function ゲームループ(プレイヤー, モンスター)
while true
for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
攻撃者, 防御者 = 攻防
行動実行!(攻撃者, 防御者)
if 防御者.HP == 0
return
end
end
end
end
かなりダイナミックに変更を行った。テストは通るだろうか?もちろん失敗する。キャラクター
という構造体を消したからだ。createキャラクター
関数の中身を修正しておこう。
function createキャラクターHP100()
return Game.Tプレイヤー("", 100, 0, 0)
end
これで自動テストは通る。致命的な失敗はしていなさそうだ。自動テストではカバーしていない部分も多くあるので、ゲームを動かしてみて、変な動きになっていないかは、確認しておこう。
今回のところはここまでだ。最後のあたりは特に重要だ。型によって適用される関数が変わるという機能を利用することで、手動での条件分岐を減らせることがある。このように関数が型によって決定されることを、「(型による)ディスパッチ」と呼んだりする。Juliaの大きな特徴の1つに「多重ディスパッチ」と呼ばれる機能がある。これは、なかなか他の言語ではお目にかかれない強力な機能なのだ。せっかくJuliaで学んでいるのだから、この部分はしっかりと身につけたい。
第4回の終わりに
ついに、ゲームプログラミングらしい題材を入れることができた。次回以降、多彩なモンスターの特徴をJuliaのディスパッチシステムを使って表現していく。さらにプレイヤー側の技のバリーションも増やしていく予定だ。これもJuliaのディスパッチシステムを利用してうまく整理していこう。また、その中で型の階層関係についても触れることになるだろう。
練習問題の解答例
今回も練習問題が1つだけと少ない。そのうえ、あまり練習問題という感じではない。型によるディスパッチの問題を出そうかと思ったが、次回以降で嫌というほどやる予定なのでやめることにした。腹落ちしていなければいろいろ試してみて欲しい。
テスト駆動開発
- 問題
コマンド選択
関数はテスト駆動では作らなかった。これはなぜかわかるだろうか?
- 解答
- 標準入力での入力を求められるからだ。自動テストではキーボードから入力することができない。
当たり前すぎて答えに詰まったかもしれないが、これがテスト駆動開発の実践が意外と難しい部分なのだ。テスト駆動開発はメリットが大きいのだが、アプリケーション全体をテスト駆動開発で作るのは難易度が高い。最初に作る部分ほど、画面表示であったり画面からの入力であったりするためだ。そのため、テスト駆動開発を取り入れるのは、アプリケーションが多少大きくなってきて、部品に別れ始めたくらいから始めるのが良いと思う。
続き
ここまでの実装
現時点でのコードを掲載しておこう。
#game.jl
module Game
mutable struct Tプレイヤー
名前
HP
攻撃力
防御力
end
mutable struct Tモンスター
名前
HP
攻撃力
防御力
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 行動順決定(プレイヤー, モンスター, 乱数)
if 乱数 < 0.5
return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
else
return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
end
end
function コマンド選択()
function isValidコマンド(コマンド)
return コマンド in ["1", "2"]
end
while true
コマンド = Base.prompt("[1]攻撃[2]大振り")
if isValidコマンド(コマンド)
return コマンド
else
println("正しいコマンドを入力してください")
end
end
end
function 行動実行!(攻撃者::Tプレイヤー, 防御者)
println("勇者のターン")
コマンド = コマンド選択()
攻撃実行!(攻撃者, 防御者, コマンド)
end
function 行動実行!(攻撃者::Tモンスター, 防御者)
攻撃実行!(攻撃者, 防御者, "1")
end
function ゲームループ(プレイヤー, モンスター)
while true
for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
攻撃者, 防御者 = 攻防
行動実行!(攻撃者, 防御者)
if 防御者.HP == 0
return
end
end
end
end
function main()
モンスター = Tモンスター("モンスター", 30, 10, 10)
プレイヤー = Tプレイヤー("勇者", 30, 10, 10)
println("モンスターに遭遇した!")
println("戦闘開始!")
ゲームループ(プレイヤー, モンスター)
if モンスター.HP == 0
println("戦闘に勝利した!")
else
println("戦闘に敗北した・・・")
end
end
end
#game_exec.jl
include("game.jl")
Game.main()
#game_test.jl
include("game.jl")
using Test
@testset "HP減少" begin
function createキャラクターHP100()
return Game.Tプレイヤー("", 100, 0, 0)
end
@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