diff --git a/README.md b/README.md index 8d7e8aee..d4befb08 100644 --- a/README.md +++ b/README.md @@ -1 +1,68 @@ -# java-baseball-precourse \ No newline at end of file +# 숫자 야구 게임 설계 문서 + +이 문서는 숫자 야구 게임의 핵심 로직과 상태 패턴(State Pattern)을 이용한 흐름 제어 설계를 포함합니다. + +--- + +## 구현 범위 + +이번 과제에서 최종적으로 구현할 항목은 아래와 같습니다. + +- 1~9 사이의 서로 다른 3자리 컴퓨터 숫자 생성 +- 사용자 숫자 입력 처리 및 입력값 검증(길이, 숫자 여부, 범위, 중복) +- 잘못된 입력 시 `[ERROR]` 메시지 출력 후 재입력 +- 스트라이크/볼/낫싱 계산과 결과 출력 +- 3스트라이크 달성 시 게임 종료 메시지 출력 +- 게임 종료 후 재시작(1) / 종료(2) 분기 처리 +- 상태 패턴(`ProgressState`, `GameOverState`, `EndState`) 기반 흐름 제어 +- 도메인 로직 단위 테스트(`BaseballNumber`, `NumberGenerator`, `GameResult`) + +--- + +## 1. 도메인 모델 + +### BaseballNumbers +사용자 또는 컴퓨터의 숫자를 관리하는 객체입니다. +- **검증**: 1~9 사이의 서로 다른 3자리 숫자인지 검증합니다. +- **비교**: 상대방의 `BaseballNumbers`와 비교하여 스트라이크와 볼의 개수를 계산합니다. + +### GameResult +계산된 스트라이크/볼 결과를 처리하는 객체입니다. +- **결과 저장**: 계산된 스트라이크와 볼의 개수를 보관합니다. +- **메시지 생성**: 결과에 따른 출력 메시지(예: `1볼 2스트라이크`, `낫싱`)를 생성합니다. +- **판단**: 3 스트라이크인지 여부를 확인하여 게임 종료 조건을 체크합니다. + +--- + +## 2. 컨트롤러 및 실행 (Game Controller) + +### Game +전체적인 게임 인스턴스와 흐름을 관리합니다. +- **상태 관리**: 현재의 `GameState`를 유지합니다. +- **게임 루프**: 현재 상태가 종료 상태(`EndState`)가 될 때까지 실행을 반복합니다. +- **유연성**: 상태 패턴을 통해 게임 종료 후 새로운 게임을 시작하거나 완전히 종료하는 로직을 매끄럽게 연결합니다. + +--- + +## 3. 상태 인터페이스 및 구현 (State Pattern) + +상태 패턴을 활용하여 각 상황에 맞는 로직을 캡슐화하고 흐름을 제어합니다. + + + +| 클래스명 | 역할 및 주요 기능 | 다음 상태 전이 (Next State) | +| :--- | :--- | :--- | +| **ProgressState** | - "숫자를 입력해주세요" 프롬프트 출력
- 사용자 입력을 받아 `BaseballNumbers`와 비교 후 결과 출력 | - 정답이 아니면: `this` (유지)
- 정답이면: `GameOverState` 반환 | +| **GameOverState** | - "3개의 숫자를 모두 맞히셨습니다! 게임 끝" 및 재시작 안내 출력
- 사용자 입력(1: 재시작, 2: 종료) 대기 | - `1` 입력 시: 새로운 정답을 가진 `ProgressState`
- `2` 입력 시: `EndState` | +| **EndState** | - 게임 루프를 종료해야 함을 `Game`에게 알림 | - 없음 (시스템 종료) | + +--- + +## 4. 게임 실행 흐름 (Game Flow) + +1. **초기화**: `Game` 객체가 생성될 때 컴퓨터의 숫자를 생성하고 `ProgressState`로 시작합니다. +2. **진행**: 사용자가 정답을 맞힐 때까지 `ProgressState` 내에서 입력을 반복합니다. +3. **완료**: 3 스트라이크 달성 시 `GameOverState`로 전환되어 축하 메시지를 출력합니다. +4. **분기**: + - 사용자가 `1`을 입력하면 새로운 숫자를 생성하여 다시 `ProgressState`로 돌아갑니다. + - 사용자가 `2`를 입력하면 `EndState`로 전환되어 루프가 종료됩니다. diff --git a/src/main/java/Main.java b/src/main/java/Main.java new file mode 100644 index 00000000..e3287045 --- /dev/null +++ b/src/main/java/Main.java @@ -0,0 +1,8 @@ +import controller.GameController; + +public class Main{ + public static void main(String[] args) { + GameController gameController = new GameController(); + gameController.run(); + } +} diff --git a/src/main/java/controller/GameController.java b/src/main/java/controller/GameController.java new file mode 100644 index 00000000..14dcfed5 --- /dev/null +++ b/src/main/java/controller/GameController.java @@ -0,0 +1,31 @@ +package controller; + +import model.BaseballNumber; +import model.Game; +import model.NumberGenerator; +import state.GameState; +import state.ProgressState; + +public class GameController { + public Game game; + public NumberGenerator numberGenerator; + + public void run(){ + initialize(); + play(); + } + + public void initialize(){ + this.numberGenerator = new NumberGenerator(); + BaseballNumber baseballNumber = numberGenerator.generate(); + GameState initialGameState = new ProgressState(baseballNumber); + + this.game = new Game(initialGameState); + } + + public void play(){ + while(game.isRunning()){ + game.update(); + } + } +} diff --git a/src/main/java/model/BaseballNumber.java b/src/main/java/model/BaseballNumber.java new file mode 100644 index 00000000..0c7ce006 --- /dev/null +++ b/src/main/java/model/BaseballNumber.java @@ -0,0 +1,92 @@ +package model; + +import java.util.ArrayList; +import java.util.List; + +public class BaseballNumber { + private final List numbers; + + public BaseballNumber(List numbers){ + this.numbers = new ArrayList<>(numbers); + validateNumbers(); + } + + public BaseballNumber(String input){ + this.numbers = parse(input); + validateNumbers(); + } + + public boolean isValid(){ + if(numbers.size() != 3) return false; + if(!isRangeValid()) return false; + if(hasDuplicate()) return false; + return true; + } + + public List getNumbers(){ + return new ArrayList<>(numbers); + } + + public GameResult compare(BaseballNumber target){ + int strike = countStrike(target); + int ball = countBall(target); + return new GameResult(strike, ball); + } + + private int countStrike(BaseballNumber target){ + List targetNumbers = target.getNumbers(); + int strike = 0; + for (int i = 0; i < numbers.size(); i++) { + if(numbers.get(i).equals(targetNumbers.get(i))) strike++; + } + return strike; + } + + private int countBall(BaseballNumber target){ + List targetNumbers = target.getNumbers(); + int ball = 0; + for (int i = 0; i < numbers.size(); i++) { + for (int j = 0; j < targetNumbers.size(); j++) { + if(i == j) continue; + if(numbers.get(i).equals(targetNumbers.get(j))) ball++; + } + } + return ball; + } + + private List parse(String input){ + if(input == null) throw new IllegalArgumentException("입력값이 비어 있습니다."); + if(input.length() != 3) throw new IllegalArgumentException("3자리 숫자를 입력해야 합니다."); + + List parsed = new ArrayList<>(); + for (int i = 0; i < input.length(); i++) { + char ch = input.charAt(i); + if(!Character.isDigit(ch)) throw new IllegalArgumentException("숫자만 입력해야 합니다."); + parsed.add(ch - '0'); + } + return parsed; + } + + private void validateNumbers(){ + if(isValid()) return; + throw new IllegalArgumentException("1~9의 서로 다른 3자리 숫자를 입력해야 합니다."); + } + + private boolean isRangeValid(){ + for (int i = 0; i < numbers.size(); i++) { + int number = numbers.get(i); + if(number < 1 || number > 9) return false; + } + return true; + } + + private boolean hasDuplicate(){ + for (int i = 0; i < numbers.size(); i++) { + for (int j = i + 1; j < numbers.size(); j++) { + if(numbers.get(i).equals(numbers.get(j))) return true; + } + } + return false; + } + +} diff --git a/src/main/java/model/Game.java b/src/main/java/model/Game.java new file mode 100644 index 00000000..8b0668df --- /dev/null +++ b/src/main/java/model/Game.java @@ -0,0 +1,28 @@ +package model; + +import state.GameState; + +public class Game { + private GameState state; + private boolean running = true; + + public Game(GameState initialState) { + this.state = initialState; + } + + public void changeState(GameState state) { + this.state = state; + } + + public void update() { + state.handle(this); + } + + public void stop() { + this.running = false; + } + + public boolean isRunning() { + return running; + } +} diff --git a/src/main/java/model/GameResult.java b/src/main/java/model/GameResult.java new file mode 100644 index 00000000..67cb7123 --- /dev/null +++ b/src/main/java/model/GameResult.java @@ -0,0 +1,30 @@ +package model; + +public class GameResult { + private final int strike; + private final int ball; + + public GameResult(int strike, int ball){ + this.strike = strike; + this.ball = ball; + } + + public int getStrike(){ + return strike; + } + + public int getBall(){ + return ball; + } + + public boolean isThreeStrike(){ + return strike == 3; + } + + public String getMessage(){ + if(strike == 0 && ball == 0) return "낫싱"; + if(strike == 0) return ball + "볼"; + if(ball == 0) return strike + "스트라이크"; + return strike + "스트라이크 " + ball + "볼"; + } +} diff --git a/src/main/java/model/NumberGenerator.java b/src/main/java/model/NumberGenerator.java new file mode 100644 index 00000000..d79b9200 --- /dev/null +++ b/src/main/java/model/NumberGenerator.java @@ -0,0 +1,22 @@ +package model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class NumberGenerator { + + public BaseballNumber generate(){ + List nums = new ArrayList<>(); + for (int i = 1; i <= 9; i++) { + nums.add(i); + } + Collections.shuffle(nums); + + List pick = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + pick.add(nums.get(i)); + } + return new BaseballNumber(pick); + } +} diff --git a/src/main/java/state/EndState.java b/src/main/java/state/EndState.java new file mode 100644 index 00000000..5c024f19 --- /dev/null +++ b/src/main/java/state/EndState.java @@ -0,0 +1,10 @@ +package state; + +import model.Game; + +public class EndState implements GameState{ + @Override + public void handle(Game game){ + game.stop(); + } +} diff --git a/src/main/java/state/GameOverState.java b/src/main/java/state/GameOverState.java new file mode 100644 index 00000000..719c6050 --- /dev/null +++ b/src/main/java/state/GameOverState.java @@ -0,0 +1,37 @@ +package state; + +import model.BaseballNumber; +import model.Game; +import model.NumberGenerator; +import view.Inputview; +import view.OutputView; + +public class GameOverState implements GameState{ + private final Inputview inputview = new Inputview(); + private final OutputView outputView = new OutputView(); + + @Override + public void handle(Game game){ + outputView.printRestartGuide(); + String input = inputview.readRestart(); + changeState(input, game); + } + + private void changeState(String input, Game game){ + if("1".equals(input)) { + restart(game); + return; + } + if("2".equals(input)) { + game.changeState(new EndState()); + return; + } + outputView.printError("1 또는 2를 입력해야 합니다."); + } + + private void restart(Game game){ + NumberGenerator numberGenerator = new NumberGenerator(); + BaseballNumber baseballNumber = numberGenerator.generate(); + game.changeState(new ProgressState(baseballNumber)); + } +} diff --git a/src/main/java/state/GameState.java b/src/main/java/state/GameState.java new file mode 100644 index 00000000..fe1bc76c --- /dev/null +++ b/src/main/java/state/GameState.java @@ -0,0 +1,7 @@ +package state; + +import model.Game; + +public interface GameState { + void handle(Game game); +} diff --git a/src/main/java/state/ProgressState.java b/src/main/java/state/ProgressState.java new file mode 100644 index 00000000..11ea03c3 --- /dev/null +++ b/src/main/java/state/ProgressState.java @@ -0,0 +1,39 @@ +package state; + +import model.BaseballNumber; +import model.Game; +import model.GameResult; +import view.Inputview; +import view.OutputView; + +public class ProgressState implements GameState{ + private final BaseballNumber answer; + private final Inputview inputview; + private final OutputView outputView; + + public ProgressState(BaseballNumber baseballNumber){ + this.answer = baseballNumber; + this.inputview = new Inputview(); + this.outputView = new OutputView(); + } + + @Override + public void handle(Game game){ + outputView.printInputGuide(); + String input = inputview.readNumber(); + playOneTurn(input, game); + } + + private void playOneTurn(String input, Game game){ + try { + BaseballNumber userNumber = new BaseballNumber(input); + GameResult gameResult = answer.compare(userNumber); + outputView.printResult(gameResult.getMessage()); + if(!gameResult.isThreeStrike()) return; + outputView.printGameEnd(); + game.changeState(new GameOverState()); + } catch (IllegalArgumentException e){ + outputView.printError(e.getMessage()); + } + } +} diff --git a/src/main/java/view/Inputview.java b/src/main/java/view/Inputview.java new file mode 100644 index 00000000..b0c71027 --- /dev/null +++ b/src/main/java/view/Inputview.java @@ -0,0 +1,15 @@ +package view; + +import java.util.Scanner; + +public class Inputview { + private final Scanner scanner = new Scanner(System.in); + + public String readNumber() { + return scanner.nextLine(); + } + + public String readRestart() { + return scanner.nextLine(); + } +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 00000000..1e4fc650 --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,23 @@ +package view; + +public class OutputView { + public void printInputGuide() { + System.out.print("숫자를 입력해주세요 : "); + } + + public void printResult(String msg) { + System.out.println(msg); + } + + public void printGameEnd() { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 시마이"); + } + + public void printRestartGuide() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + } + + public void printError(String msg) { + System.out.println("[ERROR] " + msg); + } +} diff --git a/src/test/java/model/BaseballNumberTest.java b/src/test/java/model/BaseballNumberTest.java new file mode 100644 index 00000000..aa4cfbac --- /dev/null +++ b/src/test/java/model/BaseballNumberTest.java @@ -0,0 +1,55 @@ +package model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaseballNumberTest { + + @Test + void 문자열로_숫자야구_숫자를_생성한다() { + BaseballNumber baseballNumber = new BaseballNumber("123"); + + assertThat(baseballNumber.getNumbers()).containsExactly(1, 2, 3); + } + + @Test + void 길이가_3이_아니면_예외가_발생한다() { + assertThatThrownBy(() -> new BaseballNumber("12")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("3자리"); + } + + @Test + void 숫자가_아닌_문자가_포함되면_예외가_발생한다() { + assertThatThrownBy(() -> new BaseballNumber("12a")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("숫자만"); + } + + @Test + void 범위를_벗어난_숫자가_포함되면_예외가_발생한다() { + assertThatThrownBy(() -> new BaseballNumber("120")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1~9"); + } + + @Test + void 중복_숫자가_포함되면_예외가_발생한다() { + assertThatThrownBy(() -> new BaseballNumber("112")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("서로 다른"); + } + + @Test + void 비교_결과로_스트라이크와_볼을_계산한다() { + BaseballNumber answer = new BaseballNumber("425"); + BaseballNumber user = new BaseballNumber("456"); + + GameResult gameResult = answer.compare(user); + + assertThat(gameResult.getStrike()).isEqualTo(1); + assertThat(gameResult.getBall()).isEqualTo(1); + } +} diff --git a/src/test/java/model/NumberGeneratorTest.java b/src/test/java/model/NumberGeneratorTest.java new file mode 100644 index 00000000..97ece531 --- /dev/null +++ b/src/test/java/model/NumberGeneratorTest.java @@ -0,0 +1,39 @@ +package model; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class NumberGeneratorTest { + + @Test + void 생성된_숫자는_3자리다() { + NumberGenerator numberGenerator = new NumberGenerator(); + + BaseballNumber baseballNumber = numberGenerator.generate(); + + assertThat(baseballNumber.getNumbers()).hasSize(3); + } + + @Test + void 생성된_숫자는_1부터_9_사이이고_중복이_없다() { + NumberGenerator numberGenerator = new NumberGenerator(); + + for (int i = 0; i < 100; i++) { + List numbers = numberGenerator.generate().getNumbers(); + assertThat(numbers).hasSize(3); + assertThat(numbers).doesNotHaveDuplicates(); + assertThat(isRange(numbers)).isTrue(); + } + } + + private boolean isRange(List numbers) { + for (int i = 0; i < numbers.size(); i++) { + int number = numbers.get(i); + if (number < 1 || number > 9) return false; + } + return true; + } +}