Archive for December, 2008|Monthly archive page

Quick XML attributes parsing

Scenario: you’re loading XML data where the bulk of the fields are stored as node attributes. You now want to transfer these node attributes onto typed objects so that you’ll have compile-time validation of your data integration.

One solution here is to laboriously write an accessor for every XML attribute that sets the value to the corresponding class field… hmmm. Tedious. So, I’ve turned to using a quick solution that works like a charm:

function parseAttributes($obj:Object, $atts:XMLList):Object
{
	for (var j:int=0; j < $atts.length(); j++)
	{
		var $prop:String = $atts[j].name();
		if ($obj.hasOwnProperty($prop))
		{
			if ($obj[$prop] is Boolean) $obj[$prop] = ($atts[j] == "1" || $atts[j] == "true");
			else $obj[$prop] = $atts[j];
		}
	}
	return $obj;
}

An implementation that copies all XML attributes over to a class object looks like this:

var $xml:XML = <myNode name="greg" job="flash"/>;
var $myObj:MyObject = new MyObject();
parseAttributes($myObj, $xml.@*);

The parseAttributes() method receives a object class instance and an XMLList of attributes. It then runs through the XMLList and attempts to set each XML value on the object if a corresponding property exists on the class. Of course, this begs the question of datatyping… How are XML attribute strings set as numeric properties, right? Well, apparently this is a very handy built in feature of the E4X XML parser in ActionScript3. While I’ve never found explicit documentation saying that E4X automatically converts data types, it seem to work perfectly. E4X apparently checks the recipient property’s datatype, and then parses the XML field accordingly.

That said, here are my observations: [int] works (“5” == 5), [Number] works (“5.321” == 5.321), and [uint] works (“0xFF0000” == 0xFF0000). The one exception here seems to be [Boolean]. E4X appears to evaluate all Boolean recipients as “true”, even if the XML value is “0” or “false”. So, I’ve written a specific catch into the above method to handle Boolean cases. If the recipient property is a Boolean, parseAttributes() will only evaluate the XML field as being true for a value of “1” or “true”.

Advertisements

onReleaseOutside() in AS3

Some AS2 developers probably noticed quickly that our old friend, the onReleaseOutside handler, does not have an ActionScript3 counterpart. While that old handler’s uses were limited, it played an essential role in AS2 drag-and-drop implementations since a rolled-out cursor still needs to call back to the drag target to disable dragging.

Well, the onReleaseOutside event is now obsolete thanks to AS3’s new event model; so I don’t fault Adobe in the least bit for getting rid of it. However, this is yet another case of point where ratified ActionScript methodology has gone undocumented. If Adobe does have one fault with AS3, it’s that they assume these crossovers are obvious and intuitive to the average Flash tech. And that, unfortunately, has been extremely alienating to Flash’s single biggest user-base.

Anyhow, let’s get back to the issue at hand: how do we manage an onReleaseOutside event in AS3? Quite simply, we listen to the stage for ANY MOUSE_UP event. Here’s what a simple drag-and-drop script looks like in AS3:

myClip.addEventListener(MouseEvent.MOUSE_DOWN, this._onMousePress);

function _onMousePress($event:MouseEvent):void {
    stage.addEventListener(MouseEvent.MOUSE_UP, this._onMouseRelease);
    myClip.startDrag();
}

function _onMouseRelease($event:MouseEvent):void {
    stage.removeEventListener(MouseEvent.MOUSE_UP, this._onMouseRelease);
    myClip.stopDrag();
}

A* Pathfinding With AS3

When I earned my degree as a designer, I never thought I’d end up writing a blog post about AI. However, I took a crack at writing an A* (A-star) pathfinding algorithm about five years ago for the avatar movement system in the first generation of the Lassie Engine (built using Macromedia Director and Lingo). My entire source of reference for that first implementation was The Beginner’s Guide to Pathfinding Algorithms. If you’re interested in pathfinding theory, that article is an absolute gold mine. I won’t even pretend that I could do a better job of explaining the algorithm’s nuts and bolts, so I’ll just stick to what an AS3 implementation looks like.

First, we need a data structure of a grid to run searches across. I’m generally drawn to XML for raw data structures like this since it’s easy to compose and maintain by hand. Here’s a simple XML grid of points:

Save as: grid.xml

<nodes>
    <node id="n1" x="25" y="25" join="n2,n3"/>
    <node id="n2" x="110" y="110" join="n1,n3,n4"/>
    <node id="n3" x="50" y="180" join="n1,n2,n5,n6"/>
    <node id="n4" x="225" y="90" join="n2,n5,n6"/>
    <node id="n5" x="190" y="160" join="n3,n4"/>
    <node id="n6" x="230" y="170" join="n3,n4"/>
</nodes>

The above XML creates a grid that looks like this:

demo grid of points

That XML structure is extremely simple. Each node is given an ID, an X and Y coordinate, and has a comma-separated list of other node ID’s that it connects to.

Next, we move on to the simple geometry structures that we’ll feed into our pathfinder. First is the Node object. While we’ll load our grid data as XML, we’ll want to parse that into typed Objects to give our implementation compile-time validation. So, we’d load our XML and then parse the nodes into this structure:

Save as: com/lassie/player/geom/Node.as

package com.lassie.player.geom
{
    import flash.geom.Point;

    public final class Node extends Point
    {
        // public
        public var id:String = "";

        // private
        private var _neighbors:Array;

        public function Node(key:String="", x:int=0, y:int=0):void
        {
            super(x, y);
            id = key;
            _neighbors = new Array();
        }

        /*
        * Returns all current data as a new Node object.
        */
        public function cloneNode():Node
        {
            var node:Node = new Node(id, x, y);
            node.parseNeighbors(_neighbors.join(","));
            return node;
        }

        /*
        * Parses a list of comma-seperated node id's into the array of neighbors.
        */
        public function parseNeighbors(csv:String):void
        {
            for each (var j:String in csv.split(",")) addNeighbor(j);
        }

        /*
        * Adds a new neighbor Id.
        */
        public function addNeighbor(id:String):void
        {
            if (!containsNeighbor(id) && id != "") _neighbors.push(id);
        }

        /*
        * Gets a neightbor Id by numeric index.
        */
        public function getNeighbor(index:int):String
        {
            if (index >= 0 && index < _neighbors.length) return _neighbors[index];
            return null;
        }

        /*
        * Gets the number of neighbors assigned to this node.
        */
        public function get numNeighbors():int
        {
            return _neighbors.length;
        }

        /*
        * Tests if the specified node id is among this node's neighbors.
        */
        public function containsNeighbor(id:String):Boolean
        {
            return _neighbors.indexOf(id) > -1;
        }

        /*
        * Appends an additional namespace key onto all Node references. Avoids conflicts during Grid unions.
        */
        public function expandNamespace(key:String):void
        {
            id += key;
            for each (var j:String in _neighbors) j += key;
        }

        /*
        * Trace object to string.
        */
        public override function toString():String
        {
            return "[Node] id:"+ id +", x:" + x + ", y:" + y + ", neighbors:(" + _neighbors + ")";
        }
    }
}

Now we need a Path object. Paths will be the actual search routes that our A* algorithm will create, branch, prioritize, and purge as it works. Here’s the Path class:

Save as: com/lassie/player/geom/Path.as

package com.lassie.player.geom
{
    public final class Path
    {
        // public
        public var length:int = -1;
        public var bestCase:int = -1;
        public var nodes:Array;

        // private
        private var _path:Array;

        public function Path($length:Number=-1, $bestCase:Number=-1, $path:Array=null):void
        {
            length = $length;
            bestCase = $bestCase;
            _path = ($path != null) ? $path : new Array();
        }

        public function destroy():void
        {
            _path = null;
            nodes = null;
        }

        /*
        * Returns all current data as a new Path object.
        */
        public function clone():Path
        {
            return new Path(length, bestCase, _path.slice());
        }

        /*
        * Tests if path has been initialized with actual pathing data.
        */
        public function get hasLength():Boolean
        {
            return length + bestCase >= 0;
        }

        /*
        * Returns the last node id contained within the path.
        */
        public function get lastElement():String
        {
            return _path.slice(-1)[0];
        }

        /*
        * Tests if this path contains a node Id.
        */
        public function containsNode($id:String):Boolean
        {
            return _path.indexOf($id) > -1;
        }

        /*
        * Adds a node to the path if not already present.
        */
        public function addNode($id:String):void
        {
            if (!containsNode($id)) _path.push($id);
        }

        /*
        * Trace object to string.
        */
        public function toString():String
        {
            return "[Path] length:"+ length +", nodes:("+ _path +")";
        }
    }
}

And finally we need a grid class. A Grid will hold Nodes, keyed by their respective Id’s, and will have the actual A* implementation within it to do the heavy lifting of grid searches.

Save as: com/lassie/player/geom/Grid.as

package com.lassie.player.geom
{
    import flash.geom.Point;

    public final class Grid extends Object
    {
        private var _nodes:Object;

        public function Grid():void
        {
            super();
            _nodes = new Object();
        }

        /*
        * Parses an XML structure into the grid object.
        */
        public function parseXML($xml:XML):void
        {
            // loop through all <node> XML items
            for each (var $nodeXML in $xml.children())
            {
                // create a new Node object for each XML item
                var $node:Node = new Node($nodeXML.@id, $nodeXML.@x, $nodeXML.@y);
                $node.parseNeighbors($nodeXML.@join);

                // register node by Id within the grid
                _nodes[$node.id] = $node;
            }
        }

        /*
        * Gets a node object by ID name
        */
        public function getNodeById($id:String):Node
        {
            if (_nodes[$id] != undefined) return _nodes[$id] as Node;
            return null;
        }

        /*
        * Finds the shortest path between two nodes.
        */
        public function findPath($startId:String, $goalId:String):Path
        {
            // queue of paths to search
            var $stack:Array = new Array(new Path(0, 0, [$startId]));
            // best path to goal
            var $best:Path = new Path();
            // shortest distance to each node reached thus far
            var $reachedNodes:Object = new Object();
            // cycle counter (for debug and optimization)
            var $cyc:int = 0;

            // UNTIL ALL SEARCH PATHS HAVE BEEN ELIMINATED
            while ($stack.length > 0)
            {
                // pull the first path out from the search stack
                var $searchPath:Path = $stack.shift() as Path;

                // pull the last node element from the path
                var $searchNode:Node = getNodeById($searchPath.lastElement);

                // EXTEND PATH ONE STEP TO ALL NEIGHBORS
                // creating X new paths
                for (var j:int=0; j < $searchNode.numNeighbors; j++)
                {
                    // Branch: duplicate current search path as a new branch
                    var $branch:Path = $searchPath.clone();

                    // Pull and expand upon each of searchNode's neighbor Id's.
                    var $expandNode:String = $searchNode.getNeighbor(j);

                    // REJECT PATHS WITH LOOPS
                    // if branch does NOT already contain next search node
                    if (!$branch.containsNode($expandNode))
                    {
                        // get coordinates of previous, current, and goal nodes
                        var $prevCoord:Node = getNodeById($branch.lastElement);
                        var $currentCoord:Node = getNodeById($expandNode);
                        var $goalCoord:Node = getNodeById($goalId);

                        // extend branch after having referenced last end-point.
                        $branch.addNode($expandNode);

                        // UPDATE BRANCH LENGTH AND UNDERESTIMATE
                        $branch.length += Point.distance($prevCoord, $currentCoord);
                        $branch.bestCase = $branch.length + Point.distance($currentCoord, $goalCoord);

                        // TRACK SHORTEST DISTANCE TO EACH NODE REACHED
                        // attempt to pull a distance-to-node from the register of reached nodes.
                        // if node has not yet been reached, register it with the current branch length.
                        var $shortest:Number = $reachedNodes[$expandNode];
                        if (isNaN($shortest)) $shortest = $branch.length;

                        // TEST IF PATH IS WORTH KEEPING (keep if:)
                        // - if branch is as short or shorter than the best distance to the current expansion node
                        // - and if a best-path has not been found yet, OR if this branch's best case scenario is still shorter than the best path.
                        if ($branch.length <= $shortest && (!$best.hasLength || $branch.bestCase < $best.length))
                        {
                            // log as best path to current search node
                            $reachedNodes[$expandNode] = $branch.length;

                            // If the expansion node is the goal, save branch as the parth to beat.
                            // Otherwise, add the branch back into the search stack.
                            if ($expandNode == $goalId) $best = $branch;
                            else $stack.push($branch);
                        }
                    }
                }

                // PRIORITIZE SEARCH PATHS
                // sort paths by best-case scenario so that most likely paths are searched first.
                var $priority:Function = function($a:Path, $b:Path):int
                {
                    if ($a.bestCase < $b.bestCase) return -1;
                    else if ($a.bestCase > $b.bestCase) return 1;
                    else return 0;
                }
                $stack.sort($priority);
                $cyc++;
            }
            return $best;
        }
    }
}

Now let’s put all that together. Create a new Flash document in the root of your class path and put that “grid.xml” file in with it. Then in frame-1 actions of the root timeline, just add the following:

import com.lassie.player.geom.Grid;
var $xmlLoader:URLLoader = new URLLoader();
$xmlLoader.addEventListener(Event.COMPLETE, this._onXMLLoaded);
$xmlLoader.load(new URLRequest("grid.xml"));

function _onXMLLoaded($event:Event):void
{
    // retrieve the loaded XML data
    var $xml:XML = new XML($event.target.data);

    // create a new grid and parse the loaded XML into it.
    var $grid:Grid = new Grid();
    $grid.parseXML($xml);

    // find a path between two node Id's
    trace($grid.findPath("n1", "n5"));
    trace($grid.findPath("n1", "n6"));
}

If you run that script as is, you should get the following output:

[Path] length:298, nodes:(n1,n3,n5)
[Path] length:316, nodes:(n1,n2,n4,n6)

Those are traces of two separate path searches run across the grid. The first search connected n1 and n5 to one another through their common neighbor of n3. The second search took a dramatically different route from n1 to n6 that branched through n2 and n4. Why? Because it was slightly shorter to run through two connections rather take the single connection through n3.