Both were mine, from the scoreboard units: - ScoreboardLayout.FitLine null-forgave tileWidths on one expression and then iterated the field anyway; it now binds a non-null local once and uses that throughout. - the output resolver dereferenced _scoreboardPresenter, which is assigned in InitScoreboard — it is nullable by construction, so the resolver reads '?.' exactly like the other optional services. Clean build, 0 warnings, scoreboard tests green.
91 lines
3.6 KiB
C#
91 lines
3.6 KiB
C#
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 widths = tileWidths ?? System.Array.Empty<double>();
|
|
var count = widths.Count;
|
|
if (count == 0 || availableWidth <= 0)
|
|
return new Fit(requestedSize, 1, false);
|
|
|
|
var total = widths.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 widths)
|
|
{
|
|
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);
|
|
}
|
|
}
|