You can see an index of all the posts in this series: go to index.
If you want to view my progress to date, you can find the files from the end of the previous post here: Improving the AI player
In the previous post, I upgraded the AI player to use a combination of a variant of my initial AI routine and the popular Minimax algorithm. The AI player at its hardest level now presents a serious challenge, while still being easy to beat at the lower levels.
With that done I have a completely playable game, but there are a couple of issues. Firstly, it looks scrappy:
The user interface as it looks currently.
Secondly, human players can only make a move by entering the number of the cell they want to play to. Ideally I want a player to have a choice between that and simply clicking inside the cell they want to play to.
I’ll deal with the first problem, but just to set your expectations, I’m no graphic artist, so, while I can make improvements, the end result won’t look as professional as it could do in the hands of a good artist/interface designer.
I’m going to largely use CSS to improve the look of the game. Before I do that, I have to make some changes to index.html to add in some extra classes that I’ll need to reference.
Most of this is simply providing new styles for elements that were completely unstyled in the previous version. Things worth noting are the use of flexbox for the game setup screen (lines 30-34), which allows the setup controls for the two players to sit side by side if there’s room, and the media queries (89 – 103) that adjust the size of the margins either side of the UI depending on overall screen width.
Here’s an example of the end result:
The upgraded look of the game
As I said, it’s not up to the standard that a pro designer could achieve, but I hope you agree it’s a vast improvement on the vanilla interface.
The final thing for me to fix is the modes of play available to human players. I don’t want to remove the ability to play by entering the number of a cell in the box and clicking the Play button, because that keeps the game accessible for players who can’t use a mouse or mouse equivalent. That being said, I do want players to be able to use a mouse to select a cell as an alternative means of input.
To achieve this, the first thing I need is to know where a player is clicking on the board. This entails a further change to index.html:
The change here is that I’ve added a unique id to each of the “cell_container” divs. Note that these are numbered from 1 to 9 rather than 0 to 8 as the “cell_content_ divs are. You’ll see why I made that choice in a moment.
Now that I can identify those containers individually, I can upgrade ui.js which contains the code for the UI class:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// UI class
// Encapsulates the user interface for Tic Tac Toe
// UI class
// Encapsulates the user interface for Tic Tac Toe
import { Game} from './game.js';
class Ui {
#controls = null;
#makeMoveHandler = null;
#playerMoveEnabled = false;
constructor(quitHandler, newGameHandler, makeMoveHandler) {
this.#controls = {
'compStrengthO': document.querySelector('#compStrengthO'),
'compStrengthX': document.querySelector('#compStrengthX'),
'drawMsg': document.querySelector('#drawMsg'),
'inPlayControls': document.querySelector('#inPlayControls'),
'new': document.querySelector('#new'),
'newGameControls': document.querySelector('#newGameControls'),
'play': document.querySelector('#play'),
'playerOComputer': document.querySelector('#playerOComputer'),
'playerXComputer': document.querySelector('#playerXComputer'),
'posWarning': document.querySelector('#posWarning'),
'quit': document.querySelector('#quit'),
'resultMsg': document.querySelector('#resultMsg'),
'selectedCell': document.querySelector('#selectedCell'),
'turn': document.querySelector('#turn'),
'valWarning': document.querySelector('#valWarning'),
'winMsg': document.querySelector('#winMsg'),
'winner': document.querySelector('#winner'),
'board': document.querySelector('#board')
}
this.#makeMoveHandler = makeMoveHandler;
this.#controls['quit'].addEventListener('click', quitHandler);
this.#controls['new'].addEventListener('click', newGameHandler);
this.#controls['play'].addEventListener('click', makeMoveHandler);
let that = this;
this.#controls['board'].addEventListener('click', function(event){that.selectCell(event, that)});
}
// Draws the board
drawBoard(board) {
for(let i = 0; i < board.length; i++) {
document.querySelector('#cell' + i).innerHTML = board[i];
}
}
// Displays the mark of the player whose turn it is to play
displayTurn(mark) {
this.#controls['turn'].innerHTML = mark;
}
// Displays the winner as the given mark
displayWinner(mark) {
this.#controls['winner'].innerHTML = mark;
this.#showControl(this.#controls['winMsg']);
}
// Enables buttons so that a human player can make a move
requestMove() {
document.querySelector('#play').disabled = false;
document.querySelector('#quit').disabled = false;
this.#playerMoveEnabled = true;
}
// Shows the listed controls
showControls(controls) {
this.applyControlFunction(controls, this.#showControl);
}
// Show control
#showControl(control) {
control.classList.remove('hidden');
}
// Hide the listed controls
hideControls(controls) {
this.applyControlFunction(controls, this.#hideControl);
}
// Hide control
#hideControl(control) {
control.classList.add('hidden');
}
// Disable the listed controls
disableControls(controls) {
this.applyControlFunction(controls, this.disableControl);
}
// Disable control
disableControl(control) {
control.disabled = true;
}
// Clear controls
clearControls(controls) {
this.applyControlFunction(controls, this.clearControl);
}
// Clear control
clearControl(control) {
control.value = '';
}
// Apply function to controls
applyControlFunction(controls, funcToApply) {
for (let i = 0; i < controls.length; i++) {
funcToApply(this.#controls[controls[i]]);
}
}
// Stops a player making moves with a mouse (or equivalent)
disablePlayerMove()
{
this.#playerMoveEnabled = false;
}
// Validate numeric input
isValidNumber(control, min, max) {
let value = this.getNumericValue(control);
return (!(isNaN(value) || value < min || value > max));
}
// Gets a numeric value for a control
getNumericValue(control) {
return parseInt(this.#controls[control].value);
}
// Returns true if the indicated control is checked
isChecked(control) {
return this.#controls[control].checked;
}
selectCell(event, that) {
if (that.#playerMoveEnabled) {
let clickedElement = event.target;
while (!clickedElement.classList.contains('cell_container')) {
clickedElement = clickedElement.parentNode;
}
let elementID = clickedElement.id;
let selectedCell = elementID.charAt(elementID.length-1);
that.#controls['selectedCell'].value = selectedCell;
that.#makeMoveHandler();
}
}
}
export { Ui };
// UI class
// Encapsulates the user interface for Tic Tac Toe
import { Game} from './game.js';
class Ui {
#controls = null;
#makeMoveHandler = null;
#playerMoveEnabled = false;
constructor(quitHandler, newGameHandler, makeMoveHandler) {
this.#controls = {
'compStrengthO': document.querySelector('#compStrengthO'),
'compStrengthX': document.querySelector('#compStrengthX'),
'drawMsg': document.querySelector('#drawMsg'),
'inPlayControls': document.querySelector('#inPlayControls'),
'new': document.querySelector('#new'),
'newGameControls': document.querySelector('#newGameControls'),
'play': document.querySelector('#play'),
'playerOComputer': document.querySelector('#playerOComputer'),
'playerXComputer': document.querySelector('#playerXComputer'),
'posWarning': document.querySelector('#posWarning'),
'quit': document.querySelector('#quit'),
'resultMsg': document.querySelector('#resultMsg'),
'selectedCell': document.querySelector('#selectedCell'),
'turn': document.querySelector('#turn'),
'valWarning': document.querySelector('#valWarning'),
'winMsg': document.querySelector('#winMsg'),
'winner': document.querySelector('#winner'),
'board': document.querySelector('#board')
}
this.#makeMoveHandler = makeMoveHandler;
this.#controls['quit'].addEventListener('click', quitHandler);
this.#controls['new'].addEventListener('click', newGameHandler);
this.#controls['play'].addEventListener('click', makeMoveHandler);
let that = this;
this.#controls['board'].addEventListener('click', function(event){that.selectCell(event, that)});
}
// Draws the board
drawBoard(board) {
for(let i = 0; i < board.length; i++) {
document.querySelector('#cell' + i).innerHTML = board[i];
}
}
// Displays the mark of the player whose turn it is to play
displayTurn(mark) {
this.#controls['turn'].innerHTML = mark;
}
// Displays the winner as the given mark
displayWinner(mark) {
this.#controls['winner'].innerHTML = mark;
this.#showControl(this.#controls['winMsg']);
}
// Enables buttons so that a human player can make a move
requestMove() {
document.querySelector('#play').disabled = false;
document.querySelector('#quit').disabled = false;
this.#playerMoveEnabled = true;
}
// Shows the listed controls
showControls(controls) {
this.applyControlFunction(controls, this.#showControl);
}
// Show control
#showControl(control) {
control.classList.remove('hidden');
}
// Hide the listed controls
hideControls(controls) {
this.applyControlFunction(controls, this.#hideControl);
}
// Hide control
#hideControl(control) {
control.classList.add('hidden');
}
// Disable the listed controls
disableControls(controls) {
this.applyControlFunction(controls, this.disableControl);
}
// Disable control
disableControl(control) {
control.disabled = true;
}
// Clear controls
clearControls(controls) {
this.applyControlFunction(controls, this.clearControl);
}
// Clear control
clearControl(control) {
control.value = '';
}
// Apply function to controls
applyControlFunction(controls, funcToApply) {
for (let i = 0; i < controls.length; i++) {
funcToApply(this.#controls[controls[i]]);
}
}
// Stops a player making moves with a mouse (or equivalent)
disablePlayerMove()
{
this.#playerMoveEnabled = false;
}
// Validate numeric input
isValidNumber(control, min, max) {
let value = this.getNumericValue(control);
return (!(isNaN(value) || value < min || value > max));
}
// Gets a numeric value for a control
getNumericValue(control) {
return parseInt(this.#controls[control].value);
}
// Returns true if the indicated control is checked
isChecked(control) {
return this.#controls[control].checked;
}
selectCell(event, that) {
if (that.#playerMoveEnabled) {
let clickedElement = event.target;
while (!clickedElement.classList.contains('cell_container')) {
clickedElement = clickedElement.parentNode;
}
let elementID = clickedElement.id;
let selectedCell = elementID.charAt(elementID.length-1);
that.#controls['selectedCell'].value = selectedCell;
that.#makeMoveHandler();
}
}
}
export { Ui };
The first thing to note here is that there are two new private properties for the UI class on lines 8 and 9:
#makeMoveHandler holds a reference to the function that is passed in to handle player moves, because the UI will need to call this directly when a click on the board is detected. This is initialised on line 32.
#playerMoveEnabled is a flag that determines whether clicks on the board should be actioned or ignored.
You can see that the board has now been added to the array of controls on line 30. This is because the board is now interactive rather than just being a display feature. On lines 36-37 I set up a handler for clicks on the board.
One tricky thing about event handlers is that they change the meaning of this. For normal methods in the UI class, this will reference the instance of the class in which the code is running. However, in an event handler, this references the object that triggered the event. Because I need to be able to reference the class instance within the event handler, before I set up the event handler, I create a variable that on line 36, which is a copy of this while it is referencing the class instance. Then in the event handler on line 37, I pass the event object and that to the selectCell method, which processes the player’s click.
The combination of lines 36 and 37 creates something called a closure. A closure is a mechanism that gives an inner function access to an outer function’s scope. This means, when the event handler is called, it still has access to the local variable that. Closures can be a bit tricky to get your head round, but they are extremely useful in situations like this.
So let’s take a look at the selectCell method and see what that does. The first thing it does is check to see if it should do anything at all. The condition on line 135 stops the code from running if #playerMoveEnabled is false. This is necessary because there will be times, e.g. when a game has finished and the player has yet to start a new game, when we want to ignore any clicks on the board.
In an event handler, event.target references the object that triggered the event. The problem I have here, is that this could be any one of:
the “cell_content” div that contains a player’s mark, i.e. “O” or “X”
the “cell_id” div that contains the number in the top-left corner that identifies the cell
or the “cell_container” div that contains both of the above divs plus the surrounding space
To get round this, I create a local variable called clickedElement on line 136 which references event.target in the first instance. I then set up a loop on lines 138 to 140. This interrogates clickedElement to see if it is a”cell_container” div. If it is, we’re done. If not, I set clickedElement to be the parent of the current object and try again.
One thing to note about this is that, with the current html, if event.target is not a “cell_container” div then its parent always will be, so I could have replaced the loop with:
if (!clickedElement.classList.contains('cell_container') {
clickedElement = clickedElement.parentNode;
}
if (!clickedElement.classList.contains('cell_container') {
clickedElement = clickedElement.parentNode;
}
However, the loop is a little more robust in that, if I were to add a further layer inside the cells at some point in the future, it would still work, whereas the simple conditional check might not.
Once I know I have the “cell_container” div, I simply get the final character of the id. This gives me a number between 1 and 9. Now all I have to do is put that number into the text box and call the function referenced by #makeMoveHandler, which effectively simulates clicking the Play button. You can see now why I numbered the cell container ids from 1 to 9 rather than from 0 to 8!
Doing things this way means the player can use either the original method of entering a cell number or they can just click on the cell and they can even switch methods mid game, since both forms of input are always active.
Note the disablePlayerMove method, which sets #playerMoveEnabled to false. This is used by tictactoe.js to prevent mouse selection of cells when it is not a human player’s turn, or once the game has ended (you might recall that cell selection with the text box is prevented because that control gets hidden when the game ends):
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// if valid cell has been entered, pass the move to the game for processing
if(valid){
ui.disablePlayerMove();
ui.disableControls(['play', 'quit']);
ui.clearControls(['selectedCell']);
processOutcome(game.processMove(selectedCell));
}
}
// Called when the game ends. Displays the winner or a draw message and the
// if valid cell has been entered, pass the move to the game for processing
if (valid) {
ui.disablePlayerMove();
ui.disableControls(['play', 'quit']);
ui.clearControls(['selectedCell']);
processOutcome(game.processMove(selectedCell));
}
}
// Called when the game ends. Displays the winner or a draw message and the
// controls to start a new game
function quit(outcome) {
ui.hideControls(['winMsg', 'drawMsg']);
if(outcome == 'O' || outcome == 'X') {
ui.displayWinner(outcome);
} else {
ui.showControls(['drawMsg']);
}
ui.disablePlayerMove();
ui.hideControls(['inPlayControls']);
ui.showControls(['resultMsg', 'newGameControls']);
}
// if valid cell has been entered, pass the move to the game for processing
if (valid) {
ui.disablePlayerMove();
ui.disableControls(['play', 'quit']);
ui.clearControls(['selectedCell']);
processOutcome(game.processMove(selectedCell));
}
}
// Called when the game ends. Displays the winner or a draw message and the
// controls to start a new game
function quit(outcome) {
ui.hideControls(['winMsg', 'drawMsg']);
if(outcome == 'O' || outcome == 'X') {
ui.displayWinner(outcome);
} else {
ui.showControls(['drawMsg']);
}
ui.disablePlayerMove();
ui.hideControls(['inPlayControls']);
ui.showControls(['resultMsg', 'newGameControls']);
}
You might have noticed that there is no corresponding enablePlayerMove method. This is because mouse control is automatically re-enabled by the UI class when the requestMove method is called (see line 62 of ui.js shown earlier in the post).
So that’s it for Tic Tac Toe. For my next project, I’m going to create a real time game using the canvas API and a very useful function of the window object called requestAnimationFrame.
Be First to Comment