Swingطريقة إنشاء لعبة الأفعى Snake 2D
في هذا الدرس ستتعلم طريقة إنشاء لعبة ( Snake 2D ) إحترافية بإستخدام إطار الـ Swing.
مميزات اللعبة
- أعلى مجموع يصل إليه اللاعب يبقى مخزناً حتى إذا تم إغلاق اللعبة.
- يمكن إيقاف و متابعة اللعبة بالنقر على زر المسافة الفارغة Space.
- إذا لمست الأفعى الحائط لا يخسر اللاعب, لأن الأفعى ستظهر من الجهة المقابلة.
- يمكن تعديل كود اللعبة بكل سهولة لأنه غير معقد.
بناء اللعبة
في هذا المشروع قمنا بوضع ملفات الجافا بداخل مجلد إسمه snake_2d
.
و قمنا بوضع الصور بداخل مجلد إسمه images
كما في الصورة التالية.
⇓ تحميل اللعبة ⇓ تحميل المشروع كاملاً ⇓ تحميل مجلد الصور فقط
كود اللعبة
// snake_2d موجود بداخل المجلد Position.java هنا ذكرنا أن الملف package snake_2d; // قمنا بإنشاء هذا الكلاس لجعل أي كائن منه يمثل موقع دائرة من الدوائر الموجودة في الأفعى public class Position { // إذاً كل دائرة في الأفعى ستكون موجودة في نفس الوقت على خط طول و خط عرض محددين int x, y; // قمنا بتجهيز هذا الكونستركتور لتحديد مكان وجود أي دائرة في الأفعى لحظة إنشاء الكائن منه public Position(int x, int y) { this.x = x; this.y = y; } }
// snake_2d موجود بداخل المجلد GameDrawer.java هنا ذكرنا أن الملف package snake_2d; import java.awt.Color; import java.awt.Dimension; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.util.LinkedList; import java.util.Random; import javax.swing.ImageIcon; import javax.swing.JPanel; import javax.swing.Timer; // أي أصبح هذا الكلاس يمثل حاوية ,JPanel هنا قمنا بإنشاء كلاس يرث من الكلاس // حتى نجعل المستخدم قادر على تحريك الأفعى من الكيبورد KeyListener و جعلناه يطبق الإنترفيس // حتى نستطيع رسم الأفعى من جديد كلما تحركت ActionListener و جعلناه يطبق الإنترفيس public class GameDrawer extends JPanel implements KeyListener, ActionListener{ // قمنا بإنشاء هاتين المصفوفتين لتخزين مكان وجود كل دائرة من الأفعى كل لحظة, أي لتحديد المكان المحجوز لعرض دوائر الأفعى // ( بما أن عدد الدوائر الأقصى بالطول هو 22 و بالعرض هو أيضاً 22, فهذا يعني أنه يمكن تخزين 22×22 دائرة ( أي 484 final int[] boardX = new int[484]; final int[] boardY = new int[484]; // سنستخدم هذه المصفوفة لتخزين مكان وجود كل دائرة في الأفعى حتى نعرف الموقع الذي لا يجب أن نظهر فيه الدائرة الحمراء // ملاحظة: سبب إستخدام هذا المصفوفة لتخزين موقع كل دائرة في الأفعى من جديد هو فقط لجعل اللعبة لا تعلق, أي لتحسين أداء اللعبة LinkedList<Position> snake = new LinkedList(); // سنستخدم هذه المتغيرات لتحديد الإتجاه الذي ستتجه إليه الأفعى boolean left = false; boolean right = false; boolean up = false; boolean down = false; // سنستخدم هذه الكائنات لرسم إتجاه وجه الأفعى ImageIcon lookToRightImage = new ImageIcon(this.getClass().getResource("/images/face-look-right.jpg")); ImageIcon lookToLeftImage = new ImageIcon(this.getClass().getResource("/images/face-look-left.jpg")); ImageIcon lookToUpImage = new ImageIcon(this.getClass().getResource("/images/face-look-up.jpg")); ImageIcon lookToDownImage = new ImageIcon(this.getClass().getResource("/images/face-look-down.jpg")); // سنستخدم هذا الكائن في كل مرة لرسم جسد الأفعى ImageIcon snakeBodyImage = new ImageIcon(this.getClass().getResource("/images/body.png")); // سنستخدم هذا الكائن في كل مرة لرسم طعام الأفعى ImageIcon fruitImage = new ImageIcon(this.getClass().getResource("/images/fruit.png")); // سنستخدم هذا المتغير لتخزين عدد الدوائر التي تشكل الأفعى int lengthOfSnake = 3; // سنستخدم هاتين المصفوفتين لتحديد الأماكن التي يمكن أن يظهر فيها الطعام int[] fruitXPos = {20, 40, 60, 80, 100, 120, 140, 160, 200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 460}; int[] fruitYPos = {20, 40, 60, 80, 100, 120, 140, 160, 200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 460}; // سنستخدم هذا الكائن لجعل الأفعى تستمر بالحركة .Thread و الذي يشبه الـ Timer هنا قمنا بإنشاء كائن من الكلاس Timer speedTimer; // سنستخدم هذا المتغير لتحديد أنه كل 0.1 ثانية سستتحرك الأفعى int delay = 100; // سنستخدم هذا المتغير لمعرفة إذا كانت الأفعى تتحرك أم لا int moves = 0; // سنستخدم هذه المتغيرات لتحديد المجموع الذي يحققه اللاعب أثناء اللعب int totalScore = 0; int fruitEaten = 0; int scoreReverseCounter = 99; // في حال كان اللاعب قد حقق مجموع عالي من قبل, سيتم إظهاره كأفضل مجموع وصل إليه int bestScore = readBestScorefromTheFile(); // لتوليد أماكن ظهور طعام الأفعى بشكل عشوائي random سنستخدم الكائن Random random = new Random(); // هنا قمنا بتحديد مكان أول طعام سيظهر في اللعبة قبل أن يبدأ المستخدم باللعب, و جعلناه يظهر تحت الأفعى int xPos = random.nextInt(22); int yPos = 5+random.nextInt(17); // سنستخدم هذا المتغير لمعرفة ما إذا كان المستخدم قد خسر أم لا boolean isGameOver = false; // هنا تحديد حجم اللعبة و تهيئتها لإستشعار الأزرار التي ينقر عليها المستخدم لحظة إنشاء كائن من هذا الكلاس // لجعل اللعبة تتحرك بسرعة 0.1 بالثانية speedTimer و قمنا تهيئة اللعبة لإستشعار الأزرار التي ينقر عليها المستخدم و تهيئة الكائن public GameDrawer() { setPreferredSize(new Dimension(750, 500)); addKeyListener(this); setFocusable(true); setFocusTraversalKeysEnabled(false); speedTimer = new Timer(delay, this); } public void setSize(int width, int height) { super.setSize(width, height); } public void setBounds(int x, int y, int width, int height) { super.setBounds(x, y, width, height); } // (لتحديد كيف سيتم رسم و تلوين كل شيء في الحاوية (أي في اللعبة paint() للدالة Override هنا فعلنا // ملاحظة هذه الدالة تستدعى بشكل تلقائي عند رسم أي شيء في الحاوية public void paint(Graphics g) { // هنا قمنا بتحديد مكان وجود الأفعى في كل مرة يقوم المستخدم ببدأ اللعبة من جديد if(moves == 0) { boardX[2] = 40; boardX[1] = 60; boardX[0] = 80; boardY[2] = 100; boardY[1] = 100; boardY[0] = 100; scoreReverseCounter = 99; speedTimer.start(); } // هنا قمنا بجعل المجموع الحالي الذي وصل إليه المستخدم يظهر كأعلى مجموع وصل إليه في حال تخطى المجموع القديم if(totalScore > bestScore) bestScore = totalScore; // هنا قمن بإنشاء مربع أسود يمثل لون خلفية اللعبة g.setColor(Color.BLACK); g.fillRect(0, 0, 750, 500); // هنا قمنا برسم المربعات التي تشكل الحدود التي لا تستطيع الأفعى عبورها باللون الرمادي // حجم كل مربع 13 بيكسل و المسافة بينهما 5 بيكسل g.setColor(Color.DARK_GRAY); for(int i=6; i<=482; i+=17) for(int j=6; j<=482; j+=17) g.fillRect(i, j, 13, 13); // هنا فمنا بإنشاء مربع أسود كبير فوق المربعات التي تشكل حدود اللعبة لتظهر و كأنها فارغة من الداخل g.setColor(Color.BLACK); g.fillRect(20, 20, 460, 460); // هنا قمنا بكتابة إسم اللعبة و تلوينه بالأزرق g.setColor(Color.CYAN); g.setFont(new Font("Arial", Font.BOLD, 26)); g.drawString("Snake 2D", 565, 35); // هنا قمنا بكتابة إسم اللعبة و تلوينه بالأزرق g.setFont(new Font("Arial", Font.PLAIN, 13)); g.drawString("+ "+scoreReverseCounter, 510, 222); // هنا جعلنا أي شيء سنقوم بكتابته يظهر باللون الرمادي g.setColor(Color.LIGHT_GRAY); // هنا قمنا بطباعة أنه تم تطوير اللعبة بواسطة موقعنا g.setFont(new Font("Arial", Font.PLAIN, 15)); g.drawString("Developed by harmash.com", 530, 60); // لقياس حجم الأرفام الظاهرة في المربعات الثلاث بالبيكسل حتى نستطيع عرضهم في الوسط FontMetrics هنا قمنا بإنشاء الكائن FontMetrics fm = g.getFontMetrics(); // هنا جعلنا أي شيء سنقوم بكتابته يظهر بنوع و حجم هذا الخط g.setFont(new Font("Arial", Font.PLAIN, 18)); //و المربع الذي تحته و الرقم الذي بداخله Best Score هنا قمنا برسم النص g.drawString("Best Score", 576, 110); g.drawRect(550, 120, 140, 30); g.drawString(bestScore+"", 550+(142-fm.stringWidth(bestScore+""))/2, 142); //و المربع الذي تحته و الرقم الذي بداخله Total Score هنا قمنا برسم النص g.drawString("Total Score", 573, 190); g.drawRect(550, 200, 140, 30); g.drawString(totalScore+"", 550+(142-fm.stringWidth(totalScore+""))/2, 222); //و المربع الذي تحته و الرقم الذي بداخله Fruit Eaten هنا قمنا برسم النص g.drawString("Fruit Eaten", 575, 270); g.drawRect(550, 280, 140, 30); g.drawString(fruitEaten+"", 550+(142-fm.stringWidth(fruitEaten+""))/2, 302); // Controls هنا قمنا برسم النص g.setFont(new Font("Arial", Font.BOLD, 16)); g.drawString("Controls", 550, 360); // Controls هنا قمنا برسم النصوص الظاهرة تحت النص g.setFont(new Font("Arial", Font.PLAIN, 14)); g.drawString("Pause / Start : Space", 550, 385); g.drawString("lookTo Up : Arrow Up", 550, 410); g.drawString("lookTo Down : Arrow Down", 550, 435); g.drawString("lookTo Left : Arrow Left", 550, 460); g.drawString("lookTo Right : Arrow Right", 550, 485); // هنا جعلنا الأفعى تنظر ناحية اليمين قبل أن يبدأ اللاعب بتحريكها lookToRightImage.paintIcon(this, g, boardX[0], boardY[0]); // هنا قمنا بمسح مكان وجود الأفعى السابق لأننا سنقوم بتخزين المكان الجديد كلما تحركت snake.clear(); // هنا قمنا بإنشاء حلقة ترسم كامل الدوائر التي تشكل الأفعى كل 0.1 ثانية for(int i=0; i<lengthOfSnake; i++) { if(i==0 && left) lookToLeftImage.paintIcon(this, g, boardX[i], boardY[i]); else if(i==0 && right) lookToRightImage.paintIcon(this, g, boardX[i], boardY[i]); else if(i==0 && up) lookToUpImage.paintIcon(this, g, boardX[i], boardY[i]); else if(i==0 && down) lookToDownImage.paintIcon(this, g, boardX[i], boardY[i]); else if(i!=0) snakeBodyImage.paintIcon(this, g, boardX[i], boardY[i]); // snake هنا قمنا بتخزين الموقع الحالي لكل دائرة في الأفعى في الكائن snake.add(new Position(boardX[i], boardY[i])); } // تقل بشكل تدريجي و أدنى قيمة ممكن أن تصل إليها هي 10 scoreReverseCounter هنا جعلنا قيمة العداد if(scoreReverseCounter != 10) scoreReverseCounter--; // هنا قمنا بإنشاء هذه الحلقة للتأكد إذا كان رأس الأفعى قد لامس أي جزء من جسدها for(int i=1; i<lengthOfSnake; i++) { // إذاً عندما يلمس رأس الأفعى جسدها سيتم جعل أول دائرة موجودة خلف الرأس تمثل رأس الأفعى حتى لا يظهر رأسها فوق جسدها if(boardX[i] == boardX[0] && boardY[i] == boardY[0]) { if(right) lookToRightImage.paintIcon(this, g, boardX[1], boardY[1]); else if(left) lookToLeftImage.paintIcon(this, g, boardX[1], boardY[1]); else if(up) lookToUpImage.paintIcon(this, g, boardX[1], boardY[1]); else if(down) lookToDownImage.paintIcon(this, g, boardX[1], boardY[1]); // للإشارة إلى أن اللاعب قد خسر true تساوي isGameOver بعدها سيتم جعل قيمة الـ // Space و بالتالي يمكنه أن يبدأ من جديد بالنقر على زر المسافة الفارغة isGameOver = true; // يتوقف و بالتالي ستتوقف الأفعى تماماً عن الحركة speedTimer بعدها سيتم جعل الـ speedTimer.stop(); // Game Over بعدها سيتم إظهار النص g.setColor(Color.WHITE); g.setFont(new Font("Arial", Font.BOLD, 50)); g.drawString("Game Over", 110, 220); // تحته Press Space To Restart و سيتم إظهار النص g.setFont(new Font("Arial", Font.BOLD, 20)); g.drawString("Press Space To Restart", 130, 260); // في الأخير سيتم إستدعاء هذه الدالة لحفظ أكبر مجموع وصل إليه اللاعب writeBestScoreInTheFile(); } } // إذا لمس رأس الأفعى الطعام سيتم إخفاء الطعام و زيادة مجموع اللاعب if((fruitXPos[xPos] == boardX[0]) && fruitYPos[yPos] == boardY[0]) { totalScore += scoreReverseCounter; scoreReverseCounter = 99; fruitEaten++; lengthOfSnake++; } // هنا في كل مرة سيتم ضمان أن لا يظهر الطعام فوق الأفعى for(int i=0; i<snake.size(); i++) { // في حال ظهر الطعام فوق جسد الأفعى سيتم خلق مكان عشوائي آخر لوضعها فيه if(snake.get(i).x == fruitXPos[xPos] && snake.get(i).y == fruitYPos[yPos]) { xPos = random.nextInt(22); yPos = random.nextInt(22); } } // في الأخير سيتم عرض الطعام بعيداً عن جسد الأفعى fruitImage.paintIcon(this, g, fruitXPos[xPos], fruitYPos[yPos]); // قمنا باستدعاء هذه الدالة للتخلص من أي مصادر لا حاجة لها في البرنامج و يمكنك إزالتها لأنها لن تؤثر هنا g.dispose(); } // لتحديد الإتجاه الذي ستتحرك فيه النافذة keyPressed() للدالة Override هنا فعلنا public void keyPressed(KeyEvent e) { // Space هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر المسافة الفارغة if(e.getKeyCode() == KeyEvent.VK_SPACE) { // سيتم إيقاف اللعبة بشكل مؤقت إذا كانت اللعبة شغالة if(speedTimer.isRunning() && isGameOver == false) speedTimer.stop(); // سيتم إعادة اللعبة للعمل إذا كان قد تم إيقافها سابقاً else if(!speedTimer.isRunning() && isGameOver == false) speedTimer.start(); // سيتم بدأ اللعبة من جديد في حال كان قد تم إيقاف اللعبة لأن اللاعب قد خسر else if(!speedTimer.isRunning() && isGameOver == true) { isGameOver = false; speedTimer.start(); moves = 0; totalScore = 0; fruitEaten = 0; lengthOfSnake = 3; right = true; left = false; xPos = random.nextInt(22); yPos = 5+random.nextInt(17); } } // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم الأيمن else if(e.getKeyCode() == KeyEvent.VK_RIGHT) { // إذا لم تكن الأفعى تسير في الإتجاه الأيسر سيتم توجيهها نحو الإتجاه الأيمن moves++; right = true; if(!left) right = true; else { right = false; left = true; } up = false; down = false; } // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم الأيسر else if(e.getKeyCode() == KeyEvent.VK_LEFT) { // إذا لم تكن الأفعى تسير في الإتجاه الأيمن سيتم توجيهها نحو الإتجاه الأيسر moves++; left = true; if(!right) left = true; else { left = false; right = true; } up = false; down = false; } // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم المتجه للأعلى else if(e.getKeyCode() == KeyEvent.VK_UP) { // إذا لم تكن الأفعى تسير في اتجاه الأسفل سيتم توجيهها نحو الأعلى moves++; up = true; if(!down) up = true; else { up = false; down = true; } left = false; right = false; } // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم المتجه للأسفل else if(e.getKeyCode() == KeyEvent.VK_DOWN) { // إذا لم تكن الأفعى تسير في اتجاه الأعلى سيتم توجيهها نحو الأسفل moves++; down = true; if(!up) down = true; else { up = true; down = false; } left = false; right = false; } } // هنا قمنا بتحديد المكان الذي ستظهر فيه الأفعى لحظة تحركها // و لاحظ أنه يوجد شرط متعلق بكل إتجاه تسلكه الأفعى حالياً // هذه الشروط الموضوعة تجعل الأفعى تظهر في الجهة المقابلة لها عندما تلمس الحائط public void actionPerformed(ActionEvent e) { if(right) { for(int i = lengthOfSnake-1; i>=0; i--) boardY[i+1] = boardY[i]; for(int i = lengthOfSnake; i>=0; i--) { if(i==0) boardX[i] = boardX[i] + 20; else boardX[i] = boardX[i-1]; if(boardX[i] > 460) boardX[i] = 20; } } else if(left) { for(int i = lengthOfSnake-1; i>=0; i--) boardY[i+1] = boardY[i]; for(int i = lengthOfSnake; i>=0; i--) { if(i==0) boardX[i] = boardX[i] - 20; else boardX[i] = boardX[i-1]; if(boardX[i] < 20) boardX[i] = 460; } } else if(up) { for(int i = lengthOfSnake-1; i>=0; i--) boardX[i+1] = boardX[i]; for(int i = lengthOfSnake; i>=0; i--) { if(i==0) boardY[i] = boardY[i] - 20; else boardY[i] = boardY[i-1]; if(boardY[i] < 20) boardY[i] = 460; } } else if(down) { for(int i = lengthOfSnake-1; i>=0; i--) boardX[i+1] = boardX[i]; for(int i = lengthOfSnake; i>=0; i--) { if(i==0) boardY[i] = boardY[i] + 20; else boardY[i] = boardY[i-1]; if(boardY[i] > 460) boardY[i] = 20; } } repaint(); } public void keyReleased(KeyEvent e) { } public void keyTyped(KeyEvent e) { } // هذه الدالة تحفظ أعلى مجموع وصل إليه اللاعب في ملف خارجي بجانب ملف اللعبة private void writeBestScoreInTheFile() { if(totalScore >= bestScore) { try { FileOutputStream fos = new FileOutputStream("./snake-game-best-score.txt"); OutputStreamWriter osw = new OutputStreamWriter(fos, "UTF-8"); osw.write(bestScore+""); osw.flush(); osw.close(); } catch(IOException e) { } } } // هذه الدالة تقرأ أعلى مجموع وصل إليه اللاعب من الملف الخارجي الموجود بجانب ملف اللعبة // في حال كان لا يوجد ملف خارجي ستقوم بإنشائه و وضع القيمة 0 فيه كقيمة أولية و هذا ما سيحدث عندما يقوم اللاعب بتشغيل اللعبة أول مرة private int readBestScorefromTheFile() { try { InputStreamReader isr = new InputStreamReader( new FileInputStream("./snake-game-best-score.txt"), "UTF-8" ); BufferedReader br = new BufferedReader(isr); String str = ""; int c; while( (c = br.read()) != -1){ if(Character.isDigit(c)) str += (char)c; } if(str.equals("")) str = "0"; br.close(); return Integer.parseInt(str); } catch(IOException e) { } return 0; } }
// snake_2d موجود بداخل المجلد Main.java هنا ذكرنا أن الملف package snake_2d; import javax.swing.JFrame; import javax.swing.SwingUtilities; // و بالتالي أصبح إنشاء كائن منه يمثل إنشاء نافذة JFrame يرث من الكلاس Main هنا جعلنا الكلاس public class Main extends JFrame { // فقط createAndShowGUI() سيقوم الكونستركتور بإستدعاء الدالة Main عند إنشاء كائن من الكلاس public Main() { createAndShowGUI(); } // هنا نضع كود إنشاء النافذة و محتوياتها private void createAndShowGUI() { // هنا قمنا بإنشاء كائن من الحاوية التي تحتوي على اللعبة GameDrawer gameDrawer = new GameDrawer(); // هنا وضعنا الحاوية التي تحتوي على اللعبة في النافذة حتى تظهر بداخلها add(gameDrawer); // هنا قمنا بتحديد بعض خصائص النافذة و جعلناها مرئية setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); pack(); setLocationRelativeTo(null); setVisible(true); } public static void main(String[] args) { SwingUtilities.invokeLater(new Runnable() { public void run() { // التي ستنشئ النافذة createAndShowGUI() و بالتالي سيتم إستدعاء الدالة Main هنا قمنا بإنشاء كائن من الكلاس new Main(); } }); } }