كيفية بناء لعبة بطاقات الذاكرة باستخدام Vue.js: دليل شامل للمبتدئين
مقدمة إلى بناء لعبة بطاقات الذاكرة باستخدام Vue.js
إذا كنت حديث العهد بإطار عمل Vue.js وتتطلع إلى ترسيخ مفاهيمك الأساسية بطريقة ممتعة وتفاعلية، فإن هذا المشروع العملي سيساعدك على بناء لعبة شيقة. في هذا المقال، سنأخذك في رحلة خطوة بخطوة لإنشاء لعبة بطاقات الذاكرة (Memory Card Game) باستخدام Vue.js. ستكتسب من خلال هذا الدليل خبرة عملية في العديد من المفاهيم الأساسية والمتقدمة في Vue.js وتطوير الواجهة الأمامية.
ماذا ستتعلم من هذا المقال؟
- كيفية استخدام التوجيه
v-forللتكرار عبر مصفوفة من الكائنات (Array of Objects). - الربط الديناميكي للفئات (
Dynamic class binding) والأنماط (style binding) باستخدام التوجيهv-bind. - كيفية إضافة الدوال (
Methods) والخصائص المحسوبة (Computed Properties). - كيفية إضافة خصائص تفاعلية (
reactive properties) إلى كائن باستخدام الدالةVue.set. - كيفية استخدام دالة
setTimeoutلتأخير تنفيذ شيفرةJavaScript. - الفرق بين النسخ السطحي (
Shallow cloning) والنسخ العميق (Deep Cloning) لكائناتJavaScript. - كيفية الاستفادة من مكتبة الأدوات المساعدة
Lodash.
لنبدأ رحلتنا في بناء هذه اللعبة الممتعة!
التحضير للعمل: تضمين المكتبات الأساسية
الخطوة الأولى بسيطة للغاية، وتتمثل في استيراد المكتبات الضرورية من شبكة توصيل المحتوى (CDN) إلى هيكل HTML5 الأساسي الخاص بنا. هذا سيمكننا من البدء في مشروعنا الصغير.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memory Card Game</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<!-- إصدار التطوير، يتضمن تحذيرات مفيدة في وحدة التحكم -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
</body>
</html>
عرض شبكة البطاقات للمستخدم
الآن، دعنا نحدد هيكل HTML اللازم، وتصميم CSS الأساسي، ومثيل Vue بسيط حتى يتمكن المستخدم من رؤية شبكة البطاقات.
إنشاء مثيل Vue (Vue Instance)
سنقوم بإنشاء مثيل Vue جديد وتحديد خاصية بيانات واحدة تسمى cards، والتي ستحتوي على قائمة البطاقات.
let app = new Vue({
el: '#app',
data: {
cards: [
{ name: 'Apple', img: 'apple.gif' },
{ name: 'Banana', img: 'banana.gif' },
{ name: 'Orange', img: 'orange.jpg' },
{ name: 'Pineapple', img: 'pineapple.png' },
{ name: 'Strawberry', img: 'strawberry.png' },
{ name: 'watermelon', img: 'watermelon.jpg' },
],
},
});
يحتوي كل كائن في المصفوفة على خاصيتين: name (اسم الصورة، والذي سيستخدم للمطابقة) و img (مسار الصورة الفعلية للبطاقة).
هيكل HTML (HTML MarkUp)
بما أن لدينا الآن البيانات جاهزة في مثيل Vue الخاص بنا، يمكننا استخدام التوجيه v-for في Vue.js للتكرار عبرها وعرض البطاقات.
<div id="app">
<div class="row">
<div class="col-md-6 col-lg-6 col-xl-5 mx-auto">
<div class="row justify-content-md-center">
<div v-for="card in cards" class="col-auto mb-3 flip-container">
<div class="memorycard">
<div class="front border rounded shadow">
<img width="100" height="150" src="/assets/images/memorycard/pattern3.jpeg">
</div>
<div class="back rounded border">
<img width="100" height="150" :src="'/assets/images/memorycard/'+card.img">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
لقد استخدمنا بعض ترميزات Bootstrap الأساسية وتوجيه v-for الخاص بـ Vue.js للتكرار عبر البطاقات وعرضها بتنسيق شبكي. تتكون كل بطاقة ذاكرة من جزأين:
front: يحتوي على صورة نمط مشتركة لجميع البطاقات (العرض الافتراضي للبطاقة).back: يحتوي على صورة البطاقة الفعلية (يجب أن تكون مخفية افتراضيًا).
دعنا نضيف بعض أنماط CSS الأساسية بحيث نعرض فقط الجزء الأمامي من البطاقة (نمط التصميم المشترك):
.flip-container {
-webkit-perspective: 1000;
-moz-perspective: 1000;
-o-perspective: 1000;
perspective: 1000;
min-height: 120px;
cursor: pointer;
}
.front, .back {
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-o-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transition: 0.6s;
-webkit-transform-style: preserve-3d;
-moz-transition: 0.6s;
-moz-transform-style: preserve-3d;
-o-transition: 0.6s;
-o-transform-style: preserve-3d;
-ms-transition: 0.6s;
-ms-transform-style: preserve-3d;
transition: 0.6s;
transform-style: preserve-3d;
top: 0;
left: 0;
width: 100%;
}
.back {
-webkit-transform: rotateY(-180deg);
-moz-transform: rotateY(-180deg);
-o-transform: rotateY(-180deg);
-ms-transform: rotateY(-180deg);
transform: rotateY(-180deg);
position: absolute;
}
بعد تحديث الصفحة، يجب أن ترى ست بطاقات مكدسة بتنسيق شبكي تواجه الواجهة الأمامية. صورة البطاقة الفعلية مخفية في الخلف.

