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

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


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

一覧はこちら

「刃に毒を塗る」の残りの実装

前回の続きで、「刃に毒を塗る」の実装である。25%の確率で毒にするところからである。ダメージが0でない時に状態異常を付与する判定をするようにしよう。

まずはキャラクターが状態異常を保持できるようにしよう。

#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    防御力
    状態異常s #追加
    物理攻撃時状態異常付与確率
    ...
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(..., 防御力, Set(), Dict()...)
    end
end

状態異常s」という名前で保持することにした。ここで「Set」というものが出てきた。これは数学でいうところの「集合」を表現するデータ構造だ。配列のように複数の要素を保持できるが、重複したデータは存在しない。あるキャラクターが毒状態のときに重ねて毒攻撃を受けても、状態異常sは毒を一つ持つだけだ。

ダメージを与えた時に毒状態にするためのテストを先に書いておこう。

#game_test.jl
@testset "刃に毒を塗ってから通常攻撃実行" begin
    p = createプレイヤー()
    刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
    行動実行!(刃に毒を塗る, get乱数生成器())

    乱数生成器 = get乱数生成器stub()
    for i in 1:3 #25%の確率で状態異常付与
        m = createモンスター()
        プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
        行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
        @test :毒 in m.状態異常s
    end
    for i in 1:7 #75%の確率で外れる
        m = createモンスター()
        プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
        行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
        @test isempty(m.状態異常s)
    end    
end

このテストを通すべく、次のように実装しよう。

#戦闘.jl
function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃, 乱数生成器)
    ...
    HP減少!(防御者, 防御者ダメージ)
    if 防御者ダメージ > 0
        状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
    end
end

function 状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
    状態異常付与確率 = 攻撃者.物理攻撃時状態異常付与確率
    for (状態異常, 付与確率) in 状態異常付与確率
        乱数 = 乱数生成器()
        if 乱数 < 付与確率
            状態異常付与!(防御者, 状態異常)
        end
    end
end

function 状態異常付与!(対象者, 状態異常)
    push!(対象者.状態異常s, 状態異常)
end

これでテストが通るはずだ。特に難しいところはないはずだ。

ダメージ0の時に状態異常を付与できないテストケースも追加しておこう。

@testset "刃に毒を塗ってから通常攻撃実行するもダメージ0" begin
    p = createプレイヤー(攻撃力=0)
    刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
    行動実行!(刃に毒を塗る, get乱数生成器())

    乱数生成器 = get乱数生成器stub()
    for i in 1:10 #ダメージ0なので毒にできない
        m = createモンスター()
        プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
        行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
        @test isempty(m.状態異常s)
    end
end

このテストは通るはずだ。

通常攻撃だけでなく、スキル攻撃にも対応しておこう。

@testset "刃に毒を塗ってからスキル攻撃実行" begin
    p = createプレイヤー()
    刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
    行動実行!(刃に毒を塗る, get乱数生成器())

    乱数生成器 = get乱数生成器stub()
    m = createモンスター()
    プレイヤーからモンスターへ攻撃 = T行動(createスキル(:連続攻撃), p, m)
    行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
    @test :毒 in m.状態異常s
end

通常攻撃の時と違い、テストはシンプルにしてある。毒の確率が25%だから、とループを回したりはしてない。これは、スキル攻撃の中でも乱数を使っているためである。通常攻撃の時には、毒の判定だけで乱数を使っていたため、攻撃の度に乱数スタブが0, 0.1, 0.2, …と消費されていくのが明確なのだが、スキル攻撃の時にはもっとややこしくなってしまう。本質的には、テストコード側では乱数そのものというよりは、判定結果をコントロールしたいだけなのに、現状乱数の生成の仕方を細かくコントロールしすぎているという問題があるので、ここは別途改良しよう。

スキル攻撃の時にも、通常攻撃と同様に状態異常判定を組み込むようにする。

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル, 乱数生成器)
            ...
            HP減少!(防御者, 防御者ダメージ)
            if 防御者ダメージ > 0
                状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
            end
            ...
end

あとは、相手を毒状態にしたというメッセージを表示したい。これはお決まりの方法だ。特には解説不要だろう。

#戦闘.jl
function 状態異常付与!(対象者, 状態異常)
    ...
    状態異常付与イベント通知!(対象者, 状態異常) #追加
end

#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    状態異常付与イベントリスナーs #追加
    ...
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(..., [状態異常付与ui処理!], ...)
    end
end

#キャラクター.jl
function 状態異常付与イベント通知!(対象者, 状態異常)
    for リスナー in 対象者.状態異常付与イベントリスナーs
        リスナー(対象者, 状態異常)
    end
end

#ui.jl
function 状態異常付与ui処理!(対象者, 状態異常)
    println("$(対象者.名前)は$(状態異常)状態になった!")
end

#ui_stub.jl
function 状態異常付与ui処理!(対象者, 状態異常) end

さて、動かしてみるとわかるが、

  • 刃に毒を塗ったあと、本当に毒を付与できる状態になっているのか?
  • 毒を付与されたモンスターが毒状態になっているのか?

というあたりがわかりづらいので、これらの情報も画面に表示されるようにしよう。戦況表示関数を修正する。これも特段難しいところはない。

