This commit is contained in:
garrettmills 2020-04-08 08:42:39 -05:00
commit f871593191
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E
14 changed files with 720 additions and 0 deletions

22
index.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Connect 4 - Garrett Mills | EECS 368</title>
<link rel="stylesheet" href="./style/index.css">
</head>
<body>
<!-- Rivets binds to this. -->
<div id="app-container">
<!-- The App component. -->
<app-root></app-root>
</div>
<!-- Libraries. -->
<script src="lib/rivets.bundled.min.js"></script>
<!-- Start the application. -->
<script src="src/index.js" type="module"></script>
</body>
</html>

6
lib/rivets.bundled.min.js vendored Normal file

File diff suppressed because one or more lines are too long

18
src/components.js Normal file
View File

@ -0,0 +1,18 @@
import App from './components/app.component.js'
import Grid from './components/grid/grid.component.js'
import GridCell from './components/grid/grid-cell.component.js'
import GridRow from './components/grid/grid-row.component.js'
import GridSlots from './components/grid/grid-slots.component.js'
import Scoreboard from './components/scoreboard.component.js'
// Registry of components to be loaded by Rivets.js
const components = {
App, // the main app
Grid, // the connect-4 grid
GridCell, // a single cell in the connect-4 grid
GridRow, // a row of cells in the connect-4 grid
GridSlots, // the slots at the top of the connect-4 grid
Scoreboard, // the game scoreboard
}
export default components

View File

@ -0,0 +1,33 @@
import Component from '../rivets/Component.js'
// The root application component
export default class App extends Component {
static selector() { return 'app-root' }
static template() { return `
<h1>Connect 4 by Garrett Mills</h1>
<p>This is a simple Connect-4 webtoy that I built as a project for EECS 368 at the University of Kansas. It's a two person game - take turns. Click the slot at the top of a column on the grid to drop the token on your turn.</p>
<app-grid parent-app="self"></app-grid>
<app-scoreboard parent-app="self"></app-scoreboard>
<p><small>&copy; { year } <a href="https://garrettmills.dev/">Garrett Mills</a> | <a href="#">Source Code</a></small></p>
` }
// The current year for the copyright message
year = ''
constructor(el, data) {
super(el, data)
// Set the current year
this.year = (new Date).getFullYear()
}
// Callback for the grid creation. Gives access to the Grid class
register_grid(grid) {
this.grid = grid
}
// Callback for the scoreboard creation. Gives access to the Scoreboard class
register_scoreboard(scoreboard) {
this.scoreboard = scoreboard
}
}

View File

@ -0,0 +1,43 @@
import Component from '../../rivets/Component.js'
import { COLORS, PLAYER } from './grid.component.js'
// A single cell in the Connect-4 grid
export default class GridCell extends Component {
static selector() { return 'app-grid-cell' }
static template() { return `
<div class="cell-outer">
<div class="cell-inner" rv-background="color"></div>
</div>
` }
constructor(el, data) {
super(el, data)
// This cell's column number
this.column_number = data.columnNumber;
// The player occupying this cell
this.player = PLAYER.none;
// True if a player occupies this cell
this.occupied = false;
// The color of this cell
this.color = COLORS.empty;
this.parent_row = data.parentRow;
this.parent_row.register_cell(this, this.column_number)
}
// Change the player that occupies this cell - default none
set_player(player = PLAYER.none) {
this.player = player;
this.occupied = player !== PLAYER.none;
this.color = (player === PLAYER.one ? COLORS.player_1 : (player === PLAYER.two ? COLORS.player_2 : COLORS.empty))
}
// Returns true if this cell is empty
is_available() {
return !this.occupied
}
}

View File

@ -0,0 +1,37 @@
import Component from '../../rivets/Component.js'
import { PLAYER } from './grid.component.js'
import { bindable } from '../../rivets/helpers.js'
// A row of cells in the Connect-4 grid
export default class GridRow extends Component {
static selector() { return 'app-grid-row' }
static template() { return `
<div class="grid-row-container">
<app-grid-cell parent-row="self" column-number="0"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="1"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="2"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="3"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="4"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="5"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="6"></app-grid-cell>
<app-grid-cell parent-row="self" column-number="7"></app-grid-cell>
</div>
` }
cells = Array(8).fill()
constructor(el, data) {
super(el, data)
// This row's row-number
this.row_number = data.rowNumber;
this.parent_grid = data.parentGrid;
this.parent_grid.register_row(this, this.row_number);
}
// Callback for cell creation - gives access to the cell component classes
register_cell(cell_class, column_number) {
this.cells[column_number] = cell_class;
}
}

View File

@ -0,0 +1,41 @@
import Component from '../../rivets/Component.js'
import { bindable } from '../../rivets/helpers.js'
// Component for the slots at the top of the Connect-4 grid
export default class GridSlots extends Component {
static selector() { return 'app-grid-slots' }
static template() { return `
<div class="grid-slots-container">
<div class="grid-slot-outer" rv-each-column="columns" rv-on-click="column.on_click">
<div class="grid-slot-inner"></div>
</div>
</div>
` }
columns = []
constructor(el, data) {
const { nColumns, parentGrid } = data
super(el, data)
this.parent_grid = parentGrid
// Populate the columns in this object
this.columns = Array(8).fill().map((x,i) => this.column_state(i));
}
// Called when a column is clicked - pass the event up
on_column_clicked(column_number) {
if ( this.parent_grid.allow_drop )
this.parent_grid.on_drop(column_number)
}
// Get the state object for a single column
column_state(column_number) {
return {
on_click: () => {
this.on_column_clicked(column_number)
}
}
}
}

View File

@ -0,0 +1,230 @@
import Component from '../../rivets/Component.js'
import { wait } from '../../rivets/helpers.js'
// Enum for the different players
const PLAYER = {
one: Symbol('one'),
two: Symbol('two'),
none: Symbol('none'),
}
// Enum for some commonly used colors
const COLORS = {
empty: 'darkblue',
player_1: '#f30000',
player_2: '#daf500',
victory: 'darkgreen',
dark: '#333',
}
export { PLAYER, COLORS }
// Component for the Connect-4 grid
export default class Grid extends Component {
static selector() { return 'app-grid' }
static template() { return `
<app-grid-slots parent-grid="self"></app-grid-slots>
<app-grid-row parent-grid="self" row-number="0"></app-grid-row>
<app-grid-row parent-grid="self" row-number="1"></app-grid-row>
<app-grid-row parent-grid="self" row-number="2"></app-grid-row>
<app-grid-row parent-grid="self" row-number="3"></app-grid-row>
<app-grid-row parent-grid="self" row-number="4"></app-grid-row>
<app-grid-row parent-grid="self" row-number="5"></app-grid-row>
` }
// Array of GridRow components
rows = Array(6).fill()
// GridSlots only allows drop if this is true
allow_drop = true
// The current player
current_player = PLAYER.one
// The designated winner
winner = undefined
constructor(el, data) {
super(el, data)
this.app = data.parentApp
this.app.register_grid(this)
}
on_drop(column_number) {
// Check if a drop is possible on the column
if ( this._can_drop_on_column(column_number) ) {
// Disallow drops temporarily
this.allow_drop = false
// Wrap async so we can use wait non-blocking
;(async () => {
// Animate the move so there's an actual 'drop'
for ( let i = 0; i < this.rows.length; i++ ) {
const cell = this.rows[i].cells[column_number]
await wait(20)
if ( cell.is_available() ) {
if ( i > 0 ) this.rows[i - 1].cells[column_number].set_player()
cell.set_player(this.current_player)
await wait(20)
} else break // stop if we hit the end, or this row has a chip already
}
// Check for a winner
const winner = this.get_winner()
if ( winner ) {
this.winner = winner
this.on_game_over()
return
}
// Check for full grid
if ( this.is_full() ) {
this.winner = PLAYER.none
this.on_game_over()
return
}
// Unblock dropping and switch to the other player
this.allow_drop = true
this.change_player()
})()
}
}
// Callback from row creation - register the rows' component classes
register_row(row, row_number) {
this.rows[row_number] = row;
}
// Change the current player (e.g. P1 -> P2/P2 -> P1)
change_player() {
if ( this.current_player === PLAYER.one ) this.current_player = PLAYER.two
else this.current_player = PLAYER.one
}
// Clears the grid and resets the game to play again
reset() {
this.get_cells().some(cell => cell.set_player())
this.winner = undefined
this.current_player = PLAYER.one
this.allow_drop = true
}
// Get a flat array of all the grid's cells
get_cells() {
return this.rows.map(row => row.cells).flat()
}
// Get the cells in groups of columns, not rows
get_columns() {
return Array(8).fill().map((x,i) => this.rows.map(row => row.cells[i]))
}
// Get all sets of 4 diagonal cells
get_diagonals() {
const columns = this.get_columns()
const diagonal_groups = []
// Build the upward-right diagonals (possible from cell 3 on columns 0 -> 4)
const up_right_cols = columns.slice(0, 5);
up_right_cols.some((col, i) => {
for ( const cell_index of [3, 4, 5] ) {
const diagonal_group = [
col[cell_index],
columns[i + 1][cell_index - 1],
columns[i + 2][cell_index - 2],
columns[i + 3][cell_index - 3],
]
diagonal_groups.push(diagonal_group)
}
})
// Build the upward-left diagonals (possible from cell 3 on columns 3 -> 7)
const up_left_cols = columns.slice(3, 8);
up_left_cols.some((col, ind) => {
// Correct the index with respect to this.columns (because we sliced from the front)
const i = ind + 3;
for ( const cell_index of [3, 4, 5] ) {
const diagonal_group = [
col[cell_index],
columns[i - 1][cell_index - 1],
columns[i - 2][cell_index - 2],
columns[i - 3][cell_index - 3],
]
diagonal_groups.push(diagonal_group)
}
})
return diagonal_groups
}
// True if the grid is full, false otherwise
is_full() {
return Array(8).fill().every((x, i) => !this._can_drop_on_column(i))
}
// Returns true if a drop is possible on the given column number
_can_drop_on_column(column_number) {
return this.rows[0].cells[column_number].is_available()
}
// Check if either of the players has won the game
// If they have, return the winner, otherwise return undefined
get_winner() {
// Check if there are any row-wise runs of 4
const row_wise = this.rows.map(row => this._check_cells_for_winner(row.cells))
.filter(player => player !== PLAYER.none)
.shift()
// Check if there are any column-wise runs of 4
const column_wise = this.get_columns()
.map(col_group => this._check_cells_for_winner(col_group))
.filter(player => player !== PLAYER.none)
.shift()
// Check if there are any diagonal runs of 4
const diagonal_wise = this.get_diagonals()
.map(diag_group => this._check_cells_for_winner(diag_group))
.filter(player => player !== PLAYER.none)
.shift()
return [row_wise, column_wise, diagonal_wise].filter(Boolean).shift()
}
// Check the group of cells for a 4-long run
// Returns the player with the run, or PLAYER.none if no run was found
_check_cells_for_winner(cells = []) {
let current_player = PLAYER.none
let run_length = 0
for ( const cell of cells ) {
if ( cell.is_available() ) {
current_player = PLAYER.none
run_length = 0
} else if ( cell.player === current_player ) {
run_length += 1;
if ( run_length >= 4 ) return current_player
} else {
current_player = cell.player
run_length = 1
}
}
return PLAYER.none
}
// Fired when the game has ended, either from a winner or a draw
on_game_over() {
;(async () => {
if ( this.winner !== PLAYER.none ) {
await this.app.scoreboard.increment(this.winner)
this.reset()
}
})()
}
}

View File

@ -0,0 +1,71 @@
import Component from '../rivets/Component.js'
import { PLAYER, COLORS } from './grid/grid.component.js'
import { wait } from '../rivets/helpers.js'
// The scoreboard for the Connect-4 games
export default class Scoreboard extends Component {
static selector() { return 'app-scoreboard' }
static template() { return `
<div class="scoreboard-container">
<div class="player" rv-background="player_one_bkg">
<p class="score">{ player_one }</p>
<p class="name">Player One</p>
</div>
<div class="player" rv-background="player_two_bkg">
<p class="score">{ player_two }</p>
<p class="name">Player Two</p>
</div>
<div class="buttons">
<button rv-on-click="bind.on_board_reset_click">Reset Board</button>
<button rv-on-click="bind.on_score_reset_click">Reset Score</button>
</div>
</div>
` }
// The players' scores
player_one = 0
player_two = 0
// The players' background colors
player_one_bkg = COLORS.dark
player_two_bkg = COLORS.dark
constructor(el, data) {
super(el, data)
this.app = data.parentApp
this.app.register_scoreboard(this)
}
// Increment the specified player's score, animating the background for a few seconds
async increment(player) {
if ( player === PLAYER.one ) {
this.player_one_bkg = COLORS.victory
this.player_one += 1
await wait(3000)
this.player_one_bkg = COLORS.dark
} else if ( player === PLAYER.two ) {
this.player_two_bkg = COLORS.victory
this.player_two += 1
await wait(3000)
this.player_two_bkg = COLORS.dark
}
}
// Reset the score
reset() {
this.player_one = 0
this.player_two = 0
}
// Called when the "Reset Board" button is clicked
on_board_reset_click() {
this.app.grid.reset()
}
// Called when the "Reset Score" button is clicked
on_score_reset_click() {
this.app.grid.reset()
this.reset()
}
}

11
src/index.js Normal file
View File

