Lights Out

Create the Lights Out game with Ember and D3!

Two months ago, I got to use D3 to help visualize the steps for getting something approved and interact with these steps. I affectionally call my code Roadmap.

roadmap-screenshot
An everyday example.

I had a great time because (1) the way we draw things in D3 reminded me of Matlab, whom I will always love; (2) I got to re-learn graph theory (fun fact: all digraphs have a topological sort—being able to sort is always nice and being able to in linear time even more so); and (3) I got to play with Post-it notes to make up an algorithm for drawing graphs on desktop and mobile.

roadmap-planning.jpg
Office supplies to the rescue.

I also had a hard time finding out how to use D3 in Ember. Because Ember is rather a rare species, there was only 1 tutorial that helped me understand what I needed to do. Thanks to that tutorial, I was able to create a prototype of Roadmap over a weekend, use Ember’s mixin feature effectively, and write extensive tests to show that Roadmap really works. In case you want to learn Ember and D3, let me show you what I learned.

We will create a game from the 90s called Lights Out. The game consists of a 5 x 5 grid of lights, which you can press like buttons. When the game starts, some of the lights are on. When you press a light, that light and its adjacent ones—top, right, bottom, and left, if they exist—are switched from on to off, or off to on. The goal of the game is for you to turn off all lights, preferably in as few moves as possible.

1. Setup

First, let’s check that you have GitNode, and Ember. Any recent version should do; I used Git 2.16.2, Node 9.6.1, and Ember 2.18.0.

(Ember should be 2.16 or higher because we will use ember-d3, which takes advantage of named imports. The folder structure is going to look different if you have Ember 3.0 or higher and are using module unification.)

git --version
node -v
ember -v

Ember has a command-line interface (CLI) that we can use to create and manage a project. Let’s create a project called lights-out and head inside the directory.

ember new lights-out
cd lights-out

For convenience, we will denote the path to the new directory

HOME_DIRECTORY/lights-out

by a single forward slash, /.

Once inside /, we install 3 packages: d3, ember-d3, and ember-cli-sass.

npm install d3
ember install ember-d3 ember-cli-sass

The first two help us use D3 in Ember, and the last one lets us use Sass for styling our app. Note, we can import D3 with ember-browserify, too. I recommend ember-d3 because we can use named imports and whitelist packages to reduce the build size.

a. D3

We will end up using two packages in D3, called d3-scale and d3-selection. In the configuration file, environment.js, we can tell Ember to use only these two.

'use strict'

module.exports = function(environment) {
    let ENV = {
        [...]

        APP: {
            // Here you can pass flags/options to your application instance
            // when it is created
        },

        'ember-d3': {
            only: [
                'd3-scale', 'd3-selection',
                // Dependencies of d3-scale
                'd3-array', 'd3-collection', 'd3-color', 'd3-format', 'd3-interpolate', 'd3-time', 'd3-time-format'
            ]
        }
    };

    [...]
};

Simple, right? Unfortunately, ember-d3 doesn’t know if there are additional packages that we need to meet dependencies. You will need to manually run ember server and check the Console to find out if there are missing dependencies. For this tutorial, we need to include 7 more packages to be able to use d3-scale.

b. Sass

We will use Sass and Google Fonts for styling. First, let’s rename the file app.css to app.scss:

mv app/styles/app.css app/styles/app.scss

Then, copy-paste this code, which will make our app look nice:

body {
    background-color: #322b33;
    font-family: 'Patrick Hand SC', cursive;
    font-size: 5vw;
    letter-spacing: 0.025vw;

    #message {
        padding: 0.05vw 0;
        color: #e8e5ce;
        text-align: center;
    }

    #game {
        .buttons {
            &:hover {
                cursor: pointer;
            }
        }
    }
}

@media (max-width: 840px) {
    #message {
        font-size: 7.5vw;
    }
}

@media (max-width: 600px) {
    #message {
        font-size: 10vw;
    }
}

Finally, we include the custom font in index.html.

