Преглед изворни кода

Configurable multi-hex attacks

- Added bonus type MULTIHEX_UNIT_ATTACK - configurable version of Dragon
Breath.
- Added bonus type MULTIHEX_ENEMY_ATTACK - configurable version of
Cerberi multi-headed attack that only hits enemies
- Added bonus type MULTIHEX_ANIMATION - optional bonus that does not
affects gameplay, but allows to define in which cases game should use
alternative attack animation.
- All existing multi-hex attack bonuses other than ATTACKS_ALL_ADJACENT
are presumable deprecated, but will be supported for now.
- It is now possible to precisely configure which hexes are targeted by
MULTIHEX_XXX bonuses. See docs for details.
- Unified logic of all multi-hex attacks, all existing bonuses are now
implemented as specific case of MULTIHEX_XXX bonus
- Added tests to cover Cerberi attack logic, and fixed incorrect edge
case of Dragon Breath
Ivan Savenko пре 5 месеци
родитељ
комит
e90d8c318d

+ 1 - 0
client/CPlayerInterface.cpp

@@ -943,6 +943,7 @@ void CPlayerInterface::battleAttack(const BattleID & battleID, const BattleAttac
 	info.unlucky = ba->unlucky();
 	info.unlucky = ba->unlucky();
 	info.deathBlow = ba->deathBlow();
 	info.deathBlow = ba->deathBlow();
 	info.lifeDrain = ba->lifeDrain();
 	info.lifeDrain = ba->lifeDrain();
+	info.playCustomAnimation = ba->playCustomAnimation();
 	info.tile = ba->tile;
 	info.tile = ba->tile;
 	info.spellEffect = SpellID::NONE;
 	info.spellEffect = SpellID::NONE;
 
 

+ 1 - 0
client/battle/BattleInterface.h

@@ -85,6 +85,7 @@ struct StackAttackInfo
 	bool unlucky;
 	bool unlucky;
 	bool deathBlow;
 	bool deathBlow;
 	bool lifeDrain;
 	bool lifeDrain;
+	bool playCustomAnimation;
 };
 };
 
 
 /// Main class for battles, responsible for relaying information from server to various battle entities
 /// Main class for battles, responsible for relaying information from server to various battle entities

+ 2 - 3
client/battle/BattleStacksController.cpp

@@ -574,7 +574,6 @@ void BattleStacksController::stackAttacking( const StackAttackInfo & info )
 	auto defender    = info.defender;
 	auto defender    = info.defender;
 	auto tile        = info.tile;
 	auto tile        = info.tile;
 	auto spellEffect = info.spellEffect;
 	auto spellEffect = info.spellEffect;
-	auto multiAttack = !info.secondaryDefender.empty();
 	bool needsReverse = false;
 	bool needsReverse = false;
 
 
 	if (info.indirectAttack)
 	if (info.indirectAttack)
@@ -625,7 +624,7 @@ void BattleStacksController::stackAttacking( const StackAttackInfo & info )
 		}
 		}
 	}
 	}
 
 
-	owner.addToAnimationStage(EAnimationEvents::ATTACK, [this, attacker, tile, defender, multiAttack, info]()
+	owner.addToAnimationStage(EAnimationEvents::ATTACK, [this, attacker, tile, defender, info]()
 	{
 	{
 		if (info.indirectAttack)
 		if (info.indirectAttack)
 		{
 		{
@@ -633,7 +632,7 @@ void BattleStacksController::stackAttacking( const StackAttackInfo & info )
 		}
 		}
 		else
 		else
 		{
 		{
-			addNewAnim(new MeleeAttackAnimation(owner, attacker, tile, defender, multiAttack));
+			addNewAnim(new MeleeAttackAnimation(owner, attacker, tile, defender, info.playCustomAnimation));
 		}
 		}
 	});
 	});
 
 

+ 17 - 0
docs/images/Bonus_Multihex_Attack_Horizontal.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="235" height="150" viewBox="10 75 235 150">
+	<polygon fill="white" stroke="black" stroke-width="1" points="37, 96, 37, 128, 59, 138, 81, 128, 81, 96, 59, 86"/><text x="59" y="118" text-anchor="middle" fill="black" font-size="20">LB</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="81, 96, 81, 128, 103, 138, 125, 128, 125, 96, 103, 86"/><text x="103" y="118" text-anchor="middle" fill="black" font-size="20">L</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="125, 96, 125, 128, 147, 138, 169, 128, 169, 96, 147, 86"/><text x="147" y="118" text-anchor="middle" fill="black" font-size="20">FL</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="169, 96, 169, 128, 191, 138, 213, 128, 213, 96, 191, 86"/><text x="191" y="118" text-anchor="middle" fill="black" font-size="20">FFL</text>
+
+	<polygon fill="white" stroke="black" stroke-width="1" points="15, 138, 15, 170, 37, 180, 59, 170, 59, 138, 37, 128"/><text x="37" y="160" text-anchor="middle" fill="black" font-size="20">BB</text>
+	<polygon fill="green" stroke="black" stroke-width="1" points="59, 138, 59, 170, 81, 180, 103, 170, 103, 138, 81, 128"/><text x="81" y="160" text-anchor="middle" fill="black" font-size="20">B</text>
+	<polygon fill="red" stroke="black" stroke-width="1" points="103, 138, 103, 170, 125, 180, 147, 170, 147, 138, 125, 128"/><text x="125" y="160" text-anchor="middle" fill="black" font-size="20">F</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="147, 138, 147, 170, 169, 180, 191, 170, 191, 138, 169, 128"/><text x="169" y="160" text-anchor="middle" fill="black" font-size="20">FF</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="191, 138, 191, 170, 213, 180, 235, 170, 235, 138, 213, 128"/><text x="213" y="160" text-anchor="middle" fill="black" font-size="20">FFF</text>
+
+	<polygon fill="white" stroke="black" stroke-width="1" points="37, 180, 37, 212, 59, 222, 81, 212, 81, 180, 59, 170"/><text x="59" y="202" text-anchor="middle" fill="black" font-size="20">RB</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="81, 180, 81, 212, 103, 222, 125, 212, 125, 180, 103, 170"/><text x="103" y="202" text-anchor="middle" fill="black" font-size="20">R</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="125, 180, 125, 212, 147, 222, 169, 212, 169, 180, 147, 170"/><text x="147" y="202" text-anchor="middle" fill="black" font-size="20">RF</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="169, 180, 169, 212, 191, 222, 213, 212, 213, 180, 191, 170"/><text x="191" y="202" text-anchor="middle" fill="black" font-size="20">RFF</text>
+</svg>

