UI/SoftwareCursor.cs
using System;
using Sandbox.Rendering;
namespace BlockParty;
/// <summary>Replaces the OS cursor with a chunky pixel arrow. The arrow is painted through a
/// command list at Stage.AfterUI order 8000 — above every UI panel, but before the CRT pass at
/// 9000 — so it draws on top of everything yet is still curved together with the buttons it
/// points at: what the player sees under the tip is exactly what the engine hit-tests. In the
/// letterbox nothing is warped, so the arrow sits exactly on the real mouse position.
/// While the s&box escape menu is open the arrow hides instead: that overlay composites after
/// every camera stage (so above the CRT pass AND above this arrow), and its own input context
/// outranks the game's — the OS cursor it summons is the real arrow/hand, which is why
/// Cursors.config only blanks the game-only "blank-*" names and leaves the standard ones
/// alone.</summary>
[Title( "Software Cursor" )]
[Category( "UI" )]
[Icon( "mouse" )]
public sealed class SoftwareCursor : Component
{
// '#' = white fill, 'X' = black outline, ' ' = transparent. Classic arrow, hotspot at (0,0).
private static readonly string[] ARROW =
{
"X",
"XX",
"X#X",
"X##X",
"X###X",
"X####X",
"X#####X",
"X######X",
"X###XXXX",
"X#X#X",
"XX X#X",
" X#X",
" XX",
};
// Pointing hand for interactive elements; the hotspot sits at the raised fingertip.
private static readonly string[] HAND =
{
" XX",
" X##X",
" X##X",
" X##X",
" X##X",
" X##XXXX",
" XX##X#X#X",
"X#X######X",
"X########X",
" X#######X",
" X######X",
" X#####X",
" XXXXXXX",
};
private static readonly Vector2 HAND_HOTSPOT = new( 4f, 0f ); // fingertip centre, in cells
private GameManager _manager;
private CommandList _commands;
private CameraComponent _attachedCamera;
protected override void OnEnabled()
{
_manager = Components.Get<GameManager>( FindMode.InAncestors );
}
protected override void OnDisabled()
{
if ( _attachedCamera.IsValid() && _commands is not null )
_attachedCamera.RemoveCommandList( _commands );
_attachedCamera = null;
}
protected override void OnUpdate()
{
ApplyCursorState( out bool drawArrow );
if ( _commands is null && drawArrow )
return; // camera not ready yet; try again next frame
_commands?.Reset();
if ( !drawArrow ) return;
// Interactive elements mark themselves with `cursor: blank-hand` in scss — a game-only
// cursor name mapped to a blank texture in Cursors.config, so the style purely serves as
// the "show the hand" marker here. (Standard names like "hand" stay unmapped so the s&box
// escape menu keeps its real OS cursors.) Held state needs both
// sources: presses on panels suppress the input action (the UI swallows the click) but
// set :active on the pressed panel chain; background presses are the reverse.
ScanPanels( out bool interactive, out bool panelPressed );
bool held = panelPressed || Input.Down( "CursorClick" );
string[] sprite = interactive ? HAND : ARROW;
Vector2 hotspot = interactive ? HAND_HOTSPOT : Vector2.Zero;
// Slightly smaller than the game grid reads better as a cursor. Cell edges are computed
// from the shared formula and rounded to whole pixels, so neighbouring cells always abut
// exactly — subpixel seams between cells otherwise get amplified into visible holes when
// the quantize pass point-samples the frame.
float px = MathF.Max( Screen.Height / Arena.HEIGHT, 1f ) * 0.75f;
// Both cursors "press in" by a cell while held.
float pressNudge = held ? px : 0f;
float mouseX = Mouse.Position.x - hotspot.x * px + pressNudge;
float mouseY = Mouse.Position.y - hotspot.y * px + pressNudge;
using var paint = Painter.Begin( _commands );
for ( int row = 0; row < sprite.Length; row++ )
{
float y0 = MathF.Round( mouseY + row * px );
float y1 = MathF.Round( mouseY + (row + 1) * px );
// Draw contiguous same-colour cells as one rect: fewer quads, no internal seams.
string line = sprite[row];
for ( int col = 0; col < line.Length; )
{
char cell = line[col];
int end = col + 1;
while ( end < line.Length && line[end] == cell ) end++;
if ( cell != ' ' )
{
float x0 = MathF.Round( mouseX + col * px );
float x1 = MathF.Round( mouseX + end * px );
paint.Fill = cell == '#' ? Color.White : Color.Black;
paint.Rect( new Rect( x0, y0, x1 - x0, y1 - y0 ) );
}
col = end;
}
}
}
// Re-assert the pointer type right before rendering too: clicking can make the engine
// re-resolve the cursor after our OnUpdate ran, flashing the OS cursor for a frame.
protected override void OnPreRender()
{
ApplyCursorState( out _ );
}
private void ApplyCursorState( out bool drawArrow )
{
var camera = _manager?.Camera;
AttachCommandList( camera );
// The drawn arrow is the only in-game cursor: inside the effect region it warps with the
// UI it points at; in the letterbox nothing is warped so it sits exactly on the real
// position. Never swapping to the OS cursor also avoids the engine flashing it on clicks.
// The pointer IMAGE is hidden via CursorType — never MouseVisibility.Hidden, which is
// mouse capture and relocates/locks the cursor. Panel `cursor:` styles override
// CursorType, so game styles only ever use the blank-* names that map to a blank 1x1
// texture in ProjectSettings/Cursors.config.
Mouse.Visibility = MouseVisibility.Visible;
Mouse.CursorType = "none";
// While the s&box escape menu is up, hide the arrow: that overlay draws above the CRT
// pass (and above this command list), so the arrow would sit warped UNDER the modal while
// the menu hit-tests real positions. The menu's own input context outranks the game's and
// summons the standard OS arrow/hand — the state forced above only touches the game
// context, so the system cursor shows normally over the menu.
drawArrow = camera.IsValid() && Mouse.Active && !(_manager?.IsPaused ?? false);
}
// One pass over all screen-panel trees (panel counts are small): `interactive` when the mouse
// hovers any panel styled `cursor: blank-hand` — the game's marker for clickable things — and
// `pressed` when any panel carries :active, i.e. the mouse is held down on it.
private void ScanPanels( out bool interactive, out bool pressed )
{
interactive = false;
pressed = false;
foreach ( var screen in Scene.GetAllComponents<ScreenPanel>() )
{
var root = screen.GetPanel();
if ( root is null ) continue;
Walk( root, ref interactive, ref pressed );
if ( interactive && pressed ) return;
}
}
private static void Walk( Sandbox.UI.Panel panel, ref bool interactive, ref bool pressed )
{
var pseudo = panel.PseudoClass;
if ( pseudo.HasFlag( Sandbox.UI.PseudoClass.Hover ) && panel.ComputedStyle?.Cursor == "blank-hand" )
interactive = true;
if ( pseudo.HasFlag( Sandbox.UI.PseudoClass.Active ) )
pressed = true;
if ( interactive && pressed ) return;
foreach ( var child in panel.Children )
{
Walk( child, ref interactive, ref pressed );
if ( interactive && pressed ) return;
}
}
private void AttachCommandList( CameraComponent camera )
{
if ( !camera.IsValid() || _attachedCamera == camera ) return;
if ( _attachedCamera.IsValid() && _commands is not null )
_attachedCamera.RemoveCommandList( _commands );
_commands ??= new CommandList( "BlockParty Software Cursor" );
camera.AddCommandList( _commands, Sandbox.Rendering.Stage.AfterUI, 8000 );
_attachedCamera = camera;
}
}