JavaFX طريقة إنشاء لعبة الأفعى Snake 2D

في هذا الدرس ستتعلم طريقة إنشاء لعبة ( Snake 2D ) إحترافية بإستخدام JavaFX.

javafx 2d snake game source code تحميل كود لعبة الأفعى في جافا


مميزات اللعبة

  • أعلى مجموع يصل إليه اللاعب يبقى مخزناً حتى إذا تم إغلاق اللعبة.
  • يمكن إيقاف و متابعة اللعبة بالنقر على زر المسافة الفارغة Space.
  • إذا لمست الأفعى الحائط لا يخسر اللاعب, لأن الأفعى ستظهر من الجهة المقابلة.
  • يمكن تعديل كود اللعبة بكل سهولة لأنه غير معقد.


بناء اللعبة

  • ملفات الجافا وضعناها مباشرةً في المشروع.
  • الصور وضعناه بداخل مجلد إسمه images.


خيارات التحميل

⇓ تحميل اللعبة ⇓ تحميل المشروع كاملاً ⇓ تحميل مجلد الصور فقط



كود اللعبة

Position.java
// قمنا بإنشاء هذا الكلاس لجعل أي كائن منه يمثل موقع دائرة من الدوائر الموجودة في الأفعى
public class Position {
 
    // إذاً كل دائرة في الأفعى ستكون موجودة في نفس الوقت على خط طول و خط عرض محددين
    int x, y;
 
    // قمنا بتجهيز هذا الكونستركتور لتحديد مكان وجود أي دائرة في الأفعى لحظة إنشاء الكائن منه
    public Position(int x, int y) {
        this.x = x;
        this.y = y;
    }
 
}
		

Main.java
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;
            }
        }
		
    }

	
    @Override
    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);
    }

}
		

الدورات

أدوات مساعدة

الأقسام

دورات
مقالات أسئلة مشاريع كتب