feat(scoreboard): unit 3 — edge coupling and line fitting, both pure

ResolveEdges: the strip and the social bar are a coupled pair that own opposite edges whenever both
are visible; the social bar keeps its choice and the scoreboard is pushed to the other edge. Hide
either and the survivor keeps its own preference — so hiding the scoreboard frees the social bar,
which is what 'resolved at render time' has to mean.

FitLine: takes MEASURED tile widths (the renderer measures with FormattedText; a guessed character
width would make the fit lie for a long handle), keeps full size when it fits, shrinks above the
14px legibility floor, and only then wraps — never scrolls, because a reader cannot wait for the
fact to come round.
This commit is contained in:
2026-10-30 14:36:56 -07:00
parent 6b6ccd7b0a
commit 359e58a4c2
2 changed files with 139 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>Which edge a full-width bar occupies.</summary>
public enum BarEdge
{
Top,
Bottom,
}
/// <summary>The scoreboard's placement and line-fitting arithmetic, kept pure so both are testable
/// without a renderer.
///
/// <para>Two rules from the creator (2026-10-30):
/// the strip and the social bar are a COUPLED pair that own opposite edges, resolved at render time
/// from what is actually visible; and a strip that does not fit SHRINKS to a floor and then wraps to
/// a second line — it never scrolls, because a creator reading the screen cannot wait for the fact
/// to come round.</para></summary>
public static class ScoreboardLayout
{
public const double FloorFontSize = 14;
public const double MaxFontSize = 24;
/// <summary>Result of fitting the enabled tiles into one strip.</summary>
public readonly record struct Fit(double FontSize, int LineCount, bool Shrunk)
{
public bool Wrapped => LineCount > 1;
}
/// <summary>Resolves the edge pair. When both bars are visible they take opposite edges; the
/// social bar keeps its own choice and the scoreboard is pushed to the other one. When either is
/// hidden the survivor keeps its preferred edge — so hiding the scoreboard frees the social bar
/// to sit anywhere, exactly as the creator asked.</summary>
public static (BarEdge Social, BarEdge Scoreboard) ResolveEdges(
bool socialVisible, BarEdge socialPreferred,
bool scoreboardVisible, BarEdge scoreboardPreferred)
{
if (socialVisible && scoreboardVisible)
return (socialPreferred, Opposite(socialPreferred));
return (socialPreferred, scoreboardPreferred);
}
public static BarEdge Opposite(BarEdge edge) => edge == BarEdge.Top ? BarEdge.Bottom : BarEdge.Top;
/// <summary>Fits measured tile widths (at <paramref name="requestedSize"/>) into the available
/// width: full size if they fit, otherwise shrink, and only if the shrink would drop below the
/// legibility floor does it wrap. Widths are passed in because the renderer MEASURES them —
/// guessing a character width would make the fit lie for a wide glyph or a long handle.</summary>
public static Fit FitLine(IReadOnlyList<double> tileWidths, double separatorWidth,
double availableWidth, double requestedSize = MaxFontSize, double floorSize = FloorFontSize)
{
var count = tileWidths?.Count ?? 0;
if (count == 0 || availableWidth <= 0)
return new Fit(requestedSize, 1, false);
var total = tileWidths!.Sum() + separatorWidth * (count - 1);
if (total <= availableWidth)
return new Fit(requestedSize, 1, false);
var scaled = requestedSize * (availableWidth / total);
if (scaled >= floorSize)
return new Fit(scaled, 1, true);
// Below the floor: keep the floor and wrap. Greedy packing, measured widths scaled to the
// floor size so the line breaks match what is actually drawn.
var factor = floorSize / requestedSize;
var lines = 1;
var lineWidth = 0.0;
foreach (var width in tileWidths)
{
var w = width * factor;
var needed = lineWidth == 0 ? w : lineWidth + separatorWidth + w;
if (needed > availableWidth && lineWidth > 0)
{
lines++;
lineWidth = w;
}
else
{
lineWidth = needed;
}
}
return new Fit(floorSize, lines, true);
}
}