Game.js
In our src
folder we create a new file called Game.js
and populate it with the following code;
We wont be going to explain every line in detail but will highlight some parts:
import { Lightning } from "@lightningjs/sdk";
export default class Game extends Lightning.Component {
static _template(){
return {
Game:{
PlayerPosition:{
rect: true, w: 250, h: 250, color: 0x40ffffff,
x: 425, y: 125
},
Field:{
x: 400, y: 100,
children:[
{rect: true, w:1, h:5, y:300},
{rect: true, w:1, h:5, y:600},
{rect: true, h:1, w:5, x:300, y:0},
{rect: true, h:1, w:5, x:600, y:0}
]
},
Markers:{
x: 400, y: 100
},
ScoreBoard:{ x: 100, y: 170,
Player:{
text:{text:'Player 0', fontSize:29, fontFace:'Pixel'}
},
Ai:{ y: 40,
text:{text:'Computer 0', fontSize:29, fontFace:'Pixel'}
}
}
},
Notification:{
x: 100, y:170, text:{fontSize:70, fontFace:'Pixel'}, alpha: 0
}
}
}
}
We've added a Game
component which acts as a wrapper for the Game board an score board so it will be easy
to hide all the contents at once.
- PlayerPosition, this is a focus indicator of which tile the player currently is
- Field, the outlines of the game field
- Markers, the placed [ X ] / [ 0 ]
- ScoreBoard, the current score for player and computer
- Notification, the endgame notification (player wins, tie etc), in a real world app we probably would move the Notification handler to a different (higher) level so we multiple component can make use of it.
It's also possible to (instead instancing a component via type) populate the children
within the template.
This will populate the Field
Component with 5 lines (rectangles) we also draw two 1px by 5px component and 2 components
5px by 1px components.
Field:{
x: 400, y: 100,
children:[
{rect: true, w:1, h:5, y:300},
{rect: true, w:1, h:5, y:600},
{rect: true, h:1, w:5, x:300, y:0},
{rect: true, h:1, w:5, x:600, y:0}
]
}
Let's start adding some logic, we start by adding a new lifecycle event called construct
_construct(){
// current player tile index
this._index = 0;
// computer score
this._aiScore = 0;
// player score
this._playerScore = 0;
}
Next lifecycle event we add is active
this will be called when a component visible
property is true,
alpha
higher then 0 and positioned in the renderable screen.
_active(){
this._reset();
// we iterate over the outlines of the field and do a nice
// transition of the width / height, so it looks like the
// lines are being drawn realtime.
this.tag("Field").children.forEach((el, idx)=>{
el.setSmooth(idx<2?"w":"h", 900, {duration:0.7, delay:idx*0.15})
})
}
The setSmooth
function creates a transition for a give property with the provided value:
Look in the documentation to read more about smoothing.
We add the _reset()
method which fills all available tiles with e
for empty, render the tiles and change the state back to root state.
For the tile we use an array of 9 elements that we can use to all sorts of logic with (rendering / checking for winner / decide next move for the computer etc..)
_reset(){
// reset tiles
this._tiles = [
'e','e','e','e','e','e','e','e','e'
];
// force render
this.render(this._tiles);
// change back to rootstate
this._setState("");
}
Next is our render
method that accepts a set of tiles and draw some text based on the tile value.
e => empty / x => Player / 0 => computer
render(tiles){
this.tag("Markers").children = tiles.map((el, idx)=>{
return {
x: idx%3*300 + 110,
y: ~~(idx/3)*300 + 90,
text:{text:el === "e"?'':`${el}`, fontSize:100},
}
});
}
Now that we have a good setup for rendering tiles and showing outlines on active
we can proceed to implement remote control handling.
Since we're working with a 3x3 playfield we check (on remotecontrol up
) if the new index we want to focus on is larger or
equal then zero, if we so we call the (to be implemented) setIndex()
function.
_handleUp(){
let idx = this._index;
if(idx-3 >= 0){
this._setIndex(idx-3);
}
}
The logic for pressing down
is mostly equal to the up
but we check if the new index
is not larger then the amount of available tiles.
_handleDown(){
let idx = this._index;
if(idx+3 <= this._tiles.length - 1){
this._setIndex(idx+3);
}
}
We don't want continues navigation so we check if we're on the most left tile of a column, if so
we block navigation. Lets say we're on the second row, second column (which is tile index 4)
and we press left
, we check if the remainder is truthy 4%3 === 1
and call setIndex
with the new index.
If we're on the second row, first column (which is tile index 3) the remainder of 3%3
is 0 which
so we don't match the condition and will not call setIndex()
.
_handleLeft(){
let idx = this._index;
if(idx%3){
this._setIndex(idx - 1);
}
}
The logic for pressing right
is mostly the same but check if the index
of the new tile
where we're navigating to has a remainder.
_handleRight(){
const newIndex = this._index + 1;
if(newIndex%3){
this._setIndex(newIndex);
}
}
And the setIndex()
function which does a transition of the PlayerPosition
component to the new tile
and stores the new index for future use.
_setIndex(idx){
this.tag("PlayerPosition").patch({
smooth:{
x: idx%3*300 + 425,
y: ~~(idx/3)*300 + 125
}
});
this._index = idx;
}
If we run our game you can see that the outlines of the Field
will be drawn and we can navigate over the
game tiles. The next thing we need to do is the actual capturing of a tile by placing your marker on remote enter
press.
On enter
we first check if we're on an empty tile, if so we place our X
marker and if the function's return value
is true
we set the Game
component in a Computer
state (which means it's the computers turn to play)
_handleEnter(){
if(this._tiles[this._index] === "e"){
if(this.place(this._index, "X")){
this._setState("Computer");
}
}
}
The place()
function will be called (as stated above) when a user presses ok
or enter
on the remote control:
- we update the tile value
- we render the new field
- We check if we have a winner (We will go over the Utils in a short moment)
- If we have a Winner we set the app to
End
state andWinner
sub state - and return false, so the _handleEnter logic will not go to
Computer
state - If we don't have a winner we return true so the
Game
can go toComputer
state
place(index, marker){
this._tiles[index] = marker;
this.render(this._tiles);
const winner = Utils.getWinner(this._tiles);
if(winner){
this._setState("End.Winner",[{winner}]);
return false;
}
return true;
}
In a real world game we would implement the logic of checking for a winner a changing to Computer state on a different level to make the app a bit more robust.
Next thing that we're going to do is model the statemachine. The first state that we're going to add is the Computer
state which means it's the computers turn to play.
in the $enter()
hook we
- We calculate the new position the computer can move to
- If the
return
value is-1
it means there are no possible moves left and we force theGame
Component in aTie
state because we don't have a winner - We create a random timeout to give a player a feeling that it's really playing against a human opponent.
- We hide the
PlayerPosition
indicator - When the timeout expires we call
place()
with th0
marker and go back to the root state `_setState("")
By adding _captureKey()
we make that every keypress will be captured, but you can still perform some keyCode
specific logic.
When we $exit()
the Computer
state we show the PlayerPosition
indicator again, the player knows it is its turn to play.
static _states(){
return [
class Computer extends this {
$enter(){
const position = Utils.AI(this._tiles);
if(position === -1){
this._setState("End.Tie");
return false;
}
setTimeout(()=>{
if(this.place(position,"0")){
this._setState("");
}
}, ~~(Math.random()*1200)+200);
this.tag("PlayerPosition").setSmooth("alpha",0);
}
// make sure we don't handle
// any keypresses when the computer is playing
_captureKey({keyCode){ }
$exit(){
this.tag("PlayerPosition").setSmooth("alpha",1);
}
}
]
}
Next state that we're adding is the End
state with the sub state Winner
and Tie
.
First we add some shared logic between the Winner
and Tie
state.
We wait for a user to press enter / ok
in the End
state and then we reset the Game
(in reset() we also go back to root state)
so this will make sure the $exit()
hook will be called and that's where we show the complete Game
component again
and we hide the notification.
static _states(){
return [
class Computer extends this {
// we hide the code for now
},
class End extends this{
_handleEnter(){
this._reset();
}
$exit(){
this.patch({
Game:{
smooth:{alpha:1}
},
Notification: {
text:{text:''},
smooth:{alpha:0}
}
});
}
static _states(){
return [
]
}
}
]
}
We add a new _states
object so we can start adding sub states.
When we $enter()
the End.Winner
state we
- Check if the winner is
X
so we increase to the player score - If not, we increase the computer score
- Next we do a big patch of the template in which we
hide the Game field, updated the text of the scoreboard, update the
Notification
text and show theNotification
Component
When we $enter()
the End.Tie
state we
- Hide the Game field
- Update the
Notification
text - And show the
Notification
Component
static _states(){
return [
class Computer extends this {
// we hide the code for now
},
class End extends this{
// we hide the code for now
static _states(){
return [
class Winner extends this {
$enter(args, {winner}){
if(winner === 'X'){
this._playerScore+=1;
}else{
this._aiScore+=1;
}
this.patch({
Game:{
smooth:{alpha:0},
ScoreBoard:{
Player:{text:{text:`Player ${this._playerScore}`}},
Ai:{text:{text:`Computer ${this._aiScore}`}},
}
},
Notification: {
text:{text:`${winner==='X'?`Player`:`Computer`} wins (press enter to continue)`},
smooth:{alpha:1}
}
});
}
},
class Tie extends this {
$enter(){
this.patch({
Game: {
smooth: {alpha: 0}
},
Notification: {
text:{text:`Tie :( (press enter to try again)`},
smooth:{alpha:1}
}
});
}
}
]
}
}
]
}
Now that we have modeled most of our game components it's time to start adding the the logic for the Computer
controlled player.
GameUtils.js
Inside our src
folder we add a lib
folder and create a new file GameUtils.js
and add the following function.
We test the current state of the game against a set of winning patterns by normalizing the actual pattern values and testing them against a provided regular expression.
const getMatchingPatterns = (regex, tiles) => {
const patterns = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6],
[1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]
];
return patterns.reduce((sets, pattern) => {
const normalized = pattern.map((tileIndex) => {
return tiles[tileIndex];
}).join("");
if (regex.test(normalized)) {
sets.push(pattern);
}
return sets;
}, []);
};
Next we add getFutureWinningIndex
which checks if there is a potential upcoming winning move
for itself (computer) or it's opponent (the player). We give priority to returning the index
for the computer's winning move over blocking a potential win for the player.
const getFutureWinningIndex = (tiles) => {
let index = -1;
const player = /(ex{2}|x{2}e|xex)/i;
const ai = /(e0{2}|0{2}e|0e0)/i;
// since we're testing for ai we give prio to letting ourself win
// instead of blocking the potential win for the player
const set = [
...getMatchingPatterns(player, tiles),
...getMatchingPatterns(ai, tiles)
];
if (set.length) {
set.pop().forEach((tileIndex) => {
if (tiles[tileIndex] === 'e') {
index = tileIndex;
}
});
}
return index;
};
We finished all the logic for the Game
and now it's time to test it (something we normally do during development ;) )
Run lng dev
and you should be able to play a well deserved game of tic-tac-toe against an AI opponent.
For more information on the CLI please refer to Run, Test & Deploy.
If you want to take a look at all the raw files, please take a look at the app's Github Repository