#ui.jl
function 戦況表示(プレイヤーs, モンスターs)
    function 表示(c::Tキャラクター)
        s = "$(c.名前) HP:$(c.HP) MP:$(c.MP)"

        状態異常s = c.状態異常s
        if length(状態異常s) > 0
            s *= " " * join(["$(j)" for j in 状態異常s], " ")
        end

        付与s = keys(c.物理攻撃時状態異常付与確率)
        if length(付与s) > 0
            s *= " " * join(["$(f)付与" for f in 付与s], " ")
        end
        return s
    end

    結果 = ["*****プレイヤー*****", 
            join([表示(p) for p in プレイヤーs], "\n"), 
            "*****モンスター*****", 
            join([表示(m) for m in モンスターs], "\n"), 
            "********************"]

    return join(結果, "\n")
end

次のようになる。

*****プレイヤー*****
太郎 HP:100 MP:5 毒付与
花子 HP:40 MP:0
遠藤君 HP:30 MP:20
高橋先生 HP:100 MP:0
*****モンスター*****
ドラゴン HP:280 MP:70 毒
********************

「一度相手を毒状態にすると毒を与える効果が切れる」の実装

これも特に難しくはない。状態異常の付与に成功したら、物理攻撃時状態異常付与確率の辞書から消してしまおう。辞書から指定されたキーの値を消すにはdelete!を使えばいい。

#戦闘.jl
function 状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
        ...
        if 乱数 < 付与確率
            状態異常付与!(防御者, 状態異常)
            物理攻撃時状態異常付与解除!(攻撃者, 状態異常) #追加
        end
        ...
end

function 物理攻撃時状態異常付与解除!(攻撃者, 状態異常)
    delete!(攻撃者.物理攻撃時状態異常付与確率, 状態異常)
end

簡単だった。

しかし、この後にテストを実行すると失敗することに気づくだろう。これは、毒の付与確率が25%であることのテストをするために、繰り返しで実行していた部分が壊れてしまうからである。あまり筋のいいテストの書き方ではなかった。

乱数生成スタブの種類追加

今の乱数生成スタブは、0, 0.1, 0.2, …と繰り返すというものだが、もっと柔軟なものを用意するようにしよう。get乱数生成stubに配列を引数として渡せるようにして、その配列が乱数値として順番に表示されるようなものにしたい。次のようなものになる。

#乱数.jl
function get乱数生成器stub(指定数列)
    if length(指定数列) == 0
        throw(DomainError("指定数列として1つ以上の要素を含むようにしてください"))
    end
    function 数列生成(c::Channel)
        i = 1
        while (true)
            put!(c, 指定数列[i])
            i += 1
            if length(指定数列) < i
                i = 1
            end
        end
    end
    chnl = Channel(数列生成);
    
    return function exec()
        return take!(chnl)
    end
end

次のように使う。

julia> 乱数生成器 = get乱数生成器stub([0.0, 0.1, 0.2, 0.3])
julia> 乱数生成器()
0.0
julia> 乱数生成器()
0.1
julia> 乱数生成器()
0.2
julia> 乱数生成器()
0.3
julia> 乱数生成器()
0.0

これを使ってテストコードを書き直す。25%の確率で成功することをテストしたい。0.2を返すスタブであれば成功し、0.3を返すスタブであれば失敗するはずだ。次のようなテストに変更した。重複が気になるかもしれないが、私はこれでいいと思っている。何度か書いている気もするが、テストコードは明快さが重要だからだ。多少の保守性の悪さは目を瞑っても良い。

@testset "刃に毒を塗ってから通常攻撃実行" begin
    @testset "成功" begin
        p = createプレイヤー()
        刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
        行動実行!(刃に毒を塗る, get乱数生成器())

        m = createモンスター()
        プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
        乱数生成器 = get乱数生成器stub([0.2]) #25%の確率で状態異常付与なので成功する想定
        行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
        @test :毒 in m.状態異常s
    end

    @testset "失敗" begin
        p = createプレイヤー()
        刃に毒を塗る = T行動(createスキル(:刃に毒を塗る), p, p)
        行動実行!(刃に毒を塗る, get乱数生成器())

        m = createモンスター()
        プレイヤーからモンスターへ攻撃 = T行動(T通常攻撃(), p, m)
        乱数生成器 = get乱数生成器stub([0.3]) #25%の確率で状態異常付与なので失敗する想定
        行動実行!(プレイヤーからモンスターへ攻撃, 乱数生成器)
        @test isempty(m.状態異常s)
    end
end

これでテストは通るはずだ。さらに、もともとのget乱数生成stubの引数なしバージョンは、今回作った引数ありバージョンの引数に[0.0, 0.1, ..., 0.9]を指定したものと同じである。今はそれぞれで似たような実装を抱えているが、引数なしバージョンは引数ありバージョンを呼び出すように変更しよう。

function get乱数生成器stub()
    return get乱数生成器stub([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])
end

問題なく動くはずだ。

julia> 乱数生成器 = get乱数生成器stub()
julia> 乱数生成器()
0.0
julia> 乱数生成器()
0.1
julia> 乱数生成器()
0.2

