Skip to content

Latest commit

 

History

History
1172 lines (934 loc) · 46.7 KB

File metadata and controls

1172 lines (934 loc) · 46.7 KB

八、使用电子脚本移植游戏

正如第 7 章从头开始创建应用中所展示的,WebAssembly 目前的形式仍然相对有限。Emscripten 提供了强大的 API 来扩展 WebAssembly 的功能,从而为您的应用添加功能。在某些情况下,编译到网络组件模块和 JavaScript 粘合代码(而不是可执行代码)只需要对现有的 C 或 C++ 源代码进行微小的更改。

在本章中,我们将获取一个用 C++ 编写的代码库,该代码库被编译成传统的可执行文件,并更新该代码,以便可以将其编译成 Wasm/JavaScript。我们还将添加一些附加功能,以便与浏览器更紧密地集成。

到本章结束时,您将知道如何执行以下操作:

  • 更新 C++ 代码库以编译成 Wasm 模块/JavaScript 粘合代码(而不是本机可执行文件)
  • 使用 Emscripten 的 API 将浏览器集成添加到 C++ 应用中
  • 用适当的emcc标志构建一个多文件 C++ 项目
  • 使用emrun在浏览器中运行并测试一个 C++ 应用

游戏概述

在这一章中,我们将获取一个用 C++ 编写的俄罗斯方块克隆,并更新代码以集成 Emscripten 并编译到 Wasm/JS。编译成可执行文件的原始形式的代码库利用 SDL2,可以从命令行加载。在本节中,我们将简要回顾什么是俄罗斯方块,如何获取代码(无需从头开始编写),以及如何让它运行。

什么是俄罗斯方块?

在俄罗斯方块中,游戏的主要目标是在一个游戏场地(矩阵)内旋转和移动各种形状的棋子(四亚氨基)以创建一排没有间隙的方块。当一整行被创建时,它将从比赛场地中删除,您的分数将增加一分。在我们的游戏版本中,不会有获胜条件(尽管添加它会很简单)。

理解游戏的规则和机制很重要,因为代码使用算法来处理诸如碰撞检测和评分等概念。理解函数的目标有助于理解其中的代码。如果你需要复习俄罗斯方块的技巧,我建议你在网上试一试。你可以在https://emulatoronline.com/nes-games/classic-tetris/玩,不用安装 Adobe Flash。它看起来就像最初的任天堂版本:

Classic Tetris at EmulatorOnline.com

我们将使用的版本不包含棋子计数器、等级或点数(我们坚持行计数),但它将以相同的方式运行。

来源的来源

事实证明,对俄罗斯方块 C++ 的搜索提供了大量教程和示例库可供选择。为了坚持到目前为止我一直使用的格式和命名约定,我结合了这些资源来创建我自己的游戏版本。如果你有兴趣了解更多,本章末尾的继续阅读部分有这些资源的链接。移植代码库的概念和过程是适用的,不管它的来源是什么。在这一点上,让我们暂时不谈一下移植。

关于移植的一个注记

将现有代码库移植到 Emscripten 并不总是一件简单的任务。在评估 C、C++ 或 Rust 应用是否适合转换时,有几个变量需要考虑。例如,使用几个第三方库甚至几个相当复杂的第三方库的游戏可能需要大量的努力。Emscripten 提供了以下常用的现成库:

  • asio:网络和低级 I/O 编程库
  • Bullet:实时碰撞检测和多物理仿真库
  • Cocos2d:一套开源、跨平台、游戏开发工具
  • FreeType:用于渲染字体的库
  • HarfBuzz:OpenType 文本整形引擎
  • libpng:巴布亚新几内亚官方参考图书馆
  • Ogg:一种多媒体容器格式
  • SDL2:设计用于提供对音频、键盘、鼠标、操纵杆和图形硬件的低级访问的库
  • SDL2_image:图像文件加载库
  • SDL2_mixer:一个样本多声道混音器库
  • SDL2_net:跨平台组网库小样本
  • SDL2_ttf:一个示例库,允许您在 SDL 应用中使用 TrueType 字体
  • Vorbis:通用音频和音乐编码格式
  • zlib:无损数据压缩库

如果库还没有移植,你需要自己做。这将有利于社区,但需要大量的时间和资源投入。我们的俄罗斯方块示例只使用了 SDL2,这使得移植过程相对简单。

获取代码

本章的代码位于learn-webassembly存储库的/chapter-08-tetris文件夹中。/chapter-08-tetris中有两个目录:/output-native文件夹包含原始(预移植)代码,/output-wasm文件夹包含移植代码。

If you want to use VS Code's Task feature for the native build step, you'll need to open the /chapter-08-tetris/output-native folder in VS Code, not the top-level /learn-webassembly folder.

构建本地项目

构建项目需要/cmake文件夹和/output-native文件夹中的CMakeLists.txt文件。README.md文件包含在每个平台上启动和运行代码的说明。构建项目对于移植过程来说不是必需的。安装所需依赖项和让项目在您的平台上成功构建的过程可能既耗时又复杂。如果您仍然希望继续,您可以通过选择任务|运行任务,通过 VS 代码的任务功能构建可执行文件...按照README.md文件中的说明,从菜单中选择构建可执行文件。

行动中的游戏

如果您成功构建了项目,您应该能够通过选择任务 | 运行任务来运行它...从 VS 代码菜单中选择,并从列表中选择开始可执行任务。如果一切都成功了,你应该看到这样的东西:

Compiled game running natively

我们版本的游戏没有输的条件;它只是为您清除的每一行增加一个 ROWS 计数。如果其中一个四亚氨基接触到棋盘的顶部,游戏结束,棋盘重置。这是游戏的基本实现,但是额外的特性增加了复杂性和所需的代码量。让我们更详细地回顾一下代码库。

深度代码库

