C++游戏开发之旅 25

问题概述

目前只有一个场景,本章实现一个新的场景载入。

解决方案

  • 在Tiled中设置关卡触发器(传送门)
  • 扩展LevelLoader支持自定义形状
  • GameScene支持加载不同地图
  • 实现场景切换逻辑

1.在Tiled中创建关卡出口

Tiled的对象层不仅可以放置基于图块的对象,还可以绘制自定义形状,可以使用该工具进行绘制。

我们需要为其设置两个关键信息:

  • 名称(name) : level2, 下一关卡文件名
  • 自定义属性(tag): next_level ; 触发器类型标识

设置完后,可以在level1.tmj文件中增加如下描述:

{
    "height":88.3419170243205,
    "id":34,
    "name":"level2",
    "properties":[
        {
            "name":"tag",
            "type":"string",
            "value":"next_level"
        }],
    "rotation":0,
    "type":"",
    "visible":true,
    "width":23.8898305084747,
    "x":1431.5,
    "y":71.2457081545064
}

我们可以注意到,这个和其他对象层中的对象最大的区别就是它是没有gid这个键的;将这个关卡名称直接存储在触发器游戏对象里,这个对象有标签(碰撞检测标签)和名字(用于切换关卡)

2.LevelLoader识别自定义形状

由于自定义的矩形没有gid,所以在对象层中需要识别该对象。

auto gid = object.value("gid", 0);
if(gid == 0){ // 0代表自己绘制的形状
    if (object.value("point", false)) {             // 如果是点对象
        continue;       // TODO: 点对象的处理方式
    } else if (object.value("ellipse", false)) {    // 如果是椭圆对象
        continue;       // TODO: 椭圆对象的处理方式
    } else if (object.value("polygon", false)) {    // 如果是多边形对象
        continue;       // TODO: 多边形对象的处理方式
    } else {
        // 矩形对象
        // 创建游戏对象并添加Transform组件
        const std::string& obj_name = object.value("name", "none");
        auto obj = std::make_unique<object::GameObject>(obj_name);

        // 获取Transform相关信息 自定义位置并不是左下角,而是左上角,所以不需要转换
        auto pos = glm::vec2(object.value("x", 0.0f), object.value("y", 0.0f));
        auto dst_size = glm::vec2(object.value("width", 0.0f), object.value("height", 0.0f));
        auto rotation = object.value("rotation", 0.0f);

        obj->addComponent<component::TransformComponent>(pos, glm::vec2(1.0f), rotation);

        // 添加碰撞盒组件
        auto collider = std::make_unique<physics::AABBCollider>(dst_size);
        auto* cc = obj->addComponent<component::ColliderComponent>(std::move(collider));// 这里必须移动,因为独占型指针不能拷贝
        cc->setTrigger(true); // 将对象设置为触发器

        // 添加物理组件,没物理无法发生碰撞,我们需要将这个对象注册到物理引擎中
        // 不受重力影响
        obj->addComponent<engine::component::PhysicsComponent>(&scene.getContext().getPhysicsEngine(),false);
        // 设置标签
        auto tag = getTileProperty<std::string>(object, "tag");
        if(tag){
            obj->setTag(*tag);
        }
        scene.addGameObject(std::move(obj));
        spdlog::info("加载对象: '{}' 完成 (类型: 自定义形状)", obj_name);
    }
}

代码解析

自定义形状加载流程:
    ↓
1. 检测 gid == 0
    确认是自定义形状
    ↓
2. 读取属性
    • name (对象名称)
    • x, y (位置)
    • width, height (尺寸)
    • tag (自定义标签)
    ↓
3. 创建 GameObject
    new GameObject(name)
    ↓
4. 添加 TransformComponent
    设置位置和尺寸
    ↓
5. 添加 ColliderComponent
    创建 AABBCollider
    setTrigger(true)  ← 关键!
    ↓
6. 添加 PhysicsComponent
    不受重力影响
    ↓
7. 设置标签
    setTag("next_level")
    ↓
8. 添加到场景
    scene.addGameObject()

触发器和实体对比

Trigger vs Solid:
┌──────────────────────┐
│ Solid (实体碰撞器): │
│ • 阻挡移动           │
│ • 产生碰撞响应       │
│ • 例如:墙壁         │
├──────────────────────┤
│ Trigger (触发器):   │
│ • 不阻挡移动 ✓       │
│ • 检测碰撞 ✓         │
│ • 触发事件 ✓         │
│ • 例如:传送门       │
└──────────────────────┘

