From 0a4327e6a71e0273bbc55423d3bb21e621af8db0 Mon Sep 17 00:00:00 2001 From: Jeremy Leland Date: Thu, 31 Oct 2024 12:54:47 -0700 Subject: [PATCH] Better moves for computer player (looks further ahead, doesn't miss as many obvious moves) --- games/Connect4/Canvas.js | 41 +++++++--- games/Connect4/Connect4.js | 162 ++++++++++++++++++++++++++++--------- games/Connect4/index.html | 27 ++++--- 3 files changed, 167 insertions(+), 63 deletions(-) diff --git a/games/Connect4/Canvas.js b/games/Connect4/Canvas.js index d0a39f2..faf415d 100644 --- a/games/Connect4/Canvas.js +++ b/games/Connect4/Canvas.js @@ -3,6 +3,8 @@ export class Canvas { scrollX = 0; scrollY = 0; + backgroundColor = 'black'; + #scale = 1; #offsetX = 0; #offsetY = 0; @@ -38,15 +40,13 @@ export class Canvas { this.#scale = Math.min( inlineSize, blockSize ); // this might get messed up if writing mode is vertical - this.#offsetX = inlineSize - this.#scale; - this.#offsetY = blockSize - this.#scale; + // Why did we have devicePixelRatio in here before? Is it needed by Safari? + this.#offsetX = ( inlineSize - this.#scale );// / devicePixelRatio; + this.#offsetY = ( blockSize - this.#scale );// / devicePixelRatio; + + // console.log( 'offsetX = ' + this.#offsetX + ', offsetY = ' + this.#offsetY ); } ); - this.ctx.translate( this.#offsetX, this.#offsetY ); - - this.ctx.scale( devicePixelRatio, devicePixelRatio ); - this.ctx.scale( this.#scale, this.#scale ); - this.redraw(); } ); @@ -54,14 +54,25 @@ export class Canvas { } redraw() { - this.ctx.clearRect( 0, 0, this.ctx.canvas.width, this.ctx.canvas.height ); + this.ctx.fillStyle = this.backgroundColor; + this.ctx.fillRect( 0, 0, this.ctx.canvas.width, this.ctx.canvas.height ); this.ctx.save(); { + this.ctx.translate( this.#offsetX, this.#offsetY ); + + this.ctx.scale( devicePixelRatio, devicePixelRatio ); + this.ctx.scale( this.#scale, this.#scale ); + this.ctx.scale( this.zoom, this.zoom ); - this.ctx.translate( this.scrollX, this.scrollY ); + this.ctx.translate( -this.scrollX, -this.scrollY ); this.ctx.lineWidth = this.zoom; - this.draw( this.ctx ); + try { + this.draw( this.ctx ); + } + catch ( e ) { + console.error( e ); + } } this.ctx.restore(); } @@ -96,10 +107,16 @@ export class Canvas { // TODO: Account for offset when centered canvas getPointerX( e ) { - return ( ( e.clientX - this.#offsetX / devicePixelRatio ) / this.#scale ) / this.zoom - this.scrollX; + return ( ( e.clientX - this.#offsetX / devicePixelRatio ) / this.#scale ) / this.zoom + this.scrollX; } getPointerY( e ) { - return ( ( e.clientY - this.#offsetY / devicePixelRatio ) / this.#scale ) / this.zoom - this.scrollY; + return ( ( e.clientY - this.#offsetY / devicePixelRatio ) / this.#scale ) / this.zoom + this.scrollY; + } + + zoomAt( x, y, amount ) { + this.zoom *= amount; + + } } diff --git a/games/Connect4/Connect4.js b/games/Connect4/Connect4.js index e681318..9cf766e 100644 --- a/games/Connect4/Connect4.js +++ b/games/Connect4/Connect4.js @@ -122,36 +122,41 @@ export class Connect4 { } applyMove( move ) { - console.log( `Applying move for Player ${ this.turn }: ${ move }` ); + // console.log( `Applying move for Player ${ this.turn }: ${ move }` ); if ( this.victory > 0 ) { console.warn( `Player ${ this.victory } already won game` ); - return; + return 0; } if ( move[ 0 ] < 0 || move[ 0 ] >= Cols ) { console.warn( `Invalid column: ${ move[ 0 ] }` ); - return; + return 0; } if ( move[ 1 ] < 0 || move[ 1 ] >= Rows ) { console.warn( `Invalid row: ${ move[ 1 ] }` ); - return; + return 0; } const current = this.getAt( move[ 0 ], move[ 1 ] ); if ( current != 0 ) { console.warn( `Board already has Player ${ current } at ${ move }` ); - return; + return 0; } this.setAt( move[ 0 ], move[ 1 ], this.turn ); - const longest = this.getLongestAt( move[ 0 ], move[ 1 ], this.turn ); - if ( longest >= 4 ) { + const longest = this.getLongestAt( move[ 0 ], move[ 1 ] ); + + if ( Math.abs( longest ) > 4 ) { + // debugger; + } + + if ( Math.abs( longest ) >= 4 ) { this.victory = this.turn; - console.log( `Player ${ this.turn } wins with ${ longest } in a row!` ); + // console.log( `Player ${ this.turn } wins with ${ longest } in a row!` ); } this.turn = this.turn == Players ? 1 : this.turn + 1; @@ -196,44 +201,121 @@ export class Connect4 { return moves; } - getLongestAt( col, row, team ) { - // console.log( `Finding longest line for Player ${ team } at ${ col },${ row }` ); + getLongestAt( col, row ) { + // console.log( `Finding longest line score at ${ col },${ row }` ); let longest = 0; - if ( this.getAt( col, row ) == team ) { - [ - [ 1, 0 ], // vertical - [ 0, 1 ], // horizontal - [ 1, 1 ], // diagonal 1 - [ 1, -1 ], // diagonal 2 - ].forEach( orientation => { - let length = 1; - - [ -1, 1 ].forEach( dir => { - let c = col, r = row; - - while ( true ) { - c += dir * orientation[ 0 ]; - r += dir * orientation[ 1 ]; - - if ( 0 <= c && c < Cols && 0 <= r && r < Rows && this.getAt( c, r ) == team ) { - length ++; - } - else { - break; - } - } - } ); + const team = this.getAt( col, row ); + + if ( team == 0 ) { + return 0; + } + + [ + [ 1, 0 ], // vertical + [ 0, 1 ], // horizontal + [ 1, 1 ], // diagonal 1 + [ 1, -1 ], // diagonal 2 + ].forEach( orientation => { + let length = 1; + + [ -1, 1 ].forEach( dir => { + let c = col, r = row; - // console.log( ` Length for orientation ${ orientation } is ${ length }`); + while ( true ) { + c += dir * orientation[ 0 ]; + r += dir * orientation[ 1 ]; - longest = Math.max( longest, length ); + if ( 0 <= c && c < Cols && 0 <= r && r < Rows && this.getAt( c, r ) == team ) { + length ++; + } + else { + break; + } + } } ); - } - else { - console.warn( `Player at ${ col },${ row } is actually ${ this.getAt( col, row ) }, expecting ${ team }` ); + + // console.log( ` Length for orientation ${ orientation } is ${ length }`); + + longest = Math.max( longest, length ); + } ); + + return ( team == 1 ? -1 : 1 ) * longest; + } + + // + // NOTE: Currently, tied boards may give -# or # depending on which pieces it finds first + // TODO: Should tied boards be represented differently? + // NOW: Trying only win and loss numbers, zero otherwise + + getScore() { + let longest = 0; + + for ( let col = 0; col < Cols; col ++ ) { + for ( let row = 0; row < Rows; row ++ ) { + const longestAt = this.getLongestAt( col, row ); + + if ( Math.abs( longestAt ) >= 4 ) { + return longestAt; + } + + // console.log( `Longest at ${ col },${ row } is ${ longestAt }` ); + + // if ( Math.abs( longestAt ) > Math.abs( longest ) ) { + // longest = longestAt; + // } + } } - return longest; + // return longest; + return 0; + } + + getNextMoves( depth ) { + const moves = []; + + this.getPossibleMoves().forEach( move => { + this.applyMove( move ); + + const item = { + move: move, + }; + + const score = this.getScore(); + + if ( Math.abs( score ) >= 4 || depth <= 1 ) { + item.score = score; + } + else { + item.nextMoves = this.getNextMoves( depth - 1 ); + + // How to account for a move that gets 4 now being better than a move that gets 4 in several moves? + + // TODO: Need to min/max here. It's assuming best moves for same player in every case + // Need to take in which player we are optimizing for to properly summarize + + // FIXME: Why is it getting zero so often now? + + const minFunc = ( a, b ) => a < b; + const maxFunc = ( a, b ) => a > b; + + const func = this.turn == 1 ? minFunc : maxFunc; + + let best = this.turn == 1 ? Infinity : -Infinity; + item.nextMoves.forEach( nextMove => { + if ( func( nextMove.score, best ) ) { + best = nextMove.score; + } + } ); + + item.score = best; + } + + moves.push( item ); + + this.undo(); + } ); + + return moves; } } \ No newline at end of file diff --git a/games/Connect4/index.html b/games/Connect4/index.html index cb9cb86..90bad4d 100644 --- a/games/Connect4/index.html +++ b/games/Connect4/index.html @@ -28,8 +28,8 @@ const canvas = new Canvas(); canvas.zoom = 1 / 7; - canvas.scrollX = 0.5; - canvas.scrollY = 1.5; + canvas.scrollX = -0.5; + canvas.scrollY = -1.5; let game = Connect4.fromLocalStore() ?? Connect4.newGame( 1 ); @@ -37,25 +37,32 @@ if ( !game.update( dt ) ) { game.toLocalStore(); + // NOTE: Hardcoded for 2nd AI player only at the moment (wants positive moves) if ( game.victory == 0 && game.aiPlayers.includes( game.turn ) ) { - let bestMove, bestScore = 0; - game.getPossibleMoves().forEach( move => { - const score = game.applyMove( move ); - game.undo(); + const nextMoves = game.getNextMoves( 6 ); + + // console.log( nextMoves ); - if ( score > bestScore || ( score == bestScore && Math.random() < 0.5 ) ) { - bestMove = move; - bestScore = score; + let bestMove, bestScore = -Infinity; + nextMoves.forEach( nextMove => { + if ( nextMove.score > bestScore || ( nextMove.score == bestScore && Math.random() < 0.5 ) ) { + bestMove = nextMove.move; + bestScore = nextMove.score; } } ); + // console.log( `Found bestMove ${ bestMove } with score ${ bestScore }` ); + game.active.x = bestMove[ 0 ]; game.active.y = -1; game.dropActive(); } else { canvas.stop(); + + // TODO: Should we do one last frame here? + // (need clearer case to check, should be showing yellow piece while thinking) } } } @@ -64,8 +71,6 @@ game.draw( ctx ); } - canvas.redraw(); - function updateActivePosition( e ) { const mouseX = canvas.getPointerX( e ); const mouseY = canvas.getPointerY( e );