animations in goo¶
every animated value in goo lives in one of two lanes, split by a single question: who owns the value?
- you own the value - the authored lane. dampers, tweens, timelines,
AgePhase. you store the state, advance it inTick, sample it inBuild. this is almost everything: positions, scales, colors, opacities, scores, gauges. - the engine owns the value - the declared lane. exactly two properties:
TransitionMsandLayoutTransition. state-variant colors and layout positions are resolved engine-side afterBuildreturns, so you cannot sample them; you declare a duration and the engine animates the change.
there is no third lane. the framework doesn't track animations: no transition property beyond those two, no @keyframes. when in doubt, you own the value.
the authored lane in four steps¶
- store state on the panel as a damper field.
- mutate the target when input changes. call
Rebuild(). - advance the damper in your
Tick(dt)override and return itsTick(dt)result. true means "still moving, rebuild me". - sample
.CurrentinsideBuildand feed it into the tree.
one demo, the whole surface¶
Code/Demo/StylishHud/StylishHudUI.cs is the verified capstone: a combat HUD exercising every primitive on this page in one component. when a section below feels abstract, that file is the working reference:
| primitive | where it appears |
|---|---|
DecayFloat |
hit heat: spiked to 1 on every hit, decays to drive a gauge glow |
SpringFloat |
grade letter punch: _letterScale.Velocity += 6f on grade-up |
SmoothColor |
HUD-wide palette crossfade between grade accent colors |
SmoothFloat |
score readout rolling toward the real total, never snapping |
SpringVector2 + AnimationSet |
floating score popups bursting outward from center |
Timeline.Sequence + SegmentIndex |
kill banner choreography: slam, shake, hold, exit |
AgePhase |
kill feed slide-in / hold / fade-out, popup fades |
LayoutTransition |
keyed feed rows gliding when the list restacks |
the \| chain |
one Tick advancing four dampers plus an AnimationSet |
the damper types¶
all are mutable record struct (zero-allocation, copy semantics):
| type | backing | configured by | tracks velocity? | when to use |
|---|---|---|---|---|
DecayFloat |
MathX.ExponentialDecay |
Halflife |
no | simple geometric approach toward a target. no overshoot, no momentum. |
DecayColor |
per-channel ExponentialDecay |
Halflife |
no | color crossfades, no momentum |
DecayVector2 |
per-axis ExponentialDecay |
Halflife |
no | 2D position/size lerps, no momentum |
SmoothFloat |
MathX.SmoothDamp |
SmoothTime |
yes | UI that needs to respond to rapid target changes mid-flight (snappy feedback) |
SmoothColor |
per-channel SmoothDamp |
SmoothTime |
yes | color crossfades with snap-back support |
SmoothVector2 |
per-axis SmoothDamp |
SmoothTime |
yes | 2D position/size with snap-back support |
SpringFloat |
MathX.SpringDamp |
Frequency + Damping |
yes | bouncy/overshoot motion - pops, springs, elastic snaps |
SpringColor |
per-channel SpringDamp |
Frequency + Damping |
yes | color bounce/overshoot effects |
SpringVector2 |
per-axis SpringDamp |
Frequency + Damping |
yes | bouncy 2D position/size - drag-release, elastic UI |
hover-pulse on a container¶
using Goo;
using Goo.Animation;
public class HoverButtonUI : GooPanel<Container>
{
DecayFloat _hover = new(initial: 0f, halflife: 0.06f);
protected override bool Tick(float dt) => _hover.Tick(dt);
protected override Container Build() => new Container
{
Padding = 12,
BackgroundColor = Color.Lerp(Color.Gray, Color.Cyan, _hover.Current),
OnMouseEnter = _ => { _hover.Target = 1f; Rebuild(); },
OnMouseLeave = _ => { _hover.Target = 0f; Rebuild(); },
Children = { new Text("hover me") },
};
}
every damper has Tick(dt): it advances the value and returns true while still moving. GooPanel calls your Tick override every frame before the build gate; returning true requests a rebuild, returning false lets the panel idle. once the damper settles, no more rebuilds happen until the next input event.
(Update(dt) and IsSettled still exist individually for cases where you need them apart. Tick(dt) is just Update plus !IsSettled in one call.)
chaining gotcha: when one Tick override drives several dampers, combine with bitwise |, never short-circuiting ||:
// good: every damper advances every frame
protected override bool Tick(float dt) =>
_hover.Tick(dt) | _press.Tick(dt) | _open.Tick(dt);
// bad: once _hover settles, || stops evaluating and the rest freeze mid-flight
protected override bool Tick(float dt) =>
_hover.Tick(dt) || _press.Tick(dt) || _open.Tick(dt);
the shipped shape of the chain, from the stylish HUD's Tick (Code/Demo/StylishHud/StylishHudUI.cs): four dampers and an AnimationSet in one | chain, then non-damper liveness (a running timeline clock, aging list entries) OR'd on top:
bool moving = _hitHeat.Tick(dt)
| _letterScale.Tick(dt)
| _palette.Tick(dt)
| _shownScore.Tick(dt)
| _popupAnimators.UpdateAll(dt);
moving |= _bannerClock >= 0f && _bannerClock < BannerTimeline.Duration;
moving |= _feed.Count > 0;
return moving;
anything that should keep the panel rebuilding belongs in this flag: dampers report it themselves, clocks and lifecycled lists you report by hand.
AnimationSet: multiple animators per panel¶
a panel holding more than one or two animators turns into a ceremony. tick each one, ask each one if it's settled, rebuild if any isn't. AnimationSet collapses that boilerplate.
register each animator as a TickFn that advances it and returns true if it's still in motion. tick the whole set once per frame, if anything is still running, rebuild.
using Goo;
using Goo.Animation;
public class MultiAnimUI : GooPanel<Container>
{
SmoothFloat _scale = new(initial: 1f, smoothTime: 0.08f);
SpringFloat _bounce = new(initial: 0f, frequency: 6f, damping: 0.5f);
AnimationSet _anims = new();
protected override void OnEnabled()
{
base.OnEnabled();
_anims.Clear();
_anims.Add(dt => _scale.Tick(dt));
_anims.Add(dt => _bounce.Tick(dt));
}
protected override bool Tick(float dt) => _anims.UpdateAll(dt);
}
a few notes on the shape:
- the
TickFnalways runs. it has no short-circuit. each animator advances every frame. only the rebuild gate is conditional. - the damper
Tick(dt)method has exactly theTickFnshape (advance, return true while moving), so registration is a one-liner per damper. - registration allocates one closure per
Addcall. do it fromOnEnabled(not per-frame), and clear first to avoid re-enable / hotload double-register. - arrays of animators register per element, capturing the index in a local:
// SlotCount and _slotBounce are illustrative. substitute your own array and length.
// Example: const int SlotCount = 4; SpringFloat[] _slotBounce = new SpringFloat[SlotCount];
for (int i = 0; i < SlotCount; i++)
{
int k = i;
_anims.Add(dt => _slotBounce[k].Tick(dt));
}
transient animators (spawned per entry rather than declared per panel) follow the same registration shape with one twist: close over a class instance so the closure ticks the same damper Build reads. a damper stored in a struct or captured by value would advance a copy. from the stylish HUD's score popups (Code/Demo/StylishHud/StylishHudUI.cs):
sealed class Popup
{
public int Id;
public string Text = "";
public float Age;
public SpringVector2 Spring;
}
void SpawnPopup(string text)
{
var p = new Popup
{
Id = _nextPopupId++,
Text = text,
Spring = new SpringVector2(Vector2.Zero, 5f, 0.6f) { Target = burstOffset },
};
_popups.Add(p);
_popupAnimators.Add(dt => p.Spring.Tick(dt));
}
an expired popup's tick function keeps returning false harmlessly, so the set never needs surgical removal; clear it wholesale when the list empties to bound its growth:
if (_popups.Count == 0 && _popupAnimators.Count > 0) _popupAnimators.Clear();
the set itself is a thin wrapper around a List<TickFn>. once you've outgrown a single damper, this is the next stop.
a panel rebuilds for two reasons, and GooPanel already handles both around this set, so there is no separate gate to wire up:
- state changed. an event mutated state, so the tree needs a rebuild. that is
Rebuild(), and with the defaultAutoRebuildOnEventsit fires automatically after any event handler on the panel's tree. you rarely call it by hand. - motion. an animator is moving. that is what
Tickreports by returning_anims.UpdateAll(dt). when motion stops (this frame returns false after the previous returned true),GooPanelrebuilds one extra time so the final resting value paints exactly once. you get the settled edge for free, no_dirtybookkeeping.
easings¶
Goo.Animation.Easing re-exports the 16 engine easings as static fields:
Easing.Linear Easing.Ease Easing.EaseIn Easing.EaseOut
Easing.EaseInOut Easing.ExpoIn Easing.ExpoOut Easing.ExpoInOut
Easing.BounceIn Easing.BounceOut Easing.BounceInOut
Easing.SineIn Easing.SineOut Easing.SineInOut
Easing.StepStart Easing.StepEnd
CSS-name lookup via Easing.FromName("ease-in-out"). this returns a nullable Sandbox.Utility.Easing.Function?, so check for null before using it. an unrecognised name returns null rather than throwing.
two factories: Easing.Steps(count, atStart) and Easing.CubicBezier(x1, y1, x2, y2).
important: the two factories return closures (engine-internal capture). cache them once in a static readonly field. never call them inside Build() or Tick. calling per-frame allocates a delegate per call and will blow the framework's per-Rebuild allocation budget.
the recommended pattern is to wrap the factory result in a Tween at declaration time, matching how Code/Demos/AnimationShowcase/Sections/EasingsSection.cs handles it:
// good: cache factory result inside a Tween at the static field level
static readonly Tween TSnap = new Tween(Easing.CubicBezier(0.8f, 0f, 0.2f, 1f), duration: 1.2f);
// bad: allocates a closure every frame
protected override Container Build() => new Container { ... uses Easing.CubicBezier(...) ... };
tweens (time-based playback)¶
dampers are velocity-driven and settle asymptotically. Goo.Animation.Tween is the opposite: time-driven, stateless, runs on a known schedule. you own the clock. the tween is a pure function of elapsed seconds.
var tween = new Tween(Easing.EaseOut, duration: 0.4f);
float t = tween.Eval(elapsedSec); // returns 0..1, clamps past the end
Color faded = Color.Lerp(Color.Black, Color.White, t);
six extension methods chain off the value, each returning a new immutable Tween:
| method | what it does |
|---|---|
.Loop() |
repeats forever |
.Times(n) |
repeats n times then clamps to the natural end value |
.PingPong() |
each iteration is forward then backward (cycle = 2 * duration) |
.Scale(speed) |
> 1 faster, < 1 slower, <= 0 freezes at the start |
.WithDelay(s) |
shifts start by s seconds |
.Reverse() |
inverts the curve (start at 1, end at 0) |
a continuous breathing pulse using a ping-pong loop:
static readonly Tween s_pulse = new Tween(Easing.SineInOut, duration: 0.8f).PingPong().Loop();
protected override bool Tick(float dt) => true; // looping pulse never settles
protected override Container Build() => new Container
{
BackgroundColor = Color.Lerp(BaseColor, HighlightColor, s_pulse.Eval(Time.Now)),
};
Tween is a readonly record struct. Eval is allocation-free and consumes one cached Easing.Function delegate. the same factory-cache rule from the easings section above applies: don't construct Easing.Steps or Easing.CubicBezier inside Eval's caller per frame.
tweens from a curve¶
Tween.FromCurve(curve, duration, delay) bridges a designer-authored Sandbox.Curve into a Tween. the curve is assumed to be authored over [0, 1]. the engine presets Curve.Linear, Curve.Ease, Curve.EaseIn, and Curve.EaseOut all match that domain.
static readonly Tween s_settle = Tween.FromCurve(Curve.EaseOut, duration: 0.6f);
protected override Container Build() => new Container
{
Left = s_settle.Eval(_elapsed) * TravelDistance,
};
FromCurve allocates one delegate per call (the engine Curve struct is copied into the delegate target). cache the result in a static readonly field the same way you would cache Easing.CubicBezier. the returned tween supports the full extension chain. Tween.FromCurve(Curve.Ease, 1f).PingPong().Loop() works identically to a tween built from a coded easing.
animators (owned clock, pause/resume/seek/restart)¶
Tween is stateless. the caller passes in elapsed seconds and gets back a value. when you want a clock attached to a tween, one that pauses, resumes, restarts, or jumps to a position, reach for Goo.Animation.Animator.
Animator _anim = new Animator(new Tween(Easing.EaseOut, duration: 0.4f));
protected override bool Tick(float dt)
{
_anim.Update(dt);
return !_anim.IsFinished;
}
protected override Container Build() => new Container
{
Left = _anim.Value * TravelDistance,
OnClick = _ => { _anim.Restart(); Rebuild(); },
};
Animator is a record struct matching the damper convention. hold it in a field, advance it with Update(dt), sample Value inside Build. allocation-free.
| member | what it does |
|---|---|
Update(dt) |
advances the internal clock when not paused, scaled by Speed |
Pause() / Resume() |
freezes / unfreezes the clock without resetting it |
Restart() |
resets elapsed to 0 and clears paused |
Seek(t) |
jumps to a specific elapsed time, independent of paused state |
Tween |
the tween this animator was constructed with (readable, e.g. _anim.Tween.Duration) |
Elapsed |
seconds since last Restart(), adjusted by Speed |
Paused |
readable bool: true when paused, false when running |
Value |
shortcut for Tween.Eval(Elapsed) (property, not a method) |
IsFinished |
true once Elapsed is past the tween's natural end, always false for Loop() (property) |
Speed is a runtime-tweakable playback multiplier independent of Tween.Scale(n). both compose. the tween's bake-time scale and the animator's runtime speed multiply together.
like dampers, Animator lives in a field. storing animators in a List<Animator> and trying to mutate via index (list[i].Pause()) does nothing. that's a value-type copy. the field-resident pattern is the supported one.
timelines (multi-segment choreography)¶
when a single Tween isn't enough (say, "fade in, hold, fade out" or "slide in then bounce"), wrap several Tweens in a Timeline. the result is still a value-sampling primitive: timeline.Eval(elapsedSec) returns a TimelineSample{int SegmentIndex, float Value}. the caller routes the value (same posture as Tween).
// Same property, multi-phase: caller uses sample.Value, ignores SegmentIndex.
static readonly Timeline FadePulse = Timeline.Sequence(
new Tween( Easing.EaseOut, duration: 0.6f ), // 0..1
new Tween( t => 1f, duration: 0.4f ), // hold at 1 (degenerate easing)
new Tween( Easing.EaseIn, duration: 0.6f ).Reverse() // 1..0
);
// In Tick:
_elapsed += dt;
var s = FadePulse.Eval( _elapsed );
opacity = s.Value;
for explicit timing (gaps are allowed, overlaps throw), use At:
static readonly Timeline Stagger = Timeline.At(
( 0.0f, fadeIn ),
( 0.7f, slideIn ), // 0.3s gap after fadeIn ends, HOLD-LAST during the gap
( 1.4f, bounce )
);
rules:
- segments must have
Duration > 0and non-negativeStartTime. - segments may not overlap. adjacent (one ends exactly when the next starts) is fine.
- construction allocates the
ImmutableArray<Segment>once.Evalis zero-alloc per call. - during a gap between segments,
Evalreports the last-finished segment's index with that segment's end value (HOLD-LAST). - before the first segment's
StartTime,Evalreturns{ SegmentIndex = -1, Value = 0 }. - for finite
Iterations, past-end clamps to the last segment's end value.Loop()(or anyIterations <= 0) wraps moduloDurationforever.
multi-property choreography uses SegmentIndex. the shipped example is the stylish HUD's kill banner (Code/Demo/StylishHud/StylishHudUI.cs): four segments, each valued 0..1, mapped to scale, shake, and opacity inside Build:
static readonly Timeline BannerTimeline = Timeline.Sequence(
new Tween(Easing.ExpoOut, duration: 0.12f), // 0: slam in
new Tween(Easing.SineInOut, duration: 0.10f).PingPong().Times(2), // 1: shake
new Tween(Easing.Linear, duration: 0.60f), // 2: hold
new Tween(Easing.EaseIn, duration: 0.25f) // 3: exit
);
// In Build:
var sample = BannerTimeline.Eval(_bannerClock);
float v = sample.Value;
float scale = sample.SegmentIndex == 0 ? v
: sample.SegmentIndex == 3 ? 1f - v * 0.3f
: 1f;
float opacity = sample.SegmentIndex == 3 ? 1f - v : 1f;
float shakeX = sample.SegmentIndex == 1 ? (v - 0.5f) * 12f : 0f;
the clock is a plain float field advanced in Tick and reset to a sentinel (-1f) past BannerTimeline.Duration; while it runs, the Tick motion flag ORs in _bannerClock >= 0f so the panel keeps rebuilding. derivation stays stateless: each property is a pure function of the sample, so restarting the banner is just _bannerClock = 0f.
concurrent tracks - hold N Timelines as fields and sample each independently. there's no Parallel primitive. the user is the parallel composer.
TimelineAnimator (owned clock over a Timeline)¶
Timeline is stateless. when you want a clock attached to one (pause, resume, restart, seek), reach for Goo.Animation.TimelineAnimator. mirrors Animator exactly except .Sample returns a TimelineSample (not a float).
static readonly Timeline Choreography = Timeline.Sequence( ... );
TimelineAnimator _anim = new( Choreography );
protected override bool Tick( float dt )
{
_anim.Update( dt );
return !_anim.IsFinished;
}
protected override Container Build()
{
var s = _anim.Sample;
return new Container
{
Opacity = s.SegmentIndex == 0 ? s.Value : (s.SegmentIndex > 0 ? 1f : 0f),
OnClick = _ => { _anim.Restart(); Rebuild(); },
};
}
| member | what it does |
|---|---|
Update(dt) |
advances the internal clock when not paused, scaled by Speed |
Pause() / Resume() |
freezes / unfreezes the clock without resetting it |
Restart() |
resets elapsed to 0 and clears paused |
Seek(t) |
jumps to a specific elapsed time, independent of paused state |
Sample |
shortcut for Timeline.Eval(Elapsed) (property, returns TimelineSample, not a float) |
IsFinished |
true once Elapsed >= Duration * Iterations, always false for Loop() (property) |
sample-and-derive over sample-and-cache. when a Timeline drives multiple properties, derive each from Sample at Build time rather than caching _opacity / _xOffset / _scale fields and dispatching switch ( s.SegmentIndex ) inside Update. the cached pattern means Restart() doesn't fully reset the visual. each cached field has to be zeroed manually, and the reset list scales with property count. stateless derivation makes Restart() a true one-liner. pattern:
float PhaseValue( TimelineSample s, int phase )
{
if ( s.SegmentIndex < phase ) return 0f; // not started yet
if ( s.SegmentIndex == phase ) return s.Value;
return 1f; // past this phase, hold at end
}
not in v1 (deferred until a real use case lands): Timeline.PingPong(), Timeline.Scale(speed), a Parallel / Track composer. for speed, multiply elapsed at the call site (_anim.Seek( elapsed * speed ) or scale via TimelineAnimator.Speed).
AgePhase: slide-in / hold / fade-out by age¶
list entries that spawn, sit, then expire (toasts, notification stacks, log lines) share one lifecycle: slide in while young, hold while fresh, fade out once idle. AgePhase is the pure projection for that shape. it is a value-type sibling of Tween: you give it an entry's age and idle time, it returns normalized slide / fade / opacity in 0..1. no clock, no stored state, you own the timing.
construct one with the three durations (optional easings), then call Project per entry per frame and route the result:
using Goo;
using Goo.Animation;
using PanelTransform = Goo.PanelTransform;
static readonly AgePhase s_entry = new(
slideInDuration: 0.25f, holdTime: 3f, fadeOutDuration: 0.5f );
Container Entry( ToastModel toast ) // toast carries its own Age + IdleAge clocks
{
var p = s_entry.Project( toast.Age, toast.IdleAge );
return new Container
{
Opacity = p.Opacity,
Transform = PanelTransform.TranslateX( Px.Of( (1f - p.Slide) * 32f ) ),
Children = { new Text( toast.Message ) },
};
}
| member | what it is |
|---|---|
SlideInDuration |
seconds the slide-in ramp takes (driven by age) |
HoldTime |
seconds held fully visible before fade begins (driven by idleAge) |
FadeOutDuration |
seconds the fade-out ramp takes |
SlideEasing / FadeEasing |
easing for each ramp; default Easing.EaseOut / Easing.EaseIn |
Project(age, idleAge) |
returns an AgeProjection { Slide, Fade, Opacity } |
age is seconds since the entry spawned (drives slide-in). idleAge is seconds since the entry last saw input (drives hold + fade-out). for entries with no separate idle concept, pass age for both. the returned AgeProjection.Opacity is the composite (1 - Fade) * Slide, ready to drop straight into Opacity; Slide is the raw slide ramp (feed it a TranslateX/Y offset) and Fade is the raw fade ramp, exposed for cases that drive them independently.
the stylish HUD's kill feed and score popups (Code/Demo/StylishHud/StylishHudUI.cs) both run on this shape: a static AgePhase per list, per-entry Age/IdleAge clocks advanced in Tick, projection sampled in Build. expiry gotcha: when removing entries once they've faded, guard the opacity test with the entry's age, because a newborn's projection also reads near-zero opacity (the slide-in hasn't ramped yet):
var proj = FeedPhase.Project(e.Age, e.IdleAge);
if (proj.Opacity < 0.005f && e.Age > FeedPhase.SlideInDuration) _feed.RemoveAt(i);
without the Age guard, every entry is culled on the frame it spawns.
declared transitions¶
Everything above is the authored lane: you own a value, advance it in Tick, sample it in Build. This section is the other lane from the top of the page. Two values are engine-resolved and invisible to Build at the right time, so they get declared transitions instead: you state a duration on the blob, and the engine side animates the change.
TransitionMsfades the state-variant colors (hover, active, focus).LayoutTransitionglides aContainerto its new layout position when the tree changes (pop, insert, reorder, reflow). Keyed siblings keep their panel identity, so survivors glide instead of snapping. The stylish HUD's kill feed rows (Code/Demo/StylishHud/StylishHudUI.cs) combine both lanes on one blob: the glide is declared, while the slide-in offset and fade are authored per frame from anAgePhaseprojection:
col.Children.AddRange( _feed, ( i, entry ) =>
{
var proj = FeedPhase.Project( entry.Age, entry.IdleAge );
return new Container
{
Key = $"feed-{entry.Id}", // stable identity, never the index
LayoutTransition = new LayoutTransition( 180f, Easing.EaseOut ), // engine glides the restack
Opacity = proj.Opacity, // authored: AgePhase fade
Position = PositionMode.Relative,
Left = Px.Of( (1f - proj.Slide) * 40f ), // authored: AgePhase slide-in
// ...
};
} );
A second layout change mid-glide retargets smoothly from the current visual position. The glide moves the layout rect itself, so children and hit-testing follow; it never writes the transform channel, and authored Transform animations (an exit slide, for example) compose freely on the same blob. Declare it per child, not on the parent, and avoid nesting gliding containers: a gliding child inside a gliding parent compounds the smoothing.
Changing the transition's own Ms or easing while a glide is in flight finishes the current glide on the original curve; the new timing applies to the next glide. In practice the config is a constant, so this only matters if you swap it live.
This pair is a deliberately closed set: a property gets a declared transition only when the author cannot own the value. Everything else animates through the authored lane.
see also¶
- styles - static visual properties that complement animated ones
- events - the event model for
OnMouseEnter,OnMouseLeave, and other input callbacks - build method - the build method,
Rebuild(), and the update cycle