diff --git a/src/engine/Board.ts b/src/engine/Board.ts index 94d7e5f..67a9e6e 100644 --- a/src/engine/Board.ts +++ b/src/engine/Board.ts @@ -12,7 +12,9 @@ export interface Disc { export interface GridSlot { x: number; z: number; - disc?: Disc; + row: number; + col: number; + disc: Disc | null; } export class Board { @@ -31,31 +33,50 @@ export class Board { const offsetX = 0.0847; const offsetY = 0.0679; - for (let i = 0; i < 6; i++) { - Board[i] = []; - for (let j = 0; j < 7; j++) { - Board[i].push({ - x: j * discDiameter - offsetX, - z: i * discDiameter - offsetY, + for (let row = 0; row < 6; row++) { + Board[row] = []; + for (let col = 0; col < 7; col++) { + Board[row].push({ + x: col * discDiameter - offsetX, + z: row * discDiameter - offsetY, + row, + col, + disc: null, }); } } return Board; } - public isValidLocation(x: number, disc: Disc) { - for (let i = this.grid.length - 1; i >= 0; i--) { - const col = this.grid[this.grid.length - 1].findIndex((c) => c.x === x); + public getColFromXCoord(x: number) { + return this.grid[this.grid.length - 1].findIndex((c) => c.x === x); + } - if (!this.grid[i][col].disc) { - this.grid[i][col].disc = disc; - return this.grid[i][col]; + public isValidLocation(board: GridSlot[][], col: number) { + for (let i = board.length - 1; i >= 0; i--) { + if (!board[i][col].disc) { + return { + ...board[i][col], + col, + row: i, + }; } } return null; } + public allValidLocations(board: GridSlot[][]) { + const validMoves: GridSlot[] = []; + + for (let i = 0; i < board[0].length; i++) { + const isValid = this.isValidLocation(board, i); + if (isValid) validMoves.push(isValid); + } + + return validMoves; + } + public getDiscById(id: number): Disc | undefined { return this.discs.find((disc) => disc.id === id); } diff --git a/src/engine/GameManager.ts b/src/engine/GameManager.ts index 1916234..1cb3356 100644 --- a/src/engine/GameManager.ts +++ b/src/engine/GameManager.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Board } from "@/engine/Board"; -import { Disc } from "./Board"; +import { Minimax } from "@/engine/Minimax"; +import { Board, Disc, GridSlot } from "@/engine/Board"; export default class GameManager { public board: Board; + public minimax: Minimax; + public dropping: boolean; public state: { @@ -15,6 +17,8 @@ export default class GameManager { constructor() { this.board = new Board(); + this.minimax = new Minimax(this); + if (this.board.discs.length === 0) this.spawnNext(-1, "YELLOW"); this.dropping = false; @@ -31,12 +35,16 @@ export default class GameManager { if (!currentDisc) return; - const isValid = this.board.isValidLocation(x, currentDisc); + const isValid = this.board.isValidLocation( + this.board.grid, + this.board.getColFromXCoord(x) + ); if (isValid) { this.dropping = true; - if (!currentDisc) return; + this.board.grid[isValid.row][isValid.col].disc = currentDisc; + currentDisc.x = isValid.x; currentDisc.z = isValid.z; currentDisc.dropped = true; @@ -44,7 +52,7 @@ export default class GameManager { } spawnNext(id: number, color: "RED" | "YELLOW") { - const isFinished = this.check(this.board); + const isFinished = this.check(this.board.grid); if (isFinished.result) return (this.state = { @@ -60,23 +68,31 @@ export default class GameManager { z: -0.14, }); + if (color === "RED") { + const best = this.minimax.getBestMove(); + + setTimeout(() => { + this.drop(best.move.x); + }, 500); + } + setTimeout(() => { this.dropping = false; }, 250); } - check(board: Board): { + check(board: GridSlot[][]): { result: "RED" | "YELLOW" | "TIE" | null; discs: Disc[]; } { - const rows = board.grid.length; - const cols = board.grid[0].length; + const rows = board.length; + const cols = board[0].length; let empty = 0; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { - const disc = board.grid[row][col].disc; + const disc = board[row][col].disc; if (!disc) continue; @@ -85,18 +101,18 @@ export default class GameManager { col < cols - 3 && this.fourConnected( disc, - board.grid[row][col + 1].disc, - board.grid[row][col + 2].disc, - board.grid[row][col + 3].disc + board[row][col + 1].disc, + board[row][col + 2].disc, + board[row][col + 3].disc ) ) { return { result: disc.color, discs: [ disc, - board.grid[row][col + 1].disc!, - board.grid[row][col + 2].disc!, - board.grid[row][col + 3].disc!, + board[row][col + 1].disc!, + board[row][col + 2].disc!, + board[row][col + 3].disc!, ], }; } @@ -106,18 +122,18 @@ export default class GameManager { row < rows - 3 && this.fourConnected( disc, - board.grid[row + 1][col].disc, - board.grid[row + 2][col].disc, - board.grid[row + 3][col].disc + board[row + 1][col].disc, + board[row + 2][col].disc, + board[row + 3][col].disc ) ) { return { result: disc.color, discs: [ disc, - board.grid[row + 1][col].disc!, - board.grid[row + 2][col].disc!, - board.grid[row + 3][col].disc!, + board[row + 1][col].disc!, + board[row + 2][col].disc!, + board[row + 3][col].disc!, ], }; } @@ -128,18 +144,18 @@ export default class GameManager { col < cols - 3 && this.fourConnected( disc, - board.grid[row + 1][col + 1].disc, - board.grid[row + 2][col + 2].disc, - board.grid[row + 3][col + 3].disc + board[row + 1][col + 1].disc, + board[row + 2][col + 2].disc, + board[row + 3][col + 3].disc ) ) { return { result: disc.color, discs: [ disc, - board.grid[row + 1][col + 1].disc!, - board.grid[row + 2][col + 2].disc!, - board.grid[row + 3][col + 3].disc!, + board[row + 1][col + 1].disc!, + board[row + 2][col + 2].disc!, + board[row + 3][col + 3].disc!, ], }; } @@ -150,18 +166,18 @@ export default class GameManager { col > 2 && this.fourConnected( disc, - board.grid[row + 1][col - 1].disc, - board.grid[row + 2][col - 2].disc, - board.grid[row + 3][col - 3].disc + board[row + 1][col - 1].disc, + board[row + 2][col - 2].disc, + board[row + 3][col - 3].disc ) ) { return { result: disc.color, discs: [ disc, - board.grid[row + 1][col - 1].disc!, - board.grid[row + 2][col - 2].disc!, - board.grid[row + 3][col - 3].disc!, + board[row + 1][col - 1].disc!, + board[row + 2][col - 2].disc!, + board[row + 3][col - 3].disc!, ], }; } @@ -177,10 +193,10 @@ export default class GameManager { } fourConnected( - a: Disc | undefined, - b: Disc | undefined, - c: Disc | undefined, - d: Disc | undefined + a: Disc | null, + b: Disc | null, + c: Disc | null, + d: Disc | null ) { return ( !!a && diff --git a/src/engine/Minimax.ts b/src/engine/Minimax.ts new file mode 100644 index 0000000..96cbced --- /dev/null +++ b/src/engine/Minimax.ts @@ -0,0 +1,105 @@ +import GameManager from "@/engine/GameManager"; +import { GridSlot } from "@/engine/Board"; + +export class Minimax { + private manager: GameManager; + + public depth: number; + + constructor(manager: GameManager) { + this.manager = manager; + + this.depth = 5; + } + + public getBestMove() { + const { moves, bestScore } = this.getMoves(); + + const bestMoves = moves.filter((m) => m.score === bestScore); + + return bestMoves[(bestMoves.length * Math.random()) | 0]; + } + + private getMoves(depth: number = this.depth) { + const board = [...this.manager.board.grid]; + let bestScore = -Infinity; + const moves: { score: number; move: GridSlot }[] = []; + + const validMoves = this.manager.board.allValidLocations(board); + + for (const { row, col, x, z } of validMoves) { + board[row][col].disc = { + dropped: false, + color: "YELLOW", + id: -1, + x, + z, + }; + + const score = this.minimax(board, depth, false); + + board[row][col].disc = null; + + moves.push({ score, move: { row, col, x, z, disc: null } }); + + if (score > bestScore) { + bestScore = score; + } + } + + return { moves, bestScore }; + } + + private minimax( + board: GridSlot[][], + depth: number, + playing: boolean + ): number { + const res = this.manager.check(board); + if (res.result != null || depth === 0) { + if (res.result === "RED") return -1; + else if (res.result === "YELLOW") return 1; + return 0; + } + + if (playing) { + let bestScore = -Infinity; + const validMoves = this.manager.board.allValidLocations(board); + for (const { row, col, x, z } of validMoves) { + board[row][col].disc = { + dropped: false, + color: "YELLOW", + id: -1, + x, + z, + }; + const score = this.minimax(board, depth - 1, false); + board[row][col].disc = null; + if (score > bestScore) { + bestScore = score; + } + } + return bestScore; + } else if (!playing) { + let bestScore = Infinity; + const validMoves = this.manager.board.allValidLocations(board); + for (const { row, col, x, z } of validMoves) { + board[row][col].disc = { + dropped: false, + color: "RED", + id: -1, + x, + z, + }; + const score = this.minimax(board, depth - 1, true); + board[row][col].disc = null; + if (score < bestScore) { + bestScore = score; + } + } + return bestScore; + } + + return 0; + } +}