RPG Systems Implementation (continued)
response2.add_action('give_quest', 'main_1')
self.npcs.append({
'name': 'Village Elder',
'x': 200,
'y': 200,
'dialogue': root
})
def use_item(self, item: Item):
"""Use consumable item"""
if item.item_type == ItemType.CONSUMABLE:
if item.effect == 'heal':
heal_amount = item.value or 50
self.character.hp = min(self.character.max_hp,
self.character.hp + heal_amount)
return True
elif item.effect == 'mana':
mana_amount = item.value or 30
self.character.mp = min(self.character.max_mp,
self.character.mp + mana_amount)
return True
return False
def perform_attack(self, attacker: Character, target: Dict) -> int:
"""Perform combat attack"""
# Base damage calculation
base_damage = attacker.get_attack_power()
defense = target.get('defense', 0)
# Random variation
damage_variance = random.randint(-5, 5)
damage = max(1, base_damage - defense + damage_variance)
# Critical hit check
if random.random() * 100 < attacker.get_crit_chance():
damage *= 2
print(f"Critical hit! {damage} damage!")
# Apply damage
target['hp'] -= damage
return damage
def update_quest_progress(self, quest_type: str, amount: int = 1):
"""Update quest progress"""
for quest in self.quests:
if quest.completed:
continue
for i, obj in enumerate(quest.objectives):
if quest_type in obj['text'].lower():
quest.update_progress(i, amount)
if quest.is_complete():
self.complete_quest(quest)
def complete_quest(self, quest: Quest):
"""Complete a quest and give rewards"""
if quest.rewards['exp']:
self.character.gain_experience(quest.rewards['exp'])
if quest.rewards['gold']:
self.character.gold += quest.rewards['gold']
for item_name in quest.rewards.get('items', []):
# Create and add reward items
item = Item(item_name, ItemType.WEAPON, "🎁")
self.inventory.add_item(item)
print(f"Quest completed: {quest.name}")
def save_game(self, filename: str = "savegame.json"):
"""Save game state"""
save_data = {
'character': asdict(self.character),
'inventory': {
'slots': [asdict(item) if item else None
for item in self.inventory.slots],
'equipment': {
k: asdict(v) if v else None
for k, v in self.inventory.equipment.items()
}
},
'quests': [
{
'id': q.id,
'objectives': q.objectives,
'completed': q.completed
} for q in self.quests
]
}
with open(filename, 'w') as f:
json.dump(save_data, f, indent=2)
print(f"Game saved to {filename}")
def load_game(self, filename: str = "savegame.json"):
"""Load game state"""
try:
with open(filename, 'r') as f:
save_data = json.load(f)
# Restore character
char_data = save_data['character']
self.character = Character(
char_data['name'],
CharacterClass(char_data['char_class'])
)
for key, value in char_data.items():
if hasattr(self.character, key):
setattr(self.character, key, value)
# Restore inventory
self.inventory = Inventory()
for i, item_data in enumerate(save_data['inventory']['slots']):
if item_data:
item = Item(
item_data['name'],
ItemType(item_data['item_type']),
item_data.get('icon', '📦'),
item_data.get('quantity', 1)
)
self.inventory.slots[i] = item
print(f"Game loaded from {filename}")
except FileNotFoundError:
print(f"Save file {filename} not found")
def render_ui(self):
"""Render game UI"""
# Character info
info_text = [
f"Name: {self.character.name}",
f"Class: {self.character.char_class.value}",
f"Level: {self.character.level}",
f"HP: {int(self.character.hp)}/{self.character.max_hp}",
f"MP: {int(self.character.mp)}/{self.character.max_mp}",
f"Gold: {self.character.gold}",
f"XP: {self.character.experience}/{self.character.exp_to_next}"
]
font = pygame.font.Font(None, 24)
y_offset = 10
for text in info_text:
text_surface = font.render(text, True, (255, 255, 255))
self.screen.blit(text_surface, (10, y_offset))
y_offset += 25
# Inventory display
if self.show_inventory:
self.render_inventory()
# Quest display
if self.show_quests:
self.render_quests()
def render_inventory(self):
"""Render inventory UI"""
# Background
inv_surface = pygame.Surface((600, 400))
inv_surface.fill((50, 50, 50))
inv_surface.set_alpha(240)
self.screen.blit(inv_surface, (100, 100))
# Title
font = pygame.font.Font(None, 32)
title = font.render("Inventory", True, (255, 255, 255))
self.screen.blit(title, (350, 110))
# Grid
slot_size = 50
margin = 5
cols = 10
for i, item in enumerate(self.inventory.slots):
row = i // cols
col = i % cols
x = 110 + col * (slot_size + margin)
y = 150 + row * (slot_size + margin)
# Slot background
pygame.draw.rect(self.screen, (100, 100, 100),
(x, y, slot_size, slot_size), 2)
if item:
# Item icon (simplified)
font = pygame.font.Font(None, 36)
icon = font.render(item.icon, True, (255, 255, 255))
self.screen.blit(icon, (x + 10, y + 10))
# Quantity
if item.quantity > 1:
font = pygame.font.Font(None, 16)
qty = font.render(str(item.quantity), True, (255, 255, 0))
self.screen.blit(qty, (x + 35, y + 35))
def render_quests(self):
"""Render quest log"""
# Background
quest_surface = pygame.Surface((400, 300))
quest_surface.fill((50, 50, 50))
quest_surface.set_alpha(240)
self.screen.blit(quest_surface, (200, 150))
# Title
font = pygame.font.Font(None, 32)
title = font.render("Quest Log", True, (255, 255, 255))
self.screen.blit(title, (350, 160))
# Quests
y_offset = 200
for quest in self.quests:
if quest.completed:
continue
# Quest name
font = pygame.font.Font(None, 24)
name_text = font.render(quest.name, True, (255, 215, 0))
self.screen.blit(name_text, (220, y_offset))
y_offset += 25
# Objectives
font = pygame.font.Font(None, 20)
for obj in quest.objectives:
status = "✓" if obj['completed'] else "○"
obj_text = f"{status} {obj['text']} ({obj['current']}/{obj['required']})"
text_surface = font.render(obj_text, True, (200, 200, 200))
self.screen.blit(text_surface, (240, y_offset))
y_offset += 20
y_offset += 10
def run(self):
"""Main game loop"""
running = True
while running:
dt = self.clock.tick(60) / 1000.0 # Delta time in seconds
# Handle events
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_i:
self.show_inventory = not self.show_inventory
elif event.key == pygame.K_q:
self.show_quests = not self.show_quests
elif event.key == pygame.K_c:
self.show_character = not self.show_character
elif event.key == pygame.K_s and pygame.key.get_mods() & pygame.KMOD_CTRL:
self.save_game()
elif event.key == pygame.K_l and pygame.key.get_mods() & pygame.KMOD_CTRL:
self.load_game()
# Update
# Regeneration
if self.character.hp < self.character.max_hp:
self.character.hp += 0.1 * dt
if self.character.mp < self.character.max_mp:
self.character.mp += 0.2 * dt
# Render
self.screen.fill((34, 139, 34)) # Forest green background
# Draw game world
# (simplified - would include tile map, entities, etc.)
# Draw UI
self.render_ui()
pygame.display.flip()
pygame.quit()
# Skill Tree System
class Skill:
"""Individual skill in skill tree"""
def __init__(self, skill_id: str, name: str, max_level: int = 5):
self.id = skill_id
self.name = name
self.level = 0
self.max_level = max_level
self.cost = 1
self.requirements = []
self.effects = {}
def can_learn(self, character: Character, skill_tree: 'SkillTree') -> bool:
"""Check if skill can be learned"""
if self.level >= self.max_level:
return False
if character.skill_points < self.cost:
return False
# Check requirements
for req in self.requirements:
req_skill = skill_tree.get_skill(req['skill_id'])
if not req_skill or req_skill.level < req['level']:
return False
return True
def learn(self, character: Character):
"""Learn or upgrade skill"""
if character.skill_points >= self.cost:
self.level += 1
character.skill_points -= self.cost
# Apply effects
for stat, value in self.effects.items():
if hasattr(character, stat):
current = getattr(character, stat)
setattr(character, stat, current + value)
return True
return False
class SkillTree:
"""Skill tree system"""
def __init__(self):
self.skills = {}
self.create_default_skills()
def create_default_skills(self):
"""Create default skill trees"""
# Warrior skills
power_strike = Skill('power_strike', 'Power Strike')
power_strike.effects = {'strength': 2}
self.skills['power_strike'] = power_strike
iron_skin = Skill('iron_skin', 'Iron Skin')
iron_skin.effects = {'vitality': 3, 'max_hp': 10}
self.skills['iron_skin'] = iron_skin
berserker = Skill('berserker', 'Berserker Rage', max_level=3)
berserker.cost = 2
berserker.requirements = [{'skill_id': 'power_strike', 'level': 3}]
berserker.effects = {'strength': 5}
self.skills['berserker'] = berserker
# Mage skills
fireball = Skill('fireball', 'Fireball')
fireball.effects = {'intelligence': 2}
self.skills['fireball'] = fireball
mana_shield = Skill('mana_shield', 'Mana Shield')
mana_shield.effects = {'max_mp': 20}
self.skills['mana_shield'] = mana_shield
teleport = Skill('teleport', 'Teleport', max_level=1)
teleport.cost = 3
teleport.requirements = [{'skill_id': 'fireball', 'level': 3}]
self.skills['teleport'] = teleport
def get_skill(self, skill_id: str) -> Optional[Skill]:
"""Get skill by ID"""
return self.skills.get(skill_id)
def get_available_skills(self, character: Character) -> List[Skill]:
"""Get skills available to learn"""
available = []
for skill in self.skills.values():
if skill.can_learn(character, self):
available.append(skill)
return available
# Crafting System
class Recipe:
"""Crafting recipe"""
def __init__(self, result: Item, ingredients: List[Tuple[str, int]]):
self.result = result
self.ingredients = ingredients # List of (item_name, quantity) tuples
def can_craft(self, inventory: Inventory) -> bool:
"""Check if recipe can be crafted"""
for ingredient_name, required_qty in self.ingredients:
found_qty = 0
for item in inventory.slots:
if item and item.name == ingredient_name:
found_qty += item.quantity
if found_qty < required_qty:
return False
return True
def craft(self, inventory: Inventory) -> bool:
"""Craft the item"""
if not self.can_craft(inventory):
return False
# Remove ingredients
for ingredient_name, required_qty in self.ingredients:
remaining = required_qty
for i, item in enumerate(inventory.slots):
if item and item.name == ingredient_name:
if item.quantity <= remaining:
remaining -= item.quantity
inventory.slots[i] = None
else:
item.quantity -= remaining
remaining = 0
if remaining == 0:
break
# Add crafted item
inventory.add_item(self.result)
return True
# Trading System
class Shop:
"""Shop/merchant system"""
def __init__(self, name: str):
self.name = name
self.inventory = []
self.buy_multiplier = 1.0 # Price multiplier when buying
self.sell_multiplier = 0.5 # Price multiplier when selling
def add_item(self, item: Item, stock: int = -1):
"""Add item to shop (-1 for infinite stock)"""
self.inventory.append({
'item': item,
'stock': stock,
'price': item.price
})
def buy_item(self, item_index: int, character: Character,
player_inventory: Inventory) -> bool:
"""Player buys item from shop"""
if 0 <= item_index < len(self.inventory):
shop_item = self.inventory[item_index]
price = int(shop_item['price'] * self.buy_multiplier)
if character.gold >= price:
if shop_item['stock'] != 0: # Check stock
if player_inventory.add_item(shop_item['item']):
character.gold -= price
if shop_item['stock'] > 0:
shop_item['stock'] -= 1
return True
return False
def sell_item(self, item: Item, character: Character) -> bool:
"""Player sells item to shop"""
price = int(item.price * self.sell_multiplier)
character.gold += price
return True
if __name__ == "__main__":
game = RPGGame()
game.run()
Best Practices
⚡ RPG System Best Practices
- Balance: Playtest extensively to balance progression
- Save System: Implement robust save/load functionality
- Inventory UI: Make item management intuitive
- Quest Clarity: Clear objectives and progress tracking
- Skill Trees: Meaningful choices and build variety
- Economy: Balance gold income and item prices
- Combat Feel: Make combat responsive and satisfying
- Story Integration: Weave mechanics into narrative
Key Takeaways
- 📊 Character progression drives player engagement
- 🎒 Inventory management is core to RPGs
- ⚔️ Combat systems need depth and strategy
- 📜 Quest systems provide structure and goals
- 💬 Dialogue trees create narrative branches
- 🌳 Skill trees offer customization
- ⚗️ Crafting adds resource management
- 💰 Economy systems create meaningful choices
🏋️♂️ Practice Exercise
🏋️♂️ Exercise 1: Three Pillars of Python RPG Architecture — Enum-Roundtrip Identity + Dataclass-asdict Save Format + Declarative Skill-Prerequisite Records in One Pygame Window
Objective: Build a single ~80-line pygame program that simultaneously demonstrates the three orthogonal Python-specific architectural levers this lesson exercises. (a) Enum-roundtrip-through-save at CHARACTER-CLASS-AND-ITEM-TYPE IDENTITY scope where class CharacterClass(Enum) and class ItemType(Enum) enumerate valid identities at design-time so a typo ItemType.CONSUM raises AttributeError at the import statement of the typo-containing module, and the CharacterClass(char_data['char_class']) reconstructor at load time raises ValueError if the saved string does not match a declared member — making save-file format drift a LOAD-TIME exception rather than silent runtime corruption. (b) Dataclass-asdict serialization as schema-IS-the-class at SAVE-FORMAT scope where asdict(self.character) walks the @dataclass-decorated Character's fields recursively producing a JSON-serializable dict whose keys EXACTLY match the field names — adding a new field to Character automatically appears in saves with zero changes to save_game. (c) Skill-prerequisites-as-declarative-data-records at SKILL-TREE-VALIDATION scope where Skill.requirements = [{'skill_id': 'power_strike'}] is data the can_learn() method iterates through, so adding a new prerequisite is a single requirements.append({...}) call with zero changes to can_learn()'s control flow — Open/Closed Principle satisfaction. This is the SIXTH Phase-8 lesson reinforcing the design-time-validation-vs-runtime-discovery pattern (after chat-47 platformer_level_design + chat-70 publishing_executables + chat-72 publishing_performance + chat-73 publishing_platforms + chat-76 genres_puzzle_python) at axis (a), and the TWENTY-FIRST Phase-8 lesson reinforcing data-driven externalization (after chat-58 / 60 / 61 / 62 / 68 / 70 / 71 / 72 / 73 / 74 / 75 / 76 / 77 / 78) at axis (c) applied at SKILL-TREE-VALIDATION scope. Three labeled regions visible per frame on an 800×480 window.
Instructions:
- Define
class CharacterClass(Enum)with WARRIOR / MAGE / ROGUE members assigned string values, andclass ItemType(Enum)with WEAPON / POTION members assigned string values. - Define
@dataclassCharacter (name, char_class: CharacterClass, level, strength, intelligence) and Item (name, item_type: ItemType, value), and@dataclassSkill (skill_id, name, requirements: List[Dict] = field(default_factory=list)). - Build the SKILLS dict with five entries — power_strike (no reqs), iron_skin (req power_strike), berserker (reqs power_strike + iron_skin), fireball (no reqs), teleport (req fireball) — each Skill carrying its prerequisites as a List[Dict] of {skill_id} records.
- Implement
can_learn(skill, learned)that returns True iff every requirement's skill_id is in the learned set, iterating overskill.requirementsas data. - Implement
save(char, items, learned, path)that usesasdict(char)and overrideschar_classtochar.char_class.valuefor JSON-roundtrippability, doing the same with each Item's item_type; persist the learned set as a sorted list. - Implement
load(path)that reads the JSON, reconstructsCharacterClass(cd['char_class'])andItemType(i['item_type'])(raising ValueError on drift), and rebuilds Character and Item dataclass instances viaCharacter(**cd)/Item(**i). - Bind keys S=save, L=load, 1=learn power_strike, 2=learn fireball, 3=learn iron_skin (only if can_learn returns True); update a status string on each event capturing which architectural lever just fired as visible feedback.
- Render three regions per frame: LEFT panel (x=10, y=10) shows the SKILL TREE with each skill's reqs=[...] data record visualized in a color encoding learned / learnable / blocked status; TOP-RIGHT panel (x=380, y=10) shows the CHARACTER with both
char_class.name(static) andchar_class.value(the round-trip string); BOTTOM panel (x=10, y=320) shows the SAVE FORMAT json.dumps preview demonstrating the asdict-walked field set + Enum.value extraction.
💡 Hint
The three axes are three independent state-shape choices: Enum-with-string-value-roundtrip moves identity-validation from runtime to design-time AND to load-time (catching both source typos at import and save-file drift at load); dataclass-asdict makes the field declaration the single source of truth for the save format (any field rename breaks saves loudly at exactly the renamed field rather than silently constructing a half-loaded character); requirements-as-data-records lets skill_tree growth happen at construction time with zero validator changes (adding berserker with two prerequisites is a single Skill('berserker', 'Berserker', [{'skill_id': 'power_strike'}, {'skill_id': 'iron_skin'}]) call). Per-frame renderer reads are O(1) field accesses (char.char_class.name / char.char_class.value / asdict(char)); maintenance cost lives in the write paths (Enum-from-string at load time, asdict overrides for non-default-serializable Enum fields, requirements.append for skill graph growth). The classic failure mode for axis (a) is silently constructing invalid characters on load when an Enum member was renamed or removed; for axis (b) is hand-rolled save dicts drifting out of sync with the dataclass field set; for axis (c) is scattered if-elif prerequisite chains in can_learn() forcing every new skill to add a new branch.
✅ Example Solution
import pygame, json
from dataclasses import dataclass, asdict, field
from enum import Enum
from typing import List, Dict
class CharacterClass(Enum):
WARRIOR = 'warrior'
MAGE = 'mage'
ROGUE = 'rogue'
class ItemType(Enum):
WEAPON = 'weapon'
POTION = 'potion'
@dataclass
class Item:
name: str
item_type: ItemType
value: int
@dataclass
class Character:
name: str
char_class: CharacterClass
level: int = 1
strength: int = 10
intelligence: int = 10
@dataclass
class Skill:
skill_id: str
name: str
requirements: List[Dict] = field(default_factory=list)
SKILLS = {
'power_strike': Skill('power_strike', 'Power Strike'),
'iron_skin': Skill('iron_skin', 'Iron Skin', [{'skill_id': 'power_strike'}]),
'berserker': Skill('berserker', 'Berserker', [{'skill_id': 'power_strike'}, {'skill_id': 'iron_skin'}]),
'fireball': Skill('fireball', 'Fireball'),
'teleport': Skill('teleport', 'Teleport', [{'skill_id': 'fireball'}]),
}
def can_learn(skill, learned):
return all(req['skill_id'] in learned for req in skill.requirements)
def save(char, items, learned, path):
data = {
'character': {**asdict(char), 'char_class': char.char_class.value},
'items': [{**asdict(i), 'item_type': i.item_type.value} for i in items],
'learned': sorted(learned),
}
with open(path, 'w') as f:
json.dump(data, f)
def load(path):
with open(path) as f:
data = json.load(f)
cd = data['character']
cd['char_class'] = CharacterClass(cd['char_class'])
char = Character(**cd)
items = [Item(**{**i, 'item_type': ItemType(i['item_type'])}) for i in data['items']]
return char, items, set(data['learned'])
pygame.init()
screen = pygame.display.set_mode((800, 480))
font = pygame.font.Font(None, 18)
clock = pygame.time.Clock()
char = Character('Aria', CharacterClass.WARRIOR)
items = [Item('Sword', ItemType.WEAPON, 50), Item('Potion', ItemType.POTION, 10)]
learned = {'power_strike'}
status = 'S=save L=load 1=power_strike 2=fireball 3=iron_skin'
running = True
while running:
for e in pygame.event.get():
if e.type == pygame.QUIT: running = False
elif e.type == pygame.KEYDOWN:
if e.key == pygame.K_s:
save(char, items, learned, '/tmp/rpg.json')
status = 'Saved (asdict walks @dataclass fields, Enum.value extracted)'
elif e.key == pygame.K_l:
try:
char, items, learned = load('/tmp/rpg.json')
status = 'Loaded (Enum-roundtrip OK, dataclass fields populated)'
except (FileNotFoundError, ValueError) as ex:
status = f'Load failed: {ex}'
elif e.key in (pygame.K_1, pygame.K_2, pygame.K_3):
sid = {pygame.K_1: 'power_strike', pygame.K_2: 'fireball', pygame.K_3: 'iron_skin'}[e.key]
if can_learn(SKILLS[sid], learned):
learned.add(sid)
status = f'Learned {sid} (prereqs satisfied per declarative requirements list)'
else:
reqs = ', '.join(r['skill_id'] for r in SKILLS[sid].requirements)
status = f'Cannot learn {sid}: needs [{reqs}]'
screen.fill((20, 20, 30))
screen.blit(font.render('SKILL TREE (declarative requirements as data records):', True, (255, 255, 0)), (10, 10))
y = 40
for sid, skill in SKILLS.items():
ok = can_learn(skill, learned)
color = (0, 255, 0) if sid in learned else ((255, 255, 255) if ok else (110, 110, 110))
reqs = ', '.join(r['skill_id'] for r in skill.requirements) or 'none'
screen.blit(font.render(f'{skill.name:14s} reqs=[{reqs}]', True, color), (20, y))
y += 22
screen.blit(font.render('CHARACTER (Enum-typed identity, .name vs .value):', True, (255, 255, 0)), (380, 10))
screen.blit(font.render(f'name={char.name} class.name={char.char_class.name} class.value={char.char_class.value!r}', True, (255, 255, 255)), (380, 35))
screen.blit(font.render(f'level={char.level} STR={char.strength} INT={char.intelligence}', True, (255, 255, 255)), (380, 55))
screen.blit(font.render('ITEMS:', True, (255, 255, 0)), (380, 85))
iy = 105
for it in items:
screen.blit(font.render(f' {it.name} type.name={it.item_type.name} value={it.value}', True, (255, 255, 255)), (380, iy))
iy += 20
screen.blit(font.render('SAVE FORMAT (asdict walks @dataclass fields; Enum -> .value -> JSON string -> Enum on load):', True, (255, 255, 0)), (10, 320))
sample = {'character': {**asdict(char), 'char_class': char.char_class.value}}
payload = json.dumps(sample, separators=(',', ':'))
screen.blit(font.render(payload[:100], True, (200, 200, 200)), (10, 345))
screen.blit(font.render(payload[100:200], True, (200, 200, 200)), (10, 365))
screen.blit(font.render(f'STATUS: {status}', True, (0, 255, 255)), (10, 410))
screen.blit(font.render('(Enum-from-string raises ValueError on drift; asdict tracks dataclass fields; requirements iterated as data)', True, (180, 180, 180)), (10, 440))
pygame.display.flip()
clock.tick(60)
pygame.quit()
🎯 Quick Quiz
Question 1: Why does the save/load roundtrip use CharacterClass(char_data['char_class']) to reconstruct the character class on load, rather than storing and restoring the raw string?
Question 2: What architectural property does using asdict(self.character) in save_game() provide that hand-rolled {'name': self.character.name, 'level': self.character.level, ...} would not?
Question 3: Why does Skill.can_learn() iterate over self.requirements (a List[Dict] of {skill_id, level} records) rather than checking prerequisites with hardcoded if-elif chains keyed on self.id?
What's Next?
Now that you've mastered RPG systems, next we'll explore strategy game mechanics!