Share via


Adicionar uma interface do usuário

Observação

Este tópico faz parte da série de tutoriais Criar um jogo simples da Plataforma Universal do Windows (UWP) com DirectX. O tópico nesse link define o contexto da série.

Agora que nosso jogo tem visuais 3D implementados, é hora de se concentrar em adicionar alguns elementos 2D para que o jogo possa fornecer feedback sobre o estado do jogo para o jogador. Isso pode ser feito adicionando opções de menu simples e componentes de exibição de avisos na parte superior da saída do pipeline de gráficos 3D.

Observação

Se não tiver feito download do código de jogo mais recente para essa amostra, acesse jogo de exemplo do Direct3D. Esta amostra faz parte de uma grande coleção de amostras de recursos UWP. Para obter instruções sobre como fazer o download da amostra, confira Aplicativos de amostra para desenvolvimento do Windows.

Objetivo

Usando o Direct2D, adicione vários gráficos e comportamentos da interface do usuário ao nosso jogo UWP DirectX, incluindo:

Sobreposição da interface do usuário

Embora haja muitas maneiras de exibir texto e elementos da interface do usuário em um jogo em DirectX, vamos nos concentrar no uso do Direct2D. Também usaremos o DirectWrite para os elementos de texto.

Direct2D é um conjunto de APIs de desenho 2D usado para desenhar primitivas e efeitos baseados em pixel. Ao começar com o Direct2D, é melhor manter as coisas simples. Layouts complexos e comportamentos de interface precisam de tempo e planejamento. Se o seu jogo requer uma interface de usuário complexa, como as encontradas em jogos de simulação e estratégia, considere usar XAML.

Observação

Para obter informações sobre como desenvolver uma interface de usuário com XAML em um jogo de UWP em DirectX, consulte Estender o jogo de exemplo.

O Direct2D não foi projetado especificamente para interfaces de usuário ou layouts como HTML e XAML. Ele não fornece componentes de interface do usuário, como listas, caixas ou botões. Ele também não fornece componentes de layout como divs, tabelas ou grades.

Para este jogo de exemplo, temos dois componentes principais da interface do usuário.

  1. Uma exibição de aviso para a pontuação e controles no jogo.
  2. Uma sobreposição usada para exibir texto e opções de estado do jogo, como informações de pausa e opções de início de nível.

Usar o Direct2D para uma exibição de aviso

A imagem a seguir mostra a exibição de aviso no jogo para o exemplo. É simples e organizado, permitindo que o jogador se concentre em navegar pelo mundo 3D e atirar em alvos. Uma boa interface ou exibição de avisos nunca deve complicar a capacidade do jogador de processar e reagir aos eventos no jogo.

uma captura de tela da sobreposição do jogo

A sobreposição consiste nas seguintes primitivas básicas.

  • Texto em DirectWrite no canto superior direito que informa o jogador de
    • Resultados bem-sucedidos
    • Número de tiros que o jogador deu
    • Tempo restante no nível
    • Número do nível atual
  • Dois segmentos de linha de intersecção usados para formar uma mira
  • Dois retângulos nos cantos inferiores para os limites do controlador move-look.

O estado de exibição de aviso no jogo da sobreposição é desenhado no método GameHud::Render da classe GameHud. Nesse método, a sobreposição do Direct2D que representa nossa interface do usuário é atualizada para refletir as alterações no número de acertos, tempo restante e número de nível.

Se o jogo tiver sido inicializado, adicionamos TotalHits(), TotalShots()e TimeRemaining() a um buffer swprintf_s e especificamos o formato de impressão. Podemos então desenhá-lo usando o método DrawText. Fazemos o mesmo para o indicador de nível atual, desenhando números vazios para mostrar níveis incompletos, como ➀, e números preenchidos, como ➊, para mostrar que o nível específico foi concluído.

O trecho de código a seguir percorre o processo do método GameHud::Render para

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
            ...
        }
    }
}

Ao dividir ainda mais o método, essa parte do método GameHud::Render desenha nossos retângulos de movimento e disparo com ID2D1RenderTarget::DrawRectangle e mira usando duas chamadas para 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
        );
}

No método GameHud::Render, armazenamos o tamanho lógico da janela do jogo na variável windowBounds. Isso usa o método GetLogicalSize da classe DeviceResources.

auto windowBounds = m_deviceResources->GetLogicalSize();

Obter o tamanho da janela do jogo é essencial para a programação da interface do usuário. O tamanho da janela é dado em uma medida chamada DIPs (pixels independentes de dispositivo), em que um DIP é definido como 1/96 de uma polegada. O Direct2D dimensiona as unidades de desenho para pixels reais quando o desenho ocorre, fazendo isso usando a configuração de pontos por polegada (DPI) do Windows. Da mesma forma, ao desenhar texto usando o DirectWrite, você especifica DIPs em vez de pontos para o tamanho da fonte. Os DIPs são expressos como números de ponto flutuante. 

Exibir informações de estado do jogo

Além da exibição de aviso, o jogo de amostra tem uma sobreposição que representa seis estados de jogo. Todos os estados apresentam uma grande primitiva de retângulo preta com texto para o jogador ler. Os retângulos e as miras do controlador move-look não são desenhados porque não estão ativos nesses estados.

A sobreposição é criada usando a classe GameInfoOverlay, permitindo alternar qual texto é exibido, alinhando-o ao estado do jogo.

status e ação de sobreposição

A sobreposição é dividida em duas seções: Status e Ação. A seção Status é dividida em retângulos de Título e Corpo. A seção Ação tem apenas um retângulo. Cada retângulo tem uma finalidade diferente.

  • titleRectangle contém o texto do título.
  • bodyRectangle contém o texto do corpo.
  • actionRectangle contém o texto que informa o jogador para tomar uma ação específica.

O jogo tem seis estados que podem ser definidos. O estado do jogo transmitido usando a parte Status da sobreposição. Os retângulos de Status são atualizados usando vários métodos correspondentes aos seguintes estados.

  • Carregando
  • Estatísticas iniciais de início/pontuação alta
  • Início do nível
  • Jogo pausado
  • Fim de jogo
  • Jogo ganho

A parte Ação da sobreposição é atualizada usando o método GameInfoOverlay::SetAction , permitindo que o texto da ação seja definido como um dos seguintes.

  • "Toque para jogar novamente..."
  • "Nível sendo carregado, aguarde..."
  • "Toque para continuar..."
  • Nenhum

Observação

Ambos os métodos serão discutidos mais adiante na seção Representar o estado do jogo.

Dependendo do que está acontecendo no jogo, os campos de texto da seção Status e Ação são ajustados. Vejamos como inicializamos e desenhamos a sobreposição para esses seis estados.

Inicializar e desenhar a sobreposição

Os seis estados de Status têm algumas coisas em comum, tornando os recursos e métodos de que precisam muito semelhantes. - Todos eles usam um retângulo preto no centro da tela como fundo. - O texto exibido é Título ou Corpo. - O texto usa a fonte Segoe UI e é desenhado em cima do retângulo posterior.

O jogo de exemplo tem quatro métodos que são aplicados ao criar a sobreposição.

GameInfoOverlay::GameInfoOverlay

O construtor GameInfoOverlay::GameInfoOverlay inicializa a sobreposição, mantendo a superfície de bitmap que usaremos para exibir informações ao jogador. O construtor obtém uma fábrica do objeto ID2D1Device passado para ele, que ele usa para criar um ID2D1DeviceContext que o próprio objeto de sobreposição pode desenhar. IDWriteFactory::CreateTextFormat

GameInfoOverlay::CreateDeviceDependentResources

GameInfoOverlay::CreateDeviceDependentResources é o nosso método para criar pincéis que serão usados para desenhar nosso texto. Para fazer isso, obtemos um objeto ID2D1DeviceContext2 que permite a criação e o desenho de geometria, além de funcionalidades como renderização de malha de tinta e gradiente. Em seguida, criamos uma série de pincéis coloridos usando ID2D1SolidColorBrush para desenhar os seguintes elementos da interface do usuário.

  • Pincel preto para fundos de retângulo
  • Pincel branco para texto de status
  • Pincel laranja para texto de ação

DeviceResources::SetDpi

O método DeviceResources::SetDpi define os pontos por polegada da janela. Esse método é chamado quando o DPI é alterado e precisa ser reajustado, o que acontece quando a janela do jogo é redimensionada. Depois de atualizar o DPI, esse método também chama DeviceResources::CreateWindowSizeDependentResources para garantir que os recursos necessários sejam recriados sempre que a janela for redimensionada.

GameInfoOverlay::CreateWindowsSizeDependentResources

