انیمیشن در canvas

پس از قدرت گرفتن مرورگر‌ها امکانات مناسبی برای ساخت انیمیشن ایجاد شد که هم قدرت بالایی دارند، و هم روش استفاده از آن‌ها بسیار آسان است. در این آموزش به زیر و بم ساخت انیمیشن، ترفند‌های اجرای بهتر، و راه‌حل برخی از مشکلات آن می‌پردازیم. این آموزش فقط ویژه‌ی canvas نیست و از این توابع می‌توان برای ایجاد انیمیشن روی هر بخشی در سند استفاده کرد.

از آنجایی که در این آموزش هم به موارد ابتدایی و ساده، و هم به موارد پیشرفته اشاره شده، سعی کنید موارد گفته شده را به آرامی بیاموزید تا هم تسلط بهتری به موضوع به دست آورده و هم نکات مهم را از قلم نیندازید. اینکه درک کامل و مناسبی از موضوع به دست بیاورید مهم‌تر از خواندن یک‌باره‌ی این مطلب است!

مشکلات توابع پیشین

سال‌ها پیش، توابع setInterval و setTimeout در کنار فلش و دیگر فناوری‌ها برای ساخت انیمیشن استفاده می‌شدند؛ اما این موارد مشکلات زیادی داشتند که از جمله‌ی آن‌ها می‌توان به این موارد اشاره کرد:

  • این توابع مرورگر را مجبور می‌کردند که تابع انیمیشن را در زمان‌های مشخص‌شده (مثلا هر 10 میلی‌ثانیه یک بار) اجرا کند که باعث فشار روی مرورگر می‌شد و سرعت صفحه پایین می‌آمد.
  • این توابع حتی هنگامی که صفحه در حال نمایش نبود (مثلا هنگام باز بودن چند تب) باز هم اجرا می‌شدند و پردازش بی‌دلیل ایجاد می‌کردند. چند سند با انیمیشن می‌توانستند به سادگی باعث crash شدن مرورگر شوند.

توابع AnimationFrame

توابع جدیدی که برای ساخت انیمیشن معرفی شده‌اند، نه‌تنها مشکلات توابع قبلی را ندارند، بلکه بسیار هم سریع و بهینه هستند. این توابع requestAnimationFrame و cancelAnimationFrame نام دارند که تابع اول برای اجرای انیمیشن و تابع دوم برای توقف انیمیشن به کار می‌رود.

استفاده از این توابع بسیار ساده است. کافیست تابعی که می‌خواهیم انیمیشن را اجرا کند، به عنوان ورودی تابع requestAnimationFrame (یا به اختصار rAF) بدهیم. نمونه کد زیر این موضوع را به خوبی نشان می‌دهد:


function animated_function () { /* ... */ }
requestAninmationFrame(animated_function);

بهتر است با یک نمونه‌ی مناسب این تابع را بررسی کنیم. کد زیر را درنظر بگیرید. در تابع draw_rect یک مربع با طول ضلع و رنگ تصادفی ایجاد شده و در یک مختصات تصادفی از صفحه رسم می‌شود. در این کد این تابع را به کمک تابع rAF به صورت انیمیشن اجرا می‌کنیم:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min | 0;
}

function draw_rect () {
    let color = "hsl(" + get_random(0, 360) + ", 100%, 50%)",
        size = get_random(50, 200),
        
        x = get_random(0, cvs.width),
        y = get_random(0, cvs.height);
    
    ctx.fillStyle = color;
    ctx.fillRect(x - size, y - size, size * 2, size * 2);
}

requestAnimationFrame(draw_rect);

پرسش اصلی اینجاست که چرا فقط یک مربع رسم می‌شود و انیمیشن اجرا نمی‌شود؟ پاسخ در رفتار این تابع نهفته است. همانطور که از نام این تابع پیداست، این تابع فقط یک فریم از انیمیشن را اجرا می‌کند، نه یک انیمیشن کامل! خب پرسش جدید این است که چطور یک انیمیشن کامل با این تابع ایجاد کنیم؟ پاسخ این است که باید تابع rAF درون تابع انیمیشن باشد و با اجرای هر فریم انیمیشن، فریم بعدی را اجرا کند! یعنی کد بالا باید به این شکل اصلاح شود:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min | 0;
}

function draw_rect () {
    let color = "hsl(" + get_random(0, 360) + ", 100%, 50%)",
        size = get_random(50, 200),
        
        x = get_random(0, cvs.width),
        y = get_random(0, cvs.height);
    
    ctx.fillStyle = color;
    ctx.fillRect(x - size, y - size, size * 2, size * 2);
    
    requestAnimationFrame(draw_rect);
}

requestAnimationFrame(draw_rect);