「毒状態のキャラクターは1ターンごとにダメージを受ける」の実装

毒状態の真髄、毎ターンのダメージ計算処理だ。もちろん次の仕様も入れたい。

  • 雑魚敵は1ターンにつき最大HPの50%のダメージ
  • ボス敵は1ターンにつき最大HPの20%のダメージ

順序としては、まずはダメージ計算処理を実装し、次にボス敵と雑魚敵のところを実装しよう。

まずはテストから作ろう。モンスターを毒状態にして、行動後処理!が呼ばれると20%のダメージを受けるというシナリオだ。

#game_test.jl
@testset "毒ダメージ" begin
    @testset "20%ダメージ" begin
        m = createモンスター(HP=100)
        状態異常付与!(m, :毒)
        行動後処理!(m, nothing, nothing)
        @test m.HP == 100 - 20
    end
end 

まず、行動後処理!という処理が、何らかの行動を行った後に呼ばれるようにしよう。引数や内容は行動前処理!に似せている。

#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs, 乱数生成器)
                ...
                行動前処理!(行動者, プレイヤーs, モンスターs)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動, 乱数生成器)
                行動後処理!(行動者, プレイヤーs, モンスターs) #追加
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
                ...
end

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

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

行動後処理!で毒のダメージが計算されるように、毒ダメージの計算処理を作り、それをイベントリスナーとして登録する。さらに、毒ダメージが発生したことも画面上に表示したいので、毒ダメージ発生のイベントも定義している。

#スキル.jl
function 毒ダメージ発生!(対象者)
    if :毒 in 対象者.状態異常s
        毒ダメージ発生イベント通知!(対象者)
        毒ダメージ = round(Int, 対象者.最大HP * 0.2, RoundDown) 
        HP減少!(対象者, 毒ダメージ)
    end
end

#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    ...
    行動前処理イベントリスナーs
    行動後処理イベントリスナーs #追加
    戦闘不能イベントリスナーs
    ...
    毒ダメージ発生イベントリスナーs
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        new(...,  
            [getかばう解除!(:行動前処理)], 
            [毒ダメージ発生!], #追加
            [getかばう解除!(:戦闘不能)], 
            ...,
            [毒ダメージ発生ui処理!]) #追加
    end
end

#キャラクター.jl
function 毒ダメージ発生イベント通知!(対象者)
    for リスナー in 対象者.毒ダメージ発生イベントリスナーs
        リスナー(対象者)
    end
end

#ui.jl
function 毒ダメージ発生ui処理!(対象者)
    println("$(対象者.名前)は毒に苦しんでいる!")
end

#ui_stub.jl
function 毒ダメージ発生ui処理!(対象者) end

ごちゃごちゃ書いたが、特に目新しいことはない。これまでと同じような流れだ。テストが通ることを確認し、画面からも動作確認をしておこう。

ドラゴンの攻撃!
高橋先生は40のダメージを受けた!
高橋先生の残りHP:20
ドラゴンは毒に苦しんでいる!
ドラゴンは80のダメージを受けた!
ドラゴンの残りHP:45

雑魚敵とボス敵の区別

いよいよ仕上げだ。ボス敵は1ターンごとに20%のダメージ、雑魚敵は1ターンごとに50%のダメージとなるようにしよう。モンスターを作る際に、ボスかどうかを指定できるようにする。テストコードは次のようになるだろう。2ケースあるうちの一つは先程のテストケースを少しいじったもの、もう一つは新しいテストケースだ。このテストが通るように実装していこう。

#game_test.jl
@testset "毒ダメージ" begin
    @testset "ボス敵:20%ダメージ" begin
        m = createモンスター(HP=100, isボス=true)
        状態異常付与!(m, :毒)
        行動後処理!(m, nothing, nothing)
        @test m.HP == 100 - 20
    end
    @testset "ザコ敵:50%ダメージ" begin
        m = createモンスター(HP=100, isボス=false)
        状態異常付与!(m, :毒)
        行動後処理!(m, nothing, nothing)
        @test m.HP == 100 - 50
    end
end 

まずは、モンスターがボスかどうかを指定できる必要がある。

#game_test.jl
function createモンスター(;名前="ドラゴン", HP=400, MP=80, 攻撃力=20, 防御力=10, スキルs=[], isボス=false)
    return Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs, isボス)
end

もちろん、プロダクトコード側に修正が必要だ。構造体にisボスというフィールドを作る必要がある。ここで初めて、モンスター専用のフィールドが出てきた。これまでプレイヤーとモンスターは全く同じフィールドを使っていたが、ボスかどうか、というフィールドはモンスター専用なわけだ。ボスプレイヤーというものを作っても面白いかもしれないが、今はやらない。ママ友軍団を主人公にする時には検討する価値があるだろう。

#Tキャラクター.jl
mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
    isボス #追加
end

#キャラクター.jl
function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs, isボス)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs), isボス)    
end

新しく追加したフィールドを使って、毒ダメージの計算処理を変更しよう。最大HPに対して、ボス敵は20%、ザコ敵は50%のダメージだ。プレイヤー側はどうするべきか?決めていなかったが20%ということにしよう。プレイヤー側はザコ敵相手にはまず負けないし20連戦くらいこなせちゃうが、ボス敵とは死闘を繰り広げる、というくらいのバランス調整が普通だと思うので、どちらかといえばボス敵側だという判断だ。