<!DOCTYPE html>
<html>
    <head>
        [...]

        {{content-for "head"}}

        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Patrick+Hand+SC">
        <link rel="stylesheet" href="{{rootURL}}assets/vendor.css">
        <link rel="stylesheet" href="{{rootURL}}assets/lights-out.css">
        <link rel="icon" href="/assets/favicon.png">

        {{content-for "head-footer"}}

        [...]

c. Ember

We will create a component called lights-out, which displays the game and a message to the player. In addition, we will create a mixin called responsive, which ensures that our D3 is mobile responsive.

In general, a mixin lets us practice inheritance (reuse code). I’ll take my Roadmap as an example. Roadmap is a mixin that just draws a graph. It doesn’t know what the graph is for. On the other hand, you may want to draw a graph for a very specific purpose. If so, in your component, you can extend my Roadmap and override its properties (variables and methods) to achieve that purpose.

Here, because at some time, I want to do a live demo of this tutorial, I use the mixin to hide details and save time. All D3 and game-related code (the interesting stuff) will be in the component, while all non-D3, fine-detail will be hidden in the mixin.

We can create the component and mixin as follows. Note, the pod option places the Handlebars and JavaScript files (the template and component) together in a folder, for ease of access. This is the default behavior in module unification.

ember generate component lights-out --pod
ember generate mixin responsive

Let’s head to the template, then create placeholders for the game and message to the user:

<div id='message'></div>

<div id='game'></div>

We can also go ahead and include the mixin in our component:

import Component from '@ember/component';
import ResponsiveMixin from 'lights-out/mixins/responsive';

export default Component.extend(ResponsiveMixin, {
});

Finally, we can tell our app to show the lights-out component:

{{lights-out}}

d. Check-in

Whew, let’s check where we are by running ember server. Open your browser and go to localhost:4200. You should see an aubergine background.

step-1
If everything went well, you should see a blank screen (heh). Hey, it’s aubergine.

We will learn more about Ember and D3 in the next section. If you want to use my code up to this moment, feel free to do so:

git clone git@github.com:ijlee2/lights-out.git
cd lights-out
npm install
git checkout step-1
ember server

2. Lights Out

Now that we have set up an Ember-D3 project, let’s create our game.

a. init

First, let’s test the Ember water by showing a simple message to the user. Modify the lights-out template as follows:

<div id='message'>
    {{#if areLightsOut}}
        Congratulations!
    {{else}}
        Number of Moves: {{numMoves}}
    {{/if}}
</div>

<div id='game'></div>

Even if this is your first time using Handlebars, you probably get the gist. In the lights-out component, there will be two variables, areLightsOut and numMoves. We can use an if-else statement to either congratulate the player or show how many moves they have made.

Let’s take a look at the component:

import Component from '@ember/component';
import ResponsiveMixin from 'lights-out/mixins/responsive';

export default Component.extend(ResponsiveMixin, {
    init() {
        this._super(...arguments);

        // Set game properties
        this.set('numButtons', {x: 5, y: 5});
        this.set('numMoves', 0);
    },
});

We can initialize variables in init, one of Ember’s lifecycle methods. We are saying that, when the component is created, the game has 5 buttons across each axis, and that the user hasn’t made a move yet. In Ember, we can use this.set and its counterpart this.get to pass variables between methods, listen to changes in their values, and display these values in the template.

As an aside, this can be a source of confusion in JavaScript. From line 4, we can see that this refers to the lights-out component because the component is what we will export. In Ember, we have to call this._super in init. Naturally, this means, call the component’s parent’s init. Again from line 4, we see that the component’s parent is ResponsiveMixin. Thus, the mixin’s init is called first, then the component’s init continues by setting the game properties.

With these changes, we now see a message on the screen:

step-2a-part1
We see how many moves the player has made.

Next, let’s initialize the buttons, which have coordinates and light status as their properties. Since we have a grid of buttons, it is natural to consider a 2D array of buttons.

import Component from '@ember/component';
import ResponsiveMixin from 'lights-out/mixins/responsive';

export default Component.extend(ResponsiveMixin, {
    init() {
        this._super(...arguments);

        // Set game properties
        this.set('numButtons', {x: 5, y: 5});
        this.set('numMoves', 0);

        // Create an array of buttons
        let buttons = [];

        for (let i = 0; i < this.get('numButtons.y'); i++) {
            let rowOfButtons = [];

            for (let j = 0; j < this.get('numButtons.x'); j++) {
                rowOfButtons.push({
                    coordinates: {x: j, y: i},
                    isLightOn: false
                });
            }

            buttons.push(rowOfButtons);
        }

        this.set('buttons', buttons);
    },
});

We see that each button is a JavaScript object, which contains the button’s coordinates and light status. With these, we will know where to draw the buttons and when the game is finished.

buttons
A layout of our buttons. Note, a button’s coordinates refer to the top-left corner.

Finally, let’s check that the light statuses are correct. Try changing the template to:

<div id='message'>
    {{#if areLightsOut}}
        Congratulations!
    {{else}}
        Number of Moves: {{numMoves}}
    {{/if}}

    {{#each buttons as |rowOfButtons|}}
        <div>
            {{#each rowOfButtons as |button|}}
                <span>{{if button.isLightOn '1' '0'}}</span>
            {{/each}}
        </div>
    {{/each}}
</div>

<div id='game'></div>

We have used a double for-loop and an if-else statement to show the light status as text. Since all lights are currently off, we will see twenty-five 0’s. If we change the initial state of isLightOn to true, we will see twenty-five 1’s.

step-2a-part2
We see the correct light status.

Since we will use D3 to draw buttons and lights, make sure to remove the for-loops from the template before proceeding to the next step.

b. responsive

Recall that I wanted to use mixin to save time in demo. Go ahead and copy-paste this code:

// Ember-related packages
import { computed } from '@ember/object';
import Mixin from '@ember/object/mixin';
import { debounce } from '@ember/runloop';

// Miscellaneous
import $ from 'jquery';

const MAX_MOBILE_SCREEN_SIZE = 600;
const MAX_TABLET_SCREEN_SIZE = 840;

export default Mixin.create({
    init() {
        this._super(...arguments);

        this.set('elementIdentifier', '#game');
    },

    didInsertElement() {
        this._super(...arguments);

        this.addResizeListener();
    },

    willDestroyElement() {
        this.removeResizeListener();
    },


    /*************************************************************************************

        Create a responsive window

    *************************************************************************************/
    // Step 1. Set the width of the container. Then, scale everything relative to the
    // width of the container.
    containerWidth: computed('elementIdentifier', function() {
        return this.$(this.get('elementIdentifier')).width();
    }),

    normalizationFactor: computed('containerWidth', function() {
        const containerWidth = this.get('containerWidth');

        if (containerWidth < MAX_MOBILE_SCREEN_SIZE) return containerWidth / 576;
        else if (containerWidth < MAX_TABLET_SCREEN_SIZE) return containerWidth / 896;
        else return containerWidth / 1280;
    }),

    // Step 2. Set the button size
    buttonSize: computed('normalizationFactor', function() {
        return 100 * this.get('normalizationFactor');
    }),

    // Step 3. Set the width and height of the board
    width: computed('numButtons.x', 'buttonSize', function() {
        return this.get('numButtons.x') * this.get('buttonSize');
    }),

    height: computed('numButtons.y', 'buttonSize', function() {
        return this.get('numButtons.y') * this.get('buttonSize');
    }),

    // Step 4. Set the margin around the board (in pixels). This is equal to the padding
    // inside the container.
    margin: computed('normalizationFactor', 'containerWidth', 'width', function() {
        const normalizationFactor = this.get('normalizationFactor');
        const remainingWidth      = this.get('containerWidth') - this.get('width');

        return {
            top   : 25  * normalizationFactor,
            right : 0.5 * remainingWidth,
            bottom: 25  * normalizationFactor,
            left  : 0.5 * remainingWidth,
        };
    }),

    // Step 5. Set the height of the container
    containerHeight: computed('height', 'margin.{top,bottom}', function() {
        return this.get('height') + this.get('margin.top') + this.get('margin.bottom');
    }),


    /*************************************************************************************

        Listen to window resize

    *************************************************************************************/
    addResizeListener() {
        const _resizeHandler = () => {
            debounce(this, this.updateContainerSize, 200);
        };

        $(window).on(`resize.${this.get('elementIdentifier')}`, _resizeHandler);
    },

    removeResizeListener() {
        $(window).off(`resize.${this.get('elementIdentifier')}`);
    },

    updateContainerSize() {
        this.notifyPropertyChange('containerWidth');
        this.drawGame();
    }
});

There are a few points worth noting. One, the variable elementIdentifier tells Ember where to insert D3 code. It is the #game div element in the template. Two, we can use jQuery to find out how much width the div can take. If the screen size changes, the div element is drawn again. Three, we can easily make our D3 mobile responsive with a bit of math and planning.

Save the mixin file. We should see no changes to the screen.

step-2a-part1
We now have a mobile responsive screen.

c. scale

We wish to draw buttons as rectangles. To draw a rectangle in D3, we need to know its location on the screen, width, and height. We know the width and the height from the mixin. What about the location?

In computer-aided design (CAD), the coordinate system where we can easily draw and place objects is called the parametric space. For example, our buttons have x- and y-coordinates of 0, 1, 2, 3, or 4 because integers are easy to work with. Once we have a parametric space, we can map it to a different space—the physical space. Our buttons appear on a desktop or mobile screen, where we measure location by pixel. In short, we can move the objects to wherever we want and shape them however we want.

parametric-mapping
Thanks to the idea of parametric and physical spaces, we can build complicated geometries easily. Both rectangular and circular bars above came from the same “stack of boxes” in the parametric space.

D3 comes equipped with several maps. The one that we need here and that you will probably use most often is scaleLinear. It stretches the parametric space linearly and shifts it by a constant to give you the physical space. In linear algebra terms, it does an affine transformation.

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    // Place points from left to right in the physical space
    scaleX: computed('numButtons.x', 'width', function() {
        return scaleLinear()
            .domain([0, this.get('numButtons.x')])
            .range([0, this.get('width')]);
    }),

    // Place points from top to bottom in the physical space
    scaleY: computed('numButtons.y', 'height', function() {
        return scaleLinear()
            .domain([0, this.get('numButtons.y')])
            .range([0, this.get('height')]);
    }),
});

Notice the use of dots to create and run a chain of D3 methods. Feel free to find all attributes in the D3 documentation and experiment with them.

scaleX and scaleY are called computed properties. We are telling Ember to update the maps when the number of buttons or the screen size changes. It’s an awesome way to always use the most current setup.

buttons-mapping
An illustration of how scaleLinear maps buttons to desktop or mobile.

From lines 15-16, we see that the leftmost button will get mapped to the left side of the screen and the rightmost button to the right side of the screen. Similarly, from top to bottom. To actually map a button to the physical space, we can simply call scaleX and scaleY as if they were functions. Consider this snippet of code:

export default Component.extend(ResponsiveMixin, {
    scaleX: [...],

    scaleY: [...],

    someMethod() {
        // Get the parametric coordinates
        const button = {
            coordinates: {x: 1, y: 2},
            isLightOn: true
        };

        const scaleX = this.get('scaleX');
        const scaleY = this.get('scaleY');

        // Find the physical coordinates
        console.log('Physical x: ' + scaleX(button.coordinates.x));
        console.log('Physical y: ' + scaleY(button.coordinates.y));
    }
});

d. container

We need an SVG container for D3 elements. An SVG container, like a div container, lets us reserve space on the screen so that we can display D3 elements inside. With that in mind, let’s create a function called createContainer and pull in variables from the mixin. Then, call createContainer as a part of the component’s life cycle.

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() {
        this.drawGame();

        this._super(...arguments);
    },

    scaleX: [...],

    scaleY: [...],

    drawGame() {
        this.createContainer();
    },

    createContainer() {
        // Clear the DOM
        this.$(this.get('elementIdentifier'))[0].innerHTML = '';

        // Get visual properties
        const containerWidth  = this.get('containerWidth');
        const containerHeight = this.get('containerHeight');
        const margin          = this.get('margin');
    },
});

We use didInsertElement to make sure that the #game div element is rendered first. This way, the SVG container has a place to belong. We call this._super afterwards so that the resize listener (from the mixin) is turned on.

Next, we create the container and add a game board inside.

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() [...],

    scaleX: [...],

    scaleY: [...],

    drawGame() [...],

    createContainer() {
        // Clear the DOM
        this.$(this.get('elementIdentifier'))[0].innerHTML = '';

        // Get visual properties
        const containerWidth  = this.get('containerWidth');
        const containerHeight = this.get('containerHeight');
        const margin          = this.get('margin');

        // Create an SVG container
        let lightsOutContainer = select(this.get('elementIdentifier'))
            .append('svg')
            .attr('class', 'container')
            .attr('width', containerWidth)
            .attr('height', containerHeight)
            .attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
            .attr('preserveAspectRatio', 'xMidYMin');

        this.set('lightsOutContainer', lightsOutContainer);

        // Create a board inside the container
        let lightsOutBoard = lightsOutContainer
            .append('g')
            .attr('class', 'board')
            .attr('transform', `translate(${margin.left}, ${margin.top})`);

        this.set('lightsOutBoard', lightsOutBoard);
    },
});

We see that the container starts at (0, 0) and takes up its whole width and height. The xMidYMin tells the container to grow or shrink relative to its top-center. This results in a smooth transition when the user changes the screen size.

We also see that the board is a group (g). A group allows us to group D3 elements that are related, much like a folder allows us to group files that are related. We can use groups to structure the DOM in an organized manner, which in turn will help with debugging and testing.

Finally, note that we can add CSS classes to svg and g elements. We can add ids too. With classes and ids, we can select and modify D3 elements in the component, and write acceptance and integration tests. If your project includes ember-test-selectors, you can also sprinkle data-test-* tags:

export default Component.extend(ResponsiveMixin, {
    createContainer() {
        // If we have ember-test-selectors
        let lightsOutContainer = select(this.get('elementIdentifier'))
            .append('svg')
            .attr('data-test-container', '')
            .attr('class', 'container');

        let lightsOutBoard = lightsOutContainer
            .append('g')
            .attr('data-test-board', '')
            .attr('class', 'board');
    }
});

e. buttons

I promise, just two more steps before you can play the game. (There is a reason game development is tough!)

Here, we will actually draw the buttons. To do this, we place a buttons group inside the board group. The buttons group contains rect elements that will act as buttons.

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() [...],

    scaleX: [...],

    scaleY: [...],

    drawGame() {
        this.createContainer();
        this.createButtons();
    },

    createContainer() [...],

    createButtons() {
        // Get visual properties
        const buttonSize = this.get('buttonSize');
        const scaleX     = this.get('scaleX');
        const scaleY     = this.get('scaleY');

        // Add a buttons group inside the board
        this.get('lightsOutBoard')
            .append('g')
            .attr('class', 'buttons');

        // Create buttons inside the buttons group
        let buttonGroup = select('.buttons')
            .selectAll('rect');
    },
});

