Skip to content

fix(view): Implement state for user controlled camera to properly distinguish between scripted and user camera#2363

Open
xezon wants to merge 3 commits intoTheSuperHackers:mainfrom
xezon:xezon/add-user-controlled-camera-state
Open

fix(view): Implement state for user controlled camera to properly distinguish between scripted and user camera#2363
xezon wants to merge 3 commits intoTheSuperHackers:mainfrom
xezon:xezon/add-user-controlled-camera-state

Conversation

@xezon
Copy link

@xezon xezon commented Feb 28, 2026

This change implements a new state for the user controlled camera to properly distinguish between scripted and user camera.

Originally, there is no 100% clear cut between user camera and scripted camera movements. To make a clear cut, all user input camera controls are now explicitly called through "user" functions that set a flag for the user controlled camera. This will be very relevant in later changes when different logic needs to apply in user and scripted camera, because the scripted camera is far more sensitive to changes and to preserve its original look there needs to be a very clear distinction.

The scripted camera state is set by doing one of the 5 scripted camera modes for waypoint path, pitch, zoom, angle and follow object.

m_viewLockedUntilFrame was changed to m_userControlLockedUntilFrame. m_viewLockedUntilFrame no longer worked correctly after #2280 and broke the Replay Player Camera, because the locked view then also applied to the Replay Player Camera, which of course was not intended. Instead, now user control functions are blocked only while the Replay Player Camera is active.

Known issues

  • USA01 intro cutscene is still broken (is fixed in future change)
  • Replay Player Camera has zoom issues when logic step is decoupled from render update (is fixed in future change)

TODO

  • Replicate to Generals

@xezon xezon added this to the Camera Polish milestone Feb 28, 2026
@xezon xezon added Minor Severity: Minor < Major < Critical < Blocker Gen Relates to Generals ZH Relates to Zero Hour Fix Is fixing something, but is not user facing labels Feb 28, 2026
@greptile-apps
Copy link

greptile-apps bot commented Feb 28, 2026

Greptile Summary

This PR establishes a clear architectural distinction between user-controlled and scripted camera movements by introducing explicit state tracking and wrapper functions. All user input camera controls now route through user* wrapper functions (e.g., userLookAt, userSetAngle, userZoom) that set the m_isUserControlled flag and stop any active scripted camera movements. The scripted camera state is set when using waypoint paths, pitch/zoom/angle animations, or object following.

Key changes:

  • Renamed m_viewLockedUntilFrame to m_userControlLockedUntilFrame to specifically lock user input rather than all camera movement
  • Added m_isUserControlled boolean to track camera control source
  • Implemented template-based doUserAction() wrappers that check lock state, stop scripted camera, and set user control flag
  • Updated all user input paths across 12 files to use new wrapper functions
  • Fixed Replay Player Camera by using user control lock instead of view lock, allowing replay camera to function while blocking actual user input
  • Optimized scroll speed check by using squared length comparison (m_scrollAmountCutoffSqr)

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk - it's a well-structured refactoring with consistent changes across all affected files
  • The refactoring follows a clear, consistent pattern across all 12 files. The template-based wrapper functions provide type-safe encapsulation of the user control logic. The previous issue with squared length comparison has been resolved. All user input code paths have been systematically updated to use the new wrapper functions, and the distinction between user and scripted camera is now architecturally sound.
  • No files require special attention

Important Files Changed

