Today we'll start implementing a simple turn based combat system in our RPG game.
The combat will work like this - whenever an enemy touches the player, there are two action options - to attack or to flee. When the player flees the battle, they don't get any exp, but the combat ends. When the player chooses to attack, they deal some damage to the enemy, then the enemy deals some damage to them and the player gets to choose their action again.
The cycle keeps repeating until one of the combatants' health reaches 0.
Let's start by creating a new CombatWindow class.
Here's the full CombatWindow.hx code:
package ;
import flixel.FlxG;
import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup;
import flixel.text.FlxText;
import flixel.ui.FlxButton;
import flixel.util.FlxColor;
/**
* Turn based combat window.
* @author Kirill Poletaev
*/
class CombatWindow extends FlxSpriteGroup
{
private var btn_attack:FlxButton;
private var btn_flee:FlxButton;
private var bg:FlxSprite;
private var txt:FlxText;
private var playState:PlayState;
private var enemy:Enemy;
public function new(playState:PlayState)
{
super();
this.playState = playState;
this.scrollFactor.x = 0;
this.scrollFactor.y = 0;
x = FlxG.width / 2 - 100;
y = FlxG.height / 2 - 40;
bg = new FlxSprite();
add(bg);
bg.makeGraphic(200, 80, 0xff222222);
btn_attack = new FlxButton(5, 55, "Attack", onAttack);
btn_flee = new FlxButton(115, 55, "Flee", onFlee);
add(btn_attack);
add(btn_flee);
txt = new FlxText(5, 5, 190);
add(txt);
}
public function fight(enemy:Enemy) {
this.enemy = enemy;
txt.text = "A wild enemy appears!";
}
private function onAttack() {
var dmg:Int = 1;
enemy.health -= dmg;
txt.text = "You hit the enemy, dealing " + dmg + " damage.";
if (enemy.health > 0) {
var enemyDmg:Int = 0;
txt.text += "\nThe enemy strikes, dealing " + enemyDmg + " damage.";
} else {
playState.winCombat(enemy);
}
}
private function onFlee() {
playState.endCombat(enemy);
}
}
As you can see, it's a FlxSpriteGroup subclass, which has 4 visible elements - a background sprite, 2 buttons and a text field. We receive a reference to the PlayState object in the constructor and store it in a variable.
There's a function called fight() which stores a specific enemy in a local variable. This reference is later used in onAttack(), to read and modify its health property. The onAttacK() method has all of the actual combat logic, which is very simple right now - the player always deals 1 damage and the enemy always deals 0.
Whenever the enemy is killed, a winCombat() method is called in the PlayState in stance. Whenever the player flees, an endCombat() method is called.
Back to PlayState.hx, introduce a combatWindow variable:
private var combatWindow:CombatWindow;
Instantiate it and make it invisible by default:
combatWindow = new CombatWindow(this);
combatWindow.active = false;
combatWindow.visible = false;
add(combatWindow);
Edit the addEnemy() function to set the health of all enemies to 5:
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);
}
Update the onEnemyCollision() method to pass an enemy object reference to the startCombat() function:
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);
}
}
The startCombat() function starts the fight by calling the fight() method of the combatWindow object, and makes the window actually visible:
private function startCombat(enemy:Enemy):Void {
combatWindow.active = true;
combatWindow.visible = true;
combatWindow.fight(enemy);
}
The winCombat() and endCombat() functions do the same thing right now, which is kill() the enemy, hide the window, and make the character interactable again.
public function winCombat(enemy:Enemy):Void {
endCombat(enemy);
}
public function endCombat(enemy:Enemy):Void {
enemy.kill();
combatWindow.active = false;
combatWindow.visible = false;
hero.active = true;
currentAction = Walking;
}
Here's the full PlayState.hx code so far:
package ;
import flixel.FlxCamera;
import flixel.FlxG;
import flixel.FlxObject;
import flixel.FlxSprite;
import flixel.FlxState;
import flixel.group.FlxTypedGroup;
import flixel.tile.FlxTilemap;
import flixel.util.FlxColor;
import flixel.util.FlxPath;
import flixel.util.FlxPoint;
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;
private var hud:HUD;
private var potions:FlxTypedGroup<Potion>;
private var enemies:Array<Enemy>;
private var currentAction:PlayerAction;
private var combatWindow:CombatWindow;
/**
* 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);
}
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;
combatWindow.fight(enemy);
}
public function winCombat(enemy:Enemy):Void {
endCombat(enemy);
}
public function endCombat(enemy:Enemy):Void {
enemy.kill();
combatWindow.active = false;
combatWindow.visible = false;
hero.active = true;
currentAction = Walking;
}
/**
* 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();
// 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);
}
}
}
The results should look something like this: