diff --git a/README.md b/README.md index 7e878ff1a..7f3bbcb64 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,16 @@ ### 요구사항 쪼개기 - CellFinder - Cell을 입력받을 경우 해당 셀을 제외한 주변 8개 사각형에 포함된 지뢰의 개수를 반환한다. + +- Position + - Position은 주변 8개의 좌표를 담은 위치들을 반환한다. + +## 3단계 - 지뢰 찾기(게임 실행) + +### 기능 요구사항 +- 지뢰가 없는 인접한 칸이 모두 열리게 된다. + +### 요구사항 쪼개기 +- MineSweeperGame + - Open을 하면 해당 좌표에 인접한 칸 중 지뢰가 없는 칸이 모두 Open 상태로 바뀐다. + - Open 상태이면 주변 지뢰의 개수를 출력한다. diff --git a/src/main/kotlin/minesweeper/MineSweeperApplication.kt b/src/main/kotlin/minesweeper/MineSweeperApplication.kt index 70adab5a3..c44a417b4 100644 --- a/src/main/kotlin/minesweeper/MineSweeperApplication.kt +++ b/src/main/kotlin/minesweeper/MineSweeperApplication.kt @@ -1,7 +1,11 @@ package minesweeper import minesweeper.domain.CellFinder +import minesweeper.domain.HeightAndWidth +import minesweeper.domain.MineSweeperGame +import minesweeper.domain.Position import minesweeper.domain.RandomPositionGenerator +import minesweeper.domain.Size import minesweeper.ui.InputType import minesweeper.ui.InputView import minesweeper.ui.ResultView @@ -9,11 +13,35 @@ import minesweeper.ui.ResultView fun main() { val height = InputView.inputSize(InputType.HEIGHT) val width = InputView.inputSize(InputType.WIDTH) - val count = InputView.inputSize(InputType.COUNT) + val mineCount = InputView.inputSize(InputType.COUNT) - val minePositions = RandomPositionGenerator(height, width).generate(count) + val minePositions = RandomPositionGenerator(height, width).generate(mineCount) val cellFinder = CellFinder.init(height, width) cellFinder.convert(minePositions) - ResultView.printMines(height, width, cellFinder) + val mineSweeperGame = MineSweeperGame(cellFinder) + play(mineSweeperGame, HeightAndWidth(height, width), mineCount) +} + +private fun play(mineSweeperGame: MineSweeperGame, heightAndWidth: HeightAndWidth, mineCount: Size) { + ResultView.printGameStartMessage() + while (!mineSweeperGame.isFinished(mineCount)) { + val position = InputView.inputOpenPosition() + mineSweeperGame.open(position) + printResult(mineSweeperGame, position, heightAndWidth) + printWinResult(mineSweeperGame, mineCount) + } +} + +private fun printResult(mineSweeperGame: MineSweeperGame, position: Position, heightAndWidth: HeightAndWidth) { + when (mineSweeperGame.isMine(position)) { + true -> ResultView.printLoseGameMessage() + false -> ResultView.printMines(mineSweeperGame, heightAndWidth) + } +} + +private fun printWinResult(mineSweeperGame: MineSweeperGame, mineCount: Size) { + if (mineSweeperGame.isWin(mineCount)) { + ResultView.printWinGameMessage() + } } diff --git a/src/main/kotlin/minesweeper/domain/CellFinder.kt b/src/main/kotlin/minesweeper/domain/CellFinder.kt index dda0a4740..8da3131ab 100644 --- a/src/main/kotlin/minesweeper/domain/CellFinder.kt +++ b/src/main/kotlin/minesweeper/domain/CellFinder.kt @@ -3,10 +3,6 @@ package minesweeper.domain class CellFinder(private val map: MutableMap) { private constructor(initPositions: List) : this(initPositions.associateWith { Cell(it) }.toMutableMap()) - private val list = listOf( - Position(-1, -1), Position(-1, 0), Position(-1, 1), Position(0, -1), Position(0, 1), Position(1, -1), Position(1, 0), Position(1, 1) - ) - fun convert(minePosition: List) { minePosition.forEach { map[it] = Cell(it, true) @@ -17,12 +13,21 @@ class CellFinder(private val map: MutableMap) { return map[position] } - fun getAroundMinesCount(cell: Cell): Int { - val position = cell.position - return list.mapNotNull { - val nextPosition = position + it - find(nextPosition) - }.count { it.isMine } + fun getAroundMinesCount(position: Position): Int { + return position.getAround() + .mapNotNull { find(it) } + .count { + it.isMine + } + } + + fun isMine(position: Position): Boolean { + val cell = find(position) ?: throw IllegalArgumentException("주어진 위치를 찾을 수 없습니다.") + return cell.isMine + } + + fun size(): Int { + return map.size } companion object { diff --git a/src/main/kotlin/minesweeper/domain/HeightAndWidth.kt b/src/main/kotlin/minesweeper/domain/HeightAndWidth.kt new file mode 100644 index 000000000..70875eb4d --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/HeightAndWidth.kt @@ -0,0 +1,3 @@ +package minesweeper.domain + +data class HeightAndWidth(val height: Size, val width: Size) diff --git a/src/main/kotlin/minesweeper/domain/MineSweeperGame.kt b/src/main/kotlin/minesweeper/domain/MineSweeperGame.kt new file mode 100644 index 000000000..19b181a46 --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/MineSweeperGame.kt @@ -0,0 +1,55 @@ +package minesweeper.domain + +class MineSweeperGame( + private val cellFinder: CellFinder, + private val openPositions: MutableSet = mutableSetOf(), +) { + + fun open(position: Position) { + if (cellFinder.find(position) == null) { + return + } + + openPositions.add(position) + if (getAroundMinesCount(position) != 0) { + return + } + position.getAdjacent() + .filter { !isOpen(it) && !isMine(it) } + .forEach { open(it) } + } + + fun isOpen(position: Position): Boolean { + if (cellFinder.find(position) == null) { + return false + } + return openPositions.contains(position) + } + + fun isMine(position: Position): Boolean { + if (cellFinder.find(position) == null) { + return false + } + return cellFinder.isMine(position) + } + + fun isFinished(mineCount: Size): Boolean { + if (isLose()) { + return true + } + + return cellFinder.size() == openPositions.size + mineCount.value + } + + fun getAroundMinesCount(position: Position): Int { + return cellFinder.getAroundMinesCount(position) + } + + fun isWin(mineCount: Size): Boolean { + return cellFinder.size() == openPositions.size + mineCount.value + } + + private fun isLose(): Boolean { + return openPositions.any { isMine(it) } + } +} diff --git a/src/main/kotlin/minesweeper/domain/Position.kt b/src/main/kotlin/minesweeper/domain/Position.kt index abfd5e1f5..f27787e9a 100644 --- a/src/main/kotlin/minesweeper/domain/Position.kt +++ b/src/main/kotlin/minesweeper/domain/Position.kt @@ -1,10 +1,37 @@ package minesweeper.domain +private val leftUp = Position(-1, -1) +private val up = Position(-1, 0) +private val rightUp = Position(-1, 1) +private val left = Position(0, -1) +private val right = Position(0, 1) +private val leftDown = Position(1, -1) +private val down = Position(1, 0) +private val rightDown = Position(1, 1) + data class Position(val x: Point, val y: Point) { + constructor(x: String, y: String) : this(Point(x.toInt()), Point(y.toInt())) constructor(x: Int, y: Int) : this(Point(x), Point(y)) constructor(x: Size, y: Size) : this(Point(x.value), Point(y.value)) operator fun plus(it: Position): Position { return Position(x + it.x, y + it.y) } + + fun getAround(): List { + return aroundPositions.map { + this + it + } + } + + fun getAdjacent(): List { + return adjacentPositions.map { + this + it + } + } + + companion object { + private val aroundPositions = listOf(leftUp, up, rightUp, left, right, leftDown, down, rightDown) + private val adjacentPositions = listOf(up, left, right, down) + } } diff --git a/src/main/kotlin/minesweeper/ui/InputView.kt b/src/main/kotlin/minesweeper/ui/InputView.kt index ff91c73af..3e340d1c6 100644 --- a/src/main/kotlin/minesweeper/ui/InputView.kt +++ b/src/main/kotlin/minesweeper/ui/InputView.kt @@ -1,5 +1,6 @@ package minesweeper.ui +import minesweeper.domain.Position import minesweeper.domain.Size object InputView { @@ -26,4 +27,14 @@ object InputView { inputSize(inputType) } } + + fun inputOpenPosition(): Position { + print("open: ") + return try { + val inputPosition = readln().split(", ") + Position(inputPosition[0], inputPosition[1]) + } catch (e: RuntimeException) { + inputOpenPosition() + } + } } diff --git a/src/main/kotlin/minesweeper/ui/ResultView.kt b/src/main/kotlin/minesweeper/ui/ResultView.kt index ab4bdc884..159ca5052 100644 --- a/src/main/kotlin/minesweeper/ui/ResultView.kt +++ b/src/main/kotlin/minesweeper/ui/ResultView.kt @@ -1,29 +1,46 @@ package minesweeper.ui -import minesweeper.domain.CellFinder +import minesweeper.domain.HeightAndWidth +import minesweeper.domain.MineSweeperGame import minesweeper.domain.Position import minesweeper.domain.Size object ResultView { - private const val mine_symbol = "*" + private const val mine_symbol = "C" - fun printMines(height: Size, width: Size, cellFinder: CellFinder) { + fun printMines(mineSweeperGame: MineSweeperGame, heightAndWidth: HeightAndWidth) { + heightAndWidth.height + .getNumbers() + .forEach { printRow(it, heightAndWidth.width, mineSweeperGame) } println() - println("지뢰찾기 게임 시작") - height.getNumbers() - .forEach { printRow(it, width, cellFinder) } } - private fun printRow(rowNum: Size, width: Size, cellFinder: CellFinder) { + private fun printRow(rowNum: Size, width: Size, mineSweeperGame: MineSweeperGame) { width.getNumbers() .forEach { - val cell = cellFinder.find(Position(rowNum, it)) ?: throw RuntimeException("출력 도중 알 수 없는 에러가 발생했습니다.") - when (cell.isMine) { - true -> print("$mine_symbol ") - false -> print("${cellFinder.getAroundMinesCount(cell)} ") - } + val position = Position(rowNum, it) + printEachPosition(mineSweeperGame, position) } println() } + + private fun printEachPosition(mineSweeperGame: MineSweeperGame, position: Position) { + when (mineSweeperGame.isOpen(position)) { + true -> print("${mineSweeperGame.getAroundMinesCount(position)} ") + false -> print("$mine_symbol ") + } + } + + fun printGameStartMessage() { + println("지뢰찾기 게임 시작") + } + + fun printLoseGameMessage() { + println("Lose Game.") + } + + fun printWinGameMessage() { + println("Win Game.") + } } diff --git a/src/test/kotlin/minesweeper/domain/CellFinderTest.kt b/src/test/kotlin/minesweeper/domain/CellFinderTest.kt index 2b1e1baca..78c989961 100644 --- a/src/test/kotlin/minesweeper/domain/CellFinderTest.kt +++ b/src/test/kotlin/minesweeper/domain/CellFinderTest.kt @@ -1,6 +1,8 @@ package minesweeper.domain import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.data.forAll +import io.kotest.data.row import io.kotest.matchers.shouldBe class CellFinderTest : BehaviorSpec({ @@ -16,16 +18,50 @@ class CellFinderTest : BehaviorSpec({ } } - Given("셀이 주어질 때") { - val cell = Cell(Position(2, 2)) + Given("위치가 주어질 때") { + val position = Position(2, 2) val cellFinder = CellFinder.init(Size(10), Size(10)) val minePositions = listOf(Position(1, 2), Position(1, 3)) cellFinder.convert(minePositions) When("CellFinder의 getAroundMinesCount 함수를 호출하면") { - val result = cellFinder.getAroundMinesCount(cell) + val result = cellFinder.getAroundMinesCount(position) Then("자신을 제외한 주변 8개 사각형에 포함된 지뢰의 개수를 반환한다.") { result shouldBe 2 } } } + + Given("지뢰가 있는지 알고 싶은 위치가 주어질 때") { + val cellFinder = CellFinder.init(Size(10), Size(10)) + val minePositions = listOf(Position(1, 2), Position(1, 3)) + cellFinder.convert(minePositions) + When("isMine 함수를 호출하면") { + Then("지뢰가 있는지 여부를 반환한다.") { + forAll( + row(Position(1, 2), true), + row(Position(1, 3), true), + row(Position(2, 2), false), + ) { position, expected -> + cellFinder.isMine(position) shouldBe expected + } + } + } + } + + Given("찾고 싶은 위치가 주어질 때") { + val cellFinder = CellFinder.init(Size(10), Size(10)) + val minePositions = listOf(Position(1, 2), Position(1, 3)) + cellFinder.convert(minePositions) + When("find 함수를 호출하면") { + Then("해당 위치의 Cell을 반환한다.") { + forAll( + row(Position(1, 2)), + row(Position(1, 3)), + row(Position(2, 2)), + ) { position -> + cellFinder.find(position)?.position shouldBe position + } + } + } + } }) diff --git a/src/test/kotlin/minesweeper/domain/MineSweeperGameTest.kt b/src/test/kotlin/minesweeper/domain/MineSweeperGameTest.kt new file mode 100644 index 000000000..f9d910607 --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/MineSweeperGameTest.kt @@ -0,0 +1,85 @@ +package minesweeper.domain + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.data.forAll +import io.kotest.data.row +import io.kotest.matchers.shouldBe + +class MineSweeperGameTest : BehaviorSpec({ + Given("지뢰가 있는지 알고 싶은 위치가 주어지면") { + val height = Size(10) + val width = Size(10) + val mineCount = Size(10) + + val minePositions = FixedPositionGenerator(height, width).generate(mineCount) + val cellFinder = CellFinder.init(height, width) + cellFinder.convert(minePositions) + When("지뢰찾기게임은") { + val mineSweeperGame = MineSweeperGame(cellFinder) + Then("해당 위치의 지뢰 유무를 반환한다.") { + forAll( + row(Position(1, 2), true), + row(Position(-1, -1), false), + row(Position(5, 5), false), + ) { position, expected -> + mineSweeperGame.isMine(position) shouldBe expected + } + } + } + } + + Given("Open 하려는 위치가 주어지면") { + val height = Size(10) + val width = Size(10) + val mineCount = Size(10) + + val minePositions = FixedPositionGenerator(height, width).generate(mineCount) + val cellFinder = CellFinder.init(height, width) + cellFinder.convert(minePositions) + + val mineSweeperGame = MineSweeperGame(cellFinder) + When("지뢰찾기게임은") { + mineSweeperGame.open(Position(10, 10)) + Then("지뢰가 없는 인접한 칸이 모두 열린다.") { + forAll( + row(Position(1, 1), false), + row(Position(-1, -1), false), + row(Position(1, 2), false), + row(Position(5, 5), true), + row(Position(10, 10), true), + ) { position, expected -> + mineSweeperGame.isOpen(position) shouldBe expected + } + } + } + } + + Given("지뢰 개수가 주어지면") { + val height = Size(10) + val width = Size(10) + val mineCount = Size(10) + + val minePositions = FixedPositionGenerator(height, width).generate(mineCount) + val cellFinder = CellFinder.init(height, width) + cellFinder.convert(minePositions) + + When("지뢰찾기게임은") { + val mineSweeperGame = MineSweeperGame(cellFinder) + val actual = mineSweeperGame.isWin(mineCount) + Then("게임에서 이겼는지 여부를 반환한다.") { + actual shouldBe false + } + } + + When("지뢰찾기게임은") { + val mineSweeperGame = MineSweeperGame(cellFinder) + mineSweeperGame.open(Position(1, 1)) + mineSweeperGame.open(Position(2, 2)) + mineSweeperGame.open(Position(10, 10)) + val actual = mineSweeperGame.isWin(mineCount) + Then("게임에서 이겼는지 여부를 반환한다.") { + actual shouldBe true + } + } + } +}) diff --git a/src/test/kotlin/minesweeper/domain/PositionTest.kt b/src/test/kotlin/minesweeper/domain/PositionTest.kt new file mode 100644 index 000000000..1ea166d18 --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/PositionTest.kt @@ -0,0 +1,40 @@ +package minesweeper.domain + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldContainExactly + +class PositionTest : BehaviorSpec({ + Given("주변 8개의 위치를 알고 싶은 위치가 주어지면") { + val position = Position(2, 2) + When("getAround 함수는") { + val aroundPositions = position.getAround() + Then("주변 8개의 위치를 반환한다.") { + aroundPositions shouldContainExactly listOf( + Position(1, 1), + Position(1, 2), + Position(1, 3), + Position(2, 1), + Position(2, 3), + Position(3, 1), + Position(3, 2), + Position(3, 3), + ) + } + } + } + + Given("인접한 상하좌우의 위치를 알고 싶은 위치가 주어지면") { + val position = Position(2, 2) + When("getAdjacent 함수는") { + val aroundPositions = position.getAdjacent() + Then("인접한 상하좌우의 위치를 반환한다.") { + aroundPositions shouldContainExactly listOf( + Position(1, 2), + Position(2, 1), + Position(2, 3), + Position(3, 2), + ) + } + } + } +}) diff --git a/src/test/kotlin/minesweeper/domain/SizeTest.kt b/src/test/kotlin/minesweeper/domain/SizeTest.kt index 2a479b903..165b27a93 100644 --- a/src/test/kotlin/minesweeper/domain/SizeTest.kt +++ b/src/test/kotlin/minesweeper/domain/SizeTest.kt @@ -71,4 +71,14 @@ class SizeTest : BehaviorSpec({ } } } + + Given("더하려는 다른 사이즈가 주어지면") { + val other = Size(1) + When("사이즈는") { + val actual = Size(2) + other + Then("더한 값 원소로 갖는 사이즈를 반환한다.") { + actual shouldBe Size(3) + } + } + } })