Next, we can pass data (an array) to rect elements so that D3 knows how to draw each of them. Because our buttons variable is a 2D array, we make sure to convert it to an 1D array using JavaScript’s reduce method. (It is possible to work with 2D arrays in D3, but the selection logic becomes more complex.)

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() [...],

    scaleX: [...],

    scaleY: [...],

    drawGame() [...],

    createContainer() [...],

    createButtons() {
        // Get visual properties
        const buttonSize = this.get('buttonSize');
        const scaleX     = this.get('scaleX');
        const scaleY     = this.get('scaleY');

        // Add a buttons group inside the board
        this.get('lightsOutBoard')
            .append('g')
            .attr('class', 'buttons');

        // It's easier to work with 1D data in D3. Convert the 2D array to an 1D array.
        const buttons = this.get('buttons').reduce((accumulator, rowOfButtons) => accumulator.concat(rowOfButtons), []);

        // Create buttons inside the buttons group
        let buttonGroup = select('.buttons')
            .selectAll('rect')
            .data(buttons);

        // Draw buttons
        buttonGroup
            .enter()
            .append('rect')
            .attr('class', button => `button-id_${button.coordinates.x}_${button.coordinates.y}`)
            .attr('x', button => scaleX(button.coordinates.x))
            .attr('y', button => scaleY(button.coordinates.y))
            .attr('width', buttonSize)
            .attr('height', buttonSize)
            .attr('fill', button => button.isLightOn ? '#e64182' : '#806fbc')
            .attr('stroke', '#cbd0d3')
            .attr('stroke-width', 0.075 * buttonSize);
    },
});

Notice how we can use the arrow function to write concise statements for customizing each rectangle. enter is a special command, along with merge and exit that I do not use in this tutorial. Think of these as lifecycle methods in D3. We can indicate what to do when a D3 element is created, updated, and removed.

step-2e-part1
We can add buttons using rect elements.

I think it’s cool how we can faithfully render the elements in the original game (the gray borders, purple buttons, and pink lights), just in one chain of D3 commands. Let’s add some pizzazz by experimenting with gradients.

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() [...],

    scaleX: [...],

    scaleY: [...],

    drawGame() {
        this.createContainer();
        this.createGradients();
        this.createButtons();
    },

    createContainer() [...],

    createGradients() {
        // Create light-off effect
        let linearGradient = this.get('lightsOutBoard')
            .append('defs')
            .append('linearGradient')
            .attr('id', 'linear-gradient')
            .attr('x1', '0%')
            .attr('y1', '0%')
            .attr('x2', '0%')
            .attr('y2', '100%');

        linearGradient.append('stop')
            .attr('offset', '5%')
            .attr('stop-color', '#9688cc');

        linearGradient.append('stop')
            .attr('offset', '90%')
            .attr('stop-color', '#806fbc');

        // Create light-on effect
        let radialGradient = this.get('lightsOutBoard')
            .append('defs')
            .append('radialGradient')
            .attr('id', 'radial-gradient');

        radialGradient.append('stop')
            .attr('offset', '5%')
            .attr('stop-color', '#eb71dc');

        radialGradient.append('stop')
            .attr('offset', '90%')
            .attr('stop-color', '#e64182');
    },

    createButtons() {
        [...]

        // Draw buttons
        buttonGroup
            .enter()
            .append('rect')
            .attr('class', button => `button-id_${button.coordinates.x}_${button.coordinates.y}`)
            .attr('x', button => scaleX(button.coordinates.x))
            .attr('y', button => scaleY(button.coordinates.y))
            .attr('width', buttonSize)
            .attr('height', buttonSize)
            .attr('fill', button => button.isLightOn ? 'url(#radial-gradient)' : 'url(#linear-gradient)')
            .attr('stroke', '#cbd0d3')
            .attr('stroke-width', 0.075 * buttonSize);
    },
});

