Canvas animacje

Aby animować cokolwiek na canvasie musimy zastosować podejście znane chociażby z gier. Robimy więc główną pętlę (intervał), w której każdorazowo czyścimy cały canvas, rysujemy daną klatkę animacji, po czym powtarzamy rysując kolejną. I tak w koło Macieju...

Aby animować, musimy nasz kod rysujący wywoływać cyklicznie. Możemy do tego wykorzystać funkcję setInterval():


function draw() {
    ...kod rysujący
}


setInterval(draw, 1000/60);

lub funkcję setTimeout:


function draw() {
    ...kod rysujący

    setTimeout(draw, 1000/60);
}


setTimeout(draw, 1000/60);

Problem z zastosowaniem tych metod w przypadku animacji jest taki, że nie mamy pewności, czy rysowanie pojedynczej klatki zakończy się na czas. Może to powodować, że odpalony interwał zacznie się dławić, a nasza animacja nie będzie należycie płynna. Moglibyśmy to próbować obchodzić przez samo wywołujący się setTimeout, ale wtedy nasza animacja mogła by nie być optymalnie płynna.

Jeżeli zależy nam by nasza animacja działała optymalnie, wtedy do takiego cyklicznego wywoływania kodu powinniśmy użyć metody requestAnimationFrame(fn). Metoda ta wywoła dany kod przed kolejną klatką rysowania strony. Dzięki temu przeglądarka optymalnie dobierze moment kiedy taki kod ma być odpalony, a nasza animacja będzie płynniejsza.


function draw() {
    ...kod rysujący

    requestAnimationFrame(draw);
}

requestAnimationFrame(draw);

Moment taki zależy od kilku czynników. Obciążenie procesora czy chociażby to, czy nasza strona jest w aktywnej zakładce, czy działa gdzieś w tle. Jeżeli w tle - wtedy animacja nie musi być odgrywana.

Każde użycie requestAnimationFrame zwraca ID, które możemy wykorzystać do przerwania tak trwającej animacji:

Aby zatrzymać tak odpaloną animację skorzystamy z metody cancelAnimationFrame(id):


let requestId;

function loop() {
    requestId = requestAnimationFrame(loop);
    ...
}

function start() {
    requestId = requestAnimationFrame(loop);
}

function pause() {
    cancelAnimationFrame(requestId)
}

Niestety w przeciwieństwie do setInterval nie podajemy tutaj szybkości działania takiej animacji. Częstotliwość wykonywania funkcji wynosi zazwyczaj 60 razy na sekundę, jednakże według rekomendacji W3C w większości przeglądarek odpowiada częstotliwości odświeżania ekranu. Co więc mamy zrobić, jeżeli chcemy naszą animację trochę zwolnić - np. do 30 klatek na sekundę? Możemy posłużyć się trikiem znanym z programowania gier - czyli wyliczać czy minął między kolejnymi klatkami odpowiedni czas.

Aby to naprawić możemy skorzystać z przepisu np. z tej strony (chociaż tak naprawdę wystarczy w google wpisać requestAnimationFrame control frames).


class AnimationFrame {
    constructor( fps = 60, animate ) {
        this.requestID = 0;
        this.fps = fps;
        this.animate = animate;
    }

    start() {
        let then = performance.now();
        const interval = 1000 / this.fps;
        const tolerance = 0.1;

        const animateLoop = now => {
            this.requestID = requestAnimationFrame( animateLoop );
            const delta = now - then;

            if ( delta >= interval - tolerance ) {
                then = now - ( delta % interval );
                this.animate( delta );
            }
        };
        this.requestID = requestAnimationFrame( animateLoop );
    }

    stop() {
        cancelAnimationFrame( this.requestID );
    }

}

I użyjmy - na razie do wypisywania tekstu:


let count = 0;

function draw() {
    count++;
    divText.innerText = "Animujemy " + count;
}

const animLoop = new AnimationFrame(60, draw);

const btnStart = document.querySelector("#animStart");
const btnStop = document.querySelector("#animStop");
const divText = document.querySelector("#testController");

btnStart.addEventListener("click", e => {
    count = 0;
    animLoop.start();
    btnStart.disabled = true;
    btnStop.disabled = false;
});

btnStop.addEventListener("click", e => {
    animLoop.stop();
    divText.innerText = "Animacja przerwana!";
    btnStart.disabled = false;
    btnStop.disabled = true;
})

Mając już podstawowy kontroler do obsługi głównej pętli animacji możemy przejść do sedna rozdziału - animowania.

Animowanie pojedynczego obiektu

Do czyszczenia klatki wykorzystamy znaną nam metodę clearRect(), którą będziemy czyścić całą zawartość całego canvasa.


function draw() {
    //czyścimy obszar canvasu
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //rysujemy
    ...
}

const animLoop = new AnimationFrame(30, draw);

Przypuśćmy, że chcemy animować odbijającą się od krawędzi gwiazdkę. Wpierw musimy wczytać grafikę gwiazdki:


function draw() {
    ...
}

const image = new Image(24, 24); //grafika ma rozmiary 24x24
image.addEventListener("load", e => {
    const anim = new AnimationFrame(30, draw);
    anim.start();
});
image.src = "./gwiazdka.png";

Następnie dla naszej gwiazdki musimy stworzyć zmienne dla jej pozycji. W każdej klatce animacji będziemy zmieniać jej pozycję oraz wyliczać czy gwiazdka nie dotknęła krawędzi canvas. Jeżeli dotknęła, wtedy kierunek ruchu na danej osi (x lub y) zmieniamy na odwrotny:


const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const star = {
    x : 0, //x i y gwiazdki
    y : 0,
    dirX : 1, //kierunek ruchu
    dirY : 1,
    speed : 2 //szybkość ruchu
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //jeżeli gwiazdka dotknie krawędzi canvasu, zmieniamy jej kierunek na przeciwny
    //x jest liczony od lewej strony, dlatego dla prawej i dolnej krawędzi musimy odejmować wymiary obrazka (24x24)
    if (star.x < 0 || star.x > canvas.width - 24) {
        star.dirX = -star.dirX;
    }

    if (star.y < 0 || star.y > canvas.height - 24) {
        star.dirY = -star.dirY;
    }

    //do x i y gwiazdki dodajemy przesuniecie
    if (star.x < 0) { star.dirX = 1 }
    if (star.x + 24 > canvas.width) { star.dirX = -1 }
    if (star.y < 0) { star.dirY = 1 }
    if (star.y + 24 > canvas.height) { star.dirY = -1 }

    //rysujemy gwiazdkę w nowym miejscu
    ctx.drawImage(image, star.x, star.y);
}

const image = new Image(24, 24);
image.addEventListener("load", e => {
    const anim = new AnimationFrame(30, draw);
    anim.start();
});
image.src = "gwiazdka.png";

Animowanie kilku obiektów

Dla większej liczby obiektów będziemy musieli nieco zmodyfikować nasz kod.
Wszystkie gwiazdki będziemy trzymać w tablicy. Podczas pojedynczej klatki zrobimy pętlę po tej tablicy, obliczymy nową pozycję dla każdej gwiazdki i narysujemy każdy obiekt.

Do generowania randomowych liczb użyjemy funkcji, którą napisaliśmy w poprzednim rozdziale.

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const starsCount = 20; //liczba gwiazdek do animowania
const starsList = []; //tablica przechowująca gwiazdki

const rand = function(min, max) {
    return Math.floor(Math.random()*(max-min+1)+min);
}

//tworzymy nowe gwiazdki i wrzucamy je do tablicy
for (let i=0; i<starsCount; i++) {
    const star = {
        x : rand(0, canvas.width - 40),
        y : rand(0, canvas.width - 40),
        dirX : (Math.random() > 0.5) ? 1 : -1,
        dirY : (Math.random() > 0.5) ? 1 : -1,
        speed : rand(1, 2)
    }
    starsList.push(star);
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    for (const el of starsList) {
        el.x += el.speed * el.dirX;
        el.y += el.speed * el.dirY;

        if (el.x < 0) { el.dirX = 1 }
        if (el.x + 24 > canvas.width) { el.dirX = -1 }
        if (el.y < 0) { el.dirY = 1 }
        if (el.y + 24 > canvas.height) { el.dirY = -1 }

        ctx.drawImage(image, el.x, el.y);
    }
}

