Skip to content

Commit

Permalink
Add Connect4 (no AI yet)
Browse files Browse the repository at this point in the history
  • Loading branch information
JeremyLeland committed Aug 1, 2024
1 parent 7186fe4 commit ebc5dc7
Show file tree
Hide file tree
Showing 5 changed files with 462 additions and 0 deletions.
105 changes: 105 additions & 0 deletions games/Connect4/Canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export class Canvas {
zoom = 1;
scrollX = 0;
scrollY = 0;

#scale = 1;
#offsetX = 0;
#offsetY = 0;

#reqId;

constructor( canvas ) {
this.canvas = canvas;

if ( !this.canvas ) {
this.canvas = document.createElement( 'canvas' );
document.body.appendChild( this.canvas );
}
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';

this.canvas.oncontextmenu = () => { return false };

this.ctx = this.canvas.getContext( '2d' /*, { alpha: false }*/ );

const resizeObserver = new ResizeObserver( entries => {
entries.forEach( entry => {
// safari does not support devicePixelContentBoxSize, attempting to work around
const width = entry.devicePixelContentBoxSize?.[ 0 ].inlineSize ?? ( entry.contentBoxSize[ 0 ].inlineSize * devicePixelRatio );
const height = entry.devicePixelContentBoxSize?.[ 0 ].blockSize ?? ( entry.contentBoxSize[ 0 ].blockSize * devicePixelRatio );
this.canvas.width = width;
this.canvas.height = height;

// this still needs to be based on content box
const inlineSize = entry.contentBoxSize[ 0 ].inlineSize;
const blockSize = entry.contentBoxSize[ 0 ].blockSize;

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;
} );

this.ctx.translate( this.#offsetX, this.#offsetY );

this.ctx.scale( devicePixelRatio, devicePixelRatio );
this.ctx.scale( this.#scale, this.#scale );

this.redraw();
} );

resizeObserver.observe( this.canvas );
}

redraw() {
this.ctx.clearRect( 0, 0, this.ctx.canvas.width, this.ctx.canvas.height );

this.ctx.save(); {
this.ctx.scale( this.zoom, this.zoom );
this.ctx.translate( this.scrollX, this.scrollY );
this.ctx.lineWidth = this.zoom;

this.draw( this.ctx );
}
this.ctx.restore();
}

start() {
if ( !this.#reqId ) { // don't try to start again if already started
let lastTime;
const animate = ( now ) => {
lastTime ??= now; // for first call only
this.update( now - lastTime );
lastTime = now;

this.redraw();

if ( this.#reqId ) { // make sure we didn't stop it
this.#reqId = requestAnimationFrame( animate );
}
};

this.#reqId = requestAnimationFrame( animate );
}
}

stop() {
cancelAnimationFrame( this.#reqId );
this.#reqId = null; // so we can check if stopped
}

update( dt ) {}
draw( ctx ) {}

// TODO: Account for offset when centered canvas

getPointerX( e ) {
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;
}
}
232 changes: 232 additions & 0 deletions games/Connect4/Connect4.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
const Cols = 7, Rows = 6, Players = 2;

const PieceColors = [ '', 'red', 'yellow' ];
const BoardColor = 'tan';

const GameStateKey = 'connect4GameState';

const piecePath = new Path2D();
piecePath.arc( 0, 0, 0.5, 0, Math.PI * 2 );

const BoardPath = new Path2D();
BoardPath.rect( -0.5, -0.5, Cols, Rows );
for ( let row = 0; row < Rows; row++ ) {
for ( let col = 0; col < Cols; col++ ) {
BoardPath.moveTo( col, row );
BoardPath.arc( col, row, 0.4, 0, Math.PI * 2 );
}
}


// NOTE: Turn is 1-indexed so the turn/team match the values in board (0 means no piece)

export class Connect4 {
static fromLocalStore() {
const gameState = JSON.parse( localStorage.getItem( GameStateKey ) );

if ( gameState ) {
return new Connect4( gameState );
}
}

toLocalStore() {
localStorage.setItem( GameStateKey, JSON.stringify( this ) );
}

static newGame() {
return new Connect4( {
board: Array( Cols * Rows ).fill( 0 ),
history: [],
turn: 1,
victory: 0,
active: { x: 0, y: -1, vy: 0, ay: 0 },
} );
}

constructor( json ) {
Object.assign( this, json );
}

update( dt ) {
this.active.y += this.active.vy * dt + 0.5 * this.active.ay * dt * dt;
this.active.vy += this.active.ay * dt;

const col = this.active.x;
const row = Math.round( this.active.y );
const nextRow = Math.round( this.active.y + 0.5 );

if ( nextRow < Rows && this.getAt( this.active.x, nextRow ) == 0 ) {
return true; // keep going
}
else {
this.applyMove( [ col, row ] );

this.active.y = -1;
this.active.vy = 0;
this.active.ay = 0;

return false;
}
}

draw( ctx ) {
if ( this.victory == 0 ) {
ctx.save(); {
ctx.translate( this.active.x, this.active.y );
ctx.fillStyle = PieceColors[ this.turn ];
ctx.fill( piecePath );
}
ctx.restore();
}

for ( let row = 0; row < Rows; row++ ) {
for ( let col = 0; col < Cols; col++ ) {
const team = this.board[ col + row * Cols ];
if ( team > 0 ) {
ctx.save(); {
ctx.translate( col, row );
ctx.fillStyle = PieceColors[ team ];
ctx.fill( piecePath );
}
ctx.restore();
}
}
}

ctx.fillStyle = BoardColor;
ctx.fill( BoardPath, 'evenodd' );

if ( this.victory > 0 ) {
ctx.font = '1px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = 'white';
ctx.shadowColor = 'black';
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;

ctx.fillText( `Player ${ this.victory } Wins!`, 3, 3 );
}
}

getAt( col, row ) {
return this.board[ col + row * Cols ];
}

setAt( col, row, team ) {
this.board[ col + row * Cols ] = team;
}

applyMove( move ) {
console.log( `Applying move for Player ${ this.turn }: ${ move }` );

if ( this.victory > 0 ) {
console.warn( `Player ${ this.victory } already won game` );
return;
}

if ( move[ 0 ] < 0 || move[ 0 ] >= Cols ) {
console.warn( `Invalid column: ${ move[ 0 ] }` );
return;
}

if ( move[ 1 ] < 0 || move[ 1 ] >= Rows ) {
console.warn( `Invalid row: ${ move[ 1 ] }` );
return;
}

const current = this.getAt( move[ 0 ], move[ 1 ] );

if ( current != 0 ) {
console.warn( `Board already has Player ${ current } at ${ move }` );
return;
}

this.setAt( move[ 0 ], move[ 1 ], this.turn );

const longest = this.getLongestAt( move[ 0 ], move[ 1 ], this.turn );
if ( longest >= 4 ) {
this.victory = this.turn;
console.log( `Player ${ this.turn } wins with ${ longest } in a row!` );
}

this.turn = this.turn == Players ? 1 : this.turn + 1;
this.history.push( move );
}

undo() {
const toRemove = this.history.pop();

if ( toRemove ) {
this.turn = this.turn == 1 ? Players : this.turn - 1;
this.victory = 0;
this.setAt( toRemove[ 0 ], toRemove[ 1 ], 0 );
}
else {
console.warn( 'No moves to undo' );
}
}

getNextRowAt( col ) {
for ( let row = Rows - 1; row >= 0; row-- ) {
if ( this.getAt( col, row ) == 0 ) {
return row;
}
}

return -1;
}

getPossibleMoves() {
const moves = [];

for ( let col = 0; col < Cols; col ++ ) {
const nextRow = this.getNextRowAt( col );
if ( nextRow > -1 ) {
moves.push( [ col, nextRow ] );
}
}

return moves;
}

getLongestAt( col, row, team ) {
// console.log( `Finding longest line for Player ${ team } 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;
}
}
} );

// console.log( ` Length for orientation ${ orientation } is ${ length }`);

longest = Math.max( longest, length );
} );
}
else {
console.warn( `Player at ${ col },${ row } is actually ${ this.getAt( col, row ) }, expecting ${ team }` );
}

return longest;
}
}
Loading

0 comments on commit ebc5dc7

Please sign in to comment.