HaxeFlixel RPG tutorial: Part 17

posted on Oct 25, 2014 in Haxe, OpenFL, Game design, HaxeFlixel
HaxeFlixel RPG tutorial

Today we'll begin adding the saving functionality.

HaxeFlixel provides a FlxSave class, which can be used to easily save and load data locally.

We already have a "Load game" button in the main menu, it's also needed to add a "Save" button to the game. We'll only show it when the player is outside of combat.

The game data will be saved under a specific keyword, such as "RPG_Save", which we can store as a static variable of our PlayState class.

With all of that in mind, introduce these three variables in the PlayState.hx class:

private var saveButton:FlxButton;
public static var SAVE_NAME:String = "RPG_Save";
public var loadedGame:Bool;

The first two I already explained, the third variable will simply determine whether the game needs to load any data after its instantiated.

Add this to the bottom of the create() function:

saveButton = new FlxButton(FlxG.width - 80, 0, "Save", doSave);
add(saveButton);

if (loadedGame) {
	loadGame();
}

Here we create a button with a doSave() click handler, and check if the game was loaded (this value is set elsewhere), calling the loadGame() method if it was.

We only want to show the save button when the player is in Walking mode, outside of combat. Add this line to the update() function to manage its visibility:

// Save visibility
saveButton.visible = currentAction == Walking;

Now for the doSave() function. Here's the code, which I will explain shortly:

private function doSave():Void {
	var save:FlxSave = new FlxSave();
	// Delete all existing data
	save.bind(SAVE_NAME);
	save.erase();
	// Write new data
	save.bind(SAVE_NAME);
	save.data.hp = hud.hp;
	save.data.maxHp = hud.maxHp;
	save.data.exp = hud.exp;
	save.data.maxExp = hud.maxExp;
	save.data.level = hud.level;
	save.flush();
	FlxG.switchState(new MenuState());
}

We create a new FlxSave object and bind it to a save file using our static keyword. Before saving any data, we'll erase all the existing data under that save file.

Once the data is erased, we bind() the FlxSave instance using the keyword again and then, using its "data" property, save all the HUD properties that we'll later load.

When the game is saved, we return the player to the main menu.

Since we're accessing these HUD properties, we'll need to make them public in HUD.hx:

public var hp:Int;
public var maxHp:Int;
public var exp:Int;
public var maxExp:Int;
public var level:Int;

Back to PlayState.hx, add the loadGame() method, which loads the data instead of saving it:

public function loadGame():Void {
	var save:FlxSave = new FlxSave();
	save.bind(SAVE_NAME);
	hud.hp = save.data.hp;
	hud.maxHp = save.data.maxHp;
	hud.exp = save.data.exp;
	hud.maxExp = save.data.maxExp;
	hud.level = save.data.level;
}

We can now save our player data, let's add the ability to load it.

Update the MenuState.hx code:

package ;

import flixel.FlxG;
import flixel.FlxSprite;
import flixel.FlxState;
import flixel.text.FlxText;
import flixel.ui.FlxButton;
import flixel.util.FlxMath;
import flixel.util.FlxSave;

/**
 * A FlxState which can be used for the game's menu.
 */
class MenuState extends FlxState
{
	/**
	 * Function that is called up when to state is created to set it up.
	 */
	override public function create():Void
	{
		super.create();
		
		var init_x:Int = Math.floor(FlxG.width / 2 - 40);
		
		var btn_new = new FlxButton(init_x, 50, "New game", onNew);
		add(btn_new);
		
		var save:FlxSave = new FlxSave();
		save.bind(PlayState.SAVE_NAME);
		
		if (save.data.hp!=null) {
			var btn_load = new FlxButton(init_x, 80, "Load", onLoad);
			add(btn_load);
		}
	}
	
	private function onNew():Void {
		var playState:PlayState = new PlayState();
		FlxG.switchState(playState);
		playState.loadedGame = false;
	}
	
