شرح React: كيفية بناء لعبة 2048 باستخدام React مع TypeScript وتحريك العناصر بسلاسة
مقدمة: بناء لعبة 2048 في React بطريقة عملية
في هذا الدليل ستتعرف على كيفية إنشاء نسخة خاصة بك من لعبة 2048 باستخدام React. ما يميز هذا الشرح أنه لا يركز فقط على منطق اللعبة، بل يمنح اهتماماً كبيراً لتجربة المستخدم عبر التحريكات Animations التي تجعل التفاعل أكثر سلاسة ووضوحاً.
سنستخدم في المشروع مجموعة من التقنيات الحديثة تشمل:
- React لبناء الواجهة.
- TypeScript لكتابة كود أكثر أماناً وتنظيماً.
- LESS لتنسيق الواجهة وإدارة التحولات البصرية.
- Hooks وContext API من React بدلاً من الأساليب القديمة.
الهدف من هذا المقال ليس مجرد تقليد اللعبة، بل فهم كيفية تصميم مكوّنات قابلة للتوسع، وإدارة الحالة بشكل نظيف، وإضافة انتقالات بصرية تحسن جودة المنتج النهائي.
![]()
ما هي قواعد لعبة 2048؟
تعتمد لعبة 2048 على دمج المربعات التي تحمل القيم نفسها حتى الوصول إلى المربع الذي يحمل الرقم 2048. تبدأ القيم من 2 وتتضاعف وفق قوى العدد اثنين مثل:
- 2
- 4
- 8
- 16
- 32
- 64
تتكون لوحة اللعب من شبكة بحجم 4 × 4، أي أنها تستوعب حتى 16 مربعاً. وعندما تمتلئ اللوحة بالكامل دون وجود أي حركة ممكنة أو فرصة لدمج مربعين متماثلين، تنتهي اللعبة.
الفكرة المثالية في اللعب هي الوصول إلى الرقم 2048 بأقل عدد ممكن من الحركات، مع الحفاظ على ترتيب ذكي للمربعات داخل اللوحة.

ما الذي سنبنيه وما الذي تم تبسيطه؟
لجعل الشرح أكثر تركيزاً على المفاهيم الأساسية، تم تبسيط بعض الجوانب في هذا التطبيق التجريبي، ومن المهم معرفتها قبل البدء:
- كل مربع جديد يتم إنشاؤه ستكون قيمته دائماً 2، بينما النسخة الأصلية من اللعبة تنشئ 2 أو 4 بشكل عشوائي.
- لن يتم التعامل مع حالات الفوز أو الخسارة داخل المنطق البرمجي الكامل.
- عند الوصول إلى 2048 لن تظهر آلية فوز خاصة.
- عند امتلاء اللوحة دون حركات متاحة، لن تنتهي اللعبة تلقائياً، بل سيحتاج المستخدم إلى الضغط على زر إعادة التعيين.
- تم تجاوز نظام احتساب النقاط لتقليل التعقيد.
رغم هذه الاختصارات، يبقى المشروع مثالاً ممتازاً لفهم منطق الحركة والدمج والتحريك في ألعاب الويب المبنية بـ React.
هيكل المشروع في React
يتكون التطبيق من عدة عناصر مترابطة، وكل جزء منها يؤدي وظيفة محددة بوضوح:
- Board: مسؤول عن عرض اللوحة والمربعات.
- Grid: يرسم شبكة 4 × 4 الثابتة في الخلفية.
- Tile: مسؤول عن عرض المربع نفسه وإدارة التحريكات المرتبطة به.
- Game: يجمع كل الأجزاء السابقة، ويتعامل مع منطق اللعب من خلال useGame.
هذا التقسيم مهم جداً لأنه يمنحك مشروعاً نظيفاً يسهل صيانته واختباره وتطويره لاحقاً.
بناء مكوّن Tile وإضافة التحريكات
يُعد مكوّن Tile حجر الأساس في الجانب البصري للعبة، لأنه المسؤول المباشر عن تحريك المربعات وإبراز التغييرات التي تطرأ عليها أثناء اللعب.
في لعبة 2048 توجد حركتان بصريتان أساسيتان:
- إبراز المربع عند إنشائه أو عند تغير قيمته بعد الدمج.
- تحريك المربع على اللوحة عند السحب في أحد الاتجاهات.
للبدء، يمكن تعريف انتقال بسيط باستخدام CSS transitions كما يلي:
.tile {
// ...
transition-property: transform;
transition-duration: 100ms;
transform: scale(1);
}
في هذه المرحلة نستخدم الخاصية transform لتكبير المربع قليلاً ثم إعادته إلى حجمه الطبيعي، وهي طريقة فعالة لإعطاء المستخدم إشارة بصرية بأن هذا العنصر جديد أو تم دمجه للتو.
تعريف البيانات الوصفية للمربع TileMeta
قبل بناء التحريك، يجب تحديد شكل البيانات الخاصة بكل مربع. تم اعتماد النوع TileMeta لتجنب التعارض مع اسم المكوّن Tile نفسه:
type TileMeta = {
id: number;
position: [number, number];
value: number;
mergeWith?: number;
};
ويتضمن هذا النوع الحقول التالية:
- id: معرف فريد لكل مربع، وهو ضروري حتى لا يعيد React رسم جميع المربعات من الصفر عند كل تغيير.
- position: موقع المربع على اللوحة، ويتكوّن من الإحداثيين x وy بقيم بين 0 و3.
- value: القيمة الرقمية للمربع مثل 2 أو 4 أو 8.
- mergeWith: قيمة اختيارية تشير إلى المربع الذي سيتم الدمج معه، ما يعني أن هذا المربع سيختفي بعد اكتمال الدمج.
تحريك المربع عند الإنشاء أو الدمج
لتحسين تجربة اللعب، من المفيد أن يتغير حجم المربع لحظة ظهوره أو عند تبدل قيمته. يمكن تنفيذ ذلك داخل مكوّن Tile باستخدام useState وuseEffect:
export const Tile = ({ value, position }: Props) => {
const [scale, setScale] = useState(1);
const prevValue = usePrevProps<number>(value);
const isNew = prevCoords === undefined;
const hasChanged = prevValue !== value;
const shallAnimate = isNew || hasChanged;
useEffect(() => {
if (shallAnimate) {
setScale(1.1);
setTimeout(() => setScale(1), 100);
}
}, [shallAnimate, scale]);
const style = {
transform: `scale(${scale})`,
};
return (
<div className={`tile tile-${value}`} style={style}>
{value}
</div>
);
};
تعمل هذه الآلية في حالتين:
- إذا كان المربع جديداً، فلن تكون له قيمة سابقة.
- إذا تغيرت قيمته بعد الدمج، فستختلف القيمة السابقة عن الحالية.

