C++游戏开发之旅 9

问题概述

现在我们的引擎结构已经是比较完整的了,但是我们现在创建的游戏对象(房子)是通过在代码中“硬编码”创建的。

void GameScene::createTestObject(){
    auto obj = std::make_unique<GameObject>("house");
    obj->addComponent<TransformComponent>(glm::vec2(100,100));
    obj->addComponent<SpriteComponent>("house.png");
    // 创建100个对象,手动写?这太费力了
}

这种方式对于开发一个完整游戏来说是不可行的。

游戏开发流程会有关卡设计师,使用专业工具设计关卡,导出为数据文件,然后程序读取并解释数据,自动构建游戏世界。

这章将学习开源地图编辑器 Tiled,并开始编写我们的关卡加载器(LevelLoader)

第一步,我们将学习如何解析Tiled导出的JSON文件,并加载其中最简单的一种图层 --- 图片层(ImageLayer),以实现视差滚动背景。

1.Tiled与JSON格式简介

Tiled是一款开源、灵活的关卡编辑器,可以像搭积木一样组合瓦片,放置对象和实体,创建多层背景,导出为多种格式(JSON、XML、TMX等)

我们选择的是易于解析的JSON格式

Tiled JSON 地图文件结构

e.g
{
    "compressionlevel": -1,
    "height": 30,
    "width": 100,
    "tileheight": 16,
    "tilewidth": 16,
    "layers": [
        {
            "id": 1,
            "image": "../textures/Layers/back.png",
            "name": "Background",
            "offsetx": -168,
            "offsety": -168,
            "parallaxx": 0.5,
            "parallaxy": 0.5,
            "repeatx": true,
            "repeaty": false,
            "type": "imagelayer",
            "visible": true,
            "opacity": 1,
            "x": 0,
            "y": 0
        },
        {
            "id": 2,
            "name": "Tiles",
            "type": "tilelayer",
            "data": [0, 0, 1, 2, 3, ...],
            "visible": true
        },
        {
            "id": 3,
            "name": "Objects",
            "type": "objectgroup",
            "objects": [...]
        }
    ]
}
关键字段说明
字段 说明 使用场景
type 图层类型 "imagelayer"/"tilelayer"/"objectgroup"
image 图片路径 Image Layer 专用
parallaxx/parallaxy 视差因子 控制滚动速度(0=静止,1=跟随相机)
repeatx/repeaty 是否重复 背景是否平铺
offsetx/offsety 偏移量 图层初始位置
visible 是否可见 控制图层显示

Tiled地图解析

我们可以看到,type表示地图类型,而地图是在layers数组中,根据每个图层对象的type字段来决定如何处理。

开始解析 ---> 获取 layers 数组并遍历 ---> 检查图层类型(type)--->

|--- type:"imagelayer"

​ |--- 读取图片路径,视差因子、重复标志

​ |--- 创建GameObject

​ |--- 添加ParallaxComponent

|--- type:"tilelayer"

|--- type:"objectgroup"

2.ParallaxComponent:视差背景实现

imagelayer通常用于实现多层滚动的背景,即"视差"效果。

之前我们似乎写过了一个SpriteComponent组件,也是渲染一张图片,那为什么还需要这个新组件?

因为SpriteComponentrender是跟随相机渲染移动的,我们可以对比一下

特性 SpriteComponent ParallaxComponent
定位方式 绝对世界坐标 相对相机位置
滚动速度 固定不变 根据视差因子变化
重复平铺 不支持 支持水平/垂直重复
使用场景 游戏对象、角色 背景层、装饰层

效果示意:

远处的云很慢,只有相机移动的0.3倍数中等距离的树木移动较慢,相机移动1个单位,树木可能只移动0.6个单位(0.6倍数),近处的玩家,跟随相机1比1倍数移动,一些UI是固定的不动,那么就是0倍数了。

为此,我们创建一个专门的ParallaxComponent

parallax_component.h

#pragma once
#include "component.h"
#include "../render/sprite.h"
#include <optional>
#include <glm/vec2.hpp>
#include <string>

