15 KiB
15 KiB
02. 场景系统
Extra2D 的场景系统提供了游戏内容的分层管理和切换功能。本教程将详细介绍场景的生命周期、切换和过渡效果,以及视口适配功能。
完整示例
参考 examples/flappy_bird/ 和 examples/push_box/ 中的实现:
BaseScene.h/cpp- 基础场景类(视口适配)StartScene.h/cpp- 开始菜单场景GameScene.h/cpp- 游戏主场景GameOverLayer.h/cpp- 游戏结束层
视口适配(重要)
问题背景
在游戏开发中,不同设备有不同的屏幕分辨率。如果直接使用窗口坐标,游戏内容可能会变形或显示不完整。
Extra2D 提供了居中视口适配方案,让游戏在任意分辨率下都能正确显示:
- 游戏使用固定的逻辑分辨率(如 Flappy Bird 的 288×512)
- 根据窗口大小自动缩放,保持宽高比
- 游戏内容居中显示,四周显示黑边
实现方案
参考 examples/flappy_bird/BaseScene.h/cpp:
// BaseScene.h
#pragma once
#include <extra2d/extra2d.h>
namespace flappybird {
// 游戏逻辑分辨率(原始 Flappy Bird 尺寸)
static constexpr float GAME_WIDTH = 288.0f;
static constexpr float GAME_HEIGHT = 512.0f;
/**
* @brief Flappy Bird 基础场景类
* 所有游戏场景都应继承此类,以获得统一的居中视口适配功能
*/
class BaseScene : public extra2d::Scene {
public:
BaseScene();
void onEnter() override;
void onRender(extra2d::RenderBackend &renderer) override;
protected:
void updateViewport();
float scaledGameWidth_ = 0.0f;
float scaledGameHeight_ = 0.0f;
float viewportOffsetX_ = 0.0f;
float viewportOffsetY_ = 0.0f;
};
} // namespace flappybird
// BaseScene.cpp
#include "BaseScene.h"
namespace flappybird {
BaseScene::BaseScene() {
setBackgroundColor(extra2d::Color(0.0f, 0.0f, 0.0f, 1.0f));
}
void BaseScene::onEnter() {
extra2d::Scene::onEnter();
updateViewport();
}
void BaseScene::updateViewport() {
auto &app = extra2d::Application::instance();
float windowWidth = static_cast<float>(app.window().getWidth());
float windowHeight = static_cast<float>(app.window().getHeight());
// 保持游戏原始宽高比,进行"黑边"适配
float scaleX = windowWidth / GAME_WIDTH;
float scaleY = windowHeight / GAME_HEIGHT;
float scale = std::min(scaleX, scaleY);
scaledGameWidth_ = GAME_WIDTH * scale;
scaledGameHeight_ = GAME_HEIGHT * scale;
viewportOffsetX_ = (windowWidth - scaledGameWidth_) * 0.5f;
viewportOffsetY_ = (windowHeight - scaledGameHeight_) * 0.5f;
// 设置视口大小为游戏逻辑分辨率
setViewportSize(GAME_WIDTH, GAME_HEIGHT);
// 创建并设置相机
auto camera = extra2d::makePtr<extra2d::Camera>();
camera->setViewport(0.0f, GAME_WIDTH, GAME_HEIGHT, 0.0f);
setCamera(camera);
}
void BaseScene::onRender(extra2d::RenderBackend &renderer) {
// 检查窗口大小是否改变
auto &app = extra2d::Application::instance();
float currentWindowWidth = static_cast<float>(app.window().getWidth());
float currentWindowHeight = static_cast<float>(app.window().getHeight());
float expectedWidth = scaledGameWidth_ + viewportOffsetX_ * 2.0f;
float expectedHeight = scaledGameHeight_ + viewportOffsetY_ * 2.0f;
if (std::abs(currentWindowWidth - expectedWidth) > 1.0f ||
std::abs(currentWindowHeight - expectedHeight) > 1.0f) {
updateViewport();
}
// 设置视口为居中区域
renderer.setViewport(
static_cast<int>(viewportOffsetX_), static_cast<int>(viewportOffsetY_),
static_cast<int>(scaledGameWidth_), static_cast<int>(scaledGameHeight_));
extra2d::Scene::onRender(renderer);
}
} // namespace flappybird
使用基础场景类
所有游戏场景继承 BaseScene,自动获得视口适配功能:
// GameScene.h
class GameScene : public BaseScene {
public:
void onEnter() override;
void onUpdate(float dt) override;
};
// GameScene.cpp
void GameScene::onEnter() {
BaseScene::onEnter(); // 必须调用父类方法
// 使用游戏逻辑分辨率进行布局
float screenWidth = GAME_WIDTH; // 288.0f
float screenHeight = GAME_HEIGHT; // 512.0f
// 所有坐标都基于逻辑分辨率
auto bird = extra2d::makePtr<Bird>();
bird->setPosition(extra2d::Vec2(screenWidth / 2.0f - 50.0f, screenHeight / 2.0f));
addChild(bird);
}
void GameScene::onUpdate(float dt) {
// 游戏逻辑更新...
// 必须调用父类方法,确保子节点动画正常播放
BaseScene::onUpdate(dt);
}
视口适配原理图
┌─────────────────────────────────────────────┐
│ 窗口 (1280 x 720) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 黑边区域 │ │
│ │ │ │
│ │ ┌───────────────────────────┐ │ │
│ │ │ 游戏视口 (288 x 512) │ │ │
│ │ │ 自动缩放并居中显示 │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ 游戏内容 │ │ │ │
│ │ │ │ (逻辑分辨率) │ │ │ │
│ │ │ └─────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────┘ │ │
│ │ │ │
│ │ 黑边区域 │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────┘
关键要点
- 定义游戏逻辑分辨率 - 如
GAME_WIDTH = 288.0f, GAME_HEIGHT = 512.0f - 在
onEnter()中调用updateViewport()- 初始化视口 - 在
onRender()中设置渲染器视口 - 确保正确渲染区域 - 所有坐标使用逻辑分辨率 - 不依赖窗口实际大小
- 处理窗口大小变化 - 在
onRender()中检测并更新
注意事项
- 相机设置必须正确 - 使用
camera->setViewport(0.0f, GAME_WIDTH, GAME_HEIGHT, 0.0f)确保相机覆盖整个游戏区域 - 渲染器视口与相机视口不同 - 渲染器视口控制实际渲染到屏幕的区域,相机视口控制世界坐标到屏幕坐标的映射
- 窗口大小变化检测 - 在
onRender()中检测窗口大小变化并重新计算视口,确保窗口调整时正确适配 - 子类必须调用父类方法 -
onEnter()和onUpdate()中必须调用BaseScene::onEnter()和BaseScene::onUpdate() - UI元素坐标空间 - UI控件通常使用
CoordinateSpace::Screen固定在屏幕上,不受视口适配影响
场景基础
创建场景
#include <extra2d/extra2d.h>
using namespace extra2d;
class GameScene : public Scene {
public:
void onEnter() override {
// 必须先调用父类方法
Scene::onEnter();
// 设置背景色
setBackgroundColor(Color(0.1f, 0.1f, 0.3f, 1.0f));
// 设置视口大小(用于UI布局)
setViewportSize(1280.0f, 720.0f);
}
void onExit() override {
// 清理资源
removeAllChildren();
Scene::onExit();
}
void onUpdate(float dt) override {
Scene::onUpdate(dt);
// 游戏逻辑更新
}
};
场景切换(新API)
通过 SceneManager 进行场景切换:
auto& scenes = app.scenes();
// 运行第一个场景
scenes.runWithScene(makePtr<GameScene>());
// 替换当前场景(无过渡)
scenes.replaceScene(makePtr<GameScene>());
// 替换当前场景(有过渡效果)
scenes.replaceScene(makePtr<GameScene>(), TransitionType::Fade, 0.5f);
// 推入场景(保留当前场景)
scenes.pushScene(makePtr<NewScene>());
scenes.pushScene(makePtr<NewScene>(), TransitionType::SlideLeft, 0.5f);
// 弹出场景(返回上一个场景)
scenes.popScene();
scenes.popScene(TransitionType::Fade, 0.5f);
// 弹出到根场景
scenes.popToRootScene();
scenes.popToRootScene(TransitionType::Fade, 0.5f);
// 弹出到指定场景
scenes.popToScene("SceneName");
scenes.popToScene("SceneName", TransitionType::Fade, 0.5f);
过渡效果类型
enum class TransitionType {
None, // 无过渡
Fade, // 淡入淡出
SlideLeft, // 向左滑动
SlideRight, // 向右滑动
SlideUp, // 向上滑动
SlideDown, // 向下滑动
Scale, // 缩放过渡
Flip, // 翻转过渡
Box // 盒子过渡
};
场景管理器
通过 app.scenes() 访问场景管理器:
auto& scenes = app.scenes();
// 获取当前场景
auto current = scenes.getCurrentScene();
// 获取上一个场景
auto previous = scenes.getPreviousScene();
// 获取根场景
auto root = scenes.getRootScene();
// 通过名称获取场景
auto scene = scenes.getSceneByName("SceneName");
// 获取场景栈深度
size_t count = scenes.getSceneCount();
// 检查是否正在过渡
bool transitioning = scenes.isTransitioning();
// 清空场景栈
scenes.end();
场景生命周期
创建场景 (makePtr<Scene>)
↓
进入场景 (runWithScene/replaceScene/pushScene)
↓
onEnter() - 初始化资源
↓
主循环
├── onUpdate(dt) - 每帧更新
└── onRender(renderer) - 每帧渲染
↓
退出场景
↓
onExit() - 清理资源
↓
场景销毁
推箱子示例场景结构
┌─────────────────────────────────────┐
│ StartScene │
│ ┌─────────────────────────────┐ │
│ │ 开始菜单界面 │ │
│ │ - 新游戏 │ │
│ │ - 继续游戏 │ │
│ │ - 退出 │ │
│ └─────────────────────────────┘ │
└──────────────┬──────────────────────┘
│ 选择"新游戏"
↓
┌─────────────────────────────────────┐
│ PlayScene │
│ ┌─────────────────────────────┐ │
│ │ 游戏主界面 │ │
│ │ - 地图渲染 │ │
│ │ - 玩家控制 │ │
│ │ - 关卡信息 │ │
│ └─────────────────────────────┘ │
└──────────────┬──────────────────────┘
│ 通关
↓
┌─────────────────────────────────────┐
│ SuccessScene │
│ ┌─────────────────────────────┐ │
│ │ 通关界面 │ │
│ │ - 显示成绩 │ │
│ │ - 下一关/返回菜单 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
代码示例:菜单场景
class MenuScene : public Scene {
public:
void onEnter() override {
Scene::onEnter();
auto& app = Application::instance();
auto& resources = app.resources();
// 加载背景
auto bgTex = resources.loadTexture("assets/bg.jpg");
if (bgTex) {
auto bg = Sprite::create(bgTex);
bg->setAnchor(0.0f, 0.0f);
addChild(bg);
}
// 加载字体
font_ = resources.loadFont("assets/font.ttf", 28, true);
// 创建菜单按钮
createMenuButtons();
}
void onUpdate(float dt) override {
Scene::onUpdate(dt);
auto& input = Application::instance().input();
// 方向键导航
if (input.isButtonPressed(GamepadButton::DPadUp)) {
selectedIndex_ = (selectedIndex_ - 1 + menuCount_) % menuCount_;
updateMenuColors();
}
else if (input.isButtonPressed(GamepadButton::DPadDown)) {
selectedIndex_ = (selectedIndex_ + 1) % menuCount_;
updateMenuColors();
}
// A键确认
if (input.isButtonPressed(GamepadButton::A)) {
executeMenuItem();
}
}
private:
void createMenuButtons() {
float centerX = 640.0f;
float startY = 300.0f;
float spacing = 50.0f;
for (int i = 0; i < menuCount_; ++i) {
auto btn = Button::create();
btn->setFont(font_);
btn->setAnchor(0.5f, 0.5f);
btn->setPosition(centerX, startY + i * spacing);
addChild(btn);
buttons_.push_back(btn);
}
buttons_[0]->setText("开始游戏");
buttons_[1]->setText("设置");
buttons_[2]->setText("退出");
updateMenuColors();
}
void updateMenuColors() {
for (int i = 0; i < buttons_.size(); ++i) {
auto color = (i == selectedIndex_) ? Colors::Red : Colors::White;
buttons_[i]->setTextColor(color);
}
}
void executeMenuItem() {
auto& scenes = Application::instance().scenes();
switch (selectedIndex_) {
case 0:
scenes.replaceScene(makePtr<GameScene>(),
TransitionType::Fade, 0.25f);
break;
case 1:
// 打开设置
break;
case 2:
Application::instance().quit();
break;
}
}
Ptr<FontAtlas> font_;
std::vector<Ptr<Button>> buttons_;
int selectedIndex_ = 0;
int menuCount_ = 3;
};
最佳实践
- 始终在 onEnter 中调用 Scene::onEnter() - 确保场景正确初始化
- 在 onExit 中清理资源 - 避免内存泄漏
- 使用过渡效果 - 提升用户体验
- 分离场景逻辑 - 每个场景负责自己的功能
- 使用视口适配 - 确保游戏在不同分辨率下正确显示
- 正确处理窗口大小变化 - 在
onRender()中检测并更新视口