1
\$\begingroup\$

This is designed to be a simple example of a RPG battle, for a talk I hope to give. The rules:

  • Alice and Bob both have 5 hit points and inflict 2 damage.
  • Each turn, Alice attacks Bob, and Bob attacks Alice.
  • 3/4 of attacks hit their target.
  • 1/2 of the attacks that hit their target are blocked, inflicting half damage.
  • When one character is dead, the game is over.

Important ideas:

  • Sending the attack and receiving the attack are handled separately.
  • It's a re-usable API for characters, so game.rb is a single use case. There could be others.

character.rb

class Character

  attr_accessor :name, :hit_points

  def initialize(name, hit_points, hit_chance, block_chance, damage)
    @name = name
    @hit_points = hit_points
    @hit_chance = hit_chance
    @block_chance = block_chance
    @damage = damage
  end

  def attack(other_character)
    puts "#{name} attacks #{other_character.name}"
    if roll(@hit_chance)
      puts "  The attack hits"
      other_character.be_attacked(@damage)
    else
      puts "  The attack missed"
    end
  end

  def be_attacked(damage)
    if roll(@block_chance)
      puts "  It was blocked, half damage done."
      take_damage(damage / 2)
    else
      take_damage(damage)
    end
  end

  def take_damage(damage)
    @hit_points -= damage
    puts "  -#{damage} hit points"
    puts "  #{name} has been defeated" if dead?
  end

  def dead?
    @hit_points <= 0
  end

  private

  def roll(chance)
    rand(100) < chance
  end

end

game.rb

class Game

  def initialize
    @alice = Character.new('Alice', 5, 75, 50, 2)
    @bob = Character.new('Bob', 5, 75, 50, 2)
  end

  def turn
    @alice.attack(@bob)
    @bob.attack(@alice)
    report_status
    sleep 1
  end

  def over?
    (@alice.dead? or @bob.dead?)
  end

  def report_status
    puts ''
    if over?
      puts "The game is over!"
    else
      puts "Alice: #{@alice.hit_points} - Bob: #{@bob.hit_points}"
      puts ''
    end
  end

end

script.rb (executable file)

require_relative 'character.rb'
require_relative 'game.rb'

game = Game.new
game.turn until game.over?

Example output:

Alice attacks Bob
  The attack missed
Bob attacks Alice
  The attack hits
  -2 hit points

Alice: 3 - Bob: 5

Alice attacks Bob
  The attack hits
  -2 hit points
Bob attacks Alice
  The attack missed

Alice: 3 - Bob: 3

Alice attacks Bob
  The attack hits
  -2 hit points
Bob attacks Alice
  The attack hits
  -2 hit points

Alice: 1 - Bob: 1

Alice attacks Bob
  The attack hits
  It was blocked, half damage done.
  -1 hit points
  Bob has been defeated
Bob attacks Alice
  The attack hits
  -2 hit points
  Alice has been defeated

The game is over!
\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

When one character is dead, the game is over.

No, it isn't, because in your own example output it says:

Alice attacks Bob [...] Bob has been defeated

Bob attacks Alice [...] Alice has been defeated

The game is over!

Bob is apparently dangerous, even in death.

Anyway, code review:

I'd prefer that the characters weren't responsible for printing anything. Their responsibility should only be to be number generators; let the game handle the encounter.

I'd also advise against using attr_accessor :hit_points, since that means you can set the hit points from anywhere. Using attr_reader would be more fitting, I think.

Lastly, naming you instance variables @alice and @bob is rather too specific; they're both just characters. Besides, you might want more at some point.

Here's how I might do the character class:

class Character
  attr_reader :name, :hit_points, :damage

  def initialize(name, hit_points, damage, hit_chance, block_chance)
    @name = name
    @hit_points = hit_points
    @damage = damage
    @hit_chance = hit_chance
    @block_chance = block_chance
  end

  def dead?
    @hit_points <= 0
  end

  def hits?
    roll(@hit_chance)
  end

  def blocks?
    roll(@block_chance)
  end

  def take_damage(amount)
    @hit_points -= amount
  end

  def block_damage
    @block_damage ||= (damage / 2).round
  end

  private

  def roll(threshold)
    rand(100) > threshold
  end
