UWP komponenta pro zobrazování spritů a frame animací

Seznam článků

Jak na framovou animaci v UWP (univerzálních aplikací) pomocí XAML + C#? Zde najdete postup, jak si vytvořit kkomponentu která zvládne nejen to, ale i další typy animací a bude možné ji použít i pro tvorbu herního světa skládáním ze spritů, ať již statických či animovaných.

 

1. Úvod

Animace založená na rychlém střídání jednotlivých obrázků je podstatou filmové technologie.

Mission Game - AgentXAML umožňuje animace objektů i obrázků na základě plynulé změny hodnot určitých vlastností či transformací (např. úhel otočení, změnu měřítka, posun apod.) prostřednictvím třídy Storyboard, DoubleAnimation a dalších. Ta však nedokáže postihnout případy, kdy se v rámci animace mění obsah obrázku samotného. V těchto případech, chceme-li setrvat u 2D, je zatím stále nejefektivnější všechny tyto proměny nakreslit (ať již ručně, v grafickém editoru nebo pomocí 3D animace) jako jednotlivé obrázky a ty pak rychle za sebou střídat jako u animovaného filmu, aby vznikl dojem pohybu, který má objekt v dané situaci představovat.

Bývá zvykem, tyto jednotlivé obrázky (sprity, u animace pak framy) neukládat jako samostatné soubory, ale poskládat je do jednoho velkého obrázku (sprite sheet) vedle sebe a následně zobrazovat pouze jeho výřezy obsahující právě tyto jednotlivé malé obrázky. Do jednoho obrázku je přitom možné vměstnat ne jen jednu animaci, ale mnoho animací a stejný postup se používá i v případě, že tyto sprity jsou statické, tj. jedno obrázkové bez animace (viz např. tento článek). Tento postup je výhodný z několika důvodů:

  • opakovatelnost použití
    • jednotlivé sprity se mohou na obrazovce v různých kombinacích opakovat, navazovat na sebe nebo se překrývat a vytvářet tak dojem rozsáhlého obrazu či herního světa
    • každému políčku (spritu) lze přiřadit i určité vlastnosti, které se pak opakovaně používají všude kde je vykreslen
    • mapy takovýchto světů pak lze definovat jednoduchými textovými či binárními zápisy, čímž je vytvořeno mnohem více oblastí či úrovní každé hry s týmiž sprite sheety
  • úspora paměti
    • do paměti stačí načíst jeden či jen několik málo obrázků
    • opakovaně se pak používá pouze reference na tento jeden načtený sprite sheet obrázek a více jeho instancí se nevytváří
  • rychlost
    • zaměnit výřez v rámci jednoho obrázku je při správném použití rychlejší, než vyměnit celý obrázek
    • určité technologie, jak SW tak HW, s tímto postupem počítají a výkonnostně jej podporují
    • pokud jsou zdroje stahovány z internetu, tak jeden větší soubor se stáhne rychleji než mnoho malých
  • skiny
    • pro změnu vzhledu celé hry stačí zaměnit jeden soubor s obrázkem
  • správa
    • jeden obrázek s mnoha sprity je během tvorby programu přehlednější a snazší na údržbu než mnoho malých
    • pro generování sprite sheetu z více souborů s jednotlivými sprity existuje mnoho nástrojů

Ukázka použití vykreslení mapy pomocí spritů, zdroj: http://opengameart.org/content/platformer-art-deluxe

Jak již bylo zmíněno, s principem vykreslování scény pomocí spritů z jednoho zdrojového obrázku počítá hardware grafických karet i některé technologie pro tvorbu software. V základu je to samotný Direct X pro 2D hry, nad kterým pracovala například technologie XNA, jejíž podpora již bohužel byla ukončena a nahrazena DirectX projekty pro C++, nicméně díky projektu Monogame lze i na dále stejným principem psát hry v C#.

Pokud na C# trváte i v univerzálních aplikacích (UWP) pro Windows 10, nebo jen nechcete opouštět XAML, který přímou podporou tohoto typu zobrazování spritů ani jejich animování nedisponuje, může být pro vás tento návod užitečný. Obsahuje totiž podrobný postup vytvoření vlastní třídy pro XAML prvek, který bude umět zobrazovat jak jednotlivé sprity z většího obrázku, tak je i animovat rychlým přepínáním jejich jednotlivých framů.


2. Třída SpriteSheetData - data o zdrojovém obrázku

Pro zjednodušení budeme předpokládat, že všechny sprity v obrázku mají stejný obdélníkový (popř. čtvercový) rozměr. Pokud má být nějaký sprit animovaný, jeho jednotlivé framy následují v obrázku bezprostředně za sebou a to ve směru čtení, tzn. zleva doprava a pak po řádcích. Pokud by toto omezení v budoucnu vadilo, bylo by možné do popisu obrázku zařadit i vymezení oblasti, které se specifikace těchto vlastností týká a pro jeho další oblast zase použít parametry jiné. Pro evidování informací o jednotlivých obrázkových souborech (sprite sheet) vytvoříme třídu SpriteSheetData.

public class SpriteSheetData
{	
}

Ta bude v první řadě obsahovat referenci na obrázek typu BitmapImage načtený do paměti a specifikace potřebné pro lokalizaci jednotlivých spritů.

public BitmapImage Image { get; private set; } // Obrázek s jednotlivými sprity (sprite sheet)
public int ImageWidth { get; private set; }    // Šířka obrázku v pixelech
public int ImageHeight { get; private set; }   // Výška obrázku v pixelech
public int SpriteSpace { get; private set; }   // Mezera mezi sprity (jejich ochranné orámování, které se nezobrazuje)
public int SpriteWidth { get; private set; }   // Šířka výřezu jednoho spritu (obrázku)
public int SpriteHeight { get; private set; }  // Výška výřezu jednoho spritu (obrázku)
public int SpriteRows { get; private set; }    // Počet spritů v jednom sloupci (počet řádků) v obrázku
public int SpriteCols { get; private set; }    // Počet spritů v jednom řádku (počet sloupců) v obrázku
public int SpritesCount { get { return SpriteRows * SpriteCols; } }  // Celkový počet spritů v celém obrázku

Hodnoty těchto vlastností jsou pro ostatní objekty pouze pro čtení (set kód je private), takže jejich hodnoty musí nastavit třída sama. To je z toho důvodu, že se v průběhu celé životnosti obrázku měnit nebudou - není k tomu důvod. Obrázek se načte a pak bude až do konce života aplikace stále stejný - stejné budou tedy i jeho parametry. Načtení hodnot vlastností tedy může obstarat přímo konstruktor této třídy, abychom se vyhnuli dalším dodatečným kontrolám, je-li to či ono již nastaveno a v souladu s tím ostatním. Tyto validace by ve smyčce, která má být vykonávána co nejrychleji, pouze spotřebovávaly výpočetní čas, takže se provedou pouze 1x na začátku, při vytváření instance třídy SpriteSheetData. V případě nesouladu některých hodnot (nulový popř. záporný rozměr obrázku, spritu či mezery, nebo nesoulad některého rozměru obrázku s násobkem rozměru spritu) tak bude vyvolána výjimka oznamující co a kde se stalo. Pro případ přejmenování některého z parametrů je použita direktiva nameof podporující refactoring v parametrizovaném textovém řetězci.

public SpriteSheetData(BitmapImage image, int spriteWidth, int spriteHeight = -1, int spriteSpace = 0)
{
    if (spriteHeight < 0)             // Pokud je parametr s hodnotou výšky spritu záporný...
        spriteHeight = spriteWidth;   // pak šlo o zkrácenou definici čtvercového spritu (výška = šířka)
    // Validace vstupních parametrů
    if (image == null)
        throw new Exception($"{nameof(SpriteSheetData)}: {nameof(image)} is null");
    if (image.PixelWidth == 0 || image.PixelHeight == 0)
        throw new Exception($"{nameof(SpriteSheetData)}: {nameof(image)}.{nameof(image.PixelWidth)} or {nameof(image)}.{nameof(image.PixelHeight)} is 0");
    if (spriteWidth <= 0 || spriteHeight <= 0)
        throw new Exception($"{nameof(SpriteSheetData)}: {nameof(spriteWidth)} or {nameof(spriteHeight)} is 0 or less");
    if (spriteSpace < 0)
        throw new Exception($"{nameof(SpriteSheetData)}: {nameof(spriteSpace)} is less then 0");
    if (image.PixelWidth % (spriteWidth + 2 * spriteSpace) != 0)
        throw new Exception($"{nameof(SpriteSheetData)}: {nameof(image)}.{nameof(image.PixelWidth)} not coresponds with {nameof(spriteWidth)} and {nameof(spriteSpace)}");
    if (image.PixelHeight % (spriteHeight + 2 * spriteSpace) != 0)
        throw new Exception($"{nameof(SpriteSheetData)}: {nameof(image)}.{nameof(image.PixelHeight)} not coresponds with {nameof(spriteHeight)} and {nameof(spriteSpace)}");

    // Uložení hodnot do vlastností
    Image = image;
    ImageWidth = image.PixelWidth;
    ImageHeight = image.PixelHeight;
    SpriteWidth = spriteWidth;
    SpriteHeight = spriteHeight;
    SpriteSpace = spriteSpace;
    SpriteRows = ImageHeight / (SpriteHeight + SpriteSpace);
    SpriteCols = ImageWidth / (SpriteWidth + SpriteSpace);
}

Konstruktor tedy jako vstupní parametry vyžaduje referenci na obrázek načtený do paměti (image) a alespoň jeden rozměr spritu (popř. framu) v tomto sprite sheetu. Není-li zadána výška spritu (spriteHeight), bude místo ní dosazena jeho šířka (spriteWidth), čili pro čtvercové sprity by bylo možné zápis příkazu zkrátit, protože výchozí hodnota výšky je -1.

