00:00
00:00
3p0ch
If you like hard games try my Daxolissian System series

plasmid @3p0ch

Cat

Scientist

Read the manual & try stuff

So Cal

Joined on 2/13/10

Level:
13
Exp Points:
1,638 / 1,880
Exp Rank:
38,468
Vote Power:
5.48 votes
Audio Scouts
1
Rank:
Portal Security
Global Rank:
23,697
Blams:
50
Saves:
371
B/P Bonus:
8%
Whistle:
Normal
Medals:
4,611
Supporter:
3y 2m 23d

3p0ch's News

Posted by 3p0ch - September 19th, 2021


Have a GDevelop game and want to add medals and/or leaderboards? This is one way to do it, and the easiest I could come up with.


First get stuff set up on your NewGrounds project page by going to the API tools link near the bottom. Once it's set up, you'll see an App ID and Encryption Key (AES-128/Base64) for your game – you'll need these later. On NewGrounds set up Medals and/or Scoreboards for your game, and once they're set up you should see them listed including an ID# for each medal and scoreboard. (You might need to reload the page to see the scoreboard listing after it's set up because of some glitchiness with the current page.)

iu_425496_3205434.png


Next open your game in GDevelop where you'll add a new Function to handle NG.io requests to unlock medals and post scores – this will be a little wonky because GDevelop doesn't have a straightforward way of importing Functions so bear with me. Open the Project Manager panel, click Functions/Behaviors to expand it, then click the button to add a new function but don't open or edit it -- just stay on the Project Manager panel. Copy all the stuff in the code block at the very end of this post so it's in your computer's clipboard (on Windows triple-clicking it and Ctrl-C should work), then right click on the new empty function you just made in GDevelop and paste it. Now you can delete the NewExtension you made initially and just keep the NGIO extension that was pasted in.

iu_425500_3205434.png


Now in your game's Events sheets you'll have a new option when you add an action: under Other Actions you'll have an NGIO category where you can pick "Send a NewGrounds.io request". Add it just like you would a regular action. It will have five parameters. The app ID and AES encryption key that you can get from your NewGrounds page in the API tools (currently the page is designed so you can click on a button to copy those values to your clipboard and then go to GDevelop and paste them in). Next will be a parameter to say which NewGrounds.io component you want to call, use "Medal.unlock" to unlock a medal or "ScoreBoard.postScore" if you want to do that, and be careful about capitalization. In the fourth parameter put the ID of the medal or scoreboard. In the fifth parameter put the score you want to post if it's a scoreboard, or your favorite number if it's a medal (which will not actually affect anything with medal unlocking).

iu_425498_3205434.png


The medal unlocking and scoreboard posts won't work while the game is previewed in GDevelop, and will only work after you've uploaded it to NewGrounds and are playing it online. They will work if you play your game in Preview mode on NewGrounds before publishing. Also while running your game in Preview on NewGrounds, you can open your web browser's Developer Tools panel and go to the Console pane – you'll probably see a lot of messages there, and the one that's generated from the NewGrounds.io request will say "The response from the NewGrounds.io request was:" and hopefully be followed by data that includes "success":true, or at least information that can help you figure out what the issue is.

iu_425499_3205434.png


Here's the code to copy/paste into GDevelop as a Function

{"000kind":"GDEVELOP_Events Functions Extension_CLIPBOARD_KIND-jsBdHbLy912y8Rc","content":{"eventsFunctionsExtension":{"author":"","description":"","extensionNamespace":"","fullName":"","helpPath":"","iconUrl":"","name":"NGIO","previewIconUrl":"","shortDescription":"","version":"","tags":[],"dependencies":[],"eventsFunctions":[{"description":"Sends a request to NewGrounds.io, can be used to unlock medals or post to leaderboards","fullName":"Send a NewGrounds.io request","functionType":"Action","name":"NGIOrequest","private":false,"sentence":"Send request for App ID _PARAM1_ using AES encryption key _PARAM2_ with component _PARAM3_ for medal or leaderboard ID _PARAM4_ and score _PARAM5_","events":[{"disabled":false,"folded":false,"type":"BuiltinCommonInstructions::JsCode","inlineCode":"let app_id = eventsFunctionContext.getArgument(\"AppID\");\nlet aes_key = eventsFunctionContext.getArgument(\"AesKey\");\nlet component = eventsFunctionContext.getArgument(\"Component\");\nlet parameters = {\n    \"id\": eventsFunctionContext.getArgument(\"MedalOrScoreboardID\"),\n    \"value\": eventsFunctionContext.getArgument(\"LeaderboardScore\"),\n};\n\n/*\nCryptoJS v3.1.2\ncode.google.com/p/crypto-js\n(c) 2009-2013 by Jeff Mott. All rights reserved.\ncode.google.com/p/crypto-js/wiki/License\n*/\nvar CryptoJS=CryptoJS||function(u,p){var d={},l=d.lib={},s=function(){},t=l.Base={extend:function(a){s.prototype=this;var c=new s;a&&c.mixIn(a);c.hasOwnProperty(\"init\")||(c.init=function(){c.$super.init.apply(this,arguments)});c.init.prototype=c;c.$super=this;return c},create:function(){var a=this.extend();a.init.apply(a,arguments);return a},init:function(){},mixIn:function(a){for(var c in a)a.hasOwnProperty(c)&&(this[c]=a[c]);a.hasOwnProperty(\"toString\")&&(this.toString=a.toString)},clone:function(){return this.init.prototype.extend(this)}},\nr=l.WordArray=t.extend({init:function(a,c){a=this.words=a||[];this.sigBytes=c!=p?c:4*a.length},toString:function(a){return(a||v).stringify(this)},concat:function(a){var c=this.words,e=a.words,j=this.sigBytes;a=a.sigBytes;this.clamp();if(j%4)for(var k=0;k<a;k++)c[j+k>>>2]|=(e[k>>>2]>>>24-8*(k%4)&255)<<24-8*((j+k)%4);else if(65535<e.length)for(k=0;k<a;k+=4)c[j+k>>>2]=e[k>>>2];else c.push.apply(c,e);this.sigBytes+=a;return this},clamp:function(){var a=this.words,c=this.sigBytes;a[c>>>2]&=4294967295<<\n32-8*(c%4);a.length=u.ceil(c/4)},clone:function(){var a=t.clone.call(this);a.words=this.words.slice(0);return a},random:function(a){for(var c=[],e=0;e<a;e+=4)c.push(4294967296*u.random()|0);return new r.init(c,a)}}),w=d.enc={},v=w.Hex={stringify:function(a){var c=a.words;a=a.sigBytes;for(var e=[],j=0;j<a;j++){var k=c[j>>>2]>>>24-8*(j%4)&255;e.push((k>>>4).toString(16));e.push((k&15).toString(16))}return e.join(\"\")},parse:function(a){for(var c=a.length,e=[],j=0;j<c;j+=2)e[j>>>3]|=parseInt(a.substr(j,\n2),16)<<24-4*(j%8);return new r.init(e,c/2)}},b=w.Latin1={stringify:function(a){var c=a.words;a=a.sigBytes;for(var e=[],j=0;j<a;j++)e.push(String.fromCharCode(c[j>>>2]>>>24-8*(j%4)&255));return e.join(\"\")},parse:function(a){for(var c=a.length,e=[],j=0;j<c;j++)e[j>>>2]|=(a.charCodeAt(j)&255)<<24-8*(j%4);return new r.init(e,c)}},x=w.Utf8={stringify:function(a){try{return decodeURIComponent(escape(b.stringify(a)))}catch(c){throw Error(\"Malformed UTF-8 data\");}},parse:function(a){return b.parse(unescape(encodeURIComponent(a)))}},\nq=l.BufferedBlockAlgorithm=t.extend({reset:function(){this._data=new r.init;this._nDataBytes=0},_append:function(a){\"string\"==typeof a&&(a=x.parse(a));this._data.concat(a);this._nDataBytes+=a.sigBytes},_process:function(a){var c=this._data,e=c.words,j=c.sigBytes,k=this.blockSize,b=j/(4*k),b=a?u.ceil(b):u.max((b|0)-this._minBufferSize,0);a=b*k;j=u.min(4*a,j);if(a){for(var q=0;q<a;q+=k)this._doProcessBlock(e,q);q=e.splice(0,a);c.sigBytes-=j}return new r.init(q,j)},clone:function(){var a=t.clone.call(this);\na._data=this._data.clone();return a},_minBufferSize:0});l.Hasher=q.extend({cfg:t.extend(),init:function(a){this.cfg=this.cfg.extend(a);this.reset()},reset:function(){q.reset.call(this);this._doReset()},update:function(a){this._append(a);this._process();return this},finalize:function(a){a&&this._append(a);return this._doFinalize()},blockSize:16,_createHelper:function(a){return function(b,e){return(new a.init(e)).finalize(b)}},_createHmacHelper:function(a){return function(b,e){return(new n.HMAC.init(a,\ne)).finalize(b)}}});var n=d.algo={};return d}(Math);\n(function(){var u=CryptoJS,p=u.lib.WordArray;u.enc.Base64={stringify:function(d){var l=d.words,p=d.sigBytes,t=this._map;d.clamp();d=[];for(var r=0;r<p;r+=3)for(var w=(l[r>>>2]>>>24-8*(r%4)&255)<<16|(l[r+1>>>2]>>>24-8*((r+1)%4)&255)<<8|l[r+2>>>2]>>>24-8*((r+2)%4)&255,v=0;4>v&&r+0.75*v<p;v++)d.push(t.charAt(w>>>6*(3-v)&63));if(l=t.charAt(64))for(;d.length%4;)d.push(l);return d.join(\"\")},parse:function(d){var l=d.length,s=this._map,t=s.charAt(64);t&&(t=d.indexOf(t),-1!=t&&(l=t));for(var t=[],r=0,w=0;w<\nl;w++)if(w%4){var v=s.indexOf(d.charAt(w-1))<<2*(w%4),b=s.indexOf(d.charAt(w))>>>6-2*(w%4);t[r>>>2]|=(v|b)<<24-8*(r%4);r++}return p.create(t,r)},_map:\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\"}})();\n(function(u){function p(b,n,a,c,e,j,k){b=b+(n&a|~n&c)+e+k;return(b<<j|b>>>32-j)+n}function d(b,n,a,c,e,j,k){b=b+(n&c|a&~c)+e+k;return(b<<j|b>>>32-j)+n}function l(b,n,a,c,e,j,k){b=b+(n^a^c)+e+k;return(b<<j|b>>>32-j)+n}function s(b,n,a,c,e,j,k){b=b+(a^(n|~c))+e+k;return(b<<j|b>>>32-j)+n}for(var t=CryptoJS,r=t.lib,w=r.WordArray,v=r.Hasher,r=t.algo,b=[],x=0;64>x;x++)b[x]=4294967296*u.abs(u.sin(x+1))|0;r=r.MD5=v.extend({_doReset:function(){this._hash=new w.init([1732584193,4023233417,2562383102,271733878])},\n_doProcessBlock:function(q,n){for(var a=0;16>a;a++){var c=n+a,e=q[c];q[c]=(e<<8|e>>>24)&16711935|(e<<24|e>>>8)&4278255360}var a=this._hash.words,c=q[n+0],e=q[n+1],j=q[n+2],k=q[n+3],z=q[n+4],r=q[n+5],t=q[n+6],w=q[n+7],v=q[n+8],A=q[n+9],B=q[n+10],C=q[n+11],u=q[n+12],D=q[n+13],E=q[n+14],x=q[n+15],f=a[0],m=a[1],g=a[2],h=a[3],f=p(f,m,g,h,c,7,b[0]),h=p(h,f,m,g,e,12,b[1]),g=p(g,h,f,m,j,17,b[2]),m=p(m,g,h,f,k,22,b[3]),f=p(f,m,g,h,z,7,b[4]),h=p(h,f,m,g,r,12,b[5]),g=p(g,h,f,m,t,17,b[6]),m=p(m,g,h,f,w,22,b[7]),\nf=p(f,m,g,h,v,7,b[8]),h=p(h,f,m,g,A,12,b[9]),g=p(g,h,f,m,B,17,b[10]),m=p(m,g,h,f,C,22,b[11]),f=p(f,m,g,h,u,7,b[12]),h=p(h,f,m,g,D,12,b[13]),g=p(g,h,f,m,E,17,b[14]),m=p(m,g,h,f,x,22,b[15]),f=d(f,m,g,h,e,5,b[16]),h=d(h,f,m,g,t,9,b[17]),g=d(g,h,f,m,C,14,b[18]),m=d(m,g,h,f,c,20,b[19]),f=d(f,m,g,h,r,5,b[20]),h=d(h,f,m,g,B,9,b[21]),g=d(g,h,f,m,x,14,b[22]),m=d(m,g,h,f,z,20,b[23]),f=d(f,m,g,h,A,5,b[24]),h=d(h,f,m,g,E,9,b[25]),g=d(g,h,f,m,k,14,b[26]),m=d(m,g,h,f,v,20,b[27]),f=d(f,m,g,h,D,5,b[28]),h=d(h,f,\nm,g,j,9,b[29]),g=d(g,h,f,m,w,14,b[30]),m=d(m,g,h,f,u,20,b[31]),f=l(f,m,g,h,r,4,b[32]),h=l(h,f,m,g,v,11,b[33]),g=l(g,h,f,m,C,16,b[34]),m=l(m,g,h,f,E,23,b[35]),f=l(f,m,g,h,e,4,b[36]),h=l(h,f,m,g,z,11,b[37]),g=l(g,h,f,m,w,16,b[38]),m=l(m,g,h,f,B,23,b[39]),f=l(f,m,g,h,D,4,b[40]),h=l(h,f,m,g,c,11,b[41]),g=l(g,h,f,m,k,16,b[42]),m=l(m,g,h,f,t,23,b[43]),f=l(f,m,g,h,A,4,b[44]),h=l(h,f,m,g,u,11,b[45]),g=l(g,h,f,m,x,16,b[46]),m=l(m,g,h,f,j,23,b[47]),f=s(f,m,g,h,c,6,b[48]),h=s(h,f,m,g,w,10,b[49]),g=s(g,h,f,m,\nE,15,b[50]),m=s(m,g,h,f,r,21,b[51]),f=s(f,m,g,h,u,6,b[52]),h=s(h,f,m,g,k,10,b[53]),g=s(g,h,f,m,B,15,b[54]),m=s(m,g,h,f,e,21,b[55]),f=s(f,m,g,h,v,6,b[56]),h=s(h,f,m,g,x,10,b[57]),g=s(g,h,f,m,t,15,b[58]),m=s(m,g,h,f,D,21,b[59]),f=s(f,m,g,h,z,6,b[60]),h=s(h,f,m,g,C,10,b[61]),g=s(g,h,f,m,j,15,b[62]),m=s(m,g,h,f,A,21,b[63]);a[0]=a[0]+f|0;a[1]=a[1]+m|0;a[2]=a[2]+g|0;a[3]=a[3]+h|0},_doFinalize:function(){var b=this._data,n=b.words,a=8*this._nDataBytes,c=8*b.sigBytes;n[c>>>5]|=128<<24-c%32;var e=u.floor(a/\n4294967296);n[(c+64>>>9<<4)+15]=(e<<8|e>>>24)&16711935|(e<<24|e>>>8)&4278255360;n[(c+64>>>9<<4)+14]=(a<<8|a>>>24)&16711935|(a<<24|a>>>8)&4278255360;b.sigBytes=4*(n.length+1);this._process();b=this._hash;n=b.words;for(a=0;4>a;a++)c=n[a],n[a]=(c<<8|c>>>24)&16711935|(c<<24|c>>>8)&4278255360;return b},clone:function(){var b=v.clone.call(this);b._hash=this._hash.clone();return b}});t.MD5=v._createHelper(r);t.HmacMD5=v._createHmacHelper(r)})(Math);\n(function(){var u=CryptoJS,p=u.lib,d=p.Base,l=p.WordArray,p=u.algo,s=p.EvpKDF=d.extend({cfg:d.extend({keySize:4,hasher:p.MD5,iterations:1}),init:function(d){this.cfg=this.cfg.extend(d)},compute:function(d,r){for(var p=this.cfg,s=p.hasher.create(),b=l.create(),u=b.words,q=p.keySize,p=p.iterations;u.length<q;){n&&s.update(n);var n=s.update(d).finalize(r);s.reset();for(var a=1;a<p;a++)n=s.finalize(n),s.reset();b.concat(n)}b.sigBytes=4*q;return b}});u.EvpKDF=function(d,l,p){return s.create(p).compute(d,\nl)}})();\nCryptoJS.lib.Cipher||function(u){var p=CryptoJS,d=p.lib,l=d.Base,s=d.WordArray,t=d.BufferedBlockAlgorithm,r=p.enc.Base64,w=p.algo.EvpKDF,v=d.Cipher=t.extend({cfg:l.extend(),createEncryptor:function(e,a){return this.create(this._ENC_XFORM_MODE,e,a)},createDecryptor:function(e,a){return this.create(this._DEC_XFORM_MODE,e,a)},init:function(e,a,b){this.cfg=this.cfg.extend(b);this._xformMode=e;this._key=a;this.reset()},reset:function(){t.reset.call(this);this._doReset()},process:function(e){this._append(e);return this._process()},\nfinalize:function(e){e&&this._append(e);return this._doFinalize()},keySize:4,ivSize:4,_ENC_XFORM_MODE:1,_DEC_XFORM_MODE:2,_createHelper:function(e){return{encrypt:function(b,k,d){return(\"string\"==typeof k?c:a).encrypt(e,b,k,d)},decrypt:function(b,k,d){return(\"string\"==typeof k?c:a).decrypt(e,b,k,d)}}}});d.StreamCipher=v.extend({_doFinalize:function(){return this._process(!0)},blockSize:1});var b=p.mode={},x=function(e,a,b){var c=this._iv;c?this._iv=u:c=this._prevBlock;for(var d=0;d<b;d++)e[a+d]^=\nc[d]},q=(d.BlockCipherMode=l.extend({createEncryptor:function(e,a){return this.Encryptor.create(e,a)},createDecryptor:function(e,a){return this.Decryptor.create(e,a)},init:function(e,a){this._cipher=e;this._iv=a}})).extend();q.Encryptor=q.extend({processBlock:function(e,a){var b=this._cipher,c=b.blockSize;x.call(this,e,a,c);b.encryptBlock(e,a);this._prevBlock=e.slice(a,a+c)}});q.Decryptor=q.extend({processBlock:function(e,a){var b=this._cipher,c=b.blockSize,d=e.slice(a,a+c);b.decryptBlock(e,a);x.call(this,\ne,a,c);this._prevBlock=d}});b=b.CBC=q;q=(p.pad={}).Pkcs7={pad:function(a,b){for(var c=4*b,c=c-a.sigBytes%c,d=c<<24|c<<16|c<<8|c,l=[],n=0;n<c;n+=4)l.push(d);c=s.create(l,c);a.concat(c)},unpad:function(a){a.sigBytes-=a.words[a.sigBytes-1>>>2]&255}};d.BlockCipher=v.extend({cfg:v.cfg.extend({mode:b,padding:q}),reset:function(){v.reset.call(this);var a=this.cfg,b=a.iv,a=a.mode;if(this._xformMode==this._ENC_XFORM_MODE)var c=a.createEncryptor;else c=a.createDecryptor,this._minBufferSize=1;this._mode=c.call(a,\nthis,b&&b.words)},_doProcessBlock:function(a,b){this._mode.processBlock(a,b)},_doFinalize:function(){var a=this.cfg.padding;if(this._xformMode==this._ENC_XFORM_MODE){a.pad(this._data,this.blockSize);var b=this._process(!0)}else b=this._process(!0),a.unpad(b);return b},blockSize:4});var n=d.CipherParams=l.extend({init:function(a){this.mixIn(a)},toString:function(a){return(a||this.formatter).stringify(this)}}),b=(p.format={}).OpenSSL={stringify:function(a){var b=a.ciphertext;a=a.salt;return(a?s.create([1398893684,\n1701076831]).concat(a).concat(b):b).toString(r)},parse:function(a){a=r.parse(a);var b=a.words;if(1398893684==b[0]&&1701076831==b[1]){var c=s.create(b.slice(2,4));b.splice(0,4);a.sigBytes-=16}return n.create({ciphertext:a,salt:c})}},a=d.SerializableCipher=l.extend({cfg:l.extend({format:b}),encrypt:function(a,b,c,d){d=this.cfg.extend(d);var l=a.createEncryptor(c,d);b=l.finalize(b);l=l.cfg;return n.create({ciphertext:b,key:c,iv:l.iv,algorithm:a,mode:l.mode,padding:l.padding,blockSize:a.blockSize,formatter:d.format})},\ndecrypt:function(a,b,c,d){d=this.cfg.extend(d);b=this._parse(b,d.format);return a.createDecryptor(c,d).finalize(b.ciphertext)},_parse:function(a,b){return\"string\"==typeof a?b.parse(a,this):a}}),p=(p.kdf={}).OpenSSL={execute:function(a,b,c,d){d||(d=s.random(8));a=w.create({keySize:b+c}).compute(a,d);c=s.create(a.words.slice(b),4*c);a.sigBytes=4*b;return n.create({key:a,iv:c,salt:d})}},c=d.PasswordBasedCipher=a.extend({cfg:a.cfg.extend({kdf:p}),encrypt:function(b,c,d,l){l=this.cfg.extend(l);d=l.kdf.execute(d,\nb.keySize,b.ivSize);l.iv=d.iv;b=a.encrypt.call(this,b,c,d.key,l);b.mixIn(d);return b},decrypt:function(b,c,d,l){l=this.cfg.extend(l);c=this._parse(c,l.format);d=l.kdf.execute(d,b.keySize,b.ivSize,c.salt);l.iv=d.iv;return a.decrypt.call(this,b,c,d.key,l)}})}();\n(function(){for(var u=CryptoJS,p=u.lib.BlockCipher,d=u.algo,l=[],s=[],t=[],r=[],w=[],v=[],b=[],x=[],q=[],n=[],a=[],c=0;256>c;c++)a[c]=128>c?c<<1:c<<1^283;for(var e=0,j=0,c=0;256>c;c++){var k=j^j<<1^j<<2^j<<3^j<<4,k=k>>>8^k&255^99;l[e]=k;s[k]=e;var z=a[e],F=a[z],G=a[F],y=257*a[k]^16843008*k;t[e]=y<<24|y>>>8;r[e]=y<<16|y>>>16;w[e]=y<<8|y>>>24;v[e]=y;y=16843009*G^65537*F^257*z^16843008*e;b[k]=y<<24|y>>>8;x[k]=y<<16|y>>>16;q[k]=y<<8|y>>>24;n[k]=y;e?(e=z^a[a[a[G^z]]],j^=a[a[j]]):e=j=1}var H=[0,1,2,4,8,\n16,32,64,128,27,54],d=d.AES=p.extend({_doReset:function(){for(var a=this._key,c=a.words,d=a.sigBytes/4,a=4*((this._nRounds=d+6)+1),e=this._keySchedule=[],j=0;j<a;j++)if(j<d)e[j]=c[j];else{var k=e[j-1];j%d?6<d&&4==j%d&&(k=l[k>>>24]<<24|l[k>>>16&255]<<16|l[k>>>8&255]<<8|l[k&255]):(k=k<<8|k>>>24,k=l[k>>>24]<<24|l[k>>>16&255]<<16|l[k>>>8&255]<<8|l[k&255],k^=H[j/d|0]<<24);e[j]=e[j-d]^k}c=this._invKeySchedule=[];for(d=0;d<a;d++)j=a-d,k=d%4?e[j]:e[j-4],c[d]=4>d||4>=j?k:b[l[k>>>24]]^x[l[k>>>16&255]]^q[l[k>>>\n8&255]]^n[l[k&255]]},encryptBlock:function(a,b){this._doCryptBlock(a,b,this._keySchedule,t,r,w,v,l)},decryptBlock:function(a,c){var d=a[c+1];a[c+1]=a[c+3];a[c+3]=d;this._doCryptBlock(a,c,this._invKeySchedule,b,x,q,n,s);d=a[c+1];a[c+1]=a[c+3];a[c+3]=d},_doCryptBlock:function(a,b,c,d,e,j,l,f){for(var m=this._nRounds,g=a[b]^c[0],h=a[b+1]^c[1],k=a[b+2]^c[2],n=a[b+3]^c[3],p=4,r=1;r<m;r++)var q=d[g>>>24]^e[h>>>16&255]^j[k>>>8&255]^l[n&255]^c[p++],s=d[h>>>24]^e[k>>>16&255]^j[n>>>8&255]^l[g&255]^c[p++],t=\nd[k>>>24]^e[n>>>16&255]^j[g>>>8&255]^l[h&255]^c[p++],n=d[n>>>24]^e[g>>>16&255]^j[h>>>8&255]^l[k&255]^c[p++],g=q,h=s,k=t;q=(f[g>>>24]<<24|f[h>>>16&255]<<16|f[k>>>8&255]<<8|f[n&255])^c[p++];s=(f[h>>>24]<<24|f[k>>>16&255]<<16|f[n>>>8&255]<<8|f[g&255])^c[p++];t=(f[k>>>24]<<24|f[n>>>16&255]<<16|f[g>>>8&255]<<8|f[h&255])^c[p++];n=(f[n>>>24]<<24|f[g>>>16&255]<<16|f[h>>>8&255]<<8|f[k&255])^c[p++];a[b]=q;a[b+1]=s;a[b+2]=t;a[b+3]=n},keySize:8});u.AES=p._createHelper(d)})();\n\nlet urlParams = new URLSearchParams(window.location.search);\nlet ngio_request = new XMLHttpRequest();\nngio_request.addEventListener(\"load\", function() {\n    console.log(\"The response from the NewGrounds.io request was: \", ngio_request.responseText);\n});\nngio_request.open('POST', '//newgrounds.io/gateway_v3.php', true);\nlet call_parameters = {\"component\": component, \"parameters\": parameters};\nlet iv = CryptoJS.lib.WordArray.random(16);\nlet encrypted = CryptoJS.AES.encrypt(JSON.stringify(call_parameters), CryptoJS.enc.Base64.parse(aes_key), { iv: iv });\nlet secure_encoding = CryptoJS.enc.Base64.stringify(iv.concat(encrypted.ciphertext));\nlet input = {\n    \"app_id\": app_id,\n    \"session_id\": urlParams.get(\"ngio_session_id\"),\n    \"call\": {\"secure\": secure_encoding},\n}\nlet formData = new FormData();\nformData.append('input', JSON.stringify(input));\nngio_request.send(formData);","parameterObjects":"","useStrict":true,"eventsSheetExpanded":true}],"parameters":[{"codeOnly":false,"defaultValue":"","description":"Your game's App ID, found on your NewGrounds project page under the API tools","longDescription":"","name":"AppID","optional":false,"supplementaryInformation":"","type":"string"},{"codeOnly":false,"defaultValue":"","description":"Your game's AES encryption key, also found in your game's API tools","longDescription":"","name":"AesKey","optional":false,"supplementaryInformation":"","type":"string"},{"codeOnly":false,"defaultValue":"","description":"The NewGrounds.io component you want to call. Use Medal.unlock or ScoreBoard.postScore here.","longDescription":"","name":"Component","optional":false,"supplementaryInformation":"","type":"string"},{"codeOnly":false,"defaultValue":"","description":"Either the medal's ID or the scoreboard's ID (you'll get the ID after you set up the medal or scoreboard at NG in the API tools)","longDescription":"","name":"MedalOrScoreboardID","optional":false,"supplementaryInformation":"","type":"expression"},{"codeOnly":false,"defaultValue":"","description":"The score if you're posting to a leaderboard. This has no effect if you're unlocking a medal.","longDescription":"","name":"LeaderboardScore","optional":false,"supplementaryInformation":"","type":"expression"}],"objectGroups":[]}],"eventsBasedBehaviors":[]},"name":"NGIO"}}

Tags:

8

Posted by 3p0ch - September 9th, 2021


Note: This NewGrounds API implementation was written for Godot 3.x. If you're using Godot 4, that version of the implementation is available at

https://3p0ch.newgrounds.com/news/post/1374957

but as of this writing (July 2023) Godot 4 web exports are not compatible with Apple (macOS and iOS) devices so I would recommend sticking with Godot 3 until that's confirmed to be fixed.


Just copy/paste the code below and save it in your Godot project as an AutoLoad script named ngio.gd and see the instructions near the top.


extends Node

var app_id = "" # Put your app ID from NewGrounds here
var aes_key = "" # Put your AES-128/Base64 encryption key 
var show_cloudsave_results = true # Whether to show API call results in Developer Tools - Console
#                                   You might want to turn that false in the final published version

# To add this NewGrounds.io helper script to your Godot project as an AutoLoad:
#   Save this file as ngio.gd anywhere in your project
#   In the top menu bar: Project -> Project Settings... -> AutoLoad tab
#   Click the folder button next to Path and choose this file (ngio.gd)
#   To the right of the Node Name box (it can say Ngio) click the Add button
# It should be added to your AutoLoad list and you're good to go

# Make sure you've added your App ID, and your
# AES-128/Base64 encryption key, to the lines near the top

# To make calls to the NewGrounds.io API from your scripts, use the command
#   Ngio.request(component, parameters)
# For example, to send a score to a leaderboard
#   Ngio.request("ScoreBoard.postScore", {"id": your_scoreboard_id, "value": the_score})
# Or to unlock a medal
#   Ngio.request("Medal.unlock", {"id": your_medal_id})
# For details on the components, see http://www.newgrounds.io/help/components/

