C++游戏开发之旅 20

问题概述

上一节,我们实现了生命组件以及受伤死亡的状态添加,但是目前的玩家只能被动挨打,无法和其他游戏对象进行交互。

解决方案:完整的交互系统

  • 玩家攻击敌人 ---> 踩踏判断:玩家中心点高于敌人中心点,最小平移向量判断:overlap.x > overlap.y
  • 创建特效 ---> 创建一个指定精灵、动画的游戏对象,设定one_shot_removeal,动画播放完删除
  • 陷阱尖刺对象 ---> 对象层:在对象碰撞列表中查找标签tag == "hazard";瓦片层:物理引擎每帧存储游戏对象特殊瓦片碰撞对列表

核心思想

  1. 在 GameScene 中建立碰撞处理中心
  2. 实现经典的踩踏攻击机制
  3. 添加道具收集系统
  4. 动态创建特效对象
  5. 实现危险地形触发
交互系统架构:
┌──────────────┐
│ PhysicsEngine│ ← 检测碰撞
│ 报告碰撞事件  │
└──────┬───────┘
       │
       ↓
┌──────────────────┐
│ GameScene        │ ← 处理中心
│ handleCollisions │
├──────────────────┤
│ 玩家 vs 敌人     │ → 踩踏/受伤
│ 玩家 vs 道具     │ → 收集
│ 玩家 vs 地形     │ → 触发陷阱
└──────────────────┘

章节目标

  • 实现碰撞处理中心 --- 在GameScene统一处理所有碰撞逻辑
  • 实现踩踏攻击 ---- 经典攻击手段
  • 实现道具搜集 ---- 拾取水果回血、宝石加分
  • 动态创建特效 ---- 死亡、拾取等视觉反馈
  • 实现危险地形 ---- 尖刺等陷阱伤害机制

玩家与动态对象的交互(敌人与道具)

物理引擎负责检测和解决物理层面的碰撞,但是玩家踩到敌人,玩家撞到敌人是有区别的,这些是高层逻辑,我们需要对其进行区分。这些逻辑该在哪里进行处理呢?

设计思想

最佳位置就是GameScene

  • Scene 是所有游戏对象的管理者
  • Scene 需要了解对象之间的关系
  • Scene 可以访问所有组件

由PhysicsEngine 进行物理碰撞检测,防止穿透,记录碰撞对;GameScene去获取物理引擎中的碰撞对,进行逻辑决策(踩踏、拾取)

1.碰撞处理中心:handleObjectCollisions

我们在GameScene中创建一个handleObjectCollisions函数,在update循环中每帧调用。

class GameScene final : public engine::scene::Scene {
private:
    //...
void handleObjectCollisions(); // 处理物体之间的碰撞
// 处理玩家与敌人的碰撞
void handlePlayerVsEnemyCollision(engine::object::GameObject *player, engine::object::GameObject *enemy); 
// 处理玩家与物品的碰撞
void handlePlayerVsItemCollision(engine::object::GameObject *player, engine::object::GameObject *item); 

/**
     * 创建一个特效
     * @param center_pos 特效中心位置
     * @param tag 特效标签
     */
void createEffect(const glm::vec2& center_pos, const std::string& tag);
};

// game_scene.cpp
void GameScene::update(float deltaTime)
{
    Scene::update(deltaTime);
    handleObjectCollisions();

}

void GameScene::handleObjectCollisions()
{
    const auto& collisions = context_.getPhysicsEngine().getCollisionPairs();
    for(auto& pair : collisions){
        auto *obj1 = pair.first;
        auto* obj2 = pair.second;

        // 处理玩家与敌人的碰撞
        if(obj1->getTag() == "player" && obj2->getTag() == "enemy"){
            handlePlayerVsEnemyCollision(obj1, obj2);
        } else if(obj1->getTag() == "enemy" && obj2->getTag() == "player"){
            handlePlayerVsEnemyCollision(obj2, obj1);
        }

        // 处理玩家与道具的碰撞
        if(obj1->getTag() == "player" && obj2->getTag() == "item"){
            handlePlayerVsItemCollision(obj1, obj2);
        } else if(obj1->getTag() == "item" && obj2->getTag() == "player"){
            handlePlayerVsItemCollision(obj2, obj1);
        }
    }
}

碰撞分发机制

我们的handleObjectCollisions()获取物理引擎中的碰撞记录对,遍历所有碰撞对,根据name或是tag判断类型,分发处理玩家和敌人碰撞、玩家和物品道具间的碰撞。

2.踩踏和受伤

紧接着我们完成handlePlayerVsEnemyCollision的具体逻辑

void GameScene::handlePlayerVsEnemyCollision(engine::object::GameObject *player, engine::object::GameObject *enemy)
{
    // 获取玩家和敌人的碰撞箱
    auto player_cc_rect = player->getComponent<engine::component::ColliderComponent>()->getWorldAABB();
    auto enemy_cc_rect = enemy->getComponent<engine::component::ColliderComponent>()->getWorldAABB();
    glm::vec2 player_center = player_cc_rect.position + player_cc_rect.size / 2.0f;
    glm::vec2 enemy_center = enemy_cc_rect.position + enemy_cc_rect.size / 2.0f;

    // 得到重叠量 半宽之和 - 中心点绝对距离
    glm::vec2 overlap = (player_cc_rect.size / 2.0f + enemy_cc_rect.size / 2.0f) - glm::abs(player_center - enemy_center);
    // 只有一种情况触发,踩踏检测,玩家在敌人上方
    if(overlap.x >= overlap.y && player_center.y < enemy_center.y){ // 垂直碰撞,玩家在上方
        // 敌人扣血
        auto enemy_hc = enemy->getComponent<engine::component::HealthComponent>();
        enemy_hc->takeDamage(1);
        if(!enemy_hc->isAlive()){ // 敌人死亡
            // 创建一个死亡特效
            createEffect(enemy_cc_rect.position, "enemy");
            // 移除敌人
            enemy->setNeedRemove(true);
        }
        // 玩家给予一个向上的速度
        auto player_pc = player->getComponent<engine::component::PhysicsComponent>();
        player_pc->velocity_.y = -300.f;
    } else {
        // 玩家扣血
        auto player_component = player->getComponent<game::component::PlayerComponent>();
        player_component->takeDamage(1);
    }

}
踩踏判定算法

使用重叠量比较来判断碰撞的主要方向,如果玩家在上方,发生垂直碰撞,则水平重叠大,垂直重叠小;如果发生水平碰撞,那么水平重叠就小,垂直重叠就大。

通过判断重叠量以及碰撞盒中心判断是否发生踩踏。

3.道具收集与特效

玩家与道具发生碰撞,道具消失,玩家获得增益。

src/game/scene/game_scene.cpp

void GameScene::handlePlayerVsItemCollision(engine::object::GameObject *player, engine::object::GameObject *item)
{
    // 玩家吃到水果加血
    if(item->getName() == "fruit"){
        auto player_hc = player->getComponent<engine::component::HealthComponent>();
        player_hc->heal(1);
    }
    else if (item->getName() == "gem"){
        // 玩家吃到宝石加分数,后面实现

    }
    auto item_cc = item->getComponent<engine::component::ColliderComponent>();
    createEffect(item_cc->getWorldAABB().position + item_cc->getWorldAABB().size / 2.0f,"item");
    // 移除道具
    item->setNeedRemove(true);
}


void GameScene::createEffect(const glm::vec2 &center_pos, const std::string &tag)
{
    // 特效是一个游戏对象,要加动画,就要有Sprite
    // 需要添加transform组件、sprite组件、动画组件
    auto effect = std::make_unique<engine::object::GameObject>("effect");
    effect->addComponent<engine::component::TransformComponent>(center_pos);

    auto anim = std::make_unique<engine::render::Animation>("effect" + tag, false);
    if(tag == "enemy"){
        effect->addComponent<engine::component::SpriteComponent>(
            "assets/textures/FX/enemy-deadth.png",
            context_.getResourceManager(),
            engine::utils::Alignment::CENTER
        );
        for(int i = 0; i < 6; ++i){
            SDL_FRect rect = {static_cast<float>(i * 40), 0.0f, 40.0f, 41.0f};
            anim->addFrame(rect, 0.1f);
        }
    } else if(tag == "item"){
        effect->addComponent<engine::component::SpriteComponent>(
            "assets/textures/FX/item-feedback.png",
            context_.getResourceManager(),
            engine::utils::Alignment::CENTER
        );
        for(int i = 0; i < 5; ++i){
            SDL_FRect rect = {static_cast<float>(i * 32), 0.0f, 32.f, 32.f};
            anim->addFrame(rect, 0.1f);
        }
    }
    auto ac = effect->addComponent<engine::component::AnimationComponent>();
    ac->addAnimation(std::move(anim));
    ac->playAnimation("effect" + tag);
    // 特效只播放一次,播放完就移除
    ac->setOneShotRemoval(true);
    // 添加到场景中
    safeAddGameObject(std::move(effect));
}


动态创建对象

我们采用在 代码中动态创建 的方式创建特效,特效需要添加transform组件、sprite组件、动画组件,并在动画播放完毕后将特效游戏对象标记为need_remove,这样Scene可以在下一帧安全地删除对象。

第二部分:玩家与静态环境的交互(危险地形)

我们的关卡中还有尖刺陷阱,这些特殊的瓦片也会和玩家产生交互。

设计思想

危险地形可以进入,但是会触发伤害,这种机制称为触发器(Trigger)

4.定义危险瓦片

首先,我们需要在Tiled编辑器中为尖刺瓦片添加一个自定义属性"hazard":true

"properties":[
                {
                 "name":"hazard",
                 "type":"bool",
                 "value":true
                }]

之后我们对TileType进行拓展,让levelLoader可以识别这种类型的瓦片。

enum class TileType {
    //...
    SLOPE_2_0,  ///< @brief 斜坡瓦片,高度:左1/2右0
    HAZARD,     ///< @brief 危险瓦片(例如火焰、尖刺等)
};

// level_loader.cpp getTileType
// 在 getTileType 函数中
else if (property.contains("name") && property["name"] == "hazard") {
    auto is_hazard = property.value("value", false);
    return is_hazard ? engine::component::TileType::HAZARD : engine::component::TileType::NORMAL;
}

瓦片类型总览

类型 说明 碰撞 触发
NORMAL 普通瓦片
SOLID 实体瓦片
UNISOLID 单向平台 ✅ (仅向下)
SLOPE 斜坡
HAZARD 危险瓦片

现在,levelLoader在加载地图时,可以识别出危险瓦片了。

5.物理引擎升级:瓦片触发器

陷阱瓦片区别于普通墙壁,玩家可以进入其区域,但是进入后会触发事件(受伤),我们需要对特殊瓦片做一些处理。

之前,我们在物理引擎中处理游戏对象间的碰撞的时候,是通过记录collision_pair碰撞对来进行的,那么类似的,我们使用游戏对象瓦片类型来记录游戏对象与特殊瓦片间触发的事件。

class PhysicsEngine final {
//...
private:
    /// @brief 存储本帧发生的 GameObject 碰撞对
    std::vector<std::pair<engine::object::GameObject*, engine::object::GameObject*>> collision_pairs_;
    /// @brief 存储本帧发生的瓦片触发事件 (GameObject*, 触发的瓦片类型)
    std::vector<std::pair<engine::object::GameObject*, engine::component::TileType>> tile_trigger_events_;

public:
    //...
    const std::vector<std::pair<engine::object::GameObject*, engine::component::TileType>>& getTileTriggerEvents() const {
        return tile_trigger_events_;
    };

private:
    //...
    void checkTileTriggers();
};


在我们的物理引擎cpp的update中,需要想之前的碰撞对collision_pairs_一样,先清空,然后最后进行瓦片触发事件的逻辑。

void PhysicsEngine::update(float delta_time) {
    // ...
    // 每帧开始时清空
    collision_pairs_.clear();
    tile_trigger_events_.clear();

    // ... (物理位移计算) ...

    // 检测瓦片触发事件 (检测前已经处理完位移)
    checkTileTriggers();
}

void PhysicsEngine::checkTileTrigger()
{
    for(auto* pc : components_){
        if(!pc || !pc->isEnabled()) continue;
        auto obj = pc->getOwner();
        if(!obj) continue;
        auto* cc = obj->getComponent<engine::component::ColliderComponent>();
        // 如果游戏对象本就是触发器,则不需要检查瓦片触发事件,触发器:仅检测碰撞,触发事件之类的东西
        if(!cc || !cc->isActive() || cc->isTrigger()) continue;

        auto world_rect = cc->getWorldAABB();
        auto obj_pos = world_rect.position;
        auto obj_size = world_rect.size;

        /* 使用set来跟踪循环遍历中已经触发过的瓦片类型,防止重复添加
            (例如,玩家同时踩到两个尖刺,只需要受到一次伤害)*/
        std::set<engine::component::TileType> triggers_set;
        // 遍历注册的瓦片层
        for(auto* layer : collision_tileLayer_){
            if(!layer) continue;
            auto tile_size = layer->getTileSize();
            float tolerance = 1.0f;

            auto start_x = static_cast<int>(glm::floor(obj_pos.x / tile_size.x));
            auto start_y = static_cast<int>(glm::floor(obj_pos.y / tile_size.y));
            auto end_x = static_cast<int>(glm::floor((obj_pos.x + obj_size.x - tolerance) / tile_size.x));
            auto end_y = static_cast<int>(glm::floor((obj_pos.y + obj_size.y - tolerance) / tile_size.y));

            // 遍历从开始到结束的瓦片,实际上就是通过计算出碰撞体所在的瓦片的始末,然后判断瓦片类型
            for(int x = start_x; x <= end_x; ++x){
                for(int y = start_y; y <= end_y; ++y){
                    auto tile_type = layer->getTileTypeAt(glm::ivec2(x, y));
                    if(tile_type == engine::component::TileType::HAZARD){
                        triggers_set.insert(tile_type);
                    }
                }
            }
        }
        // 单个物理组件(游戏对象)判断完后,就可以获取游戏对象与瓦片类型的触发器事件了
        for(auto tile_type : triggers_set){
            tile_trigger_events_.emplace_back(std::make_pair(obj, tile_type));
        }
    }
}

触发检测流程

checkTileTriggers() 流程:
    ↓
1. 遍历所有物理组件
    ↓
2. 获取对象的碰撞盒(AABB)
    ↓
3. 计算覆盖的瓦片范围
    start_x, start_y, end_x, end_y
    ↓
4. 遍历范围内的所有瓦片
    ↓
5. 检查瓦片类型
    if (type == HAZARD)
    ↓
6. 添加到 triggers_set
    使用 set 去重
    ↓
7. 记录到 tile_trigger_events_
    {GameObject*, TileType}

这里使用了set集合,里边的元素不能重复,确保玩家触发陷阱后,仅触发一次伤害,玩家所处范围内检测瓦片类型同样的最多一次。

这里检测时机也比较重要,我们是处理完瓦片碰撞,对象碰撞后再进行处理触发陷阱事件的,这可以在位移完成之后再进行检测。

6.GameScene 中处理瓦片触发

最后,我们需要在GameScene处理瓦片触发事件,类似于对象间的碰撞对一样。

void GameScene::update(float delta_time) {
    Scene::update(delta_time);
    handleObjectCollisions();
    handleTileTriggers(); // 新增调用
}

void GameScene::handleTileTriggers()
{
    const auto& trigger_events = context_.getPhysicsEngine().getTileTriggerEvents();
    for(auto& pair : trigger_events){
        auto* obj = pair.first;
        auto tiletype = pair.second;

        // 玩家和陷阱触发
        if(obj->getTag() == "player" && tiletype == engine::component::TileType::HAZARD){
            auto player_component = obj->getComponent<game::component::PlayerComponent>();
            player_component->takeDamage(1);
        }
        // ... 敌人可补充

    }
}

之后我们可以对此进行拓展,添加更多触发类型的地块,比如回血的(HEAL_ZONE)、加速的、保存进度等等。

编译运行

现在我们实现了踩踏敌人造成伤害,实现特效,触发陷阱。

总结

框架流程

  • 物理引擎 PhysicsEngine 检测碰撞,对象间、特殊瓦片间的触发事件,并记录,我们得到了底层的碰撞逻辑供上层判断
  • GameScene 是游戏场景,它可以获得物理引擎中的碰撞、事件,这些逻辑我们会写在该类中判断处理,我们会进行一个分发判断,比如玩家和敌人、玩家和物品等等
  • 为了游戏更生动,我们有创建特效的函数接口,动态创建特效游戏对象,播放完毕自动删除

特效系统优势

动态创建 :按需创建,无数量限制,自动管理

预先创建(对象池):占用内存、数量有限、管理复杂

遇到的问题

1.我们游戏场景在进行更新时是先进行物理引擎的更新,之后再进行遍历游戏对象看是否需要进行删除的逻辑。因为再物理引擎的更新时候会记录碰撞对collision_pairs_,存放GameObject指针,但是后续会删除标记为移除的对象,这回导致collision_pairs_中出现悬空指针/野指针

void Scene::update(float delta_time)
{
    if(!is_initialized_) return;

    // 更新物理引擎
    context_.getPhysicsEngine().update(delta_time);
    // 更新相机
    context_.getCamera().update(delta_time);

    // 更新所有游戏对象,并删除需要移除的对象
    for (auto it = game_objects_.begin(); it != game_objects_.end();){
        if(*it && !(*it)->needRemove()){
            (*it)->update(delta_time,context_);
            ++it;
        } else {
            if(*it){  // 安全的删除需要移除对象
                (*it)->clean(); // 清理对象
            }
            it = game_objects_.erase(it); // 删除游戏对象,智能指针自动管理内存
        }
    }
    // 处理待添加(延时添加)的游戏对象
    processPendingAdditions();
}

这个问题出在我测试函数部分

void GameScene::update(float deltaTime)
{
    Scene::update(deltaTime);
    testCollisionPair(); // 此处有风险
    handleObjectCollisions();
}
void GameScene::testCollisionPair()
{
    for (auto& pair : context_.getPhysicsEngine().getCollisionPairs()){
        spdlog::info("{}和{}发生碰撞",pair.first->getName(),pair.second->getName());
    }
}

我的测试函数,去获取得到的是已经记录下来的裸指针地址,这个地址是有效的(虽然之后马上被智能指针给清理了),但是解引用后的对象是不存在的,因为智能指针已经释放了,所以会造成悬空指针导致报错

2.在进行编写item特效的时候,发现特效很奇怪,似乎错位了,最后发现动画帧数搞错了,其实是32×32的区域,最后一帧是空的,我错认为是40px一帧。

3.在创建itemEffect时候,刚开始对于SpriteComponent有一些疑问,就是我并没有设置源矩形,所以Sprite精灵开始应该是一个nullopt,但是后面通过动画组件设置动画播放的时候,playAnimation的时候,会进行一个设置,将精灵组件的源矩形设置成第一帧。

void GameScene::createEffect(const glm::vec2 &center_pos, const std::string &tag)
{
    // 特效是一个游戏对象,要加动画,就要有Sprite
    auto effect = std::make_unique<engine::object::GameObject>("effect");
    if(tag == "enemy"){
        // 需要添加transform组件、sprite组件、动画组件
        effect->addComponent<engine::component::TransformComponent>(center_pos);

        effect->addComponent<engine::component::SpriteComponent>(
            "assets/textures/FX/enemy-deadth.png",
            context_.getResourceManager()
        );
        auto anim = std::make_unique<engine::render::Animation>("enemy-death", false);
        for(int i = 0; i < 6; ++i){
            SDL_FRect rect = {static_cast<float>(i * 40), 0.0f, 40.0f, 41.0f};
            anim->addFrame(rect, 0.1f);
        }
        auto ac = effect->addComponent<engine::component::AnimationComponent>();
        ac->addAnimation(std::move(anim));
        ac->playAnimation("enemy-death");
        // 特效只播放一次,播放完就移除
        ac->setOneShotRemoval(true);
    } else if(tag == "item"){
        // 需要添加transform组件、sprite组件、动画组件
        effect->addComponent<engine::component::TransformComponent>(center_pos);

        effect->addComponent<engine::component::SpriteComponent>(
            "assets/textures/FX/item-feedback.png",
            context_.getResourceManager()
        );
        auto anim = std::make_unique<engine::render::Animation>("item-feedback", false);
        for(int i = 0; i < 5; ++i){
            SDL_FRect rect = {static_cast<float>(i * 32), 0.0f, 32.f, 32.f};
            anim->addFrame(rect, 0.1f);
        }
        auto ac = effect->addComponent<engine::component::AnimationComponent>();
        ac->addAnimation(std::move(anim));
        ac->playAnimation("item-feedback");
        // 特效只播放一次,播放完就移除
        ac->setOneShotRemoval(true);
    }
    // 添加到场景中
    safeAddGameObject(std::move(effect));

}

// AnimationComponent.cpp
void AnimationComponent::playAnimation(const std::string &animation_name)
{
    auto it = animations_.find(animation_name);
    if(it == animations_.end()) {
        spdlog::error("动画组件播放动画失败,动画名称:{} 未找到", animation_name);
        return;
    }
    current_animation_ = it->second.get();
    animation_timer_ = 0.0f;
    is_playing_ = true;

    if(sprite_component_ && !current_animation_->isEmpty()) {
        auto frame_0 = current_animation_->getFrames(0.0f);
        sprite_component_->setSourceRect(frame_0.source_rect);
        spdlog::debug("游戏对象{}的动画组件播放动画成功,动画名称:{}", owner_->getName(), animation_name);
    }
}

4.跟着教程的时候,发现我的对象层的尖刺不和玩家发生碰撞,然后去level_loader载入对象层的时候找问题,发现我压根没有为特殊瓦片设置tag,我太废物了。

posted @ 2026-03-01 21:04  wenyiGamecpp  阅读(43)  评论(0)    收藏  举报