制作四排 - 第7部分:画板
#javascript #教程 #gamedev #games

介绍

欢迎回来!在上一篇博客文章中,您在其上设置了画布并创建了图纸。现在在这篇文章中,您将绘制游戏板。

游戏对象

什么是游戏对象?

游戏在画布上具有多个图纸。这些不同的图纸在水平和垂直方面都占据空间。它们可能由其他较小的相关图纸组成。这些不同的图纸将被称为“游戏对象”。

为什么我们需要游戏对象?

回顾游戏UI的故障。可以识别三个组件:

  1. 状态区域
  2. 游戏板
  3. 再次播放按钮

所有这些组件彼此共享以下共同点:

  • 他们有位置
  • 他们有尺寸
  • 全部绘制在画布上

以广义的方式参考这些组件时,我们将仅考虑这些共同的特征和属性。在此游戏的上下文中,这些组件的一般名称将是“游戏对象”。上面列出的组件都是游戏对象

要在代码中表示这种关系,您将创建一个GameObject类。组件类将从GameObject类继承。这将避免您重写所有组件中共享的常见逻辑和属性。

创建GameObject类

与HTML元素不同,您必须自己实现游戏对象的大小和定位。您也必须自己绘制游戏对象。

在称为componentssrc目录下创建一个目录。

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();
    }
}

请注意,xywidthheight字段如何在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服务器检查游戏,您会在画布上看到与您在上一篇博客文章中绘制的那个相同的白色矩形。

Image of canvas drawing with white rectangle over blue background

绘制游戏板

真棒!您已经弄清楚了如何创建自己的游戏对象并将其绘制在画布上。现在是时候绘制实际的游戏板了。

董事会背景

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服务器时,如果您在浏览器中查看游戏,您会发现蓝色矩形已在画布上呈现。

Image of game board background rendered on the canvas

板插槽

现在您准备好绘制板上的插槽。

添加一个称为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目录中包括TokenColorgameLogic目录中的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;
    }
}

如果您现在在运行服务器时检查浏览器,则会在板上看到空插槽:

Canvas in browser featuring board with slots

使游戏可以玩

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;
    }
}

如果您现在在服务器运行时检查浏览器,那么您现在可以将令牌放在板上,基于您单击的哪个列。

Canvas in browser featuring board with slots with a few slots around the bottom left filled with yellow and red tokens

结论

恭喜!这是一篇很长的帖子(也许是到目前为止该博客系列中最长的帖子!)。希望现在您制作了一个可玩的四英寸游戏,这对您来说是值得的。

但是,尚不清楚游戏中发生了什么。当玩家获胜或游戏以平局结束时,董事会停止更新。没有迹象表明游戏的当前状态。

在下一篇文章中,您将解决该问题。您将在游戏中添加状态区域组件。这将使玩家和观众随时了解游戏的当前状态。

现在! ð