用 Canvas 打造超炫五子棋游戏:代码深度解析之旅

392 阅读11分钟

DOM 结构搭建:游戏的基石

这里,我们精心定义了一系列响应式数据,它们如同游戏的 “神经中枢”,掌控着游戏的各个关键环节。canvasRef 和 context2D 负责与页面的 canvas 元素交互,获取绘图的能力;isMe 决定着回合的归属;chessBoard 记录着棋盘上的每一步落子;winscountmeWin 和 AIWin 则紧密协作,为判断游戏胜负提供依据;gameOver 则是游戏结束的标志。在组件挂载后,我们确保能够正确获取 canvas 的绘图上下文,为后续的精彩绘图操作做好充分准备。

<script setup lang="ts">
// 用于获取 canvas 元素的引用,初始化为 null
const canvasRef = ref<HTMLCanvasElement|null>(null);
// 保存 canvas 的 2D 绘图上下文,初始为 null
const context2D = ref<CanvasRenderingContext2D|null>(null);
// 标记是否轮到玩家(“我”)落子,初始是玩家回合
const isMe = ref(true);
// 二维数组,记录棋盘上每个位置的状态,0 表示无子 1 表示玩家 2 表示AI
const chessBoard = ref<number[][]>([]);
// 三维数组,存储所有可能的赢法集合
const wins = ref<boolean[][][]>([]);
// 赢法的总数,初始为 0
const count = ref(0);
// 记录玩家在每种赢法下的落子数量,用于判断输赢
const meWin = ref<number[]>([]);
// 记录 AI 在每种赢法下的落子数量,用于判断输赢
const AIWin = ref<number[]>([]);
// 标记游戏是否结束,初始未结束
const gameOver = ref(false);

onMounted(() => {
  if (canvasRef.value){
      context2D.value = canvasRef.value.getContext("2d");
      drawChessBoard();
  };
})
</script>
<template>
  <canvas class="board" ref="canvasRef" width="450px" height="450px"></canvas>
</template>

绘制棋盘:打造对战舞台

在这个函数中,我们巧妙地运用了 canvas 的绘图 API。首先,设定好线条的颜色,然后通过两层循环,分别绘制纵线和横线。对于每条线,我们精确地计算起点和终点的坐标,从棋盘的内边距(四边各留 15px)开始,以 30px 的均匀间隔绘制,最终呈现出一个标准的五子棋棋盘,为棋子的落子提供了清晰的 “战场”。

// 绘制棋盘的函数
const drawChessBoard = () => {
  // 设置棋盘线条颜色为浅灰色
  context2D.value!.strokeStyle = "#BFBFBF";
  // 循环绘制纵线
  for (let i = 0; i < 15; i++) {
    // 移动到纵线起点
    context2D.value!.moveTo(15 + i * 30, 15);
    // 绘制纵线到终点
    context2D.value!.lineTo(15 + i * 30, 435);
    // 绘制线条
    context2D.value!.stroke();
  }
  // 循环绘制横线
  for (let i = 0; i < 15; i++) {
    // 移动到横线起点
    context2D.value!.moveTo(15, 15 + i * 30);
    // 绘制横线到终点
    context2D.value!.lineTo(435, 15 + i * 30);
    // 绘制线条
    context2D.value!.stroke();
  }
}

绘制棋子:落子的艺术呈现

在这个函数里,我们先开启绘图路径,然后使用 arc 方法绘制出圆形的棋子轮廓。为了让棋子更加美观逼真,我们运用了 createRadialGradient 方法创建了径向渐变效果,根据传入的参数判断是玩家棋子还是 AI 棋子,分别设置不同的颜色渐变,从圆心向外扩散,模拟出棋子的立体感。最后,通过设置填充样式并填充路径,将精美的棋子呈现在棋盘上,每一次落子都仿佛是一场艺术的创作。