کد بالا را اجرا کرده و نتیجه را ببینید. شما اولین انیمیشن خود را ایجاد کردید! (البته اگر از انیمیشن‌های آموزش‌های پیشین چشم‌پوشی کنیم!) خب حال باید به ویژگی‌های این تابع و انیمیشن‌ها بپردازیم. این تابع می‌تواند در هر جایی از تابع انیمیشن استفاده شود، تنها نکته‌ی مهم این است که این تابع فقط یک بار به ازای هر فریم انیمیشن اجرا شود. برای نمونه تابع rAF می‌تواند همان ابتدای تابع فراخوانی شود، اما اگر یک حلقه درون تابع انیمیشن وجود داشته باشد، تابع rAF هرگز نباید درون حلقه قرار بگیرد، زیرا باعث می‌شود که در یک فریم، چند درخواست برای فریم بعدی داده شود و پس از تنها چند فریم مرورگر متوقف می‌شود! به طور کلی تا هنگامی که در هر فریم فقط یک درخواست برای فریم بعدی داده شود، مشکلی وجود ندارد.

نکته‌ی دیگر اینکه هر تابعی که درون تابع انیمیشن اجرا شود نیز به صورت انیمیشن اجرا می‌شود. این یعنی کد زیر درست مانند کد بالا رفتار می‌کند. در کد زیر یک تابع به نام loop ایجاد شده که درون آن تابع draw_rect اجرا می‌شود. همانطور که می‌بینید، فریم اول انیمیشن می‌تواند بدون تابع rAF اجرا شود، اما فریم‌های بعدی حتما باید با این تابع اجرا شوند:


(function loop () {
    draw_rect();
    requestAnimationFrame(loop);
}) ();

متوقف کردن انیمیشن

حال که شما روش اجرای انیمیشن را آموخته‌اید، باید یاد بگیرید که چطور آن را متوقف کنید! روش متوقف کردن این توابع نیز شبیه به توابع setInterval و setTimout است. تابع rAF یک شناسه‌ی انیمیشن بازمی‌گرداند که باید از این شناسه به عنوان ورودی تابع cancelAnimationFrame استفاده کنیم. این تابع (به اختصار cAF) برای توقف انیمیشن استفاده می‌شود و ورودی آن، شناسه‌ی انیمیشن موردنظر است. به نمونه کد زیر توجه کنید. در این کد، تابع انیمیشن my_animation ده ثانیه پس از اجرا متوقف می‌شود:


let anim_id;

function my_animation () {
    /**/
    
    anim_id = requestAnimationFrame(my_animation);
}

requestAnimationFrame(my_animation);

setTimeout(function () {
    cancelAnimationFrame(anim_id);
}, 10000);

استفاده از تابع cAF ساده‌ترین راه برای توقف انیمیشن است اما تنها راه نیست. موقعیت‌هایی وجود دارند که در آن‌ها این تابع موفق به متوقف کردن انیمیشن نمی‌شود. یکی از این موقعیت‌ها هنگامی است که تابع rAF بیش از یک بار اجرا شده باشد. در این حالت، fps (یا تعداد اجرای انیمیشن در یک ثانیه) تغییر نمی‌کند، (معمولا 60fps است) بلکه در هر فریم، به جای یک بار اجرا، دو بار (یا بیشتر) اجرا می‌شود. در این حالت اگر تابع cAF اجرا شود، فقط یکی از این اجرا‌ها در هر فریم را متوقف می‌کند ولی دیگر اجرا‌ها همچنان ادامه پیدا می‌کنند.

یک راه دیگر برای توقف انیمیشن، استفاده از یک دستور شرطی برای ادامه‌ی انیمیشن است. این دستور بررسی می‌کند که آیا انیمیشن باید اجرا شود یا خیر، در صورت مثبت بودن پاسخ، تابع rAF اجرا شده و در غیر این صورت اجرا نمی‌شود. به نمونه کد زیر توجه کنید. توابع draw_rect و get_random را به این کد اضافه کرده و آن را اجرا کنید:


/* include (draw_rect) & (get_random) */

let animation_status = true;

function loop () {
    draw_rect();
    
    if (animation_status) requestAnimationFrame(loop);
}

loop();

هنگامی که انیمیشن در حال اجراست، وارد console شده و مقدار animation_status را به false تغییر دهید. بعد از این کار انیمیشن متوقف می‌شود. این روش در مقایسه با روش cAF اطمینان بیشتری دارد اما دارای مشکلی نیز هست. اگر از این روش در انیمیشن‌هایی استفاده شود که به نوعی دارای حساسیت هستند، برای نمونه در یک بازی که مکان دشمن یا دیگر موارد مهم است، این روش ممکن است باعث خراب شدن صحنه شود.

بهترین روش استفاده از هر دو گزینه همراه با یک ساختار مناسب است که مانع ارسال درخواست اضافی شود. در ادامه قدم به قدم یک کلاس مناسب برای این موضوع ایجاد می‌کنیم که این مشکل را نیز، همراه با مشکلات احتمالی دیگر، حل می‌کند و پشتیبانی مناسبی نیز دارد.

بررسی چند نمونه

در این بخش به ایجاد چند انیمیشن با مواردی که آموخته‌ایم می‌پردازیم. از آنجایی که این موارد از پیش بررسی شده‌اند، توضیحاتی درباره‌ی کد داده نمی‌شود بلکه فقط کاری که انجام می‌دهد بررسی می‌شود.

رسم دایره‌های تصادفی

این کد نیز شبیه به کد مربع‌های تصادفی است، با این تفاوت که پیش از رسم شکل فعلی پاک می‌شود و استفاده از دستور stroke لازم است. سعی کنید برای ایجاد تنوع و یادگیری بهتر، کد زیر را تغییر دهید:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min | 0;
}

function draw_arc () {
    let radius = get_random(25, 100),
        color = "hsl(" + get_random(0, 360) + ", 100%, 50%)",
        
        x = get_random(0, cvs.width),
        y = get_random(0, cvs.height);
    
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    
    ctx.strokeStyle = color;
    ctx.lineWidth = radius / 20 | 0;
    
    ctx.stroke();
    requestAnimationFrame(draw_arc);
}

draw_arc();

رسم خطوط تصادفی با حاشیه

این کد نسبتا ساده است و فقط یک زنجیره از خطوط ایجاد می‌کند. که در هر فریم یک خط به آن‌ها اضافه می‌شود. در این کد از شفافیت زیادی برای ویژگی fillStyle استفاده شده (95% شفافیت) و در هر فریم یک مربع روی لایه‌ی ترسیمات رسم می‌شود که باعث ایجاد یک جلوه در ناپدید شدن خطوط شده است. با استفاده از مقدار destination-out نیز می‌توان به نتیجه‌ی مشابهی رسید اما این روش کمی بهینه‌تر است:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min | 0;
}

let line_chain = [],
    max_chain_size = 5;

ctx.lineWidth = 5;
ctx.lineJoin = ctx.lineCap = "round";

ctx.strokeStyle = "#FFF";
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";

function draw_lines () {
    let x = get_random(0, cvs.width),
        y = get_random(0, cvs.height);
    
    line_chain.push([x, y]);
    if (line_chain.length > max_chain_size) line_chain.shift();
    
    ctx.beginPath();
    
    for (let i = 0, l = line_chain.length; i < l; i++) {
        ctx.lineTo(...line_chain[i]);
    }
    
    ctx.fillRect(0, 0, cvs.width, cvs.height);
    
    ctx.stroke();
    requestAnimationFrame(draw_lines);
}

draw_lines();

رسم خطوط با خط‌چین توسط نشانگر

این کد ابتدا یک خط در نقطه‌ای تصادفی انتخاب می‌کند و قلم را به آنجا می‌برد، حال با هر کلیک روی صفحه، یک خط به مختصات نشانگر موس رسم می‌شود. انیمیشن این برنامه نیز روی حاشیه‌ی این شکل متمرکز است و به آن انیمیشن می‌دهد. شیوه‌ی ایجاد انیمیشن روی خط‌چین پیش از این بررسی شده و نیاز به توضیح ندارد:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min | 0;
}

function get_cursor (e) {
    let box = cvs.getBoundingClientRect(),
        
        x = e.clientX - box.left,
        y = e.clientY - box.top;
    
    ctx.lineTo(x, y);
}

function draw_dash_lines () {
    ctx.clearRect(0, 0, cvs.width, cvs.height);
    
    ctx.lineDashOffset = -offset;
    ctx.stroke();
    
    offset = ++offset % max_dash;
    requestAnimationFrame(draw_dash_lines);
}

ctx.setLineDash([
    get_random(10, 20),
    get_random(20, 30),
    get_random(15, 25)
]);

let offset = 0,
    max_dash = ctx.getLineDash().reduce((a, b) => a + b);

ctx.moveTo(get_random(0, cvs.width), get_random(0, cvs.height));
cvs.addEventListener("click", get_cursor);

draw_dash_lines();

دایره‌های سرگردان

این نمونه کمی پیچیده‌تر است اما یادگیری آن بسیار ضروری است زیرا بیشتر ترسیمات پویا در canvas با این روش‌ها ایجاد می‌شوند. در این کد با هر حرکت موس در صفحه، چند دایره با شعاع تصادفی، در جهت‌های تصادفی حرکت کرده و کوچک می‌شوند تا اینکه ناپدید شوند. در ادامه برای بخش‌های مورد نیاز توضیحاتی آورده شده است:


cvs.width = cvs.height = 700;

/* (1) */
function get_random (min, max) {
    return ((Math.random() * (max - min + 1) + min) * 100 | 0) / 100;
}

class Circle {
    /* (2) */
    constructor (x, y) {
        this.x = x;
        this.y = y;
        
        this.r = get_random(5, 15);
        this.rs = get_random(0.2, 2.2);
        
        this.v = [get_random(-5, 5), get_random(-5, 5)];
        this.status = true;
    }
    
