JavaFXطريقة إنشاء لعبة الأفعى Snake 2D
في هذا الدرس ستتعلم طريقة إنشاء لعبة ( Snake 2D ) إحترافية بإستخدام JavaFX.
مميزات اللعبة
- أعلى مجموع يصل إليه اللاعب يبقى مخزناً حتى إذا تم إغلاق اللعبة.
- يمكن إيقاف و متابعة اللعبة بالنقر على زر المسافة الفارغة Space.
- إذا لمست الأفعى الحائط لا يخسر اللاعب, لأن الأفعى ستظهر من الجهة المقابلة.
- يمكن تعديل كود اللعبة بكل سهولة لأنه غير معقد.
بناء اللعبة
- ملفات الجافا وضعناها مباشرةً في المشروع.
- الصور وضعناه بداخل مجلد إسمه
images
.
خيارات التحميل
⇓ تحميل اللعبة ⇓ تحميل المشروع كاملاً ⇓ تحميل مجلد الصور فقط
كود اللعبة
// قمنا بإنشاء هذا الكلاس لجعل أي كائن منه يمثل موقع دائرة من الدوائر الموجودة في الأفعى public class Position { // إذاً كل دائرة في الأفعى ستكون موجودة في نفس الوقت على خط طول و خط عرض محددين int x, y; // قمنا بتجهيز هذا الكونستركتور لتحديد مكان وجود أي دائرة في الأفعى لحظة إنشاء الكائن منه public Position(int x, int y) { this.x = x; this.y = y; } }
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 javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Application; import javafx.event.ActionEvent; import javafx.scene.Scene; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.Image; import javafx.scene.input.KeyEvent; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import javafx.scene.text.Text; import javafx.stage.Stage; import javafx.util.Duration; public class Main extends Application { // قمنا بإنشاء هاتين المصفوفتين لتخزين مكان وجود كل دائرة من الأفعى كل لحظة, أي لتحديد المكان المحجوز لعرض دوائر الأفعى // ( بما أن عدد الدوائر الأقصى بالطول هو 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; // سنستخدم هذه الكائنات لرسم إتجاه وجه الأفعى Image lookToRightImage = new Image(getClass().getResourceAsStream("/images/face-look-right.jpg")); Image lookToLeftImage = new Image(getClass().getResourceAsStream("/images/face-look-left.jpg")); Image lookToUpImage = new Image(getClass().getResourceAsStream("/images/face-look-up.jpg")); Image lookToDownImage = new Image(getClass().getResourceAsStream("/images/face-look-down.jpg")); // سنستخدم هذا الكائن في كل مرة لرسم جسد الأفعى Image snakeBodyImage = new Image(getClass().getResourceAsStream("/images/body.png")); // سنستخدم هذا الكائن في كل مرة لرسم طعام الأفعى Image fruitImage = new Image(getClass().getResourceAsStream("/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 و الذي يشبه الـ Timeline هنا قمنا بإنشاء كائن من الكلاس // من جديد كل 0.1 ثانية مما يجعلنا قادرين على رسم الأفعى من جديد كلما تغير موقعها بالإضافة إلى رسم مجموع المستخدم Timeline timeline = new Timeline(); // سنستخدم هذا المتغير لمعرفة إذا كانت الأفعى تتحرك أم لا int moves = 0; // سنستخدم هذه المتغيرات لتحديد المجموع الذي يحققه اللاعب أثناء اللعب int totalScore = 0; int fruitEaten = 0; int scoreReverseCounter = 99; // في حال كان اللاعب قد حقق مجموع عالي من قبل, سيتم إظهاره كأفضل مجموع وصل إليه // readBestScorefromTheFile() ملاحظة: أعلى مجموع يصل إليه اللاعب, نحصل عليه من الدالة int bestScore = readBestScorefromTheFile(); // لتوليد أماكن ظهور طعام الأفعى بشكل عشوائي random سنستخدم الكائن Random random = new Random(); // هنا قمنا بتحديد مكان أول طعام سيظهر في اللعبة قبل أن يبدأ المستخدم باللعب, و جعلناه يظهر تحت الأفعى int xPos = random.nextInt(22); int yPos = 5 + random.nextInt(17); // سنستخدم هذا المتغير لمعرفة ما إذا كان المستخدم قد خسر أم لا boolean isGameOver = false; // هذه الدالة تحفظ أعلى مجموع وصل إليه اللاعب في ملف خارجي بجانب ملف اللعبة 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; } // بهدف تحديد كيف سيتم رسم و تلوين كل شيء يظهر في اللعبة drawShapes() هنا قمنا بتعريف الدالة // قلنا أنه سيتم إستدعاء هذه الدالة كل 0.1 ثانية start() ملاحظة: في الدالة private void drawShapes(GraphicsContext gc) { // هنا قمنا بتحديد مكان وجود الأفعى في كل مرة يقوم المستخدم ببدأ اللعبة من جديد if (moves == 0) { boardX[2] = 40; boardX[1] = 60; boardX[0] = 80; boardY[2] = 100; boardY[1] = 100; boardY[0] = 100; scoreReverseCounter = 99; timeline.play(); } // هنا قمنا بجعل المجموع الحالي الذي وصل إليه المستخدم يظهر كأعلى مجموع وصل إليه في حال تخطى المجموع القديم if (totalScore > bestScore) { bestScore = totalScore; } // هنا قمن بإنشاء مربع أسود يمثل لون خلفية اللعبة gc.setFill(Color.BLACK); gc.fillRect(0, 0, 750, 500); // هنا قمنا برسم المربعات التي تشكل الحدود التي لا تستطيع الأفعى عبورها باللون الرمادي // حجم كل مربع 13 بيكسل و المسافة بينهما 5 بيكسل gc.setFill(Color.color(0.2, 0.2, 0.2)); for (int i = 6; i <= 482; i += 17) { for (int j = 6; j <= 482; j += 17) { gc.fillRect(i, j, 13, 13); } } // هنا فمنا بإنشاء مربع أسود كبير فوق المربعات التي تشكل حدود اللعبة لتظهر و كأنها فارغة من الداخل gc.setFill(Color.BLACK); gc.fillRect(20, 20, 460, 460); // هنا قمنا بكتابة إسم اللعبة و تلوينه بالأزرق gc.setFill(Color.CYAN); gc.setFont(Font.font("Arial", FontWeight.BOLD, 26)); gc.fillText("Snake 2D", 565, 35); // باللون الأزرق الفاتح Total Score التي ستظهر بجانب قيمة الـ Bonus هنا قمنا برسم النص قيمة الـ gc.setFont(Font.font("Arial", FontWeight.NORMAL, 13)); gc.fillText("+ " + scoreReverseCounter, 510, 222); // هنا جعلنا أي شيء سنقوم بكتابته يظهر باللون الرمادي gc.setFill(Color.LIGHTGRAY); // هنا قمنا بطباعة أنه تم تطوير اللعبة بواسطة موقعنا gc.setFont(Font.font("Arial", FontWeight.NORMAL, 15)); gc.fillText("Developed by harmash.com", 530, 60); // هنا جعلنا أي شيء سنقوم بكتابته يظهر بنوع و حجم هذا الخط gc.setFont(Font.font("Arial", FontWeight.NORMAL, 18)); // و المربع الذي تحته و الرقم الذي بداخله Best Score هنا قمنا برسم النص gc.fillText("Best Score", 576, 110); gc.fillRect(550, 120, 140, 30); gc.setFill(Color.BLACK); gc.fillRect(551, 121, 138, 28); gc.setFill(Color.LIGHTGRAY); gc.fillText(bestScore + "", 550 + (142 - new Text(bestScore + "").getLayoutBounds().getWidth()) / 2, 142); // و المربع الذي تحته و الرقم الذي بداخله Total Score هنا قمنا برسم النص gc.fillText("Total Score", 573, 190); gc.fillRect(550, 200, 140, 30); gc.setFill(Color.BLACK); gc.fillRect(551, 201, 138, 28); gc.setFill(Color.LIGHTGRAY); gc.fillText(totalScore + "", 550 + (142 - new Text(totalScore + "").getLayoutBounds().getWidth()) / 2, 222); // و المربع الذي تحته و الرقم الذي بداخله Fruit Eaten هنا قمنا برسم النص gc.fillText("Fruit Eaten", 575, 270); gc.fillRect(550, 280, 140, 30); gc.setFill(Color.BLACK); gc.fillRect(551, 281, 138, 28); gc.setFill(Color.LIGHTGRAY); gc.fillText(fruitEaten + "", 550 + (142 - new Text(fruitEaten + "").getLayoutBounds().getWidth()) / 2, 302); // Controls هنا قمنا برسم النص gc.setFont(Font.font("Arial", FontWeight.BOLD, 16)); gc.fillText("Controls", 550, 360); // Controls هنا قمنا برسم نصوص الإرشاد الظاهرة تحت النص gc.setFont(Font.font("Arial", FontWeight.NORMAL, 14)); gc.fillText("Pause / Start : Space", 550, 385); gc.fillText("Move Up : Arrow Up", 550, 410); gc.fillText("Move Down : Arrow Down", 550, 435); gc.fillText("Move Left : Arrow Left", 550, 460); gc.fillText("Move Right : Arrow Right", 550, 485); // هنا جعلنا الأفعى تنظر ناحية اليمين قبل أن يبدأ اللاعب بتحريكها gc.drawImage(lookToRightImage, boardX[0], boardY[0]); // هنا قمنا بمسح مكان وجود الأفعى السابق لأننا سنقوم بتخزين المكان الجديد كلما تحركت snake.clear(); // هنا قمنا بإنشاء حلقة ترسم كامل الدوائر التي تشكل الأفعى كل 0.1 ثانية for (int i = 0; i < lengthOfSnake; i++) { if (i == 0 && left) { gc.drawImage(lookToLeftImage, boardX[i], boardY[i]); } else if (i == 0 && right) { gc.drawImage(lookToRightImage, boardX[i], boardY[i]); } else if (i == 0 && up) { gc.drawImage(lookToUpImage, boardX[i], boardY[i]); } else if (i == 0 && down) { gc.drawImage(lookToDownImage, boardX[i], boardY[i]); } else if (i != 0) { gc.drawImage(snakeBodyImage, 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) gc.drawImage(lookToRightImage, boardX[1], boardY[1]); else if (left) gc.drawImage(lookToLeftImage, boardX[1], boardY[1]); else if (up) gc.drawImage(lookToUpImage, boardX[1], boardY[1]); else if (down) gc.drawImage(lookToDownImage, boardX[1], boardY[1]); // للإشارة إلى أن اللاعب قد خسر true تساوي isGameOver بعدها سيتم جعل قيمة الـ // Space و بالتالي يمكنه أن يبدأ من جديد بالنقر على زر المسافة الفارغة isGameOver = true; // يتوقف و بالتالي ستتوقف الأفعى تماماً عن الحركة speedTimeline بعدها سيتم جعل الـ timeline.stop(); // Game Over بعدها سيتم إظهار النص gc.setFill(Color.WHITE); gc.setFont(Font.font("Arial", FontWeight.BOLD, 50)); gc.fillText("Game Over", 110, 220); // تحته Press Space To Restart و سيتم إظهار النص gc.setFont(Font.font("Arial", FontWeight.BOLD, 20)); gc.fillText("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); } } // في الأخير سيتم عرض الطعام بعيداً عن جسد الأفعى gc.drawImage(fruitImage, fruitXPos[xPos], fruitYPos[yPos]); 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; } } } public void start(Stage stage) { // لأنه يمثل حاوية يمكن الرسم عليها بسهولة Canvas هنا قمنا بإنشاء كائن من الكلاس Canvas canvas = new Canvas(750, 500); // canvas لأننا سنستخدمه للرسم على الكائن canvas مبني على الكائن GraphicsContext هنا قمنا بإنشاء كائن من الكلاس // gc سيكون بواسطة دوال جاهزة موجودة في الكائن canvas فعلياً أي شيء سنرسمه على الكائن GraphicsContext gc = canvas.getGraphicsContext2D(); // في النافذة لأننا ننوي ترتيب العناصر فيه بشكل عامودي Root Node و الذي ننوي جعله الـ VBox هنا قمنا بإنشاء كائن من الكلاس Pane root = new Pane(); root.setStyle("-fx-background-color: black;"); root.getChildren().add(canvas); // فيها و تحديد حجمها Node كأول root هنا قمنا بإنشاء محتوى النافذة مع تعيين الكائن Scene scene = new Scene(root); // هنا وضعنا عنوان للنافذة stage.setTitle("Snake 2D"); // أي وضعنا محتوى النافذة الذي قمنا بإنشائه للنافذة .stage في كائن الـ scene هنا وضعنا كائن الـ stage.setScene(scene); // هنا قمنا بإظهار النافذة stage.show(); // timeline لترسم محتوى النافذة كل 0.1 ثانية بشكل تلقائي عندما يتم تشغيل الكائن drawShapes() هنا قمنا باستدعاء الدالة timeline.getKeyFrames().add(new KeyFrame(Duration.seconds(0.1), (ActionEvent event) -> { drawShapes(gc); })); // يستمر بالعمل بلا توقف حين يتم تشغيله timeline لها لجعل الكائن INDEFINITE و تمرير الثابت setCycleCount() هنا قمنا باستدعاء الدالة timeline.setCycleCount(Timeline.INDEFINITE); // timeline لتشغيل الكائن play() هنا قمنا باستدعاء الدالة timeline.play(); // لتحديد الإتجاه الذي ستتحرك فيه النافذة keyPressed() للدالة Override هنا فعلنا scene.addEventFilter(KeyEvent.KEY_PRESSED, (KeyEvent e) -> { if (null != e.getCode()) { switch (e.getCode()) { // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر المسافة case SPACE: // سيتم إيقاف اللعبة بشكل مؤقت إذا كانت اللعبة شغالة if (timeline.getStatus() == Timeline.Status.RUNNING && isGameOver == false) { timeline.stop(); } // سيتم إعادة اللعبة للعمل إذا كان قد تم إيقافها سابقاً else if (timeline.getStatus() != Timeline.Status.RUNNING && isGameOver == false) { timeline.play(); } // سيتم بدأ اللعبة من جديد في حال كان قد تم إيقاف اللعبة لأن اللاعب قد خسر else if (timeline.getStatus() != Timeline.Status.RUNNING && isGameOver == true) { isGameOver = false; timeline.play(); moves = 0; totalScore = 0; fruitEaten = 0; lengthOfSnake = 3; right = true; left = false; xPos = random.nextInt(22); yPos = 5 + random.nextInt(17); } break; // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم الأيمن case RIGHT: // إذا لم تكن الأفعى تسير في الإتجاه الأيسر سيتم توجيهها نحو الإتجاه الأيمن moves++; right = true; if (!left) { right = true; } else { right = false; left = true; } up = false; down = false; break; // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم الأيسر case LEFT: // إذا لم تكن الأفعى تسير في الإتجاه الأيمن سيتم توجيهها نحو الإتجاه الأيسر moves++; left = true; if (!right) { left = true; } else { left = false; right = true; } up = false; down = false; break; // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم المتجه للأعلى case UP: // إذا لم تكن الأفعى تسير في اتجاه الأسفل سيتم توجيهها نحو الأعلى moves++; up = true; if (!down) { up = true; } else { up = false; down = true; } left = false; right = false; break; // هنا قمنا بتحديد ما سيحدث إذا قام اللاعب بالنقر على زر السهم المتجه للأسفل case DOWN: // إذا لم تكن الأفعى تسير في اتجاه الأعلى سيتم توجيهها نحو الأسفل moves++; down = true; if (!up) { down = true; } else { up = true; down = false; } left = false; right = false; break; } } }); } // هنا قمنا بتشغيل التطبيق public static void main(String[] args) { launch(args); } }