drawing a gauge¶
stack a track ring and a fill arc to show a 0-to-1 value as a circular gauge
by the end of this guide you will have a circular gauge component that accepts a float value between 0 and 1 and renders a grey track ring behind a green fill arc. both shapes are Sector blobs stacked concentrically inside a single container. you will know exactly which layout rule makes the overlap work, why the fill angle is computed in Build() from a field, and how to drive that field from outside a handler, celebrate it filling up, and reuse the whole thing across several stat bars at once.
note:
GaugeUIbelow is not itself a checked-in demo file. the two-Sectorabsolute stacking it teaches matches the blessedCode/Demo/DocsProbes/DocsShapesSvgProbeUI.csprobe (the "absolute gauge stack" section).
the two shapes¶
a circular gauge needs exactly two layers: a full-circle track that shows the whole range, and a partial-circle fill that sweeps from 0 up to the current value. goo provides Sector for both. a Sector draws an annular wedge defined by StartAngle, EndAngle, InnerRadius, and OuterRadius. a full-circle track is just a Sector where EndAngle = 360f.
using Goo;
using Sandbox.UI;
public class GaugeUI : GooPanel<Container>
{
private float _value = 0.75f;
protected override Container Build()
{
return new Container
{
Width = 140,
Height = 140,
Position = PositionMode.Relative,
Children =
{
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = 360f,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Gray,
},
},
};
}
}
press play and drop GaugeUI on a panel. you will see a grey donut ring. that is the track.
why both children must be absolute¶
add the fill Sector as a sibling of the track. the catch: sibling blobs default to flex flow, not overlap. if you add the fill as a plain sibling without Position = PositionMode.Absolute, the engine places them side by side and each is squashed to half the container width, producing what looks like two tilted ovals. it is not a 3D artifact: the shapes are flat, and a shape evaluates in unit-square UV with no transform in its render path. it is a layout problem.
the rule is: any two shapes you want to overlap concentrically both need Position = PositionMode.Absolute, Top = 0, Left = 0, and their parent needs Position = PositionMode.Relative so it is the positioned ancestor they resolve against.
protected override Container Build()
{
float fillAngle = _value * 360f;
return new Container
{
Width = 140,
Height = 140,
Position = PositionMode.Relative,
Children =
{
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = 360f,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Gray,
},
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = fillAngle,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Green,
},
},
};
}
the track and fill now share exactly the same bounding box. the fill arc sweeps clockwise from 12 o'clock (angle 0) to the fraction of the full circle defined by _value.
Layout.Stack() and Layout.Layer(child) bake this exact pattern into two calls: Layout.Stack() returns the Position = PositionMode.Relative parent, and Layout.Layer(sector) returns the sector with Position = PositionMode.Absolute, Top = 0, Left = 0 appended so it wins over anything already set. reach for it once the by-hand version above makes sense; see shapes guidance for the full helper.
make it respond to a value¶
expose a property so the gauge can be driven from outside, for example a component that polls a stat every frame or receives it over the network. that is exactly the case where a plain field plus a manual Rebuild() call is the wrong tool: the mutation happens outside any goo event handler, so wrap the backing value in a State<T> instead. create it with Track(initial), once, in OnEnabled. writing state.Value marks the panel dirty automatically, and an equal write is a no-op, so the setter below never calls Rebuild() itself.
public class GaugeUI : GooPanel<Container>
{
State<float> _value;
protected override void OnEnabled()
{
base.OnEnabled();
_value = Track(0.75f);
}
public float Value
{
get => _value.Value;
set => _value.Value = Math.Clamp(value, 0f, 1f);
}
protected override Container Build()
{
float fillAngle = _value.Value * 360f;
return new Container
{
Width = 140,
Height = 140,
Position = PositionMode.Relative,
Children =
{
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = 360f,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Gray,
},
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = fillAngle,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Green,
},
},
};
}
}
set gauge.Value = 0.3f and the fill arc sweeps to 30 percent. the track stays full-circle behind it. the setter reads and writes _value.Value, never Rebuild(), because State<T> already invalidates the panel for you.
animating the fill is cheap, no re-bake to worry about¶
Sector carries its geometry in shader uniforms, not a baked texture: StartAngle, EndAngle, InnerRadius, and OuterRadius feed a GPU signed-distance shader that evaluates the wedge per pixel. changing any of them costs nothing extra, so driving fillAngle every frame from a Tick override is just as cheap as the discrete Value setter above. the Value setter shown here is the simpler shape: one State<T> write per external event, correct for a gauge that jumps to a new reading. if you want the needle to ease toward a target instead of snapping, override Tick(float dt), advance a damper or spring toward the target each frame, and write the eased result into _value.Value; returning true from Tick keeps the panel rebuilding while it moves, and the State<T> write still guards against redundant rebuilds once it settles. there is no mask to re-bake either way.
the one shape blob where this does not hold for free is Polygon (and the Ngon/Star factories built on it): its vertices ride a points-texture keyed by content, so changing them every frame bakes a fresh texture every frame. Sector has no such cost. see shapes guidance for the full rule across all three shape blobs.
celebrate a full gauge¶
a gauge that reaches 100 percent is a good moment for a little payoff, a loading bar finishing or a health orb topping off. own a ParticleField as a field, and burst into it the instant Value crosses from below 1 up to 1. mount the field with Hud.Particles(_fx), which steps and draws itself from the paint callback every frame, so it needs no Tick override of its own.
public class GaugeUI : GooPanel<Container>
{
State<float> _value;
readonly ParticleField _fx = new();
protected override void OnEnabled()
{
base.OnEnabled();
_value = Track(0.75f);
}
public float Value
{
get => _value.Value;
set
{
float clamped = Math.Clamp(value, 0f, 1f);
if (clamped >= 1f && _value.Value < 1f)
_fx.Burst(new Vector2(70, 70), count: 24, speed: 220f, color: Color.Green);
_value.Value = clamped;
}
}
protected override Container Build()
{
float fillAngle = _value.Value * 360f;
return new Container
{
Width = 140,
Height = 140,
Position = PositionMode.Relative,
Children =
{
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = 360f,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Gray,
},
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = fillAngle,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Green,
},
Hud.Particles(_fx),
},
};
}
}
the burst origin, (70, 70), is the center of the 140x140 box. Hud.Particles already returns a full-bleed, pointer-through, absolutely-positioned container, so it drops straight into the Children list after the two sectors with no Layout.Layer wrap needed. the check runs in the setter, comparing the old _value.Value against the new clamped value before the write replaces it, so the burst fires exactly once per rising edge to full, not once per frame while the gauge sits at 100 percent.
reusing the gauge across several stats¶
one Value property on the root panel works for one gauge. a stats readout with health, mana, and stamina should not need three near-identical GooPanel subclasses, or three parallel fields threaded through one root's Build(). move the part that draws a gauge into a Cell, and mount one per stat, each keyed and each fed its fraction through configure.
sealed class GaugeCell : Cell<Container>
{
public float Value;
public Color FillColor = Color.Green;
protected override Container Build()
{
float fillAngle = Value * 360f;
return new Container
{
Width = 140,
Height = 140,
Position = PositionMode.Relative,
Children =
{
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = 360f,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = Color.Gray,
},
new Sector
{
Position = PositionMode.Absolute, Top = 0, Left = 0,
StartAngle = 0f, EndAngle = fillAngle,
InnerRadius = 0.55f, OuterRadius = 0.8f,
BackgroundColor = FillColor,
},
},
};
}
}
public class StatsUI : GooPanel<Container>
{
State<float> _health;
State<float> _mana;
protected override void OnEnabled()
{
base.OnEnabled();
_health = Track(1f);
_mana = Track(1f);
}
public float Health { set => _health.Value = Math.Clamp(value, 0f, 1f); }
public float Mana { set => _mana.Value = Math.Clamp(value, 0f, 1f); }
protected override Container Build() => new Container
{
Gap = 16,
Children =
{
Cell.Mount<GaugeCell>(key: "health", configure: c => { c.Value = _health.Value; c.FillColor = Color.Green; }),
Cell.Mount<GaugeCell>(key: "mana", configure: c => { c.Value = _mana.Value; c.FillColor = Color.Blue; }),
},
};
}
GaugeCell holds no state of its own: Value and FillColor are plain fields, fully overwritten by configure on every rebuild, so there is no seed to reach for here. the root, StatsUI, keeps exactly the same State<T>-via-Track shape as the single-gauge Value setter above, once per stat. an external combat system calls stats.Health -= dmg or stats.Mana -= cost from wherever damage and spending happen, neither of which is a goo handler, and each State<T> write invalidates StatsUI on its own. the reconciler still only re-diffs the gauge whose fraction actually changed.
what just happened¶
Sectordraws an annular wedge using four geometry fields:StartAngle,EndAngle,InnerRadius,OuterRadius. a full ring is0fto360fwith a non-zeroInnerRadius.- stacking two shapes concentrically requires both to carry
Position = PositionMode.Absolute, Top = 0, Left = 0and their parent to beRelative. without this the flex layout places them side by side.Layout.Stack()andLayout.Layer()bake this pattern into two calls. - the fill angle is pure arithmetic inside
Build(), read from aState<float>created once withTrack()inOnEnabled. theValuesetter clamps and assigns; theState<T>write invalidates the panel by itself, so there is no manualRebuild()call anywhere in the setter. Sectorgeometry changes are cheap GPU shader work, not a texture bake, so animateStartAngle/EndAngle/InnerRadius/OuterRadiusper frame if the gauge should ease rather than snap.Polygonis the one shape blob where per-frame vertex changes do bake a new texture each frame.- a
ParticleFieldmounted withHud.Particlesadds a one-shot payoff on top of the same two-Sectorstack, with noTickoverride needed on the panel. - pulling the two-
Sectorstack into aCelland mounting it once per stat withCell.Mountreuses the drawing code across as many gauges as a layout needs, each fed its fraction throughconfigure.
see also¶
- your first counter - the simpler field-plus-
Rebuild()pattern this guide'sValuesetter moves beyond. - managing state -
State<T>andTrack()in full, plus when a field and manualRebuild()is still the right call. - cells -
Cell<TRoot>,Cell.Mount, and theseed/configuresplit this guide'sGaugeCelluses. - shapes guidance - the full shapes surface: the color quirk, the
Shapes.Ringcompositor, theLayout.Stack/Layout.Layerhelpers, and whySector/Arcgeometry animates for free whilePolygondoes not. - build method - how
Rebuild()schedules a freshBuild(), structural diff, and theKeyproperty. - container reference - the full layout and style surface that
Sectordraws a subset from. - panel transforms - rotate, scale, and skew a shape after building it, an alternative to driving geometry directly.