diff --git a/README.md b/README.md index 454cf64..5486d01 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,42 @@ 일관된 커밋 메시지를 작성하기 위한 가이드입니다. --- + + +## 📘 과제 소개 +### 🎯 과제 주제 +- Swift 기본 문법을 활용하여 **숫자 야구(Baseball) 게임**을 구현합니다. +- 콘솔 기반 프로그램으로, 사용자가 숫자를 입력해 정답을 맞히는 구조입니다. + +### 📌 요구 사항 +1. 랜덤한 정답 숫자 생성 (중복 없는 n자리 수) +2. 사용자 입력 처리 (`readLine()` 사용 → 비동기 처리 개선) +3. 정답과 입력값을 비교하여 **스트라이크 / 볼** 판정 +4. 정답을 맞히면 게임 종료 후 다시 시작 가능 +5. 잘못된 입력(중복 숫자, 범위 오류 등)에 대한 예외 처리 + +### 🛠️ 학습 목표 +- **Swift 기본 문법** (변수, 함수, 조건문, 반복문) +- **컬렉션 활용** (Array, Set) +- **함수 분리와 모듈화** +- **비동기 처리**(`Task`, `withCheckedContinuation`) +- **파일 분리 리팩토링**을 통한 코드 구조화 + +# 📌 트러블슈팅 +이 프로젝트를 진행하면서 중점적으로 개선한 부분은 다음과 같습니다. + +### 1) `readLine()` 비동기 처리 +- 기존의 `readLine()`은 입력 대기 동안 프로그램 전체를 멈추게 하는 **동기 방식**이었음. +- `Task`와 `withCheckedContinuation`을 활용하여 **비동기 입력 처리**로 개선. +- 입력 대기 중에도 안내 문구 출력 등 다른 동작과 병렬 실행 가능. + +### 2) 파일 분리 리팩토링 +- 초기 코드가 `main.swift`에 모두 섞여 있어 가독성과 유지보수성이 떨어짐. +- `AnswerGenerator.swift`, `InputParser.swift`, `Judge.swift`, `Game.swift` 등으로 **역할별 파일 분리**. +- 각 기능의 책임이 명확해져 수정과 확장이 용이해짐. + +--- + +## 🚀 실행 방법 +```bash +swift run diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift new file mode 100644 index 0000000..882808c --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift @@ -0,0 +1,10 @@ +// +// AnswerGeneratorProtocol.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +protocol AnswerGeneratorProtocol { + func make(digits: Int) -> [Int] +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift new file mode 100644 index 0000000..f2b00c4 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift @@ -0,0 +1,18 @@ +// +// RandomAnswerGenerator.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + + +struct RandomAnswerGenerator: AnswerGeneratorProtocol, Sendable { + func make(digits: Int) -> [Int] { + let first = Int.random(in: 1...9) + var pool = Array(0...9).filter { $0 != first } + pool.shuffle() + return [first] + Array(pool.prefix(digits - 1)) + } +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift index c01172d..3256512 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift @@ -9,75 +9,48 @@ import Foundation import LogMacro actor BaseBallGame { - private let digits = 3 + private let digits: Int + private let generator: AnswerGeneratorProtocol + private let parser: GuessParser + private let judgeEngine: JudgeEngine + private let console: IOProvider + private var records: GameRecord + private let menu = MenuAction() + private let runner: RoundRunner + + init( + digits: Int = 3, + generator: AnswerGeneratorProtocol = RandomAnswerGenerator(), + judgeEngine: JudgeEngine = .init(), + console: IOProvider = ConsoleIO(), + records: GameRecord = .init() + ) { + self.digits = digits + self.records = records + self.generator = generator + self.parser = GuessParser(digits: digits, forbidLeadingZero: true) + self.judgeEngine = judgeEngine + self.console = console + self.records = records + self.runner = RoundRunner(digits: digits, generator: generator, parser: parser , judge: judgeEngine, console: console) + } - // MARK: - 게임 시작 - func start() async { - #logDebug("< 게임을 시작합니다 >") - let answer = makeAnswer() - #logDebug("정답 생성: \(answer)") + // MARK: - 게임시작 + func start() async { while true { - #logDebug("숫자를 입력하세요") - guard let line = await readLineAsync(), - let guess = parseGuess(from: line) else { - #logDebug("올바르지 않은 입력값입니다") - continue - } - - let result = judge(answer: answer, guess: guess) - - switch result { - case .correct: - #logDebug("정답입니다!") + switch await menu.ask(using: console) { + case .startGame: + let attempts = await runner.run() + await records.saveRecord(attempts) + case .showRecords: + await records.showRecords(using: console) + case .invalid: + console.show("올바르지 않은 입력입니다. 1~3 중 하나를 입력해주세요.") + case .quit: + console.show("프로그램을 종료합니다.") return - - case .nothing: - #logDebug("Nothing") - - case .strikeAndBall(let strike, let ball): - var parts: [String] = [] - if strike > 0 { parts.append("\(strike)스트라이크") } - if ball > 0 { parts.append("\(ball)볼") } - #logDebug(parts.joined(separator: " ")) - } - } - } - - // MARK: - 입력 파싱 - private func parseGuess(from input: String) -> [Int]? { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count == digits, - trimmed.allSatisfy({ $0.isNumber }), - !trimmed.contains("0") else { return nil } - let numbers = trimmed.compactMap { Int(String($0)) } - guard Set(numbers).count == digits else { return nil } - return numbers - } - - // MARK: - 판정 - private func judge(answer: [Int], guess: [Int]) -> JudgeResult { - let strikeCount = zip(answer, guess).filter { $0 == $1 }.count - let ballCount = guess.filter { answer.contains($0) }.count - strikeCount - if strikeCount == answer.count { return .correct } - if strikeCount == 0 && ballCount == 0 { return .nothing } - return .strikeAndBall(strike: strikeCount, ball: ballCount) - } - - // MARK: - 정답 생성 - private func makeAnswer() -> [Int] { - var pool = Array(1...9) - pool.shuffle() - return Array(pool.prefix(digits)) - } - - // MARK: - readLine 비동기 래핑 - func readLineAsync() async -> String? { - await withCheckedContinuation { continuation in - Task.detached(priority: .userInitiated) { - continuation.resume(returning: readLine()) } } } } - diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift deleted file mode 100644 index 25aee43..0000000 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// GameManger.swift -// SpartBaseBallGame -// -// Created by Wonji Suh on 8/27/25. -// - -import Foundation -import Combine - -actor GameManger { - let baseBall = BaseBallGame() - // MARK: - 야구 게임 시작 - func baseBallGameStart() async { - await baseBall.start() - } -} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift new file mode 100644 index 0000000..68687f6 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift @@ -0,0 +1,31 @@ +// +// GuessParser.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +/// 1) Parser: 입력 파싱 +struct GuessParser: Sendable { + let digits: Int + let forbidLeadingZero: Bool + + init(digits: Int, forbidLeadingZero: Bool = true) { + precondition((1...10).contains(digits)) + self.digits = digits + self.forbidLeadingZero = forbidLeadingZero + } + + // MARK: - 입력 파싱 + func parse(_ input: String) -> [Int]? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count == digits, trimmed.allSatisfy(\.isNumber) else { return nil } + if forbidLeadingZero && trimmed.first == "0" { return nil } + let nums = trimmed.compactMap { Int(String($0)) } + guard Set(nums).count == digits else { return nil } // 중복 금지 + return nums + } +} + diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift new file mode 100644 index 0000000..1ea2311 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift @@ -0,0 +1,22 @@ +// +// ConsoleIO.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation +import LogMacro + +struct ConsoleIO: IOProvider { + func show(_ message: String) { + #logDebug(message) + } + + // MARK: - readLine 비동기 래핑 + func readLine() async -> String? { + await withCheckedContinuation { continuation in + Task.detached { continuation.resume(returning: Swift.readLine()) } + } + } +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift new file mode 100644 index 0000000..d39e0d4 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift @@ -0,0 +1,13 @@ +// +// IOProvider.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +protocol IOProvider { + func show(_ message: String) + func readLine() async -> String? +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift new file mode 100644 index 0000000..7217e88 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift @@ -0,0 +1,20 @@ +// +// JudgeEngine.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +/// 2) Judge: 판정 로직 +struct JudgeEngine: Sendable { + func judge(answer: [Int], guess: [Int]) -> JudgeResult { + let strike = zip(answer, guess).filter { $0 == $1 }.count + let ball = Set(answer).intersection(guess).count - strike + if strike == answer.count { return .correct } + if strike == .zero && ball == .zero { return .nothing } + return .strikeAndBall(strike: strike, ball: ball) + } +} + diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift new file mode 100644 index 0000000..048364d --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift @@ -0,0 +1,31 @@ +// +// MenuAction.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +enum MenuType { + case startGame, showRecords, quit, invalid +} + +struct MenuAction: Sendable { + func ask(using io: IOProvider) async -> MenuType { + io.show(""" + 환영합니다! 원하시는 번호를 입력해주세요 + 1. 게임 시작하기 2. 게임 기록 보기 3. 종료하기 + """, ) + + guard let line = await io.readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + let menuNumber = Int(line) else { return .invalid } + + switch menuNumber { + case 1: return .startGame + case 2: return .showRecords + case 3: return .quit + default: return .invalid + } + } +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift new file mode 100644 index 0000000..512840f --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift @@ -0,0 +1,32 @@ +// +// GameRecord.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +// MARK: - 기록 저장/출력 +actor GameRecord: Sendable { + private(set) var items: [Int] = [] + + func saveRecord(_ attempts: Int) async { + items.append(attempts) + } + + func isEmpty() -> Bool { + items.isEmpty + } + + func showRecords(using console: IOProvider) async { + console.show("< 게임 기록 보기 >") + if items.isEmpty { + console.show("완료된 게임 기록이 없습니다.") + return + } + for (idx, tries) in items.enumerated() { + console.show("\(idx + 1)번째 게임 : 시도 횟수 - \(tries)") + } + } +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift new file mode 100644 index 0000000..ec0eaa1 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift @@ -0,0 +1,47 @@ +// +// RoundRunner.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +struct RoundRunner { + let digits: Int + let generator: AnswerGeneratorProtocol + let parser: GuessParser + let judge: JudgeEngine + let console: IOProvider + + func run() async -> Int { + console.show("< 게임을 시작합니다 >") + let answer = generator.make(digits: digits) +#if DEBUG + console.show("정답 생성: \(answer)") +#endif + + var attempts: Int = .zero + while true { + console.show("숫자를 입력하세요") + guard let line = await console.readLine(), + let guess = parser.parse(line) else { + console.show("올바르지 않은 입력값입니다") + continue + } + attempts += 1 + + switch judge.judge(answer: answer, guess: guess) { + case .correct: + console.show("정답입니다!") + return attempts + case .nothing: + console.show("Nothing") + case let .strikeAndBall(strike, ball): + console.show( + [strike > .zero ? "\(strike)스트라이크" : nil, + ball > .zero ? "\(ball)볼" : nil].compactMap { $0 }.joined(separator: " ")) + } + } + } +}