Parametr spriteSpace umožňuje definovat šířku (také v pixelech) orámování jednotlivých spritů/framů. Tohoto orámování, obvykle 1px průhledné barvy ze všech čtyř stran každého spritu, se používá z toho důvodu, že obzvláště při transformaci měřítka je výsledná barva každého pixelu zobrazovaného obrázku dopočítávána na základě barev pixelů okolních. To uvnitř spritu nevadí, ale právě na jeho okrajích by se tak mohl zobrazit určitý podíl pixelů z okolí za jeho hranicemi, tzn. od sousedního spritu. Proto je dobré tuto mezeru do obrázku zahrnout a zde umožnit definovat její šířku.

Hodnoty ostatních vlastností se pak rovnou zde uloží popř. dopočítají na základě těch zadaných a obrázku. Jde o rozměry obrázku (ImageWidth a ImageHeight), rozměry spritu (SpriteWidth a SpriteHeight), mezeru mezi sprity (SpriteSpace) a počet řádků a sloupců spritů v obrázku / sprite sheetu (SpriteRows a SpriteCols).

 

Jedinou nestatickou metodu, kterou tato třída bude disponovat je GetSpritePosition. Ta nám na základě indexu spritu/framu vrátí souřadnice pixelu (Point) jeho levého horního rohu (již uvnitř případného orámování/mezery SpriteSpace). Výřez obrázku od této souřadnice v rozměrech SpriteWidth a SpriteHeight tak bude ukazovat právě tu část obrázku na které je vyobrazen právě sprit na tomto indexu.

/// <summary>
/// Vrátí souřadnice pixelu, který je levým horním rohem spritu na daném indexu
/// </summary>
/// <param name="index">Index framu (ze všech v celém Image), jehož pozici chceme</param>
/// <returns>Souřadnice pixelu levého horního rohu spritu na daném indexu</returns>
public Point GetSpritePosition(int index)
{
    if (index < 0 || index >= SpritesCount)                                        // Kontrola rozsahu indexu
        throw new Exception($"{nameof(SpriteSheetData)}.{nameof(GetSpritePosition)}: {nameof(index)} is out of range");
    return new Point(
            (SpriteWidth + 2 * SpriteSpace) * (index % SpriteCols) + SpriteSpace,  // X souřadnice
            (SpriteHeight + 2 * SpriteSpace) * (index / SpriteCols) + SpriteSpace  // Y souřadnice
        );
}

Metoda LoadImageFromApp je statická a asynchronní, tzn. že její kód je prováděn v jiném vlákně, než je hlavní vlákno aplikace. To sice při správném volání této metody (s await) počká s vykonáváním dalšího kódu, než tato metoda skončí a něco vrátí, ovšem paralelně s tím mohou běžet další procesy obsluhy aplikace, která díky tomu nezamrzne a lze např. posouvat její okno na ploše, minimalizovat jej, aplikaci suspendovat nebo ji i ukončit bez chybového stavu. Tento postup je pro déle trvající operace (což jsou všechny manipulující se soubory či přistupující k síti), u zařízení pracujících na akumulátor velmi žádoucí a Microsoftem u UWP aplikací pro Store vynucovaný.

/// <summary>
/// Načte obrázek ze obsahu (Content) aplikace
/// </summary>
/// <param name="uri">Cesta k souboru aplikace ve formátu "ms-appx:///Pictures/picture.png"</param>
/// <returns>Načtený obrázek</returns>
public static async Task<BitmapImage> LoadImageFromApp(string uri)
{
    var file = await StorageFile.GetFileFromApplicationUriAsync(new Uri(uri));         // Získání informací o souboru s obrázkem
    var image = new BitmapImage();                                                     // Příprava prázdného obrázku pro načtení dat
    using (IRandomAccessStream fileStream = await file.OpenAsync(FileAccessMode.Read)) // Otevření souboru pro čtení
        await image.SetSourceAsync(fileStream);                                        // Načtení obrázku ze streamu
    return image;                                                                      // Vrácení načteného obrázku 
}

Tato metoda tedy načte obrázek do třídy BitmapImage z aplikačního balíčku. Aby se do něho obrázek dostal, musí být přidán do projektu jako soubor a v jeho vlastnostech nastavena Build Action na Content a kopírování do výstupního balíčku (Copy to Output Directory) nastaveno např. na Copy if newer (kopírovat do výstupního sestavení balíčku poprvé nebo pokud byl obrázek změněn).

Adresa takovéhoto souboru (uri) je pak ve formátu odkazujícím na aplikační balíček "ms-appx:///" za čímž následuje již klasická cesta k souboru uvnitř projektu včetně všech podsložek. Jako oddělovač složek je použito obyčejné (nikoli obrácené) lomítko. Je-li tedy obrázek ve složce Assets a jmenuje se MySprites.png, pak bude cesta k němu (uri) následující: "ms-appx:///Assets/MySprites.png".

 

Jako formát obrázků se sprity je v době psaní tohoto článku nejlepší používat PNG. Ten je na rozdíl od např. JPG bezeztrátový, tzn. nedeformuje obsah obrázku ztrátovou kompresí, přitom ale dosti bezeztrátově komprimovaný (obsahuje-li nějaké souvislé stejnobarevné plochy). Hlavní výhodou je ale fakt, že podporuje transparentní barvu a to dokonce s alfa-kanály. Místo RGB se tedy definují 4 složky - ARGB, kde A znamená alfa - průhlednost. Ta je taktéž definována od 0 do 255 a určuje míru průhlednosti každého pixelu. Částečně průhledné pixely po okraji obrázku tak umožňují to, aby obrázek (sprit) zapadal do jakkoli barevného prostředí (pozadí) aniž by okolo něj byly nějaké zubaté (ať již bílé či černé) okraje.

Ukázka vlivu alfa kanálů, zdroj: http://wiki.mcneel.com/rhino/rhinoicons

PNG také podporuje různé rozsahy barevné palety, takže v případě potřeby a možnosti redukce barev, kvůli snížení bytové velikosti souboru s obrázkem (pro snížení velikosti aplikačního balíčku i jejích požadavků na paměť), je zde prostor i na tyto úpravy.


3. Třída AnimationControl - hlavní komponenta

Třída AnimationControl bude komponentou, kterou bude možné umístit přímo do XAML kódu, byť bez příslušné inicializace nebude nic zobrazovat. Abychom získali všechny potřebné vlastnosti takového XAML-control prvku, můžeme ji založit na třídě některého z prvků již hotových. Je celkem jedno, který to bude, ovšem musí umožňovat nastavení pozadí typu ImageBrush, tj. pozadí definované obrázkem. Tuto vlastnost mají všechny geometrické tvary (Shape) jako je Rectangle, Ellipse, Plygon, Path apod. ve vlastnosti Fill. U kontejnerových polohovacích prvků (Panel) jako např. Grid, StackPanel, Canvas apod. se pozadí definuje ve vlastnosti Background. V tomto případě bude použita jako bázová třída Grid, nicméně stejně by vše fungovalo i s kteroukoli jinou.

public class AnimationControl : Grid
{
}

Základní metody a vlastnosti

Aby bylo možné komponentu vykládat přímo přes XAML, je třeba jí definovat bezparametrický konstruktor. V něm nastavíme bod pro transformace prvku (RenderTransformOrigin) na jeho střed (relativní souřadnice 0.5 z celkové šířky objektu horizontálně a 0.5 z celkové výšky vertikálně). Tento bod se týká transformací komponenty jako celku, nikoli transformací, které budeme provádět s obrázkem v jeho pozadí.

Ukázka relativních souřadnic

V konstruktoru také přidáme zpracování události změny velikosti prvku (SizeChanged), při které budeme muset přepočítat změnu měřítka obrázku tak, aby jeden jeho sprit přesně odpovídal novému rozměru (vždy aktuálnímu) tohoto prvku. Bude tak možné komponentě nastavit např. rozměry na Stretch či Auto a ten se tak bude automaticky přizpůsobovat změnám rozměrů ostatních prvků či celého okna aplikace. Obsluhu této události obstará metoda AnimationControl_SizeChanged, která bude implementována níže.

public AnimationControl()
{
    RenderTransformOrigin = new Point(0.5, 0.5);      // Nastavení středu otáčení a změny měřítka objektu (nikoli obrázku v pozadí) pro tyto jeho transformace
    this.SizeChanged += AnimationControl_SizeChanged; // Hlídání změn velikosti tohoto objektu (komponenty)
}

V případě, že bude scéna složená ze spritů generována ze C# kódu na pozadí, což je pravděpodobnější scénář než přímá definice v XAMLu, tak následující přetížení konstruktoru umožní definovat vše potřebné v rámci jednoho příkazu. V tomto případě konstruktoru předáme jako vstupní parametry referenci na data o obrázku se sprity (imageData) a index spritu (či 1. framu jeho animace) v tomto obrázku frameIndexStart (indexováno je od 0 do počtu spritů - 1). Další nepovinné parametry se týkají již pouze animovaného spritu a určují počet framů této animace (framesCount, 1 = sprit je statický - neanimovaný, tj. má jen jeden frame), dobu trvání animace v sekundách (animationDuration, doba jednoho prostřídání všech framů daného spritu od prvního do posledního) a má-li se animace přehrávat i zpětně (autoReverse).

Auto reverze znamená, že se nejprve v zadaném čase prostřídají všechny framy jednoho spritu od prvního do posledního a pak, místo aby se začaly přehrávat znovu do prvního, tak se (opět v témže čase daném animationDuration) přehrají v pořadí opačném, tj. od posledního k prvnímu. Následně se pak budou zase přehrávat klasicky.

Pro tuto animaci pak místo tohoto stačí toto.
Ukázka animace, kde lze použít reverzi, zdroj: http://www.netanimations.net/Swinging-moving-pendulum-and-metronome-images-and-rhythmic-beat-animations.htm Sprite sheet s framy pro animaci bez autoreverze Sprite sheet s framy pro animaci s autoreverzí
public AnimationControl(SpriteSheetData imageData, int frameIndexStart, int framesCount = 1,
    double animationDuration = 0, bool autoReverse = false)
{
    RenderTransformOrigin = new Point(0.5, 0.5); // Nastavení středu otáčení a změny měřítka objektu (nikoli obrázku v pozadí) pro tyto jeho transformace
    this.SizeChanged += AnimationControl_SizeChanged;   // Hlídání změn velikosti tohoto objektu (spritu / komponenty)
    Initialize(imageData, frameIndexStart, framesCount, animationDuration, autoReverse); // Nastavení výchozích hodnot
}

Nyní můžeme implementovat metodu pro obsluhu změny velikosti tohoto prvku. Ta v podstatě pouze zavolá jinou metodu RecalcTransormations, která bude vše vykonávat i pro další události (např. prvotní inicializace). Tu opět implementujeme až v dalším kódu.

private void AnimationControl_SizeChanged(object sender, Windows.UI.Xaml.SizeChangedEventArgs e)
{
    RecalcTransormations(); // Při změně velikosti komponenty přepočítat jeho transformace
}

Dále nedefinujeme vlastnosti pro nastavení parametrů zobrazování spritu a jeho případné animace. ImageData tedy bude uchovávat informace o celém obrázku (sprite sheetu) a rozměrech jeho spritů (viz třída SpriteSheetData), přičemž společně s jeho definicí bude zapotřebí určit i další parametry tohoto spritu, které by se individuálně měnit neměly (pro uhlídání konzistence všech těchto hodnot). Vlastnost je tedy pro okolí pouze pro čtení (set kód má private) a bude možné ji nastavit pouze přes metodu Initialize (bude implementována níže) společně s dalšími nezbytnými parametry.

Doba trvání animace (AnimationDuration) naopak být za běhu měněna může, neboť se každé přepnutí framu bude počítat individuálně.

Proměnná nextFrameSign bude určovat znaménko pro přepínání framů, tzn. buď bude +1 a index aktuálního framu se bude zvyšovat, nebo bude -1 (pouze při auto revezri) a index aktuálního framu se bude snižovat. Proměnná je soukromá (private), takže si její hodnotu bude měnit a hlídat pouze tato třída sama.

S tím souvisí i vlastnost AutoReverse, která sice může být taktéž měněna za běhu, ale musí si ohlídat právě toto znaménko přepínání framů. Jediná kombinace, která by neměla nastat totiž je, že pokud bude auto-revezre vypnuta, tak se nesmí framy přepínat v opačném pořadí, tzn. pokud se této vlastnosti bude nastavovat hodnota false, tak je třeba znaménko pro posun framů nextFrameSign nastavit na +1.

public SpriteSheetData ImageData { get; private set; } // Informace o obrázku s jednotlivými sprity/framy
public double AnimationDuration { get; set; }          // Doba trvání animace v sekundách
private short nextFrameSign = 1;                       // Směr (znaménko) posunu framů (při AutoReverse se bude střídat z +1 na -1)

private bool autoReverse;
public bool AutoReverse                                // Po dosažení posledního framu nezačínat znovu od prvního, ale přehrát nejprve animaci v opačném pořadí framů
{
    get { return autoReverse; }
    set
    {
        autoReverse = value;                           // Uložení hodnoty do datové položky
        if (!autoReverse)                              // Pokud byla právě AutoReverse vypnuta pak...
            nextFrameSign = 1;                         // ... směr animace nuceně nastavit dopředu
    }
}

Další vlastnosti se týkají vymezení spritu a jeho případné animace v rámci zdrojového obrázku. FrameIndexStart určuje index spritu (či 1. framu jeho animace) v obrázku, přičemž indexováno je od 0 do počtu spritů v obrázku - 1. FramesCount určuje počet framů animace tohoto spritu, takže hodnota 1 znamená, že sprit je statický - neanimovaný, neboli má jen jeden frame. Obě tyto hodnoty podléhají validaci správnosti a zásadně mění průběh animace, proto nemohou být individuálně měněny. Pro jejich změnu je třeba použít metodu Initilalize nebo ChangeFrameIndexes, které budou implementovány níže.

Vlastnost FrameIndexCurrent, zapouzdřující stejnojmennou proměnnou (s malým písmenem na začátku), pak určuje relativní index aktuálně zobrazeného framu daného spritu. Relativní v tom smyslu, že se nepočítá v rámci pozice spritu v celém obrázku, ale pouze ve rozsahu vymezeném indexem prvního framu a počtem framů animace pro tento sprit, tj. od 0 do FramesCount-1. V případě statického spritu bude tedy tato hodnota vždy 0. Změna hodnoty vlastnosti zároveň rovnou mění zobrazovaný výřez obrázku pomocí níže implementované metody SetFrame, takže ve svém set kódu obsahuje i příslušné kontroly této hodnoty.

public int FrameIndexStart { get; private set; } // Index prvního framu v obrázku pro tento sprit 
public int FramesCount { get; private set; }     // Počet framů náležících animaci tohoto spritu

public int frameIndexCurrent = 0;
public int FrameIndexCurrent                     // Relativní index aktuálně zobrazeného framu
{
    get { return frameIndexCurrent; }
    set
    {
        if (value >= 0 && value < FramesCount)   // Index framu musí být z vymezeného rozsahu
        {
            frameIndexCurrent = value;           // Uložení hodnoty nového indexu
            SetFrame(FrameIndexCurrent);         // Upravit transformaci posunu obrázku v pozadí na aktuální frame
        }
    }
}

Inicializace hodnot vlastností

Jak již bylo zmíněno výše, hodnoty některých vlastností nelze měnit individuálně, aby bylo možné efektivně uhlídat jejich konzistentnost (vzájemnou validitu). Pro jejich hromadné nastavení tedy budou sloužit následující dvě metody. První z nich Initialize umožňuje kompletní nastavení prvku, stejně jako parametrizovaný konstruktor této třídy, který tuto metodu používá. V případě, že byla instance této třídy vytvořena v separátním příkazu pomocí bezparametrického konstruktoru, lze tuto metodu zavolat zvlášť dodatečně, což platí i pro prvky vložené přes XAML.

Hlavním faktorem je nastavení vlastnosti ImageData, tj. obrázku se sprity, který se již jinak nastavit nedá. Dále je rovnou vymezen rozsah spritu a jeho framů (frameIndexStart a framesCount) pro animaci a popř. nepovinně i další vlastnosti této animace, které lze ale měnit i dodatečně (animationDuration a autoReverse; null signalizuje. že má ve vlastnosti zůstat ta hodnota, která tam aktuálně je). Pokud by měl být v průběhu animace změněn zdrojový obrázek (např. na jiný skin), pak lze tuto metodu zavolat i opakovaně.

public void Initialize(SpriteSheetData imageData, int frameIndexStart, int framesCount = 1, 
    double? animationDuration = null, bool? autoReverse = null)
{
    ImageData = imageData;
    if (animationDuration != null)
        AnimationDuration = (double)animationDuration;
    if (autoReverse != null)
        AutoReverse = (bool)autoReverse;
    ChangeFrameIndexes(frameIndexStart, framesCount, -2);
    SetBackground();
}

Další metoda ChangeFrameIndexes umožňuje změnit rozsah spritů (jejich indexy) i za běhu, bez nutnosti měnit zdrojový obrázek. To se může hodit např. pokud pohybující se objekt používá jinou animaci pro pohyb do různých stran, nebo během hry dochází ke změně jeho vzhledu. Metoda obsahuje validace vzájemné konzistentnosti všech vstupních parametrů i vzhledem ke zdrojovému obrázku a případné srozumitelné oznámení jejich nesouladu výjimkou. Ohlídá si i aby, index aktuálního framu byl v nově nastaveném rozsahu a pokud není, tak jej do něho vrátí. Jeho hodnota může být nastavena na novou hodnotu (>= 0), ponechána hodnota původní (-1), nebo být dokonce zcela ignorována (-2), což je např. potřeba při volání předchozí inicializační metodou, která obrázek v pozadí a jeho transformace nastaví až po zavolání této metody samostatně, takže by zatím k hodnotám těchto vlastností vůbec neměla přistupovat.

/// <summary>
/// Umožňuje změnit index a počet pro vymezení framů pro animaci tohoto spritu (třeba pro změnu vzhledu za běhu)
/// </summary>
/// <param name="frameIndexStart">Index prvního framu pro animaci</param>
/// <param name="framesCount">Počet framů pro animaci</param>
/// <param name="frameIndexCurrent">Index aktuálního framu (-1 = nechat stávající, vejde-li se do nového rozsahu, -2 = vůbec s hodnotou nemanipulovat)</param>
public void ChangeFrameIndexes(int frameIndexStart, int framesCount, int frameIndexCurrent = -1)
{
    // Změny lze provádět pouze pokud je již nastaveno ImageData
    if (ImageData == null)
        throw new Exception($"{nameof(AnimationControl)}: {nameof(ChangeFrameIndexes)} cannot be invoked before {nameof(Initialize)}");
    // Kontrola parametrů
    if (frameIndexStart < 0 || frameIndexStart >= ImageData.SpritesCount)
        throw new Exception($"{nameof(AnimationControl)}: invalid {nameof(frameIndexStart)}");
    if (framesCount <= 0 || framesCount > ImageData.SpritesCount)
        throw new Exception($"{nameof(AnimationControl)}: invalid {nameof(framesCount)}");
    if (frameIndexStart + framesCount > ImageData.SpritesCount)
        throw new Exception($"{nameof(AnimationControl)}: {nameof(frameIndexStart)} + {nameof(framesCount)} is out of range");

    // Uložení hodnot
    FrameIndexStart = frameIndexStart;
    FramesCount = framesCount;
    if (frameIndexCurrent != -2)
    {
        if (frameIndexCurrent < 0)                                     // -1 -> Nechat současný index
            frameIndexCurrent = FrameIndexCurrent;
        if (frameIndexCurrent >= 0 && frameIndexCurrent < framesCount) // Kontrola, je-li index v rozsahu
            FrameIndexCurrent = frameIndexCurrent;                     // Pokud ano, tak jej nastavit
        else
            FrameIndexCurrent = 0;                                     // Pokud ne, začne animace od začátku
    }
}

Práce s obrázkem v pozadí

Další proměnné a metody budou pracovat s obrázkem v pozadí (sprite sheetem) tak, aby zobrazovaly vždy jen jeho příslušný výřez, tj. jeden sprit/frame na daném indexu.

Soukromé proměnné traBackTrans a traBackScale budou vztaženy k obrázku v pozadí (přes jeho ImageBrush), nikoli k prvku jako celku. První z těchto transformací (traBackTrans) bude umožňovat horizontální (X) a vertikální (Y) posun obrázku v pozadí, čehož bude využito pro nastavení obrázku takovým způsobem, aby pod výřezem komponenty byl právě ten jeden konkrétní sprit/frame.

Druhá transformace (traBackScale) bude umožňovat změnu měřítka obrázku, zvlášť horizontálního (ScaleX) a vertikálního (ScaleY), přičemž např. 1 = 100% velikosti, 0.5 = 50% velikosti a 2 = 200% velikosti originálního obrázku. Této transformace bude využito pro korekci rozměrů obrázku, kdyby rozměr komponenty neodpovídal rozměrům spritu.

private ScaleTransform traBackScale;     // Transformace měřítka obrázku v pozadí
private TranslateTransform traBackTrans; // Transformace posunu obrázku v pozadí

Metoda SetBackground je volána za účelem inicializace obrázku v pozadí. Jinými slovy vezme obrázek z vlastnosti ImageData, vytvoří pozadí obrázku (Background) typu ImageBrush do něhož tento obrázek nastaví a pro toto pozadí připraví a naváže transformace traBackTrans a traBackScale pro jeho přizpůsobování, které sloučí pomocí skupiny transformací (TransformGroup). Na závěr nastaví hodnoty transformací níže implementovanou metodou RecalcTransormations tak, aby odpovídaly aktuálnímu nastavení, tj. komponenta na pozadí vyobrazovala právě jeden sprite/frame na daném indexu.

/// <summary>
/// Obrázek z ImageData nastaví jako pozadí tohoto objektu, a vytvoří mu rovnou i transformace umožňující 
/// změnu jeho velikosti a posun tak, aby ve výřezu obrázku určeným rozměry objektu byl vždy vyobrazen právě 
/// jeden frame na zvoleném indexu.
/// </summary>
private void SetBackground()
{
    var img = new ImageBrush() {                          // Vytvoření obrázkového pozadí a jeho výchozí nastavení       
        ImageSource = ImageData.Image,                    // Obrázek
        Stretch = Stretch.None,                           // Vypnutí automatického roztahování obrázku
        AlignmentX = AlignmentX.Left,                     // Horizontální zarovnání obrázku dle levého okraje
        AlignmentY = AlignmentY.Top                       // Vertikální zarovnání obrázku dle horního okraje
    };
    var tg = new TransformGroup();                        // Vytvoření skupiny transformací pro obrázek v pozadí
    traBackTrans = new TranslateTransform() {             // Vytvoření transformace pro posun obrázku
        X = 0, Y = 0
    };
    traBackScale = new ScaleTransform() {                 // Vytvoření transformace pro měřítko obrázku
        CenterX = 0, CenterY = 0, ScaleX = 1, ScaleY = 1
    };
    tg.Children.Add(traBackTrans);                        // Přidání transformace posunu do skupiny transformací obrázku  (1.)
    tg.Children.Add(traBackScale);                        // Přidání transformace měřítka do skupiny transformací obrázku (2.)
    img.Transform = tg;                                   // Přiřazení transformační skupiny obrázku v pozadí
    RecalcTransormations();                               // Přepočet hodnot obou transformací dle rozměrů obrázku a tohoto objektu
    Background = img;                                     // Obrázek pro zobrazení nastavit až po všech jeho úpravách
}

Výše již několikrát použitá metoda RecalcTransormations má za úkol nastavit obě transformace obrázku v pozadí (traBackTrans a traBackScale) tak, aby komponenta v rámci svých mezí zobrazovala právě jeden sprite/frame na aktuálním indexu. O nastavení transformace měřítka (přizpůsobení velikosti spritu rozměrům prvku) se zde stará přímo metoda sama, výpočtem poměru stran komponenty a spritu, transformaci posunu (posunutí obrázku v pozadí tak, aby byl ve výřezu prvku vidět ten správný sprit/frame) nastavuje pomocí níže implementované metody SetSprite.

/// <summary>
/// Přepočet hodnot transformací obrázku v pozadí podle rozměrů obrázku, rozměrů tohoto objektu a indexu framu, který se má zobrazovat
/// </summary>
private void RecalcTransormations()
{
    // Upravit měřítko obrázku tak, výsledný rozměr jednoho spritu/framu na obrázku v pozadí  přesně odpovídal rozměrům tohot objektu (AnimationControl)
    if (traBackScale != null)                                        // Transformace měřítka již byla vytvořena
    {
        traBackScale.ScaleX = ActualWidth / ImageData.SpriteWidth;   // Výpočet horizontálního měřítka
        traBackScale.ScaleY = ActualHeight / ImageData.SpriteHeight; // Výpočet vertikálního měřítka
    }
    // Posunout obrázek v pozadí tak, aby byl zobrazen frame (sprite) na aktuálním indexu
    SetFrame(FrameIndexCurrent);                                     // Nastavení transformace posunu na aktuální frame
}

Metoda SetSprite má tedy za úkol posunout obrázek na pozadí tak, aby ve výřezu komponenty byl právě požadovaný sprite na daném indexu (parametr index). Metoda si kontroluje, je-li vše správně nastaveno, aby nedošlo k neočekávané výjimce. Následně si pomocí metody GetSpritePosition ze třídy SpriteSheetData nechá určit souřadnice levého horního pixelu požadovaného spritu a nastaví transformaci posunu traBackTrans tak, aby bylo docíleno kýženého efektu. Přitom se předpokládá, že měřítko obrázku bylo upraveno tak, aby rozměry spritu a komponenty přesně seděly. Jelikož je transformace posunu v transformační skupině obrázku v pozadí na prvním místě, je tento posun určen v jednotkách celých pixelů dle originálního obrázku, neboť k transformaci měřítka dojde až po tomto posunu (na pořadí transformací ve skupině totiž záleží).

Parametr index je této metodě zadán relativně (0 ... FrameCount-1), takže pro jeho celkový index v obrázku je k němu přičtena ještě hodnota z vlastnosti FrameIndexStart.

/// <summary>
/// Nastaví transformaci posunu obrázku v pozadí tak, aby ve výřezu obrázku určeném rozměrem tohoto prvku (AnimationControl) 
/// byl frame na zadaném indexu (relativním)
/// </summary>
/// <param name="index">Relativní index framu (od 0 do FramesCount-1), který má být vyobrazen</param>
private void SetFrame(int index)
{
    if (traBackTrans != null && index >= 0 && FramesCount > 0 && index < FramesCount && ImageData != null) // Validace parametrů
    {
        var pos = ImageData.GetSpritePosition(index + FrameIndexStart); // Získání souřadnic pixelu v levém horním rohu požadovaného framu
        traBackTrans.X = -pos.X;  // Horizontální posun obrázku v pozadí
        traBackTrans.Y = -pos.Y;  // Vertikální posun obrázku v pozadí
    }
}

Animování obrázku

S pomocí výše uvedené části kódu třídy je již možné tuto komponentu snadno používat pro zobrazování spritů z větších obrázků (sprite-sheetů), nebo je i efektivně přepínat v nějaké smyčce a provádět tak jejich framové animace. Abychom uživateli této komponenty proces ještě více usnadnili, připravíme i metodu, která veškeré animování zařídí sama, bude-li správně volána.

Nejprve deklarujeme proměnnou animationDelay, ve které se bude počítat čas od posledního přepnutí obrázku (framu). Na začátku a po každém přepnutí framu bude tedy tato proměnná nastavena na 0 a postupně se bude zvyšovat o čas uplynulý od posledního pokusu o animaci. Čas se bude počítat v sekundách popř. v jejich zlomcích.

private double animationDelay = 0;                             // Počítadlo odpočtu pro přepnutí na další obrázek

Metoda Animate je právě ta, kterou je potřeba volat v rámci nějaké herní smyčky neustále dokola. Jejím vstupním parametrem ellapsedTime je čas vyjádřený v sekundách (např. 0.05 = 50ms), který uplynul od posledního volání této metody daného objektu. O jeho výpočet by se mělo starat hlavní vlákno herní smyčky a zde tedy bude přijímán pouze jako parametr.

Metoda následně ověří je-li co animovat, tj. je-li nastaven obrázek a rozsah indexů framů takový, že je mezi čím přepínat. Pak už jen zvyšuje počítadlo animationDelay o uplynul čas ellapsedTime a dosáhne či přesáhne-li čas vymezený pro setrvání jednoho políčka animace (frame) na "scéně" AnimationDuration, pak je počítadlo vynulováno a frame přepnut na další v pořadí níže implementovanou metodou NextFrame. Místo dělení celkové doby přehrání jedné série framů jejich počtem je zde použito rychlejší operace násobení času odpočtu jejich počtem, neboli upravená verze této nerovnice (místo "animationDelay >= AnimationDuration / FramesCount" je zde "animationDelay * FramesCount >= AnimationDuration").