# If you want to handle results from a component call, you can do it by
# making the script calling ngio.gd set up a funcref for the function that
# will handle the results, like so ...
# func _ready():
# 	Ngio.request("Medal.getList", {}, funcref(self, "handle_results"))
# 
# func handle_results(results):
# 	print ("Results: ", results) # results will show in your browser under Developer Tools in the Console tab)

# Cloud saving is more complicated and for a full description see my news post
# https://3p0ch.newgrounds.com/news/post/1294903
# But here's a quick reference
#   Ngio.cloud_save(data_to_be_saved, slot_number, funcref(self, "_on_cloudsave_complete"))
#     slot_number is optional and default is slot 1
#     the funcref is optional and in practice you probably don't want to use it
#
#   Ngio.cloud_load(funcref(self, "_on_cloud_loaded"), slot_number)
#     slot_number is optional and default is slot 1
#     the function that the funcref points to should be something like
#   func _on_cloud_loaded(loaded_data):
#     if loaded_data == null:
#       there was no data in that save slot or load attempt failed
#     elif loaded_data is TheDataStructureYouExpect: (likely a Dictionary)
#       handle the loaded data
#
#   Ngio.cloud_load_all(funcref(self, "_on_cloud_loaded"))
#     the function that funcref points to should handle each save slot as it comes in
#   func _on_cloud_loaded(slot_number, loaded_data):
#
#   To clear the data from a slot
#   Ngio.request("CloudSave.clearSlot", {"id": slot_number})

var newgroundsio_request
var session_id
var gateway_uri = "https://newgrounds.io/gateway_v3.php"

# Ping the server occasionally to keep the session_id from timing out
var ping_cooldown_max:float = 300.0
var ping_cooldown:float = ping_cooldown_max
func _physics_process(delta):
	ping_cooldown -= delta
	if ping_cooldown < 0.0:
		request("Gateway.ping", {})
		ping_cooldown = ping_cooldown_max

func request(component, parameters, func_ref = null, on_load_function = null):
	if OS.has_feature('JavaScript'):
		newgroundsio_request = HTTPRequest.new()
		add_child(newgroundsio_request)
		if func_ref and not on_load_function:
			newgroundsio_request.connect("request_completed", self, "_request_completed", [func_ref])
		if func_ref and on_load_function:
			newgroundsio_request.connect("request_completed", self, "_request_completed", [func_ref, on_load_function])
		
		var call_parameters = {
			"component": component,
			"parameters": parameters,
		}
		
		var secure_encoding = str(JavaScript.eval(
			"""
				/*
				CryptoJS v3.1.2
				code.google.com/p/crypto-js
				(c) 2009-2013 by Jeff Mott. All rights reserved.
				code.google.com/p/crypto-js/wiki/License
				*/
				var CryptoJS=CryptoJS||function(u,p){var d={},l=d.lib={},s=function(){},t=l.Base={extend:function(a){s.prototype=this;var c=new s;a&&c.mixIn(a);c.hasOwnProperty("init")||(c.init=function(){c.$super.init.apply(this,arguments)});c.init.prototype=c;c.$super=this;return c},create:function(){var a=this.extend();a.init.apply(a,arguments);return a},init:function(){},mixIn:function(a){for(var c in a)a.hasOwnProperty(c)&&(this[c]=a[c]);a.hasOwnProperty("toString")&&(this.toString=a.toString)},clone:function(){return this.init.prototype.extend(this)}},
				r=l.WordArray=t.extend({init:function(a,c){a=this.words=a||[];this.sigBytes=c!=p?c:4*a.length},toString:function(a){return(a||v).stringify(this)},concat:function(a){var c=this.words,e=a.words,j=this.sigBytes;a=a.sigBytes;this.clamp();if(j%4)for(var k=0;k<a;k++)c[j+k>>>2]|=(e[k>>>2]>>>24-8*(k%4)&255)<<24-8*((j+k)%4);else if(65535<e.length)for(k=0;k<a;k+=4)c[j+k>>>2]=e[k>>>2];else c.push.apply(c,e);this.sigBytes+=a;return this},clamp:function(){var a=this.words,c=this.sigBytes;a[c>>>2]&=4294967295<<
				32-8*(c%4);a.length=u.ceil(c/4)},clone:function(){var a=t.clone.call(this);a.words=this.words.slice(0);return a},random:function(a){for(var c=[],e=0;e<a;e+=4)c.push(4294967296*u.random()|0);return new r.init(c,a)}}),w=d.enc={},v=w.Hex={stringify:function(a){var c=a.words;a=a.sigBytes;for(var e=[],j=0;j<a;j++){var k=c[j>>>2]>>>24-8*(j%4)&255;e.push((k>>>4).toString(16));e.push((k&15).toString(16))}return e.join("")},parse:function(a){for(var c=a.length,e=[],j=0;j<c;j+=2)e[j>>>3]|=parseInt(a.substr(j,
				2),16)<<24-4*(j%8);return new r.init(e,c/2)}},b=w.Latin1={stringify:function(a){var c=a.words;a=a.sigBytes;for(var e=[],j=0;j<a;j++)e.push(String.fromCharCode(c[j>>>2]>>>24-8*(j%4)&255));return e.join("")},parse:function(a){for(var c=a.length,e=[],j=0;j<c;j++)e[j>>>2]|=(a.charCodeAt(j)&255)<<24-8*(j%4);return new r.init(e,c)}},x=w.Utf8={stringify:function(a){try{return decodeURIComponent(escape(b.stringify(a)))}catch(c){throw Error("Malformed UTF-8 data");}},parse:function(a){return b.parse(unescape(encodeURIComponent(a)))}},
				q=l.BufferedBlockAlgorithm=t.extend({reset:function(){this._data=new r.init;this._nDataBytes=0},_append:function(a){"string"==typeof a&&(a=x.parse(a));this._data.concat(a);this._nDataBytes+=a.sigBytes},_process:function(a){var c=this._data,e=c.words,j=c.sigBytes,k=this.blockSize,b=j/(4*k),b=a?u.ceil(b):u.max((b|0)-this._minBufferSize,0);a=b*k;j=u.min(4*a,j);if(a){for(var q=0;q<a;q+=k)this._doProcessBlock(e,q);q=e.splice(0,a);c.sigBytes-=j}return new r.init(q,j)},clone:function(){var a=t.clone.call(this);
				a._data=this._data.clone();return a},_minBufferSize:0});l.Hasher=q.extend({cfg:t.extend(),init:function(a){this.cfg=this.cfg.extend(a);this.reset()},reset:function(){q.reset.call(this);this._doReset()},update:function(a){this._append(a);this._process();return this},finalize:function(a){a&&this._append(a);return this._doFinalize()},blockSize:16,_createHelper:function(a){return function(b,e){return(new a.init(e)).finalize(b)}},_createHmacHelper:function(a){return function(b,e){return(new n.HMAC.init(a,
				e)).finalize(b)}}});var n=d.algo={};return d}(Math);
				(function(){var u=CryptoJS,p=u.lib.WordArray;u.enc.Base64={stringify:function(d){var l=d.words,p=d.sigBytes,t=this._map;d.clamp();d=[];for(var r=0;r<p;r+=3)for(var w=(l[r>>>2]>>>24-8*(r%4)&255)<<16|(l[r+1>>>2]>>>24-8*((r+1)%4)&255)<<8|l[r+2>>>2]>>>24-8*((r+2)%4)&255,v=0;4>v&&r+0.75*v<p;v++)d.push(t.charAt(w>>>6*(3-v)&63));if(l=t.charAt(64))for(;d.length%4;)d.push(l);return d.join("")},parse:function(d){var l=d.length,s=this._map,t=s.charAt(64);t&&(t=d.indexOf(t),-1!=t&&(l=t));for(var t=[],r=0,w=0;w<
				l;w++)if(w%4){var v=s.indexOf(d.charAt(w-1))<<2*(w%4),b=s.indexOf(d.charAt(w))>>>6-2*(w%4);t[r>>>2]|=(v|b)<<24-8*(r%4);r++}return p.create(t,r)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="}})();
				(function(u){function p(b,n,a,c,e,j,k){b=b+(n&a|~n&c)+e+k;return(b<<j|b>>>32-j)+n}function d(b,n,a,c,e,j,k){b=b+(n&c|a&~c)+e+k;return(b<<j|b>>>32-j)+n}function l(b,n,a,c,e,j,k){b=b+(n^a^c)+e+k;return(b<<j|b>>>32-j)+n}function s(b,n,a,c,e,j,k){b=b+(a^(n|~c))+e+k;return(b<<j|b>>>32-j)+n}for(var t=CryptoJS,r=t.lib,w=r.WordArray,v=r.Hasher,r=t.algo,b=[],x=0;64>x;x++)b[x]=4294967296*u.abs(u.sin(x+1))|0;r=r.MD5=v.extend({_doReset:function(){this._hash=new w.init([1732584193,4023233417,2562383102,271733878])},
				_doProcessBlock:function(q,n){for(var a=0;16>a;a++){var c=n+a,e=q[c];q[c]=(e<<8|e>>>24)&16711935|(e<<24|e>>>8)&4278255360}var a=this._hash.words,c=q[n+0],e=q[n+1],j=q[n+2],k=q[n+3],z=q[n+4],r=q[n+5],t=q[n+6],w=q[n+7],v=q[n+8],A=q[n+9],B=q[n+10],C=q[n+11],u=q[n+12],D=q[n+13],E=q[n+14],x=q[n+15],f=a[0],m=a[1],g=a[2],h=a[3],f=p(f,m,g,h,c,7,b[0]),h=p(h,f,m,g,e,12,b[1]),g=p(g,h,f,m,j,17,b[2]),m=p(m,g,h,f,k,22,b[3]),f=p(f,m,g,h,z,7,b[4]),h=p(h,f,m,g,r,12,b[5]),g=p(g,h,f,m,t,17,b[6]),m=p(m,g,h,f,w,22,b[7]),
				f=p(f,m,g,h,v,7,b[8]),h=p(h,f,m,g,A,12,b[9]),g=p(g,h,f,m,B,17,b[10]),m=p(m,g,h,f,C,22,b[11]),f=p(f,m,g,h,u,7,b[12]),h=p(h,f,m,g,D,12,b[13]),g=p(g,h,f,m,E,17,b[14]),m=p(m,g,h,f,x,22,b[15]),f=d(f,m,g,h,e,5,b[16]),h=d(h,f,m,g,t,9,b[17]),g=d(g,h,f,m,C,14,b[18]),m=d(m,g,h,f,c,20,b[19]),f=d(f,m,g,h,r,5,b[20]),h=d(h,f,m,g,B,9,b[21]),g=d(g,h,f,m,x,14,b[22]),m=d(m,g,h,f,z,20,b[23]),f=d(f,m,g,h,A,5,b[24]),h=d(h,f,m,g,E,9,b[25]),g=d(g,h,f,m,k,14,b[26]),m=d(m,g,h,f,v,20,b[27]),f=d(f,m,g,h,D,5,b[28]),h=d(h,f,
				m,g,j,9,b[29]),g=d(g,h,f,m,w,14,b[30]),m=d(m,g,h,f,u,20,b[31]),f=l(f,m,g,h,r,4,b[32]),h=l(h,f,m,g,v,11,b[33]),g=l(g,h,f,m,C,16,b[34]),m=l(m,g,h,f,E,23,b[35]),f=l(f,m,g,h,e,4,b[36]),h=l(h,f,m,g,z,11,b[37]),g=l(g,h,f,m,w,16,b[38]),m=l(m,g,h,f,B,23,b[39]),f=l(f,m,g,h,D,4,b[40]),h=l(h,f,m,g,c,11,b[41]),g=l(g,h,f,m,k,16,b[42]),m=l(m,g,h,f,t,23,b[43]),f=l(f,m,g,h,A,4,b[44]),h=l(h,f,m,g,u,11,b[45]),g=l(g,h,f,m,x,16,b[46]),m=l(m,g,h,f,j,23,b[47]),f=s(f,m,g,h,c,6,b[48]),h=s(h,f,m,g,w,10,b[49]),g=s(g,h,f,m,
				E,15,b[50]),m=s(m,g,h,f,r,21,b[51]),f=s(f,m,g,h,u,6,b[52]),h=s(h,f,m,g,k,10,b[53]),g=s(g,h,f,m,B,15,b[54]),m=s(m,g,h,f,e,21,b[55]),f=s(f,m,g,h,v,6,b[56]),h=s(h,f,m,g,x,10,b[57]),g=s(g,h,f,m,t,15,b[58]),m=s(m,g,h,f,D,21,b[59]),f=s(f,m,g,h,z,6,b[60]),h=s(h,f,m,g,C,10,b[61]),g=s(g,h,f,m,j,15,b[62]),m=s(m,g,h,f,A,21,b[63]);a[0]=a[0]+f|0;a[1]=a[1]+m|0;a[2]=a[2]+g|0;a[3]=a[3]+h|0},_doFinalize:function(){var b=this._data,n=b.words,a=8*this._nDataBytes,c=8*b.sigBytes;n[c>>>5]|=128<<24-c%32;var e=u.floor(a/
				4294967296);n[(c+64>>>9<<4)+15]=(e<<8|e>>>24)&16711935|(e<<24|e>>>8)&4278255360;n[(c+64>>>9<<4)+14]=(a<<8|a>>>24)&16711935|(a<<24|a>>>8)&4278255360;b.sigBytes=4*(n.length+1);this._process();b=this._hash;n=b.words;for(a=0;4>a;a++)c=n[a],n[a]=(c<<8|c>>>24)&16711935|(c<<24|c>>>8)&4278255360;return b},clone:function(){var b=v.clone.call(this);b._hash=this._hash.clone();return b}});t.MD5=v._createHelper(r);t.HmacMD5=v._createHmacHelper(r)})(Math);
				(function(){var u=CryptoJS,p=u.lib,d=p.Base,l=p.WordArray,p=u.algo,s=p.EvpKDF=d.extend({cfg:d.extend({keySize:4,hasher:p.MD5,iterations:1}),init:function(d){this.cfg=this.cfg.extend(d)},compute:function(d,r){for(var p=this.cfg,s=p.hasher.create(),b=l.create(),u=b.words,q=p.keySize,p=p.iterations;u.length<q;){n&&s.update(n);var n=s.update(d).finalize(r);s.reset();for(var a=1;a<p;a++)n=s.finalize(n),s.reset();b.concat(n)}b.sigBytes=4*q;return b}});u.EvpKDF=function(d,l,p){return s.create(p).compute(d,
				l)}})();
				CryptoJS.lib.Cipher||function(u){var p=CryptoJS,d=p.lib,l=d.Base,s=d.WordArray,t=d.BufferedBlockAlgorithm,r=p.enc.Base64,w=p.algo.EvpKDF,v=d.Cipher=t.extend({cfg:l.extend(),createEncryptor:function(e,a){return this.create(this._ENC_XFORM_MODE,e,a)},createDecryptor:function(e,a){return this.create(this._DEC_XFORM_MODE,e,a)},init:function(e,a,b){this.cfg=this.cfg.extend(b);this._xformMode=e;this._key=a;this.reset()},reset:function(){t.reset.call(this);this._doReset()},process:function(e){this._append(e);return this._process()},
				finalize:function(e){e&&this._append(e);return this._doFinalize()},keySize:4,ivSize:4,_ENC_XFORM_MODE:1,_DEC_XFORM_MODE:2,_createHelper:function(e){return{encrypt:function(b,k,d){return("string"==typeof k?c:a).encrypt(e,b,k,d)},decrypt:function(b,k,d){return("string"==typeof k?c:a).decrypt(e,b,k,d)}}}});d.StreamCipher=v.extend({_doFinalize:function(){return this._process(!0)},blockSize:1});var b=p.mode={},x=function(e,a,b){var c=this._iv;c?this._iv=u:c=this._prevBlock;for(var d=0;d<b;d++)e[a+d]^=
				c[d]},q=(d.BlockCipherMode=l.extend({createEncryptor:function(e,a){return this.Encryptor.create(e,a)},createDecryptor:function(e,a){return this.Decryptor.create(e,a)},init:function(e,a){this._cipher=e;this._iv=a}})).extend();q.Encryptor=q.extend({processBlock:function(e,a){var b=this._cipher,c=b.blockSize;x.call(this,e,a,c);b.encryptBlock(e,a);this._prevBlock=e.slice(a,a+c)}});q.Decryptor=q.extend({processBlock:function(e,a){var b=this._cipher,c=b.blockSize,d=e.slice(a,a+c);b.decryptBlock(e,a);x.call(this,
				e,a,c);this._prevBlock=d}});b=b.CBC=q;q=(p.pad={}).Pkcs7={pad:function(a,b){for(var c=4*b,c=c-a.sigBytes%c,d=c<<24|c<<16|c<<8|c,l=[],n=0;n<c;n+=4)l.push(d);c=s.create(l,c);a.concat(c)},unpad:function(a){a.sigBytes-=a.words[a.sigBytes-1>>>2]&255}};d.BlockCipher=v.extend({cfg:v.cfg.extend({mode:b,padding:q}),reset:function(){v.reset.call(this);var a=this.cfg,b=a.iv,a=a.mode;if(this._xformMode==this._ENC_XFORM_MODE)var c=a.createEncryptor;else c=a.createDecryptor,this._minBufferSize=1;this._mode=c.call(a,
				this,b&&b.words)},_doProcessBlock:function(a,b){this._mode.processBlock(a,b)},_doFinalize:function(){var a=this.cfg.padding;if(this._xformMode==this._ENC_XFORM_MODE){a.pad(this._data,this.blockSize);var b=this._process(!0)}else b=this._process(!0),a.unpad(b);return b},blockSize:4});var n=d.CipherParams=l.extend({init:function(a){this.mixIn(a)},toString:function(a){return(a||this.formatter).stringify(this)}}),b=(p.format={}).OpenSSL={stringify:function(a){var b=a.ciphertext;a=a.salt;return(a?s.create([1398893684,
				1701076831]).concat(a).concat(b):b).toString(r)},parse:function(a){a=r.parse(a);var b=a.words;if(1398893684==b[0]&&1701076831==b[1]){var c=s.create(b.slice(2,4));b.splice(0,4);a.sigBytes-=16}return n.create({ciphertext:a,salt:c})}},a=d.SerializableCipher=l.extend({cfg:l.extend({format:b}),encrypt:function(a,b,c,d){d=this.cfg.extend(d);var l=a.createEncryptor(c,d);b=l.finalize(b);l=l.cfg;return n.create({ciphertext:b,key:c,iv:l.iv,algorithm:a,mode:l.mode,padding:l.padding,blockSize:a.blockSize,formatter:d.format})},
				decrypt:function(a,b,c,d){d=this.cfg.extend(d);b=this._parse(b,d.format);return a.createDecryptor(c,d).finalize(b.ciphertext)},_parse:function(a,b){return"string"==typeof a?b.parse(a,this):a}}),p=(p.kdf={}).OpenSSL={execute:function(a,b,c,d){d||(d=s.random(8));a=w.create({keySize:b+c}).compute(a,d);c=s.create(a.words.slice(b),4*c);a.sigBytes=4*b;return n.create({key:a,iv:c,salt:d})}},c=d.PasswordBasedCipher=a.extend({cfg:a.cfg.extend({kdf:p}),encrypt:function(b,c,d,l){l=this.cfg.extend(l);d=l.kdf.execute(d,
				b.keySize,b.ivSize);l.iv=d.iv;b=a.encrypt.call(this,b,c,d.key,l);b.mixIn(d);return b},decrypt:function(b,c,d,l){l=this.cfg.extend(l);c=this._parse(c,l.format);d=l.kdf.execute(d,b.keySize,b.ivSize,c.salt);l.iv=d.iv;return a.decrypt.call(this,b,c,d.key,l)}})}();
				(function(){for(var u=CryptoJS,p=u.lib.BlockCipher,d=u.algo,l=[],s=[],t=[],r=[],w=[],v=[],b=[],x=[],q=[],n=[],a=[],c=0;256>c;c++)a[c]=128>c?c<<1:c<<1^283;for(var e=0,j=0,c=0;256>c;c++){var k=j^j<<1^j<<2^j<<3^j<<4,k=k>>>8^k&255^99;l[e]=k;s[k]=e;var z=a[e],F=a[z],G=a[F],y=257*a[k]^16843008*k;t[e]=y<<24|y>>>8;r[e]=y<<16|y>>>16;w[e]=y<<8|y>>>24;v[e]=y;y=16843009*G^65537*F^257*z^16843008*e;b[k]=y<<24|y>>>8;x[k]=y<<16|y>>>16;q[k]=y<<8|y>>>24;n[k]=y;e?(e=z^a[a[a[G^z]]],j^=a[a[j]]):e=j=1}var H=[0,1,2,4,8,
				16,32,64,128,27,54],d=d.AES=p.extend({_doReset:function(){for(var a=this._key,c=a.words,d=a.sigBytes/4,a=4*((this._nRounds=d+6)+1),e=this._keySchedule=[],j=0;j<a;j++)if(j<d)e[j]=c[j];else{var k=e[j-1];j%d?6<d&&4==j%d&&(k=l[k>>>24]<<24|l[k>>>16&255]<<16|l[k>>>8&255]<<8|l[k&255]):(k=k<<8|k>>>24,k=l[k>>>24]<<24|l[k>>>16&255]<<16|l[k>>>8&255]<<8|l[k&255],k^=H[j/d|0]<<24);e[j]=e[j-d]^k}c=this._invKeySchedule=[];for(d=0;d<a;d++)j=a-d,k=d%4?e[j]:e[j-4],c[d]=4>d||4>=j?k:b[l[k>>>24]]^x[l[k>>>16&255]]^q[l[k>>>
				8&255]]^n[l[k&255]]},encryptBlock:function(a,b){this._doCryptBlock(a,b,this._keySchedule,t,r,w,v,l)},decryptBlock:function(a,c){var d=a[c+1];a[c+1]=a[c+3];a[c+3]=d;this._doCryptBlock(a,c,this._invKeySchedule,b,x,q,n,s);d=a[c+1];a[c+1]=a[c+3];a[c+3]=d},_doCryptBlock:function(a,b,c,d,e,j,l,f){for(var m=this._nRounds,g=a[b]^c[0],h=a[b+1]^c[1],k=a[b+2]^c[2],n=a[b+3]^c[3],p=4,r=1;r<m;r++)var q=d[g>>>24]^e[h>>>16&255]^j[k>>>8&255]^l[n&255]^c[p++],s=d[h>>>24]^e[k>>>16&255]^j[n>>>8&255]^l[g&255]^c[p++],t=
				d[k>>>24]^e[n>>>16&255]^j[g>>>8&255]^l[h&255]^c[p++],n=d[n>>>24]^e[g>>>16&255]^j[h>>>8&255]^l[k&255]^c[p++],g=q,h=s,k=t;q=(f[g>>>24]<<24|f[h>>>16&255]<<16|f[k>>>8&255]<<8|f[n&255])^c[p++];s=(f[h>>>24]<<24|f[k>>>16&255]<<16|f[n>>>8&255]<<8|f[g&255])^c[p++];t=(f[k>>>24]<<24|f[n>>>16&255]<<16|f[g>>>8&255]<<8|f[h&255])^c[p++];n=(f[n>>>24]<<24|f[g>>>16&255]<<16|f[h>>>8&255]<<8|f[k&255])^c[p++];a[b]=q;a[b+1]=s;a[b+2]=t;a[b+3]=n},keySize:8});u.AES=p._createHelper(d)})();
			""" +
			"var iv = CryptoJS.lib.WordArray.random(16); " +
			"var encrypted = CryptoJS.AES.encrypt('" + JSON.print(call_parameters) + "', CryptoJS.enc.Base64.parse('" + aes_key + "'), { iv: iv }); " +
			"CryptoJS.enc.Base64.stringify(iv.concat(encrypted.ciphertext));"
			, true))
		
		var input_parameters = {
			"app_id": app_id,
			"session_id": str(JavaScript.eval(
				'var urlParams = new URLSearchParams(window.location.search);' +
				'urlParams.get("ngio_session_id");'
				, true)),
			"call": {
				"secure": secure_encoding,
			}
		}
		
		newgroundsio_request.request(
			gateway_uri,
			["Content-Type: application/x-www-form-urlencoded"],
			true,
			HTTPClient.METHOD_POST,
			"input=" + JSON.print(input_parameters).percent_encode()
		)

func _request_completed(result, response_code, headers, body, func_ref, on_load_function = null):
	if on_load_function:
		func_ref.call_func(parse_json(body.get_string_from_ascii()), on_load_function)
	else:
		func_ref.call_func(parse_json(body.get_string_from_ascii()))


func cloud_save(data:Dictionary, slot:int = 1, func_ref:FuncRef = funcref(self, "_show_cloud_save_results")):
	var stringified_data = to_json(data)
	stringified_data = stringified_data.replace('"', '<<<doublequote>>>')
	stringified_data = stringified_data.replace("'", '<<<singlequote>>>')
	request("CloudSave.setData", {"data": stringified_data, "id": slot}, func_ref)

func _show_cloud_save_results(results):
	if show_cloudsave_results:
		print ("Results from cloud save request: ", results)


func cloud_load(on_load_function:FuncRef, slot:int = 1):
	if show_cloudsave_results:
		print("Sending cloud load request for slot ", slot)
	request("CloudSave.loadSlot", {"id": slot}, funcref(self, "_handle_single_cloud_load_results"), on_load_function)

func _handle_single_cloud_load_results(results, on_load_function:FuncRef):
	var load_http_request = HTTPRequest.new()
	if show_cloudsave_results:
		print("Results from single slot cloud load request (not the actual data yet): ", results)
	if not results["success"]:
		on_load_function.call_func(null)
		return
	if results["result"]["data"]["slot"]["url"] == null:
		on_load_function.call_func(null)
		return
	add_child(load_http_request)
	load_http_request.connect("request_completed", self, "_handle_single_cloud_load_data", [on_load_function])
	load_http_request.request(results["result"]["data"]["slot"]["url"])

func _handle_single_cloud_load_data(result, response_code, headers, body, on_load_function:FuncRef):
	var stringified_data:String = body.get_string_from_ascii()
	stringified_data = stringified_data.replace('<<<doublequote>>>', '"')
	stringified_data = stringified_data.replace('<<<singlequote>>>', "'")
	if show_cloudsave_results:
		print("Loaded object: ", parse_json(stringified_data))
	on_load_function.call_func(parse_json(stringified_data))


func cloud_load_all(on_load_function:FuncRef):
	if show_cloudsave_results:
		print("Sending cloud load request for all slots")
	request("CloudSave.loadSlots", {}, funcref(self, "_handle_multiple_cloud_load_results"), on_load_function)

func _handle_multiple_cloud_load_results(results, on_load_function:FuncRef):
	var load_http_request
	if show_cloudsave_results:
		print("Results from all slot cloud load request (not the actual data yet): ", results)
	if not results["success"]:
		on_load_function.call_func(null)
		return
	for slot in results["result"]["data"]["slots"]:
		if slot["url"] == null:
			on_load_function.call_func(slot["id"], null)
		else:
			load_http_request = HTTPRequest.new()
			add_child(load_http_request)
			load_http_request.connect("request_completed", self, "_handle_multiple_cloud_load_data", [on_load_function, slot["id"]])
			load_http_request.request(slot["url"])

func _handle_multiple_cloud_load_data(result, response_code, headers, body, on_load_function:FuncRef, slot_number):
	var stringified_data:String = body.get_string_from_ascii()
	stringified_data = stringified_data.replace('<<<doublequote>>>', '"')
	stringified_data = stringified_data.replace('<<<singlequote>>>', "'")
	if show_cloudsave_results:
		print("Loaded from slot #", slot_number, ": ", parse_json(stringified_data))
	on_load_function.call_func(slot_number, parse_json(stringified_data))


Tags:

14

Posted by 3p0ch - April 10th, 2021


If you're making a Godot game and upload it to NewGrounds or pretty much anywhere online then with the default settings your graphics will get jacked if players adjust the zoom on their browsers. To un-jack it, in Project Settings / Display / Window / Stretch set Mode to 2D or Viewport and Aspect to keep.

iu_275897_3205434.png


Tags:

Posted by 3p0ch - March 10th, 2021


I'm about to publish Daxolissian System: Espionage tomorrow, and last night I went back to play the game again and had a pretty bad surprise: the music that had been playing just fine was now so choppy and slowed down that the game was at best an unpleasant experience if not outright unplayable without muting it. And seeing as how I invited a bunch of participants in the NewGrounds Video Game Music Challenge to have their music showcased in the game, that simply wouldn't do. I managed to get a solution working and it was more complex than I would've liked, so I'ma write out the approach here while it's still fresh in memory in case anyone else runs into the same problem with music in Godot (or in case I need to do it again and can't remember how).


The solution was to use howler.js which can be gotten from github. I credit FernandoValcazara with coming up with the idea, and he posted a link to an implementation here but I couldn't get that implementation to work in my game. Instead I took the howler.core.js file from github and put it in the game's build directory (not the Godot project directory, but the directory where the index.html file will be made). I had a custom html template set up to add the background image during the loading screen (I'll add a note at the end on how to do that), and I added the following line to load howler.js just after the line where it loads your Godot js file.

  <script type='text/javascript' src='$GODOT_BASENAME.js'></script>
  <script type='text/javascript' src='howler.core.js'></script>

and soon after that in the JavaScript code, I added variables for each song like so

    var engine = new Engine;
    var setStatusMode;
    var setStatusNotice;
    
    var binary_lightning_strike = new Howl({src: ['binary_lightning_strike.ogg'], loop: true});
    var class_s_debris          = new Howl({src: ['class_s_debris.ogg'],          loop: true});

etc with one Howl variable for each song. Then I moved the actual music files to the game's build directory too and deleted them from the Godot project. In retrospect, instead of deleting the music files from the Godot project, it would've been better to just exclude them from the build by going to the Export screen, Resources tab, and excluding all .ogg files.


Then in the GDScript code, I previously had an AutoLoad set up to be a music player, and I just changed its functions to

func start_playing(track_name):
	JavaScript.eval("if (!" + track_name + ".playing()) {" + track_name + ".play();}")

func stop_playing():
	JavaScript.eval("Howler.stop();")

func _physics_process(delta):
	if Input.is_action_just_pressed("ui_mute"):
		muted = not(muted)
		JavaScript.eval("Howler.mute(" + str(muted) + ");")

Again, I did this because I'm ready to publish, but if you're not in the same situation then you might want to do

func whatever():
	if OS.has_feature('JavaScript'):
		# This implementation
	else:
		# Normal implementation


And now the music plays great on Chrome, Edge, and Firefox. I'm still not sure what made the music suddenly play like crap in the first place, and why it affected Chrome and Edge but not Firefox, and only affected the Godot component of the game and not the HaxeFlixel component. I do know it's probably not because of excessive memory usage in the Godot component of Daxolissian System because I made an empty project with just an AudioStreamPlayer node playing the intro music and it still sounded horrible. So it's weird as all get-out but at least it's fixed now.


Oh yeah, I said I would explain how I added a picture to the Godot loading screen. In the index.html template (you can get the default template from Godot's website) I just changed this block

    body {
      touch-action: none;
      margin: 0;
      border: 0 none;
      padding: 0;
      text-align: center;
      background-color: black;
      background-image: url(boot_splash.png);
      background-repeat: no-repeat;
    }

and put my boot_splash.png file in the build directory right next to the index.html file. Save that html template file somewhere in your Godot project like in res://html/custom.html and in Godot on the Export screen after you pick HTML there's a box for Custom Html Shell where you can pick your html template file.


Tags:

2

Posted by 3p0ch - February 1st, 2021


My upcoming game Daxolissian System: Espionage includes flight sim levels that take place on an asteroid orbiting the (fictional) star Daxolis, which has an asteroid belt with its own atmosphere making it easy for species from different asteroids to interact. And it means that flight takes place in an atmosphere instead of the vacuum of space. Whether or not it’s possible to have an asteroid belt with an atmosphere is beyond the scope of this post, I’m just going to talk about the flight sim aspects.


iu_234889_3205434.png

When you’re greeted with the controls you’ll see the terms “roll”, “pitch”, and “yaw”. Those are the three ways that an aircraft can rotate. Roll is rotation around the blue axis in the picture so the wing tips follow the blue circle, controlled on a real aircraft by the ailerons (flaps at the back of the wings). Pitch is rotation around the red axis to make the nose move upward or downward. For the most part, those are the major ways a pilot controls an aircraft -- roll so that

"up" points toward the direction you want to turn, and pitch upward.


There’s also yaw controlled by the upright rudder in the back, which rotates the aircraft around the green axis in the picture and is essentially like steering a car. In practice yaw is just used for small adjustments and not to execute turns on its own, in part because humans tend to be much less likely to puke if you pull off a “coordinated turn” with roll and pitch so people in the plane feel like gravity is pulling downward than if you just yaw so it feels like gravity is pulling sideways, and in the game the effect of yawing is weak to mirror that – it’s useful for small adjustments like lining up shots but not for turning quickly.


iu_234890_3205434.png

When you’re flying straight and level there are four main forces at play. Thrust from the engine pushes you forward. Drag dampens your motion (in the forward direction or any other direction you might be moving). Gravity pulls you down. And lift from the wings pushes you up. Notably, the amount of lift generated by the wings is dependent on how fast you’re going. In Daxolissian System lift is just proportional to forward velocity. You might notice it if you hit the afterburners to increase your forward speed and see that the increased lift is pulling you upward.


iu_234891_3205434.png

The other thing to notice about lift is that it doesn’t push “up” as in “the opposite direction of gravity”, it pushes “up” as in “toward the upper and more curved part of the wing”. So if you start rolling then the lift produced by the wing pushes less upward and more sideways, and in an extreme case if you’re rolled 90 degrees then your “lift” won’t be lifting the plane at all and will just be pushing sideways. You can still fly like that, but in order to maintain altitude you’ll need to have the nose pointed upward so the thrust is pushing up enough to fight gravity instead of depending on the lift produced by the wings.


That also brings up something that might seem counterintuitive or “wrong” as you’re playing – if you’re rolled steeply then it will seem like the camera is following the plane at a funny angle instead of following right behind it. In fact, that’s exactly how the physics works – the lift from the wings will push you sideways so you really will be moving at a funny angle compared to the camera that’s chasing you.


Another thing that might seem “wrong” with the physics is that you might turn very hard but feel like you’re not moving in the direction that you just turned. That’s also how things actually work. The physics in Daxolissian System handles your plane as an object with inertia, and the thrust will eventually get you moving in the direction that the nose is pointing but it will take a bit for the drag from the air to stop the inertia in the direction that you have been moving. I did make drag be related to the angle of attack, so if you’re moving forward straight and level you’ll have less drag than if you’ve just banked hard and you have a broad surface facing your direction of motion. Also, I gave the Daxolissian atmosphere a lot of drag and the plane a lot of thrust so you can change direction fairly quickly compared to an Earthling, which makes aerobatics a lot easier and makes for a funner game. And lastly for those of you who have read this long, a protip is that a good time to hit the afterburners is just after you’ve changed your orientation -- that extra boost will help overcome your inertia and get you moving in the desired direction a lot faster, which can be very useful if a mountain face is approaching.

iu_234892_3205434.png


There are some things I haven’t talked about like stalling. In real life if you try to bring a plane’s nose up to 90 degrees you won’t be flying straight up, you’ll go into a stall where you’re no longer generating lift because your angle of attack is so wide that air can’t flow laminarly across the wing and instead goes into turbulent flow, so the amount of lift you generate (as well as your plane) plummets. I did not model stalling into Daxolissian System: Espionage out of mercy for the players. Similarly I made the plane roll, pitch, and yaw at the same rate regardless of how fast you're moving instead of having them be dependent on the airspeed. And the last thing I’ll touch on is gliding. In real life you can cut the engine and still make some use of the flight surfaces, but I didn’t model it in the game, in part because I made the drag so strong (to facilitate aerobatics) that it wouldn’t have much impact, and in part because I haven’t found great theoretical discussions of directional change while gliding vs directional change due to thrust and which one is really the dominant force in practice. I would've thought that a heavy aircraft (one not specifically designed to be a glider) would have negligible ability to change its direction of motion by gliding without engine thrust, but I concede that Chesley Sullenberger and Jeffrey Skiles successfully executing the Miracle on the Hudson in an emergency situation proves that the gliding effect in heavy aircraft isn't zero.


Hope that’s helpful, and if you’re interested in aerodynamics and flight either in air or space there’s a whole internet of information out there. NASA's aeronautics page would be a good starting point to see what sort of stuff is currently cutting edge while the free eBooks go more in depth.


Tags:

1

Posted by 3p0ch - January 31st, 2021


Ok so I know it's a pretty minor thing, but I hate the fact that you have to click the game canvas to give it focus before you can start playing. It turns out that it's not actually necessary though, you can program your game so it automatically wrests the focus into its clutches after it's loaded and flips the bird at the main browser window.


In Godot I did it with this code

	if OS.has_feature('JavaScript'):
		JavaScript.eval("document.getElementById(\"canvas\").focus();")

and in most other game engines if you can run JavaScript (see this post for an example of how to run JavaScript from Unity) then you can do this by looking through the .html file that your engine generates for an element similar to the "canvas" element that Godot makes and use its ID in the getElementById().focus() call.


I also did it in HaxeFlixel where it's even easier, just slap this code in

Browser.window.focus();

I put it in the Main.hx file's new() function just before the addChild(new FlxGame)


1

Posted by 3p0ch - May 29th, 2020


These are the SaveHandler classes that allow games to interact for a Meta-Game collab. Each of them has instructions on how to include it in your game in the comments at the beginning. They fundamentally work by keeping the shared data in a SaveHandler.publicSaveData object and have functions to either read (SaveHandler.readSave()) or write (SaveHandler.writeSave()) the data to Local Storage on the player's computer under the key publicKey. In general, you'll probably want to call SaveHandler.readSave() when your game starts and SaveHandler.writeSave() whenever you change any of the save data, but that's left for you to decide how to execute in case for example you update publicSaveData very frequently (every frame) and it would be a bad idea to write to Local Storage that frequently. The SaveHandlers also have .openSite(url) and .switchToSite(url) functions to open other games in the collab either in a new browser tab or replacing the current game's page.


If you're not familiar with Local Storage: it's a set of key / value pairs stored by the player's browser for the webpage's domain. You can see it in action in Chrome or Firefox by opening a game in NewGrounds, opening the developer tools, and looking under the Application tab on Chrome or Storage tab in Firefox. On the left should be a Local Storage thing to select, and there should be data for newgrounds.com and uploads.ungrounded.net. The ungrounded.net one is where the games' save data is stored, and if you select it then it should show a bunch of key / value pairs with save data. The key that was used in the MetaGame Collab Interface Prototype was NGcollabTest, so you might be able to find it and see its structure. Also note that the Local Storage save data for EVERY GAME YOU'VE EVER PLAYED ON NEWGROUNDS is stored there under ungrounded.net so they all share the same space. A relatively little known fact is that if you save data with Local Storage and happen to save your game's data under the same key that another game uses, then the games can inadvertently read from or write to the same data. If that's done accidentally then that'll probably mess things up, but for a Meta-Game collab that's exactly what we want to do. The SaveHandler scripts just facilitate doing that from any framework, and read-write all of the save data into the publicKey of Local Storage as a JSON-encoded object of the publicSaveData that the games use. The structure of publicSaveData should be maintained by all of the games in the collab, and that's why it's important for the collab to specify the publicKey and the structure of publicSaveData.


The collab will also need one special game that will be the first game that's launched when a player runs the meta-game. It takes advantage of publicSaveData.saveDataVersion in case the structure of publicSaveData ever needs to be changed. When the first game is called, it sets its internal variable SaveHandler.publicSaveData to default values and then attempts to read the publicSaveData from Local Storage -- if there's data already saved in Local Storage then that overrides the defaults, and if not (like if this is the first time the player plays the game) then it proceeds with the default values. If the collab decides to change the structure of publicSaveData, then the default value of publicSaveData.saveDataVersion will be incremented in the first game's code. If a player plays and the first game reads "old" save data from before the update of publicSaveData's structure then it will see an older saveDataVersion from the player's Local Storage and should decide what to do -- whether to keep some of the data and restructure publicSaveData or do anything else that might be appropriate for how the collab is structured. An example of such code, which would be used only by the starting game in the collab (for the prototype this is in Phaser and will just make a new publicSaveData with default values and ignore saved data from the previous version) is:


  publicReadSave = function() {
    if (window.localStorage.getItem(publicKey) != null) {
      var checkSaveData = JSON.parse(window.localStorage.getItem(publicKey));
      if (checkSaveData.saveDataVersion >= publicSaveData.saveDataVersion) {
        publicSaveData = JSON.parse(window.localStorage.getItem(publicKey));
      }
    }
  }

Now for the actual code of the SaveHandlers for each framework.


--== Phaser ==--

// Save this file as savehandler.js
// In your index.html file, after loading the phaser script, add a
// line to load the savehandler.js script like:
// <script src="savehandler.js"></script>

// This will make a publicly accessible SaveHandler which you can
// use in any other script. Public save data (shared between all
// the games in the collab) is in a SaveHandler.publicSaveData
// object with properties for each of the shared data elements.
// SaveHandler.privateSaveData keeps data just for your game that
// isn't shared across the collab.

// Set the publicKey (for shared data) to the key for the collab
// and privateKey (with data only for your game) to whatever you want.
// Set up publicSaveData per the collab's specifications and
// privateSaveDate can have whatever you want. Both of them are
// set up with default values.

// Read the save data from disk with SaveHandler.readSave() and write
// the current SaveHandler.publicSaveData and SaveHandler.privateSaveData
// to disk with SaveHandler.writeSave(). You can also use publicReadSave(),
// publicWriteSave(), privateReadSave(), and privateWriteSave() to read
// or write just the public or private data.

// Open another game in a new tab with SaveHandler.openSite(url) or make
// another game take over the current page with SaveHandler.switchToSite(url)

let SaveHandler = (function() {
  publicKey = "NGcollabTest";
  privateKey = "NGcollabPhaserTest";
  
  publicSaveData = {
    saveDataVersion: 0,
    urlStartPage: "https://www.newgrounds.com/projects/games/1469789/preview/filetype/2",
    urlPhaserPage: "https://www.newgrounds.com/",
    urlHaxeFlixelPage: "https://www.newgrounds.com/",
    urlGodotPage: "https://www.newgrounds.com/",
    urlUnityPage: "https://www.newgrounds.com/",
    thisIsaLie: true,
    hp: 2,
    pi: 3.14159,
    pi2: 3.1415926,
    playerName: "yo mama",
    floatArray: [1.5, 2.3, 9.2],
    lotsaData: {
      dataInt: 8,
      dataString: "gobble",
    },
  };

  privateSaveData = {
    backgroundColor: "purple",
  };

  readSave = function() {publicReadSave(); privateReadSave();}
  writeSave = function() {publicWriteSave(); privateWriteSave();}
  publicReadSave = function() {
    if (window.localStorage.getItem(publicKey) != null) {
      publicSaveData = JSON.parse(window.localStorage.getItem(publicKey));
    }
  }
  privateReadSave = function() {
    if (window.localStorage.getItem(privateKey) != null) {
      privateSaveData = JSON.parse(window.localStorage.getItem(privateKey));
    }
  }
  publicWriteSave = function() {window.localStorage.setItem(publicKey, JSON.stringify(publicSaveData));}
  privateWriteSave = function() {window.localStorage.setItem(privateKey, JSON.stringify(privateSaveData));}

  openSite = function(url) {window.open(url);}
  switchToSite = function(url) {window.open(url, "_parent");}

  return this;
})();


--== HaxeFlixel ==--

// Add this code to your Main.hx file underneath the top
// package; line and replacing the import statements.

// It will create a SaveHandler class that can be used in
// any other scripts, as long as you include the line
// import Main.SaveHandler;
// in the script that's using it.

// Set the publicKey to whatever the collab specifies, and privateKey
// to anything you want.

// Public save data (accessible by the entire collab) is stored in
// SaveHandler.publicSaveData and should be set per the specifications
// of the collab, values here would serve as defaults but shouldn't
// end up mattering if this game is called from another game that sets
// values. Private save data (visible only to your game) is in
// SaveHandler.privateSaveData

// Call the functions SaveHandler.readSave() or SaveHandler.writeSave()
// to read or write the data to the player's computer. You can also use
// just the .publicReadSave(), .privateWriteSave(), etc. forms.

// Open a new tab with another game in the collab using a function like
// SaveHandler.openSite(SaveHandler.publicSaveData.urlPhaserPage);
// or to open another game replacing the current page by using
// SaveHandler.switchToSite(SaveHandler.publicSaveData.urlPhaserPage);

import haxe.Json;
import flixel.FlxGame;
import openfl.display.Sprite;
import js.Browser;

class SaveHandler {
  public static var publicKey = "NGcollabTest";
  public static var privateKey = "NGcollabHaxeTest";

  public static var publicSaveData = {
    saveDataVersion: 0,
    urlStartPage: "https://www.newgrounds.com/projects/games/1469789/preview/filetype/2",
    urlPhaserPage: "https://www.newgrounds.com/",
    urlHaxeFlixelPage: "https://www.newgrounds.com/",
    urlGodotPage: "https://www.newgrounds.com/",
    urlUnityPage: "https://www.newgrounds.com/",
    thisIsaLie: true,
    hp: 2,
    pi: 3.14159,
    pi2: 3.1415926,
    playerName: "yo mama",
    floatArray: [1.5, 2.3, 9.2],
    lotsaData: {
      dataInt: 8,
      dataString: "gobble",
    },
  };

  public static var privateSaveData = {
    backgroundColor: "green",
  };

  public static function readSave() {
    publicReadSave();
    privateReadSave();
  }
  public static function writeSave() {
    publicWriteSave();
    privateWriteSave();
  }
  public static function publicReadSave() {
    if (Json.parse(Browser.window.localStorage.getItem(publicKey)) != null) {
      publicSaveData = Json.parse(Browser.window.localStorage.getItem(publicKey));
    }
  }
  public static function privateReadSave() {
    if (Json.parse(Browser.window.localStorage.getItem(privateKey)) != null) {
      privateSaveData = Json.parse(Browser.window.localStorage.getItem(privateKey));
    }
  }
  public static function publicWriteSave() {
    Browser.window.localStorage.setItem(publicKey, Json.stringify(publicSaveData));
  }
  public static function privateWriteSave() {
    Browser.window.localStorage.setItem(privateKey, Json.stringify(privateSaveData));
  }

  public static function openSite(url:String) {
    Browser.window.open(url);
  }
  public static function switchToSite(url:String) {
    Browser.window.open(url, "_parent");
  }
}


--== Godot ==--

extends Node

# Save this script as SaveHandler.gd
# In Project > Project Settings, AutoLoad tab: add this script and make sure
# the Enable checkbox is on

# This will make publicly accessible SaveHandler stuff available for other
# scripts. Public save data (available to any game in the collab) is in
# SaveHandler.publicSaveData with dictionary entries for each data element,
# and private save data (available only to your game and not shared across
# the collab) is stored in SaveHandler.privateSaveData

# Set the publicKey variable to whatever the collab picks
# Set the privateKey variable to whatever you want
# Make sure publicSaveData is set up per the collab's specifications
# Set up privateSaveData however you want

# Read the save data with SaveHandler.readSave() and write it with
# SaveHandler.writeSave(). You can also use publicReadSave(),
# privateWriteSave(), etc to handle just public or private data

# If you run this from a browser as an HTML5 build, it will read/write data
# to the browser's localStorage. If you run it from within Godot, it will
# read/write to files on your hard drive named according to publicKey and
# privateKey with .save suffixes, so things should work while you're testing
# your game without needing to build to HTML5.

# Open another game in a new tab with SaveHandler.openSite(url) or make
# another game take over the current page with SaveHandler.switchToSite(url)
# It'll look like SaveHandler.openSite(SaveHandler.publicSaveData.urlPhaserPage)
# and         SaveHandler.switchToSite(SaveHandler.publicSaveData.urlPhaserPage)
# Obviously that will only work if you make an HTML5 build.
# Otherwise, the console will just show a message saying that it couldn't be
# done since it's not an HTML5 build, but you'll know for debugging purposes
# that the game attempted to call the function.

var publicKey = "NGcollabTest"
var privateKey = "NGcollabGodotTest"

var publicSaveData = {
	"saveDataVersion": 0,
	"urlStartPage": "https://www.newgrounds.com/",
	"urlPhaserPage": "https://www.newgrounds.com/",
	"urlHaxeFlixelPage": "https://www.newgrounds.com/",
	"urlGodotPage": "https://www.newgrounds.com/",
	"urlUnityPage": "https://www.newgrounds.com/",
	"thisIsaLie": true,
	"hp": 2,
	"pi": 3.14159,
	"pi2": 3.1415926,
	"playerName": "yo mama",
	"floatArray": [1.5, 2.3, 9.2],
	"lotsaData": {
		"dataInt": 8,
		"dataString": "gobble",
	}
}

var privateSaveData = {
	"backgroundColor": "cyan",
}

func readSave():
	publicReadSave()
	privateReadSave()
	
func writeSave():
	publicWriteSave()
	privateWriteSave()

func publicReadSave():
	if OS.has_feature('JavaScript'):
		var JSONstr = JavaScript.eval("window.localStorage.getItem(\"" + publicKey + "\");")
		if (JSONstr != null):
			publicSaveData = parse_json(JSONstr)
	else:
		var save_file = File.new()
		if not save_file.file_exists("user://" + publicKey + ".save"):
			return
		save_file.open("user://" + publicKey + ".save", File.READ)
		publicSaveData = parse_json(save_file.get_line())
		save_file.close()

func privateReadSave():
	if OS.has_feature('JavaScript'):
		var JSONstr = JavaScript.eval("window.localStorage.getItem(\"" + privateKey + "\");")
		if (JSONstr != null):
			privateSaveData = parse_json(JSONstr)
	else:
		var save_file = File.new()
		if not save_file.file_exists("user://" + privateKey + ".save"):
			return
		save_file.open("user://" + privateKey + ".save", File.READ)
		privateSaveData = parse_json(save_file.get_line())
		save_file.close()

func publicWriteSave():
	if OS.has_feature('JavaScript'):
		JavaScript.eval("window.localStorage.setItem(\"" + publicKey + "\", \'" + to_json(publicSaveData) + "\');")
	else:
		var save_file = File.new()
		save_file.open("user://" + publicKey + ".save", File.WRITE)
		save_file.store_line(to_json(publicSaveData))
		save_file.close()

func privateWriteSave():
	if OS.has_feature('JavaScript'):
		JavaScript.eval("window.localStorage.setItem(\"" + privateKey + "\", \'" + to_json(privateSaveData) + "\');")
	else:
		var save_file = File.new()
		save_file.open("user://" + privateKey + ".save", File.WRITE)
		save_file.store_line(to_json(privateSaveData))
		save_file.close()

func openSite(url):
	if OS.has_feature('JavaScript'):
		JavaScript.eval("window.open(\"" + url + "\");")
	else:
		print("Could not open site " + url + " without an HTML5 build")

func switchToSite(url):
	if OS.has_feature('JavaScript'):
		JavaScript.eval("window.open(\"" + url + "\", \"_parent\");")
	else:
		print("Could not switch to site " + url + " without an HTML5 build")


--== Unity ==--

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Newtonsoft.Json;
using System.Runtime.InteropServices;

// ==================
// === FILE SETUP ===
// ==================
// Save this file as "SaveScript.cs" in Unity
// Then attach it to any GameObject in the first scene

// Save the following code between /* and */ as "link.xml" in Unity's Assets folder
/*
<linker>
  <assembly fullname="System.Core">
    <type fullname="System.Linq.Expressions.Interpreter.LightLambda" preserve="all" />
  </assembly>
</linker>
*/

// Make an Assets/Plugins subdirectory in Unity
// Go to https://www.nuget.org/packages/Newtonsoft.Json/ and click "Download Package"
// Rename it to newtonsoft.json.12.0.3.zip (instead of .nupkg)
// Put its /lib/netstandard2.0/Newtonsoft.Json.dll file in Assets/Plugins in Unity

// Save the following code between /* and */ as "SaveData.jslib" in Assets/Plugins
/*
mergeInto(LibraryManager.library, {
  ReadSave: function(key_ptr) {
    var key = Pointer_stringify(key_ptr);
    var returnString = window.localStorage.getItem(key) || "";
    var bufferSize = lengthBytesUTF8(returnString) + 1;
    var buffer = _malloc(bufferSize);
    stringToUTF8(returnString, buffer, bufferSize);
    return buffer;
  },

  WriteSave: function(key_ptr, saveData_ptr) {
    var key = Pointer_stringify(key_ptr);
    var saveData = Pointer_stringify(saveData_ptr);
    window.localStorage.setItem(key, saveData);
  },

  OpenSite: function(url_ptr) {
    var url = Pointer_stringify(url_ptr);
    window.open(url);
  },

  SwitchToSite: function(url_ptr) {
    var url = Pointer_stringify(url_ptr);
    window.open(url, "_parent");
  },
});
*/

// =========================================
// === USING THIS AFTER FILES ARE SET UP ===
// =========================================
// Make sure the PublicSaveData class is formatted per the collab's specs
// Format the PrivateSaveData class to save any data not shared with the collab
// Set publicKey to the collab's key and privateKey to whatever you want

// Modify the data by changing values of SaveHandler.publicSaveData.property or
//   SaveHandler.privateSaveData.property from any C# script
// Load the data by calling SaveHandler.readSave();
//   (If there's no save data, SaveHandler.public/privateSaveData will not change)
// Save the data by calling SaveHandler.writeSave();
//   (or publicReadSave(), publicWriteSave(), privateReadSave(), privateWriteSave())

// To open another game's page in a new tab:
//   SaveHandler.openSite(SaveHandler.publicSaveData.urlPhaserPage);
// or to switch the current game canvas to a new game:
//   SaveHandler.switchToSite(SaveHandler.publicSaveData.urlHaxeFlixelGame);

// ================================
// ===== End of documentation =====
// ================================

// Structure and default values for collab-wide save data
// Note that if it includes JavaScript multi-level parameters, this should be
//   handled in C# with a new class embedded in PublicSaveData
public class PublicSaveData {
  public int       saveDataVersion = 0;
  public string    urlStartPage = "https://www.newgrounds.com/";
  public string    urlPhaserPage = "https://www.newgrounds.com/";
  public string    urlHaxeFlixelPage = "https://www.newgrounds.com/";
  public string    urlGodotPage = "https://www.newgrounds.com/";
  public string    urlUnityPage = "https://www.newgrounds.com/";
  public bool      thisIsaLie = true;
  public int       hp = 2;
  public float     pi = 3.14159f;
  public double    pi2 = 3.1415926;
  public string    playerName = "pajama";
  public float[]   floatArray = {1.4f, 2.2f, 9.1f};
  public LotsaData lotsaData = new LotsaData();
}

public class LotsaData {
  public int     dataInt = 12; // This is accessed as SaveHandler.publicSaveData.lotsaData.dataInt
  public string  dataString = "gobble"; // Accessed as SaveHandler.publicSaveData.lotsaData.dataString
}

// Structure and default values for save data only seen by this game
public class PrivateSaveData {
  public string backgroundColor = "blue";
}

public static class SaveHandler {
  // Keys to save under
  static string publicKey = "NGcollabTest";
  static string privateKey = "NGcollabUnityTest";

  // Create static variables that will hold the save data
  // and set them to default values
  public static PublicSaveData publicSaveData = new PublicSaveData();
  public static PrivateSaveData privateSaveData = new PrivateSaveData();

  #if (UNITY_WEBGL && !UNITY_EDITOR)
    // If this is a WebGL build, set up functions from the jslib file
    [DllImport("__Internal")]
    private static extern string ReadSave(string key);
    [DllImport("__Internal")]
    private static extern void WriteSave(string key, string serializedSaveData);
    [DllImport("__Internal")]
    private static extern string OpenSite(string url);
    [DllImport("__Internal")]
    private static extern string SwitchToSite(string url);
  #else
    // Otherwise, use PlayerPrefs to save the data
    private static string ReadSave(string key) {
      return PlayerPrefs.GetString(key);
    }
    private static void WriteSave(string key, string serializedSaveData) {
      PlayerPrefs.SetString(key, serializedSaveData);
    }
    private static void OpenSite(string url) {
      Debug.Log("Cannot open another game except when running WebGL in a browser!");
    }
    private static void SwitchToSite(string url) {
      Debug.Log("Cannot open another game except when running WebGL in a browser!");
    }
  #endif

  // These call the jslib or PlayerPrefs functions
  // and convert between objects and strings for the calls
  public static void readSave() {
    publicReadSave(); privateReadSave();
  }
  public static void writeSave() {
    publicWriteSave(); privateWriteSave();
  }
  public static void publicReadSave() {
    if (ReadSave(publicKey) != "") {
      publicSaveData = JsonConvert.DeserializeObject<PublicSaveData>(ReadSave(publicKey));
    }
  }
  public static void publicWriteSave() {
    WriteSave(publicKey, JsonConvert.SerializeObject(publicSaveData));
  }
  public static void privateReadSave() {
    if (ReadSave(privateKey) != "") {
      privateSaveData = JsonConvert.DeserializeObject<PrivateSaveData>(ReadSave(privateKey));
    }
  }
  public static void privateWriteSave() {
    WriteSave(privateKey, JsonConvert.SerializeObject(privateSaveData));
  }

  // These call the functions to open other games
  public static void openSite(string url) {
    OpenSite(url);
  }
  public static void switchToSite(string url) {
    SwitchToSite(url);
  }
}

// This does nothing, but needs to be present so Unity
// will let you attach this script to a GameObject
public class SaveScript : MonoBehaviour {
}

4

Posted by 3p0ch - May 4th, 2020


If I were to attempt to run a Meta-Game Collab on NewGrounds where a bunch of games share the same save data and essentially become components of a big meta-game, this would be how I’d recommend setting up Phaser3 to use IndexedDB instead of using localStorage.


If you’re new to Phaser3, visit phaser.io and go to their Learn page under Getting Started and read up. I’m on Windows and I like using XAMPP as the webserver for Phaser, which is also available for Mac and Linux, and you’ll need to install before you do anything else. If you’re using Windows and want to use XAMPP, download XAMPP from apacefriends.org and install it to c:\xampp. Run c:\xampp\xampp-control.exe and tell it to Start the Apache module, and you’ll be good to go when the module light turns green. All of your Phaser stuff should go in c:\xampp\htdocs (sort of the root of what XAMPP considers the website that it’s hosting), and you don’t need the stuff that xampp installs in htdocs so you can just delete the folder or move it to an htdocs_bak folder. Be aware that there can be cache issues, so when you’re making changes to your Phaser game if you’re using Chrome as your browser then you should have the Developer Tools window open (Ctrl-Shift-I) and on the page reload button click, hold, and pick Empty Cache and Hard Reload when you’re making changes. Firefox might generally be better with Phaser and/or XAMPP though.


Then at Phaser.io download it – it’s sufficient to just download the min.js file and put it in a js folder of your game’s directory (like c:\xampp\htdocs\js\phaser.min.js). You can and should then go through the Phaser.io Learn section’s Getting Started including their guide walking you through how to make a game in Phaser3, and remember that to load the phaser.min.js library from your js folder you should have your index.html file’s script line that loads phaser be

<script src="js/phaser.min.js"></script>

instead of anything they might say in the tutorials. Once you’ve made an index.html file in c:\xampp\htdocs you should be able to open a browser and in the URL put localhost/index.html to run the game.


Once you’ve done all that, it’s time to un-learn some of what you just saw. The guide on making your first game did a good job of showing you what the tools are, but they put the entire game’s code in a single .html file with a huge wall of embedded JavaScript, which I consider unadvisable programming practice if you’re going to make an even moderately complex game. Instead, check out @PsychoGoldfish ’s guide on how to really set up a Phaser3 game, and get used to assigning properties to your scenes instead of using variables like in the tutorial. I will assume you’re setting your game up with the structure that he recommends in that guide.


Now’s where the IndexedDB stuff comes in. You can read up on IndexedDB here, here, and some more here, but I’ll break it down for you. First, it helps to see it in action in your browser. If you’re using Chrome, open the Developer Tools window (Ctrl-Shift-I) and go to the Application tab (you might need to click a >> arrow on the top bar to see the Application tab). On the left you should see a list including a Storage section with an IndexedDB item there. If you go to a NewGrounds game’s webpage, then it might be populated with a lot of entries from uploads.ungrounded.net and show you a lot of entries if you click an arrow by IndexedDB to expand it. Each of those entries in IndexedDB is a database, and you might have an /idbfs database from Unity games where the devs lazily used PlayerPrefs to save data. If so and you expand the /idbfs database then you’ll see FILE_DATA under it – that’s an ObjectStore within the idbfs database. The actual values that get saved are within the ObjectStore, and if you click on FILE_DATA then on the right there will probably be a lot of Key / Value pairs if you’ve played a lot of Unity games on NewGrounds. Once you get your game running, this is where you’ll be able to look to verify that stuff is getting saved. Be aware that on Chrome the Key / Value data won’t be updated in real time if you have your game open and the Developer Tools window open, and you may have to click the reload button on the bar just above the Key / Value pairs to make it update.


Let’s get to your code. In the index.html file, I’ve got

<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8">

		<title>My Phaser Game</title>
		<meta name="description" content="This is a Phaser Game!">
		<meta name="author" content="Your Name Here">

		<style>
			body {
				background-color: black;
				margin: 0px;
				padding: 0px;
			}
		</style>

	</head>

	<body>

	<!-- This is where we load our scripts -->
    <script src="js/phaser.min.js"></script>
    <script src="bin/newgroundsio.js" type="text/javascript"></script>
    <script src="js/main.js"></script>

	</body>
</html>

Notice that I’ve installed NewGrounds.io and put it in the bin folder of my build. I highly recommend learning how to implement NewGrounds.io to add medals to your game etc, but if now is not the time then just remove that line. [Side note: if you use NewGrounds.io for Phaser games be aware I noticed that if I do like in the NG.io JS/HTML5 docs and check for nullness of ngio.user then it always gave null even if I was logged in so medals never worked, but if I instead checked ngio.session_id for nullness it worked fine, so try doing that instead.] Otherwise, not much else to see here.


In the js/main.js file I’ve got a bunch of changes to what PsychoGoldfish has

var MainConfig = (()=>{

	// loose reference
	let config = this;

	// path to our scene scripts
	let scene_path = "js/scenes/";

	// class/file names of all our scenes
	config.scene_names = [
		'InitScene',
		'PreloaderScene',
		'GameScene'
	];

	// This will be called when all our scene files have loaded and we are ready to start the Phaser game.
	function startGame() {

		config.game = new Phaser.Game({

			width: 860,
			height: 640,
			type: Phaser.AUTO,	// Render mode, could also be Phaser.CANVAS or Phaser.WEBGL
			scene: config.scene_classes // the code below will set this for us

		});
	}

	// Start NewGrounds.io
	// *************************************************
	// ** Set your game's info for NewGrounds.io here **
	// ** (and un-comment it)                         **
	// *************************************************
	// var ngio = new Newgrounds.io.core("50069:Mc8Q3Qo4", "P0yB53I+JzHKw7PT2YDCkQ==");

	// ****************************************************
	// ** Set the default values for saveData here       **
	// ** (Note that making this "config.saveData" means **
	// ** the rest of your code can access it by calling **
	// ** MainConfig.saveData)                           **
	// ****************************************************
	config.saveData = {
		hp: 1,
		playerName: "yo mama",
	};

	// Open IndexedDB for saving
	// This will be called by the preloader scene
	// *****************************************************************
	// ** Make the indexedDB.open("") target be your game's number on **
	// ** NewGrounds (as a string) or something else appropriate      **
	// *****************************************************************
	config.idb = null;
	config.openIDB = function() {
		return indexedDB.open("testDB");
	}

	// If you ever want to read the saveData while in the middle
	// of the game, use this function but call it from a state that
	// will sort of lock the game until it gets an onsuccess or
	// onerror event like so:
	//
	// var readRequest = readSaveData();
	// readRequest.onsuccess = function(event) {
	//	 if (event.target.result != null) {
	//     MainConfig.saveData = event.target.result;
	//   }
	//   // code to resume the game
	// }
	// readRequest.onerror = function(event) {
	//   // show an error message if you want
	//   // put the code to resume the game here if you want
	// }
	config.readSaveData = function() {
		return config.idb.transaction("thisObjectStore").objectStore("thisObjectStore").get("saveData");
	}

	// Write saveData to the database
	config.writeSaveData = function() {
		if (config.idb != null) {
			config.idb.transaction("thisObjectStore", "readwrite").objectStore("thisObjectStore").put(saveData, "saveData");
		}
	}


	//---------- You shouldn't need to edit anything below here ----------\\

	// this will store references to our scene classes as they are loaded.
	config.scene_classes = [];

	// get the body tag in the HTML document
	let body = document.getElementsByTagName('body')[0];

	// keep track of number of loaded scenes
	let scenes_loaded = 0;

	// Loads a scene file by adding a script tag for it in our page HTML
	function loadScene(scene_name) {

		let script_tag = document.createElement('script');
		script_tag.src = scene_path + scene_name + ".js";

		// wait for the scene file to load, then check if we have loaded them all
		script_tag.addEventListener('load', ()=>{
			scenes_loaded++;
			eval("config.scene_classes.push("+scene_name+")");

			// looks like we can start the game!
			if (scenes_loaded === config.scene_names.length) startGame();
		});

		body.appendChild(script_tag);
	}

	// start loading all of our scene files
	config.scene_names.forEach((scene_name)=> {
		loadScene(scene_name);
	});

	return this;
})();

First is starting the NewGrounds.io core, which you could do here but you don’t need to worry about it for now. Next I’m making a saveData variable, which is an object in MainConfig (accessible by typing MainConfig anywhere else in your Phaser game – as PsychoGoldfish said, this is a good place to put global variables) and giving it default values for HP (MainConfig.saveData.hp) and playerName (MainConfig.saveData.playerName). I would plan to make saveData be a single object that will store all of the data that you plan to save, and the saved data will be put in its object properties. In case you’re wondering, the line at the beginning of

let config = this;

means that the config variable is a reference for “this”, where “this” is MainConfig… I wish I knew about that trick when I was doing the Phaser game jam because I don't have a JavaScript background and I often found myself banging my head against the wall because the thing that "this" refers to changed within functions, methods, event listeners (I think?), and maybe other stuff, although my memory of details is hazy.


Next is the code to open a database in IndexedDB. The MainConfig.idb property will point to the IndexedDB database once it’s opened, and what the config.openIDB function does it a little bit subtle. Actions with IndexedDB don’t work like normal functions. Instead, JavaScript sends a Request to IndexedDB to do something and waits for an Event letting it know that the Request was processed. Here, the line in this function

return indexedDB.open(“testDB”);

will make a request to have IndexedDB open the database named testDB (kind of like that /idbfs database in IndexedDB that we were looking at earlier from Unity games). What it returns will be a Request object that you can attach listeners to. Jumping a little ahead, let’s look at how I modified the create() of js/scenes/PreloaderScene.js where the function is called to open the database.

	create() {
		var thisScene = this;

		let play_btn = this.add.image(this.interact_point.x, this.interact_point.y, 'preloader_button');
		// make the button interactive
		play_btn.setInteractive();
		// add some alpha for the default state
		play_btn.alpha = 0.9;

		// remove alpha on hover
		play_btn.on('pointerover', ()=>{
			play_btn.alpha = 1;
		});

		// add alpha on roll out
		play_btn.on('pointerout', ()=>{
			play_btn.alpha = 0.9;
		});

		// start the GameScene when the button is clicked. Bonus - this will enable audo playback as well!
		play_btn.on('pointerup', ()=>{
			this.scene.start('GameScene');
        });
    
        // Make the Play button inactive until IndexedDB data is loaded
  		play_btn.setActive(false);
  		
  		// First open the IndexedDB database
  		thisScene.idbRequest = MainConfig.openIDB();
  
  		// If IDB can't be opened, throw an error to the browser's console
  		thisScene.idbRequest.onerror = function() {
  			console.log("Error opening IDB");
  		}
  
  		// The "onupgradeneeded" case happens if this is the first time
  		// this game is saving to IndexedDB, so make an object store
  		thisScene.idbRequest.onupgradeneeded = function(event) {
  			thisScene.idbRequest.result.createObjectStore("thisObjectStore");
  		};
  
  		thisScene.idbRequest.onsuccess = function(event) {
  			// Make MainConfig.idb point to the indexedDB object
  			MainConfig.idb = thisScene.idbRequest.result;
  
  			// Now that the IndexedDB database is open, read saveData
  			thisScene.readRequest = MainConfig.readSaveData();
  			thisScene.readRequest.onsuccess = function(event) {
  				// event.target.result will be null if the game has never been played before
  				// and will have save data if it has been played before
  				// So this checks for nullness -- if it's not null then it puts the result
  				// from reading IndexedDB into MainConfig.saveData
  				// And if it is null, then MainConfig.saveData is still the default value,
  				// so go ahead and write that to IndexedDB
  				if (event.target.result != null) {
  					MainConfig.saveData = event.target.result;
  				} else {
  					MainConfig.writeSaveData();
  				}
  				play_btn.setActive(true);
  			}
  			thisScene.readRequest.onerror = function(event) {
  				play_btn.setActive(true);
  			}
  		}
  	}
}

The IndexedDB handling starts at the

thisScene.idbRequest = MainConfig.openIDB();

line, which uses that function so the Request to open the IndexedDB database is stored in thisScene.idbRequest. Just after that are listener statements telling it what to do when the Request is finished. If there’s an error, it logs an error message to the console. Next it has an “onupgradeneeded” handler. If IndexedDB doesn’t already have a testDB database (or if it has one with an obsolete version – something I’m not going to go into detail about right now) then it creates it and should add ObjectStores to it (remember that Unity games had the /idbfs database and the FILE_DATA ObjectStore under it – same thing here). So the onupgradeneeded handler creates a creatively named “thisObjectStore” ObjectStore. The next handler is onsuccess, which fires if the database is successfully opened, and if this is the first time you run the program then onupgradeneeded happens and then onsuccess happens. In onsuccess it sets the MainConfig.idb property to point to the result of the Request to open the database, so now MainConfig.idb is a reference to the thisDB database. We’ll get to the rest of the code after that in a moment.


Now going back to the main.js file, there are two more functions that I’ve added to PsychoGoldfish’s code. The first reads the saved data and the next writes it. Note all the comments above readSaveData. Just like opening the database was a matter of sending a Request and waiting for an event to let you know it’s done, the same is true of reading save data. You probably don’t want to tell your game to read save data and then have the player continue playing for a while before the save data actually gets loaded, so you probably want to have your game pause or something while it’s reading save data (maybe with a popup window that pauses the game and lets the player click a Load or Cancel button, and if the player clicks Load then the game doesn’t unpause and close the popup window until loading is finished). But when it comes to writing the save data I don’t sweat it so much – if your game sends a command to save the data then it’s probably fine to let the player keep playing and the save data can finish writing whenever it finishes writing with no impact on anything.


Going back to the PreloaderScene, the way I handled it is that I deactivated the play button while we’re opening IndexedDB and reading the save data. That’s the

play_btn.setActive(false);

command just before the code to open the database, and we’re not going to activate it again until we’re done. Picking up where we left off after opening the database, next we send a request to read the saveData which should be in testDB under thisObjectStore if it was saved from a previous play. Note that this is all embedded within the onsuccess event from opening the database, which ensures we won’t try to read from the database until after it’s open. Then we have event handlers for the readRequest. If it read a non-null value (it would be null if this were the first time we ran the game and there was no saveData stored yet) then it sets MainConfig.saveData to be the result from the call. If it read null, then remember that we set default values for saveData in main.js, so we’ll write those default values to IndexedDB for now. Either way, we’ll go ahead and activate the Play button so the player can get on with the game. And if there was an error reading saveData then I’ll still let the player play because I’m such a nice guy.


I didn’t make any real changes to InitScene.js and it still has this.load.image commands for the background and button that PsychoGoldfish had if you finished his post, so I’ll skip showing it. And we already went through all the significant changes in PreloaderScene.js. So to wrap up, we’ll look at GameScene.js.

class GameScene extends Phaser.Scene {
  
  constructor () {
  // Sets the string name we can use to access this scene from other scenes
	super({key:'GameScene'});
  }

  init() {
  }

  create() {
    var thisScene = this;
    this.cameras.main.setBackgroundColor(0x00AAFF);

    this.hpText = this.add.text(5, 5, "HP: " + MainConfig.saveData.hp + " Click this to increment");
    this.hpText.setInteractive();
    this.hpText.on("pointerup", function() {
      MainConfig.saveData.hp++;
      MainConfig.writeSaveData();
      this.text = "HP: " + MainConfig.saveData.hp + " Click this to increment";
    });

    this.playerNameText = this.add.text(5, 55, "Player Name: " + MainConfig.saveData.playerName);

    this.readButton = this.add.text(5, 105, "Click here to read saveData");
    this.readButton.setInteractive();
    this.readButton.on("pointerup", function() {
      thisScene.readRequest = MainConfig.readSaveData();
      thisScene.readRequest.onsuccess = function(event) {
        if (event.target.result != null) {
          MainConfig.saveData = event.target.result;
        }
        // Ordinarily you would have the game paused while
        // reading saveData (like have it be in a state that
        // doesn't process any actions) and resume it here
        // after the read returns a result and saveData
        // (any any relevant game variables) get updated
        thisScene.hpText.text = "HP: " + MainConfig.saveData.hp + " Click this to increment";
        thisScene.playerNameText.text = "Player Name: " + MainConfig.saveData.playerName;
      }
      thisScene.readRequest.onerror = function(event) {
        playerNameText = "There was an error reading the save data"
      }
    });
  }
  
  update() {
    // This is your main game loop. Code in here runs on every frame tick.
  }
}

I have

var thisScene = this;

to use that handy trick. Then I add the hpText object which will show the HP from MainConfig.saveData.hp (you have my permission to go ahead and use variables directly from MainConfig.saveData without pulling the values into variables / class properties within your game scene classes if you’re careful about it) and be clickable so you can click on the text to increase the HP by one per click, and it will write the updated HP to IndexedDB after each click. Remember that just because you change a value in saveData doesn’t mean it’s immediately written to IndexedDB and you’ll have to call MainConfig.writeSaveData() at appropriate times. It will also show a text box with MainConfig.saveData.playerName but it won’t be editable (for now). Last it will have a button to read the data from IndexedDB – if you click it, it will send a read request and set MainConfig.saveData to be what was read from IndexedDB, and then update the text boxes. For now it won’t really accomplish much because the values in IndexedDB will always be equal to the game’s current values, but it will become neat in not too long.


Now if you run your index.html file via XAMPP (with the Apache server on and by typing localhost/index.html in your web browser) the game should work. When it’s running you should be able to open the Developer Tools window and look in the Application tab for the IndexedDB entry named testDB that has an ObjectStore named thisObjectStore, and if you click the thisObjectStore then you should see the key / value pair of saveData with a structure that can be expanded to show you the hp and playerName values. And you should be able to click the HP text box to increment the HP by one per click and the value in IndexedDB should be updated – but remember that at least with Chrome you’ll need to click the Refresh button just above the key / value pairs in the Developer Tools window to make it update.


That’s fine and everything, but not too mind blowing yet. I went ahead and uploaded that project here. The really cool part is the other project that I uploaded here. It looks pretty similar, but clicking its HP button will decrement the HP and clicking the Player Name will change the name.


Now try opening both games from the same browser (they can be open simultaneously in different tabs of the same browser), clicking around with them, and try out that button to read saveData from IndexedDB in one game right after you’ve changed the values in the other game. Neat, huh? The details of exactly how I made that second project are left as an exercise for the reader, but it’s not much of a change from what I just showed you.


A key step in making a Meta-Game collab possible is in place. As an aside, this could be done with localStorage without having to go through IndexedDB, but localStorage is gross for reasons I discussed in previous posts. IndexedDB doesn’t completely eliminate all of the issues of potentially getting save data messed up but it makes it far less likely.


Tags:

Posted by 3p0ch - May 1st, 2020


Two reasons for this post:

1) I might use this later in an effort to pull off a NewGrounds collab for a huge meta-game where all of the games interact with each other, and this would be how HaxeFlixel games would interact with others in the collab.

2) Even if a collab never happens, HaxeFlixel's built-in save functionality uses LocalStorage which is unsafe for reasons that I discussed in my news feed post on Phaser where I described how to make Phaser use IndexedDB instead of LocalStorage. This would also be nice if you wanted to make a game series and let different games in the series know what the player did on other games in the series - just store everything in the same IndexedDB database (you can set the name of the database to use as described later) and the games will be able to read each other. While that should also be doable with LocalStorage, the risk of something bad happening to the data over a relatively long time between installments of a game hosted on a gaming site should be much lower with IndexedDB than LocalStorage.


I'm dropping some code below that you can use as your Main.hx file. This code will make a public static Main.saveData anonymous structure variable that you can modify however you want to keep all your save data (so it would have stuff like saveData.hp, saveData.playerName, saveData.someBigArray, etc) that you can modify from anywhere in your game with Main.saveData.someField = someValue. If you don't want to put all of the save data in one anonymous structure (like if you'll have some crazy terabyte size saves or smth and want to be able to read and write small bits at a time) that's fine too, just declare as many public static variables as you like where it currently has saveData in the list of class variables and also in the new() function where it sets default values. Once you have that set up, you can use the Main.readSave() and Main.writeSave() functions to read or write a variable (by default the saveData structure) to your IndexedDB save.


If you want to do that, follow these steps.

1) Use the code at the very end of this post as your Main.hx file

2) Adjust the saveData structure (just under where the Main class definition starts) to whatever you want, or use a bunch of variables instead of putting everything in saveData if you want

3) Adjust the default values of saveData or the other variables you made (just under the where the new() function starts)

4) Adjust the name for your IndexedDB database just after that... if you're just making a standalone game then I suggest you use the number for your game that shows up in the URL of your browser when you load your game's project page in NewGrounds (but make it a string in HaxeFlixel) so you get a unique identifier

5 only if you made new variables instead of just using saveData) Where it has

      readRequest = readSave("saveData");
      readRequest.onsuccess = function() {
        // If there was no previously saved data then stick with
        // the default values set earlier
        if (readRequest.result != null) {
          saveData = readRequest.result;
        }
        startGame();
      }
      readRequest.onerror = function() {
        startGame();
      }

make a bunch of repeats of that to read each of the variables you want to read, and only call startGame() for the last one. (Don't want to make a whole bunch of variables so much anymore, now do you? >:)

6) To save the current data from anywhere within your game, just use Main.writeSave() which will default to saving the saveData structure, or Main.writeSave(key:String, value) if you want to use your own variables and save them as key / value pairs in IndexedDB.

7) If you want to read from the IndexedDB save data while you're in the middle of your game, do it like this (you can omit "saveData" from the Main.readSave call if you want because it will default to that):

      var readRequest:Request;

      readRequest = Main.readSave("saveData");
      readRequest.onsuccess = function() {
        saveData = readRequest.result;
        // continue the game by opening a new state or smth
      }
      readRequest.onerror = function() {
        // handle the error
      }

Probably what you want to do is either 1) have a FlxState where the player sees a screen that lets them load their game, and the only way they can leave that screen is when the readRequest.onsuccess or readRequest.onerror event kicks in and makes the game go to another FlxState, or 2) just let this Main.hx file load everything when the player starts the game, and never try to read the save data from IndexedDB in the middle of the game.


Ok, that's enough talking, here's the Main.hx code

package;

import js.html.idb.Request;
import js.html.idb.Transaction;
import js.html.idb.ObjectStore;
import js.html.idb.OpenDBRequest;
import flixel.FlxGame;
import openfl.display.Sprite;
import js.Browser;

class Main extends Sprite {
  // Variable(s) for saved game data
  public static var saveData:{
    HP:Int,
    PlayerName:String
  }
  
  // Variables that will be used for IndexedDB operations
  static var idbName:String;
  static var openRequest:OpenDBRequest;
  static var readRequest:Request;
  static var db:Dynamic;
  static var store:ObjectStore;
  static var tx:Transaction;

  public function new() {
    super();

    // Set saved data to default values
    saveData = {"hp" : 1,
                "playerName" : "Yo mama"};

    // Set the name of your IndexedDB database
    idbName = "testDB";

    // Get the IndexedDB database set up
    openRequest = Browser.window.indexedDB.open(idbName);

    // If this is the first time the player ran the game,
    // the database will need to create an object store
    openRequest.onupgradeneeded = function() {
      db = openRequest.result;
      store = db.createObjectStore("thisObjectStore");
    }

    // If the object store exists (either because the game was
    // previously played, or because it was created by the
    // onupgradeneeded function) open the db and
    // read the saved data
    openRequest.onsuccess = function() {
      db = openRequest.result;
      readRequest = readSave("saveData");
      readRequest.onsuccess = function() {
        // If there was no previously saved data then stick with
        // the default values set earlier
        if (readRequest.result != null) {
          saveData = readRequest.result;
        }
        startGame();
      }
      readRequest.onerror = function() {
        startGame();
      }
    }
  }

  /*  Returns a Request from reading the save data
      The parameter is the Key of the value to read
      Defaults to reading saveData

      You should use it in your code like this:
      var readRequest:Request;
      readRequest = Main.readSave("saveData");
      readRequest.onsuccess = function() {
        saveData = readRequest.result;
        // continue the game by opening a new state or smth
      }
      readRequest.onerror = function() {
        // handle the error
      }
  */
  public static function readSave(?saveKey:String = "saveData"):Request {
    var returnValue:Dynamic = null;

    tx = db.transaction("thisObjectStore", "readonly");
    store = tx.objectStore("thisObjectStore");
    return store.get(saveKey);
  }

  /*  Writes data to IndexedDB
      Parameters are the Key (string) and Value (whatever
        data type) to be saved
      Defaults to writing the saveData variable

      This function doesn't bother with returning a Request
      because it shouldn't be necessary in this case
  */
  public static function writeSave(?saveKey:String, ?saveValue:Dynamic) {
    if (saveKey == null) {
      saveKey = "saveData";
      saveValue = Main.saveData;
    }
    tx = db.transaction("thisObjectStore", "readwrite");
    store = tx.objectStore("thisObjectStore");
    store.put(saveValue, saveKey);
  }

  public function startGame() {
    addChild(new FlxGame(0, 0, PlayState));
  }
}

Tags:

Posted by 3p0ch - April 6th, 2020


If you're like me and a newbie to HaxeFlixel working on the Game Jam, and if you're like me and have been looking for some fancy spritesheets to make level maps, then you might've run into this problem too. I found this really nice spritesheet at https://opengameart.org/content/exterior-32x32-town-tileset. But it's a little too nice to use the same sort of approaches that were covered in the HaxeFlixel tutorial, and the issues would become immediately obvious if you tried to work with something like this, so I'm going to break down how I approached it.


First things first, if you look at the spritesheet for a while then you might see that it makes sense to classify tiles into one of three categories. 1) Stuff like the ground, floors, etc that characters should be able to walk over unimpeded. 2) Stuff like tree trunks and building walls that characters shouldn't be able to walk on. 3) Stuff like the tops of trees that should be in the foreground -- characters should be able to walk through those tiles unimpeded, but the tiles should be rendered "above" the characters so it looks like the character is underneath the treetops. Also notice that you've dealt with the first two types of things in the tutorial -- there was a "floor" tile and a "wall" tile and you manually set it where you could walk over floor tiles but were blocked by wall tiels -- but this new slick spritesheet I'm working with is huge and I'm not gonna sit there manually typing out which tiles should be walls and which should be floors. Finally, notice that some tiles like treetops have transparent areas within the tile, and are intended to be placed "on top of" other tiles like grass or a road or whatever so you see the appropriate ground behind the treetop, and the tutorial sure didn't teach how to handle that sort of thing.


So here's what you do. In Ogmo3, go to the Layers screen and add some Tile layers until you have (at least) three layers - I'll call mine "floor", "walls", and "foreground" for each of the three categories I talked about above. Depending on your spritesheet, if you have something like tiles of grass and also tiles with flowers with transparent area around the flowers so the grass can show up behind the flower but they should both still be part of the "floor" layer (rendered behind the characters and not blocking their movement), then you might need to get fancy and do things like have a "floorFront" and "floorBack" layer, but I'm gonna just stick with the three types to illustrate the concept in this post. For the spritesheet I'm working with, all sorts of tiles (floor, walls, and foreground) are scattered around the single spritesheet, so for each of the layers I'm going to have its Tileset be that spritesheet (so they're all looking at the same spritesheet). Also, once you've made the layers, you can drag them up or down on the list of layers. You should make foreground be on the top of the list, then walls, then floor so that Ogmo will show things how you'll ultimately want them to look and make stuff easier to see. The Entities layer can be right next to the walls layer, doesn't matter whether it's just above or just below.


Now when you go to edit levels in Ogmo, you'll see that you can pick which layer you're editing on the far left. You should place a tile on every square of the floor layer, the wall layer should have tiles placed only where you want to block character movement -- characters will be blocked at a tile IF AND ONLY IF there is a tile (any tile) placed there in the walls layer (remember you can delete a tile by right-clicking), and the foreground should have tiles placed only if you want that stuff to be rendered in front of the characters. Now save your level and get to your PlayState.hx file.


Here are key parts of my PlayState() class (note that if the code doesn't all fit in your window there's a horizontal scroll bar at the bottom of the code section), and I'll walk you through it

  var player:Player;
  var map:FlxOgmo3Loader;
  var ground:FlxTilemap;
  var walls:FlxTilemap;
  var foreground:FlxTilemap;
  var dudes:FlxTypedGroup<Dude>;

  override public function create():Void {
    /* Get the level's Ogmo3 tilemap
       In Ogmo3 I made a layer called floor with stuff like grass that should just be background,
       a layer called walls with stuff that the player and enemies should collide with and not walk through,
       and a layer called foreground with stuff like treetops that should be rendered in front of the player
       and enemies but shouldn't block them from moving on that tile.
    */
    map = new FlxOgmo3Loader("assets/data/haxeFlixelJam.ogmo", "assets/data/" + Main.currentLevel.x + "-" + Main.currentLevel.y + ".json");
    ground =     map.loadTilemap("assets/images/tileset_town_multi_v002.png", "floor");
    walls =      map.loadTilemap("assets/images/tileset_town_multi_v002.png", "walls");
    foreground = map.loadTilemap("assets/images/tileset_town_multi_v002.png", "foreground");

    // Add the layers to the scene in the correct order (draw ground, then walls on top of ground,
    // then foreground on top of everything else)
    add(ground);
    add(walls);

    // Add the characters to the level
    player = new Player(Main.playerSpawn.x, Main.playerSpawn.y);
    add(player);
    dudes = new FlxTypedGroup<Dude>();
    map.loadEntities(placeEntities, "entities");
    add(dudes);

    // Add the foreground last (so it's rendered on top of the player & enemies)
    add(foreground);

    super.create();
  }

  override public function update(elapsed:Float):Void {
    super.update(elapsed);

    // Make anything in the walls layer block movement
    FlxG.collide(player, walls);
  }

You'll have a map:FlxOgmo3Loader variable to put the Ogmo3 loader into just like in the tutorial. Then we make three calls so the ground, walls, and foreground FlxTilemap variables are set by using map.loadTilemap(), where the second parameter is the name of the layer from Ogmo3 that we're reading in.


Next thing to notice is that it matters what order you list things when you use the add() function. HaxeFlixel will draw things in the order that it sees them, so you want the ground to be drawn first, then walls and characters drawn next so they end up being drawn on top of the ground, then foreground drawn last so it's on top of everything else. If you have more stuff to add like textboxes that should be in front of even the foreground, then of course add them later.


Last thing to notice is that all you've got to do to make walls block movement is to add lines to the update() function specifying what should be blocked by walls. In this case I'm showing it for the player, but you would also want to have similar lines making the walls block enemies and projectiles and anything else that should be blocked. Notice that you don't need to specify which tiles block movement and which ones don't, by default HaxeFlixel will make every non-empty tile in a layer block movement, which is why I stressed to only put tiles in the walls layer in Ogmo3 if you want those tiles to block movement.


And that's it, now you know how to work with a slick spritesheet like this in Ogmo3 and bring it into your HaxeFlixel game!


Tags:

1