Recursive AI

Tweens and CSS transitions

All onegame recipes

Animate a C# game’s UI with tweens and CSS style transitions in onegame: slide a label in with an easing curve, blend a color on a class change, step view time from a scenario, and check that the simulation never saw it.

New in 1.4.0. A tween moves a number, a vector or a color from one value to another over some seconds and hands you each value on the way. Tweens run on the view clock, not in the simulation, so they can’t change synced state and a replay or a state hash is the same with or without them. The transition style property does the same for a style change, with no code.

The game

A score that slides in when it changes, and a lamp that fades between two colors. Create the game with onegame new glow, delete art/ and everything in scenarios/, and replace Game.cs:

C#
using OneGame.Core;
using OneGame.Host;
using OneGame.Tweens;
using OneGame.UI;
 
namespace Glow;
 
/// <summary>A score that only the commands change. Everything that moves on screen is view state.</summary>
[Entity]
public partial class Score : Entity
{
    [Synced] public partial int Points { get; private set; }
 
    [Command]
    public void Add(int by) => Points += by;
}
 
public sealed class GlowGame : GameDefinition
{
    public override string Name => "glow";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Glow.EntityTypes.Register(registry);
 
    public override void Setup(World world) => world.Add(new Score());
 
    // A transition: when `lit` is added or removed, the background blends over 300 ms instead of jumping.
    public override void Style(UiTree ui) => ui.AddStyleSheet(new StyleSheet()
        .Rule(".lamp", s => s.Size(160, 40).Background(Color.Rgb(0x203040)).Transition("background-color 300ms ease-out"))
        .Rule(".lamp.lit", s => s.Background(Color.Rgb(0xF2C14E))));
 
    public override void DefineScreens(ScreenSet screens) => screens.Add("main", game =>
    {
        var score = Ui.Label(() => $"{ScoreOf(game).Points} points").Named("score").Padding(Length.Px(12)).Background(Color.Rgb(0x35506B));
        var lamp = Ui.Label("").Named("lamp").Class("lamp");
        return Ui.Column(
            Ui.Button("Score", () =>
            {
                ScoreOf(game).Send.Add(10);
                // Slide the score in from the left and land with a small overshoot. Tweens run on view time, never in the sim.
                game.Tweens.Number(-200, 0, 0.4f, x => score.Style(s => s.Translate(x, 0)))
                    .Ease(Easing.OutBack)
                    .Named("score-in")
                    .OnComplete(() => score.Class("landed"));
            }).Named("add"),
            Ui.Button("Toggle lamp", () => lamp.SetClass("lit", !lamp.HasClass("lit"))).Named("toggle"),
            score, lamp)
            .Padding(Length.Px(16)).Gap(Length.Px(12)).TextColor(Color.White);
    });
 
    private static Score ScoreOf(GameInstance game) => (Score)game.World.Entities[0];
}
  • game.Tweens.Number(from, to, seconds, setter) starts a tween and returns a handle. Chain .Ease(...), .Delay(...), .Repeat(...), .OnComplete(...) and .Named(...) on it. Vector2, Vector3, Vector4 and Color work the same way, and Sequence, Parallel, Delay and Call build longer animations.
  • Easing has the CSS timing functions (ease, ease-in-out, cubic-bezier(...), steps(...)) and the named curves (OutCubic, OutBack, InOutSine and the rest). Easing.Parse("ease-out") reads the CSS text.
  • Transition("background-color 300ms ease-out") follows CSS Transitions: when a listed property changes, from a class switch, :hover or a binding, it moves to the new value over the time given instead of jumping.
  • The button sends a command for the score and starts a tween for the look. The command changes the world; the tween only changes how the label is drawn.

Test it with scenarios

tweens: moves view time without running a tick, and the tweens: expectation counts what is running:

scenarios/score-and-lamp.yaml

YAML
name: score-and-lamp
description: A tween slides the score in and a class change starts a style transition; both finish, and neither touches the sim.
steps:
  - click: add
  - step: 1
  - tweens: 0.2                      # 0.2 s of view time; no ticks run
  - expect: { tweens: { active: 1 } }
  - tweens: settle                   # let everything finite finish
  - expect: { tweens: { active: 0 } }
  - click: toggle
  - tweens: 0.1
  - expect: { tweens: { active: 1 } }
  - tweens: settle
expect:
  - tweens: { active: 0 }
  - ui: { selector: .landed, count: 1 }
  - ui: { selector: .lit, count: 1 }
  - state: { entity: Score, property: Points, equals: 10 }
  - tick: 1
Terminal
onegame test
Text
PASS score-and-lamp (47 ms, tick 1, hash 04ddfdc2ce735cc6)
1 passed, 0 failed

The run ends at tick 1 although the animations took 0.7 seconds of view time. A scenario that only clicks Score and steps once ends on the same hash, 04ddfdc2ce735cc6: the tweens never reached the world.

Look at a tween halfway

The tweens tool lists what is running and moves view time, so a capture can be taken at an exact point of an animation. With the MCP client from Drive a running game over MCP, with the 200-character cut taken out:

Text
ui_click {"id": "add"} -> {"focused":"Button#add","hovered":"Label","tick":0}
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"04ddfdc2ce735cc6"}
tweens {"advance": 0.1} -> {"time":0.1,"paused":false,"timeScale":1,"active":1,"tweens":[{"id":1,"kind":"value","name":"score-in","elapsed":0.1,"duration":0.4,"progress":0.25,"paused":false,"children":0}]}
capture_frame {"path": "shots/mid.png", "size": "360p"} -> {"path":"/tmp/onegame/glow/artifacts/shots/mid.png","width":640,"height":360,"scale":1,"sha256":"c4dc93aad5640f74a2e27774c3264b6b66c987d3325f4f3968935b7da99e5523","renderer":"NVIDIA GeForce RTX 4070","tick":1,"hash":"04ddfdc2ce735cc6"}
tweens {"settle": true} -> {"time":0.401,"paused":false,"timeScale":1,"active":0,"tweens":[]}
capture_frame {"path": "shots/end.png", "size": "360p"} -> {"path":"/tmp/onegame/glow/artifacts/shots/end.png","width":640,"height":360,"scale":1,"sha256":"cd3cba59041243a1641d59cd570620dd000a554720389e65b2da288fe8f21a15","renderer":"NVIDIA GeForce RTX 4070","tick":1,"hash":"04ddfdc2ce735cc6"}
hash {} -> {"frame":1,"tick":1,"hash":"04ddfdc2ce735cc6"}
quit {} -> {"frame":1,"tick":1,"hash":"04ddfdc2ce735cc6"}

In mid.png the score label is a quarter of the way through, still partly off the left edge. In end.png it sits in line with the lamp. The tick and the hash didn’t move between the two. Add capture: { reference: ... } after a tweens: 0.1 step to make that halfway frame a screenshot test.

Good to know

  • Settle at the end of a scenario. At 1.5.1 a tween still running when a scenario ends carries over into the next scenario of the same onegame test run. A scenario that clicked Score and stepped once, run before score-and-lamp, made its first active: 1 check fail with “running tweens is 2, expected 1”. A tweens: settle step at the end fixed it.
  • In a running game, the host advances game.Tweens once per frame by the view time that passed. A stepped run or a capture moves it one tick (1/60 s) per tick, so captures repeat.
  • Starting and running tweens allocates nothing once the pool is warm. A handle works until its tween ends; Cancel() stops it without calling OnComplete, and Complete() jumps to the end.
  • Style transitions are tweens in ui.Tweens, so game.Tweens lists them too. Layout properties such as width and padding reflow every frame while they move.
  • The first style an element gets never animates, and transition: none or a zero duration turns it off.

Every call is in Tweens and transitions.