Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// AnswerGeneratorProtocol.swift
// SpartBaseBallGame
//
// Created by Wonji Suh on 8/28/25.
//

protocol AnswerGeneratorProtocol {
func make(digits: Int) -> [Int]
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 앞자리를 0을 받느냐 아니냐에 대한 것이죠 ??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// 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
}
}

Original file line number Diff line number Diff line change
@@ -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()) }
}
}
}
Original file line number Diff line number Diff line change
@@ -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?
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

31 changes: 31 additions & 0 deletions SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
Loading