Simple Tic-Tac-Toe game developed with heavy use of design-by-contract
A Pen by Jack Firth on CodePen.
| <link href='http://fonts.googleapis.com/css?family=Fredoka+One' rel='stylesheet' type='text/css'> | |
| <div data-ng-app="TicTacToeApp"> | |
| <div class="tic-tac-toe" data-ng-controller="TicTacToeController"> | |
| <div class="turn-header" data-ng-show = "!isGameOver"> | |
| {{activePlayer}} to move | |
| </div> | |
| <div class="turn-header" data-ng-show = "isGameOver && isTie"> | |
| tie game | |
| </div> | |
| <div class="turn-header" data-ng-show = "isGameOver && !isTie"> | |
| {{winner}} won the game | |
| </div> | |
| <div class="board"> | |
| <div class="row group" data-ng-repeat="row in board"> | |
| <div class="cell" data-ng-repeat="cell in row.cells" data-ng-click="clickCell(cell)" data-ng-class="{'cell-player-won': cell.winner && isX(winner), 'cell-player-lost': cell.winner && isO(winner)}"> | |
| <span class="cell-value"> | |
| {{cell.value}} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="button-footer"> | |
| <button class="large" data-ng-disabled="!isGameOver" data-ng-click="resetGame()"> | |
| replay | |
| </button> | |
| </div> | |
| <div class="button-footer"> | |
| <button data-ng-disabled="AI.active" data-ng-click="setActiveAI(AI)" data-ng-repeat="AI in AIs" > | |
| {{AI.name}} | |
| </button> | |
| </div> | |
| </div> | |
| </div> |
| 'use strict'; | |
| (function() { | |
| var contractsEnabled = false; | |
| /* String helpers */ | |
| function toStringWithQuotes(v) { | |
| return typeof v === "string" | |
| ? '"' + v.toString() + '"' | |
| : typeof v === "undefined" | |
| ? "undefined" | |
| : v.toString(); | |
| } | |
| function capitalizeFirstLetter(string) { | |
| return string.charAt(0).toUpperCase() + string.slice(1); | |
| } | |
| /* Function wrapping and metadata manipulation */ | |
| Function.prototype.getName = function getName() { | |
| return typeof this.wrappedName === "undefined" ? this.name : this.wrappedName; | |
| } | |
| Function.prototype.getLength = function getLength() { | |
| return typeof this.wrappedName === "undefined" ? this.length : this.wrappedLength; | |
| } | |
| Function.prototype.wraps = function wraps(f) { | |
| this.wrappedLength = f.getLength(); | |
| this.wrappedName = f.getName(); | |
| return this; | |
| } | |
| Function.prototype.renamed = function renamed(name) { | |
| this.wrappedName = name; | |
| return this; | |
| } | |
| /* Helper predicates - no contracts because they're used in building the contract functions */ | |
| var isOfType = (function isOfType(typeString) { | |
| return (function (v) { | |
| return typeof v === typeString; | |
| }).renamed("is" + capitalizeFirstLetter(typeString)); | |
| }); | |
| var isBoolean = isOfType("boolean"); | |
| var isObject = isOfType("object"); | |
| var isFunction = isOfType("function"); | |
| var isUndefined = isOfType("undefined"); | |
| var isString = isOfType("string"); | |
| var isNumber = isOfType("number"); | |
| var isAny = (function isAny(v) { | |
| return true; | |
| }); | |
| var isInteger = (function isInteger(v) { | |
| return typeof v === "number" | |
| && v % 1 === 0; | |
| }); | |
| /* Arity checker */ | |
| Function.prototype.arity = function arity() { | |
| var f = this; | |
| return function() { | |
| if (arguments.length != f.getLength()) { | |
| throw new Error("arity mismatch in " + f.getName() + ": expected " + f.getLength() + " arguments, got " + arguments.length); | |
| } else { | |
| return f.apply(this, arguments); | |
| } | |
| }.wraps(f); | |
| } | |
| /* Function return value contract */ | |
| Function.prototype.post = (function post(p) { | |
| var f = this; | |
| return (function() { | |
| var ret = f.apply(this, arguments); | |
| if (p(ret)) { | |
| return ret; | |
| } else { | |
| throw new Error(f.getName() + " broke it's contract: promised " + p.getName() + " but returned " + toStringWithQuotes(ret)); | |
| } | |
| }).wraps(f); | |
| }).arity(); | |
| /* Function arguments contract */ | |
| Function.prototype.pre = function pre() { | |
| var f = this; | |
| var ps = arguments; | |
| return (function () { | |
| for (var i=0; i < ps.length; i+=1) { | |
| if (!ps[i](arguments[i])) { | |
| throw new Error("contract error in " + f.getName() + ": expected " + ps[i].getName() + " at position " + i + " but received " + toStringWithQuotes(arguments[i])); | |
| } | |
| } | |
| return f.apply(this, Array.prototype.slice.call(arguments, 0)); | |
| }).wraps(f); | |
| }; | |
| Function.prototype.contract = function contract() { | |
| var f = this; | |
| if (f.getLength() !== arguments.length - 1) { | |
| throw new Error("Contract construction error in " + f.getName() + " - number of argument predicates provided does not match number of function arguments - provided " + (arguments.length - 1) + " argument predicates"); | |
| } | |
| if (!contractsEnabled) | |
| return f; | |
| var prePreds = Array.prototype.slice.call(arguments, 0, arguments.length - 1); | |
| var postPred = arguments[arguments.length - 1]; | |
| if (arguments.length > 1) { | |
| return f | |
| .arity() | |
| .pre.apply(f, prePreds) | |
| .post(postPred); | |
| } | |
| if (arguments.length == 1) { | |
| return f | |
| .arity() | |
| .post(postPred); | |
| } | |
| else { | |
| throw new Error("Contract construction error in " + f.getName() + " - no predicates provided"); | |
| } | |
| }; | |
| /* Custom predicate helpers */ | |
| var isObjectOf = (function isObjectOf(name, propPredicates) { | |
| return (function (v) { | |
| if (!isObject(v)) { | |
| return false; | |
| } | |
| for (var prop in propPredicates) { | |
| if (propPredicates.hasOwnProperty(prop)) { | |
| if (isUndefined(v[prop])) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| }).contract(isAny, isBoolean).renamed("is" + capitalizeFirstLetter(name)); | |
| }).contract(isString, isObject, isFunction); | |
| var isValue = (function isValue(value) { | |
| return (function (v) { | |
| return v === value; | |
| }).contract(isAny, isBoolean); | |
| }).contract(isAny, isFunction); | |
| var isNot = (function isNot(p) { | |
| return (function (v) { | |
| return !p(v); | |
| }).contract(isAny, isBoolean); | |
| }).contract(isFunction, isFunction); | |
| var isOneOf = (function isOneOf() { | |
| var ps = arguments; | |
| return (function (v) { | |
| for (var i = 0; i < ps.length; i += 1) { | |
| if (ps[i](v)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }).contract(isAny, isBoolean); | |
| }); | |
| var isEach = (function isEach() { | |
| var ps = arguments; | |
| return (function (v) { | |
| for (var i = 0; i < ps.length; i += 1) { | |
| if (!ps[i](v)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| }).contract(isAny, isBoolean); | |
| }); | |
| var hasPropertyOf = (function hasPropertyOf(name, p) { | |
| return (function (v) { | |
| return !isUndefined(v[name]) && p(v[name]); | |
| }).contract(isAny, isBoolean); | |
| }).contract(isString, isFunction, isFunction); | |
| var isNumberInRange = (function isNumberInRange(low, high) { | |
| return (function (v) { | |
| return isNumber(v) && low <= v && v <= high; | |
| }).contract(isAny, isBoolean).renamed("isNumberInRange" + low + "to" + high); | |
| }).contract(isNumber, isNumber, isFunction); | |
| var isDefined = isNot(isUndefined).renamed("isDefined"); | |
| /* FP helpers */ | |
| var buildArray = (function buildArray(length, f) { | |
| var a = new Array(); | |
| for (var i=0; i<length; i++) { | |
| a[i] = f(i); | |
| } | |
| return a; | |
| }).contract(isInteger, isFunction, Array.isArray); | |
| var zip = (function zip(f, as, bs) { | |
| var arr = new Array(); | |
| for (var i = 0; i < as.length; i += 1) { | |
| arr[i] = f(as[i], bs[i]); | |
| } | |
| return arr; | |
| }).contract(isFunction, Array.isArray, Array.isArray, Array.isArray); | |
| var foldOr = (function foldOr(f, vs) { | |
| for (var i = 0; i < vs.length; i+=1) { | |
| if (f(vs[i])) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }).contract(isFunction, Array.isArray, isBoolean); | |
| var foldAnd = (function foldAnd(f, vs) { | |
| for (var i = 0; i < vs.length; i+=1) { | |
| if (!f(vs[i])) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| }).contract(isFunction, Array.isArray, isBoolean); | |
| /* Board data definitions */ | |
| var cellValues = {empty: "", x: "X", o: "O"} | |
| var isEmptyCellValue = isValue(cellValues.empty).renamed("isEmptyCellValue"); | |
| var isX = isValue(cellValues.x).renamed("isX"); | |
| var isO = isValue(cellValues.o).renamed("isO"); | |
| var isPlayerValue = isOneOf(isX, isO).renamed("isPlayerValue"); | |
| var isCellValue = isOneOf(isEmptyCellValue, isPlayerValue).renamed("isCellValue"); | |
| var isCell = isObjectOf("cell", { | |
| value: isCellValue, | |
| row: isInteger, | |
| column: isInteger, | |
| winning: isBoolean | |
| }); | |
| var isEmptyCell = isEach(isCell, hasPropertyOf("value", isEmptyCellValue)).renamed("isEmptyCell"); | |
| var isCellOrUndefined = isOneOf(isUndefined, isCell).renamed("isCellOrUndefined"); | |
| var isRow = isObjectOf("row", { cells: Array.isArray }); | |
| var isColumnIndex = isEach(isInteger, isNumberInRange(0, 2)).renamed("isColumnIndex"); | |
| var isRowIndex = isEach(isInteger, isNumberInRange(0, 2)).renamed("isRowIndex"); | |
| var isBoard = Array.isArray.contract(isAny, isBoolean).renamed("isBoard"); | |
| var isMoveAndValue = isObjectOf("moveAndValue", { | |
| cell: isEmptyCell, | |
| value: isInteger | |
| }); | |
| /* Querying boards */ | |
| var getCell = (function getCell(board, row, column) { | |
| return board[row].cells[column]; | |
| }).contract(isBoard, isRowIndex, isColumnIndex, isCell); | |
| var getCellValue = (function getCellValue(board, row, column) { | |
| return getCell(board, row, column).value; | |
| }).contract(isBoard, isRowIndex, isColumnIndex, isCellValue); | |
| var isPlayerAt = (function isPlayerAt(board, player, row, column) { | |
| return isValue(player)(getCellValue(board, row, column)); | |
| }).contract(isBoard, isPlayerValue, isRowIndex, isColumnIndex, isBoolean); | |
| var isPlayerAtEach = (function isPlayerAtEach(board, player, rows, columns) { | |
| return foldAnd( | |
| function(indices) { return isPlayerAt(board, player, indices.row, indices.column); }, | |
| zip(function(r, c) { return { row: r, column: c }; }, rows, columns)); | |
| }).contract(isBoard, isPlayerValue, Array.isArray, Array.isArray, isBoolean); | |
| /* Board terminality predicates */ | |
| var rowFull = (function rowFull(row) { | |
| return !foldOr(isEmptyCell, row.cells); | |
| }).contract(isRow, isBoolean); | |
| var isNotFullBoard = (function isNotFullBoard(board) { | |
| return isBoard(board) && !foldAnd(rowFull, board); | |
| }).contract(isAny, isBoolean); | |
| var isRowWonBy = (function isRowWonBy(board, player, row) { | |
| return isPlayerAtEach(board, player, [row, row, row], [0, 1, 2]); | |
| }).contract(isBoard, isPlayerValue, isRowIndex, isBoolean); | |
| var isColumnWonBy = (function isColumnWonBy(board, player, column) { | |
| return isPlayerAtEach(board, player, [0, 1, 2], [column, column, column]); | |
| }).contract(isBoard, isPlayerValue, isColumnIndex, isBoolean); | |
| var anyRowsWonBy = (function anyRowsWonBy(board, player) { | |
| return isRowWonBy(board, player, 0) | |
| || isRowWonBy(board, player, 1) | |
| || isRowWonBy(board, player, 2); | |
| }).contract(isBoard, isPlayerValue, isBoolean); | |
| var anyColumnsWonBy = (function anyColumnsWonBy(board, player) { | |
| return isColumnWonBy(board, player, 0) | |
| || isColumnWonBy(board, player, 1) | |
| || isColumnWonBy(board, player, 2); | |
| }).contract(isBoard, isPlayerValue, isBoolean); | |
| var isTopLeftDiagonalWonBy = (function isTopLeftDiagonalWonBy(board, player) { | |
| return isPlayerAtEach(board, player, [0, 1, 2], [0, 1, 2]); | |
| }).contract(isBoard, isPlayerValue, isBoolean); | |
| var isTopRightDiagonalWonBy = (function isTopLeftDiagonalWonBy(board, player) { | |
| return isPlayerAtEach(board, player, [0, 1, 2], [2, 1, 0]); | |
| }).contract(isBoard, isPlayerValue, isBoolean); | |
| var isBoardWonBy = (function isBoardWonBy(board, player) { | |
| return anyRowsWonBy(board, player) | |
| || anyColumnsWonBy(board, player) | |
| || isTopLeftDiagonalWonBy(board, player) | |
| || isTopRightDiagonalWonBy(board, player); | |
| }).contract(isBoard, isPlayerValue, isBoolean); | |
| var isTerminalBoard = (function isTerminalBoard(board) { | |
| return isBoard(board) | |
| && (isBoardWonBy(board, cellValues.x) | |
| || isBoardWonBy(board, cellValues.o) | |
| || !isNotFullBoard(board)); | |
| }).contract(isAny, isBoolean); | |
| var isNotTerminalBoard = isNot(isTerminalBoard).renamed("isNotTerminalBoard"); | |
| var otherPlayer = (function otherPlayer(v) { | |
| return v === cellValues.x ? cellValues.o : cellValues.x; | |
| }).contract(isPlayerValue, isPlayerValue); | |
| /* Construction of data structures */ | |
| var makeEmptyCell = (function makeEmptyCell(row) { | |
| return (function makeEmptyRowCell(column) { | |
| return { | |
| value: cellValues.empty, | |
| row: row, | |
| column: column, | |
| winning: false | |
| }; | |
| }).contract(isInteger, isEmptyCell); | |
| }).contract(isInteger, isFunction); | |
| var makeEmptyRow = (function makeEmptyRow(row) { | |
| return { cells: buildArray(3, makeEmptyCell(row)) }; | |
| }).contract(isInteger, isRow); | |
| var makeEmptyBoard = (function makeEmptyBoard() { | |
| return buildArray(3, makeEmptyRow); | |
| }).contract(Array.isArray); | |
| var copyBoard = (function copiedBoard(board) { | |
| var copied = makeEmptyBoard(); | |
| for (var row = 0; row < 3; row += 1) { | |
| for (var column = 0; column < 3; column += 1) { | |
| copied[row].cells[column].value = board[row].cells[column].value; | |
| } | |
| } | |
| return copied; | |
| }).contract(isBoard, isBoard); | |
| var getBoardWithMove = (function getBoardWithMove(board, row, column, player) { | |
| var copiedBoard = copyBoard(board); | |
| copiedBoard[row].cells[column].value = player; | |
| return copiedBoard; | |
| }).contract(isNotFullBoard, isRowIndex, isColumnIndex, isPlayerValue, isBoard); | |
| /* Random move AI */ | |
| var randomInArray = (function randomInArray(array) { | |
| return array[Math.floor(Math.random() * array.length)]; | |
| }).contract(Array.isArray, isAny); | |
| var randomRowCell = (function randomRowCell(row) { | |
| return randomInArray(row.cells); | |
| }).contract(isRow, isCell); | |
| var randomRow = (function randomRow(board) { | |
| return randomInArray(board); | |
| }).contract(isBoard, isRow); | |
| var randomMove = (function randomMove(board, movingPlayer) { | |
| var cell = randomRowCell(randomRow(board)); | |
| return isEmptyCell(cell) ? cell : randomMove(board, movingPlayer); | |
| }).contract(isNotTerminalBoard, isPlayerValue, isEmptyCell); | |
| /* Optimal move AI */ | |
| var minMove = (function minMove(board, movingPlayer, lastCell) { | |
| var bestMove = maxMove(board, movingPlayer, lastCell); | |
| return { | |
| cell: bestMove.cell, | |
| value: bestMove.value * -1 | |
| }; | |
| }).contract(isBoard, isPlayerValue, isCell, isMoveAndValue); | |
| var maxMove = (function maxMove(board, movingPlayer, lastCell) { | |
| if (isBoardWonBy(board, movingPlayer)) { | |
| return { cell: lastCell, value: 1 }; | |
| } if (isBoardWonBy(board, otherPlayer(movingPlayer))) { | |
| return { cell: lastCell, value: -1 }; | |
| } if (!isNotFullBoard(board)) { | |
| return { cell: lastCell, value: 0 }; | |
| } | |
| var bestMove = false; | |
| for (var row = 0; row < 3; row += 1) { | |
| for (var column = 0; column < 3; column += 1) { | |
| var currentCell = board[row].cells[column]; | |
| if (!isEmptyCell(currentCell)) { | |
| continue; | |
| } | |
| var boardWithMove = getBoardWithMove(board, row, column, movingPlayer); | |
| var boardMinMove = minMove(boardWithMove, otherPlayer(movingPlayer), currentCell); | |
| if (!bestMove || boardMinMove.value > bestMove.value) { | |
| bestMove = { cell: currentCell, value: boardMinMove.value }; | |
| } | |
| if (bestMove.value === 1) { | |
| return bestMove; | |
| } | |
| } | |
| } | |
| return bestMove; | |
| }).contract(isBoard, isPlayerValue, isCellOrUndefined, isMoveAndValue); | |
| var bestMove = (function bestMove(board, movingPlayer) { | |
| if (isEmptyCellValue(getCellValue(board, 1, 1))) { | |
| return getCell(board, 1, 1); | |
| } else { | |
| return maxMove(board, movingPlayer, window.undefined).cell; | |
| } | |
| }).contract(isNotTerminalBoard, isPlayerValue, isEmptyCell); | |
| /* Optimal move AI with mistakes */ | |
| var bestMoveWithMistakes = (function bestMoveWithMistakes(board, movingPlayer) { | |
| if (Math.random() < .2) { | |
| return randomMove(board, movingPlayer); | |
| } else { | |
| return bestMove(board, movingPlayer); | |
| } | |
| }).contract(isNotTerminalBoard, isPlayerValue, isEmptyCell); | |
| /* App construction */ | |
| angular | |
| .module('TicTacToeApp', []) | |
| .controller( | |
| 'TicTacToeController', | |
| ['$scope', '$timeout', | |
| function($scope, $timeout) { | |
| $scope.board = makeEmptyBoard(); | |
| $scope.activePlayer = cellValues.x; | |
| $scope.isGameOver = false; | |
| $scope.winner = undefined; | |
| $scope.isTie = undefined; | |
| $scope.isX = isX; | |
| $scope.isO = isO; | |
| $scope.nextTurn = function nextTurn() { | |
| $scope.activePlayer = otherPlayer($scope.activePlayer); | |
| }; | |
| $scope.AIfunc = randomMove; | |
| $scope.AIs = [ | |
| { | |
| active: true, | |
| name: "easy", | |
| func: randomMove | |
| }, | |
| { | |
| active: false, | |
| name: "normal", | |
| func: bestMoveWithMistakes | |
| }, | |
| { | |
| active: false, | |
| name: "hard", | |
| func: bestMove | |
| } | |
| ]; | |
| $scope.setActiveAI = function setActiveAI(AI) { | |
| for (var i = 0; i < $scope.AIs.length; i+=1) { | |
| $scope.AIs[i].active = false; | |
| } | |
| AI.active = true; | |
| $scope.AIfunc = AI.func; | |
| $scope.resetGame(); | |
| }; | |
| $scope.clickCell = function clickCell(cell) { | |
| if (!$scope.isGameOver && isEmptyCell(cell) && isX($scope.activePlayer)) { | |
| cell.value = cellValues.x; | |
| if ($scope.gameOver()) { | |
| $scope.endGame(); | |
| } else { | |
| $scope.nextTurn(); | |
| $timeout($scope.takeAITurn, 500); | |
| } | |
| } | |
| }; | |
| $scope.takeAITurn = function() { | |
| $scope.AIfunc($scope.board, $scope.activePlayer).value = $scope.activePlayer; | |
| if ($scope.gameOver()) { | |
| $scope.endGame(); | |
| } else { | |
| $scope.nextTurn(); | |
| } | |
| }; | |
| $scope.gameOver = function gameOver() { | |
| return isTerminalBoard($scope.board); | |
| }; | |
| $scope.endGame = function endGame() { | |
| $scope.isGameOver = true; | |
| $scope.isTie = false; | |
| if (isBoardWonBy($scope.board, cellValues.x)) { | |
| $scope.winner = cellValues.x; | |
| } else if (isBoardWonBy($scope.board, cellValues.o)) { | |
| $scope.winner = cellValues.o; | |
| } else if (!isNotFullBoard($scope.board)) { | |
| $scope.isTie = true; | |
| } else { | |
| throw new Error("endGame method was called while board was not in a terminal state"); | |
| } | |
| if (!$scope.isTie) { | |
| for (var i = 0; i < 3; i += 1) { | |
| if (isRowWonBy($scope.board, $scope.winner, i)) { | |
| for (var j = 0; j < 3; j += 1) { | |
| getCell($scope.board, i, j).winner = true; | |
| } | |
| } | |
| if (isColumnWonBy($scope.board, $scope.winner, i)) { | |
| for (var j = 0; j < 3; j += 1) { | |
| getCell($scope.board, j, i).winner = true; | |
| } | |
| } | |
| } | |
| if (isTopLeftDiagonalWonBy($scope.board, $scope.winner)) { | |
| getCell($scope.board, 0, 0).winner = true; | |
| getCell($scope.board, 1, 1).winner = true; | |
| getCell($scope.board, 2, 2).winner = true; | |
| } | |
| if (isTopRightDiagonalWonBy($scope.board, $scope.winner)) { | |
| getCell($scope.board, 0, 2).winner = true; | |
| getCell($scope.board, 1, 1).winner = true; | |
| getCell($scope.board, 2, 0).winner = true; | |
| } | |
| } | |
| }; | |
| $scope.resetGame = (function resetGame() { | |
| $scope.board = makeEmptyBoard(); | |
| $scope.isGameOver = false; | |
| $scope.winner = undefined; | |
| $scope.tie = undefined; | |
| $scope.activePlayer = cellValues.x; | |
| }); | |
| }]); | |
| })(); |
| /* box-sizing */ | |
| html { | |
| box-sizing: border-box; | |
| font-family: 'Fredoka One', cursive; | |
| background: #1A1A1A; | |
| height: 100%; | |
| } | |
| *, *:before, *:after { | |
| box-sizing: inherit; | |
| } | |
| /* clearfix */ | |
| .group:after { | |
| content: ""; | |
| display: table; | |
| clear: both; | |
| } | |
| .tic-tac-toe { | |
| width: 400px; | |
| margin: 50px auto; | |
| } | |
| .board { | |
| height: 400px; | |
| } | |
| .turn-header { | |
| text-align: center; | |
| font-size: 300%; | |
| color: darkgrey; | |
| padding-bottom: 20px; | |
| } | |
| .row { | |
| height: 33.3333% | |
| } | |
| .row:not(:last-of-type) { | |
| border-bottom: solid 2px darkgrey; | |
| } | |
| .cell { | |
| float: left; | |
| width: 33.3333%; | |
| height: 100%; | |
| color: darkgrey; | |
| position: relative; | |
| font-size: 600%; | |
| } | |
| .cell:not(:last-of-type) { | |
| border-right: solid 2px darkgrey; | |
| } | |
| .cell-value { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| } | |
| .cell-player-won { | |
| animation-name: winner-glow; | |
| animation-duration: .5s; | |
| animation-iteration-count: infinite; | |
| } | |
| @keyframes winner-glow { | |
| 0% { | |
| color: darkgrey; | |
| } | |
| 50% { | |
| color: darkgreen; | |
| } | |
| 100% { | |
| color: darkgrey; | |
| } | |
| } | |
| .cell-player-lost { | |
| animation-name: loser-glow; | |
| animation-duration: .5s; | |
| animation-iteration-count: infinite; | |
| } | |
| @keyframes loser-glow { | |
| 0% { | |
| color: darkgrey; | |
| } | |
| 50% { | |
| color: darkred; | |
| } | |
| 100% { | |
| color: darkgrey; | |
| } | |
| } | |
| .button-footer { | |
| margin: 20px auto; | |
| text-align: center; | |
| } | |
| button { | |
| border: solid 2px #666; | |
| border-radius: 5px; | |
| font-family: inherit; | |
| color: darkgrey; | |
| padding: 5px 10px; | |
| font-size: 150%; | |
| background: #333; | |
| } | |
| button.large { | |
| font-size: 200%; | |
| } | |
| button:disabled { | |
| background: #222; | |
| color: #444; | |
| border-color: #444; | |
| } | |
| button:not(:last-of-type) { | |
| border-right: 0px; | |
| border-top-right-radius: 0px; | |
| border-bottom-right-radius: 0px; | |
| margin-right: 0px; | |
| } | |
| button:not(:first-of-type) { | |
| border-top-left-radius: 0px; | |
| border-bottom-left-radius: 0px; | |
| margin-left: 0px; | |
| } |
Simple Tic-Tac-Toe game developed with heavy use of design-by-contract
A Pen by Jack Firth on CodePen.