跳转至

02-资源管理器与游戏主循环优化

植物明星大乱斗中,我们使用了硬编码加载资源,很容易出错,同时散落的各种图片或图集变量存放在全局环境中,引入声明和使用时也容易搞错。
有没有什么方案可以方便地加载资源,并可以即使地检查加载成功与否的状态,同时还可以把所有资源统一存访,避免散落各处?

1 单例模式

单例模式的核心思想是限制一个类只能被实例化一次。我们通过私有其构造函数和拷贝构造函数等创建对象的方法,容易对外构造一个获取类实例的静态方法。
这样,我们遍可以用它轻松实现管理器的概念。

2 资源管理器

什么是管理器呢?
我们以游戏场景为例。游戏场景中存在玩家、敌人和弹射物游戏对象。它们彼此之间都存在碰撞关系。玩家攻击碰撞箱碰触到敌人时能够造成伤害,玩家自身碰触到敌人或者弹射物也会受到伤害。
那么我们应该在哪里存放这些碰撞箱对象呢?如果把它们各自存放为各自对象的成员,那么敌人对象在更新时就需要访问玩家的数据。整个游戏中的对象就会杂糅到一起。
如果我们使用一个碰撞管理器来统一管理这些碰撞体对象,玩家等不同的游戏对象只是持有管理器中碰撞箱的引用。
我们要先实现资源管理器,它的思路和这里介绍的碰撞管理器相似。我们将游戏所需的资源统一交付给全局的资源管理器进行加载和记录。我们在用到资源时,就可以十分简洁的代码来获取对应的资源实例。

我们先创建resources_manager头文件

class ResourcesManager
{
public:
    static ResourcesManager *instance();
private:
    static ResourcesManager * manager;
private:
    ResourcesManager();
    ~ResourcesManager();
};

我们提供了一个静态的instance方法,用来放回ResourcesManager类唯一的实例
而实例指针和构造函数等内容我们便设为私有字段。
而在对应的Resources Manager中,我们便可以编写这样的代码实现
#include "resource_manager.h"

ResourcesManager *ResourcesManager::manager = nullptr;

ResourcesManager* ResourcesManager::instance()
{
    if(!manager)
        manager = new ResourcesManager();
    return manager;
}

ResourcesManager::ResourcesManager() = default;

ResourcesManager::~ResourcesManager() = default;

2.1 资源保存

如果我们还是把资源散落在类的成员变量中,那么避免不了手动编写许多加载代码,不方便统一管理。
因此这里我们引入STL库的unordered_map来进行管理,它是一种哈希表的实现,记录了从key到value的映射关系,其中每一个key是唯一存在的。
对应到我们的代码中,我们便可以扩展这些私有变量和公共访问接口。

我们使用字符串来当作资源的ID来映射到对应的资源对象。由于我们使用的美术资源可以分为两类,Atlas图集和IMAGE对象。所以分别创建了两种类型的资源池,atlas_pool和image_pool.
对应的,我们也提供find方法,根据传入的字符串,查找对应的资源。
而具体的加载逻辑,我们封装到了load方法中。
除此之外,由于对动画序列帧的左右翻转是一个资源加载阶段的处理行为。所以我们把相关的逻辑也封装为资源管理器类的私有方法。

class ResourcesManager
{
public:
    static ResourcesManager *instance();

    void load();

    Atlas *find_atlas(const std::string &id) const;
    IMAGE *find_image(const std::string &id) const;

private:
    static ResourcesManager * manager;

    std::unordered_map<std::string, Atlas*> atlas_pool;
    std::unordered_map<std::string, IMAGE *> image_pool;

private:
    ResourcesManager();
    ~ResourcesManager();

    void flip_image(IMAGE *src_image, IMAGE *dst_image, int num_h = 1);
    void flip_image(const std::string &src_id, const std::string dst_id, int num_h = 1);
    void flip_atlas(const std::string &src_id, const std::string dst_id);

2.2 加载图片

为了减少人工编码,我们需要提供一套数据结构,来记录哪些资源分别对应哪些id存放在资源池中。
于是我们定义了ImageResInfo和AtlasResInfo,两种资源信息描述结构。

public:
    struct ImageResInfo
    {
        std::string id;
        LPCTSTR path;
    };

