diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..c31809fb0fd 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -76,6 +76,14 @@ class OptionPreferences : public UserPreferences Real getScrollFactor(); Bool getDrawScrollAnchor(); Bool getMoveScrollAnchor(); + Bool getGridHotkeysEnabled() const; + AsciiString getGridHotkeyLayout() const; + Int getGridHotkeyColumns() const; + AsciiString getNonGridHotkeys() const; + Bool isNonGridHotkey(const AsciiString& key) const; + // TheSuperHackers @feature Exposed statically so GlobalData can test its cached copy of the + // list without re-reading Options.ini on every command bar rebuild. + static Bool isNonGridHotkeyInList(const AsciiString& list, const AsciiString& key); Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Include/GameClient/ControlBar.h b/Core/GameEngine/Include/GameClient/ControlBar.h index 446277a13be..39083bea671 100644 --- a/Core/GameEngine/Include/GameClient/ControlBar.h +++ b/Core/GameEngine/Include/GameClient/ControlBar.h @@ -768,6 +768,10 @@ class ControlBar : public SubsystemInterface /// set the command data into the button void setControlCommand( GameWindow *button, const CommandButton *commandButton ); + // TheSuperHackers @feature Grid hotkey for a command bar slot, empty if not applicable. + // isGridSlot, if given, reports whether the button is a command bar slot the grid covers, + // which is how the caller tells "excluded, so no key" apart from "not in the grid at all". + AsciiString getGridHotKeyForButton( GameWindow *button, Bool *isGridSlot = nullptr ) const; void getForegroundMarkerPos(Int *x, Int *y); void getBackgroundMarkerPos(Int *x, Int *y); diff --git a/Core/GameEngine/Include/GameClient/HotKey.h b/Core/GameEngine/Include/GameClient/HotKey.h index 70206f24cf6..8cd1b998024 100644 --- a/Core/GameEngine/Include/GameClient/HotKey.h +++ b/Core/GameEngine/Include/GameClient/HotKey.h @@ -98,6 +98,11 @@ class HotKeyManager : public SubsystemInterface AsciiString searchHotKey( const AsciiString& label); AsciiString searchHotKey( const UnicodeString& uStr ); + // TheSuperHackers @feature Is this key currently claimed by a visible, enabled command + // button? Used so grid hotkeys can take precedence over a meta event bound to the same + // letter, but only while a button actually wants it. + Bool isHotKeyClaimed( const AsciiString& key ) const; + 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..0fc39e840e7 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,96 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Grid hotkeys assign a command bar key by slot position rather than +// by whatever letter the string file marked with an ampersand. +// Options.ini: GridHotkeys = Yes +Bool OptionPreferences::getGridHotkeysEnabled() const +{ + OptionPreferences::const_iterator it = find("GridHotkeys"); + if (it == end()) + return FALSE; + + if (stricmp(it->second.str(), "yes") == 0) { + return TRUE; + } + return FALSE; +} + +// TheSuperHackers @feature The key for each slot, in reading order. Defaults to two rows of +// nine, matching a command bar that is two rows deep. Mods with a different bar can set their +// own; slots past the end of the layout simply get no grid key. The layout should cover whole +// rows -- pad any slot that should stay keyless with a non letter, e.g. "QWERT....", since the +// row shape is derived from the layout's length. +// Options.ini: GridHotkeyLayout = QWERTYUIOASDFGHJKL +AsciiString OptionPreferences::getGridHotkeyLayout() const +{ + OptionPreferences::const_iterator it = find("GridHotkeyLayout"); + if (it == end() || it->second.isEmpty()) + return AsciiString("QWERTYUIOASDFGHJKL"); + + return it->second; +} + +// TheSuperHackers @feature How many columns the command bar is wide. The engine numbers command +// slots down each column rather than across each row, so the layout string -- which is written in +// reading order -- has to be mapped through this to land the right key on the right button. +// 0 means do not remap, i.e. the layout string is already in slot order. +// Options.ini: GridHotkeyColumns = 9 +Int OptionPreferences::getGridHotkeyColumns() const +{ + OptionPreferences::const_iterator it = find("GridHotkeyColumns"); + if (it == end()) + return 9; // two rows of nine, matching a bar two rows deep + + Int columns = atoi(it->second.str()); + if (columns < 0) + columns = 0; + + return columns; +} + +// TheSuperHackers @feature Keys listed here opt out of the grid entirely. The slot that would +// have taken such a key falls back to its string file letter, and the key keeps whatever it +// normally does -- so a mod can leave S, G and friends on their usual bindings while everything +// else grids. Letters only, case insensitive, separators optional: "SG" and "S,G" both work. +// Options.ini: NonGridHotkeys = SG +AsciiString OptionPreferences::getNonGridHotkeys() const +{ + OptionPreferences::const_iterator it = find("NonGridHotkeys"); + if (it == end()) + return AsciiString::TheEmptyString; + + return it->second; +} + +// TheSuperHackers @feature Is this key one the player asked to keep out of the grid? +Bool OptionPreferences::isNonGridHotkey(const AsciiString& key) const +{ + if (key.isEmpty()) + return FALSE; + + return isNonGridHotkeyInList(getNonGridHotkeys(), key); +} + +// TheSuperHackers @feature Shared by the option lookup above and by GlobalData's cached copy, +// so both read the exclusion list exactly the same way. +Bool OptionPreferences::isNonGridHotkeyInList(const AsciiString& list, const AsciiString& key) +{ + if (list.isEmpty() || key.isEmpty()) + return FALSE; + + const char wanted = tolower(key.getCharAt(0)); + const char *c = list.str(); + for (; *c; ++c) + { + // a plain scan, so commas, spaces or nothing at all all work as separators + if (tolower(*c) == wanted) + return TRUE; + } + + return FALSE; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index 697ee47a0a4..d1f82211299 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -40,6 +40,8 @@ #include "Common/GameType.h" #include "Common/MultiplayerSettings.h" #include "Common/NameKeyGenerator.h" +// TheSuperHackers @feature for the grid hotkey exclusion list +#include "Common/OptionPreferences.h" #include "Common/Override.h" #include "Common/PlayerTemplate.h" #include "Common/Player.h" @@ -2456,6 +2458,78 @@ void ControlBar::setCommandBarBorder( GameWindow *button, CommandButtonMappedBor //------------------------------------------------------------------------------------------------- /** Set the command data into the control */ //------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature Grid hotkeys. +//------------------------------------------------------------------------------------------------- +/** Which grid key belongs to this command bar button, or empty if it is not one of them. + * + * The slot is found by looking the window up in m_commandWindows, so the mapping follows the + * bar's own ordering and needs no assumption about how many slots a mod uses. Slots past the + * end of the configured layout simply get no grid key. */ +//------------------------------------------------------------------------------------------------- +AsciiString ControlBar::getGridHotKeyForButton( GameWindow *button, Bool *isGridSlot ) const +{ + if( button == nullptr || TheGlobalData == nullptr ) + return AsciiString::TheEmptyString; + + const AsciiString &layout = TheGlobalData->m_gridHotkeyLayout; + if( layout.isEmpty() ) + return AsciiString::TheEmptyString; + + for( Int i = 0; i < MAX_COMMANDS_PER_SET; ++i ) + { + if( m_commandWindows[ i ] != button ) + continue; + + // The command bar numbers its slots down each column, but the layout string is written + // in reading order, so translate between the two. Without this, sequential letters land + // on alternating rows. + Int layoutIndex = i; + const Int columns = TheGlobalData->m_gridHotkeyColumns; + if( columns > 0 ) + { + // Rows come from the layout's own length, not MAX_COMMANDS_PER_SET, which is the + // internal cap of 32 rather than however many slots the mod actually shows. + const Int rows = ( layout.getLength() + columns - 1 ) / columns; + if( rows > 0 ) + { + const Int column = i / rows; // slots run down a column before moving right + const Int row = i % rows; + layoutIndex = row * columns + column; + } + } + + if( layoutIndex >= layout.getLength() ) + break; // this mod has more slots than the layout covers + + // this slot belongs to the grid, whatever we decide its key is + if( isGridSlot != nullptr ) + *isGridSlot = TRUE; + + // A non alphanumeric layout character is a placeholder: the slot is covered by the + // grid but gets no key of its own. That lets a layout shorter than a full bar keep + // its row shape by padding with dots -- the row count above is derived from the + // layout's length, so a partial row would otherwise shift every key after it. + const char layoutChar = layout.getCharAt( layoutIndex ); + if( !isalnum( (unsigned char)layoutChar ) ) + return AsciiString::TheEmptyString; + + AsciiString key; + key.concat( layoutChar ); + + // A key on the exclusion list is not used for this slot, so the slot falls back to its + // string file letter -- which is the point of the exclusion list: the key is freed for + // whatever the player wants it for, and the button keys the way it did before grid + // hotkeys existed. isGridSlot stays set, so the caller knows to check for a collision + // before handing the slot a letter the grid is already using. + if( OptionPreferences::isNonGridHotkeyInList( TheGlobalData->m_nonGridHotkeys, key ) ) + return AsciiString::TheEmptyString; + + return key; + } + + return AsciiString::TheEmptyString; +} + void ControlBar::setControlCommand( GameWindow *button, const CommandButton *commandButton ) { @@ -2527,7 +2601,57 @@ void ControlBar::setControlCommand( GameWindow *button, const CommandButton *com if (TheHotKeyManager) { - AsciiString hotKey = TheHotKeyManager->searchHotKey(commandButton->getTextLabel()); + // TheSuperHackers @feature Grid hotkeys key a button by where it sits in the command + // bar rather than by the letter its string file marked with an ampersand, so the keys + // stay in the same place whatever is being built. + AsciiString hotKey; + Bool isGridSlot = FALSE; + + if( TheGlobalData && TheGlobalData->m_gridHotkeysEnabled ) + hotKey = getGridHotKeyForButton( button, &isGridSlot ); + + // Fall back to the string file letter whenever the grid did not supply one. That covers a + // button the grid does not reach -- past the end of the layout, or not in the command bar + // at all -- and also a slot whose key the player put on NonGridHotkeys, which is what + // excluding a key is for: the key goes back to the game, and the button keys the way it + // did before grid hotkeys existed. + if( hotKey.isEmpty() ) + { + const AsciiString strHotKey = TheHotKeyManager->searchHotKey(commandButton->getTextLabel()); + + // A string file letter that the grid layout also uses would be a straight collision: + // addHotKey keeps whichever slot registered first and silently drops the other, so one + // of the two buttons would end up with no key and no overlay letter, seemingly at + // random. Leave the excluded slot without a hotkey instead -- the grid keeps its own + // letters, which is the behaviour the fixed layout is there to guarantee. + // + // Tested against the layout rather than against what is currently registered, so the + // result does not depend on the order the slots happen to be populated in. + Bool collidesWithGrid = FALSE; + if( isGridSlot && strHotKey.isNotEmpty() && TheGlobalData ) + { + const AsciiString &layout = TheGlobalData->m_gridHotkeyLayout; + const char wanted = tolower( strHotKey.getCharAt( 0 ) ); + for( const char *c = layout.str(); *c; ++c ) + { + // A letter the player excluded is no longer the grid's, so it does not collide. + if( tolower( *c ) != wanted ) + continue; + + AsciiString gridKey; + gridKey.concat( *c ); + if( !OptionPreferences::isNonGridHotkeyInList( TheGlobalData->m_nonGridHotkeys, gridKey ) ) + { + collidesWithGrid = TRUE; + } + break; + } + } + + if( !collidesWithGrid ) + hotKey = strHotKey; + } + if(hotKey.isNotEmpty()) TheHotKeyManager->addHotKey(button, hotKey); } diff --git a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp index 0b7ecf22d1e..d6041f57c66 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp @@ -96,7 +96,11 @@ GameMessageDisposition HotKeyTranslator::translateGameMessage(const GameMessage { newModState |= ALT; } - if(newModState != 0) + + // TheSuperHackers @feature Let Shift through so shift+hotkey batches production the same + // way shift+clicking the cameo does. Ctrl and Alt still bail, since those carry their own + // bindings (control groups and so on) that must not be shadowed by command hotkeys. + if( (newModState & ~SHIFT) != 0 ) return disp; WideChar key = TheKeyboard->getPrintableKey((KeyDefType)msg->getArgument(0)->integer, 0); UnicodeString uKey; @@ -157,6 +161,31 @@ void HotKeyManager::addHotKey( GameWindow *win, const AsciiString& keyIn) m_hotKeyMap[key] = newHK; } +// TheSuperHackers @feature See HotKey.h. +//----------------------------------------------------------------------------- +Bool HotKeyManager::isHotKeyClaimed( const AsciiString& keyIn ) const +{ + AsciiString key = keyIn; + key.toLower(); + + HotKeyMap::const_iterator it = m_hotKeyMap.find(key); + if( it == m_hotKeyMap.end() ) + return FALSE; + + GameWindow *win = it->second.m_win; + if( win == nullptr ) + return FALSE; + + // Only a visible button counts as claiming the key. A visible but currently disabled + // button claims it deliberately: the player pressing a greyed out cameo's key should get + // the disabled click feedback from executeHotKey, not have the press fall through to a + // meta event bound to the same letter and do something unrelated to the button they see. + if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) ) + return FALSE; + + return TRUE; +} + //----------------------------------------------------------------------------- Bool HotKeyManager::executeHotKey( const AsciiString& keyIn ) { diff --git a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp index 31a954e67b5..1ac046eabad 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp @@ -61,6 +61,12 @@ #include "GameClient/Keyboard.h" #endif +// TheSuperHackers @feature for grid hotkeys yielding to command bar buttons +#include "Common/GlobalData.h" +#include "Common/OptionPreferences.h" +#include "GameClient/HotKey.h" +#include "GameClient/Keyboard.h" + MetaMap *TheMetaMap = nullptr; @@ -623,6 +629,38 @@ void MetaEventTranslator::onKeyPressed(GameMessageDisposition &disp, Int systemK } else { + // TheSuperHackers @feature With grid hotkeys on, a command bar button that has + // claimed this key wins over the meta event bound to the same letter. Checked + // here rather than by reordering translators, because the order is fixed before + // Options.ini is ever read. The meta event still fires whenever no button wants + // the key, so select all and friends keep working the rest of the time. + // Only ever yield for an unmodified key. A command bar hotkey is a bare letter, or + // Shift plus one for batching, so anything carrying Ctrl or Alt cannot be a cameo + // press and must keep its meta event. Without this a Ctrl combo whose base letter + // happens to sit on a visible button is swallowed: the meta event steps aside and + // HotKeyTranslator then rejects the modifiers, so the key does nothing at all. + if( (keyModState & ~SHIFT) == 0 && + TheGlobalData && TheGlobalData->m_gridHotkeysEnabled && TheHotKeyManager ) + { + WideChar wKey = TheKeyboard->getPrintableKey( (KeyDefType)keyType, 0 ); + UnicodeString uKey; + uKey.concat( wKey ); + AsciiString aKey; + aKey.translate( uKey ); + + // A key on the exclusion list keeps its meta event no matter what, which is the + // whole point of listing it -- otherwise a button that took the key through the + // string file fallback would still shadow it. + const Bool nonGrid = OptionPreferences::isNonGridHotkeyInList( + TheGlobalData->m_nonGridHotkeys, aKey ); + + if( !nonGrid && aKey.isNotEmpty() && TheHotKeyManager->isHotKeyClaimed( aKey ) ) + { + disp = KEEP_MESSAGE; // let HotKeyTranslator have it + break; + } + } + // THIS IS A GREASY HACK... MESSAGE SHOULD BE HANDLED IN A TRANSLATOR, BUT DURING CINEMATICS THE TRANSLATOR IS DISABLED if( map->m_meta == GameMessage::MSG_META_TOGGLE_FAST_FORWARD_REPLAY) { diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index 0c8e8820660..184ae8844a7 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -142,6 +142,12 @@ class GlobalData : public SubsystemInterface Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; Bool m_doubleClickAttackMove; + // TheSuperHackers @feature Command bar keys by slot position instead of by string file. + Bool m_gridHotkeysEnabled; + AsciiString m_gridHotkeyLayout; + Int m_gridHotkeyColumns; + // Keys the player asked to leave out of the grid; those slots keep their string file letter. + AsciiString m_nonGridHotkeys; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. Bool m_useWaterPlane; diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index f7720c351a2..2819ae09efb 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1048,6 +1048,10 @@ GlobalData::GlobalData() #endif m_clientRetaliationModeEnabled = TRUE; //On by default. m_doubleClickAttackMove = FALSE; + m_gridHotkeysEnabled = FALSE; + m_gridHotkeyLayout.clear(); + m_gridHotkeyColumns = 0; + m_nonGridHotkeys.clear(); } @@ -1199,6 +1203,10 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_gridHotkeysEnabled = optionPref.getGridHotkeysEnabled(); + TheWritableGlobalData->m_gridHotkeyLayout = optionPref.getGridHotkeyLayout(); + TheWritableGlobalData->m_gridHotkeyColumns = optionPref.getGridHotkeyColumns(); + TheWritableGlobalData->m_nonGridHotkeys = optionPref.getNonGridHotkeys(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 89a5fa08f9d..434626a745a 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -143,6 +143,12 @@ class GlobalData : public SubsystemInterface Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; Bool m_doubleClickAttackMove; + // TheSuperHackers @feature Command bar keys by slot position instead of by string file. + Bool m_gridHotkeysEnabled; + AsciiString m_gridHotkeyLayout; + Int m_gridHotkeyColumns; + // Keys the player asked to leave out of the grid; those slots keep their string file letter. + AsciiString m_nonGridHotkeys; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. Bool m_useWaterPlane; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..08ee7453859 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1063,6 +1063,10 @@ GlobalData::GlobalData() #endif m_clientRetaliationModeEnabled = TRUE; //On by default. m_doubleClickAttackMove = FALSE; + m_gridHotkeysEnabled = FALSE; + m_gridHotkeyLayout.clear(); + m_gridHotkeyColumns = 0; + m_nonGridHotkeys.clear(); } @@ -1206,6 +1210,10 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_gridHotkeysEnabled = optionPref.getGridHotkeysEnabled(); + TheWritableGlobalData->m_gridHotkeyLayout = optionPref.getGridHotkeyLayout(); + TheWritableGlobalData->m_gridHotkeyColumns = optionPref.getGridHotkeyColumns(); + TheWritableGlobalData->m_nonGridHotkeys = optionPref.getNonGridHotkeys(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor();