World Wide Maze è un gioco in cui usi lo smartphone per far rotolare una palla attraverso labirinti 3D creati da siti web per cercare di raggiungere i punti di destinazione.
Il gioco utilizza ampiamente le funzionalità HTML5. Ad esempio, l'evento DeviceOrientation recupera i dati sull'inclinazione dallo smartphone, che vengono poi inviati al PC tramite WebSocket, dove i giocatori si orientano negli spazi 3D creati da WebGL e Web Workers.
In questo articolo spiegherò con precisione come vengono utilizzate queste funzionalità, il processo di sviluppo complessivo e i punti chiave per l'ottimizzazione.
DeviceOrientation
L'evento DeviceOrientation (esempio) viene utilizzato per recuperare i dati sull'inclinazione dallo smartphone. Quando addEventListener
viene utilizzato con l'evento DeviceOrientation
, un callback con l'oggetto DeviceOrientationEvent
viene invocato come argomento a intervalli regolari. Gli intervalli stessi variano in base al dispositivo utilizzato. Ad esempio, in iOS + Chrome e iOS + Safari, il callback viene richiamato ogni circa 1/20 di secondo, mentre in Android 4 + Chrome viene richiamato ogni circa 1/10 di secondo.
window.addEventListener('deviceorientation', function (e) {
// do something here..
});
L'oggetto DeviceOrientationEvent
contiene i dati sull'inclinazione per ciascuno degli assi X
, Y
e Z
in gradi (non in radianti) (scopri di più su HTML5Rocks). Tuttavia, i valori restituiti variano anche in base alla combinazione di dispositivo e browser utilizzati. Gli intervalli dei valori restituiti effettivi sono riportati nella tabella seguente:
I valori in alto evidenziati in blu sono quelli definiti nelle specifiche W3C. Quelli evidenziati in verde corrispondono a queste specifiche, mentre quelli evidenziati in rosso presentano delle deviazioni. Sorprendentemente, solo la combinazione Android-Firefox ha restituito valori corrispondenti alle specifiche. Tuttavia, per quanto riguarda l'implementazione, ha più senso tenere conto dei valori che si verificano di frequente. Pertanto, World Wide Maze utilizza i valori restituiti di iOS come standard e si adegua di conseguenza ai dispositivi Android.
if android and event.gamma > 180 then event.gamma -= 360
Tuttavia, Nexus 10 non è ancora supportato. Sebbene Nexus 10 restituisca lo stesso intervallo di valori degli altri dispositivi Android, è presente un bug che inverte i valori beta e gamma. Il problema è in fase di risoluzione. (Forse è impostato l'orientamento orizzontale per impostazione predefinita?)
Come dimostrato, anche se le API che coinvolgono dispositivi fisici hanno specifiche impostate, non vi è alcuna garanzia che i valori restituiti corrispondano a queste specifiche. È quindi fondamentale testarle su tutti i dispositivi potenziali. Ciò significa anche che potrebbero essere inseriti valori imprevisti, il che richiede la creazione di soluzioni alternative. World Wide Maze chiede ai giocatori che lo utilizzano per la prima volta di calibrare i loro dispositivi come primo passaggio del tutorial, ma non eseguirà la calibrazione correttamente in posizione zero se riceve valori di inclinazione imprevisti. Ha quindi un limite di tempo interno e chiede al giocatore di passare ai controlli della tastiera se non riesce a eseguire la calibrazione entro questo limite.
WebSocket
In World Wide Maze, lo smartphone e il PC sono connessi tramite WebSocket. Più precisamente, sono collegati tramite un server di inoltro, ad esempio dallo smartphone al server e al PC. Questo perché WebSocket non è in grado di connettere direttamente i browser tra loro. L'utilizzo dei canali di dati WebRTC consente la connettività peer-to-peer ed elimina la necessità di un server di inoltro, ma al momento dell'implementazione questo metodo poteva essere utilizzato solo con Chrome Canary e Firefox Nightly.
Ho scelto di implementare l'utilizzo di una libreria chiamata Socket.IO (v0.9.11), che include funzionalità per ricollegarsi in caso di timeout o disconnessione della connessione. L'ho utilizzato insieme a NodeJS, poiché questa combinazione di NodeJS + Socket.IO ha mostrato le migliori prestazioni lato server in diversi test di implementazione di WebSocket.
Accoppiamento per numeri
- Il PC si connette al server.
- Il server assegna al tuo PC un numero generato in modo casuale e memorizza la combinazione di numero e PC.
- Dal tuo dispositivo mobile, specifica un numero e connettiti al server.
- Se il numero specificato corrisponde a quello di un PC connesso, il dispositivo mobile è accoppiato a quel PC.
- Se non è presente alcun PC designato, si verifica un errore.
- Quando i dati arrivano dal tuo dispositivo mobile, vengono inviati al PC con cui è accoppiato e viceversa.
In alternativa, puoi effettuare la connessione iniziale dal tuo dispositivo mobile. In questo caso, i dispositivi vengono semplicemente invertiti.
Sincronizzazione schede
La funzionalità di sincronizzazione delle schede specifica di Chrome semplifica ulteriormente la procedura di accoppiamento. In questo modo, le pagine aperte sul PC possono essere aperte facilmente su un dispositivo mobile e viceversa. Il PC prende il numero di connessione emesso dal server e lo aggiunge all'URL di una pagina utilizzando history.replaceState
.
history.replaceState(null, null, '/maze/' + connectionNumber)
Se la sincronizzazione delle schede è attiva, l'URL viene sincronizzato dopo alcuni secondi e la stessa pagina può essere aperta sul dispositivo mobile. Il dispositivo mobile controlla l'URL della pagina aperta e, se viene aggiunto un numero, inizia immediatamente a connettersi. In questo modo non è necessario inserire i numeri manualmente o scansionare i codici QR con una fotocamera.
Latenza
Poiché il server di inoltro si trova negli Stati Uniti, l'accesso dal Giappone comporta un ritardo di circa 200 ms prima che i dati sull'inclinazione dello smartphone raggiungano il PC. I tempi di risposta erano chiaramente lenti rispetto a quelli dell'ambiente locale utilizzato durante lo sviluppo, ma l'inserimento di un filtro passa basso (ho utilizzato EMA) ha migliorato la situazione a livelli non invadenti. (In pratica, era necessario un filtro passa basso anche per la presentazione; i valori restituiti dal sensore di inclinazione includevano una quantità considerevole di rumore e l'applicazione di questi valori allo schermo così com'erano ha provocato un notevole tremolio.) Questo non ha funzionato con i salti, che erano chiaramente lenti, ma non è stato possibile fare nulla per risolvere il problema.
Dato che mi aspettavo problemi di latenza fin dall'inizio, ho preso in considerazione la possibilità di configurare server di inoltro in tutto il mondo in modo che i client potessero connettersi a quello più vicino disponibile (riducendo così al minimo la latenza). Tuttavia, ho finito per utilizzare Google Compute Engine (GCE), che all'epoca esisteva solo negli Stati Uniti, quindi non era possibile.
Il problema dell'algoritmo di Nagle
L'algoritmo Nagle viene in genere incorporato nei sistemi operativi per una comunicazione efficiente tramite il buffering a livello di TCP, ma ho scoperto che non riuscivo a inviare dati in tempo reale quando questo algoritmo era abilitato. Nello specifico, se combinato con l'acknowledgement ritardato TCP. Anche se non si verificano ritardi per ACK
, si verifica lo stesso problema se ACK
è in ritardo fino a un certo punto a causa di fattori come la posizione del server all'estero.
Il problema di latenza di Nagle non si è verificato con WebSocket in Chrome per Android, che include l'opzione TCP_NODELAY
per disattivare Nagle, ma si è verificato con WebKit WebSocket utilizzato in Chrome per iOS, che non ha questa opzione attivata. Anche Safari, che utilizza lo stesso WebKit, ha riscontrato questo problema. Il problema è stato segnalato ad Apple tramite Google e sembra essere stato risolto nella versione di sviluppo di WebKit.
Quando si verifica questo problema, i dati sull'inclinazione inviati ogni 100 ms vengono combinati in blocchi che raggiungono il PC solo ogni 500 ms. Il gioco non può funzionare in queste condizioni, quindi evita questa latenza facendo in modo che il lato server invii i dati a brevi intervalli (ogni 50 ms circa). Credo che la ricezione di ACK
a brevi intervalli induca l'algoritmo Nagle a pensare che sia possibile inviare dati.
Il grafico sopra riportato mostra gli intervalli di dati effettivi ricevuti. Indica gli intervalli di tempo tra i pacchetti: il verde rappresenta gli intervalli di output e il rosso gli intervalli di input. Il valore minimo è 54 ms, il massimo è 158 ms e il valore intermedio è vicino a 100 ms. In questo caso ho utilizzato un iPhone con un server di inoltro in Giappone. L'output e l'input sono entrambi di circa 100 ms e il funzionamento è fluido.
Al contrario, questo grafico mostra i risultati dell'utilizzo del server negli Stati Uniti. Mentre gli intervalli di output verdi rimangono invariati a 100 ms, gli intervalli di input oscillano tra valori minimi di 0 ms e massimi di 500 ms, a indicare che il PC sta ricevendo i dati a blocchi.
Infine, questo grafico mostra i risultati dell'evitare la latenza facendo in modo che il server invii dati segnaposto. Anche se il rendimento non è altrettanto buono rispetto all'utilizzo del server giapponese, è chiaro che gli intervalli di input rimangono relativamente stabili a circa 100 ms.
Un bug?
Sebbene il browser predefinito in Android 4 (ICS) abbia un'API WebSocket, non riesce a connettersi, generando un evento connect_failed di Socket.IO. Viene raggiunto il timeout interno e anche il lato server non riesce a verificare una connessione. Non l'ho provato solo con WebSocket, quindi potrebbe essere un problema di Socket.IO.
Scalabilità dei server di inoltro
Poiché il ruolo del server di inoltro non è così complicato, l'aumento e l'incremento del numero di server non dovrebbe essere difficile, a condizione che tu ti assicuri che lo stesso PC e il dispositivo mobile siano sempre connessi allo stesso server.
Fisica
Il movimento della palla in-game (rotazione in discesa, collisione con il suolo, collisione con le pareti, raccolta di oggetti e così via) viene eseguito con un simulatore di fisica 3D. Ho utilizzato Ammo.js, una porta del motore fisico Bullet ampiamente utilizzato in JavaScript utilizzando Emscripten, insieme a Physijs per utilizzarlo come "Web Worker".
Web worker
Web Workers è un'API per l'esecuzione di JavaScript in thread separati. Il codice JavaScript avviato come Web Worker viene eseguito come thread separato da quello che lo ha chiamato inizialmente, pertanto è possibile eseguire attività complesse mantenendo la pagina reattiva. Physijs utilizza in modo efficiente i Web Worker per contribuire al corretto funzionamento del motore fisico 3D normalmente intensivo. World Wide Maze gestisce il motore fisico e il rendering delle immagini WebGL a frame rate completamente diversi, quindi anche se il frame rate cala su una macchina con specifiche ridotte a causa del carico elevato del rendering WebGL, il motore fisico stesso manterrà più o meno 60 fps e non ostacolerà i controlli del gioco.
Questa immagine mostra le frequenze frame risultanti su un Lenovo G570. La casella superiore mostra la frequenza frame per WebGL (rendering delle immagini), mentre quella inferiore mostra la frequenza frame per il motore fisico. La GPU è un chip Intel HD Graphics 3000 integrato, pertanto la frequenza dei fotogrammi di rendering delle immagini non ha raggiunto i 60 fps previsti. Tuttavia, poiché il motore fisico ha raggiunto la frequenza fotogrammi prevista, il gameplay non è molto diverso dalle prestazioni su una macchina con specifiche elevate.
Poiché i thread con Web Worker attivi non hanno oggetti della console, i dati devono essere inviati al thread principale tramite postMessage per produrre i log di debug. L'utilizzo di console4Worker crea l'equivalente di un oggetto console nel worker, semplificando notevolmente la procedura di debug.
Le versioni recenti di Chrome ti consentono di impostare i punti di interruzione quando avvii i worker web, il che è utile anche per il debug. che puoi trovare nel riquadro "Worker" (Lavoratori) degli Strumenti per sviluppatori.
Prestazioni
A volte le fasi con un numero elevato di poligoni superano i 100.000 poligoni, ma il rendimento non ne ha risentito particolarmente anche quando sono state generate interamente come Physijs.ConcaveMesh
(btBvhTriangleMeshShape
in Bullet).
Inizialmente, la frequenza dei fotogrammi diminuiva con l'aumento del numero di oggetti che richiedono il rilevamento delle collisioni, ma l'eliminazione dell'elaborazione non necessaria in Physijs ha migliorato le prestazioni. Questo miglioramento è stato apportato a un fork di Physijs originale.
Oggetti fantasma
Gli oggetti che hanno il rilevamento delle collisioni, ma non subiscono alcun impatto in caso di collisione e quindi non influiscono su altri oggetti, sono chiamati "oggetti fantasma" in Bullet. Anche se Physijs non supporta ufficialmente gli oggetti fantasma, è possibile crearli modificando i flag dopo aver generato un Physijs.Mesh
. World Wide Maze utilizza oggetti fantasma per il rilevamento delle collisioni di elementi e punti di destinazione.
hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)
Per collision_flags
, 1 è CF_STATIC_OBJECT
e 4 è CF_NO_CONTACT_RESPONSE
. Per ulteriori informazioni, prova a cercare nel forum di Bullet, su Stack Overflow o nella documentazione di Bullet. Poiché Physijs è un wrapper per Ammo.js e Ammo.js è praticamente identico a Bullet, la maggior parte delle operazioni che è possibile eseguire in Bullet può essere eseguita anche in Physijs.
Il problema di Firefox 18
L'aggiornamento di Firefox dalla versione 17 alla 18 ha modificato il modo in cui i worker web scambiano dati e, di conseguenza, Physijs ha smesso di funzionare. Il problema è stato segnalato su GitHub e risolto dopo alcuni giorni. Sebbene questa efficienza open source mi abbia impressionato, l'incidente mi ha anche ricordato che World Wide Maze è composto da diversi framework open source. Sto scrivendo questo articolo nella speranza di fornire una sorta di feedback.
asm.js
Anche se questo non riguarda direttamente World Wide Maze, Ammo.js supporta già asm.js, annunciato di recente da Mozilla (non sorprende, dato che asm.js è stato creato principalmente per velocizzare il codice JavaScript generato da Emscripten e il creator di Emscripten è anche il creator di Ammo.js). Se Chrome supporta anche asm.js, il carico di calcolo del motore fisico dovrebbe diminuire notevolmente. La velocità è stata notevolmente superiore quando è stato eseguito il test con Firefox Nightly. Forse sarebbe meglio scrivere le sezioni che richiedono maggiore velocità in C/C++ e poi portarle in JavaScript utilizzando Emscripten?
WebGL
Per l'implementazione di WebGL ho utilizzato la libreria più sviluppata attivamente, three.js (r53). Sebbene la revisione 57 fosse già stata rilasciata nelle ultime fasi di sviluppo, erano state apportate modifiche sostanziali all'API, quindi ho mantenuto la revisione originale per il rilascio.
Effetto bagliore
L'effetto bagliore aggiunto al nucleo della palla e agli elementi viene implementato utilizzando una versione semplice del cosiddetto "MGF del metodo Kawase". Tuttavia, mentre il metodo Kawase fa risaltare tutte le aree luminose, World Wide Maze crea target di rendering separati per le aree che devono essere illuminate. Questo perché per le texture della scena deve essere utilizzato uno screenshot del sito web e l'estrazione di tutte le aree luminose comporterebbe l'illuminazione dell'intero sito web se, ad esempio, ha uno sfondo bianco. Ho anche preso in considerazione l'elaborazione di tutto in HDR, ma questa volta ho deciso di non farlo perché l'implementazione sarebbe stata piuttosto complicata.
In alto a sinistra è mostrato il primo passaggio, in cui le aree di bagliore sono state visualizzate separatamente e poi è stata applicata una sfocatura. In basso a destra è visibile il secondo passaggio, in cui le dimensioni dell'immagine sono state ridotte del 50% e poi è stata applicata una sfocatura. In alto a destra è visibile la terza passata, in cui l'immagine è stata nuovamente ridotta del 50% e poi sfocata. Le tre immagini sono state poi sovrapposte per creare l'immagine composita finale mostrata in basso a sinistra. Per la sfocatura ho utilizzato VerticalBlurShader
e HorizontalBlurShader
, inclusi in three.js, quindi c'è ancora spazio per ulteriori ottimizzazioni.
Palla riflettente
Il riflesso sulla palla si basa su un esempio di three.js. Tutte le direzioni vengono visualizzate dalla posizione della palla e utilizzate come mappe dell'ambiente. Le mappe dell'ambiente devono essere aggiornate ogni volta che la palla si muove, ma poiché l'aggiornamento a 60 fps è impegnativo, vengono aggiornate ogni tre frame. Il risultato non è così fluido come l'aggiornamento di ogni frame, ma la differenza è praticamente impercettibile, a meno che non venga evidenziata.
Shader, shader, shader…
WebGL richiede shader (vertex shader, fragment shader) per tutto il rendering. Sebbene gli shader inclusi in three.js consentano già una vasta gamma di effetti, è inevitabile scriverne di personalizzati per ombreggiature e ottimizzazioni più elaborate. Poiché World Wide Maze tiene occupata la CPU con il suo motore fisico, ho provato a utilizzare la GPU scrivendo il più possibile in linguaggio di shading (GLSL), anche quando l'elaborazione della CPU (tramite JavaScript) sarebbe stata più semplice. Gli effetti delle onde oceaniche si basano sugli shader, così come i fuochi d'artificio nei punti di goal e l'effetto mesh utilizzato quando appare la palla.
L'immagine sopra è tratta dai test dell'effetto mesh utilizzato quando viene visualizzata la palla. Quella a sinistra è quella utilizzata in-game, composta da 320 poligoni. Quella al centro utilizza circa 5000 poligoni, mentre quella a destra utilizza circa 300.000 poligoni. Anche con questo numero di poligoni, l'elaborazione con gli shader può mantenere una frequenza fotogrammi costante di 30 fps.
I piccoli elementi sparsi per il palco sono tutti integrati in un unico mesh e il movimento individuale si basa sugli shader che spostano ciascuna delle punte del poligono. Si tratta di un test per verificare se il rendimento peggiora in presenza di un numero elevato di oggetti. Sono disposti circa 5000 oggetti, composti da circa 20.000 poligoni. Le prestazioni non hanno subito alcuna penalizzazione.
poly2tri
Le fasi vengono formate in base alle informazioni di sfondo ricevute dal server e poi poligonizzate da JavaScript. La triangolazione, una parte fondamentale di questo processo, non è implementata correttamente da three.js e in genere non va a buon fine. Ho quindi deciso di integrare personalmente una libreria di triangolazione diversa chiamata poly2tri. A quanto pare, three.js aveva già tentato la stessa cosa in passato, quindi ho fatto in modo che funzionasse semplicemente commentando parte del codice. Di conseguenza, gli errori sono diminuiti in modo significativo, consentendo molti più livelli giocabili. L'errore occasionale persiste e per qualche motivo poly2tri gestisce gli errori emettendo avvisi, quindi l'ho modificato in modo che generi eccezioni.
L'immagine sopra mostra come il contorno blu viene suddiviso in triangoli e vengono generati i poligoni rossi.
Filtro anisotropico
Poiché la mappatura MIP isotropica standard riduce le dimensioni delle immagini su entrambi gli assi orizzontale e verticale, la visualizzazione dei poligoni da angolazioni oblique fa sì che le texture all'estremità opposta delle fasi di World Wide Maze appaiano come texture allungate orizzontalmente e a bassa risoluzione. L'immagine in alto a destra in questa pagina di Wikipedia ne è un buon esempio. In pratica, è necessaria una maggiore risoluzione orizzontale, che WebGL (OpenGL) risolve utilizzando un metodo chiamato filtro anisotropico. In three.js, l'impostazione di un valore maggiore di 1 per THREE.Texture.anisotropy
attiva il filtro anisotropico. Tuttavia, questa funzionalità è un'estensione e potrebbe non essere supportata da tutte le GPU.
Ottimizza
Come indicato anche in questo articolo sulle best practice per WebGL, il modo più importante per migliorare le prestazioni di WebGL (OpenGL) è ridurre al minimo le chiamate draw. Durante lo sviluppo iniziale di World Wide Maze, tutte le isole, i ponti e le guardrail in-game erano oggetti distinti. A volte questo ha comportato oltre 2000 chiamate di draw, rendendo poco pratici gli stati complessi. Tuttavia, dopo aver raggruppato gli stessi tipi di oggetti in un unico mesh, le chiamate di draw sono scese a circa cinquanta, migliorando notevolmente le prestazioni.
Ho utilizzato la funzionalità di monitoraggio di Chrome per un'ulteriore ottimizzazione. I profiler inclusi negli Strumenti per sviluppatori di Chrome possono determinare in qualche misura i tempi di elaborazione complessivi del metodo, ma il monitoraggio può indicare con precisione il tempo necessario per ogni parte, fino a un millesimo di secondo. Consulta questo articolo per informazioni dettagliate su come utilizzare il monitoraggio.
I risultati della traccia sopra riportati sono stati ottenuti creando mappe di ambiente per il riflesso della palla. L'inserimento di console.time
e console.timeEnd
in posizioni apparentemente pertinenti in three.js ci dà un grafico simile a questo. Il tempo scorre da sinistra a destra e ogni livello è simile a una pila di chiamate. Nidificare un comando console.time all'interno di un console.time
consente ulteriori misurazioni. Il grafico in alto è precedente all'ottimizzazione, mentre quello in basso è successivo. Come mostrato dal grafico in alto, updateMatrix
(anche se la parola è troncata) è stata chiamata per ogni rendering da 0 a 5 durante la preottimizzazione. L'ho modificata in modo che venga chiamata una sola volta, poiché questa procedura è necessaria solo quando gli oggetti cambiano posizione o orientamento.
Il processo di monitoraggio stesso occupa risorse, quindi l'inserimento eccessivo di console.time
può causare una deviazione significativa dalle prestazioni effettive, rendendo difficile individuare le aree di ottimizzazione.
Regolatore delle prestazioni
A causa della natura di internet, è probabile che il gioco venga giocato su sistemi con specifiche molto diverse. Find Your Way to Oz, rilasciato all'inizio di febbraio, utilizza una classe chiamata IFLAutomaticPerformanceAdjust
per ridurre gli effetti in base alle fluttuazioni della frequenza dei fotogrammi, contribuendo a garantire una riproduzione fluida. World Wide Maze si basa sulla stessa classe IFLAutomaticPerformanceAdjust
e riduce gli effetti nel seguente ordine per rendere il gameplay il più fluido possibile:
- Se la frequenza dei fotogrammi scende al di sotto di 45 fps, le mappe di ambiente smettono di aggiornarsi.
- Se il valore è ancora inferiore a 40 fps, la risoluzione di rendering viene ridotta al 70% (50% del rapporto di superficie).
- Se il valore scende ancora al di sotto di 40 fps, l'anti-aliasing FXAA viene eliminato.
- Se il valore è ancora inferiore a 30 fps, gli effetti di illuminazione vengono eliminati.
Perdita di memoria
Eliminare gli oggetti in modo ordinato è un po' complicato con three.js. Ma lasciarli inutilizzati causerebbe ovviamente perdite di memoria, quindi ho ideato il metodo riportato di seguito. @renderer
fa riferimento a THREE.WebGLRenderer
. (L'ultima revisione di three.js utilizza un metodo di deallocazione leggermente diverso, quindi probabilmente non funzionerà così com'è.)
destructObjects: (object) =>
switch true
when object instanceof THREE.Object3D
@destructObjects(child) for child in object.children
object.parent?.remove(object)
object.deallocate()
object.geometry?.deallocate()
@renderer.deallocateObject(object)
object.destruct?(this)
when object instanceof THREE.Material
object.deallocate()
@renderer.deallocateMaterial(object)
when object instanceof THREE.Texture
object.deallocate()
@renderer.deallocateTexture(object)
when object instanceof THREE.EffectComposer
@destructObjects(object.copyPass.material)
object.passes.forEach (pass) =>
@destructObjects(pass.material) if pass.material
@renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
@renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
@renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2
HTML
Personalmente, penso che la cosa migliore dell'app WebGL sia la possibilità di progettare il layout della pagina in HTML. Creare interfacce 2D come la visualizzazione di punteggi o testo in Flash o openFrameworks (OpenGL) è un po' complicato. Flash ha almeno un IDE, ma openFrameworks è difficile se non lo conosci (l'utilizzo di qualcosa come Cocos2D potrebbe semplificare le cose). L'HTML, invece, consente un controllo preciso di tutti gli aspetti del design frontend con CSS, proprio come quando si creano siti web. Sebbene effetti complessi come le particelle che si condensano in un logo siano impossibili, sono possibili alcuni effetti 3D nell'ambito delle funzionalità delle trasformazioni CSS. Gli effetti di testo "GOAL" e "TIME IS UP" di World Wide Maze vengono animati utilizzando la scala nella transizione CSS (implementata con Transit). Ovviamente, le gradazioni di sfondo utilizzano WebGL.
Ogni pagina del gioco (titolo, RISULTATO, CLASSIFICA e così via) ha il proprio file HTML e, una volta caricati come modelli, $(document.body).append()
viene chiamato con i valori appropriati al momento opportuno. Un problema è stato che non è stato possibile impostare gli eventi del mouse e della tastiera prima dell'aggiunta, quindi il tentativo di el.click (e) -> console.log(e)
prima dell'aggiunta non ha funzionato.
Internazionalizzazione
Lavorare in HTML è stato pratico anche per creare la versione in lingua inglese. Per le mie esigenze di internazionalizzazione ho scelto di utilizzare i18next, una libreria i18n web, che ho potuto utilizzare così com'è senza modifiche.
La modifica e la traduzione del testo in-game sono state eseguite nel foglio di lavoro di Documenti Google. Poiché i18next richiede file JSON, ho esportato i fogli di lavoro in TSV e poi li ho convertiti con un convertitore personalizzato. Ho apportato molti aggiornamenti poco prima del rilascio, quindi l'automazione della procedura di esportazione dal foglio di lavoro di Documenti Google avrebbe semplificato molto le cose.
Anche la funzionalità di traduzione automatica di Chrome funziona normalmente, poiché le pagine sono create con HTML. Tuttavia, a volte non riesce a rilevare correttamente la lingua, scambiandola per un'altra completamente diversa (ad es. vietnamita), pertanto questa funzionalità è attualmente disattivata. Può essere disattivato utilizzando i meta tag.
RequireJS
Ho scelto RequireJS come sistema di moduli JavaScript. Le 10.000 righe di codice sorgente del gioco sono suddivise in circa 60 classi (= file coffee) e compilate in singoli file js. RequireJS carica questi singoli file nell'ordine appropriato in base alla dipendenza.
define ->
class Hoge
hogeMethod: ->
La classe definita sopra (hoge.coffee) può essere utilizzata come segue:
define ['hoge'], (Hoge) ->
class Moge
constructor: ->
@hoge = new Hoge()
@hoge.hogeMethod()
Per funzionare, hoge.js deve essere caricato prima di moge.js e poiché "hoge" è designato come primo argomento di "define", viene sempre caricato per primo (viene richiamato al termine del caricamento di hoge.js). Questo meccanismo è chiamato AMD e qualsiasi libreria di terze parti può essere utilizzata per lo stesso tipo di callback, a condizione che supporti AMD. Anche quelli che non lo fanno (ad es. three.js) avranno un rendimento simile, a condizione che le dipendenze siano specificate in anticipo.
È simile all'importazione di AS3, quindi non dovrebbe sembrare così strano. Se finisci con più file dipendenti, questa è una possibile soluzione.
r.js
RequireJS include un ottimizzatore chiamato r.js. Questo comprime il file JS principale con tutti i file JS dipendenti in uno solo, quindi lo riduce al minimo utilizzando UglifyJS (o Closure Compiler). In questo modo si riduce il numero di file e la quantità totale di dati che il browser deve caricare. Le dimensioni totali del file JavaScript per World Wide Maze sono di circa 2 MB e possono essere ridotte a circa 1 MB con l'ottimizzazione di r.js. Se il gioco potesse essere distribuito utilizzando gzip, le dimensioni si ridurrebbero ulteriormente a 250 KB. (GAE ha un problema che non consente la trasmissione di file gzip di dimensioni pari o superiori a 1 MB, pertanto il gioco è attualmente distribuito non compresso come 1 MB di testo normale).
Generatore di fasi
I dati della fase vengono generati come segue, eseguiti interamente sul server GCE negli Stati Uniti:
- L'URL del sito web da convertire in una fase viene inviato tramite WebSocket.
- PhantomJS acquisisce uno screenshot e le posizioni dei tag div e img vengono recuperate e stampate in formato JSON.
- In base allo screenshot del passaggio 2 e ai dati di posizionamento degli elementi HTML, un programma C++ personalizzato (OpenCV, Boost) elimina le aree non necessarie, genera isole, collega le isole con ponti, calcola le posizioni di guard rail e articoli, imposta il punto di destinazione e così via. I risultati vengono visualizzati in formato JSON e restituiti al browser.
PhantomJS
PhantomJS è un browser che non richiede una schermata. Può caricare pagine web senza aprire finestre, quindi può essere utilizzato nei test automatici o per acquisire screenshot lato server. Il suo motore del browser è WebKit, lo stesso utilizzato da Chrome e Safari, quindi anche il layout e i risultati dell'esecuzione di JavaScript sono più o meno gli stessi dei browser standard.
Con PhantomJS, JavaScript o CoffeeScript vengono utilizzati per scrivere le procedure da eseguire. Acquisire screenshot è molto semplice, come mostrato in questo esempio. Stavo lavorando su un server Linux (CentOS), quindi dovevo installare i caratteri per visualizzare il giapponese (M+ FONTS). Anche in questo caso, il rendering dei caratteri viene gestito in modo diverso rispetto a Windows o macOS, quindi lo stesso carattere può avere un aspetto diverso su altre macchine (anche se la differenza è minima).
Il recupero delle posizioni dei tag img e div viene gestito in modo simile alle pagine standard. Anche jQuery può essere utilizzato senza problemi.
stage_builder
Inizialmente ho preso in considerazione l'utilizzo di un approccio più basato sul DOM per generare le fasi (simile all'strumento di ispezione 3D di Firefox) e ho tentato qualcosa di simile a un'analisi DOM in PhantomJS. Alla fine, però, ho optato per un approccio di elaborazione delle immagini. A questo scopo ho scritto un programma C++ che utilizza OpenCV e Boost chiamato "stage_builder". Esegue le seguenti operazioni:
- Carica lo screenshot e i file JSON.
- Converte immagini e testo in "isole".
- Crea ponti per collegare le isole.
- Elimina i ponti non necessari per creare un labirinto.
- Posiziona oggetti di grandi dimensioni.
- Posiziona piccoli oggetti.
- Posiziona le barriere di sicurezza.
- Consente di visualizzare i dati di posizionamento in formato JSON.
Ogni passaggio è descritto dettagliatamente di seguito.
Caricamento dello screenshot e dei file JSON
Per caricare gli screenshot viene utilizzato il solito cv::imread
. Ho testato diverse librerie per i file JSON, ma picojson mi è sembrata la più semplice da utilizzare.
Conversione di immagini e testo in "isole"
Sopra è riportato uno screenshot della sezione Notizie di aid-dcc.com (fai clic per visualizzare le dimensioni effettive). Le immagini e gli elementi di testo devono essere convertiti in isole. Per isolare queste sezioni, dobbiamo eliminare il colore dello sfondo bianco, in altre parole il colore più prevalente nello screenshot. Ecco come si presenta al termine dell'operazione:
Le sezioni bianche sono le potenziali isole.
Il testo è troppo sottile e nitido, quindi lo appesantiremo con cv::dilate
, cv::GaussianBlur
e cv::threshold
. Mancano anche i contenuti delle immagini, quindi riempiremo queste aree di bianco, in base ai dati del tag img generati da PhantomJS. L'immagine risultante sarà simile alla seguente:
Ora il testo forma raggruppamenti adatti e ogni immagine è un'isola vera e propria.
Creazione di ponti per collegare le isole
Una volta pronte, le isole vengono collegate con ponti. Ogni isola cerca isole adiacenti a sinistra, a destra, sopra e sotto, quindi collega un ponte al punto più vicino dell'isola più vicina, ottenendo un risultato simile al seguente:
Eliminazione di ponti non necessari per creare un labirinto
Mantenere tutti i ponti renderebbe il livello troppo facile da navigare, quindi alcuni devono essere eliminati per creare un labirinto. Viene scelta un'isola (ad es. quella in alto a sinistra) come punto di partenza e tutti i ponti che la collegano, tranne uno (selezionato in modo casuale), vengono eliminati. Poi viene eseguita la stessa operazione per l'isola successiva collegata dal ponte rimanente. Quando il percorso termina in un vicolo cieco o riporta a un'isola già visitata, torna indietro fino a un punto che consente l'accesso a una nuova isola. Il labirinto è completato una volta elaborate tutte le isole in questo modo.
Posizionamento di oggetti di grandi dimensioni
Su ogni isola vengono posizionati uno o più elementi di grandi dimensioni, a seconda delle dimensioni dell'isola, scegliendo i punti più lontani dai bordi. Anche se non molto chiari, questi punti sono indicati in rosso di seguito:
Tra tutti questi punti possibili, quello in alto a sinistra viene impostato come punto di partenza (cerchio rosso), quello in basso a destra come obiettivo (cerchio verde) e un massimo di sei degli altri vengono scelti per il posizionamento di articoli di grandi dimensioni (cerchio viola).
Posizionare piccoli oggetti
Un numero adeguato di piccoli elementi viene posizionato lungo linee a distanze prestabilite dai bordi dell'isola. L'immagine sopra (non di aid-dcc.com) mostra le linee di posizionamento proiettate in grigio, sfalsate e posizionate a intervalli regolari dai bordi dell'isola. I puntini rossi indicano dove sono posizionati gli oggetti di piccole dimensioni. Poiché questa immagine proviene da una versione in fase di sviluppo, gli elementi sono disposti in linee rette, ma nella versione finale sono disposti in modo un po' più irregolare ai lati delle linee grigie.
Posizionamento di guard rail
I guardrail sono generalmente posizionati lungo i confini esterni delle isole, ma devono essere interrotti nei pressi dei ponti per consentire l'accesso. La libreria di geometria Boost si è dimostrata utile per questo, semplificando i calcoli geometrici come la determinazione del punto in cui i dati dei confini delle isole intersecano le linee su entrambi i lati di un ponte.
Le linee verdi che contornano le isole sono le barriere di sicurezza. Potrebbe essere difficile da vedere in questa immagine, ma non ci sono linee verdi dove si trovano i ponti. Questa è l'immagine finale utilizzata per il debug, in cui sono inclusi tutti gli oggetti che devono essere visualizzati in JSON. I punti blu chiaro sono elementi di piccole dimensioni, mentre i punti grigi sono punti di riavvio proposti. Quando la palla cade nell'oceano, la partita viene ripresa dal punto di riavvio più vicino. I punti di riavvio sono disposti più o meno nello stesso modo dei piccoli elementi, a intervalli regolari e a una distanza fissa dal bordo dell'isola.
Output dei dati di posizionamento in formato JSON
Ho utilizzato anche picojson per l'output. Scrive i dati nell'output standard, che vengono poi ricevuti dall'utente che ha chiamato la funzione (Node.js).
Creazione di un programma C++ su un Mac da eseguire in Linux
Il gioco è stato sviluppato su un Mac e di cui è stato eseguito il deployment in Linux, ma poiché OpenCV e Boost erano disponibili per entrambi i sistemi operativi, lo sviluppo in sé non è stato difficile una volta stabilito l'ambiente di compilazione. Ho utilizzato gli strumenti a riga di comando in Xcode per eseguire il debug della build su Mac, quindi ho creato un file di configurazione utilizzando automake/autoconf in modo che la build potesse essere compilata in Linux. Poi ho dovuto semplicemente utilizzare "configure && make" in Linux per creare il file eseguibile. Ho riscontrato alcuni bug specifici di Linux a causa delle differenze nelle versioni del compilatore, ma sono riuscito a risolverli abbastanza facilmente utilizzando gdb.
Conclusione
Un gioco come questo potrebbe essere creato con Flash o Unity, il che offrirebbe numerosi vantaggi. Tuttavia, questa versione non richiede plug-in e le funzionalità di layout di HTML5 + CSS3 si sono dimostrate estremamente potenti. È sicuramente importante avere gli strumenti giusti per ogni attività. Personalmente, mi ha sorpreso il buon risultato ottenuto da un gioco realizzato interamente in HTML5 e, anche se manca ancora in molti aspetti, non vedo l'ora di vedere come si evolverà in futuro.