Search This Blog

Exploring Monster Taming Mechanics in Final Fantasy XIII-2: Filtering Monsters

We've finished building up views and controllers in the Rails MVC architecture for the various tables of monster data we have in this Final Fantasy XIII-2 monster taming mechanics series. However, some of the navigation for the latest monster details page and ability table pages is a bit clunky, so we're going to improve that today. First, when a link is clicked in the monster details page to go to the ability table, the clicked ability is highlighted, but the user needs to search through the table to find the highlighted row. We'll see how to go to the desired row directly. Then, we want to make the return trip from a link in the ability table take the user to a monster table that shows only the monsters that have that ability. Let's see how it's done.

Final Fantasy XIII-2 Battle Scene

Jump to a Table Row

It's not a pleasant experience to click on a link in the monster details page that takes us to one of the ability tables, and then have to search through that long table for the highlighted link. It would be much nicer if the link could take us to the highlighted row automagically. Luckily, that's not too hard to do with HTML anchors. In order to set up this page jump, we can first add an anchor to the default passive ability links in the monster detail page. This anchor name needs to be a string with no spaces, so we'll create a string like "ability100" using the ability's database ID. This code is near the end of app/views/monster/show.html.erb:
    <td><strong>Default</strong></td>
    <td>
      <% (1..4).each do |n| %>
        <% ability = @monster.send "default_passive#{n}" %>
        <% if ability %>
          <%= link_to ability.name,
            ability_index_path(anchor: "ability#{ability.id}", highlight: ability.name) %><br />
        <% end %>
      <% end %>
    </td>
Note that I added a variable ability to make the rest of the code more concise, and so we don't need to send the default passive string to the monster model over and over again. Otherwise, the only change was to add an :anchor value to the parameters sent to the ability_index_path helper function. This :anchor will end up as a hashtag at the end of the URL, and if we have a target that corresponds to that hashtag on the page that we're going to, the browser will jump down the page to that tag. To add the appropriate tags on the passive ability page, we simply add anchor tags (<a>) with id strings to each row in app/views/ability/index.html.erb:
  <% @abilities.each do |ability| %>
    <tr <%= "class=table-primary" if ability.name == @highlighted %>>
      <td><a id=<%= "ability#{ability.id}" %>><%= ability.name %></a></td>
      <td><%= ability.rank %></td>
      <td><%= ability.description %></td>
    </tr>
  <% end %>
</table>
With that done, we can test out our new feature by clicking on a default passive ability in any monster detail page:
Screenshot of monster ability table after jump to ability
Huh, that's funny. We definitely jumped further down the table, but where's the highlighted row? It turns out that it's behind the page header, and if we scroll up a bit, we'll find it right there. That's not the greatest experience in the world either, but it can be fixed with a little trick. All we have to do is increment the anchor ids in the table a few times, and we'll jump to the ability that many before the one we want:
  <% @abilities.each do |ability| %>
    <tr <%= "class=table-primary" if ability.name == @highlighted %>>
      <td><a id=<%= "ability#{ability.id + 3}" %>><%= ability.name %></a></td>
      <td><%= ability.rank %></td>
      <td><%= ability.description %></td>
    </tr>
  <% end %>
</table>
Now we can see the highlighted row after the jump:

Screenshot of monster ability table after jumping a little past the ability

So what happens if we try to jump to "ability1" now, which no longer exists? It turns out that's just fine because the browser will stay at the top of the page, and the highlighted first row will be visible anyway! Since the feature is working, we can quickly add anchors to the other abilities, like the default skills in app/views/monster/show.html.erb:
    <td>
      <% (1..8).each do |n| %>
        <% skill = @monster.send "default_skill#{n}" %>
        <% if skill %>
          <%= link_to skill.name,
            role_ability_index_path(anchor: "skill#{skill.id}", highlight: skill.name) %><br />
        <% end %>
      <% end %>
    </td>
