2015-02-07
ReactJS Basilisk Puzzle

I’ve cloned the previous Sliding Block Puzzle and have hacked on it to make it a little bit closer to something that illustrates the Combinator Birds concept (basically, $\lambda$-calculus). The resultant evolution will take several posts.

This V1 iteration is a simple puzzle game called Basilisk, where the object is to assemble the letters in the grid to form the word ‘BASILISK’. The puzzle is wildly easy. The point here is to create a baseline for building more complex games and puzzles.

#game-board { display: inline-block; width: 400px; height: 236px; padding: 0; margin: 0; }

.tile, .button { text-align: center; user-select: none; }

.tile { margin:1px; width: 125px; height: 75px; float: left; line-height: 75px; font-size: 25px; font-weight: bold; background: #dfd; border:3px solid lightgray; border-radius: 3px; }

.tile.focused { border:3px solid blue; }

.tile.operator { background:lightblue; }

.tile.operand { background:lightyellow; }

.tile.empty { background:aliceblue; }

.tile:hover:not(:empty) { cursor: pointer; -webkit-transition: -webkit-transform 0.2s, background 0.2s; transition: transform 0.2s, background 0.2s; border:3px solid cyan; }

.win { -webkit-animation: winner 2s infinite; animation: winner 2s infinite; }

.highlight, .move-up, .move-right, .move-down, .move-left { background: lightblue !important; }

.highlight { background: #fff; }

.move-up { -webkit-transform: translateY(-75px); transform: translateY(-75px); } .move-right { -webkit-transform: translateX(75px); transform: translateX(75px); } .move-down { -webkit-transform: translateY(75px); transform: translateY(75px); } .move-left { -webkit-transform: translateX(-75px); transform: translateX(-75px); }

.button { display: inline-block; padding: 4px 10px; color: black; border: 2px solid black; } .button:hover { cursor: pointer; }

@keyframes winner { 0% { background: #fdd; } 50% { background: #fff; } 100% { background: #fdd; } }

@keyframes highlight { 0% { background: #dfd; } 100% { background: #fff; } }

.controls { //display: inline-block; }

.arrow-keys { margin: 0 auto; }

.arrow-keys-line { text-align: center; }


What is this?

It’s an incremental step on the way towards something; I’ll know when I get there. Part of how I learn a new technology is to play around with it on toy problems. Since I’m starting with a 3x3 Grid pattern inherited from the previous puzzle example, I’ll use that as a constraint and instead look at different interaction options for this toy.

The current state of this app is it is a puzzle game, where an initial configuration must be manipulated to a state satisfying a goal predicate. In the Sliding Block Puzzle, there was a single goal state. This puzzle is more liberal and may have several states satisfying the goal.

General Puzzle Rules
  • Victory is achieved by establishing a particular tile configuration. Initially, let’s just say that the I (Identity) combinator is the target. So an initial tableau must be transformed into the single expression I ($\lambda x.x$).
  • Movement is based upon selecting a cell to be the operator and another to be the operand. In Combinator Bird Terms, this is the listening bird and the singing bird.
  • In a future version of this puzzle, when you apply a cell to an adjacent cell, the two cells will animate and the resulting expression will occupy the cell formerly occupied by the operand, leaving the operator’s former cell empty (PacMan-style). This is a starting point and we’ll see what’s most playable.
  • For this V1 iteration, I’m going to detour into a simpler interaction where all you need to do to reduce the number of cells is to match the operator and operand. I’ll then duplicate this app and build out a richer semantics for the interaction. This may also challenge me to reuse some of the common code between the two versions.
Release Notes
  • I’ve cleaned up the code to use 2-column tab stops (with spaces, of course).
  • I’ve removed almost all extraneous custom styles and fonts and am using Twitter Bootstrap where possible.
  • I added keyboard handling to allow the game to be played with only the keyboard. I adapted some of the code from Henley’s Flip Game to manage the keyboard.
  • I’ve reformatted the ReactJS class construction so that it is more maintainable and amenable to reuse. I’ve adopted a convention when declaring React components of first declaring the class descriptor and then defining the class based upon this. This lets me format my functions as independent elements rather than trying to stick them into a big Javascript object. For example, here is how the Menu component is defined:
  // Menu
  var MenuMeta = pureMeta();

  MenuMeta.render =
    function() {
      var winTitle = this.props.win ? 'You win!' : 'xSpell "Basilisk"';
      var winClass =
        classNames({
          'btn': true,
          'btn-primary': true,
          'btn-xs': true,
          'win': this.props.win
        });

      return (
        <div id="menu" className="col-8">
          <h6 id="subtitle">{winTitle}</h6>
          <h6>1) Activate an Operator</h6>
          <h6>2) Activate an adjacent Operand</h6>
          <h6>3) Apply the pair by Activating the Operand a second time</h6>
          <h6>4) To Move a tile, Apply it to an adjacent empty space</h6>
          <div>
            <button className={winClass} onClick={this.props.restart}>Restart</button>
          </div>
        </div>
      );
    };
  var Menu = createReactClass(MenuMeta);
  • I’m not really sure whether I need or should use React.addons.PureRenderMixin. Seems like it should be the default. More info at PureRenderMixin. In the meantime, I’ve create two versions of the Meta constructor to make it easy to choose and experiment; I’m currently using the pureMeta() constructor with no ill effects:
  function impureMeta() {
    return {};
  };
  function pureMeta() {
    return {};  // {mixins: [React.addons.PureRenderMixin]};
  };

Code for this V1 Puzzle is below.

<style>
#game-board, #game-board * {
  box-sizing: border-box;
}

#game-board {
  display: inline-block;
  width: 400px;
  height: 236px;
  padding: 0;
  margin: 0;
}

.tile,
.button {
  text-align: center;
  user-select: none;
}

.tile {
  margin:1px;
  width: 125px;
  height: 75px;
  float: left;
  line-height: 75px;
  font-size: 25px;
  font-weight: bold;
  background: #dfd;
  border:3px solid lightgray;
  border-radius: 3px;
}

.tile.focused {
  border:3px solid blue;
}

.tile.operator {
  background:lightblue;
}

.tile.operand {
  background:lightyellow;
}

.tile.empty {
  background:aliceblue;
}

.tile:hover:not(:empty) {
  cursor: pointer;
  -webkit-transition: -webkit-transform 0.2s, background 0.2s;
          transition: transform 0.2s, background 0.2s;
  border:3px solid cyan;
}

.win {
  -webkit-animation: winner 2s infinite;
          animation: winner 2s infinite;
}

.highlight,
.move-up,
.move-right,
.move-down,
.move-left {
  background: lightblue !important;
}

.highlight {
  background: #fff;
 }

.move-up {
  -webkit-transform: translateY(-75px);
          transform: translateY(-75px);
}
.move-right {
  -webkit-transform: translateX(75px);
          transform: translateX(75px);
}
.move-down {
  -webkit-transform: translateY(75px);
          transform: translateY(75px);
}
.move-left {
  -webkit-transform: translateX(-75px);
          transform: translateX(-75px);
}

.button {
  display: inline-block;
  padding: 4px 10px;
  color: black;
  border: 2px solid black;
}
.button:hover {
  cursor: pointer;
}

@keyframes winner {
  0%    { background: #fdd; }
  50%   { background: #fff; }
  100%  { background: #fdd; }
}

@keyframes highlight {
  0%    { background: #dfd; }
  100%  { background: #fff; }
}


.controls {
  //display: inline-block;
}

.arrow-keys {
  margin: 0 auto;
}

.arrow-keys-line {
  text-align: center;
}

</style>

<div  class="container card p-1" style="text-align:center;background:#EFEFEF;">
  <div id="game-container">
  </div>
</div>


<script type="text/jsx">
(function() {
  var keyIsDown = false;

  function impureMeta() {
    return {};
  };
  function pureMeta() {
    return {mixins: [React.addons.PureRenderMixin]};
  };

  // Game
  var GameMeta = pureMeta();

  GameMeta.getInitialState =
    function() {
      return {
        tiles:  _.shuffle([
                  _.shuffle(['B', 'A', 'S']),
                  _.shuffle(['I', 'L', 'I']),
                  _.shuffle(['S', 'K', ''])
                ]),
        posX: 2,
        posY: 2,
        operatorX: -1,
        operatorY: -1,
        operandX: -1,
        operandY: -1,
        win: false
      };
    };

  GameMeta.restartGame =
    function() {
      this.setState(this.getInitialState());
    };

  GameMeta.toggleCell =
    function(posX, posY) {
      var operatorSelectionMode = (this.state.operatorX == -1);
      var operandSelectionMode = !operatorSelectionMode && (this.state.operandX == -1);
      var operatorSelected = (this.state.operatorX == posX) && (this.state.operatorY == posY);
      var operandSelected = (this.state.operandX == posX) && (this.state.operandY == posY);

      var cell = this.state.tiles[posY][posX];
      if ((cell == '') && !(operandSelectionMode || operandSelected)) {
        return;
      }

      if (operatorSelected) {
        this.setState({
            operatorX: -1,
            operatorY: -1,
            operandX: -1,
            operandY: -1
          });
      }
      else if (operatorSelectionMode) {
      console.log('toggleCell:', posX, posY);

        this.setState({
          operatorX: posX,
          operatorY: posY
        });
      }
      else if (!operandSelectionMode && operandSelected) {
        function getDirectionName(x0, y0, x1, y1) {
          var result = '';
          var xdelta = (x1 - x0);
          var ydelta = (y1 - y0);
          if (xdelta == 0) {
            if (ydelta > 0) {
              result = 'down';
            }
            else {
              result = 'up';
            }
          }
          else if (xdelta > 0) {
            if (ydelta == 0) {
              result = 'right';
            }
          }
          else {
            if (ydelta == 0) {
              result = 'left';
            }
          }

          return result;
        }

        var state = this.state;
        var operatorTileIndex = state.operatorY * 3 + state.operatorX + 1;
        var operandTileIndex = state.operandY * 3 + state.operandX + 1;
        var operatorEl = document.querySelector('.tile:nth-child(' + operatorTileIndex + ')');
        var operandEl = document.querySelector('.tile:nth-child(' + operandTileIndex + ')');
        var direction = getDirectionName(state.operatorX, state.operatorY, state.operandX, state.operandY);
        // operatorEl.classList.add('dbgOperator');
        // operandEl.classList.add('dbgOperand');


        function afterAnimate() {
          var newTiles = this.state.tiles;
          newTiles[this.state.operandY][this.state.operandX] =
               newTiles[this.state.operatorY][this.state.operatorX] +
               newTiles[this.state.operandY][this.state.operandX];
          newTiles[this.state.operatorY][this.state.operatorX] = '';

          this.setState({
            posX: this.state.operandX,
            posY: this.state.operandY,
            operatorX: -1,
            operatorY: -1,
            operandX: -1,
            operandY: -1,
            tiles: newTiles,
            win: this.checkBoard()
          });
        };

        var game = this;
        operatorEl.classList.add('move-' + direction);
        // this is all a little hackish.
        // css/js are used together to create the illusion of moving blocks
        setTimeout(function() {
          operandEl.classList.add('highlight');
          operatorEl.classList.remove('move-' + direction);
          // time horribly linked with css transition
          setTimeout(function() {
              operandEl.classList.remove('highlight');
              setTimeout(afterAnimate.bind(game), 200);
          }, 400);
        }, 200);
      }
      else if (operandSelectionMode) {
        this.setState({
          operandX: posX,
          operandY: posY
        });
      }
    };

  GameMeta.touchCell =
    function(positionX, positionY) {
      var newPosX = positionX;
      var newPosY = positionY;
      this.toggleCell(newPosX, newPosY);
    };

  GameMeta.checkBoard =
    function() {
      var tiles = this.state.tiles;
      for (var row = 0; row < tiles.length; ++row) {
        var rowTiles = tiles[row];
        for (var col = 0; col < rowTiles.length; ++col) {
          var tile = rowTiles[col];
          if (tile === 'BASILISK') {
            return true;
          }
        }
      }

      return false;
    };

  GameMeta.isValidMoveTo =
    function(x, y) {
      var hasOperator = (this.state.operatorX != -1);
      var hasOperand = (this.state.operandX != -1);
      var result = true;

      if (hasOperator) {
        var xDistanceFromOperator = Math.abs(this.state.operatorX - x);
        var yDistanceFromOperator = Math.abs(this.state.operatorY - y);
        var xDistanceFromOperand = Math.abs(this.state.operandX - x);
        var yDistanceFromOperand = Math.abs(this.state.operandY - y);

        // result = (possibleTile === operatorTile);

        result = (xDistanceFromOperator + yDistanceFromOperator) <= 1;
        if (result && hasOperand)
        {
          result =  (xDistanceFromOperator + yDistanceFromOperator) <= 0 ||
                    (xDistanceFromOperand + yDistanceFromOperand) <= 0;
        }
      }

      return result;
    };

  GameMeta.isValidMove =
    function(direction) {
      var x = this.state.posX;
      var y = this.state.posY;
      x = ( 3 + x + direction.x) % 3;
      y = ( 3 + y + direction.y) % 3;
      return this.isValidMoveTo(x, y);
    };

  GameMeta.beforeMove =
    function(direction) {
      if (this.state.win) { return; }
      if (this.isValidMove(direction)) {
        this.move(direction);
      }
      else {
      }
    };

  GameMeta.move =
    function(direction) {
      var x = this.state.posX;
      var y = this.state.posY;

      x = ( 3 + x + direction.x) % 3;
      y = ( 3 + y + direction.y) % 3;
      this.setState({posX: x, posY: y});
    };

  GameMeta.tileClick =
    function(tileEl, positionX, positionY, status) {
      // Possible moves
      // [up,right,down,left]
      // 9 = out of bounds
      var moves = [
        [null,1,3,null],[null,2,4,0],[null,null,5,1],
        [0,4,6,null],   [1,5,7,3],   [2,null,8,4],
        [3,7,null,null],[4,8,null,6],[5,null,null,7]
      ];

      // return if they've already won
      if (this.state.win) return;

      if (this.isValidMoveTo(positionX, positionY)) {
        this.touchCell(positionX, positionY);
      }
    };

  GameMeta.handleKeyEvent =
    function(e) {
      var key = e.keyCode;

      // stop arrow keys/spacebar from scrolling
      if ([32,37,38,39,40,82,188,190].indexOf(key) !== -1) {
        if (e.metaKey || e.ctrlKey) {
          return;
        }
        e.preventDefault();
      }

      // prevent multiple firings of spacebar with keyIsDown
      if (key === 32 && !keyIsDown) {
        keyIsDown = true;
        this.toggleCell(this.state.posX, this.state.posY);
      }
      else if (key === 37)  { this.beforeMove({x: -1, y:  0}); } // left
      else if (key === 38)  { this.beforeMove({x:  0, y: -1}); } // up
      else if (key === 39)  { this.beforeMove({x:  1, y:  0}); } // right
      else if (key === 40)  { this.beforeMove({x:  0, y:  1}); } // down
      else if (key === 82)  { this.changeLevel(this.state.currentLevel.level - 1); }
      else if (key === 188) { this.changeLevel(this.state.currentLevel.level - 2); }
      else if (key === 190) { this.changeLevel(this.state.currentLevel.level); }
      else if (key === 70)  {}
    };

  GameMeta.render =
    function() {
      function toggleCurrent() {
        this.toggleCell(this.state.posX, this.state.posY);
      }

      return <div class="row">
        <Menu restart={this.restartGame}  win={this.state.win}/>
        <Controls sendMove={this.beforeMove} sendOperator={toggleCurrent.bind(this)} restart={this.restartGame} winClass={this.state.win ? 'btn btn-default btn-sm win' : 'btn btn-default btn-sm'}/>
        <div id="game-board">
          {this.state.tiles.map(function(rowTiles, row) {
            var rowDOM =
              rowTiles.map(function(colTile, col) {
                var focused = this.state.posX == col && this.state.posY == row;
                var operator = this.state.operatorX == col && this.state.operatorY == row;
                var operand = this.state.operandX == col && this.state.operandY == row;
                var key = row * 3 + col;
                var t = <Tile key={key} positionX={col} positionY={row} status={colTile} focused={focused} operator={operator} operand={operand} tileClick={this.tileClick} />;

                return t;
              }, this);

              return rowDOM;
          }, this)}
        </div>
      </div>;
    };

  GameMeta.componentDidMount =
    function() {
      // Keyboard Events
      window.addEventListener('keydown', this.handleKeyEvent);
      window.addEventListener('keyup', function() {
        keyIsDown = false;
      });

      setTimeout(function() {
        moveEnabled = true;
      }, 1000);
    };

  var Game = createReactClass(GameMeta);


  // Tile
  var TileMeta = pureMeta();

  TileMeta.clickHandler =
    function(e) {
      this.props.tileClick(e.target, this.props.positionX, this.props.positionY, this.props.status);
    };

  TileMeta.render =
    function() {
      var cx = classNames;
      var tileClasses = cx({
          'tile': true,
          // 'button': true,
          // 'btn': true,
          'focused': this.props.focused,
          'empty': (this.props.status === '') && !this.props.operand,
          'operator': this.props.operator,
          'operand': this.props.operand,
      });

      return (
        <div className={tileClasses} onClick={this.clickHandler}>
          {this.props.status}
        </div>
      );
    };
  var Tile = createReactClass(TileMeta);


  // Menu
  var MenuMeta = pureMeta();

  MenuMeta.render =
    function() {
      var winTitle = this.props.win ? 'You win!' : 'Spell "Basilisk"';
      var winClass =
        classNames({
          'btn': true,
          'btn-default': true,
          'btn-sm': true,
          'win': this.props.win
        });

      return (
        <div id="menu" className="col-8">
          <h5 id="subtitle">{winTitle}</h5>
          <h6>1) Activate an Operator</h6>
          <h6>2) Activate an adjacent Operand</h6>
          <h6>3) Apply the pair by Activating the Operand a second time</h6>
          <h6>4) To Move a tile, Apply it to an adjacent empty space</h6>
          <div>
            <button className={winClass} onClick={this.props.restart}>Restart</button>
          </div>
        </div>
      );
    };
  var Menu = createReactClass(MenuMeta);


  // Controls
  var ControlsMeta = pureMeta();

  ControlsMeta.handleClick =
    function(direction) {
      if (direction === 'up')         { direction = {x:  0, y: -1}; }
      else if (direction === 'down')  { direction = {x:  0, y:  1}; }
      else if (direction === 'left')  { direction = {x: -1, y:  0}; }
      else if (direction === 'right') { direction = {x:  1, y:  0}; }
      this.props.sendMove(direction);
    };

  ControlsMeta.render =
    function() {
      /* jshint ignore:start */
      return (
        <div className="controls row">
          <div className="col-4">
            <br/>
          </div>
          <div className="col-4">
            Controls
          </div>
          <div className="col-4">
            <br/>
          </div>
          <div className="col-4 arrow-keys">
            <div className="arrow-keys-line">
              <a className="btn btn-default btn-sm clickable" onClick={this.handleClick.bind(this, 'up')} onTouchEnd={this.handleClick.bind(this, 'up')}><span className="fa fa-arrow-up"></span></a>
            </div>
            <div className="arrow-keys-line">
              <a className="btn btn-default btn-sm clickable" onClick={this.handleClick.bind(this, 'left')} onTouchEnd={this.handleClick.bind(this, 'left')}><span className="fa fa-arrow-left"></span></a>
              <a className="btn btn-default btn-sm clickable" onClick={this.handleClick.bind(this, 'down')} onTouchEnd={this.handleClick.bind(this, 'down')}><span className="fa fa-arrow-down"></span></a>
              <a className="btn btn-default btn-sm clickable" onClick={this.handleClick.bind(this, 'right')} onTouchEnd={this.handleClick.bind(this, 'right')}><span className="fa fa-arrow-right"></span></a>
            </div>
            <br/>
            <a className="btn btn-default btn-sm clickable" onClick={this.props.sendOperator} onTouchEnd={this.props.sendOperator}>
              <span>Spacebar</span>
            </a>
          </div>
        </div>
      );
      /* jshint ignore:end */
    };

  var Controls = createReactClass(ControlsMeta);

  //
  // render Game to container
  //

  ReactDOM.render(
    <Game />,
    document.getElementById('game-container')
  );
}());
</script>