Python 콘솔 기반 텍스트 RPG 제작하기

터미널 환경에서 동작하는 텍스트 기반 RPG를 직접 구현해보자. 이 게임에서는 플레이어가 캐릭터를 생성하고, 다양한 지역을 탐험하며, 몬스터와 전투를 벌이는 핵심 메커닉을 파이썬으로 구성한다.

프로젝트 구조

게임은 크게 캐릭터 시스템, 전투 시스템, 맵 탐험 시스템으로 구성된다. 각 모듈을 분리하여 유지보수성을 높인다.

캐릭터 클래스 설계

플레이어와 적 모두 공통 속성을 가지므로 기본 클래스를 상속받아 구현한다.

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
import random

class DamageType(Enum):
    PHYSICAL = auto()
    MAGIC = auto()
    TRUE = auto()

@dataclass
class Stats:
    vitality: int = 10
    strength: int = 10
    agility: int = 10
    intelligence: int = 10
    
    @property
    def max_health(self) -> int:
        return self.vitality * 15 + 50
    
    @property
    def attack_power(self) -> int:
        return self.strength * 2 + 10
    
    @property
    def defense_value(self) -> int:
        return self.agility // 2 + 5

class Entity(ABC):
    def __init__(self, name: str, stats: Stats):
        self.name = name
        self.stats = stats
        self.current_hp = stats.max_health
        self.is_alive = True
    
    def take_damage(self, amount: int, damage_type: DamageType = DamageType.PHYSICAL) -> int:
        if damage_type == DamageType.PHYSICAL:
            mitigated = max(1, amount - self.stats.defense_value)
        elif damage_type == DamageType.MAGIC:
            mitigated = max(1, int(amount * (1 - self.stats.intelligence / 200)))
        else:
            mitigated = amount
        
        actual = max(0, mitigated)
        self.current_hp = max(0, self.current_hp - actual)
        if self.current_hp == 0:
            self.is_alive = False
        return actual
    
    def heal(self, amount: int) -> None:
        self.current_hp = min(self.stats.max_health, self.current_hp + amount)
    
    @abstractmethod
    def choose_action(self, target: 'Entity') -> tuple[str, int, DamageType]:
        pass

class Player(Entity):
    def __init__(self, name: str, stats: Stats):
        super().__init__(name, stats)
        self.experience = 0
        self.level = 1
        self.gold = 0
        self.inventory = []
    
    def choose_action(self, target: Entity) -> tuple[str, int, DamageType]:
        # 실제 구현에서는 사용자 입력 받음
        base_damage = self.stats.attack_power
        variance = random.randint(-2, 2)
        return ("일반 공격", base_damage + variance, DamageType.PHYSICAL)
    
    def gain_exp(self, amount: int) -> None:
        self.experience += amount
        threshold = self.level * 100
        if self.experience >= threshold:
            self.level_up()
    
    def level_up(self) -> None:
        self.level += 1
        self.experience = 0
        self.stats.vitality += 2
        self.stats.strength += 1
        self.current_hp = self.stats.max_health
        print(f"🎉 {self.name} 레벨 {self.level} 달성!")

class Monster(Entity):
    MONSTER_TEMPLATES = {
        'goblin': {'hp_mod': 0.8, 'atk_mod': 1.0, 'exp': 30, 'gold': 10},
        'orc': {'hp_mod': 1.5, 'atk_mod': 1.3, 'exp': 60, 'gold': 25},
        'dragon': {'hp_mod': 3.0, 'atk_mod': 2.0, 'exp': 300, 'gold': 200},
    }
    
    def __init__(self, template_key: str, name: str):
        template = self.MONSTER_TEMPLATES[template_key]
        stats = Stats(
            vitality=int(10 * template['hp_mod']),
            strength=int(10 * template['atk_mod'])
        )
        super().__init__(name, stats)
        self.reward_exp = template['exp']
        self.reward_gold = template['gold']
    
    def choose_action(self, target: Entity) -> tuple[str, int, DamageType]:
        damage = self.stats.attack_power + random.randint(-1, 3)
        return ("몬스터 공격", damage, DamageType.PHYSICAL)

전투 시스템 구현

턴제 전투를 구현하며, 속도에 따른 선후공 결정과 상태 이상 효과를 추가한다.

class BattleResult(Enum):
    VICTORY = auto()
    DEFEAT = auto()
    ESCAPE = auto()

class CombatEngine:
    def __init__(self, player: Player, enemy: Monster):
        self.player = player
        self.enemy = enemy
        self.turn_count = 0
    
    def determine_initiative(self) -> list[Entity]:
        participants = [self.player, self.enemy]
        # 민첩성 기반 + 약간의 무작위성
        participants.sort(
            key=lambda e: e.stats.agility + random.randint(0, 5),
            reverse=True
        )
        return participants
    
    def execute_turn(self, attacker: Entity, defender: Entity) -> str:
        action_name, damage, damage_type = attacker.choose_action(defender)
        actual = defender.take_damage(damage, damage_type)
        return f"{attacker.name}의 {action_name}! {defender.name}에게 {actual}의 피해"
    
    def start(self) -> BattleResult:
        print(f"⚔️ 전투 시작! {self.player.name} VS {self.enemy.name}")
        
        while self.player.is_alive and self.enemy.is_alive:
            self.turn_count += 1
            print(f"\n--- 턴 {self.turn_count} ---")
            
            for attacker in self.determine_initiative():
                if not attacker.is_alive:
                    continue
                    
                defender = self.enemy if attacker == self.player else self.player
                if not defender.is_alive:
                    break
                
                message = self.execute_turn(attacker, defender)
                print(message)
                print(f"  {defender.name} HP: {defender.current_hp}/{defender.stats.max_health}")
        
        if self.player.is_alive:
            self._grant_rewards()
            return BattleResult.VICTORY
        return BattleResult.DEFEAT
    
    def _grant_rewards(self) -> None:
        print(f"\n🏆 승리! {self.enemy.reward_exp} 경험치, {self.enemy.reward_gold} 골드 획득")
        self.player.gain_exp(self.enemy.reward_exp)
        self.player.gold += self.enemy.reward_gold

맵 탐험 시스템

그리드 기반 맵에서 이동하며 이벤트를 발생시킨다.

@dataclass
class Tile:
    x: int
    y: int
    terrain: str
    has_encounter: bool = False
    is_explored: bool = False

class WorldMap:
    TERRAINS = {
        'forest': {'encounter_rate': 0.3, 'symbol': '🌲'},
        'plains': {'encounter_rate': 0.1, 'symbol': '🌾'},
        'mountain': {'encounter_rate': 0.2, 'symbol': '⛰️'},
        'town': {'encounter_rate': 0.0, 'symbol': '🏘️'},
    }
    
    def __init__(self, width: int = 10, height: int = 10):
        self.width = width
        self.height = height
        self.grid: dict[tuple[int, int], Tile] = {}
        self._generate_map()
    
    def _generate_map(self) -> None:
        import noise
        for x in range(self.width):
            for y in range(self.height):
                # 간단한 노이즈 기반 지형 생성
                val = noise.pnoise2(x * 0.1, y * 0.1)
                if val < -0.3:
                    terrain = 'forest'
                elif val < 0.3:
                    terrain = 'plains'
                elif val < 0.7:
                    terrain = 'mountain'
                else:
                    terrain = 'town'
                
                encounter = random.random() < self.TERRAINS[terrain]['encounter_rate']
                self.grid[(x, y)] = Tile(x, y, terrain, encounter)
    
    def get_tile(self, x: int, y: int) -> Tile | None:
        return self.grid.get((x, y))
    
    def display(self, player_x: int, player_y: int) -> None:
        for y in range(self.height):
            row = ""
            for x in range(self.width):
                if x == player_x and y == player_y:
                    row += "🧙"
                else:
                    tile = self.grid[(x, y)]
                    row += self.TERRAINS[tile.terrain]['symbol']
            print(row)

class Explorer:
    DIRECTIONS = {
        'w': (0, -1), 's': (0, 1),
        'a': (-1, 0), 'd': (1, 0)
    }
    
    def __init__(self, world: WorldMap):
        self.world = world
        self.x, self.y = world.width // 2, world.height // 2
    
    def move(self, direction: str) -> Tile | None:
        dx, dy = self.DIRECTIONS.get(direction, (0, 0))
        new_x, new_y = self.x + dx, self.y + dy
        
        if self.world.get_tile(new_x, new_y):
            self.x, self.y = new_x, new_y
            return self.world.get_tile(new_x, new_y)
        return None
    
    def check_encounter(self) -> Monster | None:
        tile = self.world.get_tile(self.x, self.y)
        if tile and tile.has_encounter and random.random() < 0.5:
            templates = list(Monster.MONSTER_TEMPLATES.keys())
            chosen = random.choice(templates)
            return Monster(chosen, chosen.capitalize())
        return None

게임 루프 조합

모든 시스템을 통합하여 메인 게임 루프를 구성한다.

class TextRPG:
    def __init__(self):
        self.player: Player | None = None
        self.world = WorldMap(8, 8)
        self.explorer: Explorer | None = None
    
    def create_character(self) -> None:
        print("=== 캐릭터 생성 ===")
        name = input("이름을 입력하세요: ")
        
        print("\n스탯 분배 (총 30점)")
        remaining = 30
        stats = {}
        for stat in ['vitality', 'strength', 'agility', 'intelligence']:
            while True:
                try:
                    val = int(input(f"{stat} (남은 포인트: {remaining}): "))
                    if 0 <= val <= remaining:
                        stats[stat] = val + 10  # 기본값 10
                        remaining -= val
                        break
                except ValueError:
                    pass
        
        self.player = Player(name, Stats(**stats))
        self.explorer = Explorer(self.world)
        print(f"\n{self.player.name} 모험을 시작합니다!")
    
    def game_loop(self) -> None:
        if not self.player:
            self.create_character()
        
        while self.player.is_alive:
            print(f"\n[{self.player.name}] HP: {self.player.current_hp}/{self.player.stats.max_health} | "
                  f"레벨: {self.player.level} | 골드: {self.player.gold}")
            self.world.display(self.explorer.x, self.explorer.y)
            
            cmd = input("\n이동(WASD), 상태(Status), 종료(Quit): ").lower()
            
            if cmd in Explorer.DIRECTIONS:
                tile = self.explorer.move(cmd)
                if tile:
                    print(f"{tile.terrain} 지형으로 이동")
                    monster = self.explorer.check_encounter()
                    if monster:
                        self._handle_combat(monster)
                else:
                    print("이동할 수 없습니다!")
            
            elif cmd == 'status':
                self._show_status()
            
            elif cmd == 'quit':
                break
        
        if not self.player.is_alive:
            print("💀 게임 오버...")
    
    def _handle_combat(self, monster: Monster) -> None:
        engine = CombatEngine(self.player, monster)
        result = engine.start()
        
        if result == BattleResult.DEFEAT:
            print("당신은 패배했습니다...")
    
    def _show_status(self) -> None:
        s = self.player.stats
        print(f"\n=== {self.player.name}의 상태 ===")
        print(f"레벨: {self.player.level} | 경험치: {self.player.experience}")
        print(f"체력: {self.player.current_hp}/{s.max_health}")
        print(f"공격력: {s.attack_power} | 방어력: {s.defense_value}")


# 실행
if __name__ == "__main__":
    game = TextRPG()
    game.game_loop()

확장 포인트

기본 구조 위에 다음 기능을 추가하여 게임을 풍성하게 만들 수 있다.

  • 직업 시스템: 전사, 마법사, 도적 등 직업별 고유 스킬 구현
  • 장비 시스템: 무기, 방어구 장착으로 스탯 변동
  • 퀘스트 시스템: NPC 대화와 목표 달성 보상
  • 저장/로드: pickle 또는 json으로 진행 상황 기록
  • 저주/버프: 지속 시간이 있는 상태 효과 관리

텍스트 RPG의 핵심은 플레이어의 상상력을 자극하는 서술과 의미 있는 선택지 제공이다. 전투 밸런스와 진행 난이도 곡선을 반복 테스트하며 조율하는 과정이 완성도를 높인다.

태그: python Text-RPG game-development Turn-Based-Combat Procedural-Generation

7월 15일 00:32에 게시됨