现在,我们的引擎已经能够将Tiled中绘制的触发区正确加载为游戏世界中一个带触发功能的、不可见的碰撞盒了

3.GameScene的复用与关卡加载

现在我们需要对关卡部分进行简单的调整,使其可以加载不同的关卡地图。

首先,我们来看看地图加载是怎么样的。

bool GameApp::init()
{
    if(!initConfig()) return false;
    if(!initSDL()) return false;
    if(!initTime()) return false;
    if(!initResourceManager()) return false;
    if(!initCamera()) return false;
    if(!initRenderer()) return false;
    if(!initInputManager()) return false;
    if(!initPhysicsEngine()) return false;
    if(!initAudioPlayer()) return false;

    if(!initContext()) return false;  // 注意顺序
    if(!initSceneManager()) return false;

    auto scene = std::make_unique<game::scene::GameScene>("level1", *context_, *scene_manager_);
    scene_manager_->requestPushScene(std::move(scene));

    spdlog::info("GameApp初始化成功");
    is_running_ = true;
    return true;
}

// src/engine/core/game_app.cpp (部分)
bool GameApp::init() {
    // ...
    // 创建第一个场景并压入栈
    auto scene = std::make_unique<game::scene::GameScene>("level1", *context_, *scene_manager_);
    scene_manager_->requestPushScene(std::move(scene));
    // ...
}

// ---------------------------------------------

void GameApp::run()
{
    if(!init()){
        spdlog::error("GameApp初始化失败,无法进行游戏");
        return;
    }

    // 游戏循环
    while(is_running_){
        time_->update();
        float delta_time = time_->getDeltaTime();
        if(delta_time > 0.025f) continue;; // 如果delta_time过大,直接返回
        input_manager_->update(); // 更新输入管理器

        handleEvents();
        update(delta_time);
        render();

        // spdlog::info("delta_time: {}", delta_time);
    }
    close();
}

我们在创建场景的时候最初是在GameApp的初始化函数中创建的,然后让scene_manager压入这个场景,发出的是一个请求,然后就交给场景管理器来管了,什么时候真正压入场景呢?下一帧的update,scene_manager->update(),他会先获取栈顶的场景,更新栈顶,然后处理待处理的场景,在处理待处理的场景的时候,会判断场景是push、pop还是replace,这里会顺手对场景进行初始化。(这部分有点久远了,有点忘记了,理了一遍思路清晰多了)

好,现在我们看看原来的代码,主要看看原来初始化中的写法:

// 加载关卡(level_loader通常加载完成后即可销毁,因此不存为成员变量)
engine::scene::LevelLoader level_loader;
if (!level_loader.loadLevel("assets/maps/level1.tmj", *this)){
    spdlog::error("关卡加载失败");
    return false;
}

这部分,这样是一种硬编码的方式,只是加入一关,所以我们需要修改一下写法,我们的关卡是这样的,它是在"assets/maps/"这个文件夹下面,名字是level1,level2,所以我们在GameScene中写了一个工具函数,

// 根据关卡名称获取对应的地图文件路径
std::string levelNameToPath(const std::string& levelName) const {
    return "assets/maps/" + levelName + ".tmj";
}

// 进入下一个关卡
void toNextLevel(engine::object::GameObject *trigger);

非常简单,只是通过名字来得到一个地图路径,这样我们就可以在Tiled地图中将那个触发器游戏对象的名字设置成level2这样就可以直接得到目标地图了。

之后稍微修改一下GameScene的init()中的initLevel()

