Animacja w canvas

Aby animować cokolwiek na canvasie musimy zastosować podejście znane chociażby z gier. W głównym zapętlonym kodzie każdorazowo czyścimy klatkę, rysujemy wszystko od nowa, po czym powtarzamy cały proces. 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);

Pierwsza z nich odpala cyklicznie dany kod co określoną liczbę milisekund. Druga odpala kod po określonej liczbie milisekund tylko jeden raz.

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 samowywoł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)
}

Problem z requestAnimationFrame jest taki, że 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:


const delay = 1000 / fps;
let time = null;
let frame = -1;
let req;
let isPlaying = false;

function loop(timestamp) {
    if (time === null) {
        time = timestamp;
    }
    let seg = Math.floor((timestamp - time) / delay);
    if (seg > frame) {
        frame = seg;

        draw({
            time: timestamp,
            frame: frame
        })
    }
    req = requestAnimationFrame(loop)
}

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

function pause() {
    cancelAnimationFrame(requestId)
}

loop();

Żeby użycie tego kodu było przyjemniejsze, stwórzmy z niego konstruktor:


function FpsCtrl(fps, cb) {
    const delay = 1000 / fps;
    let time = null;
    let frame = -1;
    let req;
    let isPlaying = false;

    function loop(timestamp) {
        if (time === null) {
            time = timestamp;
        }
        let seg = Math.floor((timestamp - time) / delay);
        if (seg > frame) {
            frame = seg;

            cb({
                time: timestamp,
                frame: frame
            })
        }
        req = requestAnimationFrame(loop)
    }

    this.start = function() {
        if (!this.isPlaying) {
            this.isPlaying = true;
            req = requestAnimationFrame(loop);
        }
    };

    this.pause = function() {
        if (this.isPlaying) {
            cancelAnimationFrame(req);
            this.isPlaying = false;
            time = null;
            frame = -1;
        }
    };
}

I użyjmy - na razie do wypisywania tekstu w konsoli:


function FpsCtrl() {
    ...
}

function draw() {
    console.log('Animacja!');
}

const animLoop = new FpsCtrl(30, draw);

document.querySelector('#animStart').addEventListener('click', function() {
    animLoop.start();
    if (animLoop.isPlaying) {
        console.log('Animacja rozpoczęta!');
    }
});
document.querySelector('#animStop').addEventListener('click', function() {
    animLoop.pause();
    if (!animLoop.isPlaying) {
        console.log('Animacja przerwana!');
    }
})

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(x, y, width, height), którą będziemy czyścić całą zawartość klatki. Zamiast tego możemy użyć także innych metod. Możemy zamalować całe płótno kwadratem (fillRect(x, y, width, height)), lub np. narysować grafikę która przykryje całe płótno (drawImage(obr, x, y))

Czyścić canvas możemy także za pomocą zmiany jego rozmiaru. Jeżeli rozmiar canvasu ulega zmianie, wtedy cały jego obraz musi zostać wyczyszczony, by pasował do nowych wymiarów.
Wystarczy więc użyć canvasElem.width = canvasElem.width Metoda nie polecana, ponieważ jest mniej wydajna niż powyższe.


function FpsCtrl(fps, cb) {
    ...
}

function draw() {
    //czyścimy obszar canvasu
    ctx.clearRect(0, 0, 200, 100);

    //rysujemy
    ...
}

const animLoop = new FpsCtrl(30, draw);

Przypuśćmy, że chcemy animować odbijającą się od krawędzi gwiazdkę. Wpierw musimy wczytać grafikę gwiazdki. To znamy z poprzedniego rozdziału.


function FpsCtrl(fps, cb) {
    ...
}

function draw() {
    ...
}