#スキル.jl
include("Tキャラクター.jl")
...
function 毒ダメージ発生!(対象者)
    if :毒 in 対象者.状態異常s
        毒ダメージ発生イベント通知!(対象者)
        毒ダメージ = 毒ダメージ計算(対象者) #関数化した
        HP減少!(対象者, 毒ダメージ)
    end
end

function 毒ダメージ計算(対象者)
    function 係数(対象者::Tプレイヤー)
        return 0.2
    end

    function 係数(対象者::Tモンスター)
        if 対象者.isボス
            return 0.2
        else
            return 0.5
        end
    end

    round(Int, 対象者.最大HP * 係数(対象者), RoundDown) 
end

if文が気になるかもしれない。ここはTボスモンスターという構造体を定義して、型によるディスパッチを行った方がいいのでは?次のようなイメージだ。

  • Tキャラクター
    • Tプレイヤー
    • Tモンスター
      • Tボスモンスター
      • Tザコモンスター

しかし、この方法は採らない。なぜかというと、モンスターには次のような別の階層構造を考えているからだ。

  • Tモンスター
    • Tドラゴン族
    • T猛獣族
    • Tゴーレム族

Juliaでは型の階層構造はピラミッド型に制限されており、複数の親を持つことはできない。ボスドラゴンはボスモンスター型であり、ドラゴン族型である、というふうにはできないのだ。

この場合どうすればいいかというと、第7回で説明したHoly Traitsパターンを使う。階層構造とは別に型の情報をつくり、それでディスパッチするのだ。[1] … Continue reading

ボスかザコか、という区別は「モンスターヒエラルキー」という名前にすることにしよう。新しくモンスターヒエラルキー.jlというファイルを作る。

#モンスターヒエラルキー.jl
struct Tボスモンスター end
struct Tザコモンスター end

function モンスターヒエラルキー(モンスター::Tモンスター) 
    if モンスター.isボス
        return Tボスモンスター()
    else
        return Tザコモンスター()
    end
end

モンスターヒエラルキーをつかうことで、係数を取得する関数はシンプルにできる。

#スキル.jl
function 毒ダメージ計算(対象者)
    係数(::Tプレイヤー) = 0.2
    係数(対象者::Tモンスター) = 係数(モンスターヒエラルキー(対象者))
    係数(::Tボスモンスター) = 0.2
    係数(::Tザコモンスター) = 0.5

    round(Int, 対象者.最大HP * 係数(対象者), RoundDown) 
end

if文がなくなり、1行の代入形式で表現できるようになったため、いくぶん宣言的になっている。

プレイヤー側のテストも作り、通ることを確認しておこう。

#game_test.jl
@testset "プレイヤー:20%ダメージ" begin
    p = createプレイヤー(HP=100)
    状態異常付与!(p, :毒)
    行動後処理!(p, nothing, nothing)
    @test p.HP == 100 - 20
end

よろしい、それでは最後に敵キャラクターを複数にして、ボスドラゴンとミニドラゴンという構成にしよう。

#game.jl
function main(乱数生成器)
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう), 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スキル(:連続攻撃)])

    モンスター1 = Tモンスター("ボスドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)], true)
    モンスター2 = Tモンスター("ミニドラゴン", 50, 10, 5, 10, [createスキル(:連続攻撃)], false)
    モンスター3 = Tモンスター("ミニドラゴン", 50, 10, 5, 10, [createスキル(:連続攻撃)], false)

    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!])
    
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター1, モンスター2, モンスター3]

    ゲームループ(プレイヤーs, モンスターs, 乱数生成器)

    if is全滅(モンスターs)
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!])
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!])
    end
end

無事戦闘が行えたと思う。

お疲れ!本当にお疲れ!コングラッチレーション!(まだまだ最終回じゃないよ!)

ふうー、なんとか「刃に毒を塗る」というスキルも実装し終えることができた。随分長いことかかってしまったが、太郎のスキルが一通り実装できたのは感無量だ。一区切りついたので、改めて当初計画していたスキルのリストを確認しておこう。

  • 太郎
    • 連続攻撃
      • 2〜5回の連続攻撃を行う。1回当たりのダメージは半減する。
    • かばう
      • 一定期間、指定した相手が受ける攻撃を代わりに受ける。
    • ヒール
      • HPを回復する。
    • 刃に毒を塗る
      • 攻撃がヒットしたら一定確率で毒を与えることができるようになる。
  • 花子
    • ファイア
      • 敵に炎属性の攻撃。
    • アイス
      • 敵に氷属性の攻撃。
    • ドレイン
      • 敵のHPを吸収する。
    • 集中
      • 魔法攻撃の威力を上昇させる。
  • 遠藤君
    • 大振り
      • 命中率は低いが通常の2倍の威力の攻撃を行う。
    • かばう
      • 一定期間、指定した相手が受ける攻撃を代わりに受ける。
    • 捨て身
      • 自分のHPと引き換えに相手に大ダメージを与える
  • 高橋先生
    • ヒール
      • HPを回復する。
    • バリア
      • 指定した相手が受けるダメージを軽減させる。
    • 濃霧
      • 一定期間敵味方全ての攻撃の命中率を下げる。
    • 金縛り
      • 指定した相手を一定期間行動不能にする。

