Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Core/GameEngine/Include/Common/GameCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ enum
LOGICFRAMES_PER_SECOND = WWSyncPerSecond,
MSEC_PER_SECOND = 1000
};
// Live-observer broadcast delay. In seconds, not frames, so it survives a change of logic tick
// rate; lives here rather than Recorder.h so OptionPreferences (Core) can share it.
enum
{
LIVE_DELAY_SECONDS_DEFAULT = 15, // used when the streamer or relay supplies nothing
LIVE_DELAY_SECONDS_MAX = 600
};

const Real LOGICFRAMES_PER_MSEC_REAL = (((Real)LOGICFRAMES_PER_SECOND) / ((Real)MSEC_PER_SECOND));
const Real MSEC_PER_LOGICFRAME_REAL = (((Real)MSEC_PER_SECOND) / ((Real)LOGICFRAMES_PER_SECOND));
const Real LOGICFRAMES_PER_SECONDS_REAL = (Real)LOGICFRAMES_PER_SECOND;
Expand Down
12 changes: 11 additions & 1 deletion Core/GameEngine/Include/Common/OptionPreferences.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,14 @@ class OptionPreferences : public UserPreferences
Int getObserverNotificationFontSize(void);
Bool getObserverNotificationSpecialPowerUsage(void);
Bool getObserverNotificationSpecialPowerPurchase(void);
Bool getObserverNotificationMilestone(void);};
Bool getObserverNotificationMilestone(void);

Bool getLiveStreamEnabled() const;
void setLiveStreamEnabled(Bool enabled);
Bool getLiveStreamCanStream() const;

/// Broadcast delay in seconds that this client asks the relay to apply to its observers.
/// Clamped to [0, LIVE_DELAY_SECONDS_MAX]; absent or malformed reads as the default.
Int getLiveStreamDelaySeconds() const;
void setLiveStreamDelaySeconds(Int seconds);
};
5 changes: 5 additions & 0 deletions Core/GameEngine/Include/GameClient/GameWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ class GameWindow : public MemoryPoolObject

// --------------------------------------------------------------------------
// new methods for setting images
/// Adopt another window's whole visual state, so a gadget created in code can look like one
/// defined in a .wnd instead of the placeholder gogoGadget*(..., defaultVisual=TRUE) leaves.
/// Source and destination must be the same gadget type for the draw-data slots to line up.
void winCopyVisualsFrom(GameWindow* src);

Int winSetEnabledImage(Int index, const Image* image);
Int winSetEnabledColor(Int index, Color color);
Int winSetEnabledBorderColor(Int index, Color color);
Expand Down
65 changes: 65 additions & 0 deletions Core/GameEngine/Source/Common/OptionPreferences.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

#include "Common/AudioSettings.h"
#include "Common/GameAudio.h"
#include "Common/GameCommon.h" // LIVE_DELAY_SECONDS_DEFAULT / _MAX
#include "Common/GameLOD.h"
#include "Common/GlobalData.h"
#include "Common/OptionPreferences.h"
Expand Down Expand Up @@ -170,6 +171,70 @@ Bool OptionPreferences::getObserverNotificationMilestone(void)
return FALSE;
}

Bool OptionPreferences::getLiveStreamEnabled() const
{
OptionPreferences::const_iterator it = find("LiveStreamEnabled");
if (it == end())
return TRUE;
if (stricmp(it->second.str(), "yes") == 0)
{
return TRUE;
}
return FALSE;
}

void OptionPreferences::setLiveStreamEnabled(Bool enabled)
{
(*this)["LiveStreamEnabled"] = enabled ? "yes" : "no";
}

Int OptionPreferences::getLiveStreamDelaySeconds() const
{
OptionPreferences::const_iterator it = find("LiveObserverDelaySeconds");
if (it == end())
return LIVE_DELAY_SECONDS_DEFAULT;

// atoi returns 0 for junk, which is indistinguishable from a deliberate 0 - and silently
// streaming with no delay would defeat the spoiler window entirely.
const char* str = it->second.str();
if (str == nullptr || *str == '\0')
return LIVE_DELAY_SECONDS_DEFAULT;
for (const char* c = str; *c != '\0'; ++c)
{
if (*c < '0' || *c > '9')
return LIVE_DELAY_SECONDS_DEFAULT;
}

Int seconds = atoi(str);
if (seconds > LIVE_DELAY_SECONDS_MAX)
return LIVE_DELAY_SECONDS_MAX;
return seconds;
}

void OptionPreferences::setLiveStreamDelaySeconds(Int seconds)
{
if (seconds < 0)
seconds = 0;
if (seconds > LIVE_DELAY_SECONDS_MAX)
seconds = LIVE_DELAY_SECONDS_MAX;

AsciiString value;
value.format("%d", seconds);
(*this)["LiveObserverDelaySeconds"] = value;
}

Bool OptionPreferences::getLiveStreamCanStream() const
{
OptionPreferences::const_iterator it = find("LiveStreamCanStream");
if (it == end())
return TRUE;
if (stricmp(it->second.str(), "yes") == 0)
{
return TRUE;
}
return FALSE;
}

Int OptionPreferences::getObserverStatsFontSize(void)
{
OptionPreferences::const_iterator it = find("ObserverStatsFontSize");
Expand Down
34 changes: 34 additions & 0 deletions Core/GameEngine/Source/GameClient/GUI/GameWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,40 @@ void GameWinDefaultDraw( GameWindow *window, WinInstanceData *instData )

}

// GameWindow::winCopyVisualsFrom =============================================
/** Adopt another window's complete visual state. See the header for why. */
//=============================================================================
void GameWindow::winCopyVisualsFrom( GameWindow *src )
{
if( src == NULL )
return;

for( Int i = 0; i < MAX_DRAW_DATA; ++i )
{
winSetEnabledImage( i, src->winGetEnabledImage( i ) );
winSetEnabledColor( i, src->winGetEnabledColor( i ) );
winSetEnabledBorderColor( i, src->winGetEnabledBorderColor( i ) );

winSetDisabledImage( i, src->winGetDisabledImage( i ) );
winSetDisabledColor( i, src->winGetDisabledColor( i ) );
winSetDisabledBorderColor( i, src->winGetDisabledBorderColor( i ) );

winSetHiliteImage( i, src->winGetHiliteImage( i ) );
winSetHiliteColor( i, src->winGetHiliteColor( i ) );
winSetHiliteBorderColor( i, src->winGetHiliteBorderColor( i ) );
}

WinInstanceData *srcData = src->winGetInstanceData();
if( srcData != NULL )
{
winSetEnabledTextColors( srcData->m_enabledText.color, srcData->m_enabledText.borderColor );
winSetDisabledTextColors( srcData->m_disabledText.color, srcData->m_disabledText.borderColor );
winSetHiliteTextColors( srcData->m_hiliteText.color, srcData->m_hiliteText.borderColor );
if( srcData->getFont() != NULL )
winSetFont( srcData->getFont() );
}
}

// GameWindow::winSetEnabledImage =============================================
/** Set an enabled image into the draw data for the enabled state */
//=============================================================================
Expand Down
32 changes: 29 additions & 3 deletions Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
#include "Common/Player.h"
#include "Common/PlayerList.h"
#include "Common/PlayerTemplate.h"
#include "Common/Recorder.h"
#include "GameClient/CampaignManager.h"
#include "GameClient/Display.h"
#include "GameClient/GadgetProgressBar.h"
Expand Down Expand Up @@ -1268,7 +1269,10 @@ void MultiPlayerLoadScreen::init( GameInfo *game )
m_mapPreview = TheWindowManager->winGetWindowFromId( m_loadScreen,TheNameKeyGenerator->nameToKey( "MultiplayerLoadScreen.wnd:WinMapPreview"));
GameSlot *lSlot = game->getSlot(game->getLocalSlotNum());
const PlayerTemplate* pt;
if (lSlot->getPlayerTemplate() >= 0)
// A live observer is not in the slot list, so getLocalSlotNum() can legitimately find nobody and
// hand back -1, which getSlot() answers with NULL. FactionObserver is the right general to show
// for exactly that case, and it is already the fallback below.
if (lSlot && lSlot->getPlayerTemplate() >= 0)
pt = ThePlayerTemplateStore->getNthPlayerTemplate(lSlot->getPlayerTemplate());
else
pt = ThePlayerTemplateStore->findPlayerTemplate( TheNameKeyGenerator->nameToKey("FactionObserver") );
Expand Down Expand Up @@ -1390,6 +1394,9 @@ void MultiPlayerLoadScreen::init( GameInfo *game )
const PlayerTemplate* pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate());
GadgetStaticTextSetText(m_playerSide[netSlot], pt ? pt->getDisplayName() : slot->getApparentPlayerTemplateDisplayName());
#else
// A live observer needs no special case here: isSlotLocalAlly() now recognises one, so the
// apparent-* accessors already hand back the real side, colour and start position instead of
// masking the whole board to "Random".
GadgetStaticTextSetText(m_playerSide[netSlot], slot->getApparentPlayerTemplateDisplayName());
#endif

Expand Down Expand Up @@ -1462,10 +1469,29 @@ void MultiPlayerLoadScreen::update( Int percent )
TheNetwork->updateLoadProgress( percent );
TheNetwork->liteupdate();
}
else
else if (percent <= 100)
{
if (percent <= 100)
#if defined(GENERALS_ONLINE)
// A live observer loads alone: there is no network to carry anyone else's progress, and it
// is not in the slot list to have a bar of its own, so every bar would sit at zero for the
// whole load. Our own percentage is the only figure there is, so show it for each player -
// the bars then read as "the load is this far along", which is what they are here for.
const Bool liveObserver = (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER);
#else
const Bool liveObserver = FALSE;
#endif
if (liveObserver)
{
for (Int slot = 0; slot < MAX_SLOTS; ++slot)
{
if (m_playerLookup[slot] != -1)
TheGameLogic->processProgress( slot, percent );
}
}
else
{
TheGameLogic->processProgress( TheGameInfo->getLocalSlotNum(), percent );
}
}

//GadgetProgressBarSetProgress(m_progressBars[TheNetwork->getLocalPlayerID()], percent );
Expand Down
90 changes: 89 additions & 1 deletion Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
#include "Common/PlayerTemplate.h"
#include "Common/Radar.h"
#include "Common/Recorder.h"
#if defined(GENERALS_ONLINE)
#include "Common/LiveObserver.h"
#endif
#include "Common/SpecialPower.h"
#include "Common/StatsCollector.h"
#include "Common/ThingTemplate.h"
Expand Down Expand Up @@ -93,6 +96,22 @@
#include "WW3D2/ww3d.h"
#include "../OnlineServices_Init.h"

// A live-observer session runs as a replay game, but its chat window is meaningful: Enter
// sends to the spectator channel instead of the mesh. Constant FALSE outside GeneralsOnline
// so the call sites below need no guards of their own - this file is shared with Generals,
// which has neither LiveObserver nor the recorder mode.
//
// Constant FALSE outside GeneralsOnline so the call sites below need no guards of their own:
// this file is shared with Generals, which has neither LiveObserver nor the recorder mode.
static Bool IsLiveObserverSession()
{
#if defined(GENERALS_ONLINE)
return TheRecorder != nullptr && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER;
#else
return FALSE;
#endif
}

#if defined(RTS_DEBUG)
/*non-static*/ Real TheSkateDistOverride = 0.0f;

Expand Down Expand Up @@ -3255,6 +3274,8 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage

//-----------------------------------------------------------------------------------------
case GameMessage::MSG_META_CHAT_ALLIES:
// Deliberately not opened for a live observer: they have no allies, and every send
// from that window goes to the spectator channel regardless of the type shown.
if (TheGameLogic->isInMultiplayerGame() && !TheGameLogic->isInReplayGame())
{
Player *localPlayer = ThePlayerList->getLocalPlayer();
Expand All @@ -3269,7 +3290,7 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage

//-----------------------------------------------------------------------------------------
case GameMessage::MSG_META_CHAT_EVERYONE:
if (TheGameLogic->isInMultiplayerGame() && !TheGameLogic->isInReplayGame())
if ((TheGameLogic->isInMultiplayerGame() && !TheGameLogic->isInReplayGame()) || IsLiveObserverSession())
{
Player *localPlayer = ThePlayerList->getLocalPlayer();
// TheSuperHackers @tweak skyaero 19/07/2025 Observers can now chat
Expand Down Expand Up @@ -3563,6 +3584,17 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage
{
if( TheGlobalData )
{
#if defined(GENERALS_ONLINE)
// Fast forward is disabled once within the broadcast delay of live, so it can only
// ever close a backlog, never catch up to the real game and spoil it.
if (TheLiveObserver && TheLiveObserver->isWithinBroadcastDelay(TheGameLogic->getFrame()))
{
TheInGameUI->messageNoFormat(
TheGameText->FETCH_OR_SUBSTITUTE("GUI:FF_DISABLED_LIVE", L"Fast Forward is disabled in live mode"));
disp = DESTROY_MESSAGE;
break;
}
#endif
#if !defined(_ALLOW_DEBUG_CHEATS_IN_RELEASE)//may be defined in GameCommon.h
if (TheGameLogic->isInReplayGame())
#endif
Expand All @@ -3582,6 +3614,17 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage
case GameMessage::MSG_META_TOGGLE_PAUSE:
case GameMessage::MSG_META_TOGGLE_PAUSE_ALT:
{
#if defined(GENERALS_ONLINE)
// P toggles the user's intent only; the observer recomputes the actual pause as
// (userPaused || waitingForData) on its next poll. Calling setGamePaused() here would
// let the buffering gate re-pause on the next tick and discard the user's intent.
if (IsLiveObserverSession() && TheLiveObserver)
{
TheLiveObserver->toggleUserPause();
disp = DESTROY_MESSAGE;
break;
}
#endif
if (!TheGameLogic->isInMultiplayerGame())
{
if (TheGameLogic->isGamePaused())
Expand Down Expand Up @@ -3933,6 +3976,51 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage

disp = DESTROY_MESSAGE;
}
else if (key == KEY_F6)
{
if (TheInGameUI)
{
TheInGameUI->toggleLiveObserverStatusVisible();
if (TheControlBar && TheControlBar->isObserverControlBarOn())
TheInGameUI->messageNoFormat(TheInGameUI->isLiveObserverStatusVisible()
? TheGameText->FETCH_OR_SUBSTITUTE("GUI:LiveStatusOn", L"Live status bar ON (F6)")
: TheGameText->FETCH_OR_SUBSTITUTE("GUI:LiveStatusOff", L"Live status bar OFF (F6)"));
}
disp = DESTROY_MESSAGE;
}
// Cycles auto (spoiler-gated) -> forced ON -> off.
else if (key == KEY_F7)
{
if (IsLiveObserverSession() && TheLiveObserver)
{
const LiveObserver::SpectatorChatMode mode = TheLiveObserver->toggleSpectatorChatMode();
UnicodeString label;
if (mode == LiveObserver::SPECTATOR_CHAT_AUTO)
label = TheGameText->FETCH_OR_SUBSTITUTE("GUI:SpecChatAuto", L"Spectator chat: auto (F7)");
else if (mode == LiveObserver::SPECTATOR_CHAT_FORCED_ON)
label = TheGameText->FETCH_OR_SUBSTITUTE("GUI:SpecChatOn", L"Spectator chat: forced ON (F7)");
else
label = TheGameText->FETCH_OR_SUBSTITUTE("GUI:SpecChatOff", L"Spectator chat: OFF (F7)");
if (TheInGameUI)
TheInGameUI->messageNoFormat(label);
}
disp = DESTROY_MESSAGE;
}
// Rate-matched playback on/off. Following an 11 fps match means watching it at 11 fps
// and staying as far behind as you already are; full speed spends the backlog instead
// and closes the gap, at the price of stalling once there is no backlog left.
else if (key == KEY_F8)
{
if (IsLiveObserverSession() && TheLiveObserver)
{
const Bool matching = TheLiveObserver->togglePaceMatching();
if (TheInGameUI)
TheInGameUI->messageNoFormat(matching
? TheGameText->FETCH_OR_SUBSTITUTE("GUI:PaceMatchOn", L"Playback: match the game's speed (F8)")
: TheGameText->FETCH_OR_SUBSTITUTE("GUI:PaceMatchOff", L"Playback: full speed (F8)"));
}
disp = DESTROY_MESSAGE;
}

break;
}
Expand Down
16 changes: 16 additions & 0 deletions Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@

#include "Common/GameUtility.h"
#include "Common/INI.h"
#if defined(GENERALS_ONLINE)
#include "Common/LiveObserver.h"
#endif
#include "Common/MessageStream.h"
#include "Common/Player.h"
#include "Common/PlayerList.h"
Expand Down Expand Up @@ -636,6 +639,19 @@ void MetaEventTranslator::onKeyPressed(GameMessageDisposition &disp, Int systemK
if( TheGlobalData && TheGameLogic->isInReplayGame())
#endif
{
#if defined(GENERALS_ONLINE)
// Same broadcast-delay rule as CommandXlat's MSG_META_TOGGLE_FAST_FORWARD_REPLAY;
// this path exists because the translator is disabled during cinematics.
if (TheLiveObserver && TheLiveObserver->isWithinBroadcastDelay(TheGameLogic->getFrame()))
{
if (TheInGameUI)
TheInGameUI->messageNoFormat(
TheGameText->FETCH_OR_SUBSTITUTE("GUI:FF_DISABLED_LIVE", L"Fast Forward is disabled in live mode"));
disp = KEEP_MESSAGE;
break;
}
#endif

if ( TheWritableGlobalData )
TheWritableGlobalData->m_TiVOFastMode = 1 - TheGlobalData->m_TiVOFastMode;

Expand Down
Loading