	private function onLoad():Void {
		var playState:PlayState = new PlayState();
		FlxG.switchState(playState);
		playState.loadedGame = true;
	}

	/**
	 * Function that is called when this state is destroyed - you might want to
	 * consider setting all objects this state uses to null to help garbage collection.
	 */
	override public function destroy():Void
	{
		super.destroy();
	}

	/**
	 * Function that is called once every frame.
	 */
	override public function update():Void
	{
		super.update();
	}
}

First of all, we only display the "Load game" button if a save file exists. We can check this by comparing any of the saved properties to null.

Then we simply set the PlayState instance's loadedGame property accordingly in the button handler functions.

The PlayState.hx code ends up looking like this:

package ;

import flixel.FlxCamera;
import flixel.FlxG;
import flixel.FlxObject;
import flixel.FlxSprite;
import flixel.FlxState;
import flixel.group.FlxTypedGroup;
import flixel.system.FlxSound;
import flixel.tile.FlxTilemap;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.ui.FlxButton;
import flixel.util.FlxColor;
import flixel.util.FlxPath;
import flixel.util.FlxPoint;
import flixel.util.FlxSave;
import openfl.Assets;

enum PlayerAction {
	Walking;
	Combat;
}

/**
 * A FlxState which can be used for the actual gameplay.
 */
class PlayState extends FlxState
{
	private var tileMap:FlxTilemap;
	public static var TILE_WIDTH:Int = 16;
	public static var TILE_HEIGHT:Int = 16;
	public static var LEVEL_WIDTH:Int = 50;
	public static var LEVEL_HEIGHT:Int = 50;
	public static var CAMERA_SPEED:Int = 8;
	private var camera:FlxCamera;
	private var cameraFocus:FlxSprite;
	private var movementMarker:FlxSprite;
	public var hero:FlxSprite;
	private var path:FlxPath;
	public var hud:HUD;
	private var potions:FlxTypedGroup<Potion>;
	private var enemies:Array<Enemy>;
	private var currentAction:PlayerAction;
	private var combatWindow:CombatWindow;
	private var combatHide:FlxTween;
	
	private var saveButton:FlxButton;
	public static var SAVE_NAME:String = "RPG_Save";
	public var loadedGame:Bool;
	
	/**
	 * Function that is called up when to state is created to set it up.
	 */
	override public function create():Void
	{
		super.create();
		
		FlxG.worldBounds.width = TILE_WIDTH * LEVEL_WIDTH;
		FlxG.worldBounds.height = TILE_HEIGHT * LEVEL_HEIGHT;
		
		tileMap = new FlxTilemap();
		tileMap.loadMap(Assets.getText("assets/data/map.csv"), "assets/images/tileset.png", TILE_WIDTH, TILE_HEIGHT, 0, 1);
		tileMap.setTileProperties(0, FlxObject.ANY);
		tileMap.setTileProperties(1, FlxObject.ANY);
		tileMap.setTileProperties(2, FlxObject.NONE);
		add(tileMap);
		
		cameraFocus = new FlxSprite();
		cameraFocus.makeGraphic(1, 1, FlxColor.TRANSPARENT);
		add(cameraFocus);
		
		camera = FlxG.camera;
		camera.follow(cameraFocus, FlxCamera.STYLE_LOCKON);
		
		movementMarker = new FlxSprite();
		movementMarker.visible = false;
		add(movementMarker);
		
		hero = new FlxSprite(TILE_WIDTH * 7, TILE_HEIGHT * 3);
		hero.loadGraphic("assets/images/hero.png", true, TILE_WIDTH, TILE_HEIGHT);
		hero.animation.add("down", [0, 1, 0, 2]);
		hero.animation.add("up", [3, 4, 3, 5]);
		hero.animation.add("right", [6, 7, 6, 8]);
		hero.animation.add("left", [9, 10, 9, 11]);
		add(hero);
		
		potions = new FlxTypedGroup<Potion>();
		add(potions);
		spawnPotion(5, 5);
		spawnPotion(6, 5);
		spawnPotion(3, 10);
		spawnPotion(4, 10);
		spawnPotion(1, 10);
		
		hero.animation.play("down");
		path = new FlxPath();
		
		enemies = new Array<Enemy>();
		addEnemy(10, 15);
		addEnemy(12, 10);
		addEnemy(15, 6);
		addEnemy(20, 6);
		addEnemy(12, 20);
		
		hud = new HUD();
		add(hud);
		
		currentAction = Walking;
		hero.active = true;
		
		combatWindow = new CombatWindow(this);
		combatWindow.active = false;
		combatWindow.visible = false;
		add(combatWindow);
		
		saveButton = new FlxButton(FlxG.width - 80, 0, "Save", doSave);
		add(saveButton);
		
		if (loadedGame) {
			loadGame();
		}
	}
	
	public function loadGame():Void {
		var save:FlxSave = new FlxSave();
		save.bind(SAVE_NAME);
		hud.hp = save.data.hp;
		hud.maxHp = save.data.maxHp;
		hud.exp = save.data.exp;
		hud.maxExp = save.data.maxExp;
		hud.level = save.data.level;
	}
	
	private function doSave():Void {
		var save:FlxSave = new FlxSave();
		// Delete all existing data
		save.bind(SAVE_NAME);
		save.erase();
		// Write new data
		save.bind(SAVE_NAME);
		save.data.hp = hud.hp;
		save.data.maxHp = hud.maxHp;
		save.data.exp = hud.exp;
		save.data.maxExp = hud.maxExp;
		save.data.level = hud.level;
		save.flush();
		FlxG.switchState(new MenuState());
	}
	
	private function spawnPotion(x:Int, y:Int):Void{
		var potion:Potion = new Potion();
		potion.x = x * TILE_WIDTH;
		potion.y = y * TILE_HEIGHT;
		potions.add(potion);
	}
	
	private function addEnemy(x:Int, y:Int):Void {
		var enemy:Enemy = new Enemy(tileMap, hero);
		enemy.x = x * TILE_WIDTH;
		enemy.y = y * TILE_HEIGHT;
		enemy.health = 5;
		enemies.push(enemy);
		add(enemy);
	}
	
	private function onPotionCollision(hero:FlxSprite, potion:Potion):Void {
		if (potion.exists && hero.exists) {
			potion.kill();
			hud.addHealth(1);
		}
	}
	
	private function onEnemyCollision(hero:FlxSprite, enemy:Enemy):Void {
		if (enemy.exists && hero.exists && hero.active && enemy.active) {
			hero.active = false;
			enemy.active = false;
			currentAction = Combat;
			startCombat(enemy);
		}
	}
	
	private function startCombat(enemy:Enemy):Void {
		combatWindow.active = true;
		combatWindow.visible = true;
		if (combatHide!=null && combatHide.active) {
			combatHide.cancel();
		}
		FlxTween.tween(combatWindow, { y: FlxG.height / 2 - 40 }, 1, { type: FlxTween.ONESHOT, ease: FlxEase.quadOut } );
		combatWindow.y = -200;
		combatWindow.fight(enemy);
	}
	
	public function winCombat(enemy:Enemy):Void {
		endCombat(enemy);
	}
	
	public function endCombat(enemy:Enemy):Void {
		enemy.kill();
		combatHide = FlxTween.tween(combatWindow, {y: -200 }, 1, { type: FlxTween.ONESHOT, ease: FlxEase.quadIn, complete: hideCombat} );
		hero.active = true;
		currentAction = Walking;
	}
	
	private function hideCombat(tween:FlxTween):Void {
		combatWindow.active = false;
		combatWindow.visible = false;	
	}

	/**
	 * Function that is called when this state is destroyed - you might want to
	 * consider setting all objects this state uses to null to help garbage collection.
	 */
	override public function destroy():Void
	{
		super.destroy();
	}

	/**
	 * Function that is called once every frame.
	 */
	override public function update():Void
	{
		super.update();
		
		// Save visibility
		saveButton.visible = currentAction == Walking;
		
		// Collisions
		FlxG.overlap(hero, potions, onPotionCollision);
		var i:Int;
		for (i in 0...enemies.length) {
			FlxG.overlap(hero, enemies[i], onEnemyCollision);
		}
		
		// Animation
		if (!path.finished && path.nodes!=null) {
			if (path.angle == 0 || path.angle == 45 || path.angle == -45) {
				hero.animation.play("up");
			}
			if (path.angle == 180 || path.angle == -135 || path.angle == 135) {
				hero.animation.play("down");
			}
			if (path.angle == 90) {
				hero.animation.play("right");
			}
			if (path.angle == -90) {
				hero.animation.play("left");
			}
		} else {
			hero.animation.curAnim.curFrame = 0;
			hero.animation.curAnim.stop();
		}
		
		// Camera movement
		if (FlxG.keys.anyPressed(["DOWN", "S"])) {
			cameraFocus.y += CAMERA_SPEED;
		}
		if (FlxG.keys.anyPressed(["UP", "W"])) {
			cameraFocus.y -= CAMERA_SPEED;
		}
		if (FlxG.keys.anyPressed(["RIGHT", "D"])) {
			cameraFocus.x += CAMERA_SPEED;
		}
		if (FlxG.keys.anyPressed(["LEFT", "A"])) {
			cameraFocus.x -= CAMERA_SPEED;
		}
		
		// Camera bounds
		if (cameraFocus.x < FlxG.width / 2) {
			cameraFocus.x = FlxG.width / 2;
		}
		if (cameraFocus.x > LEVEL_WIDTH * TILE_WIDTH - FlxG.width / 2) {
			cameraFocus.x = LEVEL_WIDTH * TILE_WIDTH - FlxG.width / 2;
		}
		if (cameraFocus.y < FlxG.height / 2) {
			cameraFocus.y = FlxG.height / 2;
		}
		if (cameraFocus.y > LEVEL_HEIGHT * TILE_HEIGHT - FlxG.height / 2) {
			cameraFocus.y = LEVEL_HEIGHT * TILE_HEIGHT - FlxG.height / 2;
		}
		
		// Mouse clicks
		if (currentAction == Walking && FlxG.mouse.justReleased){
			var tileCoordX:Int = Math.floor(FlxG.mouse.x / TILE_WIDTH);
			var tileCoordY:Int = Math.floor(FlxG.mouse.y / TILE_HEIGHT);
			
			movementMarker.visible = true;
			if (tileMap.getTile(tileCoordX, tileCoordY) == 2) {
				var nodes:Array<FlxPoint> = tileMap.findPath(FlxPoint.get(hero.x + TILE_WIDTH/2, hero.y + TILE_HEIGHT/2), FlxPoint.get(tileCoordX * TILE_WIDTH + TILE_WIDTH/2, tileCoordY * TILE_HEIGHT + TILE_HEIGHT/2));
				if (nodes != null) {
					path.start(hero, nodes);
					movementMarker.loadGraphic(AssetPaths.marker_move__png, false, TILE_WIDTH, TILE_HEIGHT);
				}else {
					movementMarker.loadGraphic(AssetPaths.marker_stop__png, false, TILE_WIDTH, TILE_HEIGHT);
				}
			}else {
				movementMarker.loadGraphic(AssetPaths.marker_stop__png, false, TILE_WIDTH, TILE_HEIGHT);
			}
			movementMarker.setPosition(tileCoordX * TILE_WIDTH, tileCoordY * TILE_HEIGHT);
		}
	}
}

And now we can save player data, such as health, level and experience.

This may be enough for some games, but next time we'll expand this system, adding the ability to save and load additional data, such as player's position, remaining enemies and potions.

16993