    /* (3) */
    update () {
        if (this.r === 0) {
            this.status = false;
            return;
        }
        
        this.x += this.v[0];
        this.y += this.v[1];
        
        this.r = Math.max(0, this.r - this.rs);
    }
    
    /* (4) */
    draw () {
        ctx.moveTo(this.x + this.r, this.y);
        ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
    }
}

function draw_arcs () {
    /* (5) */
    ctx.fillStyle = "#111";
    ctx.fillRect(0, 0, cvs.width, cvs.height);
    
    ctx.beginPath();
    ctx.fillStyle = "#FFF";
    
    /* (6) */
    for (let i = arcs.length - 1; i > -1; i--) {
        if (!arcs[i].status) {
            arcs.splice(i, 1);
            continue;
        }
        
        arcs[i].update();
        arcs[i].draw();
    }
    
    /* (7) */
    ctx.fill();
    requestAnimationFrame(draw_arcs);
}

/* (8) */
function add_arcs (e) {
    let box = cvs.getBoundingClientRect(),
        
        x = e.clientX - box.left,
        y = e.clientY - box.top;
    
    /* (9) */
    for (let i = 0; i < 100; i++) {
        arcs.push(new Circle(x, y));
    }
}

/* (10) */
let arcs = [];

cvs.addEventListener("mousemove", add_arcs);
requestAnimationFrame(draw_arcs);

بخش 1 تابع جدید اعداد تصادفی

این تابع تفاوت کوچکی با تابع پیشین دارد. در این تابع اعداد تولید‌شده دارای دو رقم اعشار هستند. در برنامه‌های قبل می‌شد از این مورد چشم‌پوشی کرد اما در این برنامه وجود قسمت اعشاری در اعداد بهتر است.

بخش 2 کلاس Circle

در این بخش یک کلاس به نام Circle ایجاد می‌کنیم. این کلاس یک مختصات به عنوان ورودی دریافت کرده و یک شئ برمی‌گرداند که دارای ویژگی‌های (x,y)، r، rs، v، و status است. به جز مختصات مرکز و status، دیگر ویژگی‌های این شئ تصادفی هستند. ویژگی r شعاع دایره، ویژگی v سرعت و جهت حرکت دایره، ویژگی rs سرعت کوچک شدن شعاع، و ویژگی status وضعیت دایره را نشان می‌دهد.

بخش 3 متد update

این متد برای به‌روز‌رسانی مختصات و وضعیت دایره است. این متد ابتدا مثبت بودن شعاع دایره را بررسی می‌کند. اگر شعاع صفر بود، وضعیت یا status دایره را به false تغییر داده و اجرای متد را متوقف می‌کند. در غیر این صورت، مختصات دایره را توسط سرعت تغییر داده، و شعاع آن را به اندازه‌ی rs کوچک می‌کند؛ البته به کمک متد Math.max اطمینان حاصل می‌کند که شعاع منفی نشود.

بخش 4 متد draw

وظیفه‌ی این متد، قرار دادن یک دایره با مشخصات موردنظر در شکل فعلی است. این متد ابتدا با کمک متد moveTo، قلم را به زاویه‌ی آغاز دایره برده و سپس یک دایره رسم می‌کند تا از رسم خطوط اضافی جلوگیری کند. از آنجایی که شکل فعلی در تابع انیمیشن پاک می‌شود، نیازی به پاک کردن آن درون این متد نیست.

بخش 5 تابع رسم دایره‌ها

در این بخش تابع draw_arcs را تعریف می‌کنیم. وظیفه‌ی این تابع، به‌روز‌رسانی دایره‌ها و رسم آن‌ها، و در صورت نیاز حذف دایره‌هایی است که شعاع آن‌ها به صفر رسیده. این تابع ابتدا ویژگی fillStyle را به سیاه تغییر داده و یک مربع روی کل لایه‌ی ترسیمات رسم می‌کند، سپس آن را به سفید تغییر می‌دهد و شکل فعلی را برای رسم دایره‌های جدید پاک می‌کند.

بخش 6 بررسی دایره‌ها

در این بخش یک حلقه از پایان حلقه تا آغاز آن اجرا می‌شود و هرکدام از دایره‌هایی که در آرایه‌ی arcs هستند را بررسی می‌کند. اگر ویژگی status هر دایره برابر false باشد، آن را از آرایه حذف می‌کند و به سراغ دایره‌ی بعدی می‌رود. سپس متد draw و update را روی دایره‌های مناسب اجرا می‌کند.

بخش 7 رسم دایره‌ها

در این بخش با استفاده از متد fill تمام دایره‌هایی که در شکل فعلی قرار گرفته‌اند رسم می‌شوند. سپس به کمک تابع rAF فریم بعدی از انیمیشن (از همین تابع) درخواست می‌شود.

بخش 8 اضافه کردن دایره‌ها

