使用HTML5 Canvas实现五子棋游戏(美化)

360 阅读3分钟

在之前两篇文章中,我们构建了一个基础的五子棋游戏,包括棋盘绘制、鼠标点击事件处理、检查获胜条件以及禁手的基本逻辑。然而只有黑白线条和棋子太过单调,现在,我们将对其进行美化并加入声音,让我们下棋更有“感觉”。

首先我们需要一些素材

喜欢下围棋的小伙伴可能都知道星阵围棋,我们直接借用期棋盘背景及落子声音等。

111.jpg

加入棋盘背景、棋子图片元素

棋盘背景直接用css,所有canvas外加一层div用于显示背景图。并且引入黑白棋子图片备用。

<div id="board">
  <canvas id="game" width="480" height="480"></canvas>
</div>
<img id="black" style="display: none;" src="https://assets.19x19.com/img/skin/stone/shell_stb3.png" />
<img id="white" style="display: none;" src="https://assets.19x19.com/img/skin/stone/shell_stw1.png" />
#board {
  background-color: #cdb087;
  background-image: url('https://assets.19x19.com/img/skin/board/b1.png');
  background-size: 100%;
  width: 480px;
  height: 480px;
  position: relative;
  margin: 16px 0 0 16px;
}

绘制标记点、坐标和图片棋子

我们看看到围棋棋盘上有9个标记点,并且有数字和字母的坐标。因为我们的五子棋是15x15的,所有坐标点也可以只绘制5个,不过这里我们还在按照9个来绘制。修改一下之前绘制棋盘的函数和棋子绘制函数并新增绘制坐标函数。

const pieceImages = {
  black: document.getElementById('black'),
  white: document.getElementById('white')
}

// 绘制棋盘
function drawBoard() {
  ctx.lineCap = 'square';
  ctx.strokeStyle = 'black';
  for (let i = 0; i < boardSize; i++) {
    // ...之前绘制棋盘代码

    // 绘制九个标记点
    if (i === 4 || i === (boardSize + 1) / 2 || i === boardSize - 3) {
      ctx.beginPath();
      ctx.arc(cellSize * i, cellSize * i, 3, 0, 2 * Math.PI);
      if (i === 4) {
        ctx.arc(cellSize * ((boardSize + 1) / 2), cellSize * i, 3, 0, 2 * Math.PI);
        ctx.arc(cellSize * (boardSize - 3), cellSize * i, 3, 0, 2 * Math.PI);
      } else if (i === (boardSize + 1) / 2) {
        ctx.arc(cellSize * 4, cellSize * i, 3, 0, 2 * Math.PI);
        ctx.arc(cellSize * (boardSize - 3), cellSize * i, 3, 0, 2 * Math.PI);
      } else {
        ctx.arc(cellSize * ((boardSize + 1) / 2), cellSize * i, 3, 0, 2 * Math.PI);
        ctx.arc(cellSize * 4, cellSize * i, 3, 0, 2 * Math.PI);
      }
      ctx.fill();
    }
  }
}

// 绘制棋子
function drawPiece(ctx, x, y, size, color) {
  ctx.drawImage(pieceImages[color], (x + .6) * size, (y + .6) * size, size / 1.25, size / 1.25);
}

// 绘制坐标
function drawAxis() {
  const xAxis = 'ABCDEFGHIJKLMNO'.split('');
  ctx.font = `${cellSize / 3.5}px serif`;
  ctx.fillStyle = 'black';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  for (let i = 0; i < boardSize; i++) {
    let m = (i + 1) * cellSize, n = (boardSize + .5) * cellSize, d = cellSize / 2
    ctx.fillText(xAxis[i], m, d);
    ctx.fillText(xAxis[i], m, n);
    ctx.fillText(boardSize - i, d, m);
    ctx.fillText(boardSize - i, n, m);
  }
}

绘制棋子投影

  • 当鼠标在棋盘上移动时,我们希望可以看到棋子可能落下的位置,所有这里需要绘制一个棋子的投影。那是不是直接在之前的棋盘上绘制就可以呢?
  • 答案是否定的,因为鼠标移动时只显示一个投影,那么鼠标上一个坐标上的棋子都有就必须清除,而清除画布后,该位置就会空白,如果该位置时已落子坐标,那就出问题了。所有这里需要再加一个canvas叠加再之前的棋盘上,落子投影在这里绘制。
  • 并且我们需要鼠标移动时就知道该位置是否禁手,添加禁手绘制函数。
<div id="board">
  <canvas id="game" width="480" height="480"></canvas>
  <canvas id="mark" width="480" height="480"></canvas>
</div>
const mark = document.getElementById('mark');
const mctx = mark.getContext('2d');

// 绘制影子棋子
function drawMarkPiece(ctx, x, y, size, color) {
  ctx.beginPath();
  ctx.arc(x * size + size, y * size + size, size / 2.5, 0, 2 * Math.PI);
  ctx.fillStyle = color;
  ctx.fill();
}

// 绘制禁手标志
function drawNotArrowedMark(ctx, x, y, size, color) {
  ctx.fillStyle = 'white';
  ctx.strokeStyle = color;
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(x * size + size, y * size + size, size / 2.5, 0, 2 * Math.PI);
  ctx.moveTo((x + 0.75) * size, (y + 0.75) * size);
  ctx.lineTo((x + 1.25) * size, (y + 1.25) * size);
  ctx.fill();
  ctx.stroke();
}

