Funded with Kickstarter

Pitfalls!

Our unassuming protagonist has had it easy so far. The ball bounces around our level with some harmless, hovering pods. Let’s throw some danger his way. Crossover time! Enter the platformer’s arch-enemy: the bottomless pit.

Pity the Fall

For our pits, we’re going to make use of Flixel’s super nice auto-tiling feature. For that, we’ll use two pit tilesets. The two tilesets are named the same, so pay attention! :)

Download the simpler of the two and add it to your project’s bin folder. (It’s really not much to look at—one blank tile, one filled.)

pits.png

Then download the more complex of the two and add it to your project’s assets folder. (That’s inside the src folder, remember?) This tileset includes all the corners and caps necessary to frame pretty much any irregularly-shaped pit you can throw at it.

pits.png

Now open your level.tmx in Tiled. Create a new Tile Layer (Layer > Add Tile Layer) and name it pits. Then add a new Tileset (Map > New Tileset…) using the pits.png in your project’s bin folder. Using the Stamp tool draw in some pits. Once you’re done, your level.tmx should look something like this:

tiled-pits.png

Tiled Gotcha: It’s very easy to get turned around in Tiled. You can draw on your bg layer with your pit tileset but you usually don’t mean to. If you are trying to draw on your map and nothing seems to be happening or it doesn’t look right in game, make sure the correct tool, layer, tileset, and tile are selected. Turning layers on and off is an easy way to spot the source of the problem. (Photoshop’s custom cursors for each tool don’t seem so excessive now, do they?)

Save level.tmx and open FDT.

The Pits

First we need to get our pits drawing in game, then we’ll hook up the related behavior. Open ROM.as and embed pits.png by adding:

[Embed(source="assets/pits.png")] static public var ImgPits : Class;

ROM.as should now look like this:

package {
    public class ROM {
        [Embed(source="assets/bg.png")] static public var ImgBg : Class;
        [Embed(source="assets/pits.png")] static public var ImgPits : Class;
        [Embed(source="assets/ball.png")] static public var ImgBall : Class;
        [Embed(source="assets/pod.png")] static public var ImgPod : Class;
    }
}

Open PlayState.as and beneath:

private var background:FlxTilemap = new FlxTilemap();

Add:

private var pits:FlxTilemap = new FlxTilemap();

Then, inside the loadStateFromTmx() function, beneath:

add(background);

Add:

var csvPits:String = tmx.getLayer('pits').toCsv(tmx.getTileSet('pits'));
pits.loadMap(csvPits, ROM.ImgPits, 16, 16, FlxTilemap.AUTO);
add(pits);

Note the FlxTilemap.AUTO in our call to loadMap(). This tells Flixel to take our simple tilemap and automatically replace all corners and caps with the appropriate tile from our more complex tilemap.

Now run your game. You should see something like this:

pitted.gif

Mostly Harmless

If you bounced around the updated level (and why wouldn’t you?) you found that the pits are currently harmless. A game designer’s work is never done! Let’s add some bite to their bark.

We’re anticipating adding enemies later that can also be knocked into the pit so we’re going to abstract this next bit of code out into a new Actor class. Here’s how it’s going to work:

  1. Check if the actor overlaps any pit tiles.
  2. If so, add pit gravity to the actor to pull them towards the center of any overlapped pit tile.
  3. If all four corners of an actor overlap a pit tile then the actor has fallen into the pit.

Back in FDT, create Actor.as (File > New > ActionScript Class). Enter the Class name Actor, enter the Superclass org.flixel.FlxSprite, check “Explicitly invoke superclass constructor”, and click the Finish button.

Replace the contents of Actor.as with:

package {
    import org.flixel.*;
    import org.flixel.system.FlxTile;

    public class Actor extends FlxSprite {
        static public const pitGravity:Number = 20;

        public var px:Number = 0;
        public var py:Number = 0;
        public var inPitTL:Boolean = false;
        public var inPitTR:Boolean = false;
        public var inPitBR:Boolean = false;
        public var inPitBL:Boolean = false;

        public var didFallIntoPit:Boolean = false;

        public function Actor(X:Number=0, Y:Number=0, SimpleGraphic:Class = null) {
            super(X, Y, SimpleGraphic);
        }

        override public function update():void {
            if (!didFallIntoPit) {
                // halve pit gravity if player is already moving towards the pit
                if ((velocity.x > 0 && px > 0) || (velocity.x < 0 && px < 0)) px /= 2;
                if ((velocity.y < 0 && py < 0) || (velocity.y < 0 && py < 0)) py /= 2;
                velocity.x += px;
                velocity.y += py;
            }

            super.update();

            if (!didFallIntoPit) {
                // if all four corners are inside the pit
                if (inPitTL && inPitTR && inPitBR && inPitBL) {
                    didFallIntoPit = true;
                    alive = false;
                    fallIntoPit();
                }
            }

            // reset
            px = py = 0;
            inPitTL = inPitTR = inPitBR = inPitBL = false;
        }

        public function fallIntoPit():void {
            // override in subclass
        }

        public function afterOverlapPit():void {
            // clamp pit gravity
            if (px > pitGravity)    px = pitGravity;
            if (px < -pitGravity)   px = -pitGravity;
            if (py > pitGravity)    py = pitGravity;
            if (py < -pitGravity)   py = -pitGravity;
        }

        static public function overlapPit(tile:FlxTile, actor:Actor):Boolean {
            // if actor overlaps pit tile
            if ((actor.x + actor.width > tile.x) && (actor.x < tile.x + tile.width) && (actor.y + actor.height > tile.y) && (actor.y < tile.y + tile.height)) {
                // distance between the actor and the pit tile
                var dx:Number = actor.x - tile.x;
                var dy:Number = actor.y - tile.y;

                // figure out in which direction to apply pit gravity, if any
                actor.px += dx>0?-pitGravity:dx<0?pitGravity:0;
                actor.py += dy>0?-pitGravity:dy<0?pitGravity:0;

                var rActorTop:Number = Math.round(actor.y);
                var rActorLeft:Number = Math.round(actor.x);
                var rActorRight:Number = Math.round(actor.x+actor.width);
                var rActorBottom:Number = Math.round(actor.y+actor.height);

                var rTileTop:Number = Math.round(tile.y);
                var rTileLeft:Number = Math.round(tile.x);
                var rTileRight:Number = Math.round(tile.x+tile.width);
                var rTileBottom:Number = Math.round(tile.y+tile.height);

                // check all four corners
                if (rActorTop >= rTileTop) {
                    if (rActorLeft >= rTileLeft)    actor.inPitTL = true;
                    if (rActorRight <= rTileRight)  actor.inPitTR = true;
                }
                if (rActorBottom <= rTileBottom) {
                    if (rActorLeft >= rTileLeft)    actor.inPitBL = true;
                    if (rActorRight <= rTileRight)  actor.inPitBR = true;
                }
                return true;
            }
            else {
                return false;
            }
        }
    }
}

Phew! That’s our three item list translated into code. What’s neat about this implementation is that two adjacent pit tiles will cancel each other’s gravity. When an actor overlaps two tiles horizontally, it is only pulled vertically (and vice versa). Also of note, in the update() function we halve pit gravity when the player is already moving in that direction so the player has a little more time to react.

Pitball

ball.png

Download the updated ball.png and replace the old one in your project’s assets folder. Open up Ball.as and replace:

public class Ball extends FlxSprite {

with:

public class Ball extends Actor {

Then beneath:

addAnimation("bounce", [0,1,2,3], 10);

add:

addAnimation("fall", [4,5,6,7,7,7,7,7,7,7,7,7,7,7,7], 10, false);

What’s with all the sevens? Falling is a non-looping animation. We’re going to use the animation’s finished state to determine when to reload the level after the player dies. The extra sevens extend the duration of the entire animation to 1.5 seconds. Just enough time for the player to reflect on their mistake.

Next, we need to override the fallIntoPit() function to use our new animation:

override public function fallIntoPit():void {
    play("fall");
}

With these changes, Ball.as should now look like this:

package {
    import org.flixel.FlxSprite;

    public class Ball extends Actor {
        public function Ball(X:Number, Y:Number) {
            super(X, Y);
            loadGraphic(ROM.ImgBall, true, true, 16, 16);
            addAnimation("bounce", [0,1,2,3], 10);
            addAnimation("fall", [4,5,6,7,7,7,7,7,7,7,7,7,7,7,7], 10, false);
            play("bounce");
            offset.y = 4;
        }
    }

    override public function fallIntoPit():void {
        play("fall");
    }
}

Finally, open PlayState.as. We need to check for pit overlaps and disable user input if the ball has fallen into a pit and died. At the bottom of the update() function add:

pits.overlapsWithCallback(ball, Actor.overlapPit);
ball.afterOverlapPit();

if (ball.didFallIntoPit && ball.finished) {
    FlxG.switchState(new PlayState);
}

Then find:

if (FlxG.keys.RIGHT) {
    ball.velocity.x = ballVelocity;
}
else if (FlxG.keys.LEFT) {
    ball.velocity.x = -ballVelocity;
}

if (FlxG.keys.UP) {
    ball.velocity.y = -ballVelocity;
}
else if (FlxG.keys.DOWN) {
    ball.velocity.y = ballVelocity;
}

and wrap it in a conditional, like so:

if (ball.alive) {
    if (FlxG.keys.RIGHT) {
        ball.velocity.x = ballVelocity;
    }
    else if (FlxG.keys.LEFT) {
        ball.velocity.x = -ballVelocity;
    }

    if (FlxG.keys.UP) {
        ball.velocity.y = -ballVelocity;
    }
    else if (FlxG.keys.DOWN) {
        ball.velocity.y = ballVelocity;
    }
}

With this Primer’s additions, PlayState.as should now look like this:

package {
    import org.flixel.*;
    // TMX Loading
    import net.pixelpracht.tmx.*;
    import flash.events.Event;
    import flash.net.URLLoader;
    import flash.net.URLRequest;

    public class PlayState extends FlxState {
        private var hasLoaded:Boolean = false;
        private var background:FlxTilemap = new FlxTilemap();
        private var pits:FlxTilemap = new FlxTilemap();
        private var ball:Ball;
        private var podGroup:FlxGroup = new FlxGroup();

        override public function create():void {
            // initiate variables, add sprites, tilemaps, etc

            loadTmxFile();
        }

        // Level loading
        private function loadTmxFile():void {
            var loader:URLLoader = new URLLoader();
            loader.addEventListener(Event.COMPLETE, onTmxLoaded);
            loader.load(new URLRequest('level.tmx'));
        }

        private function onTmxLoaded(e:Event) : void {
            var loader:URLLoader = e.target as URLLoader;
            var xml:XML = new XML(loader.data);
            var tmx:TmxMap = new TmxMap(xml);
            loadStateFromTmx(tmx);
            hasLoaded = true; // allow updates
        }

        private function loadStateFromTmx(tmx:TmxMap) : void {
            var csvBg:String = tmx.getLayer('bg').toCsv(tmx.getTileSet('bg'));
            background.loadMap(csvBg, ROM.ImgBg, 16, 16, FlxTilemap.OFF,0,0,2);
            add(background);

            var csvPits:String = tmx.getLayer('pits').toCsv(tmx.getTileSet('pits'));
            pits.loadMap(csvPits, ROM.ImgPits, 16, 16, FlxTilemap.AUTO);
            add(pits);

            var group : TmxObjectGroup = tmx.getObjectGroup('entities');
            for each (var object:TmxObject in group.objects) {
                spawnObject(object);
            }
            add(podGroup);
            add(ball);

            FlxG.camera.setBounds(0, 0, background.width, background.height, true);
            FlxG.camera.follow(ball, FlxCamera.STYLE_TOPDOWN_TIGHT);
        }

        private function spawnObject(obj : TmxObject) : void {
            switch(obj.type) {
                case "Ball":
                    ball = new Ball(obj.x, obj.y);
                break;

                case "Pod":
                    podGroup.add(new Pod(obj.x, obj.y));
                break;
            }
        }

        override public function update():void {
            // listen for user input, move sprites, etc

            if (!hasLoaded) return;

            if (FlxG.keys.justPressed("ENTER")) {
                FlxG.switchState(new PlayState); // reload to test map changes
            }

            // handle user input
            var ballVelocity:Number = 64;

            ball.velocity.x = 0;
            ball.velocity.y = 0;

            if (ball.alive) {
                if (FlxG.keys.RIGHT) {
                    ball.velocity.x = ballVelocity;
                }
                else if (FlxG.keys.LEFT) {
                    ball.velocity.x = -ballVelocity;
                }

                if (FlxG.keys.UP) {
                    ball.velocity.y = -ballVelocity;
                }
                else if (FlxG.keys.DOWN) {
                    ball.velocity.y = ballVelocity;
                }
            }

            // update after user input
            super.update();

            // collision logic
            FlxG.collide(background, ball);
            FlxG.overlap(podGroup, ball, collectPod);

            // it's the pits!
            pits.overlapsWithCallback(ball, Actor.overlapPit);
            ball.afterOverlapPit();

            if (ball.didFallIntoPit && ball.finished) {
                FlxG.switchState(new PlayState);
            }
        }

        private function collectPod(aPod:Pod, aBall:Ball):void {
            aBall; // silence FDT warning about unused arg
            aPod.kill();
        }
    }
}

Now run your game and “accidentally” get too close to a pit. Try to recover. Let yourself fall in. Next time we’ll wrap up by adding music and sound effects.

Copyright © 2012–2018 Retro Game Crunch.