+ 17 - 0
docs/images/Bonus_Multihex_Attack_Vertical.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="235" height="150" viewBox="10 75 235 150">
+	<polygon fill="white" stroke="black" stroke-width="1" points="37, 96, 37, 128, 59, 138, 81, 128, 81, 96, 59, 86"/><text x="59" y="118" text-anchor="middle" fill="black" font-size="20">FL</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="81, 96, 81, 128, 103, 138, 125, 128, 125, 96, 103, 86"/><text x="103" y="118" text-anchor="middle" fill="black" font-size="20">F</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="125, 96, 125, 128, 147, 138, 169, 128, 169, 96, 147, 86"/><text x="147" y="118" text-anchor="middle" fill="black" font-size="20">FR</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="169, 96, 169, 128, 191, 138, 213, 128, 213, 96, 191, 86"/><text x="191" y="118" text-anchor="middle" fill="black" font-size="20">RR</text>
+
+	<polygon fill="white" stroke="black" stroke-width="1" points="15, 138, 15, 170, 37, 180, 59, 170, 59, 138, 37, 128"/><text x="37" y="160" text-anchor="middle" fill="black" font-size="20"></text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="59, 138, 59, 170, 81, 180, 103, 170, 103, 138, 81, 128"/><text x="81" y="160" text-anchor="middle" fill="black" font-size="20">L</text>
+	<polygon fill="red" stroke="black" stroke-width="1" points="103, 138, 103, 170, 125, 180, 147, 170, 147, 138, 125, 128"/><text x="125" y="160" text-anchor="middle" fill="black" font-size="20">F</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="147, 138, 147, 170, 169, 180, 191, 170, 191, 138, 169, 128"/><text x="169" y="160" text-anchor="middle" fill="black" font-size="20">R</text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="191, 138, 191, 170, 213, 180, 235, 170, 235, 138, 213, 128"/><text x="213" y="160" text-anchor="middle" fill="black" font-size="20"></text>
+
+	<polygon fill="white" stroke="black" stroke-width="1" points="37, 180, 37, 212, 59, 222, 81, 212, 81, 180, 59, 170"/><text x="59" y="202" text-anchor="middle" fill="black" font-size="20"></text>
+	<polygon fill="green" stroke="black" stroke-width="1" points="81, 180, 81, 212, 103, 222, 125, 212, 125, 180, 103, 170"/><text x="103" y="202" text-anchor="middle" fill="black" font-size="20"></text>
+	<polygon fill="green" stroke="black" stroke-width="1" points="125, 180, 125, 212, 147, 222, 169, 212, 169, 180, 147, 170"/><text x="147" y="202" text-anchor="middle" fill="black" font-size="20"></text>
+	<polygon fill="white" stroke="black" stroke-width="1" points="169, 180, 169, 212, 191, 222, 213, 212, 213, 180, 191, 170"/><text x="191" y="202" text-anchor="middle" fill="black" font-size="20"></text>
+</svg>

+ 46 - 10
docs/modders/Bonus/Bonus_Types.md

