From 10aa2f5257333c0c519f8e16c9f75454209f8147 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:24:00 +0300 Subject: [PATCH 1/3] feat(client): Add BuildTimerDisplayMode option for countdown numbers on cameos Draws the remaining time over build queue and special power cameos, next to the existing clock sweep: BuildTimerDisplayMode = None ; retail, default BuildTimerDisplayMode = Seconds ; always plain seconds BuildTimerDisplayMode = Auto ; seconds under a minute, M:SS above Covers the head production queue slot (units and upgrades) and special power recharge. Times use an integer frame ceiling so the number never reads one second short. The shared display string is returned to the manager by the W3DDisplayStringManager destructor before teardown. Available in both Zero Hour and Generals: the drawing lives in the shared gadget and W3D push button code. --- .../Include/Common/OptionPreferences.h | 13 +++ Core/GameEngine/Include/GameClient/Gadget.h | 4 + .../Include/GameClient/GadgetPushButton.h | 2 + .../Source/Common/OptionPreferences.cpp | 22 ++++ .../GUI/ControlBar/ControlBarCommand.cpp | 38 ++++++ .../GUI/Gadget/GadgetPushButton.cpp | 24 ++++ .../Include/W3DDevice/GameClient/W3DGadget.h | 3 + .../GameClient/GUI/Gadget/W3DPushButton.cpp | 109 ++++++++++++++++++ .../GameEngine/Include/Common/GlobalData.h | 3 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameClient/W3DDisplayStringManager.cpp | 7 ++ .../GameEngine/Include/Common/GlobalData.h | 3 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameClient/W3DDisplayStringManager.cpp | 7 ++ 14 files changed, 239 insertions(+) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..d18450c1c79 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -38,6 +38,18 @@ typedef UnsignedInt CursorCaptureMode; typedef UnsignedInt ScreenEdgeScrollMode; +// TheSuperHackers @feature How remaining time is shown on build queue and cooldown cameos. +// Purely a client side display preference. +enum BuildTimerDisplayMode CPP_11(: Int) +{ + BuildTimerDisplayMode_None = 0, ///< no numbers, just the existing clock sweep (retail behavior) + BuildTimerDisplayMode_Seconds, ///< always plain seconds, however large + BuildTimerDisplayMode_Auto, ///< seconds under a minute, M:SS above it + + BuildTimerDisplayMode_Count, + BuildTimerDisplayMode_Default = BuildTimerDisplayMode_None +}; + //----------------------------------------------------------------------------- // OptionsPreferences options menu class //----------------------------------------------------------------------------- @@ -76,6 +88,7 @@ class OptionPreferences : public UserPreferences Real getScrollFactor(); Bool getDrawScrollAnchor(); Bool getMoveScrollAnchor(); + BuildTimerDisplayMode getBuildTimerDisplayMode() const; Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Include/GameClient/Gadget.h b/Core/GameEngine/Include/GameClient/Gadget.h index 30c9d45206c..1851c041826 100644 --- a/Core/GameEngine/Include/GameClient/Gadget.h +++ b/Core/GameEngine/Include/GameClient/Gadget.h @@ -431,6 +431,10 @@ typedef struct _PushButtonData void *userData; ///< random additional data we can set const Image *overlayImage; ///< An overlay image (like a veterancy symbol) AsciiString altSound; ///< use an alternative sound if one is set + // TheSuperHackers @feature Remaining time drawn over the cameo, in seconds. Negative means + // nothing to show. Like drawClock this is one shot -- it is cleared after being drawn, so + // whoever sets it must re-set it every frame. + Int countdownSeconds; } PushButtonData; // TabControlData ------------------------------------------------------------ diff --git a/Core/GameEngine/Include/GameClient/GadgetPushButton.h b/Core/GameEngine/Include/GameClient/GadgetPushButton.h index ced1e0c3013..5a2893e329f 100644 --- a/Core/GameEngine/Include/GameClient/GadgetPushButton.h +++ b/Core/GameEngine/Include/GameClient/GadgetPushButton.h @@ -76,6 +76,8 @@ void GadgetButtonEnableCheckLike( GameWindow *g, Bool makeCheckLike, Bool initia void GadgetButtonSetText( GameWindow *g, UnicodeString text ); void GadgetButtonDrawClock( GameWindow *g, Int percent, Color color ); //Darkens the progress void GadgetButtonDrawInverseClock( GameWindow *g, Int percent, Color color ); //Darkens the remaining portion. +// TheSuperHackers @feature Remaining time in seconds, drawn over the cameo. One shot, like the clocks. +void GadgetButtonDrawCountdown( GameWindow *g, Int seconds ); void GadgetButtonDrawOverlayImage( GameWindow *g, const Image *image ); void GadgetButtonSetBorder( GameWindow *g, Color color, Bool drawBorder = TRUE ); void GadgetButtonSetData(GameWindow *g, void *data); diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index e681ef8b192..39590f40df0 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,28 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Countdown numbers on build queue and cooldown cameos, read from +// Options.ini as BuildTimerDisplayMode = None | Seconds | Auto (a plain index also works). +BuildTimerDisplayMode OptionPreferences::getBuildTimerDisplayMode() const +{ + OptionPreferences::const_iterator it = find("BuildTimerDisplayMode"); + if (it == end()) + return BuildTimerDisplayMode_Default; + + if (stricmp(it->second.str(), "Auto") == 0) + return BuildTimerDisplayMode_Auto; + if (stricmp(it->second.str(), "Seconds") == 0) + return BuildTimerDisplayMode_Seconds; + if (stricmp(it->second.str(), "None") == 0) + return BuildTimerDisplayMode_None; + + Int mode = atoi(it->second.str()); + if (mode >= 0 && mode < BuildTimerDisplayMode_Count) + return (BuildTimerDisplayMode)mode; + + return BuildTimerDisplayMode_Default; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommand.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommand.cpp index 5a9099af8a9..b73bd7df448 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommand.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommand.cpp @@ -40,6 +40,7 @@ #include "Common/Upgrade.h" #include "Common/BuildAssistant.h" #include "GameLogic/GameLogic.h" +#include "Common/OptionPreferences.h" #include "GameLogic/Module/BattlePlanUpdate.h" #include "GameLogic/Module/DozerAIUpdate.h" #include "GameLogic/Module/OverchargeBehavior.h" @@ -794,6 +795,30 @@ void ControlBar::updateContextCommand() GadgetButtonDrawInverseClock(win,produce->getPercentComplete(), m_buildUpClockColor); + // TheSuperHackers @feature Remaining build time on the head queue slot. + if( TheGlobalData->m_buildTimerDisplayMode != BuildTimerDisplayMode_None ) + { + Int totalFrames = 0; + if( produce->getProductionType() == PRODUCTION_UNIT ) + { + if( produce->getProductionObject() ) + totalFrames = produce->getProductionObject()->calcTimeToBuild( obj->getControllingPlayer() ); + } + else if( produce->getProductionUpgrade() ) + { + totalFrames = produce->getProductionUpgrade()->calcTimeToBuild( obj->getControllingPlayer() ); + } + + if( totalFrames > 0 ) + { + Real remainingReal = totalFrames * (100.0f - produce->getPercentComplete()) / 100.0f; + Int remainingFrames = ( remainingReal > 0.0f ) ? REAL_TO_INT_CEIL( remainingReal ) : 0; + // integer ceiling -- see formatBuildTimeForTooltip for why not the float form + GadgetButtonDrawCountdown( win, + ( remainingFrames + LOGICFRAMES_PER_SECOND - 1 ) / LOGICFRAMES_PER_SECOND ); + } + } + } } @@ -1418,6 +1443,19 @@ CommandAvailability ControlBar::getCommandAvailability( const CommandButton *com Int percent = mod->getPercentReady() * 100; GadgetButtonDrawInverseClock( applyToWin, percent, m_buildUpClockColor ); + + // TheSuperHackers @feature Remaining recharge time. getReadyFrame is an absolute + // frame, and already accounts for paused and shared/synced powers. + if( TheGlobalData->m_buildTimerDisplayMode != BuildTimerDisplayMode_None ) + { + UnsignedInt now = TheGameLogic->getFrame(); + UnsignedInt readyFrame = mod->getReadyFrame(); + UnsignedInt remainingFrames = ( readyFrame > now ) ? ( readyFrame - now ) : 0; + // integer ceiling -- see formatBuildTimeForTooltip for why not the float form + GadgetButtonDrawCountdown( applyToWin, + ( remainingFrames + LOGICFRAMES_PER_SECOND - 1 ) / LOGICFRAMES_PER_SECOND ); + } + return COMMAND_NOT_READY; } else if( SpecialAbilityUpdate *spUpdate = obj->findSpecialAbilityUpdate( command->getSpecialPowerTemplate()->getSpecialPowerType() ) ) diff --git a/Core/GameEngine/Source/GameClient/GUI/Gadget/GadgetPushButton.cpp b/Core/GameEngine/Source/GameClient/GUI/Gadget/GadgetPushButton.cpp index 8818806ed48..810cb13ed9e 100644 --- a/Core/GameEngine/Source/GameClient/GUI/Gadget/GadgetPushButton.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/Gadget/GadgetPushButton.cpp @@ -600,10 +600,34 @@ PushButtonData * getNewPushButtonData() p->userData = nullptr; p->drawBorder = FALSE; p->drawClock = NO_CLOCK; + p->countdownSeconds = -1; p->overlayImage = nullptr; return p; } +// GadgetButtonSetBorder ====================================================== +/** Set to draw the special borders in the game */ +//============================================================================= +// GadgetButtonDrawCountdown ================================================== +/** TheSuperHackers @feature Show remaining time in seconds over the button. Like the clock + * above this is one shot, so it must be re-set every frame while the timer is running. */ +//============================================================================= +void GadgetButtonDrawCountdown( GameWindow *g, Int seconds ) +{ + + if( g == nullptr ) + return; + + PushButtonData *pData = (PushButtonData *)g->winGetUserData(); + if(!pData) + { + pData = getNewPushButtonData(); + } + pData->countdownSeconds = seconds; + g->winSetUserData(pData); + +} + // GadgetButtonSetBorder ====================================================== /** Set to draw the special borders in the game */ //============================================================================= diff --git a/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DGadget.h b/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DGadget.h index 5979a8a5f4f..eea23a9a517 100644 --- a/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DGadget.h +++ b/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DGadget.h @@ -65,6 +65,9 @@ /////////////////////////////////////////////////////////////////////////////// extern void W3DGadgetPushButtonDraw( GameWindow *window, WinInstanceData *instData ); +// TheSuperHackers @feature Free the countdown text's display string; must run before +// TheDisplayStringManager is destroyed. +extern void W3DGadgetPushButtonFreeCountdownString( void ); extern void W3DGadgetPushButtonImageDraw( GameWindow *window, WinInstanceData *instData ); extern void W3DGadgetCheckBoxDraw( GameWindow *window, WinInstanceData *instData ); extern void W3DGadgetCheckBoxImageDraw( GameWindow *window, WinInstanceData *instData ); diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/GUI/Gadget/W3DPushButton.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/GUI/Gadget/W3DPushButton.cpp index 2b89eb1e564..8e4f353366b 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/GUI/Gadget/W3DPushButton.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/GUI/Gadget/W3DPushButton.cpp @@ -53,6 +53,12 @@ #include "GameClient/GameWindowManager.h" #include "GameClient/GadgetPushButton.h" #include "GameClient/Display.h" +// TheSuperHackers @feature for the command bar countdown text +#include "Common/GlobalData.h" +#include "Common/OptionPreferences.h" +#include "GameClient/DisplayStringManager.h" +#include "GameClient/GameFont.h" +#include "GameClient/GlobalLanguage.h" #include "W3DDevice/GameClient/W3DGameWindow.h" #include "W3DDevice/GameClient/W3DDisplay.h" #include "W3DDevice/GameClient/W3DGadget.h" @@ -78,6 +84,91 @@ void W3DGadgetPushButtonImageDrawOne(GameWindow *window, WinInstanceData *instDa // drawButtonText ============================================================= /** Draw button text to the screen */ //============================================================================= +// TheSuperHackers @feature Countdown text over a cameo (Options.ini: BuildTimerDisplayMode). +// One shared string, rebuilt only when the displayed value changes -- at one tick per second +// that is far less often than we are drawn. Returned to the manager by +// W3DGadgetPushButtonFreeCountdownString before the manager is torn down. +static DisplayString *s_countdownString = nullptr; +static Int s_countdownLastSeconds = -1; +static Int s_countdownLastMode = -1; + +//============================================================================= +/** Return the countdown's display string to the manager. Must run before + * TheDisplayStringManager is destroyed, which asserts on any string still + * registered -- the W3DDisplayStringManager destructor calls this. */ +//============================================================================= +void W3DGadgetPushButtonFreeCountdownString( void ) +{ + if( s_countdownString != nullptr && TheDisplayStringManager != nullptr ) + TheDisplayStringManager->freeDisplayString( s_countdownString ); + + s_countdownString = nullptr; + s_countdownLastSeconds = -1; + s_countdownLastMode = -1; +} + +// drawButtonCountdown ======================================================== +/** Draw the remaining time for whatever this button is counting down -- a queued unit or + * upgrade, or a special power recharging. Drawn after the clock sweep so the darkening + * does not swallow the digits. */ +//============================================================================= +static void drawButtonCountdown( GameWindow *window, Int seconds ) +{ + if( seconds < 0 ) + return; + + if( !TheGlobalData || TheGlobalData->m_buildTimerDisplayMode == BuildTimerDisplayMode_None ) + return; + + if( TheDisplayStringManager == nullptr ) + return; + + if( s_countdownString == nullptr ) + { + s_countdownString = TheDisplayStringManager->newDisplayString(); + if( s_countdownString == nullptr ) + return; + + Int pointSize = 10; + if( TheGlobalLanguageData ) + pointSize = TheGlobalLanguageData->adjustFontSize( pointSize ); + s_countdownString->setFont( TheFontLibrary->getFont( AsciiString( "Arial" ), pointSize, TRUE ) ); + } + + // only rebuild the sentence when the displayed value actually changes, which at one + // tick per second is far less often than we are drawn + if( s_countdownLastSeconds != seconds || s_countdownLastMode != TheGlobalData->m_buildTimerDisplayMode ) + { + UnicodeString text; + if( TheGlobalData->m_buildTimerDisplayMode == BuildTimerDisplayMode_Auto && seconds >= 60 ) + text.format( L"%d:%2.2d", seconds / 60, seconds % 60 ); + else + text.format( L"%d", seconds ); + + s_countdownString->setText( text ); + s_countdownLastSeconds = seconds; + s_countdownLastMode = TheGlobalData->m_buildTimerDisplayMode; + } + + ICoord2D origin, size; + window->winGetScreenPosition( &origin.x, &origin.y ); + window->winGetSize( &size.x, &size.y ); + + Int width, height; + s_countdownString->getSize( &width, &height ); + + // centered along the bottom, clear of the top left corner decorations + const Int pad = 1; + const Int textX = origin.x + (size.x / 2) - (width / 2); + const Int textY = origin.y + size.y - height - 2; + + TheDisplay->drawFillRect( textX - pad, textY - pad, + width + pad * 2, height + pad * 2, GameMakeColor( 0, 0, 0, 128 ) ); + + s_countdownString->draw( textX, textY, + GameMakeColor( 255, 255, 255, 255 ), GameMakeColor( 0, 0, 0, 255 ) ); +} + static void drawButtonText( GameWindow *window, WinInstanceData *instData ) { ICoord2D origin, size, textPos; @@ -434,6 +525,15 @@ void W3DGadgetPushButtonImageDrawOne( GameWindow *window, window->winSetUserData(pData); } + // TheSuperHackers @feature Countdown text, after the clock so the darkening does not + // swallow the digits. One shot, like the clock above. + if( pData->countdownSeconds >= 0 ) + { + drawButtonCountdown( window, pData->countdownSeconds ); + pData->countdownSeconds = -1; + window->winSetUserData(pData); + } + if( pData->drawBorder && pData->colorBorder != GAME_COLOR_UNDEFINED ) { @@ -693,6 +793,15 @@ void W3DGadgetPushButtonImageDrawThree(GameWindow *window, WinInstanceData *inst window->winSetUserData(pData); } + // TheSuperHackers @feature Countdown text, after the clock so the darkening does not + // swallow the digits. One shot, like the clock above. + if( pData->countdownSeconds >= 0 ) + { + drawButtonCountdown( window, pData->countdownSeconds ); + pData->countdownSeconds = -1; + window->winSetUserData(pData); + } + if( pData->drawBorder && pData->colorBorder != GAME_COLOR_UNDEFINED ) { TheDisplay->drawOpenRect(start.x - 1, start.y - 1, size.x + 2, size.y + 2, 1, pData->colorBorder); diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index 0c8e8820660..cacd1152fb8 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -141,6 +141,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 Countdown numbers on build queue and cooldown cameos. + // Holds a BuildTimerDisplayMode; stored as Int to avoid pulling OptionPreferences.h in here. + Int m_buildTimerDisplayMode; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index f7720c351a2..5f100d40032 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1047,6 +1047,7 @@ GlobalData::GlobalData() m_useRightMouseScrollWithAlternateMouse = TRUE; #endif m_clientRetaliationModeEnabled = TRUE; //On by default. + m_buildTimerDisplayMode = BuildTimerDisplayMode_Default; m_doubleClickAttackMove = FALSE; } @@ -1199,6 +1200,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_buildTimerDisplayMode = optionPref.getBuildTimerDisplayMode(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); diff --git a/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp b/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp index d67d6282d30..6c22b364b9f 100644 --- a/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp +++ b/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp @@ -36,6 +36,8 @@ #include "GameClient/DrawGroupInfo.h" #include "GameClient/GlobalLanguage.h" #include "W3DDevice/GameClient/W3DDisplayStringManager.h" +// TheSuperHackers @feature for the countdown string teardown +#include "W3DDevice/GameClient/W3DGadget.h" /////////////////////////////////////////////////////////////////////////////////////////////////// // PUBLIC FUNCTIONS @@ -56,6 +58,11 @@ W3DDisplayStringManager::W3DDisplayStringManager() //------------------------------------------------------------------------------------------------- W3DDisplayStringManager::~W3DDisplayStringManager() { + // TheSuperHackers @feature The countdown text's shared string must come back to the + // manager while it is still alive; the base class destructor asserts on any string + // left registered. + W3DGadgetPushButtonFreeCountdownString(); + for (Int i = 0; i < MAX_GROUPS; ++i) { if (m_groupNumeralStrings[i]) diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 89a5fa08f9d..a709cb31204 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 Countdown numbers on build queue and cooldown cameos. + // Holds a BuildTimerDisplayMode; stored as Int to avoid pulling OptionPreferences.h in here. + Int m_buildTimerDisplayMode; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..33ccb617aca 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_buildTimerDisplayMode = BuildTimerDisplayMode_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_buildTimerDisplayMode = optionPref.getBuildTimerDisplayMode(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp index 34d1c0e0046..591eaddbd31 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplayStringManager.cpp @@ -36,6 +36,8 @@ #include "GameClient/DrawGroupInfo.h" #include "GameClient/GlobalLanguage.h" #include "W3DDevice/GameClient/W3DDisplayStringManager.h" +// TheSuperHackers @feature for the countdown string teardown +#include "W3DDevice/GameClient/W3DGadget.h" /////////////////////////////////////////////////////////////////////////////////////////////////// // PUBLIC FUNCTIONS @@ -56,6 +58,11 @@ W3DDisplayStringManager::W3DDisplayStringManager() //------------------------------------------------------------------------------------------------- W3DDisplayStringManager::~W3DDisplayStringManager() { + // TheSuperHackers @feature The countdown text's shared string must come back to the + // manager while it is still alive; the base class destructor asserts on any string + // left registered. + W3DGadgetPushButtonFreeCountdownString(); + for (Int i = 0; i < MAX_GROUPS; ++i) { if (m_groupNumeralStrings[i]) From c38ed624cf118642733f5834e893d58a8b296321 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:25:16 +0300 Subject: [PATCH 2/3] feat(client): Show build time next to cost in the build tooltip Formats the build time with the same Auto/Seconds rules as the cameo countdowns and appends it to the tooltip's cost line, separated by a watch glyph - the description window sits directly beneath the cost line in the .wnd layout, so a second line would overlap it. Shown for free items too, which have no cost of their own but still take time to build. Unit times come from calcTimeToBuild, which folds in the current energy penalty, so the tooltip tracks the player's power state; upgrades carry no penalty in the engine. Tooltip times round to nearest in integer math so a fractional BuildTime reads as the number the modder typed, while the live countdowns keep ceiling - a running timer must not show 0 with work left. Follows BuildTimerDisplayMode: with the option at None the tooltip stays retail. --- .../ControlBarPopupDescription.cpp | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp b/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp index 35d9a3e80d8..fe6128377ab 100644 --- a/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp @@ -68,6 +68,7 @@ #include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine #include "Common/GlobalData.h" +#include "Common/OptionPreferences.h" #include "Common/BuildAssistant.h" #include "Common/Player.h" #include "Common/PlayerList.h" @@ -231,6 +232,39 @@ void ControlBar::showBuildTooltipLayout( GameWindow *cmdButton ) } +// TheSuperHackers @feature Format a build time for the tooltip, using the same Auto/Seconds +// rules as the cameo countdowns (Options.ini: BuildTimerDisplayMode). +// +// Note the value passed in should come from calcTimeToBuild(), which for units already folds +// in the player's current energy penalty -- so a tooltip read while on low power correctly +// shows the slower time, and speeds back up once power is restored. Upgrades have no energy +// penalty in the engine, so their time is the same regardless of power state. +static UnicodeString formatBuildTimeForTooltip( Int buildFrames ) +{ + UnicodeString result = UnicodeString::TheEmptyString; + + if( buildFrames <= 0 ) + return result; + + if( TheGlobalData == nullptr || + TheGlobalData->m_buildTimerDisplayMode == BuildTimerDisplayMode_None ) + return result; + + // Round to nearest, so a template built from a fractional BuildTime reads as the number + // the modder typed rather than always the one above it. Integer math throughout, because + // 1/30 is not exact in float and an exact 8 second build would otherwise come out as + // 8.0000004. The live countdowns deliberately still ceil -- a running timer must not + // show 0 while there is work left. + const Int seconds = ( buildFrames + LOGICFRAMES_PER_SECOND / 2 ) / LOGICFRAMES_PER_SECOND; + + if( TheGlobalData->m_buildTimerDisplayMode == BuildTimerDisplayMode_Auto && seconds >= 60 ) + result.format( L"%d:%2.2d", seconds / 60, seconds % 60 ); // already reads as time + else + result.format( L"%ds", seconds ); + + return result; +} + void ControlBar::repopulateBuildTooltipLayout() { if(!prevWindow || !m_buildToolTipLayout) @@ -248,6 +282,8 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, Player *player = ThePlayerList->getLocalPlayer(); UnicodeString name, cost, descrip; + // TheSuperHackers @feature Build time shown under the cost, formatted like the cameo timers. + UnicodeString buildTimeText; UnicodeString requiresFormat = UnicodeString::TheEmptyString, requiresList; Bool firstRequirement = true; const ProductionPrerequisite *prereq; @@ -415,6 +451,10 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, cost.format( TheGameText->fetch("TOOLTIP:Cost"), costToBuild ); } + // TheSuperHackers @feature calcTimeToBuild folds in the current energy penalty, + // so this tracks the player's power state as it changes. + buildTimeText = formatBuildTimeForTooltip( thingTemplate->calcTimeToBuild( player ) ); + // ask each prerequisite to give us a list of the non satisfied prerequisites for( Int i=0; igetPrereqCount(); i++ ) { @@ -512,6 +552,10 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, cost.format( TheGameText->fetch("TOOLTIP:Cost"), costToBuild ); } + // TheSuperHackers @feature Upgrades carry no energy penalty in the engine, so + // unlike units this time does not change with the player's power state. + buildTimeText = formatBuildTimeForTooltip( upgradeTemplate->calcTimeToBuild( player ) ); + if( missingScience ) { if( !descrip.isEmpty() ) @@ -607,10 +651,24 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, win = TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextCost")); if(win) { - if( costToBuild > 0 ) + // TheSuperHackers @feature Show the build time alongside the cost. Also shown for + // free items, which have no cost line of their own but still take time to build. + // Kept on the cost line, separated by a watch glyph. The description window sits + // directly beneath this one in the .wnd layout, so a second line here overlaps it. + UnicodeString costLine = cost; + if( !buildTimeText.isEmpty() ) + { + if( !costLine.isEmpty() ) + costLine.concat( L" " ); + costLine.concat( (WideChar)0x231A ); // WATCH + costLine.concat( L" " ); + costLine.concat( buildTimeText ); + } + + if( !costLine.isEmpty() ) { win->winHide( FALSE ); - GadgetStaticTextSetText(win, cost); + GadgetStaticTextSetText(win, costLine); } else { From ea8d6bcf84c4d1b91085eaafd4ad24f0b42b8623 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:21:44 +0300 Subject: [PATCH 3/3] fix(client): Refresh the open build tooltip so its time tracks power changes The unit build time folds in the player's energy penalty, but nothing marks the control bar dirty when power production or consumption changes, so an already open tooltip kept showing the old time. The tooltip's own update now repopulates it about once a second while visible - only when the timer display is on, so retail stays as cheap as before. --- .../GUICallbacks/ControlBarPopupDescription.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp b/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp index fe6128377ab..0c76e61e174 100644 --- a/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp @@ -105,6 +105,20 @@ void ControlBarPopupDescriptionUpdateFunc( WindowLayout *layout, void *param ) if(TheScriptEngine->isGameEnding()) TheControlBar->hideBuildTooltipLayout(); + // TheSuperHackers @fix The unit build time follows the player's power state, but nothing + // marks the control bar dirty when power changes - so an open tooltip kept showing the + // old time. Refresh it about once a second while it is up; with the timer display off + // the tooltip carries no time and stays as cheap as retail. + if( TheGlobalData && TheGlobalData->m_buildTimerDisplayMode != BuildTimerDisplayMode_None ) + { + static UnsignedInt s_framesSinceRefresh = 0; + if( ++s_framesSinceRefresh >= LOGICFRAMES_PER_SECOND ) + { + s_framesSinceRefresh = 0; + TheControlBar->repopulateBuildTooltipLayout(); + } + } + if(theAnimateWindowManager && !TheControlBar->getShowBuildTooltipLayout() && !theAnimateWindowManager->isReversed()) theAnimateWindowManager->reverseAnimateWindow(); else if(!TheControlBar->getShowBuildTooltipLayout() && (!TheGlobalData->m_animateWindows || !useAnimation))