Definire il framework dell'app UWP di un gioco

Nota

Questo argomento fa parte della serie di esercitazioni Creare un semplice gioco UWP (Universal Windows Platform) con DirectX. L'argomento in tale collegamento imposta il contesto per la serie.

Il primo passaggio per la codifica di un gioco UWP è la realizzazione del framework che consente all'oggetto dell'app di interagire con Windows, che comprende funzionalità di Windows Runtime, quali gestione degli eventi di sospensione-ripresa, modifiche nella visibilità della finestra e allineamento automatico (snapping).

Obiettivi

  • Configurare il framework per un gioco UWP DirectX e implementare la macchina a stati che definisce il flusso generale del gioco.

Nota

Per seguire questo argomento, cercare nel codice sorgente il gioco di esempio Simple3DGameDX scaricato.

Introduzione

Nell'argomento Configurare il progetto di gioco abbiamo introdotto la funzione wWinMain e le interfacce IFrameworkViewSource e IFrameworkView. Abbiamo appreso che la classe App (che è possibile vedere definita nel file di codice sorgente App.cpp nel progetto Simple3DGameDX) funge sia da factory del provider di viste che da provider di viste.

Questo argomento riprende da dove era stato interrotto e illustra in modo più dettagliato il modo in cui la classe App in un gioco deve implementare i metodi di IFrameworkView.

Il metodo App::Initialize

All'avvio dell'applicazione, il primo metodo che Windows chiama è la nostra implementazione di IFrameworkView::Initialize.

L'implementazione deve gestire i comportamenti maggiormente fondamentali di un gioco UWP, ad esempio accertandosi che il gioco possa gestire un evento di sospensione (e una possibile ripresa successiva) sottoscrivendo tali eventi. Abbiamo anche accesso al dispositivo adattatore di visualizzazione qui, in modo da poter creare risorse grafiche che dipendono dal 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>();
}

Evitare i puntatori non elaborati quando possibile (ed è quasi sempre possibile).

  • Per i tipi di Windows Runtime, è molto spesso possibile evitare completamente i puntatori e costruire semplicemente un valore nello stack. Se un puntatore è necessario, utilizzare winrt::com_ptr (vedremo presto un esempio).
  • Per i puntatori univoci, utilizzare std::unique_ptr e std::make_unique.
  • Per i puntatori condivisi, utilizzare std::shared_ptr e std::make_shared.

Il metodo App::SetWindow

Dopo Initialize, Windows chiama la nostra implementazione di IFrameworkView::SetWindow trasferendo un oggetto CoreWindow che rappresenta la finestra principale del gioco.

In App::SetWindow, sottoscriviamo gli eventi correlati alla finestra e configuriamo alcuni comportamenti di visualizzazione e della finestra. Ad esempio, costruiamo un puntatore del mouse (tramite la classe CoreCursor), che può essere utilizzato dai controlli del mouse e touch. Trasferiamo anche l'oggetto finestra all'oggetto risorse dipendente dal nostro dispositivo.

Illustreremo altre informazioni sulla gestione degli eventi nell'argomento Gestione del flusso di gioco.

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

Il metodo App::Load

Ora che la finestra principale è impostata, viene chiamata la nostra implementazione di IFrameworkView::Load. Load è una posizione migliore per pre-recuperare i dati o gli asset del gioco rispetto a Initialize e SetWindow.

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

Come è possibile notare, il lavoro effettivo viene delegato al costruttore dell'oggetto GameMain che realizziamo qui. La classe GameMain è definita in GameMain.h e GameMain.cpp.

Il costruttore GameMain::GameMain

Il costruttore GameMain (e le altre funzioni membro chiamate) avvia un serie di operazioni di caricamento asincrone per creare gli oggetti del gioco, caricare le risorse grafiche e inizializzare la macchina a stati del gioco. Facciamo anche tutte le operazioni di preparazione necessarie prima dell'inizio del gioco, ad esempio l'impostazione di qualsiasi stato o valore globale iniziale.

Windows impone un limite al tempo che il gioco può richiedere prima di iniziare l'elaborazione dell'input. Quindi, utilizzando async, come facciamo qui, significa che Load può tornare rapidamente mentre il lavoro che ha iniziato continua in background. Se il caricamento richiede molto tempo o se sono presenti molte risorse, fornire agli utenti una barra di progressione aggiornata di frequente è una buona idea.

Se non si ha familiarità con la programmazione asincrona, vedere Concorrenza e operazioni asincrone con 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.
    ...
}

Ecco una descrizione della sequenza di lavoro avviata dal costruttore.

  • Creare e inizializzare un oggetto di tipo GameRenderer. Per maggiori informazioni, vedere Framework di rendering I: Introduzione al rendering.
  • Creare e inizializzare un oggetto di tipo Simple3DGame . Per maggiori informazioni, vedere Definire l'oggetto principale del gioco.
  • Creare l'oggetto controllo dell'interfaccia utente del gioco e visualizzare la sovrimpressione delle informazioni sul gioco per visualizzare una barra di progressione durante il caricamento dei file di risorse. Per maggiori informazioni, vedere Aggiunta di un'interfaccia utente.
  • Creare un oggetto controller per leggere l'input dal controller (touch, mouse o controller di gioco). Per maggiori informazioni, vedere Aggiunta di controlli.
  • Definire due aree rettangolari negli angoli inferiore sinistro e inferiore destro dello schermo rispettivamente per i controlli di movimento e touch della telecamera. Il giocatore utilizza il rettangolo in basso a sinistra (definito nella chiamata a SetMoveRect) come un pad di controllo virtuale per spostare la telecamera avanti e indietro e da un lato all'altro. Il rettangolo inferiore destro (definito dal metodo SetFireRect) viene utilizzato come pulsante virtuale per generare le munizioni.
  • Utilizzare coroutine per interrompere il caricamento delle risorse in stadi separati. L'accesso al contesto del dispositivo Direct3D è limitato al thread in cui è stato creato il contesto del dispositivo; mentre l'accesso al dispositivo Direct3D per la creazione di oggetti è a thread libero. Di conseguenza, la coroutine GameRenderer::CreateGameDeviceResourcesAsync può essere eseguita su un thread separato dall'attività di completamento (GameRenderer::FinalizeCreateGameDeviceResources), eseguita sul thread originale.
  • Utilizziamo un modello simile per caricare risorse di livello con Simple3DGame::LoadLevelAsync e Simple3DGame::FinalizeLoadLevel.

Nell'argomento successivo (Gestione del flusso di gioco) vedremo maggiori informazioni su GameMain::InitializeGameState.

Il metodo App::OnActivated

Viene quindi generato l'evento CoreApplicationView::Activated. Viene quindi chiamato un qualsiasi gestore di eventi OnActivated (quale il nostro metodo App::OnActivated).

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

L'unico lavoro che facciamo qui consiste nell'attivare la CoreWindow principale. In alternativa, è possibile scegliere di eseguire questa operazione in App::SetWindow.

Il metodo App::Run

Initialize, SetWindow e Load hanno impostato lo stadio. Ora che il gioco è attivo e in esecuzione, viene chiamata la nostra implementazione di IFrameworkView::Run.

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

Anche in questo caso, il lavoro viene delegato a GameMain.

Il metodo GameMain::Run

GameMain::Run è il ciclo principale del gioco; è possibile trovarlo in GameMain.cpp. La logica di base, mentre la finestra per il gioco rimane aperta, è quella di inviare tutti gli eventi, aggiornare il timer e quindi eseguire il rendering e presentare i risultati della pipeline grafica. Anche qui, gli eventi utilizzati per la transizione tra gli stati del gioco vengono inviati ed elaborati.

Il codice qui riguarda anche due degli stati nella macchina a stati del motore di gioco.

  • UpdateEngineState::Deactivated. Specifica che la finestra del gioco viene disattivata (ha perso lo stato attivo) o viene ritagliata.
  • UpdateEngineState::TooSmall. Specifica che l'area client è troppo piccola per eseguire il rendering del gioco.

In uno di questi stati, il gioco sospende l'elaborazione degli eventi e attende che la finestra venga attivata, annullata o ridimensionata.

Mentre la finestra del gioco è visibile (Window.Visible è true), è necessario gestire ogni evento nella coda di messaggi nel momento in cui arriva, e quindi è necessario chiamare CoreWindowDispatch.ProcessEvents con l'opzione ProcessAllIfPresent. Altre opzioni possono causare ritardi nell'elaborazione degli eventi dei messaggi, che possono rendere il gioco non responsivo o causare comportamenti touch che appaiono lenti.

Quando il gioco non è visibile (Window.Visible è false) o quando è sospeso o quando è troppo piccolo (è ritagliato), non si desidera che le risorse vengano consumate provando a inviare messaggi che non arriveranno mai. In questo caso, il gioco deve utilizzare l'opzione ProcessOneAndAllPending. Questa opzione si blocca fino a quando non ottiene un evento e quindi elabora tale evento (nonché qualsiasi altro che arriva nella coda del processo durante l'elaborazione del primo). CoreWindowDispatch.ProcessEvents quindi restituisce immediatamente dopo l'elaborazione della coda.

Nel codice di esempio illustrato di seguito, il membro dati m_visible rappresenta la visibilità della finestra. Quando il gioco viene sospeso, la sua finestra non è visibile. Quando la finestra è visibile, il valore di m_updateState (un'enumerazione UpdateEngineState) determina ulteriormente se la finestra viene o meno disattivata (messa a fuoco persa), è troppo piccola (ritagliata) o è delle dimensioni corrette.

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

Il metodo App::Uninitialize

Al termine del gioco, viene chiamata la nostra implementazione di IFrameworkView::Uninitialize. Questa è la nostra opportunità per eseguire la pulizia. La chiusura della finestra dell'app non termina il processo dell'app; ma scrive in memoria lo stato del singleton dell'app. Se qualcosa di speciale deve verificarsi quando il sistema recupera questa memoria, inclusa una pulizia speciale delle risorse, inserire il codice per tale pulizia in Uninitialize.

Nel nostro caso, App::Uninitialize è una no-op.

void Uninitialize()
{
}

Suggerimenti

Quando si sviluppa un gioco personalizzato, progettare il codice di avvio in base ai metodi descritti in questo argomento. Ecco un semplice elenco di suggerimenti di base per ciascun metodo.

  • UtilizzareInitialize per allocare le classi principali e collegare i gestori di eventi di base.
  • UtilizzareSetWindow per sottoscrivere qualsiasi evento specifico della finestra e passare la finestra principale all'oggetto risorse dipendenti dal dispositivo in modo che possa utilizzare tale finestra durante la creazione di una catena di scambio.
  • UtilizzareLoad per gestire qualsiasi configurazione rimanente e per iniziare la creazione asincrona di oggetti e il caricamento di risorse. Se è necessario creare file o dati temporanei, ad esempio asset generati in modo procedurale, eseguire questa operazione anche qui.

Passaggi successivi

Questo argomento ha trattato alcune delle strutture di base di un gioco UWP che utilizza DirectX. È consigliabile tenere a mente questi metodi, perché si farà riferimento ad alcuni di essi negli argomenti successivi.

Nell'argomento successivo, la Gestione del flusso di gioco, esamineremo in modo approfondito come gestire gli stati del gioco e la gestione degli eventi per mantenere il flusso del gioco.