    struct AtlasResInfo
    {
        std::string id;
        LPCTSTR path;
        int num_frame = 0;
    };

紧接着我们可以添加图片信息的定义.这里使用列表初始化,来构造了一个静态的vector。使用R标记来表示原始字符串,这样就不需要对字符串内部的反斜线进行转义处理了。
static const std::vector<ImageResInfo> image_info_list =
{
    { "background", _T(R"(resources\background.png)") },
    { "ui_heart",   _T(R"(resources\ui_heart.png)") },

    { "player_attack_right",    _T(R"(resources\player\attack.png)") },
    { "player_dead_right",      _T(R"(resources\player\dead.png)") },
    { "player_fall_right",      _T(R"(resources\player\fall.png)") },
    { "player_idle_right",      _T(R"(resources\player\idle.png)") },
    { "player_jump_right",      _T(R"(resources\player\jump.png)") },
    { "player_run_right",       _T(R"(resources\player\run.png)") },
    { "player_roll_right",      _T(R"(resources\player\roll.png)") },

    { "player_vfx_attack_down",   _T(R"(resources\player\vfx_attack_down.png)") },
    { "player_vfx_attack_left",   _T(R"(resources\player\vfx_attack_left.png)") },
    { "player_vfx_attack_right",  _T(R"(resources\player\vfx_attack_right.png)") },
    { "player_vfx_attack_up",     _T(R"(resources\player\vfx_attack_up.png)") },
    { "player_vfx_jump",          _T(R"(resources\player\vfx_jump.png)") },
    { "player_vfx_land",          _T(R"(resources\player\vfx_land.png)") },
};

static const std::vector<AtlasResInfo> atlas_info_list =
{
    { "barb_break", _T(R"(resources\enemy\barb_break\%d.png)"), 3 },
    { "barb_loose", _T(R"(resources\enemy\barb_loose\%d.png)"), 5 },
    { "silk",      _T(R"(resources\enemy\silk\%d.png)"),        9 },
    { "sword_left",_T(R"(resources\enemy\sword\%d.png)"),       3 },

    { "enemy_aim_left",           _T(R"(resources\enemy\aim\%d.png)"),           9 },
    { "enemy_dash_in_air_left",   _T(R"(resources\enemy\dash_in_air\%d.png)"),   2 },
    { "enemy_dash_on_floor_left", _T(R"(resources\enemy\dash_on_floor\%d.png)"), 2 },
    { "enemy_fall_left",          _T(R"(resources\enemy\fall\%d.png)"),          4 },
    { "enemy_idle_left",          _T(R"(resources\enemy\idle\%d.png)"),          6 },
    { "enemy_jump_left",          _T(R"(resources\enemy\jump\%d.png)"),          8 },
    { "enemy_run_left",           _T(R"(resources\enemy\run\%d.png)"),           8 },
    { "enemy_squat_left",         _T(R"(resources\enemy\squat\%d.png)"),         10 },
    { "enemy_throw_barb_left",    _T(R"(resources\enemy\throw_barb\%d.png)"),    8 },
    { "enemy_throw_silk_left",    _T(R"(resources\enemy\throw_silk\%d.png)"),    17 },
    { "enemy_throw_sword_left",   _T(R"(resources\enemy\throw_sword\%d.png)"),   16 },

    { "enemy_vfx_dash_in_air_left",   _T(R"(resources\enemy\vfx_dash_in_air\%d.png)"),   5 },
    { "enemy_vfx_dash_on_floor_left", _T(R"(resources\enemy\vfx_dash_on_floor\%d.png)"), 6 },
};

当把这些直接写在头文件的类定义中,会有报错。在 C++11 之前的标准中,类内的static const成员如果是复杂类型(如std::vector),不能直接在类内部进行初始化。即使开启了 C++11,对于这种包含自定义结构体的vector,直接类内初始化也可能因编译器实现差异导致报错。

所以可以在头文件中仅声明这两个变量,然后在.cpp文件中对他们进行初始化。

static const std::vector<ImageResInfo> image_info_list; 
static const std::vector<AtlasResInfo> atlas_info_list;

// 2. 在类外部初始化静态常量成员
const std::vector<ResourcesManager::ImageResInfo> ResourcesManager::image_info_list =
{
    { "background", _T(R"(resources\background.png)") },
    { "ui_heart",   _T(R"(resources\ui_heart.png)") },

    { "player_attack_right",    _T(R"(resources\player\attack.png)") },
    { "player_dead_right",      _T(R"(resources\player\dead.png)") },
    { "player_fall_right",      _T(R"(resources\player\fall.png)") },
    { "player_idle_right",      _T(R"(resources\player\idle.png)") },
    { "player_jump_right",      _T(R"(resources\player\jump.png)") },
    { "player_run_right",       _T(R"(resources\player\run.png)") },
    { "player_roll_right",      _T(R"(resources\player\roll.png)") },

    { "player_vfx_attack_down",   _T(R"(resources\player\vfx_attack_down.png)") },
    { "player_vfx_attack_left",   _T(R"(resources\player\vfx_attack_left.png)") },
    { "player_vfx_attack_right",  _T(R"(resources\player\vfx_attack_right.png)") },
    { "player_vfx_attack_up",     _T(R"(resources\player\vfx_attack_up.png)") },
    { "player_vfx_jump",          _T(R"(resources\player\vfx_jump.png)") },
    { "player_vfx_land",          _T(R"(resources\player\vfx_land.png)") },
};

const std::vector<ResourcesManager::AtlasResInfo> ResourcesManager::atlas_info_list =
{
    { "barb_break", _T(R"(resources\enemy\barb_break\%d.png)"), 3 },
    { "barb_loose", _T(R"(resources\enemy\barb_loose\%d.png)"), 5 },
    { "silk",      _T(R"(resources\enemy\silk\%d.png)"),        9 },
    { "sword_left",_T(R"(resources\enemy\sword\%d.png)"),       3 },

    { "enemy_aim_left",           _T(R"(resources\enemy\aim\%d.png)"),           9 },
    { "enemy_dash_in_air_left",   _T(R"(resources\enemy\dash_in_air\%d.png)"),   2 },
    { "enemy_dash_on_floor_left", _T(R"(resources\enemy\dash_on_floor\%d.png)"), 2 },
    { "enemy_fall_left",          _T(R"(resources\enemy\fall\%d.png)"),          4 },
    { "enemy_idle_left",          _T(R"(resources\enemy\idle\%d.png)"),          6 },
    { "enemy_jump_left",          _T(R"(resources\enemy\jump\%d.png)"),          8 },
    { "enemy_run_left",           _T(R"(resources\enemy\run\%d.png)"),           8 },
    { "enemy_squat_left",         _T(R"(resources\enemy\squat\%d.png)"),         10 },
    { "enemy_throw_barb_left",    _T(R"(resources\enemy\throw_barb\%d.png)"),    8 },
    { "enemy_throw_silk_left",    _T(R"(resources\enemy\throw_silk\%d.png)"),    17 },
    { "enemy_throw_sword_left",   _T(R"(resources\enemy\throw_sword\%d.png)"),   16 },

    { "enemy_vfx_dash_in_air_left",   _T(R"(resources\enemy\vfx_dash_in_air\%d.png)"),   5 },
    { "enemy_vfx_dash_on_floor_left", _T(R"(resources\enemy\vfx_dash_on_floor\%d.png)"), 6 },
};

这样看,我们还是把资源硬编码到代码中了。但是我们实际上可以通过外部的配置文件记录游戏中使用资源的具体路径,又或者借助c++17的filesystem递归地扫描资源目录。将资源根据扩展名类型执行完全自动化地加载。有时间可以自行尝试。

我们首先封装一个工具方法,用来检查图片对象是否加载成功。在EasyX中,如果加载图片到IMAGE对象中发生错误,GetImageBuffer函数获取地图片像素缓冲区便是空指针。

static inline bool check_image_valid(IMAGE* image)
{
    return GetImageBuffer(image);
}

这是一个内联静态函数,static表示该函数仅在当前编译单元(.c/.cpp 文件) 内可见,外部文件无法调用,避免命名冲突。

之后我们开始着手编写具体地加载行为代码

void ResourcesManager::load()
{
    for(const auto& info : image_info_list)
    {
        IMAGE *image = new IMAGE();
        loadimage(image, info.path);
        if (!check_image_valid(image))
            throw info.path;
        image_pool[info.id] = image;
    }

    for(const auto& info : atlas_info_list)
    {
        Atlas *atlas = new Atlas();
        atlas->load_from_file(info.path, info.num_frame);
        for(int i = 0; i < atlas->get_size(); i++)
        {
            IMAGE *image = atlas->get_image(i);
            if(!check_image_valid(image))
                throw info.path;
        }
        atlas_pool[info.id] = atlas;
    }
}

这里我们使用异常,而不是用布尔标志来作为返回值告知上层程序是否加载成功。
虽然对于这种调用层数不深的函数,使用异常还是布尔返回值都没有什么问题,但是资源结构复杂,我们需要递归地加载某些资源的依赖项时,使用异常机制直接将错误抛出至底层调用者还是很有必要的。

之后我们在load函数中使用下述代码来将对应的资源进行翻转。

flip_image("player_attack_right", "player_attack_left", 5);
flip_image("player_dead_right", "player_dead_left", 6);
flip_image("player_fall_right", "player_fall_left", 5);
flip_image("player_idle_right", "player_idle_left", 5);
flip_image("player_jump_right", "player_jump_left", 5);
flip_image("player_run_right", "player_run_left", 10);
flip_image("player_roll_right", "player_roll_left", 7);

flip_atlas("sword_left", "sword_right");
flip_atlas("enemy_aim_left", "enemy_aim_right");
flip_atlas("enemy_dash_in_air_left", "enemy_dash_in_air_right");
flip_atlas("enemy_dash_on_floor_left", "enemy_dash_on_floor_right");
flip_atlas("enemy_fall_left", "enemy_fall_right");
flip_atlas("enemy_idle_left", "enemy_idle_right");
flip_atlas("enemy_jump_left", "enemy_jump_right");
flip_atlas("enemy_run_left", "enemy_run_right");
flip_atlas("enemy_squat_left", "enemy_squat_right");
flip_atlas("enemy_throw_barb_left", "enemy_throw_barb_right");
flip_atlas("enemy_throw_silk_left", "enemy_throw_silk_right");
flip_atlas("enemy_throw_sword_left", "enemy_throw_sword_right");

flip_atlas("enemy_vfx_dash_in_air_left", "enemy_vfx_dash_in_air_right");
flip_atlas("enemy_vfx_dash_on_floor_left", "enemy_vfx_dash_on_floor_right");

2.3 加载音频

对于音频加载,我们使用的MCI接口会自动记录我们给音频资源取得别名。所以我们不需要再自行使用和图片图集一样得资源池进行处理。但是为了简化代码,我们来到util.h工具头文件中, 提供下述三个语义更明确得音频处理接口。

inline void load_audio(LPCSTR path, LPCSTR id)
{
    static TCHAR str_cmd[512];
    _stprintf_s(str_cmd, _T("open %s alias %s"), path, id);
    mciSendString(str_cmd, NULL, 0, NULL);
}

inline void play_audio(LPCTSTR id, bool is_loop = false)
{
    static TCHAR str_cmd[512];
    _stprintf_s(str_cmd, _T("play %s %s from 0"), id, is_loop ? _T("repeat") : _T(""));
    mciSendString(str_cmd, NULL, 0, NULL);
}

inline void stop_audio(LPCTSTR id)
{
    static TCHAR str_cmd[512];
    _stprintf_s(str_cmd, _T("stop %s"), id);
    mciSendString(str_cmd, NULL, 0, NULL);
}

之后,我们再load()方法中添加音频加载得代码
load_audio(_T(R"(resources\audio\bgm.mp3)"), _T("bgm"));
load_audio(_T(R"(resources\audio\barb_break.mp3)"), _T("barb_break"));
load_audio(_T(R"(resources\audio\bullet_time.mp3)"), _T("bullet_time"));

load_audio(_T(R"(resources\audio\enemy_dash.mp3)"), _T("enemy_dash"));
load_audio(_T(R"(resources\audio\enemy_run.mp3)"), _T("enemy_run"));
load_audio(_T(R"(resources\audio\enemy_hurt_1.mp3)"), _T("enemy_hurt_1"));
load_audio(_T(R"(resources\audio\enemy_hurt_2.mp3)"), _T("enemy_hurt_2"));
load_audio(_T(R"(resources\audio\enemy_hurt_3.mp3)"), _T("enemy_hurt_3"));
load_audio(_T(R"(resources\audio\enemy_throw_barbs.mp3)"), _T("enemy_throw_barbs"));
load_audio(_T(R"(resources\audio\enemy_throw_silk.mp3)"), _T("enemy_throw_silk"));
load_audio(_T(R"(resources\audio\enemy_throw_sword.mp3)"), _T("enemy_throw_sword"));

load_audio(_T(R"(resources\audio\player_attack_1.mp3)"), _T("player_attack_1"));
load_audio(_T(R"(resources\audio\player_attack_2.mp3)"), _T("player_attack_2"));
load_audio(_T(R"(resources\audio\player_attack_3.mp3)"), _T("player_attack_3"));
load_audio(_T(R"(resources\audio\player_dead.mp3)"), _T("player_dead"));
load_audio(_T(R"(resources\audio\player_hurt.mp3)"), _T("player_hurt"));
load_audio(_T(R"(resources\audio\player_jump.mp3)"), _T("player_jump"));
load_audio(_T(R"(resources\audio\player_land.mp3)"), _T("player_land"));
load_audio(_T(R"(resources\audio\player_roll.mp3)"), _T("player_roll"));
load_audio(_T(R"(resources\audio\player_run.mp3)"), _T("player_run"));

2.4 find方法

随后来到对应的find方法中,我们通过unordered_map的find方法,获取了键值对的迭代器,如果迭代器等于容器的end,则代表没有找到对应id的资源。反之,则返回second中存储的资源对象指针。

Atlas* ResourcesManager::find_atlas(const std::string& id) const
{
    const auto& itor = atlas_pool.find(id);
    if (itor == atlas_pool.end()) {
        return nullptr;
    }
    return itor->second;
}

IMAGE* ResourcesManager::find_image(const std::string& id) const
{
    const auto &itor = image_pool.find(id);
    if (itor == image_pool.end()) {
        return nullptr;
    }
    return itor->second;
}

2.5 图片和图集的翻转

下面便是图片翻转的实现了。

void ResourcesManager::flip_image(IMAGE *src_image, IMAGE *dst_image, int num_h)
{
    int w = src_image->getwidth();
    int h = src_image->getheight();
    int w_frame = w / num_h;
    Resize(dst_image, w, h);
    DWORD *src_buffer = GetImageBuffer(src_image);
    DWORD *dst_buffer = GetImageBuffer(dst_image);

    for (int i = 0; i < num_h; i++)
    {
        int x_left = i * w_frame;
        int x_right = (i + 1) * w_frame;
        for (int y = 0; y < h; y++)
        {
            for (int x = x_left; x < x_right; x++)
            {
                int idx_src = y * w + x;
                int idx_dst = y * w + x_right - (x - x_left);
                dst_buffer[idx_dst] = src_buffer[idx_src];
            }
        }
    }
}

接下来,我们利用这个函数实现参数为资源id的flip_image
void ResourcesManager::flip_image(const std::string &src_id, const std::string dst_id, int num_h)
{
    IMAGE *src_image = image_pool[src_id];
    IMAGE *dst_image = new IMAGE();

    flip_image(src_image, dst_image, num_h);

    image_pool[dst_id] = dst_image;
}

随后翻转图集资源的方法实现起来也比较容易
void ResourcesManager::flip_atlas(const std::string& src_id, const std::string dst_id)
{
    Atlas *src_atlas = atlas_pool[src_id];
    Atlas *dst_atlas = new Atlas();

    for (int i = 0; i < src_atlas->get_size(); i++) {
        IMAGE img_flipped;
        flip_image(src_atlas->get_image(i), &img_flipped);
        dst_atlas->add_image(img_flipped);
    }
    atlas_pool[dst_id] = dst_atlas;
}

上面我们在实现资源管理器时,把声明放在了头文件中,而把实现放在了.cpp文件中,这其实就是声明和实现分离最简单的形式了。
不过这里的必要性并不是十分典型。在后续代码编写过程中,我们会有更多需要声明实现分离的地方。

3 程序入口

现在,我们便可以开始着手程序的入口函数编写了。
首先创建main.cpp源文件,在其中定义main函数。

3.1 控制帧率

由于帧循环的速度很快,如果我们想要维稳帧率并避免性能浪费,就需要记录帧更新间的时间并进行动态延时。在先前的教程中我们使用GetTickCount函数,配合Sleep实现了简单的延时效果。但受限于API对于时间精度的控制,在不同设备和环境上,有不同程度的误差。所以这里我们借助chrono和thread的更高精度时间控制

与先前思路一致,我们记录帧与帧之间耗费的时间,如果帧更新所消耗的时间小于我们期望的帧率标准时间。则使用sleep_for休眠剩余的时间。

int main(int argc, char** argv)
{
    using namespace std::chrono;

    HWND hwnd = initgraph(1280, 720, EW_SHOWCONSOLE);
    SetWindowText(hwnd, _T("Hollow Katana"));

    const nanoseconds frame_duration(1000000000 / 144);
    steady_clock::time_point last_tick = steady_clock::now();

    ExMessage msg;
    bool is_quit = false;

    BeginBatchDraw();

    while(!is_quit) 
    {
        while(peekmessage(&msg))
        {
            // 处理消息
        }

        steady_clock::time_point frame_start = steady_clock::now();
        duration<float> delta = duration<float>(frame_start - last_tick);

        // 处理更新

        setbkcolor(RGB(0, 0, 0));
        cleardevice();

        // 处理绘图
        FlushBatchDraw();

        last_tick = frame_start;
        nanoseconds sleep_duration = frame_duration - (steady_clock::now() - frame_start);
        if (sleep_duration > nanoseconds(0))
            std::this_thread::sleep_for(sleep_duration);
    }

    EndBatchDraw();

    return 0;
}

3.2 加载资源

随后我们便可以来到开始处,窗口创建之后且计时开始之前,添加资源管理器执行加载逻辑的方法调用。

try
{
    ResourcesManager::instance()->load();
}
catch(const LPCTSTR id)
{
    TCHAR err_msg[512];
    _stprintf_s(err_msg, _T("无法加载:%s"), id);
    MessageBox(hwnd, err_msg, _T("资源加载失败"), MB_OK | MB_ICONERROR);
    return -1;
}

我们使用了try-catch捕获了可能出现的异常。并将捕获到加载失败的资源id,通过MessageBox弹窗的形式显示出来。

最后运行程序,能够看到一个黑色窗口说明程序正常。需要注意的是因为最终产生的exe文件在build文件夹中,而程序在加载资源时候会在它运行的目录寻找resources目录。所以可以把resources文件夹存放在build目录下,方便运行程序。

3.3 绘制背景

最后我们可以尝试在窗口中绘制背景图片,在main.cpp的开头编写draw_background()函数。
先获取id为background的图片资源,并构造了显示在窗口上的矩形区域,
由于背景素材略大于我们的窗口大小。所以在计算左上角的点坐标时,进行了居中处理。

static void draw_background()
{
    static IMAGE *img_background = ResourcesManager::instance()->find_image("background");
    static Rect rect_dst = 
    {
        (getwidth()-img_background->getwidth()) / 2,
        (getheight()-img_background->getheight()) / 2,
        img_background->getwidth(),
        img_background->getheight()
    };
    putimage_ex(img_background, &rect_dst);
}

之后来到帧循环的绘图部分,添加draw_background()函数的调用

draw_background();

运行程序,便可以看到窗口中成功绘制了我们的背景图片
Pasted image 20260309141421.png

评论

如果你已登录 GitHub,就可以直接在这里评论。