Skip to content

Example: Bestiary

Contributed by Mushymato and focustense

  • Bindings
  • Two-Way
  • Events
  • Repeaters
  • Conditionals
  • Tabs
  • Grid
  • Animation

Shows information about all monsters in the game, including their first animation, dangerous variants, and combat stats and loot drops in a tabbed view.

internal enum BestiaryTab { General, Combat, Loot }

internal partial class BestiaryViewModel : INotifyPropertyChanged
{
    public IReadOnlyList<MonsterViewModel> AllMonsters { get; set; } = [];
    public IReadOnlyList<BestiaryTabViewModel> AllTabs { get; } =
        Enum.GetValues<BestiaryTab>()
            .Select(tab =>
                new BestiaryTabViewModel(tab, tab == BestiaryTab.General))
            .ToArray();
    public MonsterViewModel? SelectedMonster =>
        SelectedIndex >= 0 ? AllMonsters[SelectedIndex] : null;
    public string SelectedMonsterName => SelectedMonster?.DisplayName ?? "";

    [Notify] private int selectedIndex;
    [Notify] private BestiaryTab selectedTab;

    public void NextMonster()
    {
        SelectedIndex++;
        if (SelectedIndex >= AllMonsters.Count)
        {
            SelectedIndex = 0;
        }
        SelectedMonster?.CurrentAnimation?.Reset();
    }

    public void PreviousMonster()
    {
        SelectedIndex--;
        if (SelectedIndex < 0)
        {
            SelectedIndex = AllMonsters.Count - 1;
        }
        SelectedMonster?.CurrentAnimation?.Reset();
    }

    public void SelectTab(BestiaryTab tab)
    {
        SelectedTab = tab;
        foreach (var tabViewModel in AllTabs)
        {
            tabViewModel.IsActive = tabViewModel.Value == tab;
        }
    }

    public void Update(TimeSpan elapsed)
    {
        SelectedMonster?.CurrentAnimation?.Animate(elapsed);
    }
}

internal partial class BestiaryTabViewModel(BestiaryTab value, bool active)
    : INotifyPropertyChanged
{
    public Tuple<int, int, int, int> Margin =>
        IsActive ? new(0, 0, -12, 0) : new(0, 0, 0, 0);
    public BestiaryTab Value { get; } = value;

    [Notify] private bool isActive = active;
}
internal partial class MonsterViewModel : INotifyPropertyChanged
{
    public string Name { get; set; } = "";
    public string DisplayName { get; set; } = "";
    public MonsterAnimation? DefaultAnimation { get; set; }
    public MonsterAnimation? DangerousAnimation { get; set; }
    public MonsterAnimation? CurrentAnimation =>
        IsDangerousSelected ? DangerousAnimation : DefaultAnimation;
    public bool HasDangerousVariant => DangerousAnimation is not null;
    public int Health { get; set; }
    public int Attack { get; set; }
    public int Defense { get; set; }
    public int Speed { get; set; }
    public int Accuracy { get; set; }
    public int Experience { get; set; }
    public IReadOnlyList<MonsterDrop> Drops { get; set; } = [];

    [Notify] private bool isDangerousSelected;
}

public partial record MonsterAnimation(Texture2D Texture, int Width, int Height)
    : INotifyPropertyChanged
{
    public string Layout => $"{Width * 4}px {Height * 4}px";
    public Tuple<Texture2D, Rectangle> Sprite =>
        Tuple.Create(Texture, new Rectangle(Width * FrameIndex, 0, Width, Height));

    private static readonly TimeSpan animationInterval =
        TimeSpan.FromMilliseconds(150);

    private TimeSpan animationProgress;
    [Notify] private int frameIndex;

    public void Animate(TimeSpan elapsed)
    {
        animationProgress += elapsed;
        if (animationProgress >= animationInterval)
        {
            FrameIndex = (FrameIndex + 1) % 4;
            animationProgress = TimeSpan.Zero;
        }
    }

    public void Reset()
    {
        FrameIndex = 0;
        animationProgress = TimeSpan.Zero;
    }
}

