الفصل 17
الرسم على Canvas
الرسم خداع.
— م. ك. إيشر، نقلاً عن برونو إرنست في «المرآة السحرية لم. ك. إيشر»

تمنحنا المتصفحات عدة طرق لعرض الرسومات. أبسط طريقة هي استخدام الأنماط لتحديد موضع عناصر DOM العادية وتلوينها. وهذا يمكن أن يبلغ بنا شوطاً بعيداً، كما أظهرت اللعبة في الفصل السابق. فبإضافة صور خلفية شفافة جزئياً إلى العقد، يمكننا جعلها تبدو بالضبط كما نريد. بل إن من الممكن تدوير العقد أو إمالتها باستخدام نمط transform.
لكننا سنكون حينها نستخدم DOM لشيء لم يُصمَّم له أصلاً. فبعض المهام، مثل رسم خط بين أي نقطتين، بالغة الصعوبة مع عناصر HTML العادية.
هناك بديلان. الأول مبني على DOM لكنه يستخدم الرسومات المتجهة القابلة للتوسع (Scalable Vector Graphics) واختصارها SVG بدلاً من HTML. فكّر في SVG كلهجة من لهجات ترميز المستندات تركّز على الأشكال بدلاً من النص. يمكنك تضمين مستند SVG مباشرة في مستند HTML أو إدراجه بوسم <img>.
أما البديل الثاني فيسمى Canvas. والـ Canvas عنصر DOM واحد يغلّف صورة. وهو يوفر واجهة برمجية لرسم الأشكال على المساحة التي تشغلها العقدة. الفرق الرئيسي بين Canvas وصورة SVG هو أن الوصف الأصلي للأشكال يبقى محفوظاً في SVG بحيث يمكن تحريكها أو تغيير حجمها في أي وقت. أما Canvas فيحوّل الأشكال إلى بكسلات (نقاط ملونة على شبكة نقطية) بمجرد رسمها ولا يتذكر ما تمثله هذه البكسلات. والطريقة الوحيدة لتحريك شكل على Canvas هي محو الـ Canvas (أو الجزء المحيط بالشكل منه) وإعادة رسمه والشكل في موضع جديد.
SVG
لن يتعمق هذا الكتاب في SVG، لكنني سأشرح بإيجاز كيف يعمل. وفي نهاية الفصل، سأعود إلى المقايضات التي يجب أن تأخذها في الحسبان عند تقرير آلية الرسم الملائمة لتطبيق معين.
هذا مستند HTML يحتوي صورة SVG بسيطة:
<p>Normal HTML here.</p>
<svg xmlns="http://www.w3.org/2000/svg">
<circle r="50" cx="50" cy="50" fill="red"/>
<rect x="120" y="5" width="90" height="90"
stroke="blue" fill="none"/>
</svg>
تغيّر خاصية xmlns العنصر (وأبناءه) إلى فضاء أسماء XML مختلف. وفضاء الأسماء هذا، المُعرَّف برابط URL، يحدد اللهجة التي نتحدث بها حالياً. ووسما <circle> و<rect>، وهما غير موجودين في HTML، لهما معنى في SVG—فهما يرسمان أشكالاً بالنمط والموضع المحددين بخصائصهما.
تنشئ هذه الوسوم عناصر DOM، تماماً مثل وسوم HTML، يمكن للسكربتات التفاعل معها. على سبيل المثال، هذا يغيّر عنصر <circle> ليصبح لونه سماوياً بدلاً من ذلك:
let circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");
عنصر canvas
يمكن رسم رسومات Canvas على عنصر <canvas>. ويمكنك إعطاء عنصر كهذا خاصيتي width وheight لتحديد حجمه بالبكسلات.
الـ Canvas الجديد فارغ، أي أنه شفاف تماماً، ولذلك يظهر كمساحة فارغة في المستند.
الغرض من وسم <canvas> هو السماح بأنماط مختلفة من الرسم. وللوصول إلى واجهة رسم فعلية، نحتاج أولاً إلى إنشاء سياق (context)، وهو كائن توفر طرقه واجهة الرسم. وهناك حالياً ثلاثة أنماط رسم مدعومة على نطاق واسع: "2d" للرسومات ثنائية الأبعاد، و"webgl" للرسومات ثلاثية الأبعاد عبر واجهة OpenGL، و"webgpu"، وهو بديل أكثر حداثة ومرونة من WebGL.
لن يناقش هذا الكتاب WebGL أو WebGPU—سنلتزم بالبعدين الاثنين. لكن إن كنت مهتماً بالرسومات ثلاثية الأبعاد، فأنا أشجعك حقاً على البحث في WebGPU. فهو يوفر واجهة مباشرة إلى عتاد الرسومات ويتيح لك عرض مشاهد حتى المعقدة منها بكفاءة، باستخدام JavaScript.
تُنشئ سياقاً بطريقة getContext على عنصر <canvas> في DOM.
<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
let canvas = document.querySelector("canvas");
let context = canvas.getContext("2d");
context.fillStyle = "red";
context.fillRect(10, 10, 100, 50);
</script>
بعد إنشاء كائن السياق، يرسم المثال مستطيلاً أحمر عرضه 100 بكسل وارتفاعه 50 بكسل، وتقع زاويته العلوية اليسرى عند الإحداثيين (10, 10).
وكما في HTML (وSVG)، يضع نظام الإحداثيات الذي يستخدمه Canvas النقطة (0, 0) في الزاوية العلوية اليسرى، ويمتد المحور y الموجب نزولاً من هناك. وهذا يعني أن (10, 10) تقع 10 بكسلات أسفل ويمين الزاوية العلوية اليسرى.
الخطوط والأسطح
في واجهة Canvas، يمكن تعبئة الشكل، أي إعطاء مساحته لوناً أو نمطاً معيناً، أو يمكن تحديده بخط، أي رسم خط على طول حافته. ويستخدم SVG المصطلحات نفسها.
تعبّئ طريقة fillRect مستطيلاً. وهي تأخذ أولاً إحداثيي x وy للزاوية العلوية اليسرى للمستطيل، ثم عرضه، ثم ارتفاعه. وهناك طريقة مشابهة تسمى strokeRect ترسم محيط مستطيل.
لا تأخذ أي من الطريقتين معاملات إضافية. فلون التعبئة، وسماكة الخط، وما إلى ذلك، لا تحددها معطى للطريقة، كما قد تتوقع بحق، بل تحددها خصائص كائن السياق.
تتحكم خاصية fillStyle في طريقة تعبئة الأشكال. ويمكن ضبطها على نص يحدد لوناً، باستخدام تدوين الألوان الذي تستخدمه CSS.
وتعمل خاصية strokeStyle بطريقة مشابهة لكنها تحدد اللون المستخدم لخط مرسوم. أما عرض ذلك الخط فتحدده خاصية lineWidth، التي قد تحمل أي عدد موجب.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.strokeStyle = "blue";
cx.strokeRect(5, 5, 50, 50);
cx.lineWidth = 5;
cx.strokeRect(135, 5, 50, 50);
</script>
عندما لا تُحدد خاصية width أو height، كما في المثال، يحصل عنصر Canvas على عرض افتراضي قدره 300 بكسل وارتفاع 150 بكسل.
المسارات
المسار سلسلة من الخطوط. وتتبع واجهة Canvas ثنائية الأبعاد نهجاً غريباً في وصف مسار كهذا. فالأمر يتم بالكامل عبر تأثيرات جانبية. فالمسارات ليست قيماً يمكن تخزينها وتمريرها. بل إن أردت فعل شيء بمسار، تجري سلسلة من نداءات الطرق لوصف شكله.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
for (let y = 10; y < 100; y += 10) {
cx.moveTo(10, y);
cx.lineTo(90, y);
}
cx.stroke();
</script>
ينشئ هذا المثال مساراً بعدد من قطع الخطوط الأفقية ثم يرسمه بخط باستخدام طريقة stroke. وتبدأ كل قطعة تُنشأ بـ lineTo من الموضع الحالي للمسار. وهذا الموضع هو عادة نهاية القطعة الأخيرة، ما لم تُستدعَ moveTo. وفي هذه الحالة، تبدأ القطعة التالية من الموضع الممرر إلى moveTo.
عند تعبئة مسار (بطريقة fill)، يُعبَّأ كل شكل على حدة. ويمكن للمسار أن يحتوي أشكالاً متعددة—فكل حركة moveTo تبدأ شكلاً جديداً. لكن المسار يحتاج إلى أن يكون مغلقاً (أي أن بدايته ونهايته في الموضع نفسه) قبل أن يمكن تعبئته. وإن لم يكن المسار مغلقاً بالفعل، يُضاف خط من نهايته إلى بدايته، ويُعبَّأ الشكل المحصور بالمسار المكتمل.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(50, 10);
cx.lineTo(10, 70);
cx.lineTo(90, 70);
cx.fill();
</script>
يرسم هذا المثال مثلثاً معبأً. لاحظ أن ضلعين فقط من أضلاع المثلث مرسومان صراحة. أما الضلع الثالث، من الزاوية السفلية اليمنى عودة إلى القمة، فمضمون ضمنياً ولن يكون موجوداً لو رسمت المسار بخط.
يمكنك أيضاً استخدام طريقة closePath لإغلاق مسار صراحة بإضافة قطعة خط فعلية عائدة إلى بداية المسار. وهذه القطعة تُرسم عند رسم المسار بخط.
المنحنيات
قد يحتوي المسار أيضاً على خطوط منحنية. وهذه للأسف أصعب قليلاً في الرسم.
ترسم طريقة quadraticCurveTo منحنى إلى نقطة معينة. ولتحديد انحناء الخط، تُعطى الطريقة نقطة تحكم بالإضافة إلى نقطة وجهة. تخيّل نقطة التحكم هذه وهي تجذب الخط، فتمنحه انحنائه. ولن يمر الخط عبر نقطة التحكم، لكن اتجاهه عند نقطتي البداية والنهاية سيكون بحيث أن خطاً مستقيماً في ذلك الاتجاه سيشير نحو نقطة التحكم. يوضح المثال التالي ذلك:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(10, 90);
// نقطة التحكم=(60, 10) نقطة الهدف=(90, 90)
cx.quadraticCurveTo(60, 10, 90, 90);
cx.lineTo(60, 10);
cx.closePath();
cx.stroke();
</script>
نرسم منحنى تربيعياً من اليسار إلى اليمين، ونقطة التحكم فيه هي (60, 10)، ثم نرسم قطعتَي خط تمران عبر نقطة التحكم تلك وتعودان إلى بداية الخط. وتشبه النتيجة إلى حد ما شارة Star Trek. ويمكنك رؤية أثر نقطة التحكم: فالخطان الخارجان من الزاويتين السفليتين يبدآن في اتجاه نقطة التحكم ثم ينحنيان نحو هدفهما.
ترسم طريقة bezierCurveTo نوعاً مشابهاً من المنحنيات. وبدلاً من نقطة تحكم واحدة، لهذه الطريقة نقطتان—واحدة لكل من طرفي الخط. وهنا رسم تخطيطي مشابه لتوضيح سلوك منحنى كهذا:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(10, 90);
// نقطة التحكم1=(10, 10) نقطة التحكم2=(90, 10) الهدف=(50, 90)
cx.bezierCurveTo(10, 10, 90, 10, 50, 90);
cx.lineTo(90, 10);
cx.lineTo(10, 10);
cx.closePath();
cx.stroke();
</script>
تحدد نقطتا التحكم الاتجاه عند طرفي المنحنى. وكلما ابتعدتا عن النقطة المقابلة لهما، زاد «انتفاخ» المنحنى في ذلك الاتجاه.
قد يكون التعامل مع منحنيات كهذه صعباً—فليس واضحاً دائماً كيف تجد نقاط التحكم التي تعطي الشكل الذي تبحث عنه. أحياناً يمكنك حسابها، وأحياناً سيكون عليك فقط إيجاد قيمة مناسبة بالتجربة والخطأ.
طريقة arc وسيلة لرسم خط ينحني على طول حافة دائرة. وهي تأخذ زوجاً من الإحداثيين لمركز القوس، ونصف قطر، ثم زاوية بداية وزاوية نهاية.
يتيح هذان المعامَلان الأخيران رسم جزء من الدائرة فقط. وتُقاس الزوايا بالراديان، لا بالدرجات. وهذا يعني أن الدائرة الكاملة لها زاوية مقدارها 2π، أو 2 * Math.PI، أي نحو 6.28. وتبدأ الزاوية العدّ من النقطة الواقعة إلى يمين مركز الدائرة وتتجه من هناك في اتجاه عقارب الساعة. يمكنك استخدام بداية 0 ونهاية أكبر من 2π (لنقل 7) لرسم دائرة كاملة.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
// المركز=(50, 50) نصف القطر=40 الزاوية من 0 إلى 7
cx.arc(50, 50, 40, 0, 7);
// المركز=(150, 50) نصف القطر=40 الزاوية من 0 إلى ½π
cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
cx.stroke();
</script>
تحتوي الصورة الناتجة على خط من يمين الدائرة الكاملة (النداء الأول لـ arc) إلى يمين ربع الدائرة (النداء الثاني).
وكغيرها من طرق رسم المسارات، يُوصل الخط المرسوم بـ arc بقطعة المسار السابقة. ويمكنك استدعاء moveTo أو بدء مسار جديد لتجنب ذلك.
رسم مخطط دائري
تخيّل أنك حصلت للتو على وظيفة في شركة EconomiCorp, Inc. ومهمتك الأولى هي رسم مخطط دائري لنتائج استبيان رضا العملاء لديها.
يحتوي ارتباط results على مصفوفة من الكائنات تمثل ردود الاستبيان.
const results = [
{name: "Satisfied", count: 1043, color: "lightblue"},
{name: "Neutral", count: 563, color: "lightgreen"},
{name: "Unsatisfied", count: 510, color: "pink"},
{name: "No comment", count: 175, color: "silver"}
];
لرسم مخطط دائري، نرسم عدداً من شرائح الدائرة، تتكون كل واحدة من قوس وزوج من الخطوط إلى مركز ذلك القوس. ويمكننا حساب الزاوية التي يشغلها كل قوس بقسمة دائرة كاملة (2π) على العدد الإجمالي للردود ثم ضرب ذلك الناتج (الزاوية لكل رد) في عدد الأشخاص الذين اختاروا خياراً معيناً.
<canvas width="200" height="200"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let total = results
.reduce((sum, {count}) => sum + count, 0);
// ابدأ من الأعلى
let currentAngle = -0.5 * Math.PI;
for (let result of results) {
let sliceAngle = (result.count / total) * 2 * Math.PI;
cx.beginPath();
// المركز=100,100، نصف القطر=100
// من الزاوية الحالية، باتجاه عقارب الساعة بمقدار زاوية الشريحة
cx.arc(100, 100, 100,
currentAngle, currentAngle + sliceAngle);
currentAngle += sliceAngle;
cx.lineTo(100, 100);
cx.fillStyle = result.color;
cx.fill();
}
</script>
لكن مخططاً لا يخبرنا ماذا تعني الشرائح ليس مفيداً كثيراً. نحتاج إلى وسيلة لرسم نص على الـ Canvas.
النص
يوفر سياق الرسم ثنائي الأبعاد للـ Canvas الطريقتين fillText وstrokeText. وقد تكون الأخيرة مفيدة لتحديد حدود الحروف، لكن fillText هي عادة ما تحتاجه. فهي تعبّئ حدود النص المعطى بـ fillStyle الحالية.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.font = "28px Georgia";
cx.fillStyle = "fuchsia";
cx.fillText("I can draw text, too!", 10, 50);
</script>
يمكنك تحديد حجم النص ونمطه وخطه بخاصية font. وهذا المثال يعطي فقط حجم خط واسم عائلة. ومن الممكن أيضاً إضافة italic أو bold إلى بداية النص لاختيار نمط.
يوفر المعامَلان الأخيران لـ fillText وstrokeText الموضع الذي يُرسم فيه الخط. وهما يشيران افتراضياً إلى موضع بداية خط الأساس الأبجدي للنص، وهو الخط الذي «تقف» عليه الحروف، دون احتساب الأجزاء المتدلية في حروف مثل j أو p. ويمكنك تغيير الموضع الأفقي بضبط خاصية textAlign على "end" أو "center"، والموضع الرأسي بضبط textBaseline على "top" أو "middle" أو "bottom".
سنعود إلى مخططنا الدائري، وإلى مشكلة وسم الشرائح، في التمارين في نهاية الفصل.
الصور
في رسومات الحاسوب، كثيراً ما يُميَّز بين الرسومات المتجهة (vector) والرسومات النقطية (bitmap). فالأولى هي ما كنا نفعله حتى الآن في هذا الفصل—تحديد صورة بإعطاء وصف منطقي للأشكال. أما الرسومات النقطية فلا تحدد أشكالاً فعلية، بل تعمل ببيانات البكسلات (شبكات نقطية من النقاط الملونة).
تتيح لنا طريقة drawImage رسم بيانات البكسلات على Canvas. ويمكن أن تأتي بيانات البكسلات هذه من عنصر <img> أو من Canvas آخر. ينشئ المثال التالي عنصر <img> منفصلاً ويحمّل ملف صورة فيه. لكن الطريقة لا تستطيع البدء في الرسم من هذه الصورة فوراً لأن المتصفح قد لا يكون قد حمّلها بعد. وللتعامل مع ذلك، نسجّل معالج حدث "load" ونجري الرسم بعد تحميل الصورة.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/hat.png";
img.addEventListener("load", () => {
for (let x = 10; x < 200; x += 30) {
cx.drawImage(img, x, 10);
}
});
</script>
افتراضياً، ترسم drawImage الصورة بحجمها الأصلي. ويمكنك أيضاً إعطاؤها معامَلين إضافيين لتحديد عرض الصورة المرسومة وارتفاعها، عندما لا يكونان مماثلين للصورة الأصلية.
عندما تُعطى drawImage تسعة معاملات، يمكن استخدامها لرسم جزء من صورة فقط. وتشير المعاملات من الثاني إلى الخامس إلى المستطيل (x وy والعرض والارتفاع) في الصورة المصدر الذي ينبغي نسخه، بينما تعطي المعاملات من السادس إلى التاسع المستطيل (على الـ Canvas) الذي ينبغي النسخ إليه.
يمكن استخدام هذا لحزم عدة sprites (عناصر صور) في ملف صورة واحد ثم رسم الجزء الذي تحتاجه فقط. على سبيل المثال، تحتوي هذه الصورة على شخصية لعبة في أوضاع متعددة:

وبتبديل الوضع الذي نرسمه، يمكننا عرض حركة تبدو كشخصية تمشي.
لتحريك صورة على Canvas، تكون طريقة clearRect مفيدة. فهي تشبه fillRect، لكنها بدلاً من تلوين المستطيل تجعله شفافاً، فتزيل البكسلات المرسومة سابقاً.
نعلم أن كل sprite، أي كل صورة فرعية، عرضها 24 بكسل وارتفاعها 30 بكسل. تحمّل الشيفرة التالية الصورة ثم تُنشئ فترة زمنية (مؤقت متكرر) لرسم الإطار التالي:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/player.png";
let spriteW = 24, spriteH = 30;
img.addEventListener("load", () => {
let cycle = 0;
setInterval(() => {
cx.clearRect(0, 0, spriteW, spriteH);
cx.drawImage(img,
// المستطيل المصدر
cycle * spriteW, 0, spriteW, spriteH,
// المستطيل الهدف
0, 0, spriteW, spriteH);
cycle = (cycle + 1) % 8;
}, 120);
});
</script>
يتتبع ارتباط cycle موضعنا في الحركة. وفي كل إطار، يُزاد ثم يُقصّ عائداً إلى المدى من 0 إلى 7 باستخدام معامل باقي القسمة. ثم يُستخدم هذا الارتباط لحساب الإحداثي x الذي يقع عنده الـ sprite الخاص بالوضع الحالي في الصورة.
التحويلات
ماذا لو أردنا أن تمشي شخصيتنا إلى اليسار بدلاً من اليمين؟ يمكننا بالطبع رسم مجموعة أخرى من الـ sprites. لكن يمكننا أيضاً أن نأمر الـ Canvas برسم الصورة بالاتجاه المعاكس.
استدعاء طريقة scale سيجعل كل ما يُرسم بعدها محجَّماً. وتأخذ هذه الطريقة معامَلين، أحدهما لضبط مقياس أفقي والآخر لضبط مقياس رأسي.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.scale(3, .5);
cx.beginPath();
cx.arc(50, 50, 40, 0, 7);
cx.lineWidth = 3;
cx.stroke();
</script>
سيؤدي التحجيم إلى تمدد كل ما يتعلق بالصورة المرسومة، بما في ذلك عرض الخط، أو انضغاطه كما هو محدد. والتحجيم بمقدار سالب سيقلب الصورة. ويحدث القلب حول النقطة (0, 0)، ما يعني أنه سيقلب أيضاً اتجاه نظام الإحداثيات. فعند تطبيق تحجيم أفقي بمقدار -1، سينتهي شكل مرسوم عند الموضع x 100 إلى ما كان سابقاً الموضع -100.
لقلب صورة، لا يمكننا ببساطة إضافة cx.scale(-1, 1) قبل نداء drawImage. فذلك سينقل صورتنا خارج الـ Canvas حيث لن تكون مرئية. ويمكننا تعديل الإحداثيين المعطيين لـ drawImage للتعويض عن ذلك برسم الصورة عند الموضع x -50 بدلاً من 0. وهناك حل آخر، لا يتطلب أن تعرف الشيفرة التي تجري الرسم شيئاً عن تغيير المقياس، وهو تعديل المحور الذي يحدث التحجيم حوله.
توجد عدة طرق أخرى غير scale تؤثر في نظام إحداثيات الـ Canvas. فيمكنك تدوير الأشكال المرسومة لاحقاً بطريقة rotate وتحريكها بطريقة translate. والأمر المثير—والمربك—هو أن هذه التحويلات تتراكم، أي أن كل واحد منها يحدث نسبة إلى التحويلات السابقة.
إذا حرّكنا بمقدار 10 بكسلات أفقية مرتين، فسيُرسم كل شيء على بعد 20 بكسل إلى اليمين. وإذا نقلنا أولاً مركز نظام الإحداثيات إلى (50, 50) ثم دوّرنا بمقدار 20 درجة (نحو 0.1π راديان)، فسيحدث ذلك الدوران حول النقطة (50, 50).
لكن إذا دوّرنا أولاً بمقدار 20 درجة ثم نقلنا بـ (50, 50)، فسيحدث النقل في نظام الإحداثيات المدوَّر، ومن ثم ينتج توجيهاً مختلفاً. فالترتيب الذي تُطبَّق به التحويلات مهم.
لقلب صورة حول الخط العمودي عند موضع x معين، يمكننا فعل ما يلي:
function flipHorizontally(context, around) {
context.translate(around, 0);
context.scale(-1, 1);
context.translate(-around, 0);
}
ننقل المحور y إلى حيث نريد أن يكون انعكاسنا، ونطبّق الانعكاس، ثم نعيد المحور y أخيراً إلى موضعه الصحيح في الكون المنعكس. وتوضح الصورة التالية لماذا يعمل هذا:
يوضح هذا نظامي الإحداثيات قبل الانعكاس عبر الخط المركزي وبعده. والمثلثات مرقمة لتوضيح كل خطوة. فإذا رسمنا مثلثاً عند موضع x موجب، فسيكون افتراضياً في الموضع الذي يوجد فيه المثلث 1. ونداء flipHorizontally يجري أولاً نقلاً إلى اليمين، ما ينقلنا إلى المثلث 2. ثم يحجّم، فيقلب المثلث إلى الموضع 3. وهذا ليس حيث ينبغي أن يكون لو كان منعكساً في الخط المعطى. ونداء translate الثاني يصلح هذا—فهو «يلغي» النقل الأولي ويجعل المثلث 4 يظهر بالضبط حيث ينبغي.
يمكننا الآن رسم شخصية منعكسة عند الموضع (100, 0) بقلب العالم حول المركز الرأسي للشخصية.
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/player.png";
let spriteW = 24, spriteH = 30;
img.addEventListener("load", () => {
flipHorizontally(cx, 100 + spriteW / 2);
cx.drawImage(img, 0, 0, spriteW, spriteH,
100, 0, spriteW, spriteH);
});
</script>
تخزين التحويلات ومحوها
تبقى التحويلات سارية. فكل ما نرسمه بعد رسم تلك الشخصية المنعكسة سيكون منعكساً أيضاً. وقد يكون ذلك مزعجاً.
من الممكن حفظ التحويل الحالي، وإجراء بعض الرسم والتحويل، ثم استعادة التحويل القديم. وهذا عادة هو التصرف السليم لدالة تحتاج إلى تحويل نظام الإحداثيات مؤقتاً. أولاً، نحفظ أي تحويل كانت الشيفرة التي استدعت الدالة تستخدمه. ثم تفعل الدالة ما تفعله، مضيفة تحويلات أخرى فوق التحويل الحالي. وأخيراً، نعود إلى التحويل الذي بدأنا به.
تدير الطريقتان save وrestore على سياق Canvas ثنائي الأبعاد هذه التحويلات. فهما تحفظان تصورياً مكدساً من حالات التحويل. فعندما تستدعي save، تُدفع الحالة الحالية إلى المكدس، وعندما تستدعي restore، تُسحب الحالة العليا من المكدس وتُستخدم كتحويل السياق الحالي. ويمكنك أيضاً استدعاء resetTransform لإعادة التحويل بالكامل.
توضح دالة branch في المثال التالي ما يمكنك فعله بدالة تغيّر التحويل ثم تستدعي دالة (هي نفسها في هذه الحالة)، تواصل الرسم بالتحويل المعطى.
ترسم هذه الدالة شكلاً شبيهاً بالشجرة برسم خط، ونقل مركز نظام الإحداثيات إلى نهاية الخط، واستدعاء نفسها مرتين—أولاً مدوَّرة إلى اليسار ثم مدوَّرة إلى اليمين. ويقلل كل استدعاء طول الفرع المرسوم، ويتوقف التعاود عندما يهبط الطول دون 8.
<canvas width="600" height="300"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
function branch(length, angle, scale) {
cx.fillRect(0, 0, 1, length);
if (length < 8) return;
cx.save();
cx.translate(0, length);
cx.rotate(-angle);
branch(length * scale, angle, scale);
cx.rotate(2 * angle);
branch(length * scale, angle, scale);
cx.restore();
}
cx.translate(300, 0);
branch(60, 0.5, 0.8);
</script>
لو لم يكن نداءا save وrestore موجودين، لانتهى النداء التعاودي الثاني لـ branch بالموضع والدوران اللذين أنشأهما النداء الأول. ولكان متصلاً لا بالفرع الحالي بل بالفرع الأعمق والأقصى يميناً الذي رسمه النداء الأول. وقد يكون الشكل الناتج مثيراً أيضاً، لكنه بالتأكيد ليس شجرة.
العودة إلى اللعبة
نعرف الآن ما يكفي عن الرسم على Canvas للبدء في العمل على نظام عرض قائم على Canvas للعبة من الفصل السابق. ولن يعرض نظام العرض الجديد صناديق ملونة فقط. بل سنستخدم drawImage لرسم صور تمثل عناصر اللعبة.
نعرّف نوع كائن عرض آخر يسمى CanvasDisplay، يدعم الواجهة نفسها التي يدعمها DOMDisplay من الفصل 16—أي الطريقتين syncState وclear.
يحتفظ هذا الكائن بمعلومات أكثر قليلاً من DOMDisplay. فبدلاً من استخدام موضع التمرير في عنصر DOM الخاص به، يتتبع منفذ عرضه (viewport) الخاص، الذي يخبرنا أي جزء من المستوى ننظر إليه حالياً. وأخيراً، يحتفظ بخاصية flipPlayer بحيث تظل الشخصية، حتى وهي واقفة ساكنة، متجهة نحو الاتجاه الذي تحركت فيه آخر مرة.
class CanvasDisplay {
constructor(parent, level) {
this.canvas = document.createElement("canvas");
this.canvas.width = Math.min(600, level.width * scale);
this.canvas.height = Math.min(450, level.height * scale);
parent.appendChild(this.canvas);
this.cx = this.canvas.getContext("2d");
this.flipPlayer = false;
this.viewport = {
left: 0,
top: 0,
width: this.canvas.width / scale,
height: this.canvas.height / scale
};
}
clear() {
this.canvas.remove();
}
}
تحسب طريقة syncState أولاً منفذ عرض جديداً ثم ترسم مشهد اللعبة في الموضع المناسب.
CanvasDisplay.prototype.syncState = function(state) {
this.updateViewport(state);
this.clearDisplay(state.status);
this.drawBackground(state.level);
this.drawActors(state.actors);
};
على عكس DOMDisplay، يجب على نمط العرض هذا إعادة رسم الخلفية في كل تحديث. ولأن الأشكال على Canvas مجرد بكسلات، فلا توجد بعد رسمها طريقة جيدة لتحريكها (أو إزالتها). والطريقة الوحيدة لتحديث عرض الـ Canvas هي محوه وإعادة رسم المشهد. وقد نكون مررنا أيضاً، ما يتطلب أن تكون الخلفية في موضع مختلف.
تشبه طريقة updateViewport طريقة scrollPlayerIntoView في DOMDisplay. فهي تتحقق مما إذا كانت الشخصية قريبة جداً من حافة الشاشة وتحرك منفذ العرض عندما يكون الأمر كذلك.
CanvasDisplay.prototype.updateViewport = function(state) {
let view = this.viewport, margin = view.width / 3;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5));
if (center.x < view.left + margin) {
view.left = Math.max(center.x - margin, 0);
} else if (center.x > view.left + view.width - margin) {
view.left = Math.min(center.x + margin - view.width,
state.level.width - view.width);
}
if (center.y < view.top + margin) {
view.top = Math.max(center.y - margin, 0);
} else if (center.y > view.top + view.height - margin) {
view.top = Math.min(center.y + margin - view.height,
state.level.height - view.height);
}
};
تضمن نداءات Math.max وMath.min ألا ينتهي منفذ العرض بعرض مساحة خارج المستوى. فـ Math.max(x, 0) يضمن ألا يكون العدد الناتج أقل من صفر. وبالمثل يضمن Math.min أن تظل القيمة دون حد معين.
عند محو العرض، سنستخدم لوناً مختلفاً قليلاً حسب ما إذا كانت اللعبة قد كُسبت (أفتح) أو خُسرت (أغمق).
CanvasDisplay.prototype.clearDisplay = function(status) {
if (status == "won") {
this.cx.fillStyle = "rgb(68, 191, 255)";
} else if (status == "lost") {
this.cx.fillStyle = "rgb(44, 136, 214)";
} else {
this.cx.fillStyle = "rgb(52, 166, 251)";
}
this.cx.fillRect(0, 0,
this.canvas.width, this.canvas.height);
};
لرسم الخلفية، نمر على البلاطات المرئية في منفذ العرض الحالي، باستخدام الحيلة نفسها المستخدمة في طريقة touches من الفصل السابق.
let otherSprites = document.createElement("img");
otherSprites.src = "img/sprites.png";
CanvasDisplay.prototype.drawBackground = function(level) {
let {left, top, width, height} = this.viewport;
let xStart = Math.floor(left);
let xEnd = Math.ceil(left + width);
let yStart = Math.floor(top);
let yEnd = Math.ceil(top + height);
for (let y = yStart; y < yEnd; y++) {
for (let x = xStart; x < xEnd; x++) {
let tile = level.rows[y][x];
if (tile == "empty") continue;
let screenX = (x - left) * scale;
let screenY = (y - top) * scale;
let tileX = tile == "lava" ? scale : 0;
this.cx.drawImage(otherSprites,
tileX, 0, scale, scale,
screenX, screenY, scale, scale);
}
}
};
تُرسم البلاطات غير الفارغة بـ drawImage. وتحتوي صورة otherSprites على الصور المستخدمة لعناصر غير الشخصية. وهي تحتوي، من اليسار إلى اليمين، على بلاطة الجدار، وبلاطة الحمم، وsprite العملة.
![]()
عرض بلاطات الخلفية 20 بكسل وارتفاعها 20 بكسل، لأننا سنستخدم المقياس نفسه المستخدم في DOMDisplay. وهكذا تكون الإزاحة لبلاطات الحمم 20 (قيمة ارتباط scale)، والإزاحة للجدران 0.
لا نتكبد عناء انتظار تحميل صورة الـ sprite. فاستدعاء drawImage بصورة لم تُحمَّل بعد لن يفعل شيئاً ببساطة. وهكذا قد نفشل في رسم اللعبة بشكل صحيح في الإطارات الأولى بينما لا تزال الصورة تُحمَّل، لكن هذه ليست مشكلة خطيرة. ولأننا نستمر في تحديث الشاشة، سيظهر المشهد الصحيح بمجرد انتهاء التحميل.
ستُستخدم الشخصية الماشية المعروضة سابقاً لتمثيل اللاعب. وتحتاج الشيفرة التي ترسمها إلى اختيار الـ sprite والاتجاه الصحيحين بناءً على حركة اللاعب الحالية. تحتوي الـ sprites الثمانية الأولى على حركة مشي. وعندما يتحرك اللاعب على أرضية، نتنقل بينها بناءً على الوقت الحالي. ونريد تبديل الإطارات كل 60 مللي ثانية، لذا يُقسم الوقت على 60 أولاً. وعندما يقف اللاعب ساكناً، نرسم الـ sprite التاسع. وأثناء القفزات، التي تُعرف بكون السرعة الرأسية ليست صفراً، نستخدم الـ sprite العاشر، الأقصى يميناً.
ولأن الـ sprites أعرض قليلاً من كائن اللاعب—24 بدلاً من 16 بكسل لإفساح بعض المساحة للقدمين والذراعين—على الطريقة أن تعدّل الإحداثي x والعرض بمقدار معين (playerXOverlap).
let playerSprites = document.createElement("img");
playerSprites.src = "img/player.png";
const playerXOverlap = 4;
CanvasDisplay.prototype.drawPlayer = function(player, x, y,
width, height){
width += playerXOverlap * 2;
x -= playerXOverlap;
if (player.speed.x != 0) {
this.flipPlayer = player.speed.x < 0;
}
let tile = 8;
if (player.speed.y != 0) {
tile = 9;
} else if (player.speed.x != 0) {
tile = Math.floor(Date.now() / 60) % 8;
}
this.cx.save();
if (this.flipPlayer) {
flipHorizontally(this.cx, x + width / 2);
}
let tileX = tile * width;
this.cx.drawImage(playerSprites, tileX, 0, width, height,
x, y, width, height);
this.cx.restore();
};
تُستدعى طريقة drawPlayer من drawActors، المسؤولة عن رسم جميع الفاعلين في اللعبة.
CanvasDisplay.prototype.drawActors = function(actors) {
for (let actor of actors) {
let width = actor.size.x * scale;
let height = actor.size.y * scale;
let x = (actor.pos.x - this.viewport.left) * scale;
let y = (actor.pos.y - this.viewport.top) * scale;
if (actor.type == "player") {
this.drawPlayer(actor, x, y, width, height);
} else {
let tileX = (actor.type == "coin" ? 2 : 1) * scale;
this.cx.drawImage(otherSprites,
tileX, 0, width, height,
x, y, width, height);
}
}
};
عند رسم شيء ليس اللاعب، ننظر إلى نوعه لإيجاد إزاحة الـ sprite الصحيح. فبلاطة الحمم موجودة عند الإزاحة 20، وsprite العملة موجود عند 40 (اثنان مضروباً في scale).
علينا طرح موضع منفذ العرض عند حساب موضع الفاعل، لأن (0, 0) على الـ Canvas عندنا يقابل أعلى يسار منفذ العرض، لا أعلى يسار المستوى. وكان يمكننا أيضاً استخدام translate لهذا. وكلتا الطريقتين تعمل.
يدمج هذا المستند نظام العرض الجديد في runGame:
<body>
<script>
runGame(GAME_LEVELS, CanvasDisplay);
</script>
</body>
اختيار واجهة الرسومات
عندما تحتاج إلى توليد رسومات في المتصفح، يمكنك الاختيار بين HTML العادي وSVG وCanvas. ولا يوجد نهج واحد أفضل يعمل في جميع الحالات. ولكل خيار نقاط قوة ونقاط ضعف.
يتمتع HTML العادي بميزة البساطة. وهو أيضاً يتكامل جيداً مع النص. ويتيح كل من SVG وCanvas رسم النص، لكنهما لن يساعداك في تحديد موضع ذلك النص أو لفّه عندما يشغل أكثر من سطر. أما في صورة قائمة على HTML، فإدراج كتل من النص أسهل بكثير.
يمكن استخدام SVG لإنتاج رسومات واضحة تبدو جيدة عند أي مستوى تكبير. وهو، بخلاف HTML، مصمم للرسم ومن ثمّ أنسب لهذا الغرض.
يبني كل من SVG وHTML بنية بيانات (DOM) تمثل صورتك. وهذا يجعل من الممكن تعديل العناصر بعد رسمها. وإذا احتجت إلى تغيير جزء صغير من صورة كبيرة مراراً استجابةً لما يفعله المستخدم أو كجزء من حركة، فقد يكون فعل ذلك على Canvas مكلفاً بلا داعٍ. كما يتيح لنا DOM تسجيل معالجات أحداث الفأرة على كل عنصر في الصورة (حتى على الأشكال المرسومة بـ SVG). ولا يمكنك فعل ذلك مع Canvas.
لكن نهج Canvas الموجّه نحو البكسلات قد يكون ميزة عند رسم عدد ضخم من العناصر الصغيرة. وكونه لا يبني بنية بيانات بل يرسم مراراً على سطح البكسلات نفسه فقط يمنح Canvas تكلفة أقل لكل شكل. وهناك أيضاً تأثيرات لا تكون عملية إلا مع عنصر Canvas، مثل عرض مشهد بكسل واحد في كل مرة (باستخدام متتبع أشعة مثلاً) أو معالجة صورة لاحقاً بـ JavaScript (تمويهها أو تشويهها).
في بعض الحالات، قد ترغب في الجمع بين عدة من هذه التقنيات. على سبيل المثال، قد ترسم مخططاً بـ SVG أو Canvas لكن تعرض معلومات نصية بوضع عنصر HTML فوق الصورة.
بالنسبة للتطبيقات غير المتطلبة، لا يهم كثيراً فعلاً أي واجهة تختار. فالعرض الذي بنيناه للعبتنا في هذا الفصل كان يمكن تنفيذه بأي من تقنيات الرسومات الثلاث هذه، لأنه لا يحتاج إلى رسم نص، ولا التعامل مع تفاعل الفأرة، ولا العمل مع عدد ضخم بشكل استثنائي من العناصر.
الملخص
ناقشنا في هذا الفصل تقنيات رسم الرسومات في المتصفح، مع التركيز على عنصر <canvas>.
تمثل عقدة Canvas مساحة في مستند يجوز لبرنامجنا الرسم عليها. ويتم هذا الرسم عبر كائن سياق رسم، يُنشأ بطريقة getContext.
تتيح لنا واجهة الرسم ثنائية الأبعاد تعبئة أشكال مختلفة وتحديدها بخط. وتحدد خاصية fillStyle في السياق طريقة تعبئة الأشكال. وتتحكم خاصيتا strokeStyle وlineWidth في طريقة رسم الخطوط.
يمكن رسم المستطيلات وقطع النص بنداء طريقة واحد. فترسم الطريقتان fillRect وstrokeRect المستطيلات، وترسم الطريقتان fillText وstrokeText النص. ولإنشاء أشكال مخصصة، علينا أولاً بناء مسار.
يبدأ استدعاء beginPath مساراً جديداً. وتضيف عدة طرق أخرى خطوطاً ومنحنيات إلى المسار الحالي. فيمكن لـ lineTo مثلاً إضافة خط مستقيم. وعند انتهاء المسار، يمكن تعبئته بطريقة fill أو رسمه بخط بطريقة stroke.
تُنقل البكسلات من صورة أو من Canvas آخر إلى الـ Canvas الخاص بنا بطريقة drawImage. وافتراضياً ترسم هذه الطريقة الصورة المصدر كاملة، لكن بإعطائها معاملات أكثر يمكنك نسخ مساحة محددة من الصورة. استخدمنا هذا في لعبتنا بنسخ أوضاع فردية لشخصية اللعبة من صورة تحتوي على العديد من هذه الأوضاع.
تتيح لك التحويلات رسم شكل بتوجيهات متعددة. ولسياق الرسم ثنائي الأبعاد تحويل حالي يمكن تغييره بالطرق translate وscale وrotate. وستؤثر هذه في جميع عمليات الرسم اللاحقة. ويمكن حفظ حالة تحويل بطريقة save واستعادتها بطريقة restore.
عند عرض حركة على Canvas، يمكن استخدام طريقة clearRect لمحو جزء من الـ Canvas قبل إعادة رسمه.
التمارين
الأشكال
اكتب برنامجاً يرسم الأشكال التالية على Canvas:
- شبه منحرف (مستطيل أعرض من جهة واحدة)
- معيّن أحمر (مستطيل مدوَّر 45 درجة أو ¼π راديان)
- خط متعرج
- حلزون مكوَّن من 100 قطعة خط مستقيمة
- نجمة صفراء

