Home Yu-Gi-Oh! 카드 게임
Post
Cancel

Yu-Gi-Oh! 카드 게임

유희왕이란?

만화 원작 유희왕이 배경

1996년 주간 소년 만화 원작이 되어 흥했지만, 햔제는 ocg (오프라인 카드 게임)이 원작이 되어 현재는 ocg에서 만화,애니 등을 제공하고있습니다.

왜 이런 프로젝트를 잡게 되었는가

평소에도 유희왕 카드게임에 관심이 많았기에 가장 친숙하고 게임 시스템을 이미 이해하고있는 것 위주로 프로젝트를 잡게 되었습니다.

Yu-Gi-Oh! 카드 게임

개발 환경

게임 다운로드 및 실행

최신 버전: v2.0 (2025-11-27 배포)

Download YuGiOh-2.0.exe

시스템 요구사항

  • OS: Windows 10/11
  • 설치 크기: 약 62MB (완전 독립형)
  • 추가 설치 요구사항: 없음 (JRE 포함)

특징: Java 설치 불필요 - EXE 파일만 실행하면 설치 마법사가 자동 시작됩니다.


목차

  1. 프로젝트 개요
  2. 개발 중 직면한 주요 문제와 해결 전략
  3. 게임 시스템 설명
  4. 기술 아키텍처
  5. 핵심 기능 구현
  6. 성능 최적화
  7. 배포 방식

프로젝트 개요

프로젝트 소개

이 프로젝트는 유명한 트레이딩 카드 게임(유희왕)을 Java로 구현한 PC 버전 게임입니다. 단순한 학습 프로젝트를 넘어 실제 배포 가능한 완성 소프트웨어로 제작되었습니다.

개발 목표

  • 복잡한 게임 규칙을 정확하게 코드로 구현
  • MVC 패턴을 활용한 확장성 높은 아키텍처 설계
  • 직관적인 GUI 제공
  • AI 플레이어 구현으로 혼자도 게임 가능
  • 최종 사용자도 사용 가능한 배포판 생성

프로젝트 규모

  • 총 코드 라인: 약 5,000+ 줄
  • 클래스 개수: 40+개
  • 패키지 구조: 3개 주요 패키지 (game, gui, listener)
  • 개발 기간: 약 2개월
  • 개발 언어: Java (JDK 25)
  • GUI 프레임워크: Java Swing

개발 중 직면한 주요 문제와 해결 전략

Problem #1: 플랫폼 간 경로 호환성 문제

문제 상황

1
2
3
4
에러 메시지:
java.nio.file.NoSuchFileException: resources/Blue-Eyes-White-Dragon.jpg
    at java.base/sun.nio.fs.WindowsFileSystemProvider.newFileChannel(...)
    at GetCardImage.java:25

증상:

  • Mac/Linux에서 개발한 코드를 Windows에서 실행하면 이미지 로드 실패

  • 카드가 회색 박스로만 표시되거나 아예 보이지 않음

해결

  • os 경로 문제를 적당히 포기하여 해결.

해결 방법

Step 1: 클래스패스 리소스 사용

Problem #1: String 비교 로직 버그

문제 상황

1
2
3
4
5
6
7
8
증상: 게임이 실행되지만 게임 로직이 작동하지 않음
- 몬스터를 소환하려고 해도 "소환할 수 없습니다" 에러
- 배틀포지션을 변경해도 변경되지 않음
- 페이즈가 정상적으로 진행되지 않음

콘솔 출력:
현재 페이즈: MAIN PHASE 1
페이즈 조건 체크: false (계속 거짓!)

근본 원인 분석

문제가 있던 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Player.java - 초기 버전
public void summonMonster(MonsterCard monsterCard) throws WrongPhaseException {
    
    // 이 조건이 MAIN PHASE 1이 맞는데도 항상 거짓으로 판정됨!
    if (field.getPhase() == "MAIN PHASE 1") {
        System.out.println("소환 가능");
    } else {
        throw new WrongPhaseException("현재 페이즈에서는 소환 불가");
    }
}

public boolean switchMonsterMode(MonsterCard monsterCard) {
    // 여기서도 같은 문제
    if (field.getPhase() == "MAIN PHASE 2") {
        // 배틀포지션 변경 로직
    }
}

왜 이런 일이?

Java의 == 연산자는 메모리 주소를 비교합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13

String phase1 = "MAIN PHASE 1";
String phase2 = "MAIN PHASE 1";

//같은 문자열이지만 다른 메모리 위치
System.out.println(phase1 == phase2);// false
System.out.println(phase1.equals(phase2));// true

//메모리 주소 확인
System.out.println("phase1: " + System.identityHashCode(phase1));
System.out.println("phase2: " + System.identityHashCode(phase2));

해결 방법

모든 String 비교를 .equals()로 변경:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 수정된 Player.java
public void summonMonster(MonsterCard monsterCard) 
    throws WrongPhaseException, MaxFieldSizeException, SummonLimitException {
    
    //올바른 비교 방식
    if (!field.getPhase().equals("MAIN PHASE 1") && 
        !field.getPhase().equals("MAIN PHASE 2")) {
        throw new WrongPhaseException(
            "Cannot summon in " + field.getPhase() + " phase");
    }
    
    if (field.getMonsters().size() >= 5) {
        throw new MaxFieldSizeException("Field is full (max 5 monsters)");
    }
    
    if (monsterSummoned) {
        throw new SummonLimitException("Already summoned this turn");
    }
    
    //안전한 소환
    field.setMonster(monsterCard);
    hand.removeCardFromHand(monsterCard);
    monsterSummoned = true;
    
    System.out.println("Successfully summoned: " + monsterCard.getName());
}

//배틀포지션 변경
public boolean switchMonsterMode(MonsterCard monsterCard) 
    throws WrongPhaseException, DefenseModeException {
    
    if (!field.getPhase().equals("MAIN PHASE 1") && 
        !field.getPhase().equals("MAIN PHASE 2")) {
        throw new WrongPhaseException(
            "Cannot switch mode in " + field.getPhase());
    }
    
    if (monsterCard.getHaveAttacked()) {
        throw new DefenseModeException("Cannot switch after attacking");
    }
    
    String newMode = monsterCard.getMode().equals("ATTACK") 
        ? "DEFENSE" 
        : "ATTACK";
    
    monsterCard.setMode(newMode);
    
    System.out.println("Switched to: " + newMode);
    return true;
}

//배틀 로직
public void attack(MonsterCard attacker, MonsterCard defender, Player opponent)
    throws AlreadyAttackedException, WrongPhaseException, DefenseModeException {
    
    // 다양한 조건 체크 - 모두 .equals() 사용
    if (attacker.getHaveAttacked()) {
        throw new AlreadyAttackedException(
            "Already attacked with this monster this turn");
    }
    
    if (!field.getPhase().equals("BATTLE PHASE")) {
        throw new WrongPhaseException(
            "Can only attack during Battle Phase");
    }
    
    if (!attacker.getMode().equals("ATTACK")) {
        throw new DefenseModeException(
            "Cannot attack with Defense position monster");
    }
    
    // 데미지 계산
    int damage = calculateDamage(attacker, defender);
    opponent.lifepoints -= damage;
    attacker.setHaveAttacked(true);
    
    System.out.println("battle" + attacker.getName() + 
        " attacked for " + damage + " damage!");
}

디버깅 과정 (어떻게 버그를 찾았나?)

처음에는 왜 소환이 안 되는지 몰라서 단계적으로 디버그:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 디버깅을 위해 추가한 코드
System.out.println("--- Debug Info ---");
System.out.println("Phase value: '" + field.getPhase() + "'");
System.out.println("Phase type: " + field.getPhase().getClass());
System.out.println("Is 'MAIN PHASE 1'? " + field.getPhase() == "MAIN PHASE 1");
System.out.println("Using equals(): " + field.getPhase().equals("MAIN PHASE 1"));

