Definir a estrutura do aplicativo UWP do jogo

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.

A primeira etapa na codificação de um jogo da Plataforma Universal do Windows (UWP) é criar a estrutura que permite que o objeto de aplicativo interaja com o Windows, incluindo recursos do Windows Runtime, como tratamento de eventos de suspensão-retomada, alterações na visibilidade da janela e ajuste.

Objetivos

  • Configure a estrutura para um jogo DirectX da Plataforma Universal do Windows (UWP) e implemente o computador de estado que define o fluxo geral do jogo.

Observação

Para acompanhar este tópico, examine o código-fonte do jogo de exemplo Simple3DGameDX que você baixou.

Introdução

No tópico Configurar o projeto do jogo, introduzimos a função wWinMain, bem como as interfaces IFrameworkViewSource e IFrameworkView. Aprendemos que a classe App (que você pode ver definida no arquivo de código-fonte App.cpp no projeto Simple3DGameDX) serve como fábrica de provedor de exibição e provedor de exibição.

Este tópico começa a partir daí e entra em muito mais detalhes sobre como a classe App em um jogo deve implementar os métodos de IFrameworkView.

O método App::Initialize

Após a inicialização do aplicativo, o primeiro método que o Windows chama é a implementação de IFrameworkView::Initialize.

Sua implementação deve lidar com os comportamentos mais fundamentais de um jogo da UWP, como garantir que o jogo possa lidar com um evento de suspensão (e uma possível retomada posterior) assinando esses eventos. Também temos acesso ao dispositivo do adaptador de exibição aqui, para que possamos criar recursos gráficos que dependem do dispositivo.

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

Evite ponteiros brutos sempre que possível (e é quase sempre possível).

  • Para tipos do Windows Runtime, muitas vezes você pode evitar ponteiros completamente e apenas construir um valor na pilha. Se você precisar de um ponteiro, use winrt::com_ptr (veremos um exemplo disso em breve).
  • Para ponteiros exclusivos, use std::unique_ptr e std::make_unique.
  • Para ponteiros compartilhados, use std::shared_ptr e std::make_shared.

O método App::SetWindow

Depois de Initialize, o Windows chama nossa implementação de IFrameworkView::SetWindow, passando um objeto CoreWindow que representa a janela principal do jogo.

Em App::SetWindow, assinamos eventos relacionados à janela e configuramos alguns comportamentos de janela e exibição. Por exemplo, criamos um ponteiro do mouse (por meio da classe CoreCursor), que pode ser usado por controles de mouse e toque. Também passamos o objeto de janela para nosso objeto de recursos dependentes do dispositivo.

Falaremos mais sobre como lidar com eventos no tópico de gerenciamento de fluxo de jogos.

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

O método App::Load

Agora que a janela principal está definida, nossa implementação de IFrameworkView::Load é chamada. Load é melhor para pré-buscar dados ou ativos do jogo do que Initialize e SetWindow.

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

Como você pode ver, o trabalho real é delegado ao construtor do objeto GameMain que fazemos aqui. A classe GameMain é definida em GameMain.h e GameMain.cpp.

O construtor GameMain::GameMain

O construtor GameMain (e as outras funções de membro que ele chama) inicia um conjunto de operações de carregamento assíncronas para criar os objetos do jogo, carregar recursos gráficos e inicializar a máquina de estado do jogo. Também fazemos os preparativos necessários antes do início do jogo, como definir quaisquer estados iniciais ou valores globais.

O Windows impõe um limite sobre o tempo que seu jogo pode levar para começar a processar a entrada. Portanto, usar assíncrono, como fazemos aqui, significa que Load pode retornar rapidamente, enquanto o trabalho que ele começou continua em segundo plano. Se o carregamento levar muito tempo ou se houver muitos recursos, fornecer aos usuários uma barra de progresso atualizada com frequência é uma boa ideia.

Se você não estiver familiarizado com a programação assíncrona, confira Simultaneidade e operações assíncronas com 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.
    ...
}

Aqui está uma estrutura de tópicos da sequência de trabalho iniciada pelo construtor.

  • Crie e inicialize um objeto do tipo GameRenderer. Para obter mais informações, confira Estrutura de renderização I: introdução à renderização.
  • Crie e inicialize um objeto do tipo Simple3DGame. Para obter mais informações, confira Definir o objeto principal do jogo.
  • Crie o objeto de controle de interface do usuário do jogo e exiba a sobreposição de informações do jogo para mostrar uma barra de progresso à medida que os arquivos de recurso são carregados. Para obter mais informações, confira Adicionar uma interface do usuário.
  • Crie um objeto de controlador para ler a entrada do controle (toque, mouse ou controlador de jogo). Para obter mais informações, confira Adicionar controles.
  • Defina duas áreas retangulares nos cantos inferior esquerdo e inferior direito da tela para os controles de movimento e toque da câmera, respectivamente. O player usa o retângulo inferior esquerdo (definido na chamada para SetMoveRect) como um painel de controle virtual para mover a câmera para frente e para trás e de um lado para o outro. O retângulo inferior direito (definido pelo método SetFireRect) é usado como um botão virtual para disparar a munição.
  • Use corrotinas para interromper o carregamento de recursos em estágios separados. O acesso ao contexto do dispositivo Direct3D é restrito ao thread no qual o contexto do dispositivo foi criado; enquanto o acesso ao dispositivo Direct3D para criação de objeto é livre de threads. Consequentemente, a corrotina GameRenderer::CreateGameDeviceResourcesAsync pode ser executada em um thread separado da tarefa de conclusão (GameRenderer::FinalizeCreateGameDeviceResources), que é executada no thread original.
  • Usamos um padrão semelhante para carregar recursos de nível com Simple3DGame::LoadLevelAsync e Simple3DGame::FinalizeLoadLevel.