// 绘制棋子的函数
const drawPieces = (i: number, j: number, me: boolean) => {
  // 开始绘制路径
  context2D.value!.beginPath();
  // 绘制圆形棋子,圆心根据棋盘格位置计算,半径为 13px
  context2D.value!.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);
  // 闭合路径
  context2D.value!.closePath();
  // 创建径向渐变,用于棋子的颜色填充,实现立体效果
  const gradient = context2D.value!.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 1, 0);
  if (me) {
    // 玩家棋子的颜色渐变设置,从黑色到深灰色
    gradient.addColorStop(0, "#0A0A0A");
    gradient.addColorStop(1, "#636766");
  } else {
    // AI 棋子的颜色渐变设置,从浅灰色到白色
    gradient.addColorStop(0, "#D1D1D1");
    gradient.addColorStop(1, "#F9F9F9");
  }
  // 设置填充样式为渐变
  context2D.value!.fillStyle = gradient;
  // 填充棋子颜色
  context2D.value!.fill();
}

初始化棋盘与赢法:布局胜负规则

initializeBoard 函数通过两层嵌套循环,将 chessBoard 二维数组的每个元素初始化为 0,代表棋盘上所有位置初始均无子。而 initializeWinningPatterns 函数则是整个游戏胜负判断的关键所在。它通过多层嵌套循环,仔细地构建了所有可能的赢法集合,包括横线、纵线、斜线和反斜线这四种情况,每种赢法在 wins 三维数组中都有对应的位置标记为 true,同时记录赢法的总数 count。最后,将 meWin 和 AIWin 数组的每个元素初始化为 0,为后续统计双方在各种赢法下的落子情况做好铺垫,确保游戏的胜负判断能够准确无误地进行。

// 初始化棋盘可落子节点的函数
const initializeBoard = () => {
  for (let i = 0; i < 15; i++) {
    // 为每一行创建一个空数组
    chessBoard.value[i] = [];
    for (let j = 0; j < 15; j++) {
      // 初始化每个棋盘格为无子状态(0)
      chessBoard.value[i][j] = 0;
    }
  }
}

// 初始化所有赢法集合的函数
const initializeWinningPatterns = () => {
  for (let i = 0; i < 15; i++) {
    wins.value[i] = [];
    for (let j = 0; j < 15; j++) {
      wins.value[i][j] = [];
    }
  }
  // 初始化横线赢法
  for (let i = 0; i < 15; i++) {
    for (let j = 0; j < 11; j++) {
      for (let k = 0; k < 5; k++) {
        wins.value[i][j + k][count.value] = true;
      }
      count.value++;
    }
  }
  // 初始化纵线赢法
  for (let i = 0; i < 15; i++) {
    for (let j = 0; j < 11; j++) {
      for (let k = 0; k < 5; k++) {
        wins.value[j + k][i][count.value] = true;
      }
      count.value++;
    }
  }
  // 初始化斜线赢法
  for (let i = 0; i < 11; i++) {
    for (let j = 0; j < 11; j++) {
      for (let k = 0; k < 5; k++) {
        wins.value[i + k][j + k][count.value] = true;
      }
      count.value++;
    }
  }
  // 初始化反斜线赢法
  for (let i = 0; i < 11; i++) {
    for (let j = 14; j > 3; j--) {
      for (let k = 0; k < 5; k++) {
        wins.value[i + k][j - k][count.value] = true;
      }
      count.value++;
    }
  }
  // 初始化玩家和 AI 的每种赢法统计数组
  for (let i = 0; i < count.value; i++) {
    meWin.value[i] = 0;
    AIWin.value[i] = 0;
  }
}

检查赢的状态:胜负一念之间

在这个函数中,我们遍历所有的赢法集合,当棋子落在与某个赢法相关的位置时(wins[i][j][k] 为 true),根据落子的玩家(player),在对应的 meWin 或 AIWin 数组中增加该赢法的统计数量,并将对方在该赢法下的统计置为无效(-1)。然后,检查统计数量是否达到了获胜所需的 5 颗棋子,如果满足条件,则返回 true,表示该玩家获胜;如果遍历完所有赢法后都未满足获胜条件,则返回 false,游戏继续进行,胜负就在这一次次的检查中逐渐明晰。

// 检查赢的状态的函数
const checkWin = (i: number, j: number, player: 1 | 2) => {
  for (let k = 0; k < count.value; k++) {
    if (wins.value[i][j][k]) {
      if (player === 1) {
        // 玩家落子后,对应赢法的统计数量加 1
        meWin.value[k]++;
        // 将 AI 在该赢法下的统计置为 -1,表示无效
        AIWin.value[k] = -1;
        // 判断玩家是否在该赢法下达到 5 颗棋子,即获胜
        if (meWin.value[k] >= 5) {
          return true;
        }
      } else {
        // AI 落子后,对应赢法的统计数量加 1
        AIWin.value[k]++;
        // 将玩家在该赢法下的统计置为 -1,表示无效
        meWin.value[k] = -1;
        // 判断 AI 是否在该赢法下达到 5 颗棋子,即获胜
        if (AIWin.value[k] >= 5) {
          return true;
        }
      }
    }
  }
  return false;
}

落子逻辑:游戏的核心交互

在 placePiece 函数中,首先进行了落子的合法性检查,确保棋子只能落在空的棋盘格上,并且游戏未结束。然后,调用 drawPieces 函数绘制出相应的棋子,并更新 chessBoard 数组记录落子情况。接着,通过 checkWin 函数检查此次落子是否导致游戏胜负结果的产生,如果有玩家获胜,则设置 gameOver 为 true,并在控制台输出获胜信息。如果游戏仍未结束,通过切换 isMe 的值来实现回合的交替,并且当玩家落子后,立即调用 computerAI 函数,让 AI 进行下一步落子决策,整个游戏流程在这一函数中流畅地运转起来。

// 落子的函数
const placePiece = (i: number, j: number, player: 1 | 2) => {
  // 检查该位置是否可以落子以及游戏是否已结束
  if (chessBoard.value[i][j]!== 0 || gameOver.value) return;
  // 绘制棋子
  drawPieces(i, j, player === 1);
  // 更新棋盘状态,记录落子情况
  chessBoard.value[i][j] = player;
  // 检查落子后是否有玩家获胜
  if (checkWin(i, j, player)) {
    gameOver.value = true;
    console.log(player === 1? "黑棋赢了🥇" : "白棋赢了✌️");
  }
  // 如果游戏未结束,切换回合
  if (!gameOver.value) {
    isMe.value =!isMe.value;
    // 如果是玩家落子后,轮到 AI 落子
    if (player === 1) {
      computerAI();
    }
  }
}

玩家与 AI 落子交互:人机对决的智慧碰撞

玩家通过鼠标点击棋盘进行落子,而 AI 则通过复杂的算法来选择落子位置,这两者的交互构成了游戏的精彩对战过程。

玩家点击落子

在 handleClick 函数中,当玩家点击 canvas 棋盘时,首先检查是否轮到玩家回合(isMe 为 true),如果是,则获取点击位置的坐标,并通过简单的数学计算将其转换为棋盘格的索引值,最后调用 placePiece 函数完成玩家的落子操作,整个过程简洁明了,符合玩家的操作习惯。

// 玩家落子的点击事件处理函数
const handleClick = (event: MouseEvent) => {
  // 检查是否轮到玩家落子
  if (!isMe.value) return;
  // 获取鼠标点击在 canvas 上的坐标
  const x = event.offsetX;
  const y = event.offsetY;
  // 计算棋子在棋盘格中的位置索引
  const i = Math.floor(x / 30);
  const j = Math.floor(y / 30);
  // 执行落子操作
  placePiece(i, j, 1);
}

AI 落子决策

// AI 落子的函数
const computerAI = () => {
    // 用于记录每个空棋盘格对于玩家的得分
    const meScore: number[][] = [];
    // 用于记录每个空棋盘格对于 AI 的得分
    const AIScore: number[][] = [];
    let max = 0;
    let u = 0;
    let v = 0;
    // 初始化得分数组
    for (let i = 0; i < 15; i++) {
        meScore[i] = [];
        AIScore[i] = [];
        for (let j = 0; j < 15; j++) {
            meScore[i][j] = 0;
            AIScore[i][j] = 0;
        }
    }
    // 遍历棋盘上的每个空棋盘格
    for (let i = 0; i < 15; i++) {
        for (let j = 0; j < 15; j++) {
            if (chessBoard.value[i][j] === 0) {
                // 对于每个空棋盘格,检查其在所有赢法中的情况
                for (let k = 0; k < count.value; k++) {
                    if (wins.value[i][j][k]) {
                        // 根据玩家和 AI 在该赢法下的已有落子情况,计算得分
                        if (meWin.value[k] === 1) {
                            meScore[i][j] += 200;
                        } else if (meWin.value[k] === 2) {
                            meScore[i][j] += 400;
                        } else if (meWin.value[k] === 3) {
                            meScore[i][j] += 2000;
                        } else if (meWin.value[k] === 4) {
                            meScore[i][j] += 10000;
                        }

                        if (AIWin.value[k] === 1) {
                            AIScore[i][j] += 220;
                        } else if (AIWin.value[k] === 2) {
                            AIScore[i][j] += 420;
                        } else if (AIWin.value[k] === 3) {
                            AIScore[i][j] += 2200;
                        } else if (AIWin.value[k] === 4) {
                            AIScore[i][j] += 20000;
                        }
                    }
                }
                // 比较得分,更新最大值及对应的位置
                if (meScore[i][j] > max) {
                    max = meScore[i][j];
                    u = i;
                    v = j;
                } else if (meScore[i][j] === max) {
                    if (AIScore[i][j] > AIScore[u][v]) {
                        u = i;
                        v = j;
                    }
                }

                if (AIScore[i][j] > max) {
                    max = AIScore[i][j];
                    u = i;
                    v = j;
                } else if (AIScore[i][j] === max) {
                    if (meScore[i][j] > meScore[u][v]) {
                        u = i;
                        v = j;
                    }
                }
            }
        }
    }
    // AI 在选择的最佳位置落子
    placePiece(u, v, 2);
}
  1. 得分数组初始化
    首先,创建了两个二维数组 meScore 和 AIScore,用于分别记录棋盘上每个空位置对于玩家和 AI 的得分情况,初始值都设为 0。同时,定义了变量 max 用于记录当前找到的最大得分,u 和 v 用于记录具有最大得分的棋盘格位置坐标,初始化为 0
  2. 遍历空棋盘格
    通过两层嵌套循环遍历整个棋盘上的每个位置,当发现某个位置 chessBoard.value[i][j] 为 0,即该位置为空时,进入下一步的得分计算逻辑。
  3. 基于赢法计算得分
    针对每个空棋盘格,再遍历所有的赢法(通过 count.value 控制循环次数)。当该空棋盘格处于某种赢法中(wins.value[i][j][k] 为 true)时,根据玩家(meWin 数组)和 AI(AIWin 数组)在该赢法下已有的落子数量来为对应的 meScore 和 AIScore 数组元素增加得分。例如,如果玩家在某赢法下已经有 1 颗棋子(meWin.value[k] === 1),就给该空位置在 meScore 中的对应元素增加 200 分,以此类推,不同的落子数量对应不同的分值,体现了该位置对于形成获胜局面的潜在价值,AI 的得分计算同理。
  4. 确定最佳落子位置
    在计算完每个空位置的得分后,通过一系列比较逻辑来确定最佳落子位置。首先,比较 meScore 数组中的得分,如果某个位置的 meScore 值大于当前记录的最大得分 max,则更新 max 以及对应的位置坐标 u 和 v;如果得分相等,则进一步比较该位置的 AIScore 值,选择 AIScore 更大的位置。同样地,也会比较 AIScore 数组中的得分情况来确定最佳位置,若 AIScore 值更大或者相等但对应 meScore 值更大时,更新最佳位置坐标。
  5. 执行落子操作
    经过上述复杂的评估和比较过程,最终确定了 AI 认为的最佳落子位置 (u, v),然后调用 placePiece 函数,传入 AI 对应的标识 2,在该位置完成 AI 的落子操作,使游戏继续推进下去。

通过这样一套目前看来合理且易于实现的简单算法,AI 得以初步具备模拟策略性落子行为的能力。不过,这仅仅是一个基础框架,仍存在很大的优化空间。


感谢阅读,敬请斧正!