SpriteSheet class for AS3Isolib

One of the requirements of my latest as3isolib project was to have “realistic” looks for all objects. The only way to achieve this is use bitmap assets so I paid a friend who’s really good at making animated 3d models to create animated assets for me and render them as PNG sprite sheets. Another thing I had to do was blit the sheets but there’s no built-in bitmap blitter class in as3isolib that I could use so I wrote the SpriteSheet class below.

package iso
{
	import de.polygonal.ds.Array2;
	import iso.Directions;
	import flash.display.Bitmap;
	import flash.display.BitmapData;
	import flash.display.DisplayObject;
	import flash.display.Sprite;
	import flash.events.Event;
	import flash.geom.Point;
	import flash.geom.Rectangle;
	import flash.utils.getTimer;

	/**
	 * Bitmap-blitter. The source must be a bitmap or sprite with 8 rows representing 8 directions.
	 * Each row contains an animation cycle where column 0 is for idle state.
	 * 
	 * @author Anggie Bratadinata
	 */
	public class SpriteSheet extends Sprite
	{
		//rendering interval
		public static const RENDER_INTERVAL:Number = 64;

		//bitmap frames
		protected var _bitmapArray:Array2;
		//sheet source
		protected var _source:DisplayObject;
		//frame size
		protected var _frameWidth:Number;
		protected var _frameHeight:Number;
		//the rendered bitmap
		protected var _frameBitmap:Bitmap;
		protected var _frameRect:Rectangle;

		//current facing direction
		protected var _direction:String;
		//current frame row
		protected var _currentRow:Array;
		protected var _currentColNum:Number;
		//render timer
		protected var _oldTime:Number = 0;
		protected var _renderInterval:Number;
		
		//is the south-facing sequence on the first row (true) or not (false)?
		protected var _southFirst:Boolean;
		
		//the visual state 
		protected var _isIdle:Boolean;
		
		//if true, a new bitmap will be generated
		protected var _isDirty:Boolean;

		public function SpriteSheet()
		{

		}

		/**
		 * Build the sprite sheet. The source must contain 8 rows of animation frames arranged in clockwise order.
		 * The first row can be the south face or the south west. A tool like SpriteForge renders tilesheets 
		 * with the south-facing sequence on the first row. 
		 * 
		 * Acceptable row arrangements : S,SW,W,NW,N,NE,E,SE or SW,W,NW,N,NE,E,SE,S
		 * 
		 * @param	source			The source Display object. 
		 * @param	frameWidth		animation frame width
		 * @param	frameHeight		animation frame height
		 * @param	renderInterval	delay between renders. This defines the animation blitting speed in milliseconds
		 * @param	southFirst		the south-facing sequence is on the first row (true) or the last (false). 
		 *                          
		 */
		public function build(source:DisplayObject, frameWidth:Number, frameHeight:Number, renderInterval:Number = SpriteSheet.RENDER_INTERVAL, southFirst:Boolean = true):void
		{
			
			_source = source;
			_frameWidth = frameWidth;
			_frameHeight = frameHeight;
			_renderInterval = renderInterval;
			_southFirst = southFirst;

			var sourceBd:BitmapData = new BitmapData(source.width, source.height, true, 0x00000000);
			sourceBd.draw(source, null, null, null, null, true);

			var numCols:Number = Math.floor(source.width / frameWidth);
			var numRows:Number = Math.floor(source.height / frameHeight);

			_frameRect = new Rectangle(0, 0, frameWidth, frameHeight);

			_bitmapArray = new Array2(numCols, numRows);

			for (var i:int = 0; i < numRows; i++)
			{
				for (var j:int = 0; j < numCols; j++)
				{
					var bd:BitmapData = new BitmapData(frameWidth, frameHeight, true, 0x000000);
					_frameRect.x = j * frameWidth;
					_frameRect.y = i * frameHeight;

					bd.copyPixels(sourceBd, _frameRect, new Point(0, 0));

					_bitmapArray.set(j, i, bd);

				}

			}
			
			_frameRect.x = 0;
			_frameRect.y = 0;
			
			//if the south faces is on the last row, move it to the first
			if (!_southFirst)
			{
				_bitmapArray.shiftDown();
			}

			setDirection(Directions.S);
			idle();

			_oldTime = getTimer();
			addEventListener(Event.ENTER_FRAME, onEnterFrame);
			
		}
		
		/**
		 * Set the state to idle
		 */
		public function idle():void
		{
			
			_isIdle = true;
			_isDirty = true;
		};
		
		/**
		 * Set the state to walk/animated
		 * 
		 */
		public function action():void
		{
			_isIdle = false;
			_isDirty = true;
		};

		protected function onEnterFrame(e:Event = null):void
		{

			var elapsed:Number = getTimer() - _oldTime;

			if (elapsed >= _renderInterval && _isDirty)
			{
				render();
				_oldTime = getTimer();
			}

		}

		protected function render():void
		{
			if (_frameBitmap == null)
			{
				_frameBitmap = new Bitmap(new BitmapData(_frameWidth, _frameHeight, true, 0x00000000));
				addChild(_frameBitmap);
			}

			_frameBitmap.bitmapData.lock();
			
			if (_isIdle)
			{
				_currentColNum = 0;
				_frameBitmap.bitmapData.copyPixels(_currentRow[_currentColNum], _frameRect, new Point(0, 0), null, null, false);
				//trace(this + "render idle");
				_isDirty = false;
			}
			else
			{
				
				if (_currentColNum < _currentRow.length-1)
				{
					try {
						_frameBitmap.bitmapData.copyPixels(_currentRow[_currentColNum], _frameRect, new Point(0, 0), null, null, false);
					}catch (error:Error) {
						//trace("ERROR RENDERING : " + _currentColNum);
						return;
					}
					_currentColNum++;
				}
				else
				{
					_currentColNum = 1;
				}
				
				_isDirty = true;
			}
			
			_frameBitmap.bitmapData.unlock();
		}

		/**
		 * Set the active direction and make _currentRow point to a specific row in _bitmapArray
		 * @param	direction
		 */
		public function setDirection(direction:String):void
		{
			//trace(this +"set direction : " +direction);
			_direction = direction;
			var rowNum:Number;
			switch (direction)
			{
				case Directions.S:
					rowNum = 0;
					break;
				case Directions.SW:
					rowNum = 1;
					break;
				case Directions.W:
					rowNum = 2;
					break;
				case Directions.NW:
					rowNum = 3;
					break;
				case Directions.N:
					rowNum = 4;
					break;
				case Directions.NE:
					rowNum = 5;
					break;
				case Directions.E:
					rowNum = 6;
					break;
				case Directions.SE:
					rowNum = 7;
					break;
				default:
					rowNum = 0;
			}
			//trace(this + "setDirection, row " + rowNum);
			_currentRow = _bitmapArray.getRow(rowNum);
			
			_isDirty = true;
			render();
			
		}
		
		/**
		 * Get currently rendered frame
		 * 
		 * @return Bitmap
		 */
		public function getFrameBitmap():Bitmap
		{
			return _frameBitmap;
		}
		
		/**
		 * Get current direction
		 * @return
		 */
		public function getDirection():String
		{
			return _direction;
		};

	}

}

Next, I'll explain how to create your sheets.

Creating the sheets

Rules:

  • The sheet must have 8 rows , one for each direction, arranged in clockwise order
  • Either the first or last row must contain sequence for animations used when the object is facing south. The acceptable row order is S-SW-W-NW-N-NE-E-SE or SW-W-NW-N-NE-E-SE-S
  • The first column is used for "idle" state

Example:

Sample Sprite Sheet

3d artists usually know how to render 3d animated models as sprite sheets but if you don't have anyone to do that for you and you, like myself, don't know how to use 3d editors, you can use one of these tools below to make png sprite sheets out of 3d models. None of them is free but they are all worth buying:

  • SpriteForge ( supports multiple formats : md2, b3d, x, etc). This is what I use. The site seems down when I'm writing this article, though.
  • SpriteWorks (collada & .dts only)
  • FragMotion (supports multiple formats ). Last time I tried it, it could only render one row at a time so I had to use Fireworks to combine and arrange the output PNGs as one big sheet.

Next, you have to find out the dimension of the frame in your png sheet which is easy; just divide the width of the png by the number of columns to get the frame width and divide the height of the png by the number of rows to get the frame height. If you use one of the tools in the list above, you don't have to calculate the frame dimensions yourself because you have to enter their values somewhere before you hit render button.