internal record MonsterDrop(ParsedItemData Item, float Chance)
{
    public Color ChanceColor =>
        Chance switch
        {
            < 0.1f => Color.DarkRed,
            <= 0.25f => Game1.textColor,
            <= 0.5f => Color.Blue,
            _ => Color.Green,
        };
    public string FormattedChance = string.Format("{0:0.0%}", Chance);
    public string ItemDisplayName => Item.DisplayName;
}
<lane orientation="vertical" horizontal-content-alignment="middle">
    <lane vertical-content-alignment="middle">
        <image layout="48px 48px"
               horizontal-alignment="middle"
               vertical-alignment="middle"
               sprite={@Mods/StardewUI/Sprites/SmallLeftArrow}
               focusable="true"
               click=|PreviousMonster()| />
        <banner layout="350px content"
                margin="16, 0"
                background={@Mods/StardewUI/Sprites/BannerBackground}
                background-border-thickness="48, 0"
                padding="12"
                text={SelectedMonsterName} />
        <image layout="48px 48px"
               horizontal-alignment="middle"
               vertical-alignment="middle"
               sprite={@Mods/StardewUI/Sprites/SmallRightArrow}
               focusable="true"
               click=|NextMonster()| />
    </lane>
    <lane>
        <lane layout="150px content"
              margin="0, 16, 0, 0"
              orientation="vertical"
              horizontal-content-alignment="end"
              z-index="2">
            <frame *repeat={AllTabs}
                   layout="120px 64px"
                   margin={Margin}
                   padding="16, 0"
                   horizontal-content-alignment="middle"
                   vertical-content-alignment="middle"
                   background={@Mods/focustense.StardewUITest/Sprites/MenuTiles:TabButton}
                   focusable="true"
                   click=|^SelectTab(Value)|>
                <label text={Value} />
            </frame>
        </lane>
        <frame *switch={SelectedTab}
               layout="400px 300px"
               margin="0, 16, 0, 0"
               padding="32, 24"
               background={@Mods/StardewUI/Sprites/ControlBorder}>
            <lane *case="General"
                  *context={SelectedMonster}
                  layout="stretch content"
                  orientation="vertical"
                  horizontal-content-alignment="middle">
                <panel *context={CurrentAnimation}
                       layout="stretch 128px"
                       margin="0, 0, 0, 12"
                       horizontal-content-alignment="middle"
                       vertical-content-alignment="middle">
                    <image layout={Layout} sprite={Sprite} />
                </panel>
                <label margin="0, 8" color="#136" text={Name} />
                <lane *if={HasDangerousVariant}
                      margin="0, 16"
                      vertical-content-alignment="middle">
                    <checkbox layout="content 32px"
                              label-text="Dangerous"
                              is-checked={<>IsDangerousSelected} />
                </lane>
            </lane>
            <lane *case="Combat"
                  *context={SelectedMonster}
                  orientation="vertical">
                <lane margin="0, 0, 0, 6" vertical-content-alignment="middle">
                    <image layout="20px content"
                           sprite={@Mods/focustense.StardewUITest/Sprites/Cursors:HealthIcon} />
                    <label layout="140px content" margin="8, 0" text="Health" />
                    <label color="#136" text={Health} />
                </lane>
                <lane margin="0, 6" vertical-content-alignment="middle">
                    <image layout="20px content"
                           sprite={@Mods/focustense.StardewUITest/Sprites/Cursors:AttackIcon} />
                    <label layout="140px content" margin="8, 0" text="Attack" />
                    <label color="#136" text={Attack} />
                </lane>
                <lane margin="0, 6" vertical-content-alignment="middle">
                    <image layout="20px content"
                           sprite={@Mods/focustense.StardewUITest/Sprites/Cursors:DefenseIcon} />
                    <label layout="140px content" margin="8, 0" text="Defense" />
                    <label color="#136" text={Defense} />
                </lane>
                <lane margin="0, 6" vertical-content-alignment="middle">
                    <image layout="20px content"
                           sprite={@Mods/focustense.StardewUITest/Sprites/Cursors:SpeedIcon} />
                    <label layout="140px content" margin="8, 0" text="Speed" />
                    <label color="#136" text={Speed} />
                </lane>
                <lane margin="0, 6" vertical-content-alignment="middle">
                    <image layout="20px content"
                           sprite={@Mods/focustense.StardewUITest/Sprites/Cursors:LuckIcon} />
                    <label layout="140px content" margin="8, 0" text="Hit Chance" />
                    <label color="#136" text={Accuracy} />
                </lane>
            </lane>
            <grid *case="Loot"
                  *context={SelectedMonster}
                  layout="stretch"
                  item-layout="count: 5"
                  item-spacing="16, 16"
                  horizontal-item-alignment="middle">
              <lane *repeat={Drops} orientation="vertical" horizontal-content-alignment="middle">
                  <image layout="64px" margin="0, 0, 0, 4" sprite={Item} />
                  <label color={ChanceColor} scale="0.66" text={FormattedChance} />
              </lane>
            </grid>
        </frame>
        <spacer layout="50px content" />
    </lane>
</lane>