void GameScene::init()
{
    if(is_initialized_) return;

    if (!initLevel()) {
        spdlog::error("关卡初始化失败,无法继续。");
        context_.getInputManager().setShouldQuit(true);
        return;
    }
    if (!initPlayer()) {
        spdlog::error("玩家初始化失败,无法继续。");
        context_.getInputManager().setShouldQuit(true);
        return;
    }
    if (!initEnemyAndItem()) {
        spdlog::error("敌人初始化失败,无法继续。");
        context_.getInputManager().setShouldQuit(true);
        return;
    }

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

bool GameScene::initLevel()
{
    // 加载场景
    engine::scene::LevelLoader loader;
    // 使用辅助函数根据场景名获取路径
    auto level_path = levelNameToPath(scene_name_);
    if(!loader.loadLevel(level_path, *this)) {
        spdlog::error("关卡{}加载失败,无法继续", level_path);
        return false;
    }
    // 注册main层到物理引擎 
    glm::vec2 world_size = {0.0f,0.0f};
    auto* tile_obj = findGameObjectByName("main");
    if(tile_obj){
        auto* tilelayer = tile_obj->getComponent<engine::component::TileLayerComponent>();
        context_.getPhysicsEngine().registerCollisionTileLayer(tilelayer);
        // 设置世界边界
        context_.getPhysicsEngine().setWorldBounds(engine::utils::Rect(glm::vec2(0.0f,0.0f), tilelayer->getWorldSize()));
        world_size = tilelayer->getWorldSize();
    }
    // 设置相机边界
    const engine::utils::Rect &cameraBounds = {glm::vec2(0.0f,0.0f), world_size};
    context_.getCamera().setLimitBounds(cameraBounds);


    // 设置音量
    context_.getAudioPlayer().setMusicVolume(0.3f);
    context_.getAudioPlayer().setSoundVolume(0.5f);
    // 播放背景音乐
    context_.getAudioPlayer().playMusic("assets/audio/hurry_up_and_run.ogg", -1, 1000);


    spdlog::info("关卡初始化完成");
    return true;
}

通过这个简单的改造,GameScene 类变得更加通用和强大。我们只需要用不同的名字(如 "level1", "level2")来实例化它,它就能自动加载对应的地图文件。

4.碰撞检测与场景切换

当玩家与我们设置的next_level触发器碰撞时,就执行切换逻辑。

并不困难,在之前的handleObjectCollisions()函数中写入玩家与触发器的碰撞逻辑就可以了

// 玩家和关卡底触发器碰撞
if(obj1->getTag() == "player" && obj2->getTag() == "next_level"){
    toNextLevel(obj2);
} else if(obj1->getTag() == "next_level" && obj2->getTag() == "player"){
    toNextLevel(obj1);
}


切换的时候就发起replace场景的请求,替换场景。

void GameScene::toNextLevel(engine::object::GameObject *trigger)
{
    auto scene_name = trigger->getName(); // 获取场景名字 level2
    auto scene = std::make_unique<game::scene::GameScene>(scene_name, context_, sceneManager_); // 创建一个新的 GameScene 实例
    sceneManager_.requestReplaceScene(std::move(scene)); // 替换当前场景
}

这里用 Replace 而不是 Push

  • Replace --- 替换,关卡切换,原场景是返回不了的,已经清理了
  • Push --- 压入场景,可以返回,一般用于商店、暂停菜单

编译运行

可以切换场景,但是感觉切换时有点顿感

章节总结

这节主要实现了关卡切换的系统架构,在Tiled的对象层中自定义一个矩形,这个矩形的位置是左上角,而一般对象是左下,注意区别,然后我们需要让引擎可以识别我们定义的矩形区域,通过gid == 0 来进行区分,我们要创建这个游戏对象,充当为触发器,为其添加相应的组件,Transform、Collider、Physics,设置为触发器,这样物理引擎可以汇报给GameScene,让上层来处理碰撞切换场景的逻辑

设计模式应用

策略模式 + 工厂模式:
┌──────────────────────┐
│ GameScene 工厂:     │
│ • 同一个类           │
│ • 不同的场景名       │
│ • 加载不同地图       │
├──────────────────────┤
│ new GameScene(       │
│   "level1", ...)     │
│ new GameScene(       │
│   "level2", ...)     │
│ new GameScene(       │
│   "boss", ...)       │
└──────────────────────┘

遇到的问题

1.在处理玩家与关卡底触发器碰撞的时候要切换场景,但是当时发现,切换不了,所以去debug,然后发现,标签没有给触发器游戏对象附上,就转到level_loader中对象层载入去找问题,最后发现是getTileProperty这个函数没有获取到标签,返回的是std::nullopt,噫,为什么?哦,搞错了传入的 json 对象,难怪

posted @ 2026-03-06 21:29  wenyiGamecpp  阅读(6)  评论(0)    收藏  举报