Test your sheet

Now you can test your sheet. The SpriteSheet class has 2 states : "idle" and "action". If you set it to idle by calling idle() function, it will render a frame from the first column. If you set it to action by calling action(), it will blit all frames in a row, depends on the direction you set, starting from the second column until you set it to idle again. Since the SpriteSheet class doesn't depend on any as3isolib class and it extends flash.display.Sprite, you can test it easily. Here's a sample tester:

package
{
	import flash.display.Sprite;
	import flash.events.Event;
	import flash.events.MouseEvent;
	import flash.text.TextField;
	import flash.text.TextFieldAutoSize;
	import iso.Directions;
	import iso.SpriteSheet;

	/**
	 * ...
	 * @author Anggie Bratadinata | www.masputih.com
	 */
	public class Tester extends Sprite
	{
		[Embed(source='f0.png')]
		public const SHEET:Class;

		public const SHEET_FRAME_WIDTH:Number = 48;
		public const SHEET_FRAME_HEIGHT:Number = 92;

		private var _ss:SpriteSheet;

		public function Tester()
		{
			addEventListener(Event.ADDED_TO_STAGE, init);
		}

		private function init(e:Event):void
		{
			removeEventListener(Event.ADDED_TO_STAGE, init);

			createUI();
			createSpriteSheet();

		}

		private function createSpriteSheet():void
		{

			_ss = new SpriteSheet();
			_ss.build(new SHEET(), SHEET_FRAME_WIDTH, SHEET_FRAME_HEIGHT);
			_ss.x = _ss.y = 100
			addChild(_ss);

		}

		private function changeDirection(e:Event):void
		{
			var s:Sprite = Sprite(e.target);
			_ss.setDirection(s.name);
		}

		private function changeState(e:Event):void
		{
			var s:Sprite = Sprite(e.target);

			if (s.name == "idle")
			{
				_ss.idle();
			}
			else if (s.name == "action")
			{
				_ss.action();
			}
		}

		private function createUI():void
		{
			addChild(createButton("action", 10, 10, changeState));
			addChild(createButton("idle", 80, 10, changeState));

			addChild(createButton(Directions.S, 10, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.SW, 80, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.W, 150, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.NW, 230, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.N, 10, 70, changeDirection, 0x400080));
			addChild(createButton(Directions.NE, 80, 70, changeDirection, 0x400080));
			addChild(createButton(Directions.E, 150, 70, changeDirection, 0x400080));
			addChild(createButton(Directions.SE, 230, 70, changeDirection, 0x400080));

		}

		private function createButton(labelText:String, x:Number, y:Number, clickListener:Function, bgColor:uint = 0, labelColor:uint = 0xFFFFFF, width:Number = 60):Sprite
		{

			var label:TextField = new TextField();
			label.textColor = labelColor;
			label.autoSize = TextFieldAutoSize.LEFT;
			label.text = labelText;

			var s:Sprite = new Sprite();
			s.addChild(label);
			s.name = labelText;

			with (s)
			{
				graphics.beginFill(bgColor);
				graphics.drawRect(0, 0, 60, 20);
				graphics.endFill();
			}
			s.x = x;
			s.y = y;
			s.mouseChildren = false;
			s.addEventListener(MouseEvent.CLICK, clickListener);
			return s;
		}

	}

}

Click on the black/blue buttons

Attaching the SpriteSheet to IsoSprite

Using the SpriteSheet to skin an IsoSprite is easy. An extra step you must do is offset the SpriteSheet so its frames can be placed correctly in IsoSprite.container. See the samples below.

Without & With offsets

