السلام عليكم ورحمة الله
سنقوم في هذا المقال من سلسلة صنع لعبة Platformer بإضافة ميكانيكيات متقدمة، ونبدأ بالتحليق.
قمنا في المقالات السابقة بتحريك الشخصية فيزيائيًا، ولكن بحركات اعتيادية فقط، مثل المشي والركض والقفز.
والآن لكي نجعل اللعبة حماسية أكثر سنطبق هذه الميكانيكيات، باستخدام خصائص فيزيائية مدمجة بالمحرك لجعل الموضوع أسهل وأمتع.
وهذه النتيجة التي سنحصل عليها:
المحتوى
- تحديد آلية التحليق
- الحصول على مظلة للتحليق
- إضافة عنصر إدخال مخصص للتحليق
- تطبيق التحليق: مقاومة الموائع Drag
- تغيير السرعة عند التحليق
- تحديد أدنى ارتفاع للتحليق
- إضافة Animation لتأثير التحليق
- إضافة صورة المظلة
- إضافة حالة الخروج من التحليق
- برمجة الـAnimation: إعطاء قيمة للمُعطى Flying
- تغيير سرعة الانتقال وإصلاح المشاكل
تحديد آلية التحليق
الطريقة التي سنطير أو نحلق بها ستكون بدائية، وهي مقاومة الجاذبية لجعل الهبوط بطيئًا جدًا.
خطوات عملية التحليق كالآتي:
- الضغط على زر مخصص للتحليق، والإبقاء عليه.
- فحص ما إذا كنّا على ارتفاع عالٍ كفاية
- تشغيل ملف حركيات Animation مناسب
- تفعيل قوى تقاوم الجاذبية، وتغيير قوة الدفع (جعلها أسرع/أبطأ من المشي)
عند ترك زر التحليق، نهبط على الأرض مباشرة، وعند العودة وضغطه عند ارتفاع كاف، نعود لوضع التحليق.
الحصول على مظلة للتحليق
كيف سنستطيع التحليق؟ لابد من مساعدة أداة ما، ومما وجدته في الحزمة التي نزلناها في المقال السابق، صور لمظلة مفتوحة ومغلقة.
لذا لمَ لا نستخدمها للتحليق؟ تفتح عند الضغط وتغلق عند الهبوط.
وجدتها في المجلد الآتي:
أنشأت مجلدًا جديدًا في المشروع سميته Items داخل المجلد الموجود سابقًا Sprites، وأضفت إليه هاتين الصورتين:
إضافة عنصر إدخال مخصص للتحليق
نذهب إلى إعدادات الإدخال عن طريق: Edit -> Project Settings ثم في النافذة نختار القسم Input.
يمكن الآن إما تعديل اسم أحد العناصر الموجودة، ليصبح اسمه Fly، أو إضافة عنصر جديد عن طريق زيادة 1 إلى طول المصفوفة Size
بالنسبة لي أريد جعل التحليق يبدأ بالزر Left Control، لهذا حصلت على الآتي:
في خانة positive button عينت الزر بأنه left ctrl، يمكن أن تجدوا قائمة برموز الأزرار هنا
والآن في الملف البرمجي PlayerInput سنضيف متغيرات مسؤولة عن زر التحليق:
... [SerializeField] string flyInputName = "Fly"; ... public bool pressingFly { get; private set; } ... public void CheckContinuousInput(){ ... pressingFly = Input.GetButton(flyInputName); }
المتغير pressingFly ستكون قيمته صحيحة عندما يكون اللاعب ضاغطًا لزر التحليق.
لاحظوا بأننا نستطيع استخدام هذا المتغير ذاته لبدأ عملية التحليق ولإنهائها، ولا نحتاج لمتغير آخر مخصص للإطار الذي بدأ فيه التحليق، كما فعلنا مع القفز.
لماذا لم نستخدم هذه الطريقة في مقال القفز؟
لأننا قد نفقد عناصر الإدخال. تذكروا مشكلة تزامن إطارات اللعبة Update مع إطارات الفيزياء Fixed Update.
قد يضغط اللاعب في إطار Update، لمرة واحدة سريعة، ثم في الـFixed Update، لا يكون ضاغطًا للزر.
وأيضًا، سنواجه مشاكل في مزامنة زر القفز والحركة الأفقية لتطبيق الارتداد عن الجدران.
تطبيق التحليق: مقاومة الموائع Drag
قلت بأننا سنقوم بتطبيق قوى معاكسة لقوة الجاذبية، بحيث لا تلغيها بالكامل ولكن تخففها.
كيف سنقوم بذلك؟ سنستخدم ما يسمى بقوة مقاومة المائع أو Drag Force.
بشكل مبسط جدًا، هذه القوة تطبق في الطبيعة على أي جسم يتحرك في وسط ما (هواء، ماء، تراب) وتتجه عكس اتجاه حركة الجسم.
مثلاً، عند وقوع جسم لأسفل بشكل حر في الهواء، تكون القوى الأصلية المطبقة هي قوة الجاذبية،
ولكن هناك قوة أخرى وهي قوة الهواء المقاوم لحركة الجسم، وهي تتجه لأعلى.
تخيلوا بأن الهواء مثل الماء، يمنع الأجسام من الحركة بحرية، ولكن لأن كتلته وكثافته أقل، فإن مقاومته تكون ضعيفة.
هذه القوة المقاومة تزيد كلما زادت سرعة حركة الجسم. وهذا يمثل حالة الارتداد عند رمي جسم خفيف كثافته قليلة في الماء، يغوص قليلاً ثم يرتد ويخرج إلى السطح لأن قوة المقاومة ازدادت.
معادلة القوة الناتجة من قوة المقاومة لجسم يهبط إلى أسفل تحت قوة الجاذبية فقط هي كالآتي:
F = m*g – (v^2) * Dc
حيث m الكتلة و g = 9.81 تقريبا و v هي السرعة النهائية (بعد إضافة العجلة v= vi + a * t)
و Dc هو معامل المقاومة.
هذا المعامل يختلف بـ”عوامل” كثيرة، مثل مساحة السطح والكثافة ونوع المادة وما إلى ذلك.
وهو الخاصية LinearDrag الموجودة ضمن خصائص الـRigidbdoy 2D:
ما سنقوم به بكل بساطة هو زيادة قيمة الـLinear Drag عند بدء التحليق، ثم إعادتها للقيمة الأصلية (0 في هذه الحالة) عند توقف التحليق.
ما الفرق بين Linear Drag و Angular Drag؟
الأولى هي المقاومة الخطية، تطبق على الحركة الخطية، أي حركة الجسم على محور يمثل خط ما (يمين يسار ، الهبوط الحر، انزلاق على منحدر ، وهكذا)
الثانية المقاومة الزاويّة، وهي تطبق على دوران الجسم حول نفسه (حول محور ما يتسبب في دوران الجسم)
هذه المقاومة تقوم بتقليل سرعة دوران الجسم حول نفسه، فإذا كانت القيمة عالية، ستتوقف، مثلاً، العجلة عن الدوران بسرعة بعد تدويرها.
يجب الملاحظة بأن المقاومة الزاويّة لا تطبق على جسم يدور حول محور خارجي، مثل قمر يدور حول كوكب ما. إلا إذا كان القمر نفسه يدور حول نفسه.
التطبيق البرمجي
في كل إطار، نفحص متغير زر التحليق، إذا كان مضغوطًا، وفي حال لم نكن نطير سابقًا، نقوم بتغيير قيمة المقاومة على الـRigidbody.
لنحدد ما إذا كنا نطير حاليًا، سنضيف Enum يخبرنا بالحالة الحالية للشخصية، و
سنضيف الـEnum في الملف PlayerManager هذا يسهل معرفة الحالة من أي ملف آخر.
ثم نضيف دالة تغير الحالة للطيران، ودالة تخبرنا إذا كنا نطير حاليًا.
ما هو الـ Enum؟
اختصار لـEnumeration أو التعداد أو القائمة.
هو قائمة يمثل كل عنصر فيها قيمة رقم صحيح int معين.
ولكن بدلاً من تمثيله برقم، نسميه باسم معين، مثلا لتمثيل الإجراء الذي سيقوم به اللاعب:
;int currentAction
يمكن بعدها كتابة ملاحظة بأن المشي تكون قيمته 1 والركض 2 أو إنشاء عدة متغيرات هكذا:
int Walk = 1; int Run = 2; int Jump= 3; ثم استدعاء دالة كهذه: DoAction(3);
بدلا من كل هذا، نقوم بإنشاء قائمة Action بكل بساطة:
enum Action{ Walk, Run, Jump } ثم نستدعي الدالة هكذا: DoAction(Action.Jump);
الفوائد:
- منع الأرقام الدخيلة، مثلا (DoAction(5 ليس لها معنى
- تخفيف الأخطاء، مثل إدخال رقم بدل رقم.
- جعل الملفات أسهل للقراءة
أولاً في الملف PlayerManager نضيف الآتي في أي مكان مناسب:
//سنستخدم هذه الخاصية لمعرفة الحالة الحالية public State currentState { get; private set; } public enum State { Standard, Flying } //سنستدعي هذه الدالة عند البدء في التحليق public void EnterFly() { currentState = State.Flying; } //سنستدعي هذه الدالة عند التوقف عن التحليق public void StopFlying() { currentState = State.Standard; } //سنستخدم هذه الدالة لنعرف ما إذا كنا نطير حاليًا public bool IsFlying(){ return currentState == State.Flying; }
ثم في الملف PlayerMovement سنضيف متغير يحدد معامل مقاومة الهواء، ونضيف دوال جديدة مسؤولة عن الطيران:
... [Header("Flying")] [SerializeField] float dragCoefficient = 15; public void UpdateMovement() { ... UpdateFlying(); } public void UpdateFlying() { if (!playerManager.IsFlying()) { if ( inputHandler.pressingFly) EnableFlying(); } if (playerManager.IsFlying()) { //إذا لامسنا الأرضية if (playerManager.grounded || !inputHandler.pressingFly) { playerManager.StopFlying(); } } } private void EnableFlying() { playerManager.EnterFly(); playerManager.rig2D.drag = dragCoefficient ; }
ولكننا سنواجه مشكلة هنا وهي أن اللاعب لن يستطيع تغيير اتجاهه أثناء التحليق، وذلك بسبب قيد طبقناه في مقال القفز السابق..
ولكننا سنواجه مشكلتين هنا، الأولى أن اللاعب لن يستطيع تغيير اتجاهه أثناء التحليق، وذلك بسبب قيد طبقناه في مقال القفز السابق..
والثانية أننا في داخل الدالة DoJump نطبق قوة تتجه للأسفل في حال ما كان اللاعب غير ملامس للأرض.
نحل هذه المشاكل ببساطة في الدالة UpdateMovement والدالة DoJump في الملف PlayerMovement:
public void UpdateMovement() { ... if (IsDirectionFlipped(x_axis)){ //نضيف حالة التحليق إلى الشروط if (playerManager.grounded || jumpedOffWall || playerManager.foundWall || playerManager.IsFlying()) {...} } ... } ... public void DoJump(){ ... if (applyGravityForce && !playerManager.IsFlying()){ ... } ... }
قمت بتعيين القيمة 15 للـdragCoefficient
لنرى النتيجة الآن:
الخروج من وضعية التحليق
لاحظوا بأن الشخصية أصبحت بطيئة بعد وصولها للأرض، وذلك بسبب أن قيمة الـDrag ما زالت معينة بالقيمة المخصصة للتحليق.
سنقوم بإعادتها الآن، في الملف PlayerMovement:
... float originalDrag; ... void Start() { originalDrag = playerManager.rig2D.drag; } public void UpdateFlying() { ... if (playerManager.IsFlying()) { //إذا لامسنا الأرضية if (playerManager.grounded || !inputHandler.pressingFly) { DisableFlying(); } } } private void DisableFlying() { playerManager.StopFlying(); playerManager.rig2D.drag = originalDrag; }
قد نواجه مشكلة هنا وهي أنه عند لمس الأرضية دون ترك زر الطيران، فإن قيمة المقاومة ستبقى كما هي وتبقى الشخصية صعبة الحركة.
السبب لهذه المشكلة هو أن الشخصية ستدخل في نمط الطيران في الإطار التالي بعد أن خرجنا منه عند لمس الأرضية.
سنقوم بحل هذه المشكلة عند تطبيق الارتفاع الأدنى للتحليق.
تغيير السرعة عند التحليق
السرعة الآن يتم التحكم بها كما لو كنا على الأرض، وهذا يسبب مشاكل لأننا طبقنا عدة تحفظات على السرعة عند تغيير الاتجاه وما إلى ذلك (راجع المقال الأول من السلسلة).
إذا لم تكن تلاحظ مشاكل فلا بأس، ولكن من الأفضل تطبيق السرعة بشكل منفصل عند التحليق.
لذلك لن نقوم باستدعاء الدالة MoveHorizontally عند التحليق، وبدلاً من ذلك سنطبق قوى عند الطيران تتجه أفقيًا على حسب الزر المضغوط.
سنضيف متغير جديد flyingSpeed يحدد سرعة الطيران. وسنسخدم المتغير السابق x_axis لتحديد مستوى واتجاه الحركة.
في الملف PlayerMovement:
... [SerializeField] float flyingSpeed = 15; ... public void UpdateMovement() { ... //أضف هذا الشرط قبل استدعاء الدالة if (!playerManager.IsFlying()) MoveHorizontally(); ... } public void UpdateFlying() { ... if (playerManager.IsFlying()) { ... float currXVel = playerManager.rig2D.velocity.x; float newXVel = flyingSpeed * x_axis; playerManager.rig2D.AddForce(( (newXVel - currXVel) / timeToReachSpeed) * Vector2.right); } }
استخدمت طريقة تطبيق القوى ذاتها التي استخدمتها للمشي. نطرح السرعة الحالية من السرعة المطلوبة ونقسمها على الزمن لنحصل على العجلة
ثم ضربت العجلة بالمتجه Vector2.right لأحصل على متجه على المحور الأفقي. وكل ذلك وضعته في الدالة AddForce.
تحديد أدنى ارتفاع للتحليق
سنقوم بتطبيق هذا القيد لأن الواقع يتطلب ذلك. من الصعب التحليق بمظلة دون وجود ارتفاع كاف لتقفز منه.
سننشئ دالة CanFly تخبرنا ما إذا كنا نستطيع التحليق الآن أو لا.
هذه الدالة أولاً تتحقق ما إذا كنا ملامسين للأرضية، وفي هذه الحالة، بالتأكيد لا نستطيع التحليق.
وإلا، تقوم بإرسال شعاع إلى الأسفل لمسافة محددة، وإذا اصطدم بشيء يعتبر من الأرضية، تخبرنا بأننا نستطيع التحليق.
ثم سنستدعي الدالة قبل الانتقال لوضع التحليق.
سنحصل على طبقات الأرضية من المتغير الموجود في الملف PlayerManager، لذا نضيف الدالة الآتية فيه:
public LayerMask GetGroundLayers(){ return groundLayers; }
ثم في الملف PlayerMovement:
//أدنى ارتفاع مطلوب للتحليق [SerializeField] float minAmplitudeToFly = 5f; public void UpdateFlying() { if (!playerManager.IsFlying()) { if (CanFly() && inputHandler.pressingFly) { EnableFlying(); } } ... } private bool CanFly() { if (playerManager.grounded) return false; else { RaycastHit2D checkAmplitude = Physics2D.Raycast(transform.position, -transform.up, minAmplitudeToFly, playerManager.GetGroundLayers()); return checkAmplitude.collider == null; } }
ملاحظة عن الـRaycast
قمت بتحديد مسافة minAmplitudeToFly يمتد خلالها الشعاع. لن يستمر الشعاع لمسافة أطول. إذا لم يلمس أي شيء خلالها، فهذا يعني أننا نستطيع التحليق.
راجع المقال المخصص للقفز لفهم طريقة عمل الـRaycast
وجدت بأن الارتفاع المناسب للتحليق هو 5.
والآن لنرى كيف ستختلف التجربة:
إضافة Animation لتأثير التحليق
سنقوم الآن بإضافة مقاطع حركيات Animation تقوم بفتح المظلة وتغيير وضعية الشخصية إلى الوضعية المناسبة.
ولكن اولاً، لنقم بإضافة المظلة الجميلة.
إضافة صورة المظلة
سأضيف كائن جديد ابن للشخصية، أضيف إليه عنصر Sprite وأعين صورة المظلة.
لتعيين موقعها بشكل دقيق، علينا أولاً تحديد الصورة التي سنستخدمها أثناء التحليق.
بالنسبة لي، أعجبتني صورة القفز، لذلك سأستخدمها نفسها أثناء التحليق..
سأقوم بتعيين الصورة alienGreen_jump مؤقتًا لأستطيع وضع المظلة بالمكان المناسب.
قمت بتكبير المظلة 3 أضعاف حتى حصلت على حجم مناسب:
ثم لكي نخفي المظلة، نعين قيمة الـAlpha للصفر، هكذا:
إنشاء مقطع Animation للدخول لوضع التحليق
سأقوم الآن بإنشاء مقطع Animation جديد أسميه Enter Fly.
هذا المقطع يجب أن يتم تشغيله عند الدخول لوضع التحليق، سيقوم بعرض وفتح المظلة على مدى مدة قصيرة.
الآن نختار المقطع Enter Fly من واجهة الـAnimation ونضغط على زر التسجيل:
بعد الانتهاء منه، سننتقل مباشرة، باستخدام الـAnimator، إلى مقطع آخر يغير صورة الشخصية إلى الصورة alienGreen_jump (أو الصورة التي تريدها)
سأسمي هذا الملف الجديد Fly، وهو سيكون المقطع الذي يبقى مُشَغلاً ويتكرر دائمًا ما دامت الشخصية تُحلق.
الآن في الملف Fly:
ملاحظة: استخدمت الـProperties مباشرة لأن الصورة المطلوبة مُعيّنة بالأصل. و بدلاً من اتباع الطريقة ذاتها، يمكن تغيير صورة الـCharacter Sprite إلى صورة أخرى، ثم إعادتها إلى الصورة المطلوبة أثناء التسجيل.
ربط الحالات في الـAnimator
والآن عندما نذهب إلى الـAnimator Controller الخاص بالشخصية، سوف نرى حالاتٍ الجديدة قد أُضيفت إلى الواجهة:
لنحدد الآن الانتقالات التي نريد تطبيقها.
بدايةً، كل ما أردنا الانتقال لحالة التحليق، يجب أن ننتقل إلى الحالة Enter Fly أولاً ثم بعد انتهائها نذهب إلى الـFly.
نحن نستطيع الانتقال لحالة التحليق أثناء وجودنا في مكان مرتفع فقط، أي إما أثناء القفز أو الهبوط.
ولكن أولاً علينا أن ننشئ مُعطى يخبرنا بأننا نُحَلّق، لذلك سنضيف Bool أسميه Flying:
تصبج لدينا الآن الانتقالات الآتية:
- من Enter Fly إلى Fly بشكل تلقائي.
- من Jump إلى Enter Fly عند Flying = true
- من Fall إلى Enter Fly عند Flying = true
فيما يلي تطبيق هذه الانتقالات، (قمت بإعادة ترتيب الواجهة لكي لا تصبح لدينا خريطة):
هكذا تصبح واجهة الـController لدينا:
إضافة حالة الخروج من التحليق
الخروج من التحليق يحدث عند ترك زر التحليق أو عند لمس الأرضية، وهناك عدة حالات يمكن أن ننتقل إليها هنا.
قبل تحديد الانتقالات الممكنة، فكما أنه لدينا مقطع مخصص للدخول في حالة التحليق، Enter Fly، نريد أن نُكَوّن مقطع مخصص للخروج من حالة التحليق.
هذا المقطع يمكن أن يمشي على خطوات المقطع Enter Fly، ولكن بالمعكوس.
هل علينا إنشاء ملف جديد نسميه Exit Fly مثلا؟ طبعًا لا! نحن نستخدم الـAnimtor، يمكننا استغلاله لعكس المقطع.
ما سنقوم به هو تكرار، نسخ، الحالة Enter Fly الموجودة في الـ Controller، إعادة تسميتها بـExit Fly، ثم تحديد سرعتها بـ 1-
هكذا ستبقى السرعة ذاتها ولكن سيُشغل المقطع عند الانتقال إلى هذه الحالة بالمعكوس أو المقلوب:
إضافة انتقالات الخروج من التحليق
لدينا الانتقالات الآتية:
- من Fly إلى Exit Fly عند Flying = false – بشكل مباشر دون تأخير
- من Exit Fly إلى Enter Fly عند Flying = true
- من Exit Fly إلى Idle عند Grounded = trueو xSpeed < 0.01
- من Exit Fly إلى Walk عند Grounded = true و xSpeed > 0
- من Exit Fly إلى Fall عند Grounded = false
- من Exit Fly إلى Jump عند تفعيل إشارة Jumped و Grounded = true
عند تطبيق الانتقالات من 2 إلى 6، وسنقوم بدمج الحالتين مع بعضهما، لكي تتغير صورة الشخصية مباشرة دون تأخير.
إذا لم تفهموا شيئًا من هذه الجملة، فقط طبقوا ما سأقوم به:
أين هو الدمج الذي تحدثت عنه؟
انظروا هذه الصورة للانتقال من حالة الخروج من التحليق إلى حالة الوقوف:
كل مربع في الأسفل يمثل دورة للحالة، لاحظوا أننا بمجرد ما نبدأ بتشغيل الحالة Exit Fly، ستبدأ أيضًا حالة Idle
هكذا ستصبح الشخصية واقفة بينما تختفي وتُغلق المظلة.
ملاحظة: عند الانتقال من Fly إلى Exit Fly، ستبدأ الحالة Exit Fly بالتشغيل ومباشرة سيبدأ الانتقال (الدمج)
برمجة الـAnimation: إعطاء قيمة للمُعطى Flying
هذه أسهل خطوة في المقال كله، كل ما علينا فعله هو إعطاء القيمة للمعطى Paramater: Flying.
هذه القيمة تكون إما true أو false، سنأخذها من الدالة التي عرّفناها سابقًا IsFlying.
الآن في الملف PlayerAnimation:
... [SerializeField] string flyingStatusParameter = "Flying"; public void UpdateAnimation() { ... animator.SetBool(flyingStatusParameter, playerManager.IsFlying()); }
هكذا نحصل على:
تغيير سرعة الانتقال وإصلاح المشاكل
وجدت أن الانتقال من وإلى وضع التحليق يحدث بسرعة، سبب ذلك أن الملف Enter Fly سريع نسبيًا.
لنجعله أبطأ وسلس أكثر، سأقوم بتغيير خاصية السرعة على الحالات في الـAnimator إلى 0.5 (أو أي قيمة ترونها مناسبة)
بعد فعل ذلك، سنحتاج لإعادة تعيين موقع ومدة الانتقال في خصائص الانتقالات من وإلى وبين الحالتين Enter Fly و Exit Fly.
وأيضًا، بعد أن نقوم بتغيير السرعة، ستظهر لنا مشاكل كانت موجودة أصلاً ولكن لم تظهر إلا عند تقليل السرعة.
المشاكل التي لاحظتها:
- عند الدخول في وضع التحليق، تتحول صورة الشخصية إلى الصورة الأصلية stand لبرهة ريثما ننتقل للحالة Fly.
- عند الخروج من وضع التحليق والانتقال لوضع الهبوط مثلاً، هناك تأخير صغير يحدث في تحول صورة الشخصية
بالنسبة للمشكلة الأولى، السبب هو تفعيل خيار Write Defaults على كل من Enter Fly و Exit Fly.
ما يقوم به هذا الخيار هو إعادة تعيين الخصائص التي تتم تغييرها في حالات أخرى ضمن نطاق الـAnimator.
هنا، الصورة تتحول عندما ننتقل من حالة لأخرى، فهي تعتبر من هذه الخصائص. وفي الملف Enter Fly نحن لا نقوم بتحويل الصورة.
لهذا ما يحدث هو أن الصورة تعود للصورة الافتراضية stand.
بالنسبة للمشكلة الثانية، تظهر هذه المشكلة فقط بعد إلغاء تفعيل خيار Write Defaults، نحلها بإلغاء تفعيل خيار Has Exit Time أيضًا.
نحن حددنا الشروط في الـParameters فلا حاجة لتحديد زمن للخروج.
قبل كل شيء، سأذهب إلى خصائص الملف Enter Fly وألغي خاصية Loop Time:
بعدها لنعد إلى واجهة الـAnimator ونطبق ما أخبرتكم به:
هكذا نكون قد انتهينا من إضافة خاصية التحليق للشخصية، وسنقوم في المقال القادم إن شاءالله بتطبيق خاصية السباحة والغوص.
ما رأيكم في اللعبة الآن بعد إضافة التحليق؟ بالتأكيد أصبحت أفضل.
لا تترددوا بالسؤال عن أي مشكلة تواجهوها أثناء تطبيق المقال في التعليقات.