ECS框架-关卡载入器

有必要写个周报,总结一下一周的进度。
这周比较繁忙,导师需要按装服务器,还需要做个映射,课题这边还需要补充大论文的内容,哎,想着加点机器学习相关的内容,但是处理一组数据就要近4个小时,真是累啊。目前还是在学习C++游戏开发,还抽空学了一些Tiled的关卡制作。就学到现在,这门语言吧,我觉得越学越不懂啊,不得了,这周花了近2天才消化之前关卡载入的内容,更新了很多内容,学了后面忘了前面,想找相关实习也是没有任何消息啊,呵呵呵呵,路途遥远啊。

文件载入功能

上一节我们将框架从GameObject + Component切换到了ECS(entt::registry + 组件 + 系统),接下来我们将使用Tiled编辑地图,实现数据驱动,我们需要一个关卡载入器(LevelLoader)

  • 读取.tmj/.tsj(JSON)
  • 只管解析JSON数据,并把“地图/背景/装饰/对象”转换成 ECS 的实体与组件

学习目标

  • 理解 Tiled 导出的 .tmj/.tsj 里最关键的字段:tilesets / layers / (tile)gid
  • 实现 LevelLoader::loadLevel():加载 tileset、分发三类图层解析
  • 引入建造者模式:用 BasicEntityBuilder 把“解析”与“建实体”解耦
  • 为瓦片层新增容器组件 TileLayerComponent,并支持瓦片动画的解析
  • 调整config配置,让其可以改变游戏的窗口和逻辑分辨率
  • 解决背景颜色的设置
  • 实现正确的渲染顺序
  • 解决gid异常的问题

1.Tiled 的 .tmj/.tsj 内容数据说明

.tmj:地图文件(layers + tilesets)

Tiled 的 .tmj 本质是一个 JSON:里面有地图尺寸、瓦片尺寸、引用的 tileset,以及一个 layers 数组。

assets/maps/level1.tmj 为例,它开头就能看到第一个 tilelayerdata(一维数组,按行从左到右):

{ "height":19,
  "layers":[
    {
      "data":[0, 0, 0, 0, ...], // 瓦片层的data
      "name":"ground1",
      "type":"tilelayer",
      "visible":true,
      "width":25
    }
  ]
}

objectgroup(对象层)会在 objects 里给出每个对象的 x/y/width/height,以及可选的 gid

{
  "name":"deco1",
  "type":"objectgroup",
  "objects":[
    {
      "gid":2147484301,	
      "width":128,
      "height":128,
      "x":778.666666666667,
      "y":878.666666666667
    }
  ]
}

注意这里的 gid:它不仅仅是“图块 ID”,高位还可能编码了翻转/旋转等 flag(例如 2147484301)。

.tsj:图块集文件(单图集/多图集)

  • 单图集:整个 tileset 由一张大图(atlas)组成,tileset JSON 里有 image/columns/tilewidth/tileheight
  • 多图集:每个 tile 都是一张独立图片(或带裁剪信息),tileset JSON 里会有 tiles[],每个 tile 里有 image/imagewidth/imageheight/...
"tilesets":[
        {
         "firstgid":1,
         "source":"tileset\/Tilemap.tsj"
        }, 
        {
         "firstgid":641,
         "source":"tileset\/Sheep.tsj"
        }, 
        {
         "firstgid":677,
         "source":"tileset\/Bush.tsj"
        }, 
        {
         "firstgid":709,
         "source":"tileset\/Tree1.tsj"
        }, 
        {
         "firstgid":725,
         "source":"tileset\/Tree2.tsj"
        }, 
        {
         "firstgid":741,
         "source":"tileset\/buildings.tsj"
        }, 
        {
         "firstgid":750,
         "source":"tileset\/Warrior.tsj"
        }, 
        {
         "firstgid":774,
         "source":"tileset\/Lancer.tsj"
        }, 
        {
         "firstgid":798,
         "source":"..\/..\/..\/..\/art\/MonsterWar\/maps\/tileset\/Archer.tsj"
        }],

tilesets,也就是通过{gid, file_path}键值对的方式,记录不同的图块集文件。

瓦片也有相关的动画,来自单图集情况,例如 assets/maps/tileset/Tilemap.tsj

{
  "columns":20,
  "image":"..\/..\/textures\/Terrain\/Tilemap.png",
  "tileheight":64,
  "tiles":[
    {
      "id":81,
      "animation":[
        { "duration":100, "tileid":560 },
        { "duration":100, "tileid":561 }
      ]
    }
  ]
}

2.架构设计 LevelLoader + Builder (“解析” 与 “建实体”拆开)

这节引入建造者模式

image-20260330095556938

LevelLoader只负责解析数据(读取.tmj/.tsj,按图层类型组织解析流程),而建立实体和添加组件的工作就由BasicEntityBuilder完成(解析得到的TileInfo/object_json转成entity+component)。

这样做的收益就很高,不像之前阳光岛中一个函数300多行代码,我们游戏侧如果要看到特定对象就加特定组件/生成特定实体,只需要继承一个Builder并通过setEntityBuilder()替换就可以。

LevelLoader

LevelLoader 内部记录了 map 的关键信息(地图路径/尺寸/瓦片尺寸)、加载到的 tileset 数据,以及一个可替换的 entity_builder_

class LevelLoader {
private:
    std::string level_path_;      // 关卡地图路径
    engine::scene::Scene* scene_;   // 场景指针(非拥有)

    glm::ivec2 map_size_{};     // 地图大小
    glm::ivec2 tile_size_{};    // 瓦片大小
    std::map<int, nlohmann::json> tileset_data_;  // 瓦片集数据

    std::unique_ptr<engine::load::BasicEntityBuilder> entity_builder_; // 实体构建器
    int current_layer_ = 0; 
    //....
};

loadLevel()详解

主要分为5个步骤:

  1. 校验 scene;若未指定 builder,则创建默认 BasicEntityBuilder
  2. 读取 .tmj 并解析 JSON
  3. 读取地图基本信息:map_size_ / tile_size_ / map_path_
  4. 遍历 tilesets[],加载每个 .tsj,并按 firstgid 存入 tileset_data_
  5. 遍历 layers[],按 type 分发到三种加载函数
bool LevelLoader::levelLoad(std::string_view level_path, engine::scene::Scene *scene)
{
    // 前置判断
    if(!scene){
        spdlog::error("场景为空");
        return false;
    }
    scene_ = scene;
    // 如果entity_builder_为空,则设置默认的entity_builder_
    if (!entity_builder_) {
        spdlog::info("设置默认的实体生成器");
        entity_builder_ = std::make_unique<BasicEntityBuilder>(*this, scene->getContext(), scene->getRegistry());
    }


    // 1. 文件载入
    std::ifstream file(level_path.data());
    if (!file.is_open()) {
        spdlog::error("无法打开文件: {}", level_path);
        return false;
    }

    // 2. 解析文件
    nlohmann::json json_data;
    try {
        file >> json_data;
    } catch (nlohmann::json::parse_error& e) {
        spdlog::error("解析文件失败: {}", e.what());
        return false;
    }

    // 3. 获取基本信息(地图路径、地图大小、tile大小、tileset)
    level_path_ = level_path;
    map_size_ = {json_data.value("width", 0), json_data.value("height", 0)};
    tile_size_ = {json_data.value("tilewidth", 0), json_data.value("tileheight", 0)};
    // 设置背景色
    std::string hex_color = json_data.value("backgroundcolor", "#000000");
    engine::utils::FColor bg_color = engine::utils::parseHexColor(hex_color); 
    scene_->getContext().getRenderer().setBgColorFloat(bg_color.r, bg_color.g, bg_color.b, bg_color.a);

    // 4. 加载tileset
    if(json_data.contains("tilesets") && json_data["tilesets"].is_array()){
        for(const auto &tileset : json_data["tilesets"]){
            auto resolve_path = resolvePath(tileset["source"], level_path);
            loadTileset(resolve_path, tileset["firstgid"].get<int>());
        }
    }

    // 5. 加载layer
    if(!json_data.contains("layers") || !json_data["layers"].is_array() ){
        spdlog::error("地图文件缺少图层信息或者没有数组layers, {}", level_path_);
        return false;
    }
    for (const auto& layer_json : json_data["layers"]) {
        std::string layer_type = layer_json.value("type", "none");
        if(!layer_json.value("visible",true)){
            spdlog::debug("图层{}不可见,跳过", layer_json.value("name", "none"));
            continue;
        }
        // 获取图层层级
        if(auto layer_id = getTileProperty<int>(layer_json, "order"); layer_id){
            current_layer_ = *layer_id;
        }

        if(layer_type == "imagelayer"){
            loadImageLayer(layer_json);
        } else if (layer_type == "tilelayer") {
            loadTileLayer(layer_json);
        } else if (layer_type == "objectgroup") {
            loadObjectLayer(layer_json);
        } else {
            spdlog::warn("未知的图层类型: {}", layer_type);
        }
        spdlog::info("当前图层: {}", current_layer_);
        current_layer_++;
    }
    spdlog::info("加载地图文件成功: {}", level_path_);
    return true;

}

loadTileset

加载tileset,获得firstgid到特定图块集的键值对map,这里还需要顺手解决一个路径问题,就是我们的.tmj文件中的tilesets下的source是相对与.tmj地图文件目录下的路径,而我们需要得到各图块集的绝对路径。(虽然)

思路也很清晰,首先,需要知道level_path.tmj地图路径,获取其父路径,然后与相对路径拼接就可以得到图块集的绝对路径

std::string LevelLoader::resolvePath(std::string_view relative_path, std::string_view file_path)
{
    try {
        // 获取文件所在的父路径
        auto file_dir = std::filesystem::path(file_path).parent_path();
        // 合并路径,获得绝对路径、
        auto abs_path = file_dir / relative_path;
        // 规范化处理
        abs_path = std::filesystem::canonical(abs_path);
        return abs_path.string();
    } catch (const std::filesystem::filesystem_error &e) {
        spdlog::error("解析路径失败: {}", e.what());
        return "";
    }
}

得到路径后就可以进行加载图块集数据了。

void LevelLoader::loadTileset(std::string_view tileset_path, int first_id)
{
    std::ifstream file(tileset_path.data());
    if (!file.is_open()) {
        spdlog::error("无法打开文件: {}", tileset_path);
        return;
    }
    // TODO: Load tileset
    nlohmann::json json_data;
    try {
        file >> json_data;
    } catch (nlohmann::json::parse_error& e) {
        spdlog::error("解析文件失败: {}", e.what());
        return;
    }
    // add path to json
    json_data["file_path"] = tileset_path; 
    tileset_data_.emplace(first_id, std::move(json_data));
    spdlog::info("加载了Tileset: {}", tileset_path);
}

这里有个小细节,把路径也加到json数据里了,这个主要是后面加载资源的时候需要用到路径,因为图块集.tsj中有相关的图片资源路径,所以它也需要用到路径转换的函数,那么也就需要其所在的路径位置。

加载三种图层转成ECS

ImageLayer 背景图
void LevelLoader::loadImageLayer(const nlohmann::json &json)
{
    std::string image_path = json.value("image", ""); // 获取图像路径
    if (image_path.empty()) {
        spdlog::error("图层 {} 缺少image属性", json.value("name", "none"));
        return;
    }
    // 获得绝对路径
    auto texture_path = resolvePath(image_path, level_path_);

    // 加载图像层
    auto& resource_manager = scene_->getContext().getResourceManager();
    glm::vec2 texture_size = resource_manager.getTextureSize(entt::hashed_string(texture_path.c_str()), texture_path);
    auto sprite = engine::component::Sprite(texture_path, {glm::vec2(0), texture_size});
    // offset
    const glm::vec2 offset = {json.value("offsetx", 0), json.value("offsety", 0)};
    // 视差因子、重复标志
    const glm::vec2 scroll_factor = {json.value("parallaxx", 1.0f), json.value("parallaxy", 1.0f)};
    const glm::bvec2 repeat = {json.value("repeatx", false), json.value("repeaty", false)};

    // 创建图层实体
    std::string layer_name = json.value("name", "none");
    entt::id_type name_id = entt::hashed_string(layer_name.c_str());
    auto& registry = scene_->getRegistry();
    auto layer_entity = registry.create();
    registry.emplace<engine::component::NameComponent>(layer_entity, name_id,layer_name);
    registry.emplace<engine::component::TransformComponent>(layer_entity, offset);
    registry.emplace<engine::component::ParallaxComponent>(layer_entity, scroll_factor, repeat);
    registry.emplace<engine::component::SpriteComponent>(layer_entity, sprite);
    registry.emplace<engine::component::RenderComponent>(layer_entity, current_layer_);
}
TileLayer 瓦片图层

瓦片层是地图的主体,data数组中的每个非0瓦片都对应一个"瓦片实体"(有Sprite/Transform,可选Animation),每个瓦片层是一个图层实体(承载容器组件TileLayerComponent与名字组件NameComponent)。

void LevelLoader::loadTileLayer(const nlohmann::json &json)
{
    if(!json.contains("data") || !json["data"].is_array()){
        spdlog::error("图层 {} 缺少data属性", json.value("name","none"));
        return;
    }
    // 获取图层名称
    std::string layer_name = json.value("name", "none");
    entt::id_type name_id = entt::hashed_string(layer_name.c_str());
    // 创建图层实体
    auto& registry = scene_->getRegistry();
    auto layer_entity = registry.create();
    registry.emplace<engine::component::NameComponent>(layer_entity, name_id,layer_name);

    // 瓦片实体的准备
    std::vector<entt::entity> tiles;
    tiles.reserve(map_size_.x * map_size_.y);
    const auto& data = json["data"];    // id 列表

    size_t index = 0;
    for (const int &gid : data) {
        if(gid == 0){
            ++index;
            continue;
        }
        auto tile_info = getTileInfoByGid(gid);
        if(!tile_info){
            spdlog::error("无法获取瓦片信息,gid: {}", gid);
            ++index;
            continue;
        }
        // 创建瓦片实体
        auto tile_id = entity_builder_->configure(index, &tile_info.value())->build()->getEntityID();
        tiles.push_back(tile_id);
        ++index;
    }
    registry.emplace<engine::component::TileLayerComponent>(layer_entity, tile_size_, map_size_, tiles);
}

TileInfo作为临时瓦片数据结构,是LevelLoader解析过程的中间产物,不会被长期保存

1. 从Tiled数据中提取gid
2. 通过getTileInfoByGid()解析gid,创建TileInfo

- 解析翻转标志位
- 查找对应的tileset
- 提取精灵、类型、动画、属性等信息

3. 将TileInfo传递给EntityBuilder
4. EntityBuilder使用TileInfo创建完整的实体
// src/engine/component/tilelayer_component.h(节选)
struct TileInfo {
    engine::component::Sprite sprite_;
    engine::component::TileType type_;
    std::optional<engine::component::Animation> animation_;
    std::optional<nlohmann::json> properties_;
};
ObjectLayer 对象图层(形状自定义对象/图片对象)

对象层的每个 object 一般都有 gid,如果没有就说明是自己画的自定义形状。

void LevelLoader::loadObjectLayer(const nlohmann::json &json)
{
    if(!json.contains("objects") || !json["objects"].is_array()){
        spdlog::error("图层 {} 缺少objects属性", json.value("name","none"));
        return;
    }
    auto objects = json["objects"];
    for(const auto &object : objects){
        auto gid = object.value("gid", 0);
        if(gid == 0) { // 如果gid为0,则表示这是一个自定义对象
            entity_builder_->configure(&object)->build();
        } else { // 是一个图片对象
            auto tile_info = getTileInfoByGid(gid);
            entity_builder_->configure(&object, &tile_info.value())->build();
        }
    }

}

getTileInfoByGid() 详解

