素人が RPG の開発に挑戦してみる 5

引き続きソード・ワールドの戦闘システムの実装に奮闘中。なんとかキャラクター同士の戦闘処理が形になったので、次はモンスターとの戦闘を実装しよう。お相手はゴブリン君。データは物理戦闘に関係ある項目だけ抽出。

■ゴブリン
敏捷度 = 13
攻撃点 = 武器または牙: 9(2)
打撃点 = 7
回避点 = 10(3)
防御点 = 5
生命点/抵抗値 = 12/10(3)

基本的にはキャラクター同士の戦闘と同じように扱えるはずだが、モンスターはピンゾロでも失敗しないし 6 ゾロでも自動成功ではない点に注意。そして攻撃点と回避点の比較では、同値ならキャラクター有利となることも忘れてはならない。

モンスターにはサイコロの目による自動成功も自動失敗もない。従ってキャラクターを攻撃する際に呼び出す dodged? メソッドの第二引数に Dice.roll_2d6 の戻り値をそのまま渡すわけにはいかない。モンスターの出目が 12 でもキャラクターは回避を諦める必要はないのだ。

今のところ Character#dodged? の第二引数は 12 でなければ特に何も起こらないので、攻撃点と出目の合計値を第一引数として渡し、第二引数は 0 にすることにする。この第二引数はモンスターの攻撃では常に 0 になるわけだから、呼び出し側でいちいち 0 を渡すよりも引数のデフォルト値を 0 にしてしまえば良い。

2D6 の乱数を出力するメソッドも共有化のために Dice クラスとして Character クラスの外に出す。Character#power メソッドと Character#prevent メソッドに相当する処理は、Monster クラスでは固定値なので同名のインスタンス変数で代用*1

その他変数名などを修正して、こうなった。

RATING_SHEET = {
   7 => [nil, nil, nil, 0, 1, 1, 2, 3, 4, 4 ,5, 5, 6],
  14 => [nil, nil, nil, 1, 2, 3, 4, 4, 4, 5, 6 ,7 ,8],
}

class Dice
  def self.roll_2d6
    (rand(6) + 1) + (rand(6) + 1)
  end
end

class Judge
  def resolve(a, b)
    offense, defense = [a, b].sort{|a, b| b.nimble <=> a.nimble}
    turn = 1.0
    loop do
      puts "■第 #{sprintf('%d', turn)} ターン" if turn % 1 == 0
      turn += 0.5
      print "  #{offense.name} の攻撃: "
      damage = offense.attack(defense)
      begin
        unless damage
          puts "攻撃は外れた"
          next
        end
        puts "#{damage} のダメージを与えた"
        if turn % 1 == 0 || offense.nimble != defense.nimble
          puts "  #{a.name} は倒れた" unless a.active?
          puts "  #{b.name} は倒れた" unless b.active?
          next if offense.active? && defense.active?
          break
        end
      ensure
        # 攻守交代
        offense, defense = defense, offense
      end
    end
  end
end

class Character
  attr_accessor :name, :life, :deft, :nimble, :power
  attr_accessor :weapon, :shield, :armor
  attr_accessor :fighter, :thief, :ranger

  def attack(d)
    dice = Dice.roll_2d6
    return nil if dice == 2
    return nil if d.dodged?(bonus(@deft), dice)
    d.damage(power)
  end

  def dodged?(a, a_dice = 0)
    dice = Dice.roll_2d6
    return true if dice == 12
    return false if a_dice == 12
    return false if dice == 2
    a + a_dice <= bonus(@nimble) + dice + (@shield ? 1 : 0)
  end

  def damage(p)
    return nil unless p
    d = p - prevent
    d = (d > 0 ? d : 0)
    @life -= d
    d
  end

  def power
    result = nil
    loop do
      dice = Dice.roll_2d6
      unless dice == 2
        result ||= 0
        result += RATING_SHEET[@weapon][dice]
      end
      break if dice < 10
    end
    result += @fighter + bonus(@power) if result
    result
  end

  def prevent
    dice = Dice.roll_2d6
    return 0 if dice == 2
    RATING_SHEET[@armor][dice] + @fighter
  end

  def active?
    @life > 0
  end

  def bonus(p)
    p / 6
  end
end

