Inizializzazione dell'app e loop di gioco semplice

Sep 27 2020

Sommario

Vengo da un background Java ma sto cercando di creare un gioco in C ++. Questo è il mio tentativo di un sistema di gestione dello stato, che dovrebbe permettermi di passare facilmente da uno "stato" all'altro (menu, gioco, tabellone, ecc.).

L'idea è che:

  • Quando il programma si avvia, creo un file Application.
  • L'applicazione contiene il ciclo di gioco, che viene eseguito fino alla chiusura del programma.
  • Ogni frame, l'applicazione aggiorna e rende lo stato corrente.
  • Stateè una classe astratta ed Rivalè una sottoclasse concreta.

Risposta

Mi piacerebbe davvero qualsiasi feedback. Nello specifico, le aree che mi preoccupano di più sono:

  • Ereditarietà: non l'ho mai usato prima in C ++. La mia comprensione è che utilizzando un unique_ptrmy state viene memorizzato nell'heap, e questo evita il problema dell'affettatura degli oggetti. I miei Statemetodi sono tutti virtuali puri e sovrascritti dalla sottoclasse. Mi manca qualcosa?
  • Proprietà: l'applicazione possiede lo stato attuale; Rival possiede lo scenario attuale. La mia comprensione è che quando l'applicazione esce (o cambia in un nuovo stato), lo stato corrente verrà distrutto / liberato. Quando Rival viene liberato, lo scenario corrente verrà successivamente liberato. Ho capito bene?
  • Heap vs Stack: capisco che l'accesso allo stack sia più veloce, ma è piuttosto piccolo e non particolarmente adatto per oggetti di lunga durata (vengono liberati quando escono dall'ambito), oggetti polimorfici o oggetti di dimensioni variabili. Per questo lo Stato e lo Scenario vivono sullo stesso mucchio, ma tutto il resto vive sullo stack. Suona bene?

NOTA: a questo punto non sono troppo preoccupato per gli aspetti tecnici del ciclo di gioco stesso (intervallo temporale fisso o variabile, durata del sonno, ecc.) - Voglio solo assicurarmi che il codice sia pulito, privo di bug / perdite di memoria e segue le migliori pratiche ove possibile. Ti sarei grato se potessi provare a includere una spiegazione insieme a qualche suggerimento, in modo che io possa imparare PERCHÉ e non solo COSA.

Codice

Ho cercato di omettere tutti i dettagli che non sono rilevanti per questo particolare meccanismo, ma il codice completo può essere trovato qui .

Main.cpp

#include "pch.h"

#include <iostream>
#include <stdexcept>

#include "Application.h"
#include "Rival.h"
#include "Scenario.h"
#include "ScenarioBuilder.h"
#include "ScenarioReader.h"
#include "Window.h"

/**
 * Entry point for the application.
 */
int main() {

    try {

        // Create our Window
        Rival::Window window(800, 600, "Rival Realms");
        window.use();

        // Create our Application
        Rival::Application app(window);

        // Load some scenario
        Rival::ScenarioReader reader(Rival::Resources::mapsDir + "example.sco");
        Rival::ScenarioBuilder scenarioBuilder(reader.readScenario());
        std::unique_ptr<Rival::Scenario> scenario = scenarioBuilder.build();

        // Create our initial state
        std::unique_ptr<Rival::State> initialState =
                std::make_unique<Rival::Rival>(app, std::move(scenario));

        // Run the game!
        app.start(std::move(initialState));

    } catch (const std::runtime_error& e) {
        std::cerr << "Unhandled error during initialization or gameplay\n";
        std::cerr << e.what() << "\n";
        return 1;
    }

    return 0;
}

Application.h

#ifndef APPLICATION_H
#define APPLICATION_H

#include <memory>

#include "Resources.h"
#include "State.h"
#include "Window.h"

namespace Rival {

    class Application {

    public:
        bool vsyncEnabled;

        Application(Window& window);

        /**
         * Runs the Application until the user exits.
         */
        void start(std::unique_ptr<State> state);

        /**
         * Exits the Application cleanly.
         */
        void exit();

        Window& getWindow();
        Resources& getResources();

    private:
        Window& window;
        Resources res;
        std::unique_ptr<State> state;
    };

}  // namespace Rival

#endif  // APPLICATION_H

Application.cpp

#include "pch.h"
#include "Application.h"

#include <SDL.h>

namespace Rival {

    bool vsyncEnabled = true;

    Application::Application(Window& window)
        : window(window) {

        // Try to enable vsync
        if (SDL_GL_SetSwapInterval(1) < 0) {
            printf("Unable to enable vsync! SDL Error: %s\n", SDL_GetError());
            vsyncEnabled = false;
        }
    }

    void Application::start(std::unique_ptr<State> initialState) {

        // Event handler
        SDL_Event e;

        state = std::move(initialState);
        bool exiting = false;
        Uint32 nextUpdateDue = SDL_GetTicks();

        // Game loop
        while (!exiting) {
            Uint32 frameStartTime = SDL_GetTicks();

            // Is the next update due?
            if (vsyncEnabled || nextUpdateDue <= frameStartTime) {

                // Handle events on the queue
                while (SDL_PollEvent(&e) != 0) {
                    if (e.type == SDL_QUIT) {
                        exiting = true;
                    } else if (e.type == SDL_KEYDOWN) {
                        state->keyDown(e.key.keysym.sym);
                    } else if (e.type == SDL_MOUSEWHEEL) {
                        state->mouseWheelMoved(e.wheel);
                    }
                }

                // Update the game logic, as many times as necessary to keep it
                // in-sync with the refresh rate.
                //
                // For example:
                //  - For a 30Hz monitor, this will run twice per render.
                //  - For a 60Hz monitor, this will run once per render.
                //  - For a 120Hz monitor, this will run every other render.
                //
                // If vsync is disabled, this should run once per render.
                while (nextUpdateDue <= frameStartTime) {
                    state->update();
                    nextUpdateDue += TimerUtils::timeStepMs;
                }

                // Render the game, once per iteration.
                // With vsync enabled, this matches the screen's refresh rate.
                // Otherwise, this matches our target FPS.
                state->render();

                // Update the window with our newly-rendered game.
                // If vsync is enabled, this will block execution until the
                // next swap interval.
                window.swapBuffers();

            } else {
                // Next update is not yet due.
                // Sleep for the shortest possible time, so as not to risk
                // overshooting!
                SDL_Delay(1);
            }
        }

        // Free resources and exit SDL
        exit();
    }

    void Application::exit() {
        SDL_Quit();
    }

    Window& Application::getWindow() {
        return window;
    }

    Resources& Application::getResources() {
        return res;
    }

}  // namespace Rival

State.h

#ifndef STATE_H
#define STATE_H

#include <SDL.h>

namespace Rival {

    // Forward declaration to avoid circular reference
    class Application;

    class State {

    public:
        /**
         * Handles keyDown events.
         */
        virtual void keyDown(const SDL_Keycode keyCode) = 0;

        /**
         * Handles mouse wheel events.
         */
        virtual void mouseWheelMoved(const SDL_MouseWheelEvent evt) = 0;

        /**
         * Updates the logic.
         *
         * It is assumed that a fixed amount of time has elapsed between calls
         * to this method, equal to TimerUtils::timeStepMs.
         */
        virtual void update() = 0;

        /**
         * Renders the current frame.
         */
        virtual void render() = 0;
    };

}  // namespace Rival

#endif  // STATE_H

Rival.h

#ifndef RIVAL_H
#define RIVAL_H

#include <SDL.h>
#include <memory>

#include "Application.h"
#include "Scenario.h"
#include "State.h"
#include "Window.h"

namespace Rival {

    class Rival : public State {

    public:
        Rival(Application& app, std::unique_ptr<Scenario> scenario);

        // Inherited from State
        void keyDown(const SDL_Keycode keyCode) override;
        void mouseWheelMoved(const SDL_MouseWheelEvent evt) override;
        void render() override;
        void update() override;

    private:
        Application& app;
        Window& window;
        Resources& res;
        std::unique_ptr<Scenario> scenario;
    };

}  // namespace Rival

#endif  // RIVAL_H

Rival.cpp

#include "pch.h"
#include "Rival.h"

namespace Rival {

    Rival::Rival(Application& app, std::unique_ptr<Scenario> scenarioToMove)
        : app(app),
          window(app.getWindow()),
          res(app.getResources()),
          scenario(std::move(scenarioToMove)) {}

    void Rival::update() {
        // ...
    }

    void Rival::render() {
        // ...
    }

    void Rival::keyDown(const SDL_Keycode keyCode) {
        // ...
    }

    void Rival::mouseWheelMoved(const SDL_MouseWheelEvent evt) {
        // ...
    }

}  // namespace Rival
```

Risposte

7 G.Sliepen Sep 27 2020 at 01:51

Risposte alle tue domande

Ereditarietà: non l'ho mai usato prima in C ++. La mia comprensione è che utilizzando un unique_ptrmy state viene memorizzato nell'heap, e questo evita il problema dell'affettatura degli oggetti. I metodi My State sono tutti puramente virtuali e sovrascritti dalla sottoclasse. Mi manca qualcosa?

L'affettamento di oggetti si verifica quando si copia una variabile di classe derivata in una variabile di classe di base. L'uso di qualsiasi tipo di puntatore impedisce la creazione di una copia. Tuttavia, probabilmente si desidera utilizzare comunque un puntatore (o riferimento), anche se non è stata eseguita la suddivisione degli oggetti.

Proprietà: l'applicazione possiede lo stato attuale; Rival possiede lo scenario attuale. La mia comprensione è che quando l'applicazione esce (o cambia in un nuovo stato), lo stato corrente verrà distrutto / liberato. Quando Rival viene liberato, lo scenario corrente verrà successivamente liberato. Ho capito bene?

Sì, non appena una classe viene distrutta, anche tutte le sue variabili membro vengono distrutte. Se una variabile membro è una std::unique_ptr, questo assicurerà che deletevenga chiamata sul puntatore.

Heap vs Stack: capisco che l'accesso allo stack sia più veloce, ma è piuttosto piccolo e non particolarmente adatto per oggetti di lunga durata (vengono liberati quando escono dall'ambito), oggetti polimorfici o oggetti di dimensioni variabili. Per questo lo Stato e lo Scenario vivono sullo stesso mucchio, ma tutto il resto vive sullo stack. Questo suona bene?

Il thread principale di un'applicazione ha in genere megabyte di spazio stack su un computer desktop, quindi non me ne preoccuperei più di tanto. Per le variabili regolari, anche se il loro tipo è quello di una classe grande, per lo più andrà bene, ma se inizi ad allocare array nello stack devi stare attento. La durata dipende dalla durata dell'oscilloscopio, ma può essere molto lunga; per esempio, le variabili allocate nello stack frame di main()funzioneranno fondamentalmente per tutto il tempo in cui vive il programma.

Per quanto riguarda l'accesso più rapido: l'unico problema con le variabili sull'heap è che sono accessibili tramite un puntatore, quindi a un certo punto il puntatore deve essere dereferenziato. Questo può o non può essere un problema per le prestazioni. Non me ne preoccuperei nelle prime fasi del tuo programma, è qualcosa di cui ti puoi preoccupare in seguito se stai facendo l'ottimizzazione delle prestazioni, e solo allora se un profiler ti dice che questo è effettivamente un problema.

Dovrebbe andare bene dichiarare una variabile Statee Scenarionello stack di main():

// Load some scenario
Rival::ScenarioReader reader(Rival::Resources::mapsDir + "example.sco");
Rival::ScenarioBuilder scenarioBuilder(reader.readScenario());
Rival::Scenario scenario = scenarioBuilder.build();

// Create our initial state
Rival::Rival initialState(scenario);

// Run the game!
app.start(initialState);

Ciò richiede che il costruttore di Rival::Rivale Application::start()prenda un semplice riferimento come argomento. Ciò significa che anche quegli oggetti non possiedono più il file scenarioe state. Ma dovrebbe andare bene, quelle variabili verranno ora distrutte quando main()esce.

Non intercettare eccezioni se non puoi fare nulla al riguardo

In main(), ne prendi qualsiasi std::runtime_error(), ma l'unica cosa che fai è stampare un errore ed uscire con un codice di uscita diverso da zero. Questo è esattamente ciò che accadrà già se non prendi le eccezioni lì, quindi è un esercizio inutile. Forse Java ti ha insegnato che devi catturarli tutti, ma non è così in C ++. Lascia che le eccezioni fatali che non puoi affrontare cadano.

A parte questo, se vuoi avere un rilevatore di eccezioni generico, dovresti std::exceptioninvece catturare , è la classe base di std::runtime_errore catturerà anche altri tipi di eccezioni.

Non tutto deve essere un file class

Ancora una volta, penso che questo provenga dal tuo background in Java, dove tutte le funzioni devono risiedere all'interno di un file class. Questo non è il caso in C ++. In particolare, class Applicationè solo qualcosa che costruisci una volta, chiami start()e poi esce e il gioco è fatto. Per una tale operazione one-shot, puoi usare solo una singola funzione. Poiché Applicationimplementa principalmente il ciclo principale della tua applicazione, creerei solo una singola funzione chiamata main_loop():

void main_loop(Window& window, State& initialState) {
    bool vsyncEnabled = SDL_GL_SetSwapInterval(1) == 0;

    if (!vsyncEnabled) {
            printf("Unable to enable vsync! SDL Error: %s\n", SDL_GetError());
    }

    SDL_Event e;
    bool exiting = false;
    Uint32 nextUpdateDue = SDL_GetTicks();

    // Game loop
    while (!exiting) {
        ...
    }
}

E poi in main():

Rival::Window window(800, 600, "Rival Realms");
...
Rival::State initialState(scenario);

// Run the game!
main_loop(window, initialState);

Hai davvero bisogno dell'eredità?

C'è un motivo per cui hai creato le classi base virtuali pure Rival::State? Se hai solo una classe derivata Rival::Rival, in realtà non fa nulla, tranne che ora devi mantenere sincronizzati i membri della classe base e della classe derivata, che è un lavoro per te, e ora l'accesso allo stato dovrà andare tramite un vtable, che potrebbe influire sulle prestazioni. Anche se pensi di averne bisogno in futuro, il principio YAGNI si applica qui: se non ne hai bisogno ora, non scriverlo.

Non chiamare SDL_Quit()troppo presto

Nel codice originale, dopo essere uscito dal ciclo principale, chiami Application::exit(), che a sua volta chiama SDL_Quit(). Tuttavia, per quanto ne so, nulla in class ApplicationSDL è mai stato inizializzato, quindi non dovrebbe neppure deinizializzarlo. In particolare, il distruttore della variabile windowin main()verrà chiamato in seguito, in modo che possa ancora fare affidamento sul fatto che SDL venga inizializzato correttamente.

Considera l'idea di spostare la gestione degli eventi nella sua funzione

Nel ciclo principale, hai switch()un'istruzione che gestisce tutti i possibili eventi SDL. Considera l'idea di spostare questa parte nella sua funzione, in modo che il ciclo principale appaia il più semplice possibile:

while (!exiting) {
    handle_events(); // or maybe state.handle_events()?
    state.update();
    state.render();
    window.swapBuffers();
}

Ciò manterrà breve il ciclo principale e fornirà una chiara panoramica di alto livello di ciò che si sta facendo per ogni fotogramma di cui si esegue il rendering.

Evita lunghe attese e ritardi arbitrari

Se vuoi aspettare che passi un po 'di tempo o che si verifichi un evento, non implementare mai un'attesa occupata, né un ciclo che chiama SDL_Delay(1). Questo sprecherà solo cicli della CPU e, sebbene l' SDL_Delay(1)istruzione utilizzerà sicuramente meno cicli, attendere solo un millisecondo probabilmente impedirà al processore di entrare in uno stato di basso consumo mentre si attende il prossimo aggiornamento. Ciò significa che avrà una temperatura più alta, che potrebbe causare il throttling termico, e per gli utenti su un dispositivo a batteria, scaricheranno le batterie più velocemente.

Se lo sai nextUpdateDue > frameStartTime, chiama SDL_Delay(nextUpdateDue - frameStartTime).

4 Rish Sep 27 2020 at 02:07

La mia comprensione è che utilizzando un unique_ptr il mio stato viene memorizzato nell'heap e questo evita il problema dell'affettatura degli oggetti

Beh ... tecnicamente no. Quando si dispone di un puntatore a una classe base che punta a una classe derivata, i metodi non virtuali vengono comunque suddivisi. Tuttavia, poiché allociamo sull'heap e passiamo un puntatore alla classe base, quell'informazione non è "persa", ma solo "nascosta".

La mia comprensione è che quando l'applicazione esce (o cambia in un nuovo stato), lo stato corrente verrà distrutto / liberato. Quando Rival viene liberato, lo scenario corrente verrà successivamente liberato. Ho capito bene?

Sì (dato che stai usando un puntatore intelligente), ma è sempre buona pratica definire un distruttore per la tua classe (anche se è solo un distruttore predefinito). Soprattutto nella classe State, è necessario definire un distruttore virtuale, altrimenti il ​​compilatore non saprà che deve chiamare anche il distruttore della classe derivata. Se il distruttore della tua classe derivata fa qualcosa di non banale, non verrà chiamato quando l'oggetto viene distrutto e questo può portare a tutti i tipi di cattiveria.

Capisco che l'accesso allo stack sia più veloce, ma è piuttosto piccolo e non particolarmente adatto per oggetti di lunga durata

Non proprio. L'allocazione sullo stack è più veloce che sull'heap perché richiede un po 'più di contabilità, ma a meno che tu non stia allocando migliaia di oggetti sull'heap per ogni frame, raramente è un problema.

Ora sul codice:

  1. Perché la chiamata a window.use()? Chiamalo semplicemente nel costruttore. Per inciso, window.useè un nome un po 'strano di ciò che sta facendo il metodo, qualcosa di simile window.initpotrebbe essere più adatto.

  2. A differenza di Java, C ++ non richiede l'utilizzo di classi per tutto. ScenarioReadere ScenarioBuilderpuò essere riscritta come funzioni a libera installazione, qualcosa di simile a questo: auto scenario_desc = readScenario(scenarioPath); auto scenario = buildScenario(scenario_desc); . Ancora meglio, puoi inserirli in uno spazio dei nomi comune (qualcosa di simile ScenarioUtilse raggrupparli.

  3. Application::exit dovrebbe essere spostato all'interno del distruttore, quindi viene chiamato automaticamente alla distruzione o se si è verificata un'eccezione.

  4. Application::getWindowdovrebbe restituire un const Window&se non si desidera che il codice chiamante possa essere modificato Window. Inoltre, assicurati di utilizzare i constmodificatori ogni volta che è possibile.