Stages/LevelSelectStage.cs
using System;
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// Level-select "map": an authored graph (see <see cref="LevelMap"/>) of a single upward spine of
/// major levels, each with an optional grid of side levels. The selector cube highlights one node;
/// arrows walk the grid, Confirm starts the level (if unlocked), Back returns to the title. A soft
/// "camera" follows the highlighted node, clamped to the central square so the bottom node sits near
/// the bottom rather than being centred over empty space. The highlighted node is remembered across
/// sessions (<see cref="LevelProgress"/>).
/// </summary>
public sealed class LevelSelectStage : MenuStageBase
{
// --- Camera-follow tuning (1080-ref px) --------------------------------------------------------
// The visible level-select area is the central 1080x1080 square (the game's fixed square fills the
// screen height; overflow outside it is clipped). VIEW_HALF is half that square on each axis.
private const float VIEW_HALF = 540f;
// Padding kept between the outermost nodes and the square edges when the map is bottom/edge-anchored
// (content smaller than the view, or scrolled to a limit). Bottom pad clears the leaderboard FOOTER
// (see LevelSelectScreen .footer, ~340px) plus the selector cube; top pad clears the header bar.
private const float PAD_BOTTOM = 440f;
private const float PAD_TOP = 220f;
private const float PAD_X = 160f;
// How fast the camera + selector cube chase their targets (per second, exp-style lerp factor).
private const float FOLLOW_LERP = 12f;
private const float UNLOCK_REVEAL_TIME = 0.7f;
private const float CAMERA_SETTLE_DISTANCE = 2f;
private const float MAP_CLIP_INSET = 11f;
private const float MAJOR_NODE_HALF_SIZE = 66f;
private const float SIDE_NODE_HALF_SIZE = 42f;
private const float BLOCKED_FEEDBACK_DURATION = 0.24f;
private const float WHEEL_MOVE_SFX_MIN_INTERVAL = 0.09f;
private const float WHEEL_ERROR_SFX_MIN_INTERVAL = 0.25f;
// ================================ DEBUG ================================
// Right-click a node to toggle its beaten flag, so map gating/unlocks can be tested without
// actually clearing levels. Editor-only: standalone builds never get the toggle.
private static readonly bool DEBUG_NODE_TOGGLE = Game.IsEditor;
// =======================================================================
private MapNode _selected;
private readonly MenuSelectorAnim _selector = new();
// Hold-to-repeat for map navigation (one per axis so vertical/horizontal repeat independently),
// sharing the same cadence as every other menu (see NavRepeater). Tapping steps once; holding a
// direction long enough starts auto-walking the grid.
private readonly NavRepeater _navVertical = new();
private readonly NavRepeater _navHorizontal = new();
// Bumped whenever beaten-state changes on this screen (the DEBUG toggle) so the Razor panel
// repaints node/line colours — BuildHash otherwise doesn't sample beaten state (it's constant for
// a normal visit, only changing after a run when the stage is recreated).
private int _revision;
// Soft-followed camera centre (map space) and selector-cube position (map space), both lerped.
private Vector2 _cam;
private Vector2 _selectorPos;
private MapNode _blockedFeedbackNode;
private Vector2 _blockedFeedbackDirection;
private float _blockedFeedbackTimer;
private float _wheelSfxCooldown;
private LevelSelectScreen _screen;
private readonly List<MapNode> _unlockQueue = new();
private readonly HashSet<string> _presentationLockedLevelIds = new();
private MapNode _unlockingNode;
private UnlockRevealPhase _unlockRevealPhase;
private float _unlockRevealTimer;
private Vector2? _unlockCameraTarget;
private bool _unlockSequenceStarted;
private enum UnlockRevealPhase
{
None,
Focusing,
Revealing,
Restoring,
}
// --- Razor-facing accessors --------------------------------------------------------------------
public System.Collections.Generic.IReadOnlyList<MapNode> Nodes => LevelMap.Nodes;
public System.Collections.Generic.IReadOnlyList<MapConnection> Connections => LevelMap.Connections;
public MapNode Selected => _selected;
public float CamX => _cam.x;
public float CamY => _cam.y;
public float SelectorX => _selectorPos.x;
public float SelectorY => _selectorPos.y;
public int SelectorFrame => _selector.Frame;
public bool BlockedFeedbackActive => _blockedFeedbackTimer > 0f;
public MapNode BlockedFeedbackNode => BlockedFeedbackActive ? _blockedFeedbackNode : null;
public Vector2 BlockedFeedbackDirection => BlockedFeedbackActive ? _blockedFeedbackDirection : Vector2.Zero;
public float BlockedFeedbackProgress => Math.Clamp( 1f - _blockedFeedbackTimer / BLOCKED_FEEDBACK_DURATION, 0f, 1f );
/// <summary>True while the map still owes or is playing its own newly-unlocked-node presentation
/// (camera focus → reveal → restore). It plays itself out on a timer, so anything that would cover
/// the map — e.g. the "YOU UNLOCKED" character overlay — waits for it
/// (see <c>GameManager.TickUnlockReveal</c>). The not-yet-started window counts: the sequence waits
/// for the entry wipe to finish, and the overlay is polled BEFORE this stage ticks, so it would
/// otherwise claim that very frame and hide the whole presentation.</summary>
public bool UnlockPresentationActive
=> !_unlockSequenceStarted || _unlockRevealPhase != UnlockRevealPhase.None;
/// <summary>Repaint token for the Razor panel (see <see cref="_revision"/>).</summary>
public int Revision => _revision;
/// <summary>How long the map's music takes to fade up to full when the screen opens.</summary>
private const float MusicFadeInSeconds = 0.5f;
// Off for now: every other screen/song change cuts in hard, so fading only here reads inconsistent.
// Flip to true to re-enable (the fade plumbing lives in Audio.FadeInMusic / TickMusicFade).
private const bool MusicFadeInEnabled = false;
// Song changes wait for the highlight to settle: scrolling fast through nodes shouldn't kick off a
// song change per step, only the node we actually stop on.
private const float MusicSwitchDelaySeconds = 0.1f;
private LevelDef _pendingMusicLevel;
private bool _musicSwitchPending;
private float _musicSwitchTimer;
/// <summary>Fade the map's music up from silence on entry. Set only when arriving from the main
/// menu — returning from a run/tally keeps the music at full volume.</summary>
public bool FadeInMusic { get; init; }
public LevelSelectStage( GameManager manager ) : base( manager ) { }
protected override void OnEnter()
{
SpawnBackgroundBlocks();
// Retry the cloud progress merge in case the boot-time fetch was offline (the engine throttles
// the underlying request, so repeat map visits are cheap). If it restores anything it calls
// RefreshProgress on this stage itself once the fetch lands.
_ = CloudProgress.MergeAsync();
// Re-check progression achievements with the map guaranteed built. The boot check can run
// before the asset mount is ready (empty map), so this is what covers a profile that already
// owns everything and has no further win to trigger on.
Achievements.CheckProgress();
_selected = ResolveSavedSelection();
Audio.PlayLevelMusic( _selected?.Level );
// Coming from the main menu, fade the map's music up. It's a global multiplier, so picking a
// different node mid-fade swaps the song without restarting (or cutting) the fade.
if ( FadeInMusic && MusicFadeInEnabled )
Audio.FadeInMusic( MusicFadeInSeconds );
PrepareUnlockQueue();
// Snap the camera + selector to the initial node so the screen opens already focused.
_cam = ClampCamera( _selected?.Pos ?? Vector2.Zero );
_selectorPos = _selected?.Pos ?? Vector2.Zero;
var go = CreateUiRoot();
_screen = go.Components.Create<LevelSelectScreen>();
_screen.Stage = this;
_screen.ShowBoardFor( _selected?.LevelId );
}
protected override void OnExit()
{
// Don't leave the next screen's music stuck mid-fade if the map is left early.
Audio.ClearMusicFade();
}
/// <summary>Queue the highlighted level's song, played once the highlight has rested on it for
/// <see cref="MusicSwitchDelaySeconds"/> (see <see cref="TickMusicSwitch"/>).</summary>
private void RequestLevelMusic( LevelDef level )
{
_pendingMusicLevel = level;
_musicSwitchPending = true;
_musicSwitchTimer = MusicSwitchDelaySeconds;
}
/// <summary>Switch songs immediately, dropping any queued switch.</summary>
private void PlayLevelMusicNow( LevelDef level )
{
_pendingMusicLevel = null;
_musicSwitchPending = false;
Audio.PlayLevelMusic( level );
}
// Run out the settle delay on a queued song change. Leaving the map drops it — the next stage picks
// its own song, so a switch mid-wipe would just be a wasted restart.
private void TickMusicSwitch( float dt )
{
if ( !_musicSwitchPending ) return;
if ( IsFadingOut )
{
_musicSwitchPending = false;
_pendingMusicLevel = null;
return;
}
_musicSwitchTimer -= dt;
if ( _musicSwitchTimer > 0f ) return;
_musicSwitchPending = false;
Audio.PlayLevelMusic( _pendingMusicLevel );
_pendingMusicLevel = null;
}
/// <summary>The node the map should open focused on. Falls back to the first level when the saved
/// selection is missing from the map, points at a locked node, or sits beyond a locked node (e.g.
/// progress was reset or edited out from under the save, leaving a locked gap the highlight could
/// never cross to reach earlier levels), repairing the persisted value so it doesn't recur.</summary>
private MapNode ResolveSavedSelection()
{
MapNode node = LevelMap.Find( LevelProgress.LastSelected );
if ( node is null || !HasClearPathToFirst( node ) )
{
node = LevelMap.First;
if ( node is not null )
LevelProgress.SetLastSelected( node.LevelId );
}
return node;
}
/// <summary>Whether the highlight could walk from <paramref name="node"/> back to the first level.
/// The route back to the start is exactly the predecessor chain (Down along the spine, inward along
/// a side chain), and each step requires the target node to be unlocked — so the path is clear only
/// if the node and every ancestor are selectable.</summary>
private static bool HasClearPathToFirst( MapNode node )
{
for ( ; node is not null; node = node.Predecessor )
if ( !node.Selectable )
return false;
return true;
}
/// <summary>Re-focus and repaint the map after progression is changed by a console command.</summary>
public void RefreshProgress()
{
_selected = ResolveSavedSelection();
PlayLevelMusicNow( _selected?.Level );
_cam = ClampCamera( _selected?.Pos ?? Vector2.Zero );
_selectorPos = _selected?.Pos ?? Vector2.Zero;
_unlockQueue.Clear();
_presentationLockedLevelIds.Clear();
_unlockingNode = null;
_unlockRevealPhase = UnlockRevealPhase.None;
_unlockCameraTarget = null;
_unlockSequenceStarted = true;
_revision++;
_screen?.ShowBoardFor( _selected?.LevelId );
}
public override void Tick( float dt )
{
base.Tick( dt );
Audio.TickMusicFade( dt );
TickMusicSwitch( dt );
if ( !_unlockSequenceStarted && !IsFadingOut )
{
_unlockSequenceStarted = true;
BeginNextUnlockReveal();
}
TickUnlockReveal( dt );
_wheelSfxCooldown = MathF.Max( 0f, _wheelSfxCooldown - dt );
if ( _blockedFeedbackTimer > 0f )
{
_blockedFeedbackTimer = MathF.Max( 0f, _blockedFeedbackTimer - dt );
if ( _blockedFeedbackTimer == 0f )
{
_blockedFeedbackNode = null;
_blockedFeedbackDirection = Vector2.Zero;
}
}
bool medalsOpen = _screen?.MedalStandingsOpen == true;
if ( medalsOpen && InputState.BackJust )
_screen.CloseMedalStandings();
if ( !medalsOpen && !IsFadingOut && !UnlockPresentationActive && _selected is not null )
{
// All four directions walk connected neighbours — nodes a line is drawn to (the spine and
// side grids alike; adjacent-but-unconnected grid cells are dead ends). All four hold-to-repeat
// (a tap steps once; holding long enough auto-walks) via the shared NavRepeater cadence,
// reading the same held A/D + arrow menu inputs the rest of the UI uses. After each held
// step we suppress further repeats the moment the (possibly newly-selected) node has no
// reachable neighbour left in that direction — so arriving on a dead end stops immediately
// instead of playing blocked-move feedback one more time.
int vStep = _navVertical.Tick( dt, InputState.NavUp, InputState.NavDown );
if ( vStep < 0 ) { Navigate( _selected.Up, new Vector2( 0f, 1f ) ); if ( !CanReach( _selected.Up ) ) _navVertical.SuppressRepeat(); }
else if ( vStep > 0 ) { Navigate( _selected.Down, new Vector2( 0f, -1f ) ); if ( !CanReach( _selected.Down ) ) _navVertical.SuppressRepeat(); }
int hStep = _navHorizontal.Tick( dt, InputState.Left, InputState.Right );
if ( hStep < 0 ) { Navigate( _selected.Left, new Vector2( -1f, 0f ) ); if ( !CanReach( _selected.Left ) ) _navHorizontal.SuppressRepeat(); }
else if ( hStep > 0 ) { Navigate( _selected.Right, new Vector2( 1f, 0f ) ); if ( !CanReach( _selected.Right ) ) _navHorizontal.SuppressRepeat(); }
if ( InputState.ConfirmJust ) Activate();
if ( InputState.BackJust ) GoBack();
}
// Chase the highlighted node (camera clamped to keep the map framed in the square).
float k = MathF.Min( 1f, FOLLOW_LERP * dt );
if ( _selected is not null )
{
Vector2 cameraTarget = _unlockRevealPhase != UnlockRevealPhase.Restoring
&& _unlockCameraTarget is Vector2 unlockTarget
? unlockTarget
: ClampCamera( _selected.Pos );
_cam = Vector2.Lerp( _cam, cameraTarget, k );
_selectorPos = Vector2.Lerp( _selectorPos, _selected.Pos, k );
if ( _unlockRevealPhase == UnlockRevealPhase.Focusing && CameraHasSettled( cameraTarget ) )
BeginCurrentUnlockReveal();
else if ( _unlockRevealPhase == UnlockRevealPhase.Restoring && CameraHasSettled( cameraTarget ) )
{
_unlockRevealPhase = UnlockRevealPhase.None;
_revision++;
}
}
_selector.Tick( dt, 0 ); // 1D slide unused on the map; only drive the squash frame.
}
/// <summary>Whether a node should currently render and behave as unlocked.</summary>
public bool IsNodeSelectable( MapNode node )
=> node is not null
&& !_presentationLockedLevelIds.Contains( node.LevelId )
&& node.Selectable;
public bool IsConnectionTraversable( MapConnection connection ) => IsNodeSelectable( connection.To );
public bool IsNodeUnlocking( MapNode node )
=> node is not null
&& _unlockRevealPhase == UnlockRevealPhase.Revealing && node == _unlockingNode;
/// <summary>Capture this arrival's unlock snapshot and queue only levels which were locked on the
/// previous visit. Major spine levels reveal before side levels; order within each group follows
/// the authored map.</summary>
private void PrepareUnlockQueue()
{
// An empty map means the manifest failed to load (or the filesystem isn't mounted yet), not
// that everything locked — capturing it would clobber the snapshot and make EVERY unlocked
// level replay its reveal ceremony once the map builds. Leave the snapshot untouched.
if ( Nodes.Count == 0 )
return;
var currentlyUnlocked = new List<string>();
foreach ( var node in Nodes )
if ( node.Selectable )
currentlyUnlocked.Add( node.LevelId );
var newlyUnlocked = new HashSet<string>( LevelProgress.CaptureNewlyUnlocked( currentlyUnlocked ) );
for ( int majorPass = 1; majorPass >= 0; majorPass-- )
{
bool major = majorPass == 1;
foreach ( var node in Nodes )
{
if ( node.IsMajor != major || !newlyUnlocked.Contains( node.LevelId ) )
continue;
_unlockQueue.Add( node );
_presentationLockedLevelIds.Add( node.LevelId );
}
}
}
private void TickUnlockReveal( float dt )
{
if ( _unlockRevealPhase != UnlockRevealPhase.Revealing )
return;
_unlockRevealTimer -= dt;
if ( _unlockRevealTimer > 0f )
return;
_unlockingNode = null;
_revision++;
BeginNextUnlockReveal();
}
private void BeginNextUnlockReveal()
{
if ( _unlockQueue.Count == 0 )
{
_unlockingNode = null;
_unlockCameraTarget = null;
Vector2 selectedTarget = ClampCamera( _selected?.Pos ?? Vector2.Zero );
_unlockRevealPhase = CameraHasSettled( selectedTarget )
? UnlockRevealPhase.None
: UnlockRevealPhase.Restoring;
_revision++;
return;
}
_unlockingNode = _unlockQueue[0];
_unlockQueue.RemoveAt( 0 );
if ( IsNodeFullyVisible( _unlockingNode, _cam ) )
{
BeginCurrentUnlockReveal();
return;
}
_unlockCameraTarget = ClampCamera( _unlockingNode.Pos );
_unlockRevealPhase = UnlockRevealPhase.Focusing;
_revision++;
}
private void BeginCurrentUnlockReveal()
{
if ( _unlockingNode is null )
return;
_presentationLockedLevelIds.Remove( _unlockingNode.LevelId );
_unlockRevealPhase = UnlockRevealPhase.Revealing;
_unlockRevealTimer = UNLOCK_REVEAL_TIME;
_revision++;
Audio.PlaySfx( SfxType.TurnVisible, volume: 0.75f, pitch: 1.15f );
}
private bool CameraHasSettled( Vector2 target ) => (_cam - target).Length <= CAMERA_SETTLE_DISTANCE;
private static bool IsNodeFullyVisible( MapNode node, Vector2 camera )
{
float nodeHalf = node.IsMajor ? MAJOR_NODE_HALF_SIZE : SIDE_NODE_HALF_SIZE;
float visibleHalf = VIEW_HALF - MAP_CLIP_INSET;
return MathF.Abs( node.Pos.x - camera.x ) + nodeHalf <= visibleHalf
&& MathF.Abs( node.Pos.y - camera.y ) + nodeHalf <= visibleHalf;
}
/// <summary>Move the highlight to a grid neighbour. A dead end shakes the player marker in the
/// attempted direction; a locked neighbour also shakes its node. Locked neighbours are
/// refused with an error cue (you must beat the level leading to it first). <paramref name="silent"/>
/// suppresses the move/error SFX so wheel scrolling can provide its own rate-limited cues.</summary>
private void Navigate( MapNode next, Vector2 attemptedDirection, bool silent = false )
{
if ( next is null )
{
PlayBlockedFeedback( null, attemptedDirection, silent, pitch: 0.95f );
return;
}
if ( !IsNodeSelectable( next ) )
{
PlayBlockedFeedback( next, next.Pos - _selected.Pos, silent );
return;
}
SelectNode( next, silent );
}
/// <summary>Whether the highlight could step onto <paramref name="node"/> (a non-null, unlocked
/// neighbour). Used to stop hold-to-repeat the instant no further move exists in that direction.</summary>
private bool CanReach( MapNode node ) => IsNodeSelectable( node );
/// <summary>Mouse-wheel navigation: walk one node along the spine per notch — wheel up.
/// A negative <paramref name="dir"/> moves up toward later levels; a positive one moves down.
/// Successful steps use a quieter click than keyboard navigation, rate-limited for high-resolution
/// wheels. Locked levels and dead ends use a separately rate-limited error cue.</summary>
public void WheelStep( int dir )
{
if ( IsFadingOut || UnlockPresentationActive || _selected is null || dir == 0 )
return;
MapNode next = dir < 0 ? _selected.Up : _selected.Down;
MapNode previous = _selected;
Navigate( next,
dir < 0 ? new Vector2( 0f, 1f ) : new Vector2( 0f, -1f ), silent: true );
if ( _wheelSfxCooldown > 0f )
return;
if ( _selected != previous )
{
Audio.PlaySfx( SfxType.BlockSidePressed0, volume: 0.45f, pitch: 1.35f );
_wheelSfxCooldown = WHEEL_MOVE_SFX_MIN_INTERVAL;
}
else
{
Audio.PlaySfx( SfxType.Error, pitch: next is null ? 0.95f : 0.85f );
_wheelSfxCooldown = WHEEL_ERROR_SFX_MIN_INTERVAL;
}
}
/// <summary>Highlight a node (keyboard move or click). Persists the choice so the map reopens here
/// next time. Never called for locked nodes — callers refuse those first.</summary>
public void SelectNode( MapNode node, bool silent = false )
{
if ( node is null || node == _selected )
return;
_blockedFeedbackNode = null;
_blockedFeedbackDirection = Vector2.Zero;
_blockedFeedbackTimer = 0f;
_selected = node;
RequestLevelMusic( node.Level );
_selector.PlayMove();
if ( !silent ) Audio.PlaySfx( SfxType.MenuBlip );
Haptics.MenuBlip();
LevelProgress.SetLastSelected( node.LevelId );
_screen?.ShowBoardFor( node.LevelId );
}
/// <summary>Enter the highlighted level. The highlight only ever rests on unlocked nodes, so the
/// locked guard here is a safety net.</summary>
public void Activate()
{
if ( IsFadingOut || UnlockPresentationActive || _selected is null )
return;
if ( !IsNodeSelectable( _selected ) )
{
PlayBlockedFeedback( _selected, Vector2.Zero );
return;
}
_selector.PlayWobble();
Audio.PlaySfx( SfxType.MenuStart );
Haptics.MenuConfirm();
Manager.StartLevel( _selected.LevelId );
}
/// <summary>Click a node: a locked node is refused with an error cue. Clicking a node that isn't
/// already highlighted just selects it; clicking the already-highlighted node enters the level.
/// Hovering only highlights (via CSS) — it never changes the selection.</summary>
public void ClickNode( MapNode node )
{
if ( node is null || IsFadingOut || UnlockPresentationActive )
return;
if ( !IsNodeSelectable( node ) )
{
PlayBlockedFeedback( node, node.Pos - _selected.Pos );
return;
}
// First click selects; a second click on the already-highlighted node enters it.
if ( node != _selected )
{
SelectNode( node );
return;
}
Activate();
}
/// <summary>DEBUG: right-click a node to flip its beaten flag (persisted), for testing map gating
/// without playing the level. No-op unless <see cref="DEBUG_NODE_TOGGLE"/> is on.</summary>
public void DebugToggleBeaten( MapNode node )
{
if ( !DEBUG_NODE_TOGGLE || node is null )
return;
//LevelProgress.ToggleBeaten( node.LevelId );
//_revision++;
//Audio.PlaySfx( SfxType.MenuBlip );
//Log.Info( $"[DEBUG] Level '{node.LevelId}' beaten = {node.Beaten}." );
}
/// <summary>Feedback for a blocked move. The UI nudges the player marker in the attempted direction
/// and, when <paramref name="node"/> is non-null, also shakes that locked target.
/// <paramref name="silent"/> skips the error SFX (wheel scrolling).</summary>
private void PlayBlockedFeedback( MapNode node, Vector2 direction, bool silent = false, float pitch = 0.85f )
{
_blockedFeedbackNode = node;
_blockedFeedbackDirection = direction.Length > 0.001f ? direction / direction.Length : Vector2.Zero;
_blockedFeedbackTimer = BLOCKED_FEEDBACK_DURATION;
if ( !silent ) Audio.PlaySfx( SfxType.Error, pitch: pitch );
Haptics.MenuBlip();
}
private void GoBack()
{
Audio.PlaySfx( SfxType.MenuStart );
FadeToStage( new TitleStage( Manager ) );
}
/// <summary>Editor button (bottom-left letterbox, editor-only): open the level editor. Locked
/// during the exit wipe / unlock presentation like every other input path.</summary>
public void ClickEditor()
{
if ( IsFadingOut || UnlockPresentationActive )
return;
Audio.PlaySfx( SfxType.MenuStart, 0.7f );
LevelEditorStage.OpenLevelEditor( "" );
}
/// <summary>Home button (bottom-right letterbox): return to the title screen, same as Back.</summary>
public void ClickHome()
{
if ( IsFadingOut || UnlockPresentationActive )
return;
GoBack();
}
/// <summary>Footer trophy button: open the full High Scores screen on the highlighted level's
/// board. BACK there returns to this map (not the title) — see <see cref="HighscoreStage.GoBack"/>.</summary>
public void ClickLeaderboards()
{
if ( IsFadingOut || UnlockPresentationActive || _selected is null )
return;
Audio.PlaySfx( SfxType.MenuStart );
FadeToStage( new HighscoreStage( Manager, levelId: _selected.LevelId, backToLevelSelect: true ) );
}
/// <summary>Clamp a desired camera centre so the map stays framed in the square. Horizontally the
/// camera centres on the selected node (even if that leaves the map lopsided — a major with only a
/// right side-chain still sits dead-centre when selected). Vertically it bottom-anchors so the first
/// level rests near the bottom rather than floating in the middle. Either axis scrolls (clamped)
/// once the content is taller/wider than the view.</summary>
private static Vector2 ClampCamera( Vector2 target )
{
// Horizontally, ALWAYS centre on the selected node (no content-edge clamp): a major on the spine
// stays dead-centre even when a side chain is very long, and stepping into a chain pans the
// camera along it. The old bounds-clamp shoved a selected major off-centre toward a long branch.
float camX = target.x;
float camY = ClampAxis( target.y, LevelMap.MinY, LevelMap.MaxY, PAD_BOTTOM, PAD_TOP, FitAnchor.Low );
return new Vector2( camX, camY );
}
/// <summary>When the content (plus padding) is smaller than the view it can't scroll to frame both
/// edges: <see cref="Low"/> pins it to the low edge (used vertically so the first node sits near the
/// bottom); <see cref="Follow"/> centres the target node (used horizontally).</summary>
private enum FitAnchor { Low, Follow }
/// <summary>Clamp one camera axis. <paramref name="padLow"/>/<paramref name="padHigh"/> are the gaps
/// kept at the low/high content edges when scrolled to a limit.</summary>
private static float ClampAxis( float target, float min, float max, float padLow, float padHigh, FitAnchor fit )
{
float lo = min + VIEW_HALF - padLow; // low edge of content near low edge of view
float hi = max - VIEW_HALF + padHigh; // high edge of content near high edge of view
if ( hi >= lo )
return Math.Clamp( target, lo, hi );
return fit == FitAnchor.Low ? lo : target;
}
}