در این بخش تابع add_arcs ایجاد می‌شود. این تابع هنگامی اجرا می‌شود که موس روی عنصر cvs حرکت کند. این تابع ابتدا به کمک getBoundingClientRect و ویژگی‌های ورودی e (از مدیریت رویداد) مختصات موس روی عنصر را به دست می‌آورد و در متغیر‌های x و y ذخیره می‌کند.

بخش 9 دایره به تعداد دلخواه

در این بخش یک حلقه به طول دلخواه ایجاد می‌شود. درون این حلقه، به کمک کلاس Circle دایره در مختصات (x,y) ایجاد شده و به آرایه‌ی arcs اضافه می‌شود. می‌توانید طول حلقه را هرچقدر خواستید تغییر دهید اما مراقب باشید چون تعداد خیلی زیاد ممکن است باعث توقف مرورگر شود!

بخش 10 آغاز برنامه

در این بخش آرایه‌ی arcs ایجاد می‌شود، سپس مدیریت رویداد mouse-move روی عنصر cvs ایجاد شده و انیمیشن تابع draw_arcs آغاز می‌شود. حال برنامه در حال اجراست و با هر حرکت موس روی عنصر، تعداد 100 دایره با سرعت و شعاع تصادفی در آن نقطه ایجاد شده و شروع به حرکت می‌کنند.

همانطور که می‌بینید، با چند دستور ساده می‌توان چنین جلوه‌های زیبایی ساخت. در آموزش‌های آینده نمونه‌های بیشتری بررسی خواهد شد، اما لازم است ابتدا با نمونه‌های ساده‌تر، مانند این نمونه، آشنا باشید. در زیر یک نمونه‌ی پیشرفته‌تر که با همین روش ایجاد شده می‌بینید:

See the Pen
Canvas Shape Sparkle
by Hossein Rafie (@Hossein_Rafie)
on CodePen.

زمان‌بندی در انیمیشن

انیمیشن‌هایی که با توابع AnimationFrame ساخته می‌شوند، نسبت به پردازش موردنیاز انیمیشن اجرا می‌شوند. این انیمیشن‌ها معمولا 60fps هستند اما این مقدار ممکن است نسبت به کیفیت نمایشگر کاربر بیشتر باشد. همچنین اگر انیمیشن سنگین باشد، این مقدار پایین می‌آید. خب یک پرسش مهم به ذهن می‌آید: «چطور می‌توانیم مستقل از fps انیمیشن، آن را با سرعت ثابت اجرا کنیم؟»

خب چرا باید بخواهیم انیمیشن با سرعت ثابت اجرا شود؟ یک بازی تحت وب را در نظر بگیرید. حال فرض کنید سه نفر در حال اجرای این بازی هستند. نفر اول یک سیستم عادی در اختیار دارد که انیمیشن را با سرعت 60fps اجرا می‌کند. نفر دوم چند پردازش سنگین در پس‌زمینه‌ی خود دارد و سرعت انیمیشن برای او به 30fps رسیده است. نفر سوم یک سیستم به‌روز و قدرتمند دارد که در حالت عادی انیمیشن را با سرعت 90fps اجرا می‌کند. اگر سرعت بازی به fps انیمیشن بستگی داشته باشد، یعنی سرعت بازی برای این سه نفر متفاوت است. این موضوع می‌تواند باعث بازخورد منفی فراوان شود و باید به دنبال یک راه برای اجرای انیمیشن با سرعت ثابت باشیم.

برای اینکه سرعت انیمیشن ثابت شود، باید یک متغیر از نوع زمان وارد آن کنیم. خوشبختانه تابع rAF این کار را انجام می‌دهد و یک ورودی عددی از نوع HighResTimeStamp به عنوان ورودی تابع انیمیشن می‌دهد که زمان گذشته از آغاز انیمیشن را به ما می‌دهد. البته می‌توان از روش‌های دیگر مانند Date.now نیز استفاده کرد اما استفاده از ورودی دقیق‌تر است. به نمونه کد زیر دقت کنید. در کد زیر شعاع دایره در آغاز 50 است و پس از 5 ثانیه به 250 می‌رسد:


cvs.width = cvs.height = 500;

let radius = 50,
    duration = 5,
    
    min_radius = 50,
    max_radius = 250,
    
    last_time = 0,
    delta;

function grow_arc (t) {
    ctx.clearRect(0, 0, 500, 500);
    
    ctx.beginPath();
    ctx.arc(250, 250, radius, 0, Math.PI * 2);
    ctx.fill();
    
    delta = Math.abs(t - last_time) / (1000 * duration);
    
    if (radius < max_radius) {
        last_time = t;
        requestAnimationFrame(grow_arc);
    }
    
    radius += (max_radius - min_radius) * delta;
}

requestAnimationFrame(grow_arc);

در کد بالا دو متغیر last_time و delta مسئول زمان‌بندی انیمیشن هستند. متغیر last_time آخرین مقدار زمانی‌ای که در ورودی t بوده را دارد. این مقدار در آغاز انیمیشن صفر است بنابراین مقدار اولیه‌ی این متفیر نیز صفر است. متغیر delta نیز اختلاف ورودی t و last_time است. این متغیر زمانی که از اجرای آخرین فریم گذشته را در یکای میلی‌ثانیه نشان می‌دهد؛ به همین دلیل تقسیم بر 1000 می‌شود تا یکای آن ثانیه شود.

همانطور که می‌دانید، شعاع دایره باید در مدت پنج ثانیه به اندازه‌ی 200 افزایش یابد. برای این کار عبارت max_radius – min_radius یا همان 200 را تقسیم بر delta * duration می‌کنیم و نتیجه را به شعاع دایره اضافه می‌کنیم. حال ممکن است بپرسید نقش delta * duration چیست؟ برای پاسخ به آن فرض کنید طول انیمیشن یک ثانیه، یعنی duration برابر 1 باشد.

با این فرض، و فرض اینکه انیمیشن با سرعت 60fps اجرا می‌شود، می‌بینیم که در هر فریم 200/60 مقدار به شعاع اضافه می‌شود. وقتی سرعت انیمیشن 60fps باشد، یعنی مقدار 200/60 در یک ثانیه 60 بار به شعاع اضافه می‌شود. این یعنی مقداری که در یک ثانیه به شعاع اضافه شده برابر 200 * 60 / 60 یا همان 200 است! این نتیجه‌گیری روی هر سرعتی در انیمیشن جواب می‌دهد، حتی در انیمیشن‌های با سرعت متغیر!

خب نقش duration چیست؟ این متغیر مدت زمان انیمیشن (یا یک حرکت در انیمیشن) را به ثانیه مشخص می‌کند. البته در حالت کلی از این متغیر استفاده نمی‌شود، بلکه سعی می‌شود مقدار تغییرات با این متغیر ترکیب شود. یعنی به جای اینکه بگوییم شعاع در مدت پنج ثانیه 200 واحد تغییر کند، می‌گوییم در مدت یک ثانیه 40 واحد تغییر کند. با انجام این کار نیازی به متغیر duration نیست و همان نتیجه به دست می‌آید.

نکته‌ی مهمی که باید به آن توجه کنید، این است که به طور کلی زمان و کار با آن نتایج بسیار دقیقی به دست نمی‌دهند. این مورد نه فقط در توابع AnimationFrame بلکه در همه‌جا وجود دارد فقط مقدار خطا متفاوت است. در این دست انیمیشن‌ها نیز مقدار خطا، بسته به نوع کدی که نوشته شده، می‌تواند تا 30 میلی‌ثانیه باشد که البته بسیار کوچک است.

از آنجایی که خطا در محاسبات (هرچند کوچک) می‌تواند باعث بروز خطا در برخی برنامه‌ها شود، توصیه می‌شود کران‌های مقداری را بهتر وارد برنامه کنید. برای نمونه به کد زیر دقت کنید. در این کد یک دایره به شعاع 40 و رنگ زرد یک حرکت تصادفی در صفحه آغاز می‌کند و پس از برخورد به دیواره‌های صفحه، حرکت آن بازتابی می‌شود. با کلیک روی عنصر می‌توانید سرعت آن را تغییر دهید:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min | 0;
}

const X = 0, Y = 1;

let circle = {
        x: cvs.width / 2,
        y: cvs.height / 2,
        
        r: 40,
        c: "#CC0",
        
        init: function () {
            this.min = [this.r, this.r];
            this.max = [cvs.width - this.r, cvs.height - this.r];
            
            this.v = [get_random(-300, 300), get_random(-300, 300)];
        },
        
        draw: function () {
            ctx.clearRect(0, 0, cvs.width, cvs.height);
            
            ctx.beginPath();
            ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
            
            ctx.fillStyle = this.c;
            ctx.fill();
        },
        
        update: function (d) {
            this.x = Math.max(this.min[X], Math.min(this.x + this.v[X] * d, this.max[X]));
            this.y = Math.max(this.min[Y], Math.min(this.y + this.v[Y] * d, this.max[Y]));
            
            if (this.x === this.min[X] || this.x === this.max[X]) this.v[X] *= -1;
            if (this.y === this.min[Y] || this.y === this.max[Y]) this.v[Y] *= -1;
        }
    },
    
    last_time = 0,
    delta;

function show_circle (t) {
    delta = (t - last_time) / 1000;
    
    circle.draw();
    circle.update(delta);
    
    last_time = t;
    requestAnimationFrame(show_circle);
}

circle.init.call(circle);
requestAnimationFrame(show_circle);

cvs.addEventListener("click", circle.init.bind(circle));

