介绍
欢迎回来!在上一篇博客文章中,您在其上设置了画布并创建了图纸。现在在这篇文章中,您将绘制游戏板。
游戏对象
什么是游戏对象?
游戏在画布上具有多个图纸。这些不同的图纸在水平和垂直方面都占据空间。它们可能由其他较小的相关图纸组成。这些不同的图纸将被称为“游戏对象”。
为什么我们需要游戏对象?
回顾游戏UI的故障。可以识别三个组件:
- 状态区域
- 游戏板
- 再次播放按钮
所有这些组件彼此共享以下共同点:
- 他们有位置
- 他们有尺寸
- 全部绘制在画布上
以广义的方式参考这些组件时,我们将仅考虑这些共同的特征和属性。在此游戏的上下文中,这些组件的一般名称将是“游戏对象”。上面列出的组件都是游戏对象。
要在代码中表示这种关系,您将创建一个GameObject
类。组件类将从GameObject
类继承。这将避免您重写所有组件中共享的常见逻辑和属性。
创建GameObject类
与HTML元素不同,您必须自己实现游戏对象的大小和定位。您也必须自己绘制游戏对象。
在称为components
的src
目录下创建一个目录。
在src/components
目录中,创建一个名为GameObject.js
的新文件。
在src/components/GameObject.js
中,将以下内容添加到文件:
export default class GameObject {
x;
y;
width;
height;
context; // CanvasRenderingContext2D
constructor(context, x, y, width, height) {
this.context = context;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
clear() {
this.context.clearRect(this.x, this.y, this.width, this.height);
}
}
继承GameObject类
现在,您已经在src/components
目录中创建了GameObject
类,请创建一个名为Board.js
的新文件。之后,在该文件中,创建一个从GameObject
类继承的Board
类:
import GameObject from "./GameObject.js";
export default class Board extends GameObject {
}
要测试GameObject
是否正确地由Board
继承,您将使用Board
类重新创建最后一篇文章的白色矩形绘图。
将一种称为render()
的方法添加到Board
类中,该类将呈现一个白色矩形,然后还原上下文对象的状态:
import GameObject from "./GameObject.js";
export default class Board extends GameObject {
render() {
this.context.save();
this.clear();
this.context.fillStyle = "white";
this.context.fillRect(this.x, this.y, this.width, this.height);
this.context.restore();
}
}
请注意,x
,y
,width
和height
字段如何在Board
中定义。这是因为它们是从GameObject
继承的。
在src/components
中创建一个名为index.js
的新文件,并用以下内容填充它:
import Board from "./Board.js";
export { Board };
此代码块以上代码块将Board
类作为模块导出,从而可以从src/components/index.js
导入。将来会有更多的组件。此更改将简化随着时间的推移导入多个组件所需的代码。
返回FrontEnd
类。从src/components/index.js
导入Board
类:
import { FrontEndConfig } from "./constants/index.js";
import { Board } from "./components/index.js";
添加一个名为board
的字段:
export default class FrontEnd {
game;
canvas;
width;
height;
context;
board;
// ...
}
在Board
类中重写start()
方法:
- 将
board
字段设置为Board
类的新实例 - 在
board
上致电render()
方法
export default class FrontEnd {
// ..
start() {
this.board = new Board(this.context, 20, 20, 50, 100);
this.board.render();
}
}
请注意,Board
使用GameObject
中定义的相同构造函数。
如果您使用Web服务器检查游戏,您会在画布上看到与您在上一篇博客文章中绘制的那个相同的白色矩形。
绘制游戏板
真棒!您已经弄清楚了如何创建自己的游戏对象并将其绘制在画布上。现在是时候绘制实际的游戏板了。
董事会背景
在src/components/Board.js
中,从常数文件导入BoardConfig
:
import GameObject from './GameObject.js';
import { BoardConfig } from '../constants/index.js';
然后在称为renderBoardBackground()
的Board
类中创建一个新方法。
之后,执行以下步骤:
- 将将白色矩形绘制到
renderBoardBackground()
方法的代码 - 用导入的
BoardConfig
对象中定义的BACKGROUND_COLOR
字段替换填充样式。
import GameObject from "./GameObject.js";
import { BoardConfig } from "../constants/index.js";
export default class Board extends GameObject {
render() {
this.context.save();
this.clear();
this.renderBoardBackground();
this.context.restore();
}
renderBoardBackground() {
this.context.fillStyle = BoardConfig.BACKGROUND_COLOR;
this.context.fillRect(this.x, this.y, this.width, this.height);
}
}
在FrontEnd
类中,从constants
文件导入BoardConfig
:
import { FrontEndConfig, BoardConfig } from "./constants/index.js";
添加一种称为createBoard()
的方法,然后执行以下操作:
- 在
createBoard()
中,创建一个存储Board
类的新实例的局部变量,调用render()
方法,然后返回。 - 重写
start()
方法,以便将board
字段设置为从调用createBoard()
方法返回的结果
export default class FrontEnd {
// ..
start() {
this.board = this.createBoard();
}
createBoard() {
let board = new Board(this.context, BoardConfig.MARGIN_LEFT, BoardConfig.MARGIN_TOP, BoardConfig.WIDTH, BoardConfig.HEIGHT);
board.render();
return board;
}
}
现在创建了板的定位和尺寸。
现在,在运行Web服务器时,如果您在浏览器中查看游戏,您会发现蓝色矩形已在画布上呈现。
板插槽
现在您准备好绘制板上的插槽。
添加一个称为nextBoard
的参数到Board
类'render()
方法:
export default class Board extends GameObject {
render(nextBoard) {
this.context.save();
this.clear();
this.renderBoardBackground();
this.context.restore();
}
// ..
nextBoard
将包含董事会状态。一个代表每个董事会位置的令牌的数组数组。这些将用于在插槽中渲染董事会令牌。
接下来,将一种称为renderSlots()
的方法创建为Board
类。为了使即将到来的绘图命令简单,您将修改开始绘制插槽的原始点。这样,您就不必考虑在renderSlots()
中的以后的绘制命令中进行填充。为此,您将使用koude63方法:
export default class Board extends GameObject {
// ..
renderSlots(nextBoard) {
this.context.translate(this.x + BoardConfig.HORIZONTAL_PADDING, this.y + BoardConfig.VERTICAL_PADDING);
}
开始绘制插槽。
设置冲程颜色和线宽:
this.context.strokeStyle = BoardConfig.SLOT_OUTLINE_COLOR;
this.context.lineWidth = 2;
将插槽的半径存储在称为slotRadius
的局部变量中:
const slotRadius = BoardConfig.SLOT_WIDTH / 2;
在src/components/Board.js
中更新导入语句以在constants
目录中包括TokenColor
和gameLogic
目录中的Constants
:
import { BoardConfig, TokenColor } from "../constants/index.js";
import { Constants } from "../gameLogic/index.js";
import GameObject from "./GameObject.js";
循环遍历板上的每个插槽,
- 计算定位:
- 获得令牌颜色以呈现插槽
for (let rowIndex = 0; rowIndex < Constants.BoardDimensions.ROWS; rowIndex++) {
for (let columnIndex = 0; columnIndex < Constants.BoardDimensions.COLUMNS; columnIndex++) {
// Note slot is a circle. (x, y) coordinates are the circle's centre.
const totalSlotMarginsX = BoardConfig.SLOT_MARGIN * columnIndex;
const totalPreviousSlotWidthsX = BoardConfig.SLOT_WIDTH * columnIndex;
const slotX = totalSlotMarginsX + totalPreviousSlotWidthsX + slotRadius;
const totalSlotMarginsY = BoardConfig.SLOT_MARGIN * rowIndex;
const totalPreviousSlotWidthsY = BoardConfig.SLOT_WIDTH * rowIndex;
const slotY = totalSlotMarginsY + totalPreviousSlotWidthsY + slotRadius;
const tokenColorValue = nextBoard[rowIndex][columnIndex];
let tokenColor;
switch (tokenColorValue) {
case Constants.BoardToken.YELLOW:
tokenColor = TokenColor.YELLOW;
break;
case Constants.BoardToken.RED:
tokenColor = TokenColor.RED;
break;
default:
tokenColor = TokenColor.NONE
break;
}
}
}
您现在拥有渲染每个插槽所需的值。为此,将一种称为renderSlot()
的方法添加到Board
类:
export default class Board extends GameObject {
// ..
renderSlot(x, y, radius, color) {
this.context.beginPath();
this.context.arc(x, y, radius, 0, Math.PI * 2);
this.context.closePath();
this.context.stroke();
this.context.fillStyle = color;
this.context.beginPath();
this.context.arc(x, y, radius - 1, 0, Math.PI * 2);
this.context.closePath();
this.context.fill();
}
}
现在,在renderSlots()
中的switch
街区之后立即致电renderSlot()
:
renderSlots(nextBoard) {
this.context.translate(this.x + BoardConfig.HORIZONTAL_PADDING, this.y + BoardConfig.VERTICAL_PADDING);
this.context.strokeStyle = BoardConfig.SLOT_OUTLINE_COLOR;
this.context.lineWidth = 2;
const slotRadius = BoardConfig.SLOT_WIDTH / 2;
for (let rowIndex = 0; rowIndex < Constants.BoardDimensions.ROWS; rowIndex++) {
for (let columnIndex = 0; columnIndex < Constants.BoardDimensions.COLUMNS; columnIndex++) {
// Note slot is a circle. (x, y) coordinates are the circle's centre.
const totalSlotMarginsX = BoardConfig.SLOT_MARGIN * columnIndex;
const totalPreviousSlotWidthsX = BoardConfig.SLOT_WIDTH * columnIndex;
const slotX = totalSlotMarginsX + totalPreviousSlotWidthsX + slotRadius;
const totalSlotMarginsY = BoardConfig.SLOT_MARGIN * rowIndex;
const totalPreviousSlotWidthsY = BoardConfig.SLOT_WIDTH * rowIndex;
const slotY = totalSlotMarginsY + totalPreviousSlotWidthsY + slotRadius;
const tokenColorValue = nextBoard[rowIndex][columnIndex];
let tokenColor;
switch (tokenColorValue) {
case Constants.BoardToken.YELLOW:
tokenColor = TokenColor.YELLOW;
break;
case Constants.BoardToken.RED:
tokenColor = TokenColor.RED;
break;
default:
tokenColor = TokenColor.NONE
break;
}
this.renderSlot(slotX, slotY, slotRadius, tokenColor);
}
}
}
在render()
方法中添加呼叫到renderSlots()
:
render(nextBoard) {
this.context.save();
this.clear();
this.renderBoardBackground();
this.renderSlots(nextBoard);
this.context.restore();
}
最后,在FrontEnd
类中,在createBoard()
中更新board.render()
方法调用,以便您通过当前的董事会状态:
export default class FrontEnd {
// ..
createBoard() {
let board = new Board(this.context, BoardConfig.MARGIN_LEFT, BoardConfig.MARGIN_TOP, BoardConfig.WIDTH, BoardConfig.HEIGHT);
board.render(this.game.currentBoard);
return board;
}
}
如果您现在在运行服务器时检查浏览器,则会在板上看到空插槽:
使游戏可以玩
在board
变量上调用render()
方法时,您可以尝试对自己的板状态参数进行编码。该游戏将相应地呈现董事会状态。
但是,您目前无法在游戏中更新董事会状态。
在游戏中更新董事会状态的一种方法是响应画布上的点击。
与HTML元素不同,您不能仅仅依靠内置的DOM事件系统来处理我们的游戏对象的点击。由于您在画布上绘画,因此您必须自己处理命中检测,并提出自己的活动处理界面。
介绍您的活动处理API
在此游戏中,您将通过收听页面正文上的单击事件来检测单击,然后将事件数据传递给要处理的相关游戏对象。
为了开始此操作,在称为handleClick()
的GameObject
类中添加新方法:
export default class GameObject {
x;
y;
width;
height;
context;
constructor(context, x, y, width, height) {
this.context = context;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
clear() {
this.context.clearRect(this.x, this.y, this.width, this.height);
}
handleClick(clickEvent) { }
}
handleClick()
将被GameObject
类的继承所覆盖。继承将添加逻辑到handleClick()
方法,以从点击处理事件数据。
实施自己的活动处理API
现在,在Board
类中,添加一个名为columnSelected
的字段。这将用于存储回调。回调将包含运行的逻辑,该逻辑将在FrontEnd
类中定义:
export default class Board extends GameObject {
columnSelected;
// ..
}
之后,添加一种称为setColumnSelectionHandler()
的方法,该方法将用于设置将从回调运行的逻辑:
export default class Board extends GameObject {
// ..
setColumnSelectionHandler(callback) {
this.columnSelected = callback;
}
}
您现在有在Board
类中覆盖handleClick()
的先决条件。
将以下方法添加到Board
类:
handleClick()
-
trySelectColumn()
export default class Board extends GameObject {
// ..
handleClick(clickEvent) {
this.trySelectColumn(clickEvent);
}
trySelectColumn(clickEvent) {
for (let columnIndex = 0; columnIndex < Constants.BoardDimensions.COLUMNS; columnIndex++) {
const totalSlotMargins = BoardConfig.SLOT_MARGIN * columnIndex;
const totalPreviousSlotWidths = BoardConfig.SLOT_WIDTH * columnIndex;
const columnX = this.x + BoardConfig.HORIZONTAL_PADDING + totalSlotMargins + totalPreviousSlotWidths;
const wasColumnClicked = clickEvent.offsetX >= columnX
&& clickEvent.offsetX <= (columnX + BoardConfig.SLOT_WIDTH)
&& clickEvent.offsetY >= this.y + BoardConfig.VERTICAL_PADDING
&& clickEvent.offsetY <= this.y + BoardConfig.HEIGHT - BoardConfig.VERTICAL_PADDING;
if (wasColumnClicked) {
this.columnSelected(columnIndex);
break;
}
}
}
}
trySelectColumn()
方法算出了播放器是否单击列。如果播放器单击一列,它将使用所选列的位置索引运行columnSelected
回调。例如,最左的列将具有0
的索引,第三列将具有2
的索引。
添加回调逻辑
最后,您将在FrontEnd
类中设置单击事件并处理它们。
在start
方法中,添加一个事件侦听器,以单击文档的主体。这将使用事件数据在板上调用handleClick()
方法:
export default class FrontEnd {
// ..
start() {
this.board = this.createBoard();
document.body.addEventListener('click', (clickEvent) => {
this.board.handleClick(clickEvent);
});
}
}
现在,您将添加将在Board
类'selectedColumn
回调中运行的逻辑。
在FrontEnd
类中添加一个名为gameOver
的字段。将其值设置为FrontEnd
类构造器中的false
:
export default class FrontEnd {
game;
canvas;
width;
height;
context;
board;
gameOver;
constructor(game) {
this.game = game;
this.canvas = document.getElementById("canvas");
this.canvas.style.background = FrontEndConfig.GAME_BACKGROUND_COLOR;
this.width = canvas.width;
this.height = canvas.height;
this.context = this.canvas.getContext("2d");
this.gameOver = false;
this.enableHiDPISupport();
}
// ..
}
更新src/FrontEnd.js
中的导入语句以在gameLogic
目录中包括Constants
:
import { FrontEndConfig, BoardConfig } from "./constants/index.js";
import { Board } from "./components/index.js";
import { Constants } from "./gameLogic/index.js";
创建以下方法:playMove()
和processMoveResult()
:
export default class FrontEnd {
// ..
playMove(columnIndex) {
let moveResult = this.game.playMove(columnIndex);
this.processMoveResult(moveResult);
}
processMoveResult(moveResult) {
if (this.gameOver || moveResult.status.value === Constants.MoveStatus.INVALID) {
return;
}
this.board.render(this.game.currentBoard);
if (moveResult.status.value === Constants.MoveStatus.WIN || moveResult.status.value === Constants.MoveStatus.DRAW) {
this.gameOver = true;
}
}
}
在createBoard()
中,在板上的render()
呼叫之前添加一条线。在该行中,调用setColumnSelectionHandler()
方法:
export default class FrontEnd {
createBoard() {
let board = new Board(this.context, BoardConfig.MARGIN_LEFT, BoardConfig.MARGIN_TOP, BoardConfig.WIDTH, BoardConfig.HEIGHT);
board.setColumnSelectionHandler((columnIndex) => this.playMove(columnIndex));
board.render(this.game.currentBoard);
return board;
}
}
如果您现在在服务器运行时检查浏览器,那么您现在可以将令牌放在板上,基于您单击的哪个列。
结论
恭喜!这是一篇很长的帖子(也许是到目前为止该博客系列中最长的帖子!)。希望现在您制作了一个可玩的四英寸游戏,这对您来说是值得的。
但是,尚不清楚游戏中发生了什么。当玩家获胜或游戏以平局结束时,董事会停止更新。没有迹象表明游戏的当前状态。
在下一篇文章中,您将解决该问题。您将在游戏中添加状态区域组件。这将使玩家和观众随时了解游戏的当前状态。
现在! ð