现在您已经有了可用的代码,您需要熟悉代码库。如果不能很好地理解您想要移植的代码,您将很难成功移植它。在本章中,我们将遍历每个 C++ 类和头文件,并描述它们在应用中的角色。

将代码分解成对象

C++ 是围绕面向对象的范例设计的,这是俄罗斯方块代码库用来简化应用管理的。代码库由 C++ 类文件组成

(.cpp)和头文件(.h)表示游戏上下文中的对象。我用的是*的游戏总结什么是俄罗斯方块?*部分来推断我需要哪些对象。

游戏棋子(四亚氨基)和游戏场地(被称为井或矩阵)是上课的好选择。也许不那么直观,但仍然同样有效的是游戏本身。类不一定需要像实际对象一样具体——它们非常适合存储共享代码。我很喜欢少打字,所以我选择用Piece来代表一个四亚氨基,用Board来代表比赛场地(虽然这个词比更短,但不太合适)。我创建了一个头文件来存储全局变量(constants.h),一个Game类来管理游戏性,还有一个main.cpp文件,作为游戏的入口点。以下是/src文件夹的内容:

├── board.cpp
├── board.h
├── constants.h
├── game.cpp
├── game.h
├── main.cpp
├── piece.cpp
└── piece.h

每个文件(除了main.cppconstants.h)都有一个类(.cpp)和头(.h)文件。头文件允许您跨多个文件重用代码,并防止代码重复。进一步阅读部分包含资源,如果您感兴趣,可以了解更多关于头文件的信息。constants.h文件几乎用于应用中的所有其他文件,所以让我们先回顾一下。

常数文件

我选择了包含我们将要使用的常量(constants.h)的头文件,而不是在整个代码库中散布令人困惑的神奇数字。此文件的内容如下所示:

#ifndef TETRIS_CONSTANTS_H
#define TETRIS_CONSTANTS_H

namespace Constants {
    const int BoardColumns = 10;
    const int BoardHeight = 720;
    const int BoardRows = 20;
    const int BoardWidth = 360;
    const int Offset = BoardWidth / BoardColumns;
    const int PieceSize = 4;
    const int ScreenHeight = BoardHeight + 50;
}

#endif // TETRIS_CONSTANTS_H

文件第一行的#ifndef语句是一个#include保护符,防止头文件在编译过程中被多次包含。这些保护用于所有应用的头文件。当我们单步执行每个类时,这些常量的目的将变得清晰。我首先包含它是为了提供各种元素大小的上下文,以及它们之间的关系。

让我们继续讨论代表游戏各个方面的各个类。Piece类代表最低级别的对象,因此我们将从那里开始,一路向上到达BoardGame类。

小品类

棋子,或四亚氨基,是可以在棋盘上移动和旋转的元素。四亚氨基有七种——每种都用一个字母表示,并有相应的颜色:

Tetrimino colors, taken from Wikipedia

我们需要一种根据形状、颜色和当前方向来定义每件作品的方法。每件作品有四个不同的方向(以 90 度增量),这导致所有作品的 28 个总变化。颜色不变,所以只需要分配一次。考虑到这一点,我们首先来看看头文件(piece.h):

#ifndef TETRIS_PIECE_H
#define TETRIS_PIECE_H

#include <SDL2/SDL.h>
#include "constants.h"

class Piece {
 public:
  enum Kind { I = 0, J, L, O, S, T, Z };

  explicit Piece(Kind kind);

  void draw(SDL_Renderer *renderer);
  void move(int columnDelta, int rowDelta);
  void rotate();
  bool isBlock(int column, int row) const;
  int getColumn() const;
  int getRow() const;

 private:
  Kind kind_;
  int column_;
  int row_;
  int angle_;
};

#endif // TETRIS_PIECE_H

游戏使用 SDL2 来渲染各种图形元素和处理键盘输入,这就是为什么我们在draw()函数中传递一个SDL_Renderer的原因。您将会看到 SDL2 在Game类中的使用,但是现在只需注意它的包含性。头文件定义了Piece类的接口;让我们回顾一下piece.cpp的实施情况。我们将遍历每一段代码并描述功能。

构造函数和 draw()函数

第一段代码定义了Piece类的构造函数和draw()函数:

#include "piece.h"

using namespace Constants;

Piece::Piece(Piece::Kind kind) :
    kind_(kind),
    column_(BoardColumns / 2 - PieceSize / 2),
    row_(0),
    angle_(0) {
}

void Piece::draw(SDL_Renderer *renderer) {
    switch (kind_) {
        case I:
            SDL_SetRenderDrawColor(renderer,
                /* Cyan: */ 45, 254, 254, 255);
            break;
        case J:
            SDL_SetRenderDrawColor(renderer,
                /* Blue: */ 11, 36, 251, 255);
            break;
        case L:
            SDL_SetRenderDrawColor(renderer,
                /* Orange: */ 253, 164, 41, 255);
            break;
        case O:
            SDL_SetRenderDrawColor(renderer,
                /* Yellow: */ 255, 253, 56, 255);
            break;
       case S:
            SDL_SetRenderDrawColor(renderer,
                /* Green: */ 41, 253, 47, 255);
            break;
        case T:
            SDL_SetRenderDrawColor(renderer,
                /* Purple: */ 126, 15, 126, 255);
            break;
        case Z:
            SDL_SetRenderDrawColor(renderer,
                /* Red: */ 252, 13, 28, 255);
            break;
        }

        for (int column = 0; column < PieceSize; ++ column) {
            for (int row = 0; row < PieceSize; ++ row) {
                if (isBlock(column, row)) {
                    SDL_Rect rect{
                        (column + column_) * Offset + 1,
                        (row + row_) * Offset + 1,
                        Offset - 2,
                        Offset - 2
                    };
                SDL_RenderFillRect(renderer, &rect);
            }
        }
    }
}

构造函数用默认值初始化类。BoardColumnsPieceSize值是来自constants.h文件的常数。BoardColumns表示一块板上可以容纳的列数,在本例中为10PieceSize常数表示一个零件在列中占据的面积或块,即4。分配给私有columns_变量的初始值代表板的中心。

draw()函数循环遍历板上所有可能的行和列,并使用与其种类相对应的颜色填充由一块填充的任何单元格。确定一个单元是否由一个片段填充是在isBlock()函数中执行的,我们接下来将讨论这个函数。

移动()、旋转()和锁定()函数

第二部分包含移动或旋转工件并确定其当前位置的逻辑:

void Piece::move(int columnDelta, int rowDelta) {
    column_ += columnDelta;
    row_ += rowDelta;
}

void Piece::rotate() {
    angle_ += 3;
    angle_ %= 4;
}

bool Piece::isBlock(int column, int row) const {
    static const char *Shapes[][4] = {
        // I
        {
            " *  "
            " *  "
            " *  "
            " *  ",
            "    "
            "****"
            "    "
            "    ",
            " *  "
            " *  "
            " *  "
            " *  ",
            "    "
            "****"
            "    "
            "    ",
        },
        // J
        {
            "  * "
            "  * "
            " ** "
            "    ",
            "    "
            "*   "
            "*** "
            "    ",
            " ** "
            " *  "
            " *  "
            "    ",
            "    "
            "    "
            "*** "
            " *  ",
        },
        ...
    };
    return Shapes[kind_][angle_][column + row * PieceSize] == '*';
}

int Piece::getColumn() const {
 return column_;
}
int Piece::getRow() const {
 return row_;
}

move()函数更新私有的column_row_变量的值,这决定了棋子在棋盘上的位置。rotate()功能将私有angle_变量的值设置为0123(这就是使用%= 4的原因)。

isBlock()功能中确定显示哪种工件、其位置和旋转。为了避免弄乱文件,我省略了Shapes多维数组的前两个元素以外的所有元素,但是剩下的五种类型都存在于实际代码中。我承认这不是最优雅的实现,但它非常适合我们的目的。

私有的kind_angle_值被指定为Shapes数组中的尺寸,以选择四个对应的char*元素。这四个元素代表了该作品的四种可能的方向。如果字符串中column + row * PieceSize的索引是星号,则该片段出现在指定的行和列中。如果您决定浏览网络上的俄罗斯方块教程(或者查看 GitHub 上的众多俄罗斯方块存储库之一),您会发现有几种不同的方法来计算一个单元格是否由一部分填充。我选择这种方法是因为它更容易将碎片可视化。

getColumn()和 getRow()函数

代码的最后一部分包含获取片段的行和列的函数:

int Piece::getColumn() const {
    return column_;
}

int Piece::getRow() const {
    return row_;
}

这些函数只是返回私有column_row_变量的值。既然你对Piece班有了更好的了解,让我们进入Board班。

董事会

Board包含Piece类的实例,需要检测棋子之间的碰撞,何时排满,何时游戏结束。让我们从头文件(board.h)的内容开始:

#ifndef TETRIS_BOARD_H
#define TETRIS_BOARD_H

#include <SDL2/SDL.h>
#include <SDL2/SDL2_ttf.h>
#include "constants.h"
#include "piece.h"

using namespace Constants;

class Board {
 public:
  Board();
  void draw(SDL_Renderer *renderer, TTF_Font *font);
  bool isCollision(const Piece &piece) const;
  void unite(const Piece &piece);

 private:
  bool isRowFull(int row);
  bool areFullRowsPresent();
  void updateOffsetRow(int fullRow);
  void displayScore(SDL_Renderer *renderer, TTF_Font *font);

  bool cells_[BoardColumns][BoardRows];
  int currentScore_;
};

#endif // TETRIS_BOARD_H

Board有一个类似于Piece类的draw()功能,以及其他几个用于管理行和跟踪板上填充了哪些单元格的功能。SDL2_ttf库用于渲染窗口底部带有当前分数(已清除行数)的 ROWS: text。现在,让我们看一下实现文件(board.cpp)的每个部分。

构造函数和 draw()函数

第一段代码定义了Board类的构造函数和draw()函数:

#include <sstream>
#include "board.h"

using namespace Constants;

Board::Board() : cells_{{ false }}, currentScore_(0) {}

void Board::draw(SDL_Renderer *renderer, TTF_Font *font) {
    displayScore(renderer, font);
    SDL_SetRenderDrawColor(
        renderer,
        /* Light Gray: */ 140, 140, 140, 255);
    for (int column = 0; column < BoardColumns; ++ column) {
        for (int row = 0; row < BoardRows; ++ row) {
            if (cells_[column][row]) {
                SDL_Rect rect{
                    column * Offset + 1,
                    row * Offset + 1,
                    Offset - 2,
                    Offset - 2
                };
                SDL_RenderFillRect(renderer, &rect);
            }
        }
    }
}

Board构造函数将私有cells_currentScore_变量的值初始化为默认值。cells_变量是布尔的二维数组,第一维表示列,第二行表示行。如果一个片段占据了特定的列和行,数组中对应的值就是truedraw()功能的行为类似于Piecedraw()功能,因为它用颜色填充包含片段的单元格。但是,该功能仅填充由浅灰色到达板底部的块占据的单元格,而不管它是什么类型的块。

isCollision()函数

代码的第二部分包含检测冲突的逻辑:

bool Board::isCollision(const Piece &piece) const {
    for (int column = 0; column < PieceSize; ++ column) {
        for (int row = 0; row < PieceSize; ++ row) {
            if (piece.isBlock(column, row)) {
                int columnTarget = piece.getColumn() + column;
                int rowTarget = piece.getRow() + row;
                if (
                    columnTarget < 0
                    || columnTarget >= BoardColumns
                    || rowTarget < 0
                    || rowTarget >= BoardRows
                ) {
                    return true;
                }
                if (cells_[columnTarget][rowTarget]) return true;
            }
        }
    }
    return false;
}