// 处理鼠标hover事件
function handleMouseMove(event) {
  if (gameOver || event.target.nodeName.toLowerCase() !== 'canvas') {
    clearCanvas(mctx);
    return;
  }

  const rect = mark.getBoundingClientRect();
  const left = (event.clientX - rect.left - cellSize / 2) / cellSize
  const top = (event.clientY - rect.top - cellSize / 2) / cellSize
  const x = Math.floor((event.clientX - rect.left - cellSize / 2) / cellSize);
  const y = Math.floor((event.clientY - rect.top - cellSize / 2) / cellSize);

  if (x < 0 || y < 0 || x >= boardSize || y >= boardSize) {
    clearCanvas(mctx);
  }

  if (x >= 0 && y >= 0 && x < boardSize && y < boardSize && gameBoard[y][x] === null) {
    clearCanvas(mctx);

    if (currentPlayer.color === 'black' && (checkLongConnect(gameBoard, x, y, currentPlayer.color) || checkThreeOrFour(gameBoard, x, y, currentPlayer.color))) {
      drawNotArrowedMark(mctx, x, y, cellSize, 'red');
    } else {
      drawMarkPiece(mctx, x, y, cellSize, currentPlayer.color === 'black' ? 'rgba(0, 0, 0, .3)' : 'rgba(255, 255, 255, .3)');
    }

    lastHoverPoint = { x, y };
  }
}

document.addEventListener('mousemove', handleMouseMove);

绘制手数

  • 我们知道落子是交替的,落子多了就需要之前最后落子哪一颗,所有需要标记一下最后一首。如果可以显示落子顺序(手数)就更好,所有添加一个手数的绘制函数,玩家可以通过按钮控制是否显示手数。
  • 同样,我们需要新增一个canvas来绘制,并新增一个按钮控制是否显示手数。
<div id="board">
  <canvas id="game" width="480" height="480"></canvas>
  <canvas id="mark" width="480" height="480"></canvas>
  <canvas id="step" width="480" height="480"></canvas>
</div>
<button style="margin: 10px;background-color: #fff; color: #000;" onclick="showStep()">手数(123)</button>
const step = document.getElementById('step');
const nctx = step.getContext('2d');
let currentStep = 1; // 当前手数
let lastHoverPoint; // 鼠标上一次位置
let stepShow = false; // 是否开启步数显示

// 绘制手数
function drawStep(ctx, x, y, size, step, color) {
  ctx.font = `${size / 2}px serif`;
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(step, (y + 1) * size, (x + 1) * size);
}

// 标记最后一手
function drawLastPiece(ctx, x, y, size, color) {
  ctx.beginPath();
  ctx.arc(x * size + size, y * size + size, size / 10, 0, 2 * Math.PI);
  ctx.fillStyle = color;
  ctx.fill();
}

// 显示手数
function showStep() {
  let color = '';

  clearCanvas(nctx);

  if (stepShow) {
    drawLastPiece(nctx, currentPlayer.x, currentPlayer.y, cellSize, 'red');
  } else {
    gameBoard.forEach((item, index) => {
      item.forEach((entry, idx) => {
        if (entry?.step) {
          drawStep(
            nctx,
            index,
            idx,
            cellSize,
            entry.step,
            entry.step === currentStep - 1 ? 'red' : entry.color === 'black' ? 'white' : 'black'
          );
        }
      })
    })
  }

  stepShow = !stepShow;
}

添加声音

到这里,棋盘的美化基本完成了,但是游戏怎么能没有声音,我们还需要加点声音,落子声、获胜和禁手声音。

const audio = new Audio('https://assets.19x19.com/voice/play.wav'); // 落子声音
const winAudio = new Audio('https://assets.19x19.com/voice/win.wav'); // 胜利声音
const rejectAudio = new Audio('https://assets.19x19.com/voice/actionReject.wav'); // 禁止声音

 // 处理鼠标点击事件
function handleMouseClick(event) {
  // ... 之前的代码

  if (x >= 0 && y >= 0 && x < boardSize && y < boardSize && gameBoard[y][x] === null) {
    if (currentPlayer.color === 'black' && (checkLongConnect(gameBoard, x, y, currentPlayer.color) || checkThreeOrFour(gameBoard, x, y, currentPlayer.color))) {
      rejectAudio.play(); // 播放禁手声
      return;
    }

    // ... 之前的代码
    
    audio.play(); // 播放落子声
    
    // ... 之前的代码
    
    if (winner) {
      gameOver = true;
      winAudio.play(); // 播放获胜声
      openDialog({ text: winner === 'black' ? '黑方获胜' : '白方获胜' });
      return;
    }
    
    // ... 之前的代码
  }
}

结语

通过美化棋盘和增加声音,我们的五子棋游戏变得更加完善。玩家们现在能更好地享受到游戏的乐趣。

希望这篇文章能够帮助你进一步了解如何开发和完善一个五子棋游戏。如果你有任何建议或问题,欢迎在评论区交流。