
كيفية برمجة لعبة مطابقة العناصر Match-3 على LibGDX Java Android Studio
تُعد ألعاب مطابقة العناصر (Match-3) من أكثر أنواع الألعاب شعبية عل
ى الأجهزة المحمولة، بفضل بساطة آلياتها وإمكانياتها اللانهائية للتحدي والمتعة.
في هذا المقال، سنقوم بإنشاء لعبة Match-3 احترافية من البداية باستخدام إطار
عمل LibGDX القوي والمرن، والذي يتيح لنا تطوير الألعاب مرة واحدة ونشرها
على منصات متعددة، بما في ذلك Android. سنركز على الجوانب الأساسية من
تصميم واجهة المستخدم (UI) التفاعلية، منطق اللعبة الرئيسي
(مطابقة العناصر، الحركة، توليد العناصر الجديدة)، إضافة الرسوم المتحركة الجذابة،
المؤثرات الصوتية، إدارة شاشات اللعبة المتعددة، والعناصر الخاصة، وصولاً إلى حفظ النقاط العالية.
* المتطلبات الأساسية:
Android Studio مثبت، معرفة أساسية بلغة Java،
فهم لمفاهيم البرمجة الكائنية التوجه (OOP).
خطوات بناء لعبة مطابقة العناصر (Match-3) باستخدام LibGDX (Java)
الخطوة 1: إعداد مشروع LibGDX في Android Studio وتهيئة بيئة العمل
أولاً، نحتاج إلى إعداد بيئة المشروع وتجهيزها للاعتمادات الإضافية.
1- تحميل LibGDX Project Setup Tool:
انتقل إلى الموقع الرسمي لـ LibGDX وقم بتحميل أداة الإعداد:
https://libgdx.com/download.html
(عادة ما تكون gdx-setup.jar).
2- تشغيل أداة الإعداد:
افتح موجه الأوامر (CMD/Terminal) وانتقل إلى المجلد الذي حفظت فيه gdx-setup.jar. ثم قم بتشغيله:
java -jar gdx-setup.jar
--
3- تكوين المشروع:
- Name: Match3Game
- Package: com.yourcompany.match3game
(استبدل yourcompany باسم شركتك أو اسمك).
- Game Class: Match3Game
- Destination: اختر مجلدًا فارغًا حيث سيتم إنشاء المشروع.
- Android SDK: تأكد من أن المسار إلى Android SDK صحيح.
- Sub Projects: تأكد من تحديد Desktop و Android.
- Extensions: هنا نقطة مهمة: ستحتاج إلى تحديد freetype (لإدارة الخطوط بشكل أفضل) و
box2d (حتى لو لم تستخدمها للفيزياء، فإنها غالبًا ما تُضمّن مع مشاريع LibGDX الأكبر وقد تحتاجها لاحقًا).
- انقر على "Generate".
4- استيراد المشروع إلى Android Studio:
افتح Android Studio، اختر Open an existing Android Studio project،
اذهب إلى المجلد الذي أنشأه gdx-setup.jar وحدده،
انتظر حتى يقوم Android Studio بمزامنة مشروع Gradle.
5- تحديث build.gradle (اختياري لكن موصى به):
تحقق من ملف core/build.gradle وتأكد من إضافة الاعتمادات التالية ضمن قسم dependencies
(يجب أن تكون قد أضيفت تلقائيًا إذا اخترتها في أداة الإعداد):
Gradle
// في ملف core/build.gradle
dependencies {
// ... (الاعتمادات الموجودة)
api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" // لإدارة الخطوط بشكل أفضل
api "com.badlogicgames.gdx:gdx-box2d:$gdxVersion" // (إذا اخترتها، ليست ضرورية لـ Match-3 بحد ذاتها)
}
--
بعد أي تعديلات، قم بمزامنة مشروع Gradle بالنقر على "Sync Now" إذا ظهرت لك.
الخطوة 2: إدارة الأصول (Assets) والأصوات والـ Skin
تُعد الأصول المرئية والصوتية أساسية لجعل اللعبة جذابة.
1- صور العناصر (Gems/Tiles):
- صمم 5 صور مختلفة (أو أكثر، حسب NUM_ELEMENT_TYPES).
- اجعل حجم كل صورة 64x64 بكسل (أو الحجم الذي حددته لـ GameElement.SIZE).
- احفظها بأسماء مثل gem0.png, gem1.png, gem2.png, gem3.png, gem4.png.
2- صور العناصر الخاصة:
- إذا أردت عناصر خاصة مثل القنابل، قم بتصميم صور لها.
مثال: bomb_gem.png, color_bomb_gem.png, line_clearer_gem.png.
3- ملفات الصوت:
- احصل على ملفات صوتية بصيغ مثل .mp3 أو .ogg أو .wav.
مثال: match_sound.ogg (عند المطابقة)، swap_sound.ogg (عند التبديل)،
background_music.ogg (موسيقى خلفية).
4- Skin لـ Scene2D.ui:
لتشغيل واجهة المستخدم المتقدمة، تحتاج إلى "Skin" يحتوي على الأنسجة
والخطوط وأنماط الأزرار. يمكنك استخدام uiskin.json الافتراضي لـ LibGDX أو إنشاء واحد خاص بك.
الخيار الأسهل: قم بتحميل uiskin.json, uiskin.atlas, uiskin.png من أمثلة
LibGDX (ابحث عن "LibGDX uiskin") أو من مستودع LibGDX GitHub.
5- وضع جميع الأصول:
ضع جميع هذه الملفات (الصور، الأصوات، ملفات الـ Skin) في مجلد
android/assets داخل مشروعك. هذا هو المجلد الذي يبحث فيه LibGDX عن الأصول.
الخطوة 3: تصميم فئات اللعبة الأساسية (العناصر ولوحة اللعب)
سنقوم بإنشاء الفئات الأساسية للعبة، مع الأخذ في الاعتبار الرسوم المتحركة والتفاعل المستقبلي.
أ. فئة GameElement (العنصر)
هذه الفئة ستمثل كل عنصر في لوحة اللعبة، وستدعم الرسوم المتحركة
بفضل وراثتها من Actor من مكتبة Scene2D.
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.graphics.Texture;import com.badlogic.gdx.graphics.g2d.SpriteBatch;import com.badlogic.gdx.math.Interpolation;import com.badlogic.gdx.scenes.scene2d.Actor; // هام: للرسوم المتحركة والأكشنزimport com.badlogic.gdx.scenes.scene2d.actions.Actions;
public class GameElement extends Actor { public static final float SIZE = 64; // حجم العنصر بالبكسل
private int type; // نوع العنصر (مثال: 0 لأحمر، 1 لأزرق، إلخ.) private int logicalRow, logicalCol; // موضع العنصر المنطقي في اللوحة (غير متحرك) private Texture texture; // نسيج (صورة) العنصر private boolean isSelected; // هل العنصر محدد حالياً؟
public enum ElementSpecialType { NONE, BOMB, COLOR_BOMB, LINE_CLEARER // أنواع خاصة للعناصر } private ElementSpecialType specialType;
// Constructor للعناصر العادية public GameElement(int type, int row, int col, Texture texture) { this(type, row, col, texture, ElementSpecialType.NONE); }
// Constructor للعناصر الخاصة public GameElement(int type, int row, int col, Texture texture, ElementSpecialType specialType) { this.type = type; this.logicalRow = row; this.logicalCol = col; this.texture = texture; this.isSelected = false; this.specialType = specialType;
// تعيين حجم وموضع Actor الأولي (موضع الرسم) setSize(SIZE, SIZE); setPosition(col * SIZE, row * SIZE); }
// لتحديث الموضع المنطقي للعنصر بعد الحركة public void setLogicalPosition(int row, int col) { this.logicalRow = row; this.logicalCol = col; }
// بدء رسوم متحركة لتحريك العنصر إلى موضع جديد public void startMoveTo(int newRow, int newCol, float duration) { float targetX = newCol * SIZE; float targetY = newRow * SIZE;
// إزالة أي Actions سابقة لمنع التداخل clearActions(); // إضافة Action للحركة addAction(Actions.sequence( Actions.moveTo(targetX, targetY, duration, Interpolation.sineOut), // حركة سلسة Actions.run(() -> { // عندما تنتهي الحركة isMoving = false; // بعد انتهاء الحركة، تحديث الموضع المنطقي this.setLogicalPosition(newRow, newCol); }) )); isMoving = true; }
public void startFadeOut(float duration) { clearActions(); addAction(Actions.sequence( Actions.fadeOut(duration) )); }
// رسم العنصر @Override public void draw(SpriteBatch batch, float parentAlpha) { // نستخدم getX() و getY() من Actor لأنها تعكس الموضع المتحرك // نستخدم getColor().a للشفافية إذا كان العنصر يتلاشى batch.setColor(getColor().r, getColor().g, getColor().b, getColor().a * parentAlpha); batch.draw(texture, getX(), getY(), SIZE, SIZE); batch.setColor(1, 1, 1, 1); // إعادة تعيين اللون الافتراضي
// رسم إطار التحديد إذا كان العنصر محدداً batch.draw(texture, drawX, drawY, SIZE, SIZE); if (isSelected) { // رسم إطار التحديد، ستحتاج إلى Texture للإطار // مثال بسيط: رسم مربع أبيض شبه شفاف فوق العنصر // (يجب أن يكون لديك texture للاطار أو ترسم مستطيل بـ ShapeRenderer) // batch.setColor(1, 1, 1, 0.5f); // نصف شفاف أبيض // batch.draw(selectionFrameTexture, drawX, drawY, SIZE, SIZE); // batch.setColor(1, 1, 1, 1); // إعادة اللون الأصلي } }
// يجب استدعاء هذا في حلقة update
// يتم استدعاء act(delta) تلقائياً بواسطة الـ Stage // وهو المسؤول عن تحديث الـ Actions @Override public void act(float delta) { super.act(delta); }
// Getters public int getType() { return type; } public int getLogicalRow() { return logicalRow; } public int getLogicalCol() { return logicalCol; } public ElementSpecialType getSpecialType() { return specialType; } public boolean isSelected() { return isSelected; }
// Setters public void setSelected(boolean selected) { this.isSelected = selected; } public void setType(int type, Texture texture) { this.type = type; this.texture = texture; } public void setSpecialType(ElementSpecialType specialType, Texture texture) { this.specialType = specialType; this.texture = texture; }}
--
ب. فئة ScoreManager (إدارة النقاط)
هذه الفئة ستقوم بإدارة النقاط الحالية وحفظ أعلى نقاط اللاعب باستخدام Preferences.
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.Gdx;import com.badlogic.gdx.Preferences;
public class ScoreManager { private static final String PREFS_NAME = "Match3GamePrefs"; private static final String HIGH_SCORE_KEY = "highScore";
private Preferences prefs; private int currentScore; private int highScore;
public ScoreManager() { prefs = Gdx.app.getPreferences(PREFS_NAME); highScore = prefs.getInteger(HIGH_SCORE_KEY, 0); currentScore = 0; }
public void addScore(int points) { currentScore += points; if (currentScore > highScore) { highScore = currentScore; prefs.putInteger(HIGH_SCORE_KEY, highScore); prefs.flush(); // حفظ التغييرات على الفور } }
public void resetScore() { currentScore = 0; }
public int getCurrentScore() { return currentScore; }
public int getHighScore() { return highScore; }
// يمكنك إضافة دالة لحفظ النقاط يدويا (ليست ضرورية هنا لأنها تحفظ عند تجاوز الرقم القياسي) // public void saveScore() { // prefs.putInteger(HIGH_SCORE_KEY, highScore); // prefs.flush(); // }}
--
ج. فئة GameBoard (لوحة اللعب)
هذه هي الفئة الأساسية لمنطق اللعبة، حيث تتم معالجة التبديل، المطابقة، السقوط، وتوليد العناصر.
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.Gdx;import com.badlogic.gdx.graphics.Texture;import com.badlogic.gdx.graphics.g2d.SpriteBatch;import com.badlogic.gdx.scenes.scene2d.Stage;import com.badlogic.gdx.utils.Array;import com.badlogic.gdx.utils.Disposable;import com.badlogic.gdx.utils.Timer;import com.badlogic.gdx.audio.Sound;
import java.util.ArrayList;import java.util.Collections;import java.util.HashSet;import java.util.List;import java.util.Random;import java.util.Set;
public class GameBoard implements Disposable { public static final int COLS = 8; public static final int ROWS = 8; public static final int NUM_ELEMENT_TYPES = 5; // عدد أنواع العناصر العادية
private GameElement[][] board; private Texture[] elementTextures; // أنسجة العناصر العادية private Texture bombTexture, colorBombTexture, lineClearerTexture; // أنسجة العناصر الخاصة private Random random; private GameElement selectedElement1; // العنصر الأول المحدد باللمس private GameElement selectedElement2; // العنصر الثاني الذي تم التبديل معه
private ScoreManager scoreManager; private Stage stage; // Stage لإدارة GameElements كـ Actors
private Sound matchSound, swapSound; // المؤثرات الصوتية
// حالات اللعبة لمنع التفاعل أثناء الرسوم المتحركة ومعالجة اللوحة public enum GameState { WAITING_FOR_INPUT, // انتظار تحديد العنصر الأول ELEMENT_SELECTED, // تم تحديد العنصر الأول، انتظار الثاني أو السحب SWAPPING, // عناصر تتبادل الأماكن PROCESSING_BOARD, // معالجة التطابقات، السقوط، التوليد GAME_OVER // اللعبة انتهت } public GameState currentGameState; // جعلها عامة للوصول من GameScreen
public GameBoard(ScoreManager scoreManager, Stage stage, Sound matchSound, Sound swapSound) { this.scoreManager = scoreManager; this.stage = stage; this.matchSound = matchSound; this.swapSound = swapSound; board = new GameElement[ROWS][COLS]; elementTextures = new Texture[NUM_ELEMENT_TYPES]; random = new Random();
// تحميل أنسجة العناصر العادية (gem0.png, gem1.png, ...) for (int i = 0; i < NUM_ELEMENT_TYPES; i++) { elementTextures[i] = new Texture(Gdx.files.internal("gem" + i + ".png")); } // تحميل أنسجة العناصر الخاصة bombTexture = new Texture(Gdx.files.internal("bomb_gem.png")); colorBombTexture = new Texture(Gdx.files.internal("color_bomb_gem.png")); lineClearerTexture = new Texture(Gdx.files.internal("line_clearer_gem.png"));
initializeBoard(); currentGameState = GameState.WAITING_FOR_INPUT; }
private void initializeBoard() { // توليد عناصر اللوحة الابتدائية، مع التأكد من عدم وجود تطابقات في البداية for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { int type; do { type = random.nextInt(NUM_ELEMENT_TYPES); } while (isStartingMatch(r, c, type)); GameElement newElement = new GameElement(type, r, c, elementTextures[type]); board[r][c] = newElement; stage.addActor(newElement); // إضافة العنصر كـ Actor إلى الـ Stage } } }
// يتحقق مما إذا كان هناك تطابق 3 عناصر في البداية لتجنب ذلك private boolean isStartingMatch(int row, int col, int type) { // تحقق أفقي if (col >= 2 && board[row][col - 1] != null && board[row][col - 2] != null && board[row][col - 1].getType() == type && board[row][col - 2].getType() == type) { return true; } // تحقق عمودي if (row >= 2 && board[row - 1][col] != null && board[row - 2][col] != null && board[row - 1][col].getType() == type && board[row - 2][col].getType() == type) { return true; } return false; }
public void update(float delta) { // منطق اللعبة يعتمد على الحالة الحالية switch (currentGameState) { case SWAPPING: // انتظر انتهاء الرسوم المتحركة للتبديل if (!isAnyElementMoving()) { currentGameState = GameState.PROCESSING_BOARD; // بعد انتهاء التبديل، تحقق من وجود تطابقات جديدة if (findMatches().isEmpty()) { // إذا لم يكن هناك تطابق، قم بالتبديل مرة أخرى لإرجاع العناصر swapElementsAnimation(selectedElement1, selectedElement2, 0.2f, true); // يتم إعادة تعيين الحالة إلى WAITING_FOR_INPUT بعد انتهاء الحركة العكسية } else { // هناك تطابقات، ابدأ المعالجة processMatches(); } // إعادة تعيين التحديد بغض النظر عن التبديل selectedElement1.setSelected(false); if (selectedElement2 != null) selectedElement2.setSelected(false); selectedElement1 = null; selectedElement2 = null; } break; case PROCESSING_BOARD: // منطق معالجة اللوحة (إزالة، سقوط، توليد) if (!isAnyElementMoving()) { // تأكد من عدم وجود عناصر متحركة List<GameElement> matchedElements = findMatches(); if (!matchedElements.isEmpty()) { removeMatchedElements(matchedElements); // تأخير بسيط قبل السقوط بعد اختفاء العناصر Timer.schedule(new Timer.Task() { @Override public void run() { fillEmptySpaces(); // سحب العناصر لأسفل وملء الفراغات // لا نغير الحالة هنا، لأن fillEmptySpaces ستبدأ حركات السقوط // وسنظل في PROCESSING_BOARD حتى تتوقف كل الحركات } }, 0.3f); } else { // لا توجد المزيد من التطابقات، العودة لحالة الانتظار currentGameState = GameState.WAITING_FOR_INPUT; } } break; case WAITING_FOR_INPUT: case ELEMENT_SELECTED: case GAME_OVER: // لا شيء يحدث في هذه الحالات في دالة update break; } }
// يتعامل مع النقر الأولي لتحديد العنصر public void handleTouchDown(float screenX, float screenY) { if (currentGameState != GameState.WAITING_FOR_INPUT && currentGameState != GameState.ELEMENT_SELECTED) { return; // لا تعالج المدخلات إذا كانت اللعبة في حالة معالجة أو تبديل }
float touchX = screenX; float touchY = Gdx.graphics.getHeight() - screenY; // تحويل إحداثيات الشاشة إلى إحداثيات عالم اللعبة
int touchedCol = (int) (touchX / GameElement.SIZE); int touchedRow = (int) (touchY / GameElement.SIZE);
if (touchedRow >= 0 && touchedRow < ROWS && touchedCol >= 0 && touchedCol < COLS) { GameElement touchedElement = board[touchedRow][touchedCol];
if (currentGameState == GameState.WAITING_FOR_INPUT) { selectedElement1 = touchedElement; selectedElement1.setSelected(true); currentGameState = GameState.ELEMENT_SELECTED; } else if (currentGameState == GameState.ELEMENT_SELECTED) { // إذا تم النقر على عنصر آخر غير المحدد (دون سحب)، قم بتحديده بدلاً من السابق selectedElement1.setSelected(false); selectedElement1 = touchedElement; selectedElement1.setSelected(true); } } }
// يتعامل مع إيماءة السحب (Fling) لتبديل العناصر public void handleFling(float velocityX, float velocityY) { if (currentGameState != GameState.ELEMENT_SELECTED || selectedElement1 == null) { return; // يجب أن يكون هناك عنصر محدد بالفعل }
int targetRow = selectedElement1.getLogicalRow(); int targetCol = selectedElement1.getLogicalCol();
// تحديد اتجاه السحب (الأكبر في القيمة المطلقة يحدد الاتجاه) if (Math.abs(velocityX) > Math.abs(velocityY)) { // سحب أفقي if (velocityX > 0) { // لليمين targetCol++; } else { // لليسار targetCol--; } } else { // سحب عمودي if (velocityY > 0) { // للأسفل (في نظام الإحداثيات الشاشي، الأسفل يعني Y أكبر) targetRow--; // في نظام إحداثيات اللعبة، Y أكبر تعني صف أقل } else { // للأعلى targetRow++; } }
// التأكد من أن الموضع المستهدف ضمن حدود اللوحة if (targetRow >= 0 && targetRow < ROWS && targetCol >= 0 && targetCol < COLS) { selectedElement2 = board[targetRow][targetCol]; if (selectedElement2 != null && areAdjacent(selectedElement1, selectedElement2)) { swapElementsAnimation(selectedElement1, selectedElement2, 0.2f, false); currentGameState = GameState.SWAPPING; // الدخول في حالة التبديل } else { // إذا لم يكن متجاوراً أو كان فارغاً، قم بإلغاء التحديد selectedElement1.setSelected(false); selectedElement1 = null; selectedElement2 = null; currentGameState = GameState.WAITING_FOR_INPUT; } } else { // سحب خارج حدود اللوحة، إلغاء التحديد selectedElement1.setSelected(false); selectedElement1 = null; selectedElement2 = null; currentGameState = GameState.WAITting_FOR_INPUT; } }
// تحقق مما إذا كان العنصران متجاورين أفقياً أو عمودياً فقط private boolean areAdjacent(GameElement e1, GameElement e2) { return (e1.getLogicalRow() == e2.getLogicalRow() && Math.abs(e1.getLogicalCol() - e2.getLogicalCol()) == 1) || (e1.getLogicalCol() == e2.getLogicalCol() && Math.abs(e1.getLogicalRow() - e2.getLogicalRow()) == 1); }
// تبديل العناصر مع رسوم متحركة private void swapElementsAnimation(GameElement e1, GameElement e2, float duration, boolean isReverse) { swapSound.play(0.7f); // تشغيل صوت التبديل
// تبديل المراجع في المصفوفة المنطقية board[e1.getLogicalRow()][e1.getLogicalCol()] = e2; board[e2.getLogicalRow()][e2.getLogicalCol()] = e1;
// بدء الرسوم المتحركة للحركة e1.startMoveTo(e2.getLogicalRow(), e2.getLogicalCol(), duration); e2.startMoveTo(e1.getLogicalRow(), e1.getLogicalCol(), duration);
// تحديث المواقع المنطقية فوراً بعد بدء الحركة int tempRow1 = e1.getLogicalRow(); int tempCol1 = e1.getLogicalCol(); e1.setLogicalPosition(e2.getLogicalRow(), e2.getLogicalCol()); e2.setLogicalPosition(tempRow1, tempCol1);
if (isReverse) { // إذا كان التبديل عكسيا (لم يكن هناك تطابق)، أعد الحالة إلى WAITING_FOR_INPUT بعد الانتهاء Timer.schedule(new Timer.Task() { @Override public void run() { currentGameState = GameState.WAITING_FOR_INPUT; selectedElement1.setSelected(false); selectedElement2.setSelected(false); selectedElement1 = null; selectedElement2 = null; } }, duration); } }
// معالجة التطابقات المتتالية (cascading matches) private void processMatches() { List<GameElement> matchedElements; do { matchedElements = findMatches(); if (!matchedElements.isEmpty()) { removeMatchedElements(matchedElements); // تأخير بسيط قبل السقوط للسماح للرسوم المتحركة بالظهور Timer.schedule(new Timer.Task() { @Override public void run() { fillEmptySpaces(); // سحب العناصر وملء الفراغات } }, 0.3f); } } while (!matchedElements.isEmpty() || isAnyElementMoving()); // استمر طالما توجد تطابقات أو حركات }
// البحث عن جميع التطابقات (3+، L-shape, T-shape) private List<GameElement> findMatches() { Set<GameElement> allMatches = new HashSet<>(); // استخدام Set لتجنب التكرار
// 1. البحث عن تطابقات أفقية (3+, 4+, 5+) for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS - 2; c++) { if (board[r][c] != null && board[r][c+1] != null && board[r][c+2] != null && board[r][c].getType() == board[r][c+1].getType() && board[r][c].getType() == board[r][c+2].getType()) {
List<GameElement> currentHorizontalMatch = new ArrayList<>(); currentHorizontalMatch.add(board[r][c]); currentHorizontalMatch.add(board[r][c+1]); currentHorizontalMatch.add(board[r][c+2]);
for (int k = c + 3; k < COLS; k++) { if (board[r][k] != null && board[r][k].getType() == board[r][c].getType()) { currentHorizontalMatch.add(board[r][k]); } else { break; } } allMatches.addAll(currentHorizontalMatch); } } }
// 2. البحث عن تطابقات عمودية (3+, 4+, 5+) for (int c = 0; c < COLS; c++) { for (int r = 0; r < ROWS - 2; r++) { if (board[r][c] != null && board[r+1][c] != null && board[r+2][c] != null && board[r][c].getType() == board[r+1][c].getType() && board[r][c].getType() == board[r+2][c].getType()) {
List<GameElement> currentVerticalMatch = new ArrayList<>(); currentVerticalMatch.add(board[r][c]); currentVerticalMatch.add(board[r+1][c]); currentVerticalMatch.add(board[r+2][c]);
for (int k = r + 3; k < ROWS; k++) { if (board[k][c] != null && board[k][c].getType() == board[r][c].getType()) { currentVerticalMatch.add(board[k][c]); } else { break; } } allMatches.addAll(currentVerticalMatch); } } }
// 3. البحث عن تطابقات على شكل حرف L أو T (أكثر تعقيداً، هذا مثال مبسط) // يتم ذلك عن طريق التحقق من تقاطع التطابقات الأفقية والعمودية. // يمكن أن تزيد من تعقيد المنطق بشكل كبير. لأغراض التوضيح: // تحقق من كل عنصر إذا كان جزءًا من تطابق أفقي وتطابق عمودي في نفس الوقت // (وهو ما يتم تضمينه بالفعل عن طريق إضافة كل من التطابقات الأفقية والعمودية إلى نفس الـ Set)
return new ArrayList<>(allMatches); }
private void removeMatchedElements(List<GameElement> matches) { int scoreToAdd = 0; if (!matches.isEmpty()) { matchSound.play(0.7f); // تشغيل صوت المطابقة }
// منطق توليد العناصر الخاصة قبل إزالة العناصر المطابقة // يمكن تحسين هذا المنطق ليكون أكثر تعقيداً بناءً على حجم أو شكل التطابق GameElement specialElementToCreate = null; int specialElementRow = -1, specialElementCol = -1;
if (matches.size() >= 4) { // اختر موقع العنصر الخاص ليكون في وسط التطابق أو في مكان محدد // لأغراض التبسيط، سنختار أول عنصر في القائمة specialElementRow = matches.get(0).getLogicalRow(); specialElementCol = matches.get(0).getLogicalCol();
if (matches.size() == 4) { // Match-4: توليد قنبلة (تزيل 3x3) specialElementToCreate = new GameElement(matches.get(0).getType(), specialElementRow, specialElementCol, bombTexture, GameElement.ElementSpecialType.BOMB); } else if (matches.size() >= 5) { // Match-5 أو أكثر: توليد قنبلة ألوان (تزيل جميع العناصر من نفس النوع) specialElementToCreate = new GameElement(matches.get(0).getType(), specialElementRow, specialElementCol, colorBombTexture, GameElement.ElementSpecialType.COLOR_BOMB); } // يمكن إضافة منطق لـ LINE_CLEARER من تطابق 4 في صف/عمود مستقيم، أو L/T }
// إزالة العناصر المطابقة من اللوحة وبدء رسوم متحركة للتلاشي for (GameElement element : matches) { scoreToAdd += 10; board[element.getLogicalRow()][element.getLogicalCol()] = null; // تصفير العنصر في المصفوفة element.startFadeOut(0.2f); // بدء رسوم متحركة للاختفاء Timer.schedule(new Timer.Task() { @Override public void run() { element.remove(); // إزالة الـ Actor من الـ Stage بعد انتهاء التلاشي } }, 0.2f); } scoreManager.addScore(scoreToAdd);
// إضافة العنصر الخاص الجديد بعد إزالة العناصر المطابقة if (specialElementToCreate != null) { board[specialElementRow][specialElementCol] = specialElementToCreate; stage.addActor(specialElementToCreate); } }
private void fillEmptySpaces() { // سحب العناصر لأسفل لملء الفراغات boolean elementsMoved = false; for (int c = 0; c < COLS; c++) { for (int r = 0; r < ROWS; r++) { if (board[r][c] == null) { // ابحث عن عنصر فوق هذا الفراغ for (int k = r + 1; k < ROWS; k++) { if (board[k][c] != null) { GameElement fallingElement = board[k][c]; board[r][c] = fallingElement; board[k][c] = null; fallingElement.startMoveTo(r, c, 0.3f); // رسوم متحركة للسقوط fallingElement.setLogicalPosition(r, c); // تحديث الموضع المنطقي elementsMoved = true; break; } } } } }
// توليد عناصر جديدة لملء الفراغات في الأعلى (مع الرسوم المتحركة) for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (board[r][c] == null) { int type = random.nextInt(NUM_ELEMENT_TYPES); GameElement newElement = new GameElement(type, r, c, elementTextures[type]); // لجعلها تسقط من خارج الشاشة newElement.setY((ROWS + 1) * GameElement.SIZE); // ابدأ من فوق اللوحة newElement.setLogicalPosition(r, c); stage.addActor(newElement); newElement.startMoveTo(r, c, 0.5f); // رسوم متحركة للسقوط للجديد elementsMoved = true; } } }
// إذا كانت هناك عناصر تحركت، سننتظر حتى تتوقف جميع الحركات ثم نعود لحالة WAITING_FOR_INPUT if (elementsMoved) { // سيتم التعامل مع الانتقال إلى WAITING_FOR_INPUT في update() بعد توقف كل الحركات } else if (!isAnyElementMoving()) { // لا توجد حركات جديدة ولا حركات قيد التنفيذ currentGameState = GameState.WAITING_FOR_INPUT; } }
// تتحقق مما إذا كان أي عنصر في اللوحة يتحرك حالياً private boolean isAnyElementMoving() { for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (board[r][c] != null && board[r][c].hasActions()) { return true; } } } return false; }
// تفعيل العناصر الخاصة (يتم استدعاؤها بعد التبديل إذا كان أحد العناصر المبدلة خاصًا) private void activateSpecialElement(GameElement specialElement) { List<GameElement> affectedElements = new ArrayList<>(); switch (specialElement.getSpecialType()) { case BOMB: // إزالة 3x3 حول القنبلة for (int r = specialElement.getLogicalRow() - 1; r <= specialElement.getLogicalRow() + 1; r++) { for (int c = specialElement.getLogicalCol() - 1; c <= specialElement.getLogicalCol() + 1; c++) { if (r >= 0 && r < ROWS && c >= 0 && c < COLS && board[r][c] != null) { affectedElements.add(board[r][c]); } } } break; case COLOR_BOMB: // إزالة جميع العناصر من نفس نوع العنصر الذي تم تبديل قنبلة الألوان معه int typeToClear = -1; // يجب تحديد نوع العنصر الذي تم تبديله مع قنبلة الألوان if (selectedElement2 != null && selectedElement2 != specialElement) { // العنصر الذي تم تبديله مع القنبلة typeToClear = selectedElement2.getType(); } else if (selectedElement1 != null && selectedElement1 != specialElement) { typeToClear = selectedElement1.getType(); } else { // إذا لم يتم تبديلها مع عنصر آخر، يمكن اختيار نوع عشوائي أو الأكثر شيوعًا typeToClear = random.nextInt(NUM_ELEMENT_TYPES); }
for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (board[r][c] != null && board[r][c].getType() == typeToClear) { affectedElements.add(board[r][c]); } } } break; case LINE_CLEARER: // إزالة الصف والعمود بالكامل for (int c = 0; c < COLS; c++) { // العمود if (board[specialElement.getLogicalRow()][c] != null) { affectedElements.add(board[specialElement.getLogicalRow()][c]); } } for (int r = 0; r < ROWS; r++) { // الصف if (board[r][specialElement.getLogicalCol()] != null) { affectedElements.add(board[r][specialElement.getLogicalCol()]); } } break; case NONE: break; }
// إزالة العنصر الخاص نفسه من اللوحة (بعد إضافة تأثيراته) board[specialElement.getLogicalRow()][specialElement.getLogicalCol()] = null; specialElement.remove(); // إزالة الـ Actor من الـ Stage
// إزالة العناصر المتأثرة removeMatchedElements(affectedElements); currentGameState = GameState.PROCESSING_BOARD; // للبدء في سحب العناصر وملء الفراغات }
@Override public void dispose() { for (Texture texture : elementTextures) { texture.dispose(); } bombTexture.dispose(); colorBombTexture.dispose(); lineClearerTexture.dispose(); // الـ Stage والـ Actors الموجودة فيها ستتم التخلص منها بواسطة Match3Game }}
--
الخطوة 4: تصميم الشاشات الرئيسية للعبة
سنقوم بإنشاء فئات منفصلة لكل شاشة من شاشات اللعبة
(القائمة الرئيسية، شاشة اللعب، شاشة نهاية اللعبة) باستخدام واجهة Screen من LibGDX.
أ. فئة Match3Game (فئة اللعبة الرئيسية - Game Class)
هذه الفئة ستكون نقطة الدخول الرئيسية للعبة وستدير الانتقال بين الشاشات المختلفة.
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.Game; // هام: يرث من Game الآنimport com.badlogic.gdx.Gdx;import com.badlogic.gdx.graphics.g2d.BitmapFont;import com.badlogic.gdx.graphics.g2d.SpriteBatch;import com.badlogic.gdx.graphics.Color;import com.badlogic.gdx.audio.Music;import com.badlogic.gdx.audio.Sound;
public class Match3Game extends Game { // الموارد المشتركة التي تحتاجها جميع الشاشات public SpriteBatch batch; public BitmapFont font; public ScoreManager scoreManager; public Music backgroundMusic; public Sound matchSound; public Sound swapSound;
@Override public void create() { batch = new SpriteBatch(); font = new BitmapFont(); font.setColor(Color.WHITE); // لون الخط font.getData().setScale(1.5f); // حجم الخط
scoreManager = new ScoreManager();
// تحميل الصوتيات backgroundMusic = Gdx.audio.newMusic(Gdx.files.internal("background_music.ogg")); matchSound = Gdx.audio.newSound(Gdx.files.internal("match_sound.ogg")); swapSound = Gdx.audio.newSound(Gdx.files.internal("swap_sound.ogg"));
backgroundMusic.setLooping(true); // تكرار الموسيقى backgroundMusic.setVolume(0.5f); // ضبط مستوى الصوت backgroundMusic.play(); // تشغيل الموسيقى
// تعيين الشاشة الأولى عند بدء اللعبة (شاشة القائمة الرئيسية) setScreen(new MainMenuScreen(this)); }
@Override public void dispose() { super.dispose(); // مهم جداً لاستدعاء dispose للشاشة الحالية batch.dispose(); font.dispose(); backgroundMusic.dispose(); matchSound.dispose(); swapSound.dispose(); }}
--
ب. فئة MainMenuScreen.java (شاشة القائمة الرئيسية)
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.Gdx;import com.badlogic.gdx.Screen;import com.badlogic.gdx.graphics.OrthographicCamera;import com.badlogic.gdx.utils.ScreenUtils;import com.badlogic.gdx.scenes.scene2d.Stage;import com.badlogic.gdx.scenes.scene2d.ui.Skin;import com.badlogic.gdx.scenes.scene2d.ui.TextButton;import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
public class MainMenuScreen implements Screen { final Match3Game game; OrthographicCamera camera; Stage stage; // Stage للـ UI Skin skin; // Skin لـ Scene2D.ui
public MainMenuScreen(final Match3Game game) { this.game = game; camera = new OrthographicCamera(); // حجم الكاميرا لشاشة القائمة (يمكن أن يكون مختلفًا عن شاشة اللعب) camera.setToOrtho(false, 800, 480);
stage = new Stage(new com.badlogic.gdx.utils.viewport.FitViewport(camera.viewportWidth, camera.viewportHeight, camera), game.batch); Gdx.input.setInputProcessor(stage); // هام: لكي تستقبل الـ Stage المدخلات
skin = new Skin(Gdx.files.internal("uiskin.json")); // تحميل الـ Skin
// إنشاء زر "Start Game" TextButton startGameButton = new TextButton("Start Game", skin); startGameButton.setSize(200, 60); // وضعه في منتصف الشاشة تقريباً startGameButton.setPosition(camera.viewportWidth / 2 - startGameButton.getWidth() / 2, camera.viewportHeight / 2 - 30);
// إضافة مستمع للأحداث للزر startGameButton.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, com.badlogic.gdx.scenes.scene2d.Actor actor) { game.setScreen(new GameScreen(game)); // الانتقال إلى شاشة اللعب dispose(); // التخلص من موارد هذه الشاشة } });
stage.addActor(startGameButton); }
@Override public void show() { // يتم استدعاؤه عندما تصبح هذه الشاشة هي الشاشة النشطة }
@Override public void render(float delta) { ScreenUtils.clear(0.0f, 0.0f, 0.2f, 1); // خلفية زرقاء داكنة
camera.update(); game.batch.setProjectionMatrix(camera.combined);
game.batch.begin(); game.font.draw(game.batch, "Match-3 Game!", 100, 400); game.batch.end();
stage.act(delta); // تحديث الـ Stage (مهم لتشغيل الـ UI) stage.draw(); // رسم عناصر الـ Stage (بما في ذلك الزر) }
@Override public void resize(int width, int height) { stage.getViewport().update(width, height, true); }
@Override public void pause() {}
@Override public void resume() {}
@Override public void hide() {}
@Override public void dispose() { stage.dispose(); skin.dispose(); }}
--
ج. فئة GameScreen.java (شاشة اللعب الرئيسية)
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.Gdx;import com.badlogic.gdx.Screen;import com.badlogic.gdx.graphics.OrthographicCamera;import com.badlogic.gdx.utils.ScreenUtils;import com.badlogic.gdx.input.GestureDetector; // لاستخدام GestureDetectorimport com.badlogic.gdx.math.Vector2; // لإحداثيات اللمس في GestureDetectorimport com.badlogic.gdx.InputMultiplexer; // للتعامل مع مدخلات متعددة (Stage و GestureDetector)
public class GameScreen implements Screen { final Match3Game game; OrthographicCamera camera; GameBoard gameBoard; Stage stage; // Stage للعناصر والرسوم المتحركة private float gameTime; // الوقت المتبقي بالثواني private final float MAX_GAME_TIME = 60; // 60 ثانية للعبة
public GameScreen(final Match3Game game) { this.game = game; camera = new OrthographicCamera(); // حجم الكاميرا لعرض اللوحة، مع مساحة إضافية للنقاط والمؤقت camera.setToOrtho(false, GameBoard.COLS * GameElement.SIZE, GameBoard.ROWS * GameElement.SIZE + 100);
stage = new Stage(new com.badlogic.gdx.utils.viewport.FitViewport(camera.viewportWidth, camera.viewportHeight, camera), game.batch);
// تمرير الأصوات إلى GameBoard gameBoard = new GameBoard(game.scoreManager, stage, game.matchSound, game.swapSound);
gameTime = MAX_GAME_TIME; // تهيئة المؤقت
// إعداد GestureDetector لمعالجة السحب GestureDetector gestureDetector = new GestureDetector(new MyGestureListener(gameBoard));
// استخدام InputMultiplexer للتعامل مع مدخلات من Stage و GestureDetector InputMultiplexer inputMultiplexer = new InputMultiplexer(); inputMultiplexer.addProcessor(stage); // Stage يجب أن تعالج المدخلات لأي عناصر UI فيها (إن وجدت) inputMultiplexer.addProcessor(gestureDetector); // GestureDetector لمعالجة السحب Gdx.input.setInputProcessor(inputMultiplexer); }
@Override public void show() { // يتم استدعاؤه عندما تصبح هذه الشاشة هي الشاشة النشطة }
@Override public void render(float delta) { ScreenUtils.clear(0.2f, 0.2f, 0.2f, 1); // خلفية رمادية داكنة
camera.update(); game.batch.setProjectionMatrix(camera.combined);
gameBoard.update(delta); // تحديث منطق اللعبة (مطابقة، سقوط، توليد) stage.act(delta); // تحديث الـ Stage لتشغيل الرسوم المتحركة
// تحديث المؤقت if (gameBoard.currentGameState != GameBoard.GameState.GAME_OVER) { gameTime -= delta; if (gameTime <= 0) { gameTime = 0; gameBoard.currentGameState = GameBoard.GameState.GAME_OVER; // تعيين حالة اللعبة كـ Game Over game.setScreen(new GameOverScreen(game)); // الانتقال إلى شاشة Game Over dispose(); } }
// الرسم game.batch.begin(); // هنا يمكنك رسم أي خلفيات أو عناصر أخرى لا تنتمي للـ Stage game.batch.end();
stage.draw(); // رسم عناصر الـ Stage (الجواهر المتحركة)
// رسم النقاط والمؤقت (يمكن أن تكون جزءًا من UI في الـ Stage أيضاً) game.batch.begin(); game.font.draw(game.batch, "Score: " + game.scoreManager.getCurrentScore(), 10, camera.viewportHeight - 20); game.font.draw(game.batch, "High Score: " + game.scoreManager.getHighScore(), 10, camera.viewportHeight - 50); game.font.draw(game.batch, String.format("Time: %.1f", gameTime), 10, camera.viewportHeight - 80); game.batch.end(); }
@Override public void resize(int width, int height) { stage.getViewport().update(width, height, true); // تحديث الـ Viewport للـ Stage camera.update(); // تأكد من تحديث الكاميرا }
@Override public void pause() {}
@Override public void resume() {}
@Override public void hide() {}
@Override public void dispose() { gameBoard.dispose(); stage.dispose(); }
// فئة داخلية لمعالجة إيماءات اللمس باستخدام GestureDetector class MyGestureListener implements GestureDetector.GestureListener { private GameBoard board;
public MyGestureListener(GameBoard board) { this.board = board; }
@Override public boolean touchDown(float x, float y, int pointer, int button) { // هذا يعالج النقر الأولي لتحديد العنصر board.handleTouchDown(x, y); return false; // نرجع false إذا أردنا أن تستمر معالجة الحدث لأداة أخرى }
@Override public boolean tap(float x, float y, int count, int button) { return false; } @Override public boolean longPress(float x, float y) { return false; }
@Override public boolean fling(float velocityX, float velocityY, int button) { // هذا هو الحدث الذي يتم استخدامه للسحب! board.handleFling(velocityX, velocityY); return false; }
@Override public boolean pan(float x, float y, float deltaX, float deltaY) { return false; } @Override public boolean panStop(float x, float y, int pointer, int button) { return false; } @Override public boolean zoom(float initialDistance, float distance) { return false; } @Override public boolean pinch(Vector2 initialPointer1, Vector2 initialPointer2, Vector2 pointer1, Vector2 pointer2) { return false; } @Override public void pinchStop() {} }}
--
د. فئة GameOverScreen.java (شاشة نهاية اللعبة)
Java
// في مجلد core/src/com/yourcompany/match3game/package com.yourcompany.match3game;
import com.badlogic.gdx.Gdx;import com.badlogic.gdx.Screen;import com.badlogic.gdx.graphics.OrthographicCamera;import com.badlogic.gdx.utils.ScreenUtils;import com.badlogic.gdx.scenes.scene2d.Stage;import com.badlogic.gdx.scenes.scene2d.ui.Skin;import com.badlogic.gdx.scenes.scene2d.ui.TextButton;import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
public class GameOverScreen implements Screen { final Match3Game game; OrthographicCamera camera; Stage stage; Skin skin;
public GameOverScreen(final Match3Game game) { this.game = game; camera = new OrthographicCamera(); camera.setToOrtho(false, 800, 480);
stage = new Stage(new com.badlogic.gdx.utils.viewport.FitViewport(camera.viewportWidth, camera.viewportHeight, camera), game.batch); Gdx.input.setInputProcessor(stage);
skin = new Skin(Gdx.files.internal("uiskin.json"));
// زر "Play Again" TextButton playAgainButton = new TextButton("Play Again", skin); playAgainButton.setSize(200, 60); playAgainButton.setPosition(camera.viewportWidth / 2 - playAgainButton.getWidth() / 2, camera.viewportHeight / 2 - 30);
playAgainButton.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, com.badlogic.gdx.scenes.scene2d.Actor actor) { game.scoreManager.resetScore(); // إعادة تعيين النقاط game.setScreen(new GameScreen(game)); // بدء لعبة جديدة dispose(); } });
stage.addActor(playAgainButton); }
@Override public void show() {}
@Override public void render(float delta) { ScreenUtils.clear(0.5f, 0.0f, 0.0f, 1); // خلفية حمراء داكنة
camera.update(); game.batch.setProjectionMatrix(camera.combined);
game.batch.begin(); game.font.draw(game.batch, "GAME OVER!", 100, 400); game.font.draw(game.batch, "Your Score: " + game.scoreManager.getCurrentScore(), 100, 350); game.font.draw(game.batch, "High Score: " + game.scoreManager.getHighScore(), 100, 300); game.batch.end();
stage.act(delta); stage.draw(); }
@Override public void resize(int width, int height) { stage.getViewport().update(width, height, true); }
@Override public void pause() {}
@Override public void resume() {}
@Override public void hide() {}
@Override public void dispose() { stage.dispose(); skin.dispose(); }}
--
الخطوة 5: التشغيل والاختبار
بعد الانتهاء من جميع الأكواد، حان الوقت لتشغيل اللعبة واختبارها.
1- تأكد من مجلد assets:
تأكد مرة أخرى أن جميع صور العناصر (العادية والخاصة)، ملفات الصوت، و
ملفات uiskin.json، uiskin.atlas، uiskin.png موجودة في مجلد android/assets.
2- تشغيل نسخة سطح المكتب:
في Android Studio، اختر التكوين desktop.
انقر على زر Run الأخضر. ستفتح نافذة على سطح المكتب تعرض اللعبة.
3- تشغيل نسخة أندرويد:
تأكد من توصيل جهاز أندرويد (مع تمكين وضع تصحيح الأخطاء USB) أو تشغيل محاكي أندرويد.
اختر التكوين android.
انقر على زر Run الأخضر. سيتم تثبيت اللعبة وتشغيلها على جهازك/المحاكي.
4- نصائح للاختبار:
- اختبر التبديل بين العناصر ، تأكد من أن المطابقات تعمل وتتم إزالتها.
- لاحظ رسوم متحركة السقوط والاختفاء.
- اختبر توليد العناصر الخاصة عند مطابقة 4 أو 5 عناصر.
- حاول تفعيل العناصر الخاصة وشاهد تأثيرها ، راقب المؤقت والنقاط.
- تأكد من أن الانتقال بين الشاشات (قائمة، لعب، نهاية لعبة) يعمل بشكل صحيح.
* خاتمة :
لقد قمنا ببناء لعبة مطابقة عناصر (Match-3) متكاملة ومتقدمة باستخدام LibGDX.
من خلال دمج الرسوم المتحركة السلسة، المؤثرات الصوتية الجذابة،
نظام إدارة الشاشات، منطق العناصر الخاصة القوية، ومؤقت اللعبة، أصبحت لديك
الآن أساس متين للعبة يمكن توسيعها وتحسينها بشكل أكبر.
هذا المقال يقدم لك الهيكل الأساسي الذي يمكنك البعد منه لإنشاء تجربة لعب غنية وجذابة!