const image = new Image(24, 24); //obrazek ma wymiary 24,24
image.addEventListener("load", e => {
    const anim = new AnimationFrame(30, draw);
    anim.start();
});
image.src = "gwiazdka.png";

Animacja poklatkowa

Jak już wiemy, metoda drawImage w postaci drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) służy do rysowania na płótnie wyciętego kawałka grafiki. Metoda ta idealnie się nadaje do stworzenia poklatkowej animacji, która polega na wyświetlaniu kolejnych klatek animacji, które są ułożone obok siebie na jednej grafice z przezroczystym tłem.

W internecie jest bardzo dużo grafik typu "sprites", które są "wyciągnięte" ze starych gier 2d. Wystarczy w Google poszukać sformułowań typu "game sprites", by dostać ich pokaźną kolekcję. Przykładowe linki:
http://www.spriters-resource.com/
http://sdb.drshnaps.com/

W poniższym przykładzie skorzystamy z poniższego sprite:

Scott Pilgrim

Aby teraz animować taką grafikę, powinniśmy kolejno wyświetlać jej wycięte fragmenty. Aby to zrobić, powinniśmy znać numer wyświetlanej klatki, i za pomocą tej liczby obliczać w każdej klatce animacji przesunięcie wycinania.


let currentFrame = 0; //aktualna klatka
let framesMax = 8; //max klatek
let frameWidth = 108; //szerokość 1 klatki
let frameHeight = 120; //wysokość klatki

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    if (currentFrame > framesMax - 1) {
        currentFrame = 0;
    }
    currentFrame++;

    const cutX = (currentFrame - 1) * frameWidth;
    const drawPosX = canvas.width / 2 - 60; //polowa canvasu minus polowa klatki
    const drawPosY = canvas.height - frameHeight; //chcemy by Pilgrim biegał na dole canvasu

    ctx.drawImage(img, cutX, 0, frameWidth, frameHeight, drawPosX, drawPosY, frameWidth, frameHeight);
}

const img = new Image();
img.src = "./scottpilgrim_multiple.png";

img.addEventListener("load", e => {
    const anim = new AnimationFrame(16, draw);
    anim.start();
});

W ramach treningu możemy też dodać do naszej animacji kreski symbolizujące pęd. Podobnie jak w przypadku gwiazdek - możemy tutaj użyć tablicy, w której będziemy przechowywać każdą kreskę:


let currentFrame = 0;
let framesMax = 8;
let frameWidth = 108;
let frameHeight = 120;
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const lines = [];
for (let i=0; i<50; i++) {
    const line = {
        x : rand(-100, canvas.width),
        y : rand(-100, canvas.height),
        speed : 40,
        length : rand(50, 250)
    }
    lines.push(line)
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    if (currentFrame > framesMax - 1) {
        currentFrame = 0;
    }
    currentFrame++;

    for (const el of lines) {
        el.x -= el.speed;
        if (el.x <= -el.length) {
            el.x = canvas.width;
        }
        ctx.beginPath();
        ctx.strokeStyle = "rgba(0,0,0,0.2)"
        ctx.lineWidth = 0.5;
        ctx.moveTo(el.x, el.y);
        ctx.lineTo(el.x + el.length, el.y);
        ctx.stroke();
    }

    const cutX = (currentFrame - 1) * frameWidth;
    const drawPosX = canvas.width / 2 - 60; //polowa canvasu minus polowa klatki
    const drawPosY = canvas.height - frameHeight; //chcemy by Pilgrim biegał na dole canvasu

    ctx.drawImage(img, cutX, 0, frameWidth, frameHeight, drawPosX, drawPosY, frameWidth, frameHeight);
}

const img = new Image();
img.src = "./scottpilgrim_multiple.png";

img.addEventListener("load", e => {
    const anim = new AnimationFrame(20, draw);
    anim.start();
});

Hola, hola! Podobne rzeczy jeszcze łatwiej zrobisz w czystym CSS. Przeczytaj sobie moje artykuły na ten temat:

animacje css i baner animowany

Jedno jednak drugiego nie wyklucza. Powyższe podejście bardziej się sprawdzi przy grach, gdzie musimy reagować na poczynania gracza...

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem. Aha - i ta strona korzysta z ciasteczek.

Menu