PyGame: Returning to Combat

30 April 2012

Where am I? Here!

Revisiting admin.py

So, remember how I joked that Pythonistas froth at the mouth over Pickles? Yeah, my admin module (which was just a bit of trash code to be used while making the game) was easily the most contraversial bit of code I churned out. This is why working on open source is a good thing, though! It inspired someone to completely rewrite it! Thanks, Ian!

The new and improved admin.py:

import sys
import xml.etree.ElementTree as etree
from xml.dom.minidom import parseString

sys.path.append("roguey/classes")

from items import Treasure
from constants import *

def prettify(element):
  # Helper function to make XML look more prettier
    txt = etree.tostring(element)
    return parseString(txt).toprettyxml()

class Admin(object):
  def __init__(self):
    # Load the existing treasures
    f = open("roguey/resources/items.xml")
    self.treasures = etree.fromstring(f.read())
    f.close()
    # trim the annoying whitespace...
    self.treasures.text = ""
    for element in self.treasures.iter():
      element.text = element.text.strip()
      element.tail = ""
    # Load the list of treasure type templates
    f = open("roguey/resources/item_templates.xml")
    self.treasure_templates = etree.fromstring(f.read())
    f.close()
    # Enter main loop
    self.running = True
    self.main()   

  def new_treasure(self):
    item_attributes = {}  # This will hold optional stats only.

    template_options = [
      template.find("item_type").text for template in self.treasure_templates
    ]

    # Gather the mandatory attributes
    selection = self.prompt_for_selection(
      prompt="Choose an item type",
      options=template_options
    )
    item_type = template_options[selection]
    template = self.treasure_templates[selection]

    title = raw_input("Give it a title: ")
    description = raw_input("Give it a description: ")

    # Check if this template requires any additional attributes
    for attr in template:
      if attr.tag == "item_type":
        continue
      prompt = attr.attrib["prompt"]
      value_type = attr.attrib.get("type", "string")  # type defaults to "string" if not specified
      item_attributes[attr.tag] = (raw_input("%s (%s): " % (prompt, value_type)), value_type)

    # finally we can add this new item to the list
    new_item = etree.SubElement(self.treasures, "item")
    etree.SubElement(new_item, "item_type").text = item_type
    etree.SubElement(new_item, "title").text = title
    etree.SubElement(new_item, "description").text = description
    for attrib, value in item_attributes.iteritems():
      optional_stat = etree.SubElement(new_item, attrib)
      optional_stat.text, optional_stat.attrib["type"] = value

  def list_treasures(self):
    for treasure in self.treasures:
      print treasure.find('title').text.strip()

  def save_and_quit(self):
    f = open("roguey/resources/items.xml", "w")
    f.write(prettify(self.treasures))
    f.close()
    self.running = False

  def delete_treasure(self):
    pass

  def main(self):
    menu_options_with_actions = [
      ("Make a new treasure", self.new_treasure),
      ("List current treasures", self.list_treasures),
      ("Delete a treasure", self.delete_treasure),
      ("Quit", self.save_and_quit),
    ]
    menu_options = [x[0] for x in menu_options_with_actions]
    menu_prompt = "Make a choice"

    while self.running:
      selection = self.prompt_for_selection(menu_prompt, menu_options)
      # Call the appropriate action based on the user selection
      menu_options_with_actions[selection][1]()

  def prompt_for_selection(self, prompt, options):
    """Given a list of options and a prompt,
    get the users selection and return the index of the selected option
    """
    # Print out the numbered options
    for i, option in enumerate(options):
      print "%3s. %s" % (i+1, option)
    # Get the users selection
    selection = raw_input("%s: " % prompt)
    return int(selection)-1

if __name__ == "__main__":
  a = Admin()

It's been updated to use an XML file, which is semi-human-readable, and the code for generating the menu has been made cleaner. Woot!

Planning ahead

Katie sits before a desk with crumpled paper on it. She holds up another piece of paper, a joyous look on her face. Caption: A plan! Now, surely things won't go to shit!

At one point, I had this lovely roadmap of what I was going to do, when, for my roguelike. That list is about as relevant today as a midieval monk's advice on what smartphone I should get. I quickly found that a round-robin approach was working much better for me. I was able to crank out enough for a blog post each week, and I wasn't getting caught up on smaller details that relied on other modules being done.

A list isn't a bad thing to have, though. You just have to respect that the universe hates a tidy plan.

As the game has grown more complex, however, I've found I've had to stop and think about what bit I need to develop first more often. Modules are starting to affect each other more, and the changes I'm making are more subtle. For example, when do I want to add animation back in? After I've added all my monsters? Before I put darkness back? Hm, when do I want to add darkness back? How about levels?

It's tempting fate, but I put together a rough roadmap for the rest of game:

  • New Monster - Aggro
  • Class, leveling up, and more stats
  • Item update: buffs and potions
  • Inventory - equip, de-equip
  • Add levels to dungeon
  • Content explosion, scrolling maps
  • Art update
  • Put darkness back in
  • Animation

You may now take bets on how well I'll adhere to this.

Combat

Combat wasn't broken in the sense that it didn't work: it was broken in the sense that it worked in spite of the way it was written. In order to get it ready for new monsters, classes, stats, and levelling up, it needed some serious work. I needed to use proper settings and getters, because now my items had meaning. First, I had to update Player.

First, I decided that having stats as their own property was getting an little unweildy. It was much better to put them into a dictionary, for later spitting out onto the screen.

def __init__(self):
  self.level = 1
  self.stats = {
    'strength': 1,
    'attack': 1,
    'defense': 5
  }
  self.current_hp = 10
  self.name = "Dudeguy McAwesomesauce"
  self.equipped = {}

  for treasure in EQUIPMENT_TYPES:
    self.equipped[treasure] = None

Now, throughout my code, I'd referred to the 'strength' and 'defense' attributes. Rather than hunt them all down, I decided to name my new properties the same thing.

@property
  def defense(self):
      return self.stats['defense'] + self.get_armor()

  @property
  def strength(self):
      return self.stats['strength']

  def get_armor(self):
      armor = 0
      for slot in self.equipped.keys():
        if self.equipped[slot]:
          try:
            armor += self.equipped[slot].armor
          except AttributeError:
            # The Treasure in this slot doesn't have an 'armor' attribute
            pass
          except TypeError:
            # The Treasure in this slot has an armor stat, however it
            # appears to be a non numeric type. Make sure this Treasures armor stat
            # has the attribute type="int" in its XML definition.
            # This sort of error should probably get properly logged somewhere
            print (
              'Please make sure the armor stat for the "%s" weapon '
              'has the type="int" attribute in its XML definition' % self.equipped[slot].title
            )
      return armor

The properties can now be called like any other attribute:

>>> from player import Player
>>> p = Player()
>>> p.strength
1
>>> p.defense
5

Now that the Player is working well, I need to update my Monster class to work the same:

class Monster(object):
  def __init__(self):
    self.level = 0
    self.stats = {
      'attack': 0,
      'defense': 0,
      'strength': 0,
    }
    self.max_hp = 1
    self.current_hq = self.max_hp

  @property
  def attack(self):
    return self.stats['attack']

  def receive_damage(self, damage):
    self.current_hp -= damage

  @property
  def defense(self):
    return self.stats['defense']

  @property
  def strength(self):
    return self.stats['strength']

class Derpy(Monster):
  def __init__(self):
    self.title = "Derpy Slime"
    self.level = 1
    self.stats ={
      'attack': 5,
      'defense': 1,
      'strength': 1,
    }
    self.current_hp = 3
    self.max_hp = 3

Hm. I'm starting to see overlap here (well, to be honest, I saw overlap a while back). I'll make a note to keep an eye on how alike these two things stay, and if it would be worth it to make a new base class when the time comes to refactor.

Now, let's update combat!

*looks at combat module*

And nothing there needs to be changed.

class Combat(object):

  def __init__(self, player, monster):
    self.player = player
    self.monster = monster
    self.fight()

  def fight(self):
    '''For now, we always start with the player.
    '''
    # Player, try to hit the monster!
    hit_attempt = randint(0, self.player.attack)
    if hit_attempt > self.monster.defense:
      damage = self.player.strength
      self.monster.receive_damage(damage)
    else:
      pass

    # Monster, try to hit back.
    if self.monster.current_hp > 0:
      hit_attempt = randint(0, self.monster.attack)
      if hit_attempt > self.player.defense:
        damage = self.monster.strength
        self.player.receive_damage(damage)
      else:
        pass

The only thing I re-jiggered was having combat call fight itself, since there's really no other reason for me to call Combat, and removed some unnecessary checks. Everything is set up, now, for our next addition...

Next time!

We add smart mobs!

Share

Related tags: pygame python

Comments

1 Rich Jones says...

Thank you so much for writing this - I love Roguelikes and I love python.

Anywhere we can follow your progress on GitHub?

Posted at 12:36 p.m. on April 30, 2012

2 Rich Jones says...

Oh, yes we can: https://github.com/kcunning/Katie-s-Rougish-PyGame

Posted at 12:40 p.m. on April 30, 2012

3 elliot says...

I was having some trouble with the items in constants.py not being recognized by the other modules.

After reading this http://stackoverflow.com/questions/10095037/why-use-sys-p... I wondered if it was trying to read PyGame's constants.py file

In main.py I changed sys.path.append("roguey/classes")

to sys.path.insert(1,"roguey/classes")

and the game runs just fine now, not sure if that was happening for anyone else or I just set it up incorrectly.

Posted at 2:10 p.m. on May 16, 2012

Comments are closed.

Comments have been closed for this post.