استخدام Hook مخصص لتتبع القيم السابقة
يعتمد الكود السابق على Hook مخصص باسم usePrevProps، ووظيفته حفظ القيمة السابقة لأي prop، وهو أسلوب أنظف من كتابة منطق التتبع داخل كل مكوّن:
import { useEffect, useRef } from "react";
/**
* `usePrevProps` stores the previous value of the prop.
*
* @param {K} value
* @returns {K | undefined}
*/
export const usePrevProps = <K = any>(value: K) => {
const ref = useRef<K>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
هذا النوع من Hooks المخصصة يفيد في بناء منطق قابل لإعادة الاستخدام، خصوصاً في المشاريع التي تتطلب تتبعاً مستمراً لحالة العناصر المرئية.
تحريك المربعات عبر اللوحة باستخدام CSS transitions
بدون حركة انزلاق واضحة للمربعات ستبدو اللعبة جامدة وغير مقنعة. لهذا السبب نضيف انتقالات على الخصائص المسؤولة عن الموضع مثل left وtop:
.tile {
position: absolute;
// ...
transition-property: left, top, transform;
transition-duration: 250ms, 250ms, 100ms;
transform: scale(1);
}
بعد ذلك يمكن حساب موضع كل مربع بالبكسل انطلاقاً من موقعه داخل الشبكة:
export const Tile = ({ value, position, zIndex }: Props) => {
const [boardWidthInPixels, tileCount] = useBoard();
// ...
useEffect(() => {
// ...
}, [shallAnimate, scale]);
const positionToPixels = (position: number) => {
return (position / tileCount) * (boardWidthInPixels as number);
};
const style = {
top: positionToPixels(position[1]),
left: positionToPixels(position[0]),
transform: `scale(${scale})`,
zIndex,
};
// ...
};
تعتمد المعادلة هنا على ثلاثة عناصر:
- إحداثي المربع داخل الصف أو العمود.
- عدد المربعات في كل صف.
- العرض الكلي للوحة بالبكسل.
بهذه الطريقة يتم تحويل الموقع المنطقي داخل الشبكة إلى موقع بصري فعلي على الشاشة.
لماذا نستخدم useBoard و zIndex؟
يمنحنا useBoard إمكانية الوصول إلى بيانات اللوحة داخل المكونات الفرعية دون الحاجة إلى تمريرها يدوياً عبر عدة مستويات من props. وهذه إحدى أبرز فوائد Context API في React.
أما الخاصية zIndex فهي مهمة عندما تتراكب المربعات أثناء الحركة أو الدمج، إذ تسمح بتحديد أي مربع يظهر فوق الآخر.

بناء مكوّن Board
يمثل Board الجزء المسؤول عن عرض الشبكة والمربعات معاً. وعلى عكس مكوّن Tile الذي يعرف فقط موضعه الخاص، يحتفظ Board بمعلومات أوسع مثل:
- عرض اللوحة وارتفاعها.
- عدد الصفوف والأعمدة.
- العناصر التي يجب عرضها داخل الشبكة.
type Props = {
tiles: TileMeta[];
tileCountPerRow: number;
};
const Board = ({ tiles, tileCountPerRow = 4 }: Props) => {
const containerWidth = tileTotalWidth * tileCountPerRow;
const boardWidth = containerWidth + boardMargin;
const tileList = tiles.map(({ id, ...restProps }) => (
<Tile key={`tile-${id}`} {...restProps} zIndex={id} />
));
return (
<div className="board" style={{ width: boardWidth }}>
<BoardProvider containerWidth={containerWidth} tileCountPerRow={tileCountPerRow}>
<div className="tile-container">{tileList}</div>
<Grid />
</BoardProvider>
</div>
);
};
المكوّن هنا يستخدم BoardProvider لمشاركة عرض الحاوية وعدد المربعات لكل صف مع بقية المكونات.
تمرير بيانات اللوحة عبر Context API
const BoardContext = React.createContext({
containerWidth: 0,
tileCountPerRow: 4,
});
type Props = {
containerWidth: number;
tileCountPerRow: number;
children: any;
};
const BoardProvider = ({ children, containerWidth = 0, tileCountPerRow = 4, }: Props) => {
return (
<BoardContext.Provider value={{ containerWidth, tileCountPerRow }}>
{children}
</BoardContext.Provider>
);
};
عندما يحتاج أي مكوّن فرعي إلى قيم اللوحة، يمكنه الوصول إليها من خلال Hook بسيط:
const useBoard = () => {
const { containerWidth, tileCount } = useContext(BoardContext);
return [containerWidth, tileCount] as [number, number];
};
هذه البنية تقلل الاعتماد على تمرير props بشكل مفرط، وتحافظ على نظافة تصميم المكونات في التطبيقات المتوسطة والكبيرة.
بناء مكوّن Game وإدارة الإدخال من لوحة المفاتيح
بعد تجهيز اللوحة والمربعات، نحتاج إلى مكوّن أعلى مستوى يجمع كل شيء ويطبق قواعد اللعبة. هذا الدور يؤديه مكوّن Game عبر Hook مخصص باسم useGame.
import { useThrottledCallback } from "use-debounce";
const Game = () => {
const [tiles, moveLeft, moveRight, moveUp, moveDown] = useGame();
const handleKeyDown = (e: KeyboardEvent) => {
// disables page scrolling with keyboard arrows
e.preventDefault();
switch (e.code) {
case "ArrowLeft":
moveLeft();
break;
case "ArrowRight":
moveRight();
break;
case "ArrowUp":
moveUp();
break;
case "ArrowDown":
moveDown();
break;
}
};
// protects the reducer from being flooded with events.
const throttledHandleKeyDown = useThrottledCallback(
handleKeyDown,
animationDuration,
{ leading: true, trailing: false }
);
useEffect(() => {
window.addEventListener("keydown", throttledHandleKeyDown);
return () => {
window.removeEventListener("keydown", throttledHandleKeyDown);
};
}, [throttledHandleKeyDown]);
return <Board tiles={tiles} tileCountPerRow={4} />;
};
من خلال هذا المكوّن نحصل على الدوال الأساسية التالية:
- moveLeft
- moveRight
- moveUp
- moveDown
كما نستخدم useThrottledCallback من مكتبة use-debounce لمنع تنفيذ عدد كبير من الحركات في وقت قصير، وهو أمر ضروري حتى لا تتداخل الأوامر مع التحريكات الجارية.
تصميم Hook مخصص باسم useGame
بدلاً من كتابة منطق اللعبة داخل مكوّن Game مباشرة، من الأفضل عزله في Hook مستقل ليسهل فهمه واختباره وإعادة تنظيمه. هذا الـ Hook يعتمد على useReducer لإدارة الحالة المعقدة.
شكل الحالة State
type TileMap = {
[id: number]: TileMeta;
}
type State = {
tiles: TileMap;
inMotion: boolean;
hasChanged: boolean;
byIds: number[];
};
وتحمل الحالة العناصر التالية:
- tiles: جدول تجزئة لتخزين المربعات حسب المعرّف، ما يسهل الوصول إليها بسرعة.
- inMotion: يحدد ما إذا كانت التحريكات لا تزال مستمرة.
- hasChanged: يحدد ما إذا كانت الحركة الأخيرة أحدثت تغييراً فعلياً في اللوحة.
- byIds: مصفوفة تحفظ ترتيب المعرّفات لضمان ثبات ترتيب العناصر في React.
تعريف الإجراءات Actions في useReducer
type Action =
| { type: "CREATE_TILE"; tile: TileMeta }
| { type: "UPDATE_TILE"; tile: TileMeta }
| { type: "MERGE_TILE"; source: TileMeta; destination: TileMeta }
| { type: "START_MOVE" }
| { type: "END_MOVE" };
كل إجراء هنا يؤدي وظيفة دقيقة:
- CREATE_TILE: إنشاء مربع جديد وإضافته إلى اللوحة.
- UPDATE_TILE: تحديث موضع مربع أو قيمته مع الحفاظ على id.
- MERGE_TILE: دمج مربع في آخر وإزالة المصدر من الحالة.
- START_MOVE: إعلام النظام بأن الحركة بدأت.
- END_MOVE: إعلام النظام بأن الحركة انتهت ويمكن إنشاء مربع جديد.
المخفض GameReducer
type TileMap = {
[id: number]: TileMeta;
}
type State = {
tiles: TileMap;
inMotion: boolean;
hasChanged: boolean;
byIds: number[];
};
type Action =
| { type: "CREATE_TILE"; tile: TileMeta }
| { type: "UPDATE_TILE"; tile: TileMeta }
| { type: "MERGE_TILE"; source: TileMeta; destination: TileMeta }
| { type: "START_MOVE" }
| { type: "END_MOVE" };
const initialState: State = {
tiles: {},
byIds: [],
hasChanged: false,
inMotion: false,
};
const GameReducer = (state: State, action: Action) => {
switch (action.type) {
case "CREATE_TILE":
return {
...state,
tiles: {
...state.tiles,
[action.tile.id]: action.tile,
},
byIds: [...state.byIds, action.tile.id],
hasChanged: false,
};
case "UPDATE_TILE":
return {
...state,
tiles: {
...state.tiles,
[action.tile.id]: action.tile,
},
hasChanged: true,
};
case "MERGE_TILE":
const { [action.source.id]: source, [action.destination.id]: destination, ...restTiles } = state.tiles;
return {
...state,
tiles: {
...restTiles,
[action.destination.id]: {
id: action.destination.id,
value: action.source.value + action.destination.value,
position: action.destination.position,
},
},
byIds: state.byIds.filter((id) => id !== action.source.id),
hasChanged: true,
};
case "START_MOVE":
return {
...state,
inMotion: true,
};
case "END_MOVE":
return {
...state,
inMotion: false,
};
default:
return state;
}
};
يوفر هذا المخفض مركزاً موحداً لإدارة جميع التحولات التي تطرأ على حالة اللعبة، وهو ما يجعل السلوك أكثر توقعاً ويقلل الأخطاء المنطقية.
كيف تعمل حركة المربعات إلى اليسار؟
لفهم منطق اللعبة بوضوح، يكفي التركيز على حركة واحدة مثل moveLeft، لأن الحركات الأخرى تتبع الفكرة نفسها مع اختلاف اتجاه الحساب.
const moveLeftFactory = () => {
const retrieveTileIdsByRow = (rowIndex: number) => {
const tileMap = retrieveTileMap();
const tileIdsInRow = [
tileMap[tileIndex * tileCount + 0],
tileMap[tileIndex * tileCount + 1],
tileMap[tileIndex * tileCount + 2],
tileMap[tileIndex * tileCount + 3],
];
const nonEmptyTiles = tileIdsInRow.filter((id) => id !== 0);
return nonEmptyTiles;
};
const calculateFirstFreeIndex = (
tileIndex: number,
tileInRowIndex: number,
mergedCount: number,
_: number
) => {
return tileIndex * tileCount + tileInRowIndex - mergedCount;
};
return move.bind(this, retrieveTileIdsByRow, calculateFirstFreeIndex);
};
const moveLeft = moveLeftFactory();
في هذا التصميم يتم تمرير دالتين إلى الدالة العامة move:
- دالة تسترجع المربعات الموجودة في صف أو عمود.
- دالة تحسب أقرب خانة متاحة للمربع أثناء الحركة.
هذا الأسلوب يندرج تحت مبدأ inversion of control، ويساعد على إعادة استخدام منطق الحركة نفسه مع اتجاهات متعددة.
الخوارزمية الأساسية للحركة والدمج
type RetrieveTileIdsByRowOrColumnCallback = (tileIndex: number) => number[];
type CalculateTileIndex = (
tileIndex: number,
tileInRowIndex: number,
mergedCount: number,
maxIndexInRow: number
) => number;
const move = (
retrieveTileIdsByRowOrColumn: RetrieveTileIdsByRowOrColumnCallback,
calculateFirstFreeIndex: CalculateTileIndex
) => {
// new tiles cannot be created during motion.
dispatch({ type: "START_MOVE" });
const maxIndex = tileCount - 1;
// iterates through every row or column (depends on move kind - vertical or horizontal).
for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
// retrieves tiles in the row or column.
const availableTileIds = retrieveTileIdsByRowOrColumn(tileIndex);
// previousTile is used to determine if tile can be merged with the current tile.
let previousTile: TileMeta | undefined;
// mergeCount helps to fill gaps created by tile merges - two tiles become one.
let mergedTilesCount = 0;
// interate through available tiles.
availableTileIds.forEach((tileId, nonEmptyTileIndex) => {
const currentTile = tiles[tileId];
// if previous tile has the same value as the current one they should be merged together.
if (
previousTile !== undefined &&
previousTile.value === currentTile.value
) {
const tile = {
...currentTile,
position: previousTile.position,
mergeWith: previousTile.id,
} as TileMeta;
// delays the merge by 250ms, so the sliding animation can be completed.
throttledMergeTile(tile, previousTile);
// previous tile must be cleared as a single tile can be merged only once per move.
previousTile = undefined;
// increment the merged counter to correct position for the consecutive tiles to get rid of gaps
mergedTilesCount += 1;
return updateTile(tile);
}
// else - previous and current tiles are different - move the tile to the first free space.
const tile = {
...currentTile,
position: indexToPosition(
calculateFirstFreeIndex(
tileIndex,
nonEmptyTileIndex,
mergedTilesCount,
maxIndex
)
),
} as TileMeta;
// previous tile becomes the current tile to check if the next tile can be merged with this one.
previousTile = tile;
// only if tile has changed its position will it be updated
if (didTileMove(currentTile, tile)) {
return updateTile(tile);
}
});
}
// wait until the end of all animations.
setTimeout(() => dispatch({ type: "END_MOVE" }), animationDuration);
};
تعتمد هذه الخوارزمية على سلسلة واضحة من الخطوات:
- بدء الحركة ومنع توليد مربعات جديدة أثناء التحريك.
- استعراض كل صف أو عمود حسب الاتجاه.
- تحديد المربعات غير الفارغة فقط لتقليل العمليات غير الضرورية.
- فحص إمكانية الدمج مع المربع السابق.
- تحريك المربع إلى أقرب موضع صالح إن لم يحدث دمج.
- إنهاء الحركة بعد اكتمال التحريكات البصرية.
هذا التصميم يوازن بين وضوح المنطق وسهولة التوسع، وهو مناسب جداً للمشاريع التعليمية والتجريبية.
ميزات يمكنك إضافتها بنفسك
إذا أردت تحويل هذا المشروع إلى نسخة أقرب للعبة الأصلية، فهناك عدة تحسينات مهمة يمكنك تنفيذها:
- إضافة score مع خوارزمية واضحة لاحتساب النقاط.
- دعم حالات الفوز والخسارة.
- توليد قيمة المربع الجديد بشكل عشوائي بين 2 و4.
- ضبط احتمال ظهور 4 بنسبة منخفضة، مثل أقل من 5%.
- إضافة زر Reset يعيد الحالة الأولية بشكل منظم.
- دعم التحكم باللمس على الأجهزة المحمولة.
تنفيذ هذه المزايا سيمنحك فهماً أعمق لإدارة الحالة والسيناريوهات الطرفية داخل ألعاب الويب.
أفضل ممارسات SEO وتجربة المستخدم عند نشر مقال تقني مماثل
إذا كنت تنوي نشر شرح مشابه على مدونة تقنية أو على منصة مثل قيد، فاحرص على النقاط التالية:
- استخدم عنواناً واضحاً يتضمن كلمات مفتاحية مثل React و2048 وTypeScript.
- قسّم المحتوى إلى عناوين فرعية منطقية لتسهيل القراءة والأرشفة.
- حافظ على الأكواد منسقة داخل وسوم pre وcode.
- أضف صوراً توضيحية مع alt دقيق ومحسن لمحركات البحث.
- تجنب النسخ الحرفي، وركز على الشرح التحليلي وليس الترجمة المباشرة فقط.
- اشرح لماذا تم اختيار كل تقنية، لأن هذا يزيد القيمة الفعلية للمحتوى ويعزز فرص قبول Google AdSense.
الخلاصة التقنية
يُظهر هذا المشروع كيف يمكن استخدام React مع TypeScript لبناء لعبة تفاعلية ذات منطق واضح وتجربة استخدام ممتعة. أكثر ما يلفت الانتباه هنا ليس فقط آلية الدمج والتحريك، بل طريقة تنظيم الكود بين components وhooks وContext API وuseReducer. من الناحية التقنية، هذا النهج ممتاز لأي مطور يريد الانتقال من بناء واجهات بسيطة إلى تطبيقات تفاعلية أكثر نضجاً، لأنه يجمع بين إدارة الحالة، والتحكم في الأحداث، والتحريكات البصرية، وقابلية التوسع في بنية واحدة متماسكة.