isCollision()函数循环遍历板上的每个单元格,直到到达由作为参数传递的&piece填充的单元格。如果工件即将与板的任一侧碰撞或已经到达底部,该功能返回true,否则返回false

unite()函数

代码的第三部分包含逻辑,当它停止时,将一个片段与顶行结合起来:

void Board::unite(const Piece &piece) {
    for (int column = 0; column < PieceSize; ++ column) {
        for (int row = 0; row < PieceSize; ++ row) {
            if (piece.isBlock(column, row)) {
                int columnTarget = piece.getColumn() + column;
                int rowTarget = piece.getRow() + row;
                cells_[columnTarget][rowTarget] = true;
            }
        }
    }

    // Continuously loops through each of the rows until no full rows are
    // detected and ensures the full rows are collapsed and non-full rows
    // are shifted accordingly:
    while (areFullRowsPresent()) {
        for (int row = BoardRows - 1; row >= 0; --row) {
            if (isRowFull(row)) {
                updateOffsetRow(row);
                currentScore_ += 1;
                for (int column = 0; column < BoardColumns; ++ column) {
                    cells_[column][0] = false;
                }
            }
        }
    }
}

bool Board::isRowFull(int row) {
    for (int column = 0; column < BoardColumns; ++ column) {
        if (!cells_[column][row]) return false;
    }
    return true;
}

bool Board::areFullRowsPresent() {
    for (int row = BoardRows - 1; row >= 0; --row) {
        if (isRowFull(row)) return true;
    }
    return false;
}

void Board::updateOffsetRow(int fullRow) {
    for (int column = 0; column < BoardColumns; ++ column) {
        for (int rowOffset = fullRow - 1; rowOffset >= 0; --rowOffset) {
            cells_[column][rowOffset + 1] =
            cells_[column][rowOffset];
        }
    }
}

unite()功能和相应的isRowFull()areFullRowsPresent()updateOffsetRow()功能执行多种操作。它通过将适当的数组位置设置为true,用指定的&piece参数占据的行和列更新私有的cells_变量。它还通过将相应的cells_阵列位置设置为false并增加currentScore_来清除板上的所有完整行(所有填充的列)。行被清除后,cells_数组被更新,将清除行上方的行下移1

displayScore()函数

代码的最后一部分在游戏窗口的底部显示分数:

void Board::displayScore(SDL_Renderer *renderer, TTF_Font *font) {
    std::stringstream message;
    message << "ROWS: " << currentScore_;
    SDL_Color white = { 255, 255, 255 };
    SDL_Surface *surface = TTF_RenderText_Blended(
        font,
        message.str().c_str(),
        white);
    SDL_Texture *texture = SDL_CreateTextureFromSurface(
        renderer,
        surface);
    SDL_Rect messageRect{ 20, BoardHeight + 15, surface->w, surface->h };
    SDL_FreeSurface(surface);
    SDL_RenderCopy(renderer, texture, nullptr, &messageRect);
    SDL_DestroyTexture(texture);
}

displayScore()功能使用SDL2_ttf库在窗口底部(棋盘下方)显示当前分数。TTF_Font *font参数是从Game类传入的,以避免每次更新分数时初始化字体。stringstream message变量用于创建文本值,并在TTF_RenderText_Blended()功能中将其设置为 C char*。代码的其余部分将文本绘制在SDL_Rect上,以确保其正确显示。

Board班到此为止;让我们转到Game来看看它是如何组合在一起的。

游戏课

Game类包含循环功能,使您能够通过按键在电路板上移动工件。以下是头文件(game.h)的内容:

#ifndef TETRIS_GAME_H
#define TETRIS_GAME_H

#include <SDL2/SDL.h>
#include <SDL2/SDL2_ttf.h>
#include "constants.h"
#include "board.h"
#include "piece.h"

class Game {
 public:
  Game();
  ~Game();
  bool loop();

 private:
  Game(const Game &);
  Game &operator=(const Game &);

  void checkForCollision(const Piece &newPiece);
  void handleKeyEvents(SDL_Event &event);

  SDL_Window *window_;
  SDL_Renderer *renderer_;
  TTF_Font *font_;
  Board board_;
  Piece piece_;
  uint32_t moveTime_;
};

#endif // TETRIS_GAME_H

loop()功能包含游戏逻辑,基于事件管理状态。private:标题下的前两行阻止创建多个游戏实例,这可能会导致内存泄漏。私有方法减少了loop()函数中的代码行数,从而简化了维护和调试。让我们进入game.cpp中的实现。

构造函数和析构函数

代码的第一部分定义了类实例加载(构造函数)和卸载(析构函数)时要执行的操作:

#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include "game.h"

using namespace std;
using namespace Constants;

Game::Game() :
    // Create a new random piece:
    piece_{ static_cast<Piece::Kind>(rand() % 7) },
    moveTime_(SDL_GetTicks())
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        throw runtime_error(
            "SDL_Init(SDL_INIT_VIDEO): " + string(SDL_GetError()));
        }
        SDL_CreateWindowAndRenderer(
            BoardWidth,
            ScreenHeight,
            SDL_WINDOW_OPENGL,
            &window_,
            &renderer_);
        SDL_SetWindowPosition(
            window_,
            SDL_WINDOWPOS_CENTERED,
            SDL_WINDOWPOS_CENTERED);
        SDL_SetWindowTitle(window_, "Tetris");

    if (TTF_Init() != 0) {
        throw runtime_error("TTF_Init():" + string(TTF_GetError()));
    }
    font_ = TTF_OpenFont("PressStart2P.ttf", 18);
    if (font_ == nullptr) {
        throw runtime_error("TTF_OpenFont: " + string(TTF_GetError()));
    }
}

