添加用户界面

注意

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

现在,我们的游戏已部署了 3D 视觉对象,可以侧重于添加一些2D 元素,以便游戏可以向玩家提供游戏状态反馈。 这可以通过在三维图形管道输出上添加简单的菜单选项和提醒显示组件来完成。

注意

如果尚未下载用于此示例的最新游戏代码,请转到 Direct3D 示例游戏。 此示例是大量 UWP 功能示例集合的一部分。 有关如何下载该示例的说明,请参阅Windows 开发的示例应用程序

目标

使用 Direct2D 将许多用户界面图形和行为添加到 UWP DirectX 游戏中,包括:

用户界面覆盖

尽管可以使用许多方法在 DirectX 游戏中显示文本和用户界面元素,但我们将重点介绍使用 Direct2D。 我们还将对文本元素使用 DirectWrite

Direct2D 是一组二维绘图 API,用于绘制基于像素的基元和效果。 从 Direct2D 着手进行时,最好保持简单。 复杂的布局和接口行为需要时间和规划。 如果你的游戏需要复杂的用户界面,如模拟游戏和战略游戏中的用户界面,请考虑改为使用 XAML。

注意

有关在 UWP DirectX 游戏中使用 XAML 开发用户界面的信息,请参阅扩展示例游戏

Direct2D 不是专门为用户界面或布局(如 HTML 和 XAML)而设计的。 它不提供用户界面组件,如列表、框或按钮。 也不提供布局组件,如 div、表或网格。

对于此示例游戏,我们有两个主要 UI 组件。

  1. 用于分数和游戏内控件的提醒显示。
  2. 用于显示游戏状态文本和选项(如暂停信息和级别开始选项)的覆盖层。

将 Direct2D 用于抬头显示

下图显示了示例的游戏内提醒显示。 它简单整齐,可让玩家专注于在三维世界中导航、射击目标。 良好的界面或提醒显示决不会使玩家处理和响应游戏中事件的能力复杂化。

游戏覆盖的屏幕截图

覆盖层由以下基本基元组成。

  • 右上角的 DirectWrite 文本,用于向玩家告知以下事项
    • 成功命中次数
    • 玩家进行的射击数
    • 级别中的剩余时间
    • 当前级别编号
  • 用于十字准线的两个相交线段
  • 移动观看控制器边界底角处的两个矩形。

覆盖层的游戏内提醒显示状态使用 GameHud 类的 GameHud::Render 方法绘制。 在此方法中,更新了表示 UI 的 Direct2D 覆盖层以反映命中次数、剩余时间和级别编号的更改。

如果游戏已初始化,则将 TotalHits()TotalShots()TimeRemaining() 添加到 swprintf_s 缓冲区,并指定打印格式。 随后可以使用 DrawText 方法进行绘制。 我们对当前级别指示器执行相同的操作,绘制空心编号以显示未完成的级别(如 ➀),绘制填充编号(如 ➊)以显示特定级别已完成。

以下代码片段演练了 GameHud::Render 方法的以下过程

void GameHud::Render(_In_ std::shared_ptr<Simple3DGame> const& game)
{
    auto d2dContext = m_deviceResources->GetD2DDeviceContext();
    auto windowBounds = m_deviceResources->GetLogicalSize();

    if (m_showTitle)
    {
        d2dContext->DrawBitmap(
            m_logoBitmap.get(),
            D2D1::RectF(
                GameUIConstants::Margin,
                GameUIConstants::Margin,
                m_logoSize.width + GameUIConstants::Margin,
                m_logoSize.height + GameUIConstants::Margin
                )
            );
        d2dContext->DrawTextLayout(
            Point2F(m_logoSize.width + 2.0f * GameUIConstants::Margin, GameUIConstants::Margin),
            m_titleHeaderLayout.get(),
            m_textBrush.get()
            );
        d2dContext->DrawTextLayout(
            Point2F(GameUIConstants::Margin, m_titleBodyVerticalOffset),
            m_titleBodyLayout.get(),
            m_textBrush.get()
            );
    }

    // Draw text for number of hits, total shots, and time remaining
    if (game != nullptr)
    {
        // This section is only used after the game state has been initialized.
        static const int bufferLength = 256;
        static wchar_t wsbuffer[bufferLength];
        int length = swprintf_s(
            wsbuffer,
            bufferLength,
            L"Hits:\t%10d\nShots:\t%10d\nTime:\t%8.1f",
            game->TotalHits(),
            game->TotalShots(),
            game->TimeRemaining()
            );

        // Draw the upper right portion of the HUD displaying total hits, shots, and time remaining
        d2dContext->DrawText(
            wsbuffer,
            length,
            m_textFormatBody.get(),
            D2D1::RectF(
                windowBounds.Width - GameUIConstants::HudRightOffset,
                GameUIConstants::HudTopOffset,
                windowBounds.Width,
                GameUIConstants::HudTopOffset + (GameUIConstants::HudBodyPointSize + GameUIConstants::Margin) * 3
                ),
            m_textBrush.get()
            );

        // Using the unicode characters starting at 0x2780 ( ➀ ) for the consecutive levels of the game.
        // For completed levels start with 0x278A ( ➊ ) (This is 0x2780 + 10).
        uint32_t levelCharacter[6];
        for (uint32_t i = 0; i < 6; i++)
        {
            levelCharacter[i] = 0x2780 + i + ((static_cast<uint32_t>(game->LevelCompleted()) == i) ? 10 : 0);
        }
        length = swprintf_s(
            wsbuffer,
            bufferLength,
            L"%lc %lc %lc %lc %lc %lc",
            levelCharacter[0],
            levelCharacter[1],
            levelCharacter[2],
            levelCharacter[3],
            levelCharacter[4],
            levelCharacter[5]
            );
        // Create a new rectangle and draw the current level info text inside
        d2dContext->DrawText(
            wsbuffer,
            length,
            m_textFormatBodySymbol.get(),
            D2D1::RectF(
                windowBounds.Width - GameUIConstants::HudRightOffset,
                GameUIConstants::HudTopOffset + (GameUIConstants::HudBodyPointSize + GameUIConstants::Margin) * 3 + GameUIConstants::Margin,
                windowBounds.Width,
                GameUIConstants::HudTopOffset + (GameUIConstants::HudBodyPointSize + GameUIConstants::Margin) * 4
                ),
            m_textBrush.get()
            );

        if (game->IsActivePlay())
        {
            // Draw the move and fire rectangles
            ...
            // Draw the crosshairs
            ...
        }
    }
}

进一步分解该方法,GameHud::Render 方法的此部分使用 ID2D1RenderTarget::DrawRectangle 绘制移动和射击矩形,使用两个 ID2D1RenderTarget::DrawLine 调用绘制十字准线。

// Check if game is playing
if (game->IsActivePlay())
{
    // Draw a rectangle for the touch input for the move control.
    d2dContext->DrawRectangle(
        D2D1::RectF(
            0.0f,
            windowBounds.Height - GameUIConstants::TouchRectangleSize,
            GameUIConstants::TouchRectangleSize,
            windowBounds.Height
            ),
        m_textBrush.get()
        );
    // Draw a rectangle for the touch input for the fire control.
    d2dContext->DrawRectangle(
        D2D1::RectF(
            windowBounds.Width - GameUIConstants::TouchRectangleSize,
            windowBounds.Height - GameUIConstants::TouchRectangleSize,
            windowBounds.Width,
            windowBounds.Height
            ),
        m_textBrush.get()
        );

    // Draw the cross hairs
    d2dContext->DrawLine(
        D2D1::Point2F(windowBounds.Width / 2.0f - GameUIConstants::CrossHairHalfSize,
            windowBounds.Height / 2.0f),
        D2D1::Point2F(windowBounds.Width / 2.0f + GameUIConstants::CrossHairHalfSize,
            windowBounds.Height / 2.0f),
        m_textBrush.get(),
        3.0f
        );
    d2dContext->DrawLine(
        D2D1::Point2F(windowBounds.Width / 2.0f, windowBounds.Height / 2.0f -
            GameUIConstants::CrossHairHalfSize),
        D2D1::Point2F(windowBounds.Width / 2.0f, windowBounds.Height / 2.0f +
            GameUIConstants::CrossHairHalfSize),
        m_textBrush.get(),
        3.0f
        );
}