namespace engine::component {
class TransformComponent;

class ParallaxComponent final : public Component {
    friend class engine::object::GameObject;
private:
    TransformComponent* transform_ = nullptr;  // 缓存TransformComponent变化组件
    engine::render::Sprite sprite_;   // 前向声明一般是指针或者引用变量使用,而这里是一个成员变量,需要完整的定义

    glm::vec2 scroll_factor_; // 滚动因子
    glm::bvec2 repeat_;      // 是否重复
    bool is_hidden_ = false; // 是否隐藏

public:
    /**
     * @brief 构造函数
     * @param texture_id 纹理id
     * @param scroll_factor 滚动因子  (0,0)静止 (1,1)跟随相机滚动, (0.5,0.5)相机一半速度滚动
     * @param repeat 是否重复
     * @note 因为是视差背景,所以区别于SpriteComponent,就没有对齐方式、source_rect和is_filpped什么的参数了,默认整张图片
     */
    ParallaxComponent(
    const std::string& texture_id,
    const glm::vec2& scroll_factor,
    const glm::bvec2& repeat
    );

    //禁用拷贝和移动语义
    ParallaxComponent(const ParallaxComponent&) = delete;
    ParallaxComponent& operator=(const ParallaxComponent&) = delete;
    ParallaxComponent(ParallaxComponent&&) = delete;
    ParallaxComponent& operator=(ParallaxComponent&&) = delete;

    // getter
    const engine::render::Sprite& getSprite() const { return sprite_; }
    const glm::vec2& getScrollFactor() const { return scroll_factor_; }
    const glm::bvec2& getRepeat() const { return repeat_; }
    bool isHidden() const { return is_hidden_; }

    // setter
    void setSprite(const engine::render::Sprite& sprite) { sprite_ = sprite; }
    void setScrollFactor(const glm::vec2& scroll_factor) { scroll_factor_ = scroll_factor; }
    void setRepeat(const glm::bvec2& repeat) { repeat_ = repeat; }
    void setHidden(bool is_hidden) { is_hidden_ = is_hidden; }

    
private:

    void init() override;  // 初始化可以获取transform组件信息
    void update(float, engine::core::Context& context) override {} // 纯虚函数,必须实现
    void render(engine::core::Context& ) override; // 渲染,调用render中的drawParallax
 
};
    
}

核心成员变量

成员变量 类型 说明
scroll_factor_ glm::vec2 滚动因子,表示随相机移动的速度
repeat_ glm::bvec2 是否在x/y轴方向平铺
is_hidden bool 是否隐藏

此外,ParallaxComponent还缓存了一个transform_组件,很显然我们的render()函数是需要频繁使用到其中的成员变量的(绘制的position、scale)。

parallax_component.cpp

#include "parallax_component.h"
#include "../object/game_object.h"
#include "transform_component.h"
#include <spdlog/spdlog.h>
#include "../core/context.h"
#include "../resource/resource_manager.h"
#include "../render/renderer.h"

namespace engine::component {
    ParallaxComponent::ParallaxComponent(const std::string &texture_id, const glm::vec2 &scroll_factor, const glm::bvec2 &repeat)
    : sprite_(texture_id), scroll_factor_(scroll_factor), repeat_(repeat) // 默认是整张图片,所以source_rect和is_filpped就默认了
    {
        spdlog::trace("视差组件构造成功");
    }

    void ParallaxComponent::init()
    {
        if(!owner_){
            spdlog::error("视差组件初始化失败,没有拥有者");
            return;
        }
        transform_ = owner_->getComponent<component::TransformComponent>();
        if(!transform_){
            spdlog::error("视差组件初始化失败,必须要有一个Transform组件");
            return;
        }
    }

    void ParallaxComponent::render(engine::core::Context &context)
    {
        if(is_hidden_ || !transform_) return;
        context.getRenderer().drawParallax(
            context.getCamera(),sprite_, 
            transform_->getPosition(),scroll_factor_,repeat_,
            transform_->getScale()
        ); 
    }
}

方法说明

  • init() ---> 获取并缓存TransformComponent指针,避免每帧查找
  • render() ---> 通过context调用Renderer::drawParallax()实现视差渲染

我们的render方法是和RendererdrawParallax参数是匹配好的。

3.LevelLoader:解析Tiled JSON

这是这章的核心,LevelLoader类将负责读取.tmj文件,并根据其内容在传入的Scene中创建相应的游戏对象。

设计目标

功能 说明
读取JSON 加载并解析Tiled导出的地图文件
分发处理 根据不同的图层类型调用不同的加载函数
路径解析 处理相对路径,转换为绝对路径
创建对象 自动在Scene中创建GameObject

engine/scene/level_loader.h

#pragma once
#include <string>
#include <nlohmann/json_fwd.hpp>

namespace engine::scene {
class Scene;

class LevelLoader final {
private:
    std::string map_path_;
public:
    LevelLoader() = default;

    /**
     * @brief 加载关卡数据到指定的 Scene 对象对
     * @param map_path 关卡文件路径
     * @param scene 需要加载关卡数据的 Scene 对象
     * @return 加载成功返回 true,否则返回 false
     */
    bool loadLevel(const std::string& map_path, Scene& scene);

private:
    void loadImageLayer(const nlohmann::json& json, Scene& scene);    
    void loadTileLayer(const nlohmann::json& json, Scene& scene);
    void loadObjectLayer(const nlohmann::json& json, Scene& scene);

    /**
     * 解析路径,将地图路径和相对路径合并
     * 地图路径:"assets/maps/level1.tmj"
     * 相对路径:"../textures/Layers/back.png"
     * 结果路径:"assets/maps/../textures/Layers/back.png
     *  maps/../ 抵消了
     */
    std::string resolvePath(const std::string& image_path);

};
}

engine/scene/level_loader.cpp

#include "level_loader.h"
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <fstream>
#include <filesystem>
#include "scene.h"
#include "../core/context.h"
#include "../resource/resource_manager.h"
#include "../object/game_object.h"
#include "../component/transform_component.h"
#include "../component/parallax_component.h"


namespace engine::scene {
    bool LevelLoader::loadLevel(const std::string &map_path, Scene &scene)
    {
        map_path_ = map_path;
        // 加载地图文件
        std::ifstream file(map_path_);
        if (!file.is_open()) {
            spdlog::error("打开地图文件失败: {}", map_path_);
            return false;
        }
        nlohmann::json json_data;
        try{
            file >> json_data;
        } catch (const nlohmann::json::parse_error &e) {
            spdlog::error("解析地图文件失败: {}", e.what());
            return false;
        }
        // 加载图层
        if(!json_data.contains("layers") || !json_data["layers"].is_array() ){
            spdlog::error("地图文件缺少图层信息或者没有数组layers, {}", map_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(layer_type == "imagelayer"){
                loadImageLayer(layer_json, scene);
            } else if (layer_type == "tilelayer") {
                loadTileLayer(layer_json, scene);
            } else if (layer_type == "objectgroup") {
                loadObjectLayer(layer_json, scene);
            } else {
                spdlog::warn("未知的图层类型: {}", layer_type);
            }
        }
        spdlog::info("加载地图文件成功: {}", map_path_);
        return true;
    }
    
    void LevelLoader::loadImageLayer(const nlohmann::json &layer_json, Scene &scene)
    {   // 获取相对路径,这里会自动处理\/
        const auto& image_path = layer_json.value("image", "");
        if (image_path.empty()) {
            spdlog::warn("图层{}缺少图片路径", layer_json.value("name", "none"));
            return;
        }
        /* 我们知道,resourceManager中的getTexture是通过绝对路径来获取图片的,我们需要将相对路径
        转换为绝对路径以此去适配它 */
        const std::string& texuture_id = resolvePath(image_path);
        if (texuture_id.empty()) {
            spdlog::warn("图片路径{}解析失败", image_path);
            return;
        }
        // 获取图片层的名字
        auto name = layer_json.value("name", "none");
        // 获取图片层的偏移量
        glm::vec2 offset = glm::vec2(layer_json.value("offsetx", 0), layer_json.value("offsety", 0));
        // 获取视差因子
        glm::vec2 scroll_factor = glm::vec2(layer_json.value("parallaxx", 1.0f), layer_json.value("parallaxy", 1.0f));
        // 是否平铺
        glm::bvec2 repeat = glm::bvec2(layer_json.value("repeatx", false), layer_json.value("repeaty", false));

        // 创建一个游戏对象 注意顺序,最先添加的组件是Transform
        auto obj = std::make_unique<object::GameObject>(name);
        obj->addComponent<component::TransformComponent>(offset);
        obj->addComponent<component::ParallaxComponent>(texuture_id,scroll_factor,repeat);
        scene.addGameObject(std::move(obj));
        spdlog::info("加载图片层成功: {}", name);
    }
    
    void LevelLoader::loadTileLayer(const nlohmann::json &json, Scene &scene)
    {
    }
    
    void LevelLoader::loadObjectLayer(const nlohmann::json &json, Scene &scene)
    {
    }

    std::string LevelLoader::resolvePath(const std::string &image_path)
    {
        try {
            // 这里可以获得地图map_path_的父路径assets/maps/level1.tmj ---> assets/maps/
            auto map_dir = std::filesystem::path(map_path_).parent_path();
            // 合并路径,获得绝对路径
            auto abs_path = map_dir / image_path;
            // 规范化处理
            abs_path = std::filesystem::canonical(abs_path);
            return abs_path.string();
        } catch (const std::filesystem::filesystem_error &e) {
            spdlog::error("解析路径失败: {}", e.what());
            return "";
        } 
    }
}

关键方法解析

1.loadLevel()

加载关卡地图文件,打开并读取JSON文件 ---> 解析JSON数据 ---> 遍历layers数组 ---> 根据type分发到不同的处理函数

其中可能会遇到不同的错误,这里需要进行判断处理

2.loadImageLayer()

处理图片层的内容;一般的图片层的layer包含的参数有:

{
    "id":4,
    "image":"..\/textures\/Layers\/middle.png",
    "imageheight":368,
    "imagewidth":176,
    "name":"mid",
    "offsetx":0,
    "offsety":96,
    "opacity":1,
    "parallaxx":0.6,
    "repeatx":true,
    "type":"imagelayer",
    "visible":true,
    "x":0,
    "y":0
} 

相关说明

参数 概念 说明
image:"../textures/Layers/middle.png"(\表示转义,不用做特殊处理) 路径 需要从image字段提取这张图片的相对位置
imageheight/imagewidth 图片的高宽 一般图片层充当视差背景,不用特别处理
name 图片层名字 可获取充当gameobject的name
offsetx/offsety 偏移 图片的位置偏移
parallaxx/parallaxy 视差参数 未写出表示1.0f
repeatx/repeaty 是否平铺 图片在xy方向是否有平铺
type 类型 表示不同类型的layer
visible 可见? 层是否显示
3.resolvePath()

这是一个较为重要的辅助函数。

我们知道Tiled中保存的图片路径是 相对于.tmj文件

地图文件:assets/maps/level1.tmj

图片路径:../textures/Layers.back.png

我们需要将其转换为绝对路径:assets/textures/Layers/back.png

因为我们的textureManager中的getTexture函数参数texture_id是绝对路径

实现方法

C++17 <filesystem>

1.获取地图文件的父目录:map_dir = std::filesystem::path(map_path_).parent_path()

2.合并路径map_dir / image_path

3.规范化路径:std::filesystem::canonical()解析...

采用到这个库使得我们可以轻松的解决路径问题

4.集成到GameScene

现在,我们可以通过在GameScene类中初始化的时候,调用levelLoader来构建场景,而不是手动创建对象。

// src/game/scene/game_scene.h
class GameScene final:public engine::scene::Scene{
private:
	void testCamera();    
}

// game_scene.cpp
#include "game_scene.h"
#include <spdlog/spdlog.h>
#include "../../engine/core/context.h"
#include "../../engine/render/camera.h"
#include "../../engine/input/input_manager.h"
#include "../../engine/object/game_object.h"
#include "../../engine/component/transform_component.h"
#include "../../engine/component/sprite_component.h"
#include "../../engine/scene/level_loader.h"

namespace game::scene{

    GameScene::GameScene(std::string name, engine::core::Context &context, engine::scene::SceneManager &sceneManager)
    : engine::scene::Scene(std::move(name), context, sceneManager){
        spdlog::trace("GameScene 构造完成");
    }

    void GameScene::init()
    {
        // 加载场景
        engine::scene::LevelLoader loader;
        loader.loadLevel("assets/maps/level1.tmj", *this);

        // 添加测试对象
        createTestObject();

        // 完成后,调用父类的init
        Scene::init();
    }

    void GameScene::update(float deltaTime)
    {
        Scene::update(deltaTime);
    }

    void GameScene::render()
    {
        Scene::render();
    }

    void GameScene::handleInput()
    {
        Scene::handleInput();
        testCamera();
    }

    void GameScene::clean()
    {
        Scene::clean();
    }

    void GameScene::createTestObject()
    {
        std::unique_ptr<engine::object::GameObject> gameobj = std::make_unique<engine::object::GameObject>("test1");
        // 添加组件进行测试
        gameobj->addComponent<engine::component::TransformComponent>(glm::vec2(500.0f,100.0f));
        gameobj->addComponent<engine::component::SpriteComponent>(
            "assets/textures/Props/straw-house.png",
            context_.getResourceManager(),
            engine::utils::Alignment::CENTER
        );
        auto size = gameobj->getComponent<engine::component::SpriteComponent>()->getSpriteSize();
        spdlog::info("Sprite size: {} {}",size.x,size.y);
        // 修改TransformComponent大小
        gameobj->getComponent<engine::component::TransformComponent>()->setScale(glm::vec2(0.5f,0.5f));
        // 修改TransformComponent角度
        gameobj->getComponent<engine::component::TransformComponent>()->setRotation(45.0f);
        spdlog::info("Sprite size: {} {}",size.x,size.y);    
        addGameObject(std::move(gameobj));
    }

    void GameScene::testCamera()
    {
        if(context_.getInputManager().isActionDown("move_up")) context_.getCamera().move(glm::vec2(0.0f,-2.0f));
        if(context_.getInputManager().isActionDown("move_down")) context_.getCamera().move(glm::vec2(0.0f,2.0f));
        if(context_.getInputManager().isActionDown("move_left")) context_.getCamera().move(glm::vec2(-2.0f,0.0f));
        if(context_.getInputManager().isActionDown("move_right")) context_.getCamera().move(glm::vec2(2.0f,0.0f));
    }

} // namespace game::scene

这里加了一个相机处理的测试函数,还有一件事,测试的时候发现handleInput函数在GameApp中的handleEvent中忘记写了,难怪按了键没有发生移动。

我们将相机控制逻辑放到场景中,而非引擎核心,这使得不同场景可以有不同的相机控制方式,代码组织上更轻松了,清楚了。

编译运行

效果很明显有视差的感觉。

image-20260201162241364

总结

对比视差组件和精灵组件的异同

这章学习了视差组件的创建,注意和精灵组件的区别。相同点:都有sprite、transform*、is_hidden等基础成员变量,以及在init()中获取transform缓存避免render函数每帧查找

TransformComponent ParallaxComponent
精灵组件需要对齐方式(Alignment),以及对变换后对对齐方式的更新updateOffset() 有滚动因子、是否平铺重复等视差参数
构造函数需要有texture_id,alignment、源矩形以及是否flipped 源矩形默认为整张图片,需要关键性的视差参数
render调用drawSprite 调用drawParallax

构建LevelLoader,自动解析JSON并构建场景,路径解析是重点内容

posted @ 2026-02-08 17:32  wenyiGamecpp  阅读(80)  评论(0)    收藏  举报