今天咱们制作方块掌机的最后一个游戏 —— 打砖块。这个游戏据说是乔布斯发明的,真假不知道,但是核心规则很简单,就是用一个挡板发射小球来撞击上方的砖块,全部击碎算是过关。原始的游戏规则我这也不太了解,本文只是介绍一个我观察出来的掌机打砖块版本,相关的等级、分数以及碰撞规则的计算方法都可能和原始版本略有不同,但是游戏的原理是一致的,学会这个版本之后想要修改规则还是非常简单的。废话不多说,接下来咱们就介绍一下掌机上的打砖块的具体制作方法。

首先咱们分析一下游戏,研究一下游戏包含的几个东西:

  • 挡板。一个用来撞击小球,并防止小球落入底部的长条。其中挡板只会在游戏窗口的底部水平移动。

  • 小球。一个在游戏窗口中不断移动的方块。该方块可以和墙壁、挡板和砖块发生碰撞。

  • 砖块。一些可以被小球击碎的方块,这些方块会在窗口的上方。

  • 容器。和其它掌机方块的游戏类似,该游戏同样可以认为有一个容器,用来放置挡板、砖块、小球等所有游戏相关的东西。

玩家可以操作挡板撞击小球,小球发生碰撞后,会产生匀速直线运动,当小球撞击到砖块后,砖块会消失,小球会产生一个反射的运动效果。如果小球下落的过程中,没有撞击到挡板,而是直接落到挡板下方,则游戏 Gameover。

1. 容器和砖块

实现游戏的第一步就是定义游戏的容器。和其它容器的实现方式一样,本文的容器实现一样用 unsigned long 类型定义容器的每一行,每个比特代表当前列的情况,上下左右各有一个隐藏行用来碰撞,所以容器的总面积是:22 * 12 。具体代码如下:

//打砖块
typedef struct Breakout
{
	unsigned long blockContainer[BREAKOUT_CONTAINER_HEIGHT];	//容器
	
	//...
} Breakout;

其中 BREAKOUT_CONTAINER_HEIGHT 表示容器的总高度:

//容器宽高
#define BREAKOUT_CONTAINER_WIDTH (1 + 10 + 1)
#define BREAKOUT_CONTAINER_HEIGHT (1 + 20 + 1)

有了容器之后,我们就可以将砖块放入其中:

memset(bo->blockContainer, 0, sizeof(unsigned long) * BREAKOUT_CONTAINER_HEIGHT);

bo->blockContainer[0] = 0x0FFF;
for (int i = 1; i < BREAKOUT_CONTAINER_HEIGHT - 1; i++)
{
	//四行砖块
	if (i <= 5) {
		bo->blockContainer[i] = 0x0FFF;
	}

	bo->blockContainer[i] |= 0x0801;
}
bo->blockContainer[BREAKOUT_CONTAINER_HEIGHT - 1] = 0x0FFF;

代码首先清空容器的所有内容,然后初始化容器四周的边框,并在上方的五行中充满砖块。具体的初始化效果如下:

breakout_0

四周的隐藏墙壁是为了让小球的碰撞检测逻辑更简单。

2. 可以操作的挡板

挡板是由固定的四个小方块组成,这里定义一个宏,用来表示挡板的长度:

//挡板宽度
#define GUARD_BLOCK_WIDTH 4

然后再定义挡板的位置和速度:

//打砖块
typedef struct Breakout
{
	int guardX;						//挡板的水平位置
	int guardDirX;					//挡板运动方向
	
	//...
}Breakout;

当按下键盘左键或者右键的时候,可以移动挡板:

//左移
void startMoveLeft(Breakout* bo)
{
	bo->guardDirX = -1;
}

//右移
void startMoveRight(Breakout* bo)
{
	bo->guardDirX = 1;
}

当抬起按键的时候,停止移动:

//停止移动
void stopMoveLeft(Breakout* bo)
{
	if (bo->guardDirX < 0)
		bo->guardDirX = 0;
}

//停止移动
void stopMoveRight(Breakout* bo)
{
	if (bo->guardDirX > 0)
		bo->guardDirX = 0;
}

这里有两个地方需要注意:

其一,挡板停止移动的时候做了一个同向判断,因为当你松开按键之前,很有可能已经按下了另一侧的方向键,如果不加同侧判断直接清零,会将当前的速度直接归零,从而影响挡板的正常移动(例如:你先按下左键,然后再按下右键,挡板已经向右移动,再松开左键的情况)。

其二,挡板的移动没有使用直接修改挡板位置的办法,而只是修改挡板的移动的方向或者速度,这样做的原因是如果直接改变挡板位置,类似下面的代码:

void startMoveLeft(Breakout* bo)
{
	bo->guardX--;
}

这样的方式虽然可行,但是因为事件响应会有时间间隔,操作上会有明显的卡顿感,最主要的是挡板的移动必须流畅,因为挡板需要另一个功能,就是当小球落在挡板的时候,可以使用挡板移动小球下一次发射的初始位置。

breakout_1

小球和挡板一起移动的代码很简单,只需要判断小球是否在挡板的上方,如果在直接改变现有小球的水平位置即可:

//球在挡板上方,球一起移动
if (bo->ballY == (BREAKOUT_CONTAINER_HEIGHT - 4) && bo->ballX >= bo->guardX && bo->ballX <= bo->guardX + GUARD_BLOCK_WIDTH) {
	//移动小球
	bo->ballX += bo->guardDirX;
	
	//防止越界
	if (bo->ballX < 0) {
		bo->ballX = 0;
	}
	else if (bo->ballX > BREAKOUT_CONTAINER_WIDTH - 3) {
		bo->ballX = BREAKOUT_CONTAINER_WIDTH - 3;
	}
}

3. 弹跳的小球

最后要实现的就是用来碰撞的小球了,首先定义小球的属性:

//打砖块
typedef struct Breakout
{
	int ballX, ballY;				//小球的位置
	int ballDirX, ballDirY;			//小球的方向
	
	//...
} Breakout;

确定了属性后,小球的核心在于碰撞检测。打砖块这个游戏的演变类型非常多,每种游戏的碰撞检测方式都略有不同,即使方砖掌机这种简单游戏我在调研的过程中也发现很多种计算方式。主要的不同之处有两个:

  • 碰撞点的检测。
  • 碰撞后的行为。

因为不知道原始游戏的处理方式,所以这里只实现了一个我认为可以接受的逻辑:“小球的运动是匀速直线运动,且 x 方向上的速度不会为 0。”

从这个逻辑上,我们可以推算出小球的运动方向共有四种:

breakout_2

图片中黄色的位置代表小球所处的位置,而蓝色的位置代表小球下一次可能移动的位置,四个位置分别处于正方形的四个角落。

假设小球的下一个移动位置是右上方,我们可以定义小球碰撞的场景和碰撞后可以销毁哪个位置的方块:

breakout_3

按照碰撞后小球的运动方向,可以分为三种类型:

  • 水平折返。这种场景一般多发生于小球碰撞左右墙壁,碰撞后小球会翻转水平的运动方向。

  • 垂直折返。这种场景一般多发生于小球碰撞上方的墙壁或下方的挡板,碰撞后小球会翻转垂直的运动方向。

  • 对角折返。这种场景多发生于小球和砖块的碰撞,碰撞后小球的水平和垂直的运动方向都会翻转。

图片中红色 X 标记,代表碰撞后小球会销毁砖块的位置。整个代码的核心碰撞逻辑如下:

			
//更新游戏
int updateBreakout(Breakout* bo, float ms)
{
    //小球下一个位置
    int nextX = bo->ballX + bo->ballDirX;
    int nextY = bo->ballY + bo->ballDirY;


    //合并挡板,检测碰撞
    bo->blockContainer[BREAKOUT_CONTAINER_HEIGHT - 2] |= (0xF << (bo->guardX+1));
    //下一个位置
    int nextPosFlag = bo->blockContainer[nextY + 1] & (1 << (nextX + 1));
    //上或下侧
    int nextYFlag = bo->blockContainer[nextY + 1] & (1 << (bo->ballX + 1));
    //左或右侧
    int nextXFlag = bo->blockContainer[bo->ballY + 1] & (1 << (nextX + 1));
    //去除挡板,还原容器
    bo->blockContainer[BREAKOUT_CONTAINER_HEIGHT - 2] = 0x0801;


    //如果当前位置到最底部,游戏结束
    if (bo->ballY >= (BREAKOUT_CONTAINER_HEIGHT - 3)) 
    {
        //GAMEOVER

        return 0;
    }

    //水平折返
    if (nextYFlag == 0 && nextXFlag != 0) 
    {

        //...

        //折返
        bo->ballDirX = -bo->ballDirX;
        bo->ballX = bo->ballX + bo->ballDirX;
        bo->ballY = nextY;
    }
    //垂直折返
    else if (nextYFlag != 0 && nextXFlag == 0) 
    {

        //...

        bo->ballDirY = -bo->ballDirY;
        bo->ballX = nextX;
        bo->ballY = bo->ballY + bo->ballDirY;
    }
    //对角折返
    else if (nextPosFlag != 0 || (nextYFlag != 0 && nextXFlag != 0)) 
    {

        //...

        bo->ballDirX = -bo->ballDirX;
        bo->ballDirY = -bo->ballDirY;

        bo->ballX = bo->ballX + bo->ballDirX;
        bo->ballY = bo->ballY + bo->ballDirY;
    }
    //通过
    else 
    {
        bo->ballX = nextX;
        bo->ballY = nextY;
    }

    return 0;
}

代码中首先获取小球下一个位置的情况,包括垂直方向和水平方向以及对角方向的方块样式,然后根据这些方块的样式判断小球的碰撞逻辑属于三种逻辑的哪一种,分别对应不同的处理策略。

4. 总结

搞定小球、挡板以及砖块的三种逻辑,基本上这个游戏的难点就搞定了,剩下的都是写边角料而已。比较遗憾的是手头没有一个方块掌机用于研究,所以碰撞的逻辑策略可能未必和原版一样,因为我发现现在的碰撞逻辑玩起来真的好简单……😂