And the rest of the level abilities and skills in app/views/monster/_level_ability.html.erb:
  <% if @monster.has_ability?(passive) || @monster.has_ability?(skill) %>
    <tr>
      <td><strong>Level <%= "%02d" % [n] %></strong></td>
      <td>
        <% if @monster.has_ability? passive %>
          <% ability = @monster.send passive %>
          <%= link_to ability.name,
            ability_index_path(anchor: "ability#{ability.id}", highlight: ability.name) %><br />
        <% end %>
      </td>
      <td/>
      <td>
        <% if @monster.has_ability? skill %>
          <% ability = @monster.send skill %>
          <%= link_to ability.name,
            role_ability_index_path(anchor: "skill#{ability.id}", highlight: ability.name) %><br />
        <% end %>
      </td>
    </tr>
  <% end %>
Finally, we need to add anchor tags to the role ability (skill) table in app/views/role_ability/index.html.erb:
  <% @role_abilities.each do |ability| %>
    <tr <%= "class=table-primary" if ability.name == @highlighted %>>
      <td><a id=<%= "skill#{ability.id+3}" %>><%= ability.name %></a></td>
      <td><%= ability.role %></td>
      <td><%= ability.infusable %></td>
    </tr>
  <% end %>
</table>
And with that, we have page jumps working for both ability tables. Notice that this was all done in the HTML template files. We didn't even have to touch the controllers or the models to make this feature happen.

Linking From Abilities to Monsters

The other feature we want to add to this website is a link back from the ability tables to the monster table, and in following these links, we want to filter the monster table down to only those monsters that have the selected ability. The tricky part about this feature is that the abilities can show up in any of the default ability or level ability properties for each monster, so we're going to have to be a bit more careful to do a complete search. The first thing we want to do, though, is create the ability links and add the filter to the URL when the links are clicked. We'll start with the passive ability table in app/views/ability/index.html.erb:
  <% @abilities.each do |ability| %>
    <tr <%= "class=table-primary" if ability.name == @highlighted %>>
      <td>
        <a id=<%= "ability#{ability.id+3}" %>>
          <%= link_to ability.name, monster_index_path(ability_filter: ability.name) %>
        </a>
      </td>
      <td><%= ability.rank %></td>
      <td><%= ability.description %></td>
    </tr>
  <% end %>
</table>
It's the same kind of link with a filter that we made when filtering by location, but since we already have that location filter, we want to name this filter something different so they won't conflict. Hence, ability_filter. The only other thing we need to do is use the filter in the controller, but we need to get creative about finding all of the monsters that have the ability in the filter so that we don't have to list all 100 levels of potential abilities like we did with locations. Here's one way to do it in app/controllers/monster_controller.rb:
class MonsterController < ApplicationController
  def index
    if params[:filter]
      location = Location.find_by(name: params[:filter])
      @monsters = location.location_monsters +
                  location.location2_monsters +
                  location.location3_monsters
    elsif params[:ability_filter]
      ability = Ability.find_by(name: params[:ability_filter])
      @monsters = ability.get_all_monsters
    else
      @monsters = Monster.all
    end
  end

  def show
    @monster = Monster.find(params[:id])
  end
end
The :ability_filter search has the same form as the :filter search, but what does this ability.get_all_monsters method do? All of the work for finding the monsters that have the specified ability is done in this method so that the logic that searches the model doesn't clutter up the controller. Instead, it's tucked away in the Ability model in app/models/ability.rb:
class Ability < ApplicationRecord
  # ... a whole mess of has_many macros ...

  def get_lv_monsters(n)
    lv_nn_passive_monsters = "lv_%02d_passive_monsters" % [n]
    respond_to?(lv_nn_passive_monsters) && send(lv_nn_passive_monsters) || []
  end

  def get_all_monsters()
    (1..4).map { |n| send "default_passive#{n}_monsters" }.flatten +
    (2..99).map{ |n| get_lv_monsters(n) }.flatten
  end
end
We can see that the get_all_monsters method collects the monsters for the four default passive ability attributes and then does something similar for the 99 level passive ability attributes using the get_lv_monsters(n) method. Sending the message of "default_passive#{n}_monsters" with n = 1..4 uses the has_many mechanisms set up in the ability model. Each has_many macro sets up a reference to each monster that has the selected ability as its first through fourth default passive abilities. This is where those macros come in handy, like they did when setting up the location filters before.

The search for the monsters that have passive abilities in the other levels has to be more careful because some level ability attributes don't exist at all. First, we use respond_to? to check that the method name exists. If it does, we move to the second part of the && operator and send the message to get the monsters with that level n ability. If there are any monsters, the list gets returned right away, but if there are no monsters with that level n ability, false would get returned. We don't want that, so we tack an "|| []" on the end of the line to return an empty list instead. All of these potential non-empty lists of monsters gets concatenated with map and then flattened.

We're now ready to test out this feature, and if we follow the first monster, Apkallu, to its details page and then click on the first default passive ability, we get a table of monsters that have the "Attack: ATB Charge" ability:

Screenshot of monster table filtered by ability

This table also happens to be sorted by where the ability appears in the default or level abilities because of how we constructed the list, so if you clicked on the Chichu at the end, you would see the "Attack: ATB Charge" show up as a level 15 ability, whereas the rest of the monsters have it as a default ability. That might be a nice thing to see in this table, but we won't try to figure out how to add that information right now. Instead, let's finish up by doing the same filtering for skills.

First, we add the links and filters to app/views/role_ability/index.html.erb:
  <% @role_abilities.each do |ability| %>
    <tr <%= "class=table-primary" if ability.name == @highlighted %>>
      <td>
        <a id=<%= "ability#{ability.id+3}" %>>
          <%= link_to ability.name, monster_index_path(skill_filter: ability.name) %>
        </a>
      </td>
      <td><%= ability.role %></td>
      <td><%= ability.infusable %></td>
    </tr>
  <% end %>
</table>
There's nothing new here. We're just copying what we did with the ability view and making sure we rename the filter to skill_filter. Then, we can add the call to the Role Ability model in app/controllers/monster_controller.rb:
class MonsterController < ApplicationController
  def index
    if params[:filter]
      location = Location.find_by(name: params[:filter])
      @monsters = location.location_monsters +
                  location.location2_monsters +
                  location.location3_monsters
    elsif params[:ability_filter]
      ability = Ability.find_by(name: params[:ability_filter])
      @monsters = ability.get_all_monsters
    elsif params[:skill_filter]
      ability = RoleAbility.find_by(name: params[:skill_filter])
      @monsters = ability.get_all_monsters
    else
      @monsters = Monster.all
    end
  end

  def show
    @monster = Monster.find(params[:id])
  end
end
Finally, we can add similar methods to the Role Ability model, taking care to make the adjustments for the differences in this model, in app/models/role_ability.rb:
class RoleAbility < ApplicationRecord
  # ... that mess of has_many macros again ...

  def get_lv_monsters(n)
    lv_nn_skill_monsters = "lv_%02d_skill_monsters" % [n]
    respond_to?(lv_nn_skill_monsters) && send(lv_nn_skill_monsters) || []
  end

  def get_all_monsters()
    (1..8).map { |n| send "default_skill#{n}_monsters" }.flatten +
    (2..99).map{ |n| get_lv_monsters(n) }.flatten
  end
end
And we've completed the filter for role abilities, a.k.a. skills. We can test it out by clicking on an ability link in the role abilities table, and see that yes, the monster table is filtered down to the monsters that have that skill, in this case adrenaline:

Screenshot of monster table filtered by adrenaline skill

At this point we're reaching a fairly complete website with this collection of tables that are connected together in important ways. There are a couple problematic tables, like the materials table isn't really connected to anything and the characteristics table is even more troublesome with monster characteristics showing up in multiple different monster properties. But overall, things are looking pretty fleshed out. We can get answers to quite a few questions now just by clicking around the tables and looking at the filtered monster table like, "Which monsters are in Bresha Ruins 100 AF?" or "Which monsters have the Critical: Haste passive ability, and where can I find them?" It's time to start thinking about how to answer some harder questions like, "What is the earliest place where I can get a monster with the Auto-Bravery passive ability?" We'll try to tackle that more complex beast next time.

No comments:

Post a Comment