O método GameInfoOverlay::CreateWindowsSizeDependentResources é onde todo o nosso desenho ocorre. A seguir está um esboço das etapas do método.

  • Três retângulos são criados para separar o texto da interface do usuário em Título, Corpo e Ação.

    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
        );
    
  • Um bitmap é criado chamado m_levelBitmap, levando em conta o DPI atual usando CreateBitmap.

  • m_levelBitmap é definido como nosso destino de renderização 2D usando ID2D1DeviceContext::SetTarget.

  • O bitmap é limpo com cada pixel preto usando ID2D1RenderTarget::Clear.

  • ID2D1RenderTarget::BeginDraw é chamado para iniciar o desenho.

  • DrawText é chamado para desenhar o texto armazenado em m_titleString, m_bodyString e m_actionString no retângulo apropriado usando o ID2D1SolidColorBrush correspondente.

  • ID2D1RenderTarget::EndDraw é chamado para interromper todas as operações de desenho em m_levelBitmap.

  • Outro bitmap é criado usando CreateBitmap chamado de m_tooSmallBitmap para usar como fallback, mostrado somente se a configuração de exibição for muito pequena para o jogo.

  • Repita o processo para desenhar m_levelBitmap para m_tooSmallBitmap, desta vez apenas desenhando a cadeia de caracteres Paused no corpo.

Agora tudo o que precisamos são seis métodos para preencher o texto de nossos seis estados de sobreposição!

Representar o estado do jogo

Cada um dos seis estados de sobreposição no jogo tem um método correspondente no objeto GameInfoOverlay. Esses métodos desenham uma variação da sobreposição para comunicar informações explícitas ao jogador sobre o jogo em si. Essa comunicação é representada com uma cadeia de caracteres Título e Corpo. Como o exemplo já configurou os recursos e o layout para essas informações quando foi inicializado e com o método GameInfoOverlay::CreateDeviceDependentResources, ele só precisa fornecer as cadeias de caracteres específicas do estado de sobreposição.

A parte Status da sobreposição é definida com uma chamada para um dos seguintes métodos.

Estado do jogo Método do conjunto de status Campos de status
Carregando GameInfoOverlay::SetGameLoading Título
Carregando recursos
Corpo
Incrementalmente imprime "." para implicar atividade de carregamento.
Estatísticas iniciais de início/pontuação alta GameInfoOverlay::SetGameStats Título
Alta Pontuação
Corpo
Níveis Concluídos #
Total de Pontos #
Total de Tiros #
Início do nível GameInfoOverlay::SetLevelStart Título
Nível #
Corpo
Descrição do objetivo do nível.
Jogo pausado GameInfoOverlay::SetPause Título
Jogo Pausado
Corpo
Nenhum
Fim de jogo GameInfoOverlay::SetGameOver Título
Fim de jogo
Corpo
Níveis concluídos #
Total de pontos #
Total de tiros #
Níveis concluídos #
Pontuação alta #
Jogo ganho GameInfoOverlay::SetGameOver Título
Você GANHOU!
Corpo
Níveis concluídos #
Total de pontos #
Total de tiros #
Níveis concluídos #
Alta pontuação #

Com o método GameInfoOverlay::CreateWindowSizeDependentResources , o exemplo declarou três áreas retangulares que correspondem a regiões específicas da sobreposição.

Com essas áreas em mente, vamos examinar um dos métodos específicos do estado, GameInfoOverlay::SetGameStats, e ver como a sobreposição é desenhada.

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);
    }
}

Usando o contexto de dispositivo do Direct2D que o objeto GameInfoOverlay inicializou, esse método preenche os retângulos de título e corpo com preto usando o pincel de plano de fundo. Ele desenha o texto da cadeia de caracteres "Alta pontuação" para o retângulo do título e uma cadeia de caracteres contendo as informações de estado do jogo de atualizações para o retângulo do corpo usando o pincel de texto branco.

O retângulo de ação é atualizado por uma chamada subsequente para GameInfoOverlay::SetAction a partir de um método no objeto GameMain, que fornece as informações de estado do jogo necessárias por GameInfoOverlay::SetAction para determinar a mensagem certa para o jogador, como "Toque para continuar".

A sobreposição para qualquer estado é escolhida no método GameMain::SetGameInfoOverlay da seguinte forma:

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;
    }
}

Agora o jogo tem uma maneira de comunicar informações de texto para o jogador com base no estado do jogo, e temos uma maneira de mudar o que é exibido para ele ao longo do jogo.

Próximas etapas

No próximo tópico, Adicionar controles, veremos como o jogador interage com o jogo de exemplo e como a entrada altera o estado do jogo.