// 출력 결과:
// --- Debug Info ---
// Phase value: 'MAIN PHASE 1'
// Phase type: class java.lang.String
// Is 'MAIN PHASE 1'? false ← 이상!
// Using equals(): true ← 이것이 정답!

이 결과를 보고 == vs .equals()의 차이

핵심 교훈

Java String 비교 규칙:

1
2
3
4
5
6
7
8
9
10
11
12
// 1
if (string1 == string2) { }

// 2
if (string1.equals(string2)) { }

// 대소문자 무시 비교
if (string1.equalsIgnoreCase(string2)) { }

// null 안전 비교 (Java 11+)
if (Objects.equals(string1, string2)) { }

1
2
3
4
## Problem #3: 이미지 로드 후 턴 전환 시 카드 미표시

###문제 상황

증상: 턴이 전환될 때 상대 플레이어의 행동이 이미지 없이 표시됨

  • 상대가 카드를 소환하지만 회색 박스로만 보임
  • “END TURN”을 누르면 이미지가 나타남 (너무 늦음!)
  • GUI가 끊김
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
### 원인 분석

**문제가 있던 코드:**

```java
// OpponentPlayerStrategy.java - 초기 버전
public void drawCard() {
    MonsterCard newCard = opponent.drawCard();
    
    // GetCardImage 호출이 없음!
    // 게임 로직만 처리하고 이미지는 로드하지 않음
    gui.addToHand(newCard, gui.getOpponentPlayer());
}

public void summonMonster() {
    for (MonsterCard card : opponent.getHand().getCardsInHand()) {
        try {
            opponent.summonMonster(card);
            // 여기도 이미지 로드 없음!
            
            gui.summonMonster(findHandButton(card), gui.getOpponentPlayer());
            break;
        } catch (Exception e) {
            // 에러 처리
        }
    }
}

public void endTurn() {
    opponent.endTurn();
    game.switchPlayer();
    / 다음 카드 준비 없음!
}

실행 흐름 분석:

1
2
3
4
5
6
7
8
9
10
11
12
플레이어: "END TURN" 클릭시 상대방
1drawCard() 실행
    게임 로직: 덱에서 카드 제거
    GUI 업데이트: 손에 카드 추가 
    이미지 로드: 없음
summonMonster() 실행
    게임 로직: 필드에 카드 추가
    GUI 업데이트: 필드에 버튼 생성
    이미지 로드: 없음
ttack() 실행
    게임 로직: 데미지 계산
    GUI 업데이트: 라이프 감소

해결 방법

상대 행동 전후로 이미지 로드 추가:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class OpponentPlayerStrategy {
    private Game game;
    private GUI gui;
    
    public OpponentPlayerStrategy(Game game, GUI gui) {
        this.game = game;
        this.gui = gui;
    }
    
    // 드로우 카드 처리
    public void drawCard() {
        MonsterCard newCard = opponent.drawCard();
        
        new GetCardImage(newCard, "s");
        System.out.println("?? Drew: " + newCard.getName());
        
        // Step 2: GUI에 카드 추가
        gui.addToHand(newCard, gui.getOpponentPlayer());
    }
    
    // 몬스터 소환 처리
    public void summonMonster() {
        for (MonsterCard card : opponent.getHand().getCardsInHand()) {
            try {

                if (card.getImageSmall() == null) {
                    new GetCardImage(card, "s");
                    System.out.println("??? Loaded small image: " + card.getName());
                }
                if (card.getImageLarge() == null) {
                    new GetCardImage(card, "l");
                    System.out.println("??? Loaded large image: " + card.getName());
                }
                
                opponent.summonMonster(card)

                HandButton handBtn = gui.findHandButton(card);
                MonsterButton monsterBtn = gui.summonMonster(
                    handBtn, 
                    gui.getOpponentPlayer()
                );
                
                System.out.println("12Summoned: " + card.getName() + 
                    " (ATK: " + card.getAttack() + ")");
                break;
                
            } catch (WrongPhaseException e) {
                System.out.println(" Wrong phase: " + e.getMessage());
            } catch (Exception e) {
                System.out.println("Cannot summon: " + e.getMessage());
            }
        }
    }
    //공격처리
    public void attack() {
        ArrayList<MonsterCard> fieldMonsters = 
            opponent.getField().getMonsters();
        
        if (fieldMonsters.isEmpty()) {
            System.out.println("?? No monsters to attack with");
            return;
        }
        
        for (MonsterCard attacker : fieldMonsters) {
            // 이미 공격했는지 확인
            if (attacker.getHaveAttacked()) {
                continue;
            }
            
            try {
                if (game.getPlayer().getField().getMonsters().isEmpty()) {
                    opponent.attackDirectly(attacker, game.getPlayer());
                    
                    System.out.println("Direct attack! " + 
                        game.getPlayer().lifepoints + " LP remaining");
                } else {
                    MonsterCard defender = 
                        game.getPlayer().getField().getMonsters().get(0);
                    
                    opponent.attack(attacker, defender, game.getPlayer());
                    
                    System.out.println("배틀" + attacker.getName() + 
                        " vs " + defender.getName());
                }
                
            } catch (AlreadyAttackedException e) {
                System.out.println(" Already attacked");
            } catch (Exception e) {
                System.out.println(" Attack failed: " + e.getMessage());
            }
        }
    }
    
    public void endTurn() { // 턴 종료 처리
        System.out.println("Opponent ending turn...");
        
        // Step 1: 다음 턴을 위해 준비
        Deck opponentDeck = opponent.getDeck();
        if (!opponentDeck.getDeck().isEmpty()) {
            MonsterCard nextCard = opponentDeck.getDeck().get(0);
            
            // 미리 다음 드로우 카드의 이미지 로드
            new GetCardImage(nextCard, "s");
            System.out.println(" Pre-loaded next card: " + nextCard.getName());
        }
        
        
        opponent.endTurn();// 턴 종료 처리
        game.switchPlayer();// 턴 넘기니 상대방 측 시작
        
        System.out.println(" Back to player's turn");
    }
}

성능 최적화 전략

이미지 로드 타이밍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//타이밍 분석
long startTime = System.currentTimeMillis();

// 이미지 로드
new GetCardImage(card, "s");

long loadTime = System.currentTimeMillis() - startTime;
System.out.println("이미지 로드 시간: " + loadTime + "ms");

// 타이밍 최적화
public void optimizedCardDisplay(MonsterCard card) {// 메인 스레드에서 빠르게 실행
    SwingUtilities.invokeLater(() -> {
        // 이미지 로드를 백그라운드에서
        new Thread(() -> {
            new GetCardImage(card, "s");
            // 로드 완료 후 UI 업데이트
            SwingUtilities.invokeLater(() -> {
                gui.refreshCardDisplay(card);
            });
        }).start();
    });
}

핵심

비동기 처리의 중요성:

  1. UI 응답성 유지 - 긴 작업을 미리 처리
  2. 순서 보장 - 이미지 로드 게임 로직00 GUI 업데이트
  3. 미리 로드 - 다음 단계에서 필요한 리소스 미리 준비

Problem #: GUI 컴포넌트 Z-Order 겹침

문제 상황

1
2
3
4
증상: 게임 실행 후 버튼을 클릭해도 작동하지 않음
- 페이즈 컨트롤 버튼이 보이지 않음
- 정보 패널이 카드 뒤에 숨음
- 마우스 커서가 손 위에서 시계 모양으로 변함 (클릭 불가)

0근본 원인 분석

문제가 있던 코드:

1
2
3
4
5
6
7
8
// GUI.java - 초기 버전
priva0te void setPanels() {
    // 순서가 완전히 잘못됨
    this.add(activePlayer);          // 맨 먼저 추가 = 맨 뒤
    this.add(opponentPlayer);        // 그 다음 (일부 가려짐)
    this.add(infoPanel);             // 그 다음 (완전히 가려짐)
    this.add(phaseControlPanel);     // 마지막 (맨 앞이지만 작아서 숨김)
}

Swing의 z-order 규칙:

1
add() 순서 역순으로 화면 렌더링먼저,add() = 맨 뒤 (배경), 나중 add() = 맨 앞 (클릭 가능)

해결 방법

명시적 z-order 설정:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 수정 GUI
private void setPanels() {
    
    getContentPane().removeAll();//기존 컴포넌트 제거
    
    
    try {//배경 이미지 추가 (맨 뒤)
        ImageIcon backgroundIcon = new ImageIcon(
            GetCardImage.class.getResource("/com/gui/resources/newBackground.png"));
        
        JLabel background = new JLabel(backgroundIcon);
        background.setBounds(0, 0, 1366, 768);
        
        // z-order = 0 (가장 뒤)
        getContentPane().add(background, Integer.valueOf(0));
        System.out.println(" Background added (z-order: 0)");
        
    } catch (Exception e) {
        System.err.println(" Background image not found");
    }
    
    // Step 3: 플레이어 필드 추가 (배경 위)
    activePlayer.setBounds(0, 450, 1366, 318);
    getContentPane().add(activePlayer, Integer.valueOf(1));
    System.out.println(" Active player field added (z-order: 1)");
    
    opponentPlayer.setBounds(0, 0, 1366, 320);
    getContentPane().add(opponentPlayer, Integer.valueOf(1));
    System.out.println(" Opponent player field added (z-order: 1)");
    
    // Step 4: 정보 패널 추가 (플레이어 필드 위)
    infoPanel.setBounds(1050, 300, 300, 400);
    getContentPane().add(infoPanel, Integer.valueOf(2));
    System.out.println(" Info panel added (z-order: 2)");
    
    // Step 5: 페이즈 컨트롤 추가 (정보 패널 위)
    phaseControlPanel.setBounds(10, 420, 1346, 30);
    getContentPane().add(phaseControlPanel, Integer.valueOf(3));
    System.out.println(" Phase control added (z-order: 3)");
    

    try {
        ImageIcon logoIcon = new ImageIcon(
            GetCardImage.class.getResource("/com/gui/resources/logo.png"));
        
        JLabel logo = new JLabel(logoIcon);
        logo.setBounds(1280, 10, 70, 70);
        

        getContentPane().add(logo, Integer.valueOf(4));
        System.out.println(" Logo added (z-order: 4)");
        
    } catch (Exception e) {
        System.err.println(" Logo image not found");
    }
    

    getContentPane().revalidate();
    getContentPane().repaint();
    System.out.println(" Panel layout refreshed");
}

// z-order 시각화
public void debugZOrder() {
    System.out.println("/n Component Z-Order Debug:");
    System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    
    Component[] components = getContentPane().getComponents();
    for (int i = 0; i < components.length; i++) {
        System.out.println(String.format(
            "%d. %s (클릭: %s)", 
            i,
            components[i].getClass().getSimpleName(),
            components[i].isVisible() ? "O" : "X"
        ));
    }
    
    System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━/n");
}

Z-Order 다이어그램:

1
2
3
4
5
6
7
8
화면
z-order 3: 페이즈 컨트롤 (버튼, 클릭 가능)
z-order 2: 정보 패널 (카드 정보, 클릭 가능)
라이프포인트, 페이즈 정보
z-order 1: 플레이어 필드 (게임 필드, 클릭 가능)
상대 필드 (상단)
내 필드 (하단)
z-order 0: 배경 (무시, 클릭 불가)이미지: Blue-Eyes Dragon

핵심

Swing 컴포넌트 배치 규칙:

  1. 명시적 z-order 사용 - add(component, Integer.valueOf(n))
  2. 절대 위치 설정 - setBounds(x, y, width, height)
  3. 항상 revalidate() + repaint() - 변경사항 반영
  4. 고려해야 할 사항:
    • 클릭 가능 영역 (버튼)
    • 시각적 계층 (배경, 카드, UI)
    • 마우스 이벤트 전파

게임 시스템 설명

턴 구조 및 페이즈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
플레이어 1 턴 시작
│
├─ Draw Phase (자동)
│  └─ 덱에서 카드 1장 드로우
│
├─ Main Phase 1 (플레이어 조작)
│  ├─ ? 몬스터 소환 (최대 1마리)
│  ├─ ? 배틀포지션 변경
│  └─ ? 마법/함정 활성화 (미구현)
│
├─ Battle Phase (플레이어 조작)
│  ├─ ? 몬스터 공격 (카드당 1회)
│  ├─ ? 직접 공격
│  └─ ? 공격 선택 취소
│
├─ Main Phase 2 (플레이어 조작)
│  ├─ ? 추가 몬스터 소환 불가
│  ├─ ? 배틀포지션 변경 불가
│  └─ ? 마법/함정 활성화 (미구현)
│
└─ End Phase
   ├─ 턴 종료 로직 실행
   │  ├─ monsterSummoned = false (초기화)
   │  └─ 모든 몬스터 haveAttacked = false (초기화)
   ├─ 플레이어 전환
   └─ 상대방 턴 시작

플레이어 2 턴 시작 (AI 자동 실행)
│
├─ Draw Phase
├─ Main Phase 1
├─ Battle Phase
├─ Main Phase 2
└─ End Phase (자동 턴 종료)

플레이어 1 턴 재개...

배틀 시스템

데미지 계산 규칙

Case 1: ATTACK vs ATTACK (공격 vs 공격)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 밑 케이스 3까지 예시입니다.

int attackerPower = 2500;
int defenderPower = 1500;

if (attackerPower > defenderPower) {
    // 방어 몬스터 파괴
    defender 제거
    // 데미지 = 공격력 차이
    opponentLifePoint -= (2500 - 1500) = 1000
    결과: 상대 라이프 1000 감소
}

Case 2: ATTACK vs DEFENSE (공격 vs 방어)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int attackerPower = 2500;
int defenderDefense = 3000;

if (attackerPower > defenderDefense) {
    // 방어 몬스터 파괴 (데미지 없음)
    defender 제거
    opponentLifePoint -= 0
    결과: 방어 카드만 파괴
} else {
    // 방어 성공 (아무 일 없음)
    defender 유지
    opponentLifePoint -= 0
    결과: 변화 없음
}

Case 3: Direct Attack (직접 공격)

1
2
3
4
5
// 상대 필드에 몬스터가 없을 때
int damage = attacker.getAttack();
opponentLifePoint -= damage;

결과: 직접 데미지 (모두 적용)

기술 아키텍처

MVC 패턴 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
┌─────────────────────────────────────────────────────────┐
│                    MVC Architecture                      │
├─────────────────────────────────────────────────────────┤
│                                                           │
│  Model Layer (게임 로직)                                 │
│  ┌───────────────────────────────────────────────────┐  │
│  │ Game.java (게임 관리)                            │  │
│  │ ├─ Player (플레이어 1, 2)                       │  │
│  │ │  ├─ Hand (손)                                │  │
│  │ │  ├─ Deck (덱)                                │  │
│  │ │  ├─ Field (필드)                             │  │
│  │ │  │  ├─ MonsterZone                          │  │
│  │ │  │  ├─ SpellZone                            │  │
│  │ │  │  └─ Graveyard                            │  │
│  │ │  └─ lifepoints (라이프포인트)                │  │
│  │ │                                              │  │
│  │ └─ Card (카드 기본 클래스)                     │  │
│  │    ├─ MonsterCard (몬스터)                    │  │
│  │    └─ SpellCard (마법/함정)                   │  │
│  └───────────────────────────────────────────────────┘  │
│           ↑                              ↓               │
│      setters/getters              읽기 위주 접근        │
│                                                           │
│  View Layer (사용자 인터페이스)                          │
│  ┌───────────────────────────────────────────────────┐  │
│  │ GUI.java (메인 프레임)                          │  │
│  │ ├─ PlayerPanel (플레이어 화면)                 │  │
│  │ │  ├─ FieldPanel (필드 표시)                   │  │
│  │ │  └─ HandPanel (손 표시)                      │  │
│  │ ├─ InfoPanel (정보 표시)                       │  │
│  │ └─ PhaseControlPanel (페이즈 버튼)             │  │
│  └───────────────────────────────────────────────────┘  │
│           ↑                              ↓               │
│      클릭 이벤트                   화면 갱신             │
│                                                           │
│  Controller Layer (이벤트 처리)                         │
│  ┌───────────────────────────────────────────────────┐  │
│  │ Listeners (마우스 이벤트 처리)                  │  │
│  │ ├─ SelectHandCardListener                      │  │
│  │ ├─ SummonMonsterListener                       │  │
│  │ ├─ AttackListener                              │  │
│  │ ├─ StartAttackListener                         │  │
│  │ ├─ DefenseModeListener                         │  │
│  │ ├─ PhaseListener                               │  │
│  │ ├─ EndTurnListener                             │  │
│  │ └─ OpponentStrategyListener (AI)               │  │
│  └───────────────────────────────────────────────────┘  │
│           ↓                              ↑               │
│      사용자 행동              게임 상태 변경             │
│                                                           │
└─────────────────────────────────────────────────────────┘

패턴 이점

이점 설명
독립성 게임 로직 변경 시 GUI 수정 불필요
테스트 용이성 각 계층을 독립적으로 테스트 가능
재사용성 다른 UI (CLI, 웹)로도 확장 가능
유지보수성 각 계층의 책임이 명확함

핵심 기능 구현

카드 소환 시스템

1
2
3
4
5
6
7
8
9
10
11
12
13
// 완전한 소환 프로세스
public void summonMonster(MonsterCard card) {
    // 1단계: 조건 검증
    validateSummonConditions(card);
    
    // 2단계: 게임 상태 변경
    field.setMonster(card);
    hand.removeCardFromHand(card);
    monsterSummoned = true;
    
    // 3단계: UI 업데이트
    gui.visualizeSummon(card);
}

배틀 시스템

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 복잡한 데미지 계산
public void attack(MonsterCard attacker, MonsterCard defender, Player opponent) {
    // 1단계: 유효성 검사
    validateAttack(attacker);
    
    // 2단계: 데미지 계산
    int damage = calculateDamage(attacker, defender);
    
    // 3단계: 상태 변경
    applyDamage(opponent, damage);
    handleCardDestruction(attacker, defender);
    
    // 4단계: UI 업데이트
    gui.visualizeBattle(attacker, defender, damage);
}

성능 최적화

이미지 캐싱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 한 번 로드한 이미지는 재사용
private static Map<String, ImageIcon> imageCache = new HashMap<>();

public GetCardImage(Card card, String size) {
    String key = card.getName() + "_" + size;
    
    if (imageCache.containsKey(key)) {
        this.image = imageCache.get(key);
        return; // 빠른 반환
    }
    
    // 첫 로드 시만 시간 소요
    BufferedImage loaded = loadImage(card, size);
    this.image = new ImageIcon(loaded);
    
    imageCache.put(key, this.image);
}

메모리 최적화

  • 불필요한 객체 즉시 GC 대상 처리
  • ArrayList -> LinkedList 선택 (성능 고려)

배포 방식

EXE 설치 프로그램 생성

장점:

  • Java 설치 불필요
  • 설치 마법사 제공
  • 바탕화면 바로가기 생성
  • 시작메뉴 등록
  • 단일 파일 배포

결론 및 학습 성과

이 프로젝트를 통해 얻은 실무 경험:

개발자로서의 성장

  1. 문제 해결 능력: 버그를 체계적으로 분석하고 해결
  2. 아키텍처 설계: MVC 패턴의 실제 적용
  3. 사용자 경험: GUI 반응성과 예외 처리의 중요성
  4. 배포: 실제 사용자가 사용 가능한 수준의 완성도

포트폴리오 가치

실제 배포를 해본 프로젝트로 어느정도는 가치가 있는 프로젝트라고 생각합니다. —

마무리

감사합니다.

This post is licensed under CC BY 4.0 by the author.