UI/CausalPauseMenu.razor
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@namespace Causal
<root class="@RootClass">
<div class="pause-layout">
<div class="pause-hitbox" @ref=_resumeHitbox @onclick=@Resume></div>
<div class="pause-hitbox" @ref=_settingsHitbox @onclick=@ToggleSettings></div>
<div class="pause-hitbox" @ref=_quitHitbox @onclick=@QuitGame></div>
</div>
<CausalSettingsPanel style="@SettingsStyle" @ref=SettingsView></CausalSettingsPanel>
</root>
@code
{
public static CausalPauseMenu Instance { get; private set; }
public CausalSettingsPanel SettingsView { get; set; }
public static ViewState CurrentView { get; set; } = ViewState.None;
private bool _open;
private Panel _resumeHitbox;
private Panel _settingsHitbox;
private Panel _quitHitbox;
private float _resumeHover;
private float _settingsHover;
private float _quitHover;
private Texture _logoTexture;
private bool _logoTried;
private string RootClass => _open ? "focused" : "hidden";
private string SettingsStyle => CurrentView == ViewState.Settings
? "opacity: 1; pointer-events: all; transform: translateX(0);"
: "opacity: 0; pointer-events: none; transform: translateX(18px);";
protected override void OnAfterTreeRender( bool firstTime )
{
Instance = this;
if ( firstTime )
{
_open = false;
CurrentView = ViewState.None;
return;
}
if ( !_open && CurrentView != ViewState.None )
{
CurrentView = ViewState.None;
}
}
public override void OnDraw( Painter painter )
{
if ( !_open )
{
return;
}
var bounds = painter.Bounds;
// Dim scrim - GPU
painter.Fill = new Color( 0f, 0f, 0f, 0.55f );
painter.Stroke = Stroke.None;
painter.Rect( bounds );
// Logo + PAUSED - Painter image + text (drawn first so chrome can anchor to hitboxes after)
DrawBrand( painter );
// Resolve hitbox rects early so chrome can anchor to Razor's viewport tracking
Rect resumeRect = default;
Rect quitRect = default;
bool hasMenu = _resumeHitbox.IsValid() && _quitHitbox.IsValid();
if ( hasMenu )
{
Vector2 rootPos = Box.Rect.Position;
resumeRect = new Rect( _resumeHitbox.Box.Rect.Position - rootPos, _resumeHitbox.Box.Rect.Size );
quitRect = new Rect( _quitHitbox.Box.Rect.Position - rootPos, _quitHitbox.Box.Rect.Size );
// Fallback if flex pushed outside viewport (your bottom-drift screenshot) - use bounds-centered
if ( resumeRect.Top < -100f || resumeRect.Top > bounds.Height - 20f )
{
float totalH = 3f * 52f + 2f * 12f;
float startY = bounds.Height * 0.5f - totalH * 0.5f;
resumeRect = new Rect( 150f, startY, 360f, 52f );
quitRect = new Rect( 150f, startY + 2f * (52f + 12f), 360f, 52f );
}
}
// Subtle chrome - now anchored to hitboxes like Razor does, not fixed screen coords
using ( painter.Scope() )
{
var cyan = new Color( 0.67f, 0.86f, 1f );
// Vertical gutter 18px left of menu, spanning menu height with 24px padding
if ( hasMenu && resumeRect.Width > 0 && quitRect.Width > 0 )
{
float x = resumeRect.Left - 18f;
float top = resumeRect.Top - 24f;
float bottom = quitRect.Bottom + 24f;
// Clamp inside viewport like Razor's overflow clip
top = Math.Max( top, bounds.Top + 100f );
bottom = Math.Min( bottom, bounds.Bottom - 40f );
if ( top < bottom && x > bounds.Left && x < bounds.Right )
{
painter.Stroke = Stroke.Solid( cyan.WithAlpha( 0.22f ), 1.2f );
painter.Line( new Vector2( x, top ), new Vector2( x, bottom ) );
}
}
else
{
// Fallback fixed if hitboxes not yet laid out
painter.Stroke = Stroke.Solid( cyan.WithAlpha( 0.18f ), 1.5f );
painter.Line( new Vector2( 132f, 118f ), new Vector2( 132f, bounds.Height - 80f ) );
}
// Horizontal hairline under brand - use brand rect width, not fixed 300
painter.Stroke = Stroke.Solid( cyan.WithAlpha( 0.14f ), 1f );
float hy = 82f;
// Brand bottom is at 32+36=68, so hairline 14px below
painter.Line( new Vector2( 32f, hy ), new Vector2( Math.Min( 320f, bounds.Width - 40f ), hy ) );
}
// Menu items - Painter text drawn into invisible hitboxes
DrawMenuItems( painter );
// Hint - Painter text
DrawHint( painter );
}
private void DrawBrand( Painter painter )
{
if ( !_logoTried )
{
_logoTried = true;
try
{
_logoTexture = Texture.Load( "textures/causal-logo.png" );
}
catch
{
_logoTexture = null;
}
}
var cyan = new Color( 0.67f, 0.86f, 1f );
var bounds = painter.Bounds;
// Use bounds as viewport like Razor does - brand stays viewport-top-left
// regardless of flex. This mirrors how Razor's Box.Rect for an absolute
// brand would be computed from screen bounds.
float bx = 40f;
float by = 32f;
if ( _logoTexture.IsValid() )
{
float logoW = 160f;
float aspect = _logoTexture.Height > 0 ? (float)_logoTexture.Width / _logoTexture.Height : 1f;
float logoH = logoW / (aspect > 0 ? aspect : 1f);
if ( logoH > 36f )
{
logoH = 36f;
logoW = logoH * aspect;
}
// Clamp logo rect to viewport so it never drifts off-screen if bounds shrinks
logoW = Math.Min( logoW, bounds.Width - bx - 240f );
var logoRect = new Rect( bx, by, logoW, logoH );
painter.Texture( _logoTexture, logoRect, Color.White );
var pausedRect = new Rect( logoRect.Right + 18f, by, 220f, logoH );
// Ensure paused stays inside viewport
if ( pausedRect.Right > bounds.Width - 20f )
{
pausedRect = new Rect( pausedRect.Left, pausedRect.Top, bounds.Width - pausedRect.Left - 20f, pausedRect.Height );
}
painter.TextStyle = new TextStyle( "Roboto", 22f, new Color( 1f, 1f, 1f, 0.9f ) )
.WithWeight( 600 )
.WithLetterSpacing( 8f )
.WithAlignment( TextFlag.LeftCenter )
.WithShadow( cyan.WithAlpha( 0.9f ), blur: 8f, offset: new Vector2( 0f, 0f ) );
painter.Text( "PAUSED", pausedRect );
}
else
{
painter.TextStyle = new TextStyle( "Roboto", 28f, new Color( 1f, 1f, 1f, 0.9f ) )
.WithWeight( 600 )
.WithLetterSpacing( 8f )
.WithAlignment( TextFlag.LeftCenter )
.WithShadow( cyan.WithAlpha( 0.9f ), blur: 8f, offset: new Vector2( 0f, 0f ) );
painter.Text( "PAUSED", new Rect( bx, by, 260f, 36f ) );
}
}
private void DrawMenuItems( Painter painter )
{
if ( !_resumeHitbox.IsValid() || !_settingsHitbox.IsValid() || !_quitHitbox.IsValid() )
{
return;
}
Vector2 rootPos = Box.Rect.Position;
var bounds = painter.Bounds;
Rect ToLocal( Panel p )
{
if ( !p.IsValid() )
{
return default;
}
var screen = p.Box.Rect;
var pos = screen.Position - rootPos;
bool outside = pos.y < -100f || pos.y > bounds.Height - 20f;
if ( outside )
{
int idx = p == _resumeHitbox ? 0 : p == _settingsHitbox ? 1 : 2;
float totalH = 3f * 52f + 2f * 12f;
float startY = bounds.Height * 0.5f - totalH * 0.5f;
float y = startY + idx * (52f + 12f);
return new Rect( 150f, y, 360f, 52f );
}
return new Rect( pos, screen.Size );
}
var resumeRect = ToLocal( _resumeHitbox );
var settingsRect = ToLocal( _settingsHitbox );
var quitRect = ToLocal( _quitHitbox );
var cyan = new Color( 0.67f, 0.86f, 1f );
DrawMenuEntry( painter, "RESUME", resumeRect, _resumeHover, _resumeHitbox.HasActive, cyan );
DrawMenuEntry( painter, "SETTINGS", settingsRect, _settingsHover, _settingsHitbox.HasActive, cyan );
DrawMenuEntry( painter, "QUIT", quitRect, _quitHover, _quitHitbox.HasActive, cyan );
}
private void DrawMenuEntry( Painter painter, string text, Rect rect, float hover, bool active, Color cyan )
{
if ( rect.Width <= 0 || rect.Height <= 0 )
{
return;
}
// Smooth eased transition like CSS transition: color 0.2s ease, transform 0.2s ease, width 0.28s ease-out, arrow 0.3s ease
hover = Math.Clamp( hover, 0f, 1f );
float eased = hover * hover * (3f - 2f * hover); // smoothstep - matches ease
float nudgeX = 5f * eased;
if ( active )
{
nudgeX += 1f * eased;
}
// Interpolate idle -> hover
var baseColor = new Color( 1f, 1f, 1f, 0.7f + 0.3f * eased );
var idleShadow = new Color( 0.47f, 0.78f, 1f, 0.35f );
var hoverShadow = cyan.WithAlpha( 0.95f );
var shadowColor = Color.Lerp( idleShadow, hoverShadow, eased );
float shadowBlur = 6f + 6f * eased;
painter.TextStyle = new TextStyle( "Roboto", 32f, baseColor )
.WithWeight( 500 )
.WithLetterSpacing( 4f )
.WithAlignment( TextFlag.LeftCenter )
.WithShadow( shadowColor, blur: shadowBlur, offset: new Vector2( 0f, 0f ) );
var textRect = new Rect( rect.Position + new Vector2( nudgeX, 0f ), rect.Size );
float scale = active ? 0.98f : 1f;
if ( active )
{
textRect = new Rect( textRect.Position + new Vector2( 1f, 1f ) * eased, textRect.Size * (1f - 0.02f * eased) );
}
_ = scale;
painter.Text( text, textRect );
// Arrow - fades/ slides in with eased hover, replicates CSS transition: all 0.3s ease, left -10 -> -15
if ( eased > 0.01f )
{
using ( painter.Scope() )
{
float arrowAlpha = eased;
// Idle left -10 (alpha 0), hover left -15 (alpha 1) - lerp
float cxBase = rect.Left - 10f - 5f * eased + nudgeX;
float cy = rect.Top + rect.Height * 0.5f;
painter.Fill = cyan.WithAlpha( 0.9f * arrowAlpha );
painter.Stroke = Stroke.None;
painter.Polygon( new Vector2[]
{
new Vector2( cxBase - 8f, cy - 6f ),
new Vector2( cxBase, cy ),
new Vector2( cxBase - 8f, cy + 6f ),
} );
}
}
// Hover line - width 0% -> 100% with ease-out, like CSS width 0.28s ease-out
if ( eased > 0.01f )
{
using ( painter.Scope() )
{
float y = rect.Bottom - 3f;
float w = rect.Width * eased;
var lineRect = new Rect( rect.Left + nudgeX, y, w, 3f );
var gradStart = new Color( 0.55f, 0.82f, 1f, 0.9f );
var gradEnd = new Color( 0.47f, 0.78f, 1f, 0.9f );
// Fade gradient with eased alpha so line grows smoothly
painter.Fill = Fill.LinearGradient( gradStart.WithAlpha( 0.9f * eased ), gradEnd.WithAlpha( 0.9f * eased ), 0f );
painter.Stroke = Stroke.None;
painter.Rect( lineRect );
painter.Fill = new Color( 0.55f, 0.82f, 1f, 0.18f * eased );
painter.Rect( new Rect( lineRect.Left, lineRect.Top - 1f, lineRect.Width, lineRect.Height + 2f ) );
}
}
}
private void DrawHint( Painter painter )
{
if ( !_quitHitbox.IsValid() )
{
return;
}
Vector2 rootPos = Box.Rect.Position;
var quitScreen = _quitHitbox.Box.Rect;
var quitLocal = new Rect( quitScreen.Position - rootPos, quitScreen.Size );
var hintRect = new Rect( quitLocal.Left, quitLocal.Bottom + 18f, 220f, 20f );
var pulse = 0.65f + 0.35f * MathF.Sin( Time.Now * 2.6f );
var hintColor = new Color( 0.67f, 0.82f, 0.94f, 0.45f * pulse + 0.35f );
painter.TextStyle = new TextStyle( "Roboto", 12f, hintColor )
.WithWeight( 400 )
.WithLetterSpacing( 2f )
.WithAlignment( TextFlag.LeftTop );
painter.Text( "ESC TO RESUME", hintRect );
}
public override void Tick()
{
var manager = CausalGameManager.Instance;
if ( !manager.IsValid() || !manager.IsActive )
{
if ( _open )
{
_open = false;
CurrentView = ViewState.None;
UpdateControls();
StateHasChanged();
}
}
if ( !_open && CurrentView != ViewState.None )
{
CurrentView = ViewState.None;
StateHasChanged();
}
// Animate hover transitions like CSS transition 0.2s ease / 0.28s ease-out / 0.3s ease
float dt = Time.Delta;
float spd = dt * 11f; // ~0.2s to 90%
if ( _resumeHitbox.IsValid() )
{
float t = _resumeHitbox.HasHovered ? 1f : 0f;
_resumeHover = _resumeHover.LerpTo( t, spd );
}
if ( _settingsHitbox.IsValid() )
{
float t = _settingsHitbox.HasHovered ? 1f : 0f;
_settingsHover = _settingsHover.LerpTo( t, spd );
}
if ( _quitHitbox.IsValid() )
{
float t = _quitHitbox.HasHovered ? 1f : 0f;
_quitHover = _quitHover.LerpTo( t, spd );
}
}
public void Toggle()
{
if ( _open )
{
Close();
}
else
{
Open();
}
}
public void Open()
{
_open = true;
CurrentView = ViewState.None;
SettingsView?.SyncDraft();
UpdateControls();
StateHasChanged();
}
public void Close()
{
Gate( () =>
{
_open = false;
CurrentView = ViewState.None;
UpdateControls();
StateHasChanged();
} );
}
private void Resume()
{
Close();
}
private void ToggleSettings()
{
if ( CurrentView != ViewState.Settings )
{
CurrentView = ViewState.Settings;
SettingsView?.SyncDraft();
StateHasChanged();
return;
}
Gate( () =>
{
CurrentView = ViewState.None;
StateHasChanged();
} );
}
private void QuitGame()
{
Gate( () => Game.Close() );
}
private void Gate( Action action )
{
if ( CurrentView == ViewState.Settings && SettingsView is not null && SettingsView.IsDirty() )
{
SettingsView.RequestExternalClose( action );
return;
}
action();
}
private void UpdateControls()
{
var manager = CausalGameManager.Instance;
if ( manager.IsValid() )
{
manager.SetPaused( _open );
}
}
protected override int BuildHash() => HashCode.Combine( _open, CurrentView );
public enum ViewState
{
None,
Settings
}
}