Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1단계 - 지뢰 찾기(그리기) #393

Open
wants to merge 9 commits into
base: oeeen
Choose a base branch
from
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,25 @@
# kotlin-minesweeper
# kotlin-minesweeper

## 기능 요구 사항

- [x] 높이와 너비, 지뢰 개수를 입력받을 수 있다.
- [x] 게임 시작 시 지뢰찾기 보드를 출력한다
- [x] 입력 받은 지뢰 개수만큼 보드에 지뢰가 채워진다.
- [x] 지뢰는 눈에 잘 띄는 것으로 표기한다.
- [x] 지뢰는 가급적 랜덤에 가깝게 배치한다.

## 프로그래밍 요구 사항

- 객체지향 생활 체조 원칙을 지키면서 프로그래밍한다.

### 객체지향 생활 체조 원칙

- 한 메서드에 오직 한 단계의 들여쓰기만 한다.
- else 예약어를 쓰지 않는다.
- 모든 원시 값과 문자열을 포장한다.
- 한 줄에 점을 하나만 찍는다.
- 줄여 쓰지 않는다(축약 금지).
- 모든 엔티티를 작게 유지한다.
- 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
- 일급 컬렉션을 쓴다.
- getter/setter/프로퍼티를 쓰지 않는다.
Empty file removed src/main/kotlin/.gitkeep
Empty file.
13 changes: 13 additions & 0 deletions src/main/kotlin/minesweeper/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package minesweeper

import minesweeper.domain.*
Copy link

Choose a reason for hiding this comment

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

와일드 카드 import는 꺼주시는게 좋습니다!
다른 파일에서도 모두 확인 해주세요 🙏🏻

import minesweeper.view.InputView
import minesweeper.view.OutputView

fun main() {
val height = InputView.receiveHeight()
val width = InputView.receiveWidth()
val mineCount = InputView.receiveMineCount()
val board = Board(BoardInformation(Height(height), Width(width), MineCount(mineCount)))
OutputView.printBoard(board)
}
24 changes: 24 additions & 0 deletions src/main/kotlin/minesweeper/domain/Board.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package minesweeper.domain

import java.util.*

class Board(
private val boardInformation: BoardInformation,
private val minePlacementStrategy: MinePlacementStrategy = RandomMinePlacementStrategy()
) {
val mineMap: List<Row>
get() = generateMap()
Copy link

Choose a reason for hiding this comment

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

mineMap은 커스텀 getter()로 작성되었습니다.
그렇다면 아래 코드의 mineMap은 모두 같은 결과를 만들어낼까요?

val board: Board
board.mineMap  // (1)
board.mineMap  // (2)
board.mineMap  // (3)
board.mineMap  // (4)
board.mineMap  // (5)


init {
require(boardInformation.isMineCountSmallerThanBoardSize()) { "지뢰 숫자는 보드 크기보다 작아야 합니다." }
}

private fun generateMap(): List<Row> {
val sortedSet: SortedSet<Row> = sortedSetOf<Row>()
for (y in 0 until boardInformation.height.value) {
sortedSet.add(RowFactory.createRows(boardInformation.width, y))
}
val allCells = sortedSet.toMutableList()
return minePlacementStrategy.placeMine(allCells, boardInformation.mineCount.value).toList()
}
}
9 changes: 9 additions & 0 deletions src/main/kotlin/minesweeper/domain/BoardInformation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package minesweeper.domain

class BoardInformation(
Copy link

Choose a reason for hiding this comment

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

Information 이라는 의미는 가급적 사용을 지양하면 좋은데요.
정보라는 이름이 어떤 역할을 하는지 모호하게 만들고, 유틸처럼 느껴져서 필요한 모든 내용을 가지는 것처럼 느껴집니다.

Width, Height 을 가지는 Size 라는 개념을 만들어 봐도 좋구요.
지뢰의 경우는 보드가 Cell을 가지고 있다면 별도의 프로퍼티가 아닌 가지고 있는 것으로도 확인이 가능 해 보이네요

val height: Height,
val width: Width,
val mineCount: MineCount
) {
fun isMineCountSmallerThanBoardSize(): Boolean = mineCount.value < height.value * width.value
}
7 changes: 7 additions & 0 deletions src/main/kotlin/minesweeper/domain/Cell.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package minesweeper.domain

data class Cell(val point: Point, val state: State) : Comparable<Cell> {
override fun compareTo(other: Cell): Int {
return compareValuesBy(this, other, { it.point.x }, { it.point.y })
}
Comment on lines +4 to +6
Copy link

Choose a reason for hiding this comment

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

셀을 비교하는것에 있어서 위치값으로 단순히 크고 작다가 결정될 수 있을까요?
위치를 찾기 편하게 하려고 만들어 진 것이라면, 다른 사람들도 이해할 수 있는 개념일까? 를 고민 하면 좋습니다.

}
7 changes: 7 additions & 0 deletions src/main/kotlin/minesweeper/domain/Height.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package minesweeper.domain

data class Height(val value: Int) {
init {
require(value > 0) { "높이는 1 이상 이어야 합니다." }
}
}
3 changes: 3 additions & 0 deletions src/main/kotlin/minesweeper/domain/MineCount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package minesweeper.domain

data class MineCount(val value: Int)
5 changes: 5 additions & 0 deletions src/main/kotlin/minesweeper/domain/MinePlacementStrategy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package minesweeper.domain

fun interface MinePlacementStrategy {
fun placeMine(rows: List<Row>, mineCount: Int): List<Row>
}
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

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

지뢰를 심기위한 전략을 인터페이스로 역할 분리를 잘 해주셨네요. 👍🏻
다만 현재 구조에서는 이 인터페이스를 사용할 때에 있어의 사용 방법이 다소 까다롭다고 느껴지는데요.

1. Cell을 준비한다
2. Row를 준비한다
3. Rows를 준비한다.
4. 지뢰 갯수를 준비한다.
5. placeMine()을 호출한다.

완성된 셀에서 지뢰를 움직이는 방법 외에도, 단순히 지뢰가 심어질 위치값들만 받아온다거나 할 수도 있지 않을까요?
보드를 생성할 때 필요한 규칙이니, 생성 이후에는 사용되지 않아서요

3 changes: 3 additions & 0 deletions src/main/kotlin/minesweeper/domain/Point.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package minesweeper.domain

data class Point(val x: Int, val y: Int)
18 changes: 18 additions & 0 deletions src/main/kotlin/minesweeper/domain/RandomMinePlacementStrategy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package minesweeper.domain

class RandomMinePlacementStrategy : MinePlacementStrategy {
override fun placeMine(rows: List<Row>, mineCount: Int): List<Row> {
val allCells = rows.flatMap { it.cells }.toMutableList()
val mineIndices = allCells.indices.shuffled().take(mineCount).toSet()
mineIndices.forEach { index ->
allCells[index] = allCells[index].copy(state = State.MINE)
}

return rows.map { row ->
val updatedCells = row.cells.map { cell ->
allCells.find { it.point == cell.point } ?: cell
}.toSortedSet()
Row(updatedCells)
}
}
}
19 changes: 19 additions & 0 deletions src/main/kotlin/minesweeper/domain/Row.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package minesweeper.domain

import java.util.*

class Row(val cells: SortedSet<Cell>) : Comparable<Row> {
Copy link

Choose a reason for hiding this comment

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

Row 라는 이름도 마찬가지로 Cells을 가지고 있다는 느낌이 잘 들지 않아요.
값 객체러럼 몇 행, 몇 열 을 나타내는 Row, Column 처럼 느껴집니다.

이 객체는 Cells 를 표현하고 싶었던 것일까요?

init {
val firstY = cells.firstOrNull()?.point?.y
require (cells.all { it.point.y == firstY }) {
throw IllegalArgumentException("한 줄을 구성하는 Cell은 모두 같은 y좌표를 가져야합니다.")
}
}

override fun compareTo(other: Row): Int {
if (this.cells.isEmpty() || other.cells.isEmpty()) {
return 0
}
return this.cells.first().point.y.compareTo(other.cells.first().point.y)
}
}
11 changes: 11 additions & 0 deletions src/main/kotlin/minesweeper/domain/RowFactory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package minesweeper.domain

object RowFactory {
fun createRows(width: Width, index: Int): Row {
val cells = sortedSetOf<Cell>()
for (x in 0 until width.value) {
cells.add(Cell(Point(x, index), State.CELL))
}
return Row(cells)
}
}
5 changes: 5 additions & 0 deletions src/main/kotlin/minesweeper/domain/State.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package minesweeper.domain

enum class State(val display: String) {
Copy link

Choose a reason for hiding this comment

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

지뢰 게임이라는 도메인에서 State 라는 타입 이름만 본다면, 무엇 이라고 생각되시나요?
저는 게임 전체적인 진행 상태로 생각이 듭니다.

Cell 이라는 타입 자체가 지뢰 셀, 지뢰가 아닌 셀로 개념을 공유할 수 있지 않을까요?

MINE("*"), CELL("C")
Copy link

Choose a reason for hiding this comment

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

display 값은 UI의 관심사입니다.
가령 지뢰 모양을 별이 아닌 💣 으로 한다면, 도메인 모델의 수정이 불가피합니다.

}
7 changes: 7 additions & 0 deletions src/main/kotlin/minesweeper/domain/Width.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package minesweeper.domain

data class Width(val value: Int) {
Copy link

Choose a reason for hiding this comment

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

단순한 값을 표현할 때에는 value class를 활용 해보셔도 좋습니다.
https://kotlinlang.org/docs/inline-classes.html

init {
require(value > 0) { "너비는 1 이상 이어야 합니다." }
}
}
20 changes: 20 additions & 0 deletions src/main/kotlin/minesweeper/view/InputView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package minesweeper.view

