ATB strategy MVC architecture refactoring
Clash Royale CLAN TAG#URR8PPP
.everyoneloves__top-leaderboard:empty,.everyoneloves__mid-leaderboard:empty margin-bottom:0;
up vote
5
down vote
favorite
NOTE: this question isn't as long as it appears to be. I added the comments to the code only to answer some possible questions that may appear.
I'm making an active time battle strategy. Its mechanics are very similar to the ones introduced in Final Fantasy, but I can't remember which one in the series was the closest to my game. There is also a flash game, called Sonny, that is 99.9% similar to the game of mine.
The goal of the game is to kill entire enemy team. Every unit has an alacrity pool, its regeneration speed depends on unit's agility aka. flow. Whenever this pool is full, the unit is allowed to cast one spell from his wheel.
The player's turn consists of two stages: choosing ability (choosing) and choosing the ability's target (targeting).
I'm using a modified version of MVC, where Controller sends requests to the Model, recieves answers with data and then sends this data to the Vision. I think this approach is too rigid, too unflexible, but that's one of the reasons I'm here. That's how it's done now:
The lever block consists of methods that modify model in a general ways (add/substract hp/mana, cast/dispell buff etc.) and are accessible from any part of the project.
The triggering block consists of functions that handle user input (not raw, primary processing is located in the Vision).
For example, if user presses the digit key, the code in the Vision (see below) handles this input and then calls the respective Controller method choose(), deciding (on its own!) which ability the player wants to choose (I'm not sure if it is a good approach to leave this type of logic in the Vision rather than in the Controller).
The useAbility block contains the useAbility() method and its auxiliary methods. Note that choose() and target() are called only when the player makes turn, but useAbility() is called whenever any unit does it.
The fact that useAbility() consists of three stages (and thus is called three times to avoid creating three different methods) is conditioned by the way the ability usage is animated. For example, when the unit kicks the target, he rushes to it, then the target recieves damage and then the unit that kicked it returns to his initial position. Different types of abilities are animated differently, but the vision-model-vision sequence remains the same.
To be able to work with the same target, caster and ability across all iterations of useAbility() method, I store this values as Controller's properties.
If the player makes turn, setUA() is called inside the target() method, else it is called inside one of the model's methods.
class Controller extends Sprite
public static var instance:Null<Controller>;
private var model:Model;
private var vision:Vision;
public var inputMode:InputMode;
private var chosenAbility:Int;
private var uatarget:UnitCoords;
private var uacaster:UnitCoords;
private var uaability:Ability;
private var uaiterator:Int;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, element:Element, source:Source)
var modelOutput:HPChangerOutput = model.changeUnitHP(target, caster, dhp, source);
vision.changeUnitHP(target, modelOutput.dhp, element, modelOutput.crit, source);
if (target.hpPool.value == 0)
vision.die(new UnitCoords(target.team, target.position));
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source)
var finalValue:Int = model.changeUnitMana(target, caster, dmana, source);
vision.changeUnitMana(target, finalValue, source);
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source)
var finalValue:Float = model.changeUnitAlacrity(target, caster, dalac, source);
vision.changeUnitAlacrity(target, finalValue, source);
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
model.castBuff(id, target, caster, duration);
vision.castBuff(id, duration);
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1)
var newBuffArray:Array<Buff> = model.dispellBuffs(target, elements, count);
vision.redrawBuffs(target, newBuffArray);
//================================================================================
// Triggering blocks
//================================================================================
public function choose(abilityNum:Int)
switch (model.checkChoose(abilityNum))
case ChooseResult.Ok:
inputMode = InputMode.Targeting;
chosenAbility = abilityNum;
vision.selectAbility(abilityNum);
case ChooseResult.Empty:
vision.printWarning("There is no ability in this slot");
case ChooseResult.Manacost:
vision.printWarning("Not enough mana");
case ChooseResult.Cooldown:
vision.printWarning("This ability is currently on cooldown");
public function use(targetCoords:UnitCoords)
switch (model.checkTarget(targetCoords, chosenAbility))
case TargetResult.Ok:
inputMode = InputMode.None;
vision.target(targetCoords);
vision.deselectAbility(chosenAbility);
setUA(targetCoords, new UnitCoords(battle.enums.Team.Left, 0), model.getPlayerAbility(chosenAbility));
chosenAbility = -1;
useAbility();
case TargetResult.Invalid:
vision.printWarning("Chosen ability cannot be used on this target");
vision.deselectAbility(chosenAbility);
chosenAbility = -1;
inputMode = InputMode.Choosing;
case TargetResult.Nonexistent, TargetResult.Dead:
//Ignore silently
public function skipTurnAttempt():Bool
if (inputMode != InputMode.None)
inputMode = InputMode.None;
model.postTurnProcess(new UnitCoords(Team.Left, 0));
return true;
return false;
public function end(winner:Null<Team>)
inputMode = InputMode.None;
if (winner == Team.Left)
vision.printWarning("You won!!!");
else if (winner == Team.Right)
vision.printWarning("You lost(");
else
vision.printWarning("A draw...");
removeChild(vision);
Main.onBattleOver();
//================================================================================
// useAbility
//================================================================================
public function useAbility()
switch (uaiterator++)
case 0:
vision.abilityIntro(uatarget, uacaster, type:uaability.type, element:uaability.element);
case 1:
if (model.checkUse(uatarget, uacaster, uaability) == UseResult.Miss)
vision.unitMiss(uatarget, uaability.element);
else
model.useAbility(uatarget, uacaster, uaability);
vision.abilityOutro(uatarget, uacaster, id:uaability.id, type:uaability.type);
case 2:
model.postTurnProcess(uacaster);
default:
clearUA();
useAbility();
public function setUA(target:UnitCoords, caster:UnitCoords, ability:Ability)
uatarget = target;
uacaster = caster;
uaability = ability;
private function clearUA()
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
//================================================================================
// INIT + Constructor
//================================================================================
public function destroy()
instance = null;
public function init(zone:Int, stage:Int, allies:Array<Unit>)
var enemyIDs:Array<ID> = XMLUtils.parseStage(zone, stage);
var enemies:Array<Unit> = ;
for (i in 0...enemyIDs.length)
enemies.push(new Unit(enemyIDs[i], Team.Right, i));
model = new Model(allies, enemies);
vision = new Vision();
addChild(vision);
vision.init(zone, allies, enemies);
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
model.alacrityIncrement();
public function new()
super();
instance = this;
Then we come to the Model:
typedef AbilityInfo =
var name:String;
var describition:String;
var type:AbilityType;
var maxCooldown:Int;
var currentCooldown:Int;
var manacost:Int;
var target:AbilityTarget;
typedef UnitInfo =
var name:String;
var buffQueue:BuffQueue;
typedef HPChangerOutput =
var dhp:Int;
var crit:Bool;
enum ChooseResult
Ok;
Empty;
Manacost;
Cooldown;
enum TargetResult
Ok;
Invalid;
Nonexistent;
Dead;
enum UseResult
Ok;
Miss;
class Model
private var allies:Array<Unit>;
private var enemies:Array<Unit>;
private var unitToProcess:Null<Unit>;
private var readyUnits:Array<Unit>;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, source:Source):HPChangerOutput
var processedDelta:Int = dhp;
var crit:Bool = false;
if (source != Source.God)
if (dhp > 0)
processedDelta = Math.round(Linear.combination([target.healIn, caster.healOut]).apply(processedDelta));
else
processedDelta = Math.round(Linear.combination([target.damageIn, caster.damageOut]).apply(processedDelta));
if (Math.random() < caster.critChance.apply(1))
processedDelta = Math.round(caster.critDamage.apply(processedDelta));
crit = true;
target.hpPool.value += processedDelta;
return dhp:processedDelta, crit:crit;
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source):Int
target.manaPool.value += dmana;
return dmana;
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source):Float
target.alacrityPool.value += dalac;
return dalac;
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
target.buffQueue.addBuff(new battle.Buff(id, target, caster, duration));
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1):Array<battle.Buff>
target.buffQueue.dispell(elements, count);
return target.buffQueue.queue;
//================================================================================
// Input
//================================================================================
public function checkChoose(abilityPos:Int):ChooseResult
var hero:Unit = allies[0];
var ability:battle.Ability = hero.wheel.get(abilityPos);
if (ability.checkEmpty())
return ChooseResult.Empty;
if (ability.checkOnCooldown())
return ChooseResult.Cooldown;
if (!hero.checkManacost(abilityPos))
return ChooseResult.Manacost;
return ChooseResult.Ok;
public function checkTarget(targetCoords:UnitCoords, abilityPos:Int):TargetResult
var target:Unit = getUnit(targetCoords);
var ability:battle.Ability = allies[0].wheel.get(abilityPos);
if (target == null)
return TargetResult.Nonexistent;
if (target.hpPool.value == 0)
return TargetResult.Dead;
if (!ability.checkValidity(target, allies[0]))
return TargetResult.Invalid;
return TargetResult.Ok;
public function checkUse(targetCoords:UnitCoords, casterCoords:UnitCoords, ability:Ability):UseResult
return UseResult.Ok;
public function getPlayerAbility(pos:Int):battle.Ability
return allies[0].wheel.get(pos);
public function useAbility(target:UnitCoords, caster:UnitCoords, ability:battle.Ability)
ability.use(getUnit(target), getUnit(caster));
//================================================================================
// Cycle control
//================================================================================
public function alacrityIncrement()
for (unit in allies.concat(enemies))
if (checkAlive([unit]))
Controller.instance.changeUnitAlacrity(unit, unit, getAlacrityGain(unit), Source.God);
if (unit.alacrityPool.value == 100)
readyUnits.push(unit);
if (Lambda.empty(readyUnits))
alacrityIncrement();
else
try
sortByFlow(readyUnits);
processReady();
catch (e:Dynamic)
trace(e);
trace(CallStack.toString(CallStack.exceptionStack()));
Sys.exit(1);
private function processReady()
if (!Lambda.empty(readyUnits))
var unit:Unit = readyUnits[0];
readyUnits.splice(0, 1);
Controller.instance.changeUnitAlacrity(unit, unit, -100, Source.God);
if (!unit.isStunned())
if (unit.team == Team.Left && unit.position == 0)
Controller.instance.inputMode = InputMode.Choosing;
else
botMakeTurn(unit);
else
postTurnProcess(new UnitCoords(unit.team, unit.position));
else
alacrityIncrement();
public function postTurnProcess(coords:UnitCoords)
var unit:Unit = getUnit(coords);
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
if (unit.hpPool.value > 0)
unit.tick();
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
processReady();
private function botMakeTurn(bot:Unit)
var decision:BotDecision = Units.decide(bot.id, allies, enemies);
trace(bot.wheel.get(decision.abilityNum));
Controller.instance.setUA(decision.target, getCoords(bot), bot.wheel.get(decision.abilityNum));
Controller.instance.useAbility();
private function getAlacrityGain(unit:Unit):Float
var sum:Float = 0;
for (unitI in allies.concat(enemies))
if (checkAlive([unitI]))
sum += unitI.flow;
return unit.flow / sum;
private function sortByFlow(array:Array<Unit>)
function swap(j1:Int, j2:Int)
var t:Unit = array[j1];
array[j1] = array[j2];
array[j2] = t;
for (i in 1...array.length)
for (j in i...array.length)
if (array[j - 1].flow < array[j].flow)
swap(j - 1, j);
else if (array[j - 1].flow == array[j].flow)
if (MathUtils.flip())
swap(j - 1, j);
//================================================================================
// Battle end utilities
//================================================================================
public function bothTeamsAlive():Bool
return checkAlive(allies) && checkAlive(enemies);
public function defineWinner():Null<Team>
if (checkAlive(allies))
return Team.Left;
else if (checkAlive(enemies))
return Team.Right;
else
return null;
private function checkAlive(array:Array<Unit>):Bool
for (unit in array)
if (unit.hpPool.value > 0)
return true;
return false;
//================================================================================
// Other
//================================================================================
private inline function getUnit(coords:UnitCoords):Null<Unit>
var array:Array<Unit> = (coords.team == battle.enums.Team.Left)? allies : enemies;
return array[coords.pos];
private inline function getCoords(unit:Unit):UnitCoords
return new UnitCoords(unit.team, unit.position);
//================================================================================
// Constructor
//================================================================================
public function new(allies:Array<Unit>, enemies:Array<Unit>)
this.allies = allies;
this.enemies = enemies;
this.readyUnits = ;
Finally, there is a Vision, but I don't think it's necessary to post all its code as it's primarily consists of visual programming, so I'll just leave there a general layout (important methods are fully listed):
class Vision extends SSprite
private var bg:DisplayObject;
private var upperBar:DisplayObject;
private var bottomBar:DisplayObject;
private var skipTurn:DisplayObject;
private var leaveBattle:DisplayObject;
private var alliesVision:Array<MovieClip>;
private var enemiesVision:Array<MovieClip>;
private var abilitiesVision:Array<MovieClip>;
private var allyNames:Array<TextField>;
private var allyHPs:Array<TextField>;
private var allyManas:Array<TextField>;
private var enemyNames:Array<TextField>;
private var enemyHPs:Array<TextField>;
private var enemyManas:Array<TextField>;
private var shiftKey:Bool;
//================================================================================
// Levers - display the canges in the game model
//================================================================================
public function changeUnitHP(target:Unit, dhp:Int, element:Element, crit:Bool, source:Source)
public function changeUnitMana(target:Unit, dmana:Int, source:Source)
public function changeUnitAlacrity(unit:Unit, dalac:Float, source:Source)
public function castBuff(id:ID, duration:Int)
public function redrawBuffs(target:Unit, buffs:Array<Buff>)
public function unitMiss(target:UnitCoords, element:Element)
public function die(unit:UnitCoords)
//================================================================================
// Input responses - some more visual stuff to make the game more responsive
//================================================================================
public function selectAbility(num:Int)
public function deselectAbility(num:Int)
public function target(coords:UnitCoords)
public function printWarning(text:String)
//================================================================================
// Basic animations - abstract animations
//================================================================================
public function abilityIntro(target:UnitCoords, caster:UnitCoords, ability:type:AbilityType, element:Element)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case AbilityType.Bolt:
animateBolt(target, caster, ability.element, callback);
case AbilityType.Kick:
animateKickIn(target, caster, callback);
default:
cleanAndCallback(callback);
public function abilityOutro(target:UnitCoords, caster:UnitCoords, ability:id:ID, type:AbilityType)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case battle.enums.AbilityType.Kick:
animateKickOut(caster, callback);
case battle.enums.AbilityType.Spell:
animateSpell(ability.id, target, callback);
default:
cleanAndCallback(callback);
//================================================================================
// Animation supply - concrete animations
//================================================================================
private function animateBolt(target:UnitCoords, caster:UnitCoords, element:Element, callback:Dynamic)
private function animateKickIn(target:UnitCoords, caster:UnitCoords, callback:Dynamic)
private function animateKickOut(caster:UnitCoords, callback:Dynamic)
private function animateSpell(abilityID:ID, target:UnitCoords, callback:Dynamic)
private function cleanAndCallback(callback:Dynamic, ?animation:Null<MovieClip>)
if (animation != null)
remove(animation);
Reflect.callMethod(callback, callback, );
//================================================================================
// Input handlers
//================================================================================
private function keyUpHandler(e:KeyboardEvent)
trace("keyUp handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = false;
private function keyHandler(e:KeyboardEvent)
trace("key handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = true;
else if (MathUtils.inRange(e.keyCode, 49, 57))
if (shiftKey)
Controller.instance.printAbilityInfo(e.keyCode - 49);
else if (Controller.instance.inputMode != InputMode.None)
Controller.instance.choose(e.keyCode - 49);
private function clickHandler(e:MouseEvent)
//...
//================================================================================
// INIT + CONSTRUCTOR
//================================================================================
public function init(zone:Int, allies:Array<battle.Unit>, enemies:Array<battle.Unit>)
bg = Assets.getBattleBG(zone);
upperBar = new UpperBattleBar();
bottomBar = new BottomBattleBar();
skipTurn = new SkipTurn();
leaveBattle = new LeaveBattle();
alliesVision = ;
enemiesVision = ;
abilitiesVision = ;
allyNames = ;
allyHPs = ;
allyManas = ;
enemyNames = ;
enemyHPs = ;
enemyManas = ;
for (ally in allies)
alliesVision.push(Assets.getBattleUnit(ally.id));
for (enemy in enemies)
enemiesVision.push(Assets.getBattleUnit(enemy.id));
for (i in 0...10)
abilitiesVision.push(Assets.getBattleAbility(allies[0].wheel.get(i).id));
//...drawing the screen...
shiftKey = false;
stage.addEventListener(KeyboardEvent.KEY_DOWN, keyHandler);
stage.addEventListener(KeyboardEvent.KEY_UP, keyUpHandler);
stage.addEventListener(MouseEvent.CLICK, clickHandler);
public function new()
super();
//================================================================================
// Inline map - used to incapsulate concrete values
//================================================================================
private static inline function abilityX(i:Int):Float
private static inline function unitX(pos:Int, team:battle.enums.Team):Float
private static inline function unitY(pos:Int):Float
private static inline function unitInfoX(team:Team, type:String)
private static inline function unitInfoY(pos:Int):Float
//================================================================================
// Graphic utils
//================================================================================
private inline function getUnitBounds(pos:Int, team:Team):Rectangle
private function addTextfield(targetArray:Array<TextField>, text:String, font:String, size:Int, color:Null<Int> = null, bold:Null<Bool> = null)
private function playOnce(mc:MovieClip, x:Float, y:Float, ?onComplete:Null<Dynamic>, ?onCompleteParams:Null<Array<Dynamic>>)
//================================================================================
// Other
//================================================================================
private function getUnit(coords:UnitCoords):MovieClip
var array:Array<MovieClip> = (coords.team == battle.enums.Team.Left)? alliesVision : enemiesVision;
return array[coords.pos];
Note that all the sprites, actors, graphical objects are stored in different variables to make the work with graphic easier. Is it a good approach, though?
Unit class. Represents player, allies and enemies.
typedef ParameterList =
var name:String;
var hp:Int;
var mana:Int;
var wheel:Array<ID>;
var strength:Int;
var flow:Int;
var intellect:Int;
class Unit
public var id(default, null):ID;
public var name(default, null):String;
public var team(default, null):Team;
public var position(default, null):Int;
public var wheel(default, null):Wheel;
public var hpPool(default, null):Pool;
public var manaPool(default, null):Pool;
public var alacrityPool(default, null):FloatPool;
public var buffQueue(default, null):BuffQueue;
public var strength:Int;
public var flow:Int;
public var intellect:Int;
public var damageIn:Linear;
public var damageOut:Linear;
public var healIn:Linear;
public var healOut:Linear;
public var critChance:Linear;
public var critDamage:Linear;
public function useAbility(target:Unit, abilityNum:Int)
Assert.assert(MathUtils.inRange(abilityNum, 0, 7));
wheel.get(abilityNum).use(target, this);
public function tick()
wheel.tick();
buffQueue.tick();
public function isStunned():Bool
return false;
public function new(id:ID, team:Team, position:Int, ?parameters:Null<ParameterList>)
Assert.assert(position >= 0 && position <= 2);
if (parameters == null)
parameters = XMLUtils.parseUnit(id);
this.id = id;
this.name = parameters.name;
this.team = team;
this.position = position;
this.wheel = new Wheel(parameters.wheel, 8);
this.hpPool = new Pool(parameters.hp, parameters.hp);
this.manaPool = new Pool(parameters.mana, parameters.mana);
this.alacrityPool = new FloatPool(0, 100);
this.buffQueue = new BuffQueue();
this.strength = parameters.strength;
this.flow = parameters.flow;
this.intellect = parameters.intellect;
this.damageIn = new Linear(1, 0);
this.damageOut = new Linear(1, 0);
this.healIn = new Linear(1, 0);
this.healOut = new Linear(1, 0);
public function figureRelation(unit:Unit):UnitType
if (team != unit.team)
return UnitType.Enemy;
else if (position == unit.position)
return UnitType.Self;
else
return UnitType.Ally;
public inline function checkManacost(abilityNum:Int):Bool
return manaPool.value >= wheel.get(abilityNum).manacost;
Ability class
class Ability
public var id(default, null):ID;
public var name(default, null):String;
public var description(default, null):String;
public var type(default, null):AbilityType;
public var possibleTarget(default, null):AbilityTarget;
public var element(default, null):Element;
private var _cooldown:Countdown;
public var cooldown(get, null):Int;
public var manacost(default, null):Int;
public function use(target:Unit, caster:Unit)
Abilities.useAbility(id, target, caster, element);
Controller.instance.changeUnitMana(caster, caster, -manacost, battle.enums.Source.God);
_cooldown.value = _cooldown.keyValue;
public function tick()
if (checkOnCooldown())
_cooldown.value--;
public function new(id:ID)
this.id = id;
if (!checkEmpty())
this.name = XMLUtils.parseAbility(id, "name", "");
this.description = XMLUtils.parseAbility(id, "description", "");
this.type = XMLUtils.parseAbility(id, "type", AbilityType.Bolt);
this._cooldown = new Countdown(XMLUtils.parseAbility(id, "delay", 0), XMLUtils.parseAbility(id, "cooldown", 0));
this.manacost = XMLUtils.parseAbility(id, "manacost", 0);
this.possibleTarget = XMLUtils.parseAbility(id, "target", AbilityTarget.All);
this.element = XMLUtils.parseAbility(id, "element", Element.Physical);
//================================================================================
// Checkers
//================================================================================
public inline function checkOnCooldown():Bool
return _cooldown.value > 0;
public inline function checkEmpty():Bool
id == ID.LockAbility;
public inline function checkValidity(target:Unit, caster:Unit):Bool
var relation:battle.enums.UnitType = caster.figureRelation(target);
switch (possibleTarget)
case battle.enums.AbilityTarget.Enemy:
return relation == battle.enums.UnitType.Enemy;
case battle.enums.AbilityTarget.Allied:
return relation == battle.enums.UnitType.Ally
//================================================================================
// Getters
//================================================================================
function get_cooldown():Int
return _cooldown.value;
Abilities class (not to be confused with Ability class) contains methods that describe what the ability does, such as that:
private static function highVoltage(target:Unit, caster:Unit, element:Element)
var damage:Int = 40 + caster.intellect * 10;
Controller.instance.changeUnitHP(target, caster, -damage, element, Source.Ability);
Controller.instance.castBuff(ID.BuffLgConductivity, target, caster, 2);
This class contains one public method called useAbility that chooses one of private methods based on the ability ID.
Units class contains the methods for each unit (except player) that analyze the current game situation and return the ability the bot wants to use and the target of this ability.
Similar to Abilities class, this class contains one public method that chooses which private method to call based on the unit ID.
Buff and Buffs classes are very similar to Ability and Abilities classes, just have a bit different mechanic (ticking instead of using)
I need to rework this code to make it simpler and to adapt it for the possible future changes including:
Autotesting
As I continue to develop my game, I'll need to balance it, so I want to have a possibility to launch series of bot vs. bot matches without graphic output just to gather statistics.
It is the most important feature and the biggest reason to ask a question here, so please pay attention to this point.
Data collection
Ability/Class pick/win rates, most popular combos etc. I'm gonna save it to a file.
Multiplayer
I have never really dived into this subject, but someday I'll want to implement a simple 1x1 PvP mode. Then I may want to upgrade it up to 3x3 fights. I don't want to rewrite all the game from the scratch so any advices how to avoid this in the future are welcome.
Dialogues
The game stops if the condition is met and starts to play dialogue boxes. The conditions may vary: turn number, % of enemy's hp reached, enemy uses special ability. The diversity of the conditions is the main problem that doesn't allow me to make an obvious architectural decision.
I wonder what approaches can I apply to pave the way for these four features and make the code better overall. Maybe some patterns could help with this?
Any questions are welcome
object-oriented game design-patterns mvc haxe
add a comment |Â
up vote
5
down vote
favorite
NOTE: this question isn't as long as it appears to be. I added the comments to the code only to answer some possible questions that may appear.
I'm making an active time battle strategy. Its mechanics are very similar to the ones introduced in Final Fantasy, but I can't remember which one in the series was the closest to my game. There is also a flash game, called Sonny, that is 99.9% similar to the game of mine.
The goal of the game is to kill entire enemy team. Every unit has an alacrity pool, its regeneration speed depends on unit's agility aka. flow. Whenever this pool is full, the unit is allowed to cast one spell from his wheel.
The player's turn consists of two stages: choosing ability (choosing) and choosing the ability's target (targeting).
I'm using a modified version of MVC, where Controller sends requests to the Model, recieves answers with data and then sends this data to the Vision. I think this approach is too rigid, too unflexible, but that's one of the reasons I'm here. That's how it's done now:
The lever block consists of methods that modify model in a general ways (add/substract hp/mana, cast/dispell buff etc.) and are accessible from any part of the project.
The triggering block consists of functions that handle user input (not raw, primary processing is located in the Vision).
For example, if user presses the digit key, the code in the Vision (see below) handles this input and then calls the respective Controller method choose(), deciding (on its own!) which ability the player wants to choose (I'm not sure if it is a good approach to leave this type of logic in the Vision rather than in the Controller).
The useAbility block contains the useAbility() method and its auxiliary methods. Note that choose() and target() are called only when the player makes turn, but useAbility() is called whenever any unit does it.
The fact that useAbility() consists of three stages (and thus is called three times to avoid creating three different methods) is conditioned by the way the ability usage is animated. For example, when the unit kicks the target, he rushes to it, then the target recieves damage and then the unit that kicked it returns to his initial position. Different types of abilities are animated differently, but the vision-model-vision sequence remains the same.
To be able to work with the same target, caster and ability across all iterations of useAbility() method, I store this values as Controller's properties.
If the player makes turn, setUA() is called inside the target() method, else it is called inside one of the model's methods.
class Controller extends Sprite
public static var instance:Null<Controller>;
private var model:Model;
private var vision:Vision;
public var inputMode:InputMode;
private var chosenAbility:Int;
private var uatarget:UnitCoords;
private var uacaster:UnitCoords;
private var uaability:Ability;
private var uaiterator:Int;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, element:Element, source:Source)
var modelOutput:HPChangerOutput = model.changeUnitHP(target, caster, dhp, source);
vision.changeUnitHP(target, modelOutput.dhp, element, modelOutput.crit, source);
if (target.hpPool.value == 0)
vision.die(new UnitCoords(target.team, target.position));
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source)
var finalValue:Int = model.changeUnitMana(target, caster, dmana, source);
vision.changeUnitMana(target, finalValue, source);
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source)
var finalValue:Float = model.changeUnitAlacrity(target, caster, dalac, source);
vision.changeUnitAlacrity(target, finalValue, source);
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
model.castBuff(id, target, caster, duration);
vision.castBuff(id, duration);
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1)
var newBuffArray:Array<Buff> = model.dispellBuffs(target, elements, count);
vision.redrawBuffs(target, newBuffArray);
//================================================================================
// Triggering blocks
//================================================================================
public function choose(abilityNum:Int)
switch (model.checkChoose(abilityNum))
case ChooseResult.Ok:
inputMode = InputMode.Targeting;
chosenAbility = abilityNum;
vision.selectAbility(abilityNum);
case ChooseResult.Empty:
vision.printWarning("There is no ability in this slot");
case ChooseResult.Manacost:
vision.printWarning("Not enough mana");
case ChooseResult.Cooldown:
vision.printWarning("This ability is currently on cooldown");
public function use(targetCoords:UnitCoords)
switch (model.checkTarget(targetCoords, chosenAbility))
case TargetResult.Ok:
inputMode = InputMode.None;
vision.target(targetCoords);
vision.deselectAbility(chosenAbility);
setUA(targetCoords, new UnitCoords(battle.enums.Team.Left, 0), model.getPlayerAbility(chosenAbility));
chosenAbility = -1;
useAbility();
case TargetResult.Invalid:
vision.printWarning("Chosen ability cannot be used on this target");
vision.deselectAbility(chosenAbility);
chosenAbility = -1;
inputMode = InputMode.Choosing;
case TargetResult.Nonexistent, TargetResult.Dead:
//Ignore silently
public function skipTurnAttempt():Bool
if (inputMode != InputMode.None)
inputMode = InputMode.None;
model.postTurnProcess(new UnitCoords(Team.Left, 0));
return true;
return false;
public function end(winner:Null<Team>)
inputMode = InputMode.None;
if (winner == Team.Left)
vision.printWarning("You won!!!");
else if (winner == Team.Right)
vision.printWarning("You lost(");
else
vision.printWarning("A draw...");
removeChild(vision);
Main.onBattleOver();
//================================================================================
// useAbility
//================================================================================
public function useAbility()
switch (uaiterator++)
case 0:
vision.abilityIntro(uatarget, uacaster, type:uaability.type, element:uaability.element);
case 1:
if (model.checkUse(uatarget, uacaster, uaability) == UseResult.Miss)
vision.unitMiss(uatarget, uaability.element);
else
model.useAbility(uatarget, uacaster, uaability);
vision.abilityOutro(uatarget, uacaster, id:uaability.id, type:uaability.type);
case 2:
model.postTurnProcess(uacaster);
default:
clearUA();
useAbility();
public function setUA(target:UnitCoords, caster:UnitCoords, ability:Ability)
uatarget = target;
uacaster = caster;
uaability = ability;
private function clearUA()
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
//================================================================================
// INIT + Constructor
//================================================================================
public function destroy()
instance = null;
public function init(zone:Int, stage:Int, allies:Array<Unit>)
var enemyIDs:Array<ID> = XMLUtils.parseStage(zone, stage);
var enemies:Array<Unit> = ;
for (i in 0...enemyIDs.length)
enemies.push(new Unit(enemyIDs[i], Team.Right, i));
model = new Model(allies, enemies);
vision = new Vision();
addChild(vision);
vision.init(zone, allies, enemies);
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
model.alacrityIncrement();
public function new()
super();
instance = this;
Then we come to the Model:
typedef AbilityInfo =
var name:String;
var describition:String;
var type:AbilityType;
var maxCooldown:Int;
var currentCooldown:Int;
var manacost:Int;
var target:AbilityTarget;
typedef UnitInfo =
var name:String;
var buffQueue:BuffQueue;
typedef HPChangerOutput =
var dhp:Int;
var crit:Bool;
enum ChooseResult
Ok;
Empty;
Manacost;
Cooldown;
enum TargetResult
Ok;
Invalid;
Nonexistent;
Dead;
enum UseResult
Ok;
Miss;
class Model
private var allies:Array<Unit>;
private var enemies:Array<Unit>;
private var unitToProcess:Null<Unit>;
private var readyUnits:Array<Unit>;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, source:Source):HPChangerOutput
var processedDelta:Int = dhp;
var crit:Bool = false;
if (source != Source.God)
if (dhp > 0)
processedDelta = Math.round(Linear.combination([target.healIn, caster.healOut]).apply(processedDelta));
else
processedDelta = Math.round(Linear.combination([target.damageIn, caster.damageOut]).apply(processedDelta));
if (Math.random() < caster.critChance.apply(1))
processedDelta = Math.round(caster.critDamage.apply(processedDelta));
crit = true;
target.hpPool.value += processedDelta;
return dhp:processedDelta, crit:crit;
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source):Int
target.manaPool.value += dmana;
return dmana;
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source):Float
target.alacrityPool.value += dalac;
return dalac;
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
target.buffQueue.addBuff(new battle.Buff(id, target, caster, duration));
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1):Array<battle.Buff>
target.buffQueue.dispell(elements, count);
return target.buffQueue.queue;
//================================================================================
// Input
//================================================================================
public function checkChoose(abilityPos:Int):ChooseResult
var hero:Unit = allies[0];
var ability:battle.Ability = hero.wheel.get(abilityPos);
if (ability.checkEmpty())
return ChooseResult.Empty;
if (ability.checkOnCooldown())
return ChooseResult.Cooldown;
if (!hero.checkManacost(abilityPos))
return ChooseResult.Manacost;
return ChooseResult.Ok;
public function checkTarget(targetCoords:UnitCoords, abilityPos:Int):TargetResult
var target:Unit = getUnit(targetCoords);
var ability:battle.Ability = allies[0].wheel.get(abilityPos);
if (target == null)
return TargetResult.Nonexistent;
if (target.hpPool.value == 0)
return TargetResult.Dead;
if (!ability.checkValidity(target, allies[0]))
return TargetResult.Invalid;
return TargetResult.Ok;
public function checkUse(targetCoords:UnitCoords, casterCoords:UnitCoords, ability:Ability):UseResult
return UseResult.Ok;
public function getPlayerAbility(pos:Int):battle.Ability
return allies[0].wheel.get(pos);
public function useAbility(target:UnitCoords, caster:UnitCoords, ability:battle.Ability)
ability.use(getUnit(target), getUnit(caster));
//================================================================================
// Cycle control
//================================================================================
public function alacrityIncrement()
for (unit in allies.concat(enemies))
if (checkAlive([unit]))
Controller.instance.changeUnitAlacrity(unit, unit, getAlacrityGain(unit), Source.God);
if (unit.alacrityPool.value == 100)
readyUnits.push(unit);
if (Lambda.empty(readyUnits))
alacrityIncrement();
else
try
sortByFlow(readyUnits);
processReady();
catch (e:Dynamic)
trace(e);
trace(CallStack.toString(CallStack.exceptionStack()));
Sys.exit(1);
private function processReady()
if (!Lambda.empty(readyUnits))
var unit:Unit = readyUnits[0];
readyUnits.splice(0, 1);
Controller.instance.changeUnitAlacrity(unit, unit, -100, Source.God);
if (!unit.isStunned())
if (unit.team == Team.Left && unit.position == 0)
Controller.instance.inputMode = InputMode.Choosing;
else
botMakeTurn(unit);
else
postTurnProcess(new UnitCoords(unit.team, unit.position));
else
alacrityIncrement();
public function postTurnProcess(coords:UnitCoords)
var unit:Unit = getUnit(coords);
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
if (unit.hpPool.value > 0)
unit.tick();
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
processReady();
private function botMakeTurn(bot:Unit)
var decision:BotDecision = Units.decide(bot.id, allies, enemies);
trace(bot.wheel.get(decision.abilityNum));
Controller.instance.setUA(decision.target, getCoords(bot), bot.wheel.get(decision.abilityNum));
Controller.instance.useAbility();
private function getAlacrityGain(unit:Unit):Float
var sum:Float = 0;
for (unitI in allies.concat(enemies))
if (checkAlive([unitI]))
sum += unitI.flow;
return unit.flow / sum;
private function sortByFlow(array:Array<Unit>)
function swap(j1:Int, j2:Int)
var t:Unit = array[j1];
array[j1] = array[j2];
array[j2] = t;
for (i in 1...array.length)
for (j in i...array.length)
if (array[j - 1].flow < array[j].flow)
swap(j - 1, j);
else if (array[j - 1].flow == array[j].flow)
if (MathUtils.flip())
swap(j - 1, j);
//================================================================================
// Battle end utilities
//================================================================================
public function bothTeamsAlive():Bool
return checkAlive(allies) && checkAlive(enemies);
public function defineWinner():Null<Team>
if (checkAlive(allies))
return Team.Left;
else if (checkAlive(enemies))
return Team.Right;
else
return null;
private function checkAlive(array:Array<Unit>):Bool
for (unit in array)
if (unit.hpPool.value > 0)
return true;
return false;
//================================================================================
// Other
//================================================================================
private inline function getUnit(coords:UnitCoords):Null<Unit>
var array:Array<Unit> = (coords.team == battle.enums.Team.Left)? allies : enemies;
return array[coords.pos];
private inline function getCoords(unit:Unit):UnitCoords
return new UnitCoords(unit.team, unit.position);
//================================================================================
// Constructor
//================================================================================
public function new(allies:Array<Unit>, enemies:Array<Unit>)
this.allies = allies;
this.enemies = enemies;
this.readyUnits = ;
Finally, there is a Vision, but I don't think it's necessary to post all its code as it's primarily consists of visual programming, so I'll just leave there a general layout (important methods are fully listed):
class Vision extends SSprite
private var bg:DisplayObject;
private var upperBar:DisplayObject;
private var bottomBar:DisplayObject;
private var skipTurn:DisplayObject;
private var leaveBattle:DisplayObject;
private var alliesVision:Array<MovieClip>;
private var enemiesVision:Array<MovieClip>;
private var abilitiesVision:Array<MovieClip>;
private var allyNames:Array<TextField>;
private var allyHPs:Array<TextField>;
private var allyManas:Array<TextField>;
private var enemyNames:Array<TextField>;
private var enemyHPs:Array<TextField>;
private var enemyManas:Array<TextField>;
private var shiftKey:Bool;
//================================================================================
// Levers - display the canges in the game model
//================================================================================
public function changeUnitHP(target:Unit, dhp:Int, element:Element, crit:Bool, source:Source)
public function changeUnitMana(target:Unit, dmana:Int, source:Source)
public function changeUnitAlacrity(unit:Unit, dalac:Float, source:Source)
public function castBuff(id:ID, duration:Int)
public function redrawBuffs(target:Unit, buffs:Array<Buff>)
public function unitMiss(target:UnitCoords, element:Element)
public function die(unit:UnitCoords)
//================================================================================
// Input responses - some more visual stuff to make the game more responsive
//================================================================================
public function selectAbility(num:Int)
public function deselectAbility(num:Int)
public function target(coords:UnitCoords)
public function printWarning(text:String)
//================================================================================
// Basic animations - abstract animations
//================================================================================
public function abilityIntro(target:UnitCoords, caster:UnitCoords, ability:type:AbilityType, element:Element)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case AbilityType.Bolt:
animateBolt(target, caster, ability.element, callback);
case AbilityType.Kick:
animateKickIn(target, caster, callback);
default:
cleanAndCallback(callback);
public function abilityOutro(target:UnitCoords, caster:UnitCoords, ability:id:ID, type:AbilityType)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case battle.enums.AbilityType.Kick:
animateKickOut(caster, callback);
case battle.enums.AbilityType.Spell:
animateSpell(ability.id, target, callback);
default:
cleanAndCallback(callback);
//================================================================================
// Animation supply - concrete animations
//================================================================================
private function animateBolt(target:UnitCoords, caster:UnitCoords, element:Element, callback:Dynamic)
private function animateKickIn(target:UnitCoords, caster:UnitCoords, callback:Dynamic)
private function animateKickOut(caster:UnitCoords, callback:Dynamic)
private function animateSpell(abilityID:ID, target:UnitCoords, callback:Dynamic)
private function cleanAndCallback(callback:Dynamic, ?animation:Null<MovieClip>)
if (animation != null)
remove(animation);
Reflect.callMethod(callback, callback, );
//================================================================================
// Input handlers
//================================================================================
private function keyUpHandler(e:KeyboardEvent)
trace("keyUp handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = false;
private function keyHandler(e:KeyboardEvent)
trace("key handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = true;
else if (MathUtils.inRange(e.keyCode, 49, 57))
if (shiftKey)
Controller.instance.printAbilityInfo(e.keyCode - 49);
else if (Controller.instance.inputMode != InputMode.None)
Controller.instance.choose(e.keyCode - 49);
private function clickHandler(e:MouseEvent)
//...
//================================================================================
// INIT + CONSTRUCTOR
//================================================================================
public function init(zone:Int, allies:Array<battle.Unit>, enemies:Array<battle.Unit>)
bg = Assets.getBattleBG(zone);
upperBar = new UpperBattleBar();
bottomBar = new BottomBattleBar();
skipTurn = new SkipTurn();
leaveBattle = new LeaveBattle();
alliesVision = ;
enemiesVision = ;
abilitiesVision = ;
allyNames = ;
allyHPs = ;
allyManas = ;
enemyNames = ;
enemyHPs = ;
enemyManas = ;
for (ally in allies)
alliesVision.push(Assets.getBattleUnit(ally.id));
for (enemy in enemies)
enemiesVision.push(Assets.getBattleUnit(enemy.id));
for (i in 0...10)
abilitiesVision.push(Assets.getBattleAbility(allies[0].wheel.get(i).id));
//...drawing the screen...
shiftKey = false;
stage.addEventListener(KeyboardEvent.KEY_DOWN, keyHandler);
stage.addEventListener(KeyboardEvent.KEY_UP, keyUpHandler);
stage.addEventListener(MouseEvent.CLICK, clickHandler);
public function new()
super();
//================================================================================
// Inline map - used to incapsulate concrete values
//================================================================================
private static inline function abilityX(i:Int):Float
private static inline function unitX(pos:Int, team:battle.enums.Team):Float
private static inline function unitY(pos:Int):Float
private static inline function unitInfoX(team:Team, type:String)
private static inline function unitInfoY(pos:Int):Float
//================================================================================
// Graphic utils
//================================================================================
private inline function getUnitBounds(pos:Int, team:Team):Rectangle
private function addTextfield(targetArray:Array<TextField>, text:String, font:String, size:Int, color:Null<Int> = null, bold:Null<Bool> = null)
private function playOnce(mc:MovieClip, x:Float, y:Float, ?onComplete:Null<Dynamic>, ?onCompleteParams:Null<Array<Dynamic>>)
//================================================================================
// Other
//================================================================================
private function getUnit(coords:UnitCoords):MovieClip
var array:Array<MovieClip> = (coords.team == battle.enums.Team.Left)? alliesVision : enemiesVision;
return array[coords.pos];
Note that all the sprites, actors, graphical objects are stored in different variables to make the work with graphic easier. Is it a good approach, though?
Unit class. Represents player, allies and enemies.
typedef ParameterList =
var name:String;
var hp:Int;
var mana:Int;
var wheel:Array<ID>;
var strength:Int;
var flow:Int;
var intellect:Int;
class Unit
public var id(default, null):ID;
public var name(default, null):String;
public var team(default, null):Team;
public var position(default, null):Int;
public var wheel(default, null):Wheel;
public var hpPool(default, null):Pool;
public var manaPool(default, null):Pool;
public var alacrityPool(default, null):FloatPool;
public var buffQueue(default, null):BuffQueue;
public var strength:Int;
public var flow:Int;
public var intellect:Int;
public var damageIn:Linear;
public var damageOut:Linear;
public var healIn:Linear;
public var healOut:Linear;
public var critChance:Linear;
public var critDamage:Linear;
public function useAbility(target:Unit, abilityNum:Int)
Assert.assert(MathUtils.inRange(abilityNum, 0, 7));
wheel.get(abilityNum).use(target, this);
public function tick()
wheel.tick();
buffQueue.tick();
public function isStunned():Bool
return false;
public function new(id:ID, team:Team, position:Int, ?parameters:Null<ParameterList>)
Assert.assert(position >= 0 && position <= 2);
if (parameters == null)
parameters = XMLUtils.parseUnit(id);
this.id = id;
this.name = parameters.name;
this.team = team;
this.position = position;
this.wheel = new Wheel(parameters.wheel, 8);
this.hpPool = new Pool(parameters.hp, parameters.hp);
this.manaPool = new Pool(parameters.mana, parameters.mana);
this.alacrityPool = new FloatPool(0, 100);
this.buffQueue = new BuffQueue();
this.strength = parameters.strength;
this.flow = parameters.flow;
this.intellect = parameters.intellect;
this.damageIn = new Linear(1, 0);
this.damageOut = new Linear(1, 0);
this.healIn = new Linear(1, 0);
this.healOut = new Linear(1, 0);
public function figureRelation(unit:Unit):UnitType
if (team != unit.team)
return UnitType.Enemy;
else if (position == unit.position)
return UnitType.Self;
else
return UnitType.Ally;
public inline function checkManacost(abilityNum:Int):Bool
return manaPool.value >= wheel.get(abilityNum).manacost;
Ability class
class Ability
public var id(default, null):ID;
public var name(default, null):String;
public var description(default, null):String;
public var type(default, null):AbilityType;
public var possibleTarget(default, null):AbilityTarget;
public var element(default, null):Element;
private var _cooldown:Countdown;
public var cooldown(get, null):Int;
public var manacost(default, null):Int;
public function use(target:Unit, caster:Unit)
Abilities.useAbility(id, target, caster, element);
Controller.instance.changeUnitMana(caster, caster, -manacost, battle.enums.Source.God);
_cooldown.value = _cooldown.keyValue;
public function tick()
if (checkOnCooldown())
_cooldown.value--;
public function new(id:ID)
this.id = id;
if (!checkEmpty())
this.name = XMLUtils.parseAbility(id, "name", "");
this.description = XMLUtils.parseAbility(id, "description", "");
this.type = XMLUtils.parseAbility(id, "type", AbilityType.Bolt);
this._cooldown = new Countdown(XMLUtils.parseAbility(id, "delay", 0), XMLUtils.parseAbility(id, "cooldown", 0));
this.manacost = XMLUtils.parseAbility(id, "manacost", 0);
this.possibleTarget = XMLUtils.parseAbility(id, "target", AbilityTarget.All);
this.element = XMLUtils.parseAbility(id, "element", Element.Physical);
//================================================================================
// Checkers
//================================================================================
public inline function checkOnCooldown():Bool
return _cooldown.value > 0;
public inline function checkEmpty():Bool
id == ID.LockAbility;
public inline function checkValidity(target:Unit, caster:Unit):Bool
var relation:battle.enums.UnitType = caster.figureRelation(target);
switch (possibleTarget)
case battle.enums.AbilityTarget.Enemy:
return relation == battle.enums.UnitType.Enemy;
case battle.enums.AbilityTarget.Allied:
return relation == battle.enums.UnitType.Ally
//================================================================================
// Getters
//================================================================================
function get_cooldown():Int
return _cooldown.value;
Abilities class (not to be confused with Ability class) contains methods that describe what the ability does, such as that:
private static function highVoltage(target:Unit, caster:Unit, element:Element)
var damage:Int = 40 + caster.intellect * 10;
Controller.instance.changeUnitHP(target, caster, -damage, element, Source.Ability);
Controller.instance.castBuff(ID.BuffLgConductivity, target, caster, 2);
This class contains one public method called useAbility that chooses one of private methods based on the ability ID.
Units class contains the methods for each unit (except player) that analyze the current game situation and return the ability the bot wants to use and the target of this ability.
Similar to Abilities class, this class contains one public method that chooses which private method to call based on the unit ID.
Buff and Buffs classes are very similar to Ability and Abilities classes, just have a bit different mechanic (ticking instead of using)
I need to rework this code to make it simpler and to adapt it for the possible future changes including:
Autotesting
As I continue to develop my game, I'll need to balance it, so I want to have a possibility to launch series of bot vs. bot matches without graphic output just to gather statistics.
It is the most important feature and the biggest reason to ask a question here, so please pay attention to this point.
Data collection
Ability/Class pick/win rates, most popular combos etc. I'm gonna save it to a file.
Multiplayer
I have never really dived into this subject, but someday I'll want to implement a simple 1x1 PvP mode. Then I may want to upgrade it up to 3x3 fights. I don't want to rewrite all the game from the scratch so any advices how to avoid this in the future are welcome.
Dialogues
The game stops if the condition is met and starts to play dialogue boxes. The conditions may vary: turn number, % of enemy's hp reached, enemy uses special ability. The diversity of the conditions is the main problem that doesn't allow me to make an obvious architectural decision.
I wonder what approaches can I apply to pave the way for these four features and make the code better overall. Maybe some patterns could help with this?
Any questions are welcome
object-oriented game design-patterns mvc haxe
Looks a bit like Final Fantasy X.
â Mast
Mar 2 at 12:08
add a comment |Â
up vote
5
down vote
favorite
up vote
5
down vote
favorite
NOTE: this question isn't as long as it appears to be. I added the comments to the code only to answer some possible questions that may appear.
I'm making an active time battle strategy. Its mechanics are very similar to the ones introduced in Final Fantasy, but I can't remember which one in the series was the closest to my game. There is also a flash game, called Sonny, that is 99.9% similar to the game of mine.
The goal of the game is to kill entire enemy team. Every unit has an alacrity pool, its regeneration speed depends on unit's agility aka. flow. Whenever this pool is full, the unit is allowed to cast one spell from his wheel.
The player's turn consists of two stages: choosing ability (choosing) and choosing the ability's target (targeting).
I'm using a modified version of MVC, where Controller sends requests to the Model, recieves answers with data and then sends this data to the Vision. I think this approach is too rigid, too unflexible, but that's one of the reasons I'm here. That's how it's done now:
The lever block consists of methods that modify model in a general ways (add/substract hp/mana, cast/dispell buff etc.) and are accessible from any part of the project.
The triggering block consists of functions that handle user input (not raw, primary processing is located in the Vision).
For example, if user presses the digit key, the code in the Vision (see below) handles this input and then calls the respective Controller method choose(), deciding (on its own!) which ability the player wants to choose (I'm not sure if it is a good approach to leave this type of logic in the Vision rather than in the Controller).
The useAbility block contains the useAbility() method and its auxiliary methods. Note that choose() and target() are called only when the player makes turn, but useAbility() is called whenever any unit does it.
The fact that useAbility() consists of three stages (and thus is called three times to avoid creating three different methods) is conditioned by the way the ability usage is animated. For example, when the unit kicks the target, he rushes to it, then the target recieves damage and then the unit that kicked it returns to his initial position. Different types of abilities are animated differently, but the vision-model-vision sequence remains the same.
To be able to work with the same target, caster and ability across all iterations of useAbility() method, I store this values as Controller's properties.
If the player makes turn, setUA() is called inside the target() method, else it is called inside one of the model's methods.
class Controller extends Sprite
public static var instance:Null<Controller>;
private var model:Model;
private var vision:Vision;
public var inputMode:InputMode;
private var chosenAbility:Int;
private var uatarget:UnitCoords;
private var uacaster:UnitCoords;
private var uaability:Ability;
private var uaiterator:Int;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, element:Element, source:Source)
var modelOutput:HPChangerOutput = model.changeUnitHP(target, caster, dhp, source);
vision.changeUnitHP(target, modelOutput.dhp, element, modelOutput.crit, source);
if (target.hpPool.value == 0)
vision.die(new UnitCoords(target.team, target.position));
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source)
var finalValue:Int = model.changeUnitMana(target, caster, dmana, source);
vision.changeUnitMana(target, finalValue, source);
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source)
var finalValue:Float = model.changeUnitAlacrity(target, caster, dalac, source);
vision.changeUnitAlacrity(target, finalValue, source);
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
model.castBuff(id, target, caster, duration);
vision.castBuff(id, duration);
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1)
var newBuffArray:Array<Buff> = model.dispellBuffs(target, elements, count);
vision.redrawBuffs(target, newBuffArray);
//================================================================================
// Triggering blocks
//================================================================================
public function choose(abilityNum:Int)
switch (model.checkChoose(abilityNum))
case ChooseResult.Ok:
inputMode = InputMode.Targeting;
chosenAbility = abilityNum;
vision.selectAbility(abilityNum);
case ChooseResult.Empty:
vision.printWarning("There is no ability in this slot");
case ChooseResult.Manacost:
vision.printWarning("Not enough mana");
case ChooseResult.Cooldown:
vision.printWarning("This ability is currently on cooldown");
public function use(targetCoords:UnitCoords)
switch (model.checkTarget(targetCoords, chosenAbility))
case TargetResult.Ok:
inputMode = InputMode.None;
vision.target(targetCoords);
vision.deselectAbility(chosenAbility);
setUA(targetCoords, new UnitCoords(battle.enums.Team.Left, 0), model.getPlayerAbility(chosenAbility));
chosenAbility = -1;
useAbility();
case TargetResult.Invalid:
vision.printWarning("Chosen ability cannot be used on this target");
vision.deselectAbility(chosenAbility);
chosenAbility = -1;
inputMode = InputMode.Choosing;
case TargetResult.Nonexistent, TargetResult.Dead:
//Ignore silently
public function skipTurnAttempt():Bool
if (inputMode != InputMode.None)
inputMode = InputMode.None;
model.postTurnProcess(new UnitCoords(Team.Left, 0));
return true;
return false;
public function end(winner:Null<Team>)
inputMode = InputMode.None;
if (winner == Team.Left)
vision.printWarning("You won!!!");
else if (winner == Team.Right)
vision.printWarning("You lost(");
else
vision.printWarning("A draw...");
removeChild(vision);
Main.onBattleOver();
//================================================================================
// useAbility
//================================================================================
public function useAbility()
switch (uaiterator++)
case 0:
vision.abilityIntro(uatarget, uacaster, type:uaability.type, element:uaability.element);
case 1:
if (model.checkUse(uatarget, uacaster, uaability) == UseResult.Miss)
vision.unitMiss(uatarget, uaability.element);
else
model.useAbility(uatarget, uacaster, uaability);
vision.abilityOutro(uatarget, uacaster, id:uaability.id, type:uaability.type);
case 2:
model.postTurnProcess(uacaster);
default:
clearUA();
useAbility();
public function setUA(target:UnitCoords, caster:UnitCoords, ability:Ability)
uatarget = target;
uacaster = caster;
uaability = ability;
private function clearUA()
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
//================================================================================
// INIT + Constructor
//================================================================================
public function destroy()
instance = null;
public function init(zone:Int, stage:Int, allies:Array<Unit>)
var enemyIDs:Array<ID> = XMLUtils.parseStage(zone, stage);
var enemies:Array<Unit> = ;
for (i in 0...enemyIDs.length)
enemies.push(new Unit(enemyIDs[i], Team.Right, i));
model = new Model(allies, enemies);
vision = new Vision();
addChild(vision);
vision.init(zone, allies, enemies);
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
model.alacrityIncrement();
public function new()
super();
instance = this;
Then we come to the Model:
typedef AbilityInfo =
var name:String;
var describition:String;
var type:AbilityType;
var maxCooldown:Int;
var currentCooldown:Int;
var manacost:Int;
var target:AbilityTarget;
typedef UnitInfo =
var name:String;
var buffQueue:BuffQueue;
typedef HPChangerOutput =
var dhp:Int;
var crit:Bool;
enum ChooseResult
Ok;
Empty;
Manacost;
Cooldown;
enum TargetResult
Ok;
Invalid;
Nonexistent;
Dead;
enum UseResult
Ok;
Miss;
class Model
private var allies:Array<Unit>;
private var enemies:Array<Unit>;
private var unitToProcess:Null<Unit>;
private var readyUnits:Array<Unit>;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, source:Source):HPChangerOutput
var processedDelta:Int = dhp;
var crit:Bool = false;
if (source != Source.God)
if (dhp > 0)
processedDelta = Math.round(Linear.combination([target.healIn, caster.healOut]).apply(processedDelta));
else
processedDelta = Math.round(Linear.combination([target.damageIn, caster.damageOut]).apply(processedDelta));
if (Math.random() < caster.critChance.apply(1))
processedDelta = Math.round(caster.critDamage.apply(processedDelta));
crit = true;
target.hpPool.value += processedDelta;
return dhp:processedDelta, crit:crit;
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source):Int
target.manaPool.value += dmana;
return dmana;
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source):Float
target.alacrityPool.value += dalac;
return dalac;
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
target.buffQueue.addBuff(new battle.Buff(id, target, caster, duration));
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1):Array<battle.Buff>
target.buffQueue.dispell(elements, count);
return target.buffQueue.queue;
//================================================================================
// Input
//================================================================================
public function checkChoose(abilityPos:Int):ChooseResult
var hero:Unit = allies[0];
var ability:battle.Ability = hero.wheel.get(abilityPos);
if (ability.checkEmpty())
return ChooseResult.Empty;
if (ability.checkOnCooldown())
return ChooseResult.Cooldown;
if (!hero.checkManacost(abilityPos))
return ChooseResult.Manacost;
return ChooseResult.Ok;
public function checkTarget(targetCoords:UnitCoords, abilityPos:Int):TargetResult
var target:Unit = getUnit(targetCoords);
var ability:battle.Ability = allies[0].wheel.get(abilityPos);
if (target == null)
return TargetResult.Nonexistent;
if (target.hpPool.value == 0)
return TargetResult.Dead;
if (!ability.checkValidity(target, allies[0]))
return TargetResult.Invalid;
return TargetResult.Ok;
public function checkUse(targetCoords:UnitCoords, casterCoords:UnitCoords, ability:Ability):UseResult
return UseResult.Ok;
public function getPlayerAbility(pos:Int):battle.Ability
return allies[0].wheel.get(pos);
public function useAbility(target:UnitCoords, caster:UnitCoords, ability:battle.Ability)
ability.use(getUnit(target), getUnit(caster));
//================================================================================
// Cycle control
//================================================================================
public function alacrityIncrement()
for (unit in allies.concat(enemies))
if (checkAlive([unit]))
Controller.instance.changeUnitAlacrity(unit, unit, getAlacrityGain(unit), Source.God);
if (unit.alacrityPool.value == 100)
readyUnits.push(unit);
if (Lambda.empty(readyUnits))
alacrityIncrement();
else
try
sortByFlow(readyUnits);
processReady();
catch (e:Dynamic)
trace(e);
trace(CallStack.toString(CallStack.exceptionStack()));
Sys.exit(1);
private function processReady()
if (!Lambda.empty(readyUnits))
var unit:Unit = readyUnits[0];
readyUnits.splice(0, 1);
Controller.instance.changeUnitAlacrity(unit, unit, -100, Source.God);
if (!unit.isStunned())
if (unit.team == Team.Left && unit.position == 0)
Controller.instance.inputMode = InputMode.Choosing;
else
botMakeTurn(unit);
else
postTurnProcess(new UnitCoords(unit.team, unit.position));
else
alacrityIncrement();
public function postTurnProcess(coords:UnitCoords)
var unit:Unit = getUnit(coords);
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
if (unit.hpPool.value > 0)
unit.tick();
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
processReady();
private function botMakeTurn(bot:Unit)
var decision:BotDecision = Units.decide(bot.id, allies, enemies);
trace(bot.wheel.get(decision.abilityNum));
Controller.instance.setUA(decision.target, getCoords(bot), bot.wheel.get(decision.abilityNum));
Controller.instance.useAbility();
private function getAlacrityGain(unit:Unit):Float
var sum:Float = 0;
for (unitI in allies.concat(enemies))
if (checkAlive([unitI]))
sum += unitI.flow;
return unit.flow / sum;
private function sortByFlow(array:Array<Unit>)
function swap(j1:Int, j2:Int)
var t:Unit = array[j1];
array[j1] = array[j2];
array[j2] = t;
for (i in 1...array.length)
for (j in i...array.length)
if (array[j - 1].flow < array[j].flow)
swap(j - 1, j);
else if (array[j - 1].flow == array[j].flow)
if (MathUtils.flip())
swap(j - 1, j);
//================================================================================
// Battle end utilities
//================================================================================
public function bothTeamsAlive():Bool
return checkAlive(allies) && checkAlive(enemies);
public function defineWinner():Null<Team>
if (checkAlive(allies))
return Team.Left;
else if (checkAlive(enemies))
return Team.Right;
else
return null;
private function checkAlive(array:Array<Unit>):Bool
for (unit in array)
if (unit.hpPool.value > 0)
return true;
return false;
//================================================================================
// Other
//================================================================================
private inline function getUnit(coords:UnitCoords):Null<Unit>
var array:Array<Unit> = (coords.team == battle.enums.Team.Left)? allies : enemies;
return array[coords.pos];
private inline function getCoords(unit:Unit):UnitCoords
return new UnitCoords(unit.team, unit.position);
//================================================================================
// Constructor
//================================================================================
public function new(allies:Array<Unit>, enemies:Array<Unit>)
this.allies = allies;
this.enemies = enemies;
this.readyUnits = ;
Finally, there is a Vision, but I don't think it's necessary to post all its code as it's primarily consists of visual programming, so I'll just leave there a general layout (important methods are fully listed):
class Vision extends SSprite
private var bg:DisplayObject;
private var upperBar:DisplayObject;
private var bottomBar:DisplayObject;
private var skipTurn:DisplayObject;
private var leaveBattle:DisplayObject;
private var alliesVision:Array<MovieClip>;
private var enemiesVision:Array<MovieClip>;
private var abilitiesVision:Array<MovieClip>;
private var allyNames:Array<TextField>;
private var allyHPs:Array<TextField>;
private var allyManas:Array<TextField>;
private var enemyNames:Array<TextField>;
private var enemyHPs:Array<TextField>;
private var enemyManas:Array<TextField>;
private var shiftKey:Bool;
//================================================================================
// Levers - display the canges in the game model
//================================================================================
public function changeUnitHP(target:Unit, dhp:Int, element:Element, crit:Bool, source:Source)
public function changeUnitMana(target:Unit, dmana:Int, source:Source)
public function changeUnitAlacrity(unit:Unit, dalac:Float, source:Source)
public function castBuff(id:ID, duration:Int)
public function redrawBuffs(target:Unit, buffs:Array<Buff>)
public function unitMiss(target:UnitCoords, element:Element)
public function die(unit:UnitCoords)
//================================================================================
// Input responses - some more visual stuff to make the game more responsive
//================================================================================
public function selectAbility(num:Int)
public function deselectAbility(num:Int)
public function target(coords:UnitCoords)
public function printWarning(text:String)
//================================================================================
// Basic animations - abstract animations
//================================================================================
public function abilityIntro(target:UnitCoords, caster:UnitCoords, ability:type:AbilityType, element:Element)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case AbilityType.Bolt:
animateBolt(target, caster, ability.element, callback);
case AbilityType.Kick:
animateKickIn(target, caster, callback);
default:
cleanAndCallback(callback);
public function abilityOutro(target:UnitCoords, caster:UnitCoords, ability:id:ID, type:AbilityType)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case battle.enums.AbilityType.Kick:
animateKickOut(caster, callback);
case battle.enums.AbilityType.Spell:
animateSpell(ability.id, target, callback);
default:
cleanAndCallback(callback);
//================================================================================
// Animation supply - concrete animations
//================================================================================
private function animateBolt(target:UnitCoords, caster:UnitCoords, element:Element, callback:Dynamic)
private function animateKickIn(target:UnitCoords, caster:UnitCoords, callback:Dynamic)
private function animateKickOut(caster:UnitCoords, callback:Dynamic)
private function animateSpell(abilityID:ID, target:UnitCoords, callback:Dynamic)
private function cleanAndCallback(callback:Dynamic, ?animation:Null<MovieClip>)
if (animation != null)
remove(animation);
Reflect.callMethod(callback, callback, );
//================================================================================
// Input handlers
//================================================================================
private function keyUpHandler(e:KeyboardEvent)
trace("keyUp handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = false;
private function keyHandler(e:KeyboardEvent)
trace("key handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = true;
else if (MathUtils.inRange(e.keyCode, 49, 57))
if (shiftKey)
Controller.instance.printAbilityInfo(e.keyCode - 49);
else if (Controller.instance.inputMode != InputMode.None)
Controller.instance.choose(e.keyCode - 49);
private function clickHandler(e:MouseEvent)
//...
//================================================================================
// INIT + CONSTRUCTOR
//================================================================================
public function init(zone:Int, allies:Array<battle.Unit>, enemies:Array<battle.Unit>)
bg = Assets.getBattleBG(zone);
upperBar = new UpperBattleBar();
bottomBar = new BottomBattleBar();
skipTurn = new SkipTurn();
leaveBattle = new LeaveBattle();
alliesVision = ;
enemiesVision = ;
abilitiesVision = ;
allyNames = ;
allyHPs = ;
allyManas = ;
enemyNames = ;
enemyHPs = ;
enemyManas = ;
for (ally in allies)
alliesVision.push(Assets.getBattleUnit(ally.id));
for (enemy in enemies)
enemiesVision.push(Assets.getBattleUnit(enemy.id));
for (i in 0...10)
abilitiesVision.push(Assets.getBattleAbility(allies[0].wheel.get(i).id));
//...drawing the screen...
shiftKey = false;
stage.addEventListener(KeyboardEvent.KEY_DOWN, keyHandler);
stage.addEventListener(KeyboardEvent.KEY_UP, keyUpHandler);
stage.addEventListener(MouseEvent.CLICK, clickHandler);
public function new()
super();
//================================================================================
// Inline map - used to incapsulate concrete values
//================================================================================
private static inline function abilityX(i:Int):Float
private static inline function unitX(pos:Int, team:battle.enums.Team):Float
private static inline function unitY(pos:Int):Float
private static inline function unitInfoX(team:Team, type:String)
private static inline function unitInfoY(pos:Int):Float
//================================================================================
// Graphic utils
//================================================================================
private inline function getUnitBounds(pos:Int, team:Team):Rectangle
private function addTextfield(targetArray:Array<TextField>, text:String, font:String, size:Int, color:Null<Int> = null, bold:Null<Bool> = null)
private function playOnce(mc:MovieClip, x:Float, y:Float, ?onComplete:Null<Dynamic>, ?onCompleteParams:Null<Array<Dynamic>>)
//================================================================================
// Other
//================================================================================
private function getUnit(coords:UnitCoords):MovieClip
var array:Array<MovieClip> = (coords.team == battle.enums.Team.Left)? alliesVision : enemiesVision;
return array[coords.pos];
Note that all the sprites, actors, graphical objects are stored in different variables to make the work with graphic easier. Is it a good approach, though?
Unit class. Represents player, allies and enemies.
typedef ParameterList =
var name:String;
var hp:Int;
var mana:Int;
var wheel:Array<ID>;
var strength:Int;
var flow:Int;
var intellect:Int;
class Unit
public var id(default, null):ID;
public var name(default, null):String;
public var team(default, null):Team;
public var position(default, null):Int;
public var wheel(default, null):Wheel;
public var hpPool(default, null):Pool;
public var manaPool(default, null):Pool;
public var alacrityPool(default, null):FloatPool;
public var buffQueue(default, null):BuffQueue;
public var strength:Int;
public var flow:Int;
public var intellect:Int;
public var damageIn:Linear;
public var damageOut:Linear;
public var healIn:Linear;
public var healOut:Linear;
public var critChance:Linear;
public var critDamage:Linear;
public function useAbility(target:Unit, abilityNum:Int)
Assert.assert(MathUtils.inRange(abilityNum, 0, 7));
wheel.get(abilityNum).use(target, this);
public function tick()
wheel.tick();
buffQueue.tick();
public function isStunned():Bool
return false;
public function new(id:ID, team:Team, position:Int, ?parameters:Null<ParameterList>)
Assert.assert(position >= 0 && position <= 2);
if (parameters == null)
parameters = XMLUtils.parseUnit(id);
this.id = id;
this.name = parameters.name;
this.team = team;
this.position = position;
this.wheel = new Wheel(parameters.wheel, 8);
this.hpPool = new Pool(parameters.hp, parameters.hp);
this.manaPool = new Pool(parameters.mana, parameters.mana);
this.alacrityPool = new FloatPool(0, 100);
this.buffQueue = new BuffQueue();
this.strength = parameters.strength;
this.flow = parameters.flow;
this.intellect = parameters.intellect;
this.damageIn = new Linear(1, 0);
this.damageOut = new Linear(1, 0);
this.healIn = new Linear(1, 0);
this.healOut = new Linear(1, 0);
public function figureRelation(unit:Unit):UnitType
if (team != unit.team)
return UnitType.Enemy;
else if (position == unit.position)
return UnitType.Self;
else
return UnitType.Ally;
public inline function checkManacost(abilityNum:Int):Bool
return manaPool.value >= wheel.get(abilityNum).manacost;
Ability class
class Ability
public var id(default, null):ID;
public var name(default, null):String;
public var description(default, null):String;
public var type(default, null):AbilityType;
public var possibleTarget(default, null):AbilityTarget;
public var element(default, null):Element;
private var _cooldown:Countdown;
public var cooldown(get, null):Int;
public var manacost(default, null):Int;
public function use(target:Unit, caster:Unit)
Abilities.useAbility(id, target, caster, element);
Controller.instance.changeUnitMana(caster, caster, -manacost, battle.enums.Source.God);
_cooldown.value = _cooldown.keyValue;
public function tick()
if (checkOnCooldown())
_cooldown.value--;
public function new(id:ID)
this.id = id;
if (!checkEmpty())
this.name = XMLUtils.parseAbility(id, "name", "");
this.description = XMLUtils.parseAbility(id, "description", "");
this.type = XMLUtils.parseAbility(id, "type", AbilityType.Bolt);
this._cooldown = new Countdown(XMLUtils.parseAbility(id, "delay", 0), XMLUtils.parseAbility(id, "cooldown", 0));
this.manacost = XMLUtils.parseAbility(id, "manacost", 0);
this.possibleTarget = XMLUtils.parseAbility(id, "target", AbilityTarget.All);
this.element = XMLUtils.parseAbility(id, "element", Element.Physical);
//================================================================================
// Checkers
//================================================================================
public inline function checkOnCooldown():Bool
return _cooldown.value > 0;
public inline function checkEmpty():Bool
id == ID.LockAbility;
public inline function checkValidity(target:Unit, caster:Unit):Bool
var relation:battle.enums.UnitType = caster.figureRelation(target);
switch (possibleTarget)
case battle.enums.AbilityTarget.Enemy:
return relation == battle.enums.UnitType.Enemy;
case battle.enums.AbilityTarget.Allied:
return relation == battle.enums.UnitType.Ally
//================================================================================
// Getters
//================================================================================
function get_cooldown():Int
return _cooldown.value;
Abilities class (not to be confused with Ability class) contains methods that describe what the ability does, such as that:
private static function highVoltage(target:Unit, caster:Unit, element:Element)
var damage:Int = 40 + caster.intellect * 10;
Controller.instance.changeUnitHP(target, caster, -damage, element, Source.Ability);
Controller.instance.castBuff(ID.BuffLgConductivity, target, caster, 2);
This class contains one public method called useAbility that chooses one of private methods based on the ability ID.
Units class contains the methods for each unit (except player) that analyze the current game situation and return the ability the bot wants to use and the target of this ability.
Similar to Abilities class, this class contains one public method that chooses which private method to call based on the unit ID.
Buff and Buffs classes are very similar to Ability and Abilities classes, just have a bit different mechanic (ticking instead of using)
I need to rework this code to make it simpler and to adapt it for the possible future changes including:
Autotesting
As I continue to develop my game, I'll need to balance it, so I want to have a possibility to launch series of bot vs. bot matches without graphic output just to gather statistics.
It is the most important feature and the biggest reason to ask a question here, so please pay attention to this point.
Data collection
Ability/Class pick/win rates, most popular combos etc. I'm gonna save it to a file.
Multiplayer
I have never really dived into this subject, but someday I'll want to implement a simple 1x1 PvP mode. Then I may want to upgrade it up to 3x3 fights. I don't want to rewrite all the game from the scratch so any advices how to avoid this in the future are welcome.
Dialogues
The game stops if the condition is met and starts to play dialogue boxes. The conditions may vary: turn number, % of enemy's hp reached, enemy uses special ability. The diversity of the conditions is the main problem that doesn't allow me to make an obvious architectural decision.
I wonder what approaches can I apply to pave the way for these four features and make the code better overall. Maybe some patterns could help with this?
Any questions are welcome
object-oriented game design-patterns mvc haxe
NOTE: this question isn't as long as it appears to be. I added the comments to the code only to answer some possible questions that may appear.
I'm making an active time battle strategy. Its mechanics are very similar to the ones introduced in Final Fantasy, but I can't remember which one in the series was the closest to my game. There is also a flash game, called Sonny, that is 99.9% similar to the game of mine.
The goal of the game is to kill entire enemy team. Every unit has an alacrity pool, its regeneration speed depends on unit's agility aka. flow. Whenever this pool is full, the unit is allowed to cast one spell from his wheel.
The player's turn consists of two stages: choosing ability (choosing) and choosing the ability's target (targeting).
I'm using a modified version of MVC, where Controller sends requests to the Model, recieves answers with data and then sends this data to the Vision. I think this approach is too rigid, too unflexible, but that's one of the reasons I'm here. That's how it's done now:
The lever block consists of methods that modify model in a general ways (add/substract hp/mana, cast/dispell buff etc.) and are accessible from any part of the project.
The triggering block consists of functions that handle user input (not raw, primary processing is located in the Vision).
For example, if user presses the digit key, the code in the Vision (see below) handles this input and then calls the respective Controller method choose(), deciding (on its own!) which ability the player wants to choose (I'm not sure if it is a good approach to leave this type of logic in the Vision rather than in the Controller).
The useAbility block contains the useAbility() method and its auxiliary methods. Note that choose() and target() are called only when the player makes turn, but useAbility() is called whenever any unit does it.
The fact that useAbility() consists of three stages (and thus is called three times to avoid creating three different methods) is conditioned by the way the ability usage is animated. For example, when the unit kicks the target, he rushes to it, then the target recieves damage and then the unit that kicked it returns to his initial position. Different types of abilities are animated differently, but the vision-model-vision sequence remains the same.
To be able to work with the same target, caster and ability across all iterations of useAbility() method, I store this values as Controller's properties.
If the player makes turn, setUA() is called inside the target() method, else it is called inside one of the model's methods.
class Controller extends Sprite
public static var instance:Null<Controller>;
private var model:Model;
private var vision:Vision;
public var inputMode:InputMode;
private var chosenAbility:Int;
private var uatarget:UnitCoords;
private var uacaster:UnitCoords;
private var uaability:Ability;
private var uaiterator:Int;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, element:Element, source:Source)
var modelOutput:HPChangerOutput = model.changeUnitHP(target, caster, dhp, source);
vision.changeUnitHP(target, modelOutput.dhp, element, modelOutput.crit, source);
if (target.hpPool.value == 0)
vision.die(new UnitCoords(target.team, target.position));
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source)
var finalValue:Int = model.changeUnitMana(target, caster, dmana, source);
vision.changeUnitMana(target, finalValue, source);
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source)
var finalValue:Float = model.changeUnitAlacrity(target, caster, dalac, source);
vision.changeUnitAlacrity(target, finalValue, source);
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
model.castBuff(id, target, caster, duration);
vision.castBuff(id, duration);
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1)
var newBuffArray:Array<Buff> = model.dispellBuffs(target, elements, count);
vision.redrawBuffs(target, newBuffArray);
//================================================================================
// Triggering blocks
//================================================================================
public function choose(abilityNum:Int)
switch (model.checkChoose(abilityNum))
case ChooseResult.Ok:
inputMode = InputMode.Targeting;
chosenAbility = abilityNum;
vision.selectAbility(abilityNum);
case ChooseResult.Empty:
vision.printWarning("There is no ability in this slot");
case ChooseResult.Manacost:
vision.printWarning("Not enough mana");
case ChooseResult.Cooldown:
vision.printWarning("This ability is currently on cooldown");
public function use(targetCoords:UnitCoords)
switch (model.checkTarget(targetCoords, chosenAbility))
case TargetResult.Ok:
inputMode = InputMode.None;
vision.target(targetCoords);
vision.deselectAbility(chosenAbility);
setUA(targetCoords, new UnitCoords(battle.enums.Team.Left, 0), model.getPlayerAbility(chosenAbility));
chosenAbility = -1;
useAbility();
case TargetResult.Invalid:
vision.printWarning("Chosen ability cannot be used on this target");
vision.deselectAbility(chosenAbility);
chosenAbility = -1;
inputMode = InputMode.Choosing;
case TargetResult.Nonexistent, TargetResult.Dead:
//Ignore silently
public function skipTurnAttempt():Bool
if (inputMode != InputMode.None)
inputMode = InputMode.None;
model.postTurnProcess(new UnitCoords(Team.Left, 0));
return true;
return false;
public function end(winner:Null<Team>)
inputMode = InputMode.None;
if (winner == Team.Left)
vision.printWarning("You won!!!");
else if (winner == Team.Right)
vision.printWarning("You lost(");
else
vision.printWarning("A draw...");
removeChild(vision);
Main.onBattleOver();
//================================================================================
// useAbility
//================================================================================
public function useAbility()
switch (uaiterator++)
case 0:
vision.abilityIntro(uatarget, uacaster, type:uaability.type, element:uaability.element);
case 1:
if (model.checkUse(uatarget, uacaster, uaability) == UseResult.Miss)
vision.unitMiss(uatarget, uaability.element);
else
model.useAbility(uatarget, uacaster, uaability);
vision.abilityOutro(uatarget, uacaster, id:uaability.id, type:uaability.type);
case 2:
model.postTurnProcess(uacaster);
default:
clearUA();
useAbility();
public function setUA(target:UnitCoords, caster:UnitCoords, ability:Ability)
uatarget = target;
uacaster = caster;
uaability = ability;
private function clearUA()
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
//================================================================================
// INIT + Constructor
//================================================================================
public function destroy()
instance = null;
public function init(zone:Int, stage:Int, allies:Array<Unit>)
var enemyIDs:Array<ID> = XMLUtils.parseStage(zone, stage);
var enemies:Array<Unit> = ;
for (i in 0...enemyIDs.length)
enemies.push(new Unit(enemyIDs[i], Team.Right, i));
model = new Model(allies, enemies);
vision = new Vision();
addChild(vision);
vision.init(zone, allies, enemies);
uatarget = new UnitCoords(Team.Left, -1);
uacaster = new UnitCoords(Team.Left, -1);
uaability = new Ability(ID.NullID);
uaiterator = 0;
model.alacrityIncrement();
public function new()
super();
instance = this;
Then we come to the Model:
typedef AbilityInfo =
var name:String;
var describition:String;
var type:AbilityType;
var maxCooldown:Int;
var currentCooldown:Int;
var manacost:Int;
var target:AbilityTarget;
typedef UnitInfo =
var name:String;
var buffQueue:BuffQueue;
typedef HPChangerOutput =
var dhp:Int;
var crit:Bool;
enum ChooseResult
Ok;
Empty;
Manacost;
Cooldown;
enum TargetResult
Ok;
Invalid;
Nonexistent;
Dead;
enum UseResult
Ok;
Miss;
class Model
private var allies:Array<Unit>;
private var enemies:Array<Unit>;
private var unitToProcess:Null<Unit>;
private var readyUnits:Array<Unit>;
//================================================================================
// Levers
//================================================================================
public function changeUnitHP(target:Unit, caster:Unit, dhp:Int, source:Source):HPChangerOutput
var processedDelta:Int = dhp;
var crit:Bool = false;
if (source != Source.God)
if (dhp > 0)
processedDelta = Math.round(Linear.combination([target.healIn, caster.healOut]).apply(processedDelta));
else
processedDelta = Math.round(Linear.combination([target.damageIn, caster.damageOut]).apply(processedDelta));
if (Math.random() < caster.critChance.apply(1))
processedDelta = Math.round(caster.critDamage.apply(processedDelta));
crit = true;
target.hpPool.value += processedDelta;
return dhp:processedDelta, crit:crit;
public function changeUnitMana(target:Unit, caster:Unit, dmana:Int, source:Source):Int
target.manaPool.value += dmana;
return dmana;
public function changeUnitAlacrity(target:Unit, caster:Unit, dalac:Float, source:Source):Float
target.alacrityPool.value += dalac;
return dalac;
public function castBuff(id:ID, target:Unit, caster:Unit, duration:Int)
target.buffQueue.addBuff(new battle.Buff(id, target, caster, duration));
public function dispellBuffs(target:Unit, ?elements:Array<Element>, ?count:Int = -1):Array<battle.Buff>
target.buffQueue.dispell(elements, count);
return target.buffQueue.queue;
//================================================================================
// Input
//================================================================================
public function checkChoose(abilityPos:Int):ChooseResult
var hero:Unit = allies[0];
var ability:battle.Ability = hero.wheel.get(abilityPos);
if (ability.checkEmpty())
return ChooseResult.Empty;
if (ability.checkOnCooldown())
return ChooseResult.Cooldown;
if (!hero.checkManacost(abilityPos))
return ChooseResult.Manacost;
return ChooseResult.Ok;
public function checkTarget(targetCoords:UnitCoords, abilityPos:Int):TargetResult
var target:Unit = getUnit(targetCoords);
var ability:battle.Ability = allies[0].wheel.get(abilityPos);
if (target == null)
return TargetResult.Nonexistent;
if (target.hpPool.value == 0)
return TargetResult.Dead;
if (!ability.checkValidity(target, allies[0]))
return TargetResult.Invalid;
return TargetResult.Ok;
public function checkUse(targetCoords:UnitCoords, casterCoords:UnitCoords, ability:Ability):UseResult
return UseResult.Ok;
public function getPlayerAbility(pos:Int):battle.Ability
return allies[0].wheel.get(pos);
public function useAbility(target:UnitCoords, caster:UnitCoords, ability:battle.Ability)
ability.use(getUnit(target), getUnit(caster));
//================================================================================
// Cycle control
//================================================================================
public function alacrityIncrement()
for (unit in allies.concat(enemies))
if (checkAlive([unit]))
Controller.instance.changeUnitAlacrity(unit, unit, getAlacrityGain(unit), Source.God);
if (unit.alacrityPool.value == 100)
readyUnits.push(unit);
if (Lambda.empty(readyUnits))
alacrityIncrement();
else
try
sortByFlow(readyUnits);
processReady();
catch (e:Dynamic)
trace(e);
trace(CallStack.toString(CallStack.exceptionStack()));
Sys.exit(1);
private function processReady()
if (!Lambda.empty(readyUnits))
var unit:Unit = readyUnits[0];
readyUnits.splice(0, 1);
Controller.instance.changeUnitAlacrity(unit, unit, -100, Source.God);
if (!unit.isStunned())
if (unit.team == Team.Left && unit.position == 0)
Controller.instance.inputMode = InputMode.Choosing;
else
botMakeTurn(unit);
else
postTurnProcess(new UnitCoords(unit.team, unit.position));
else
alacrityIncrement();
public function postTurnProcess(coords:UnitCoords)
var unit:Unit = getUnit(coords);
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
if (unit.hpPool.value > 0)
unit.tick();
if (!bothTeamsAlive())
Controller.instance.end(defineWinner());
return;
processReady();
private function botMakeTurn(bot:Unit)
var decision:BotDecision = Units.decide(bot.id, allies, enemies);
trace(bot.wheel.get(decision.abilityNum));
Controller.instance.setUA(decision.target, getCoords(bot), bot.wheel.get(decision.abilityNum));
Controller.instance.useAbility();
private function getAlacrityGain(unit:Unit):Float
var sum:Float = 0;
for (unitI in allies.concat(enemies))
if (checkAlive([unitI]))
sum += unitI.flow;
return unit.flow / sum;
private function sortByFlow(array:Array<Unit>)
function swap(j1:Int, j2:Int)
var t:Unit = array[j1];
array[j1] = array[j2];
array[j2] = t;
for (i in 1...array.length)
for (j in i...array.length)
if (array[j - 1].flow < array[j].flow)
swap(j - 1, j);
else if (array[j - 1].flow == array[j].flow)
if (MathUtils.flip())
swap(j - 1, j);
//================================================================================
// Battle end utilities
//================================================================================
public function bothTeamsAlive():Bool
return checkAlive(allies) && checkAlive(enemies);
public function defineWinner():Null<Team>
if (checkAlive(allies))
return Team.Left;
else if (checkAlive(enemies))
return Team.Right;
else
return null;
private function checkAlive(array:Array<Unit>):Bool
for (unit in array)
if (unit.hpPool.value > 0)
return true;
return false;
//================================================================================
// Other
//================================================================================
private inline function getUnit(coords:UnitCoords):Null<Unit>
var array:Array<Unit> = (coords.team == battle.enums.Team.Left)? allies : enemies;
return array[coords.pos];
private inline function getCoords(unit:Unit):UnitCoords
return new UnitCoords(unit.team, unit.position);
//================================================================================
// Constructor
//================================================================================
public function new(allies:Array<Unit>, enemies:Array<Unit>)
this.allies = allies;
this.enemies = enemies;
this.readyUnits = ;
Finally, there is a Vision, but I don't think it's necessary to post all its code as it's primarily consists of visual programming, so I'll just leave there a general layout (important methods are fully listed):
class Vision extends SSprite
private var bg:DisplayObject;
private var upperBar:DisplayObject;
private var bottomBar:DisplayObject;
private var skipTurn:DisplayObject;
private var leaveBattle:DisplayObject;
private var alliesVision:Array<MovieClip>;
private var enemiesVision:Array<MovieClip>;
private var abilitiesVision:Array<MovieClip>;
private var allyNames:Array<TextField>;
private var allyHPs:Array<TextField>;
private var allyManas:Array<TextField>;
private var enemyNames:Array<TextField>;
private var enemyHPs:Array<TextField>;
private var enemyManas:Array<TextField>;
private var shiftKey:Bool;
//================================================================================
// Levers - display the canges in the game model
//================================================================================
public function changeUnitHP(target:Unit, dhp:Int, element:Element, crit:Bool, source:Source)
public function changeUnitMana(target:Unit, dmana:Int, source:Source)
public function changeUnitAlacrity(unit:Unit, dalac:Float, source:Source)
public function castBuff(id:ID, duration:Int)
public function redrawBuffs(target:Unit, buffs:Array<Buff>)
public function unitMiss(target:UnitCoords, element:Element)
public function die(unit:UnitCoords)
//================================================================================
// Input responses - some more visual stuff to make the game more responsive
//================================================================================
public function selectAbility(num:Int)
public function deselectAbility(num:Int)
public function target(coords:UnitCoords)
public function printWarning(text:String)
//================================================================================
// Basic animations - abstract animations
//================================================================================
public function abilityIntro(target:UnitCoords, caster:UnitCoords, ability:type:AbilityType, element:Element)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case AbilityType.Bolt:
animateBolt(target, caster, ability.element, callback);
case AbilityType.Kick:
animateKickIn(target, caster, callback);
default:
cleanAndCallback(callback);
public function abilityOutro(target:UnitCoords, caster:UnitCoords, ability:id:ID, type:AbilityType)
var callback:Dynamic = Controller.instance.useAbility;
switch (ability.type)
case battle.enums.AbilityType.Kick:
animateKickOut(caster, callback);
case battle.enums.AbilityType.Spell:
animateSpell(ability.id, target, callback);
default:
cleanAndCallback(callback);
//================================================================================
// Animation supply - concrete animations
//================================================================================
private function animateBolt(target:UnitCoords, caster:UnitCoords, element:Element, callback:Dynamic)
private function animateKickIn(target:UnitCoords, caster:UnitCoords, callback:Dynamic)
private function animateKickOut(caster:UnitCoords, callback:Dynamic)
private function animateSpell(abilityID:ID, target:UnitCoords, callback:Dynamic)
private function cleanAndCallback(callback:Dynamic, ?animation:Null<MovieClip>)
if (animation != null)
remove(animation);
Reflect.callMethod(callback, callback, );
//================================================================================
// Input handlers
//================================================================================
private function keyUpHandler(e:KeyboardEvent)
trace("keyUp handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = false;
private function keyHandler(e:KeyboardEvent)
trace("key handled: " + e.keyCode);
if (e.keyCode == 16)
shiftKey = true;
else if (MathUtils.inRange(e.keyCode, 49, 57))
if (shiftKey)
Controller.instance.printAbilityInfo(e.keyCode - 49);
else if (Controller.instance.inputMode != InputMode.None)
Controller.instance.choose(e.keyCode - 49);
private function clickHandler(e:MouseEvent)
//...
//================================================================================
// INIT + CONSTRUCTOR
//================================================================================
public function init(zone:Int, allies:Array<battle.Unit>, enemies:Array<battle.Unit>)
bg = Assets.getBattleBG(zone);
upperBar = new UpperBattleBar();
bottomBar = new BottomBattleBar();
skipTurn = new SkipTurn();
leaveBattle = new LeaveBattle();
alliesVision = ;
enemiesVision = ;
abilitiesVision = ;
allyNames = ;
allyHPs = ;
allyManas = ;
enemyNames = ;
enemyHPs = ;
enemyManas = ;
for (ally in allies)
alliesVision.push(Assets.getBattleUnit(ally.id));
for (enemy in enemies)
enemiesVision.push(Assets.getBattleUnit(enemy.id));
for (i in 0...10)
abilitiesVision.push(Assets.getBattleAbility(allies[0].wheel.get(i).id));
//...drawing the screen...
shiftKey = false;
stage.addEventListener(KeyboardEvent.KEY_DOWN, keyHandler);
stage.addEventListener(KeyboardEvent.KEY_UP, keyUpHandler);
stage.addEventListener(MouseEvent.CLICK, clickHandler);
public function new()
super();
//================================================================================
// Inline map - used to incapsulate concrete values
//================================================================================
private static inline function abilityX(i:Int):Float
private static inline function unitX(pos:Int, team:battle.enums.Team):Float
private static inline function unitY(pos:Int):Float
private static inline function unitInfoX(team:Team, type:String)
private static inline function unitInfoY(pos:Int):Float
//================================================================================
// Graphic utils
//================================================================================
private inline function getUnitBounds(pos:Int, team:Team):Rectangle
private function addTextfield(targetArray:Array<TextField>, text:String, font:String, size:Int, color:Null<Int> = null, bold:Null<Bool> = null)
private function playOnce(mc:MovieClip, x:Float, y:Float, ?onComplete:Null<Dynamic>, ?onCompleteParams:Null<Array<Dynamic>>)
//================================================================================
// Other
//================================================================================
private function getUnit(coords:UnitCoords):MovieClip
var array:Array<MovieClip> = (coords.team == battle.enums.Team.Left)? alliesVision : enemiesVision;
return array[coords.pos];
Note that all the sprites, actors, graphical objects are stored in different variables to make the work with graphic easier. Is it a good approach, though?
Unit class. Represents player, allies and enemies.
typedef ParameterList =
var name:String;
var hp:Int;
var mana:Int;
var wheel:Array<ID>;
var strength:Int;
var flow:Int;
var intellect:Int;
class Unit
public var id(default, null):ID;
public var name(default, null):String;
public var team(default, null):Team;
public var position(default, null):Int;
public var wheel(default, null):Wheel;
public var hpPool(default, null):Pool;
public var manaPool(default, null):Pool;
public var alacrityPool(default, null):FloatPool;
public var buffQueue(default, null):BuffQueue;
public var strength:Int;
public var flow:Int;
public var intellect:Int;
public var damageIn:Linear;
public var damageOut:Linear;
public var healIn:Linear;
public var healOut:Linear;
public var critChance:Linear;
public var critDamage:Linear;
public function useAbility(target:Unit, abilityNum:Int)
Assert.assert(MathUtils.inRange(abilityNum, 0, 7));
wheel.get(abilityNum).use(target, this);
public function tick()
wheel.tick();
buffQueue.tick();
public function isStunned():Bool
return false;
public function new(id:ID, team:Team, position:Int, ?parameters:Null<ParameterList>)
Assert.assert(position >= 0 && position <= 2);
if (parameters == null)
parameters = XMLUtils.parseUnit(id);
this.id = id;
this.name = parameters.name;
this.team = team;
this.position = position;
this.wheel = new Wheel(parameters.wheel, 8);
this.hpPool = new Pool(parameters.hp, parameters.hp);
this.manaPool = new Pool(parameters.mana, parameters.mana);
this.alacrityPool = new FloatPool(0, 100);
this.buffQueue = new BuffQueue();
this.strength = parameters.strength;
this.flow = parameters.flow;
this.intellect = parameters.intellect;
this.damageIn = new Linear(1, 0);
this.damageOut = new Linear(1, 0);
this.healIn = new Linear(1, 0);
this.healOut = new Linear(1, 0);
public function figureRelation(unit:Unit):UnitType
if (team != unit.team)
return UnitType.Enemy;
else if (position == unit.position)
return UnitType.Self;
else
return UnitType.Ally;
public inline function checkManacost(abilityNum:Int):Bool
return manaPool.value >= wheel.get(abilityNum).manacost;
Ability class
class Ability
public var id(default, null):ID;
public var name(default, null):String;
public var description(default, null):String;
public var type(default, null):AbilityType;
public var possibleTarget(default, null):AbilityTarget;
public var element(default, null):Element;
private var _cooldown:Countdown;
public var cooldown(get, null):Int;
public var manacost(default, null):Int;
public function use(target:Unit, caster:Unit)
Abilities.useAbility(id, target, caster, element);
Controller.instance.changeUnitMana(caster, caster, -manacost, battle.enums.Source.God);
_cooldown.value = _cooldown.keyValue;
public function tick()
if (checkOnCooldown())
_cooldown.value--;
public function new(id:ID)
this.id = id;
if (!checkEmpty())
this.name = XMLUtils.parseAbility(id, "name", "");
this.description = XMLUtils.parseAbility(id, "description", "");
this.type = XMLUtils.parseAbility(id, "type", AbilityType.Bolt);
this._cooldown = new Countdown(XMLUtils.parseAbility(id, "delay", 0), XMLUtils.parseAbility(id, "cooldown", 0));
this.manacost = XMLUtils.parseAbility(id, "manacost", 0);
this.possibleTarget = XMLUtils.parseAbility(id, "target", AbilityTarget.All);
this.element = XMLUtils.parseAbility(id, "element", Element.Physical);
//================================================================================
// Checkers
//================================================================================
public inline function checkOnCooldown():Bool
return _cooldown.value > 0;
public inline function checkEmpty():Bool
id == ID.LockAbility;
public inline function checkValidity(target:Unit, caster:Unit):Bool
var relation:battle.enums.UnitType = caster.figureRelation(target);
switch (possibleTarget)
case battle.enums.AbilityTarget.Enemy:
return relation == battle.enums.UnitType.Enemy;
case battle.enums.AbilityTarget.Allied:
return relation == battle.enums.UnitType.Ally
//================================================================================
// Getters
//================================================================================
function get_cooldown():Int
return _cooldown.value;
Abilities class (not to be confused with Ability class) contains methods that describe what the ability does, such as that:
private static function highVoltage(target:Unit, caster:Unit, element:Element)
var damage:Int = 40 + caster.intellect * 10;
Controller.instance.changeUnitHP(target, caster, -damage, element, Source.Ability);
Controller.instance.castBuff(ID.BuffLgConductivity, target, caster, 2);
This class contains one public method called useAbility that chooses one of private methods based on the ability ID.
Units class contains the methods for each unit (except player) that analyze the current game situation and return the ability the bot wants to use and the target of this ability.
Similar to Abilities class, this class contains one public method that chooses which private method to call based on the unit ID.
Buff and Buffs classes are very similar to Ability and Abilities classes, just have a bit different mechanic (ticking instead of using)
I need to rework this code to make it simpler and to adapt it for the possible future changes including:
Autotesting
As I continue to develop my game, I'll need to balance it, so I want to have a possibility to launch series of bot vs. bot matches without graphic output just to gather statistics.
It is the most important feature and the biggest reason to ask a question here, so please pay attention to this point.
Data collection
Ability/Class pick/win rates, most popular combos etc. I'm gonna save it to a file.
Multiplayer
I have never really dived into this subject, but someday I'll want to implement a simple 1x1 PvP mode. Then I may want to upgrade it up to 3x3 fights. I don't want to rewrite all the game from the scratch so any advices how to avoid this in the future are welcome.
Dialogues
The game stops if the condition is met and starts to play dialogue boxes. The conditions may vary: turn number, % of enemy's hp reached, enemy uses special ability. The diversity of the conditions is the main problem that doesn't allow me to make an obvious architectural decision.
I wonder what approaches can I apply to pave the way for these four features and make the code better overall. Maybe some patterns could help with this?
Any questions are welcome
object-oriented game design-patterns mvc haxe
edited Mar 4 at 9:34
asked Mar 2 at 11:28
Gulvan
263
263
Looks a bit like Final Fantasy X.
â Mast
Mar 2 at 12:08
add a comment |Â
Looks a bit like Final Fantasy X.
â Mast
Mar 2 at 12:08
Looks a bit like Final Fantasy X.
â Mast
Mar 2 at 12:08
Looks a bit like Final Fantasy X.
â Mast
Mar 2 at 12:08
add a comment |Â
active
oldest
votes
active
oldest
votes
active
oldest
votes
active
oldest
votes
active
oldest
votes
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
StackExchange.ready(
function ()
StackExchange.openid.initPostLogin('.new-post-login', 'https%3a%2f%2fcodereview.stackexchange.com%2fquestions%2f188670%2fatb-strategy-mvc-architecture-refactoring%23new-answer', 'question_page');
);
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Looks a bit like Final Fantasy X.
â Mast
Mar 2 at 12:08