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!
Rieccomi con un nuovo devlog! Questa volta parlo di entità dinamiche e come tutto sia distruggibile.
Le cose facili
Mentre un’entità statica non è affetta da forze fisiche quindi non si muove, un’entità dinamica è affetta da forze, quindi può cadere, può essere spinta e tirata in giro. Grazie ancora a Box2D, implementare tutto questo è stata una semplice questione di impostare il tipo dei physics bodies a b2_dynamicBody
o b2_staticBody
.
Un’altra cosa importante da aggiungere era uno sfondo attendibile per dare agli occhi del giocatore qualcosa di più interessante da guardare al posto di un noioso colore. Per il momento, ho solo aggiunto un’immagine statica, ma suppongo che sarebbe bello implementare un effetto di parallax scrolling in futuro.
A questo punto, ho realizzato che mancava qualcosa. Tile ed entità potevano essere aggiunte e posizionate liberamente nella scena e, come tutte le persone normali che amano la simmetria, ho cominciato a volerle anche distruggere, non attraverso l’editor, ma come fa Mario a pugni (o a testate?)!
Appetite for Destruction
Be’, non tutte le entità dovrebbero essere distruggibili, ma alcune sicuramente potrebbero esserlo, e ho immaginato potesse essere una buona idea dare al giocatore questa possibilità.
Ho iniziato con l’introdurre una flag per determinare se una tile sia distruggibile o no. Poi ho implementato un destruction listener, sottoclasse di Box2D contact listener, responsabile di controllare gli impulsi generate da collisioni tra entità. Quanto questo impulso è abbastanza grande, entrambe le flag delle entità coinvolte nella collisione vengono controllate e, se distruggibili, vengono aggiunte ad una lista. Questa lista è processata successivamente quando la distruzione viene effettivamente invocata. È importante notare che questo non dovrebbe essere fatto nel passo precedente se si vogliono evitare errori mentre Box2D sta ancora processando il proprio update.
Dopo aver aggiustato la soglia per l’impulso che innesca le distruzioni, l’ultima cosa da fare era di aumentare manualmente l’impulse quando la collisione coinvolgeva un personaggio che colpiva dal basso verso l’alto. In parole tecniche, quando una delle entità è un personaggio e la normale della collisione è vicina a (0, -1)
viene innescata la distruzione dell’altra entità.
Il risultato è molto interessante poiché non solo le entità vengono distrutte dopo una collisione col personaggio principale, ma vengono distrutte anche quando avvengono delle importanti collisioni tra le entità stesse.
Ecco un nuovo devlog con altra roba interessante.
Push e pull
Entità dinamiche sono state aggiunge al gioco e possono essere spinte o tirate in giro per la scena. Ciò ha determinato l’aggiunta di nuovi stati al personaggio, appunto push e pull.
Il push state viene abilitato quando un personaggio prova a muoversi e trova un ostacolo o sulla sua sinistra o sulla sua destra, abbastanza semplice da implementare. Gli ostacoli che il personaggio può incontrare nelle quattro direzioni (su, destra, giù, sinistra) sono raccolti dopo l’update del physics system (Box2D) controllando le collisioni del physics body e le normali dei punti di collisione. Inoltre, delle bitflags vengono impostate a seconda della direzione in cui vi sono gli ostacoli.
Il pull state è abilitato quando il giocatore preme il pulsante x
e il personaggio ha di fronte un ostacolo dinamico, cosa che viene verificata controllando il tipo del physics body dell’ostacolo. Quando tali condizioni si verificano, un distance joint viene creato tra il personaggio e l’entità dinamica per assicurarsi che, quando il personaggio si muove, la distanza tra i due corpi rimanga la stessa, dando effettivamente l’abilità di trascinare l’oggetto in giro. Ovviamente, la giunzione viene eliminata quando il pull state viene disattivato.
Data submodule
Un git submodule è stato aggiunto al repository sotto la cartella data/
, che fa riferimento a Fahien/ncJump-data, dov’è possibile trovare gli ultimi assets del gioco: configurazioni, livelli, immagini, e animazioni.
Un editor migliorato
Varie migliorie sono state effettuate all’editor in-gioco: una barra principale in alto per scegliere la modalità di posizionamento tra tile ed entità, che inoltre cambia l’opacità di determinati elementi della scena in base alla modalità selezionata per un feedback visivo immediato; una finestra per configurare tutte quei valori che non sono collegati al gameplay, come la risoluzione della finestra, la scala di grandezza della scena o dell’interfaccia grafica, l’offset della camera per muoversi nella scena rapidamente senza necessariamente muovere il personaggio.
Conclusioni
Questi aggiornamenti sono già disponibili nella main branch, ma, come anticipazione per i più curiosi di voi, potete provare a dare un’occhiata alla branch enemies. 🍄
Un grande grazie ad @encelo che sta smanettando con la CI del progetto per rendere possibili cose come questa (bug alert!).
È finalmente giunto il momento di aggiungere dei nemici al gioco, il che vuol dire factories!
Factories
Un nemico è solo un altro tipo di entità, motivo che mi ha spinto ad implementare una classe la cui sola responsabilità è di creare entità: la EntityFactory
. Questa factory contiene una lista di prototipi che possono essere selezionati dall’editor a piazzati nella scena. Dietro le quinte, il prototipo viene clonato, il clone viene spostato nella posizione del mouse, ed è aggiunto alla lista delle entità della scena.
A questo punto ho realizzato che ogni clone stava duplicando le proprie texture, che è qualcosa che potremmo e dovremmo evitare, quindi ho aggiunto un’altra factory: la GraphicsFactory
. La responsabilità di questa classe è di mantenere una lista di texture che possano essere referenziate da più graphics components, oltre a quella di creare ogni tipo di sprite che faccia uso di una di queste texture.
Comandi
Una volta aggiunti i nemici alla scena, ho cominciato a riflettere su come farli muovere. Riutilizzare lo state component del personaggio principale sembrava una buona idea, ma c’era un problema. La logica di movimento dello state component controllava direttamente il gamepad e la tastiera, e ciò significava che l’input del giocatore potesse muovere all’unisono sia il personaggio principale che tutti i nemici. La soluzione a questo tipo di problemi sta nel decoupling.
Input → MoveCommand → StateComponent
Ho rimosso la dipendenza che lo state component aveva verso l’input, e ho introdotto il concetto di comando. Per semplicità, mi concentrerò sul MoveCommand
. Lo state component adesso sta semplicamente in attesa di move commands e, quando arrivano, il component aggiorna il proprio stato a seconda dei valori del comando. Move commands, ed eventualmente altri tipi di comandi, possono essere generati da qualsiasi fonte: player input, in-game console, script, e così via, il che li rende molto flessibili.
Script
Altri engine usano termini come behaviors, ma io preferisco il termine tecnico di script. Con questo si può certamente modellare il comportamento delle entità, come ad esempio il wandering behavior, un tipo di movimento che avevo in mente per la prima classe di nemici. Idealmente si potrebbe anche usare un linguaggio di scripting, come LUA che è supportato dall’nCine, tuttavia ho rimandato l’esplorazione di questa possibilità ad un’altra volta.
Ho scritto un paio di righe per il wandering behavior che dovrebbero muovere l’entità verso destra o sinistra finchè non vi sia un ostacolo. A quel punto dovrebbe girarsi e continuare in un loop. Il risultato è sicuramente interessante, anche se credo che possa essere migliorato ancora. Bye for now!
È passato un po’ dal mio ultimo devlog, ma finalmente ne ho scritto uno nuovo. È pieno di cose interessanti quindi l’attesa si farà ripagare. Let us ncJump into it!
Dying state
Sicuramente uno degli stati fondamentali. Ogni entità vivente dovrebbe morire a un certo punto, e questo stato rappresenta quel lasso di tempo in cui un’animazione dovrebbe essere mostrata per dire al giocatore che un nemico è sconfitto o che il gioco è finito. Il metodo di update di questo stato è semplicissimo:
void DyingState::update(Entity& entity)
{
if (entity.animation.has_finished()) {
entity.set_enabled(false);
// just reuse this entity for something else
}
}
Enabled flag
Questo ovviamente spiega il metodo Entity::set_enabled()
appena introdotto nella sezione precedente. Invocandolo, possiamo effettivamente disabilitare un’entità e tutte le sue componenti senza distruggere nulla, o rilasciare memoria, cosicché possa essere riciclata e riusata successivamente quando, per esempio, dobbiamo spawnare un’altra entità.
Array of states
Questa è stata un’importante ottimizzazione. Prima, ogni transizione di stato innescava la distruzione del vecchio stato e la creazione del nuovo stato, cosa non proprio necessaria e non scala col crescere della scena. Ora, lo state component tiene traccia di un array di tutti gli state objects possibili, insieme a un puntatore che ci indica quale sia lo stato corrente.
Initial position
Un altro requisito della scena consiste nel definire dove dovrebbe apparire il personaggio. La posizione iniziale è una coppia di coordinate che ci dice esattamente questo. Quando la scene inizia, mettiamo il personaggio lì.
Definitions and factories
Adoro questo pattern e penso che lo userò d’ora in poi per tutti i miei progetti. In pratica, abbiamo a che fare con oggetti grossi e pesanti non proprio facili da serializzare. Si pensi a dei wrapper per componenti third-party come Box2D bodies o nCine sprites. Ho provato a trovare un modo di serializzare questi oggetti e penso di aver trovato una soluzione niente male che in realtà è molto usata, quindi probabilmente ho solo reinventato la ruota qui, ma va bene.
Non serializzo questi oggetti. Serializzo le loro definizioni. Una definizione è una collezione di tutti i parametri necessari alla costruzione dell’oggetto. Prendiamo come esempio un graphics component di ncJump.
// Very simplified
auto def = GraphicsDef(GraphicsType::TILE);
def.path = String("image.png");
def.layer = 2;
auto gfx = GraphicsComponent(def);
Come si puo vedere, il costruttore del graphics component prende un graphics definition come parametro. Un graphics definition, come tutte le altre definizioni, e’ un Plain Old Data (POD) structure, che permette l’uso di nlohmann macros per definire automaticamente tutte quelle funzioni necessarie alla serializzazione. Fatto. Fantastico.
// That's it
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GraphicsDef, type, path, layer);
CI and automatic deploy
This will blown your mind, poiché il progetto ora compila automaticamente, mediante GitHub actions, per Linux, Window, MacOS, Android, e che ci crediate o no per il web. Infatti, cliccate qui e preparatevi a dire wow. Tutto il merito di questa roba qua va all’eccezionale autore dell’nCine: @encelo!
Fahien complimenti! Ottimo sia ser-de con JSON che il deploy automatico.
Sulla versione web c’è un modo per nascondere la UI di debug?
Ho visto che si possono spostare ed unire i dock che già è una cosa molto carina, c’è sempre stata questa feature in imgui?
davcri grazie! NcJump e questo devlog si stanno rivelando un ottimo esercizio e spero che siano utili a tutti, dato che non sono espressamente legati all’nCine.
Il deploy automatico è comodissimo. Mi basta un push nel branch main
, e la build emscripten viene caricata in un branch di ncJump-artifacts abilitata a GitHub pages. 🤯
Sulla versione web c’è un modo per nascondere la UI di debug?
F12
davcri Ho visto che si possono spostare ed unire i dock che già è una cosa molto carina, c’è sempre stata questa feature in imgui?
Mi piace tantissimo questa feature! Credo che per il momento si possa trovare solo nel branch docking.
Che bello, bravissimo!