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

Key Takeaways

🏋️‍♂️ 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:

  1. Define class CharacterClass(Enum) with WARRIOR / MAGE / ROGUE members assigned string values, and class ItemType(Enum) with WEAPON / POTION members assigned string values.
  2. Define @dataclass Character (name, char_class: CharacterClass, level, strength, intelligence) and Item (name, item_type: ItemType, value), and @dataclass Skill (skill_id, name, requirements: List[Dict] = field(default_factory=list)).
  3. 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.
  4. Implement can_learn(skill, learned) that returns True iff every requirement's skill_id is in the learned set, iterating over skill.requirements as data.
  5. Implement save(char, items, learned, path) that uses asdict(char) and overrides char_class to char.char_class.value for JSON-roundtrippability, doing the same with each Item's item_type; persist the learned set as a sorted list.
  6. Implement load(path) that reads the JSON, reconstructs CharacterClass(cd['char_class']) and ItemType(i['item_type']) (raising ValueError on drift), and rebuilds Character and Item dataclass instances via Character(**cd) / Item(**i).
  7. 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.
  8. 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) and char_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!