Skip to content

Commit

Permalink
Merge pull request #3 from vkarchevskyi/feat/medium-bot
Browse files Browse the repository at this point in the history
Feat: add medium bot
  • Loading branch information
vkarchevskyi authored Dec 14, 2024
2 parents 991ab59 + 8b53e45 commit e0acacd
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 49 deletions.
1 change: 1 addition & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const multiPlayerType = ref<MultiPlayerType | null>(null)
:game-type="gameType"
:single-player-type="singlePlayerType"
:multi-player-type="multiPlayerType"
:player="'X'"
>
</Game>
</div>
Expand Down
33 changes: 6 additions & 27 deletions src/TicTacToe/AI/EasyBot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type Bot from './Bot'
import type { Board, CurrentBoardIndex, Position, Sign, SmallBoard } from '@/TicTacToe/types.ts'
import { boardRowQuantity } from '@/TicTacToe/GameController.ts'
import type { Board, CurrentBoardIndex, Position, SmallBoard } from '@/TicTacToe/types.ts'
import { getEmptyCellIndexes, getRandomElement } from '@/TicTacToe/utils.ts'

export default class EasyBot implements Bot {
public constructor(
Expand All @@ -10,38 +10,17 @@ export default class EasyBot implements Bot {

public getMove(currentBoard: CurrentBoardIndex): Position {
if (currentBoard === null) {
const freeIndexes: number[] = this.getFreeBoardIndexes(this.winnerBoard)
currentBoard = this.getRandomElement<number>(freeIndexes)
const freeIndexes: number[] = getEmptyCellIndexes(this.winnerBoard)
currentBoard = getRandomElement<number>(freeIndexes)
}

const freeSmallBoardIndexes = this.getFreeBoardIndexes(this.board[currentBoard])
const randomIndex = this.getRandomElement<number>(freeSmallBoardIndexes)
const freeSmallBoardIndexes = getEmptyCellIndexes(this.board[currentBoard])
const randomIndex = getRandomElement<number>(freeSmallBoardIndexes)

return {
smallBoard: currentBoard,
row: Math.floor(randomIndex / 3),
cell: randomIndex % 3,
}
}

private getRandomElement<T>(array: Array<T>): T {
return array[(array.length * Math.random()) | 0]
}

private getFreeBoardIndexes(board: SmallBoard): number[] {
const freeIndexes: number[] = []

board.forEach((row: Sign[], index: number): void => {
freeIndexes.push(
...row
.map(
(sign: Sign, rowIndex: number): CurrentBoardIndex =>
sign === '' ? rowIndex + boardRowQuantity * index : null,
)
.filter((index: CurrentBoardIndex) => index !== null),
)
})

return freeIndexes
}
}
97 changes: 97 additions & 0 deletions src/TicTacToe/AI/MediumBot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type Bot from './Bot'
import type {
Board,
CurrentBoardIndex,
MiniMaxMove,
Position,
Sign,
SmallBoard,
} from '@/TicTacToe/types.ts'
import { checkWin } from '@/TicTacToe/GameController.ts'
import { getRandomElement, min, max, getEmptyCellIndexes } from '@/TicTacToe/utils.ts'

export default class MediumBot implements Bot {
public constructor(
private readonly board: Board,
private readonly winnerBoard: SmallBoard,
) {}

public getMove(currentBoard: CurrentBoardIndex): Position {
if (currentBoard === null) {
const freeIndexes: number[] = getEmptyCellIndexes(this.winnerBoard)
currentBoard = getRandomElement<number>(freeIndexes)
}

const index: number | undefined = this.miniMax(
this.board[currentBoard],
'O',
getEmptyCellIndexes(this.board[currentBoard]).length,
).index

if (index === undefined) {
throw new Error('Index must be present to make a minimax move')
}

return {
smallBoard: currentBoard,
row: Math.floor(index / 3),
cell: index % 3,
}
}

/**
* Inspired by:
* @see https://github.com/andersonpereiradossantos/tic-tac-toe-ai-minimax
* */
private miniMax(board: SmallBoard, player: Sign, depth: number): MiniMaxMove {
const empty = getEmptyCellIndexes(board)

if (checkWin(this.winnerBoard, 'X')) {
return { score: -1 }
}
if (checkWin(this.winnerBoard, 'O')) {
return { score: 1 }
}
if (empty.length === 0 || depth === 0) {
return { score: 0 }
}

depth--

const movePossibles: Array<{ index: number; score: number }> = []

for (let i = 0; i < empty.length; i++) {
const newBoard = JSON.parse(JSON.stringify(board))
newBoard[Math.floor(empty[i] / 3)][empty[i] % 3] = player

const result = this.miniMax(newBoard, player === 'O' ? 'X' : 'O', depth)
movePossibles.push({ index: empty[i], score: result.score })
}

let bestMove: number | null = null

if (player === 'X') {
let bestScore = -Infinity
for (let i = 0; i < movePossibles.length; i++) {
bestScore = max(bestScore, movePossibles[i].score)
if (movePossibles[i].score === bestScore) {
bestMove = i
}
}
} else {
let bestScore = Infinity
for (let i = 0; i < movePossibles.length; i++) {
bestScore = min(bestScore, movePossibles[i].score)
if (movePossibles[i].score === bestScore) {
bestMove = i
}
}
}

if (bestMove === null) {
throw new Error('Best move not found')
}

return movePossibles[bestMove]
}
}
5 changes: 4 additions & 1 deletion src/TicTacToe/GameController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@ export const isValidMove = (
gameOver: boolean,
board: Board,
currentBoard: CurrentBoardIndex,
currentPlayer: Sign,
player: Sign,
): boolean => {
const freeCell = board[smallBoardIndex][row][col] === ''
const validBoardIndex = currentBoard === null || smallBoardIndex === currentBoard
const validBoard = canMoveToSmallBoard(board[smallBoardIndex])
const validPlayer = currentPlayer === player

return freeCell && validBoardIndex && validBoard && !gameOver
return freeCell && validBoardIndex && validBoard && validPlayer && !gameOver
}

export const checkTie = (board: Board): boolean => {
Expand Down
2 changes: 2 additions & 0 deletions src/TicTacToe/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export type SmallBoard = Sign[][]
export type CurrentBoardIndex = number | null
export type Position = { smallBoard: number; row: number; cell: number }

export type MiniMaxMove = { index?: number; score: number }

export enum GameType {
SinglePlayer,
MultiPlayer,
Expand Down
31 changes: 31 additions & 0 deletions src/TicTacToe/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { CurrentBoardIndex, Sign, SmallBoard } from '@/TicTacToe/types.ts'
import { boardRowQuantity } from '@/TicTacToe/GameController.ts'

export const getRandomElement = <T>(array: Array<T>): T => {
return array[(array.length * Math.random()) | 0]
}

export const min = (a: number, b: number): number => {
return a < b ? a : b
}

export const max = (a: number, b: number): number => {
return a > b ? a : b
}

export const getEmptyCellIndexes = (board: SmallBoard): number[] => {
const emptyCellIndexes: number[] = []

board.forEach((row: Sign[], index: number): void => {
emptyCellIndexes.push(
...row
.map(
(sign: Sign, rowIndex: number): CurrentBoardIndex =>
sign === '' ? rowIndex + boardRowQuantity * index : null,
)
.filter((index: CurrentBoardIndex) => index !== null),
)
})

return emptyCellIndexes
}
40 changes: 26 additions & 14 deletions src/components/GameComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,35 @@ import {
import { reactive, ref } from 'vue'
import GameField from '@/components/GameField.vue'
import EasyBot from '@/TicTacToe/AI/EasyBot.ts'
import MediumBot from '@/TicTacToe/AI/MediumBot.ts'
const props = defineProps<{
gameType: GameType
singlePlayerType: SinglePlayerType | null
multiPlayerType: MultiPlayerType | null
player: Sign
}>()
const winner = ref<string | null>(null)
const isTie = ref<boolean>(false)
const gameOver = ref<boolean>(false)
const currentPlayer = ref<Sign>('X')
const currentBoard = ref<CurrentBoardIndex>(null)
let board = reactive(getDefaultBoard())
let winBoard = reactive(getEmptySmallBoard())
const playMove = (position: Position, botMove: boolean = false) => {
const playMove = async (position: Position, player: Sign): Promise<void> => {
const validMove = isValidMove(
position.smallBoard,
position.row,
position.cell,
gameOver.value,
board,
currentBoard.value,
currentPlayer.value,
player,
)
if (validMove) {
Expand All @@ -51,29 +56,35 @@ const playMove = (position: Position, botMove: boolean = false) => {
if (checkWin(winBoard, currentPlayer.value)) {
winner.value = currentPlayer.value
gameOver.value = true
} else if (checkTie(board)) {
isTie.value = true
gameOver.value = true
} else {
currentPlayer.value = currentPlayer.value === 'X' ? 'O' : 'X'
}
if (winner.value !== null || isTie.value) {
gameOver.value = true
return
}
currentBoard.value = getNextBoardIndex(board, position.row, position.cell)
if (botMove) {
return;
if (props.singlePlayerType !== null) {
setTimeout(makeBotMove, 0);
}
}
}
switch (props.singlePlayerType) {
case SinglePlayerType.Easy:
playMove(new EasyBot(board, winBoard).getMove(currentBoard.value), true)
break
case SinglePlayerType.Medium:
break
default:
break
}
const makeBotMove = async (): Promise<void> => {
switch (props.singlePlayerType) {
case SinglePlayerType.Easy:
await playMove(new EasyBot(board, winBoard).getMove(currentBoard.value), 'O')
break
case SinglePlayerType.Medium:
await playMove(new MediumBot(board, winBoard).getMove(currentBoard.value), 'O')
break
default:
break
}
}
Expand All @@ -98,6 +109,7 @@ const reset = () => {
:current-board="currentBoard"
:win-board="winBoard"
:game-over="gameOver"
:player="player"
@playMove="playMove"
></GameField>

Expand Down
19 changes: 12 additions & 7 deletions src/components/GameField.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<script setup lang="ts">
import type { Board, Position, SmallBoard } from '@/TicTacToe/types.ts'
import type { Board, Position, Sign, SmallBoard } from '@/TicTacToe/types.ts'
defineProps<{
board: Board
currentBoard: number | null
winBoard: SmallBoard
gameOver: boolean
player: Sign
}>()
defineEmits<{
playMove: [position: Position]
playMove: [position: Position, player: Sign]
}>()
</script>

Expand All @@ -35,11 +36,15 @@ defineEmits<{
'cell-o': cell === 'O',
}"
@click="
$emit('playMove', {
smallBoard: smallBoardIndex,
row: rowIndex,
cell: cellIndex,
})
$emit(
'playMove',
{
smallBoard: smallBoardIndex,
row: rowIndex,
cell: cellIndex,
},
player,
)
"
>
{{ cell }}
Expand Down

0 comments on commit e0acacd

Please sign in to comment.