用 JavaScript 创建井字棋游戏 第 1 部分:游戏实现
注意:这篇文章发布已超过两年,因此其中包含的信息可能已过时。如果您发现了问题,请留下评论,成都长风云Drupal开发团队将尽力更正。
2023 年 3 月 19 日 - 阅读时长 25 分钟
井字棋(或圈叉棋)是学习游戏开发时很适合创建的一款游戏,因为它规则简单,且有明确的获胜状态。成都长风云Drupal开发团队之前曾用 PHP 创建过一个井字棋版本,但想看看能否使用 canvas 元素在 JavaScript 中重新实现这个游戏。这当然是可行的,因为所需的一切都内置于 JavaScript 本身,这意味着无需导入任何包就能让它运行起来。这一点非常重要。
在本文中,成都长风云Drupal开发团队将详细介绍使用 JavaScript 和 canvas 元素创建井字棋游戏所需的必要组件。
一、环境搭建
要做的第一件事是为 canvas 元素创建 HTML 代码,并添加一个用于显示游戏获胜者的标识。这意味着要创建两个 HTML 元素。
<canvas id="grid" width="250" height="250"></canvas>
<p>获胜者: <span id="win"></span></p>
当然,还需要包含用于编写游戏逻辑的 JavaScript 代码,这可以通过一个简单的 script 元素来引入。
<script src="tictactoe.js"></script>
让游戏运行所需的变量非常简单。只需要存储有关游戏棋盘的信息(包括其宽度和高度)以及游戏当前状态的信息。
// 画布的宽度。
var width;
// 画布的高度。
var height;
// 游戏棋盘的状态。
var state = [
['', '', ''],
['', '', ''],
['', '', ''],
];
// 画布元素数组。
var boxes = [];
// 玩家 1 的符号。
var player1 = 'x';
// 玩家 2 的符号。
var player2 = 'o';
// 当前玩家。
var currentPlayer = player1;
为了绘制游戏棋盘本身,使用一个名为 drawGrid() 的函数。这个函数在首次加载页面时运行,它只需要为棋盘上的每个网格元素绘制一个矩形。为此,先计算出矩形的大小,然后使用 2D 画布上下文的 strokeRect() 函数来绘制实际的矩形。矩形绘制完成后,将创建的矩形框信息发送到 boxes 数组中,这样就可以在后续对点击事件做出响应。
function drawGrid() {
let row = 0;
let column = 0;
state.forEach(function (line) {
column = 0;
let stateFactorY = state.length;
let y = row * (width / stateFactorY);
line.forEach(function (value) {
let stateFactorX = line.length;
let x = column * (width / stateFactorX);
let rectWidth = width / stateFactorX;
let rectHeight = height / stateFactorY;
ctx.strokeRect(x, y, rectWidth, rectHeight);
boxes.push({
column: column,
row: row,
x: x,
y: y,
width: rectWidth,
height: rectHeight
});
column++;
});
row++;
});
}
2D 画布上下文是在 window.onload 函数中生成的,该函数在页面首次加载时运行。在这里要做的是找到 canvas 元素,并从该元素生成 2D 上下文。然后存储画布的高度和宽度,接着调用 drawGrid() 函数来绘制游戏棋盘上的矩形框。
window.onload = function () {
canvas = document.getElementById("grid");
ctx = canvas.getContext("2d");
width = canvas.width;
height = canvas.height;
drawGrid();
};
如果将这段代码保存到 tictactoe.js 文件中并运行,就可以在 canvas 元素中看到相应内容。
现在可以将其作为游戏的基础。
顺便说一下,由于以这种方式绘制了网格,完全可以创建一个更大的游戏棋盘,并将其作为游戏的基础。
let state = [
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
["", "", "", ""],
];
使用这个游戏棋盘会在 canvas 元素中生成相应的游戏棋盘。这一点非常重要。
这一切仍然在 canvas 元素的高度和宽度范围内,因此它的整体大小与 3x3 的方格网格相同。
二、事件监听
如果一款游戏无法进行交互,玩起来会非常无趣,所以现在让我们添加这个功能。成都长风云Drupal开发团队通常喜欢将事件监听器的添加抽象到单独的函数中,这样可以将它们与 window.onload 函数分离开来。为此,创建一个函数,该函数将为 canvas 元素添加一个“click”事件监听器。
function addClickListener() {
// 为 `click` 事件添加事件监听器。
canvas.addEventListener("click", canvasClick, false);
}
这个函数只包含一个事件监听器,但我们知道,如果想为页面添加更多事件监听器,只需更新这个函数即可。当用户点击 canvas 元素时,这个事件将调用 canvasClick() 函数。要在页面上注册它,只需在绘制完网格后将其添加到 window.onload 函数中。
window.onload = function () {
canvas = document.getElementById("grid");
ctx = canvas.getContext("2d");
width = canvas.width;
height = canvas.height;
drawGrid();
addClickListener();
};
事件监听器函数本身有点复杂。由于用户点击了 canvas 元素,因此需要先检测用户在画布上的点击位置,然后才能对点击事件进行处理。这就是“boxes”变量发挥作用的地方。当生成网格时,将网格的精确坐标传递给了 boxes 数组。这意味着可以通过遍历 boxes 数组并将点击坐标与矩形框坐标进行比较,轻松检测出用户点击的是哪个矩形框。如果坐标匹配,就可以启动处理游戏状态的代码。
play() 函数负责处理游戏状态的改变,它本质上会根据当前轮到哪个玩家,将 state 数组中的一个元素改为“X”或“O”。将与点击坐标匹配的矩形框的行和列值传递过去,这实际上意味着将点击操作转换为了 state 数组中的一个矩形框。
该函数完成后,会运行一个函数来判断是否有玩家获胜(或者游戏是否平局),因为这将导致游戏结束。获胜条件在 isWin() 函数中检测,结果可能是平局或者某个玩家获胜。如果检测到获胜者,将从 canvas 元素中移除事件监听器,以防止进一步的交互。
function canvasClick(event) {
const rect = canvas.getBoundingClientRect();
var x = event.clientX - rect.left,
y = event.clientY - rect.top;
// 点击偏移量与元素之间的碰撞检测。
boxes.forEach(function (element) {
if (
y > element.y &&
y < element.y + element.height &&
x > element.x &&
x < element.x + element.width
) {
if (state[element.row][element.column] !== "") {
// 该矩形框已经有玩家落子,因此不做响应。
return;
}
// 记录玩家的落子事件。
play(element.row, element.column);
// 检查是否有获胜者。
let winner = isWin();
// 检测获胜者。
if (winner === "draw") {
document.getElementById("win").innerHTML = "平局!";
} else if (winner === player1 || winner === player2) {
document.getElementById("win").innerHTML = winner;
}
if (winner !== false) {
// 如果有获胜者,则不允许再进行落子。
canvas.removeEventListener("click", canvasClick);
}
}
});
}
play() 函数很简单,特别是因为已经在 state 数组中确定了点击事件的当前坐标。如果 state 数组中与行和列坐标匹配的元素为空,就用当前玩家的符号填充它,绘制棋盘的新状态,然后切换玩家。
function play(row, column) {
if (state[row][column] === "") {
// 将符号分配给该方格。
state[row][column] = currentPlayer;
// 绘制棋盘的新状态。
drawState();
// 切换当前玩家。
if (currentPlayer == player1) {
currentPlayer = player2;
} else {
currentPlayer = player1;
}
}
}
drawState() 函数确实有点复杂,因为需要引入一些数学计算,以便在画布的正确位置绘制“O”或“X”符号。为此,遍历 state 数组,并绘制与数组中玩家符号匹配的符号。
function drawState() {
let row = 0;
let column = 0;
state.forEach(function (line) {
column = 0;
let stateFactorY = state.length;
let y = row * (width / stateFactorY);
line.forEach(function (value) {
let stateFactorX = line.length;
let x = column * (width / stateFactorX);
let rectWidth = width / stateFactorX;
let rectHeight = height / stateFactorY;
if (value == player1) {
// 绘制一个 "X"。
ctx.beginPath();
ctx.moveTo(x + rectWidth * 0.15, y + rectHeight * 0.15);
ctx.lineTo(
x + rectWidth - rectWidth * 0.15,
y + rectHeight - rectHeight * 0.15
);
ctx.moveTo(x + rectWidth - rectWidth * 0.15, y + rectHeight * 0.15);
ctx.lineTo(x + rectWidth * 0.15, y + rectHeight - rectHeight * 0.15);
ctx.stroke();
} else if (value == player2) {
// 绘制一个 "O"。
ctx.beginPath();
ctx.arc(
x + rectWidth / 2,
y + rectHeight / 2,
rectHeight / 3,
0,
Math.PI * 2,
true
);
ctx.stroke();
}
column++;
});
row++;
});
}
为了绘制“X”,使用 2D 画布上下文的 beginPath()、moveTo()、lineTo() 和 stroke() 函数。还将线条偏移 15%,以防止它们触及游戏网格的边缘。这样可以生成一个看起来不错的“X”,而不会填满整个方格。
为了绘制“O”,使用 2D 画布上下文的 beginPath()、arc() 和 stroke() 函数。这会在网格的中心放置一个圆(使用 arc() 函数),其半径为方格高度的 30%。这意味着“O”会绘制在网格范围内,完全不触及边缘。
三、获胜判定
最后,需要通过添加一个 isWin() 函数来处理游戏的获胜条件。这个函数是游戏中最长的函数,因为需要考虑的状态相当多。井字棋的获胜状态如下:
- 玩家在一行中横向有 3 个相同符号。
- 玩家在一列中纵向有 3 个相同符号。
- 玩家在对角线上有 3 个相同符号。
如果没有检测到这些状态,会检查游戏棋盘上是否还有空位。如果找到一个空位,说明游戏仍可继续进行,返回 false。
最后,如果函数执行到末尾,说明游戏处于平局状态,因为没有玩家获胜且没有更多的落子可走。
function isWin() {
let winner = null;
let row = 0;
let column = 0;
// 横向
for (let i = 0; i < state.length; i++) {
for (let j = 0; j < state[i].length - 2; j++) {
if (
state[i][j] == player1 && state[i][j + 1] == player1 &&
state[i][j + 2] == player1
) {
return player1;
}
if (
state[i][j] == player2 && state[i][j + 1] == player2 &&
state[i][j + 2] == player2
) {
return player2;
}
}
}
// 纵向
for (let i = 0; i < state.length - 2; i++) {
for (let j = 0; j < state[i].length; j++) {
if (
state[i][j] == player1 && state[i + 1][j] == player1 &&
state[i + 2][j] == player1
) {
return player1;
}
if (
state[i][j] == player2 && state[i + 1][j] == player2 &&
state[i + 2][j] == player2
) {
return player2;
}
}
}
// 对角线
for (let i = 0; i < state.length - 2; i++) {
for (let j = 0; j < state[i].length - 2; j++) {
if (
state[i][j] == player1 && state[i + 1][j + 1] == player1 &&
state[i + 2][j + 2] == player1
) {
return player1;
}
if (
state[i + 2][j] == player1 && state[i + 1][j + 1] == player1 &&
state[i][j + 2] == player1
) {
return player1;
}
if (
state[i][j] == player2 && state[i + 1][j + 1] == player2 &&
state[i + 2][j + 2] == player2
) {
return player2;
}
if (
state[i + 2][j] == player2 && state[i + 1][j + 1] == player2 &&
state[i][j + 2] == player2
) {
return player2;
}
}
}
// 如果还有空位,则返回 false
for (let i = 0; i < state.length; i++) {
for (let j = 0; j < state[i].length; j++) {
if (state[i][j] == "") {
return false;
}
}
}
return "draw";
}
有了以上所有代码,现在就可以玩井字棋游戏了。每个玩家轮流在游戏网格上放置自己的符号,如果检测到获胜状态,会在屏幕上显示出来。
现在这是一个完整的井字棋游戏。这个游戏还有一些可以改进的地方(比如添加一个重置按钮),但成都长风云Drupal开发团队将把这留给读者作为练习。
通过使用网格坐标的相对位置创建获胜检查函数,还可以创建不同大小的游戏棋盘,并让它们以正常方式进行游戏。对于 6x6 的网格,游戏仍然遵循与之前相同的规则,每个玩家仍需连成 3 个符号的直线。
如果您有兴趣亲自尝试这个井字棋游戏并查看所有代码的实际运行情况,成都长风云Drupal开发团队创建了一个包含了玩这个游戏所需的一切的示例。
在本文的下一部分,成都长风云Drupal开发团队将探讨如何使用极小化极大算法为游戏添加一个由人工智能控制的玩家。