@@ -500,21 +500,61 @@ Affected unit ranged attack will use animation and range of specified spell (Mag
 - subtype - spell identifier
 - subtype - spell identifier
 - value - spell mastery level
 - value - spell mastery level
 
 
-### THREE_HEADED_ATTACK
+### ATTACKS_ALL_ADJACENT
 
 
-Affected unit attacks creatures located on tiles to left and right of targeted tile (Cerberus). Only directly targeted creature will attempt to retaliate
+The affected unit attacks all adjacent units (Hydra). Only the unit that has been directly targeted will attempt to retaliate. If the unit is hypnotised, it will attack its former allies instead.
 
 
-### ATTACKS_ALL_ADJACENT
+### THREE_HEADED_ATTACK
 
 
-Affected unit attacks all adjacent creatures (Hydra). Only directly targeted creature will attempt to retaliate
+The affected unit will attack units located on the hexed to the left and right of the targeted tile (Cerberus). Only the unit that has been directly targeted will attempt to retaliate.
+Potentially deprecated. Consider using the more flexible [MULTIHEX_ENEMY_ATTACK](#multihex_unit_attack) instead with custom icon and description.
 
 
 ### TWO_HEX_ATTACK_BREATH
 ### TWO_HEX_ATTACK_BREATH
 
 
-Affected unit attacks creature located directly behind targeted tile (Dragons). Only directly targeted creature will attempt to retaliate
+The affected unit will also attack the hex located directly behind the targeted hex (Dragons). Only the unit that has been directly targeted will attempt to retaliate.
+Potentially deprecated. Consider using the more flexible [MULTIHEX_UNIT_ATTACK](#multihex_unit_attack) instead with custom icon and description.
+
+### WIDE_BREATH
+
+The affected unit will attack any units in the hexes surrounding the attacked hex.
+Deprecated. Please use [MULTIHEX_UNIT_ATTACK](#multihex_unit_attack) instead with custom icon and description.
 
 
 ### PRISM_HEX_ATTACK_BREATH
 ### PRISM_HEX_ATTACK_BREATH
 
 
-Like `TWO_HEX_ATTACK_BREATH` but affects also two additional cratures (in triangle form from target tile)
+Similar to `TWO_HEX_ATTACK_BREATH`, but affecting two additional hexes in a triangular formation from the target hex.
+Deprecated. Please use [MULTIHEX_UNIT_ATTACK](#multihex_unit_attack) instead with custom icon and description.
+
+### MULTIHEX_UNIT_ATTACK
+
+The affected unit attacks all units, friendly or not, located on specified hexes in addition to the primary target. Only the unit that has been directly targeted will attempt to retaliate.
+
+- addInfo: A list of strings describing which hexes this unit will attack, computed from the attacker's position. The possible values are: `F` (front), `L` (left), `R` (right), `B` (back). See below for more examples.
+
+Examples:
+
+- H3 Dragon Breath: `[ "FF" ]` – dragons also attack the hex located two hexes in front of the dragon's head.
+- H3 Cerberus three-headed attack: `[ "L", "R" ]` - Cerberus also attacks the hexes one hex to the left and right of itself.
+- Prism Breath (mods): `[ "FL", "FF", "FR" ]` — a more powerful version of Dragon Breath; all units behind the target are attacked.
+
+This is how all tiles can be referenced in the event of a frontal attack (green is the attacker and red is the defender). The hex on which defender is located is always included unconditionally.
+![MULTIHEX_UNIT_ATTACK frontal attack hexes indexing](../images/Bonus_Multihex_Attack_Horizontal.svg)
+
+In the case of a double-wide unit that can attack hexes to the left and right (e.g. Cerberi), the left or right hex may end up inside the attacker in certain attack configurations. To avoid this, the hex that ends up inside the unit will be 'pushed' one hex forward. This does not affect single-wide units. See below for reference:
+![MULTIHEX_UNIT_ATTACK vertical attack hexes indexing](../images/Bonus_Multihex_Attack_Vertical.svg)
+
+### MULTIHEX_ENEMY_ATTACK
+
+The affected unit will attack all enemies located on the specified hexes, in addition to its primary target. Only the unit that has been directly targeted will attempt to retaliate. If the unit is hypnotised, it will attack its former allies instead.
+
+- addInfo: see [MULTIHEX_UNIT_ATTACK](#multihex_unit_attack) for a detailed description.
+
+### MULTIHEX_ANIMATION
+
+The bonus does not affect the mechanics. If the affected unit hits any non-primary targets located on the specified tiles, the unit will play an alternative attack animation if one is present.
+
+If this bonus is not present, the unit will always use the alternative attack animation whenever its attack hits any unit other than the primary target.
+
+- addInfo: see [MULTIHEX_UNIT_ATTACK](#multihex_unit_attack) for a detailed description.
 
 
 ### RETURN_AFTER_STRIKE
 ### RETURN_AFTER_STRIKE
 
 
@@ -611,10 +651,6 @@ Affected units will retaliate against ranged attacks, if able
 
 
 Affected unit will never receive counterattack in ranged attacks. Counters RANGED_RETALIATION bonus
 Affected unit will never receive counterattack in ranged attacks. Counters RANGED_RETALIATION bonus
 
 
-### WIDE_BREATH
-
-Affected unit will attack units on all hexes that surround attacked hex
-
 ### FIRST_STRIKE
 ### FIRST_STRIKE
 
 
 Affected unit will retaliate before enemy attacks, if able
 Affected unit will retaliate before enemy attacks, if able

+ 1 - 1
lib/battle/BattleHex.h

@@ -118,7 +118,7 @@ public:
 		if(hasToBeValid)
 		if(hasToBeValid)
 		{
 		{
 			if(x < 0 || x >= GameConstants::BFIELD_WIDTH || y < 0 || y >= GameConstants::BFIELD_HEIGHT)
 			if(x < 0 || x >= GameConstants::BFIELD_WIDTH || y < 0 || y >= GameConstants::BFIELD_HEIGHT)
-				throw std::runtime_error("Hex at (" + std::to_string(x) + ", " + std::to_string(y) + ") is not valid!");
+				throw std::out_of_range("Hex at (" + std::to_string(x) + ", " + std::to_string(y) + ") is not valid!");
 		}
 		}
 
 
 		hex = x + y * GameConstants::BFIELD_WIDTH;
 		hex = x + y * GameConstants::BFIELD_WIDTH;

+ 79 - 73
lib/battle/CBattleInfoCallback.cpp

@@ -1330,89 +1330,82 @@ AttackableTiles CBattleInfoCallback::getPotentiallyAttackableHexes(
 	
 	
 	defenderPos = (defenderPos.toInt() != BattleHex::INVALID) ? defenderPos : defender->getPosition(); //real or hypothetical (cursor) position
 	defenderPos = (defenderPos.toInt() != BattleHex::INVALID) ? defenderPos : defender->getPosition(); //real or hypothetical (cursor) position
 	
 	
-	bool reverse = isToReverse(attacker, defender, attackerPos, defenderPos);
-	if(reverse && attacker->doubleWide())
-	{
-		attackOriginHex = attacker->occupiedHex(attackOriginHex); //the other hex stack stands on
-	}
 	if(attacker->hasBonusOfType(BonusType::ATTACKS_ALL_ADJACENT))
 	if(attacker->hasBonusOfType(BonusType::ATTACKS_ALL_ADJACENT))
-	{
 		at.hostileCreaturePositions.insert(attacker->getSurroundingHexes(attackerPos));
 		at.hostileCreaturePositions.insert(attacker->getSurroundingHexes(attackerPos));
-	}
-	if(attacker->hasBonusOfType(BonusType::THREE_HEADED_ATTACK))
+
+	// If attacker is double-wide and its head is not adjacent to enemy we need to turn around
+	if(attacker->doubleWide() && !vstd::contains(defender->getSurroundingHexes(defenderPos), attackOriginHex))
+		attackOriginHex = attacker->occupiedHex(attackOriginHex);
+
+	if (!vstd::contains(defender->getSurroundingHexes(defenderPos), attackOriginHex))
+		throw std::runtime_error("!!!");
+
+	auto attackDirection = BattleHex::mutualPosition(attackOriginHex, defenderPos);
+
+	// If defender is double-wide, attacker always prefers targeting its 'tail', if it is reachable
+	if(defender->doubleWide() && BattleHex::mutualPosition(attackOriginHex, defender->occupiedHex(defenderPos)) != BattleHex::NONE)
+		attackDirection = BattleHex::mutualPosition(attackOriginHex, defender->occupiedHex(defenderPos));
+
+	if (attackDirection == BattleHex::NONE)
+		throw std::runtime_error("!!!");
+
+	const auto & processTargets = [&](const std::vector<int> & additionalTargets) -> BattleHexArray
 	{
 	{
-		const BattleHexArray & hexes = attacker->getSurroundingHexes(attackerPos);
-		for(const BattleHex & tile : hexes)
+		BattleHexArray output;
+
+		for (int targetPath : additionalTargets)
 		{
 		{
-			if((BattleHex::mutualPosition(tile, destinationTile) > -1 && BattleHex::mutualPosition(tile, attackOriginHex) > -1)) //adjacent both to attacker's head and attacked tile
+			BattleHex target = attackOriginHex;
+			std::vector<BattleHex::EDir> path;
+
+			for (int targetPathLeft = targetPath; targetPathLeft != 0; targetPathLeft /= 10)
+				path.push_back(static_cast<BattleHex::EDir>((attackDirection + targetPathLeft % 10 - 1) % 6));
+
+			try
 			{
 			{
-				const auto * st = battleGetUnitByPos(tile, true);
-				if(st && battleGetOwner(st) != battleGetOwner(attacker)) //only hostile stacks - does it work well with Berserk?
-					at.hostileCreaturePositions.insert(tile);
+				if(attacker->doubleWide() && attacker->coversPos(target.cloneInDirection(path.front())))
+					target.moveInDirection(attackDirection);
+
+				for(BattleHex::EDir nextDirection : path)
+					target.moveInDirection(nextDirection);
+			}
+			catch(const std::out_of_range &)
+			{
+				// Hex out of range, for example outside of battlefield. This is valid situation, so skip this hex
+				continue;
 			}
 			}
-		}
-	}
-	if(attacker->hasBonusOfType(BonusType::WIDE_BREATH))
-	{
-		BattleHexArray hexes = destinationTile.getNeighbouringTiles();
-		if (hexes.contains(attackOriginHex))
-			hexes.erase(attackOriginHex);
 
 
-		for(const BattleHex & tile : hexes)
-		{
-			//friendly stacks can also be damaged by Dragon Breath
-			const auto * st = battleGetUnitByPos(tile, true);
-			if(st && st != attacker)
-				at.friendlyCreaturePositions.insert(tile);
-		}
-	}
-	else if(attacker->hasBonusOfType(BonusType::TWO_HEX_ATTACK_BREATH) || attacker->hasBonusOfType(BonusType::PRISM_HEX_ATTACK_BREATH))
-	{
-		auto direction = BattleHex::mutualPosition(attackOriginHex, destinationTile);
-		
-		if(direction == BattleHex::NONE
-			&& defender->doubleWide()
-			&& attacker->doubleWide()
-			&& defenderPos == destinationTile)
-		{
-			direction = BattleHex::mutualPosition(attackOriginHex, defender->occupiedHex(defenderPos));
+			if (target.isValid() && !attacker->coversPos(target))
+				output.insert(target);
 		}
 		}
+		return output;
+	};
 
 
-		for(int i = 0; i < 3; i++)
-		{
-			if(direction != BattleHex::NONE) //only adjacent hexes are subject of dragon breath calculation
-			{
-				BattleHex nextHex = destinationTile.cloneInDirection(direction, false);
+	const auto multihexUnit = attacker->getBonusesOfType(BonusType::MULTIHEX_UNIT_ATTACK);
+	const auto multihexEnemy = attacker->getBonusesOfType(BonusType::MULTIHEX_ENEMY_ATTACK);
+	const auto multihexAnimation = attacker->getBonusesOfType(BonusType::MULTIHEX_ANIMATION);
 
 
-				if ( defender->doubleWide() )
-				{
-					auto secondHex = destinationTile == defenderPos ? defender->occupiedHex(defenderPos) : defenderPos;
+	for (const auto & bonus : *multihexUnit)
+		at.friendlyCreaturePositions.insert(processTargets(bonus->additionalInfo));
 
 
-					// if targeted double-wide creature is attacked from above or below ( -> second hex is also adjacent to attack origin)
-					// then dragon breath should target tile on the opposite side of targeted creature
-					if(BattleHex::mutualPosition(attackOriginHex, secondHex) != BattleHex::NONE)
-						nextHex = secondHex.cloneInDirection(direction, false);
-				}
+	for (const auto & bonus : *multihexEnemy)
+		at.hostileCreaturePositions.insert(processTargets(bonus->additionalInfo));
 
 
-				if (nextHex.isValid())
-				{
-					//friendly stacks can also be damaged by Dragon Breath
-					const auto * st = battleGetUnitByPos(nextHex, true);
-					if(st != nullptr && st != attacker) //but not unit itself (doublewide + prism attack)
-						at.friendlyCreaturePositions.insert(nextHex);
-				}
-			}
+	for (const auto & bonus : *multihexAnimation)
+		at.overrideAnimationPositions.insert(processTargets(bonus->additionalInfo));
 
 
-			if(!attacker->hasBonusOfType(BonusType::PRISM_HEX_ATTACK_BREATH))
-				break;
+	if(attacker->hasBonusOfType(BonusType::THREE_HEADED_ATTACK))
+		at.hostileCreaturePositions.insert(processTargets({2,6}));
+
+	if(attacker->hasBonusOfType(BonusType::WIDE_BREATH))
+		at.friendlyCreaturePositions.insert(processTargets({ 11, 111, 2, 12, 6, 16 }));
+
+	if(attacker->hasBonusOfType(BonusType::TWO_HEX_ATTACK_BREATH))
+		at.friendlyCreaturePositions.insert(processTargets({ 11 }));
+
+	if (attacker->hasBonusOfType(BonusType::PRISM_HEX_ATTACK_BREATH))
+		at.friendlyCreaturePositions.insert(processTargets({ 11, 12, 16 }));
 
 
-			// only needed for prism
-			int tmpDirection = static_cast<int>(direction) + 2;
-			if(tmpDirection > static_cast<int>(BattleHex::EDir::LEFT))
-				tmpDirection -= static_cast<int>(BattleHex::EDir::TOP);
-			direction = static_cast<BattleHex::EDir>(tmpDirection);
-		}
-	}
 	return at;
 	return at;
 }
 }
 
 
@@ -1473,9 +1466,9 @@ battle::Units CBattleInfoCallback::getAttackedBattleUnits(
 	return units;
 	return units;
 }
 }
 
 
-std::set<const CStack*> CBattleInfoCallback::getAttackedCreatures(const CStack* attacker, const BattleHex & destinationTile, bool rangedAttack, BattleHex attackerPos) const
+std::pair<std::set<const CStack*>, bool> CBattleInfoCallback::getAttackedCreatures(const CStack* attacker, const BattleHex & destinationTile, bool rangedAttack, BattleHex attackerPos) const
 {
 {
-	std::set<const CStack*> attackedCres;
+	std::pair<std::set<const CStack*>, bool> attackedCres;
 	RETURN_IF_NOT_BATTLE(attackedCres);
 	RETURN_IF_NOT_BATTLE(attackedCres);
 
 
 	AttackableTiles at;
 	AttackableTiles at;
@@ -1490,7 +1483,7 @@ std::set<const CStack*> CBattleInfoCallback::getAttackedCreatures(const CStack*
 		const CStack * st = battleGetStackByPos(tile, true);
 		const CStack * st = battleGetStackByPos(tile, true);
 		if(st && battleGetOwner(st) != battleGetOwner(attacker)) //only hostile stacks - does it work well with Berserk?
 		if(st && battleGetOwner(st) != battleGetOwner(attacker)) //only hostile stacks - does it work well with Berserk?
 		{
 		{
-			attackedCres.insert(st);
+			attackedCres.first.insert(st);
 		}
 		}
 	}
 	}
 	for (const BattleHex & tile : at.friendlyCreaturePositions)
 	for (const BattleHex & tile : at.friendlyCreaturePositions)
@@ -1498,9 +1491,22 @@ std::set<const CStack*> CBattleInfoCallback::getAttackedCreatures(const CStack*
 		const CStack * st = battleGetStackByPos(tile, true);
 		const CStack * st = battleGetStackByPos(tile, true);
 		if(st) //friendly stacks can also be damaged by Dragon Breath
 		if(st) //friendly stacks can also be damaged by Dragon Breath
 		{
 		{
-			attackedCres.insert(st);
+			attackedCres.first.insert(st);
 		}
 		}
 	}
 	}
+
+	if (at.friendlyCreaturePositions.empty())
+	{
+		attackedCres.second = !attackedCres.first.empty();
+	}
+	else
+	{
+		for (const BattleHex & tile : at.friendlyCreaturePositions)
+			for (const auto & st : attackedCres.first)
+				if (st->coversPos(tile))
+					attackedCres.second = true;
+	}
+
 	return attackedCres;
 	return attackedCres;
 }
 }
 
 

+ 6 - 7
lib/battle/CBattleInfoCallback.h

@@ -38,13 +38,12 @@ namespace spells
 
 
 struct DLL_LINKAGE AttackableTiles
 struct DLL_LINKAGE AttackableTiles
 {
 {
+	/// Hexes on which only hostile units will be targeted
 	BattleHexArray hostileCreaturePositions;
 	BattleHexArray hostileCreaturePositions;
-	BattleHexArray friendlyCreaturePositions; //for Dragon Breath
-	template <typename Handler> void serialize(Handler &h)
-	{
-		h & hostileCreaturePositions;
-		h & friendlyCreaturePositions;
-	}
+	/// for Dragon Breath, hexes on which both friendly and hostile creatures will be targeted
+	BattleHexArray friendlyCreaturePositions;
+	/// for animation purposes, if any of targets are on specified positions, unit should play alternative animation
+	BattleHexArray overrideAnimationPositions;
 };
 };
 
 
 struct DLL_LINKAGE BattleClientInterfaceData
 struct DLL_LINKAGE BattleClientInterfaceData
@@ -155,7 +154,7 @@ public:
 		BattleHex attackerPos = BattleHex::INVALID,
 		BattleHex attackerPos = BattleHex::INVALID,
 		BattleHex defenderPos = BattleHex::INVALID) const; //calculates range of multi-hex attacks
 		BattleHex defenderPos = BattleHex::INVALID) const; //calculates range of multi-hex attacks
 	
 	
-	std::set<const CStack*> getAttackedCreatures(const CStack* attacker, const BattleHex & destinationTile, bool rangedAttack, BattleHex attackerPos = BattleHex::INVALID) const; //calculates range of multi-hex attacks
+	std::pair<std::set<const CStack*>, bool> getAttackedCreatures(const CStack* attacker, const BattleHex & destinationTile, bool rangedAttack, BattleHex attackerPos = BattleHex::INVALID) const; //calculates range of multi-hex attacks
 	bool isToReverse(const battle::Unit * attacker, const battle::Unit * defender, BattleHex attackerHex = BattleHex::INVALID, BattleHex defenderHex = BattleHex::INVALID) const; //determines if attacker standing at attackerHex should reverse in order to attack defender
 	bool isToReverse(const battle::Unit * attacker, const battle::Unit * defender, BattleHex attackerHex = BattleHex::INVALID, BattleHex defenderHex = BattleHex::INVALID) const; //determines if attacker standing at attackerHex should reverse in order to attack defender
 
 
 	ReachabilityInfo getReachability(const battle::Unit * unit) const;
 	ReachabilityInfo getReachability(const battle::Unit * unit) const;

+ 3 - 0
lib/bonuses/BonusEnum.h

@@ -184,6 +184,9 @@ class JsonNode;
 	BONUS_NAME(PRISM_HEX_ATTACK_BREATH) /*eg. dragons*/	\
 	BONUS_NAME(PRISM_HEX_ATTACK_BREATH) /*eg. dragons*/	\
 	BONUS_NAME(BASE_TILE_MOVEMENT_COST) /*minimal cost for moving offroad*/	\
 	BONUS_NAME(BASE_TILE_MOVEMENT_COST) /*minimal cost for moving offroad*/	\
 	BONUS_NAME(HERO_SPELL_CASTS_PER_COMBAT_TURN) /**/	\
 	BONUS_NAME(HERO_SPELL_CASTS_PER_COMBAT_TURN) /**/	\
+	BONUS_NAME(MULTIHEX_UNIT_ATTACK) /*eg. dragons*/	\
+	BONUS_NAME(MULTIHEX_ENEMY_ATTACK) /*eg. dragons*/	\
+	BONUS_NAME(MULTIHEX_ANIMATION) /*eg. dragons*/	\
 	/* end of list */
 	/* end of list */
 
 
 
 

+ 18 - 0
lib/json/JsonBonus.cpp

@@ -237,6 +237,24 @@ static void loadBonusAddInfo(CAddInfo & var, BonusType type, const JsonNode & no
 			var[1] = value[1].Integer();
 			var[1] = value[1].Integer();
 			var[2] = value[2].Integer();
 			var[2] = value[2].Integer();
 			break;
 			break;
+		case BonusType::MULTIHEX_UNIT_ATTACK:
+		case BonusType::MULTIHEX_ENEMY_ATTACK:
+		case BonusType::MULTIHEX_ANIMATION:
+			for (const auto & sequence : value.Vector())
+			{
+				static const std::map<char, int> charToDirection = {
+					{ 'f', 1 }, { 'l', 6}, {'r', 2}, {'b', 4}
+				};
+				int converted = 0;
+				for (const auto & ch : boost::adaptors::reverse(sequence.String()))
+				{
+					char chLower = std::tolower(ch);
+					if (charToDirection.count(chLower))
+						converted = 10 * converted + charToDirection.at(chLower);
+				}
+				var.push_back(converted);
+			}
+			break;
 		default:
 		default:
 			for(const auto & i : bonusNameMap)
 			for(const auto & i : bonusNameMap)
 				if(i.second == type)
 				if(i.second == type)

+ 5 - 1
lib/networkPacks/PacksForClientBattle.h

@@ -251,7 +251,7 @@ struct DLL_LINKAGE BattleAttack : public CPackForClient
 	std::vector<BattleStackAttacked> bsa;
 	std::vector<BattleStackAttacked> bsa;
 	ui32 stackAttacking = 0;
 	ui32 stackAttacking = 0;
 	ui32 flags = 0; //uses Eflags (below)
 	ui32 flags = 0; //uses Eflags (below)
-	enum EFlags { SHOT = 1, COUNTER = 2, LUCKY = 4, UNLUCKY = 8, BALLISTA_DOUBLE_DMG = 16, DEATH_BLOW = 32, SPELL_LIKE = 64, LIFE_DRAIN = 128 };
+	enum EFlags { SHOT = 1, COUNTER = 2, LUCKY = 4, UNLUCKY = 8, BALLISTA_DOUBLE_DMG = 16, DEATH_BLOW = 32, SPELL_LIKE = 64, LIFE_DRAIN = 128, CUSTOM_ANIMATION = 256};
 
 
 	BattleHex tile;
 	BattleHex tile;
 	SpellID spellID = SpellID::NONE; //for SPELL_LIKE
 	SpellID spellID = SpellID::NONE; //for SPELL_LIKE
@@ -288,6 +288,10 @@ struct DLL_LINKAGE BattleAttack : public CPackForClient
 	{
 	{
 		return flags & LIFE_DRAIN;
 		return flags & LIFE_DRAIN;
 	}
 	}
+	bool playCustomAnimation() const
+	{
+		return flags & CUSTOM_ANIMATION;
+	}
 
 
 	void visitTyped(ICPackVisitor & visitor) override;
 	void visitTyped(ICPackVisitor & visitor) override;
 
 

+ 4 - 1
server/battles/BattleActionProcessor.cpp

@@ -978,13 +978,16 @@ void BattleActionProcessor::makeAttack(const CBattleInfoCallback & battle, const
 		applyBattleEffects(battle, bat, attackerState, fireShield, defender, healInfo, distance, false);
 		applyBattleEffects(battle, bat, attackerState, fireShield, defender, healInfo, distance, false);
 
 
 	//multiple-hex normal attack
 	//multiple-hex normal attack
-	std::set<const CStack*> attackedCreatures = battle.getAttackedCreatures(attacker, targetHex, bat.shot()); //creatures other than primary target
+	const auto & [attackedCreatures, useCustomAnimation] = battle.getAttackedCreatures(attacker, targetHex, bat.shot()); //creatures other than primary target
 	for(const CStack * stack : attackedCreatures)
 	for(const CStack * stack : attackedCreatures)
 	{
 	{
 		if(stack != defender && stack->alive()) //do not hit same stack twice
 		if(stack != defender && stack->alive()) //do not hit same stack twice
 			applyBattleEffects(battle, bat, attackerState, fireShield, stack, healInfo, distance, true);
 			applyBattleEffects(battle, bat, attackerState, fireShield, stack, healInfo, distance, true);
 	}
 	}
 
 
+	if (useCustomAnimation)
+		bat.flags |= BattleAttack::CUSTOM_ANIMATION;
+
 	std::shared_ptr<const Bonus> bonus = attacker->getFirstBonus(Selector::type()(BonusType::SPELL_LIKE_ATTACK));
 	std::shared_ptr<const Bonus> bonus = attacker->getFirstBonus(Selector::type()(BonusType::SPELL_LIKE_ATTACK));
 	if(bonus && ranged && bonus->subtype.hasValue()) //TODO: make it work in melee?
 	if(bonus && ranged && bonus->subtype.hasValue()) //TODO: make it work in melee?
 	{
 	{

+ 140 - 28
test/battle/CBattleInfoCallbackTest.cpp

@@ -229,6 +229,16 @@ public:
 		return unit;
 		return unit;
 	}
 	}
 
 
+	UnitFake & addCerberi(BattleHex hex, BattleSide side)
+	{
+		auto & unit = addRegularMelee(hex, side);
+
+		unit.addCreatureAbility(BonusType::THREE_HEADED_ATTACK);
+		unit.makeDoubleWide();
+
+		return unit;
+	}
+
 	UnitFake & addDragon(BattleHex hex, BattleSide side)
 	UnitFake & addDragon(BattleHex hex, BattleSide side)
 	{
 	{
 		auto & unit = addRegularMelee(hex, side);
 		auto & unit = addRegularMelee(hex, side);
@@ -252,6 +262,91 @@ public:
 	}
 	}
 };
 };
 
 
+//// CERBERI 3-HEADED ATTACKS
+
+TEST_F(AttackableHexesTest, CerberiAttackerRight)
+{
+	//    #
+	// X A D
+	//    #
+	UnitFake & attacker = addCerberi(35, BattleSide::ATTACKER);
+	UnitFake & defender = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::RIGHT), BattleSide::DEFENDER);
+	UnitFake & right = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::DEFENDER);
+	UnitFake & left = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::TOP_RIGHT), BattleSide::DEFENDER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+
+	EXPECT_TRUE(vstd::contains(attacked, &defender));
+	EXPECT_TRUE(vstd::contains(attacked, &right));
+	EXPECT_TRUE(vstd::contains(attacked, &left));
+}
+
+TEST_F(AttackableHexesTest, CerberiAttackerTopRight)
+{
+	//  # D
+	// X A #
+	//
+	UnitFake & attacker = addCerberi(35, BattleSide::ATTACKER);
+	UnitFake & defender = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::TOP_RIGHT), BattleSide::DEFENDER);
+	UnitFake & right = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::RIGHT), BattleSide::DEFENDER);
+	UnitFake & left = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::TOP_LEFT), BattleSide::DEFENDER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+
+	EXPECT_TRUE(vstd::contains(attacked, &right));
+	EXPECT_TRUE(vstd::contains(attacked, &left));
+}
+
+TEST_F(AttackableHexesTest, CerberiAttackerTopMiddle)
+{
+	// # D #
+	//  X A
+	//
+	UnitFake & attacker = addCerberi(35, BattleSide::ATTACKER);
+	UnitFake & defender = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::TOP_LEFT), BattleSide::DEFENDER);
+	UnitFake & right = addRegularMelee(attacker.getPosition().cloneInDirection(BattleHex::TOP_RIGHT), BattleSide::DEFENDER);
+	UnitFake & left = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::TOP_LEFT), BattleSide::DEFENDER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+
+	EXPECT_TRUE(vstd::contains(attacked, &right));
+	EXPECT_TRUE(vstd::contains(attacked, &left));
+}
+
+TEST_F(AttackableHexesTest, CerberiAttackerTopLeft)
+{
+	//  D #
+	// # X A
+	//
+	UnitFake & attacker = addCerberi(40, BattleSide::ATTACKER);
+	UnitFake & defender = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::TOP_LEFT), BattleSide::DEFENDER);
+	UnitFake & right = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::TOP_RIGHT), BattleSide::DEFENDER);
+	UnitFake & left = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::LEFT), BattleSide::DEFENDER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+
+	EXPECT_TRUE(vstd::contains(attacked, &right));
+	EXPECT_TRUE(vstd::contains(attacked, &left));
+}
+
+TEST_F(AttackableHexesTest, CerberiAttackerLeft)
+{
+	//  #
+	// D X A
+	//  #
+	UnitFake & attacker = addCerberi(40, BattleSide::ATTACKER);
+	UnitFake & defender = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::LEFT), BattleSide::DEFENDER);
+	UnitFake & right = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::TOP_LEFT), BattleSide::DEFENDER);
+	UnitFake & left = addRegularMelee(attacker.occupiedHex().cloneInDirection(BattleHex::BOTTOM_LEFT), BattleSide::DEFENDER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+
+	EXPECT_TRUE(vstd::contains(attacked, &right));
+	EXPECT_TRUE(vstd::contains(attacked, &left));
+}
+
+//// DRAGON BREATH AS ATTACKER
+
 TEST_F(AttackableHexesTest, DragonRightRegular_RightHorithontalBreath)
 TEST_F(AttackableHexesTest, DragonRightRegular_RightHorithontalBreath)
 {
 {
 	// X A D #
 	// X A D #
@@ -261,6 +356,7 @@ TEST_F(AttackableHexesTest, DragonRightRegular_RightHorithontalBreath)
 
 
 	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
 	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
 
 
+	EXPECT_TRUE(vstd::contains(attacked, &defender));
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 }
 }
 
 
@@ -282,26 +378,26 @@ TEST_F(AttackableHexesTest, DragonDragonVerticalDownHead_VerticalDownBreathFromH
 {
 {
 	// X A
 	// X A
 	//  D X		target D
 	//  D X		target D
-	//   #
+	//     #
 	UnitFake & attacker = addDragon(35, BattleSide::ATTACKER);
 	UnitFake & attacker = addDragon(35, BattleSide::ATTACKER);
 	UnitFake & defender = addDragon(attacker.getPosition().cloneInDirection(BattleHex::BOTTOM_LEFT), BattleSide::DEFENDER);
 	UnitFake & defender = addDragon(attacker.getPosition().cloneInDirection(BattleHex::BOTTOM_LEFT), BattleSide::DEFENDER);
-	UnitFake & next = addRegularMelee(defender.getPosition().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::DEFENDER);
+	UnitFake & next = addRegularMelee(defender.occupiedHex().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::DEFENDER);
 
 
 	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
 	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
 
 
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 }
 }
 
 
-TEST_F(AttackableHexesTest, DragonDragonVerticalDownHeadReverse_VerticalDownBreathFromHead)
+TEST_F(AttackableHexesTest, DragonDragonVerticalDownHead_VerticalRightBreathFromHead)
 {
 {
-	//  A X
-	// X D		target D
-	//  #
-	UnitFake & attacker = addDragon(36, BattleSide::DEFENDER);
-	UnitFake & defender = addDragon(attacker.getPosition().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::ATTACKER);
-	UnitFake & next = addRegularMelee(defender.getPosition().cloneInDirection(BattleHex::BOTTOM_LEFT), BattleSide::ATTACKER);
+	// X A
+	//  D X		target X
+	//     #
+	UnitFake & attacker = addDragon(35, BattleSide::ATTACKER);
+	UnitFake & defender = addDragon(attacker.getPosition().cloneInDirection(BattleHex::BOTTOM_LEFT), BattleSide::DEFENDER);
+	UnitFake & next = addRegularMelee(defender.occupiedHex().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::DEFENDER);
 
 
-	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+	auto attacked = getAttackedUnits(attacker, defender, defender.occupiedHex());
 
 
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 }
 }
@@ -334,6 +430,36 @@ TEST_F(AttackableHexesTest, DragonDragonHeadBottomRight_BottomRightBreathFromHea
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 }
 }
 
 
+TEST_F(AttackableHexesTest, DragonLeftBottomDragonBackToBack_LeftBottomBreathFromBackHex)
+{
+	//    X A
+	// D X		target X
+	//  #
+	UnitFake & attacker = addDragon(8, BattleSide::ATTACKER);
+	UnitFake & defender = addDragon(attacker.occupiedHex().cloneInDirection(BattleHex::BOTTOM_LEFT).cloneInDirection(BattleHex::LEFT), BattleSide::DEFENDER);
+	UnitFake & next = addRegularMelee(defender.getPosition().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::DEFENDER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.occupiedHex());
+
+	EXPECT_TRUE(vstd::contains(attacked, &next));
+}
+
+//// DRAGON BREATH AS DEFENDER
+
+TEST_F(AttackableHexesTest, DragonDragonVerticalDownHeadReverse_VerticalDownBreathFromHead)
+{
+	//   A X
+	//  X D		target D
+	// #
+	UnitFake & attacker = addDragon(36, BattleSide::DEFENDER);
+	UnitFake & defender = addDragon(attacker.getPosition().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::ATTACKER);
+	UnitFake & next = addRegularMelee(defender.occupiedHex().cloneInDirection(BattleHex::BOTTOM_LEFT), BattleSide::ATTACKER);
+
+	auto attacked = getAttackedUnits(attacker, defender, defender.getPosition());
+
+	EXPECT_TRUE(vstd::contains(attacked, &next));
+}
+
 TEST_F(AttackableHexesTest, DragonVerticalDownDragonBackReverse_VerticalDownBreath)
 TEST_F(AttackableHexesTest, DragonVerticalDownDragonBackReverse_VerticalDownBreath)
 {
 {
 	// A X
 	// A X
@@ -361,28 +487,14 @@ TEST_F(AttackableHexesTest, DragonRightBottomDragonHeadReverse_RightBottomBreath
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 	EXPECT_TRUE(vstd::contains(attacked, &next));
 }
 }
 
 
-TEST_F(AttackableHexesTest, DragonLeftBottomDragonBackToBack_LeftBottomBreathFromBackHex)
-{
-	//    X A
-	// D X		target X
-	//  #
-	UnitFake & attacker = addDragon(8, BattleSide::ATTACKER);
-	UnitFake & defender = addDragon(attacker.occupiedHex().cloneInDirection(BattleHex::BOTTOM_LEFT).cloneInDirection(BattleHex::LEFT), BattleSide::DEFENDER);
-	UnitFake & next = addRegularMelee(defender.getPosition().cloneInDirection(BattleHex::BOTTOM_RIGHT), BattleSide::DEFENDER);
-
-	auto attacked = getAttackedUnits(attacker, defender, defender.occupiedHex());
-
-	EXPECT_TRUE(vstd::contains(attacked, &next));
-}
-
 TEST_F(AttackableHexesTest, DefenderPositionOverride_BreathCountsHypoteticDefenderPosition)
 TEST_F(AttackableHexesTest, DefenderPositionOverride_BreathCountsHypoteticDefenderPosition)
 {
 {
-	//  # N
-	// X D		target D
-	//  A X
+	// # N
+	//  X D		target D
+	//   A X
 	UnitFake & attacker = addDragon(35, BattleSide::DEFENDER);
 	UnitFake & attacker = addDragon(35, BattleSide::DEFENDER);
 	UnitFake & defender = addDragon(8, BattleSide::ATTACKER);
 	UnitFake & defender = addDragon(8, BattleSide::ATTACKER);
-	UnitFake & next = addDragon(2, BattleSide::ATTACKER);
+	UnitFake & next = addDragon(1, BattleSide::ATTACKER);
 
 
 	startBattle();
 	startBattle();
 	redirectUnitsToFake();
 	redirectUnitsToFake();