定义游戏的 UWP 应用框架

注意

本主题是使用 DirectX 创建简单的通用 Windows 平台 (UWP) 游戏教程系列的一部分。 此链接上的主题设置了该系列的上下文。

对通用 Windows 平台 (UWP) 游戏进行编码的第一步是构建框架,使应用对象可以与 Windows 交互,包括 Windows 运行时功能(如暂停-恢复事件处理、窗口可见性的更改以及贴靠)。

目标

  • 为通用 Windows 平台 (UWP) DirectX 游戏设置框架,以及实现定义整个游戏流的状态机。

注意

若要继续本主题,请查看下载的 Simple3DGameDX 示例游戏的源代码。

简介

设置游戏项目主题中,我们介绍了 wWinMain 函数以及 IFrameworkViewSourceIFrameworkView 接口。 我们已了解到 App 类(可以看到是在 Simple3DGameDX 项目的 App.cpp 源代码文件中进行定义)同时充当视图提供程序工厂和视图提供程序。

本主题从这里开始,更加详细地介绍了游戏中的 App 类应如何实现 IFrameworkView 方法。

App::Initialize 方法

在应用程序启动时,Windows 调用的第一种方法是 IFrameworkView::Initialize 的实现。

你的实现应处理 UWP 游戏的最基本行为,例如确保游戏可以通过订阅事件来处理暂停(稍后可以恢复)事件。 我们还可以在此处访问显示适配器设备,因此,我们可以创建依赖于设备的图形资源。

void Initialize(CoreApplicationView const& applicationView)
{
    applicationView.Activated({ this, &App::OnActivated });

    CoreApplication::Suspending({ this, &App::OnSuspending });

    CoreApplication::Resuming({ this, &App::OnResuming });

    // At this point we have access to the device. 
    // We can create the device-dependent resources.
    m_deviceResources = std::make_shared<DX::DeviceResources>();
}

尽可能避免使用原始指针(几乎总是可以)。

  • 对于 Windows 运行时类型,通常可以完全避免指针,而只在堆栈上构造值。 如果确实需要指针,请使用 winrt::com_ptr(我们很快会看到该指针的示例)。
  • 对于唯一指针,请使用 std::unique_ptr 和 std::make_unique。
  • 对于共享指针,请使用 std::shared_ptr 和 std::make_shared。

App::SetWindow 方法

执行 Initialize 后,Windows 会调用 IFrameworkView::SetWindow 的实现,传递表示游戏主窗口的 CoreWindow 对象。

在 App::SetWindow 中,我们订阅与窗口相关的事件,并配置一些窗口和显示行为。 例如,我们构造了鼠标指针(通过 CoreCursor 类),它可由鼠标和触摸控件使用。 我们还将窗口对象传递给与设备相关的资源对象。

我们将在游戏流管理主题中详细讨论事件处理。

void SetWindow(CoreWindow const& window)
{
    //CoreWindow window = CoreWindow::GetForCurrentThread();
    window.Activate();

    window.PointerCursor(CoreCursor(CoreCursorType::Arrow, 0));

    PointerVisualizationSettings visualizationSettings{ PointerVisualizationSettings::GetForCurrentView() };
    visualizationSettings.IsContactFeedbackEnabled(false);
    visualizationSettings.IsBarrelButtonFeedbackEnabled(false);

    m_deviceResources->SetWindow(window);

    window.Activated({ this, &App::OnWindowActivationChanged });

    window.SizeChanged({ this, &App::OnWindowSizeChanged });

    window.Closed({ this, &App::OnWindowClosed });

    window.VisibilityChanged({ this, &App::OnVisibilityChanged });

    DisplayInformation currentDisplayInformation{ DisplayInformation::GetForCurrentView() };

    currentDisplayInformation.DpiChanged({ this, &App::OnDpiChanged });

    currentDisplayInformation.OrientationChanged({ this, &App::OnOrientationChanged });

    currentDisplayInformation.StereoEnabledChanged({ this, &App::OnStereoEnabledChanged });

    DisplayInformation::DisplayContentsInvalidated({ this, &App::OnDisplayContentsInvalidated });
}

App::Load 方法