همانطور که می‌بینید سرعت این انیمیشن ثابت است و این یعنی در آن خطای محاسباتی وجود دارد. اگر کران‌های مقداری دایره به خوبی استفاده نشوند، در fps‌های پایین ممکن است برنامه خراب شود. مثلا توپ درون یکی از دیواره‌ها گیر کند. با بررسی مناسب کد متوجه علت آن خواهید شد! برای حل این مشکل از متد‌های Math.max و Math.min استفاده می‌کنیم تا مختصات دایره هیچگاه خارج از محدوده‌ی کران‌هایش قرار نگیرد.

در تابع انیمیشن show_circle، متغیر delta حساب شده و به عنوان ورودی متد update شئ circle استفاده می‌شود. با این کار دیگر لازم نیست درون متد update درگیر محاسبه‌ی delta شویم و می‌توانیم آن را مستقیما به عنوان ورودی متد داشته باشیم! در ادامه بیشتر به این موضوع می‌پردازیم.

در کد بالا ویژگی‌های x و y مختصات مرکز دایره، ویژگی c رنگ دایره و ویژگی r شعاع دایره هستند. برخی تعریف‌ها لازم است درون یک متد انجام شوند، بنابراین متد init را تعریف می‌کنیم. این متد بعدها در هر رویداد کلیک نیز اجرا خواهد شد. درون این متد سرعت با ویژگی v و کران یا محدوده‌ی شکل نیز درون ویژگی‌های min و max تعریف می‌شود.

متد draw وظیفه‌ی رسم دایره را بر عهده دارد و متد update نیز مختصات مرکز دایره را بر اساس سرعت و کران‌های آن به‌روز‌رسانی می‌کند. به جز این موارد، ممکن است کارکرد متد‌های bind و call برای شما مشخص نباشند. این متد‌ها در کنار متد apply سه متد قدرتمند و مهم در شئ‌گرایی جاوااسکریپت هستند و برای درک روش کار آن‌ها تسلط به شئ‌گرایی لازم است و نمی‌توان توضیحات بیشتری داد. برای خوانندگان علاقمند و مسلط خواندن این مطلب را پیشنهاد می‌کنیم.

حالت focus صفحه

همانطور که گفتیم، توابع AnimationFrame برای ایجاد انیمیشن بهینه هستند. یکی از این موارد بهینه‌سازی شده در این توابع، این است که این توابع وقتی صفحه از حالت focus خارج می‌شود، بسیار کند می‌شوند؛ یعنی متوقف می‌شوند، اما بسته به مرورگر، در هر دقیقه حدود سه فریم از آن‌ها اجرا می‌شود. البته برخی مرورگر‌ها آن‌ها را سریع‌تر اجرا می‌کنند. حال اگر در این انیمیشن‌ها از delta استفاده کرده باشید باید منتظر فاجعه باشید! زیرا delta همان فاصله‌ی زمانی است و وقتی یک فریم از انیمیشن بیست ثانیه بعد اجرا شود، بیست برابر سرعت (یا تغییرات) به اشیاء درون انیمیشن اعمال شده و ممکن است کل انیمیشن خراب شود.

راه‌حل این موضوع می‌تواند استفاده از ویژگی visibilityState باشد. به‌گونه‌ای که اگر صفحه در حال نمایش نبود، انیمیشن‌ها دیگر اجرا نشوند. می‌توان از رویکرد مشابه برای توقف انیمیشن‌ها با دستور شرطی استفاده کرد. یا اینکه می‌توان از رویداد‌های focus و blur به شکل مشابه استفاده کرد. مشکل اصلی اینجاست که راه‌حل نسبت به انیمیشنی که تعریف کرده‌ایم تغییر می‌کند. در برخی انیمیشن‌ها حتی نیاز به استفاده از متغیر delta نیست ولی در دیگر انیمیشن‌ها وجود آن حیاتی است. توقف برخی انیمیشن‌ها مهم نیست، ولی برخی دیگر نمی‌توانند و نباید متوقف شوند؛ مانند یک بازی آنلاین.

در کنار این موارد، گاهی لازم است ساختار انیمیشن، بسته به نوع آن، تغییر کند. برای نمونه گاهی می‌توان پردازشات مربوط به انیمیشن را از بخش ترسیمات آن جدا کرد. مثلا می‌توان از setInterval برای پردازشات، و از rAF برای رسم آن استفاده کرد. البته در کار با این موارد باید مراقب باشید زیرا ممکن است بازده برنامه پایین بیاید.

کلاس Animation

خب، نگاهی به متد update و ورودی آن در کد بالا بیندازید. چرا باید برای هر انیمیشن دو متغیر last_time و delta تعریف کنیم و در هر فریم آن‌ها را حساب کنیم؟ بهتر نیست این جریان را خودکار کنیم و مانند متد update آن را به صورت ورودی داشته باشیم؟ می‌توانیم برای حل این مورد و همچنین حل دیگر مشکلات انیمیشن (مانند توقف انیمیشن) یک کلاس ایجاد کرده و از آن برای ایجاد انیمیشن استفاده کنیم.