在 GameHud::Render 方法中,我们将游戏窗口的逻辑大小存储在 windowBounds 变量中。 这使用 DeviceResources 类的 GetLogicalSize 方法

auto windowBounds = m_deviceResources->GetLogicalSize();

获取游戏窗口的大小对于 UI 编程至关重要。 窗口的大小以称为 DIP(与设备无关的像素)为单位进行提供,其中 1 个 DIP 定义为 1/96 英寸。 在开始绘图时,Direct2D 将绘图单位缩放到实际像素,通过使用 Windows 每英寸的点数 (DPI) 设置进行此操作。 同样,在使用 DirectWrite 绘制文本时,指定的是 DIP 而不是字号大小的点数。 DIP 会表示为浮点数。 

显示游戏状态信息

除提醒显示外,示例游戏还有一个用于表示六个游戏状态的覆盖层。 所有状态都有一个大的黑色矩形基元,附带供玩家阅读的文本。 未绘制移动观看控制器矩形和十字准线,因为它们在这些状态下不处于活动状态。

覆盖层使用 GameInfoOverlay 类进行创建,这使我们能够切出显示的文本以便与游戏状态保持一致。

覆盖的状态和操作

覆盖层分为两个部分:“状态”和“操作”状态部分可进一步细分为标题正文矩形。 “操作”部分只有一个矩形。 每个矩形的用途不同。

  • titleRectangle 包含标题文本。
  • bodyRectangle 包含正文文本。
  • actionRectangle 包含告知玩家执行特定操作的文本。

游戏具有六个可以设置的状态。 游戏状态使用覆盖层的“状态”部分进行传达。 “状态”矩形使用与以下状态对应的一些方法进行更新

  • 加载
  • 初次开始/高分统计信息
  • 级别开始
  • 游戏暂停
  • 游戏结束
  • 游戏获胜

覆盖层的“操作”部分使用 GameInfoOverlay::SetAction 方法进行更新,这样可以将操作文本设置为以下其中一项。

  • “Tap to play again...”
  • “Level loading, please wait ...”
  • “Tap to continue ...”

注意

这两种方法将在表示游戏状态部分中进一步讨论。

根据游戏中的情况,“状态”和“操作”部分文本字段会进行调整。 下面我们了解如何初始化和绘制这六个状态的覆盖层。

初始化和绘制覆盖

六个状态有一些共同点,这使得它们需要的资源和方法非常相似。 - 它们都使用屏幕中心的黑色矩形作为其背景。 - 显示的文本为“标题”或“正文”文本。 - 文本使用 Segoe UI 字体并在背景矩形顶部进行绘制。

示例游戏有四个方法会在创建覆盖层时发挥作用。

GameInfoOverlay::GameInfoOverlay

GameInfoOverlay::GameInfoOverlay 构造函数初始化覆盖面,维护将用于向玩家显示信息的位图图面。 该构造函数从传递到它的 ID2D1Device 对象获取工厂,使用该工厂创建覆盖层对象本身可以绘入的 ID2D1DeviceContextIDWriteFactory::CreateTextFormat

GameInfoOverlay::CreateDeviceDependentResources

GameInfoOverlay::CreateDeviceDependentResources 是用于创建画笔的方法,而画笔用于绘制文本。 为此,我们会获取 ID2D1DeviceContext2 对象,该对象可实现几何图形的创建和绘制以及墨迹和渐变网格呈现等功能。 然后,使用 ID2D1SolidColorBrush 创建一系列彩色画笔来绘制以下 UI 元素。

  • 用于矩形背景的黑色画笔
  • 用于状态文本的白色画笔
  • 用于操作文本的橙色画笔

DeviceResources::SetDpi

DeviceResources::SetDpi 方法设置窗口的每英寸点数。 当 DPI 更改,需要重新调整(在游戏窗口重设大小时发生)时,会调用此方法。 更新 DPI 后,此方法还会调用 DeviceResources::CreateWindowSizeDependentResources,以确保每次窗口重设大小时都重新创建所需资源。

GameInfoOverlay::CreateWindowsSizeDependentResources

GameInfoOverlay::CreateWindowsSizeDependentResources 方法是进行所有绘制的位置。 下面是该方法步骤的概述。

  • 创建三个矩形以便为“标题”、“正文”和“操作”文本分割 UI 文本

    m_titleRectangle = D2D1::RectF(
        GameInfoOverlayConstant::SideMargin,
        GameInfoOverlayConstant::TopMargin,
        overlaySize.width - GameInfoOverlayConstant::SideMargin,
        GameInfoOverlayConstant::TopMargin + GameInfoOverlayConstant::TitleHeight
        );
    m_actionRectangle = D2D1::RectF(
        GameInfoOverlayConstant::SideMargin,
        overlaySize.height - (GameInfoOverlayConstant::ActionHeight + GameInfoOverlayConstant::BottomMargin),
        overlaySize.width - GameInfoOverlayConstant::SideMargin,
        overlaySize.height - GameInfoOverlayConstant::BottomMargin
        );
    m_bodyRectangle = D2D1::RectF(
        GameInfoOverlayConstant::SideMargin,
        m_titleRectangle.bottom + GameInfoOverlayConstant::Separator,
        overlaySize.width - GameInfoOverlayConstant::SideMargin,
        m_actionRectangle.top - GameInfoOverlayConstant::Separator
        );
    
  • 使用 CreateBitmap 创建名为 m_levelBitmap 的位图(考虑当前 DPI)。

  • m_levelBitmap 使用 ID2D1DeviceContext::SetTarget 设置为二维呈现器目标。

  • 使用 ID2D1RenderTarget::Clear 使每个像素变为黑色时,从而清除位图。

  • 调用 ID2D1RenderTarget::BeginDraw 以启动绘制。

  • 调用 DrawText 以使用对应的 ID2D1SolidColorBrush 在相应矩形内绘制存储于 m_titleStringm_bodyStringm_actionString 中的文本。

  • 调用 ID2D1RenderTarget::EndDraw 以停止对 m_levelBitmap 进行的所有绘制操作。

  • 使用 CreateBitmap 创建名为 m_tooSmallBitmap 的另一个位图以用作回退(仅当显示配置对于游戏太小时才显示)。

  • 重复为 m_tooSmallBitmapm_levelBitmap 上进行绘制的过程,这次仅在正文中绘制字符串 Paused

现在,我们只需六个方法来填充六个覆盖层状态的文本!

表示游戏状态

游戏中的六个覆盖层状态在 GameInfoOverlay 对象中均有一个对应的方法。 这些方法可绘制此覆盖的变体,从而向玩家传达有关游戏自身的显式信息。 传递的信息使用标题和正文字符串进行表示。 因为示例已经初始化时通过 GameInfoOverlay::CreateDeviceDependentResources 方法为此信息配置了资源和布局,因此只需提供特定于覆盖层状态的字符串。

覆盖层的“状态”部分通过调用以下方法之一进行设置

游戏状态 状态设置方法 状态字段
加载 GameInfoOverlay::SetGameLoading 标题
Loading Resources
正文
以递增方式打印“.”以表示加载活动。
初次开始/高分统计信息 GameInfoOverlay::SetGameStats 标题
High Score
正文
已完成级别数
总分数
总射击数
级别开始 GameInfoOverlay::SetLevelStart 标题
级别数
正文
级别目标说明。
游戏暂停 GameInfoOverlay::SetPause 标题
Game Paused
正文
游戏结束 GameInfoOverlay::SetGameOver 标题
Game Over
正文
已完成级别数
总分数
总射击数
已完成级别数
高分数
游戏获胜 GameInfoOverlay::SetGameOver 标题
You WON!
正文
已完成级别数
总分数
总射击数
已完成级别数
高分数

借助 GameInfoOverlay::CreateWindowSizeDependentResources 方法,示例声明了与覆盖层的特定区域对应的三个矩形区域。

考虑到这些方面,让我们认识一下特定于状态的其中一个方法 GameInfoOverlay::SetGameStats,并了解如何绘制覆盖。

void GameInfoOverlay::SetGameStats(int maxLevel, int hitCount, int shotCount)
{
    int length;

    auto d2dContext = m_deviceResources->GetD2DDeviceContext();

    d2dContext->SetTarget(m_levelBitmap.get());
    d2dContext->BeginDraw();
    d2dContext->SetTransform(D2D1::Matrix3x2F::Identity());
    d2dContext->FillRectangle(&m_titleRectangle, m_backgroundBrush.get());
    d2dContext->FillRectangle(&m_bodyRectangle, m_backgroundBrush.get());
    m_titleString = L"High Score";

    d2dContext->DrawText(
        m_titleString.c_str(),
        m_titleString.size(),
        m_textFormatTitle.get(),
        m_titleRectangle,
        m_textBrush.get()
        );
    length = swprintf_s(
        wsbuffer,
        bufferLength,
        L"Levels Completed %d\nTotal Points %d\nTotal Shots %d",
        maxLevel,
        hitCount,
        shotCount
        );
    m_bodyString = std::wstring(wsbuffer, length);
    d2dContext->DrawText(
        m_bodyString.c_str(),
        m_bodyString.size(),
        m_textFormatBody.get(),
        m_bodyRectangle,
        m_textBrush.get()
        );

    // We ignore D2DERR_RECREATE_TARGET here. This error indicates that the device
    // is lost. It will be handled during the next call to Present.
    HRESULT hr = d2dContext->EndDraw();
    if (hr != D2DERR_RECREATE_TARGET)
    {
        // The D2DERR_RECREATE_TARGET indicates there has been a problem with the underlying
        // D3D device. All subsequent rendering will be ignored until the device is recreated.
        // This error will be propagated and the appropriate D3D error will be returned from the
        // swapchain->Present(...) call. At that point, the sample will recreate the device
        // and all associated resources. As a result, the D2DERR_RECREATE_TARGET doesn't
        // need to be handled here.
        winrt::check_hresult(hr);
    }
}

利用由 GameInfoOverlay 对象初始化的 Direct2D 设备上下文,此方法使用背景画笔将标题和正文矩形填充为黑色。 它会将“High Score”字符串的文本绘制到标题矩形中,并使用白色文本画笔将包含游戏状态信息更新的字符串绘制到正文矩形中。

操作矩形通过从 GameMain 对象上的方法后续调用 GameInfoOverlay::SetAction 进行更新,这将提供游戏状态信息,GameInfoOverlay::SetAction 需要此信息确定向玩家显示的正确消息(如“Tap to continue”)。

任何给定状态的覆盖层都在 GameMain::SetGameInfoOverlay 方法中选择,如下所示:

void GameMain::SetGameInfoOverlay(GameInfoOverlayState state)
{
    m_gameInfoOverlayState = state;
    switch (state)
    {
    case GameInfoOverlayState::Loading:
        m_uiControl->SetGameLoading(m_loadingCount);
        break;

    case GameInfoOverlayState::GameStats:
        m_uiControl->SetGameStats(
            m_game->HighScore().levelCompleted + 1,
            m_game->HighScore().totalHits,
            m_game->HighScore().totalShots
            );
        break;

    case GameInfoOverlayState::LevelStart:
        m_uiControl->SetLevelStart(
            m_game->LevelCompleted() + 1,
            m_game->CurrentLevel()->Objective(),
            m_game->CurrentLevel()->TimeLimit(),
            m_game->BonusTime()
            );
        break;

    case GameInfoOverlayState::GameOverCompleted:
        m_uiControl->SetGameOver(
            true,
            m_game->LevelCompleted() + 1,
            m_game->TotalHits(),
            m_game->TotalShots(),
            m_game->HighScore().totalHits
            );
        break;

    case GameInfoOverlayState::GameOverExpired:
        m_uiControl->SetGameOver(
            false,
            m_game->LevelCompleted(),
            m_game->TotalHits(),
            m_game->TotalShots(),
            m_game->HighScore().totalHits
            );
        break;

    case GameInfoOverlayState::Pause:
        m_uiControl->SetPause(
            m_game->LevelCompleted() + 1,
            m_game->TotalHits(),
            m_game->TotalShots(),
            m_game->TimeRemaining()
            );
        break;
    }
}

现在,游戏提供了一种根据游戏状态向玩家传达文本信息的方式,并且我们有一种方式可以切换在整个游戏中向玩家显示的内容。

后续步骤

在下一主题添加控件中,我们将介绍玩家如何与示例游戏交互以及输入如何更改游戏状态。