Game::~Game() {
    TTF_CloseFont(font_);
    TTF_Quit();
    SDL_DestroyRenderer(renderer_);
    SDL_DestroyWindow(window_);
    SDL_Quit();
}

构造函数代表应用的入口点,因此所有需要的资源都在其中分配和初始化。TTF_OpenFont()函数引用了一个从谷歌字体下载的名为“按下开始 2P”的 TrueType 字体文件。可以在https://fonts.google.com/specimen/Press+Start+2P查看字体。它存在于存储库的/resources文件夹中,并在项目构建时被复制到与可执行文件相同的文件夹中。如果在初始化 SDL2 资源时出现错误,则会抛出一个带有错误详细信息的runtime_error。析构函数(~Game())在应用退出之前释放我们为 SDL2 和SDL2_ttf分配的资源。这样做是为了避免内存泄漏。

loop()函数

最后一段代码代表Game::loop:

bool Game::loop() {
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
        switch (event.type) {
            case SDL_KEYDOWN:
                handleKeyEvents(event);
                break;
            case SDL_QUIT:
                return false;
            default:
                return true;
        }
    }

    SDL_SetRenderDrawColor(renderer_, /* Dark Gray: */ 58, 58, 58, 255);
    SDL_RenderClear(renderer_);
    board_.draw(renderer_, font_);
    piece_.draw(renderer_);

    if (SDL_GetTicks() > moveTime_) {
        moveTime_ += 1000;
        Piece newPiece = piece_;
        newPiece.move(0, 1);
        checkForCollision(newPiece);
    }
    SDL_RenderPresent(renderer_);
    return true;
}

void Game::checkForCollision(const Piece &newPiece) {
    if (board_.isCollision(newPiece)) {
        board_.unite(piece_);
        piece_ = Piece{ static_cast<Piece::Kind>(rand() % 7) };
        if (board_.isCollision(piece_)) board_ = Board();
    } else {
        piece_ = newPiece;
    }
}

void Game::handleKeyEvents(SDL_Event &event) {
    Piece newPiece = piece_;
    switch (event.key.keysym.sym) {
        case SDLK_DOWN:
            newPiece.move(0, 1);
            break;
        case SDLK_RIGHT:
            newPiece.move(1, 0);
            break;
        case SDLK_LEFT:
            newPiece.move(-1, 0);
            break;
        case SDLK_UP:
            newPiece.rotate();
            break;
        default:
            break;
     }
     if (!board_.isCollision(newPiece)) piece_ = newPiece;
}

只要SDL_QUIT事件没有触发,loop()函数就会返回一个布尔值。每隔1秒执行一次PieceBoard实例的draw()功能,棋盘上的棋子位置也相应更新。左箭头键、右箭头键和下箭头键控制棋子的移动,而上箭头键将棋子旋转 90 度。在handleKeyEvents()功能中处理对按键的适当响应。checkForCollision()功能确定活动棋子的新实例是否与棋盘的任一侧发生碰撞,或者是否停留在其他棋子的顶部。如果是这样,就会产生一个新的片段。清除行的逻辑(通过Boardunite()功能)也在该功能中处理。我们快完成了!让我们进入main.cpp文件。

主文件

没有与main.cpp相关联的头文件,因为它的唯一目的是作为应用的入口点。事实上,文件只有七行长:

#include "game.h"

int main() {
    Game game;
    while (game.loop());
    return 0;
}

loop()功能返回false时,退出while语句,这发生在SDL_QUIT事件触发时。这个文件所做的就是创建一个新的Game实例并开始循环。代码库到此为止。我们开始移植吧!

移植到 Emscripten

您对代码库有很好的理解,所以现在是时候开始用 Emscripten 移植它了。幸运的是,我们能够利用浏览器的一些功能来简化代码,并完全删除第三方库。在本节中,我们将更新代码以编译成一个 Wasm 模块和 JavaScript 胶合文件,并更新一些功能以利用浏览器。

准备移植

/output-wasm文件夹包含最终结果,但我建议您创建一个/output-native文件夹的副本,以便您可以继续移植过程。为本机编译和电子脚本编译都设置了 VS 代码任务。如果卡住了,可以随时参考/output-wasm的内容。请确保在 VS 代码中打开您复制的文件夹(文件|打开并选择您复制的文件夹),否则您将无法使用任务功能。

有什么变化?

这款游戏是移植的理想选择,因为它使用了 SDL2,这是一个广泛使用的库,具有现有的 Emscripten 端口。在编译步骤中包含 SDL2 只需要向emcc命令传递一个额外的参数。SDL2_ttf库的一个 Emscripten 端口也存在,但是将其保存在代码库中没有多大意义。它的唯一目的是将分数(清除的行数)呈现为文本。我们需要在应用中包含 TTF 文件,并使构建过程复杂化。Emscripten 提供了在我们的 C++ 中使用 JavaScript 代码的方法,所以我们将采取一种简单得多的方法:在 DOM 中显示分数。

除了更改现有的代码,我们还需要创建一个 HTML 和 CSS 文件,用于在浏览器中显示和设置游戏的样式。我们编写的 JavaScript 代码将是最少的——我们只需要加载 Emscripten 模块,所有的功能都在 C++ 代码库中处理。我们还需要添加一些<div>元素,并对它们进行相应的布局以显示分数。开始移植吧!

添加网络资产

在项目文件夹中创建一个名为/public的文件夹。将名为index.html的新文件添加到/public文件夹,并用以下内容填充:

<!doctype html>
<html lang="en-us">
<head>
  <title>Tetris</title>
  <link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
  <div class="wrapper">
    <h1>Tetris</h1>
    <div>
      <canvas id="canvas"></canvas>
      <div class="scoreWrapper">
        <span>ROWS:</span><span id="score"></span>
      </div>
    </div>
  </div>
  <script type="application/javascript" src="index.js"></script>
  <script type="application/javascript">
    Module({ canvas: (() => document.getElementById('canvas'))() })
  </script>
</body>
</html>

第一个<script>标签中正在加载的index.js文件还不存在;将在编译步骤中生成。让我们给元素添加一些样式。在/public文件夹中创建一个styles.css文件,并用以下内容填充:

@import url("https://fonts.googleapis.com/css?family=Press+Start+2P");

* {
  font-family: "Press Start 2P", sans-serif;
}

body {
  margin: 24px;
}

h1 {
  font-size: 36px;
}

span {
  color: white;
  font-size: 24px;
}

.wrapper {
  display: flex;
  align-items: center;
  flex-direction: column;
}

.titleWrapper {
  display: flex;
  align-items: center;
  justify-content: center;
}

.header {
  font-size: 24px;
  margin-left: 16px;
}

.scoreWrapper {
  background-color: #3A3A3A;
  border-top: 1px solid white;
  padding: 16px 0;
  width: 360px;
}

span:first-child {
  margin-left: 16px;
  margin-right: 8px;
}

由于我们正在使用的“新闻开始 2P”字体托管在谷歌字体上,我们可以导入它在网站上使用。这个文件中的 CSS 规则处理简单的布局和样式。这就是我们需要创建的网络相关文件。现在,是时候更新 C++ 代码了。

移植现有代码

我们只需要编辑几个文件就可以让 Emscripten 正常工作。为了简单和紧凑起见,将只包含受影响的代码部分(而不是整个文件)。让我们按照与上一节相同的顺序浏览文件,从constants.h开始。

更新常数文件

我们将在 DOM 上显示清除的行数,而不是在游戏窗口本身,因此您可以从文件中删除ScreenHeight常量。我们不再需要额外的空间来容纳乐谱:

namespace Constants {
    const int BoardColumns = 10;
    const int BoardHeight = 720;
    const int BoardRows = 20;
    const int BoardWidth = 360;
    const int Offset = BoardWidth / BoardColumns;
    const int PieceSize = 4;
    // const int ScreenHeight = BoardHeight + 50; <----- Delete this line
}

不需要对Piece类文件(piece.cpp / piece.h)进行更改。但是,我们需要更新Board类。让我们从头文件(board.h)开始。从底部开始向上,我们来更新displayScore()功能。在index.html文件的<body>部分,有一个带有id="score"<span>元素。我们将使用emscripten_run_script命令更新这个元素来显示当前分数。因此,displayScore()功能变得更短。前后如下所示。

这是 Board 类的displayScore()函数的原始版本:

void Board::displayScore(SDL_Renderer *renderer, TTF_Font *font) {
    std::stringstream message;
    message << "ROWS: " << currentScore_;
    SDL_Color white = { 255, 255, 255 };
    SDL_Surface *surface = TTF_RenderText_Blended(
        font,
        message.str().c_str(),
        white);
    SDL_Texture *texture = SDL_CreateTextureFromSurface(
        renderer,
        surface);
    SDL_Rect messageRect{ 20, BoardHeight + 15, surface->w, surface->h };
    SDL_FreeSurface(surface);
    SDL_RenderCopy(renderer, texture, nullptr, &messageRect);
    SDL_DestroyTexture(texture);
 }

以下是displayScore()功能的移植版本:

void Board::displayScore(int newScore) {
    std::stringstream action;
    action << "document.getElementById('score').innerHTML =" << newScore;
    emscripten_run_script(action.str().c_str());
 }

emscripten_run_script动作只是在 DOM 上找到<span>元素并将innerHTML设置为当前分数。我们不能在这里使用EM_ASM()功能,因为 Emscripten 不识别document对象。由于我们可以访问类中的私有currentScore_变量,我们将把draw()函数中的displayScore()调用移到unite()函数中。这限制了对displayScore()的调用量,以确保该函数仅在分数实际发生变化时被调用。我们只需要添加一行代码就可以完成。以下是unite()功能现在的样子:

void Board::unite(const Piece &piece) {
    for (int column = 0; column < PieceSize; ++ column) {
        for (int row = 0; row < PieceSize; ++ row) {
            if (piece.isBlock(column, row)) {
                int columnTarget = piece.getColumn() + column;
                int rowTarget = piece.getRow() + row;
                cells_[columnTarget][rowTarget] = true;
            }
        }
    }

    // Continuously loops through each of the rows until no full rows are
    // detected and ensures the full rows are collapsed and non-full rows
    // are shifted accordingly:
    while (areFullRowsPresent()) {
        for (int row = BoardRows - 1; row >= 0; --row) {
            if (isRowFull(row)) {
                updateOffsetRow(row);
                currentScore_ += 1;
                for (int column = 0; column < BoardColumns; ++ column) {
                    cells_[column][0] = false;
                }
            }
        }
        displayScore(currentScore_); // <----- Add this line
    }
}

由于我们不再使用SDL2_ttf库,我们可以更新draw()函数签名并删除displayScore()函数调用。更新后的draw()功能如下:

void Board::draw(SDL_Renderer *renderer/*, TTF_Font *font */) {
                                        // ^^^^^^^^^^^^^^ <-- Remove this argument
    // displayScore(renderer, font); <----- Delete this line
    SDL_SetRenderDrawColor(
        renderer,
        /* Light Gray: */ 140, 140, 140, 255);
    for (int column = 0; column < BoardColumns; ++ column) {
        for (int row = 0; row < BoardRows; ++ row) {
            if (cells_[column][row]) {
                SDL_Rect rect{
                    column * Offset + 1,
                    row * Offset + 1,
                    Offset - 2,
                    Offset - 2
                };
                SDL_RenderFillRect(renderer, &rect);
            }
        }
    }
 }

displayScore()函数调用从函数的第一行被删除,并且TTF_Font *font参数也被删除。让我们在构造函数中添加对displayScore()的调用,以确保当游戏结束并开始新的游戏时,初始值被设置为0:

Board::Board() : cells_{{ false }}, currentScore_(0) {
    displayScore(0); // <----- Add this line
}

班级档案到此为止。由于我们更改了displayScore()draw()函数的签名,并删除了SDL2_ttf的依赖项,我们需要更新头文件。从board.h中删除以下行:

#ifndef TETRIS_BOARD_H
#define TETRIS_BOARD_H

#include <SDL2/SDL.h>
// #include <SDL2/SDL2_ttf.h> <----- Delete this line
#include "constants.h"
#include "piece.h"

using namespace Constants;

class Board {
 public:
  Board();
  void draw(SDL_Renderer *renderer /*, TTF_Font *font */);
                                    // ^^^^^^^^^^^^^^ <-- Remove this
  bool isCollision(const Piece &piece) const;
  void unite(const Piece &piece);

 private:
  bool isRowFull(int row);
  bool areFullRowsPresent();
  void updateOffsetRow(int fullRow);
  void displayScore(SDL_Renderer *renderer, TTF_Font *font);
                                         // ^^^^^^^^^^^^^^ <-- Remove this
  bool cells_[BoardColumns][BoardRows];
  int currentScore_;
};

#endif // TETRIS_BOARD_H

我们正在前进!我们需要做出的最终改变也是最大的改变。现有的代码库有一个管理应用逻辑的Game类和一个调用main()函数中Game.loop()函数的main.cpp文件。循环机制是一个 while 循环,只要SDL_QUIT事件没有触发,它就会继续运行。我们需要改变我们的方法来适应环境。

Emscripten 提供了一个emscripten_set_main_loop函数,该函数接受一个em_callback_func循环函数、fps和一个simulate_infinite_loop标志。我们不能包含Game类并通过Game.loop()作为em_callback_func参数,因为构建将失败。相反,我们将完全消除Game类,并将逻辑移到main.cpp文件中。将game.cpp的内容复制到main.cpp(覆盖已有内容),删除Game类文件(game.cpp / game.h)。因为我们没有为Game声明类,所以从函数中移除Game::前缀。构造函数和析构函数不再有效(它们不再是类的一部分),因此我们需要将该逻辑移动到不同的位置。我们还需要对文件重新排序,以确保我们调用的函数在调用函数之前。最终结果如下:

#include <emscripten/emscripten.h>
#include <SDL2/SDL.h>
#include <stdexcept>
#include "constants.h"
#include "board.h"
#include "piece.h"

using namespace std;
using namespace Constants;

static SDL_Window *window = nullptr;
static SDL_Renderer *renderer = nullptr;
static Piece currentPiece{ static_cast<Piece::Kind>(rand() % 7) };
static Board board;
static int moveTime;

void checkForCollision(const Piece &newPiece) {
    if (board.isCollision(newPiece)) {
        board.unite(currentPiece);
        currentPiece = Piece{ static_cast<Piece::Kind>(rand() % 7) };
        if (board.isCollision(currentPiece)) board = Board();
    } else {
        currentPiece = newPiece;
    }
}

void handleKeyEvents(SDL_Event &event) {
    Piece newPiece = currentPiece;
    switch (event.key.keysym.sym) {
        case SDLK_DOWN:
            newPiece.move(0, 1);
            break;
        case SDLK_RIGHT:
            newPiece.move(1, 0);
            break;
        case SDLK_LEFT:
            newPiece.move(-1, 0);
            break;
        case SDLK_UP:
            newPiece.rotate();
            break;
        default:
            break;
    }
    if (!board.isCollision(newPiece)) currentPiece = newPiece;
}

void loop() {
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
        switch (event.type) {
            case SDL_KEYDOWN:
                handleKeyEvents(event);
                break;
            case SDL_QUIT:
                break;
            default:
                break;
        }
    }

    SDL_SetRenderDrawColor(renderer, /* Dark Gray: */ 58, 58, 58, 255);
    SDL_RenderClear(renderer);
    board.draw(renderer);
    currentPiece.draw(renderer);

    if (SDL_GetTicks() > moveTime) {
        moveTime += 1000;
        Piece newPiece = currentPiece;
        newPiece.move(0, 1);
        checkForCollision(newPiece);
    }
    SDL_RenderPresent(renderer);
}

int main() {
    moveTime = SDL_GetTicks();
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        throw std::runtime_error("SDL_Init(SDL_INIT_VIDEO)");
    }
    SDL_CreateWindowAndRenderer(
        BoardWidth,
        BoardHeight,
        SDL_WINDOW_OPENGL,
        &window,
        &renderer);

    emscripten_set_main_loop(loop, 0, 1);

    SDL_DestroyRenderer(renderer);
    renderer = nullptr;
    SDL_DestroyWindow(window);
    window = nullptr;
    SDL_Quit();
    return 0;
}

handleKeyEvents()checkForCollision()功能没有变化;我们只是把它们移到文件的顶部。loop()功能返回类型根据emscripten_set_main_loop要求由bool改为void。最后,来自构造函数和析构函数的代码被移到main()函数中,对SDL2_ttf的任何引用都被移除。不是 while 语句调用Gameloop()功能,而是emscripten_set_main_loop(loop, 0, 1)调用。我们更改了文件顶部的#include语句,以适应 Emscripten、SDL2 以及我们的BoardPiece类。这就是改变——现在是时候配置构建和测试游戏了。

构建和运行游戏

随着代码的更新和所需的网络资产的出现,是时候构建和测试这个游戏了。编译步骤类似于本书前面的例子,但是我们将使用不同的技术来运行游戏。在本节中,我们将配置构建任务以适应 C++ 文件,并使用 Emscripten 提供的功能运行应用。

使用 VS 代码任务构建

我们将以两种方式配置构建:VS 代码任务和 Makefile。如果你喜欢使用不同于 VS 代码的编辑器,Makefiles 是不错的选择。/.vscode/tasks.json文件已经包含了构建项目所需的任务。Emscripten 构建步骤是默认的(还存在一组本机构建任务)。让我们浏览一下tasks阵列中的每个任务,并回顾正在发生的事情。第一个任务是在构建之前删除任何现有的编译输出文件:

{
  "label": "Remove Existing Web Files",
  "type": "shell",
  "command": "rimraf",
  "options": {
    "cwd": "${workspaceRoot}/public"
  },
  "args": [
    "index.js",
    "index.wasm"
  ]
}

第二个任务使用emcc命令执行构建:

{
  "label": "Build WebAssembly",
  "type": "shell",
  "command": "emcc",
  "args": [
    "--bind", "src/board.cpp", "src/piece.cpp", "src/main.cpp",
    "-std=c++ 14",
    "-O3",
    "-s", "WASM=1",
    "-s", "USE_SDL=2",
    "-s", "MODULARIZE=1",
    "-o", "public/index.js"
  ],
  "group": {
    "kind": "build",
    "isDefault": true
  },
  "problemMatcher": [],
  "dependsOn": ["Remove Existing Web Files"]
}

相关的参数放在同一行。对args数组的唯一新的和不熟悉的增加是带有相应的.cpp文件的--bind参数。这告诉 Emscripten】之后的所有文件都是构建项目所必需的。通过选择任务|运行构建任务来测试构建...从菜单或使用键盘快捷键Cmd/Ctrl+Shift+B。构建需要几秒钟,但是终端会在编译过程完成时让您知道。如果成功,您应该会在/public文件夹中看到一个index.jsindex.wasm文件。

使用 Makefile 构建

如果您不喜欢使用 VS 代码,您可以使用 Makefile 来完成与 VS 代码任务相同的目标。在项目文件夹中创建一个名为Makefile的文件,并用以下内容填充它(确保该文件使用制表符,而不是空格):

# This allows you to just run the "make" command without specifying
# arguments:
.DEFAULT_GOAL := build

# Specifies which files to compile as part of the project:
CPP_FILES = $(wildcard src/*.cpp)

# Flags to use for Emscripten emcc compile command:
FLAGS = -std=c++ 14 -O3 -s WASM=1 -s USE_SDL=2 -s MODULARIZE=1 \
        --bind $(CPP_FILES)

# Name of output (the .wasm file is created automatically):
OUTPUT_FILE = public/index.js

# This is the target that compiles our executable
compile: $(CPP_FILES)
    emcc  $(FLAGS) -o $(OUTPUT_FILE)

# Removes the existing index.js and index.wasm files:
clean:
    rimraf $(OUTPUT_FILE)
    rimraf public/index.wasm

# Removes the existing files and builds the project:
build: clean compile
    @echo "Build Complete!"

正在执行的操作与 VS 代码任务相同,只是使用了更通用的工具,只是格式不同。默认的构建步骤是在文件中设置的,因此您可以在项目文件夹中运行以下命令来编译项目:

make

现在你已经有了一个编译好的 Wasm 文件和 JavaScript 粘合代码,让我们试着运行这个游戏。

运行游戏

我们将使用 Emscripten 工具链的内置功能emrun,而不是使用 serve 或browser-sync。它提供了捕获stdoutstderr(如果您将--emrun链接器标志传递给emcc命令)并根据需要将它们打印到终端的额外好处。我们不会使用--emrun标志,但是拥有一个本地网络服务器而不需要安装任何额外的依赖项是一个值得注意的额外特性。在项目文件夹中打开一个终端实例,运行以下命令开始游戏:

emrun --browser chrome --no_emrun_detect public/index.html

如果您正在使用浏览器进行开发,您可以为浏览器指定firefox--no_emrun_detect标志在终端隐藏了一条消息,说明该网页不具备emrun功能。如果导航到http://localhost:6931/index.html,应该会看到以下内容:

Tetris running in the browser

尝试旋转和移动零件,以确保一切正常工作。成功清除一行后,ROWS 计数应该增加 1。你可能还会注意到,如果你离棋盘的边缘太近,你将无法旋转一些棋子。恭喜你,你已经成功地将一个 C++ 游戏移植到了 Emscripten!

摘要

在这一章中,我们移植了一个用 C++ 编写的俄罗斯方块克隆,它使用 SDL2 来编写脚本,这样它就可以在带有 WebAssembly 的浏览器中运行。我们介绍了俄罗斯方块的规则,以及它们如何映射到现有代码库中的逻辑。我们还分别查看了现有代码库中的每个文件,以及为了成功编译成 Wasm 文件和 JavaScript 粘合代码,必须进行哪些更改。在更新现有代码后,我们创建了所需的 HTML 和 CSS 文件,然后用适当的emcc标志配置了构建步骤。一旦建立,游戏就使用 Emscripten 的emrun命令运行。

第 9 章与 Node.js 集成中,我们将讨论如何将 WebAssembly 集成到 Node.js 中,以及这种集成带来的好处。

问题

  1. 俄罗斯方块里的棋子叫什么?
  2. 选择不将现有的 C++ 代码库移植到 Emscripten 的一个原因是什么?
  3. 我们使用了什么工具来编译游戏(例如,编译成可执行文件)?
  4. constants.h文件的目的是什么?
  5. 为什么我们能够消除 SDL2_ttf 库?
  6. 我们用了哪个 Emscripten 函数开始运行游戏?
  7. 我们在emcc命令中添加了哪个参数来构建游戏,它有什么作用?
  8. emrunserve和 Browsersync 这样的工具有什么优势?

进一步阅读