From d734f02678272a521a1a728fe8cbd892f27c5eea Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:39:14 +0300 Subject: [PATCH 1/2] feat(client): Add CastMode option for quick casting targeted commands Adds an Options.ini client preference for how targeted commands (guard, attack move, abilities) are triggered from the keyboard: CastMode = Normal ; click button, then click world (retail, default) CastMode = QuickCast ; hotkey fires immediately at the cursor CastMode = QuickCastWithIndicator ; hold to aim with the targeting decal, release to fire Quick cast synthesizes the same arm-and-click message flow a manual cast produces, so the engine's validation, voice responses and cleanup all apply and the logic side sees exactly what a mouse click would send - no determinism risk. Commands that must not fire blind decline into the normal two step flow: multi-click and single-use commands, rally points, beacons, construction placement, and superweapons. A cast requested while the ability is still recharging is queued and fires the moment the logic side reports ready, pinned to the world position it was aimed at; right click cancels it, and it expires rather than firing minutes later. A button disabled only by WIN_STATUS_NOT_READY lets the press through so a still-firing weapon can be retargeted without a Stop, matching the engine's own DragonTank firewall expectations. Zero Hour only, per the contribution guidelines: the indicator and queue live in Zero Hour's InGameUI, and the shared translator and command bar hooks are guarded with RTS_ZEROHOUR. Both titles compile. Adapted from the Contra fork: the indicator resolves the decal from the command's own radius cursor type (the fork's custom decal radius plumbing is not ported). --- .../Include/Common/OptionPreferences.h | 14 ++ Core/GameEngine/Include/GameClient/HotKey.h | 13 ++ .../Source/Common/OptionPreferences.cpp | 21 +++ .../ControlBarCommandProcessing.cpp | 135 +++++++++++++++ .../GameClient/MessageStream/HotKey.cpp | 65 +++++++- .../MessageStream/SelectionXlat.cpp | 13 ++ .../GameEngine/Include/Common/GlobalData.h | 3 + .../GameEngine/Include/GameClient/InGameUI.h | 19 +++ .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameEngine/Source/GameClient/InGameUI.cpp | 154 +++++++++++++++++- 10 files changed, 434 insertions(+), 5 deletions(-) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..73e57e57e77 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -38,6 +38,19 @@ typedef UnsignedInt CursorCaptureMode; typedef UnsignedInt ScreenEdgeScrollMode; +// TheSuperHackers @feature How targeted commands (guard, attack move, abilities) are triggered. +// Only affects hotkey activation -- a mouse click on a cameo leaves the cursor over the control +// bar, where there is no world position to cast at, so it always uses the normal two step flow. +enum CastMode CPP_11(: Int) +{ + CastMode_Normal = 0, ///< click the button, then click the world (retail behavior) + CastMode_QuickCast, ///< hotkey fires immediately at the cursor + CastMode_QuickCastWithIndicator, ///< as above, but flash the targeting decal where it fired + + CastMode_Count, + CastMode_Default = CastMode_Normal +}; + //----------------------------------------------------------------------------- // OptionsPreferences options menu class //----------------------------------------------------------------------------- @@ -76,6 +89,7 @@ class OptionPreferences : public UserPreferences Real getScrollFactor(); Bool getDrawScrollAnchor(); Bool getMoveScrollAnchor(); + CastMode getCastMode() const; Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Include/GameClient/HotKey.h b/Core/GameEngine/Include/GameClient/HotKey.h index 70206f24cf6..698514220aa 100644 --- a/Core/GameEngine/Include/GameClient/HotKey.h +++ b/Core/GameEngine/Include/GameClient/HotKey.h @@ -98,6 +98,19 @@ class HotKeyManager : public SubsystemInterface AsciiString searchHotKey( const AsciiString& label); AsciiString searchHotKey( const UnicodeString& uStr ); + // TheSuperHackers @feature True while a button press is being synthesized from a keyboard + // hotkey rather than an actual mouse click. Quick cast needs to tell the two apart. + static void setExecutingHotKey( Bool executing ) { s_executingHotKey = executing; } + static Bool isExecutingHotKey( void ) { return s_executingHotKey; } + + // TheSuperHackers @feature True on the key down half of a hold to aim quick cast, when the + // command should arm and show its decal rather than fire. + static void setQuickCastAiming( Bool aiming ) { s_quickCastAiming = aiming; } + static Bool isQuickCastAiming( void ) { return s_quickCastAiming; } + + static Bool s_executingHotKey; + static Bool s_quickCastAiming; + private: typedef std::map HotKeyMap; HotKeyMap m_hotKeyMap; diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index e681ef8b192..96d9094ba36 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,27 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Options.ini: CastMode = Normal | QuickCast | QuickCastWithIndicator +CastMode OptionPreferences::getCastMode() const +{ + OptionPreferences::const_iterator it = find("CastMode"); + if (it == end()) + return CastMode_Default; + + if (stricmp(it->second.str(), "QuickCastWithIndicator") == 0) + return CastMode_QuickCastWithIndicator; + if (stricmp(it->second.str(), "QuickCast") == 0) + return CastMode_QuickCast; + if (stricmp(it->second.str(), "Normal") == 0) + return CastMode_Normal; + + Int mode = atoi(it->second.str()); + if (mode >= 0 && mode < CastMode_Count) + return (CastMode)mode; + + return CastMode_Default; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp index 5e2c41a3d48..e885c3de957 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp @@ -51,6 +51,15 @@ #include "GameClient/GameWindowManager.h" #include "GameClient/InGameUI.h" #include "GameClient/AnimateWindowManager.h" +// TheSuperHackers @feature for quick cast +#if RTS_ZEROHOUR +#include "Common/OptionPreferences.h" +#include "Common/Recorder.h" +#include "GameClient/HotKey.h" +#include "GameClient/Mouse.h" +#include "GameClient/View.h" +#include "GameLogic/Module/SpecialPowerModule.h" +#endif #include "GameLogic/GameLogic.h" #include "GameLogic/Object.h" @@ -117,6 +126,123 @@ CBCommandStatus ControlBar::processCommandTransitionUI( GameWindow *control, Gad /** Process a button selected message from the window system that should be for one of * our GUI commands */ //------------------------------------------------------------------------------------------------- +#if RTS_ZEROHOUR +// TheSuperHackers @feature Quick cast (Options.ini: CastMode). +/** + * Fire a targeted command at the cursor instead of waiting for a second click. + * + * Returns TRUE only if the command was actually dispatched. Every rejection path returns + * FALSE so the caller arms the command normally -- an input is never silently eaten. + * + * Deliberately limited to keyboard activation. Clicking a cameo with the mouse leaves the + * cursor over the control bar, where there is no world position worth targeting. + */ +static Bool tryQuickCast( const CommandButton *commandButton ) +{ + if( commandButton == nullptr || TheGlobalData == nullptr ) + return FALSE; + + if( TheGlobalData->m_castMode == CastMode_Normal ) + return FALSE; + + // only from a hotkey -- see the note above + if( !HotKeyManager::isExecutingHotKey() ) + return FALSE; + + // Hold to aim: on the key down pass we want the normal arming path, which already shows the + // targeting decal and lets the player move the cursor. The key up pass then fires. + if( HotKeyManager::isQuickCastAiming() ) + return FALSE; + + if( TheInGameUI == nullptr || TheMouse == nullptr || TheTacticalView == nullptr ) + return FALSE; + + // leave replay playback alone, matching InGameUI::setGUICommand + if( TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_PLAYBACK ) + return FALSE; + + const UnsignedInt options = commandButton->getOptions(); + + // A single use command burns the button permanently, so a misfire is unrecoverable -- + // never fire one blind. + if( BitIsSet( options, SINGLE_USE_COMMAND ) ) + return FALSE; + + // Rally points and beacons place a marker wherever the cursor happens to be, which is + // silent and easy to miss. Structure placement needs a deliberate footprint. + switch( commandButton->getCommandType() ) + { + case GUI_COMMAND_SET_RALLY_POINT: + case GUICOMMANDMODE_PLACE_BEACON: + case GUI_COMMAND_DOZER_CONSTRUCT: + case GUI_COMMAND_SPECIAL_POWER_CONSTRUCT: + case GUI_COMMAND_SPECIAL_POWER_CONSTRUCT_FROM_SHORTCUT: + return FALSE; + + // Superweapons are excluded on purpose: firing one at an unintended spot cannot be + // undone, and the stray keypress that does it is easy to make. + case GUI_COMMAND_SPECIAL_POWER: + case GUI_COMMAND_SPECIAL_POWER_FROM_SHORTCUT: + return FALSE; + + default: + break; + } + + // The cursor has to be over the battlefield, not the command bar or another panel. + const MouseIO *mouseIO = TheMouse->getMouseStatus(); + if( mouseIO == nullptr ) + return FALSE; + + if( TheWindowManager && + TheWindowManager->getWindowUnderCursor( mouseIO->pos.x, mouseIO->pos.y ) != nullptr ) + return FALSE; + + // TheSuperHackers @feature If the ability is still recharging, remember the cast and let + // InGameUI fire it the moment the logic side says it is ready, rather than throwing the + // input away. The cooldown itself is untouched -- this only stops the press being wasted. + if( commandButton->getSpecialPowerTemplate() ) + { + Drawable *draw = TheInGameUI->getFirstSelectedDrawable(); + Object *source = draw ? draw->getObject() : nullptr; + if( source ) + { + SpecialPowerModuleInterface *mod = + source->getSpecialPowerModule( commandButton->getSpecialPowerTemplate() ); + if( mod && !mod->isReady() ) + { + TheInGameUI->queueQuickCast( commandButton, mouseIO->pos ); + TheInGameUI->triggerQuickCastHint( commandButton, mouseIO->pos ); + return TRUE; + } + } + } + + // Hand the click to the normal path. Synthesizing the message rather than calling the + // do*Command helpers directly means quick cast reuses the engine's own validation, + // voice responses and cleanup, and cannot drift away from normal behaviour. + // + // In hold to aim mode the command was already armed on key down; re-arming here is + // harmless and covers the case where something cleared it while the key was held. + TheInGameUI->setGUICommand( commandButton ); + + // GUICommandTranslator reads the click position from pixelRegion.hi + IRegion2D clickRegion; + clickRegion.lo = mouseIO->pos; + clickRegion.hi = mouseIO->pos; + + GameMessage *msg = TheMessageStream->appendMessage( GameMessage::MSG_MOUSE_LEFT_CLICK ); + msg->appendPixelRegionArgument( clickRegion ); + + // In indicator mode the decal has been visible the whole time the key was held, so let it + // linger briefly at the point it fired rather than vanishing the instant the key comes up. + if( TheGlobalData->m_castMode == CastMode_QuickCastWithIndicator ) + TheInGameUI->triggerQuickCastHint( commandButton, mouseIO->pos ); + + return TRUE; +} +#endif // RTS_ZEROHOUR + CBCommandStatus ControlBar::processCommandUI( GameWindow *control, GadgetGameMessage gadgetMessage ) { @@ -221,6 +347,15 @@ CBCommandStatus ControlBar::processCommandUI( GameWindow *control, //with. For example, the terrorist can jack a car and convert it into a carbomb, but he has to //click on a valid car. In this case the doCommandOrHint code will determine if the mode is valid //or not and the cursor modes will be set appropriately. + +#if RTS_ZEROHOUR + // TheSuperHackers @feature Quick cast fires the command at the cursor instead of waiting for + // a second click. If it declines -- wrong mode, unsafe command, cursor not over the + // battlefield -- fall through and arm normally, so nothing is ever silently swallowed. + if( tryQuickCast( commandButton ) ) + return CBC_COMMAND_USED; +#endif + TheInGameUI->setGUICommand( commandButton ); } else switch( commandButton->getCommandType() ) diff --git a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp index 0b7ecf22d1e..989dfddd71f 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp @@ -51,6 +51,9 @@ // USER INCLUDES ////////////////////////////////////////////////////////////// //----------------------------------------------------------------------------- #include "GameClient/HotKey.h" +// TheSuperHackers @feature for hold to aim quick cast +#include "Common/GlobalData.h" +#include "Common/OptionPreferences.h" #include "GameClient/KeyDefs.h" #include "GameClient/MetaEvent.h" #include "GameClient/GameWindow.h" @@ -72,7 +75,17 @@ GameMessageDisposition HotKeyTranslator::translateGameMessage(const GameMessage GameMessageDisposition disp = KEEP_MESSAGE; GameMessage::Type t = msg->getType(); - if ( t == GameMessage::MSG_RAW_KEY_UP) + // TheSuperHackers @feature In QuickCastWithIndicator mode a hotkey arms on key down so the + // player can see the targeting decal and still adjust aim, then fires on key up. Every other + // mode keeps the original behaviour of acting only on key up. +#if RTS_ZEROHOUR + const Bool holdToAim = TheGlobalData && + TheGlobalData->m_castMode == CastMode_QuickCastWithIndicator; +#else + const Bool holdToAim = FALSE; +#endif + + if ( t == GameMessage::MSG_RAW_KEY_UP || (holdToAim && t == GameMessage::MSG_RAW_KEY_DOWN) ) { //char key = msg->getArgument(0)->integer; @@ -103,8 +116,16 @@ GameMessageDisposition HotKeyTranslator::translateGameMessage(const GameMessage uKey.concat(key); AsciiString aKey; aKey.translate(uKey); - if(TheHotKeyManager && TheHotKeyManager->executeHotKey(aKey)) - disp = DESTROY_MESSAGE; + if( TheHotKeyManager ) + { + // key down arms and previews, key up commits at wherever the cursor ended up + HotKeyManager::setQuickCastAiming( holdToAim && t == GameMessage::MSG_RAW_KEY_DOWN ); + + if( TheHotKeyManager->executeHotKey(aKey) ) + disp = DESTROY_MESSAGE; + + HotKeyManager::setQuickCastAiming( FALSE ); + } } return disp; } @@ -157,6 +178,10 @@ void HotKeyManager::addHotKey( GameWindow *win, const AsciiString& keyIn) m_hotKeyMap[key] = newHK; } +// TheSuperHackers @feature See HotKey.h -- set only while synthesizing a button press. +Bool HotKeyManager::s_executingHotKey = FALSE; +Bool HotKeyManager::s_quickCastAiming = FALSE; + //----------------------------------------------------------------------------- Bool HotKeyManager::executeHotKey( const AsciiString& keyIn ) { @@ -170,9 +195,41 @@ Bool HotKeyManager::executeHotKey( const AsciiString& keyIn ) return FALSE; if( !BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) ) { - if( BitIsSet( win->winGetStatus(), WIN_STATUS_ENABLED ) ) + // TheSuperHackers @feature A button that is merely not ready yet -- a recharging ability, + // or a weapon still working through its burst -- is disabled, which normally swallows the + // hotkey outright. In quick cast let it through anyway. + // + // Without this a repeat press is dropped here, before quick cast ever runs, and the only + // way to retarget is to Stop first. A FIRE_WEAPON cameo reports COMMAND_NOT_READY for as + // long as its weapon is not READY_TO_FIRE, so any unit still shooting has its own button + // disabled underneath the player. Retargeting mid burst is expected behaviour -- see the + // DragonTank firewall note in ControlBarCommand.cpp, which describes the same case. + // + // Deliberately narrow: this only opens up buttons disabled by WIN_STATUS_NOT_READY. Ones + // that are restricted or unaffordable stay rejected, and the order still only does + // anything if the logic side accepts it, so this cannot fire something that is genuinely + // unavailable -- it just stops the keypress being thrown away before it is even looked at. +#if RTS_ZEROHOUR + Bool allowWhileNotReady = FALSE; + if( !BitIsSet( win->winGetStatus(), WIN_STATUS_ENABLED ) && + BitIsSet( win->winGetStatus(), WIN_STATUS_NOT_READY ) && + TheGlobalData && + TheGlobalData->m_castMode != CastMode_Normal ) + { + allowWhileNotReady = TRUE; + } +#else + const Bool allowWhileNotReady = FALSE; +#endif + + if( BitIsSet( win->winGetStatus(), WIN_STATUS_ENABLED ) || allowWhileNotReady ) { + // TheSuperHackers @feature Tell the command bar this press came from the keyboard, so + // quick cast can fire at the cursor. A mouse click on the cameo leaves the cursor over + // the control bar, where there is nothing sensible to target. + HotKeyManager::setExecutingHotKey( TRUE ); TheWindowManager->winSendSystemMsg( win->winGetParent(), GBM_SELECTED, (WindowMsgData)win, win->winGetWindowId() ); + HotKeyManager::setExecutingHotKey( FALSE ); // here we make the same click sound that the GUI uses when you click a button AudioEventRTS buttonClick("GUIClick"); diff --git a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index 321423df5b1..3d081db2ef0 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -1094,6 +1094,19 @@ GameMessageDisposition SelectionTranslator::onRawMouseRightButtonUp(MAYBE_UNUSED { //Added support to cancel the GUI command without deselecting the unit(s) involved //when you right click. + // TheSuperHackers @feature Right click also cancels a queued quick cast, so a cast + // waiting on a cooldown can be called off the same way an armed command is. +#if RTS_ZEROHOUR + if( TheInGameUI->hasQueuedQuickCast() ) + { + TheInGameUI->cancelQueuedQuickCast(); + TheInGameUI->setScrolling( FALSE ); + + //With a queued cast cancel, we want no other behavior. + return DESTROY_MESSAGE; + } +#endif + if( TheInGameUI->getGUICommand() ) { //Cancel GUI command mode... don't deselect units. diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 89a5fa08f9d..134371e8228 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -142,6 +142,9 @@ class GlobalData : public SubsystemInterface Bool m_useAlternateMouse; Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; + // TheSuperHackers @feature Hotkey activation behaviour for targeted commands. + // Holds a CastMode; stored as Int to avoid pulling OptionPreferences.h in here. + Int m_castMode; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index ecc1c4fdaf5..f5e67451d84 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -604,6 +604,15 @@ friend class Drawable; // for selection/deselection transactions void unregisterWindowLayout(WindowLayout *layout); // stop updates for this layout void triggerDoubleClickAttackMoveGuardHint(); + // TheSuperHackers @feature Flash the targeting decal where a quick cast landed. + void triggerQuickCastHint( const CommandButton *command, const ICoord2D &screenPos ); + + // TheSuperHackers @feature Queued quick cast -- remember a cast requested while the ability + // was still recharging, and fire it once the logic side says it is ready. + void queueQuickCast( const CommandButton *command, const ICoord2D &screenPos ); + void cancelQueuedQuickCast( void ); + Bool hasQueuedQuickCast( void ) const { return m_queuedCastCommand != nullptr; } + void updateQueuedQuickCast( void ); public: @@ -748,6 +757,16 @@ friend class Drawable; // for selection/deselection transactions Int m_duringDoubleClickAttackMoveGuardHintTimer; ///< Frames left to draw the doubleClickFeedbackTimer Coord3D m_duringDoubleClickAttackMoveGuardHintStashedPosition; + // TheSuperHackers @feature Quick cast indicator, same shape as the hint above. + Int m_quickCastHintTimer; + Coord3D m_quickCastHintPosition; + RadiusCursorType m_quickCastHintCursorType; + // TheSuperHackers @feature Queued quick cast state. + const CommandButton *m_queuedCastCommand; + ICoord2D m_queuedCastScreenPos; + Coord3D m_queuedCastWorldPos; + ObjectID m_queuedCastSourceID; + UnsignedInt m_queuedCastExpiryFrame; // Video playback data VideoBuffer* m_videoBuffer; ///< video playback buffer diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..867ca574210 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1062,6 +1062,7 @@ GlobalData::GlobalData() m_useRightMouseScrollWithAlternateMouse = TRUE; #endif m_clientRetaliationModeEnabled = TRUE; //On by default. + m_castMode = CastMode_Default; m_doubleClickAttackMove = FALSE; } @@ -1206,6 +1207,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_castMode = optionPref.getCastMode(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index e323d96d34d..19be7865f0d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1057,6 +1057,15 @@ InGameUI::InGameUI() m_selectCount = 0; m_frameSelectionChanged = 0; m_duringDoubleClickAttackMoveGuardHintTimer = 0; + // TheSuperHackers @feature quick cast indicator starts idle + m_quickCastHintTimer = 0; + m_quickCastHintPosition.zero(); + m_quickCastHintCursorType = RADIUSCURSOR_NONE; + m_queuedCastCommand = nullptr; + m_queuedCastScreenPos.x = m_queuedCastScreenPos.y = 0; + m_queuedCastWorldPos.zero(); + m_queuedCastSourceID = INVALID_ID; + m_queuedCastExpiryFrame = 0; m_duringDoubleClickAttackMoveGuardHintStashedPosition.zero(); m_maxSelectCount = -1; m_isScrolling = FALSE; @@ -1513,7 +1522,20 @@ void InGameUI::handleRadiusCursor() { if (!m_curRadiusCursor.isEmpty()) { - if ( TheGlobalData->m_doubleClickAttackMove && m_duringDoubleClickAttackMoveGuardHintTimer > 0 ) + // TheSuperHackers @feature While a cast is queued waiting on its cooldown, pin the decal + // to the spot it will land on, so the player can see what is pending. + if ( m_queuedCastCommand != nullptr ) + { + m_curRadiusCursor.setOpacity( 0.5f ); + m_curRadiusCursor.setPosition( m_queuedCastWorldPos ); + } + // TheSuperHackers @feature Quick cast indicator fades out where the command landed. + else if ( m_quickCastHintTimer > 0 ) + { + m_curRadiusCursor.setOpacity( m_quickCastHintTimer * 0.1f ); + m_curRadiusCursor.setPosition( m_quickCastHintPosition ); + } + else if ( TheGlobalData->m_doubleClickAttackMove && m_duringDoubleClickAttackMoveGuardHintTimer > 0 ) { m_curRadiusCursor.setOpacity( m_duringDoubleClickAttackMoveGuardHintTimer * 0.1f ); m_curRadiusCursor.setPosition( m_duringDoubleClickAttackMoveGuardHintStashedPosition ); //world space position of center of decal @@ -1552,6 +1574,121 @@ void InGameUI::handleRadiusCursor() } +// TheSuperHackers @feature Quick cast indicator. +/** Flash the command's own targeting decal where the quick cast landed, so the player gets + * the same visual confirmation they would have had from aiming manually. */ +void InGameUI::triggerQuickCastHint( const CommandButton *command, const ICoord2D &screenPos ) +{ + if( command == nullptr ) + return; + + m_quickCastHintTimer = 11; + m_quickCastHintCursorType = command->getRadiusCursorType(); + + if( !rts::localPlayerHasRadar() || (TheRadar->screenPixelToWorld( &screenPos, &m_quickCastHintPosition ) == FALSE) ) + TheTacticalView->screenToTerrain( &screenPos, &m_quickCastHintPosition ); + + setRadiusCursor( m_quickCastHintCursorType, command->getSpecialPowerTemplate(), + command->getWeaponSlot() ); +} + +// TheSuperHackers @feature Queued quick cast. +/** Remember a cast the player asked for while the ability was still recharging. */ +void InGameUI::queueQuickCast( const CommandButton *command, const ICoord2D &screenPos ) +{ + if( command == nullptr ) + return; + + m_queuedCastCommand = command; + m_queuedCastScreenPos = screenPos; + m_queuedCastSourceID = INVALID_ID; + + // Remember where in the world it was aimed, so the cast still lands on the chosen spot + // even if the camera has scrolled by the time it fires. + if( !rts::localPlayerHasRadar() || (TheRadar->screenPixelToWorld( &screenPos, &m_queuedCastWorldPos ) == FALSE) ) + TheTacticalView->screenToTerrain( &screenPos, &m_queuedCastWorldPos ); + + Drawable *draw = getFirstSelectedDrawable(); + if( draw && draw->getObject() ) + m_queuedCastSourceID = draw->getObject()->getID(); + + // Give up eventually rather than firing minutes later out of nowhere. + m_queuedCastExpiryFrame = TheGameLogic->getFrame() + (LOGICFRAMES_PER_SECOND * 60); +} + +//------------------------------------------------------------------------------------------------- +void InGameUI::cancelQueuedQuickCast( void ) +{ + if( m_queuedCastCommand == nullptr ) + return; + + m_queuedCastCommand = nullptr; + m_queuedCastSourceID = INVALID_ID; + m_queuedCastExpiryFrame = 0; + setRadiusCursorNone(); +} + +//------------------------------------------------------------------------------------------------- +/** Fire a queued cast once the ability is ready. Readiness is asked of the logic side every + * frame rather than predicted, so this never fires earlier than a manual cast could. */ +void InGameUI::updateQueuedQuickCast( void ) +{ + if( m_queuedCastCommand == nullptr ) + return; + + if( TheGameLogic == nullptr || TheGameLogic->getFrame() > m_queuedCastExpiryFrame ) + { + cancelQueuedQuickCast(); + return; + } + + // the caster has to still exist, still be ours, and still be selected + Object *source = TheGameLogic->findObjectByID( m_queuedCastSourceID ); + if( source == nullptr || source->isEffectivelyDead() || + source->getControllingPlayer() != ThePlayerList->getLocalPlayer() ) + { + cancelQueuedQuickCast(); + return; + } + + const SpecialPowerTemplate *spTemplate = m_queuedCastCommand->getSpecialPowerTemplate(); + if( spTemplate ) + { + SpecialPowerModuleInterface *mod = source->getSpecialPowerModule( spTemplate ); + if( mod == nullptr ) + { + cancelQueuedQuickCast(); + return; + } + + if( !mod->isReady() ) + return; // still charging, check again next frame + } + + // Ready. Fire it the same way a manual cast would, by arming and clicking, so all the + // engine's validation and cleanup applies. + const CommandButton *command = m_queuedCastCommand; + ICoord2D screenPos = m_queuedCastScreenPos; + + // aim at the remembered world position, so a scrolled camera does not move the target + ICoord2D reprojected; + if( TheTacticalView->worldToScreen( &m_queuedCastWorldPos, &reprojected ) ) + screenPos = reprojected; + + cancelQueuedQuickCast(); + + setGUICommand( command ); + + IRegion2D clickRegion; + clickRegion.lo = screenPos; + clickRegion.hi = screenPos; + + GameMessage *msg = TheMessageStream->appendMessage( GameMessage::MSG_MOUSE_LEFT_CLICK ); + msg->appendPixelRegionArgument( clickRegion ); + + triggerQuickCastHint( command, screenPos ); +} + void InGameUI::triggerDoubleClickAttackMoveGuardHint() { const MouseIO* mouseIO = TheMouse->getMouseStatus(); @@ -1846,6 +1983,9 @@ void InGameUI::update() //USE_PERF_TIMER(InGameUI_update) Int i; + // TheSuperHackers @feature Fire a queued quick cast as soon as its ability comes up. + updateQueuedQuickCast(); + /// @todo make sure this code gets called even when the UI is not being drawn if ( m_videoStream && m_videoBuffer ) { @@ -2862,6 +3002,18 @@ void InGameUI::createCommandHint( const GameMessage *msg ) setRadiusCursorNone(); + + // TheSuperHackers @feature Hold the quick cast decal for a few frames after firing, then + // let the normal cursor logic take back over. + if ( m_quickCastHintTimer > 0 ) + { + if ( --m_quickCastHintTimer > 0 ) + { + setRadiusCursor( m_quickCastHintCursorType, nullptr, PRIMARY_WEAPON ); + return; + } + } + if ( TheGlobalData->m_doubleClickAttackMove ) { if ( --m_duringDoubleClickAttackMoveGuardHintTimer > 0 ) From f40b4df07b37033f84e0b22fede8cddb412f30f3 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:02:25 +0300 Subject: [PATCH 2/2] fix(client): Harden quick cast against double fire, retargeting and dangling state Addresses the review findings: - In hold to aim mode the key down pass executed every hotkey, not just the targeted commands it exists to arm - a production hotkey queued two units per press and a toggle undid itself. Non targeted commands now swallow the key down half and act on key up alone. - A queued cast whose remembered target has scrolled out of the frustum no longer falls back to the originally captured pixel, which the current camera would resolve to different terrain. It holds until the spot is back on screen, or expires. - A queued cast now requires its source to still be part of the current selection at fire time, since the synthesized click acts on the current selection - changing selection while waiting cancels rather than redirects. - The queue remembers the command by name and re-resolves it through the control bar at fire time; the retained pointer could dangle when a resolution change recreates the control bar's buttons. - The fading hint redraw recreates the radius cursor with the template and weapon slot it was made with, instead of nullptr and PRIMARY_WEAPON. --- .../ControlBarCommandProcessing.cpp | 10 +++ .../GameEngine/Include/GameClient/InGameUI.h | 11 +++- .../GameEngine/Source/GameClient/InGameUI.cpp | 62 +++++++++++++------ 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp index e885c3de957..6128082c76d 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp @@ -358,6 +358,16 @@ CBCommandStatus ControlBar::processCommandUI( GameWindow *control, TheInGameUI->setGUICommand( commandButton ); } +#if RTS_ZEROHOUR + // TheSuperHackers @fix In hold to aim mode the key down pass exists only to arm targeted + // commands so the decal shows while aiming. Everything else must act on the key up pass + // alone - otherwise a production hotkey queues two units per press and a toggle undoes + // itself, one per key transition. + else if( HotKeyManager::isQuickCastAiming() ) + { + // swallow the key down half; the key up pass executes normally + } +#endif else switch( commandButton->getCommandType() ) { diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index f5e67451d84..8baf9cff1fb 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -611,7 +611,7 @@ friend class Drawable; // for selection/deselection transactions // was still recharging, and fire it once the logic side says it is ready. void queueQuickCast( const CommandButton *command, const ICoord2D &screenPos ); void cancelQueuedQuickCast( void ); - Bool hasQueuedQuickCast( void ) const { return m_queuedCastCommand != nullptr; } + Bool hasQueuedQuickCast( void ) const { return m_queuedCastCommandName.isNotEmpty(); } void updateQueuedQuickCast( void ); @@ -761,9 +761,14 @@ friend class Drawable; // for selection/deselection transactions Int m_quickCastHintTimer; Coord3D m_quickCastHintPosition; RadiusCursorType m_quickCastHintCursorType; + // Remembered so the fading redraw recreates the cursor with the same radius inputs. + const SpecialPowerTemplate *m_quickCastHintTemplate; + WeaponSlotType m_quickCastHintWeaponSlot; // TheSuperHackers @feature Queued quick cast state. - const CommandButton *m_queuedCastCommand; - ICoord2D m_queuedCastScreenPos; + // The command is remembered by name and re-resolved at fire time: the control bar owns + // the button objects and recreates them on a resolution change, so a retained pointer + // could dangle while the queue waits. + AsciiString m_queuedCastCommandName; Coord3D m_queuedCastWorldPos; ObjectID m_queuedCastSourceID; UnsignedInt m_queuedCastExpiryFrame; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 19be7865f0d..3615b854a1b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1061,8 +1061,9 @@ InGameUI::InGameUI() m_quickCastHintTimer = 0; m_quickCastHintPosition.zero(); m_quickCastHintCursorType = RADIUSCURSOR_NONE; - m_queuedCastCommand = nullptr; - m_queuedCastScreenPos.x = m_queuedCastScreenPos.y = 0; + m_quickCastHintTemplate = nullptr; + m_quickCastHintWeaponSlot = PRIMARY_WEAPON; + m_queuedCastCommandName.clear(); m_queuedCastWorldPos.zero(); m_queuedCastSourceID = INVALID_ID; m_queuedCastExpiryFrame = 0; @@ -1524,7 +1525,7 @@ void InGameUI::handleRadiusCursor() { // TheSuperHackers @feature While a cast is queued waiting on its cooldown, pin the decal // to the spot it will land on, so the player can see what is pending. - if ( m_queuedCastCommand != nullptr ) + if ( hasQueuedQuickCast() ) { m_curRadiusCursor.setOpacity( 0.5f ); m_curRadiusCursor.setPosition( m_queuedCastWorldPos ); @@ -1584,12 +1585,14 @@ void InGameUI::triggerQuickCastHint( const CommandButton *command, const ICoord2 m_quickCastHintTimer = 11; m_quickCastHintCursorType = command->getRadiusCursorType(); + m_quickCastHintTemplate = command->getSpecialPowerTemplate(); + m_quickCastHintWeaponSlot = command->getWeaponSlot(); if( !rts::localPlayerHasRadar() || (TheRadar->screenPixelToWorld( &screenPos, &m_quickCastHintPosition ) == FALSE) ) TheTacticalView->screenToTerrain( &screenPos, &m_quickCastHintPosition ); - setRadiusCursor( m_quickCastHintCursorType, command->getSpecialPowerTemplate(), - command->getWeaponSlot() ); + setRadiusCursor( m_quickCastHintCursorType, m_quickCastHintTemplate, + m_quickCastHintWeaponSlot ); } // TheSuperHackers @feature Queued quick cast. @@ -1599,8 +1602,7 @@ void InGameUI::queueQuickCast( const CommandButton *command, const ICoord2D &scr if( command == nullptr ) return; - m_queuedCastCommand = command; - m_queuedCastScreenPos = screenPos; + m_queuedCastCommandName = command->getName(); m_queuedCastSourceID = INVALID_ID; // Remember where in the world it was aimed, so the cast still lands on the chosen spot @@ -1619,10 +1621,10 @@ void InGameUI::queueQuickCast( const CommandButton *command, const ICoord2D &scr //------------------------------------------------------------------------------------------------- void InGameUI::cancelQueuedQuickCast( void ) { - if( m_queuedCastCommand == nullptr ) + if( !hasQueuedQuickCast() ) return; - m_queuedCastCommand = nullptr; + m_queuedCastCommandName.clear(); m_queuedCastSourceID = INVALID_ID; m_queuedCastExpiryFrame = 0; setRadiusCursorNone(); @@ -1633,7 +1635,7 @@ void InGameUI::cancelQueuedQuickCast( void ) * frame rather than predicted, so this never fires earlier than a manual cast could. */ void InGameUI::updateQueuedQuickCast( void ) { - if( m_queuedCastCommand == nullptr ) + if( !hasQueuedQuickCast() ) return; if( TheGameLogic == nullptr || TheGameLogic->getFrame() > m_queuedCastExpiryFrame ) @@ -1642,6 +1644,16 @@ void InGameUI::updateQueuedQuickCast( void ) return; } + // Re-resolve the button from its name: the control bar owns the buttons and recreates + // them on a resolution change, so a pointer retained across frames could dangle. + const CommandButton *command = + TheControlBar ? TheControlBar->findCommandButton( m_queuedCastCommandName ) : nullptr; + if( command == nullptr ) + { + cancelQueuedQuickCast(); + return; + } + // the caster has to still exist, still be ours, and still be selected Object *source = TheGameLogic->findObjectByID( m_queuedCastSourceID ); if( source == nullptr || source->isEffectivelyDead() || @@ -1651,7 +1663,16 @@ void InGameUI::updateQueuedQuickCast( void ) return; } - const SpecialPowerTemplate *spTemplate = m_queuedCastCommand->getSpecialPowerTemplate(); + // The cast fires through the current selection, so the source has to still be part of + // it - otherwise a selection change while waiting would redirect the queued command to + // whatever is selected now. + if( source->getDrawable() == nullptr || !source->getDrawable()->isSelected() ) + { + cancelQueuedQuickCast(); + return; + } + + const SpecialPowerTemplate *spTemplate = command->getSpecialPowerTemplate(); if( spTemplate ) { SpecialPowerModuleInterface *mod = source->getSpecialPowerModule( spTemplate ); @@ -1667,13 +1688,14 @@ void InGameUI::updateQueuedQuickCast( void ) // Ready. Fire it the same way a manual cast would, by arming and clicking, so all the // engine's validation and cleanup applies. - const CommandButton *command = m_queuedCastCommand; - ICoord2D screenPos = m_queuedCastScreenPos; - - // aim at the remembered world position, so a scrolled camera does not move the target - ICoord2D reprojected; - if( TheTacticalView->worldToScreen( &m_queuedCastWorldPos, &reprojected ) ) - screenPos = reprojected; + // + // The click has to land on the remembered world position. If the camera has scrolled it + // out of the frustum the projection fails, and reusing the originally captured pixel + // would resolve against the current camera and fire at different terrain - so hold the + // cast until the spot is back on screen, or let it expire. + ICoord2D screenPos; + if( !TheTacticalView->worldToScreen( &m_queuedCastWorldPos, &screenPos ) ) + return; cancelQueuedQuickCast(); @@ -3009,7 +3031,9 @@ void InGameUI::createCommandHint( const GameMessage *msg ) { if ( --m_quickCastHintTimer > 0 ) { - setRadiusCursor( m_quickCastHintCursorType, nullptr, PRIMARY_WEAPON ); + // recreate with the same inputs it was made with, or a special power hint would lose + // its template and a non primary weapon hint its radius + setRadiusCursor( m_quickCastHintCursorType, m_quickCastHintTemplate, m_quickCastHintWeaponSlot ); return; } }