모험의 완결: 시스템의 유기적 통합
1부에서 우리는 플레이어, 몬스터, 장비, 그리고 턴제 전투 엔진이라는 단단한 기초 뼈대를 구축했습니다. 그러나 실제 플레이어가 몰입할 수 있는 게임이 되기 위해서는 전투 외에도 다양한 시스템이 유기적으로 맞물려 돌아가야 합니다.
- 쓰러뜨린 몬스터로부터 획득하는 전리품과 인벤토리 관리
- 전투 승리를 통해 누적되는 경험치와 레벨업 성장 메커니즘
- 모험의 대미를 장식하는 다단계 보스전(Boss Encounter)
- 모든 이벤트를 순차적으로 진행시키는 메인 게임 루프(Main Game Loop)
이번 2부에서는 지난 21개 챕터 동안 연마한 Java 25의 모든 기술—불변 레코드, 현대적 컬렉션 프레임워크, 예외 처리, 객체지향 캡슐화, 상태 머신 설계—을 집대성하여 실행 가능한 완전한 Voxel Adventure를 완성합니다.
시스템 아키텍처와 데이터 흐름
완성형 게임은 다음과 같은 상호작용 흐름으로 구동됩니다.
[GameEngine (루프)]
├─ 1. 던전 진입 및 몬스터 조우 (일반 몬스터 -> 보스 몬스터)
├─ 2. CombatEngine을 통한 전투 수행
├─ 3. 승리 시 EXP 획득 -> Player.gainExp() 호출 -> 레벨업 판정
├─ 4. 전리품 아이템 드롭 -> Player.getInventory().addItem() 적재
└─ 5. 체력 위기 시 인벤토리의 포션 자동 소모 -> 회복
이 모든 컴포넌트가 결합할 때, 각 객체는 자신의 상태만을 스스로 책임집니다. 플레이어는 인벤토리의 세부 구현 방식을 알 필요가 없으며, 단지 addItem() 메서드를 호출할 뿐입니다. 인벤토리는 슬롯이 꽉 찼는지 검사하고 안전하게 보관합니다.
3대 확장 시스템 상세 설계
1. 슬롯 제한 인벤토리 (Inventory System)
인벤토리는 무한정 아이템을 담을 수 없습니다. 게임의 긴장감을 유지하기 위해 최대 수용 슬롯(예: 5슬롯)을 정의합니다. List<AdventureItem> 컬렉션을 내부 필드로 캡슐화하고, 아이템 추가, 제거, 검색, 포션 사용 등의 명확한 비즈니스 메서드를 제공합니다.
2. 레벨업과 스탯 성장 공식 (Leveling Formula)
경험치가 일정 임계치(예: 레벨 * 50 EXP)에 도달하면 플레이어의 레벨이 1 증가합니다. 레벨이 오를 때마다:
- 최대 체력이 증가합니다 (Max HP +15).
- 기본 공격력이 상승합니다 (Base Attack +3).
- 체력이 최대치로 완전 회복되는 보너스를 부여합니다.
3. 다단계 보스전: 보이드 골렘 (Void Golem Phase Shift)
일반 몬스터와 달리 최종 보스는 단조로운 공격을 반복하지 않습니다. 체력이 50% 이하로 떨어지면 **분노 모드(Enrage Phase)**로 돌입하여 공격력이 2배로 폭증하고 특수 광역 타격을 가합니다. 이를 통해 보스전 특유의 박진감 넘치는 긴장감을 구현합니다.
실행 가능한 전체 게임 완성 코드
아래 코드는 외부 라이브러리 없이 순수 Java 25 LTS 환경에서 단번에 컴파일되고 실행되는 완성형 Java Adventure 프로그램입니다.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
public class Main {
// ==========================================
// 1. 도메인 모델 (Record & Enum)
// ==========================================
enum ItemCategory { WEAPON, ARMOR, CONSUMABLE }
record AdventureItem(String id, String name, ItemCategory category, int value) {
public AdventureItem {
Objects.requireNonNull(name, "아이템 이름은 필수입니다.");
Objects.requireNonNull(category, "카테고리는 필수입니다.");
}
public static AdventureItem createPotion(String name, int healAmount) {
return new AdventureItem(UUID.randomUUID().toString(), name, ItemCategory.CONSUMABLE, healAmount);
}
public static AdventureItem createEquipment(String name, ItemCategory category, int bonus) {
return new AdventureItem(UUID.randomUUID().toString(), name, category, bonus);
}
}
// ==========================================
// 2. 인벤토리 시스템
// ==========================================
static class Inventory {
private final int capacity;
private final List<AdventureItem> storage;
public Inventory(int capacity) {
this.capacity = Math.max(1, capacity);
this.storage = new ArrayList<>();
}
public boolean addItem(AdventureItem item) {
Objects.requireNonNull(item, "아이템이 null입니다.");
if (storage.size() >= capacity) {
System.out.printf("[인벤토리] 가방이 꽉 차서 '%s'을(를) 획득하지 못했습니다!%n", item.name());
return false;
}
storage.add(item);
System.out.printf("[인벤토리] '%s' 획득! (사용 슬롯: %d/%d)%n", item.name(), storage.size(), capacity);
return true;
}
public Optional<AdventureItem> findAndConsumePotion() {
for (int i = 0; i < storage.size(); i++) {
AdventureItem item = storage.get(i);
if (item.category() == ItemCategory.CONSUMABLE) {
storage.remove(i);
return Optional.of(item);
}
}
return Optional.empty();
}
public List<AdventureItem> getItems() {
return Collections.unmodifiableList(storage);
}
}
// ==========================================
// 3. 플레이어 엔티티와 성장 로직
// ==========================================
static class Hero {
private final String name;
private int level;
private int exp;
private int maxHealth;
private int currentHealth;
private int baseAttack;
private final Inventory inventory;
private AdventureItem equippedWeapon;
public Hero(String name, int initialHealth, int initialAttack) {
this.name = Objects.requireNonNull(name, "영웅 이름은 필수입니다.");
this.level = 1;
this.exp = 0;
this.maxHealth = initialHealth;
this.currentHealth = initialHealth;
this.baseAttack = initialAttack;
this.inventory = new Inventory(5);
}
public void equipWeapon(AdventureItem weapon) {
if (weapon.category() != ItemCategory.WEAPON) {
throw new IllegalArgumentException("무기만 장착할 수 있습니다.");
}
this.equippedWeapon = weapon;
System.out.printf("[%s] 주 무기로 '%s' 장착! (추가 공격력 +%d)%n", name, weapon.name(), weapon.value());
}
public int getAttackPower() {
int weaponBonus = (equippedWeapon != null) ? equippedWeapon.value() : 0;
return this.baseAttack + weaponBonus;
}
public void takeDamage(int damage) {
int actual = Math.max(1, damage);
this.currentHealth = Math.max(0, this.currentHealth - actual);
System.out.printf(" >> [%s] %d의 타격을 입었습니다! (HP: %d/%d)%n", name, actual, currentHealth, maxHealth);
// 위기 상황: 체력이 30% 이하로 떨어지면 포션 자동 섭취 시도
if (currentHealth > 0 && currentHealth <= (maxHealth * 0.3)) {
autoDrinkPotion();
}
}
private void autoDrinkPotion() {
inventory.findAndConsumePotion().ifPresent(potion -> {
int heal = potion.value();
this.currentHealth = Math.min(maxHealth, this.currentHealth + heal);
System.out.printf(" ★ [위기 대응] '%s'을(를) 마셔 체력을 %d 회복했습니다! (현재 HP: %d/%d)%n",
potion.name(), heal, currentHealth, maxHealth);
});
}
public void gainExp(int amount) {
this.exp += amount;
System.out.printf(" + [%s] 경험치 %d EXP 획득 (누적: %d EXP)%n", name, amount, exp);
int requiredExp = this.level * 60;
while (this.exp >= requiredExp) {
this.exp -= requiredExp;
levelUp();
requiredExp = this.level * 60;
}
}
private void levelUp() {
this.level++;
this.maxHealth += 20;
this.baseAttack += 4;
this.currentHealth = this.maxHealth; // 레벨업 완치 보너스
System.out.printf("%n========================================%n");
System.out.printf(" 🎉 LEVEL UP! %s의 레벨이 [%d]로 올랐습니다!%n", name, level);
System.out.printf(" 최대 체력 증가: -> %d | 공격력 상승: -> %d (체력 완전 회복!)%n", maxHealth, getAttackPower());
System.out.printf("========================================%n%n");
}
public boolean isAlive() { return currentHealth > 0; }
public String getName() { return name; }
public int getLevel() { return level; }
public int getCurrentHealth() { return currentHealth; }
public int getMaxHealth() { return maxHealth; }
public Inventory getInventory() { return inventory; }
}
// ==========================================
// 4. 몬스터와 보스 계층 구조
// ==========================================
interface Combatant {
String getName();
int getHealth();
void takeDamage(int damage);
void attackHero(Hero hero);
boolean isAlive();
int getRewardExp();
Optional<AdventureItem> dropLoot();
}
static class NormalMonster implements Combatant {
private final String name;
private int health;
private final int attackPower;
private final int rewardExp;
private final AdventureItem dropItem;
public NormalMonster(String name, int health, int attackPower, int rewardExp, AdventureItem dropItem) {
this.name = name;
this.health = health;
this.attackPower = attackPower;
this.rewardExp = rewardExp;
this.dropItem = dropItem;
}
@Override public String getName() { return name; }
@Override public int getHealth() { return health; }
@Override public boolean isAlive() { return health > 0; }
@Override public int getRewardExp() { return rewardExp; }
@Override public Optional<AdventureItem> dropLoot() { return Optional.ofNullable(dropItem); }
@Override
public void takeDamage(int damage) {
this.health = Math.max(0, this.health - damage);
System.out.printf(" << [%s] %d 대미지를 입음! (남은 HP: %d)%n", name, damage, health);
}
@Override
public void attackHero(Hero hero) {
System.out.printf("[%s] 날카로운 손톱으로 습격합니다!%n", name);
hero.takeDamage(this.attackPower);
}
}
// 페이즈 전환이 있는 보스 몬스터
static class VoidGolemBoss implements Combatant {
private final String name;
private final int maxHealth;
private int health;
private final int baseAttack;
private boolean enraged = false;
public VoidGolemBoss(String name, int health, int baseAttack) {
this.name = name;
this.maxHealth = health;
this.health = health;
this.baseAttack = baseAttack;
}
@Override public String getName() { return name; }
@Override public int getHealth() { return health; }
@Override public boolean isAlive() { return health > 0; }
@Override public int getRewardExp() { return 250; }
@Override public Optional<AdventureItem> dropLoot() {
return Optional.of(AdventureItem.createEquipment("보이드의 지배자 왕관", ItemCategory.ARMOR, 25));
}
@Override
public void takeDamage(int damage) {
this.health = Math.max(0, this.health - damage);
System.out.printf(" << [BOSS %s] %d 대미지를 입음! (남은 HP: %d/%d)%n", name, damage, health, maxHealth);
// 50% 이하 시 2페이즈 분노 돌입
if (!enraged && health <= (maxHealth / 2) && health > 0) {
enraged = true;
System.out.printf("%n ⚡ [경고!] %s이(가) 폭주합니다! 공허의 힘으로 공격력이 2배 증가합니다!%n%n", name);
}
}
@Override
public void attackHero(Hero hero) {
int power = enraged ? (this.baseAttack * 2) : this.baseAttack;
String skill = enraged ? "★ 공허 폭발(Void Rupture) ★" : "육중한 주먹 내리치기";
System.out.printf("[BOSS %s] %s 시전! (위력: %d)%n", name, skill, power);
hero.takeDamage(power);
}
}
// ==========================================
// 5. 메인 모험 엔진 (Game Runner)
// ==========================================
public static void main(String[] args) {
System.out.println("=========================================");
System.out.println(" Voxel Adventure 캡스톤 프로젝트 완결편");
System.out.println("=========================================\n");
// 영웅 탄생 및 초기 장비 지급
Hero hero = new Hero("Alex", 70, 15);
hero.equipWeapon(AdventureItem.createEquipment("강화된 철 검", ItemCategory.WEAPON, 10));
hero.getInventory().addItem(AdventureItem.createPotion("치유의 물약", 30));
// 던전 조우 몬스터 큐 구성
List<Combatant> dungeonStages = List.of(
new NormalMonster("그림자 거미", 35, 12, 40, AdventureItem.createPotion("소형 포션", 20)),
new NormalMonster("저주받은 전사", 50, 18, 50, AdventureItem.createPotion("중형 포션", 35)),
new VoidGolemBoss("공허의 골렘 (보스)", 110, 22)
);
int stageCount = 1;
boolean adventureCleared = true;
for (Combatant monster : dungeonStages) {
System.out.printf("%n>>> 던전 제 %d 구역 진입: [%s] 등장! <<<%n", stageCount++, monster.getName());
while (hero.isAlive() && monster.isAlive()) {
// 1) 영웅 공격
int heroDamage = hero.getAttackPower();
System.out.printf("[%s] 공격! (위력: %d)%n", hero.getName(), heroDamage);
monster.takeDamage(heroDamage);
if (!monster.isAlive()) {
System.out.printf("승리! %s을(를) 격퇴했습니다!%n", monster.getName());
hero.gainExp(monster.getRewardExp());
monster.dropLoot().ifPresent(loot -> hero.getInventory().addItem(loot));
break;
}
// 2) 몬스터 반격
monster.attackHero(hero);
if (!hero.isAlive()) {
System.out.printf("%n♨ 전사... %s이(가) %s에게 쓰러졌습니다.%n", hero.getName(), monster.getName());
adventureCleared = false;
break;
}
}
if (!adventureCleared) break;
}
// 최종 여정 결산
System.out.println("\n=========================================");
System.out.println(" 모험 최종 결산 보고서");
System.out.println("=========================================");
System.out.printf("모험가: %s (최종 레벨: Lv.%d)%n", hero.getName(), hero.getLevel());
System.out.printf("남은 체력: %d / %d HP%n", hero.getCurrentHealth(), hero.getMaxHealth());
System.out.printf("클리어 여부: %s%n", adventureCleared ? "던전 완전 정복 (VICTORY)" : "탐험 실패 (DEFEAT)");
System.out.println("가방 소지품 목록:");
for (AdventureItem item : hero.getInventory().getItems()) {
System.out.printf(" - [%s] %s (%s)%n", item.category(), item.name(), item.id().substring(0, 8));
}
System.out.println("=========================================");
}
}
예상 출력
=========================================
Voxel Adventure 캡스톤 프로젝트 완결편
=========================================
[Alex] 주 무기로 '강화된 철 검' 장착! (추가 공격력 +10)
[인벤토리] '치유의 물약' 획득! (사용 슬롯: 1/5)
>>> 던전 제 1 구역 진입: [그림자 거미] 등장! <<<
[Alex] 공격! (위력: 25)
<< [그림자 거미] 25 대미지를 입음! (남은 HP: 10)
[그림자 거미] 날카로운 손톱으로 습격합니다!
>> [Alex] 12의 타격을 입었습니다! (HP: 58/70)
[Alex] 공격! (위력: 25)
<< [그림자 거미] 25 대미지를 입음! (남은 HP: 0)
승리! 그림자 거미을(를) 격퇴했습니다!
+ [Alex] 경험치 40 EXP 획득 (누적: 40 EXP)
[인벤토리] '소형 포션' 획득! (사용 슬롯: 2/5)
>>> 던전 제 2 구역 진입: [저주받은 전사] 등장! <<<
[Alex] 공격! (위력: 25)
<< [저주받은 전사] 25 대미지를 입음! (남은 HP: 25)
[저주받은 전사] 날카로운 손톱으로 습격합니다!
>> [Alex] 18의 타격을 입었습니다! (HP: 40/70)
[Alex] 공격! (위력: 25)
<< [저주받은 전사] 25 대미지를 입음! (남은 HP: 0)
승리! 저주받은 전사을(를) 격퇴했습니다!
+ [Alex] 경험치 50 EXP 획득 (누적: 90 EXP)
========================================
🎉 LEVEL UP! Alex의 레벨이 [2]로 올랐습니다!
최대 체력 증가: -> 90 | 공격력 상승: -> 29 (체력 완전 회복!)
========================================
[인벤토리] '중형 포션' 획득! (사용 슬롯: 3/5)
>>> 던전 제 3 구역 진입: [공허의 골렘 (보스)] 등장! <<<
[Alex] 공격! (위력: 29)
<< [BOSS 공허의 골렘 (보스)] 29 대미지를 입음! (남은 HP: 81/110)
[BOSS 공허의 골렘 (보스)] 육중한 주먹 내리치기 시전! (위력: 22)
>> [Alex] 22의 타격을 입었습니다! (HP: 68/90)
[Alex] 공격! (위력: 29)
<< [BOSS 공허의 골렘 (보스)] 29 대미지를 입음! (남은 HP: 52/110)
[BOSS 공허의 골렘 (보스)] 육중한 주먹 내리치기 시전! (위력: 22)
>> [Alex] 22의 타격을 입었습니다! (HP: 46/90)
[Alex] 공격! (위력: 29)
<< [BOSS 공허의 골렘 (보스)] 29 대미지를 입음! (남은 HP: 23/110)
⚡ [경고!] 공허의 골렘 (보스)이(가) 폭주합니다! 공허의 힘으로 공격력이 2배 증가합니다!
[BOSS 공허의 골렘 (보스)] ★ 공허 폭발(Void Rupture) ★ 시전! (위력: 44)
>> [Alex] 44의 타격을 입었습니다! (HP: 2/90)
★ [위기 대응] '치유의 물약'을(를) 마셔 체력을 30 회복했습니다! (현재 HP: 32/90)
[Alex] 공격! (위력: 29)
<< [BOSS 공허의 골렘 (보스)] 29 대미지를 입음! (남은 HP: 0)
승리! 공허의 골렘 (보스)을(를) 격퇴했습니다!
+ [Alex] 경험치 250 EXP 획득 (누적: 280 EXP)
========================================
🎉 LEVEL UP! Alex의 레벨이 [3]로 올랐습니다!
최대 체력 증가: -> 110 | 공격력 상승: -> 33 (체력 완전 회복!)
========================================
========================================
🎉 LEVEL UP! Alex의 레벨이 [4]로 올랐습니다!
최대 체력 증가: -> 130 | 공격력 상승: -> 37 (체력 완전 회복!)
========================================
[인벤토리] '보이드의 지배자 왕관' 획득! (사용 슬롯: 3/5)
=========================================
모험 최종 결산 보고서
=========================================
모험가: Alex (최종 레벨: Lv.4)
남은 체력: 130 / 130 HP
클리어 여부: 던전 완전 정복 (VICTORY)
가방 소지품 목록:
- [CONSUMABLE] 소형 포션 (c1d8e2a1)
- [CONSUMABLE] 중형 포션 (8f3a9b4d)
- [ARMOR] 보이드의 지배자 왕관 (7e21a0ff)
=========================================
엔터프라이즈 아키텍처 관점에서의 종합 회고
이 캡스톤 프로젝트를 완성함으로써 우리는 현대 Java 개발의 핵심 축을 몸소 체득했습니다.
- 관심사의 분리 (Separation of Concerns): 체력과 레벨은
Hero가, 소지품은Inventory가, 조우와 턴 제어는GameRunner가 전담합니다. 시스템이 복잡해져도 버그가 발생한 위치를 더 좁은 범위에서 추적할 수 있습니다. - 다형성을 통한 무한한 확장: 새로운 보스 패턴이나 특수 몬스터를 추가할 때 기존 메인 루프를 단 한 줄도 손대지 않고 새 클래스만 끼워 넣을 수 있습니다.
- 불변성과 부수 효과(Side Effect)의 통제: 아이템 모델을 레코드로 불변화하고
Collections.unmodifiableList()로 외부 조작을 방어하여 런타임 데이터 오염을 예방했습니다.
직접 해보기
완성된 캡스톤 코드를 직접 실행해 보고, 자신만의 독창적인 모험 규칙을 추가해 보세요.
public class Main {
// 완성형 Java Adventure를 확장해 봅니다.
public static void main(String[] args) {
System.out.println("Java Adventure 완결편 실습을 시작합니다.");
}
}
미션 과제
- 상점(Shop) 시스템 구현: 몬스터를 잡을 때 골드(Gold)를 드롭하도록 추가하고, 보스전 직전에 상인 NPC를 만나 무기를 업그레이드하거나 포션을 구매하는 거래 로직을 작성해 보세요.
- 속성 상성 시스템: 불(Fire), 물(Water), 풀(Grass) 속성을 열거형(Enum)으로 선언하고, 상성에 따라 공격력이 1.5배 또는 0.5배로 변동되는 속성 계산 엔진을 도입해 보세요.
- 게임 세이브/로드: 앞서 배운 JSON Formatter와 파일 입출력을 활용해, 게임 결산 시점의 영웅 상태를
savegame.json파일로 내보내고 다음 실행 때 이어하기 기능을 구현해 보세요. - ToolPado 도구 연계: 아이템 식별자 고유성 검증을 위해 UUID Generator 도구를 활용해 장비 인스턴스 ID 발급을 테스트해 보세요.