قلب البطاقات: إضافة التفاعل
الخطوة التالية هي ربط حدث (event) ببطاقاتنا بحيث عند النقر عليها، يجب أن تنقلب وتظهر الصورة الموجودة خلفها. دعنا نعدل مصفوفة البطاقات الأصلية لإضافة خاصية أخرى لكل بطاقة، والتي ستحدد ما إذا كانت البطاقة مقلوبة حاليًا أم لا.
أضف CSS التالي. عندما يتم إضافة الفئة flipped إلى عنصر البطاقة، ستظهر صورة البطاقة مع تأثير قلب لطيف.
.flip-container .flipped .back {
-webkit-transform: rotateY(0deg);
-moz-transform: rotateY(0deg);
-o-transform: rotateY(0deg);
-ms-transform: rotateY(0deg);
transform: rotateY(0deg);
}
.flip-container .flipped .front {
-webkit-transform: rotateY(180deg);
-moz-transform: rotateY(180deg);
-o-transform: rotateY(180deg);
-ms-transform: rotateY(180deg);
transform: rotateY(180deg);
}
سنستخدم حدث دورة حياة Vue المسمى created لإضافة الخاصية الجديدة، وسنضيف دالة flipCard لقلب البطاقة.
created(){
this.cards.forEach((card) => {
card.isFlipped = false;
});
},
methods:{
flipCard(card){
card.isFlipped = true;
}
}
أولاً، سنربط حدث النقر (click event) بالبطاقات لاستدعاء الدالة flipCard. ثم سنستخدم أيضًا التوجيه v-bind لربط الفئة flipped بالبطاقة بشكل ديناميكي.
...
<div v-for="card in cards" class="col-auto mb-3 flip-container" :class="{ 'flipped': card.isFlipped }" @click="flipCard(card)">
...
يبدو الأمر صحيحًا نظريًا، ولكن دعنا نرى ما إذا كانت البطاقات تنقلب عند النقر.

