UWP komponenta pro zobrazování spritů a frame animací - 4. Další typy animací

Seznam článků

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.

on 13 květen 2016