Browse Source

Implemented new unit actions panel in combat

Alternative actions submod from extras is now deprecated and will have
no effect.

As long as screen width allows, game will now display additional panel
with all possible unit actions.

Panel will also display spells that can be cast by unit, allowing small
version of unit spellbook (total limit of actions is 12, but some are
used for creature actions, so unit spells are limited to 7-9)
Ivan Savenko 5 months ago
parent
commit
1ac8080cbf

BIN
Mods/vcmi/Content/Sprites/battle/actionAttack.png


BIN
Mods/vcmi/Content/Sprites/battle/actionInfo.png


BIN
Mods/vcmi/Content/Sprites/battle/actionMove.png


BIN
Mods/vcmi/Content/Sprites/battle/actionReturn.png


BIN
Mods/vcmi/Content/Sprites/battle/actionShoot.png


BIN
Mods/vcmi/Content/Sprites/battle/queueWait.png


BIN
Mods/vcmi/Content/Sprites2x/battle/actionAttack.png


BIN
Mods/vcmi/Content/Sprites2x/battle/actionInfo.png


BIN
Mods/vcmi/Content/Sprites2x/battle/actionMove.png


BIN
Mods/vcmi/Content/Sprites2x/battle/actionReturn.png


BIN
Mods/vcmi/Content/Sprites2x/battle/actionShoot.png


BIN
Mods/vcmi/Content/Sprites2x/battle/queueWait.png


+ 6 - 0
Mods/vcmi/Content/config/english.json

@@ -32,6 +32,12 @@
 	"vcmi.adventureMap.dwelling3" : "{%s}\n\nWould you like to recruit %s, %s, or %s?",
 	
 	"vcmi.artifact.charges" : "Charges",
+	
+	"vcmi.battle.action.move" : "Move unit to specified location",
+	"vcmi.battle.action.info" : "Show unit information",
+	"vcmi.battle.action.shoot" : "Use ranged attack",
+	"vcmi.battle.action.attack" : "Use melee attack",
+	"vcmi.battle.action.return" : "Use melee attack and return",
 
 	"vcmi.bonusSource.artifact" : "Artifact",
 	"vcmi.bonusSource.creature" : "Ability",

+ 2 - 0
client/CMakeLists.txt

@@ -33,6 +33,7 @@ set(vcmiclientcommon_SRCS
 	battle/QuickSpellPanel.cpp
 	battle/StackInfoBasicPanel.cpp
 	battle/StackQueue.cpp
+	battle/UnitActionPanel.cpp
 
 	eventsSDL/NotificationHandler.cpp
 	eventsSDL/InputHandler.cpp
@@ -235,6 +236,7 @@ set(vcmiclientcommon_HEADERS
 	battle/QuickSpellPanel.h
 	battle/StackInfoBasicPanel.h
 	battle/StackQueue.h
+	battle/UnitActionPanel.h
 
 	eventsSDL/NotificationHandler.h
 	eventsSDL/InputHandler.h

+ 7 - 35
client/battle/BattleActionsController.cpp

@@ -148,7 +148,10 @@ void BattleActionsController::endCastingSpell()
 	}
 
 	if(owner.stacksController->getActiveStack())
+	{
 		possibleActions = getPossibleActionsForStack(owner.stacksController->getActiveStack()); //restore actions after they were cleared
+		owner.windowObject->setPossibleActions(possibleActions);
+	}
 
 	selectedStack = nullptr;
 	ENGINE->fakeMouseMove();
@@ -624,7 +627,7 @@ bool BattleActionsController::actionIsLegal(PossiblePlayerBattleAction action, c
 			return (targetStack && targetStackOwned && targetStack->getMovementRange() > 0);
 
 		case PossiblePlayerBattleAction::CREATURE_INFO:
-			return (targetStack && targetStackOwned && targetStack->alive());
+			return (targetStack && targetStack->alive());
 
 		case PossiblePlayerBattleAction::HERO_INFO:
 			if (targetHex == BattleHex::HERO_ATTACKER)
@@ -1037,33 +1040,7 @@ void BattleActionsController::activateStack()
 		tryActivateStackSpellcasting(s);
 
 		possibleActions = getPossibleActionsForStack(s);
-		std::list<PossiblePlayerBattleAction> actionsToSelect;
-		if(!possibleActions.empty())
-		{
-			auto primaryAction = possibleActions.front().get();
-
-			if(primaryAction == PossiblePlayerBattleAction::SHOOT || primaryAction == PossiblePlayerBattleAction::AIMED_SPELL_CREATURE
-				|| primaryAction == PossiblePlayerBattleAction::ANY_LOCATION || primaryAction == PossiblePlayerBattleAction::ATTACK_AND_RETURN)
-			{
-				actionsToSelect.push_back(possibleActions.front());
-
-				auto shootActionPredicate = [](const PossiblePlayerBattleAction& action)
-				{
-					return action.get() == PossiblePlayerBattleAction::SHOOT;
-				};
-				bool hasShootSecondaryAction = std::any_of(possibleActions.begin() + 1, possibleActions.end(), shootActionPredicate);
-
-				if(hasShootSecondaryAction) //casters may have shooting capabilities, for example storm elementals
-					actionsToSelect.emplace_back(PossiblePlayerBattleAction::SHOOT);
-
-				/* TODO: maybe it would also make sense to check spellcast as non-top priority action ("NO_SPELLCAST_BY_DEFAULT" bonus)?
-				 * it would require going beyond this "if" block for melee casters
-				 * F button helps, but some mod creatures may have that bonus and more than 1 castable spell */
-
-				actionsToSelect.emplace_back(PossiblePlayerBattleAction::ATTACK); //always allow melee attack as last option
-			}
-		}
-		owner.windowObject->setAlternativeActions(actionsToSelect);
+		owner.windowObject->setPossibleActions(possibleActions);
 	}
 }
 
@@ -1123,14 +1100,9 @@ const std::vector<PossiblePlayerBattleAction> & BattleActionsController::getPoss
 	return possibleActions;
 }
 
-void BattleActionsController::removePossibleAction(PossiblePlayerBattleAction action)
-{
-	vstd::erase(possibleActions, action);
-}
-
-void BattleActionsController::pushFrontPossibleAction(PossiblePlayerBattleAction action)
+void BattleActionsController::setPriorityActions(const std::vector<PossiblePlayerBattleAction> & actions)
 {
-	possibleActions.insert(possibleActions.begin(), action);
+	possibleActions = actions;
 }
 
 void BattleActionsController::resetCurrentStackPossibleActions()

+ 2 - 3
client/battle/BattleActionsController.h

@@ -120,10 +120,9 @@ public:
 
 	/// methods to work with array of possible actions, needed to control special creatures abilities
 	const std::vector<PossiblePlayerBattleAction> & getPossibleActions() const;
-	void removePossibleAction(PossiblePlayerBattleAction);
 	
-	/// inserts possible action in the beginning in order to prioritize it
-	void pushFrontPossibleAction(PossiblePlayerBattleAction);
+	/// sets list of high-priority actions that should be selected before any other actions
+	void setPriorityActions(const std::vector<PossiblePlayerBattleAction> &);
 
 	/// resets possible actions to original state
 	void resetCurrentStackPossibleActions();

+ 21 - 96
client/battle/BattleWindow.cpp

@@ -19,6 +19,7 @@
 #include "QuickSpellPanel.h"
 #include "StackInfoBasicPanel.h"
 #include "StackQueue.h"
+#include "UnitActionPanel.h"
 
 #include "../CPlayerInterface.h"
 #include "../GameEngine.h"
@@ -53,9 +54,8 @@
 #include "../../lib/mapObjects/CGHeroInstance.h"
 #include "../../lib/texts/CGeneralTextHandler.h"
 
-BattleWindow::BattleWindow(BattleInterface & Owner):
-	owner(Owner),
-	lastAlternativeAction(PossiblePlayerBattleAction::INVALID)
+BattleWindow::BattleWindow(BattleInterface & Owner)
+	: owner(Owner)
 {
 	OBJECT_CONSTRUCTION;
 	pos.w = 800;
@@ -98,7 +98,6 @@ BattleWindow::BattleWindow(BattleInterface & Owner):
 	addShortcut(EShortcut::BATTLE_CONSOLE_DOWN, std::bind(&BattleWindow::bConsoleDownf, this));
 	addShortcut(EShortcut::BATTLE_TACTICS_NEXT, std::bind(&BattleWindow::bTacticNextStack, this));
 	addShortcut(EShortcut::BATTLE_TACTICS_END, std::bind(&BattleWindow::bTacticPhaseEnd, this));
-	addShortcut(EShortcut::BATTLE_SELECT_ACTION, std::bind(&BattleWindow::bSwitchActionf, this));
 	addShortcut(EShortcut::BATTLE_OPEN_ACTIVE_UNIT, std::bind(&BattleWindow::bOpenActiveUnit, this));
 	addShortcut(EShortcut::BATTLE_OPEN_HOVERED_UNIT, std::bind(&BattleWindow::bOpenHoveredUnit, this));
 
@@ -194,6 +193,9 @@ void BattleWindow::createQuickSpellWindow()
 	quickSpellWindow = std::make_shared<QuickSpellPanel>(owner);
 	quickSpellWindow->moveTo(Point(pos.x - 67, pos.y));
 
+	unitActionWindow = std::make_shared<UnitActionPanel>(owner);
+	unitActionWindow->moveTo(Point(pos.x + pos.w + 15, pos.y));
+
 	if(settings["battle"]["enableQuickSpellPanel"].Bool())
 		showStickyQuickSpellWindow();
 	else
@@ -214,6 +216,7 @@ void BattleWindow::hideStickyQuickSpellWindow()
 	showStickyQuickSpellWindow->Bool() = false;
 
 	quickSpellWindow->disable();
+	unitActionWindow->disable();
 
 	setPositionInfoWindow();
 	createTimerInfoWindows();
@@ -227,10 +230,11 @@ void BattleWindow::showStickyQuickSpellWindow()
 
 	auto hero = owner.getBattle()->battleGetMyHero();
 
-	if(ENGINE->screenDimensions().x >= 1050 && hero != nullptr && hero->hasSpellbook())
-		quickSpellWindow->enable();
-	else
-		quickSpellWindow->disable();
+	bool quickSpellWindowVisible = ENGINE->screenDimensions().x >= 1050 && hero != nullptr && hero->hasSpellbook();
+	bool unitActionWindowVisible = ENGINE->screenDimensions().x >= 1050;
+
+	quickSpellWindow->setEnabled(quickSpellWindowVisible);
+	unitActionWindow->setEnabled(unitActionWindowVisible);
 
 	setPositionInfoWindow();
 	createTimerInfoWindows();
@@ -242,6 +246,7 @@ void BattleWindow::createTimerInfoWindows()
 	OBJECT_CONSTRUCTION;
 
 	int xOffsetAttacker = quickSpellWindow->isDisabled() ? 0 : -53;
+	int xOffsetDefender = unitActionWindow->isDisabled() ? 0 : 53;
 
 	if(GAME->interface()->cb->getStartInfo()->turnTimerInfo.battleTimer != 0 || GAME->interface()->cb->getStartInfo()->turnTimerInfo.unitTimer != 0)
 	{
@@ -259,7 +264,7 @@ void BattleWindow::createTimerInfoWindows()
 		if (defender.isValidPlayer())
 		{
 			if (ENGINE->screenDimensions().x >= 1000)
-				defenderTimerWidget = std::make_shared<TurnTimerWidget>(Point(pos.w + 16, 1), defender);
+				defenderTimerWidget = std::make_shared<TurnTimerWidget>(Point(pos.w + 16 + xOffsetDefender, 1), defender);
 			else
 				defenderTimerWidget = std::make_shared<TurnTimerWidget>(Point(pos.w - 78, 135), defender);
 		}
@@ -379,10 +384,12 @@ void BattleWindow::updateQueue()
 void BattleWindow::setPositionInfoWindow()
 {
 	int xOffsetAttacker = quickSpellWindow->isDisabled() ? 0 : -53;
+	int xOffsetDefender = unitActionWindow->isDisabled() ? 0 : 53;
+
 	if(defenderHeroWindow)
 	{
 		Point position = (ENGINE->screenDimensions().x >= 1000)
-				? Point(pos.x + pos.w + 15, pos.y + 60)
+				? Point(pos.x + pos.w + 15 + xOffsetDefender, pos.y + 60)
 				: Point(pos.x + pos.w -79, pos.y + 195);
 		defenderHeroWindow->moveTo(position);
 	}
@@ -396,7 +403,7 @@ void BattleWindow::setPositionInfoWindow()
 	if(defenderStackWindow)
 	{
 		Point position = (ENGINE->screenDimensions().x >= 1000)
-				? Point(pos.x + pos.w + 15, defenderHeroWindow ? defenderHeroWindow->pos.y + 210 : pos.y + 60)
+				? Point(pos.x + pos.w + 15 + xOffsetDefender, defenderHeroWindow ? defenderHeroWindow->pos.y + 210 : pos.y + 60)
 				: Point(pos.x + pos.w -79, defenderHeroWindow ? defenderHeroWindow->pos.y : pos.y + 195);
 		defenderStackWindow->moveTo(position);
 	}
@@ -502,12 +509,9 @@ void BattleWindow::tacticPhaseStarted()
 	auto menuTactics = widget<CIntObject>("menuTactics");
 	auto tacticNext = widget<CIntObject>("tacticNext");
 	auto tacticEnd = widget<CIntObject>("tacticEnd");
-	auto alternativeAction = widget<CIntObject>("alternativeAction");
 
 	menuBattle->disable();
 	console->disable();
-	if (alternativeAction)
-		alternativeAction->disable();
 
 	menuTactics->enable();
 	tacticNext->enable();
@@ -523,12 +527,9 @@ void BattleWindow::tacticPhaseEnded()
 	auto menuTactics = widget<CIntObject>("menuTactics");
 	auto tacticNext = widget<CIntObject>("tacticNext");
 	auto tacticEnd = widget<CIntObject>("tacticEnd");
-	auto alternativeAction = widget<CIntObject>("alternativeAction");
 
 	menuBattle->enable();
 	console->enable();
-	if (alternativeAction)
-		alternativeAction->enable();
 
 	menuTactics->disable();
 	tacticNext->disable();
@@ -615,63 +616,9 @@ void BattleWindow::reallySurrender()
 	}
 }
 
-void BattleWindow::showAlternativeActionIcon(PossiblePlayerBattleAction action)
+void BattleWindow::setPossibleActions(const std::vector<PossiblePlayerBattleAction> & actions)
 {
-	auto w = widget<CButton>("alternativeAction");
-	if(!w)
-		return;
-	
-	AnimationPath iconName = AnimationPath::fromJson(variables["actionIconDefault"]);
-	switch(action.get())
-	{
-		case PossiblePlayerBattleAction::ATTACK:
-			iconName = AnimationPath::fromJson(variables["actionIconAttack"]);
-			break;
-			
-		case PossiblePlayerBattleAction::SHOOT:
-			iconName = AnimationPath::fromJson(variables["actionIconShoot"]);
-			break;
-			
-		case PossiblePlayerBattleAction::AIMED_SPELL_CREATURE:
-			iconName = AnimationPath::fromJson(variables["actionIconSpell"]);
-			break;
-
-		case PossiblePlayerBattleAction::ANY_LOCATION:
-			iconName = AnimationPath::fromJson(variables["actionIconSpell"]);
-			break;
-			
-		//TODO: figure out purpose of this icon
-		//case PossiblePlayerBattleAction::???:
-			//iconName = variables["actionIconWalk"].String();
-			//break;
-			
-		case PossiblePlayerBattleAction::ATTACK_AND_RETURN:
-			iconName = AnimationPath::fromJson(variables["actionIconReturn"]);
-			break;
-			
-		case PossiblePlayerBattleAction::WALK_AND_ATTACK:
-			iconName = AnimationPath::fromJson(variables["actionIconNoReturn"]);
-			break;
-	}
-
-	w->setImage(iconName);
-	w->redraw();
-}
-
-void BattleWindow::setAlternativeActions(const std::list<PossiblePlayerBattleAction> & actions)
-{
-	assert(actions.size() != 1);
-
-	alternativeActions = actions;
-	lastAlternativeAction = PossiblePlayerBattleAction::INVALID;
-
-	if(alternativeActions.size() > 1)
-	{
-		lastAlternativeAction = alternativeActions.back();
-		showAlternativeActionIcon(alternativeActions.front());
-	}
-	else
-		showAlternativeActionIcon(PossiblePlayerBattleAction::INVALID);
+	unitActionWindow->setPossibleActions(actions);
 }
 
 void BattleWindow::bAutofightf()
@@ -761,28 +708,6 @@ void BattleWindow::bSpellf()
 	}
 }
 
-void BattleWindow::bSwitchActionf()
-{
-	if(alternativeActions.empty())
-		return;
-
-	auto actions = owner.actionsController->getPossibleActions();
-
-	if(!actions.empty() && actions.front() != lastAlternativeAction)
-	{
-		owner.actionsController->removePossibleAction(alternativeActions.front());
-		showAlternativeActionIcon(*std::next(alternativeActions.begin()));
-	}
-	else
-	{
-		owner.actionsController->resetCurrentStackPossibleActions();
-		showAlternativeActionIcon(owner.actionsController->getPossibleActions().front());
-	}
-	
-	alternativeActions.push_back(alternativeActions.front());
-	alternativeActions.pop_front();
-}
-
 void BattleWindow::bWaitf()
 {
 	if (owner.actionsController->heroSpellcastingModeActive())
@@ -850,7 +775,6 @@ void BattleWindow::blockUI(bool on)
 	setShortcutBlocked(EShortcut::BATTLE_CAST_SPELL, on || owner.tacticsMode || !canCastSpells);
 	setShortcutBlocked(EShortcut::BATTLE_WAIT, on || owner.tacticsMode || !canWait);
 	setShortcutBlocked(EShortcut::BATTLE_DEFEND, on || owner.tacticsMode);
-	setShortcutBlocked(EShortcut::BATTLE_SELECT_ACTION, on || owner.tacticsMode);
 	setShortcutBlocked(EShortcut::BATTLE_AUTOCOMBAT, (settings["battle"]["endWithAutocombat"].Bool() && onlyOnePlayerHuman) ? on || owner.tacticsMode || owner.actionsController->heroSpellcastingModeActive() : owner.actionsController->heroSpellcastingModeActive());
 	setShortcutBlocked(EShortcut::BATTLE_END_WITH_AUTOCOMBAT, on || !onlyOnePlayerHuman || owner.actionsController->heroSpellcastingModeActive());
 	setShortcutBlocked(EShortcut::BATTLE_TACTICS_END, on || !owner.tacticsMode);
@@ -859,6 +783,7 @@ void BattleWindow::blockUI(bool on)
 	setShortcutBlocked(EShortcut::BATTLE_CONSOLE_UP, on && !owner.tacticsMode);
 
 	quickSpellWindow->setInputEnabled(!on);
+	unitActionWindow->setInputEnabled(!on);
 }
 
 void BattleWindow::bOpenActiveUnit()

+ 4 - 8
client/battle/BattleWindow.h

@@ -28,6 +28,7 @@ class TurnTimerWidget;
 class HeroInfoBasicPanel;
 class StackInfoBasicPanel;
 class QuickSpellPanel;
+class UnitActionPanel;
 
 /// GUI object that handles functionality of panel at the bottom of combat screen
 class BattleWindow : public InterfaceObjectConfigurable
@@ -42,6 +43,7 @@ class BattleWindow : public InterfaceObjectConfigurable
 	std::shared_ptr<StackInfoBasicPanel> defenderStackWindow;
 
 	std::shared_ptr<QuickSpellPanel> quickSpellWindow;
+	std::shared_ptr<UnitActionPanel> unitActionWindow;
 
 	std::shared_ptr<TurnTimerWidget> attackerTimerWidget;
 	std::shared_ptr<TurnTimerWidget> defenderTimerWidget;
@@ -53,7 +55,6 @@ class BattleWindow : public InterfaceObjectConfigurable
 	void bAutofightf();
 	void bSpellf();
 	void bWaitf();
-	void bSwitchActionf();
 	void bDefencef();
 	void bConsoleUpf();
 	void bConsoleDownf();
@@ -66,11 +67,6 @@ class BattleWindow : public InterfaceObjectConfigurable
 	void reallyFlee();
 	void reallySurrender();
 	
-	/// management of alternative actions
-	std::list<PossiblePlayerBattleAction> alternativeActions;
-	PossiblePlayerBattleAction lastAlternativeAction;
-	void showAlternativeActionIcon(PossiblePlayerBattleAction);
-
 	void useSpellIfPossible(int slot);
 
 	/// flip battle queue visibility to opposite
@@ -140,8 +136,8 @@ public:
 	/// Toggle UI to displaying battle log in place of tactics UI
 	void tacticPhaseEnded();
 
-	/// Set possible alternative options. If more than 1 - the last will be considered as default option
-	void setAlternativeActions(const std::list<PossiblePlayerBattleAction> &);
+	/// Set possible alternative options to fill unit actions panel
+	void setPossibleActions(const std::vector<PossiblePlayerBattleAction> & allActions);
 
 	/// ends battle with autocombat
 	void endWithAutocombat();

+ 152 - 0
client/battle/UnitActionPanel.cpp

@@ -0,0 +1,152 @@
+/*
+ * UnitActionPanel.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+#include "StdInc.h"
+#include "UnitActionPanel.h"
+
+#include "BattleInterface.h"
+#include "BattleActionsController.h"
+
+#include "../GameEngine.h"
+#include "../eventsSDL/InputHandler.h"
+#include "../gui/WindowHandler.h"
+#include "../widgets/Buttons.h"
+#include "../widgets/GraphicalPrimitiveCanvas.h"
+#include "../widgets/Images.h"
+#include "../widgets/TextControls.h"
+#include "../windows/CSpellWindow.h"
+
+#include "../../lib/CConfigHandler.h"
+#include "../../lib/GameLibrary.h"
+#include "../../lib/battle/CPlayerBattleCallback.h"
+#include "../../lib/json/JsonUtils.h"
+#include "../../lib/mapObjects/CGHeroInstance.h"
+#include "../../lib/spells/CSpellHandler.h"
+
+UnitActionPanel::UnitActionPanel(BattleInterface & owner)
+	: CIntObject(0)
+	, owner(owner)
+{
+	OBJECT_CONSTRUCTION;
+
+	addUsedEvents(LCLICK | SHOW_POPUP | MOVE | INPUT_MODE_CHANGE);
+
+	pos = Rect(0, 0, 52, 600);
+	background = std::make_shared<CFilledTexture>(ImagePath::builtin("DIBOXBCK"), pos);
+	rect = std::make_shared<TransparentFilledRectangle>(Rect(0, 0, pos.w + 1, pos.h + 1), ColorRGBA(0, 0, 0, 0), ColorRGBA(241, 216, 120, 255));
+}
+
+void UnitActionPanel::restoreAllActions()
+{
+	owner.actionsController->resetCurrentStackPossibleActions();
+}
+
+void UnitActionPanel::setActions(int buttonIndex, const std::vector<PossiblePlayerBattleAction> & filteredActions)
+{
+	for (const auto & button : buttons)
+		if (button != buttons.at(buttonIndex))
+			button->setSelectedSilent(false);
+
+	owner.actionsController->setPriorityActions(filteredActions);
+	if (filteredActions.front().spellcast())
+		owner.actionsController->enterCreatureCastingMode();
+	owner.actionsController->setPriorityActions(filteredActions);
+}
+
+void UnitActionPanel::testAndAddAction(const std::vector<PossiblePlayerBattleAction> & allActions, const std::vector<PossiblePlayerBattleAction::Actions> & actionFilter, const ImagePath & iconPath, const std::string & descriptionTextID)
+{
+	std::vector<PossiblePlayerBattleAction> filteredActions;
+
+	for (const auto & action : allActions)
+		if (vstd::contains(actionFilter, action.get()))
+			filteredActions.push_back(action);
+
+	if (filteredActions.empty())
+		return;
+
+	int index = buttons.size();
+
+	const auto & callback = [this, filteredActions, index](bool isSelected){ if (isSelected) setActions(index, filteredActions); else restoreAllActions(); };
+
+	MetaString tooltip;
+	tooltip.appendTextID(descriptionTextID);
+
+	auto button = std::make_shared<CToggleButton>(Point(2, 7 + 50 * index), AnimationPath::builtin("battleUnitAction"), CButton::tooltip(tooltip.toString()), callback);
+	button->setOverlay(std::make_shared<CPicture>(iconPath));
+	button->setHighlightedBorderColor(Colors::WHITE);
+	button->setAllowDeselection(true);
+	buttons.push_back(button);
+}
+
+void UnitActionPanel::testAndAddSpell(const std::vector<PossiblePlayerBattleAction> & allActions, const SpellID & spellFilter)
+{
+	std::vector<PossiblePlayerBattleAction> filteredActions;
+
+	for (const auto & action : allActions)
+		if (action.spellcast() && action.spell() == spellFilter)
+			filteredActions.push_back(action);
+
+	if (filteredActions.empty())
+		return;
+
+	int index = buttons.size();
+	const auto & callback = [this, filteredActions, index](bool isSelected){ if (isSelected) setActions(index, filteredActions); else restoreAllActions();};
+
+	MetaString tooltip;
+	tooltip.appendTextID("core.genrltxt.26");
+	tooltip.replaceName(spellFilter);
+
+	std::string hoverText = tooltip.toString();
+	std::string description = spellFilter.toSpell()->getDescriptionTranslated(0);
+
+
+	auto button = std::make_shared<CToggleButton>(Point(2, 7 + 50 * index), AnimationPath::builtin("battleUnitAction"), CButton::tooltip(hoverText, description), callback);
+	button->setOverlay(std::make_shared<CAnimImage>(AnimationPath::builtin("spellint"), spellFilter.getNum() + 1));
+	button->setHighlightedBorderColor(Colors::WHITE);
+	buttons.push_back(button);
+}
+
+void UnitActionPanel::setPossibleActions(const std::vector<PossiblePlayerBattleAction> & newActions)
+{
+	OBJECT_CONSTRUCTION;
+
+	buttons.clear();
+
+	static const std::vector actionsMove = { PossiblePlayerBattleAction::MOVE_STACK };
+	static const std::vector actionsInfo = { PossiblePlayerBattleAction::CREATURE_INFO, PossiblePlayerBattleAction::HERO_INFO };
+	static const std::vector actionsShoot = { PossiblePlayerBattleAction::SHOOT };
+	static const std::vector actionsAttack = { PossiblePlayerBattleAction::ATTACK, PossiblePlayerBattleAction::WALK_AND_ATTACK };
+	static const std::vector actionsReturn = { PossiblePlayerBattleAction::ATTACK_AND_RETURN };
+
+	testAndAddAction(newActions, actionsMove, ImagePath::builtin("battle/actionMove"), "vcmi.battle.action.move");
+	testAndAddAction(newActions, actionsReturn, ImagePath::builtin("battle/actionReturn"), "vcmi.battle.action.return");
+	testAndAddAction(newActions, actionsAttack, ImagePath::builtin("battle/actionAttack"), "vcmi.battle.action.attack");
+	testAndAddAction(newActions, actionsShoot, ImagePath::builtin("battle/actionShoot"), "vcmi.battle.action.shoot");
+
+	std::vector<SpellID> spells;
+
+	for (const auto & action : newActions)
+		if (action.spellcast())
+			spells.push_back(action.spell());
+
+
+	for (const auto & spell : spells)
+		testAndAddSpell(newActions, spell);
+
+	// Not really a unit action, so place it at the end
+	testAndAddAction(newActions, actionsInfo, ImagePath::builtin("battle/actionInfo"), "vcmi.battle.action.info");
+
+	redraw();
+}
+
+void UnitActionPanel::show(Canvas & to)
+{
+	showAll(to);
+	CIntObject::show(to);
+}

+ 46 - 0
client/battle/UnitActionPanel.h

@@ -0,0 +1,46 @@
+/*
+ * UnitActionPanel.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+#pragma once
+
+#include "../gui/CIntObject.h"
+#include "../../lib/battle/PossiblePlayerBattleAction.h"
+#include "../../lib/filesystem/ResourcePath.h"
+
+class CFilledTexture;
+class TransparentFilledRectangle;
+class CToggleButton;
+class CLabel;
+class BattleInterface;
+
+class UnitActionPanel : public CIntObject
+{
+private:
+	std::shared_ptr<CFilledTexture> background;
+	std::shared_ptr<TransparentFilledRectangle> rect;
+	std::vector<std::shared_ptr<CToggleButton>> buttons;
+
+	BattleInterface & owner;
+
+	void testAndAddAction(const std::vector<PossiblePlayerBattleAction> & allActions, const std::vector<PossiblePlayerBattleAction::Actions> & actionFilter, const ImagePath & iconPath, const std::string & descriptionTextID );
+	void testAndAddSpell(const std::vector<PossiblePlayerBattleAction> & allActions, const SpellID & spellFilter );
+
+	void restoreAllActions();
+	void setActions(int buttonIndex, const std::vector<PossiblePlayerBattleAction> & newActions);
+public:
+	static constexpr int ACTION_SLOTS = 12;
+
+	UnitActionPanel(BattleInterface & owner);
+
+	void setPossibleActions(const std::vector<PossiblePlayerBattleAction> & actions);
+
+	std::vector<std::tuple<SpellID, bool>> getSpells() const;
+
+	void show(Canvas & to) override;
+};

+ 0 - 1
client/gui/Shortcut.h

@@ -192,7 +192,6 @@ enum class EShortcut
 	BATTLE_CONSOLE_DOWN,
 	BATTLE_TACTICS_NEXT,
 	BATTLE_TACTICS_END,
-	BATTLE_SELECT_ACTION, // Alternative actions toggle
 	BATTLE_TOGGLE_HEROES_STATS,
 	BATTLE_OPEN_ACTIVE_UNIT,
 	BATTLE_OPEN_HOVERED_UNIT,

+ 0 - 1
client/gui/ShortcutHandler.cpp

@@ -229,7 +229,6 @@ EShortcut ShortcutHandler::findShortcut(const std::string & identifier ) const
 		{"battleConsoleDown",        EShortcut::BATTLE_CONSOLE_DOWN       },
 		{"battleTacticsNext",        EShortcut::BATTLE_TACTICS_NEXT       },
 		{"battleTacticsEnd",         EShortcut::BATTLE_TACTICS_END        },
-		{"battleSelectAction",       EShortcut::BATTLE_SELECT_ACTION      },
 		{"battleToggleQuickSpell",   EShortcut::BATTLE_TOGGLE_QUICKSPELL   },
 		{"battleSpellShortcut0",     EShortcut::BATTLE_SPELL_SHORTCUT_0   },
 		{"battleSpellShortcut1",     EShortcut::BATTLE_SPELL_SHORTCUT_1   },

+ 11 - 0
client/widgets/Buttons.cpp

@@ -62,6 +62,11 @@ void CButton::setBorderColor(std::optional<ColorRGBA> newBorderColor)
 	borderColor = newBorderColor;
 }
 
+void CButton::setHighlightedBorderColor(std::optional<ColorRGBA> newBorderColor)
+{
+	highlightedBorderColor = newBorderColor;
+}
+
 void CButton::addCallback(const std::function<void()> & callback)
 {
 	this->callback += callback;
@@ -378,6 +383,12 @@ void CButton::showAll(Canvas & to)
 
 	if (borderColor)
 		to.drawBorder(Rect::createAround(pos, 1), *borderColor);
+
+	if (highlightedBorderColor && isHighlighted())
+	{
+		to.drawBorder(pos, *highlightedBorderColor);
+		to.drawBorder(Rect(pos.topLeft() + Point(1,1), pos.dimensions() - Point(1,1)), *highlightedBorderColor);
+	}
 }
 
 std::pair<std::string, std::string> CButton::tooltip()

+ 2 - 0
client/widgets/Buttons.h

@@ -73,6 +73,7 @@ class CButton : public ButtonBase
 
 	std::array<std::string, 4> hoverTexts; //texts for statusbar, if empty - first entry will be used
 	std::optional<ColorRGBA> borderColor; // mapping of button state to border color
+	std::optional<ColorRGBA> highlightedBorderColor; // mapping of button state to border color
 	std::string helpBox; //for right-click help
 
 	bool actOnDown; //runs when mouse is pressed down over it, not when up
@@ -88,6 +89,7 @@ protected:
 public:
 	// sets the same border color for all button states.
 	void setBorderColor(std::optional<ColorRGBA> borderColor);
+	void setHighlightedBorderColor(std::optional<ColorRGBA> borderColor);
 
 	/// adds one more callback to on-click actions
 	void addCallback(const std::function<void()> & callback);

+ 0 - 1
config/shortcutsConfig.json

@@ -67,7 +67,6 @@
 		"battleOpenActiveUnit":     "I",
 		"battleOpenHoveredUnit":    "V",
 		"battleRetreat":            "R",
-		"battleSelectAction":       "S",
 		"battleToggleQuickSpell":   "T",
 		"battleSpellShortcut0":     "1",
 		"battleSpellShortcut1":     "2",

+ 114 - 0
config/widgets/buttons/battleUnitAction.json

@@ -0,0 +1,114 @@
+{
+	"normal" : {
+		"width": 48,
+		"height": 36,
+		"items" : [
+			{
+				"type": "texture",
+				"image": "DiBoxBck",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36}
+			},
+			{
+				"type": "graphicalPrimitive",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36},
+				"primitives" : [
+					{ "type" : "filledBox", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : -3}, "color" : [ 0, 0, 0, 80 ] },
+				
+					{ "type" : "line", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : 0, "y" : -1}, "color" : [ 255, 255, 255, 64 ] },
+					{ "type" : "line", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : -1, "y" : 0}, "color" : [ 255, 255, 255, 128 ] },
+
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : 1, "y" : -2}, "color" : [ 255, 255, 255, 80 ] },
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : -2, "y" : 1}, "color" : [ 255, 255, 255, 160 ] },
+
+					{ "type" : "line", "a" : { "x" : 1, "y" : -2}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 192 ] },
+					{ "type" : "line", "a" : { "x" : -2, "y" : 1}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 192 ] },
+
+					{ "type" : "line", "a" : { "x" : 0, "y" : -1}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
+					{ "type" : "line", "a" : { "x" : -1, "y" : 0}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
+				]
+			}
+		]
+	},
+	"pressed" : {
+		"width": 48,
+		"height": 36,
+		"items" : [
+			{
+				"type": "texture",
+				"image": "DiBoxBck",
+				"rect": {"x": 1, "y": 1, "w": 47, "h": 35}
+			},
+			{
+				"type": "graphicalPrimitive",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36},
+				"primitives" : [
+					{ "type" : "filledBox", "a" : { "x" : 3, "y" : 3}, "b" : { "x" : -3, "y" : -3}, "color" : [ 0, 0, 0, 96 ] },
+				
+					{ "type" : "rectangle", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
+					
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : 1, "y" : -2}, "color" : [ 255, 255, 255, 48 ] },
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : -2, "y" : 1}, "color" : [ 255, 255, 255, 96 ] },
+
+					{ "type" : "line", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : 2, "y" : -3}, "color" : [ 255, 255, 255, 64 ] },
+					{ "type" : "line", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : 2}, "color" : [ 255, 255, 255, 128 ] },
+				]
+			}
+		]
+	},
+	"blocked" : {
+		"width": 48,
+		"height": 36,
+		"items" : [
+			{
+				"type": "texture",
+				"image": "DiBoxBck",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36}
+			},
+			{
+				"type": "graphicalPrimitive",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36},
+				"primitives" : [
+					{ "type" : "filledBox", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : -3}, "color" : [ 0, 0, 0, 80 ] },
+				
+					{ "type" : "line", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : 0, "y" : -1}, "color" : [ 255, 255, 255, 64 ] },
+					{ "type" : "line", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : -1, "y" : 0}, "color" : [ 255, 255, 255, 128 ] },
+
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : 1, "y" : -2}, "color" : [ 255, 255, 255, 80 ] },
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : -2, "y" : 1}, "color" : [ 255, 255, 255, 160 ] },
+
+					{ "type" : "line", "a" : { "x" : 1, "y" : -2}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 192 ] },
+					{ "type" : "line", "a" : { "x" : -2, "y" : 1}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 192 ] },
+					
+					{ "type" : "line", "a" : { "x" : 0, "y" : -1}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
+					{ "type" : "line", "a" : { "x" : -1, "y" : 0}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
+				]
+			}
+		]
+	},
+	"highlighted" : {
+		"width": 48,
+		"height": 36,
+		"items" : [
+			{
+				"type": "texture",
+				"image": "DiBoxBck",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36}
+			},
+			{
+				"type": "graphicalPrimitive",
+				"rect": {"x": 0, "y": 0, "w": 48, "h": 36},
+				"primitives" : [
+					{ "type" : "filledBox", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : -3}, "color" : [ 0, 0, 0, 80 ] },
+				
+					{ "type" : "rectangle", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : -3}, "color" : [ 255, 255, 255, 255 ] },
+					
+					{ "type" : "line", "a" : { "x" : 1, "y" : -2}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 255 ] },
+					{ "type" : "line", "a" : { "x" : -2, "y" : 1}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 255 ] },
+					
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : 1, "y" : -2}, "color" : [ 255, 255, 255, 160 ] },
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : -2, "y" : 1}, "color" : [ 255, 255, 255, 160 ] },
+				]
+			}
+		]
+	},
+}

+ 10 - 6
config/widgets/buttons/heroBackpack.json

@@ -100,13 +100,17 @@
 				"primitives" : [
 					{ "type" : "filledBox", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : -3}, "color" : [ 0, 0, 0, 80 ] },
 				
-					{ "type" : "rectangle", "a" : { "x" : 2, "y" : 2}, "b" : { "x" : -3, "y" : -3}, "color" : [ 255, 255, 255, 255 ] },
-					
-					{ "type" : "line", "a" : { "x" : 1, "y" : -2}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 255 ] },
-					{ "type" : "line", "a" : { "x" : -2, "y" : 1}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 255 ] },
-					
-					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : 1, "y" : -2}, "color" : [ 255, 255, 255, 160 ] },
+					{ "type" : "line", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : 0, "y" : -1}, "color" : [ 255, 255, 255, 64 ] },
+					{ "type" : "line", "a" : { "x" : 0, "y" : 0}, "b" : { "x" : -1, "y" : 0}, "color" : [ 255, 255, 255, 128 ] },
+
+					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : 1, "y" : -2}, "color" : [ 255, 255, 255, 80 ] },
 					{ "type" : "line", "a" : { "x" : 1, "y" : 1}, "b" : { "x" : -2, "y" : 1}, "color" : [ 255, 255, 255, 160 ] },
+
+					{ "type" : "line", "a" : { "x" : 1, "y" : -2}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 192 ] },
+					{ "type" : "line", "a" : { "x" : -2, "y" : 1}, "b" : { "x" : -2, "y" : -2}, "color" : [ 0, 0, 0, 192 ] },
+
+					{ "type" : "line", "a" : { "x" : 0, "y" : -1}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
+					{ "type" : "line", "a" : { "x" : -1, "y" : 0}, "b" : { "x" : -1, "y" : -1}, "color" : [ 0, 0, 0, 255 ] },
 				]
 			}
 		]

+ 1 - 1
lib/battle/CBattleInfoCallback.cpp

@@ -1901,7 +1901,7 @@ SpellID CBattleInfoCallback::getRandomCastedSpell(vstd::RNG & rand,const CStack
 	if (!bl->size())
 		return SpellID::NONE;
 
-	if(bl->size() == 1)
+	if(bl->size() == 1 && bl->front()->additionalInfo[0] > 0) // there is one random spell -> select it
 		return bl->front()->subtype.as<SpellID>();
 
 	int totalWeight = 0;