From 1facf739526f86bcb78a991f341a4c3bd828a265 Mon Sep 17 00:00:00 2001 From: MrS-ibra Date: Fri, 23 Jan 2026 16:51:37 +0300 Subject: [PATCH 1/4] Implement notifications for observer players --- .../GameEngine/Include/Common/GlobalData.h | 3 + .../Include/Common/UserPreferences.h | 1 + .../GameEngine/Include/GameClient/InGameUI.h | 40 +- .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameEngine/Source/Common/RTS/Player.cpp | 4 + .../GUI/GUICallbacks/Menus/OptionsMenu.cpp | 20 + .../GameEngine/Source/GameClient/InGameUI.cpp | 343 ++++++++++++++++++ .../SpecialPower/SpecialPowerModule.cpp | 8 + 8 files changed, 419 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 892b10354ca..8cc847eff76 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -422,6 +422,9 @@ class GlobalData : public SubsystemInterface // Generals Online @feature 11/01/2026 allow the observer stats font size to be set, a size of zero disables it Int m_observerStatsFontSize; + // Generals Online @feature 16/1/2025 allow the observer notification font size to be set, a size of zero disables it + Int m_observerNotificationFontSize; + Real m_shakeSubtleIntensity; ///< Intensity for shaking a camera with SHAKE_SUBTLE Real m_shakeNormalIntensity; ///< Intensity for shaking a camera with SHAKE_NORMAL Real m_shakeStrongIntensity; ///< Intensity for shaking a camera with SHAKE_STRONG diff --git a/GeneralsMD/Code/GameEngine/Include/Common/UserPreferences.h b/GeneralsMD/Code/GameEngine/Include/Common/UserPreferences.h index 5f9843a2898..17f3bbeb515 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/UserPreferences.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/UserPreferences.h @@ -147,6 +147,7 @@ class OptionPreferences : public UserPreferences Int getSystemTimeFontSize(void); Int getGameTimeFontSize(void); Int getObserverStatsFontSize(void); + Int getObserverNotificationFontSize(void); Real getResolutionFontAdjustment(void); diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index 4c03bbbacfb..de7103bbe23 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -365,7 +365,11 @@ friend class Drawable; // for selection/deselection transactions virtual ~InGameUI( void ); void refreshObserverStatsResources(); - void toggleObserverStats() { m_observerStatsHidden = !m_observerStatsHidden; } // Toggle visibility of the observer stats overlay + // Toggle visibility of the observer stats overlay and notifications + void toggleObserverStats() { + m_observerStatsHidden = !m_observerStatsHidden; + m_observerNotificationsHidden = !m_observerNotificationsHidden; + } // Inherited from subsystem interface ----------------------------------------------------------- virtual void init( void ); ///< Initialize the in-game user interface @@ -613,7 +617,15 @@ friend class Drawable; // for selection/deselection transactions void triggerDoubleClickAttackMoveGuardHint( void ); - + void drawObserverNotifications(Int& x, Int& y); + void updateObserverNotifications(UnsignedInt currentFrame); + void checkObserverMilestones(UnsignedInt currentFrame); + void addObserverNotification(const UnicodeString& playerName, const wchar_t* message, Color playerColor); + void addObserverNotificationRaw(const UnicodeString& message, Color color); + void notifyGeneralPromotion(Player* player, ScienceType science); + void notifySpecialPowerUsed(Player* player, const SpecialPowerTemplate* powerTemplate); + void refreshObserverNotificationResources(void); + Bool m_observerNotificationsHidden; // hide/show observer notifications public: // World 2D animation methods @@ -658,6 +670,24 @@ friend class Drawable; // for selection/deselection transactions enum { MAX_MOVE_HINTS = 256 }; + struct ObserverNotification { + UnicodeString message; + Color color; + UnsignedInt createdRenderMs; + Bool active; + }; + + struct ObserverMilestone { + //Int playerId; + Bool reachedLevel3; + Bool reachedLevel5; + Bool reached10kCPM; + Bool reached20kCPM; + Bool reached50kCPM; + Bool reached100kCPM; + Bool warnedFloating100k; + }; + struct PlayerData { UnicodeString name; UnicodeString faction; @@ -841,6 +871,12 @@ friend class Drawable; // for selection/deselection transactions Coord2D m_observerStatsPosition; Int m_observerStatsLineStep; + // Observer notifications + std::vector m_observerNotifications; + std::vector m_observerMilestones; + DisplayString* m_observerNotificationString; + Int m_observerNotificationPointSize; + #if defined(GENERALS_ONLINE) Color m_colorGood; Color m_colorBad; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e6c69bdbc9b..69e55ab88c4 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -950,6 +950,7 @@ GlobalData::GlobalData() m_systemTimeFontSize = 8; m_gameTimeFontSize = 8; m_observerStatsFontSize = 7; + m_observerNotificationFontSize = 10; m_showMoneyPerMinute = FALSE; m_allowMoneyPerMinuteForPlayer = FALSE; @@ -1236,6 +1237,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_gameTimeFontSize = optionPref.getGameTimeFontSize(); TheWritableGlobalData->m_showMoneyPerMinute = optionPref.getShowMoneyPerMinute(); TheWritableGlobalData->m_observerStatsFontSize = optionPref.getObserverStatsFontSize(); + TheWritableGlobalData->m_observerNotificationFontSize = optionPref.getObserverNotificationFontSize(); Int val=optionPref.getGammaValue(); //generate a value between 0.6 and 2.0. diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp index ec98aa370d6..723981630a6 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp @@ -2605,6 +2605,10 @@ Bool Player::attemptToPurchaseScience(ScienceType science) TheControlBar->markUIDirty(); } + if (TheInGameUI) { + TheInGameUI->notifyGeneralPromotion(this, science); + } + return true; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp index be60eadb7b3..be84d8c8bc7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp @@ -940,6 +940,16 @@ Int OptionPreferences::getRenderFpsFontSize(void) return fontSize; } +Int OptionPreferences::getObserverNotificationFontSize(void) { + OptionPreferences::const_iterator it = find("ObserverNotificationFontSize"); + if (it == end()) + return 12; + Int fontSize = atoi(it->second.str()); + if (fontSize < 0) + fontSize = 0; + return fontSize; +} + Int OptionPreferences::getObserverStatsFontSize(void) { OptionPreferences::const_iterator it = find("ObserverStatsFontSize"); @@ -1545,6 +1555,16 @@ static void saveOptions( void ) TheInGameUI->refreshRenderFpsResources(); } + //------------------------------------------------------------------------------------------------- + // Set Observer notification Font Size + val = pref->getObserverNotificationFontSize(); + if (val >= 0) { + AsciiString prefString; + prefString.format("%d", val); + (*pref)["ObserverNotificationFontSize"] = prefString; + TheInGameUI->refreshObserverNotificationResources(); + } + //------------------------------------------------------------------------------------------------- // Set Observer Stats Font Size val = pref->getObserverStatsFontSize(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 783b4107029..0d1ef752e11 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1130,6 +1130,10 @@ InGameUI::InGameUI() m_observerStatsPosition.x = kHudAnchorX; m_observerStatsPosition.y = kHudAnchorY; + // Observer notification overlay + m_observerNotificationString = NULL; + m_observerNotificationPointSize = TheGlobalData->m_observerNotificationFontSize; + #if defined(GENERALS_ONLINE) m_colorGood = GameMakeColor(0, 255, 0, 150); m_colorBad = GameMakeColor(255, 0, 0, 150); @@ -1229,6 +1233,10 @@ InGameUI::~InGameUI() // clear world animations clearWorldAnimations(); resetIdleWorker(); + + // Clean up notification resources + TheDisplayStringManager->freeDisplayString(m_observerNotificationString); + m_observerNotificationString = nullptr; } //------------------------------------------------------------------------------------------------- @@ -2090,6 +2098,10 @@ void InGameUI::reset( void ) // reset the command bar TheControlBar->reset(); + m_observerNotificationsHidden = false; + m_observerNotifications.clear(); + m_observerMilestones.clear(); + TheTacticalView->setDefaultView(0.0f, 0.0f, 1.0f); ResetInGameChat(); @@ -3741,6 +3753,9 @@ void InGameUI::postWindowDraw( void ) if (m_observerStatsPointSize > 0) drawObserverStats(hudOffsetX, hudOffsetY); + if (m_observerNotificationPointSize > 0) + drawObserverNotifications(hudOffsetX, hudOffsetY); + } //------------------------------------------------------------------------------------------------- @@ -5975,6 +5990,320 @@ void InGameUI::recreateControlBar( void ) TheControlBar->init(); } +// ====================================================================================== +// Observer Notification +// ====================================================================================== +namespace { + const Int MAX_NOTIFICATIONS = 8; + const UnsignedInt SLIDE_IN_MS = 300; + const UnsignedInt VISIBLE_MS = 3000; + const UnsignedInt SLIDE_OUT_MS = 300; + const UnsignedInt TOTAL_LIFETIME_MS = SLIDE_IN_MS + VISIBLE_MS + SLIDE_OUT_MS; + const Real BRIGHTNESS_BOOST = 0.3f; // Apply a slight brightness to make darker colors more visible + + // Layout for notifications + const Int NOTIF_LEFT_MARGIN = 20; + const Int NOTIF_VERTICAL_OFFSET = 300; // Offset from center of screen + const Int NOTIF_PADDING_X = 12; + const Int NOTIF_PADDING_Y = 10; + const Int NOTIF_BOX_SPACING = 8; +} + +// Compute animation progress from elapsed render time (0 = sliding in, 1 = visible, 2 = expired) +static Real computeSlideProgress(UnsignedInt ageMs) +{ + if (ageMs < SLIDE_IN_MS) return (Real)ageMs / SLIDE_IN_MS; + if (ageMs < SLIDE_IN_MS + VISIBLE_MS) return 1.0f; + if (ageMs < TOTAL_LIFETIME_MS) return 1.0f + (Real)(ageMs - SLIDE_IN_MS - VISIBLE_MS) / SLIDE_OUT_MS; + return 2.0f; +} + +// Apply easing curve to slide animation +static Real applyEasing(Real progress) +{ + Real t = (progress < 1.0f) ? progress : (progress - 1.0f); + return (progress < 1.0f) ? (1.0f - (1.0f - t) * (1.0f - t)) : (t * t); +} + +// Map internal support power names +static UnicodeString formatPowerAction(const AsciiString& powerNameAscii) +{ + if (powerNameAscii == "SuperweaponScudStorm") return L"LAUNCHED A SCUD STORM!!!"; + if (powerNameAscii == "SuperweaponNeutronMissile") return L"LAUNCHED A NUKE MISSILE!!!"; + if (powerNameAscii == "SuperweaponParticleUplinkCannon") return L"FIRED A PARTICLE CANNON!!!"; + if (powerNameAscii == "Infa_SuperweaponInfantryParadrop") return L"DEPLOYED A CHINA INFANTRY PARADROP!"; + if (powerNameAscii == "Tank_SuperweaponTankParadrop") return L"DEPLOYED A TANK PARADROP!"; + if (powerNameAscii == "SuperweaponParadropAmerica") return L"DEPLOYED A USA INFANTRY PARADROP!"; + if (powerNameAscii == "SuperweaponLeafletDrop") return L"CALLED IN A LEAFLET DROP!!"; + if (powerNameAscii == "Early_SuperweaponLeafletDrop") return L"CALLED IN A LEAFLET DROP!!"; + if (powerNameAscii == "SuperweaponRebelAmbush") return L"CALLED IN THE REBEL AMBUSH!!"; + if (powerNameAscii == "SuperweaponArtilleryBarrage") return L"CALLED IN THE ARTILLERY BARRAGE!!"; + if (powerNameAscii == "SuperweaponEMPPulse") return L"CALLED IN AN EMP PULSE!!!"; + if (powerNameAscii == "SuperweaponDaisyCutter" || powerNameAscii == "AirF_SuperweaponDaisyCutter") return L"CALLED IN THE MOAB!!!"; + if (powerNameAscii == "SuperweaponClusterMines" || powerNameAscii == "Nuke_SuperweaponClusterMines") return L"CALLED IN A MINE DROP!!"; + if (powerNameAscii == "AirF_SuperweaponA10ThunderboltMissileStrike" || powerNameAscii == "SuperweaponA10ThunderboltMissileStrike") return L"CALLED IN AN A10 STRIKE!!"; + if (powerNameAscii == "AirF_SuperweaponSpectreGunship" || powerNameAscii == "SuperweaponSpectreGunship") return L"CALLED IN A SPECTRE GUNSHIP!!"; + if (powerNameAscii == "AirF_SuperweaponCarpetBomb" || powerNameAscii == "Nuke_SuperweaponChinaCarpetBomb" || + powerNameAscii == "Early_SuperweaponChinaCarpetBomb" || powerNameAscii == "SuperweaponChinaCarpetBomb") return L"CALLED IN A CARPET BOMB!!"; + if (powerNameAscii == "SuperweaponFrenzy" || powerNameAscii == "Early_SuperweaponFrenzy") return L"ACTIVATED THE FRENZY!"; + if (powerNameAscii == "SuperweaponCIAIntelligence") return L"JUST ACTIVATED THE CIA INTELLIGENCE!"; + if (powerNameAscii == "Slth_SuperweaponGPSScrambler" || powerNameAscii == "SuperweaponGPSScrambler") return L"ACTIVATED A GPS SCRAMBLER!"; + if (powerNameAscii == "SuperweaponSneakAttack") return L"OPENED A SNEAK ATTACK!!!"; + if (powerNameAscii == "SuperweaponAnthraxBomb") return L"DROPPED AN ANTHRAX BOMB!!!"; + + UnicodeString result = L"USED "; // Fallback for unmapped support powers + UnicodeString temp; + temp.translate(powerNameAscii); + result.concat(temp); + return result; +} + +void InGameUI::drawObserverNotifications(Int& x, Int& y) +{ + if (!TheInGameUI->getInputEnabled() || TheGameLogic->isIntroMoviePlaying() || + TheGameLogic->isLoadingMap() || TheInGameUI->isQuitMenuVisible() || + !TheGameLogic || TheGameLogic->getFrame() <= 1 || m_observerNotificationsHidden) + return; + + Player* localPlayer = ThePlayerList->getLocalPlayer(); + if (!localPlayer || !localPlayer->isPlayerObserver()) + return; + + updateObserverNotifications(TheGameLogic->getFrame()); + + if (m_observerNotifications.empty()) + return; + + // Ensure font resources initialized + if (!m_observerNotificationString) + refreshObserverNotificationResources(); + + if (!m_observerNotificationString || m_observerNotificationPointSize <= 0) + return; + + GameFont* notifFont = m_observerNotificationString->getFont(); + Int fontHeight = notifFont ? notifFont->height : m_observerNotificationPointSize; + + // Layout calculations + Int screenW = TheDisplay->getWidth(); + Int screenH = TheDisplay->getHeight(); + Real scale = (Real)screenW / 1920.0f; + scale = (scale < 0.7f) ? 0.7f : (scale > 2.0f) ? 2.0f : scale; + + Int baseX = Int(NOTIF_LEFT_MARGIN * scale); + Int baseY = (screenH / 2) - Int(NOTIF_VERTICAL_OFFSET * scale); + Int padX = Int(NOTIF_PADDING_X * scale); + Int padY = Int(NOTIF_PADDING_Y * scale); + Int boxSpacing = Int(NOTIF_BOX_SPACING * scale); + + Color bgColor = TheWindowManager->winMakeColor(0, 0, 0, 180); + Color borderColor = TheWindowManager->winMakeColor(255, 255, 255, 255); + + UnsignedInt nowMs = timeGetTime(); + + // Render active notifications in their fixed slots + for (size_t slot = 0; slot < m_observerNotifications.size(); ++slot) { + ObserverNotification& notif = m_observerNotifications[slot]; + if (!notif.active) + continue; + + // Compute animation state from render time + UnsignedInt ageMs = nowMs - notif.createdRenderMs; + Real progress = computeSlideProgress(ageMs); + + // Expire notification if animation complete + if (progress >= 2.0f) { + notif.active = false; + continue; + } + + // Compute slide position with easing + Real eased = applyEasing(progress); + m_observerNotificationString->setText(notif.message); + Int bgW = m_observerNotificationString->getWidth() + (padX * 2); + Int bgH = fontHeight + (padY * 2); + Int slotY = baseY + (slot * (bgH + boxSpacing)); + Int slideX = baseX - Int((bgW + baseX) * ((progress < 1.0f) ? (1.0f - eased) : eased)); + + // Draw background and border + TheWindowManager->winFillRect(bgColor, 1, slideX, slotY, slideX + bgW, slotY + bgH); + TheWindowManager->winFillRect(borderColor, 1, slideX, slotY, slideX + bgW, slotY + 1); + TheWindowManager->winFillRect(borderColor, 1, slideX, slotY + bgH - 1, slideX + bgW, slotY + bgH); + TheWindowManager->winFillRect(borderColor, 1, slideX, slotY, slideX + 1, slotY + bgH); + TheWindowManager->winFillRect(borderColor, 1, slideX + bgW - 1, slotY, slideX + bgW, slotY + bgH); + + // Brighten player color for readability + UnsignedInt r = (notif.color >> 16) & 0xFF; + UnsignedInt g = (notif.color >> 8) & 0xFF; + UnsignedInt b = notif.color & 0xFF; + UnsignedInt lr = r + UnsignedInt((255 - r) * BRIGHTNESS_BOOST); + UnsignedInt lg = g + UnsignedInt((255 - g) * BRIGHTNESS_BOOST); + UnsignedInt lb = b + UnsignedInt((255 - b) * BRIGHTNESS_BOOST); + + Color textColor = TheWindowManager->winMakeColor(lr, lg, lb, 255); + Color shadowColor = TheWindowManager->winMakeColor(0, 0, 0, 255); + m_observerNotificationString->draw(slideX + padX, slotY + padY, textColor, shadowColor); + } +} + +// Handle milestone initialization and triggers milestone checks once per second. +void InGameUI::updateObserverNotifications(UnsignedInt currentFrame) +{ + if (m_observerMilestones.empty()) { + m_observerMilestones.resize(MAX_SLOTS); + } + + static UnsignedInt lastCheckFrame = 0; + if (currentFrame - lastCheckFrame >= LOGICFRAMES_PER_SECOND) { + lastCheckFrame = currentFrame; + checkObserverMilestones(currentFrame); + } +} + +void InGameUI::checkObserverMilestones(UnsignedInt currentFrame) +{ + for (Int slotIndex = 0; slotIndex < MAX_SLOTS; ++slotIndex) { + const GameSlot* slot = TheGameInfo ? TheGameInfo->getConstSlot(slotIndex) : nullptr; + if (!slot || !slot->isOccupied()) + continue; + + AsciiString nameKeyStr; + nameKeyStr.format("player%d", slotIndex); + + if (!ThePlayerList || !TheNameKeyGenerator) + continue; + + Player* p = ThePlayerList->findPlayerWithNameKey(TheNameKeyGenerator->nameToKey(nameKeyStr)); + + if (!p || !p->isPlayerActive() || p->isPlayerObserver()) + continue; + + UnicodeString name = p->getPlayerDisplayName(); + if (name.isEmpty()) + continue; + + ObserverMilestone& milestone = m_observerMilestones[slotIndex]; + Color playerColor = p->getPlayerColor(); + + // Check rank milestones + Int rank = p->getRankLevel(); + if (rank >= 3 && !milestone.reachedLevel3) { + milestone.reachedLevel3 = true; + addObserverNotification(name, L" reached Rank 3!", playerColor); + } + if (rank >= 5 && !milestone.reachedLevel5) { + milestone.reachedLevel5 = true; + addObserverNotification(name, L" reached Rank 5!", playerColor); + } + + // Check economy milestones + Money* money = p->getMoney(); + if (!money) + continue; + + UnsignedInt cash = money->countMoney(); + UnsignedInt cpm = money->getCashPerMinute(); + + if (cash >= 100000 && !milestone.warnedFloating100k) { + milestone.warnedFloating100k = true; + addObserverNotification(name, L" is floating $100k!", playerColor); + } + + // Check income milestones in ascending order + struct IncomeThreshold { UnsignedInt amount; Bool& reached; const wchar_t* msg; }; + IncomeThreshold thresholds[] = { + { 10000, milestone.reached10kCPM, L" reached 10k/min income!" }, + { 20000, milestone.reached20kCPM, L" reached 20k/min income!!" }, + { 50000, milestone.reached50kCPM, L" reached 50k/min income!!!" }, + { 100000, milestone.reached100kCPM, L" reached 100k/min income!!!!" } + }; + + for (auto& threshold : thresholds) { + if (cpm >= threshold.amount && !threshold.reached) { + threshold.reached = true; + addObserverNotification(name, threshold.msg, playerColor); + break; // Only trigger one income milestone per check + } + } + } +} + +void InGameUI::addObserverNotification(const UnicodeString& playerName, const wchar_t* message, Color playerColor) +{ + UnicodeString fullMsg; + fullMsg.format(L"%ls%ls", playerName.str(), message); + addObserverNotificationRaw(fullMsg, playerColor); +} + +void InGameUI::addObserverNotificationRaw(const UnicodeString& message, Color color) +{ + UnsignedInt nowMs = timeGetTime(); + + // Reuse first inactive slot + for (auto& n : m_observerNotifications) + if (!n.active) + return n = { message, color, nowMs, true }, void(); + + // Expand if under limit + if (m_observerNotifications.size() < MAX_NOTIFICATIONS) { + m_observerNotifications.push_back({ message, color, nowMs, true }); + return; + } + + // Replace oldest active notification + auto* oldest = &m_observerNotifications[0]; + for (auto& n : m_observerNotifications) + if (n.active && n.createdRenderMs < oldest->createdRenderMs) + oldest = &n; + + oldest->message = message; + oldest->color = color; + oldest->createdRenderMs = nowMs; + oldest->active = true; +} + +void InGameUI::notifyGeneralPromotion(Player* player, ScienceType science) +{ + if (!player || !player->isPlayerActive() || player->isPlayerObserver()) + return; + + UnicodeString scienceName, description; + if (!TheScienceStore->getNameAndDescription(science, scienceName, description)) + return; + + UnicodeString msg; + msg.format(L"%ls purchased %ls", player->getPlayerDisplayName().str(), scienceName.str()); + addObserverNotificationRaw(msg, player->getPlayerColor()); +} + +void InGameUI::notifySpecialPowerUsed(Player* player, const SpecialPowerTemplate* powerTemplate) +{ + if (!player || !player->isPlayerActive() || !powerTemplate || player->isPlayerObserver()) + return; + + // Only notify for these support powers + switch (powerTemplate->getSpecialPowerType()) { + case SPECIAL_DAISY_CUTTER: case SPECIAL_CARPET_BOMB: case AIRF_SPECIAL_DAISY_CUTTER: + case SPECIAL_PARTICLE_UPLINK_CANNON: case SPECIAL_SCUD_STORM: case SPECIAL_NEUTRON_MISSILE: + case SPECIAL_AMBUSH: case EARLY_SPECIAL_LEAFLET_DROP: case EARLY_SPECIAL_FRENZY: + case SPECIAL_CLUSTER_MINES: case SPECIAL_EMP_PULSE: case SPECIAL_ANTHRAX_BOMB: + case SPECIAL_A10_THUNDERBOLT_STRIKE: case SPECIAL_ARTILLERY_BARRAGE: case SPECIAL_SPECTRE_GUNSHIP: + case SPECIAL_FRENZY: case SPECIAL_SNEAK_ATTACK: case SPECIAL_CHINA_CARPET_BOMB: case SPECIAL_CIA_INTELLIGENCE: + case SPECIAL_LEAFLET_DROP: case SPECIAL_TANK_PARADROP: case SPECIAL_PARADROP_AMERICA: + case NUKE_SPECIAL_CLUSTER_MINES: case AIRF_SPECIAL_A10_THUNDERBOLT_STRIKE: case AIRF_SPECIAL_SPECTRE_GUNSHIP: + case INFA_SPECIAL_PARADROP_AMERICA: case SLTH_SPECIAL_GPS_SCRAMBLER: case AIRF_SPECIAL_CARPET_BOMB: + case SPECIAL_GPS_SCRAMBLER: case EARLY_SPECIAL_CHINA_CARPET_BOMB: + break; + default: + return; + } + + UnicodeString msg; + msg.format(L"%ls %ls", player->getPlayerDisplayName().str(), formatPowerAction(powerTemplate->getName()).str()); + addObserverNotificationRaw(msg, player->getPlayerColor()); +} + + void InGameUI::drawObserverStats(Int& x, Int& y) { // game state checks @@ -6288,6 +6617,19 @@ void InGameUI::refreshObserverStatsResources(void) m_observerStatsLineStep = adjustedFontSize + 10; // vertical spacing between lines } +void InGameUI::refreshObserverNotificationResources(void) +{ + if (!m_observerNotificationString) + m_observerNotificationString = TheDisplayStringManager->newDisplayString(); + + m_observerNotificationPointSize = TheGlobalData->m_observerNotificationFontSize; + if (m_observerNotificationPointSize <= 0) + return; + + Int adjustedFontSize = TheGlobalLanguageData->adjustFontSize(m_observerNotificationPointSize); + m_observerNotificationString->setFont(TheWindowManager->winFindFont("Tahoma", adjustedFontSize, true)); +} + void InGameUI::refreshCustomUiResources(void) { refreshNetworkLatencyResources(); @@ -6295,6 +6637,7 @@ void InGameUI::refreshCustomUiResources(void) refreshSystemTimeResources(); refreshGameTimeResources(); refreshObserverStatsResources(); + refreshObserverNotificationResources(); } void InGameUI::refreshNetworkLatencyResources(void) diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp index 537479f78cc..3ded25e8cc7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp @@ -644,6 +644,14 @@ void SpecialPowerModule::aboutToDoSpecialPower( const Coord3D *location ) } } + // Notify the UI that this special power was activated (for observer notifications) + const Object* obj = getObject(); + const SpecialPowerTemplate* tpl = getSpecialPowerTemplate(); + if (obj && tpl && TheInGameUI) { + if (Player* p = obj->getControllingPlayer()) + TheInGameUI->notifySpecialPowerUsed(p, tpl); + } + // get module data const SpecialPowerModuleData *modData = getSpecialPowerModuleData(); From b485cb9e8271b302b94bc5325065acdcf7004a91 Mon Sep 17 00:00:00 2001 From: MrS-ibra Date: Fri, 23 Jan 2026 17:23:42 +0300 Subject: [PATCH 2/4] tweaks --- GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h | 1 - .../Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index de7103bbe23..d670c273f79 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -678,7 +678,6 @@ friend class Drawable; // for selection/deselection transactions }; struct ObserverMilestone { - //Int playerId; Bool reachedLevel3; Bool reachedLevel5; Bool reached10kCPM; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp index be84d8c8bc7..3eda941e651 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp @@ -943,7 +943,7 @@ Int OptionPreferences::getRenderFpsFontSize(void) Int OptionPreferences::getObserverNotificationFontSize(void) { OptionPreferences::const_iterator it = find("ObserverNotificationFontSize"); if (it == end()) - return 12; + return 10; Int fontSize = atoi(it->second.str()); if (fontSize < 0) fontSize = 0; From 688fb3cc43d5d4dbc1c15950866853ceed9a288f Mon Sep 17 00:00:00 2001 From: MrS-ibra Date: Tue, 27 Jan 2026 15:24:12 +0300 Subject: [PATCH 3/4] =?UTF-8?q?Refactor=20if=E2=80=91chain=20into=20data?= =?UTF-8?q?=20driven=20lookup=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameEngine/Source/GameClient/InGameUI.cpp | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 0d1ef752e11..b7d8dd00067 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -6028,28 +6028,56 @@ static Real applyEasing(Real progress) // Map internal support power names static UnicodeString formatPowerAction(const AsciiString& powerNameAscii) { - if (powerNameAscii == "SuperweaponScudStorm") return L"LAUNCHED A SCUD STORM!!!"; - if (powerNameAscii == "SuperweaponNeutronMissile") return L"LAUNCHED A NUKE MISSILE!!!"; - if (powerNameAscii == "SuperweaponParticleUplinkCannon") return L"FIRED A PARTICLE CANNON!!!"; - if (powerNameAscii == "Infa_SuperweaponInfantryParadrop") return L"DEPLOYED A CHINA INFANTRY PARADROP!"; - if (powerNameAscii == "Tank_SuperweaponTankParadrop") return L"DEPLOYED A TANK PARADROP!"; - if (powerNameAscii == "SuperweaponParadropAmerica") return L"DEPLOYED A USA INFANTRY PARADROP!"; - if (powerNameAscii == "SuperweaponLeafletDrop") return L"CALLED IN A LEAFLET DROP!!"; - if (powerNameAscii == "Early_SuperweaponLeafletDrop") return L"CALLED IN A LEAFLET DROP!!"; - if (powerNameAscii == "SuperweaponRebelAmbush") return L"CALLED IN THE REBEL AMBUSH!!"; - if (powerNameAscii == "SuperweaponArtilleryBarrage") return L"CALLED IN THE ARTILLERY BARRAGE!!"; - if (powerNameAscii == "SuperweaponEMPPulse") return L"CALLED IN AN EMP PULSE!!!"; - if (powerNameAscii == "SuperweaponDaisyCutter" || powerNameAscii == "AirF_SuperweaponDaisyCutter") return L"CALLED IN THE MOAB!!!"; - if (powerNameAscii == "SuperweaponClusterMines" || powerNameAscii == "Nuke_SuperweaponClusterMines") return L"CALLED IN A MINE DROP!!"; - if (powerNameAscii == "AirF_SuperweaponA10ThunderboltMissileStrike" || powerNameAscii == "SuperweaponA10ThunderboltMissileStrike") return L"CALLED IN AN A10 STRIKE!!"; - if (powerNameAscii == "AirF_SuperweaponSpectreGunship" || powerNameAscii == "SuperweaponSpectreGunship") return L"CALLED IN A SPECTRE GUNSHIP!!"; - if (powerNameAscii == "AirF_SuperweaponCarpetBomb" || powerNameAscii == "Nuke_SuperweaponChinaCarpetBomb" || - powerNameAscii == "Early_SuperweaponChinaCarpetBomb" || powerNameAscii == "SuperweaponChinaCarpetBomb") return L"CALLED IN A CARPET BOMB!!"; - if (powerNameAscii == "SuperweaponFrenzy" || powerNameAscii == "Early_SuperweaponFrenzy") return L"ACTIVATED THE FRENZY!"; - if (powerNameAscii == "SuperweaponCIAIntelligence") return L"JUST ACTIVATED THE CIA INTELLIGENCE!"; - if (powerNameAscii == "Slth_SuperweaponGPSScrambler" || powerNameAscii == "SuperweaponGPSScrambler") return L"ACTIVATED A GPS SCRAMBLER!"; - if (powerNameAscii == "SuperweaponSneakAttack") return L"OPENED A SNEAK ATTACK!!!"; - if (powerNameAscii == "SuperweaponAnthraxBomb") return L"DROPPED AN ANTHRAX BOMB!!!"; + struct Entry { + const char* key; + const wchar_t* value; + }; + + static const Entry table[] = { + {"SuperweaponScudStorm", L"LAUNCHED A SCUD STORM!!!"}, + {"SuperweaponNeutronMissile", L"LAUNCHED A NUKE MISSILE!!!"}, + {"SuperweaponParticleUplinkCannon", L"FIRED A PARTICLE CANNON!!!"}, + {"SuperweaponAnthraxBomb", L"DROPPED AN ANTHRAX BOMB!!!"}, + {"SuperweaponRebelAmbush", L"CALLED IN THE REBEL AMBUSH!!"}, + {"SuperweaponArtilleryBarrage", L"CALLED IN THE ARTILLERY BARRAGE!!"}, + {"SuperweaponEMPPulse", L"CALLED IN AN EMP PULSE!!!"}, + {"SuperweaponCIAIntelligence", L"JUST ACTIVATED THE CIA INTELLIGENCE!"}, + {"SuperweaponSneakAttack", L"OPENED A SNEAK ATTACK!!!"}, + + {"SuperweaponDaisyCutter", L"CALLED IN THE MOAB!!!"}, + {"AirF_SuperweaponDaisyCutter", L"CALLED IN THE MOAB!!!"}, + + {"SuperweaponClusterMines", L"CALLED IN A MINE DROP!!"}, + {"Nuke_SuperweaponClusterMines", L"CALLED IN A MINE DROP!!"}, + + {"AirF_SuperweaponA10ThunderboltMissileStrike", L"CALLED IN AN A10 STRIKE!!"}, + {"SuperweaponA10ThunderboltMissileStrike", L"CALLED IN AN A10 STRIKE!!"}, + + {"AirF_SuperweaponSpectreGunship", L"CALLED IN A SPECTRE GUNSHIP!!"}, + {"SuperweaponSpectreGunship", L"CALLED IN A SPECTRE GUNSHIP!!"}, + + {"AirF_SuperweaponCarpetBomb", L"CALLED IN A CARPET BOMB!!"}, + {"Nuke_SuperweaponChinaCarpetBomb", L"CALLED IN A CARPET BOMB!!"}, + {"Early_SuperweaponChinaCarpetBomb", L"CALLED IN A CARPET BOMB!!"}, + {"SuperweaponChinaCarpetBomb", L"CALLED IN A CARPET BOMB!!"}, + + {"SuperweaponFrenzy", L"ACTIVATED THE FRENZY!"}, + {"Early_SuperweaponFrenzy", L"ACTIVATED THE FRENZY!"}, + + {"Slth_SuperweaponGPSScrambler", L"ACTIVATED A GPS SCRAMBLER!"}, + {"SuperweaponGPSScrambler", L"ACTIVATED A GPS SCRAMBLER!"}, + + {"Infa_SuperweaponInfantryParadrop", L"DEPLOYED A CHINA INFANTRY PARADROP!"}, + {"Tank_SuperweaponTankParadrop", L"DEPLOYED A TANK PARADROP!"}, + {"SuperweaponParadropAmerica", L"DEPLOYED A USA INFANTRY PARADROP!"}, + + {"SuperweaponLeafletDrop", L"CALLED IN A LEAFLET DROP!!"}, + {"Early_SuperweaponLeafletDrop", L"CALLED IN A LEAFLET DROP!!"}, + }; + + for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); ++i) + if (powerNameAscii == table[i].key) + return table[i].value; UnicodeString result = L"USED "; // Fallback for unmapped support powers UnicodeString temp; @@ -7019,3 +7047,4 @@ void InGameUI::drawGameTime() } + From 86e5dc092e373387b9e8123baec180cdf1e5200e Mon Sep 17 00:00:00 2001 From: MrS-ibra Date: Thu, 29 Jan 2026 17:41:09 +0300 Subject: [PATCH 4/4] tweak --- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index b7d8dd00067..654d6981005 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -6075,9 +6075,9 @@ static UnicodeString formatPowerAction(const AsciiString& powerNameAscii) {"Early_SuperweaponLeafletDrop", L"CALLED IN A LEAFLET DROP!!"}, }; - for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); ++i) - if (powerNameAscii == table[i].key) - return table[i].value; + for (const Entry& entry : table) + if (powerNameAscii == entry.key) + return entry.value; UnicodeString result = L"USED "; // Fallback for unmapped support powers UnicodeString temp; @@ -7048,3 +7048,4 @@ void InGameUI::drawGameTime() +