2D Panels and Text
Twenty-four HUD panel recipes side by side, each one property away from the last: fills, borders, rounded corners, glows of three strengths, glass, a ship-console panel with corner ticks and a gauge, dashed rings and lines that march, gradients, opacity, a translucent border, an additive glow, a hairline, a twelve-sided badge, a dashed route, overlapping see-through panels and a concave bracket. Every panel appears twice, alone and carrying world text that shows height, font size, colour alpha, opacity, glow, alignment and system fonts. Five themes switch live.
The Program.cs file shows how to:
- Panels with ShapeBatch - border, fill, glow, dashes, gradient and opacity as captured state
- A fill colour of its own versus a fill derived from the outline colour
- Showing transparency honestly by drawing a stripe behind every panel
- Dashes on rings and lines, animated by advancing the phase
- A fill gradient to a colour, and to alpha 0 for a glass fade
- One opacity over a whole panel
- Pixel-width lines and arcs as HUD ornaments that survive zooming
- An additive glow against a covering one, and a glow on a see-through panel
- A translucent border, a hairline border, and a polygon of any vertex count
- Strokes with DrawPixelPolyline - a dashed route and a concave, closed frame
- World text styling - Height, FontSize, TextColor alpha, Opacity, GlowColor and GlowSize
- Installed system fonts with SystemFonts, in four styles
- A live theme switch through a TextOverlay key menu

View on GitHub.
using E03_2D_Panels;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering;
using Stride.CommunityToolkit.Rendering.Overlay;
using Stride.CommunityToolkit.Rendering.Text;
using Stride.CommunityToolkit.Shapes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Graphics;
using Stride.Graphics.Font;
using Stride.Input;
// A gallery of HUD panels and the text on them: twenty-four stations, each one property away from
// the last, so what every setting does is a matter of looking rather than reading.
//
// Two libraries meet here and it is worth knowing which is which. The PANELS are ShapeBatch - one
// instanced draw call for all of them, outlines that stay the same number of pixels wide however far
// you zoom, and border, fill, glow, dashes, gradient and opacity as batch state captured per draw.
// The TEXT is WorldTextComponent - entities in the scene, drawn by one renderer, with their own
// colour, opacity, glow and font.
//
// Each station appears twice: the panel alone, and the same panel with text on it. A light stripe
// runs behind every cell, because a see-through panel over a flat background just looks darker.
//
// T opens the theme list; 1-5 pick one. The mouse wheel zooms and the right button pans - zoom in on
// any station to see how little the borders, glows and dashes care about it.
const int Columns = 8;
const int Rows = 6;
const float ColumnPitch = 4.6f;
const float RowPitch = 3.0f;
const float PanelWidth = 4.0f;
const int FontPixels = 48;
const float PanelHeight = 2.0f;
// The whole grid framed at once
const float ViewHeight = 29f;
// The grid sits low on purpose. The overlay is screen-space and lives in the top-right corner, so the
// band above the grid is what keeps it from covering the last columns.
const float GridOffsetY = -4.8f;
var panelSize = new Vector2(PanelWidth, PanelHeight);
// The stripe behind each cell, slanted so it is obvious it belongs to the background
var stripeAxisX = Vector3.Normalize(new Vector3(1f, 0.42f, 0f));
var stripeAxisY = Vector3.Normalize(new Vector3(-0.42f, 1f, 0f));
ShapeBatch shapes = null!; // created in Start
SpriteFont? sansFont = null;
SpriteFont? boldFont = null;
SpriteFont? italicFont = null;
SpriteFont? monoFont = null;
var themeIndex = 0;
var time = 0f;
List<(WorldTextComponent Text, Action<WorldTextComponent, Theme> Restyle)> themedText = [];
KeyMenu? themeMenu = null;
// The panel under the mouse, as the batch last drew it: every panel is drawn with its station as
// the tag, and the batch answers from the frame on screen
PanelStation? hoveredPanel = null;
// Dark grounds throughout: a glow is light added to what is behind it, so it only reads as a glow
// against something dark. That is why every HUD in every spaceship is dark.
Theme[] themes =
[
new("Blue", new Color(90, 190, 255), new Color(10, 26, 48), new Color(205, 238, 255), new Color(0, 140, 255)),
new("Red", new Color(255, 95, 90), new Color(38, 12, 14), new Color(255, 210, 205), new Color(255, 40, 40)),
new("Green", new Color(80, 245, 150), new Color(8, 34, 22), new Color(200, 255, 225), new Color(0, 220, 120)),
new("Purple", new Color(185, 130, 255), new Color(26, 14, 46), new Color(230, 210, 255), new Color(150, 60, 255)),
new("Orange", new Color(255, 170, 70), new Color(42, 22, 6), new Color(255, 228, 190), new Color(255, 130, 0)),
];
// One entry per station: how the panel is painted, and how the text on the copy below it is styled.
// Panel settings are ShapeBatch state; text settings are WorldTextComponent properties.
PanelStation[] stations =
[
new(1, "Fill only\nBorderWidth 0", "Default text",
(text, theme) => text.TextColor = theme.Text,
BorderWidth: 0f),
new(2, "Border only\nFill.Alpha 0", "Height 0.55",
(text, theme) => { text.TextColor = theme.Text; text.Height = 0.55f; },
FillAlpha: 0f),
new(3, "Border + fill\nthe default look", "FontSize 12\n(zoom in - it goes soft)",
(text, theme) => { text.TextColor = theme.Text; text.FontSize = 12f; }),
new(4, "Fill.Alpha 0.35\nthe stripe shows through", "TextColor alpha 128",
(text, theme) => text.TextColor = new Color(theme.Text.R, theme.Text.G, theme.Text.B, (byte)128),
FillAlpha: 0.35f),
new(5, "Fill.Color null\nfill derives from the border", "Opacity 0.5",
(text, theme) => { text.TextColor = theme.Text; text.Opacity = 0.5f; },
DerivedFill: true, FillAlpha: 0.45f),
new(6, "cornerRadius 0.35\nrounded", "GlowSize 4\ncrisp halo",
(text, theme) => { text.TextColor = theme.Text; text.GlowColor = theme.Glow; text.GlowSize = 4f; },
CornerRadius: 0.35f),
new(7, "BorderWidth 6\nheavy, still pixel-exact", "GlowSize 12, glow alpha 90\nsoft bloom",
(text, theme) => { text.TextColor = theme.Text; text.GlowColor = new Color(theme.Glow.R, theme.Glow.G, theme.Glow.B, (byte)90); text.GlowSize = 12f; },
BorderWidth: 6f),
new(8, "Glow.Width 6\nin the accent colour", "Dark text, white halo\nlegible on anything",
(text, _) => { text.TextColor = new Color(10, 12, 18); text.GlowColor = Color.White; text.GlowSize = 7f; },
GlowWidth: 6f),
new(9, "Glow.Width 14, glow alpha 70\nwide and weak", "System sans font",
(text, theme) => { text.TextColor = theme.Text; text.Font = sansFont; },
GlowWidth: 14f, GlowAlpha: 70),
new(10, "Glow.Color white\nindependent of the border", "System sans, bold",
(text, theme) => { text.TextColor = theme.Text; text.Font = boldFont; },
GlowWidth: 5f, WhiteGlow: true),
new(11, "Glass\nFill.Alpha 0.18 + rounded + glow", "Monospace font\n0123456789",
(text, theme) => { text.TextColor = theme.Text; text.Font = monoFont; },
FillAlpha: 0.18f, CornerRadius: 0.3f, GlowWidth: 5f),
new(12, "The lot, plus ticks and a gauge\n(the ship-HUD panel)", "DOCKING CLAMP\nSTATUS NOMINAL",
(text, theme) => { text.TextColor = theme.Text; text.Font = boldFont; text.GlowColor = theme.Glow; text.GlowSize = 5f; },
BorderWidth: 1.5f, FillAlpha: 0.55f, CornerRadius: 0.25f, GlowWidth: 6f, Ornaments: true),
new(13, "Dash.Length 6 on a ring, 8 on a line\n(polygons stay solid)", "System sans, italic",
(text, theme) => { text.TextColor = theme.Text; text.Font = italicFont; },
Dashed: true),
new(14, "Gradient.Color = the text colour\nbottom to top", "Text over a gradient",
(text, theme) => { text.TextColor = theme.Fill; text.Font = boldFont; },
GradientTo: GradientTarget.Text),
new(15, "Gradient to alpha 0\nleft to right - a glass fade", "TextColor alpha 160\n+ GlowSize 3",
(text, theme) => { text.TextColor = new Color(theme.Text.R, theme.Text.G, theme.Text.B, (byte)160); text.GlowColor = theme.Glow; text.GlowSize = 3f; },
FillAlpha: 0.6f, GradientTo: GradientTarget.Transparent),
new(16, "Opacity 0.35\nthe whole panel, one number", "Opacity 0.35\non the text as well",
(text, theme) => { text.TextColor = theme.Text; text.Opacity = 0.35f; },
GlowWidth: 5f, Opacity: 0.35f),
// The third row: what arrived with the premultiplied, linear-light renderer
new(17, "Border colour alpha 110\nthe border itself translucent", "Alignment Left",
(text, theme) => { text.TextColor = theme.Text; text.Alignment = TextAlignment.Left; },
BorderAlpha: 110),
new(18, "Glow.Additive\nlight added, nothing covered", "Alignment Right",
(text, theme) => { text.TextColor = theme.Text; text.Alignment = TextAlignment.Right; },
GlowWidth: 10f, AdditiveGlow: true),
new(19, "Glow on a see-through panel\nFill.Alpha 0.25 + Glow.Width 8", "GlowSize 2\ntight halo",
(text, theme) => { text.TextColor = theme.Text; text.GlowColor = theme.Glow; text.GlowSize = 2f; },
FillAlpha: 0.25f, GlowWidth: 8f),
new(20, "BorderWidth 0.75\na hairline, still anti-aliased", "Height 0.22\nsmall and sharp",
(text, theme) => { text.TextColor = theme.Text; text.Height = 0.22f; },
BorderWidth: 0.75f),
new(21, "Twelve vertices, cornerRadius 0.1\nany count, one shape", "Two lines,\ncentred by AddText",
(text, theme) => text.TextColor = theme.Text,
Sides: 12, CornerRadius: 0.1f),
new(22, "DrawPixelPolyline, 40 points\nwith dashes marching along it", "Opacity 0.75\nbold",
(text, theme) => { text.TextColor = theme.Text; text.Font = boldFont; text.Opacity = 0.75f; },
FillAlpha: 0.6f, Route: true),
new(23, "Two see-through panels overlapping\nFill.Alpha 0.5 over Fill.Alpha 0.5", "Monospace\n12:04:59",
(text, theme) => { text.TextColor = theme.Text; text.Font = monoFont; },
FillAlpha: 0.5f, Overlap: true),
new(24, "A concave frame\nDrawPixelPolyline, closed", "Italic + GlowSize 3",
(text, theme) => { text.TextColor = theme.Text; text.Font = italicFont; text.GlowColor = theme.Glow; text.GlowSize = 3f; },
Bracket: true),
];
using var game = new Game();
game.Run(new SceneSetup2D
{
ClearColor = new Color(6, 8, 14),
Camera = CameraSetup.Orthographic(orthographicSize: ViewHeight).WithController(),
// No ground and no physics: this is a gallery, and the default 2D ground would cut through it
Ground = null,
Resizable = true,
DpiAware = true,
}, start: Start, update: Update);
void Start(Scene scene)
{
shapes = game.AddShapeBatch();
// A code-only game has no font assets, so any font other than Stride's default one comes from
// the machine. Asking for a list rather than a name is what keeps this working off Windows;
// game.LoadSystemFont("Segoe UI", 48) is the one-family version.
sansFont = SystemFonts.LoadFirst(game.Services, SystemFonts.SansSerifCandidates, FontPixels);
boldFont = SystemFonts.LoadFirst(game.Services, SystemFonts.SansSerifCandidates, FontPixels, FontStyle.Bold);
italicFont = SystemFonts.LoadFirst(game.Services, SystemFonts.SansSerifCandidates, FontPixels, FontStyle.Italic);
monoFont = SystemFonts.LoadFirst(game.Services, SystemFonts.MonospaceCandidates, FontPixels);
// Fills the band the overlay does not reach, and is itself a station: a big glowing title is
// exactly what the text component is for
AddText(scene, new Vector3(-9f, 5.6f, 0f), "PANELS & TEXT", 0.9f,
(text, theme) =>
{
text.TextColor = theme.Text;
text.GlowColor = theme.Glow;
text.GlowSize = 6f;
text.Font = boldFont;
});
for (var i = 0; i < stations.Length; i++)
{
var station = stations[i];
var panelCenter = CellCenter(i, withText: false);
// Under the panel-only copy, in a neutral grey that belongs to no theme, so it reads as a
// caption rather than as part of the panel
AddText(scene, panelCenter - new Vector3(0f, PanelHeight / 2f + 0.38f, 0f), station.Caption, 0.23f,
(text, _) =>
{
text.TextColor = new Color(150, 160, 175);
// The halo is not decoration here: a caption sits over whatever glow the panel above
// it throws, and a tight dark outline is what keeps grey text readable on both
text.GlowColor = new Color(0, 0, 0, 200);
text.GlowSize = 3f;
},
themed: false);
// On the copy below it, the text names the setting that styles it - the station is its own label
AddText(scene, CellCenter(i, withText: true), station.Label, 0.32f, station.StyleText);
// The station's number in the top-left corner of both copies, so a panel on screen and its
// entry in the stations array above can be matched at a glance
foreach (var withText in new[] { false, true })
{
var corner = CellCenter(i, withText) + new Vector3(-PanelWidth / 2f + 0.14f, PanelHeight / 2f - 0.1f, 0f);
AddText(scene, corner, $"{station.Number:00}", 0.2f,
(text, theme) => text.TextColor = WithAlpha(theme.Accent, 170),
anchor: Anchor.TopLeft, font: monoFont);
}
}
themeMenu = new KeyMenu
{
Title = "Theme",
ToggleKey = Keys.T,
TitleColor = Color.Yellow,
SelectedIndex = themeIndex,
CloseOnSelect = false,
Items = [.. themes.Index().Select(pair => new KeyMenuItem(
(Keys)(Keys.D1 + pair.Index), pair.Item.Name, () => ApplyTheme(pair.Index)))],
};
var overlay = TextOverlay.GetOrCreate(game);
overlay.SectionGap = 0;
overlay.AddSection("Panels", BuildOverlayLines);
}
void Update(Scene scene, GameTime gameTime)
{
themeMenu?.Update(game.Input);
hoveredPanel = shapes.TryPick(game.Input.MousePosition, out var hit) ? hit.Tag as PanelStation : null;
time += (float)gameTime.Elapsed.TotalSeconds;
var theme = themes[themeIndex];
// Immediate mode: every panel is submitted again each frame, which is why a theme change needs
// nothing more than reading the new colours here. All of them go out in one draw call.
for (var i = 0; i < stations.Length; i++)
{
DrawPanel(stations[i], CellCenter(i, withText: false), theme);
DrawPanel(stations[i], CellCenter(i, withText: true), theme);
}
}
/// <summary>Paints one panel: the stripe behind it, the panel itself, and any ornaments.</summary>
void DrawPanel(PanelStation station, Vector3 center, Theme theme)
{
// The stripe is what makes transparency visible. Drawn first: shapes blend in submission order
// and never write depth, so within a batch "behind" simply means "earlier".
Reset();
// Kept just inside the panel, slant included, so it never pokes out of a cell and gets mistaken
// for part of the panel
shapes.DrawRectangle(center, stripeAxisX, stripeAxisY, new Vector2(PanelWidth * 0.85f, 0.5f), new Color(118, 130, 150));
// Tagged with its station, so a pick hands the station back; the one under the mouse gets a
// heavier border
shapes.Tag = station;
shapes.BorderWidth = station == hoveredPanel ? station.BorderWidth + 1.5f : station.BorderWidth;
// null fills with the outline colour, which is the Box2D testbed's behaviour; a colour of its own
// is what makes a dark panel behind a bright border
shapes.Fill.Set(station.DerivedFill ? null : theme.Fill, station.FillAlpha);
shapes.Glow.Set(station.GlowWidth, station.GlowWidth <= 0f ? null : WithAlpha(station.WhiteGlow ? Color.White : theme.Glow, station.GlowAlpha));
// Additive: the glow adds its light to whatever is behind it instead of covering it - a neon
// over a dark ground. Over a light one it would only saturate.
shapes.Glow.Additive = station.AdditiveGlow;
// The outline colour's own alpha applies to the border, so a border can be see-through too
var accent = WithAlpha(theme.Accent, station.BorderAlpha);
// A gradient runs from the fill to this colour across the panel; alpha 0 fades the fill out
switch (station.GradientTo)
{
case GradientTarget.Text:
shapes.Gradient.Set(theme.Text, Vector2.UnitY);
break;
case GradientTarget.Transparent:
shapes.Gradient.Set(WithAlpha(theme.Fill, 0), Vector2.UnitX);
break;
}
// One number over everything the panel draws - border, fill and glow together
shapes.Opacity = station.Opacity;
if (station.Sides > 0)
{
// A regular polygon of any vertex count is one shape, the same as a rectangle
DrawPolygon(center, station.Sides, PanelHeight / 2f, accent, station.CornerRadius);
}
else if (station.Bracket)
{
DrawBracket(center, accent);
}
else
{
shapes.DrawRectangle(center.XY(), panelSize, accent, station.CornerRadius);
}
if (station.Overlap)
{
// The same panel again, offset: two translucent fills over each other, and the border of
// the one on top over the fill of the one below, all composited in one pass
shapes.DrawRectangle(center.XY() + new Vector2(0.55f, -0.4f), panelSize * 0.8f, accent, station.CornerRadius);
}
if (station.Ornaments)
{
DrawOrnaments(center, theme);
}
if (station.Dashed)
{
DrawDashes(center, theme);
}
if (station.Route)
{
DrawRoute(center, theme);
}
Reset();
}
/// <summary>A regular polygon standing on a flat bottom edge, filled and outlined like a panel.</summary>
void DrawPolygon(Vector3 center, int sides, float radius, Color color, float cornerRadius)
{
Span<Vector2> vertices = stackalloc Vector2[sides];
// Counter-clockwise, starting so that one edge lies flat at the bottom
var start = -MathF.PI / 2f - MathF.PI / sides;
for (var i = 0; i < sides; i++)
{
var (sin, cos) = MathF.SinCos(start + i * MathF.Tau / sides);
vertices[i] = new Vector2(cos, sin) * (radius - cornerRadius);
}
shapes.DrawSolidPolygon(vertices, new Vector2(center.X, center.Y), 0f, color, cornerRadius);
}
/// <summary>
/// A panel whose outline is a closed stroke with notched corners - concave, which no polygon fill
/// could be - over a fill built from two convex rectangles underneath, a cross whose union is
/// exactly the inside of the frame. Opaque, so the overlap of the two does not show.
/// </summary>
void DrawBracket(Vector3 center, Color color)
{
const float Notch = 0.45f;
var half = new Vector2(PanelWidth / 2f, PanelHeight / 2f);
var flat = new Vector2(center.X, center.Y);
// The fill, borderless, a touch inside the stroke
var borderWidth = shapes.BorderWidth;
shapes.BorderWidth = 0f;
shapes.DrawRectangle(center.XY(), panelSize - new Vector2(0.12f, 2f * Notch + 0.12f), color);
shapes.DrawRectangle(center.XY(), panelSize - new Vector2(2f * Notch + 0.12f, 0.12f), color);
shapes.BorderWidth = borderWidth;
// Twelve points: each corner is an inward notch of three
ReadOnlySpan<Vector2> outline =
[
flat + new Vector2(-half.X + Notch, -half.Y),
flat + new Vector2(half.X - Notch, -half.Y),
flat + new Vector2(half.X - Notch, -half.Y + Notch),
flat + new Vector2(half.X, -half.Y + Notch),
flat + new Vector2(half.X, half.Y - Notch),
flat + new Vector2(half.X - Notch, half.Y - Notch),
flat + new Vector2(half.X - Notch, half.Y),
flat + new Vector2(-half.X + Notch, half.Y),
flat + new Vector2(-half.X + Notch, half.Y - Notch),
flat + new Vector2(-half.X, half.Y - Notch),
flat + new Vector2(-half.X, -half.Y + Notch),
flat + new Vector2(-half.X + Notch, -half.Y + Notch),
];
shapes.DrawPixelPolyline(outline, borderWidth, color, closed: true);
}
/// <summary>
/// A plotted route across the panel: forty points as one 2-pixel stroke with round joins, and a
/// dash pattern that runs along the whole of it.
/// </summary>
void DrawRoute(Vector3 center, Theme theme)
{
Span<Vector2> route = stackalloc Vector2[40];
for (var i = 0; i < route.Length; i++)
{
var t = i / (route.Length - 1f);
var x = center.X - PanelWidth / 2f + 0.3f + t * (PanelWidth - 0.6f);
var y = center.Y + MathF.Sin(t * MathF.Tau * 1.5f + time) * 0.55f;
route[i] = new Vector2(x, y);
}
Reset();
shapes.Dash.Set(7f, 5f, time * 25f);
shapes.DrawPixelPolyline(route, 2f, theme.Accent);
Reset();
}
/// <summary>
/// The corner ticks, divider and gauge arc that turn a rounded rectangle into something off a ship's
/// console. The ticks and divider are pixel lines, so they keep their weight as you zoom.
/// </summary>
void DrawOrnaments(Vector3 center, Theme theme)
{
const float TickLength = 0.5f;
var half = new Vector2(PanelWidth / 2f + 0.16f, PanelHeight / 2f + 0.16f);
Reset();
foreach (var (signX, signY) in (ReadOnlySpan<(float X, float Y)>)[(-1f, -1f), (-1f, 1f), (1f, -1f), (1f, 1f)])
{
var corner = center + new Vector3(signX * half.X, signY * half.Y, 0f);
shapes.DrawPixelLine(corner, corner - new Vector3(signX * TickLength, 0f, 0f), 2f, theme.Accent);
shapes.DrawPixelLine(corner, corner - new Vector3(0f, signY * TickLength, 0f), 2f, theme.Accent);
}
var divider = center + new Vector3(0f, -PanelHeight / 2f + 0.5f, 0f);
shapes.DrawPixelLine(
divider - new Vector3(PanelWidth / 2f - 0.35f, 0f, 0f),
divider + new Vector3(PanelWidth / 2f - 0.35f, 0f, 0f),
1f,
WithAlpha(theme.Accent, 110));
// A gauge sweep, three quarters of a turn, in the accent colour
shapes.DrawArc(
new Vector2(center.X + PanelWidth / 2f - 0.5f, center.Y - PanelHeight / 2f + 0.5f),
0.28f,
-MathF.PI / 2f,
MathF.PI * 1.5f,
theme.Accent,
0.05f);
}
/// <summary>
/// Dashes belong to rings, arcs and lines - a polygon's outline has no single direction to dash
/// along, so the panel itself stays solid and the dashes go on a tick ring and a rule inside it.
/// The ring turns because its phase advances; the rule's pattern marches for the same reason.
/// </summary>
void DrawDashes(Vector3 center, Theme theme)
{
Reset();
shapes.StrokeWidth = 1.5f;
shapes.Dash.Set(6f, 4f, time * 20f);
shapes.DrawArc(new Vector2(center.X - PanelWidth / 2f + 0.75f, center.Y), 0.5f, 0f, MathF.Tau, theme.Accent);
shapes.Dash.Set(8f, 5f, -time * 30f);
shapes.DrawPixelLine(center + new Vector3(-PanelWidth / 2f + 1.5f, 0f, 0f), center + new Vector3(PanelWidth / 2f - 0.3f, 0f, 0f), 1.5f, theme.Accent);
Reset();
}
/// <summary>Clears the captured state, border included, so no panel inherits the last one's.</summary>
void Reset()
{
shapes.BorderWidth = 0f;
shapes.StrokeWidth = 1f;
shapes.Fill.Set(null, 1f);
shapes.Glow.Clear();
shapes.Dash.Clear();
shapes.Gradient.Clear();
shapes.Opacity = 1f;
shapes.Tag = null;
}
/// <summary>Creates one text entity, styled through the same delegate a theme change re-runs.</summary>
void AddText(Scene scene, Vector3 position, string content, float lineHeight, Action<WorldTextComponent, Theme> restyle, bool themed = true, Anchor anchor = Anchor.MiddleCenter, SpriteFont? font = null)
{
var component = new WorldTextComponent
{
Text = content,
// Height is the whole block, so scaling it by the line count keeps one-line and two-line
// stations at the same letter size - the point of a comparison gallery
Height = lineHeight * (content.AsSpan().Count('\n') + 1),
FontSize = FontPixels,
Font = font,
Anchor = anchor,
Alignment = TextAlignment.Center,
};
restyle(component, themes[themeIndex]);
var entity = new Entity("Text") { component };
entity.Transform.Position = position;
entity.Scene = scene;
if (themed)
{
themedText.Add((component, restyle));
}
}
/// <summary>Switches theme: panels re-read it by themselves, text has to be re-styled.</summary>
void ApplyTheme(int index)
{
themeIndex = index;
var theme = themes[themeIndex];
foreach (var (text, restyle) in themedText)
{
restyle(text, theme);
}
}
/// <summary>Where a station sits: columns across, and a panel row with its text row under it.</summary>
Vector3 CellCenter(int station, bool withText)
{
var column = station % Columns;
var group = station / Columns;
var row = group * 2 + (withText ? 1 : 0);
return new Vector3(
(column - (Columns - 1) / 2f) * ColumnPitch,
((Rows - 1) / 2f - row) * RowPitch + GridOffsetY,
0f);
}
IReadOnlyList<TextElement> BuildOverlayLines() =>
[
.. themeMenu?.GetLines() ?? [],
new(""),
new("Mouse wheel", "Zoom", Color.Gold),
new(""),
new(hoveredPanel is { } hovered ? $"Under the mouse: panel {hovered.Number}" : "Under the mouse: nothing", Color.LightGray),
new("Panels: ShapeBatch, one draw call for all 48", Color.LightGreen),
new("Upper row of each pair is the panel alone", Color.LightGray),
new("Corner numbers match the stations array in Program.cs", Color.LightGray),
new("Borders, glows and dashes keep their pixel size", Color.LightGray),
];
/// <summary>The same colour at a different alpha.</summary>
static Color WithAlpha(Color color, byte alpha) => new(color.R, color.G, color.B, alpha);