public void Animate(double ellapsedTime)
{
    if (ImageData == null) return;                             // Není-li nastaven obrázek, pak není co animovat
    if (FramesCount > 1 && AnimationDuration > 0)              // Má-li sprit jen jeden frame či nulový čas animace, pak je statický (bez animace)
    {
        animationDelay += ellapsedTime;                        // Odpočet do dalšího přepnutí framu
        if (animationDelay * FramesCount >= AnimationDuration) // Odpočet vypršel...
        {
            animationDelay = 0;                                // Vynulovat počítadlo odpočtu
            NextFrame();                                       // Přepnout na další obrázek (frame) spritu
        }
    }
}

Soukromá metoda NextFrame obstarává přepnutí na další frame animovaného spritu. To, který frame bude zobrazen po tomto přepnutí je závislé na směru, kterým se aktuálně framy přehrávají/střídají/přepínají. Tento směr určuje výše deklarované proměnná nextFrameSign nabývající hodnoty +1 nebo -1. Součtem této proměnné a aktuálního indexu se určí index pro nový frame, který, pokud je v daném rozsahu, je nastaven pro zobrazení, o což se postará set kód vlastnosti FrameIndexCurrent, volající metodu SetFrame.

V případě, že by nový index framu směřoval mimo vymezený rozsah (0..FrameCount-1), tak byla zjevně přehrána již celá série framů. Ty se přehrávají ve smyčce buď stále od prvního do posledního, nebo, je-li zapnuta auto-reverze (AutoReverse), se směr střídá od prvního k poslednímu, od posledního k prvnímu (zpětně) atd. V případě auto-reverze se tedy po překročení rozsahu změní směr přehrávání (znaménko nextFrameSign) na opačný a index nového framu se vypočte znovu s tímto znaménkem. Není-li auto-reverze aktivní, nastaví se prostě FrameIndexCurrent na 0, tj. znovu na první frame animace.

private void NextFrame()
{
    if (FramesCount <= 1) return;                           // Animace probíhá až od 2 a více framů

    int newFrameIndex = FrameIndexCurrent + nextFrameSign;  // Výpočet indexu nového framu
    if (newFrameIndex < 0 || newFrameIndex >= FramesCount)  // Kontrola, je-li index nového framu mimo rozsah spritu
        if (AutoReverse)                                    // Při autoreverzi se bude animovat v opačném směru...
        {
            nextFrameSign *= -1;                            // Změna znaménka (směru) pro posun framů
            FrameIndexCurrent += nextFrameSign;             // Posun na další frame v aktuálním směru posunu
        }
        else                                                // Není zapnuta auto-reverze...
            FrameIndexCurrent = 0;                          // Začít animaci znovu od začátku
    else                                                    // Nový index je stále v rozsahu indexů framů spritu...
        FrameIndexCurrent = newFrameIndex;                  // Nastavit aktuální index na tento nový index
}

Tímto je celý kód komponenty pro zobrazování jednotlivých spritů ze sprite sheetu včetně podpory jejich do jisté míry automatizovaného střídání (animace) kompletní a lze jej již přímo používat. V další části ovšem ještě přidáme podporu pro usnadnění některých standardních animací (otáčení a zmenšování/zvětšování) bez nutnosti každou jejich fázi rozkreslovat do jednotlivých framů.


4. Další typy animací

Prostřednictvím framové animace lze řešit mnoho případů. Některé však lze obejít i jinak, např. pomocí transformací, popř. Storyboardu. V následujícím rozšíření předchozího příkladu implementujeme přímou podporu animace rotace, tj. otáčení objektu kolem své osy (středu) a animace měřítka, tj. plynulého zmenšování a zvětšování objektu. Obojí tedy bez nutnosti kreslit stupně otočení či zmenšování/zvětšování jako jednotlivé framy.

Třída AnimationData

Pro definici takovýchto animací bude vytvořena pomocná třída AnimationData, inspirovaná třídou DoubleAnimation, která je standardně součástí UWP.

public class AnimationData
{	
}

Tato třída bude zapouzdřovat více typů animací, takže názvy jejích vlastností budou obecné, inspirované standardní třídou DoubleAnimation. From bude určovat počáteční hodnotu měněné vlastnosti a To hodnotu konečnou (např. otáčení kolem dokola bude od 0° do 360°, plynulé zmenšování na polovinu bude od 1 do 0.5 atd.). Vlastnost Duration bude určovat čas v sekundách, za který má jedno kolo animace proběhnout (z From do To). Vlastnost Reverse, podobně jako AutoReverse u AnimationControl, bude znamenat, že animace po svém přehrání z From do To nebude začínat další kolo znovu od From, ale přehraje se nejprve obráceně z To do From. Aktivní příznak Ease pak bude znamenat, že začátek a konec každého kola této animace bude utlumen. Tím je myšlen "plynulý rozjezd" na začátku a "pozvolné brždění" ke konci animace, nikoli nepřirozený okamžitý náběh z nuly na plnou rychlost ani zastavení jako když "narazí do zdi".

Vlastnost CurrentValueRelative bude použita pro ukazatel aktuální animované hodnoty ovšem v normalizovaném neutlumeném rozsahu od 0 do 1. Tento typ posunu hodnot bude matematicky i výpočetně snazší a o přepočet na reálnou hodnotu v daném rozsahu se postará vlastnost implementovaná níže. V proměnné currentDirection pak bude uchováno znaménko směru animace fungující podobně jako nextFrameSign ve třídě AnimationControl, tj. +1 znamená přehrávání animace z From do To a -1 přehrávání animace z To do From (nastane pouze při zapnuté reverzi).

public double From { get; set; } = 0;      // Počáteční hodnota pro animaci
public double To { get; set; } = 0;        // Konečná hodnota pro animaci   
public double Duration { get; set; } = 0;  // Doba trvání animace (z From do To) v sekundách
public bool Reverse { get; set; } = false; // Používat Reverzi - po dosažení To za Duration animovat opačně (z To do From opět za čas Duration)
public bool Ease { get; set; } = false;    // Používat funkci útlumu na začátku a konci animace

/// <summary>
/// Posouvá animaci v rozsahu 0-1, tzn. je třeba ji vynásobit skutečným rozsahem (To-From)
/// </summary>
public double CurrentValueRelative { get; private set; } = 0; // Relativní vyjádření animované hodnoty v rozsahu 0-1

private short currentDirection = 1;        // Aktuální směr posunu animace (při auto reverzi se přepíná mezi +1 a -1)

Vlastnost CurrentValueAboslute, jež je pouze pro čtení, jen přepočítává relativní hodnotu z CurrentValueRelative na absolutní, kterou lze přímo nastavit příslušné vlastnosti dané transformace (např. animace rotace z 90° na 270° bude mít při relativní hodnotě 0.25 tu absolutní rovnu 135°, tj. "(270-90) * 0.25 + 90 = 135"). Zároveň bude do tohoto výpočtu zahrnuta i funkce útlumu, bude-li Ease rovno true.

/// <summary>
/// Skutečná hodnota animované vlastnosti v rozsahu od From do To
/// </summary>
public double CurrentValueAboslute
{
    get {
        if (Duration == 0 || From == To)                                   // Nejsou-li parametry animace validní...
            return From;                                                   // pak animovaná hodnota zůstává ve výchozím stavu
        return (To - From) * EasingFunction(CurrentValueRelative) + From;  // Výpočet skutečné (absolutní) hodnoty na základě té relativní
    }
}

Pro lepší synchronizaci a kontrolu nad veškerými animacemi místo Storyboardu, který by animace, resp. plynulou změnu hodnot, zajišťoval sám ve vlastním vlákně, implementujeme vlastní správu i těchto typů animací. Samotné animování (správné posuny hodnot) zajistí metoda AnimateValues, kterou je třeba volat v rámci herní smyčky, stejně jako metodu Animate ze třídy AnimationControl, kam ji posléze přidáme. Také zde je jediným vstupním parametrem čas (v sekundách), který uplynul od minulého volání této metody. Metoda nejprve zkontroluje, je-li vůbec co animovat, tzn. čas pro animaci je nenulový a hodnoty From a To jsou rozdílné. Pokud ne, nejedná se o animaci, stejně jako u framové animace při FrameCount=1, ale pouze o statický stav. Toho lze využít například pro otočení či zmenšení obrázku spritu o určitou hodnotu nastálo. Trvalé otočení o 90°, 180° a 270° tak může např. nasměrovat přední stranu obrázku na požadovanou stranu, aniž by pro něj bylo nutné kreslit takovýto sprit. Náhodné pootočení určitého předmětu (např. kamenů) pak zase může vytvářet dojem různorodosti terénu. A stálé zmenšení objektu zase může vytvořit jeho menší alternativu. I takto lze tedy tuto funkci využít.

Pokud jde ovšem o plnohodnotnou animaci, dochází k postupnému navyšování relativní hodnoty animace (z 0 na 1, popř. obráceně u reverze) vždy o takový díl posunu, který náleží podílu času uplynulého od minulé animace a celkové doby trvání animace. Dojde-li k překročení těchto krajních mezí (nad 1 či pod 0) je v případě reverze otočen směr navyšování/snižování této hodnoty (převráceno znaménko currentDirection) a od daného konce intervalu odečtena/přičtena ta hodnota, o kterou byl interval překročen. Není-li aktivní reverze, pak se znaménko neřeší a začne se vždy znovu od 0 (+ hodnota o kterou byla překročena 1). Tím je dosaženo toho, že hodnota v CurrentValueRelative je vždy v rozsahu 0 až 1 a tím pádem hodnota CurrentValueAboslute je vždy v rozsahu FromTo.

/// <summary>
/// Provede přepočet animované hodnoty na základě parametrů animace a času, který uplynul od poslední animace.
/// </summary>
/// <param name="time">Čas od poslední animace vyjádřený v sekundách</param>
/// <returns>TRUE - k animaci došlo, FALSE - k animaci nedošlo kvůli neplatnému nastavení parametrů pro animaci</returns>
public bool AnimateValues(double time)
{
    if (Duration == 0 || From == To) return false;                 // Validace parametrů animace (popř. oznámení, že k animaci nedošlo)
    CurrentValueRelative += (currentDirection * time / Duration);  // Zvýšení (či snížení) relativní (0-1) animované hodnoty
    if (CurrentValueRelative > 1 || CurrentValueRelative < 0)      // Kontrola rozsahu animace, překročení = toto kolo animace bylo dokončeno
    {
        if (Reverse)                                               // Je-li zapnuta reverze pak...
        {
            currentDirection *= -1;                                // obrátit směr animování,
            CurrentValueRelative = (CurrentValueRelative % 1.0);   // o kolik to přesáhlo, o tolik to posunout v novém kole
            if (currentDirection == -1)                            // Při reverzním posunu (hodnota se snižuje z 1 na 0) 
                CurrentValueRelative = 1 - CurrentValueRelative;   // bude posun odečten od 1 (místo přičten k 0)
        }
        else                                                       // Pokud není zapnuta reverze...
            CurrentValueRelative = (CurrentValueRelative % 1.0);   // tak animovaná hodnota začne znovu od 0
    }
    return true;                                                   // Animace (nějaká změna) proběhla
}

Funkce útlumu

Vlastnost CurrentValueAboslute ve svém výpočtu používá metodu EasingFunction definovanou zde. Ta, pokud je vlastnost Ease nastavena na true, upravuje rovnoměrný průběh funkce tak, že jej v krajních hodnotách prohýbá do "útlumu". Samotný funkční přepočet zajišťuje níže implementovaná metoda SmoothStep, pro kterou se zde hodnota připraví do kladného rozsahu 0-1 a po přepočtu se jí vrací původní znaménko. Tím je tato funkce univerzální, byť se v tomto procesu pracuje pouze s kladnou hodnotou v požadovaném rozsahu 0-1.

/// <summary>
/// Funkce útlumu animace (pozvolnější rozjezd a zpomalení na konci animace)
/// </summary>
/// <param name="value">Relativní hodnota animace v rozsahu 0-1</param>
/// <returns>Přepočtená (utlumená) hodnota (také v rozsahu 0-1)</returns>
public double EasingFunction(double value)
{
    if (!Ease) return value;                       // Funkce útlumu bude použita, pouze pokud je její používání nastaveno
    double x = SmoothStep(0, 1, Math.Abs(value));  // Přepočet hodnoty na "utlumenou" hodnotu
    return Math.Sign(value) * x;                   // Vrácení utlumené hodnoty s původním znamínkem
}

Metoda SmoothStep tedy přijme dolní (from) a horní (to) mez hodnoty a hodnotu (x) pro kterou má v rámci tohoto rozmezí utlumující přepočet provést. Průběh použité funkce útlumu ukazuje následující graf. Křivka na něm vyjadřuje, jaká hodnota z osy X je přepočtena na jakou hodnotu z osy Y.

public static double SmoothStep(double from, double to, double x)
{
    x = Clamp((x - from) / (to - from), 0.0, 1.0); // Přepočet hodnoty do rozmezí 0-1 (se striktním ohlídáním těchto hranic)
    return x * x * (3 - 2 * x);                    // Polynomický výpočet útlumu/zjemnění hodnoty
}

Ukázka funkce útlumu (převádí X na Y)

Metoda Clamp má za úkol zajistit pouze to, aby zadaná hodnota val byla v rozsahu od min do max. Pokud některou z krajních mezí překračuje, je přenastavena na tuto mezní hodnotu.

public static double Clamp(double val, double min, double max)
{
    if (val < min) return min;  // Ohlídání, aby hodnota nebyla menší než minimum
    if (val > max) return max;  // Ohlídání, aby hodnota nebyla větší než maximum
    return val;                 // Hodnota je v daném rozmezí, takže může být použita přímo
}

Tím je třída AnimationData je připravena a nyní je potřeba zařadit její podporu v hlavní třídě AnimationControl.

Implementace podpory dalších typů animací v AnimationControl

Vzhledem k faktu, že hodláme podporovat více než jeden typ animace, přičemž každá z nich si vyžádá vlastní transformaci, připravíme pro komponentu celou transformační skupinu (TransformGroup). Transformovat tentokrát budeme celou komponentu, nikoli jen jeho obrázek na pozadí. Ten je totiž již správně nastaven, aby zobrazoval daný sprit či frame a jeho další transformování by toto vyobrazení mohlo pokazit. Například při zmenšování by se nám do zobrazovací oblasti dostaly i okolní sprity a při rotaci bychom je zase mohli zahládnout v rozích komponenty.

Ne ve všech případech ovšem budou tyto druhy animací používány. Pokud ale bude pomocí spritů vykreslena celá scéna, bude jich najednou zobrazeno velké množství, z nichž pouze na některé bude rotaci či zmenšování aplikována. Existence transformační skupiny, resp. nastavení vlastnosti komponenty RenderTransform již znamená určité zvýšení zátěže pro vyobrazení prvku. Aby se tedy tato vlastnost nastavovala pouze v případech, kde bude skutečně využita, transformační skupina pro danou komponentu bude vytvořena a přiřazena pouze pokud bude zapotřebí a to při jejím prvním použití.

Aby byla vytvořena vždy maximálně jednou, a to ve chvíli, kdy jí bude prvně zapotřebí, lze použít návrhový vzor Singleton. Ten při požadavku na přístup k této transformační skupině se ověří, jestli již nebyla vytvořena. Pokud ano, tak vrátí tuto její instanci, pokud ne, tak ji vytvoří a vrátí. Celé to pak lze zapouzdřit buď jako metodu, nebo v tomto případě jako read-only vlastnost TraGroup.

/// <summary>
/// Skupina transformací nastavená tomuto objektu (nikoli jeho pozadí), která se vytvoří, až když je poprvé potřeba
/// </summary>
private TransformGroup TraGroup
{
    get
    {
        if (RenderTransform as TransformGroup == null) // Transformační skupina zatím nebyla vytvořena
            RenderTransform = new TransformGroup();    // tak se vytvoří
        return RenderTransform as TransformGroup;      // Ať již vytvořena byla nebo se právě vytvořila, tak je vrácena
    }
}

V následujícím kódu tak stačí používat vlastnost TraGroup s tím vědomím, že nikdy nebude null, protože kdyby byla, tak se sama vytvoří a následně se již bude používat vždy tatáž instance, resp. tentýž objekt. Návrhový vzor Singleton následně využijeme i pro jednotlivé transformace objektu.

Otáčení

Transformace pro otáčení objektu bude dostupná přes vlastnost TraRotate. Ta zapouzdřuje proměnnou traRotate, do které při prvním použití vytvoří instanci třídy RotateTransform a přidá ji do transformační skupiny objektu, opět s použitím návrhového vzoru Singleton.

private RotateTransform traRotate;
private RotateTransform TraRotate              // Transformace rotace vytvořená a zpřístupněná "singletonem"
{
    get
    {
        if (traRotate == null)                 // Pokud transformace zatím neexistuje...
        {
            traRotate = new RotateTransform(); // vytvořit ji
            TraGroup.Children.Add(traRotate);  // a přidat do transformační skupiny objektu
        }
        return traRotate;
    }
}

Animace otáčení se bude provádět pouze v případě, že bude nastavena vlastnost Rotation, resp. jí zapouzdřená proměnná rotation, tj. instance dříve vytvořené třídy AnimationData s obecnými podrobnostmi o animaci (from, to, duration, ...). Pokud hodnota této vlastnosti zůstane null, bude to znamením, že komponenta rotovat nemá.

Při tomto přístupu je třeba počítat s jednou komplikací, a to případem, kdy bychom rotaci nastavili (vlastnosti Rotation přiřadit nějakou instanci třídy AnimationData), došlo k animaci objektu a pak jsme ji zase zrušili (Rotation = null). Vlastnost Rotation by tak sice byla null a k dalšímu animování nedocházelo, ale TraRotate by již byla vytvořena a její vlastnost Angle (úhel otočení) nastavena na nenulovou hodnotu. Komponenta by tak zůstala pootočená, i když původní záměr tohoto kroku (nastavení Rotation na null) byl s nejvyšší pravděpodobností animaci otáčení nejen zastavit, ale také vynulovat úhel otočení.

Tento problém můžeme jednoduše vyřešit přímo v set kódu vlastnosti, kde před nastavením hodnoty do proměnné rotation zkontrolujeme, nejedná-li se právě o tuto kombinaci změn (hodnota se mění z něčeho na nic a transformace rotace již byla vytvořena). V tom případě kromě uložení nastavované hodnoty null do proměnné ještě odebereme transformaci rotace z transformační skupiny objektu a zrušíme referenci na ni v proměnné traRotate. Tím je zrušen její vliv na zobrazování objektu a zrušeny jsou i veškeré vazby na tento objekt. Garbage collector ji tak při nejbližší příležitosti bude moci odstranit z paměti. Pokud by v budoucnu bylo potřeba rotaci znovu používat (vlastnosti Rotation by byla nastavena nějaká instance třídy AnimationData), vytvoří se transformace pro otáčení nová. Při běžném používání se však nejedná o příliš pravděpodobný scénář, ale spíše jen velmi výjimečnou situaci.