step-2e-part2
We can easily add gradients to enhance user experience.

f. action

Last but not least, let’s add logic to the game. There are three parts. (1) When the game starts, some of the lights should be on. We can’t randomly turn on lights, since this can create an unsolvable puzzle. (2) When a light is pressed, that light and its adjacent ones should be toggled. (3) We need to check if all lights are out and respond appropriately.

First, let’s create a solvable puzzle. We can do this by “walking backwards.” Start from the state where all lights are out (the solution), then press a light at random to toggle that light and its adjacent ones (1 step before reaching the solution), then press another (2 steps before), and so on. One benefit of this approach is that we know the sequence of steps that leads to the finished game. We can use this sequence in our tests.

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import { copy } from '@ember/object/internals';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() {
        this.createPuzzle();
        this.drawGame();

        this._super(...arguments);
    },

    scaleX: [...],

    scaleY: [...],

    createPuzzle() {
        // Create a solvable puzzle by "walking backwards"
        for (let index = 0; index < 5; index++) {
            const i = Math.floor(this.get('numButtons.y') * Math.random());
            const j = Math.floor(this.get('numButtons.x') * Math.random());

            this.toggleLights(i, j);
        }
    },

    toggleLights(i, j) {
        // Make a copy so that Ember knows if there has been a change
        let buttons = copy(this.get('buttons'), true).toArray();

        // Center
        buttons[i][j].isLightOn = !buttons[i][j].isLightOn;

        // Top
        if (i > 0) {
            buttons[i - 1][j].isLightOn = !buttons[i - 1][j].isLightOn;
        }

        // Bottom
        if (i < this.get('numButtons.y') - 1) {
            buttons[i + 1][j].isLightOn = !buttons[i + 1][j].isLightOn;
        }

        // Left
        if (j > 0) {
            buttons[i][j - 1].isLightOn = !buttons[i][j - 1].isLightOn;
        }

        // Right
        if (j < this.get('numButtons.x') - 1) {
            buttons[i][j + 1].isLightOn = !buttons[i][j + 1].isLightOn;
        }

        // Save the new state
        this.set('buttons', buttons);
    },

    drawGame() [...],

    createContainer() [...],

    createGradients() [...],

    createButtons() [...],
});

