SLIP INTO RUBY: UNDER THE HOOD PART 5: GAME OBJECT CONT.

In which we get into the exciting stuff.

  • Trihan
  • 04/15/2015 01:45 PM
  • 5319 views
Attention, Sports Fans! My hair is telling me it's time for another exciting episode of



Let's just get right to it, shall we?

Game_BaseItem

This is a class that handles general stuff for skills, items, weapons and armour.

def initialize
    @class = nil
    @item_id = 0
  end


Nice easy constructor to begin with: just sets two instance variables, @class and @item_id, to nil and 0 respectively.

def is_skill?;   @class == RPG::Skill;   end
  def is_item?;    @class == RPG::Item;    end
  def is_weapon?;  @class == RPG::Weapon;  end
  def is_armor?;   @class == RPG::Armor;   end
  def is_nil?;     @class == nil;          end


Here we have a set of methods to figure out what @class is currently. is_skill? returns true if its class is RPG::Skill, is_item? returns true if it's an RPG::Item, and so on. Finally, we have is_nil? which checks whether @class is in fact empty.

def object
    return $data_skills[@item_id]  if is_skill?
    return $data_items[@item_id]   if is_item?
    return $data_weapons[@item_id] if is_weapon?
    return $data_armors[@item_id]  if is_armor?
    return nil
  end


This is a getter for the current object. Returns the relevant piece of data from the database according to what class the object is. Note that given the default values, calling object before setting @class and @item_id is always going to return nil.

def object=(item)
    @class = item ? item.class : nil
    @item_id = item ? item.id : 0
  end


Setter for object, takes "item" as a parameter. Okay, so we set @class to the result of the above ternary operator: if item is not nil @class is set to its class, otherwise it's set to nil. Then we do the same thing for @item_id: if item is not nil, it's set to the item's ID parameter, or 0 otherwise.

def set_equip(is_weapon, item_id)
    @class = is_weapon ? RPG::Weapon : RPG::Armor
    @item_id = item_id
  end


Similar setter taking "is_weapon" and "item_id" as parameters. @class is set to RPG::Weapon if is_weapon is true, or RPG::Armor otherwise. @item_id is set to the supplied item_id value.

These methods all work pretty closely in tandem with each other like cogs in a machine. Let's say we create a "potion" in item database slot 1 and at some point in the code we have something like "@item = Game_BaseItem.new". We then do "@item.object = $data_items" to set object to the potion. @class is now RPG::Item and @item_id is now 1. is_item? is now going to return true (all the others will return false) and object will return the data of the item in question.

Game_Action

This class, as the name suggests, handles battle actions, and is used within Game_Battler, which we're getting ever-closer to covering.

Public instance variables ahoy!

attr_reader   :subject                  # action subject
  attr_reader   :forcing                  # forcing flag for battle action
  attr_reader   :item                     # skill/item
  attr_accessor :target_index             # target index
  attr_reader   :value                    # evaluation value for auto battle


Okay, so we've got an attr_reader for the subject of the action, an attr_reader as a flag to determine whether the action is being "forced", an attr_reader for the skill/item being used, an attr_accessor for the index of the action's target, and an attr_reader to determine action priority for autobattle. With me so far?

def initialize(subject, forcing = false)
    @subject = subject
    @forcing = forcing
    clear
  end


The constructor takes two arguments: subject, which is obviously going to be an instance of Game_Battler, and forcing, which defaults to false if no value is supplied. Sets the appropriate instance variables and then calls the clear method.

def clear
    @item = Game_BaseItem.new
    @target_index = -1
    @value = 0
  end


Simple enough. Sets @item to a new instance of Game_BaseItem, @target_index to -1 (generally if you have an index variable somewhere, the programmer's convention for resetting it to point to nothing is to make its value -1. There's a name for this but I can't remember it), and @value to 0.

def friends_unit
    subject.friends_unit
  end


Simply calls the method of the same name (which is a method of Game_Actor and Game_Enemy, as we'll see soon).

def opponents_unit
    subject.opponents_unit
  end


Same thing, but for enemies.

def set_enemy_action(action)
    if action
      set_skill(action.skill_id)
    else
      clear
    end
  end


This method sets battle actions for enemies, and takes as its parameter "action", which must be of type RPG::Enemy::Action. You can look this up in the help file, but we'll be going over it when we get to Game_Enemy (it's gonna get a bit complicated).

def set_attack
    set_skill(subject.attack_skill_id)
    self
  end


This method sets the action to "attack"; simple enough, it calls set_skill supplying the attack_skill_id of the subject (which by default is 1 for every battler, though this can be changed to give different characters different default attack skills if that's something you want in your game) and returns self, which is the action calling the method.

def set_guard
    set_skill(subject.guard_skill_id)
    self
  end


Same thing but for guarding, which is 2 in the skill list by default (and can again be changed to give different characters different default guarding commands)

def set_skill(skill_id)
    @item.object = $data_skills[skill_id]
    self
  end


This is what we call in both of the preceding methods and for any skill being used that isn't attack or guard: it takes skill_id as a parameter, and sets @item.object to the data of the skill_idth skill in the database (remember my potion example?)

def set_item(item_id)
    @item.object = $data_items[item_id]
    self
  end


Same thing for for when an item is used. We already know from looking at Game_BaseItem that the behind-the-scenes stuff for checking classes and what kind of object has been set will be done there for us.

def item
    @item.object
  end


This is more of a shortcut method than anything else; means that anywhere in Game_Action we needed to refer to @item.object, we can just put item instead.

def attack?
    item == $data_skills[subject.attack_skill_id]
  end


Such as here! This method returns true if item (which returns @item.object) is equal to the skill set as default attack in the database.

def decide_random_target
    if item.for_dead_friend?
      target = friends_unit.random_dead_target
    elsif item.for_friend?
      target = friends_unit.random_target
    else
      target = opponents_unit.random_target
    end
    if target
      @target_index = target.index
    else
      clear
    end
  end


And again we're thankful for the item method. Okay, so.

If the item's scope is set to One Ally (Dead)...
set target to a random dead party member
Otherwise, if the item's scope is One Ally...
set target to a random living party member
Otherwise...
set target to a random enemy
If a target has been set...
set @target_index to the index of the target
Otherwise...
call clear

I guess now is a great time for me to explain a bit more about methods which exist solely to save time and typing for poor, weary programmers. Take the line

"target = friends_unit.random_dead_target"

We could just as easily have written

"target = subject.friends_unit.random_dead_target"

And same with

"if item.for_dead_friend?"

Could have been

"if @item.object.for_dead_friend?"

But obviously this results in more code and the more you use that particular object the more code you're going to have to write. Sometimes it'll give you a negligible benefit but for something like @item.object which is probably going to be used quite a few times it makes sense to make a method with a shorter name to point to it.

Note that for_dead_friend?, for_friend? etc. are built-in methods of RPG::UsableItem, which is the superclass of RPG::BaseItem. The full source code for this class is in the help file.

def set_confusion
    set_attack
  end


Simple enough, if set_confusion is called the action is set to a normal attack.

def prepare
    set_confusion if subject.confusion? && !forcing
  end


Method for action preparation. Calls set_confusion if the subject is confused and not being forced into an action.

def valid?
    (forcing && item) || subject.usable?(item)
  end


Method to determine whether an action is valid. Returns true if EITHER the action is being forced and item has data in it OR the subject can use the item in question.

def speed
    speed = subject.agi + rand(5 + subject.agi / 4)
    speed += item.speed if item
    speed += subject.atk_speed if attack?
    speed
  end


This is where we calculate action speed. Base speed is the subject's AGI stat + (a random integer between 0 and a 5 + (a quarter of the agi)). For example, given an initial agi of 20 the Soldier class has base speed 20 + (random number from 0 to 10). If there's an item or skill being used, we add the item's speed as set in the database. If the subject is attacking, we add the subject's attack speed (which will only be relevant if their character class has an attack speed set in features) and finally return the value of speed.

def make_targets
    if !forcing && subject.confusion?
      [confusion_target]
    elsif item.for_opponent?
      targets_for_opponents
    elsif item.for_friend?
      targets_for_friends
    else
      []
    end
  end


Here we're creating an array of possible targets. If we're not forcing a battle action and the subject is confused, return an array of the result from confusion_target. Otherwise, if the item's target is one opponent, return the result of targets_for_opponents. Otherwise, if the item's target is one ally, return the result of targets_for_friends. Otherwise, return an empty array.

def confusion_target
    case subject.confusion_level
    when 1
      opponents_unit.random_target
    when 2
      if rand(2) == 0
        opponents_unit.random_target
      else
        friends_unit.random_target
      end
    else
      friends_unit.random_target
    end
  end


Method for determining a confused battler's target. Checks the target's confusion level: if it's 1, just picks a random enemy. If it's 2, generate a random integer between 0 and 2 (so 0 or 1); if it's 0, pick a random enemy, otherwise pick a random ally. If the confusion level is anything else, pick a random ally. (note that confusion level corresponds to the "restriction" setting for the state that caused confusion.)

def targets_for_opponents
    if item.for_random?
      Array.new(item.number_of_targets) { opponents_unit.random_target }
    elsif item.for_one?
      num = 1 + (attack? ? subject.atk_times_add.to_i : 0)
      if @target_index < 0
        [opponents_unit.random_target] * num
      else
        [opponents_unit.smooth_target(@target_index)] * num
      end
    else
      opponents_unit.alive_members
    end
  end


Determining enemy targets. If the item is for 1, 2, 3 or 4 random enemies, return a new array with (number of targets) elements. Each element will be set to a random opponent.
Otherwise, if the item is for 1 enemy:
set a temporary variable called num to 1 + the subject's number of additional attacks if they're attacking normally, 0 otherwise (so num will be equal to the number of attacks the subject would normally get in a turn)
If the @target_index is less than 0 (in other words, no targets have been chosen for the action) return an array of num random targets.
Otherwise, return an array of num targets smoothed out (the smooth_target method makes sure we're not trying to attack dead enemies and if so makes us attack the first living enemy instead, as you'll see soon).
Otherwise:
return the entire list of living enemies (if it's not for random and it's not for 1, it has to be for all enemies)

def targets_for_friends
    if item.for_user?
      [subject]
    elsif item.for_dead_friend?
      if item.for_one?
        [friends_unit.smooth_dead_target(@target_index)]
      else
        friends_unit.dead_members
      end
    elsif item.for_friend?
      if item.for_one?
        [friends_unit.smooth_target(@target_index)]
      else
        friends_unit.alive_members
      end
    end
  end


This one's almost exactly the same but has clauses for the user and a dead ally and lacks random (since that isn't a setting you can choose in the database for items or skills) If it's for the user it returns subject, if it's for 1 dead ally it returns the smoothed out target (making sure they're not alive and if so changes the target to the first dead ally) or all dead members otherwise (since it must be for all dead allies). If it's for 1 ally return the smoothed out target, otherwise return all living party members (since if it gets here it has to be set to all allies).

def evaluate
    @value = 0
    evaluate_item if valid?
    @value += rand if @value > 0
    self
  end


This is for evaluating value of actions for autobattle. We call evaluate_item if we have a valid action set, and then if @value is greater than 0 (which it may be after calling evaluate_item) we add to it a random number between 0 and 1, then return self.

def evaluate_item
    item_target_candidates.each do |target|
      value = evaluate_item_with_target(target)
      if item.for_all?
        @value += value
      elsif value > @value
        @value = value
        @target_index = target.index
      end
    end
  end


For each possible candidate target for the current action (referred to via the block variable "target"):
value is set to the result of evaluate_item_with_target, which checks what the result of that action will end up being and returns the percentage of damage/healing.
If the item was for all targets, add value to @value
Otherwise, if value is greater than @value
set @value to value
set @target_index to the index of the target

Once I cover the last two methods, I think it might help to run an example.

def item_target_candidates
    if item.for_opponent?
      opponents_unit.alive_members
    elsif item.for_user?
      [subject]
    elsif item.for_dead_friend?
      friends_unit.dead_members
    else
      friends_unit.alive_members
    end
  end


If the item's target is enemies, return the living enemies. If it's for the user, return subject. If it's for a dead ally, return the dead allies. Otherwise, return the living allies.

def evaluate_item_with_target(target)
    target.result.clear
    target.make_damage_value(subject, item)
    if item.for_opponent?
      return target.result.hp_damage.to_f / [target.hp, 1].max
    else
      recovery = [-target.result.hp_damage, target.mhp - target.hp].min
      return recovery.to_f / target.mhp
    end
  end


Clear target's action result (every battler has one of these built-in when created) then call make_damage_value supplying subject and item (the user and the item/skill) which calculates how much damage the item or skill will do. If the item or skill's target is opponents, return the percentage of the target's HP this action would take away. Otherwise, calculate HP recovery and return what percentage of HP the target would recover.

Phew! Okay, let's step through this.

evaluate is called when a battler is putting together its list of candidate actions for autobattle, which we'll see when we get to Game_Battler. So let's say we're in autobattle with Isabelle, who has usable skills Attack, Guard, Fire and Sleep.

First Attack is evaluated. @value is set to 0, then we call evaluate_item.

item_target_candidates will end up being the list of living enemies; for sake of example, say we're fighting 2 Slimes.

First iteration will be dealing with slime #1:

value is set to the result of evaluate_item_with_target:

target (slime 1's) battle result is cleared.
we call make_damage_value, supplying Isabelle and Attack)

The actual damage algorithm doesn't matter, but say it determines that she's going to deal 20 damage. Slime 1 has 120HP, so we return (20.0 / 120), or 0.17 rounded.

Item is not for all, so we move to the else, which checks whether value > @value. 0.17 is greater than 0, so this is true. We set @value to 0.17 and @target_index to 1 (since it's the first enemy)

Now we're into the second iteration with Slime #2.

Let's say that make_damage_value now determines 23 damage will be done. The return value from evaluate_item_with_target is 0.19 rounded.

Again we're checking whether value > @value; 0.19 is greater than 0.17, so it's true. We set @value to 0.19 and @target_index to 2.

After this, since @value is greater than 0 it will have added to it a random number between 0 and 1.

We run through exactly the same process for Guard, Fire and Fire 2. The bottom line is that whichever action results in the greatest potential damage or recovery percentage is the one that will end up being chosen. (note that this does not reflect what the attack will -actually- do, just what a potential use of it resulted in). I didn't actually realise autobattle worked like this until picking through the code, so I learned something today too!

Game_ActionResult

This class handles the results of actions in battles, and as I said before is used internally within Game_Battler; on construction every battler receives its own new instance of Game_ActionResult.

On with the public instance variables!

attr_accessor :used                     # used flag
  attr_accessor :missed                   # missed flag
  attr_accessor :evaded                   # evaded flag
  attr_accessor :critical                 # critical flag
  attr_accessor :success                  # success flag
  attr_accessor :hp_damage                # HP damage
  attr_accessor :mp_damage                # MP damage
  attr_accessor :tp_damage                # TP damage
  attr_accessor :hp_drain                 # HP drain
  attr_accessor :mp_drain                 # MP drain
  attr_accessor :added_states             # added states
  attr_accessor :removed_states           # removed states
  attr_accessor :added_buffs              # added buffs
  attr_accessor :added_debuffs            # added debuffs
  attr_accessor :removed_buffs            # removed buffs/debuffs


I'm not going to dwell too much on these; they're pretty much all flags or values to show that certain things have happened in battle, and are for the most part self-explanatory.

def initialize(battler)
    @battler = battler
    clear
  end


Constructor takes battler as a parameter and sets @battler to the supplied value. This means that every battler has a Game_ActionResult with @battler set to the object of the battler it belongs to.

def clear
    clear_hit_flags
    clear_damage_values
    clear_status_effects
  end


Pretty self-explanatory. clear clears all the flags so that results from a particular action aren't carried over to the next one.

def clear_hit_flags
    @used = false
    @missed = false
    @evaded = false
    @critical = false
    @success = false
  end


This method clears the flags for an action being used, missing, being evaded, being a critical hit and being successful.

def clear_damage_values
    @hp_damage = 0
    @mp_damage = 0
    @tp_damage = 0
    @hp_drain = 0
    @mp_drain = 0
  end


This method sets all damage and drain values to 0.

def make_damage(value, item)
    @critical = false if value == 0
    @hp_damage = value if item.damage.to_hp?
    @mp_damage = value if item.damage.to_mp?
    @mp_damage = [@battler.mp, @mp_damage].min
    @hp_drain = @hp_damage if item.damage.drain?
    @mp_drain = @mp_damage if item.damage.drain?
    @hp_drain = [@battler.hp, @hp_drain].min
    @success = true if item.damage.to_hp? || @mp_damage != 0
  end


This method sets the damage values and takes two parameters: value, which is the damage done, and item, which is the skill or item that was used.

@critical is set to false if the damage value is 0 (you can't have a no-damage crit)
If the item's properties are set to damage HP, @hp_damage is set to value.
Likewise for MP damage (which is set to the minimum value between the MP damage and the battler's MP; we don't want to damage more MP than the target has), HP drain, and MP drain (which follows the same min damage principle)
@success is set to true if the item's damage settings have an effect on HP OR of the MP damage is not equal to 0.

def clear_status_effects
    @added_states = []
    @removed_states = []
    @added_buffs = []
    @added_debuffs = []
    @removed_buffs = []
  end


Clears any added/removed states, buffs or debuffs.

def added_state_objects
    @added_states.collect {|id| $data_states[id] }
  end


Gets added states an an array of objects. The actual array only contains the state IDs.

def removed_state_objects
    @removed_states.collect {|id| $data_states[id] }
  end


Same principle for removed states.

def status_affected?
    !(@added_states.empty? && @removed_states.empty? &&
      @added_buffs.empty? && @added_debuffs.empty? && @removed_buffs.empty?)
  end


Determines whether a status effect has happened. Returns true if the result is false to the logical check that ALL of the instance variables tracking states, buffs and debuffs are empty. (in other words, at least one of them isn't)

def hit?
    @used && !@missed && !@evaded
  end


Determines whether the action hit. Returns true if the action was used AND the action didn't miss AND the action wasn't evaded.

def hp_damage_text
    if @hp_drain > 0
      fmt = @battler.actor? ? Vocab::ActorDrain : Vocab::EnemyDrain
      sprintf(fmt, @battler.name, Vocab::hp, @hp_drain)
    elsif @hp_damage > 0
      fmt = @battler.actor? ? Vocab::ActorDamage : Vocab::EnemyDamage
      sprintf(fmt, @battler.name, @hp_damage)
    elsif @hp_damage < 0
      fmt = @battler.actor? ? Vocab::ActorRecovery : Vocab::EnemyRecovery
      sprintf(fmt, @battler.name, Vocab::hp, -hp_damage)
    else
      fmt = @battler.actor? ? Vocab::ActorNoDamage : Vocab::EnemyNoDamage
      sprintf(fmt, @battler.name)
    end
  end


Method for getting HP damage display text. If HP was drained, set fmt to Vocab::ActorDrain if the battler is an actor, Vocab::EnemyDrain otherwise. (which will either give us "%s was drained of %s %s!" or "Drained %s %s from %s!") then return sprintf(fmt, the name of the battler, Vocab::hp, and the value of the drain).

Example: If a Slime drains 21HP from Isabelle, it will say "Isabelle was drained of HP 21!" If you'd rather have a more natural-sounding drain message, change the string in Vocab to "%s had %s %s drained!" and then change the line here to sprintf(fmt, @battler.name, @hp_drain, Vocab::HP).

Exactly the same processes are followed for HP damage, HP recovery, and no damage.

def mp_damage_text
    if @mp_drain > 0
      fmt = @battler.actor? ? Vocab::ActorDrain : Vocab::EnemyDrain
      sprintf(fmt, @battler.name, Vocab::mp, @mp_drain)
    elsif @mp_damage > 0
      fmt = @battler.actor? ? Vocab::ActorLoss : Vocab::EnemyLoss
      sprintf(fmt, @battler.name, Vocab::mp, @mp_damage)
    elsif @mp_damage < 0
      fmt = @battler.actor? ? Vocab::ActorRecovery : Vocab::EnemyRecovery
      sprintf(fmt, @battler.name, Vocab::mp, -@mp_damage)
    else
      ""
    end
  end


The method for MP damage text is almost identical to the one for HP, with the exception of the final else: if there's no MP damage it just doesn't say anything, rather than saying there was no damage.

def tp_damage_text
    if @tp_damage > 0
      fmt = @battler.actor? ? Vocab::ActorLoss : Vocab::EnemyLoss
      sprintf(fmt, @battler.name, Vocab::tp, @tp_damage)
    elsif @tp_damage < 0
      fmt = @battler.actor? ? Vocab::ActorGain : Vocab::EnemyGain
      sprintf(fmt, @battler.name, Vocab::tp, -@tp_damage)
    else
      ""
    end
  end


Another almost identical one for TP damage.

And that's it for this week! As always, comments are welcome, questions will be answered, and bagels will be eaten. Until next time.

Posts

Pages: 1
Pages: 1