@ -0,0 +1,11 @@
import components from './components.js'
import Loader from './rivets/Loader.js'
// Create the framework loader
const loader = new Loader({ components })
// Register the components
loader.initialize()
// Bind the application to the app-container
loader.bind('#app-container')

57
src/rivets/Component.js Normal file
View File

@ -0,0 +1,57 @@
import { bindable } from './helpers.js'
// Base class for a Rivets.js component
export default class Component {
/**
* return the HTML selector of the component
* @return {string}
*/
static selector() { return '' }
/**
* return the HTML template of the component
* @return {string}
*/
static template() { return '' }
/**
* Called when the component is initialized
* @param {element} el - the instantiated DOM element
* @param {object} data - the data attributes of the component
* @return {Component}
*/
static initialize(el, data) {
return new this(el, data)
}
/**
* The constructor.
* @param {element} el - the instantiated DOM element
* @param {object} data - the data attributes of the component
*/
constructor(el, data) {
/**
* The DOM element this component is associated with.
* @type {element}
*/
this.element = el
}
/**
* Returns the reference to this component.
* Useful for passing the whole component along.
*/
get self() {
return this
}
/**
* Returns a self-binding proxy wrapper for this class.
* Useful for passing methods to components.
*/
get bind() {
return bindable(this)
}
}

41
src/rivets/Loader.js Normal file
View File

@ -0,0 +1,41 @@
// Loads the assets/components into the Rivets.js framework
export default class Loader {
constructor({ components }) {
this.components = components
this.state = {}
}
// Prepare the Rivets.js framework by loading component definitions
initialize() {
for ( const key in this.components ) {
if ( !this.components.hasOwnProperty(key) ) continue
const component = this.components[key]
// Register the component class with the rivets framework
rivets.components[component.selector()] = {
template: () => {
return component.template()
},
initialize: (el, data) => {
return new component(el, data)
}
}
}
// Load the custom property binders
this._init_binders()
}
_init_binders() {
// Binds to the CSS background property
rivets.binders.background = (el, value) => {
el.style.background = value;
}
}
// Bind the Rivets.js application to the specified target
bind(target) {
rivets.bind(document.querySelector(target), this.state)
}
}

26
src/rivets/helpers.js Normal file
View File

@ -0,0 +1,26 @@
// Wraps an object-like thing with a proxy to self-bind top-level functions
const bindable = (base_class) => {
return new Proxy(base_class, {
get(target, property) {
// If we're accessing a function of the item, force-bind it
if ( typeof base_class[property] === 'function' && base_class.hasOwnProperty(property) ) {
return base_class[property].bind(base_class)
}
return base_class[property]
},
set(target, property, value) {
base_class[property] = value
}
})
}
// Async sleeper function
const wait = (ms) => {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}
export { bindable, wait }

84
style/index.css Normal file
View File

@ -0,0 +1,84 @@
@media only screen and (min-width: 1200px) {
#app-container {
max-width: 50%;
margin-left: 25%;
margin-right: auto;
}
}
app-grid-cell .cell-outer {
width: 75px;
height: 75px;
background-color: #2222cc;
}
app-grid-cell .cell-outer .cell-inner {
width: 69px;
height: 69px;
margin: 3px;
background: darkblue;
position: absolute;
border-radius: 100px;
}
app-grid-row .grid-row-container {
display: flex;
}
app-grid-slots .grid-slots-container {
display: flex;
}
app-grid-slots .grid-slots-container .grid-slot-outer {
width: 75px;
height: 20px;
background-color: #2222cc;
transform: skew(-15deg) translateX(3px);
}
app-grid-slots .grid-slots-container .grid-slot-outer .grid-slot-inner {
width: 69px;
height: 14px;
position: absolute;
background: darkblue;
margin: 2px;
}
app-scoreboard .scoreboard-container {
display: flex;
}
app-scoreboard .scoreboard-container .player {
background: #333;
padding: 10px;
text-align: center;
color: white;
font-family: sans-serif;
margin: 7px;
border-radius: 7px;
font-size: 14pt;
}
app-scoreboard .scoreboard-container .player .score {
font-size: 40px;
line-height: 0px;
font-weight: bold;
}
app-scoreboard .scoreboard-container .buttons {
display: inline-grid;
padding: 5px;
}
app-scoreboard .scoreboard-container .buttons button {
margin: 3px;
padding: 5px 20px;
border: 1px solid #333;
border-radius: 5px;
color: #333;
}
app-scoreboard .scoreboard-container .buttons button:hover {
color: #ddd;
background: #333;
}