عند رسم الشكلين الأخيرين، قد ترغب في الرجوع إلى شرح Math.cos وMath.sin في الفصل 14، الذي يصف كيفية الحصول على إحداثيات على دائرة باستخدام هاتين الدالتين.
أوصي بإنشاء دالة لكل شكل. مرّر الموضع، واختيارياً خصائص أخرى مثل الحجم أو عدد النقاط، كمعاملات. أما البديل، وهو ترميز الأعداد مباشرة في كل مكان في شيفرتك، فيميل إلى جعل الشيفرة صعبة القراءة والتعديل بلا داعٍ.
<canvas width="600" height="200"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
// شيفرتك هنا.
</script>
إظهار التلميح
شبه المنحرف (1) أسهل ما يُرسم باستخدام مسار. اختر إحداثيات مركز مناسبة وأضف كل زاوية من الزوايا الأربع حول المركز.
يمكن رسم المعيّن (2) بالطريقة المباشرة، بمسار، أو بالطريقة المثيرة، بتحويل rotate. لاستخدام الدوران، سيكون عليك تطبيق حيلة مشابهة لما فعلناه في دالة flipHorizontally. ولأنك تريد الدوران حول مركز مستطيلك لا حول النقطة (0, 0)، عليك أولاً أن تنقل (translate) إلى هناك، ثم تدوّر، ثم تنقل عائداً.
احرص على إعادة ضبط التحويل بعد رسم أي شكل يُنشئ تحويلاً.
بالنسبة للخط المتعرج (3)، يصبح من غير العملي كتابة نداء جديد لـ lineTo لكل قطعة خط. وبدلاً من ذلك، ينبغي أن تستخدم حلقة. يمكنك أن تجعل كل تكرار يرسم قطعتَي خط (يميناً ثم يساراً مرة أخرى) أو قطعة واحدة، وفي هذه الحالة عليك استخدام زوجية فهرس الحلقة (% 2) لتحديد ما إذا كنت ستذهب يساراً أم يميناً.
ستحتاج أيضاً إلى حلقة للحلزون (4). فإذا رسمت سلسلة من النقاط، مع تحرك كل نقطة أبعد على دائرة حول مركز الحلزون، حصلت على دائرة. وإذا غيّرت أثناء الحلقة نصف قطر الدائرة التي تضع عليها النقطة الحالية ودرت أكثر من دورة واحدة، فالنتيجة حلزون.
النجمة (5) المرسومة مبنية من خطوط quadraticCurveTo. ويمكنك أيضاً رسم واحدة بخطوط مستقيمة. اقسم دائرة إلى ثمانية أجزاء لنجمة بثماني نقاط، أو أي عدد من الأجزاء تريده. ارسم خطوطاً بين هذه النقاط، واجعلها تنحني نحو مركز النجمة. وبـ quadraticCurveTo، يمكنك استخدام المركز كنقطة تحكم.
المخطط الدائري
سابقاً في الفصل، رأينا برنامجاً مثالياً يرسم مخططاً دائرياً. عدّل هذا البرنامج بحيث يظهر اسم كل فئة بجانب الشريحة التي تمثلها. حاول إيجاد طريقة جميلة المظهر لتحديد موضع هذا النص تلقائياً وتصلح لمجموعات بيانات أخرى أيضاً. يمكنك افتراض أن الفئات كبيرة بما يكفي لترك مساحة كافية لتسمياتها.
قد تحتاج إلى Math.sin وMath.cos مرة أخرى، وهما مشروحان في الفصل 14.
<canvas width="600" height="300"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let total = results
.reduce((sum, {count}) => sum + count, 0);
let currentAngle = -0.5 * Math.PI;
let centerX = 300, centerY = 150;
// أضف شيفرة لرسم تسميات الشرائح في هذه الحلقة.
for (let result of results) {
let sliceAngle = (result.count / total) * 2 * Math.PI;
cx.beginPath();
cx.arc(centerX, centerY, 100,
currentAngle, currentAngle + sliceAngle);
currentAngle += sliceAngle;
cx.lineTo(centerX, centerY);
cx.fillStyle = result.color;
cx.fill();
}
</script>
إظهار التلميح
ستحتاج إلى استدعاء fillText وضبط خاصيتي textAlign وtextBaseline في السياق بطريقة ينتهي بها النص حيث تريده.
من الطرق المعقولة لتحديد موضع التسميات وضع النص على الخط المار من مركز المخطط عبر منتصف الشريحة. ولا تريد وضع النص ملاصقاً لجانب المخطط مباشرة، بل تحريك النص إلى خارج المخطط بعدد معين من البكسلات.
زاوية هذا الخط هي currentAngle + 0.5 * sliceAngle. وتعثر الشيفرة التالية على موضع على هذا الخط يبعد 120 بكسل عن المركز:
let middleAngle = currentAngle + 0.5 * sliceAngle;
let textX = Math.cos(middleAngle) * 120 + centerX;
let textY = Math.sin(middleAngle) * 120 + centerY;
بالنسبة إلى textBaseline، القيمة "middle" مناسبة على الأرجح عند استخدام هذا النهج. أما ما تستخدمه لـ textAlign فيعتمد على أي جانب من الدائرة نحن فيه. فعلى اليسار، ينبغي أن تكون "right"، وعلى اليمين، ينبغي أن تكون "left"، حتى يوضع النص بعيداً عن المخطط.
إن لم تكن متأكداً من كيفية معرفة أي جانب من الدائرة تقع عليه زاوية معينة، فانظر إلى شرح Math.cos في الفصل 14. فجيب تمام الزاوية يخبرنا أي إحداثي x يقابلها، وهذا بدوره يخبرنا بالضبط أي جانب من الدائرة نحن فيه.
كرة ترتد
استخدم تقنية requestAnimationFrame التي رأيناها في الفصل 14 والفصل 16 لرسم صندوق فيه كرة ترتد. وتتحرك الكرة بسرعة ثابتة وترتد عن جوانب الصندوق عندما تصطدم بها.
<canvas width="400" height="400"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let lastTime = null;
function frame(time) {
if (lastTime != null) {
updateAnimation(Math.min(100, time - lastTime) / 1000);
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
function updateAnimation(step) {
// شيفرتك هنا.
}
</script>
إظهار التلميح
الصندوق سهل الرسم بـ strokeRect. عرّف ارتباطاً يحمل حجمه، أو عرّف ارتباطين إن اختلف عرض صندوقك وارتفاعه. لإنشاء كرة مستديرة، ابدأ مساراً واستدعِ arc(x, y, radius, 0, 7)، ما يُنشئ قوساً يمتد من صفر إلى أكثر من دائرة كاملة. ثم عبّئ المسار.
لنمذجة موضع الكرة وسرعتها، يمكنك استخدام صنف Vec من الفصل 16 (وهو متاح في هذه الصفحة). أعطه سرعة ابتدائية، ويُفضّل ألا تكون رأسية أو أفقية بحتة، وفي كل إطار اضرب تلك السرعة في مقدار الوقت المنقضي. وعندما تقترب الكرة كثيراً من جدار رأسي، اعكس المركّبة x في سرعتها. وبالمثل، اعكس المركّبة y عندما تصطدم بجدار أفقي.
بعد إيجاد موضع الكرة الجديد وسرعتها، استخدم clearRect لحذف المشهد وإعادة رسمه بالموضع الجديد.
انعكاس محسوب مسبقاً
من الأمور المؤسفة في التحويلات أنها تبطئ رسم الصور النقطية. فيجب تحويل موضع كل بكسل وحجمه، ورغم أنه ممكن أن تصبح المتصفحات أذكى في التعامل مع التحويل مستقبلاً، فإنها تسبب حالياً زيادة قابلة للقياس في الوقت الذي يستغرقه رسم صورة نقطية.
في لعبة مثل لعبتنا، حيث نرسم sprite واحداً محوَّلاً فقط، لا تمثل هذه مشكلة. لكن تخيّل أننا نحتاج إلى رسم مئات الشخصيات أو آلاف الجسيمات الدوارة من انفجار.
فكّر في طريقة لرسم شخصية مقلوبة دون تحميل ملفات صور إضافية ودون الاضطرار إلى إجراء نداءات drawImage محوَّلة في كل إطار.
إظهار التلميح
مفتاح الحل هو حقيقة أننا نستطيع استخدام عنصر Canvas كصورة مصدر عند استخدام drawImage. فمن الممكن إنشاء عنصر <canvas> إضافي، دون إضافته إلى المستند، ورسم spritesنا المقلوبة عليه، مرة واحدة. وعند رسم إطار فعلي، ننسخ ببساطة الـ sprites المقلوبة مسبقاً إلى الـ Canvas الرئيسي.
سيلزم بعض الحرص لأن الصور لا تُحمَّل فوراً. فنحن نجري الرسم المقلوب مرة واحدة فقط، وإذا أجريناه قبل تحميل الصورة فلن يرسم شيئاً. ويمكن استخدام معالج "load" على الصورة لرسم الصور المقلوبة على الـ Canvas الإضافي. وهذا الـ Canvas يمكن استخدامه كمصدر رسم فوراً (سيكون فارغاً ببساطة حتى نرسم الشخصية عليه).