尝试实现《Puyo Puyo》(1)

Puyo Puyo(魔法气泡)可能是我最喜欢的消除类游戏。规则很简单,超过四个方块连起来就会消除。如果结构设计良好,就会引发连锁反应。

13年对战纪念图

右边的结构,左下角黄色块引爆(就是消除,我习惯用引爆)之后,接着会引爆蓝色,接着引爆红色,蓝,红,绿,黄,蓝,红。左边的结构类似,当黄色块落下时会引爆11次,俗称11连锁。对我来说,这个游戏的乐趣就在于尽可能设计连锁最多的结构。

我的水平有限,只能按照通用软件的思路去实现,想到哪里写哪里。我不打算贴各种boilerplate,只演示核心代码。图形库使用SFML因为它易于安装调用方便。

Grid

大概研究了下,方块类游戏时常都利用grid就是网格来实现。既然使用grid就干脆统一所有数据类型为格子,后面有任何限制再改动。

先定义色块类型,色块就是格子的色彩样式:

enum ColorFlag { EMPTY, RED, GREEN, BLUE, YELLOW };
const sf::Color EmptyGrid{0x999999ff};

Empty表示空格子,先岁宾找了个不刺眼的颜色。

然后定义游戏区域:

constexpr int gridWidth = 6;
constexpr int gridHeight = 14;
std::array<std::array<ColorFlag, gridHeight>, gridWidth> gridData{{}};

尺寸暂时定义成6×14,理论上应该高一点点。

gridData储存游戏逻辑数据,可以通过扫描它的数据类型来判断格子是否被占用。实际上为了方便,核心数据类型定为ColorFlag,也就是没有格子的实体,只有格子的样式。考虑到后续算法的便利,网格次级单位为列。

渲染

Block表示渲染信息——偏移位置,尺寸和颜色。

constexpr int WndWidth = 640;
constexpr int WndHeight = 480;
constexpr int blocksize = 40;
constexpr int tilesize = blocksize - 10;
struct Block {
    Block(int x, int y, sf::Color color = EmptyGrid)
    {
        rec_.setPosition(x * blocksize + WndWidth / 4, y * blocksize + WndHeight / 4);
        rec_.setSize({tilesize, tilesize});
        rec_.setFillColor(color);
    }
    void color(sf::Color color) { rec_.setFillColor(color); }
    sf::RectangleShape rec_;
};

Blocks负责提供所有渲染信息,并绑定逻辑数据和渲染数据:

struct Blocks {
    static sf::Color dispatchColor(int flag);
    void newBoard();
    void update(sf::RenderWindow& window);
    std::vector<Block> blocks;
};

dispatchColor用来映射逻辑颜色和图形库的颜色:

static sf::Color dispatchColor(int flag)
{
    switch(flag) {
    case 0:
        return EmptyGrid;
    case 1:
        return sf::Color::Red;
    case 2:
        return sf::Color::Green;
    case 3:
        return sf::Color::Blue;
    case 4:
        return sf::Color::Yellow;
    }
    exit(-1);
}

newBoard用来创建空游戏区:

void newBoard()
{
    blocks.clear();
    for(size_t w = 0; w < gridData.size(); ++w) {
        for(size_t h = 0; h < gridData[0].size(); ++h) {
            blocks.emplace_back(w, h);
        }
    }
}

update就是update…:

void update(sf::RenderWindow& window)
{
    for(size_t w = 0; w < gridData.size(); ++w) {
        for(size_t h = 0; h < gridData[0].size(); ++h) {
            auto tcolor = dispatchColor(gridData[w][h]);
            auto pos = w * gridData[0].size() + h;
            blocks[pos].color(tcolor);
        }
    }

    for(const auto& block : blocks) {
        window.draw(block.rec_);
    }
}

先读取核心数据,分配个每一个渲染块。然后统一渲染。主程序大概是这样:

sf::RenderWindow window{{WndWidth, WndHeight}, "puyo"};
Blocks blocks;
blocks.newBoard();
while(true){
//...
  blocks.update(window);
//...
}

尝试运行:

操作行为

上面其实是个数据驱动的设计。gridData是游戏的核心数据,无论用什么图形库或引擎,这部分都不变。Block封装了图形库对单个grid的解释,Blocks储存了全面的渲染信息和行为(理论上也可以分开,不过没必要)。

PuyoPuyo的规则每次只有下落中的色块对可以操纵方向和旋转。按照上面的思路,色块对的行为应该只涉及核心数据。这里暂时打算创建BlockPair类来封装它们:

struct Pos{ int x, y; };
struct BlockPair {
    BlockPair(int x = 4, int y = 1) : pivot{x, y}, attach{x, y - 1} { render(); }
    void render()
    {
        cp = RED;
        ca = GREEN;
        gPivot() = cp;
        gAttach() = ca;
    }
    void fallDown();
    void move(int h, int v);
    bool collide();
    constexpr ColorFlag& gPivot() { return gridData[pivot.x][pivot.y]; }
    constexpr ColorFlag& gAttach() { return gridData[attach.x][attach.y]; }
    void spawn()
    {
        pivot = {4, 0};
        attach = {3, 0};
    }
    template <typename F> void rotate(F dir);

    void rotClock();
    void rotCount();
    void update()
    {
        if(sf::Keyboard::isKeyPressed(sf::Keyboard::Key::A)) {
            move(-1, 0);
        } else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Key::D)) {
            move(1, 0);
        } else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Key::S)) {
            move(0, 1);
        } else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Key::K)) {
            rotClock();
        } else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Key::J)) {
            rotCount();
        }
    }
    ColorFlag cp;
    ColorFlag ca;
    Pos pivot;
    Pos attach;
};

上述代码可以看出:

  • 键盘ASD控制色块对左下右移动,KJ控制色块对旋转。
  • 定义结构Pos表示高宽坐标。这可能是多余的,有机会再想办法精简掉。
  • render()和spawn()决定初生色块位置和颜色。暂时写成静态的。
  • 因为可以旋转,色块对由povot和attach组成,也就是附属块围着轴心旋转。(实际游戏规则稍微妙一点)
  • pivot和attach成员变量储存当前色块对分别的位置。这些属于状态,处理需谨慎。
  • gPivot()和gAttach()负责取当前grid数据,小helper。
  • cp和ca是当前色块颜色数据。(你可能奇怪为什么不写全称,如果你使用和我一样的桌子和键盘,多打一个字手都会断:-P)

move操作是可以抽象的,方便起见写成一个函数:

void move(int h, int v)
{
    gPivot() = EMPTY;
    gAttach() = EMPTY;

    pivot.x += h;
    attach.x += h;
    pivot.y += v;
    attach.y += v;
    if(collide()) {
        pivot.x -= h;
        attach.x -= h;
        pivot.y -= v;
        attach.y -= v;
        gPivot() = cp;
        gAttach() = ca;
        // get bottom
        if(v > 0) {
            //falling rule
            fallDown();
            spawn();
        }
        return;
    }
    gPivot() = cp;
    gAttach() = ca;
}

移动为非就是x,y坐标的加减法。这里的逻辑就是先移动,再满段是否碰撞,如果碰撞就撤销动作。就好像MVCC。这段代码看似可以优化,有机会再研究。

这里都是方块对方块,碰撞函数就比较简单:

bool collide()
{
    auto minLeft = std::min(pivot.x, attach.x);
    auto maxRight = std::max(pivot.x, attach.x);
    auto maxDepth = std::max(pivot.y, attach.y);
    if(minLeft < 0 || maxRight >= gridWidth) {
        return true;
    }
    if(gPivot() != EMPTY || gAttach() != EMPTY) {
        return true;
    }
    if(maxDepth >= gridHeight) {
        return true;
    }
    return false;
}

由于色块对可以旋转,每次都要重新计算边界坐标,可能有更好的做法我没发现。

现在已经有了雏形:

这个下落规则是玛丽医生的,但PuyoPuyo不同。其中一个色块被挡时,色块对破裂,另一个会自动下落。简单处理一下:

void fallDown()
{
    for(auto& col : gridData) {
        auto iter = std::remove(rbegin(col), rend(col), EMPTY);
        std::fill(iter, rend(col), EMPTY);
    }
}

写算法从来就是C++的强项,三行搞定。这也是为什么以列为次级单位。

*修正

做事的时候,灵光一闪,上面的算法不就是stable_partition(之前一篇博文涉及到它):

std::stable_partition(begin(col), end(col), 
     [](const auto& g){return g == EMPTY;});

这样更简单了。STL神乎!


现在左图图色块对就会被拆散下落。

接下来就是旋转,其实因为只能90度旋转,所以实现非常简单,只要用一点三角函数:

  • x = x * cos(x) – y * sin(y)
  • y = x * sin(x) + y * cos(y)

其次:

  • cos(90) = 0;
  • sin(90) = 1;
  • cos(-90) = 0;
  • sin(-90) = -1;

也就是,顺时针旋转:

  • x = -y
  • y = x

逆时针:

  • x = y
  • y = -x

现在只要将以pivot为原点,attach的坐标按照上述结论处理就行了。看起来旋转也可以抽象,不过方向不那么好表示,就使用一个模板封装重复代码:

template <typename F> void rotate(F trans)
{
    gPivot() = EMPTY;
    gAttach() = EMPTY;
    auto vx = attach.x - pivot.x;
    auto vy = attach.y - pivot.y;
    trans(vx, vy);
    gPivot() = cp;
    gAttach() = ca;
}

void rotClock()
{
    rotate([&](const auto& vx, const auto& vy) {
        attach.x += -vy - vx;
        attach.y += vx - vy;
        if(collide()) {
            attach.x -= -vy - vx;
            attach.y -= vx - vy;
        }
    });
}

void rotCount()
{
    rotate([&](const auto& vx, const auto& vy) {
        attach.x += vy - vx;
        attach.y += -vx - vy;
        if(collide()) {
            attach.x -= vy - vx;
            attach.y -= -vx - vy;
        }
    });
}

是不是有点像template method…而且真的用了template 😛

至此,基本元素和操作已经完成,勉强能看出是类PuyoPuyo游戏。不过该系列最大的特色——消除规则,暂时我的思路不是很清晰,只简单考虑了无向图。

其余内容且等我设计完成,并留到后续博文。

Leave a Reply

Your email address will not be published. Required fields are marked *