const image = new Image(24, 24); //obrazek ma wymiary 24,24
image.addEventListener('load', function(){
    const animLoop = new FpsCtrl(30, draw);
    animLoop.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:


function FpsCtrl(fps, cb) {
    ...
}

function draw() {
    //czyścimy obszar canvasu
    ctx.clearRect(0, 0, canvasElem.width, canvasElem.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 (starX<0 || starX>canvasElem.width - 24) {
        stepX = -stepX;
    }
    if (starY<0 || starY>canvasElem.height - 24) {
        stepY = -stepY;
    }

    //do x i y gwiazdki dodajemy przesuniecie
    starX += stepX;
    starY += stepY;

    //rysujemy gwiazdkę w nowym miejscu
    ctx.drawImage(image, starX, starY);
}

//x i y gwiazdki
let starX = 0;
let starY = 0;

//o tyle będzie przesuwała się gwiazdka
let stepX = 2;
let stepY = 2;

const image = new Image(24, 24); //obrazek ma wymiary 24,24
image.addEventListener('load', function(){
    const animLoop = new FpsCtrl(60, draw);
    animLoop.start();
});
image.src = 'gwiazdka.png';

Animowanie kilku obiektów

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

Zróbmy sobie wspólny typ obiektów za pomocą konstruktora.
Obiekty takie powinny mieć swoje właściwości, powinny umieć się przesuwać oraz rysować.


//konstruktor super gwiazdki
function SuperStar(x, y, speed, canvasElem, ctx) {
    this.x = x;
    this.y = y;
    this.speed = speed;
    this.stepX = this.speed;
    this.stepY = this.speed;

    //grafika gwiazdki
    this.image = new Image(24,24); //obrazek ma wymiary 24,24
    this.image.src = 'gwiazdka.png';

    //funkcja rysująca
    this.drawStar = function() {
        ctx.drawImage(this.image, this.x, this.y);
    }

    //funkcja poruszająca
    this.moved = function() {
        if (this.x<0 || this.x > canvasElem.width - 24) {
            this.stepX = -this.stepX;
        }
        if (this.y<0 || this.y > canvasElem.height - 24) {
            this.stepY = -this.stepY;
        }
        this.x = this.x + this.stepX;
        this.y = this.y + this.stepY;

        this.drawStar();
    }
}

Po utworzeniu klasy gwiazdki wystarczy stworzyć kilka takich obiektów, a następnie przepisać je do tablicy. To właśnie po niej będziemy robili pętlę wywołując metodę moved() dla każdego obiektu.


function FpsCtrl(fps, cb) {
    ...
}

//funkcja rysujaca animacje
function draw() {
    ctx.fillStyle = "#CFEAFD";
    ctx.fillRect(0, 0, canvasElem.width, canvasElem.height);
    for (let i=0; i<stars.length; i++) {
        stars[i].moved();
    }
}

const canvasElem = document.querySelector('#canvasAnim2');
const ctx = canvasElem.getContext('2d');

const starsCount = 10; //liczba gwiazdek do animowania
const stars = []; //tablica przechowująca obiekty gwiazdki

//tworzymy nowe obiekty gwiazdki i wrzucamy je do tablicy
for (let i=0; i<starsCount; i++) {
    const x = 20 + Math.random()*160;
    const y = 20 + Math.random() * 60;
    const speed = Math.round(1 + Math.random()*2);

    const star = new SuperStar(x, y, speed, canvasElem, ctx);
    stars.push(star);
}

const image = new Image(24, 24); //obrazek ma wymiary 24,24
image.addEventListener('load', function(){
    const animLoop = new FpsCtrl(60, draw);
    animLoop.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. Jeżeli kiedykolwiek tworzyliście gry w programie GameMaker (co osobiście bardzo polecam), jesteście w zasadzie w domu.
Animacja poklatkowa polega na wyświetlaniu kolejnych klatek animacji, które są ułożone obok siebie w jednym pliku.

fantomas klatki

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/
wyniki w google

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

Na początku deklarujemy potrzebne zmienne - aktualną klatkę, liczbę klatek, oraz wyliczamy ile jest wszystkich klatek w animacji.

Funkcja draw() wylicza skąd ma zacząć wycinanie kawałka grafiki o rozmiarach szerokość klatki na wysokość klatki, a następnie wycięty fragment wyświetla na płótnie. Animacja ma być zapętlona, stąd w funkcji draw() musimy dokonać odpowiedniego sprawdzenia, i w razie czego ustawić aktualną klatkę na pierwszą (czyli 0).
Resztę działania już znamy z poprzednich przykładów na tej stronie.


function FpsCtrl(fps, cb) {
    ...
}

function draw() {
    ctx.fillStyle = "#eee";
    ctx.fillRect(0, 0, canvasElem.width, canvasElem.height);

    frameNr++;
    if (frameNr > fantomFramesCount) {
        frameNr = 1;
    }
    const frameXpos = (frameNr-1)*frameWidth;
    //rysujemy Fanthomasa w pozycji 120x35
    ctx.drawImage(fantom, frameXpos, 0, frameWidth, frameHeight, 120, 35, frameWidth, frameHeight);
}

const fantom = new Image(240, 66); //obrazek ma wymiary 240x66, pojedyncza klatka to 800x66
fantom.src = './fantom.png';

let frameNr = 1;
const fantomFramesCount = 3;

const frameWidth = fantom.width / fantomFramesCount;
const frameHeight = fantom.height;

const canvasElem = document.querySelector('#canvasFantomas');
const ctx = canvasElem.getContext('2d');

fantom.addEventListener('load', function(){
    const animLoop = new FpsCtrl(24, draw);
    animLoop.start();
});

Powyższy kod sprawdzi się dla samotnego Fantomasa, jednak gdy będziemy chcieli animować jego przyjaciół, wówczas o wiele lepiej stworzyć je jako podobne obiekty. Podobny konstruktor już robiliśmy (SuperStar), więc wystarczy go nieco przerobić:


function AnimatedObj(x, y, speed, image, framesCount, ctx) {
    this.x = x;
    this.y = y;
    this.speed = speed;
    this.stepX = this.speed;
    this.stepY = this.speed;
    this.ctx = ctx
    this.image = image;

    this.frameNr = 1;
    this.framesCount = framesCount;
    this.frameWidth = this.image.width / this.framesCount;
    this.frameHeight = this.image.height;

    this.draw = function() {
        this.frameNr++;
        if (this.frameNr > this.framesCount) {
            this.frameNr = 1;
        }
        const frameXpos = (this.frameNr-1)*this.frameWidth;
        this.ctx.drawImage(this.image, frameXpos, 0, this.frameWidth, this.frameHeight, this.x, this.y, this.frameWidth, this.frameHeight);
    }

    this.move = function() {
        this.x = this.x + this.stepX;
        this.y = this.y + this.stepY;
        this.draw();
    }
}

Do parametrów konstruktora doszedł image, w którym będziemy przekazywać grafikę z klatkami, oraz frameCount, który będzie zawierał maksymalną liczbę klatek danej grafiki.

Przygotujmy kilka grafik, które wykorzystamy. Są to png z przezroczystym tłem:

dom
Duży dom - będzie bliżej obrazu
dom
Mały dom - dalej w tle
fire
Super strzał z super pistoletów Fantomasa
fantomas klatki
Animowany Fantomas - tą grafikę już znamy

const houseB = new Image(66, 85);
houseB.src = 'dom.png';

const houseS = new Image(60, 90);
houseS.src = 'dom2.png';

const fire = new Image(135, 41);
fire.src = "fire.png";

const fantom = new Image(240, 66);
fantom.src = 'fantom.png';

Kolejny krok to stworzenie naszego animowanego obiektu korzystając z klasy, którą przed chwilą napisaliśmy:


//x, y, speed, obrazekFantomasa, 3 klatki, ctx
const fantomas = new AnimatedObj(120, 35, 0, fantom, 3, ctx);

Utwórzmy teraz kilka domów, które będą się przemieszczać w tle. Tworzymy je za pomocą 2 pętli typu for, w których do tablic dla każdego typu domu wstawiamy randomową szybkość i pozycję x. Dzięki temu domki będą się przesuwać w różnym tempie, a ruch zacznie się od różnego miejsca w poziomie. Nie są to poklatkowo animowane obiekty, więc nie stosujemy tutaj naszej klasy.


const housesBigCount = 3;
const housesSmallCount = 5;
const housesBig = [];
const housesSmall = [];

for (let i=0; i<housesBigCount; i++) {
    const pos = [ 2+Math.random() , Math.random()*canvasElem.width ];
    housesBig.push(pos);
}

for (let i=0; i<housesSmallCount; i++) {
    const pos = [ 1+Math.random() , Math.random()*canvasElem.width ];
    housesSmall.push(pos);
}

Zakończone przygotowania do animacji, czas więc odpalić sekwencyjnie funkcję drawAnim(). Funkcja ta wykonuje pętlę po wszystkich domkach i zmienia ich pozycję x. Jeżeli wyjdą poza płótno (czyli ich x będzie mniejsze od -szerokość_domku), wtedy ustawiamy pozycję domku na nowo (na prawo) i wybieramy dla niego nową szybkość.
Funkcja drawAnim() poza przesuwaniem drzew animuje też obiekt Fanthomasa odpalając dla niego metodę draw() oraz losowo animuje strzał z pistoletów Fantomasa - który to jest zwykłym rysowaniem pojedynczego obrazka na canvasie.


function FpsCtrl(fps, cb) {
    ...
}

function AnimatedObj(x, y, speed, image, framesCount, ctx) {
    ...
}

function drawAnim() {
    ctx.fillStyle = "#111";
    ctx.fillRect(0, 0, canvasElem.width, canvasElem.height);

    //poruszamy domki i w razie czego resetujemy ich pozycję
    //na początku rysujemy małe domki w tle
    for (let i=0; i<housesSmallCount; i++) {
        housesSmall[i][1] = housesSmall[i][1] - housesSmall[i][0];
        ctx.drawImage(houseS, housesSmall[i][1], 20);
        if (housesSmall[i][1] < -houseS.width) {
            housesSmall[i][1] = canvasElem.width;
            housesSmall[i][0] = 1 + Math.random();
        }
    }

    //a potem duże domki - też w tle
    for (let j=0; j<housesBigCount; j++) {
        housesBig[j][1] = housesBig[j][1] - housesBig[j][0];
        ctx.drawImage(houseB, housesBig[j][1], 20);
        if (housesBig[j][1] < -houseB.width) {
            housesBig[j][1] = canvasElem.width;
            housesBig[j][0] = 2 + Math.random()
        }
    }

    //rysujemy fantomasa
    fantomas.draw();

    //i jego super strzał z pistoletu
    if (Math.random() > 0.9) {
        ctx.drawImage(fire, 174, 43);
    }
}

const houseB = new Image(66, 85);
houseB.src = 'dom.png';

const houseS = new Image(60, 90);
houseS.src = 'dom2.png';

const fire = new Image(135, 41);
fire.src = "fire.png";

const fantom = new Image(240, 66);
fantom.src = 'fantom.png';

const fantomas = new AnimatedObj(120, 35, 0, fantom, 3, ctx);

const housesBigCount = 3;
const housesSmallCount = 5;
const housesBig = [];
const housesSmall = [];

for (let i=0; i<housesBigCount; i++) {
    const pos = [ 2+Math.random() , Math.random()*canvasElem.width ];
    housesBig.push(pos);
}

for (let i=0; i<housesSmallCount; i++) {
    const pos = [ 1+Math.random() , Math.random()*canvasElem.width ];
    housesSmall.push(pos);
}

Cały listing wygląda tak:


function FpsCtrl(fps, cb) {
    const delay = 1000 / fps;
    let time = null;
    let frame = -1;
    let req;
    let isPlaying = false;

    function loop(timestamp) {
        if (time === null) {
            time = timestamp;
        }
        let seg = Math.floor((timestamp - time) / delay);
        if (seg > frame) {
            frame = seg;

            cb({
                time: timestamp,
                frame: frame
            })
        }
        req = requestAnimationFrame(loop)
    }

    this.start = function() {
        if (!this.isPlaying) {
            this.isPlaying = true;
            req = requestAnimationFrame(loop);
        }
    };

    this.pause = function() {
        if (this.isPlaying) {
            cancelAnimationFrame(req);
            this.isPlaying = false;
            time = null;
            frame = -1;
        }
    };
}

function AnimatedObj(x, y, speed, image, framesCount, ctx) {
    this.x = x;
    this.y = y;
    this.speed = speed;
    this.stepX = this.speed;
    this.stepY = this.speed;
    this.ctx = ctx
    this.image = image;

    this.frameNr = 1;
    this.framesCount = framesCount;
    this.frameWidth = this.image.width / this.framesCount;
    this.frameHeight = this.image.height;

    this.draw = function() {
        this.frameNr++;
        if (this.frameNr > this.framesCount) {
            this.frameNr = 1;
        }
        const frameXpos = (this.frameNr-1)*this.frameWidth;
        this.ctx.drawImage(this.image, frameXpos, 0, this.frameWidth, this.frameHeight, this.x, this.y, this.frameWidth, this.frameHeight);
    }

    this.move = function() {
        this.x = this.x + this.stepX;
        this.y = this.y + this.stepY;
        this.draw();
    }
}

function drawAnim() {
    ctx.fillStyle = "#111";
    ctx.fillRect(0, 0, canvasElem.width, canvasElem.height);

    for (let i=0; i<housesSmallCount; i++) {
        housesSmall[i][1] = housesSmall[i][1] - housesSmall[i][0];
        ctx.drawImage(houseS, housesSmall[i][1], 20);
        if (housesSmall[i][1] < -houseS.width) {
            housesSmall[i][1] = canvasElem.width;
            housesSmall[i][0] = 1 + Math.random();
        }
    }

    for (let j=0; j<housesBigCount; j++) {
        housesBig[j][1] = housesBig[j][1] - housesBig[j][0];
        ctx.drawImage(houseB, housesBig[j][1], 20);
        if (housesBig[j][1] < -houseB.width) {
            housesBig[j][1] = canvasElem.width;
            housesBig[j][0] = 2 + Math.random()
        }
    }

    fantomas.draw();

    if (Math.random() > 0.9) {
        ctx.drawImage(fire, 174, 43);
    }
}

const canvasElem = document.querySelector('#canvasFanthom')
const ctx = canvasElem.getContext('2d');

const houseB = new Image(66, 85);
houseB.src = 'dom.png';

const houseS = new Image(60, 90);
houseS.src = 'dom2.png';

const fire = new Image(135, 41);
fire.src = "fire.png";

const fantom = new Image(240, 66);
fantom.src = 'fantom.png';

const fantomas = new AnimatedObj(120, 35, 0, fantom, 3, ctx);

const housesBigCount = 3;
const housesSmallCount = 5;

const housesBig = [];
const housesSmall = [];

for (let i=0; i<housesBigCount; i++) {
    housesBig.push([2+Math.random() , Math.random()*canvasElem.width])
}

for (let i=0; i<housesSmallCount; i++) {
    housesSmall.push([1+Math.random() , Math.random()*canvasElem.width])
}

fantom.addEventListener('load', function(){
    const animLoop = new FpsCtrl(24, drawAnim);
    animLoop.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 pewnie bardziej by pasowało przy grach, gdzie logika musi reagować na poczyniania gracza...