터미널 환경에서 동작하는 텍스트 기반 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의 핵심은 플레이어의 상상력을 자극하는 서술과 의미 있는 선택지 제공이다. 전투 밸런스와 진행 난이도 곡선을 반복 테스트하며 조율하는 과정이 완성도를 높인다.