Veremos mais de GameMain::InitializeGameState no próximo tópico (gerenciamento de fluxo de jogos).

O método App::OnActivated

Em seguida, o evento CoreApplicationView::Activated é gerado. Portanto, qualquer manipulador de eventos OnActivated que você tenha (como nosso método App::OnActivated) é chamado.

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

O único trabalho que fazemos aqui é ativar o CoreWindow principal. Como alternativa, você pode optar por fazer isso em App::SetWindow.

O método App::Run

Initialize, SetWindow e Load definiram a preparação. Agora que o jogo está em execução, nossa implementação de IFrameworkView::Run é chamada.

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

Novamente, o trabalho é delegado ao GameMain.

O método GameMain::Run

GameMain::Run é o loop principal do jogo; você pode encontrá-lo em GameMain.cpp. A lógica básica é que, enquanto a janela do jogo permanecer aberta, envie todos os eventos, atualize o temporizador e, em seguida, renderize e apresente os resultados do pipeline de gráficos. Também aqui, os eventos usados para fazer a transição entre os estados do jogo são expedidos e processados.

O código aqui também está preocupado com dois dos estados no computador de estado do mecanismo de jogo.

  • UpdateEngineState::Deactivated. Isso especifica que a janela do jogo está desativada (perdeu o foco) ou está ajustada.
  • UpdateEngineState::TooSmall. Isso especifica que a área do cliente é muito pequena para renderizar o jogo.

Em qualquer um desses estados, o jogo suspende o processamento de eventos e aguarda que a janela seja ativada, desmarcada ou redimensionada.

Enquanto a janela do jogo está visível (Window.Visible é true), você deve lidar com todos os eventos na fila de mensagens à medida que ela chega e, portanto, você deve chamar CoreWindowDispatch.ProcessEvents com a opção ProcessAllIfPresent. Outras opções podem causar atrasos no processamento de eventos de mensagens, o que pode fazer com que seu jogo se sinta sem resposta ou resultar em comportamentos de toque que parecem lentos.

Quando o jogo não está visível (Window.Visible é false) ou quando ele é suspenso ou é muito pequeno (ajustado), você não quer que ele consuma nenhum recurso em ciclos para enviar mensagens que nunca chegarão. Nesse caso, seu jogo deve usar a opção ProcessOneAndAllPending. Essa opção bloqueia até obter um evento e, em seguida, processa esse evento (assim como todos os outros que chegam na fila do processo durante o processamento do primeiro). CoreWindowDispatch.ProcessEvents então retorna imediatamente após o processamento da fila.

No código de exemplo mostrado abaixo, o membro de dados m_visible representa a visibilidade da janela. Quando o jogo é suspenso, sua janela não fica visível. Quando a janela está visível, o valor de m_updateState (uma enumeração UpdateEngineState) determina ainda mais se a janela está ou não desativada (foco perdido), muito pequena (encaixada) ou no tamanho certo.

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

O método App::Uninitialize

Quando o jogo termina, nossa implementação de IFrameworkView::Uninitialize é chamada. Essa é a nossa oportunidade de executar a limpeza. Fechar a janela do aplicativo não encerra o processo do aplicativo; mas, em vez disso, grava o estado do singleton do aplicativo na memória. Se algo especial precisar acontecer quando o sistema recuperar essa memória, incluindo qualquer limpeza especial de recursos, coloque o código para essa limpeza em Uninitialize.

Em nosso caso, App::Uninitialize é uma operação vazia.

void Uninitialize()
{
}

Dicas

Ao desenvolver seu próprio jogo, crie seu código de inicialização considerando os métodos descritos neste tópico. Aqui está uma lista simples de sugestões básicas para cada método.

  • Use Initialize para alocar suas classes principais e conectar os manipuladores de eventos básicos.
  • Use SetWindow para assinar eventos específicos de janela e passar sua janela principal para o objeto de recursos dependentes do dispositivo para que ele possa usar essa janela ao criar uma cadeia de troca.
  • Use Load para lidar com qualquer configuração restante e para iniciar a criação assíncrona de objetos e o carregamento de recursos. Se você precisar criar arquivos ou dados temporários, como ativos gerados processualmente, faça isso aqui também.

Próximas etapas

Este tópico abordou algumas das estruturas básicas de um jogo da UWP que usa DirectX. É uma boa ideia ter esses métodos em mente, pois nos referiremos a alguns deles em tópicos posteriores.

No próximo tópico, Gerenciamento de fluxo de jogos, vamos dar uma olhada detalhada em como gerenciar estados de jogo e manipulação de eventos para manter o jogo fluindo.