std::optional<component::TileInfo> LevelLoader::getTileInfoByGid(int gid)
{
    if(gid == 0) return std::nullopt;

    // 判断并存储是否水平翻转 (最高的第32位为1)
    bool is_filpped_horizontal = gid & 0x80000000;
    // 判断并存储是否垂直翻转 (最高的第31位为1)
    // bool is_filpped_vertical = gid & 0x40000000;
    // 判断并存储是否旋转 (最高的第30位为1)
    // bool is_rotated = gid & 0x20000000;
    // 恢复原始的gid
    gid &= 0x1FFFFFFF;

    // 需要找比gid小的最大的firstgid
    auto it = tileset_data_.upper_bound(gid);
    if(it == tileset_data_.begin()) return std::nullopt;
    --it;   // 退一个得到目标firstgid

    const auto &tileset_data = it->second;
    int local_id = gid - it->first;
    auto file_path = tileset_data["file_path"].get<std::string>(); // 获取文件路径
    if(file_path.empty()) {
        spdlog::error("firstgid 为 {} 的Tileset文件路径为空", it->first);
        return std::nullopt;
    }

    component::TileInfo tile_info;
    bool is_single_image = false;   // 用作区分单图片和多图片的情况
    // 单图片的情况
    if(tileset_data.contains("image")) {    
        auto image_path = tileset_data["image"].get<std::string>(); // 获取图片路径
        auto texture_path = resolvePath(image_path, file_path);     // 解析图片路径
        auto src_rect = getTextureRect(tileset_data, local_id);     // 获取源矩形
        tile_info.sprite_ = engine::component::Sprite(texture_path, src_rect, is_filpped_horizontal); // 创建精灵
        tile_info.type_ = getTileTypeById(tileset_data, local_id);  // 获取瓦片类型
        is_single_image = true;
    } 

    // 多图片的情况     
    if(!is_single_image && !tileset_data.contains("tiles")) {
        spdlog::error("firstgid 为 {} 的Tileset缺少tiles属性", it->first);
        return std::nullopt;
    }
    const auto &tiles = tileset_data["tiles"];
    for(const auto &tile : tiles){
        if(tile.contains("id") && tile["id"] == local_id) {
            if(!is_single_image) { // 这是多图片情况
                auto image_path = tile["image"].get<std::string>(); // 获取图片路径
                auto texture_path = resolvePath(image_path, file_path);     // 解析图片路径
                auto image_width = tile.value("imagewidth",0);
                auto image_height = tile.value("imageheight",0);
                engine::utils::Rect src_rect = {
                    glm::vec2(tile.value("x",0), tile.value("y",0)),
                    glm::vec2(tile.value("width",image_width), tile.value("height",image_height))
                };
                tile_info.type_ = getTileType(tile);    // 获取瓦片类型
                // 创建精灵     注意不管是单个图片还是多个图片都并没有将源图片载入到资源管理器中
                tile_info.sprite_ = engine::component::Sprite(texture_path, src_rect, is_filpped_horizontal); 
            }
            // 动画信息补充
            if(tile.contains("animation") && is_single_image && tile["animation"].is_array()) {
                std::vector<engine::component::AnimationFrame> anim;
                for(const auto& frame : tile["animation"]) {
                    float duration_ms = frame.value("duration", 100.0f);
                    int tileid = frame.value("tileid",0);
                    auto source_rect = getTextureRect(tileset_data, tileid);
                    anim.push_back({source_rect, duration_ms});
                }
                tile_info.animation_ = engine::component::Animation(std::move(anim));
            }
            // 自定义属性补充
            if(tile.contains("properties") && tile["properties"].is_array()) {
                tile_info.properties_ = tile["properties"];
            }
        }

    }
    return tile_info;
}

这个就是解析的关键了,从gid判断出瓦片的信息tileInfo

这里的gid可能会出现异常,因为Tiled中水平翻转是会导致32位的高位进行置1,所以我们会使用按位与的方法恢复gid。

getTileInfoByGid 流程图

1. 输入验证阶段                                        
if(gid == 0) return std::nullopt;  // 空白格子直接返回
2. gid解码阶段
// 解析Tiled的特殊编码格式
bool is_filpped_horizontal = gid & 0x80000000;       
// 第32位:水平翻转
// bool is_filpped_vertical = gid & 0x40000000;     
// 第31位:垂直翻转
// bool is_rotated = gid & 0x20000000;             
// 第30位:旋转
gid &= 0x1FFFFFFF;  // 清除标志位,得到原始gid
3. Tileset查找阶段
// 在tileset_data_中查找对应的tileset
auto it = tileset_data_.upper_bound(gid);
if(it == tileset_data_.begin()) return std::nullopt;   
--it;  // 找到比gid小的最大的firstgid

// 计算在tileset中的局部ID
int local_id = gid - it->first;
4. 数据提取阶段,

单图片和多图片的区别就是单图片的图片路径是在外的,而多图片是在内的,所以多图片处理的时候就在tiles数组中设置TileInfo了。

component::TileInfo tile_info;
bool is_single_image = false;

// 单图片tileset处理
if(tileset_data.contains("image")) {
  // 提取图片路径、创建精灵、获取类型
  is_single_image = true;
}

// 多图片tileset处理
if(!is_single_image) {
  // 在tiles数组中查找对应ID的瓦片
  for(const auto &tile : tiles){
      if(tile["id"] == local_id) {
          // 处理多图片情况
      }
  }
}
5. 附加信息处理阶段
// 动画信息
if(tile.contains("animation")) {
  // 解析动画帧
  tile_info.animation_ =
      engine::component::Animation(...);
}

// 自定义属性
if(tile.contains("properties")) {
  tile_info.properties_ = tile["properties"];        
}

处理逻辑

1. gid → firstgid映射:通过二分查找找到对应的tileset   
2. firstgid → local_id转换:local_id = gid - firstgid  
3. 单图/多图分支处理:根据tileset类型选择不同处理路径  
4. 信息聚合:将分散的数据聚合成完整的TileInfo

返回值

  • 成功:std::optional包含完整瓦片信息
  • 失败:std::nullopt

Tiled 导出数据(.tmj/.tsj)
        |
        |  layer.data[] / object.gid
        v
     gid(全局 ID)
        |
        |  (本节暂未处理翻转/旋转 flag;下一节补齐)
        v
tileset_data_(firstgid -> tileset json)
        |
        |  upper_bound(gid) 找到最近的 firstgid
        v
tileset_json + local_id = gid - firstgid
        |
        |  单图集: image/columns/tilewidth -> src_rect
        |  多图集: tiles[] 里按 id 找 image
        v
TileInfo{ Sprite, type, animation?, properties? }
        |
        |  BasicEntityBuilder.configure(...)
        v
buildBase -> buildSprite -> buildTransform -> buildAnimation
        |
        v
ECS entity + components(交给系统渲染/更新)
单图集:计算源矩形 + 支持动画

单图集 tileset 有 image 字段,我们可以用 columns/tilewidth/tileheight 直接算源矩形:

engine::utils::Rect LevelLoader::getTextureRect(const nlohmann::json &tileset, int local_id)
{
    glm::vec2 tile_size = {tileset["tilewidth"], tileset["tileheight"]};
    int columns = tileset.value("columns",0);
    int coord_x = local_id % columns;
    int coord_y = local_id / columns;
    // 得到源矩形
    engine::utils::Rect src_rect = {
        glm::vec2(coord_x * tile_size.x, coord_y * tile_size.y), 
        tile_size
    };
    return src_rect;
}
// src/engine/loader/level_loader.cpp
auto texture_rect = getTextureRect(tileset, local_id);
auto texture_path = resolvePath(tileset["image"].get<std::string>(), file_path);
tile_info.sprite_ = engine::component::Sprite(texture_path, texture_rect);

如果某个 tile 有 animation[],本节会把它解析成 AnimationFrame 列表,并写入 tile_info.animation_

// src/engine/loader/level_loader.cpp
// 动画信息补充
if(tile.contains("animation") && is_single_image && tile["animation"].is_array()) {
    std::vector<engine::component::AnimationFrame> anim;
    for(const auto& frame : tile["animation"]) {
        float duration_ms = frame.value("duration", 100.0f);
        int tileid = frame.value("tileid",0);
        auto source_rect = getTextureRect(tileset_data, tileid);
        anim.push_back({source_rect, duration_ms});
    }
    tile_info.animation_ = engine::component::Animation(std::move(anim));
}
多图集:从 tiles[] 里按 id 找到 image

多图集 tileset 没有 image,而是在 tiles[] 里给每个 tile 配 image。本节的做法是遍历 tiles[] 查找 id == local_id,然后用 image/imagewidth/imageheight 创建精灵。

BasicEntityBuilder:配置 → 构建 → 返回

Builder 的接口非常直白:configure(...) -> build() -> getEntityID(),并且提供三个 configure 重载,分别对应:

  • 对象层的形状对象:configure(object_json)
  • 对象层的图片对象:configure(object_json, tile_info)
  • 瓦片层的 tile:configure(index, tile_info)

BasicEntityBuilder

// src/engine/loader/basic_entity_builder.h
#pragma once
#include <entt/entity/registry.hpp>
#include <nlohmann/json_fwd.hpp>
#include <glm/vec2.hpp>
#include <optional>

namespace engine::core {
class Context;
}

namespace engine::component {
    struct TileInfo;
}

namespace engine::load {
class LevelLoader;
}


namespace engine::load {

/**
 * @brief 游戏实体生成器,levelLoader使用,通过建造者模式生成各种游戏实体
 */
class BasicEntityBuilder {
protected:
    LevelLoader& loader_;
    engine::core::Context& context_;
    entt::registry& registry_;

    int index_ = -1; /// 瓦片的索引
    const engine::component::TileInfo* tile_info_ = nullptr; /// 瓦片信息
    const nlohmann::json* object_json_ = nullptr;            /// 对象的json数据

    entt::entity entity_id_;        
    glm::vec2 position_;
    glm::vec2 scale_ = glm::vec2(1.0f);
    float rotation_ = 0.0f;

public:
    BasicEntityBuilder(LevelLoader& loader, engine::core::Context& context, entt::registry& registry);
    virtual ~BasicEntityBuilder();

    // 针对自定义的对象的配置
    BasicEntityBuilder* configure(const nlohmann::json* object_json);

    // 针对图片对象的配置
    BasicEntityBuilder* configure(const nlohmann::json* object_json, const engine::component::TileInfo* tile_info);

    // 针对瓦片的配置
    BasicEntityBuilder* configure(int index, const engine::component::TileInfo* tile_info);

    virtual BasicEntityBuilder* build();    // 构建实体
    entt::entity getEntityID() const {return entity_id_;}

protected:
    void reset();       ///< @brief 重置生成器状态,每次configure的时候先调用

    void buildBase();
    void buildSprite();
    void bulidTransform();
    void buildRender();
    void buildAnimation();
    void buildAudio();

    // 代理函数补充
    template<typename T>
    std::optional<T> getTileProperty(const nlohmann::json& tile_json, std::string_view property_name);

};

}

// src/engine/loader/basic_entity_builder.cpp
#include "basic_entity_builder.h"
#include "level_loader.h"
#include <spdlog/spdlog.h>
#include "../core/context.h"
#include "../resource/resource_manager.h"
#include "../component/name_component.h"
#include "../component/sprite_component.h"
#include "../component/audio_component.h"
#include "../component/transform_component.h"
#include "../component/animation_component.h"
#include "../component/tilelayer_component.h"
#include "../component/render_component.h"

namespace engine::load {
    BasicEntityBuilder::BasicEntityBuilder(LevelLoader &loader, engine::core::Context &context, entt::registry &registry)
    : loader_(loader), context_(context), registry_(registry)
    {}


    BasicEntityBuilder::~BasicEntityBuilder() = default;

    BasicEntityBuilder *BasicEntityBuilder::configure(const nlohmann::json *object_json)
    {
        reset();
        object_json_ = object_json;
        return this;
    }

    BasicEntityBuilder *BasicEntityBuilder::configure(const nlohmann::json *object_json, const engine::component::TileInfo *tile_info)
    {
        reset();
        object_json_ = object_json;
        tile_info_ = tile_info;

        return this;
    }

    BasicEntityBuilder *BasicEntityBuilder::configure(int index, const engine::component::TileInfo *tile_info)
    {
        reset();
        index_ = index;
        tile_info_ = tile_info;

        return this;
    }

    BasicEntityBuilder *BasicEntityBuilder::build()
    {

        buildBase();
        buildSprite();
        bulidTransform();
        buildRender();
        buildAnimation();
        buildAudio();
        return this;
    }

    void BasicEntityBuilder::reset()
    {
        index_ = -1;
        tile_info_ = nullptr;
        object_json_ = nullptr;

        entity_id_ = entt::null;
        position_ = {0, 0};
        scale_ = {1, 1};
        rotation_ = 0.0f;
    }

    void BasicEntityBuilder::buildBase()
    {
        // 添加 NameComponent
        entity_id_ = registry_.create();
        if(object_json_){
            std::string name = object_json_->value("name", "none");
            entt::id_type name_id = entt::hashed_string(name.c_str());
            registry_.emplace<engine::component::NameComponent>(entity_id_, name_id, name);
        }
    }
    void BasicEntityBuilder::buildSprite()
    {
        // 添加 SpriteComponent
        // 如果是自定义的对象的话,直接返回
        if(!tile_info_) {
            return;
        }
        context_.getResourceManager().loadTexture(tile_info_->sprite_.texture_id_, tile_info_->sprite_.texture_path_);
        registry_.emplace<engine::component::SpriteComponent>(entity_id_, tile_info_->sprite_);
    }

    void BasicEntityBuilder::bulidTransform()
    {
        // 添加 TransformComponent

        // 如果是对象层的话
        if(object_json_){
            rotation_ = object_json_->value("rotation", 0.0f);
            glm::vec2 dst_size = glm::vec2(object_json_->value("width", 0.0f), object_json_->value("height", 0.0f));
            // 需要调整位置,因为对象层的位置是左下角,引擎中需要设置为左上角
            position_ = glm::vec2(object_json_->value("x", 0.0f), object_json_->value("y", 0.0f)) - 
            glm::vec2(0, dst_size.y);
            if(tile_info_) {
                glm::vec2 src_size = tile_info_->sprite_.src_rect_.size;
                scale_ = dst_size / src_size;
            }
        }
        // 如果是瓦片层的话
        if(index_ >= 0){
            int tile_x = index_ % loader_.getMapSize().x;
            int tile_y = index_ / loader_.getMapSize().x;
            position_ = {tile_x * loader_.getTileSize().x, tile_y * loader_.getTileSize().y};
        }
        registry_.emplace<engine::component::TransformComponent>(entity_id_, position_, scale_, rotation_);
    }
    
    void BasicEntityBuilder::buildRender()
    {
        // 添加 RenderComponent
        registry_.emplace<engine::component::RenderComponent>(
        entity_id_, loader_.getCurrentLayer(), position_.y);
    }
    
    void BasicEntityBuilder::buildAnimation()
    {
        // 添加 AnimationComponent
        if(tile_info_ && tile_info_->animation_){
            std::unordered_map<entt::id_type, component::Animation> anim;
            anim.emplace(entt::hashed_string("tile"), std::move(tile_info_->animation_.value()));
            registry_.emplace<engine::component::AnimationComponent>(
                entity_id_, std::move(anim), entt::hashed_string("tile"));
        }
    }
    
    void BasicEntityBuilder::buildAudio()
    {
    }
    
    // --- 代理函数,让子类能获取到LevelLoader的私有方法 ---
    template<typename T>
    std::optional<T> BasicEntityBuilder::getTileProperty(const nlohmann::json& tile_json, std::string_view property_name) {
        return loader_.getTileProperty<T>(tile_json, property_name);
    }

}

构建时按顺序添加组件:

// src/engine/loader/basic_entity_builder.cpp
buildBase();
buildSprite();
buildTransform();
buildAnimation();
buildAudio();

有一个关于Transform设置上的小细节问题,就是Tiled中对象层的x/y是左下角,我们需要对y方向做一个修正

void BasicEntityBuilder::bulidTransform()
{
    // 添加 TransformComponent

    // 如果是对象层的话
    if(object_json_){
        rotation_ = object_json_->value("rotation", 0.0f);
        glm::vec2 dst_size = glm::vec2(object_json_->value("width", 0.0f), object_json_->value("height", 0.0f));
        // 需要调整位置,因为对象层的位置是左下角,引擎中需要设置为左上角
        position_ = glm::vec2(object_json_->value("x", 0.0f), object_json_->value("y", 0.0f)) - 
            glm::vec2(0, dst_size.y);
        if(tile_info_) {
            glm::vec2 src_size = tile_info_->sprite_.src_rect_.size;
            scale_ = dst_size / src_size;
        }
    }
    // 如果是瓦片层的话
    if(index_ >= 0){
        int tile_x = index_ % loader_.getMapSize().x;
        int tile_y = index_ / loader_.getMapSize().x;
        position_ = {tile_x * loader_.getTileSize().x, tile_y * loader_.getTileSize().y};
    }
    registry_.emplace<engine::component::TransformComponent>(entity_id_, position_, scale_, rotation_);
}

瓦片层还是正常情况。

buildAnimation() 则把 tile_info_ 里解析好的动画塞进 AnimationComponent(动画名默认用 "tile"

改进Config文件

我们为Config配置类新增了窗口大小和逻辑分辨大小

class Config final {
public:
    // 为了方便拓展,设置为public
    // 窗口设置
    std::string window_title_ = "MonsterWar";
    int window_width_ = 1280;
    int window_height_ = 720;
    float window_scale_ = 1.0f;
    float window_logical_scale_ = 1.0f;
    bool window_resizable_ = false;
    // ...
}

之后我们需要在GameApp中调整GameState的初始化位置,然后在进行初始化SDL的时候读取配置文件中的内容,设置窗口大小和逻辑分辨率,然后在初始化相机的时候从GameState中读取窗口大小和逻辑分辨率。

// SDLInit()
// 考虑窗口缩放比例
int window_width = static_cast<int>(static_cast<float>(config_->window_width_) * config_->window_scale_);
int window_height = static_cast<int>(static_cast<float>(config_->window_height_) * config_->window_scale_);
window_ = SDL_CreateWindow(config_->window_title_.c_str(), window_width, window_height, SDL_WINDOW_RESIZABLE); 

// 设置渲染器逻辑分辨率
int logical_width = static_cast<int>(static_cast<float>(config_->window_width_) * config_->window_logical_scale_);
int logical_height = static_cast<int>(static_cast<float>(config_->window_height_) * config_->window_logical_scale_);
SDL_SetRenderLogicalPresentation(sdl_renderer_, logical_width, logical_height, SDL_LOGICAL_PRESENTATION_LETTERBOX); // 设置渲染器逻辑分辨率

// cameraInit()
 bool GameApp::initCamera()
 {
     try {
         camera_ = std::make_unique<engine::render::Camera>(game_state_->getLogicSize());
     } catch (const std::exception& e){
         spdlog::error("初始化Camera失败: {}", e.what());
         return false;
     }
     return true;
 }

为游戏加入背景颜色

在Tiled中设置背景颜色,那么我们的地图文件.tmj中就有"backgroundcolor":"#53a7a4"这个键值对,所以我们需要一个函数解析这个7位或者9位的std::string。在utils::math.h中加入一个工具函数,解析相关的字符,得到最终的颜色,然后在render模块中加入背景颜色与设置函数。

constexpr FColor parseHexColor(std::string_view hex_color){
    auto hexToInt = [](char c) -> int {
        if (c >= '0' && c <= '9') return c - '0';
        if (c >= 'a' && c <= 'f') return c - 'a' + 10;
        if (c >= 'A' && c <= 'F') return c - 'A' + 10;
        return 0;
    };
    if (hex_color.empty() || hex_color[0] != '#') return {0, 0, 0, 1};
    size_t len = hex_color.size();
    if (len != 7 && len != 9) return {0, 0, 0, 1};

    // 解析颜色
    int r = hexToInt(hex_color[1]) * 16 + hexToInt(hex_color[2]);
    int g = hexToInt(hex_color[3]) * 16 + hexToInt(hex_color[4]);
    int b = hexToInt(hex_color[5]) * 16 + hexToInt(hex_color[6]);

    // 解析透明度
    float a = 1.0f;
    if (len == 9) {
        a = hexToInt(hex_color[7]) * 16 + hexToInt(hex_color[8]);
        a /= 255.0f; // 将透明度转换为0.0到1.0之间的浮点数
    }

    return {r / 255.0f, g / 255.0f, b / 255.0f, a};
}
class Renderer final {
    private:
    SDL_Renderer *sdl_renderer_ = nullptr; // SDL3的渲染器
    engine::resource::ResourceManager *resource_manager_ = nullptr; // 资源管理器

    // 背景颜色 (默认为黑色)
    engine::utils::FColor background_color_ = {0.0f, 0.0f, 0.0f, 1.0f};
    //...
    void setBgColorFloat(float r, float g, float b, float a = 1.0f) { background_color_ = {r, g, b, a}; }
}

// 
void Renderer::clearScreen()
{
    setDrawColorFloat(background_color_.r, background_color_.g, background_color_.b, background_color_.a);
    if(!SDL_RenderClear(sdl_renderer_)){
        spdlog::error("清除渲染器失败,info:{}", SDL_GetError());
    };
}

这个clearScreen()会在GameApp中的render()函数中调用,这样就实现了背景色。

渲染顺序的实现 RenderComponent + 排序 + y-order

目前为止,我们确实可以将游戏实体渲染了,但是它的顺序是乱的,而我们希望渲染分为两层:

  1. 图层顺序:背景层先画、装饰层后画(Tiled 图层的顺序/自定义顺序)。
  2. 同一图层内顺序:典型 top-down 游戏里,y 越大的物体越靠近镜头,应该后画(覆盖前面的)。

所以我们需要新增RenderComponent{layer, depth}:让“顺序”变成数据

#pragma once

namespace engine::component {

struct RenderComponent {
    int layer {}; // 图层id 数字越小先绘制
    float deepth {}; // 深度id 数字越小越靠前

    RenderComponent(int layer = 0, float deepth = 0.0f) : layer(layer), deepth(deepth) {}

    // 重载小于运算符,用于排序
    bool operator<(const RenderComponent& other) const {
        if(layer == other.layer) {
            return deepth < other.deepth;
        } else{
            return layer < other.layer;
        }
    }

};

}

先按 layer,再按 depth。数字越小越先绘制;因此只要把 depth 设置为 y 坐标,就能实现“靠下的后绘制”。我们的registry会将其RenderComponent进行升序排序,这样绘制的时候可以按照期望的顺序。

所以之前我们会在LevelLoader中加一个current_layer_,并在载入一个layer后递增。当然也可以通过自定义设置属性order,强制设置渲染层级,但是会冲突当前的图层序号,用的时候要知道。

Builder自动给实体补充上RenderComponent:layer来自loader,depth来自position.y

void BasicEntityBuilder::buildRender()
{
    // 添加 RenderComponent
    registry_.emplace<engine::component::RenderComponent>(
        entity_id_, loader_.getCurrentLayer(), position_.y);
}

RenderSystem:渲染前先 sort排序,再按照排序后的view遍历

void RenderSystem::update(entt::registry &registry, render::Renderer &renderer, const render::Camera &camera)
{
    registry.sort<component::RenderComponent>([](const auto &lhs, const auto &rhs) {
        return lhs < rhs;
    });

    auto view = registry.view<component::RenderComponent, component::TransformComponent, component::SpriteComponent>();
    view.use<component::RenderComponent>();
    for (auto entity : view) {
        const auto& transform = view.get<component::TransformComponent>(entity);
        const auto& sprite = view.get<component::SpriteComponent>(entity);
        auto position = transform.position_ + sprite.offset_; // 位置 = 位置 + 偏移量
        auto size = sprite.size_ * transform.scale_;  // 大小 = 精灵大小 * 缩放

        renderer.drawSprite(camera, sprite.sprite_, position, size, transform.rotation_);
    }
}

修改我们的渲染系统,先进行排序,后渲染。这里有个细节,就是排序之后,我们创建了一个多组件视图,之后需要让视图使用RenderComponent的排序顺序view.use<component::RenderComponent>();这一行至关重要,否则依旧可能出现顺序不对的问题。告诉视图 “以 RenderComponent 的存储顺序作为遍历基准”,而非默认的实体 ID 顺序。

YsortSystem:每帧根据Transform的position_.y更新depth(同层y-order)

如果一个实体会移动,那么 depth 也要跟着更新。于是新增 YSortSystem

void YsortSystem::update(entt::registry &registry)
{
    auto view = registry.view<component::RenderComponent>();
    for (auto entity : view) {
        auto &render = view.get<component::RenderComponent>(entity);
        auto &transform = registry.get<component::TransformComponent>(entity);
        render.deepth = transform.position_.y; // 深度为y坐标
    }
}

GameScene::update() 里把它放在 MovementSystem 之后调用:

movement_system_->update(registry_, delta_time);
animation_system_->update(registry_, delta_time);
ysort_system_->update(registry_);   // 调用顺序要在MovementSystem之后

GameScene 加载游戏地图

最后在 GameScene::init() 中调用 loadLevel(),把 assets/maps/level1.tmj 解析成 ECS 实体:

// src/game/scene/game_scene.cpp(节选)
engine::loader::LevelLoader level_loader;
if (!level_loader.loadLevel("assets/maps/level1.tmj", this)) {
    spdlog::error("加载关卡失败");
    return false;
}

运行后:

image-20260330115432794

总结

这章节内容确实很多,而且关卡载入部分内容较杂,需要回顾一些知识,然后新加入建造者模式也需要消化,算得上从头开始写了,但还是学到不少内容,也加深了不少记忆。

遇到的问题

1.发现buildings.tsj文件中的除了Rock3.png,其他的在游戏中都不显示。

这个问题太隐蔽了,如果都看不到还是可以很快定位到问题的,但是因为我自己手欠,在 Tiled 中自己调整了图块的位置,导致Rock3这个图块区别其他的有width、height;所以它可以显示。我真**😂服了啊,

  Rock3的定义:  
  - id: 0
  - width: 59 (注意这里!)          - height: 64
  - imagewidth: 64                
  - imageheight: 64
  - x: 5, y: 0

  Tower的定义:
  - id: 4
  - 没有width和height属性
  - imagewidth: 128
  - imageheight: 256

const auto &tiles = tileset_data["tiles"];
        for(const auto &tile : tiles){
            if(tile.contains("id") && tile["id"] == local_id) {
                if(!is_single_image) { // 这是多图片情况
                    auto image_path = tile["image"].get<std::string>(); // 获取图片路径
                    auto texture_path = resolvePath(image_path, file_path);     // 解析图片路径
                    if(texture_path == "D:\\tools\\gameMaker\\MonsterWar315\\monsterwar\\assets\\textures\\Buildings\\Tower.png")
                    {
                        spdlog::info("tower");
                    }
                    auto image_width = tileset_data.value("imagewidth",0);
                    auto image_height = tileset_data.value("imageheight",0);
                    engine::utils::Rect src_rect = {
                        glm::vec2(tile.value("x",0), tile.value("y",0)),
                        glm::vec2(tile.value("width",image_width), tile.value("height",image_height))
                    };
                    tile_info.type_ = getTileType(tile);    // 获取瓦片类型
                    // 创建精灵     注意不管是单个图片还是多个图片都并没有将源图片载入到资源管理器中
                    tile_info.sprite_ = engine::component::Sprite(texture_path, src_rect, is_filpped_horizontal); 
                }

这里有个致命问题,这两行,很明显不对,这个 ai 自补功能,还是得仔细检查一遍,应该是tile而不是整个buildings.tsj的json。这个该死的问题还浪费了半个下午

auto image_width = tileset_data.value("imagewidth",0);
auto image_height = tileset_data.value("imageheight",0);
posted @ 2026-03-30 13:47  wenyiGamecpp  阅读(11)  评论(0)    收藏  举报