Data-Driven Design
I submitted something like this question to code review recently.
After some suggestions and improvements, the result was a simple code that would allow some relative flexibility on weapon creation based on a dictionary (or JSON). The data is interpreted at runtime and simple verifications are done by the Weapon
class itself, without the need to rely on a whole script interpreter.
Data-Driven Design, despite Python being an interpreted language (both source and data files can be edited without the need to recompile them), sounds like the right thing to do in cases such as the one you presented. This question goes into more details about the concept, its pros and cons. Theres also a nice presentation on Cornell University about it.
Compared with other languages, such as C++, that would probably use a scripting language (such as LUA) to handle data x engine interaction and scripting in general, and a certain data format (like XML) to store the data, Python can actually do it all on its own (considering the standard dict
but also weakref
, the latter specifically for resource loading and caching).
An independent developer, however, may not take the data-driven approach to its extreme as suggested on this article:
How much about data-driven design am I? I don't think a game engine should contain a single line of game-specific code. Not one. No hardcoded weapon types. No hardcoded HUD layout. No hardcoded unit AI. Nada. Zip. Zilch.
Maybe, with Python, one could benefit from the best of both object-oriented and data-driven approach, aiming for both productivity and extensibility.
Simple sample processing
In the specific case discussed on code review, a dictionary would store both the "static attributes" and the logic to be interpreted - should the weapon have any conditional behavior.
On the example below a sword should have some abilities and stats in the hands of characters of the class 'antipaladin', and no effects, with lower stats when used by other characters):
WEAPONS = {
"bastard's sting": {
# magic enhancement, weight, value, dmg, and other attributes would go here.
"magic": 2,
# Those lists would contain the name of effects the weapon provides by default.
# They are empty because, in this example, the effects are only available in a
# specific condition.
"on_turn_actions": [],
"on_hit_actions": [],
"on_equip": [
{
"type": "check",
"condition": {
'object': 'owner',
'attribute': 'char_class',
'value': "antipaladin"
},
True: [
{
"type": "action",
"action": "add_to",
"args": {
"category": "on_hit",
"actions": ["unholy"]
}
},
{
"type": "action",
"action": "add_to",
"args": {
"category": "on_turn",
"actions": ["unholy aurea"]
}
},
{
"type": "action",
"action": "set_attribute",
"args": {
"field": "magic",
"value": 5
}
}
],
False: [
{
"type": "action",
"action": "set_attribute",
"args": {
"field": "magic",
"value": 2
}
}
]
}
],
"on_unequip": [
{
"type": "action",
"action": "remove_from",
"args": {
"category": "on_hit",
"actions": ["unholy"]
},
},
{
"type": "action",
"action": "remove_from",
"args": {
"category": "on_turn",
"actions": ["unholy aurea"]
},
},
{
"type": "action",
"action": "set_attribute",
"args": ["magic", 2]
}
]
}
}
For testing purposes, I created simple Player
and Weapon
classes: the first to hold/equip the weapon (thus calling its conditional on_equip setting) and the latter as a single class that would retrieve the data from the dictionary, based on the item name passed as an argument during the Weapon
initialization. They do not reflect the proper game classes design, but can still be useful to test the data:
class Player:
"""Represent the player character."""
inventory = []
def __init__(self, char_class):
"""For this example, we just store the class on the instance."""
self.char_class = char_class
def pick_up(self, item):
"""Pick an object, put in inventory, set its owner."""
self.inventory.append(item)
item.owner = self
class Weapon:
"""A type of item that can be equipped/used to attack."""
equipped = False
action_lists = {
"on_hit": "on_hit_actions",
"on_turn": "on_turn_actions",
}
def __init__(self, template):
"""Set the parameters based on a template."""
self.__dict__.update(WEAPONS[template])
def toggle_equip(self):
"""Set item status and call its equip/unequip functions."""
if self.equipped:
self.equipped = False
actions = self.on_unequip
else:
self.equipped = True
actions = self.on_equip
for action in actions:
if action['type'] == "check":
self.check(action)
elif action['type'] == "action":
self.action(action)
def check(self, dic):
"""Check a condition and call an action according to it."""
obj = getattr(self, dic['condition']['object'])
compared_att = getattr(obj, dic['condition']['attribute'])
value = dic['condition']['value']
result = compared_att == value
self.action(*dic[result])
def action(self, *dicts):
"""Perform action with args, both specified on dicts."""
for dic in dicts:
act = getattr(self, dic['action'])
args = dic['args']
if isinstance(args, list):
act(*args)
elif isinstance(args, dict):
act(**args)
def set_attribute(self, field, value):
"""Set the specified field with the given value."""
setattr(self, field, value)
def add_to(self, category, actions):
"""Add one or more actions to the category's list."""
action_list = getattr(self, self.action_lists[category])
for action in actions:
if action not in action_list:
action_list.append(action)
def remove_from(self, category, actions):
"""Remove one or more actions from the category's list."""
action_list = getattr(self, self.action_lists[category])
for action in actions:
if action in action_list:
action_list.remove(action)
With some future improvement I hope that this will even allow me to have a dynamic crafting system someday, processing weapon components instead of whole weapons...
Test
- Character A picks weapon, equip it (we print its stats), then drop it;
- Character B picks the same weapon, equip it (and we print its stats again to show how they are different).
Like this:
def test():
"""A simple test.
Item features should be printed differently for each player.
"""
weapon = Weapon("bastard's sting")
player1 = Player("bard")
player1.pick_up(weapon)
weapon.toggle_equip()
print("Enhancement: {}, Hit effects: {}, Other effects: {}".format(
weapon.magic, weapon.on_hit_actions, weapon.on_turn_actions))
weapon.toggle_equip()
player2 = Player("antipaladin")
player2.pick_up(weapon)
weapon.toggle_equip()
print("Enhancement: {}, Hit effects: {}, Other effects: {}".format(
weapon.magic, weapon.on_hit_actions, weapon.on_turn_actions))
if __name__ == '__main__':
test()
It should print:
For a bard
Enhancement: 2, Hit effects: [], Other effects: []
For a antipaladin
Enhancement: 5, Hit effects: ['unholy'], Other effects: ['unholy aurea']