لم ينجح الأمر. لماذا؟ دعنا نعود إلى دالة دورة الحياة created، حيث قمنا بالتكرار عبر قائمة البطاقات وأضفنا خاصية جديدة تسمى isFlipped. تبدو صحيحة، لكن Vue لم يتعامل معها كتفاعلية. لكي تكون خصائص الكائن الجديدة تفاعلية (reactive)، يجب عليك إضافتها إلى الكائن باستخدام دالة Vue.set.
created(){
this.cards.forEach((card) => {
Vue.set(card, 'isFlipped', false)
});
},
الآن يجب أن تنقلب البطاقات عند النقر:

أحسنت! دعنا ننتقل إلى الخطوة التالية.
مضاعفة البطاقات وخلطها
نعم، هذا صحيح! لجعل هذه البطاقات لعبة ذاكرة، نحتاج إلى زوج واحد بالضبط من كل بطاقة. نحتاج أيضًا إلى خلط ترتيب البطاقات في كل مرة يتم فيها تحميل اللعبة. دعنا نحدد خاصية جديدة في مثيل Vue الخاص بنا تسمى memoryCards. هنا سنخزن البطاقات التي سيتم لعبها (أي ضعف عدد البطاقات الفعلية وخلطها).
...
memoryCards: [],
...
مضاعفة البطاقات (Doubling)
لإنشاء نسختين من جميع البطاقات، دعنا ندمج مصفوفة cards لإنشائها وتعيينها لخاصية memoryCards. غير توجيه v-for في ترميز HTML للتكرار عبر الخاصية memoryCards بدلاً من cards:
<div v-for="card in memoryCards" class="col-auto mb-3 flip-container" :class="{ 'flipped': card.isFlipped }" @click="flipCard(card)">
بعد ذلك، عدّل دالة دورة الحياة created لتعيين المصفوفة المدمجة إلى memoryCards:
created(){
this.cards.forEach((card) => {
Vue.set(card, 'isFlipped', false)
});
var cards1 = this.cards;
var cards2 = this.cards;
this.memoryCards = this.memoryCards.concat(cards1, cards2);
},
يبدو بسيطًا، أليس كذلك؟ لكن هذا لن يعمل بشكل صحيح. هناك مشكلتان في هذه الشيفرة:
- التعيين المباشر لـ
this.cardsإلىcards1لن ينشئ نسخة أخرى من كائن البطاقات. لا يزالcards1يشير إلى الكائن الأصلي. - بما أن
cards1وcards2لا يزالان يشيران إلى نفس الكائن، فهذا يعني أننا قمنا بدمج مصفوفتين تشيران إلى نفس مصفوفة الكائنات. سيؤدي تغيير أي خاصية للكائن في كائنmemoryCardsإلى تغيير المصفوفة الأصلية وكذلك زوجها الخاص في المصفوفة.

حسنًا، هذه مشكلة. إذا بحثت عن حلول لنسخ مصفوفة أو كائن بشكل صحيح بحيث لا يشير إلى المصفوفة الأصلية، فقد تصادف حلولًا تقوم بنسخ سطحي (shallow-copy) للمصفوفة. ما هو النسخ السطحي؟
النسخ السطحي (Shallow Copy)
يشير النسخ السطحي إلى حقيقة أنه يتم نسخ مستوى واحد فقط. سيعمل هذا بشكل جيد لمصفوفة أو كائن يحتوي على قيم أولية (primitive values) فقط. إحدى طرق القيام بالنسخ السطحي هي عبر عامل الانتشار (spread operator)، والذي سيكون في حالتنا شيئًا مثل الشيفرة أدناه:
...
var cards1 = [...this.cards];
var cards2 = [...this.cards];
this.memoryCards = this.memoryCards.concat(cards1, cards2);
...
لكن هذا ليس الحل المناسب لنا، لأنه في حالتنا لدينا مصفوفة من الكائنات وليس من أي قيم أولية. وبالتالي، يمكن حل مشكلتنا إذا قمنا بنسخ عميق (deep copy) لمصوفتنا.
النسخ العميق (Deep Copy)
بالنسبة للكائنات والمصفوفات التي تحتوي على كائنات أو مصفوفات أخرى، يتطلب نسخ هذه الكائنات نسخًا عميقًا. وإلا، فإن التغييرات التي يتم إجراؤها على المراجع المتداخلة ستغير البيانات المتداخلة في الكائن أو المصفوفة الأصلية.
هناك طرق متعددة للقيام بنسخ عميق، لكننا سنختار الطريقة الأبسط والأكثر شيوعًا باستخدام مكتبة Lodash. الآن، ما هي مكتبة Lodash؟
تجعل Lodash لغة JavaScript أسهل عن طريق إزالة متاعب العمل مع المصفوفات والأرقام والكائنات والسلاسل وما إلى ذلك. في حالتنا، تحتوي Lodash على دالة لأداء النسخ العميق (deepCopy) مما يجعل الأمر بسيطًا بشكل لا يصدق.
أولاً، قم بتضمين Lodash في صفحتك إما عن طريق التنزيل أو الإشارة إليها عبر CDN.
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.15/lodash.min.js"></script>
بعد ذلك، يمكنك استخدام دالة cloneDeep من Lodash لأداء النسخ العميق لمصفوفة البطاقات الخاصة بنا.
var cards1 = _.cloneDeep(this.cards);
var cards2 = _.cloneDeep(this.cards);
this.memoryCards = this.memoryCards.concat(cards1, cards2);
خلط البطاقات (Shuffling)
الآن نريد خلط المصفوفة المدمجة. تحتوي Lodash أيضًا على دالة للخلط (shuffle). دعنا نستخدم هذه الدالة ونبسط الشيفرة لدمج وخلط البطاقات في سطر واحد.
created(){
this.cards.forEach((card) => {
Vue.set(card, 'isFlipped', false)
});
this.memoryCards = _.shuffle(this.memoryCards.concat(_.cloneDeep(this.cards), _.cloneDeep(this.cards)));
},

البطاقات الآن تخلط وتنقلب كما هو متوقع. لننتقل إلى الشيء التالي!
مطابقة البطاقات: جوهر اللعبة
الخطوة التالية هي مطابقة البطاقات المقلوبة. يُسمح للمستخدم بقلب بطاقتين كحد أقصى في كل مرة. إذا كانتا متطابقتين، فهذه مطابقة ناجحة! وإذا لم تكونا كذلك، فإننا نقلبهما مرة أخرى. دعنا نتعامل مع هذا.
سنضيف خاصية جديدة لكل بطاقة لتتبع ما إذا كانت البطاقة قد تمت مطابقتها بالفعل. عدّل دالة created لتضمين هذه الشيفرة:
this.cards.forEach((card) => {
Vue.set(card, 'isFlipped', false);
Vue.set(card, 'isMatched', false);
});
أنشئ خاصية بيانات جديدة لتخزين البطاقات المقلوبة:
flippedCards: [],
بعد ذلك، سنعدّل دالة flipCard لأداء المطابقة:
flipCard(card){
card.isFlipped = true;
if (this.flippedCards.length < 2)
this.flippedCards.push(card);
if (this.flippedCards.length === 2)
this._match(card);
},
_match(card){
if (this.flippedCards[0].name === this.flippedCards[1].name)
this.flippedCards.forEach(card => card.isMatched = true);
else
this.flippedCards.forEach(card => card.isFlipped = false);
this.flippedCards = [];
},
المنطق هنا بسيط: نستمر في إضافة البطاقات إلى مصفوفة flippedCards حتى يصبح هناك بطاقتان. بمجرد وجود بطاقتين، نقوم بإجراء المطابقة. إذا كان اسم كلتا البطاقتين متطابقًا، فإننا نحدد البطاقات على أنها متطابقة عن طريق تعيين الخاصية isMatched إلى true. وإلا، فإننا نعيد تعيين الخاصية isFlipped إلى false. نقوم بمسح مصفوفة flippedCards بعد ذلك.
أضف خاصية CSS جديدة لتخفيف ظهور البطاقات المتطابقة:
.matched {
opacity: 0.3;
}
أضف ربط فئة (class binding) إلى الحاوية لإضافة البطاقات المتطابقة إذا تم تعيين الخاصية إلى true:
:class="{ 'flipped': card.isFlipped, 'matched': card.isMatched }"
هنا يعمل المنطق بشكل جيد، لكن كل شيء يحدث بسرعة كبيرة بحيث لا يفهم اللاعب ما يجري. إذا لم تتطابق البطاقات، يتم قلبها مرة أخرى حتى قبل أن يتمكن المستخدم من رؤية البطاقة المكشوفة. دعنا نستخدم دالة setTimeout من JavaScript لإضافة تأخير متعمد لبضع ميكروثواني.
_match(card){
if (this.flippedCards[0].name === this.flippedCards[1].name){
setTimeout(() => {
this.flippedCards.forEach(card => card.isMatched = true);
this.flippedCards = [];
}, 400);
} else {
setTimeout(() => {
this.flippedCards.forEach((card) => {card.isFlipped = false});
this.flippedCards = [];
}, 800);
}
},
لقد أضفنا 400 ميكروثانية من التأخير قبل تحديدها على أنها متطابقة، و 800 ميكروثانية من التأخير قبل قلبها مرة أخرى. قم أيضًا بتعديل دالة flipCard لعدم قلب البطاقات عندما:
- البطاقة متطابقة بالفعل (
card.isMatched). - البطاقة مقلوبة بالفعل (
card.isFlipped). - المستخدم قد قلب بطاقتين بالفعل (
this.flippedCards.length === 2).
flipCard(card){
if (card.isMatched || card.isFlipped || this.flippedCards.length === 2)
return;
card.isFlipped = true;
if (this.flippedCards.length < 2)
this.flippedCards.push(card);
if (this.flippedCards.length === 2)
this._match(card);
},

نحن على وشك الانتهاء، فقط بضع خطوات أخرى.
إنهاء اللعبة وتتبع التقدم
تُعد اللعبة منتهية عندما يتم مطابقة جميع البطاقات. دعنا نكتب شرط الشيفرة لذلك بسرعة. سنقدم خاصية بيانات جديدة في مثيل Vue الخاص بنا:
...
finish: false
بعد ذلك، سنعدّل دالة _match للتحقق مما إذا كانت جميع البطاقات متطابقة بعد كل مطابقة ناجحة.
setTimeout(() => {
this.flippedCards.forEach(card => card.isMatched = true);
this.flippedCards = [];
// هل جميع البطاقات متطابقة؟
if (this.memoryCards.every(card => card.isMatched === true)){
this.finish = true;
}
}, 400);
نحن نستخدم دالة every من مصفوفات JavaScript التي تقيّم الشرط المعطى للتحقق من صحته، وإذا لم يكن صحيحًا، فإنها تُرجع false.
تتبع إجمالي الدورات والوقت المستغرق
لقد بنينا اللعبة، لذا دعنا الآن نجعلها أكثر إثارة للاهتمام من خلال إضافة بعض اللمسات النهائية. سنضيف عدد الدورات التي قام بها المستخدم، وكذلك مدى أدائهم في الوقت المستغرق لإكمال اللعبة. أولاً، سنقدم بعض خصائص البيانات الجديدة:
start: false,
turns: 0,
totalTime: {
minutes: 0,
seconds: 0,
},
بمجرد قلب بطاقتين، سنزيد العد. وبالتالي، سنعدّل دالة _match لزيادة عدد الدورات (turns).
...
_match(card){
this.turns++;
...
بعد ذلك، نعدّل دالة flipCard لبدء المؤقت:
flipCard(card){
if (card.isMatched || card.isFlipped || this.flippedCards.length === 2)
return;
if (!this.start){
this._startGame();
}
...
...
أضف دالتين جديدتين لبدء الساعة بمجرد بدء اللعبة:
_startGame(){
this._tick();
this.interval = setInterval(this._tick, 1000);
this.start = true;
},
_tick(){
if (this.totalTime.seconds !== 59){
this.totalTime.seconds++;
return
}
this.totalTime.minutes++;
this.totalTime.seconds = 0;
},
نستخدم الخصائص المحسوبة (computed properties) لإضافة ‘0’ أمام الدقائق والثواني عندما تكون أرقامًا فردية:
computed:{
sec(){
if (this.totalTime.seconds < 10){
return '0' + this.totalTime.seconds;
}
return this.totalTime.seconds;
},
min(){
if (this.totalTime.minutes < 10){
return '0' + this.totalTime.minutes;
}
return this.totalTime.minutes;
}
}
أضف HTML التالي فوق HTML الخاص بك لعرض إجمالي عدد الدورات والوقت الإجمالي:
<div class="d-flex flex-row justify-content-center py-3">
<div class="turns p-3">
<span class="btn btn-info"> الدورات : <span class="badge" :class="finish ? 'badge-success' : 'badge-light'">{{turns}}</span>
</span>
</div>
<div class="totalTime p-3">
<span class="btn btn-info"> الوقت الإجمالي : <span class="badge" :class="finish ? 'badge-success' : 'badge-light'">{{min}} : {{sec}}</span>
</span>
</div>
</div>
عدّل شرط إنهاء اللعبة لإيقاف المؤقت بمجرد انتهاء اللعبة:
if (this.memoryCards.every(card => card.isMatched === true)){
clearInterval(this.interval);
this.finish = true;
}
إعادة تعيين اللعبة (Reset)
نحن في خطوتنا الأخيرة – عمل رائع إذا وصلت إلى هذه النقطة. دعنا نضيف زرًا لإعادة تعيين اللعبة:
<div class="totalTime p-3">
<button class="btn btn-info" @click="reset" :disabled="!start"> إعادة البدء </button>
</div>
اربط حدث النقر (click event) بدالة reset:
reset(){
clearInterval(this.interval);
this.cards.forEach((card) => {
Vue.set(card, 'isFlipped', false);
Vue.set(card, 'isMatched', false);
});
setTimeout(() => {
this.memoryCards = [];
this.memoryCards = _.shuffle(this.memoryCards.concat(_.cloneDeep(this.cards), _.cloneDeep(this.cards)));
this.totalTime.minutes = 0;
this.totalTime.seconds = 0;
this.start = false;
this.finish = false;
this.turns = 0;
this.flippedCards = [];
}, 600);
},
نقوم بمسح المؤقت، وإعادة خلط البطاقات، وإعادة تعيين جميع الحقول إلى قيمها الافتراضية. نقوم أيضًا بتعديل دالة دورة الحياة created لاستدعاء دالة reset لتجنب تكرار الشيفرة:
created(){
this.reset();
},
وهكذا تكون قد أنشأت لعبة ذاكرة كاملة باستخدام Vue.js!

الخلاصة التقنية
لقد قدم هذا الدليل رحلة شاملة في بناء لعبة بطاقات الذاكرة باستخدام Vue.js، مما يبرز قوة ومرونة الإطار في تطوير الواجهات الأمامية التفاعلية. من خلال هذا المشروع، لم نكتفِ بتطبيق المفاهيم الأساسية مثل التوجيهات (v-for, v-bind) ودوال دورة الحياة (created)، بل تعمقنا أيضًا في تحديات أكثر تعقيدًا مثل إدارة الحالة التفاعلية باستخدام Vue.set، وفهم الفروقات الجوهرية بين النسخ السطحي والعميق للكائنات في JavaScript، والاستفادة من مكتبات مساعدة خارجية مثل Lodash لتبسيط عمليات معالجة البيانات المعقدة كالخلط والنسخ العميق. كما تعلمنا كيفية تحسين تجربة المستخدم من خلال إضافة تأخيرات زمنية باستخدام setTimeout، وتتبع مقاييس اللعب مثل الدورات والوقت باستخدام الخصائص المحسوبة (computed properties) والمؤقتات. هذا المشروع يمثل نموذجًا ممتازًا للمطورين الجدد لـ Vue.js لتعزيز مهاراتهم وبناء تطبيقات ويب عملية وفعالة.