Filename Overview
Core/GameEngine/Include/GameClient/View.h Added user control wrapper functions (userSetPosition, userSetAngle, etc.) using templates, renamed m_viewLockedUntilFrame to m_userControlLockedUntilFrame, added m_isUserControlled flag for state tracking
Core/GameEngine/Source/GameClient/View.cpp Implemented isUserControlLocked() check using frame comparison, updated initialization and reset logic for new user control state members
Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp Main implementation: uses squared length comparison for scroll speed, handles user vs scripted camera state transitions, updated reset() to use direct setters, added logic to treat non-user-controlled camera as scripted movement
Core/Libraries/Include/Lib/BaseType.h Added lengthSqr() helper method to Coord2D for efficient squared length calculation (avoids sqrt)
GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp Converted mouse-driven camera controls to user functions (userSetAngle, userZoom, userScrollBy), uses direct scrollBy() when resetting scroll to zero (non-user action)
GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp Replay Player Camera now uses user functions to enable auto-zoom over terrain, changed lock function to lockUserControlUntilFrame(), added explicit scroll reset

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Start([User Input: Camera Action]) --> CheckLock{isUserControlLocked?}
    CheckLock -->|Yes| Blocked[Return false - Action Blocked]
    CheckLock -->|No| StopScripted[stopDoingScriptedCamera]
    StopScripted --> SetFlag[setUserControlled = true]
    SetFlag --> Execute[Execute Camera Function]
    Execute --> Return[Return true - Action Completed]
    
    Script([Scripted Camera Action]) --> AddState[addScriptedState]
    AddState --> SetScripted[setUserControlled = false]
    SetScripted --> ScriptExecute[Execute Camera Function]
    
    Update([W3DView::update]) --> CheckUserControlled{m_isUserControlled?}
    CheckUserControlled -->|false| SetDidScripted[didScriptedMovement = true]
    CheckUserControlled -->|true| CheckScriptedState{isDoingScriptedCamera?}
    CheckScriptedState -->|true| SetDidScripted
    CheckScriptedState -->|false| Normal[Normal Height Adjustment]
    SetDidScripted --> PreserveHeight[Preserve Scripted Camera Height]
Loading

Last reviewed commit: 34f0439

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Coord3D arbitraryPos = { 0, 0, 0 };
// Just move the camera to 0, 0, 0. It'll get repositioned at the beginning of the next game
// anyways.
resetCamera(&arbitraryPos, 1, 0.0f, 0.0f);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed from resetCamera to set function, because resetCamera is meant to be used by scripted actions and would set the user controlled flag to false, which we do not want here.

m_heightAboveGround = m_currentHeightAboveGround;
}

const Bool isScrolling = TheInGameUI && TheInGameUI->isScrolling();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I have decoupled the scrolling condition from TheInGameUI. This is necessary, because we cannot trust that the mouse input equals the actual camera scrolling, if the camera is allowed to block user input, like we do for the Replay Player Camera for every full logic frame.

else
{
m_scrollAmount.x = 0;
m_scrollAmount.y = 0;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now allows to set zero scroll speed, to signal that it is no longer scrolling. Previously it would just leave the last value and we relied on TheInGameUI->isScrolling() to tell whether it is scrolling.

void W3DView::setZoom(Real z)
{
View::setZoom(z);
m_heightAboveGround = m_maxHeightAboveGround * z;
Copy link
Author

@xezon xezon Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary, because the m_heightAboveGround setting drives the auto-zoom for terrain elevation. Function setZoomToDefault already sets m_heightAboveGround correctly.

//-------------------------------------------------------------------------------------------------
void W3DView::setUserControlled(Bool value)
{
if (m_isUserControlled != value)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition does not make much sense right now but will do later because more code is added in the conditional body.


if (!m_isUserControlled)
{
didScriptedMovement = true;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that the scripted camera state will prevail even if no script camera action is currently running, for as long as the user did no camera inputs. I cannot recall why this was necessary, but there are definitely gaps in Mission cutscenes where no scripted camera movements happen and it makes sense to keep the scripted state active for as long as the user is not controlling the camera.

Real x, y;

Real length() const { return (Real)sqrt( x*x + y*y ); }
Real lengthSqr() const { return x*x + y*y; }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor length to

return (Real)sqrt(lengthSqr())

maybe?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is fine to have them independent of each other. Their code will never change.


}

Bool View::userSetPosition(const Coord3D *pos)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the Bool value is ever used and can be made void instead.

In that case, I would also prefer a refactor reducing duplicate code

Bool View::userSetPosition(const Coord3D *pos)
{
	if (userHasControl())
		setPosition(pos);
}

Bool View::userSetAngle(Real radians)
{
	if (userHasControl())
		setAngle(radians);
}

Bool View::userHasControl()
{
	if (isUserControlLocked())
		return false;

	stopDoingScriptedCamera();
	setUserControlled(true);
	return true;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally put the bool return because it communicates to the programmer that this function CAN fail to set the position.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amount of code for user camera functions reduced by using templates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Fix Is fixing something, but is not user facing Gen Relates to Generals Minor Severity: Minor < Major < Critical < Blocker ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants