「Julia言語で入門するプログラミング」第10回である。未読の方は第1回〜第9回を読んで欲しい。
一覧はこちら
ビューの差し替え
前回の終わりで「刃に毒を塗る」の実装とビューの差し替えをやりたいと言う話をした。まずはビューの差し替えから行おう。
やりたいのは、game_exec.jl
から呼ばれる時にはui.jl
に定義された関数が呼ばれるが、game_test.jl
から呼ばれる時には、同じように関数を呼び出しても画面に何も印字しない別の関数が呼ばれて欲しい、ということだ。
ここで、今そもそもどのように、Juliaの関数が呼ばれているかをおさらいしよう。
game_exec.jl
は次のようになっている。
#game_exec.jl
include("game.jl")
Game.main()
Game.main()
は、Game
モジュールのmain
関数を呼び出せと言う意味だ。となると、Game
モジュールのmain
関数をどうにかして見つけているわけだが、その仕組みがinclude("game.jl")
である。これは、game.jl
というファイルの中身をコピーしてきて、ここにべちゃっと貼り付けているイメージの動きをする。
game.jl
は次のようになっている。
#game.jl
module Game
...
include("ui.jl")
...
ここにinclude("ui.jl")
と書かれているため、ui.jl
というファイルの中にある関数を見つけることができるのだ。
今は、game.jl
が直接ui.jl
をincludeしているために、game.jl
を使いたい時には、抱き合わせでui.jl
の関数が呼ばれることになる。同じ要領で、game_test.jl
を実行するときも、game.jl
をincludeする時にui.jl
もincludeされている。
つまり、game_exec.jl
でui.jl
の関数が呼ばれ、game_test.jl
で別の、例えばui_stub.jl
というファイルに書かれた関数が呼ばれるようにしたければ、次のようにすれば良い。
game.jl
はui.jl
をincludeするのをやめる。game_exec.jl
がui.jl
をincludeし、game_test.jl
がui_stub.jl
をincludeするようにする。
早速やってみよう。
まだui_stub.jl
は作らず、とりあえず、ui.jl
をgame.jl
から削り、game_exec.jl
とgame_test.jl
の両方にincludeしてみよう。
module Game
using Random
import REPL
using REPL.TerminalMenus
include("キャラクター.jl")
include("戦闘.jl")
#include("ui.jl")
...
#game_exec.jl
include("ui.jl") #追加
include("game.jl")
Game.main()
#game_test.jl
include("ui.jl") #追加
include("game.jl")
using Test
...
ただ、これをやるだけでは失敗する。ui.jl
中で指定されている引数の型の定義がない、と言われるのだ。もともと、ui.jl
はgame.jl
の中で暗黙的にキャラクター.jl
や戦闘.jl
に依存していた。game.jl
の中にいる間は、キャラクター.jl
と戦闘.jl
の中の構造体や関数が「見えて」いたのだ。
#game.jl
...
include("キャラクター.jl")
include("戦闘.jl")
#include("ui.jl")
雑に対応するのであれば、キャラクター.jl
や戦闘.jl
も、ui.jl
と道連れに連れていく、と言うものだが、もう少しきちんと対応しよう。ui.jl
が本当に必要とする構造体や関数だけを抽出して、明示的に依存させよう。
Tキャラクター.jl
というファイルをつくり、そこにキャラクター関連の構造体の定義のみ移動させた。それ以外の関数は、外部コンストラクタも含めて全て元のキャラクター.jl
に残っている。同様に、スキル.jl
からも構造体の定義だけを抽出した、T行動内容.jl
という新規ファイルに独立させた。この辺りは一部の言語とは違う文化だ。例えば、Javaは1クラス1ファイルが原則だ。あるクラスと、そのクラスに属するメソッドは同じファイルに書かれる。しかし、Juliaでは構造体と関数の紐付きが強くない。そもそも多重ディスパッチにより複数の構造体に関連する関数も多いので、1クラス1ファイルのような管理はできない。構造体とそれに関連する関数は、なるべく近くにあった方がわかりやすいのだが、分けるとしたら構造体を独立させると良いと思う。
#T行動内容.jl
abstract type T行動内容 end
abstract type Tスキル <: T行動内容 end
struct T通常攻撃 <: T行動内容 end
struct Tかばう <: Tスキル
...
end
struct T攻撃スキル <: Tスキル
...
end
struct T回復スキル <: Tスキル
...
end
#スキル.jl
include("T行動内容.jl") #追加
function T攻撃スキル(名前, 威力, 命中率, 消費MP)
return T攻撃スキル(名前, 威力, 命中率, 消費MP, 1, 1)
end
...
#以下"T行動内容.jl"に移動させた構造体定義は削除
#Tキャラクター.jl
mutable struct Tキャラクター共通データ
...
end
abstract type Tキャラクター end
mutable struct Tプレイヤー <: Tキャラクター
_キャラクター共通データ::Tキャラクター共通データ
end
mutable struct Tモンスター <: Tキャラクター
_キャラクター共通データ::Tキャラクター共通データ
end
#キャラクター.jl
include("Tキャラクター.jl") #追加
include("スキル.jl")
function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))
end
...
こうして、ui.jl
が明示的にTキャラクター
の情報に依存すると表現できる。
#ui.jl
include("T行動内容.jl") #追加
include("行動系統.jl")
include("Tキャラクター.jl") #追加
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
...
#以下"T行動内容.jl"に移動させた構造体定義は削除
これでとりあえずテストを動かしてみると、エラーが出る。
複数回ダメージ: Error During Test at /Users/muuumin/Documents/GitHub/julia-intro-prog/rpg10/game_test.jl:22
Got exception outside of a @test
UndefVarError: getかばう解除! not defined
Stacktrace:
[1] Main.Game.Tキャラクター共通データ(名前::String, HP::Int64, MP::Int64, 攻撃力::Int64, 防御力::Int64, スキルs::Vector{Any})
@ Main.Game ~/Documents/GitHub/julia-intro-prog/rpg10/Tキャラクター.jl:35
これは、Tキャラクター.jl
の内部コンストラクタで使用されている関数 getかばう解除!
が スキル.jl
にあるため、参照できなくなってしまったことによる問題だ。スキル.jl
をTキャラクター.jl
にincludeするという方針もあるが、今はそれで問題がなくとも、後々循環参照になってしまう可能性があり厄介だ。Tキャラクター.jl
やT行動内容.jl
は、参照されるだけにしておきたい。そのため、少々不本意ながら、getかばう解除!
の定義をTキャラクター.jl
に移しておこう。将来的にここが問題となり、いよいよgetかばう解除!
関数をTキャラクター.jl
と分けたいとなったら、Tキャラクター
の内部コンストラクタを外部コンストラクタに変えて、外部コンストラクタごと移動させると言う方法があるが、今はまだ内部コンストラクタを失うデメリットの方が大きいと判断した。
しかし、これで終わりではない。テストを実行しようとすると、次のように出る。
ダメージ < HP: Error During Test at /Users/muuumin/Documents/GitHub/julia-intro-prog/rpg10/game_test.jl:16
Got exception outside of a @test
UndefVarError: 攻撃実行ui処理! not defined
今度は、ui.jl
の中に定義されている関数が見えないと言うのだ。これはなぜかと言うと、ui.jl
がGameモジュールの外に存在するからだ。
本来モジュールは、ソフトウェア内部にアーキテクチャ的な境界を分けるためのものだが、ここまでは特にそう言う役割を担っているわけではない。ただ単に、構造体の再定義をする時にREPLの再起動が必要になるが、構造体の中に入れておけば避けられる、という理由で導入しただけのものだ。適当に対応しよう。
game.jl
からmoduleの定義を外し、game_exec.jl
が代わりにGameモジュールを作るようにする。
#game.jl
#module Game
using Random
import REPL
using REPL.TerminalMenus
...
#end
これまでは、game_exec.jl
ではGame
モジュール外からmain
関数を呼んでいたため、Game.main
とする必要があったが、その必要がなくなっている。
#game_exec.jl
module Game
include("ui.jl")
include("game.jl")
main()
end
同様に、game_test.jl
の属するモジュールをGameTest.jl
としている。その過程で、Game.
という修飾をなくしている。
#game_test.jl
module GameTest
include("ui.jl")
include("Game.jl")
using Test
...
ひとまずこれで、元通りに動く。
ここからがやりたいことの本番だ。game_test.jl
で参照する関数を、ui.jl
で定義されている本物の関数とは別の関数としたい。何もprintlnしない偽物の関数だ。このようなテスト用の偽物の関数をモックと言ったりスタブと言ったりする。モックとスタブという用語の定義に細かい違いはあるようだが、あまり気にしない。細かい違いについて気になる方は各自検索されたい。今回はスタブと呼ぶことにする。スタブ関数のみで構成されたファイルui_stub.jl
を作ろう。引数の型を指定するのに不要なinclude
は削除している。また、基本的にはui.jl
の関数で構成されているが、自動テストに不要な関数は除いている。
#ui_stub.jl
include("スキル.jl")
include("Tキャラクター.jl")
function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃) end
function 攻撃実行ui処理!(行動者, スキル::Tスキル) end
function 回復実行ui処理!(行動者, スキル::Tスキル) end
function スキル実行ui処理!(行動者, スキル::Tスキル) end
function かばう実行ui処理!(行動者, 対象者) end
function かばう発動ui処理!(防御者) end
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
if かばう解除トリガ === :行動前処理
elseif かばう解除トリガ === :戦闘不能
else
throw(DomainError("想定していないトリガでかばうが解除されました"))
end
end
function HP減少ui処理!(防御者, 防御者ダメージ) end
function HP回復ui処理!(対象者, 回復量) end
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs) end
function 攻撃失敗ui処理!() end
ほとんどが、空っぽの関数になる。一際異彩を放つのがかばう解除ui処理!
で、これだけ例外を送出している箇所がある。ここは消したくない。なぜなら、「想定外の引数の時に例外を早出する」というのは自動テストでも検知したい内容であるためだ。しかし、そうなると本体側のコードと二重実装のようになってしまう。困った事態だ。
なぜこんなことになってしまったかというと、これは本質的にはUIの処理ではなく、モデルの処理だからだろう。「:行動前処理
と:戦闘不能
で、ユーザーへの通知を変えたい」という要件はモデルの責務に思える。「それぞれをどう通知するか」というのは、UIの責務だ。前回も説明したように、モデルとUIの分離は意外と難しい。まあこの部分は少し後で直すことにしよう。
game_test.jl
が参照するのを、ui.jl
からui_stub.jl
に置き換える。
#game_test.jl
module GameTest
include("ui_stub.jl")
include("game.jl")
using Test
...
テストを動かすと、エラーが発生する。次のようなテストケースがあるためだ。
@testset "戦況表示" begin
モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [])
プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [])
プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [])
プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [])
プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [])
プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
モンスターs = [モンスター]
@test 戦況表示(プレイヤーs, モンスターs) ==
"""
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ドラゴン HP:400 MP:80
********************"""
end
このテストは消してしまおう。何かしらちゃんとした対応を入れてもいいのだが面倒だし、もしもここに問題があれば、どうせゲームを動かしたら即座に気づくからだ。
これで晴れてテストが通る。不要なui表示が消えて、結果がわかりやすくなった。
Test Summary: | Pass Total
HP減少 | 5 5
Test Summary: | Pass Total
行動実行! | 38 38
Test Summary: | Pass Total
is戦闘終了 | 7 7
Test Summary: | Pass Total
is全て相異なる | 10 10
Test Summary: | Pass Total
行動順決定 | 4 4
Test Summary: | Pass Total
is戦闘終了 | 2 2
Test Summary: | Pass Total
is行動可能 | 4 4
Test Summary: | Pass Total
行動可能な奴ら | 6 6
Main.GameTest
もちろん本体側では、これまで通りの表示となることを確認しておこう。
モンスターに遭遇した!
戦闘開始!
*****プレイヤー*****
太郎 HP:100 MP:20
花子 HP:100 MP:20
遠藤君 HP:100 MP:20
高橋先生 HP:100 MP:20
*****モンスター*****
ドラゴン HP:400 MP:80
********************
太郎のターン
選択してください:
> 攻撃
スキル
----------
太郎の攻撃!
ドラゴンは10のダメージを受けた!
ドラゴンの残りHP:390
不要コードのモデルへの移行
先ほど後送りにした、下記の例外送出の部分に手をつけよう。先ほど言ったように、これは本質的にはモデルの性質を含んでいるコードである。そのため、モデルに移せる部分はモデルに移したい。
#ui.jl
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
if かばう解除トリガ === :行動前処理
println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
elseif かばう解除トリガ === :戦闘不能
println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")
else
throw(DomainError("想定していないトリガでかばうが解除されました"))
end
end
#ui_stub.jl
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
if かばう解除トリガ === :行動前処理
elseif かばう解除トリガ === :戦闘不能
else
throw(DomainError("想定していないトリガでかばうが解除されました"))
end
end
次のようにしてみよう。まず、条件分岐の部分がモデルにあたるので、スキル.jl
に次のように関数を定義する。
#スキル.jl
#関数名が紛らわしいが、「かばうを解除する時にメッセージを出し分けたい」という
#モデルの処理なのでUI層ではなくモデル層に定義
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
if かばう解除トリガ === :行動前処理
かばう解除ui処理_行動前処理!(行動者, 対象者)
elseif かばう解除トリガ === :戦闘不能
かばう解除ui処理_戦闘不能!(行動者, 対象者)
else
throw(DomainError("想定していないトリガでかばうが解除されました"))
end
end
そして、画面表示に関する部分だけをui.jl
に定義する。
function かばう解除ui処理_行動前処理!(行動者, 対象者)
println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
end
function かばう解除ui処理_戦闘不能!(行動者, 対象者)
println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")
end
テストも書いておこう。本当は、もっと早くに書いているべきテストだったのだが、うっかりしていた。
@testset "想定外のシンボルでは例外が発生" begin
p1 = createプレイヤー()
p2 = createプレイヤー()
@test isnothing(かばう解除ui処理!(p1, p2, :行動前処理))
@test isnothing(かばう解除ui処理!(p1, p2, :戦闘不能))
@test_throws DomainError かばう解除ui処理!(p1, p2, :想定外シンボル)
end
@test_throws
というのが、例外が発生することを期待するときのテストである。引数が:行動前処理
、:戦闘不能
であれば、正常に終了するが、:想定外シンボル
であれば、DomainError
例外が発生するというテストだ。
今は、ui.jl
にだけ関数を定義したので、テストは失敗する。ui_stub.jl
に、スタブ関数を定義しよう。
function かばう解除ui処理_行動前処理!(行動者, 対象者) end
function かばう解除ui処理_戦闘不能!(行動者, 対象者) end
これで無事テストが通るはずだ。最終的に、ui_stub.jl
は次のようになる。
#ui_stub.jl
include("スキル.jl")
include("Tキャラクター.jl")
function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃) end
function 攻撃実行ui処理!(行動者, スキル::Tスキル) end
function 回復実行ui処理!(行動者, スキル::Tスキル) end
function スキル実行ui処理!(行動者, スキル::Tスキル) end
function かばう実行ui処理!(行動者, 対象者) end
function かばう発動ui処理!(防御者) end
function かばう解除ui処理_行動前処理!(行動者, 対象者) end
function かばう解除ui処理_戦闘不能!(行動者, 対象者) end
function HP減少ui処理!(防御者, 防御者ダメージ) end
function HP回復ui処理!(対象者, 回復量) end
function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs) end
function 攻撃失敗ui処理!() end
これでスッキリした。
裏技
ところで、ここまで長々と説明をしてきたが、実はもっと簡単なやり方がある。結局のところ、自動テストの文脈では、println
を排除したいというのが目的だった。それをやるだけであれば、こうしてあげても良いのだ。
#game_test.jl
function println(args) end
include("ui.jl")
これだけである。要は、Base.println
という「本当の」printlnの代わりに、偽物のprintlnを作って、テストの時にはそれを参照するようにしようというものである。まあ、少し裏技的ではあるし、きちんとスタブ関数を用意してあげた方が今後いろいろ応用も効く。例えば、画面描画の関数がきちんと呼ばれているかを自動テストで検知したいと考えたとする。そんな時に、スタブ関数として空っぽの関数ではなく、何かの変数をカウントアップする関数にすることもできる。そうすれば、その変数をチェックすることで、関数が呼ばれているかどうか、ということを自動テストで書くこともできるのだ。
「刃に毒を塗る」の実装
次は「刃に毒を塗る」スキルの実装だ。このスキル、どういうスキルだろうか。
- 刃に毒を塗る
- 攻撃がヒットしたら一定確率で毒を与えることができるようになる。
一定確率というのはどうしようか。25%くらいにしておこう。ちょっと低いと思われるかもしれないが、その代わり毒の効果を強力なものにしようと思っているのだ。
いきなり語り始めてしまうのだが、私は一般的なゲームでの「毒」という効果が弱すぎると感じている。多くのゲームでは、毒というのは割とジワジワ効いてくる。よくあるのは最大HPの5%や10%のダメージを毎ターン受ける、というようなものだ。しかし、これは非常に使いづらい。
というのが、通常のゲームでは、雑魚敵との対戦があまりストレスとならないように、大体2から3ターンで倒せる程度の強さに設計されていることが多い。これでは、ジワジワ毒で削っている暇があったら殴って倒してしまった方が早いとなってしまう。このため、雑魚敵との戦いでは毒はあまり使い物にならない。
かと言って、雑魚戦で活躍できるように毒を強くしすぎると、今度はボス戦で困ってしまう。例えば、1回の毒で最大HPの50%を減らす威力にしてしまうと、ボスに毒が効いて2ターンで終わりました、となってしまう。これではボスの威厳も何もあったものではない。
というわけで、毒というものは扱いが難しいのだが、私は雑魚敵とボス敵で効果を変えてしまえばいいのではないかと思う。
今は敵がドラゴン1体しかいないが、ここらでお供のミニドラゴン2体も登場させるようにしよう。ドラゴンはボス敵、ミニドラゴンは雑魚敵ということにしよう。
さらに、私は毒の威力を比較的高めに設定したいと思っている。これは完全に趣味の問題で、そもそも毒というのは致命的だからこそ毒と呼ばれている気がするからだ。その代わり、相手を毒にすることは少し難しくしてある。1ターンかけて刃に毒を塗って、2ターン目以降でも確実に毒にできるとは限らない。その代わり、一旦毒にかかったらそれなりのダメージが発生するようにしよう。
これらを合わせて、下記のように設定しようと思う。
- 雑魚敵は1ターンにつき最大HPの50%のダメージ
- ボス敵は1ターンにつき最大HPの20%のダメージ
仕様のまとめ
「刃に毒を塗る」のスキルの仕様を簡単にまとめると、次のようになる。
- スキルを発動したターンは、刃に毒を塗るという行動で1ターン消費する
- その次のターン以降では、攻撃がヒットすると25%の確率で相手を毒状態にする。
- 一度相手を毒状態にすると、毒を与える効果が切れる。再度毒を塗ることはできる。
- 毒状態のキャラクターは1ターンごとにダメージを受ける。雑魚敵とボス敵で威力が異なる。
いったんこんなもので良いだろう。進めていくうちに細かい部分が気になってくるものだが、いったんはこれをベースに進めていこう。
「スキルを発動したターンは、刃に毒を塗るという行動で1ターン消費する」の実装
まずはスキルを定義しよう。次のようになるだろう。消費MPは5にしておいた。
#行動内容.jl
struct T刃に毒を塗る <: Tスキル
名前
消費MP
end
#スキル.jl
function T刃に毒を塗る()
return T刃に毒を塗る("刃に毒を塗る", 5)
end
function createスキル(スキルシンボル)
...
elseif スキルシンボル === :刃に毒を塗る
return T刃に毒を塗る()
...
end
次に、このスキルを太郎に持たせよう。
#game.jl
function main()
...
プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう), createスキル(:ヒール), createスキル(:刃に毒を塗る)])
...
これで、コマンドとして、刃に毒を塗る
が選べるようになった。もちろんこれだけでは動かすとエラーが出る。次はこれを正しく動かせるようにしよう。
まず、攻撃の対象を選べる必要はない。対象は自分自身だ。
#行動系統.jl
...
struct T刃に毒を塗る行動 end
...
行動系統(::T刃に毒を塗る) = T刃に毒を塗る行動()
#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
...
function get対象リスト(::T刃に毒を塗る行動)
return [行動者]
end
...
次に、「刃に毒を塗る」で具体的に何が起こるかを記述しよう。まずはテストを作る。
@testset "刃に毒を塗る" begin
@testset "刃に毒を塗る実行" begin
p = createプレイヤー()
刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
行動実行!(刃に毒を塗る)
@test p.物理攻撃時状態異常付与確率[:毒] == 0.25
end
end
物理攻撃時状態異常付与確率[:毒]
とはなんぞや?といったところだが、詳しい内容は少し後に説明しよう。
このテストケースを通すために、行動実行!
関数のT刃に毒を塗る行動
バージョンを作る。
function 行動実行!(::T刃に毒を塗る行動, 行動::T行動)
刃に毒を塗る実行!(行動.対象者)
MP減少!(行動.行動者, 行動.コマンド)
end
刃に毒を塗る実行!
関数は次のようになる。引数にするのは、行動者か対象者か迷ったが(どちらでも同じなので)、対象者にした。
function 刃に毒を塗る実行!(対象者)
対象者.物理攻撃時状態異常付与確率[:毒] = 0.25
end
Tキャラクター
に物理攻撃時状態異常付与確率
というフィールドを追加する必要がある。物理攻撃という言葉をしれっと使ったが、後々魔法攻撃が出てくることを念頭に置いている。
mutable struct Tキャラクター共通データ
...
物理攻撃時状態異常付与確率
...
new(..., Dict(), ...
...
そして、そのフィールドに対して、[:毒]
と指定し、0.25
という値を付与している。これは一体なんだろうか?これは「辞書」と呼ばれるものだ。
辞書
「辞書」というデータ構造がある。英語でdictionaryである。言語によっては、ハッシュテーブル(hash table)やマップ(map)、連想配列という名前がついていることもあるが、同じものを指す。私は辞書というネーミングが一番好きである。
辞書は配列に似ていて、データの集合である。配列と違うのは、データを特定する方法だ。配列は「何番目か」という数字でデータにアクセスするが、辞書は「名前」でデータにアクセスする。
次の例では、"a"
が10
という値を持ち、"b"
が20
という値を持つ辞書となっている。"a"
、"b"
のことを「キー」と呼び、10
、20
のことを「値」と呼ぶ。
julia> d = Dict([("a", 10), ("b", 20)])
Dict{String,Int64} with 2 entries:
"b" => 20
"a" => 10
julia> d["a"]
10
julia> d["b"]
20
同じことを、次のように記述することもできる。この表記の方が直感的でわかりやすい。
julia> d = Dict("a"=>10, "b"=>20)
Dict{String,Int64} with 2 entries:
"b" => 20
"a" => 10
"a"=>10
というのは、これ自体がペア(Pair)というデータ構造である。
julia> typeof("a"=>10)
Pair{String,Int64}
辞書はペアの集合体とみなすこともできる。ただし、辞書から正しくデータを検索できるためには、キーが重複してはいけない。同じキーで複数の値を登録することはできない。
julia> Dict("a"=>10, "a"=>20)
Dict{String,Int64} with 1 entry:
"a" => 20
まず辞書を作って、あとからキーと値のペアを追加することができる。
julia> d = Dict()
Dict{Any,Any}()
julia> d["a"] = 10
10
julia> d["b"] = 20
20
julia> d
Dict{Any,Any} with 2 entries:
"b" => 20
"a" => 10
先ほどお見せしたのはこのやり方である。
mutable struct Tキャラクター共通データ
...
物理攻撃時ステータス異常付与確率
...
new(..., Dict(), ...
...
対象者.物理攻撃時ステータス異常付与確率[:毒] = 0.25
シンボルをキーに、数値を値に持つ辞書である。
これでモデル部分の実装は終わった。テストが通ることを確認しておこう。
あとは画面に「太郎は刃に毒を塗った!」と表示したい。
#キャラクター.jl
function 刃に毒を塗る実行イベント通知!(対象者)
for リスナー in 対象者.刃に毒を塗る実行イベントリスナーs
リスナー(対象者)
end
end
#Tキャラクター.jl
mutable struct Tキャラクター共通データ
...
刃に毒を塗る実行イベントリスナーs
Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
...
new(...
[刃に毒を塗る実行ui処理!])
end
end
#ui.jl
function 刃に毒を塗る実行ui処理!(対象者)
println("$(対象者.名前)は刃に毒を塗った!")
end
#ui_stub.jl
function 刃に毒を塗る実行ui処理!(対象者) end
画面から動かして表示を確認しよう。
********************
太郎のターン
選択してください:
攻撃
> スキル
選択してください:
連続攻撃10
かばう0
ヒール10
> 刃に毒を塗る5
太郎は刃に毒を塗った!
「その次のターン以降では、攻撃がヒットすると25%の確率で相手を毒状態にする」の実装
次は攻撃がヒットした時に、相手を毒状態にすることができるようにしたい。
このあたり関数の中で、状態異常を付与する処理を書けばいいのだろう。
function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
...
HP減少!(防御者, 防御者ダメージ)
#このあたりに処理追加?
end
function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
...
HP減少!(防御者, 防御者ダメージ)
#この辺りに処理追加?
...
end
ちょっと迷うのが、「ダメージが0だった時に、状態異常を付与できるのか?」と言うところだ。刃に毒を塗ったのだから、かすり傷くらいは与えないと毒を与えることはできないだろう。ということで、ダメージが0でない時に状態異常を付与する判定をするようにしよう。
で、まあそれはおいおい実装するとして、先にテストを作ろうと思うのだが、困ったことがある。25%で毒状態を付与するというのはどうテストすればいいだろうか?確率の問題なので、時と場合により失敗したり成功したりする。自動テストとは相性が悪い。
似たような問題は、「大振り」の時にもあった。大振りのテストは次のようにしたのだ。
@testset "大振り攻撃" begin
p = createプレイヤー(HP=100, 攻撃力=10)
m = createモンスター(HP=200, 攻撃力=20)
プレイヤーからモンスターへ攻撃 = T行動(createスキル(:大振り), p, m)
行動実行!(プレイヤーからモンスターへ攻撃)
@test p.HP == 100
@test m.HP == 180 || m.HP == 200
当たっても外れてもOKとなるようにしている。まあ、苦肉の策という感じである。同じようにしてもいいのだが、そろそろテコ入れしてもいい頃だ。確率の問題はこの先ずっとつきまとう。ちょっと高度なことをしてみたい。
やりたいのは、本体側の処理ではこれまで通りの乱数を使い、テスト側の処理では本物の乱数ではなくテストに都合のいいような固定の数字列を使いたいと言うことである。
実現の発想はUIの時と同じである。本体側の処理とテスト側の処理をスタブを使って変更したいのだ。ただ、UIの時よりややこしい点がある。UIの時には、表示を抑制しさえすればよかった。しかし、今回はテストに都合のいい数列が生成されるようにしてあげる必要があるのだ。
テストの時には乱数ではなく、0から始まり0.1刻みで0.9まで順番に数字の列が生成されるようにしたい。0.9の次は0.1に戻る。
これを実現するためには、なんらかの変数に前回生成した値を保持し、次に「乱数」を取得する時には0.1足してそれを記憶させる、ということを行う必要がある。グローバル変数を使う(最悪!)、クロージャを使う、などの実現手段があるが、あえてそれとは別の「コルーチン」と呼ばれる概念を紹介したい。
コルーチン
コルーチンのスペルはcoroutineである。co-が「協調する」と言う意味で、routineが「手続き」というような意味である。合わせて「協調して動作する手続き」というような意味だろうか。
通常の関数は、呼び出されたら関数内の処理が最後まで実行されて結果を返す。当たり前に思えるが、コルーチンはそうではない。コルーチンは、関数の途中で実行を中断し、途中結果を返すことができる。再度呼び出された時には、その続きから処理を再開する。コルーチンというのは、中断可能な関数、状態を持つ関数、と言うようなイメージである。
早速コルーチンを作ってみよう。まず、今回実現したい動作は、「最初0で、0.1ずつ加算され、1になったら0に戻る」という動きである。これを何も考えずに関数で実装すると次のようになるだろう。
function 数列生成()
val = 0
while (true)
val += 1//10
if val == 1
val = 0
end
end
end
1//10
というのは、有理数、つまり分数の表記である。Juliaは分数を小数に変換することなく、分数としてそのまま扱えるのだ。単に0.1
と書くと、これは浮動小数点小数となってしまい、厳密には0.1
ではなく微妙な誤差がつきまとう。適当な桁で四捨五入してもいいが、せっかくなので有理数を使うことにした。
もちろん、ここまではただの関数で、しかも無限ループなので終了すらしない。JuliaでこれをコルーチンにするにはChannel
というものを使う。次のようにする。
- 引数に
Channel
型の変数を追加する。 - コルーチンで中断したいところに
put
関数を挿入する。
julia> function 数列生成(c::Channel)
val = 0
while (true)
put!(c, float(val))
val += 1//10
if val == 1
return
end
end
end
put!
関数は第一引数にChannel
型を、第二引数にコルーチンの呼び出し側へ返る値を取る。float(val)
というのは、有理数型のval
を浮動小数点小数に変換している。
コルーチンは呼び出し側にも一工夫必要だ。普通に関数呼び出しするとエラーになる。
julia> x = 数列生成()
ERROR: MethodError: no method matching 数列生成()
まあこれは引数が不一致なので当然と言えば当然だが、それとは無関係に、Channel
の使い方は独特だ。
julia> チャンネル = Channel(数列生成)
こうすることで、数列生成
というコルーチンを呼び出すための手綱を握ることができる。コルーチンを一回動作させるには、take!
という関数を使う。
julia> take!(チャンネル)
0.0
julia> take!(チャンネル)
0.1
julia> take!(チャンネル)
0.2
見事、take!
という関数を呼び出すたびに、コルーチン内のput
関数まで処理が走って中断していることがわかるだろう。
チャンネルは複数同時に作ることができる。状態は独立に管理される。
julia> 別のチャンネル = Channel(数列生成)
julia> take!(別のチャンネル)
0.0
julia> take!(別のチャンネル)
0.1
julia> take!(チャンネル)
0.3
Channel
の基本的な使い方は大体このようなものである。「関数内のどのタイミングで処理を止めるか」を決めるのはコルーチン側であり、「何回コルーチンを呼び出して適切な状態まで進めるか」を決めるのは呼び出し側である。コルーチン側も呼び出し側も、互いに相手のことをよく知っている必要がある。コルーチン側は、呼び出し側が何を必要としているかを知らなければ、いつ処理を止めてどんな情報をputすべきかわからないし、呼び出し側も何回処理を呼び出せば自分の望む状態になってくれるかを知るにはコルーチン処理の詳細を知る必要がある。このあたりがcoroutineの"co"に込められた想いではないかと思う。
乱数生成のスタブ化
本体側の対応
さて、本編に戻ると、コルーチンを使って乱数生成をスタブ化したいのだ。
まず、今までは乱数を取得するためにrand()
関数を直接呼び出していたが、これをやめることにしよう。
代わりに、次のようにする。新しいファイルを乱数.jl
として、get乱数生成器
は、「呼び出すとrand()
関数を実行してくれる関数」を返す。今後は、単なるrand()
は使わず、このget乱数生成器
から乱数を取得するようにする。
#乱数.jl
using Random
function get乱数生成器()
return function exec()
return rand()
end
end
まずは、これまでの処理がこれまで通り動くように変更しよう。これまでrand()
と呼び出していたところは次のように置き換える。(なお、この関数には、rand(スキル.攻撃回数min:スキル.攻撃回数max)
と呼び出している箇所もあるが、そこはいったん後回しにする。)
#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル, 乱数生成器)
...
for _ in 1:攻撃回数
乱数 = 乱数生成器()
if 乱数 < スキル.命中率
....
end
乱数生成器
自体は外から引数として与えられるようにしている。本体側の処理だと真っ当な乱数生成器が、テスト側の処理だとスタブ版の乱数生成器が渡される必要があるためだ。
これに合わせてこちらの関数の引数も変更する必要がある。
#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃, 乱数生成器)
...
end
さらに呼び出し元にも引数を追加していく。
#戦闘.jl
function 行動実行!(::T攻撃系行動, 行動::T行動, 乱数生成器)
攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド, 乱数生成器)
...
end
#戦闘.jl
function 行動実行!(行動::T行動, 乱数生成器)
行動実行!(行動系統(行動.コマンド), 行動, 乱数生成器)
end
芋づる式に、下記のメソッドにも引数を追加する。
#戦闘.jl
function 行動実行!(::T回復系行動, 行動::T行動, 乱数生成器)
...
end
function 行動実行!(::Tかばう行動, 行動::T行動, 乱数生成器)
...
end
function 行動実行!(::T刃に毒を塗る行動, 行動::T行動, 乱数生成器)
...
end
さらに遡る。
#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs, 乱数生成器)
...
行動実行!(行動, 乱数生成器)
...
end
さらに遡る。
#game.jl
function main(乱数生成器)
...
ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター], 乱数生成器)
...
そうして、最終的には、game_exec.jl
までたどり着く。乱数.jl
をincludeし、main関数の引数に、get乱数生成器()
を渡している。
#game_exec.jl
module Game
include("乱数.jl")
....
main(get乱数生成器())
これで試しに起動してみよう。問題なく動くはずだ。
テスト側の対応
次に、テスト側の対応を行う。スタブ版の乱数生成器を作るget乱数生成器stub()
を定義しよう。
#乱数.jl
function get乱数生成器stub()
function 数列生成(c::Channel)
val = 0
while (true)
put!(c, float(val))
val += 1//10
if val == 1
val = 0
end
end
end
chnl = Channel(数列生成);
return function exec()
return take!(chnl)
end
end
先ほど例に出した数列生成
コルーチンを内部で使っている。Channel
やtake!
などはクロージャの中に隠蔽し、コルーチンであることはあまり意識する必要がないようにしている。get乱数生成器stub()
が生成する「乱数生成器」は、通常の乱数生成器と同じに扱える。
あとは、テスト側のコードで、乱数.jl
をincludeし、必要な引数を追加していく。
#game_test.jl
module GameTest
...
include("乱数.jl")
...
特に乱数をコントロールしたくなければ、通常のget乱数生成器()
を渡す。
@testset "行動実行!" begin
@testset "通常攻撃" begin
...
行動実行!(プレイヤーからモンスターへ攻撃, get乱数生成器())
...
end
一方で、乱数をコントロールしたいテストでは、get乱数生成器stub()
を使い、0から始まり0.1刻みで増える性質を利用したテストケースを作ることができる。
@testset "大振り攻撃" begin
p = createプレイヤー(HP=1000, 攻撃力=10)
m = createモンスター(HP=2000, 攻撃力=20)
乱数生成器 = get乱数生成器stub()
プレイヤーからモンスターへ攻撃 = T行動(createスキル(:大振り), p, m)
for i in 1:4 #40%の確率でヒット
行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
@test p.HP == 1000
@test m.HP == 2000 - i * 20
end
for i in 1:6 #60%の確率で外れる
行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
@test p.HP == 1000
@test m.HP == 1920
end
モンスターからプレイヤーへ攻撃 = T行動(createスキル(:大振り), m, p)
for i in 1:4 #40%の確率でヒット
行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
@test p.HP == 1000 - i * 40
@test m.HP == 1920
end
for i in 1:6 #60%の確率で外れる
行動実行!(モンスターからプレイヤーへ攻撃, 乱数生成器)
@test p.HP == 840
@test m.HP == 1920
end
end
上記では、40%の確率でヒットすることを確認するために行動実行!
を何度も呼んでいるため、HPを元のテストケースの10倍にした。なお、行動実行!
に渡す前に何回も乱数生成器()
を呼んでもいいし、なんならget乱数生成器stub()
を改良して、初期値を0以外にできるように、引数をとるようにしてもいい。
get乱数生成器()
は引数を取らず、get乱数生成器stub()
が引数をとるように変わることに違和感があるかもしれないが、スタブはテストのための補助なので、テストが書きやすいようになっていることを優先すれば良い。
必要な引数を全ての箇所で渡したら、自動テストを実行しておこう。成功を祈る。
スタブ化の方法
今回の記事の最初で提示したui処理関数のスタブ化と、今提示した乱数生成器のスタブ化は少しやり方が違うことに気づいただろう。前者は同名の関数を別ファイルに定義し、本体とテストでincludeするファイルを変えることで実現した。後者は、外部から渡す「乱数生成器」を渡し分けることで実現した。スタブ化は、本体側の処理という文脈と、テストの処理という文脈で動作を変更することさえできればいいので、私は実現手段にはこだわらないが、前者の実現方法は、かなりJuliaに特有のやり方という印象である。後者の方が一般的だ。
ちなみに後者のような、そのモジュールが依存しているものを外部から渡すようにするやり方を「依存性の注入(Dependency Injection)」という呼び方をしたりする。DIと略されることも多い。
「刃に毒を塗る」の残りの実装
「刃に毒を塗る」の実装は道半ばだが、長くなってきたので一旦この辺りで終わりにしよう。次は、25%の確率で毒にするところの続きから作りたい。その後は、
- 一度相手を毒状態にすると、毒を与える効果が切れる。再度毒を塗ることはできる。
- 毒状態のキャラクターは1ターンごとにダメージを受ける。雑魚敵とボス敵で威力が異なる。
というところを実装していく。ただ、最後に「辞書」というデータ構造の威力について、どうしても語っておきたい。
自前のディスパッチシステム
辞書は柔軟なデータ構造だ。キーとして登録するのは、文字列やシンボルにすることが多いが、値としては本当に様々なものが登録される。例えば、値として別の辞書を登録することもできるし、関数を登録することもある。これを応用することで、自前で簡単なディスパッチシステムを作ることもできる。
Juliaが得意とする、型によるディスパッチとはなんだっただろうか?これは引数の型の種類により、実行される関数が自動で選択されると言うことだった。型とはなんだったろうか?データの種類を明示する仕組みである。
julia> struct Tプレイヤー
name
end
julia> struct Tモンスター
name
end
julia> function 表示(p::Tプレイヤー)
println("ハーイ、プレイヤー$(p.name)です")
end
julia> function 表示(m::Tモンスター)
println("ハーイ、モンスター$(m.name)です")
end
julia> 表示(Tプレイヤー("太郎"))
ハーイ、プレイヤー太郎です
julia> 表示(Tモンスター("ドラゴン"))
ハーイ、モンスタードラゴンです
これはもちろんJuliaの機能を使って実現されているわけだが、これと同じようなことを自前でやりたいと言うことである。
まずは、データの種類を保持するフィールドtag
を持つ型Tキャラクター
を定義する。
julia> struct Tキャラクター
tag
name
end
julia> 太郎 = Tキャラクター(:プレイヤー, "太郎")
julia> ドラゴン = Tキャラクター(:モンスター, "ドラゴン")
次に、表示
関数を作る。
julia> function 表示(c::Tキャラクター)
if c.tag === :プレイヤー
println("ハーイ、プレイヤー$(c.name)です")
elseif c.tag === :モンスター
println("ハーイ、モンスター$(c.name)です")
end
end
こうすると、期待通りの動きをする。ここまではまだ辞書は使っていない。
julia> 表示(太郎)
ハーイ、プレイヤー太郎です
julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです
さて、今の状態はあまり良くない。大事な「加法性」が失われている。今は表示
関数の中で:プレイヤー
と:モンスター
が混ざってしまっている。「そのくらいガタガタ言うなよ!このオタンコナス!」と言われるかもしれないが、これは大きな問題なのだ。今は表示
関数だけだからいいが、同じ要領で関数がどんどん増えていき、全ての関数にこのif文が入っていったらどうなるのか?プレイヤー、モンスターに次ぐ第3のデータが来たときに、それら全てを直して回る必要がある。データの種類が増えるたびに、どんどん関数が複雑になっていく。これは嫌だ。もともとの型によるディスパッチでは、関数は全て型ごとに独立している。この形を目指したい。
このために辞書を使う。辞書にタグと関数のペアを登録していく。これは通常、初期化のような処理で行われることになるだろう。
julia> 表示辞書 = Dict()
julia> 表示辞書[:プレイヤー] = c -> println("ハーイ、プレイヤー$(c.name)です")
julia> 表示辞書[:モンスター] = c -> println("ハーイ、モンスター$(c.name)です")
簡潔さを優先してラムダ式にしたが、どこか別の場所で定義された関数を渡してもいい。大事なのは、:プレイヤー
、:モンスター
というタグ別に関数を取り扱っているというところだ。
表示辞書
を使うことで、表示
関数は、内部では辞書に登録された関数を呼び出すだけになる。
julia> function 表示(c::Tキャラクター)
表示辞書[c.tag](c)
end
少し解説しておくと、表示辞書[c.tag]
で、tag
をキーにして、辞書に登録した関数を取得し、その関数に引数としてc
を渡している、という処理になっている。
こうすると期待通りの動きをする。
julia> 表示(太郎)
ハーイ、プレイヤー太郎です
julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです
辞書を使うだけで、tag
データによって関数を独立して登録、自動選択される仕組みを作ることができた。
もっと汎用的にすることもできる。現状では、表示
という関数に対して、表示辞書
という辞書を作った。今後、関数が増えるたびに対応する辞書が増えるのも大変だ。そう思ったら、タプルをキーにする辞書を作って、関数名とタグの組み合わせで決まるようにしてもいい。
julia> 関数辞書 = Dict()
julia> 関数辞書[(:表示, :プレイヤー)] = c -> println("ハーイ、プレイヤー$(c.name)です")
julia> 関数辞書[(:表示, :モンスター)] = c -> println("ハーイ、モンスター$(c.name)です")
julia> function 表示(c::Tプレイヤー)
関数辞書[(:表示, c.tag)](c)
end
julia> 表示(太郎)
ハーイ、プレイヤー太郎です
julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです
もしも、関数名とデータタグが同列なのが気持ち悪ければ、次のように、辞書を2段階にしても良い。
julia> 関数辞書 = Dict()
julia> 関数辞書[:表示] = 表示辞書
julia> function 表示(c::Tプレイヤー)
関数辞書[:表示][c.tag](c)
end
最初に、「関数名を受け取って、対応する辞書を返す」辞書から値を取得している(関数辞書[:表示]
)。そして、その辞書にタグを渡すことで、適用すべき関数を取得し、その関数に引数を渡している。
この表示
関数もうまく動く。
julia> 表示(太郎)
ハーイ、プレイヤー太郎です
julia> 表示(ドラゴン)
ハーイ、モンスタードラゴンです
さらに汎用的にしたくなるかもしれない。次の部分だ。関数名が表示
、辞書に渡しているシンボルも:表示
、ここをなんとかできないだろうか?
julia> function 表示(c::Tプレイヤー)
関数辞書[:表示][c.tag](c)
end
これは今までの延長線上では実現できない。なんと言っても、関数名は手でベタ書きしている部分だからだ。与えられた引数を元に、関数を自動生成したい。これを実現するには、「メタプログラミング」という技法が必要になる。マクロとか、evalとかいうやつらだ。これは後のお楽しみにしておこう。
このように辞書を使うことで、Juliaが言語機能として提供する「型によるディスパッチ」を模倣することができた。もちろん、多重ディスパッチをするには複数のタグに対応する必要があるし、関数の引数だって1つとは限らないなど、課題は多い。普通であれば、わざわざこんなことをする必要はない。Juliaの言語機能に頼るべきだ。
だが、こういうことが可能であると知っておくのも良いことだと思う。
第10回のまとめ
いよいよ第10回ということで、2桁に到達してしまった。ここまでくると文法事項もかなりマニアックなものになってきた。コルーチンなどはそれなりの規模のプログラムであっても、全く使われないということもある機能だ。
気がかりなのは未だ太郎くんのスキルすら実装が終わっていないことだ。一通りスキルを実装し終えるまで、まだかなりかかりそうである。しかもその先にはドラゴン以外のモンスターの実装も待っている。青銅魔人の紹介をしたのを覚えているだろうか?一体いつ実装できることやら、見当もつかない。
とまあ一抹の不安は残るが、まあ期限があるわけでもなし、のんびり実装していこう。
続き
コード
今回のコードは以下のURLで確認できる。
https://github.com/muuumin-soft/julia-intro-prog/tree/main/rpg10