class Monster
  attr_accessor :name, :life, :nimble, :ap, :dp
  attr_accessor :power, :prevent

  def attack(d)
    return nil if d.dodged?(@ap + Dice.roll_2d6)
    d.damage(@power)
  end

  def dodged?(a, a_dice)
    dice = Dice.roll_2d6
    return false if a_dice == 12
    a + a_dice < @dp + dice
  end

  def damage(p)
    return nil unless p
    d = p - @prevent
    d = (d > 0 ? d : 0)
    @life -= d
    d
  end

  def active?
    @life > 0
  end
end

これでキャラクターとモンスターの戦闘はキャラクター同士の戦闘と同様に扱うことが可能になった。Judge クラスの処理そのものは一切変更されていない。Monster#dodged? の第二引数にデフォルト値がないのはモンスター同士の戦闘を考慮していないため。ルールにもなかったし問題ないはずだ。

いざ、勝負。

a = Character.new
a.name = 'デッカード A'
a.life = 18
a.deft = 16
a.nimble = 12
a.power = 14
a.weapon = 14
a.shield = true
a.armor = 7
a.fighter = 2

c = Monster.new
c.name = 'ゴブリン'
c.life = 12
c.nimble = 13
c.ap = 2
c.dp = 3
c.power = 7
c.prevent = 5

Judge.new.resolve(a, c)

結果。結構いい勝負をする。何十回もやっていると、ごく稀にデッカード君が第 1 ターンでクリットぶん回して、20 超のダメージでゴブリン君を瞬殺したりするのも趣き深い。そんなデッカード君も体感で二、三割程度は負けてしまう。

■第 1 ターン
  ゴブリン の攻撃: 5 のダメージを与えた
  デッカード A の攻撃: 攻撃は外れた
■第 2 ターン
  ゴブリン の攻撃: 3 のダメージを与えた
  デッカード A の攻撃: 10 のダメージを与えた
■第 3 ターン
  ゴブリン の攻撃: 2 のダメージを与えた
  デッカード A の攻撃: 0 のダメージを与えた
■第 4 ターン
  ゴブリン の攻撃: 攻撃は外れた
  デッカード A の攻撃: 攻撃は外れた
■第 5 ターン
  ゴブリン の攻撃: 攻撃は外れた
  デッカード A の攻撃: 攻撃は外れた
■第 6 ターン
  ゴブリン の攻撃: 攻撃は外れた
  デッカード A の攻撃: 1 のダメージを与えた
■第 7 ターン
  ゴブリン の攻撃: 攻撃は外れた
  デッカード A の攻撃: 3 のダメージを与えた
  ゴブリン は倒れた

少しだけ気になったのは、キャラクター同士の戦闘もそうだったが、タイマンだとどうもお互いに攻撃を外すことが多い。これは意図された仕様なのだろうか。とは言うものの、私自身はタイマン戦闘なんてやった記憶がないので、あまり気にすることはないのかも知れない。


さて、そろそろ面倒臭がってないでテストを書き始めた方がいいような気がしてきた。Dice.roll_2d6 が欲しい値を返すようにオーバライドすれば、乱数による結果のブレを無視してテストが書ける。しかしながら、今回のように明確な仕様を定めなずにコードを書き始めた場合、教科書通りメソッド単位でちまちまテストを積み上げていると、後から仕様変更が入って全てパーになる危険が高い。

私なら、とりあえず Judge#resolve のテストを書く。サイコロの出目を制御できるのだから、何ターン目にどちらが勝って最終的にそれぞれ生命点がどれだけ残っているのかは全て事前に分かる。Character クラスも Monster クラスも単純に戦闘手順を割り振っただけなので、Judge#resolve が期待通りの結果を出しさえすれば現状の形に拘る理由は全くないし、規模的にも気軽にごっそり書き換えられる程度なのでテストを書いてまで保守する必要はない。私の場合、外部とのインタフェース部とか、将来どのような変更が加えられるにしてもこいつだけは気軽に弄ってもらっては困る、そういうメソッドに絞ってテストを書くことにしている。あとはテストを書かないと自分で挙動が追いきれない処理だな。


次はファイター技能とシーフ技能の使い分けに挑戦する予定。シーフ技能の場合はクリティカル基準が 9 になるが、判定の度にいちいち今使用しているのはどっちの技能かなんて分岐を入れるのはみっともないので、技能そのものをオブジェクト化して Character#power を委譲する方向で。でも 2.0 が発売されたら、今考えてることが全部無駄になったりして。


図解 めんどくさいをなくす台所仕事事典

図解 めんどくさいをなくす台所仕事事典

*1:実際は attr_accessor が裏でアクセサを定義してくれているのだが、気持ち、メソッド呼び出しのオーバヘッドを避けてみた