step-2f
Let there be light.

Next, we will use Ember’s built-in features to find out if all lights are out and if a button has been pressed. In particular, we will use the any method (is a logic statement true for any of the elements in an array?) and actions (what should the component do when an event, such as a mouse click, happens?).

// Ember-related packages
import Component from '@ember/component';
import { computed } from '@ember/object';
import { copy } from '@ember/object/internals';
import ResponsiveMixin from 'lights-out/mixins/responsive';

// D3-related packages
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    init() [...],

    didInsertElement() [...],

    scaleX: [...],

    scaleY: [...],

    createPuzzle() [...],

    toggleLights(i, j) [...],

    areLightsOut: computed('buttons', function() {
        // If any of the lights are still on, the game continues
        return !this.get('buttons').any(rowOfButtons => rowOfButtons.any(button => button.isLightOn));
    }),

    drawGame() [...],

    createContainer() [...],

    createGradients() [...],

    createButtons() {
        [...]

        // Draw buttons
        buttonGroup
            .enter()
            .append('rect')
            .attr('class', button => `button-id_${button.coordinates.x}_${button.coordinates.y}`)
            .attr('x', button => scaleX(button.coordinates.x))
            .attr('y', button => scaleY(button.coordinates.y))
            .attr('width', buttonSize)
            .attr('height', buttonSize)
            .attr('fill', button => button.isLightOn ? 'url(#radial-gradient)' : 'url(#linear-gradient)')
            .attr('stroke', '#cbd0d3')
            .attr('stroke-width', 0.075 * buttonSize)
            .on('click', button => this.send('buttonOnClick', button));
    },

    actions: {
        buttonOnClick(thisButton) {
            if (this.get('areLightsOut')) return;

            // Toggle lights
            const { x, y } = thisButton.coordinates;
            this.toggleLights(y, x);

            // Update the game
            this.incrementProperty('numMoves');
            this.drawGame();
        }
    }
});

Notice how easily we can integrate D3’s on-click method with Ember’s actions. The on-click method also comes with event data, such as mouse location (relative to the SVG container) when a click happens. We can use the event data to create richer user experience. To access the data, use the regular function call instead:

import { mouse, select } from 'd3-selection';

export default Component.extend(ResponsiveMixin, {
    createButtons() {
        // If we still need to refer to the component
        const component = this;

        buttonGroup
            // If we need event data
            .on('click', function(button) {
                const mouseCoordinates = mouse(this);

                [...]

                component.send('buttonOnClick', button);
            });
    }
}

That’s it! You can now play Lights Out on desktop and mobile (assuming you’ve hosted the game on Heroku or somewhere else). Feel free to take this game further and learn more about Ember and D3. Restart button, difficulty levels, preset levels, visual effects, sound effects, leaderboard, daily challenge… What will you create?

Notes

For more information on Ember and D3, I encourage you to check out these tutorials:

You can find the code in its entirety here:

Download from GitHub

Advertisements

Leave a reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

w

Connecting to %s