private AnimationData rotation;                                     // Parametry o animaci rotace (NULL = nerotovat)
public AnimationData Rotation {
    get { return rotation; }
    set
    {
        if (rotation != null && value == null && traRotate != null) // Pokud se hodnota mění z něčeho na NULL...
        {
            TraGroup.Children.Remove(traRotate);                    // odebrat objektu transformaci rotace 
            traRotate = null;                                       // a zrušit referenci na tuto transformaci
        }
        rotation = value;
    }
}

Díky tomuto způsobu rotace pak místo spousty spritů/framů s obrázky jednotlivých stupňů otočení obrázku bude stačit pouze jeden jediný sprit. Otáčení pak může být třeba kolem dokola ve směru hodinových ručiček (od 0° do 360°) nebo v protisměru (od 0° do -360° nebo ekvivalentně od 360° do 0°). S použitím auto reverze a funkce útlumu pak lze například simulovat funkci pohyb kyvadla, přičemž bod kolem kterého se prvek otáči lze nastavit přes jeho vlasntost RenderTransformOrigin, kterou v konstruktoru nastavujeme na střed (0.5, 0.5).

Ukázka spritů rotace, z nichž nám bude stačit pouze jeden Ukázka animace rotace Ukázka animace částečné rotace s autoreverzí a funkcí útlumu, zdroj: http://www.tutorialized.com/tutorial/Swinging-Pendulum/16353
Ukázka animace rotace v opačném směru

Zvětšování/zmenšování

Animace zmenšování či zvětšování popř. obojího bude realizována identickým postupem, jako tomu bylo u animace otáčení. Vlastnost TraScale zajistí v případě potřeby metodou Singleton vytvoření transformace měřítka a její přidání do transformační skupiny objektu TraGroup.

private ScaleTransform traScale;
private ScaleTransform TraScale                // Transformace měřítka vytvořená a zpřístupněná "singletonem"
{
    get
    {
        if (traScale == null)                  // Pokud transformace zatím neexistuje...
        {
            traScale = new ScaleTransform();   // vytvořit ji
            TraGroup.Children.Add(traScale);   // a přidat do transformační skupiny objektu
        }
        return traScale;
    }
}

Ukázka animace měřítka s autoreverzí a funkcí útlumuVlastnost Scale bude umožňovat nadefinovat parametry animace měřítka. Zůstane-li null, tento typ animace nebude použit, v opačném případě hodnoty vlastností instance třídy AnimationData určí parametry této animace. I zde je v set kódu ohlídána varianta s nastavením hodnoty null tak, aby krom zastavení průběhu animace došlo i k návratu původního měřítka objektu (1), vyřazením této transformace z transformační skupiny objektu a zrušením reference na ni.

private AnimationData scale;                                    // Parametry o animaci měřítka (NULL = velikost neanimovat)                          
public AnimationData Scale            
{
    get { return scale; }
    set
    {
        if (scale != null && value == null && traScale != null) // Pokud se hodnota mění z něčeho na NULL...
        {
            TraGroup.Children.Remove(traScale);                 // odebrat objektu transformaci měřítka 
            traScale = null;                                    // a zrušit referenci na tuto transformaci
        }
        scale = value;
    }
}

Rozšíření animační metody

V této fázi je již vše připraveno pro animaci objektu pomocí transformací otáčení, měřítka, popř. obojího současně. Aby k této animaci skutečně docházelo, je třeba ještě doplnit patřičný kód do metody Animate. V ní se již nachází kód (ř. 3-12) pro animaci spritů pomocí rychlého střídání jejich jednotlivých framů. Ten zůstane beze změn, pouze na konec metody přidáme (ř. 13-23) podporu pro nové dva typy animací.

V obou případech je třeba vždy nejprve ověřit, zdali je ve vlastnosti definující daný typ animace (Rotation a Scale) nastavena nějaká hodnota (instance třídy AnimationData). Pokud ano, zavoláme metodu AnimateValues pro instanci této třídy v příslušné vlastnosti s parametrem ellapsedTime, tj. časem (v sekundách) určujícím dobu, která uplynula od minulého volání této metody. Tím dojde k přepočtu animované hodnoty, kterou následně přes vlastnost CurrentValueAbsolute nastavíme animované vlastnosti příslušné transformace (Angle u rotace a ScaleX + ScaleY u měřítka).

public void Animate(double ellapsedTime)
{
    if (ImageData == null) return;                             // Není-li nastaven obrázek, pak není co animovat
    if (FramesCount > 1)                                       // Má-li sprit jen jeden obrázek, pak je statický (bez animace)
    {
        animationDelay += ellapsedTime;                        // Odpočet do dalšího přepnutí obrázku
        if (animationDelay * FramesCount >= AnimationDuration) // Odpočet vypršel...
        {
            animationDelay = 0;                                // Vynulovat počítadlo odpočtu
            NextFrame();                                       // Přepnout na další obrázek (frame) spritu
        }
    }
    if (Rotation != null)                                      // Animace rotace
    {
        Rotation.AnimateValues(ellapsedTime);                  // Výpočet hodnot pro rotaci
        TraRotate.Angle = Rotation.CurrentValueAboslute;       // Nastavení transformace pro rotaci
    }
    if (Scale != null)                                         // Animace měřítka
    {
        Scale.AnimateValues(ellapsedTime);                     // Výpočet hodnot pro animaci měřítka
        TraScale.ScaleX = Scale.CurrentValueAboslute;          // Nastavení horizontální transformace měřítka
        TraScale.ScaleY = Scale.CurrentValueAboslute;          // Nastavení vertikální transformace měřítka
    }
}

Tímto tedy komponenta kromě framové animace podporuje i animace rotace a měřítka, pro jejíž použití není třeba kreslit jednotlivé framy. Stejným postupem by se daly zpracovat i další typy animací, jako např. animace zešikmení pomocí transformace SkewTransform.


5. Použití

Komponenta pro zobrazování statických i animovaných spritů ze sprite sheet obrázku je tedy hotová a můžeme ji použít. Připomeňme jen, že ač by tato komponenta fungovala v různých typech aplikací pracujících s XAML (WPF, SIlverlight), tak v tomto případě byla cílem platforma UWP, tj. univerzální aplikace Windows 10, spustitelná od mobilů, přes PC, tablety, IoT až třeba po herní konzoli Xbox One. Následující příklad použití tedy bude zasazen do aplikace tohoto typu, tj. nového projektu "Blank App (Universal Windows)". Třídy animační komponenty (AnimationControl, SpriteSheetData a AnimationData) mohou být do projektu přidány přímo, nebo ponechány v projektu vlastním (např. typu "Class Library (Universla Windows)") s pouhou referencí na ně a možností opakovaného použití v dalších projektech.

Jelikož komponenta je určena především pro vytváření z kódu na pozadí (C#), byť určitou část definice by mohla nést i přes XAML definici, bude nám v designu (XAMLu) stránky (např. výchozí MainPage.xaml) stačit pouze pár úprav oproti výchozí definici. Je totiž třeba pojmenovat si kontejner, do kterého budeme animační komponentu vkládat. V tomto případě použijeme rovnou hlavní kontejner (Grid) stránky, který nazveme (Name) MainGrid (hlavní grid), přičemž by vůbec nevadilo použít i jiný typ kontejneru (Canvas, StackPanel, jiný Grid ...) umístěný kdekoli jinde.

Další úpravou v XAMLu bude určení události, která má spustit proces vložení prvku a zahájení jeho animace. V tomto příkladu použijeme událost stránky (Page) Loaded, kam přiřadíme (vygenerujeme) metodu kódu na pozadí Page_Loaded (stránka načtena). Opět by nevadilo vše propojit s jinou událostí, třeba po kliknutí na nějaký objekt. Je však třeba rozlišit dva úkony: vytvoření instance animační komponenty a vytvoření a spuštění herní smyčky. Zatímco animačních komponent může být vytvořeno a zobrazeno v podstatě libovolné množství, herní smyčka by měla být vytvořena a spuštěna právě jednou a obsluhovat všechny tyto komponenty současně. Událost stránky Loaded je v životě stránky spuštěna právě jednou, proto se na vytvoření herní smyčky hodí. Pokud má ale první animace vzniknout až později (popř. třeba i vůbec), může se její vytvoření a spuštění například zapouzdřit v dříve již několikrát použitém návrhovém vzoru Singleton.

<Page ... Loaded="Page_Loaded">
    <Grid Name="MainGrid" ...>
    </Grid>
</Page>

Zbytek závisí již pouze na kódu na pozadí této stránky. Nejprve si v rámci třídy stránky (MainPage v MainPage.xaml.cs) deklarujeme dvě proměnné. První (timer) bude referencí na instanci třídy DispatcherTimer, tj. třídu, která dokáže cyklicky spouštět danou událost vždy po uplynutí zadaného intervalu. Čekání na další spuštění přitom běží ve vlastním vlákně, takže nebrzdí ostatní chod aplikace. Proměnná je deklarována takto v rámci třídy, abychom k "časovači" měli přístup i později a mohli jeho běh, byť třeba jen dočasně, zastavit metodou Stop (např. při "pauznutí" hry či minimalizaci okna nebo jeho vypínání). Druhou proměnnou bude lastTime typu DateTime v níž budeme uchovávat přesný čas poslední aktualizace animací všech prvků, abychom mohli dopočítat, kolik sekund od té doby uplynulo.

DispatcherTimer timer; // Deklarace proměnné pro časovač (Timer) spouštění události po daném intervalu
DateTime lastTime;     // Datum a čas poslední aktualizace animací

Následně doplníme kód do metody Page_Loaded obsluhující událost Loaded této stránky. Předně jí musíme do definice hlavičky dopsat klíčové slovo async, které metodu označí jako asynchronně volanou a umožní nám tak v jejím kódu volat další asynchronní metody pomocí klíčového slova await, bez nutnosti kód rozdělovat do dalších metod (první by spustila asynchronní kód a druhá by přes událost čekala, až bude dokončena daná činnost). V tomto případě toho využijeme hned v prvním příkazu pro načtení obrázku ze souboru aplikace. Načítání obsahu ze souboru je netriviální operace vyžadující určitý operační čas a také přístup k paměťovému médiu, na kterém je soubor uložen, což může v závislosti na dalších procesech operačního systému chvíli trvat. Byť se obvykle jedná jen o desítky milisekund, je lepší (resp. nezbytné) tuto operaci spustit ve vlastním vlákně, aby nebrzdila zbylý chod aplikace a mezitím bylo možné např. používat její další ovládací prvky (posun okna, minimalizace, přepnutí, zavření apod.). Použití await tedy toto vše zařídí a s vykonáváním dalšího kódu metody počká, dokud operace (načtení obrázku v jiném vlákně) úspěšně neskončí a nevrátí příslušnou hodnotu.

Po načtení obrázku (image) pro něj vytvoříme instanci třídy SpriteSheetData s informacemi o spritech na něm (jejich rozměry a šířka mezery mezi nimi) do proměnné imageData. Pokud bychom vytvářeli více animačních prvků zobrazujících sprity z téhož sprite sheetu, tak by pro ně reference v imageData měla být společná, tzn. tyto dva příkazy by proběhly pouze 1x, další dva pak klidně vícekrát (třeba v cyklu). Je také možné deklaraci proměnné imageData provést mimo tuto metodu a referenci v ní obsaženou používat opakovaně i později, když by měl třeba vzniknout další animační prvek za běhu.

Dalším příkazem vytvoříme konečně instanci třídy AnimationControl, tedy animační prvek do proměnné item. Místo pozdější inicializace zde předáme všechny potřebné informace rovnou v konstruktoru, tj. referenci na informace o sprite sheetu, index počátečního spritu, počet následujících spritů vymezujících framy pro animaci, dobu trvání jednoho kola animace a chceme-li či nikoli používat automatickou reverzi animace. Důležité jsou i rozměry prvku na stránce (Width a Height), jež můžeme předat rovnou v rámci konstrukce ve složených závorkách (bez definice rozměrů by se prvek roztáhl přes celou stránku). Jakmile je prvek hotov, můžeme jej rovnou přidat na plochu, v našem případě do hlavního kontejneru stránky nazvaného MainGrid, čímž se prvek stane viditelným.

Reference na prvek (proměnná item) by klidně mohla být zase deklarována mimo metodu, aby se k ní dalo přistupovat i později. Kdyby animačních prvků bylo více, mohla by také být přidána třeba do seznamu (List<AnimationControl>), který by mapoval celou scénu. Možné je i vytvořit třídu nesoucí další dodatečné informace o každém z prvků (např. typ prvku, způsob interakce s hráčem, stav apod.) + tuto referenci na tuto animační komponentu a až instanci této třídy si ukládat do typového seznamu. Vždy záleží na konkrétní situaci a potřebách.

Tímto tedy došlo k vytvoření animační komponenty (případně více komponent, pokud druhé dva příkazy prvního bloku byly volány v cyklu, např. pro načtení zobrazení nějaké mapy). Druhý blok kódu této metody vytváří a spouští herní smyčku. Ten by tedy měl v rámci stránky proběhnout pouze jedenkrát. Tentokrát vytvoříme instanci třídy DispatcherTimer a jeho referenci uložíme do dříve deklarované proměnné timer. Dále nastavíme interval mezi jednotlivými spuštěními herní smyčky. Ten je typu TimeSpan a v tomto případě jej nastavíme pomocí vyjádření v milisekundách (těch je 1000 za sekundu). Jeho hodnota zároveň určuje FPS (frames per second) hry dle vztahu "FPS = 1s / Interval". Při 20ms je to tedy 1000/20 = 50 FPS. Vyšší hodnoty FPS nemá moc cenu nastavovat, protože vše navíc samozřejmě závisí na výpočetních možnostech hardware, přičemž UWP se jej v tomto typu aplikací snaží moc nezatěžovat a standardně omezují FPS celého okna na 60. Rychlost framové animace navíc na této hodnotě nezávisí (není-li tedy FPS příliš nízké, aby občas nějaký frame přeskočilo), protože interval mezi přepínání framů je definován v závislosti na čase jako takovém bez ohledu na FPS.

Poté timeru nastavíme obsluhu události Tick metodou Timer_Tick, jež implementuje dále. Tick je právě ta událost, kterou timer periodicky spouští po každém uplynutí intervalu, tj. v tomto případě každých 20 milisekund. Na závěr již stačí pouze tuto herní smyčku spustit metodou Start.

private async void Page_Loaded(object sender, RoutedEventArgs e)
{
    // Vytvoření animovaného prvku
    var image = await SpriteSheetData.LoadImageFromApp("ms-appx:///sprites.png"); // Načtení obrázku z PNG souboru vloženého do projektu jako Content
    var imageData = new SpriteSheetData(image, 64, 64, 1);                        // Vytvoření informací o obrázku (sprity na něm jsou 64x64 a každý má okolo 1px mezeru)
    var item = new AnimationControl(imageData, 232, 4, 0.4, true)                 // Vytvoření animovaného prvku...
        { Width = 64, Height = 64 };                                              // a nastavení rozměrů tohoto prvku
    MainGrid.Children.Add(item);                                                  // Přidání prvku do hlavního gridu

    // Vytvoření herní smyčky
    timer = new DispatcherTimer();                  // Vytvoření Timeru - spouštěče události
    timer.Interval = TimeSpan.FromMilliseconds(20); // Událost by měla být vyvolána každých 20 milisekund (50x za sekundu)
    timer.Tick += Timer_Tick;                       // Metoda, kterou tato událost spustí, bude Timer_Tick
    lastTime = DateTime.Now;                        // Nastavení času poslední aktualizace na aktuální okamžik
    timer.Start();                                  // Spuštění Timeru - první vykonání události proběhne za 20ms
}

Nyní zbývá jen implementovat metodu Timer_Tick, která bude aktualizovat animaci všech animačních prvků na scéně. Pokud jsou reference na tyto prvky uloženy v typovém seznamu, stačí projít ten a u každého zavolat metodu Animate. Je-li prvek jen jeden a jeho proměnná je deklarována v rámci třídy, pak by stačilo animovat jen tuto proměnnou (není-li null). V tomto příkladu jsme si ovšem žádný seznam nevytvářeli a proměnná prvku byla deklarována pouze v rámci metody, čili přímou referenci na ni k dispozici nemáme. Prvek jsme ovšem přidali mezi potomky (Children) kontejneru MainGrid, který nám tak na něj tuto referenci udržuje. Můžeme tedy projít veškeré prvky, které se v něm nacházejí, přičemž je jedno, je-li tam jen jeden, vícero nebo žádný. U každého prvku je ale třeba zkontrolovat, je-li typu AnimationControl a pokud ne (např. to je jiný kontejner, tlačítko apod.) tak jej nechat být. Díky Elvis operátoru "?." lze toto provést i bez ověřovací podmínky (if), protože výsledek přetypování operátorem as je buď objekt daného typu nebo null (pokud objekt tohoto typu není), což je právě ten případ, jehož další zpracování Elvis operátor zastaví. Je-li vše v pořádku, pak se provede aktualizace animace daného objektu metodou Animate, se vstupním parametrem udávajícím počet sekund (typ double - desetinné číslo, v tomto případě teoreticky 20 ms = 0,05 s) od minulé aktualizace.

Výpočet počtu sekund od minule proběhl před tím do proměnné seconds, jako rozdíl aktuálního času a času poslední aktualizace vyjádřeném v celkovém počtu sekund. Po aktualizaci všech prvků, která by mimo aktualizaci animace samozřejmě mohla spočívat i v dalších faktorech (např. posun prvku po ploše, např. na základě stisknutých kláves či dotyku na obrazovce), si už jen uložíme aktuální čas do proměnné lastTime, abychom příště znovu mohli určit počet sekund od poslední aktualizace. V případě možnosti pauzy hry je pak samozřejmě za potřebí při obnovování běhu pozastavené herní smyčky (timer.Start) také nastavit lastTime na aktuální čas (DateTime.Now), aby interval od minule neposkočil příliš o nereálně vysokou hodnotu. Její výše by se pro případ "lagnutí" aplikace mohla také kontrolovat zde v rámci výpočtu seconds. Kdyby např. překročila 1s (nebo i 0,5s), pak by byl počet seconds upraven na tuto mezní hodnotu, aby po případném dočasném zatuhnutí (např. z důvodu nutnosti obsluhy jiného procesu či aplikace) vše příliš "neposkočilo" a uživateli/hráči to tak neznemožnilo zareagovat na určitou situaci.

private void Timer_Tick(object sender, object e)
{
    double seconds = (DateTime.Now - lastTime).TotalSeconds;  // Počet sekund od poslední aktualizace animací
    foreach (var ch in MainGrid.Children)                     // Zkontrolovat všechny prvky v hlavním gridu
        (ch as AnimationControl)?.Animate(seconds);           // Je-li prvek na gridu typu AnimationControl, tak aktualizovat jeho animaci
    lastTime = DateTime.Now;                                  // Zaznamenání přesného aktuálního času
}

Tímto jsme vytvořenou komponentu i vyzkoušeli a zároveň nastínili další možnosti jejího použití. Pokud jde o použití ve hrách, bylo by možné komponentu zkombinovat např. s ovládáním her s herní smyčkou, jejichž princip byl představen v tomto video-tutoriálu.

 

 

on 13 květen 2016