package
{
	import as3isolib.display.IsoSprite;
	import as3isolib.display.IsoView;
	import as3isolib.display.scene.IsoGrid;
	import as3isolib.display.scene.IsoScene;
	import flash.display.Sprite;
	import flash.events.Event;
	import flash.events.MouseEvent;
	import flash.text.TextField;
	import flash.text.TextFieldAutoSize;
	import iso.*;

	public class Demo extends Sprite
	{

		[Embed(source='f0.png')]
		public const SHEET:Class;

		public const SHEET_FRAME_WIDTH:Number = 48;
		public const SHEET_FRAME_HEIGHT:Number = 92;

		private var _view:IsoView;
		private var _ss:SpriteSheet;

		public function Demo():void
		{
			addEventListener(Event.ADDED_TO_STAGE, init);
		}

		private function init(e:Event):void
		{
			removeEventListener(Event.ADDED_TO_STAGE, init);

			createSpriteSheet();
			createIso();
			createUI();

		}

		private function createSpriteSheet():void
		{
			_ss = new SpriteSheet();
			_ss.build(new SHEET(), SHEET_FRAME_WIDTH, SHEET_FRAME_HEIGHT);
			//offset the sprite sheet
			_ss.x = -13;
			_ss.y = -65;

		}

		private function createIso():void
		{
			////////// GRID
			var grid:IsoGrid = new IsoGrid();
			grid.setGridSize(4, 4);
			grid.cellSize = 20;

			var gscene:IsoScene = new IsoScene();
			gscene.addChild(grid);

			////////// ISO SPRITE
			var isoSprite:IsoSprite = new IsoSprite();
			isoSprite.setSize(20, 20, 80);
			isoSprite.moveTo(-10, 0, 0);
			//attach the spritesheet to isosprite
			isoSprite.sprites = [_ss];

			var scene:IsoScene = new IsoScene();
			scene.addChild(isoSprite);

			////////// ISO VIEW
			_view = new IsoView();
			_view.y = 100;
			_view.setSize(stage.stageWidth, 200);
			_view.addScene(gscene);
			_view.addScene(scene);
			_view.addEventListener(Event.ENTER_FRAME, render);
			addChild(_view);

		}

		private function render(e:Event):void
		{
			_view.render(true);
		}

		private function changeDirection(e:Event):void
		{
			var s:Sprite = Sprite(e.target);
			_ss.setDirection(s.name);
		}

		private function changeState(e:Event):void
		{
			var s:Sprite = Sprite(e.target);

			if (s.name == "idle")
			{
				_ss.idle();
			}
			else if (s.name == "action")
			{
				_ss.action();
			}
		}

		private function createUI():void
		{
			addChild(createButton("action", 10, 10, changeState));
			addChild(createButton("idle", 80, 10, changeState));

			addChild(createButton(Directions.S, 10, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.SW, 80, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.W, 150, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.NW, 230, 40, changeDirection, 0x400080));
			addChild(createButton(Directions.N, 10, 70, changeDirection, 0x400080));
			addChild(createButton(Directions.NE, 80, 70, changeDirection, 0x400080));
			addChild(createButton(Directions.E, 150, 70, changeDirection, 0x400080));
			addChild(createButton(Directions.SE, 230, 70, changeDirection, 0x400080));

		}

		private function createButton(labelText:String, x:Number, y:Number, clickListener:Function, bgColor:uint = 0, labelColor:uint = 0xFFFFFF, width:Number = 60):Sprite
		{

			var label:TextField = new TextField();
			label.textColor = labelColor;
			label.autoSize = TextFieldAutoSize.LEFT;
			label.text = labelText;

			var s:Sprite = new Sprite();
			s.addChild(label);
			s.name = labelText;

			with (s)
			{
				graphics.beginFill(bgColor);
				graphics.drawRect(0, 0, 60, 20);
				graphics.endFill();
			}
			s.x = x;
			s.y = y;
			s.mouseChildren = false;
			s.addEventListener(MouseEvent.CLICK, clickListener);
			return s;
		}
	}

}

Click on the black/blue buttons

Here's the zip containing all of those stuff above:
→ Spritesheet Demo

That's it. Enjoy the class and let me know if you have problems.

Also in this category ...


6 Comments

  1. Thank you so much for the tutorial.

    However, i do notice a minor flaw of the codes above. The loop algorithm above will produce a “frame skipping” whenever:

    _currentColNum === _currentRow.length-1

    Other than that, this is perfect XD

Comments are closed.