object InputView {
fun receiveHeight(): Int {
return getInteger("높이를 입력하세요.")
}

fun receiveWidth(): Int {
return getInteger("너비를 입력하세요.")
}

fun receiveMineCount(): Int {
return getInteger("지뢰는 몇 개인가요?")
}

private fun getInteger(message: String): Int {
println(message)
return readln().toInt()
}
}
20 changes: 20 additions & 0 deletions src/main/kotlin/minesweeper/view/OutputView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package minesweeper.view

import minesweeper.domain.Board
import minesweeper.domain.Row

object OutputView {
fun printBoard(board: Board) {
println("지뢰찾기 게임 시작")
board.mineMap.forEach { row ->
printOneRow(row)
}
}

private fun printOneRow(row: Row) {
row.cells.forEach { cell ->
print("${cell.state.display} ")
}
println()
}
}
Empty file removed src/test/kotlin/.gitkeep
Empty file.
39 changes: 39 additions & 0 deletions src/test/kotlin/minesweeper/domain/BoardTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package minesweeper.domain

import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class BoardTest {
@Test
fun `Board 는 높이 개수의 Row를 이용하여 만들 수 있다`() {
val board = Board(BoardInformation(Height(10), Width(10), MineCount(1)))
board.mineMap.size shouldBe 10
}

@Test
fun `Board 는 입력받은 지뢰 개수만큼 지뢰를 가지고 있다`() {
val board = Board(BoardInformation(Height(10), Width(10), MineCount(10)))
board.mineMap.flatMap { it.cells }.count { cell -> cell.state == State.MINE } shouldBe 10
}

@Test
fun `Board 크기보다 지뢰의 숫자가 많으면 IllegalArgumentException 발생한다`() {
assertThrows<IllegalArgumentException> { Board(BoardInformation(Height(10), Width(10), MineCount(101))) }
}

@Test
fun `보드에서 원하는 위치에 지뢰를 심을 수 있다`() {
val minePoint1 = Point(0, 0)
val minePoint2 = Point(5, 5)
val minePoint3 = Point(7, 7)
val strategy = ManualMinePlacementStrategy(listOf(minePoint1, minePoint2, minePoint3))
val board = Board(BoardInformation(Height(10), Width(10), MineCount(3)), strategy)
val mineCells = board.mineMap.flatMap { it.cells }.filter { cell -> cell.state == State.MINE }
mineCells.size shouldBe 3
mineCells.shouldContain(Cell(minePoint1, State.MINE))
mineCells.shouldContain(Cell(minePoint2, State.MINE))
mineCells.shouldContain(Cell(minePoint3, State.MINE))
}
}
11 changes: 11 additions & 0 deletions src/test/kotlin/minesweeper/domain/HeightTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package minesweeper.domain

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class HeightTest {
@Test
fun `높이에 1 미만의 숫자가 들어가면 IllegalArgumentException 발생한다`() {
assertThrows<IllegalArgumentException> { Height(0) }
}
}
13 changes: 13 additions & 0 deletions src/test/kotlin/minesweeper/domain/ManualMinePlacementStrategy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package minesweeper.domain

class ManualMinePlacementStrategy(private val minePoints: List<Point>) : MinePlacementStrategy {
override fun placeMine(rows: List<Row>, mineCount: Int): List<Row> {
val minePointsSet = minePoints.toSet()

return rows.map { row ->
Row(row.cells.map { cell ->
cell.takeIf { it.point !in minePointsSet } ?: cell.copy(state = State.MINE)
}.toSortedSet())
}
}
}
23 changes: 23 additions & 0 deletions src/test/kotlin/minesweeper/domain/RowTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package minesweeper.domain

import io.kotest.matchers.shouldNotBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class RowTest {
@Test
fun `한 줄을 구성하는 Cell은 모두 같은 y값을 가지면 정상적으로 생성된다 `() {
val cell1 = Cell(Point(1, 2), State.MINE)
val cell2 = Cell(Point(2, 2), State.MINE)
val cell3 = Cell(Point(3, 2), State.MINE)
Row(sortedSetOf(cell1, cell2, cell3)) shouldNotBe null
}

@Test
fun `한 줄을 구성하는 Cell이 다른 y값을 가질 경우 IllegalArgumentException 발생한다`() {
val cell1 = Cell(Point(1, 2), State.MINE)
val cell2 = Cell(Point(2, 2), State.MINE)
val cell3 = Cell(Point(2, 3), State.MINE)
assertThrows<IllegalArgumentException> { Row(sortedSetOf(cell1, cell2, cell3)) }
}
}
11 changes: 11 additions & 0 deletions src/test/kotlin/minesweeper/domain/WidthTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package minesweeper.domain

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class WidthTest {
@Test
fun `너비에 1 미만의 숫자가 들어가면 IllegalArgumentException 발생한다`() {
assertThrows<IllegalArgumentException> { Width(0) }
}
}