张银峰的编程课堂

显示关卡

搭建项目

  1. 将地图素材放在Res/images/map目录中
  2. 向解决方案添加一个新项目
  3. 按第2节课配置好环境

glimix.com

分析关卡构成

从我们对游戏的直观感知而言,一个关卡中显而易见元素有:路,墙,玩家,箱子,目标点。 关卡设计就是将这些元素巧妙的组合起来,下面的数字组合是地图文件中一个关卡的定义。

[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0]
[0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0]
[0,0,0,0,1,4,3,0,1,0,0,0,0,0,0,0]
[0,0,0,0,1,1,3,0,1,1,0,0,0,0,0,0]
[0,0,0,0,1,1,0,3,0,1,0,0,0,0,0,0]
[0,0,0,0,1,2,3,0,0,1,0,0,0,0,0,0]
[0,0,0,0,1,2,2,5,2,1,0,0,0,0,0,0]
[0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

这里,关卡有16行、16列共计256个数字。从定义中我们并没有直观的看到路、墙这些元素,但数字确实代表的就是这些元素。现在假设关卡中的一个0代表一块平坦的石块,再将所有数字想像为0,这样你将会得到一个巨大的广场。

脑海中的想像,从程序的角度说,就是整个关卡被渲染(绘制)后,得到了可见的场景(地图)。现在广场就是这个关卡的地图,它由256个小方块组成的,每个小方块称为一个瓦片。在我们的程序中,瓦片的大小与美术素材直接相关,这个素材就是block.png,这是一个35x35像素大小的图片,所以整个广场就是560x560像素大小。

对于这些信息,我们通过定义一些常量表达:

#define MAP_ROWS        16                        // 地图行数
#define MAP_COLUMNS     16                        // 地图列数
#define TILE_WIDTH      35                        // 地面贴片元素宽度
#define TILE_HEIGHT     35                        // 地面贴片元素宽度
#define SCENE_WIDTH     TILE_WIDTH  * MAP_COLUMNS // 窗口宽度
#define SCENE_HEIGHT    TILE_HEIGHT * MAP_ROWS    // 窗口高度

而对于地图,我们则使用一个二维数组来表达。

char g_map[MAP_ROWS][MAP_COLUMNS] =
{
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,
    0,1,1,1,1,0,0,0,0,0,1,0,0,0,0,0,
    0,1,0,0,0,2,1,1,1,0,1,0,0,0,0,0,
    0,1,0,1,0,1,0,0,0,0,1,1,0,0,0,0,
    0,1,0,1,0,3,0,3,1,2,0,1,0,0,0,0,
    0,1,0,1,0,0,5,0,0,1,0,1,0,0,0,0,
    0,1,0,2,1,3,0,3,0,1,0,1,0,0,0,0,
    0,1,1,0,0,0,0,1,0,1,0,1,1,1,0,0,
    0,0,1,0,1,1,1,2,0,0,0,0,4,1,0,0,
    0,0,1,0,0,0,0,0,1,1,0,0,0,1,0,0,
    0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
};

现在我们列出数字与元素的对应关系,其中:

  • 0: 路面
  • 1: 障碍(墙面)
  • 2: 目标点
  • 3: 箱子
  • 4: 玩家
  • 5: 箱子 + 目标点

可以使用枚举来描述这些元素,这种有具体意义的名称会提高程序的可读性。

enum ELEMENT
{
    ROAD,               // 路基
    WALL,               // 障碍(墙面)
    TARGET,             // 目标点
    BOX,                // 柳木箱
    PLAYER,             // 玩家
    GOAL,               // 柳木箱 + 目标点
};

分析图片资源

美术资源并不一定是跟场景元素是一一对应的关系,如元素ROAD仅对应到Block.png,而PLAYER元素,却需要上下左右4个方位的图像资源。这些资源在整个游戏期间一直被使用,为此我们依然可以用枚举来定义一些名称以便显式的索引这些资源。

enum TEXTURE
{
    TEX_ROAD,           // 路基
    TEX_WALL,           // 障碍(墙面)
    TEX_TARGET,         // 目标点
    TEX_BOX,            // 柳木箱
    TEX_PLAYER_UP,      // 玩家上
    TEX_PLAYER_DOWN,    // 玩家下
    TEX_PLAYER_LEFT,    // 玩家左
    TEX_PLAYER_RIGHT,   // 玩家右
    TEX_COUNT           // 纹理数量
};

这里的一个技巧是TEX_COUNT的定义,它并不用于索引图像资源,而是定义了场景中所有图像资源的数量。

接下来,我们实现资源加载方法。

// 存储载入的所有图象
int g_textures[TEX_COUNT];

bool Load_Resource()
{
    struct 
    {
        int id;
        const char *file;
    } 
    img_map[] =
    {
        BLOCK,        "block.png",
        WALL,         "wall.png",
        BALL,         "ball.png",
        BOX,          "box.png",
        PLAYER_UP,    "up.png",
        PLAYER_DOWN,  "down.png",
        PLAYER_LEFT,  "left.png",
        PLAYER_RIGHT, "right.png"
    };

    char path[64];

    for (int i = 0; i < TEX_COUNT; i++)
    {
        sprintf_s(path, 64, "./res/images/map/%s", img_map[i].file);
        g_textures[img_map[i].id] = E2D_CreateTexture(path);

        if (g_textures[img_map[i].id] == E2D_INVALID_RESID)
        {
            char message[128];
            sprintf_s(message, 128, "Load resource: \"%s\" failed!\n", path);
            E2D_MessageBox("PushBox", message, E2D_STOP);
            return false;
        }
    }

    return true;
}

注意这里加载图像的路径是./res/images/map/xxx.png,由于我们是通过一个解决方案管理多个项目的方式来构建整套项目,所以当通过VS运行程序时,其工作目录(当前目录)就是项目所在目录PushBox_02,加载的是PushBox_02/res/iamges/map/xxx.png,而实际的图像资源则在PushBox_02的上层目录(../res)中,因此加载会失败。程序之所以这么设计,是考虑到游戏最终发布时,程序文件与资源文件在同一层级,即以下面的目录结构组成。

PushBox
|---------- PushBox.exe
|__________ Res
            |__________  Images
            |__________  Map.data

为此,我们可以在VS中配置一下工作目录,将$(ProjectDir)变更为$(SolutDir),即将当前工作目录从原有的“项目所在文件夹”变更为“解决方案所在文件夹”。

glimix.com

显示关卡

图象加载后可以称为纹理。所有资源加载完成后,我们就可以通过渲染(绘制)来看看关卡的真实模样!

void Render()
{
    for (int r = 0; r < MAP_ROWS; r++)
    {
        for (int c = 0; c < MAP_COLUMNS; c++)
        {
            int x = c * TILE_WIDTH;
            int y = r * TILE_HEIGHT;

            // 绘制路面
            E2D_RenderTexture(g_textures[TEX_ROAD].tex, x, y, E2D_FLIP_NONE);

            if (g_map[r][c] != ROAD)
            {
                int t = -1;

                switch (g_map[r][c])
                {
                case ROAD:  
                    t = TEX_ROAD;  
                    break;

                case WALL:  
                    t = TEX_WALL;  
                    break;

                case TARGET:
                    t = TEX_TARGET;
                    break;

                case BOX:
                case GOAL:
                    t = TEX_BOX;
                    break;

                case PLAYER:
                    t = TEX_PLAYER_DOWN;
                    break;
                }

                if (t != -1)
                {
                    E2D_RenderTexture(g_textures[t].tex, x, y, E2D_FLIP_NONE);
                }
            }
        }
    }
}

glimix.com

可以看到,结果并不理想,但仍有几点要说明的。

首先,无论地图中当前元素是什么,我们都绘制了路面,然后在路面上再绘制元素本身,这是因为路面Block.png会铺满整个瓦片,但对于障碍Wall.png却因为有镂空部分而会显露出窗口的黑色背景。

glimix.com

另一个问题是,所有的图象元素好像被压缩到一个瓦片空间中了,致使图象显示不完整。实际上是因为绘制时,我们以当前瓦片的左上角为起点,绘制出了整个图像,即使图像大小大于瓦片大小。但由于瓦片是紧密排列的,下一个元素绘制时,会遮住上一个图像的右边超出部分,下一行的元素则会遮住上一行元素的底部超出部分,因此造成了这样的视觉表现。

glimix.com

观察一下整个场景及所有的图像资源,它们带有仰视效果,因此我们可以通过让所有的图片相对于瓦片,左右居中底端对齐来达到目的。为此我们需要一些额外信息来描述资源的绘制,这通过TEXINFO结构来表达。

// 纹理信息
typedef struct tagTEXINFO
{
    int tex; // 纹理
    int dx;  // x向偏移
    int dy;  // y向偏移
} TEXINFO;

TEXINFO g_textures[TEX_COUNT];

然后,我们在载入元素时,计算这些值。

bool Load_Resource()
{
    //...

    for (int i = 0; i < TEX_COUNT; i++)
    {
        char path[64];
        sprintf_s(path, 64, "./res/images/map/%s", img_map[i].file);

        int eid = img_map[i].id;
        int tex = E2D_CreateTexture(path);

        if (tex != E2D_INVALID_RESID)
        {
            int w, h;
            E2D_GetTextureSize(tex, &w, &h);

            g_textures[eid].tex = tex;
            g_textures[eid].dx = (w - TILE_WIDTH) / 2; // 水平方向使纹理居中
            g_textures[eid].dy = (h - TILE_HEIGHT);    // 垂直方向纹理与当前行底端对齐
        }
    }

    return true;
}

相应的,渲染函数也要作出更改;至此,一切都美好如初了。

void Render()
{
    for (int r = 0; r < MAP_ROWS; r++)
    {
        for (int c = 0; c < MAP_COLUMNS; c++)
        {
            int x = c * TILE_WIDTH;
            int y = r * TILE_HEIGHT;

            E2D_RenderTexture(g_textures[TEX_ROAD].tex, x, y, E2D_FLIP_NONE);

            if (g_map[r][c] != ROAD)
            {
                const TEXINFO *t = NULL;

                switch (g_map[r][c])
                {
                case ROAD:
                    t = &g_textures[TEX_ROAD];
                    break;

                case WALL:
                    t = &g_textures[TEX_WALL];
                    break;

                case TARGET:
                    t = &g_textures[TEX_TARGET];
                    break;

                case BOX:
                case GOAL:
                    t = &g_textures[TEX_BOX];
                    break;

                case PLAYER:
                    t = &g_textures[TEX_PLAYER_DOWN];
                    break;
                }

                if (t != NULL)
                {
                    E2D_RenderTexture(t->tex, x - t->dx, y - t->dy, E2D_FLIP_NONE);
                }
            }
        }
    }
}

glimix.com