6
\$\begingroup\$

I have created a program that lets you play the fifteen puzzle game. You have to get the blocks into ascending order by moving them into the one open space. I would like some feedback on the code and how I made it. This works best in full screen mode.

"use strict";

class Block {
  constructor(x, y, w, h, value) {
    this.x = x;
    this.y = y;
    this.w = w;
    this.h = h;
    this.value = value;
  }

  draw() {
    if (this.value) {
      let padding = 5;
      ctx.strokeStyle = "#000";
      ctx.font = (this.w / 4).toString() + "px Georgia";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillStyle = "#7d4b1488";
      ctx.fillRect(this.x + padding, this.y + padding, this.w - padding, this.h - padding);
      ctx.strokeRect(this.x + padding, this.y + padding, this.w - padding, this.h - padding);
      ctx.fillStyle = "#000";
      ctx.fillText(this.value.toString(), this.x + this.w / 2, this.y + this.h / 2, width * 0.75);
    }
  }

  collidePoint(x, y) {
    return (
      x > this.x &&
      x < this.x + this.w &&
      y > this.y &&
      y < this.y + this.h
    );
  }

  sendTo(position) {
    moving++;
    let pos = {
      x: position.x,
      y: position.y,
    }
    let vel = {
      x: (this.x - pos.x) / 10,
      y: (this.y - pos.y) / 10,
    }
    let self = this;
    let n = 0;
    let movement = () => {
      drawAll();
      self.x -= vel.x;
      self.y -= vel.y;
      if (n >= 10) {
        self.x = pos.x;
        self.y = pos.y;
        moving--;
      }
      else {
        setTimeout(movement, 15);
        n++;
      }
    };
    setTimeout(movement, 15);
  }
}

function shuffle(array) {
  for (let i = 0; i < array.length; ++i) {
    let newI = Math.floor(Math.random() * (i + 1));
    let temp = array[i];
    array[i] = array[newI];
    array[newI] = temp;
  }
}

function isValidNeighbor(ind1, ind2) {
  let pos1 = toNested(ind1);
  let pos2 = toNested(ind2);
  let dist1 = Math.abs(pos1[0] - pos2[0]);
  let dist2 = Math.abs(pos1[1] - pos2[1]);
  if (!dist1 || !dist2) {
    if (dist1 === 1 || dist2 === 1) {
      return dist1 !== dist2;
    }
  }
  return false;
}

function toNested(index) {
  return [ index % boardSize, Math.floor(index / boardSize) ];
}

function findZero() {
  for (let i = 0; i < board.length; ++i) {
    if (board[i].value === 0) {
      return i;
    }
  }
}

function win() {
  if (!moving) {
    ctx.clearRect(0, 0, width, height);
    ctx.fillStyle = "#000";
    ctx.font = "50px Georgia";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(`You won in ${moves} ${moves === 1 ? "move" : "moves"}!`, width / 2, height / 2);
    setTimeout(() => {
      location.reload();
    }, 2500);
  }
  else {
    setTimeout(win, 150);
  }
}

function drawAll() {
  ctx.clearRect(0, 0, width, height);
  board.forEach(block => {
    block.draw();
  });
}

function update() {
  drawAll();
  if (checkWin()) {
    setTimeout(() => {
      canvas.removeEventListener("click", handleClick);
      setTimeout(win, 1000);
    }, 200);
  }
  document.getElementById("moves").innerHTML = "Moves: " + moves.toString();
}

function checkWin() {
  let noZ = board.slice(0);
  noZ.splice(findZero(), 1);
  for (let i = 1; i < noZ.length; ++i) {
    if (noZ[i].value < noZ[i - 1].value) {
      return false;
    }
  }
  return true;
}

const canvas = document.getElementById("display");
const ctx = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;
let boardSize;
let board;
let moves;
let moving;

function init() {
  moving = 0;
  boardSize = parseInt(document.getElementById("size").value);
  if (boardSize < 2 || boardSize > 5 || isNaN(boardSize)) {
    boardSize = 4;
  }
  board = Array.from(Array(boardSize ** 2).keys());
  moves = 0;
  shuffle(board);
  for (let i = 0; i < board.length; ++i) {
    let pos = toNested(i);
    let w = width / boardSize;
    let h = height / boardSize;
    board[i] = new Block(pos[0] * w, pos[1] * h, w, h, board[i]);
  }
  update();
}

function handleClick(e) {
  if (!moving) {
    let rect = canvas.getBoundingClientRect();
    for (let i = 0; i < board.length; ++i) {
      if (board[i].collidePoint(e.clientX - rect.x, e.clientY - rect.y)) {
        let zIndex = findZero();
        if (isValidNeighbor(i, zIndex)) {
          moves++;
          let tempPos = {
            x: board[i].x,
            y: board[i].y,
          };
          board[i].sendTo(board[zIndex]);
          board[zIndex].sendTo(tempPos);
          let temp = board[i];
          board[i] = board[zIndex];
          board[zIndex] = temp;
        }
        break;
      }
    }
    update();
  }
}

canvas.addEventListener("click", handleClick);

document.getElementById("reset").onclick = init;
init();
html {
  height: 100%;
  display: grid;
}

body {
  margin: auto;
  background-color: #fac4af;
  font-family: Georgia;
}

#board {
  display: inline-block;
  background-image: url("https://15-puzzle-game--joyalmathew.repl.co/board.jpeg");
  padding: 10px;
  box-shadow: 10px 20px #9b857a;
  border: 1px solid black;
  border-radius: 15px;
}

#display {
  margin-left: 0px;
  float: right;
}

#menu {
  display: grid;
  font-size: 15pt;
  float: left;
  width: 500px;
  height: 500px;
  margin-right: 0px;
  box-sizing: border-box;
}

#menu p {
  margin-top: 1px;
  margin-bottom: 1px;
}

#menu input, #menu button {
  font-family: Georgia;
}

#content {
  margin: auto;
  width: 450px;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>15 Puzzle Game</title>
    <link href="style.css" rel="stylesheet" type="text/css" />
  </head>
  <body>
    <div id="board">
      <canvas id="display" width="500" height="500"></canvas>
      <div id="menu">
        <div id="content">
          <p id="moves">Moves: </p><br><br>
          <p>Try getting the blocks into ascending order: left to right, top to bottom.</p>
          <p>Click any block next to the space to swap them.</p>
          <br>
          Size: <input id="size" type="number" min="2" max="5" value="4">
          <button id="reset">Reset</button>
        </div>
      </div>
    </div>
    <script src="script.js"></script>
  </body>
</html>

\$\endgroup\$
3
\$\begingroup\$

Foreword

Your code is quite decent. However, I did make a general outline of how I would probably implement a Board to improve readability. Don't feel the need to take my suggestion. The major changes I made to the code is CSS and HTML restructuring. Additionally, I made modifications to accommodate different window sizes better. The updated code is at the bottom. I know that the topic of resizing can be difficult, so I wrote up the example.

Major JavaScript changes are not implemented (although some minor are) but are outlined inside the JavaScript Structure Changes section.

Scaling problem

Your code seems to scale oddly at different dimensions. I fixed that by assigning canvas.width to Math.min(window.innerWidth - 40, 500) to accomodate for smaller windows. I also changed where you set the width/height of the #menu to #board becuase it looked better. I also changed it to max-width to accommodate smaller game boards better. It didn't make much sense to set a max-height as that is relative (depending on the size of #menu).

CSS Structure changes

I removed #content it seemed erroneous and just completely removed margin:auto except for in body.

To increase DRYness I moved all font-family: Georgia to one selector.

#menu input, #menu button, body {
  font-family: Georgia;
}

I added a spacer between menu and the game because it looked better (maring-top) I removed display:grid for #menu because when I removed #content it did not perform properly.

JavaScript Structure Changes

I liked most of your JavaScript; however, I find that allowing on-the-fly dimension changes to be important when programming a 2d game like this. So, I added a window.addEventListener to your code and had to make some minor changes to your class.

This is more of a band-aid solution, however. Optimally, the Block class's x and y should have very little to do with drawing. Block.draw would simply store how it should be drawn based on some values given (like x, y, w, h). Then Board.draw would loop through Blocks and invoke Block.draw. Board would manage location information.

More of a style thing, but I prefer:

let pos = toNested(i),
    w = width / boardSize,
    h = height / boardSize;

over having lots of let's pile up in my code:

let pos = toNested(i);
let w = width / boardSize;
let h = height / boardSize;

Some people prefer to use let, that's fine. var can be used where it does not matter when changes occur after the control block is over. let saves the head ache so I understand the choice.

Board is implemented as an array. You could probably benefit a little from turning it into a class. From there, instead of:

let temp = board[i];
board[i] = board[zIndex];
board[zIndex] = temp;

You could invoke something like board.swap. This change would improve readability in handleClick and shuffle, as you are creating clearly defined functions: one for each purpose. You could also add easy functions like moveUp or moveDown... etc.

It would be preferred to have a Board.collision that returns first collision with a block (there should only be one...). It would prevent handleClick from having to perform both collision detection and board swapping and movement checks.

I would prefer to use a 2D array within the Board class instead of toNested, it would make code more readable. I liked that you decided to just make that a function. However, it isn't as great as a simple 2d array.

It would probably also be easier to store the location of the empty square. I liked your decision to have be a 0 tile. Very clever way to work with an array. But it may be simpler to leave it as a null inside a 2d array in Board.

When swapping, it would be simpler to use Board.draw and then swapping them inside of the 2d array, just my opinion. Then having Board.draw set up how where to draw each tile.

Having a Board class would also help you encapsulate a lot of the erroneous draw functions scattered in global namespace. I typically make a Game class which is responsible for invoking appropriate draw functions, but in a game this small, I think you could incorporate it inside of Board.

"use strict";

class Block {
  constructor(value) {
    this.value = value;
  }
  
  setDrawSettings(x, y , w, h) {
    this.x = x;
    this.y = y;
    this.w = w;
    this.h = h;
  }

  draw() {
    if (this.value) {
      let padding = 5;
      ctx.strokeStyle = "#000";
      ctx.font = (this.w / 4).toString() + "px Georgia";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillStyle = "#7d4b1488";
      ctx.fillRect(this.x + padding, this.y + padding, this.w - padding, this.h - padding);
      ctx.strokeRect(this.x + padding, this.y + padding, this.w - padding, this.h - padding);
      ctx.fillStyle = "#000";
      ctx.fillText(this.value.toString(), this.x + this.w / 2, this.y + this.h / 2, width * 0.75);
    }
  }

  collidePoint(x, y) {
    return (
      x > this.x &&
      x < this.x + this.w &&
      y > this.y &&
      y < this.y + this.h
    );
  }

  sendTo(position) {
    moving++;
    let pos = {
      x: position.x,
      y: position.y,
    }
    let vel = {
      x: (this.x - pos.x) / 10,
      y: (this.y - pos.y) / 10,
    }
    let self = this;
    let n = 0;
    let movement = () => {
      drawAll();
      self.x -= vel.x;
      self.y -= vel.y;
      if (n >= 10) {
        self.x = pos.x;
        self.y = pos.y;
        moving--;
      }
      else {
        setTimeout(movement, 15);
        n++;
      }
    };
    setTimeout(movement, 15);
  }
}

function shuffle(array) {
  for (let i = 0; i < array.length; ++i) {
    let newI = Math.floor(Math.random() * (i + 1));
    let temp = array[i];
    array[i] = array[newI];
    array[newI] = temp;
  }
}

function isValidNeighbor(ind1, ind2) {
  let pos1 = toNested(ind1);
  let pos2 = toNested(ind2);
  let dist1 = Math.abs(pos1[0] - pos2[0]);
  let dist2 = Math.abs(pos1[1] - pos2[1]);
  if (!dist1 || !dist2) {
    if (dist1 === 1 || dist2 === 1) {
      return dist1 !== dist2;
    }
  }
  return false;
}

function toNested(index) {
  return [ index % boardSize, Math.floor(index / boardSize) ];
}

function findZero() {
  for (let i = 0; i < board.length; ++i) {
    if (board[i].value === 0) {
      return i;
    }
  }
}

function win() {
  if (!moving) {
    ctx.clearRect(0, 0, width, height);
    ctx.fillStyle = "#000";
    ctx.font = "50px Georgia";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(`You won in ${moves} ${moves === 1 ? "move" : "moves"}!`, width / 2, height / 2);
    setTimeout(() => {
      location.reload();
    }, 2500);
  }
  else {
    setTimeout(win, 150);
  }
}

function drawAll() {
  ctx.clearRect(0, 0, width, height);
  board.forEach(block => {
    block.draw();
  });
}

function update() {
  drawAll();
  if (checkWin()) {
    setTimeout(() => {
      canvas.removeEventListener("click", handleClick);
      setTimeout(win, 1000);
    }, 200);
  }
  document.getElementById("moves").innerHTML = "Moves: " + moves.toString();
}

function checkWin() {
  let noZ = board.slice(0);
  noZ.splice(findZero(), 1);
  for (let i = 1; i < noZ.length; ++i) {
    if (noZ[i].value < noZ[i - 1].value) {
      return false;
    }
  }
  return true;
}

const canvas = document.getElementById("display");
const ctx = canvas.getContext("2d");
let width,
    height,
    boardSize,
    board,
    moves,
    moving;

//new
function setDimensions() {
  canvas.width = Math.min(window.innerWidth - 40, 500);
  canvas.height = Math.min(window.innerHeight - 20, 500);
  width = canvas.width;
  height = canvas.height;
}
setDimensions();

window.addEventListener("resize", function () {
  setDimensions();
  let w = width / boardSize,
      h = height / boardSize;
  setUpBoard(board);
  update();
});

function setUpBoard(board) {
  for (let i = 0; i < board.length; ++i) {
    let pos = toNested(i),
        w = width / boardSize,
        h = height / boardSize;
    board[i].setDrawSettings(pos[0] * w, pos[1] * h, w, h);
  }
}

function init() {
  moving = 0;
  boardSize = parseInt(document.getElementById("size").value);
  if (boardSize < 2 || boardSize > 5 || isNaN(boardSize)) {
    boardSize = 4;
  }
  board = Array.from(Array(boardSize ** 2).keys());
  moves = 0;
  shuffle(board);
  for (let i = 0; i < board.length; i++) {
    board[i] = new Block(board[i]);
  }
  setUpBoard(board);
  update();
}

function handleClick(e) {
  if (!moving) {
    let rect = canvas.getBoundingClientRect();
    for (let i = 0; i < board.length; ++i) {
      if (board[i].collidePoint(e.clientX - rect.x, e.clientY - rect.y)) {
        let zIndex = findZero();
        if (isValidNeighbor(i, zIndex)) {
          moves++;
          let tempPos = {
            x: board[i].x,
            y: board[i].y,
          };
          board[i].sendTo(board[zIndex]);
          board[zIndex].sendTo(tempPos);
          
          let temp = board[i];
          board[i] = board[zIndex];
          board[zIndex] = temp;
        }
        break;
      }
    }
    update();
  }
}

canvas.addEventListener("click", handleClick);

document.getElementById("reset").onclick = init;
init();
html {
  height: 100%;
  display: grid;
}

body {
  background-color: #fac4af;
  margin: auto;
}

#menu input, #menu button, body {
  font-family: Georgia;
}

#board {
  display: inline-block;
  background-image: url("https://15-puzzle-game--joyalmathew.repl.co/board.jpeg");
  padding: 10px;
  box-shadow: 10px 20px #9b857a;
  border: 1px solid black;
  border-radius: 15px;
  max-width: 500px;
}

#display {
  margin-left: 0px;
  float: right;
}

#menu {
  font-size: 15pt;
  float: left;
  margin-right: 0px;
  box-sizing: border-box;
  margin-top:10px;
}

#menu p {
  margin-top: 1px;
  margin-bottom: 1px;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>15 Puzzle Game</title>
    <link href="style.css" rel="stylesheet" type="text/css" />
  </head>
  <body>
    <div id="board">
      <canvas id="display" width="500" height="500"></canvas>
      <div id="menu">
        <p id="moves">Moves: </p><br><br>
        <p>Try getting the blocks into ascending order: left to right, top to bottom.</p>
        <p>Click any block next to the space to swap them.</p>
        <br>
        Size: <input id="size" type="number" min="2" max="5" value="4">
        <button id="reset">Reset</button>
      </div>
    </div>
    <script src="script.js"></script>
  </body>
</html>

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.