现在设置了主窗口,会调用 IFrameworkView::Load 的实现。 与 SetWindow 或 Initialize 相比,Load 是预取游戏数据或资源的更好位置。

void Load(winrt::hstring const& /* entryPoint */)
{
    if (!m_main)
    {
        m_main = winrt::make_self<GameMain>(m_deviceResources);
    }
}

如你所见,实际工作会委托给我们在此处创建的 GameMain 对象的构造函数。 GameMain 类在 GameMain.hGameMain.cpp 中定义。

GameMain::GameMain 构造函数

GameMain 构造函数(以及它调用的其他成员函数)会开始一组异步加载操作,以创建游戏对象、加载图形资源并初始化游戏的状态机。 我们还会在游戏开始之前做一些必要的准备,如设置任意开始状态或全局值。

Windows 对游戏开始处理输入前所能花费的时间施加了限制。 因此使用异步(正如我们在这里所做的那样)意味着 Load 可以快速返回,而它已开始的工作会在后台继续进行。 如果加载时间较长,或者如果有大量资源,则为用户提供经常更新的进度条是一个好主意。

如果你不熟悉异步编程,请参阅使用 C++/WinRT 执行并发和异步操作

GameMain::GameMain(std::shared_ptr<DX::DeviceResources> const& deviceResources) :
    m_deviceResources(deviceResources),
    m_windowClosed(false),
    m_haveFocus(false),
    m_gameInfoOverlayCommand(GameInfoOverlayCommand::None),
    m_visible(true),
    m_loadingCount(0),
    m_updateState(UpdateEngineState::WaitingForResources)
{
    m_deviceResources->RegisterDeviceNotify(this);

    m_renderer = std::make_shared<GameRenderer>(m_deviceResources);
    m_game = std::make_shared<Simple3DGame>();

    m_uiControl = m_renderer->GameUIControl();

    m_controller = std::make_shared<MoveLookController>(CoreWindow::GetForCurrentThread());

    auto bounds = m_deviceResources->GetLogicalSize();

    m_controller->SetMoveRect(
        XMFLOAT2(0.0f, bounds.Height - GameUIConstants::TouchRectangleSize),
        XMFLOAT2(GameUIConstants::TouchRectangleSize, bounds.Height)
        );
    m_controller->SetFireRect(
        XMFLOAT2(bounds.Width - GameUIConstants::TouchRectangleSize, bounds.Height - GameUIConstants::TouchRectangleSize),
        XMFLOAT2(bounds.Width, bounds.Height)
        );

    SetGameInfoOverlay(GameInfoOverlayState::Loading);
    m_uiControl->SetAction(GameInfoOverlayCommand::None);
    m_uiControl->ShowGameInfoOverlay();

    // Asynchronously initialize the game class and load the renderer device resources.
    // By doing all this asynchronously, the game gets to its main loop more quickly
    // and in parallel all the necessary resources are loaded on other threads.
    ConstructInBackground();
}

winrt::fire_and_forget GameMain::ConstructInBackground()
{
    auto lifetime = get_strong();

    m_game->Initialize(m_controller, m_renderer);

    co_await m_renderer->CreateGameDeviceResourcesAsync(m_game);

    // The finalize code needs to run in the same thread context
    // as the m_renderer object was created because the D3D device context
    // can ONLY be accessed on a single thread.
    // co_await of an IAsyncAction resumes in the same thread context.
    m_renderer->FinalizeCreateGameDeviceResources();

    InitializeGameState();

    if (m_updateState == UpdateEngineState::WaitingForResources)
    {
        // In the middle of a game so spin up the async task to load the level.
        co_await m_game->LoadLevelAsync();

        // The m_game object may need to deal with D3D device context work so
        // again the finalize code needs to run in the same thread
        // context as the m_renderer object was created because the D3D
        // device context can ONLY be accessed on a single thread.
        m_game->FinalizeLoadLevel();
        m_game->SetCurrentLevelToSavedState();
        m_updateState = UpdateEngineState::ResourcesLoaded;
    }
    else
    {
        // The game is not in the middle of a level so there aren't any level
        // resources to load.
    }

    // Since Game loading is an async task, the app visual state
    // may be too small or not be activated. Put the state machine
    // into the correct state to reflect these cases.

    if (m_deviceResources->GetLogicalSize().Width < GameUIConstants::MinPlayableWidth)
    {
        m_updateStateNext = m_updateState;
        m_updateState = UpdateEngineState::TooSmall;
        m_controller->Active(false);
        m_uiControl->HideGameInfoOverlay();
        m_uiControl->ShowTooSmall();
        m_renderNeeded = true;
    }
    else if (!m_haveFocus)
    {
        m_updateStateNext = m_updateState;
        m_updateState = UpdateEngineState::Deactivated;
        m_controller->Active(false);
        m_uiControl->SetAction(GameInfoOverlayCommand::None);
        m_renderNeeded = true;
    }
}

