diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..435f8182c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +## 오목 + +### Domain +- [ ] 한 플레이어라도 승리할 때까지 차례를 번갈아가면서 돌을 놓는다. + - [x] 흑, 백 플레이어를 가지고 있다 + - [x] 흑돌이 먼저 시작한다. + - [ ] 게임의 진행 여부는 `PlayerState`가 결정한다. +- [x] 오목알은 자신의 위치를 알고 있다. + - [x] `x, y` 위치는 `1부터 15`로 제한된다. +- [x] 중복되는 위치의 오목알을 가질 수 없다. +- [x] 오목판의 크기는 `15 x 15`이다. +- [x] 사용자는 오목알을 놓는다. + - [x] 오목알을 놓았을 때 5개 이상 연이어 있으면 승리한다. + - [x] 오목알을 놓았을 때 5개 미만 연이어 있으면 게임을 계속 진행한다. + - [x] 특정 위치에 돌을 놓을 수 있는지 판단한다. + - [x] 플레이어는 흑과 백으로 이루어져 있다. +- [x] 오목알을 놓은 플레이어가 게임에서 이겼는지 확인한다. +- [x] 사용자는 특정 위치에 내 돌이 있는지 확인한다. +- [x] 사용자는 마지막 돌의 위치를 알고 있다. +- [x] 오목알을 놓으면 상대방의 차례가 된다. + +### Input +- [ ] 오목알을 놓을 위치를 입력받는다. + +### Output +- [ ] 오목알의 위치를 입력받기 전에 오목판을 출력한다. +- [ ] 마지막 돌의 위치를 출력한다. diff --git a/src/main/kotlin/.gitkeep b/src/main/kotlin/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt new file mode 100644 index 000000000..685c1b54e --- /dev/null +++ b/src/main/kotlin/Application.kt @@ -0,0 +1,5 @@ +import controller.OmokController + +fun main() { + OmokController().start() +} diff --git a/src/main/kotlin/controller/OmokController.kt b/src/main/kotlin/controller/OmokController.kt new file mode 100644 index 000000000..1c4350bbf --- /dev/null +++ b/src/main/kotlin/controller/OmokController.kt @@ -0,0 +1,12 @@ +package controller + +import domain.game.Omok +import domain.rule.RenjuRule +import view.InputView +import view.OutputView + +class OmokController { + fun start() { + Omok(OutputView(), InputView(), RenjuRule()).run() + } +} diff --git a/src/main/kotlin/domain/board/Board.kt b/src/main/kotlin/domain/board/Board.kt new file mode 100644 index 000000000..3a6b95ee7 --- /dev/null +++ b/src/main/kotlin/domain/board/Board.kt @@ -0,0 +1,24 @@ +package domain.board + +import domain.player.Player +import domain.player.Players +import domain.rule.OmokRule +import domain.stone.Stone +import domain.stone.StoneColor + +class Board(private val players: Players) { + constructor(blackPlayer: Player, whitePlayer: Player, rule: OmokRule) : this(Players(blackPlayer, whitePlayer, rule)) + + fun putStone(stoneColor: StoneColor, stone: Stone): Board? { + if (players.canPlace(stone)) { + return Board(players.putStone(stoneColor, stone)) + } + return null + } + + fun getPlayers(): Players = players.copy() + + fun isRunning(): Boolean = players.isRunning + + fun isLose(): Boolean = players.isBlackLose +} diff --git a/src/main/kotlin/domain/game/Omok.kt b/src/main/kotlin/domain/game/Omok.kt new file mode 100644 index 000000000..9952f7e7c --- /dev/null +++ b/src/main/kotlin/domain/game/Omok.kt @@ -0,0 +1,39 @@ +package domain.game + +import domain.board.Board +import domain.player.BlackPlayer +import domain.player.WhitePlayer +import domain.rule.OmokRule +import domain.stone.Stone +import domain.stone.StoneColor +import listener.OmokStartEndEventListener +import listener.OmokTurnEventListener + +class Omok( + private val startEndEventListener: OmokStartEndEventListener, + private val turnEventListener: OmokTurnEventListener, + private val rule: OmokRule +) { + fun run() { + startEndEventListener.onStartGame() + var curStoneColor: StoneColor = StoneColor.BLACK + var curBoard = Board(BlackPlayer(), WhitePlayer(), rule) + do { + curBoard = takeTurn(curBoard, curStoneColor) + startEndEventListener.onEndTurn(curBoard.getPlayers()) + curStoneColor = curStoneColor.next() + } while (curBoard.isRunning()) + if (curBoard.isLose()) startEndEventListener.onEndGame(curStoneColor) + else startEndEventListener.onEndGame(curStoneColor.next()) + } + + private fun takeTurn(board: Board, stoneColor: StoneColor): Board { + val newStone = Stone.of(turnEventListener.onTakeTurn(stoneColor)) + val newBoard = board.putStone(stoneColor, newStone) + if (newBoard == null) { + turnEventListener.onNotPlaceable() + return takeTurn(board, stoneColor) + } + return newBoard + } +} diff --git a/src/main/kotlin/domain/player/BlackPlayer.kt b/src/main/kotlin/domain/player/BlackPlayer.kt new file mode 100644 index 000000000..c37dff322 --- /dev/null +++ b/src/main/kotlin/domain/player/BlackPlayer.kt @@ -0,0 +1,21 @@ +package domain.player + +import domain.rule.OmokRule +import domain.state.LoseState +import domain.state.PlayerState +import domain.state.PlayingState +import domain.stone.Stone +import domain.stone.Stones + +class BlackPlayer(state: PlayerState = PlayingState()) : Player(state) { + val isLose + get() = state is LoseState + + override fun putStone(stone: Stone, otherStones: Stones, rule: OmokRule): Player { + val blackStones = state.getAllStones() + if (rule.check(blackStones, otherStones, stone)) { + return BlackPlayer(LoseState(state.getAllStones())) + } + return BlackPlayer(state.add(stone, rule)) + } +} diff --git a/src/main/kotlin/domain/player/Player.kt b/src/main/kotlin/domain/player/Player.kt new file mode 100644 index 000000000..34b8af25a --- /dev/null +++ b/src/main/kotlin/domain/player/Player.kt @@ -0,0 +1,25 @@ +package domain.player + +import domain.position.Position +import domain.rule.OmokRule +import domain.state.LoseState +import domain.state.PlayerState +import domain.state.WinState +import domain.stone.Stone +import domain.stone.Stones + +abstract class Player(protected val state: PlayerState) : Cloneable { + fun canPlace(): Boolean = state !is WinState && state !is LoseState + + fun isPlaced(stone: Stone): Boolean = state.hasStone(stone) + + fun getPositions(): List = state.getPlaced() + + fun getLastStone(): Stone = state.getLastStone() + + fun getAllStones(): Stones = state.getAllStones() + + abstract fun putStone(stone: Stone, otherStones: Stones, rule: OmokRule): Player + + public override fun clone(): Player = super.clone() as Player +} diff --git a/src/main/kotlin/domain/player/Players.kt b/src/main/kotlin/domain/player/Players.kt new file mode 100644 index 000000000..0621c9559 --- /dev/null +++ b/src/main/kotlin/domain/player/Players.kt @@ -0,0 +1,50 @@ +package domain.player + +import domain.rule.OmokRule +import domain.rule.RenjuRule +import domain.stone.Stone +import domain.stone.StoneColor + +data class Players private constructor(private val players: List, private val rule: OmokRule) { + val isRunning: Boolean + get() = players.all { it.canPlace() } + val isBlackLose: Boolean + get() = (getBlackPlayer() as BlackPlayer).isLose + + constructor(blackPlayer: Player, whitePlayer: Player, rule: OmokRule) : this( + listOf( + blackPlayer.clone(), + whitePlayer.clone() + ), + rule + ) + + fun putStone(stoneColor: StoneColor, stone: Stone): Players { + val whiteStones = getWhitePlayer().getAllStones() + val blackStones = getBlackPlayer().getAllStones() + + return when (stoneColor) { + StoneColor.BLACK -> { + Players( + blackPlayer = getBlackPlayer().putStone(stone, whiteStones, RenjuRule()), + whitePlayer = getWhitePlayer(), + rule, + ) + } + + StoneColor.WHITE -> { + Players( + blackPlayer = getBlackPlayer(), + whitePlayer = getWhitePlayer().putStone(stone, blackStones, rule), + rule, + ) + } + } + } + + fun getBlackPlayer(): Player = players.first { it is BlackPlayer } + + fun getWhitePlayer(): Player = players.first { it is WhitePlayer } + + fun canPlace(stone: Stone): Boolean = players.none { it.isPlaced(stone) } +} diff --git a/src/main/kotlin/domain/player/WhitePlayer.kt b/src/main/kotlin/domain/player/WhitePlayer.kt new file mode 100644 index 000000000..7e17da20e --- /dev/null +++ b/src/main/kotlin/domain/player/WhitePlayer.kt @@ -0,0 +1,12 @@ +package domain.player + +import domain.rule.OmokRule +import domain.state.PlayerState +import domain.state.PlayingState +import domain.stone.Stone +import domain.stone.Stones + +class WhitePlayer(state: PlayerState = PlayingState()) : Player(state) { + override fun putStone(stone: Stone, otherStones: Stones, rule: OmokRule): Player = + WhitePlayer(state.add(stone, rule)) +} diff --git a/src/main/kotlin/domain/position/Position.kt b/src/main/kotlin/domain/position/Position.kt new file mode 100644 index 000000000..f13fefd6b --- /dev/null +++ b/src/main/kotlin/domain/position/Position.kt @@ -0,0 +1,16 @@ +package domain.position + +data class Position(val row: Int, val col: Int) { + init { + require(row in POSITION_RANGE) { ROW_OUT_OF_RANGE_ERROR_MESSAGE } + require(col in POSITION_RANGE) { COLUMN_OUT_OF_RANGE_ERROR_MESSAGE } + } + + companion object { + private const val MIN_BOUND = 1 + private const val MAX_BOUND = 15 + val POSITION_RANGE = (MIN_BOUND..MAX_BOUND) + private const val ROW_OUT_OF_RANGE_ERROR_MESSAGE = "행의 범위는 ${MIN_BOUND}부터 ${MAX_BOUND}입니다" + private const val COLUMN_OUT_OF_RANGE_ERROR_MESSAGE = "열의 범위는 ${MIN_BOUND}부터 ${MAX_BOUND}입니다" + } +} diff --git a/src/main/kotlin/domain/rule/OmokRule.kt b/src/main/kotlin/domain/rule/OmokRule.kt new file mode 100644 index 000000000..142b4b184 --- /dev/null +++ b/src/main/kotlin/domain/rule/OmokRule.kt @@ -0,0 +1,8 @@ +package domain.rule + +import domain.stone.Stone +import domain.stone.Stones + +interface OmokRule { + fun check(blackStones: Stones, whiteStones: Stones, startStone: Stone): Boolean +} diff --git a/src/main/kotlin/domain/rule/RenjuRule.kt b/src/main/kotlin/domain/rule/RenjuRule.kt new file mode 100644 index 000000000..e15ebaaa2 --- /dev/null +++ b/src/main/kotlin/domain/rule/RenjuRule.kt @@ -0,0 +1,255 @@ +package domain.rule + +import domain.position.Position +import domain.stone.Stone +import domain.stone.Stones + +class RenjuRule : OmokRule { + private var threeCount: Int = 0 + + override fun check(blackStones: Stones, whiteStones: Stones, startStone: Stone): Boolean { + return check33(blackStones, whiteStones, startStone) || check44(blackStones, whiteStones, startStone) || checkLongOmok(blackStones, startStone) + } + + private fun check33(blackStones: Stones, whiteStones: Stones, startStone: Stone): Boolean { + return check33AllDirections(blackStones, whiteStones, startStone) + } + + private fun check44(blackStones: Stones, whiteStones: Stones, startStone: Stone): Boolean { + return check44AllDirections(blackStones, whiteStones, startStone) + } + + private fun checkLongOmok(blackStones: Stones, startStone: Stone): Boolean { + val directions = listOf(RIGHT_DIRECTION, TOP_DIRECTION, RIGHT_TOP_DIRECTION, LEFT_BOTTOM_DIRECTION) + for (moveDirection in directions) { + val forwardCount = findLongOmok(blackStones, startStone.position, moveDirection, FORWARD_WEIGHT) + val backCount = findLongOmok(blackStones, startStone.position, moveDirection, BACK_WEIGHT) + + if (forwardCount + backCount - 1 > 5) { + return true + } + } + return false + } + + private fun check33AllDirections(blackStones: Stones, whiteStones: Stones, startStone: Stone): Boolean { + val directions = listOf(RIGHT_DIRECTION, TOP_DIRECTION, RIGHT_TOP_DIRECTION, LEFT_BOTTOM_DIRECTION) + + for (moveDirection in directions) { + val (forwardCount, forwardEmptyCount) = + findThree(blackStones, whiteStones, startStone.position, moveDirection, FORWARD_WEIGHT) + val (backCount, backEmptyCount) = + findThree(blackStones, whiteStones, startStone.position, moveDirection, BACK_WEIGHT) + + // 만약 빈 칸이 2 미만이고, 같은 돌 개수가 무조건 3이면 3-3 가능성 ok + // 현재 방향과 3인지 여부를 확인한다. + if (forwardCount + backCount - 1 == 3 && forwardEmptyCount + backEmptyCount <= 1) { + val (upDownDir, leftRightDir) = moveDirection + + // 백돌 양쪽 합 6칸 이내에 2개 이상 있는지 확인한다. + // 닫혀 있으면 다른 방향 확인 + if (!isBlockedByWhiteStoneInSix(whiteStones, startStone.position.row, startStone.position.col, upDownDir, leftRightDir)) { + threeCount++ + } + if (threeCount == 2) { + return true + } + } + } + return false + } + + private fun check44AllDirections(blackStones: Stones, whiteStones: Stones, startStone: Stone): Boolean { + val directions = listOf(RIGHT_DIRECTION, TOP_DIRECTION, RIGHT_TOP_DIRECTION, LEFT_BOTTOM_DIRECTION) + var fourCount = 0 + for (moveDirection in directions) { + val (forwardCount, forwardEmptyCount) = + findFour(blackStones, whiteStones, startStone.position, moveDirection, FORWARD_WEIGHT) + val (backCount, backEmptyCount) = + findFour(blackStones, whiteStones, startStone.position, moveDirection, BACK_WEIGHT) + + // 만약 빈 칸이 2 미만이고, 같은 돌 개수가 무조건 3이면 3-3 가능성 ok + // 현재 방향과 3인지 여부를 확인한다. + if (forwardCount + backCount - 1 == 4 && forwardEmptyCount + backEmptyCount <= 1) { + fourCount++ + if (fourCount == 2) { + return true + } + } + } + return false + } + + private fun isBlockedByWhiteStoneInSix( + whiteStones: Stones, + row: Int, + col: Int, + upDownDir: Int, + leftRightDir: Int, + ): Boolean { + val (oneDirMoveCount, oneDirFound) = checkWhite( + whiteStones, + row + upDownDir, + col + leftRightDir, + upDownDir, + leftRightDir, + ) + val (otherDirMoveCount, otherDirFound) = checkWhite( + whiteStones, + row - upDownDir, + col - leftRightDir, + upDownDir * -1, + leftRightDir * -1, + ) + + // 양 방향 6칸 이하에 각각 1개씩 있으면 참 + if (oneDirMoveCount + otherDirMoveCount <= 6 && oneDirFound && otherDirFound) { + return true + } + return false + } + + private fun checkWhite( + whiteStones: Stones, + row: Int, + col: Int, + upDownDir: Int, + leftRightDir: Int, + ): Pair { + var (curRow, curCol) = Pair(row, col) + var moveCount = 0 + while (inRange(curRow, curCol) && moveCount <= 6) { + moveCount++ + if (whiteStones.hasStone(Stone.of(curRow, curCol))) { + return Pair(moveCount, true) + } + curRow += upDownDir + curCol += leftRightDir + } + return Pair(moveCount, false) + } + + private fun findThree( + blackStones: Stones, + whiteStones: Stones, + startPosition: Position, + direction: Pair, + weight: Int = FORWARD_WEIGHT, + ): Pair { + val (startRow, startCol) = Pair(startPosition.row, startPosition.col) + var sameStoneCount = 1 + var emptyCount = 0 + var (currentRow, currentCol) = Pair(startRow + direction.first * weight, startCol + direction.second * weight) + + // 현재 탐색 방향에 + // 흰 돌이 아니고, 범위 안에 있고 + // 같은 돌의 개수가 3개 이하이고, 공백이 1개 이하일 때까지 + while (inRange(currentRow, currentCol) && emptyCount <= 1 && + sameStoneCount < 3 && !whiteStones.hasStone(Stone.of(currentRow, currentCol)) + ) { + // 검은 돌이 있는지 확인한다. + if (blackStones.hasStone(Stone.of(currentRow, currentCol))) ++sameStoneCount + // 빈 칸인지 확인한다. + if (!blackStones.hasStone(Stone.of(currentRow, currentCol)) && + !whiteStones.hasStone(Stone.of(currentRow, currentCol)) + ) { + ++emptyCount + } + currentRow += direction.first * weight + currentCol += direction.second * weight + } + + if (sameStoneCount == 1) emptyCount = 0 // B X X X + if (sameStoneCount == 2) emptyCount = 1 // B X B + if (sameStoneCount == 2 && !blackStones.hasStone(Stone.of(currentRow - direction.first * weight, currentCol - direction.second * weight)) && + !whiteStones.hasStone(Stone.of(currentRow - direction.first * weight, currentCol - direction.second * weight)) + ) { + emptyCount -= 1 // B B X W + } + + return Pair(sameStoneCount, emptyCount) + } + + private fun findFour( + blackStones: Stones, + whiteStones: Stones, + startPosition: Position, + direction: Pair, + weight: Int = FORWARD_WEIGHT, + ): Pair { + val (startRow, startCol) = Pair(startPosition.row, startPosition.col) + var sameStoneCount = 1 + var emptyCount = 0 + var (currentRow, currentCol) = Pair(startRow + direction.first * weight, startCol + direction.second * weight) + + // 현재 탐색 방향에 + // 흰 돌이 아니고, 범위 안에 있고 + // 같은 돌의 개수가 3개 이하이고, 공백이 1개 이하일 때까지 + while (inRange(currentRow, currentCol) && emptyCount <= 1 && + sameStoneCount < 4 && !whiteStones.hasStone(Stone.of(currentRow, currentCol)) + ) { + // 검은 돌이 있는지 확인한다. + if (blackStones.hasStone(Stone.of(currentRow, currentCol))) ++sameStoneCount + // 빈 칸인지 확인한다. + if (!blackStones.hasStone(Stone.of(currentRow, currentCol)) && + !whiteStones.hasStone(Stone.of(currentRow, currentCol)) + ) { + ++emptyCount + } + currentRow += direction.first * weight + currentCol += direction.second * weight + } + + currentRow -= direction.first * weight + currentCol -= direction.second * weight + + while (inRange(currentRow, currentCol) && !blackStones.hasStone(Stone.of(currentRow, currentCol))) { + if (whiteStones.hasStone(Stone.of(currentRow, currentCol))) { + currentRow -= direction.first * weight + currentCol -= direction.second * weight + continue + } + emptyCount -= 1 + currentRow -= direction.first * weight + currentCol -= direction.second * weight + } + return Pair(sameStoneCount, emptyCount) + } + + private fun findLongOmok( + blackStones: Stones, + startPosition: Position, + direction: Pair, + weight: Int = FORWARD_WEIGHT, + ): Int { + val (startRow, startCol) = Pair(startPosition.row, startPosition.col) + var sameStoneCount = 1 + var emptyCount = 0 + var (currentRow, currentCol) = Pair(startRow + direction.first * weight, startCol + direction.second * weight) + + // 현재 탐색 방향에 + // 흰 돌이 아니고, 범위 안에 있고 + // 같은 돌의 개수가 3개 이하이고, 공백이 1개 이하일 때까지 + while (inRange(currentRow, currentCol) && blackStones.hasStone(Stone.of(currentRow, currentCol))) { + // 검은 돌이 있는지 확인한다. + sameStoneCount++ + currentRow += direction.first * weight + currentCol += direction.second * weight + } + + return sameStoneCount + } + + private fun inRange(x: Int, y: Int) = x in Position.POSITION_RANGE && y in Position.POSITION_RANGE + + companion object { + + private val RIGHT_DIRECTION = Pair(1, 0) + private val TOP_DIRECTION = Pair(0, 1) + private val RIGHT_TOP_DIRECTION = Pair(1, 1) + private val LEFT_BOTTOM_DIRECTION = Pair(-1, -1) + + private const val FORWARD_WEIGHT = 1 + private const val BACK_WEIGHT = -1 + } +} diff --git a/src/main/kotlin/domain/rule/RenjuRuleAdapter.kt b/src/main/kotlin/domain/rule/RenjuRuleAdapter.kt new file mode 100644 index 000000000..1509a1054 --- /dev/null +++ b/src/main/kotlin/domain/rule/RenjuRuleAdapter.kt @@ -0,0 +1,25 @@ +// package domain.rule +// +// import domain.stone.Stone +// import domain.stone.Stones +// import rule.ArkRenjuRule +// +// class RenjuRuleAdapter(private val arkRule: ArkRenjuRule) : OmokRule { +// override fun countOpenThrees(blackStones: Stones, whiteStones: Stones, stone: Stone): Int { +// println(arkRule.countOpenThrees(stone.position.row - 1, stone.position.col - 1)) +// return arkRule.countOpenThrees(stone.position.row - 1, stone.position.col - 1) +// } +// +// override fun countOpenFours(blackStones: Stones, whiteStones: Stones, stone: Stone): Int { +// println(arkRule.countOpenFours(stone.position.row - 1, stone.position.col - 1)) +// return arkRule.countOpenFours(stone.position.row - 1, stone.position.col - 1) +// } +// +// override fun validateWhiteWin(blackStones: Stones, whiteStones: Stones, stone: Stone): Boolean { +// return arkRule.validateWhiteWin(stone.position.row - 1, stone.position.col - 1) +// } +// +// override fun validateBlackWin(blackStones: Stones, whiteStones: Stones, stone: Stone): Boolean { +// return arkRule.validateBlackWin(stone.position.row - 1, stone.position.col - 1) +// } +// } diff --git a/src/main/kotlin/domain/state/LoseState.kt b/src/main/kotlin/domain/state/LoseState.kt new file mode 100644 index 000000000..0a3c39be0 --- /dev/null +++ b/src/main/kotlin/domain/state/LoseState.kt @@ -0,0 +1,9 @@ +package domain.state + +import domain.rule.OmokRule +import domain.stone.Stone +import domain.stone.Stones + +class LoseState(stones: Stones) : PlayerState(stones) { + override fun add(newStone: Stone, rule: OmokRule): PlayerState = this +} diff --git a/src/main/kotlin/domain/state/PlayerState.kt b/src/main/kotlin/domain/state/PlayerState.kt new file mode 100644 index 000000000..f7f8db146 --- /dev/null +++ b/src/main/kotlin/domain/state/PlayerState.kt @@ -0,0 +1,18 @@ +package domain.state + +import domain.position.Position +import domain.rule.OmokRule +import domain.stone.Stone +import domain.stone.Stones + +abstract class PlayerState(protected val stones: Stones = Stones()) { + abstract fun add(newStone: Stone, rule: OmokRule): PlayerState + + fun hasStone(stone: Stone): Boolean = stones.hasStone(stone) + + fun getPlaced(): List = stones.getPositions() + + fun getLastStone(): Stone = stones.lastStone + + fun getAllStones(): Stones = stones.copy() +} diff --git a/src/main/kotlin/domain/state/PlayingState.kt b/src/main/kotlin/domain/state/PlayingState.kt new file mode 100644 index 000000000..184ceba62 --- /dev/null +++ b/src/main/kotlin/domain/state/PlayingState.kt @@ -0,0 +1,13 @@ +package domain.state + +import domain.rule.OmokRule +import domain.stone.Stone +import domain.stone.Stones + +class PlayingState(stones: Stones = Stones()) : PlayerState(stones) { + override fun add(newStone: Stone, rule: OmokRule): PlayerState { + val newStones = stones.add(newStone) + if (newStones.checkWin(newStone)) return WinState(newStones) + return PlayingState(newStones) + } +} diff --git a/src/main/kotlin/domain/state/WinState.kt b/src/main/kotlin/domain/state/WinState.kt new file mode 100644 index 000000000..5ff3229c6 --- /dev/null +++ b/src/main/kotlin/domain/state/WinState.kt @@ -0,0 +1,9 @@ +package domain.state + +import domain.rule.OmokRule +import domain.stone.Stone +import domain.stone.Stones + +class WinState(stones: Stones) : PlayerState(stones) { + override fun add(newStone: Stone, rule: OmokRule): PlayerState = this +} diff --git a/src/main/kotlin/domain/stone/Stone.kt b/src/main/kotlin/domain/stone/Stone.kt new file mode 100644 index 000000000..f553c1565 --- /dev/null +++ b/src/main/kotlin/domain/stone/Stone.kt @@ -0,0 +1,13 @@ +package domain.stone + +import domain.position.Position +import domain.position.Position.Companion.POSITION_RANGE + +data class Stone private constructor(val position: Position) { + companion object { + private val STONES = POSITION_RANGE.map { row -> POSITION_RANGE.map { col -> Stone(Position(row, col)) } } + + fun of(row: Int, col: Int): Stone = STONES[row - 1][col - 1] + fun of(position: Position): Stone = STONES[position.row - 1][position.col - 1] + } +} diff --git a/src/main/kotlin/domain/stone/StoneColor.kt b/src/main/kotlin/domain/stone/StoneColor.kt new file mode 100644 index 000000000..a3196d25a --- /dev/null +++ b/src/main/kotlin/domain/stone/StoneColor.kt @@ -0,0 +1,10 @@ +package domain.stone + +enum class StoneColor { + BLACK, WHITE; + + fun next(): StoneColor = when (this) { + BLACK -> WHITE + WHITE -> BLACK + } +} diff --git a/src/main/kotlin/domain/stone/Stones.kt b/src/main/kotlin/domain/stone/Stones.kt new file mode 100644 index 000000000..df1147fe0 --- /dev/null +++ b/src/main/kotlin/domain/stone/Stones.kt @@ -0,0 +1,62 @@ +package domain.stone + +import domain.position.Position + +data class Stones(private val stones: List) { + val lastStone: Stone + get() = stones.last().copy() + + constructor(vararg stones: Stone) : this(stones.toList()) + + init { + require(stones.distinct().size == stones.size) { DUPLICATED_ERROR_MESSAGE } + } + + fun getPositions(): List = stones.map { it.position.copy() } + + fun add(newStone: Stone): Stones = Stones(stones + newStone) + + fun hasStone(stone: Stone): Boolean = stones.contains(stone) + + fun checkWin(startStone: Stone): Boolean { + val directions = listOf(RIGHT_DIRECTION, TOP_DIRECTION, RIGHT_TOP_DIRECTION, LEFT_BOTTOM_DIRECTION) + + for (moveDirection in directions) { + val forwardCount = checkStraight(startStone.position, moveDirection, FORWARD_WEIGHT) + val backCount = checkStraight(startStone.position, moveDirection, BACK_WEIGHT) + if (forwardCount + backCount - 1 >= MINIMUM_WIN_CONDITION) return true + } + return false + } + + private fun checkStraight( + startPosition: Position, + direction: Pair, + weight: Int = FORWARD_WEIGHT + ): Int { + val (startX, startY) = Pair(startPosition.row, startPosition.col) + var count = 1 + var (currentX, currentY) = Pair(startX + direction.first * weight, startY + direction.second * weight) + while (inRange(currentX, currentY) && hasStone(Stone.of(currentX, currentY))) { + count++ + currentX += direction.first * weight + currentY += direction.second * weight + } + return count + } + + private fun inRange(x: Int, y: Int) = x in Position.POSITION_RANGE && y in Position.POSITION_RANGE + + companion object { + private const val DUPLICATED_ERROR_MESSAGE = "중복되는 위치의 오목알을 가질 수 없습니다." + private const val MINIMUM_WIN_CONDITION = 5 + + private val RIGHT_DIRECTION = Pair(1, 0) + private val TOP_DIRECTION = Pair(0, 1) + private val RIGHT_TOP_DIRECTION = Pair(1, 1) + private val LEFT_BOTTOM_DIRECTION = Pair(-1, -1) + + private const val FORWARD_WEIGHT = 1 + private const val BACK_WEIGHT = -1 + } +} diff --git a/src/main/kotlin/listener/OmokStartEndEventListener.kt b/src/main/kotlin/listener/OmokStartEndEventListener.kt new file mode 100644 index 000000000..e337a01c8 --- /dev/null +++ b/src/main/kotlin/listener/OmokStartEndEventListener.kt @@ -0,0 +1,12 @@ +package listener + +import domain.player.Players +import domain.position.Position +import domain.stone.StoneColor + +interface OmokStartEndEventListener { + fun onStartTurn(stoneColor: StoneColor, position: Position) + fun onEndTurn(players: Players) + fun onStartGame() + fun onEndGame(stoneColor: StoneColor) +} diff --git a/src/main/kotlin/listener/OmokTurnEventListener.kt b/src/main/kotlin/listener/OmokTurnEventListener.kt new file mode 100644 index 000000000..d24c9367c --- /dev/null +++ b/src/main/kotlin/listener/OmokTurnEventListener.kt @@ -0,0 +1,9 @@ +package listener + +import domain.position.Position +import domain.stone.StoneColor + +interface OmokTurnEventListener { + fun onTakeTurn(stoneColor: StoneColor): Position + fun onNotPlaceable() +} diff --git a/src/main/kotlin/view/InputView.kt b/src/main/kotlin/view/InputView.kt new file mode 100644 index 000000000..cc2869acd --- /dev/null +++ b/src/main/kotlin/view/InputView.kt @@ -0,0 +1,43 @@ +package view + +import domain.position.Position +import domain.position.Position.Companion.POSITION_RANGE +import domain.stone.StoneColor +import listener.OmokTurnEventListener +import view.model.BoardModel + +class InputView : OmokTurnEventListener { + override fun onTakeTurn(stoneColor: StoneColor): Position = askPosition() + + override fun onNotPlaceable() { + reAskPosition(CANT_PLACE_STONE_ERROR_MESSAGE) + } + + private fun askPosition(): Position { + print(ASK_POSITION_MESSAGE) + val input = readln() + if (input.length !in POSITION_INPUT_RANGE) + return reAskPosition(INVALID_FORMAT_ERROR_MESSAGE) + + val col = BoardModel.getColInt(input.first().toString()) + val row = input.substring(ROW_INPUT_SIZE).toIntOrNull() + if (row == null || row !in POSITION_RANGE || col !in POSITION_RANGE) + return reAskPosition(INVALID_FORMAT_ERROR_MESSAGE) + return Position(row, col) + } + + private fun reAskPosition(message: String): Position { + println(message) + return askPosition() + } + + companion object { + private const val ASK_POSITION_MESSAGE = "위치를 입력하세요: " + private const val INVALID_FORMAT_ERROR_MESSAGE = "포맷에 맞지 않는 입력값입니다." + private const val CANT_PLACE_STONE_ERROR_MESSAGE = "해당 위치에는 오목알을 둘 수 없습니다." + private const val MIN_POSITION_INPUT_SIZE = 2 + private const val MAX_POSITION_INPUT_SIZE = 3 + private val POSITION_INPUT_RANGE = MIN_POSITION_INPUT_SIZE..MAX_POSITION_INPUT_SIZE + private const val ROW_INPUT_SIZE = 1 + } +} diff --git a/src/main/kotlin/view/OutputView.kt b/src/main/kotlin/view/OutputView.kt new file mode 100644 index 000000000..d8ff82316 --- /dev/null +++ b/src/main/kotlin/view/OutputView.kt @@ -0,0 +1,84 @@ +package view + +import domain.player.Players +import domain.position.Position +import domain.stone.StoneColor +import listener.OmokStartEndEventListener +import view.model.BoardModel +import view.model.ColorModel + +class OutputView : OmokStartEndEventListener { + override fun onStartGame() { + printStart() + } + + override fun onEndGame(stoneColor: StoneColor) { + printWinner(stoneColor) + } + + override fun onStartTurn(stoneColor: StoneColor, position: Position) { + printTurn(stoneColor, position) + } + + override fun onEndTurn(players: Players) { + printOmokBoard(players) + } + + private fun printStart() { + println(START_MESSAGE) + println(EMPTY_BOARD) + println(TURN_MESSAGE.format(ColorModel.getString(StoneColor.BLACK))) + } + + private fun printOmokBoard(players: Players) { + val board = EMPTY_BOARD.toMutableList() + + players.getBlackPlayer().getPositions().forEach { + board[calculateIndex(it)] = BLACK_STONE + } + players.getWhitePlayer().getPositions().forEach { + board[calculateIndex(it)] = WHITE_STONE + } + println(board.joinToString(separator = "")) + } + + private fun calculateIndex(position: Position): Int = 721 + 3 * position.col - 48 * position.row + + private fun printTurn(stoneColor: StoneColor, position: Position) { + print(TURN_MESSAGE.format(ColorModel.getString(stoneColor))) + println(LAST_STONE_POSITION_MESSAGE.format(BoardModel.getString(position))) + } + + private fun printWinner(stoneColor: StoneColor) { + println(GAME_END_MESSAGE) + println(WINNER_MESSAGE.format(ColorModel.getString(stoneColor))) + } + + companion object { + private const val START_MESSAGE = "오목 게임을 시작합니다." + private const val TURN_MESSAGE = "%s의 차례입니다. " + private const val LAST_STONE_POSITION_MESSAGE = "(마지막 돌의 위치: %s)" + private const val GAME_END_MESSAGE = "게임이 종료되었습니다." + private const val WINNER_MESSAGE = "%s의 승리입니다." + private val EMPTY_BOARD: String = """ + | 15 ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ + | 14 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 13 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 12 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 11 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 10 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 9 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 8 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 7 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 6 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 5 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 4 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 3 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 2 ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + | 1 └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + | A B C D E F G H I J K L M N O + """.trimMargin() + private const val BLACK_STONE = '●' + private const val WHITE_STONE = '○' + } +} diff --git a/src/main/kotlin/view/model/BoardModel.kt b/src/main/kotlin/view/model/BoardModel.kt new file mode 100644 index 000000000..ebeb87097 --- /dev/null +++ b/src/main/kotlin/view/model/BoardModel.kt @@ -0,0 +1,12 @@ +package view.model + +import domain.position.Position + +object BoardModel { + private const val RANGE_MIN = 'A' + private const val RANGE_MAX = 'O' + + private val range = (RANGE_MIN..RANGE_MAX).toList() + fun getString(position: Position) = range[position.col] + (Position.POSITION_RANGE.max() - position.row).toString() + fun getColInt(col: String) = range.indexOf(col.toCharArray()[0]) + 1 +} diff --git a/src/main/kotlin/view/model/ColorModel.kt b/src/main/kotlin/view/model/ColorModel.kt new file mode 100644 index 000000000..c22330c44 --- /dev/null +++ b/src/main/kotlin/view/model/ColorModel.kt @@ -0,0 +1,13 @@ +package view.model + +import domain.stone.StoneColor + +object ColorModel { + private const val BLACK = "흑" + private const val WHITE = "백" + + fun getString(color: StoneColor): String = when (color) { + StoneColor.BLACK -> BLACK + StoneColor.WHITE -> WHITE + } +} diff --git a/src/test/kotlin/.gitkeep b/src/test/kotlin/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/test/kotlin/BlackPlayerTest.kt b/src/test/kotlin/BlackPlayerTest.kt new file mode 100644 index 000000000..5cc163994 --- /dev/null +++ b/src/test/kotlin/BlackPlayerTest.kt @@ -0,0 +1,30 @@ +import domain.player.BlackPlayer +import domain.player.Player +import domain.state.PlayingState +import domain.stone.Stones +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class BlackPlayerTest { + @Test + fun `특정 위치에 흑의 오목알이 없으면 참을 반환한다`() { + val player: Player = BlackPlayer(PlayingState(Stones(ONE_ONE_STONE))) + val expected = player.isPlaced(ONE_ONE_STONE) + + assertThat(expected).isTrue + } + + @Test + fun `특정 위치에 흑의 오목알이 없으면 거짓을 반환한다`() { + val player: Player = BlackPlayer() + val expected = player.isPlaced(ONE_ONE_STONE) + + assertThat(expected).isFalse + } + + @Test + fun `마지막 놓은 돌을 반환한다`() { + val player: Player = BlackPlayer(PlayingState(Stones(ONE_ONE_STONE))) + assertThat(player.getLastStone()).isEqualTo(ONE_ONE_STONE) + } +} diff --git a/src/test/kotlin/BoardTest.kt b/src/test/kotlin/BoardTest.kt new file mode 100644 index 000000000..1b993d064 --- /dev/null +++ b/src/test/kotlin/BoardTest.kt @@ -0,0 +1,27 @@ +import domain.board.Board +import domain.player.BlackPlayer +import domain.player.Player +import domain.player.WhitePlayer +import domain.state.PlayingState +import domain.stone.StoneColor +import domain.stone.Stones +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class BoardTest { + @Test + fun `특정 위치에 돌을 놓으면 플레이어를 반환한다`() { + val blackPlayer: Player = BlackPlayer(PlayingState(Stones(ONE_ONE_STONE))) + val whitePlayer: Player = WhitePlayer(PlayingState(Stones(ONE_TWO_STONE))) + val board = Board(blackPlayer, whitePlayer) + assertThat(board.putStone(StoneColor.BLACK, ONE_THREE_STONE)).isInstanceOf(Player::class.java) + } + + @Test + fun `특정 위치에 돌을 놓지 못하면 null을 반환한다`() { + val blackPlayer: Player = BlackPlayer(PlayingState(Stones(ONE_ONE_STONE))) + val whitePlayer: Player = WhitePlayer(PlayingState(Stones(ONE_TWO_STONE))) + val board = Board(blackPlayer, whitePlayer) + assertThat(board.putStone(StoneColor.BLACK, ONE_TWO_STONE)).isNull() + } +} diff --git a/src/test/kotlin/Fixtures.kt b/src/test/kotlin/Fixtures.kt new file mode 100644 index 000000000..9968a9624 --- /dev/null +++ b/src/test/kotlin/Fixtures.kt @@ -0,0 +1,12 @@ +import domain.stone.Stone + +val ONE_ONE_STONE = Stone.of(1, 1) +val ONE_TWO_STONE = Stone.of(1, 2) +val ONE_THREE_STONE = Stone.of(1, 3) +val ONE_FOUR_STONE = Stone.of(1, 4) +val ONE_FIVE_STONE = Stone.of(1, 5) +val ONE_SIX_STONE = Stone.of(1, 6) +val ONE_SEVEN_STONE = Stone.of(1, 7) +val ONE_EIGHT_STONE = Stone.of(1, 8) +val ONE_NINE_STONE = Stone.of(1, 9) +val ONE_TEN_STONE = Stone.of(1, 10) diff --git a/src/test/kotlin/OmokTest.kt b/src/test/kotlin/OmokTest.kt new file mode 100644 index 000000000..5043dc04b --- /dev/null +++ b/src/test/kotlin/OmokTest.kt @@ -0,0 +1,9 @@ + +import org.junit.jupiter.api.Test + +class OmokTest { + @Test + fun `한 플레이어라도 승리할 때까지 차례를 번갈아가면서 돌을 놓는다`() { + // val omok = Omok() + } +} diff --git a/src/test/kotlin/PlayingStateTest.kt b/src/test/kotlin/PlayingStateTest.kt new file mode 100644 index 000000000..b6fde68c6 --- /dev/null +++ b/src/test/kotlin/PlayingStateTest.kt @@ -0,0 +1,31 @@ +import domain.state.PlayingState +import domain.state.WinState +import domain.stone.Stones +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PlayingStateTest { + + @Test + fun `오목알을 놓으면 목록에 추가한다`() { + val playingState = PlayingState(Stones(ONE_ONE_STONE, ONE_TWO_STONE)).add(ONE_THREE_STONE) + val expected = playingState.hasStone(ONE_THREE_STONE) + assertThat(expected).isTrue + } + + @Test + fun `돌을 놓았을 때 오목알 연이어져 있는 오목알이 5개 미만이면 게임을 계속한다`() { + val playingState = PlayingState(Stones(ONE_ONE_STONE, ONE_TWO_STONE)) + val expected = playingState.add(ONE_THREE_STONE) + + assertThat(expected).isInstanceOf(PlayingState::class.java) + } + + @Test + fun `돌을 놓았을 때 오목알 5개 이상이 연이어져 있으면 승리한다`() { + val playingState = PlayingState(Stones(ONE_ONE_STONE, ONE_TWO_STONE, ONE_THREE_STONE, ONE_FOUR_STONE)) + val expected = playingState.add(ONE_FIVE_STONE) + + assertThat(expected).isInstanceOf(WinState::class.java) + } +} diff --git a/src/test/kotlin/PositionTest.kt b/src/test/kotlin/PositionTest.kt new file mode 100644 index 000000000..4a661c35e --- /dev/null +++ b/src/test/kotlin/PositionTest.kt @@ -0,0 +1,30 @@ +import domain.position.Position +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows + +class PositionTest { + @Test + fun `오목알의 위치는 범위가 1부터 15인 x, y를 가지고 있다`() { + val position = Position(10, 10) + assertAll({ + assertThat(position.row).isEqualTo(10) + assertThat(position.col).isEqualTo(10) + }) + } + + @Test + fun `x의 범위가 1부터 15 사이가 아니면 에러가 발생한다`() { + assertThrows { + Position(16, 10) + } + } + + @Test + fun `y의 범위가 1부터 15 사이가 아니면 에러가 발생한다`() { + assertThrows { + Position(10, 16) + } + } +} diff --git a/src/test/kotlin/StoneColorTest.kt b/src/test/kotlin/StoneColorTest.kt new file mode 100644 index 000000000..3f4a542b1 --- /dev/null +++ b/src/test/kotlin/StoneColorTest.kt @@ -0,0 +1,13 @@ +import domain.stone.StoneColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class StoneColorTest { + @CsvSource("WHITE, BLACK", "BLACK, WHITE") + @ParameterizedTest + fun `상대방 차례를 반환한다`(myStoneColor: StoneColor, expected: StoneColor) { + val actual = myStoneColor.next() + assertThat(actual).isEqualTo(expected) + } +} diff --git a/src/test/kotlin/StoneTest.kt b/src/test/kotlin/StoneTest.kt new file mode 100644 index 000000000..2aa0ff970 --- /dev/null +++ b/src/test/kotlin/StoneTest.kt @@ -0,0 +1,10 @@ + + +class StoneTest { + // @Test + // fun `오목알은 자신의 위치를 알고 있다`() { + // val stone = domain.stone.Stone("H", 10) + // assertThat(stone.x).isEqualTo("H") + // assertThat(stone.y).isEqualTo(10) + // } +} diff --git a/src/test/kotlin/StonesTest.kt b/src/test/kotlin/StonesTest.kt new file mode 100644 index 000000000..d0629049f --- /dev/null +++ b/src/test/kotlin/StonesTest.kt @@ -0,0 +1,53 @@ +import domain.stone.Stones +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows + +class StonesTest { + @Test + fun `각각 다른 위치의 오목알을 가질 수 있다`() { + assertDoesNotThrow { + Stones(ONE_ONE_STONE, ONE_TWO_STONE) + } + } + + @Test + fun `중복되는 위치의 오목알을 가지면 에러가 발생한다`() { + assertThrows { + Stones(ONE_ONE_STONE, ONE_ONE_STONE) + } + } + + @Test + fun `오목알을 놓을 수 있다`() { + val stones = Stones(ONE_ONE_STONE, ONE_TWO_STONE) + val newStones = stones.add(ONE_THREE_STONE) + + assertThat(newStones).isEqualTo(Stones(ONE_ONE_STONE, ONE_TWO_STONE, ONE_THREE_STONE)) + } + + @Test + fun `오목알이 포함되어 있는지 판단한다`() { + val stones = Stones(ONE_ONE_STONE, ONE_TWO_STONE, ONE_THREE_STONE) + val expected = stones.hasStone(ONE_ONE_STONE) + + assertThat(expected).isTrue + } + + @Test + fun `오목알이 5개 이상 연이어 있으면 참을 반환한다`() { + val stones = Stones(ONE_ONE_STONE, ONE_TWO_STONE, ONE_THREE_STONE, ONE_FOUR_STONE, ONE_FIVE_STONE) + val expected = stones.checkWin(ONE_ONE_STONE) + + assertThat(expected).isTrue + } + + @Test + fun `오목알이 5개 미만 연이어 있으면 거짓을 반환한다`() { + val stones = Stones(ONE_ONE_STONE, ONE_TWO_STONE, ONE_THREE_STONE) + val expected = stones.checkWin(ONE_ONE_STONE) + + assertThat(expected).isFalse + } +} diff --git a/src/test/kotlin/WhitePlayerTest.kt b/src/test/kotlin/WhitePlayerTest.kt new file mode 100644 index 000000000..ffcc9d82e --- /dev/null +++ b/src/test/kotlin/WhitePlayerTest.kt @@ -0,0 +1,30 @@ +import domain.player.Player +import domain.player.WhitePlayer +import domain.state.PlayingState +import domain.stone.Stones +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class WhitePlayerTest { + @Test + fun `특정 위치에 백의 오목알이 없으면 참을 반환한다`() { + val player: Player = WhitePlayer(PlayingState(Stones(ONE_ONE_STONE))) + val expected = player.isPlaced(ONE_ONE_STONE) + + assertThat(expected).isTrue + } + + @Test + fun `특정 위치에 백의 오목알이 없으면 거짓을 반환한다`() { + val player: Player = WhitePlayer() + val expected = player.isPlaced(ONE_ONE_STONE) + + assertThat(expected).isFalse + } + + @Test + fun `마지막 놓은 돌을 반환한다`() { + val player: Player = WhitePlayer(PlayingState(Stones(ONE_ONE_STONE))) + assertThat(player.getLastStone()).isEqualTo(ONE_ONE_STONE) + } +}