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,732 / 1,880
Exp Rank:
38,250
Vote Power:
5.51 votes
Audio Scouts
1
Rank:
Portal Security
Global Rank:
23,490
Blams:
50
Saves:
378
B/P Bonus:
8%
Whistle:
Normal
Medals:
4,873
Supporter:
4y 2m 1d

3p0ch's News

Posted by 3p0ch - October 3rd, 2021


If you're thinking about getting into making a web game but aren't sure where to start, you've come to the right place. Especially if you're poor and want to do it on a budget of $0. Currently it's very possible to do with a number of different approaches depending on what path you think is best for your specific situation – how much you already know about programming, and whether you just want to doodle around for fun or want to get into a career semi-related to what you're doing. (Alas, I don't have the secret of how to make a ton of cash as an indy game dev and avoid being a working stiff, so don't quit your day job just yet.) This guide is by no means the only way to get started but is what I considered sound advice worth sharing.


If you don't already have programming experience, then your first decision will be whether you want to learn how to do the programming for your game, or whether you're fine just designing the game mechanics and bringing your ideas to a programmer to breathe life into. The latter approach might be easily doable if you're designing a point-n-click, or RPG type adventure, or certain types of puzzle games (especially ones where you can work through the puzzle aspects with pencil and paper to see that they're at an appropriate difficulty). For action games where you're going to need to do a lot of tweaking of parameters by playtesting it until things feel right (movement speed, damage per bullet, turn radius of homing missiles, jump height) it would be easier if the designer is also doing the programming and tweaking stuff on the fly, but it would be possible to have a programmer set up the game and tell the designer to download the game engine and run it locally and show you where the parameters live and can be tweaked. But that would mean you'll need to find a programmer willing to work on your game. Do a lot of the game design work up front and go to a programmer once you have a reasonably detailed plan for the game; otherwise no one will want to program a half-baked idea, and even if they did they wouldn't be able to program it until the design is fleshed out.


If you want to do the programming yourself but don't have programming experience, your next decision will be whether to try to learn some programming before diving into a game engine and figuring things out from their docs and tutorials. It's not required, but if you have the patience to learn to program in ANY language (not necessarily the language implemented by the game engine that you'll use) then things will be much easier to follow and you'll have much more flexibility to make your game do what you want. How much programming should you know before diving into a game engine? The more the better, but things that would be of practical use are if you know what variables and arrays are and how to use them, conditional (if-else) and looping flow controls, how to make functions that take parameters and return values (and the dangers of misunderstanding whether you're passing by value versus passing by reference), and the basics of object oriented programming (have a sense of what a class definition versus an instance is, and how to invoke instances and work with instance variables and methods). If you feel like you can write a program that calculates how long the longest streak of consecutive increasing cards is likely to be if you shuffle a deck of cards and flip then over one-by-one, and especially if you write the program by making a deck object and declaring functions to do major operations (like shuffling, flipping cards, and having a big outer loop that gets called 1000 times so you can get a reasonable average after a lot of runs) then you're definitely well prepared. You don't really NEED to know all that, and you're probably fine if you can handle variables, if-else conditionals, and loops, but the more you know the easier things will be.


Again, it's not required to know how to program before diving into game making, but if you want to learn how to program then some good places to learn are: MDN tutorials which go over all things web related from HTML to CSS to JavaScript, which are handy to know if you're making stuff for the web regardless of what engine you use (JavaScript is the programming part of it, you can skip the HTML and CSS if you want). That tutorial series also seemed like it would be the easiest one to follow for someone who has little to no prior programming experience. Other options would be learning C# (which Unity uses) from Microsoft's docs, or Python (particularly from this online book for games if you're completely new to programming) which might be easier to learn and is essentially the language Godot Engine uses. You'll probably want to install Visual Studio Code as a code editor and get used to using it relatively early in the process of learning how to program, unless you stick with Python / Godot since Godot has its own built-in code editor that's better at interacting with the IDE than if you used Visual Studio Code.


Now that you've either learned all the programming you want to learn or decided you're too cool for school, you're about ready to get started on your first game. I would recommend to FIRST come up with the overall plan for your game's design, THEN go download and learn how to use whichever game engine you pick. The reason is because when you go through the tutorials for the game engine, you shouldn't just go through them and essentially copy/paste to recreate the tutorial game. You should really understand what they're showing you how to do and why it's useful, and a great way of doing that is to have a project in mind and as you're reading the tutorial say "Oh, that will be useful when I'm making my game, I should remember how to do that and know how to adapt it to do what I want." Your game design will likely change as you see what sorts of things can and can't be done easily, which is fine, and you'll probably also get a sense of how complicated a game really is (not just the main play screen but also a HUD that shows your HP and ammo or whatever, a title screen with level selection, a pause screen with options, a game over screen, transitions between levels, and a lot more that you haven't thought of yet). This is why everyone always says to keep your first game simple, because it will end up being a lot more complicated than you originally think.


Now for the game engines, it's time to decide which one to start with. Don't get too bent out of shape about making the right choice – if you download an engine and either can't figure out how to use it or find out it can't do what you want or for whatever reason decide it's not right for you, just trash it and get another one. Even if you get good at using one engine and decide to switch engines later on, what you learn from game design in one engine will help you to use any other. The game engines that I'm familiar with (there are others out there but I'm not talking about them because I'm not familiar with them) are:


GDevelop. Did you decide that programming is for nerds and you're going to jump into game making without knowing anything about it? Then GDevelop is your best bet, at least out of the engines I've used; Construct and Game Maker Studio are also options but I haven't used them because I'm sticking to my $0 budget, and there's Scratch but I've seen too much Scratch cringe to want to use it. GDevelop uses visual scripting, which is similar in spirit to traditional coding but is designed to be easier to grasp by anyone without programming experience. I should give a disclaimer that I haven't made a full game of my own design in it because after going through the tutorials I decided against it, but I feel I know enough to discuss it. It will be more limited in what it can do than the other engines, but if ease of getting started is more important then it's a good choice. Their site has enough tutorials to see how things work and how you can make your first game. And I've written an implementation for the NewGrounds API in GDevelop so if you upload your game here you can add medals and leaderboards. One thing I'll say is that it will blithely allow you to pile all of your game's code into an Events page instead of breaking your code up into more modular pieces, and if you do that then you'll probably decide that your first game is done when the Events page gets to be such a complicated mess that you get afraid that if you make any more changes to it then it'll break the game. That's perfectly fine for your first game and not something you need to worry about just yet, but if you stick with GDevelop and make your second game in it, you'll want to learn how to make your own Functions and Behaviors to keep code neatly tucked away and avoid having a ginormous Events page. It will also be necessary if/when you decide to graduate from GDevelop and go to another engine.


Godot Engine (not to be confused with GDevelop even though they both start with G). This is my personal favorite and good for someone with some programming experience. It's suited for anything from a simple point-n-click to a complex 3D game. Some people think Godot doesn't handle 3D on the web and that Unity is your only option, but that's not true and I've published games that prove it. And it's much less bloated than Unity both in terms of file sizes on your computer and download times for the games you build. The documentation for Godot isn't as extensive as it is for Unity, so while I would recommend going ahead and starting with Godot and going through its docs to learn, it wouldn't be wrong to take the path I took of first learning in Unity and later switching to Godot. I would recommend working with GDScript (essentially Python) instead of C# because it seems to work better, and that's coming from someone who learned C# in Unity and never coded in Python before picking up Godot. Currently there are some minor issues with web builds but I've posted how to fix them: audio might play glitchy on Chromium browsers unless you route it through Howler.js, and if players change their browser zoom then it could jack up your graphics unless you do this.


Unity. The behemoth of game engines (excluding Unreal Engine, which I wouldn't recommend for web games because a nearly empty game ended up being almost 100 MB!). It will essentially let you make anything of any degree of complexity. It might be intimidating just because there's so much and it's unclear where to start, so to let you in on my path, I found the Ruby RPG tutorial and it's the single most useful tutorial I ever did. It pretty much taught me everything I needed to know to make my first game, and the fundamental game design concepts that let me dive into other game engines fairly easily. But be forewarned that I went into it already knowing how to program in a few languages including C so picking up C# was relatively easy for me; if you don't have programming experience then you'll probably have a rougher time with it. Downsides of Unity are its bloat which Godot is better about avoiding, C# probably being harder for most people new to programming than Python, and its lack of a good way of saving progress for web games. The easy way to save data is with PlayerPrefs, but that runs into the problem that any time you post an update to your game on a hosting website, the players will lose all of their save data. So if you use PlayerPrefs for saving, check out my post about how to make it not lose everyone's save data when you update. Or use a different and more flexible save handler that I wrote (which also makes it possible for multiple game devs to collaborate on a project with multiple cross-interacting games even if they're made in different engines, which I've done a proof-of-concept for), or write your own save handler based on this approach.


HaxeFlixel. This is a good framework to use if you want to do everything from code without the overhead of an IDE and the complexity of how things are done in Unity, and will let you make 2D games with pretty small file sizes and quick download times. Something that might be considered a downside is that there's less in terms of documentation than with the previous engines, but that might be a good thing: the tutorial (singular) and cheat sheet documentation that they do have is succinct and enough to learn everything important, so it will be a faster route to getting your first game made than wading through the Godot or especially Unity docs. When you download it, be sure to follow their instructions on incorporating it in Visual Studio Code because that will make your life a lot easier. The one thing that irked me when I learned it is that it wasn't clear from the documentation how things got layered when they're added to scenes which messed up my tilemaps, so I wrote a quick description about how to make it do what you want.


Phaser. This 2D framework is the ultimate in terms of keeping you as close as possible to the underlying JavaScript that a browser actually uses when it runs a game with no overhead. Basically you'll be writing and running your own JavaScript and using add-ins that Phaser sets up to make it easier to do game stuff. Be aware that the documentation on Phaser's website aside from their one simple tutorial is IMO the least well put together of all the engines/frameworks, so that isn't where I would recommend starting. Life will be easier if you start by learning JavaScript (ideally along with some HTML and CSS) from the MDN tutorials, and then jump into Phaser. I didn't do that and I went into the month-long Phaser game jam knowing how to program in C and C# from the Unity tutorials but not JavaScript, and I finished my game but it was hard and I ended up with a single huge messy JavaScript file that formed the basis for that warning I gave in the GDevelop section. Also, the Phaser docs will instruct you to set up a local server to playtest your game. You can do that in a number of ways, but the most elegant that I've found so far is described here.


Also, one thing you should know regardless of what engine you use: Once you've uploaded your game or are playing it locally from a web browser, you should open your web browser's Developer Tools (from the browser's menu or by pressing Ctrl-Shift-I) and look around, especially in the Console panel. That's where any errors will be displayed, and if something's not working right then that's probably the best place to find clues to fix it. Learn how to write to the Console from whatever game engine you're using (in Unity Debug.Log() shows up there, in Godot print() will do it, in HaxeFlixel you can use trace() or their more sophisticated debugger, and with JavaScript / Phaser it's console.log()) so you can see how far your code gets before things mess up and whether the variables really are what you expect them to be as the game is running.


I'm not going over everything you need to know or that would be useful in getting started, so I'm also making a thread in the Game Development forum for people to spill their thoughts on game devving. Especially when it comes to the actual design aspects -- what makes for a good top-down shooter, or a good RPG, or a good puzzle game design -- since I really focused on programming and getting started with an engine instead of how to design your game.


15

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:

15

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: