Saving game data as an image
Have you ever wondered, I wish I could load arbitrary data from an image and if only I had the time to write code to that effect, that would just be fantastic? Of course you have! That thought also occurred to me, and I did have some time to write code to that effect, and DataImageUtils
is the result of that.
What it does
I was thinking of game saves and players sharing data or levels with each other. Sending/hosting files is a right pain, but images however, well image-sharing websites are everywhere. And your game is probably capable of downloading images. Which means, that pointing your game at the right URL, you could download game data, levels; whatever you wanted.
A pixel in an image is in the form argb
(in Flash anyway), with each component being a uint
between 0-255
. Characters in Strings
are just (u)ints
behind the scenes, and the ASCII table fits handily between 0-255
(see http://stevehardie.com/2009/09/character-code-list-char-code/ or http://www.asciitable.com/ for the full list), so the stage is set.
The general idea is to get our data (XML/JSON/whatever) as a String, convert it to a ByteArray
, encode that ByteArray
as a PNG, and we have our data file. To read it back in, load the image, get the pixel data, and write those bytes to a String. Sure, a walk in the park.
Enter Flash
Flash BitmapDatas
use pre-multiplied alpha, and they also keep no record of the original data, meaning that getPixel32() != setPixel32()
. When getting the data for a pixel, Flash will use the alpha to try and reconstitute the original colours, and depending on the alpha, what you get back might not be what you put in. In general, as alpha approaches 0
, the approximation gets worse. Setting 0x817f7f7f
will return you 0x817e7e7e
, while 0x00ffffff
will give you 0x00000000
. Sadface. This post goes more in-depth into it than I'm bothered to.
Basically, to get accurate values back, and to make sure that "Hello world!"
doesn't come back as "Hemmo worle!"
, you need to set the alpha to 255
or 0xFF
. Which meant that I had to write the bytes by hand, writing 255
, then 3 bytes of our data, then 255
, then 3 bytes, etc.
So why use DataImageUtils?
There are a number of benefits of using this in your game:
- If players can share an image, they can share game data, like levels etc
- Depending on the data, the images can be between 1/3 to 1/2 of the size of a corresponding file, using text, like a peasant. If the text is repetitive enough (e.g. an XML describing a map), then the images are crazy small
- It makes pretty images
- You can remotely update data pretty easily; you just need to replace the image. You could probably do the same with text files, but whatever
- Unless they use this class, your data is literally* hacker-proof. They'll just be like, "What's all these weird images? Where's the data at, so I can hack it?"
The code
The actual code is pretty simple. I'm using blooddy crypto's PNGEncoder
to encode the PNG. You can download it at the official site, or from the bottom of the page with the other files.
A small note about the image size: When I get the final length of the ByteArray
, I find the factors to get the best image size. Sometimes, if there's not many factors, it can result in a long, thin image. It is possible to get a more square one, by just recursively adding empty bytes until you find a better size image, but I didn't do that here.
Another small note about the data entered. If you're using any characters outside the ASCII table, then you'll probably need to Base64
your String
beforehand. ASCII should cover Basic Latin and Latin Extended I for those that have embedded fonts in Flash before.
Keep scrolling for an example of the class in action.
package
{
import by.blooddy.crypto.image.PNGEncoder;
import flash.display.BitmapData;
import flash.errors.IOError;
import flash.utils.ByteArray;
/**
* Util functions for saving data to a PNG image
* @author Damian Connolly
*/
public class DataImageUtils
{
/**
* Converts a data string to a PNG. As we're writing to RGB, this assumes that
* all characters in the String conform to the ASCII chart:
* (http://www.asciitable.com/ or http://stevehardie.com/2009/09/character-code-list-char-code/).
* If this isn't the case, then Base64 encode your string first
* @param data The data that we want to convert
* @return A ByteArray representing a PNG, or null, if the data was bad
*/
public static function toPNG( data:String ):ByteArray
{
// failsafe
if ( data == null || data == "" )
{
trace( "3:Can't convert data to a PNG, as no data was passed" );
return null;
}
// write our string to a ByteArray and compress it
var rgb:ByteArray = new ByteArray;
rgb.writeUTFBytes( data );
rgb.compress();
// as we're writing to a 32 bit PNG, we need to round it up
// so that our ByteArray divides nicely by 4.
// OR it *would* be 4, if flash returned the proper colour values
// for non-opaque colours. Basically, if the alpha is less than 0xFF,
// because the colours are pre-multiplied by the alpha, to get the
// actual colour value back, flash makes an approximation, which can
// be pretty wrong. E.g. setPixel32( 0, 0, 0x00ffffff ) =>
// getPixel32( 0, 0 ); // 0x00000000 because the alpha is 0.
// Because of this, we can't simply copy our ByteArray to a BitmapData,
// rather we need to replace the alpha, which means writing out the
// bytes by hand, so our ByteArray needs to be divisible by 3 (rgb)
while ( ( rgb.length % 3 ) != 0 )
rgb.writeByte( 0 ); // fill the remainder with NUL chars
rgb.position = 0;
// find the factors for our BitmapData - take our ByteArray
// length / 3 (rgb) and get the biggest factors we can to make the
// squarest image possible - NOTE: depending on the data, it's possible
// that this can lead a skewed image, if it only has a small number of factors
var numPixels:int = rgb.length / 3; // number of pixels in our image, if ba = rgb
var factors:Vector.<uint> = DataImageUtils._getFactors( numPixels );
var index:int = factors.length / 2;
var w:int = factors[index];
var h:int = factors[index - 1];
// check the size
if ( w > 8191 || h > 8191 || ( w * h ) > 16777215 )
trace( "2:The image size (" + w + "x" + h + ") is pretty big; you might run into some issues when treating it" );
// create a new ByteArray, replacing all the alphas for our pixels with 255
// (so we get accurate colour values when reading it back in - see note above)
var argb:ByteArray = new ByteArray;
for ( var i:int = 0; i < numPixels; i++ )
{
var r:uint = rgb.readUnsignedByte();
var g:uint = rgb.readUnsignedByte();
var b:uint = rgb.readUnsignedByte();
argb.writeUnsignedInt( ( r << 16 ) | ( g << 8 ) | b ); // alpha will be automatically 255
}
argb.position = 0;
// create our BitmapData, and write our data
var bmd:BitmapData = new BitmapData( w, h, false );
bmd.setPixels( bmd.rect, argb );
// create our PNG (using blooddy crypto)
var png:ByteArray = PNGEncoder.encode( bmd );
// clean up our memory, then return
rgb.clear();
argb.clear();
bmd.dispose();
return png;
}
/**
* Converts a PNG BitmapData to a String
* @param png The BitmapData data for our PNG
* @return A String recovered from the PNG data, or null if our data was bad
*/
public static function fromPNG( png:BitmapData ):String
{
// failsafe
if ( png == null || png.width == 0 || png.height == 0 )
{
trace( "3:Can't convert a PNG to data, as no BitmapData was passed" );
return null;
}
// get our ByteArray data - NOTE: because flash only gives accurate colour
// values if the alpha is 0xFF, we need to strip out all the alpha bytes
var rgb:ByteArray = new ByteArray;
var argb:ByteArray = png.getPixels( png.rect );
argb.position = 0;
var numPixels:int = argb.length / 4;
for ( var i:int = 0; i < numPixels; i++ )
rgb.writeBytes( argb, ( 4 * i ) + 1, 3 );
// uncompress
rgb.position = 0;
try { rgb.uncompress(); }
catch ( e:IOError ) { trace( "3:The recovered ByteArray couldn't be uncompressed, did you use toPNG()?" ); }
// clean up our memory, then return
var str:String = rgb.readUTFBytes( rgb.length );
rgb.clear();
argb.clear();
return str;
}
/********************************************************************************/
// gets all the factors for a number
private static function _getFactors( n:uint ):Vector.<uint>
{
// if it's 0, then there's no factors
if ( n == 0 )
return new Vector.<uint>( 0, true );
// if it's 1, then the only factor is itself
var v:Vector.<uint> = null;
if ( n == 1 )
{
v = new Vector.<uint>( 1, true );
v[0] = 1;
return v;
}
// find our factors
v = new Vector.<uint>;
v.push( 1 ); // 1 is always a factor
v.push( n ); // our number itself is always a factor
for ( var i:uint = 2; i * i <= n; i++ )
{
// if it divides evenly, then add the divisor and quotient
if ( ( n % i ) == 0 )
{
v.push( i );
v.push( n / i );
}
}
return v.sort( Array.NUMERIC ); // sort before returning
}
}
}
Example
Click on the picture below to load the example SWF file. You can enter any data you like, and hit the generate button to make your image, which you can then save (amazeballs). To load a data image, enter the URL. I've uploaded a sample image to Imgur, which you can see here:
If you load this image, you'll get a data XML back!
Comments
nice work, maybe you could use the PNGEncoder and PNGEncoderOptions available in the new flash player. That should have a speed increase for large data.
Interesting! I didn't know there was a native class. I don't seem to have the mx.graphics package in the Flex 4.6.0/AIR 3.7 SDK - how are you accessing it?
Looks very interesting, indeed this would allow sharing of game data everywhere and between everyone very easily.
I can see this becoming kind of a trendy-standard for sharing game progress, replays, item data, ...
nice work, reminds me some of my experiments http://blog.yoz.sk/2011/10/a-sound-in-image-experiment/
Submit a comment