Compartir a través de


Agregar una interfaz de usuario

Nota:

Este tema forma parte de la serie de tutoriales Crear un juego sencillo para la Plataforma universal de Windows (UWP) con DirectX. El tema de ese vínculo establece el contexto de la serie.

Ahora que nuestro juego tiene los objetos visuales 3D en su lugar, es el momento de centrarse en agregar algunos elementos 2D para que el juego pueda proporcionar comentarios sobre el estado del juego al jugador. Esto se puede lograr agregando opciones de menú sencillas y componentes de pantalla de aviso sobre la salida de la canalización de gráficos 3D.

Nota:

Si no has descargado el código de juego más reciente para este ejemplo, ve al juego de ejemplo de Direct3D. Este ejemplo forma parte de una gran colección de ejemplos de características de UWP. Para obtener instrucciones sobre cómo descargar el ejemplo, vea Aplicaciones de ejemplo para el desarrollo de Windows.

Objetivo

Con Direct2D, agregue una serie de gráficos y comportamientos de interfaz de usuario a nuestro juego de DirectX para UWP, entre los que se incluyen:

La superposición de la interfaz de usuario

Aunque hay muchas maneras de mostrar elementos de texto y de interfaz de usuario en un juego de DirectX, nos centraremos en usar Direct2D. También usaremos DirectWrite para los elementos de texto.

Direct2D es un conjunto de API de dibujo 2D que se usan para dibujar primitivos y efectos basados en píxeles. Al empezar con Direct2D, es mejor mantener las cosas sencillas. Los diseños complejos y los comportamientos de interfaz necesitan tiempo y planificación. Si el juego requiere una interfaz de usuario compleja, como las que se encuentran en los juegos de simulación y estrategia, considere la posibilidad de usar XAML en su lugar.

Nota:

Para obtener información sobre cómo desarrollar una interfaz de usuario con XAML en un juego de DirectX para UWP, consulte Ampliar el juego de ejemplo.

Direct2D no está diseñado específicamente para interfaces de usuario o diseños como HTML y XAML. No proporciona componentes de interfaz de usuario como listas, cuadros o botones. Tampoco proporciona componentes de diseño como divs, tablas o cuadrículas.

Para este juego de ejemplo tenemos dos componentes principales de la interfaz de usuario.

  1. Una pantalla de aviso para los controles de puntuación y del juego.
  2. Una superposición que se usa para mostrar texto y opciones de estado del juego, como la información de pausa y las opciones de inicio de nivel.

Uso de Direct2D para una pantalla de aviso

En la imagen siguiente se muestra la pantalla de aviso del juego para el ejemplo. Es sencillo y ordenado, lo que permite al jugador centrarse en navegar por el mundo 3D y disparar objetivos. Una buena interfaz o pantalla de aviso nunca debe complicar la capacidad del jugador de procesar y reaccionar a los eventos del juego.

una captura de pantalla de la superposición del juego

La superposición consta de los siguientes primitivos básicos.

  • Texto de DirectWrite en la esquina superior derecha que informa al jugador de
    • Aciertos correctos
    • Número de disparos realizados por el jugador
    • Tiempo restante en el nivel
    • Número de nivel actual
  • Dos segmentos de línea que se cruzan y se usan para formar un punto de mira
  • Dos rectángulos en las esquinas inferiores para los límites del controlador de movimiento.

El estado de la pantalla de aviso en el juego se dibuja en el método GameHud::Render de la clase GameHud. Dentro de este método, la superposición de Direct2D que representa nuestra interfaz de usuario se actualiza para reflejar los cambios en el número de aciertos, el tiempo restante y el número de nivel.

Si el juego se ha inicializado, agregamos TotalHits(), TotalShots() y TimeRemaining() a un búfer swprintf_s y especificamos el formato de impresión. A continuación, podemos dibujarlo mediante el método DrawText. Hacemos lo mismo para el indicador del nivel actual, dibujando números vacíos como ➀ para mostrar niveles no completados y números rellenados como ➊ para mostrar que se completó el nivel específico.

El siguiente fragmento de código recorre el proceso del 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
            ...
        }
    }
}

Al dividir aún más el método, esta parte del método GameHud::Render dibuja nuestros rectángulos de movimiento y de disparo con ID2D1RenderTarget::DrawRectangle y puntos de mira con dos llamadas a 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
        );
}

En el método GameHud::Render almacenamos el tamaño lógico de la ventana del juego en la variable windowBounds. Aquí se usa el método GetLogicalSize de la clase DeviceResources.

auto windowBounds = m_deviceResources->GetLogicalSize();

Obtener el tamaño de la ventana del juego es esencial para la programación de la interfaz de usuario. El tamaño de la ventana se proporciona en una medida denominada DIP (píxeles independientes del dispositivo), donde una DIP se define como 1/96 de pulgada. Direct2D escala las unidades de dibujo a píxeles reales cuando se realiza el dibujo, mediante la configuración de puntos por pulgada (PPP) de Windows. De forma similar, al dibujar texto mediante DirectWrite, se especifican DIP en lugar de puntos para el tamaño de la fuente. Los DIP se expresan como números de punto flotante. 

Mostrar información del estado del juego

Además de la pantalla de avisos, el juego de muestra tiene una superposición que representa seis estados de juego. Todos los estados presentan una primitiva de rectángulo negro grande con texto que el jugador debe leer. Los rectángulos del controlador de movimiento y los puntos de mira no se dibujan porque no están activos en estos estados.

La superposición se crea mediante la clase GameInfoOverlay, lo que nos permite cambiar el texto que se muestra para alinearlo con el estado del juego.

estado y acción de superposición

La superposición se divide en dos secciones: Estado y Acción. La sección Estado se divide además en los rectángulos Título y Cuerpo. La sección Acción solo tiene un rectángulo. Cada rectángulo tiene un propósito diferente.

  • titleRectangle contiene el texto del título.
  • bodyRectangle contiene el texto del cuerpo.
  • actionRectangle contiene el texto que informa al jugador de que debe realizar una acción específica.

El juego tiene seis estados que se pueden establecer. El estado del juego comunicado mediante la parte Estado de la superposición. Los rectángulos Estado se actualizan mediante una serie de métodos correspondientes a los siguientes estados.

  • Carga
  • Estadísticas iniciales de puntuación de inicio/alta
  • Inicio de nivel
  • Juego en pausa
  • Juego terminado
  • Juego ganado

La parte Acción de la superposición se actualiza mediante el método GameInfoOverlay::SetAction, lo que permite establecer el texto de la acción en uno de los siguientes elementos.

  • "Toca para volver a jugar..."
  • "Cargando nivel, espera..."
  • "Toca para continuar..."
  • Ninguno

Nota:

Ambos métodos se analizarán más adelante en la sección Representación del estado del juego.

Dependiendo de lo que sucede en el juego, se ajustan los campos de texto Estado y Acción. Veamos cómo inicializamos y dibujamos la superposición para estos seis estados.

Inicialización y dibujo de la superposición

Los seis estados de Estado tienen algunas cosas en común, por lo que los recursos y métodos que necesitan son muy similares. - Todos usan un rectángulo negro en el centro de la pantalla como fondo. - El texto mostrado es el texto de Título o Cuerpo. - El texto usa la fuente Segoe UI y se dibuja en la parte superior del rectángulo posterior.

El juego de ejemplo tiene cuatro métodos que entran en juego al crear la superposición.

GameInfoOverlay::GameInfoOverlay

El constructor GameInfoOverlay::GameInfoOverlay inicializa la superposición, manteniendo la superficie de mapa de bits que usaremos para mostrar información al jugador. El constructor obtiene un generador del objeto ID2D1Device que se le pasa, que usa para crear un ID2D1DeviceContext en el que el propio objeto de superposición puede dibujar. IDWriteFactory::CreateTextFormat

GameInfoOverlay::CreateDeviceDependentResources

GameInfoOverlay::CreateDeviceDependentResources es nuestro método para crear pinceles que se usarán para dibujar el texto. Para ello, obtenemos un objeto ID2D1DeviceContext2 que permite la creación y dibujo de geometría, además de funcionalidades como la representación de la malla de degradado y entrada de lápiz. A continuación, creamos una serie de pinceles coloreados mediante ID2D1SolidColorBrush para dibujar los siguientes elementos de la interfaz de usuario.

  • Pincel negro para fondos de rectángulo
  • Pincel blanco para texto de estado
  • Pincel naranja para texto de acción

DeviceResources::SetDpi

El método DeviceResources::SetDpi establece los puntos por pulgada de la ventana. Se llama a este método cuando se cambian los PPP y se debe reajustar, lo que sucede cuando se cambia el tamaño de la ventana del juego. Después de actualizar los PPP, este método también llama aDeviceResources::CreateWindowSizeDependentResources para asegurarse de que los recursos necesarios se vuelven a crear cada vez que se cambia el tamaño de la ventana.

GameInfoOverlay::CreateWindowsSizeDependentResources

El método GameInfoOverlay::CreateWindowsSizeDependentResources es donde tiene lugar todo el dibujo. A continuación se muestra un resumen de los pasos del método.

  • Se crean tres rectángulos para seccionar el texto de la interfaz de usuario del texto Título, Cuerpo y Acción.

    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
        );
    
  • Se crea un mapa de bits denominado m_levelBitmap, teniendo en cuenta los PPP actuales mediante CreateBitmap.

  • m_levelBitmap se establece como nuestro destino de representación 2D mediante ID2D1DeviceContext::SetTarget.

  • El mapa de bits se borra con cada píxel hecho en negro mediante ID2D1RenderTarget::Clear.

  • Se llama a ID2D1RenderTarget::BeginDraw para iniciar el dibujo.

  • Se llama a DrawText para dibujar el texto almacenado en m_titleString, m_bodyStringy m_actionString en el rectángulo adecuado mediante el ID2D1SolidColorBrush correspondiente.

  • Se llama a ID2D1RenderTarget::EndDraw para detener todas las operaciones de dibujo en m_levelBitmap.

  • Se crea otro mapa de bits mediante CreateBitmap denominado m_tooSmallBitmap para usarlo como reserva, que se mostrará solo si la configuración de pantalla es demasiado pequeña para el juego.

  • Repita el proceso para dibujar en m_levelBitmap para m_tooSmallBitmap, esta vez dibujando solo la cadena Paused en el cuerpo.

Ahora lo único que necesitamos son seis métodos para rellenar el texto de nuestros seis estados de superposición.

Representar el estado del juego

Cada uno de los seis estados de superposición del juego tiene un método correspondiente en el objeto GameInfoOverlay. Estos métodos dibujan una variación de la superposición para comunicar información explícita al jugador sobre el propio juego. Esta comunicación se representa con una cadena Título y Cuerpo. Dado que el ejemplo ya configuró los recursos y el diseño de esta información cuando se inicializó y con el método GameInfoOverlay::CreateDeviceDependentResources, solo necesita proporcionar las cadenas específicas del estado de superposición.

La parte Estado de la superposición se establece con una llamada a uno de los métodos siguientes.

Estado del juego Método de estado establecido Campos de estado
Carga GameInfoOverlay::SetGameLoading Título
Cargando recursos
Cuerpo
imprime incrementalmente "." para implicar la actividad de carga.
Estadísticas iniciales de puntuación de inicio/alta GameInfoOverlay::SetGameStats Título
Niveles de cuerpo
de puntuación
alta completados #
Total points #
Total Shots #
Inicio de nivel GameInfoOverlay::SetLevelStart Título
Nivel #
Cuerpo
Descripción del objetivo del nivel.
Juego en pausa GameInfoOverlay::SetPause Título
Juego en pausa
Cuerpo
Ninguno
Juego terminado GameInfoOverlay::SetGameOver Título
juego sobre
niveles de cuerpo
completados #
Puntos totales #
Total disparos #
Niveles completados #
Puntuación alta #
Juego ganado GameInfoOverlay::SetGameOver ¡Título
que GANÓ!
Niveles de cuerpo
completados #
Puntos totales #
Total capturas #
Niveles completados #
Puntuación alta #

Con el método GameInfoOverlay::CreateWindowSizeDependentResources, el ejemplo declaró tres áreas rectangulares que corresponden a regiones específicas de la superposición.

Teniendo en cuenta estas áreas, echemos un vistazo a uno de los métodos específicos del estado, GameInfoOverlay::SetGameStats y veamos cómo se dibuja la superposición.

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

Con el contexto del dispositivo Direct2D que inicializó el objeto GameInfoOverlay, este método rellena los rectángulos de título y cuerpo con negro mediante el pincel de fondo. Dibuja el texto de la cadena "Puntuación alta" en el rectángulo de título y una cadena que contiene la información actualizada de estado del juego en el rectángulo del cuerpo mediante el pincel de texto blanco.

El rectángulo de acción se actualiza mediante una llamada posterior a GameInfoOverlay::SetAction desde un método en el objeto GameMain, que proporciona la información de estado del juego que necesita GameInfoOverlay::SetAction para determinar el mensaje correcto para el jugador, como "Toca para continuar".

La superposición para cualquier estado determinado se elige en el método GameMain::SetGameInfoOverlay de la siguiente manera:

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

Ahora el juego tiene una manera de comunicar información de texto al jugador en función del estado del juego, y tenemos una manera de cambiar lo que se le muestra durante todo el juego.

Pasos siguientes

En el tema siguiente, Agregar controles, veremos cómo interactúa el jugador con el juego de ejemplo y cómo las entradas cambian el estado del juego.