end

As you can see, it just a data model; it doesn't manipulate anything outside itself.

For the game class, here's a simple implementation that has 3 characters in the mix (everyone gets an attack at each of the others, which isn't too RPG'ish, but it's just for illustration).

class Game
  def initialize
    @characters = []
    @characters << Character.new("Alice", 5, 2, 75, 50)
    @characters << Character.new("Bob", 5, 2, 75, 50)
    @characters << Character.new("Carol", 5, 2, 75, 50)
  end

  def play
    while play_turn
      puts "----------------------------"
      print_status
      puts "----------------------------"
      sleep 1
    end
  end

  def play_turn
    living_characters.permutation(2) do |a, b|
      puts "#{a.name} attacks #{b.name}"

      if a.hits?
        blocked = b.blocks?
        damage = blocked ? a.block_damage : a.damage
        puts "\t#{b.name} blocks" if blocked
        puts "\t#{a.name} inflicts #{damage} points of damage"
        b.take_damage(damage)
        puts "\t#{b.name} has been defeated" if b.dead?
      else
        puts "\t#{a.name} misses"
      end

      if living_characters.size == 1
        puts "Game over, #{a.name} wins"
        return false
      end
    end
    true
  end

  def print_status
    living_characters.each do |player|
      puts "#{player.name}: #{player.hit_points} hit points"
    end
  end

  private

  def living_characters
    @characters.reject(&:dead?)
  end
end

The game class is responsible for printing too, which isn't necessary the nicest way to do things. It conflates the view and controller, even though the model (the character) is now nicely encapsulated. However, I don't know the exact ruleset and UI you're going for, so I'll leave it as-is.

This class also does things a little differently than yours: The game ends the moment there's only one character left alive; recently dead characters do not get a final swing.

\$\endgroup\$
4
  • \$\begingroup\$ What do you think of some form of interaction class. E.g. block/dodge/counter attack, if this gets more complex it might make sense to handle the send and receive part of the action as a series of commands? (Which could, for instance, be added to a character on level up). \$\endgroup\$
    – AJFaraday
    Mar 25, 2017 at 18:58
  • \$\begingroup\$ Also, fun fact: It appeasr good blocking beats good attacking... gist.github.com/AJFaraday/bc88960fe759d3812b3dd7c2135d4da1 \$\endgroup\$
    – AJFaraday
    Mar 25, 2017 at 19:10
  • \$\begingroup\$ I think you should keep the code as simple as possible but I agree that using interactor classes would quickly become a good idea as you add more types of attacks and blocks. \$\endgroup\$ Mar 25, 2017 at 20:11
  • \$\begingroup\$ @AJFaraday That could be a good way of dealing with complexity (see also Marc Rohloff's comment in this thread). For even more complex stuff, consider too, how the characters a built. It might make sense to mix-in behaviors as modules or a class hierarchy. Say Alice is acrobatic but thin-skinned; i.e. hard to hit, but takes more damage. \$\endgroup\$
    – Flambino
    Mar 25, 2017 at 20:23
1
\$\begingroup\$

O think it would be simpler if you made each turn only one attach, i.e. something like:

attacker, defender = alice, bob
while defender.alive?
  attacker.attack(defender)
  report_status
  # Swap roles
  attacker, defender = defender, attacker
end

An alternative, if you have multiple characters you could be to put them in an array and use rotate to go through one item in the array on each turn.

Update 1

I had kind of thought of suggesting something like:

characters = (1..4).map { Character.new }

while characters.length > 1
  attacker = characters.unshift
  defender = characters.sample
  attacker.attack(defender)
  report_status
  characters.delete(defender) if defender.dead?
  characters.push(attacker)
end

puts "#{characters.first.name}. You have won!"
\$\endgroup\$
1
  • \$\begingroup\$ Rotate is a good idea. Closer to the table-top RPGs I'm trying to model. You might want to look into using code formatting (four spaces at the start of each line), to monospace format your code example. \$\endgroup\$
    – AJFaraday
    Mar 27, 2017 at 21:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.