次は花子のスキルの実装だ。ここでの目玉は「魔法」である。魔法攻撃力や魔法防御力といったパラメータを作ったり、属性攻撃という概念を作ったりする必要がある。それなりに骨が折れそうだが、この辺りまで実装すれば、基本的なRPGゲームの戦闘システムとしては最低ラインに達したといえるのではないだろうか?あとは威力なんかを調整して種類を増やせばいい。

その後の遠藤君は実装済みのスキルが多いこともあり、あっさり終わりそうだ。高橋先生のスキルは特殊系のものが多いのでややこしいかもしれない。

それが終わったら、敵キャラクターのバリエーションを増やしたい。こんな敵キャラクターを想定していたのだ。

  • ドラゴン
    • 口から炎を吐き、鋭い爪と固い鱗を持つ強力なモンスターだ。爬虫類なので体温が下がると活動が鈍くなるぞ。
  • ケルベロス
    • 頭が3つある魔犬。普段は地獄の番犬をしている。弱ると遠吠えで仲間を呼ぶので、ある程度ダメージを与えたら一気に倒してしまうべし。
  • 青銅魔人
    • 魔法の力で動く金属の巨大人形。とにかく頑丈なのでまともにダメージは与えられない。しばらくするとエネルギー切れで動かなくなるので、攻撃を受けないように時間稼ぎしよう。

それぞれ個性があるが、うまく型を分類できればあまり困難なく実装できるに違いない。

その後の展望は、これは完全に見通しなく語っているので実現できるか不明だが、以下のようなことを考えている。

  • レベルの概念の導入
    • 今は戦闘に勝ってもご褒美がないのが寂しい。経験値やレベルみたいなものがあるといいなと思う。ゴールドみたいなゲーム内通貨をご褒美にしないのは、アイテムや装備品みたいなところにまで手を出す必要があるので大変だからだ。
  • ファイルのセーブ・ロードの仕組み
    • レベルの概念まで入れ始めると、当然セーブをしたいという話になってくる。ここでファイルI/Oの機能について触れることになる。セーブデータの改造防止のための仕組みを入れたりするのも面白いかもしれない。
  • メタプログラミング
    • Juliaをやるからにはメタプログラミングを導入したいが、なかなかいい題材が出てきていないんだよなあ。下手に導入するとややこしくなるだけだし・・・。とはいえ、少しアイデアはある。それはイベント関連の処理だ。画面に何かを表示するたびに、構造体にフィールドを追加して、リスナーに登録して、イベントを発行し、リスナーに通知して、というのを繰り返している。こういうコーディングにおける繰り返し処理を解決するのがメタプログラミングなのだ。何かイベントを定義するだけで、このような決まり切った定型コードが自動で生成されたら嬉しいよね?というのを、メタプログラミング導入のとっかかりにできないかと考えている。
  • ユーザーによる拡張機構
    • いわゆるMODみたいな感じで、ユーザーが自作のモンスターやスキルを定義できて、それを取り込めるような仕組みを作りたい。そのためには、専用の「ドメイン特化言語」を作る必要があるかもしれない。そうなるともう私の能力を超えている気もするが、それはそれでやりがいのある話である。

次回の予定

とかなり夢みがちな将来の展望を語ったが、実は次回は他にもやりたいことがあるのだ。今の戦闘画面は、なんというかやはり見づらい。コンソールにダーっと流れていくだけなので、どこからどこまでが区切りかわかりづらいのだ。とはいえ、UIフレームワークなどを導入すると、フレームワークの使い方の勉強のようになってしまい本意ではないので、そこまでやるつもりはないのだが、固定された画面でHPの値やメッセージやだけが切り替わるくらいにはしたいと考えている。これでテストプレイも快適になるだろう。ついでの文字コードなんかについても話せたらいいなと思っている。

コード

ここまでの実装を貼っておこう。いい加減GitHUBなんかにした方がいい気もしている。

#game_exec.jl
module Game

include("乱数.jl")
include("ui.jl")
include("game.jl")

main(get乱数生成器())

end
#game.jl
import REPL
using REPL.TerminalMenus

include("キャラクター.jl")
include("戦闘.jl")

function モンスター遭遇イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end

function 戦闘勝利イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end

function 戦闘敗北イベント通知!(リスナーs)
    for リスナー in リスナーs
        リスナー()
    end
end


function main(乱数生成器)
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう), 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スキル(:連続攻撃)])

    モンスター1 = Tモンスター("ボスドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)], true)
    モンスター2 = Tモンスター("ミニドラゴン", 50, 10, 5, 10, [createスキル(:連続攻撃)], false)
    モンスター3 = Tモンスター("ミニドラゴン", 50, 10, 5, 10, [createスキル(:連続攻撃)], false)

    モンスター遭遇イベント通知!([モンスター遭遇イベントui処理!])
    
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター1, モンスター2, モンスター3]

    ゲームループ(プレイヤーs, モンスターs, 乱数生成器)

    if is全滅(モンスターs)
        戦闘勝利イベント通知!([戦闘勝利イベントui処理!])
    else
        戦闘敗北イベント通知!([戦闘敗北イベントui処理!])
    end
end
#Tキャラクター.jl
mutable struct Tキャラクター共通データ
    名前
    HP
    最大HP
    MP
    攻撃力
    防御力
    状態異常s
    物理攻撃時状態異常付与確率
    スキルs
    かばっているキャラクター
    かばってくれているキャラクター
    行動前処理イベントリスナーs
    行動後処理イベントリスナーs
    戦闘不能イベントリスナーs
    攻撃実行イベントリスナーs
    回復実行イベントリスナーs
    状態異常付与イベントリスナーs
    かばう実行イベントリスナーs
    かばう発動イベントリスナーs
    かばう解除イベントリスナーs
    HP減少イベントリスナーs
    HP回復イベントリスナーs
    攻撃失敗イベントリスナーs
    行動決定イベントリスナー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, HP, MP, 攻撃力, 防御力, Set(), Dict(), スキルs, nothing, nothing, 
            [getかばう解除!(:行動前処理)], [毒ダメージ発生!], [getかばう解除!(:戦闘不能)], 
            [攻撃実行ui処理!], [回復実行ui処理!], [状態異常付与ui処理!],
            [かばう実行ui処理!], [かばう発動ui処理!], [かばう解除ui処理!], 
            [HP減少ui処理!], [HP回復ui処理!], [攻撃失敗ui処理!], [行動決定ui処理!],
            [刃に毒を塗る実行ui処理!], [毒ダメージ発生ui処理!])
    end
end

abstract type Tキャラクター end

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
    isボス
end
#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
function 刃に毒を塗る実行ui処理!(対象者) end
function 状態異常付与ui処理!(対象者, 状態異常) end
function 毒ダメージ発生ui処理!(対象者) end
#ui.jl
include("スキル.jl")
include("行動系統.jl")
include("Tキャラクター.jl")

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

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

    function get対象リスト(::T回復系行動)
        return プレイヤーs
    end

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

    function get対象リスト(::T刃に毒を塗る行動)
        return [行動者]
    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)
    function 表示(c::Tキャラクター)
        s = "$(c.名前) HP:$(c.HP) MP:$(c.MP)"

        状態異常s = c.状態異常s
        if length(状態異常s) > 0
            s *= " " * join(["$(j)" for j in 状態異常s], " ")
        end

        付与s = keys(c.物理攻撃時状態異常付与確率)
        if length(付与s) > 0
            s *= " " * join(["$(f)付与" for f in 付与s], " ")
        end
        return s
    end

    結果 = ["*****プレイヤー*****", 
            join([表示(p) for p in プレイヤーs], "\n"), 
            "*****モンスター*****", 
            join([表示(m) for m in モンスターs], "\n"), 
            "********************"]

    return join(結果, "\n")
end

function 攻撃実行ui処理!(攻撃者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
end

function 攻撃実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function 回復実行ui処理!(行動者, スキル::Tスキル)
    スキル実行ui処理!(行動者, スキル)
end

function スキル実行ui処理!(行動者, スキル::Tスキル)
    println("----------")
    println("$(行動者.名前)の$(スキル.名前)!")
end

function かばう実行ui処理!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)は$(対象者.名前)を身を呈して守る構えをとった!")
end

function かばう発動ui処理!(防御者)
    println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
end

function かばう解除ui処理_行動前処理!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばうのをやめた!")
end

function かばう解除ui処理_戦闘不能!(行動者, 対象者)
    println("$(行動者.名前)は$(対象者.名前)をかばえなくなった!")    
end

function HP減少ui処理!(防御者, 防御者ダメージ)
    println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

function HP回復ui処理!(対象者, 回復量)
    println("$(対象者.名前)のHPが$(回復量)回復した!")
    println("$(対象者.名前)の残りHP:$(対象者.HP)")
end

function 行動決定ui処理!(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
end

function 攻撃失敗ui処理!()
    println("攻撃は失敗した・・・")
end

function モンスター遭遇イベントui処理!()
    println("モンスターに遭遇した!")
    println("戦闘開始!")
end

function 戦闘勝利イベントui処理!()
    println("戦闘に勝利した!")
end

function 戦闘敗北イベントui処理!()
    println("戦闘に敗北した・・・")
end

function 刃に毒を塗る実行ui処理!(対象者)
    println("$(対象者.名前)は刃に毒を塗った!")
end

function 状態異常付与ui処理!(対象者, 状態異常)
    println("$(対象者.名前)は$(状態異常)状態になった!")
end

function 毒ダメージ発生ui処理!(対象者)
    println("$(対象者.名前)は毒に苦しんでいる!")
end
#キャラクター.jl
include("Tキャラクター.jl")
include("スキル.jl")

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

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs, isボス)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs), isボス)    
end

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

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end

    実際ダメージ = ダメージ < 防御者.HP ? ダメージ : 防御者.HP
    防御者.HP -= 実際ダメージ
    HP減少イベント通知!(防御者, ダメージ)

    if 防御者.HP == 0
        戦闘不能イベント通知!(防御者)
    end
end

function HP回復!(対象者, 回復量)
    if 回復量 < 0
        throw(DomainError("回復量がマイナスです"))
    end

    HP減少量 = 対象者.最大HP - 対象者.HP
    実際回復量 = HP減少量 > 回復量 ? 回復量 : HP減少量

    対象者.HP += 実際回復量
    HP回復イベント通知!(対象者, 実際回復量)
end


function MP減少!(行動者, コマンド::T通常攻撃)
end

function MP減少!(行動者, コマンド::Tスキル)
    消費MP = コマンド.消費MP
    if 消費MP < 0
        throw(DomainError("ダメージがマイナスです"))
    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

function 攻撃実行イベント通知!(攻撃者, コマンド)
    for リスナー in 攻撃者.攻撃実行イベントリスナーs
        リスナー(攻撃者, コマンド)
    end
end

function 回復実行イベント通知!(行動者, コマンド)
    for リスナー in 行動者.回復実行イベントリスナーs
        リスナー(行動者, コマンド)
    end
end

function 攻撃失敗イベント通知!(攻撃者)
    for リスナー in 攻撃者.攻撃失敗イベントリスナーs
        リスナー()
    end
end

function かばう実行イベント通知!(行動者, 対象者)
    for リスナー in 行動者.かばう実行イベントリスナーs
        リスナー(行動者, 対象者)
    end
end

function かばう発動イベント通知!(防御者)
    for リスナー in 防御者.かばう発動イベントリスナーs
        リスナー(防御者)
    end
end

function かばう解除イベント通知!(行動者, 防御者, かばう解除トリガ)
    for リスナー in 防御者.かばう解除イベントリスナーs
        リスナー(行動者, 防御者, かばう解除トリガ)
    end
end

function HP減少イベント通知!(防御者, 防御者ダメージ)
    for リスナー in 防御者.HP減少イベントリスナーs
        リスナー(防御者, 防御者ダメージ)
    end
end

function HP回復イベント通知!(対象者, 回復量)
    for リスナー in 対象者.HP回復イベントリスナーs
        リスナー(対象者, 回復量)
    end
end

function 行動決定イベント通知!(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    for リスナー in 行動者.行動決定イベントリスナーs
        リスナー(行動者, プレイヤーs, モンスターs)
    end
end

function 刃に毒を塗る実行イベント通知!(対象者)
    for リスナー in 対象者.刃に毒を塗る実行イベントリスナーs
        リスナー(対象者)
    end
end

function 状態異常付与イベント通知!(対象者, 状態異常)
    for リスナー in 対象者.状態異常付与イベントリスナーs
        リスナー(対象者, 状態異常)
    end
end

function 毒ダメージ発生イベント通知!(対象者)
    for リスナー in 対象者.毒ダメージ発生イベントリスナーs
        リスナー(対象者)
    end
end
#スキル.jl
include("Tキャラクター.jl")
include("モンスターヒエラルキー.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

struct T回復スキル <: Tスキル
    名前
    回復割合
    消費MP
    T回復スキル(名前, 回復割合, 消費MP) = begin
        if !(0 ≤ 回復割合 ≤ 1)
            throw(DomainError("回復割合は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        new(名前, 回復割合, 消費MP)
    end
end

struct T刃に毒を塗る <: Tスキル 
    名前
    消費MP
end

function T刃に毒を塗る() 
    return T刃に毒を塗る("刃に毒を塗る", 5)
end

function createスキル(スキルシンボル)
    if スキルシンボル === :大振り
        return T攻撃スキル("大振り", 2, 0.4, 0)
    elseif スキルシンボル === :連続攻撃
        return T攻撃スキル("連続攻撃", 0.5, 1, 10, 2, 5)
    elseif スキルシンボル === :かばう
        return Tかばう()
    elseif スキルシンボル === :ヒール
        return T回復スキル("ヒール", 0.5, 10)
    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 かばう実行!(行動者, 対象者)
    かばう実行イベント通知!(行動者, 対象者)
    行動者.かばっているキャラクター = 対象者
    対象者.かばってくれているキャラクター = 行動者

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

function getかばう解除!(かばう解除トリガ)
    if !(かばう解除トリガ in [:行動前処理, :戦闘不能]) 
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end

    return function かばう解除!(行動者)
        if !isnothing(行動者.かばっているキャラクター)
            対象者 = 行動者.かばっているキャラクター
            かばう解除イベント通知!(行動者, 対象者, かばう解除トリガ)
            行動者.かばっているキャラクター = nothing                    
            対象者.かばってくれているキャラクター = nothing
            #事後条件
            かばうデータ整合性チェック(行動者)
            かばうデータ整合性チェック(対象者)
        end
    end
end

#関数名が紛らわしいが、「かばうを解除する時にメッセージを出し分けたい」という
#モデルの処理なのでUI層ではなくモデル層に定義
function かばう解除ui処理!(行動者, 対象者, かばう解除トリガ)
    if かばう解除トリガ === :行動前処理
        かばう解除ui処理_行動前処理!(行動者, 対象者)
    elseif かばう解除トリガ === :戦闘不能
        かばう解除ui処理_戦闘不能!(行動者, 対象者)
    else
        throw(DomainError("想定していないトリガでかばうが解除されました"))
    end
end

function 刃に毒を塗る実行!(対象者)
    刃に毒を塗る実行イベント通知!(対象者)
    対象者.物理攻撃時状態異常付与確率[:毒] = 0.25
end

function 毒ダメージ発生!(対象者)
    if :毒 in 対象者.状態異常s
        毒ダメージ発生イベント通知!(対象者)
        毒ダメージ = 毒ダメージ計算(対象者) 
        HP減少!(対象者, 毒ダメージ)
    end
end

function 毒ダメージ計算(対象者)
    係数(::Tプレイヤー) = 0.2
    係数(対象者::Tモンスター) = 係数(モンスターヒエラルキー(対象者))
    係数(::Tボスモンスター) = 0.2
    係数(::Tザコモンスター) = 0.5

    round(Int, 対象者.最大HP * 係数(対象者), RoundDown) 
end
#モンスターヒエラルキー.jl
struct Tボスモンスター end
struct Tザコモンスター end

function モンスターヒエラルキー(モンスター::Tモンスター) 
    if モンスター.isボス
        return Tボスモンスター()
    else
        return Tザコモンスター()
    end
end
#行動系統.jl
struct T攻撃系行動 end
struct T回復系行動 end
struct Tかばう行動 end
struct T刃に毒を塗る行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::T回復スキル) = T回復系行動()
行動系統(::Tかばう) = Tかばう行動()
行動系統(::T刃に毒を塗る) = T刃に毒を塗る行動()
#戦闘.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通常攻撃, 乱数生成器)
    攻撃実行イベント通知!(攻撃者, コマンド)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者)
        防御者 = 防御者.かばってくれているキャラクター
    end
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    if 防御者ダメージ > 0
        状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
    end
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル, 乱数生成器)
    攻撃実行イベント通知!(攻撃者, スキル)
    if !isnothing(防御者.かばってくれているキャラクター)
        かばう発動イベント通知!(防御者)
        防御者 = 防御者.かばってくれているキャラクター
    end
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        乱数 = 乱数生成器()
        if 乱数 < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            if 防御者ダメージ > 0
                状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
            end
        else
            攻撃失敗イベント通知!(攻撃者)
        end
    end
end

function 状態異常付与判定!(攻撃者, 防御者, 乱数生成器)
    状態異常付与確率 = 攻撃者.物理攻撃時状態異常付与確率
    for (状態異常, 付与確率) in 状態異常付与確率
        乱数 = 乱数生成器()
        if 乱数 < 付与確率
            状態異常付与!(防御者, 状態異常)
            物理攻撃時状態異常付与解除!(攻撃者, 状態異常)
        end
    end
end

function 状態異常付与!(対象者, 状態異常)
    push!(対象者.状態異常s, 状態異常)
    状態異常付与イベント通知!(対象者, 状態異常)
end

function 物理攻撃時状態異常付与解除!(攻撃者, 状態異常)
    delete!(攻撃者.物理攻撃時状態異常付与確率, 状態異常)
end

function 回復量計算(最大HP, 回復割合)
    if 最大HP < 0
        throw(DomainError("最大HPが負の値になっています"))
    end
    if 回復割合 < 0 || 1 < 回復割合 
        throw(DomainError("回復割合が0から1の範囲を超えています"))
    end
    return round(Int, 最大HP * 回復割合, RoundDown) 
end


function 回復実行!(行動者, 対象者, スキル::T回復スキル)
    回復実行イベント通知!(行動者, スキル)
    回復量 = 回復量計算(対象者.最大HP, スキル.回復割合)
    HP回復!(対象者, 回復量)
end

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    行動決定イベント通知!(行動者, プレイヤーs, モンスターs)
    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行動, 乱数生成器) 
    回復実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動, 乱数生成器) 
    かばう実行!(行動.行動者, 行動.対象者)
end

function 行動実行!(::T刃に毒を塗る行動, 行動::T行動, 乱数生成器) 
    刃に毒を塗る実行!(行動.対象者)
    MP減少!(行動.行動者, 行動.コマンド)
end

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

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

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    行動前処理イベント通知!(行動者)
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)
                行動実行!(行動, 乱数生成器)
                行動後処理!(行動者, プレイヤーs, モンスターs)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end
#乱数.jl
using Random

function get乱数生成器()
    return function exec()
        return rand()
    end
end

function get乱数生成器stub()
    return get乱数生成器stub([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])
end

function get乱数生成器stub(指定数列)
    if length(指定数列) == 0
        throw(DomainError("指定数列として1つ以上の要素を含むようにしてください"))
    end
    function 数列生成(c::Channel)
        i = 1
        while (true)
            put!(c, 指定数列[i])
            i += 1
            if length(指定数列) < i
                i = 1
            end
        end
    end
    chnl = Channel(数列生成);
    
    return function exec()
        return take!(chnl)
    end
end

References

References
1 なお、型ではなく値によってディスパッチするテクニックもあるようだが、いろいろ注意事項のあるやり方のようなので、公式ドキュメントの該当箇所を紹介するに留めておく。Value Type