void GameMain::InitializeGameState()
{
    // Set up the initial state machine for handling Game playing state.
    ...
}

下面概述了构造函数启动的工作序列。

  • 创建和初始化 GameRenderer 类型的对象。 有关详细信息,请参阅呈现框架 I:呈现简介
  • 创建和初始化 Simple3DGame 类型的对象。 有关详细信息,请参阅定义主游戏对象
  • 创建游戏 UI 控件对象和显示游戏信息覆盖层以在加载资源文件时显示进度栏。 有关详细信息,请参阅添加用户界面
  • 创建控制器对象,以从控制器(触控装置、鼠标或 Xbox 游戏控制器)读取输入。 有关详细信息,请参阅添加控件
  • 在屏幕的左下角和右下角定义两个矩形区域,分别用于移动和相机触摸控件。 玩家会将左下角的矩形(在 SetMoveRect 调用中定义)用作虚拟控制板来前后左右移动相机。 右下角的矩形(由 SetFireRect 方法定义)可用作设计弹药的虚拟按钮。
  • 使用协同例程将资源加载分解为单独的阶段。 对 Direct3D 设备上下文的访问仅限于原先创建设备上下文的线程;而对用于对象创建的 Direct3D 设备的访问无线程限制。 因此,GameRenderer::CreateGameDeviceResourcesAsync 协同例程可以在不同于完成任务 (GameRenderer::FinalizeCreateGameDeviceResources) 的单独线程上运行,而完成任务在原始线程上运行。
  • 我们通过 Simple3DGame::LoadLevelAsync 和 Simple3DGame::FinalizeLoadLevel 使用类似的模式来加载级别资源。

我们会在下一个主题(游戏流管理)中详细了解 GameMain::InitializeGameState。

App::OnActivated 方法

接下来,引发 CoreApplicationView::Activated 事件。 因此,会调用你所拥有的任何 OnActivated 事件处理程序(如我们的 App::OnActivated 方法)。

void OnActivated(CoreApplicationView const& /* applicationView */, IActivatedEventArgs const& /* args */)
{
    CoreWindow window = CoreWindow::GetForCurrentThread();
    window.Activate();
}

我们在此处进行的唯一工作是激活主 CoreWindow。 或者,你可以选择在 App::SetWindow 中执行该操作。

App::Run 方法

Initialize、SetWindow 和 Load 已设置了此阶段。 现在游戏已设置并正在运行,会调用 IFrameworkView::Run 的实现。

void Run()
{
    m_main->Run();
}

工作会再次委托给 GameMain。

GameMain::Run 方法

GameMain::Run 是游戏的主循环;可以在 GameMain.cpp 中找到它。 基本逻辑是在游戏窗口保持到打开期间,调度所有事件,更新计时器,然后呈现并展示图形管道的结果。 另外在此处,用于在游戏状态之间转换的事件会进行调度和处理。

此处的代码还涉及游戏引擎状态机中的两个状态。

  • UpdateEngineState::Deactivated。 这会指定游戏窗口已停用(失去焦点)或已贴靠。
  • UpdateEngineState::TooSmall。 这会指定客户端区域太小,无法在其中呈现游戏。

在这两种状态中的任一状态下,游戏会暂停事件处理,并等待窗口激活、取消贴靠或重设大小。

在游戏窗口可见(Window.Visibletrue)时,必须处理消息队列中到达的每个事件,因此必须使用 ProcessAllIfPresent 选项调用 CoreWindowDispatch.ProcessEvents。 其他选项可能导致延迟处理消息事件,这会让人感到游戏停止响应,或者导致触摸行为反应慢。

当游戏不可见(Window.Visiblefalse)、暂停或太小(贴靠)时,你不希望应用使用任何资源循环去调度永不会到达的消息。 在这种情况下,游戏必须使用 ProcessOneAndAllPending 选项。 该选项在获得事件前将进行阻止,随后处理该事件(以及处理队列中在处理第一个事件期间到达的任何其他事件)。 CoreWindowDispatch.ProcessEvents 随后将在处理队列之后立即返回。

在下面所示的示例代码中,m_visible 数据成员表示窗口的可见性。 暂停游戏后,其窗口会不可见。 当窗口可见时,m_updateState 的值(UpdateEngineState 枚举)会进一步确定窗口是停用(失去焦点)、太小(贴靠)还是大小正确。

void GameMain::Run()
{
    while (!m_windowClosed)
    {
        if (m_visible)
        {
            switch (m_updateState)
            {
            case UpdateEngineState::Deactivated:
            case UpdateEngineState::TooSmall:
                if (m_updateStateNext == UpdateEngineState::WaitingForResources)
                {
                    WaitingForResourceLoading();
                    m_renderNeeded = true;
                }
                else if (m_updateStateNext == UpdateEngineState::ResourcesLoaded)
                {
                    // In the device lost case, we transition to the final waiting state
                    // and make sure the display is updated.
                    switch (m_pressResult)
                    {
                    case PressResultState::LoadGame:
                        SetGameInfoOverlay(GameInfoOverlayState::GameStats);
                        break;

                    case PressResultState::PlayLevel:
                        SetGameInfoOverlay(GameInfoOverlayState::LevelStart);
                        break;

                    case PressResultState::ContinueLevel:
                        SetGameInfoOverlay(GameInfoOverlayState::Pause);
                        break;
                    }
                    m_updateStateNext = UpdateEngineState::WaitingForPress;
                    m_uiControl->ShowGameInfoOverlay();
                    m_renderNeeded = true;
                }

                if (!m_renderNeeded)
                {
                    // The App is not currently the active window and not in a transient state so just wait for events.
                    CoreWindow::GetForCurrentThread().Dispatcher().ProcessEvents(CoreProcessEventsOption::ProcessOneAndAllPending);
                    break;
                }
                // otherwise fall through and do normal processing to get the rendering handled.
            default:
                CoreWindow::GetForCurrentThread().Dispatcher().ProcessEvents(CoreProcessEventsOption::ProcessAllIfPresent);
                Update();
                m_renderer->Render();
                m_deviceResources->Present();
                m_renderNeeded = false;
            }
        }
        else
        {
            CoreWindow::GetForCurrentThread().Dispatcher().ProcessEvents(CoreProcessEventsOption::ProcessOneAndAllPending);
        }
    }
    m_game->OnSuspending();  // Exiting due to window close, so save state.
}

App::Uninitialize 方法

游戏结束时,会调用 IFrameworkView::Uninitialize 的实现。 这使我们有机会执行清理。 关闭游戏窗口不会终止应用的进程,而是将应用单一实例的状态写入内存。 如果在系统回收此内存时必须执行一些特殊操作(包括任何特殊的资源清理),则将执行该清理的代码放入 Uninitialize 中。

在我们的示例中,App::Uninitialize 不执行任何操作。

void Uninitialize()
{
}

提示

在开发自己的游戏时,请围绕本主题中所述的方法设计启动代码。 此处是对各方法的基本建议的简单列表。

  • 使用 Initialize 分配主类和连接基本事件处理程序。
  • 使用 SetWindow 订阅任何特定于窗口的事件,并将主窗口传递到与设备相关的资源对象,以便在创建交换链时可以使用该窗口。
  • 使用 Load 来处理任何其余设置、启动对象的异步创建和加载资源。 如果需要创建任何临时文件或数据,如按顺序生成的资源,也在这里完成。

后续步骤

本主题介绍了使用 DirectX 的 UWP 游戏的一些基本结构。 最好记住这些方法,因为我们将在后面的主题中回顾其中一些方法。

在下一个主题(游戏流管理)中,我们将深入了解如何管理游戏状态和事件处理以便让游戏顺畅进行。