NcJump: a jumping project

0

Nell’ultimo mese ho cominciato a lavorare a ncJump, un progetto fatto con l’nCine, che come molti di voi sapranno è un framework open-source scritto in C++ dal nostro @encelo.

Qualche tempo fa, dei membri della community di GameLoop hanno seguito il corso CS50’s introduction to game development, e ho pensato che la sequenza di giochi presentata potesse darmi un buon suggerimento su che tipo di gioco creare.

Un paio di sviluppatori hanno già fatto o hanno cominciato a lavorare ai primi giochi della lista di quel corso. C’è un clone di Pong scritto da @encelo, un clone di Flappy Bird scritto da @Vasile, e un clone di Breakout in sviluppo da @mat3. Quindi ho optato per un clone di Super Mario Bros.

L’idea sarebbe di seguire i passi della lezione 4 del CS50 usando l’nCine invece di Love2D.

Per cominciare a sviluppare un gioco con l’nCine è necessario effettuare alcuni passi che fortunatamente sono documentati nella pagina di download dell’nCine. Quindi ho installato l’nCine, clonato il progetto ncTemplate, e ho cominciato a smanettare sul codice di quest’ultimo.

Un po’ di codice

La cosa principale da considerare dal punto di vista del design del software è che ho rinominato MyEventHandler in JumpHandler e ho introdotto la classe Game. Mentre JumpHandler svolge un ruolo da ponte tra il gioco e l’engine, Game è il nucleo del progetto ed è responsabile del ciclo di vita dei suoi oggetti e della logica dell’applicazione.

Poi ho proseguito con l’implementazione di una classe Entity seguendo l’idea di un Entity component system. L’entità può avere componenti come un’immagine, una macchina a stati, o un corpo per la fisica e le collisioni.

Un’interfaccia grafica è fondamentale per iterare velocemente su nuovi elementi man mano che vengono aggiunti al gioco, sia per avere un feedback visivo quando serve fare debugging sia per cambiare ogni tipo di valore o configurazione a run-time. Per questo motivo ho introdotto una classe Editor, che fa uso massiccio di Dear ImGui di @ocornut, oltretutto direttamente fornita dall’nCine.

Box2D è la mia scelta per quanto riguarda la fisica e le collisioni 2D. È sviluppata da @erincatto ed è molto facile da usare.

Le ultime settimane le ho passate implementando degli importanti stati per l’Entity: idle, move, jump, e fall. Secondo me rappresentano le basi dell’intero gameplay e credo che il risultato sia buono abbastanza per proseguire sui prossimi passi di sviluppo, ma per quelli dovrete aspettare il prossimo devlog, quindi stay tuned!

Fahien Grazie per aver condiviso i tuoi progressi anche sul forum. Vado subito ad aggiornare la newsletter per linkare questo thread. 😉

Buon anno con un nuovo devlog sullo sviluppo di ncJump. Ho passato un po’ di tempo sul refactoring del codice della classe Entity poiché stava diventando troppo monolitica, e ho migliorato Tileset e Tilemap usando perlappunto entities invece di semplici sprite sia per i prototipi di tile sia per i tile concreti. Entriamo nei dettagli.

Everything is entity

È importante considerare che tutto è entity. I personaggi sono entità, i tile sono entità, gli eventi sono entità, e così via. Come si fanno a distinguere tra di loro? Ogni entità può avere più di una componente, che sia un’immagine o una macchina a stati o un corpo fisico. Dunque insiemi diversi di componenti determinano i vari tipi di entità che popoleranno la scena di gioco. In questo devlog esploreremo le varie componenti necessarie alla seconda iterazione dello sviluppo di ncJump.

Component optional
Transform no
Graphics yes
Physics yes
State yes

Transform

La componente transform è un semplice guscio intorno a un ncine::SceneNode. Tutto il resto è relativo a questo nodo, per cui se volessimo muovere l’intera entità, potremmo semplicemente modificare il nodo della transform. È importante notare che uso un UniquePointer per il nodo in modo che venga creato nell’area heap e non sia soggetto a move. Ciò risulta necessario dal momento che l’nCine scene graph lavora per mezzo di puntatori.

Physics

La componente physics agisce sia da abstraction layer tra il gioco e Box2D, sia da contenitore di valori come la velocità massima, la forza di salto, e così via. Più importante di tutti è il puntatore ad un b2Body che ci permette di interrogare lo stato fisico corrente dell’entità.

Graphics e State

A livello base abbiamo una componente grafica con una singola immagine o animazione, e una componente state con un singolo stato che non cambia mai. Queste sono probabilmente comuni alla maggior parte delle entità del gioco.

Ora dobbiamo considerare il concetto di un personaggio in movimento con stati come fermo, muovi, salta, cadi. Anche questo insieme di stati è molto comune per cui ha senso implementarlo direttamente in C++. Il risultato è la seguente gerarchia di classi.

Subclass state
SingleState IDLE
CharacterState IDLE, MOVE, JUMP, FALL

Ne segue che una classe CharacterState sia fortemente accoppiata con la relativa componente grafica, che definiamo come CharacterGraphics, per cui la gerarchia di classi della componente grafica diventa simile alla precedente.

Subclass graphics
SingleGraphics IDLE
CharacterGraphics IDLE, MOVE, JUMP, FALL

Come considerazione finale, mentre una componente state dovrebbe essere unica per ogni entità, una componente grafica potrebbe essere condivisa tra più entità così da evitare la duplicazione di questo tipo di oggetti che occupa molto spazio in memoria.

Eccomi con un nuovo devlog. Questa settimana ho introdotto la componente camera, e la serializzazione per il caricamento e il salvataggio su file dello stato del gioco. Mentre la prima parte è stata abbastanza facile da implementare, la seconda ha richiesto l’aggiunta di una nuova dipendenza e varie modifiche per pagare un po’ di technical debt accumulato durante le iterazioni di sviluppo precedenti.

The smooth

Questo è stato facile. La componente camera ha due attributi principali: il nodo root della scena e il nodo target. Il nodo root della scena è necessario perché dobbiamo simulare la camera, ovvero ciò che viene modificato è in realtà il nodo root della scena. In altre parole, per muovere la camera in una direzione, trasliamo il nodo root della scena verso la direzione opposta. Il nodo target è quello che la camera dovrebbe seguire. Così come il target si muove nella scena, la camera viene aggiornata per avere le stesse coordinate.

La cosa più facile da fare per implementare una camera che segue qualcosa consiste nello scrivere roba come camera.position = target.position, e sebbene funzioni non è la migliore delle soluzioni. Un approccio migliore e più elegante, che potrebbe anche prevenire casi di motion sickness, sarebbe implementare una camera che abbia un movimento fluido, morbido. Ciò può essere fatto facendo un’interpolazione lineare tra la posizione della camera e la posizione del target usando un piccolo valore ad ogni update del gioco.

void Camera::update() {
  float smooth_factor = 0.125f;
  Vec2f smoothed_position = lerp(this->position, target->position, smooth_factor);
  this->position = smoothed_position;
}

The file

D’altra parte, questa feature ha richiesto un po’ di lavoro in più. Ho iniziato aggiungendo @nlohmann/json come nuova dipendenza del progetto per poter leggere e scrivere file json. Trovo questa libreria molto facile da usare oltre al fatto che l’ho usata con successo in altri progetti, come Jamspot: Sokoban. Il secondo step ha visto l’implementazione di funzioni per salvare e caricare determinati oggetti da file.

void to_json(json& j, const T& t);
void from_json(const json& j, T& t);

La parte più laboriosa è stata assicurarsi che tutte le classi serializzabili potessero essere default constructed. Ciò significa evitare references come membri delle classi e preferire puntatori, quindi permettere la creazione di oggetti inizializzati a metà e fare in modo allo stesso tempo che tali oggetti rimangano in questo stato per il periodo più breve possibile per evitare errori e crash.

Dopo aver risolto tutto, sono stato in grado di modificare la configurazione del gioco, il tileset, e la tilemap a runtime, chiudere il gioco, e riaprirlo per trovare tutto così come avevo lasciato. Ciò risulta di fondamentale importanza per poter lavorare ai livelli del gioco, che saranno parte della prossima iterazione dello sviluppo di ncJump.

Benvenuti su questo nuovo devlog sullo sviluppo di ncJump. Precedentemente ho scritto sui miglioramenti della camera e sulla serializzazione dello stato del gioco, e ho concluso menzionando di essere in grado di cominciare a creare dei livelli veri e propri. Un po’ di bug fixing e miglioramenti generici erano necessari in vari aspetti del codice, ma sono riuscito a modellare qualcosa di interessante che, sebbene possa semprare incompleto, si tratta di una base convincente.

Salta più in alto

Dal punto di vista del gameplay, il giocatore prende confidenza con gli elementi di interattività del gioco. Dapprima si muove, poi salta, poi salta più in alto. L’implementazione è semplice grazie a Box2D. Se il pulsante di salto è ancora premuto, continua ad applicare una piccola forza diretta verso l’alto. Si tenga a mente che tale forza dovrebbe essere più debole della forza di gravità altrimenti il personaggio decollerebbe nello spazio.

void can_jump_higher(Input& input, Entity& entity) {
  if (input.jump.down) {
    auto force = b2Vec2(0.0f, small_amount);
    entity.physics->body->ApplyForceToCenter(force, true);
  }
}

Ridimensionare la tilemap

Questo è stato meno semplice. Sto cominciando a credere che maggiore qualcosa sia noiosa, maggiore sia la difficoltà dell’implementazione. Molte cose devono essere prese in considerazione. Per semplicità qui mi concentrerò solo sulla griglia dei tile concreti.

All’inizio usavo un semplice vettore per conservare i tile concreti della mappa e con una semplice formula (y + x * height) ero in grado di accedere ad una cella della griglia. Poi ho realizzato che ridimensionare questo vettore non era conveniente dal momento che l’ordine dei tile nelle celle non sarebbe stato conservato.

Come soluzione, ho cambiato la struttura da semplice vettore a vettore di vettori, ovvero una lista di colonne. Ciò ha semplificato l’indicizzazione visto che si può usare il subscript operator per accedere al tile giusto (tiles[x][y]).

Ha anche semplificato il ridimensionamento. Se si vuole cambiare la lunghezza della tilemap, basta modificare il numero di colonne invocando tiles.resize(new_width). Come si può immaginare, se la larghezza diminuisce, si perdono le colonne, mentre se la larghezza aumenta, si ottengono nuove colonne con un tile concreto di default. Non posso sorvolare sull’importanza dei valori di default. Cambiare l’altezza è leggermente differente dato che si devono ridimensionare tutte le colonne.

for (uint32_t i = 0; i < width; ++i) {
  tiles[i].resize(new_height);
}

Spero che questo devlog vi sia piaciuto. Stay tuned per il prossimo in cui tratterò di entità dinamiche!

Comments are closed.