این کلاس یک شئ ایجاد می‌کند که دو ویژگی و دو متد دارد. ویژگی callback که همان تابع انیمیشن است و ویژگی status نیز وضعیت اجرای انیمیشن را نشان می‌دهد. متد start برای آغاز انیمیشن و متد stop برای توقف آن است. کاری که این کلاس انجام می‌دهد، این است که یک لایه‌ی کنترلی به تابع انیمیشن اضافه می‌کند و اجازه نمی‌دهد تابع rAF برای آن تابع بیش از یک بار اجرا شود. همچنین هنگام متوقف کردن آن، هم از دستور شرطی و هم از تابع cAF استفاده می‌کند تا مشکلی در توقف انیمیشن ایجاد نشود.

در کنار این کار‌ها، این کلاس مقدار delta را حساب می‌کند و به عنوان ورودی تابع انیمیشن قرار می‌دهد. این یعنی می‌توانیم از این مقدار مستقیما استفاده کنیم و نیازی به محاسبات اضافی نیست. درضمن نباید درون تابع انیمیشن از تابع rAF استفاده کنیم زیرا این کلاس این کار را انجام می‌دهد:


/*
    -- Animation v0.1
    -- by Hossein_Rafie (https://codepen.io/Hossein_Rafie)
*/

class Animation
{
    constructor (animation_function, this_value = null) {
        this.#callback = animation_function.bind(this_value);
        this.#status = false;
    }

    #last = 0;
    #delta;

    #status;
    #callback;

    #anim_id;
    #start_id;

    #run = (t) => {
        this.#delta = ~~((t - this.#last) * 100) / 100000;
        this.#last = t;
        
        if (!this.#status) return;

        this.#callback.call(null, this.#delta);
        this.#anim_id = requestAnimationFrame(this.#run);
    }

    get status () { return this.#status; }
    get callback () { return this.#callback; }

    start () {
        if (this.#status) return;
        
        if (this.#start_id) this.#last = Date.now() - this.#start_id;
        else this.#start_id = Date.now();

        this.#status = true;
        this.#anim_id = requestAnimationFrame(this.#run);
    }

    stop () {
        if (!this.#status) return;

        this.#status = false;
        cancelAnimationFrame(this.#anim_id);
    }
}

توضیح خاصی درباره‌ی کد نوشته‌شده نمی‌دهیم زیرا مسائل مربوط به آن خارج از مبحث این آموزش هستند. در کد زیر از این کلاس برای ایجاد انیمیشن روی دایره استفاده می‌کنیم، البته باید تابع show_circle را نیز تغییر دهیم:


function show_circle (d) {
    circle.draw();
    circle.update(d);
}

let circle_animate = new Animation(show_circle);
circle_animate.start();

نکته‌ی آخر اینکه استفاده از این کلاس خوب است، اما بهتر است ابتدا از توابع rAF و cAF استفاده کنید تا به خوبی کارکرد آن‌ها را بیاموزید، سپس برای راحتی کار از این کلاس استفاده کنید. درضمن این کلاس بهترین بازده را ندارد، پس برای بهینه‌سازی بهتر تغییرات موردنظر خود را روی آن اعمال کرده و با ما به اشتراک بکذارید!

به علاوه، استفاده از این کلاس در همه‌جا مناسب نیست، به ویژه در مواردی که تعداد انیمیشن‌ها بسیار زیاد است، زیرا تعریف تعداد زیادی انیمیشن با این کلاس ممکن است حافظه‌ی نسبتا زیادی اشغال کند. احتمالا در آینده به یکی از این موارد برخورد کنیم. در چنین مواردی بهتر است از تابع rAF استفاده کنید، یا یک تابع کلی برای ایجاد انیمیشن ایجاد کرده و از این کلاس روی آن استفاده کنید.

نتیجه‌گیری

در این بخش سعی بر آن بود که زیر و بم انیمیشن همراه با چندین نمونه بررسی شود. لطفا این بخش را به خوبی مطالعه کرده و سعی کنید درک مناسبی از ویژگی‌های انیمیشن به دست آورید زیرا کاربرد این موارد بیشتر از آن است که فکر می‌کنید! خوشبختانه روند آموزش رو به پایان است و می‌توانید به این آموزش به چشم دیو سفید از هفت خان این دوره نگاه کنید! در آموزش بعدی به کلاس ImageData و برخی کاربرد‌های آن می‌پردازیم.

حسین رفیعی

حسین رفیعی

طراحی و برنامه‌نویسی رو از وب شروع کردم و مثل خیلی از شماها آموزش‌های آقای سیدی خیلی بهم کمک کرد. هرچند این روزا تمرکز من روی برنامه‌نویسی خارج از وب هست ولی هنوز هم توی این فضا هستم و امیدوارم بتونم به بقیه کمک کنم!

سوال داری؟ برو به پنل پرسش و پاسخ

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *