اذهب إلى المحتوى

بناء لعبة ثنائية البعد عبر محرك الألعاب Godot - الجزء الثاني: إنشاء مشاهد اللعبة وبرمجتها


ابراهيم الخضور

بعد أن أنشأنا في المقال السابق ملفات مشروع لعبة "تفادي الزواحف" ونظمناه، سنبدأ في هذا المقال بالعمل على شخصيات اللعبة (لاعب أساسي وأعداء). إذ سنبني المشهد اﻷول Player (وهو كائن أو عقدة) للاعب وآخر Mob للأعداء، ومن ميزات إنشاء مشهد مستقل لكل منهما هو إمكانية اختبارها بشكل مستقل وقبل أن ننشئ بقية أجزاء اللعبة.

هيكلية العقدة

نحتاج بداية إلى عقدة جذرية للاعب، وكقاعدة عامة، لا بد أن تعكس العقدة الجذرية للمشهد الوظيفة المرغوبة للكائن وما هو هذا الكائن. لهذا، انقر على زر "عقدة أخرى" وأضف عقدة من نوع Area2D إلى المشهد:

01 add node

سيعرض جودو أيقونة تنبيه إلى جوار العقدة في شجرة المشاهد، تجاهلها اﻵن وسنعود إليها لاحقًا.

نتمكن باستخدام Area2D من استشعار الكائنات التي تتداخل أو تعمل ضمن اللاعب، وسنغير اسم العقدة الجديدة إلى Player بالنقر المضاعف عليها. وبعد إنشاء العقدة الجذرية، سنضيف عقدًا إضافية لمنحها مقدرات وظيفية أكبر. لكن قبل ذلك، علينا أن نتأكد من عدم تحريك أو تغيير العقد اﻷبناء عند النقر عليهم. لهذا انقر على اﻷيقونة الواقعة على يسار أيقونة القفل في شريط أدوات المشهد (سيعرض لك وصف اﻷداة عند تمرير مؤشر الفأرة فوقها العبارة "اجعل فروع العقدة المختارة غير
قابلة للاختيار"):

02 lock children

احفظ المشهد بالنقر على مشهد>حفظ أو اضغط Ctrl + S في ويندوز ولينكس أو Cmd + S في ماك أو إس.

سوف نستخدم في مشروعنا أسلوب التسمية المتبع في محرك الألعاب جودو وهو كالتالي حسب لغة البرمجة المستخدمة:

  • في لغة GDScript: نتبع أسلوب باسكال في اﻷصناف (الحرف اﻷول من كل كلمة كبير)، وفي الدوال والمتغيرات أسلوب اﻷفعى (تفصل بين كل كلمتين
    شرطة سفلية _)، أما الثوابت فتكتب كل حروفها بالشكل الكبير.
  • في لغة #C: تسمى الأصناف والمتغيرات والتوابع بأسلوب باسكال، ونستخدم في تعريف الحقول الخاصة private والمتغيرات المحلية والمعاملات أسلوب سنام الجمل (الحرف اﻷول من كل كلمة كبير ما عدا الكلمة اﻷولى). وتأكد من كتابة أسماء التوابع بشكل دقيق عند ربط اﻹشارات.

الرسم المتحرك للشخصية (Sprite)

انقر على أيقونة العقدة Player وأضف عقدة ابن من نوع AnimatedSprite2D (استخدم Ctrl + A في ويندوز ولينكس) والتي تتولى أمور مظهر وتحريك اللاعب، ولاحظ وجود إشارة تحذير إلى جانب العقدة. تحتاج العقدة موردًا يُدعى "إطارات السبرايت SpriteFrames"، وﻹنشائه، ابحث عن الخاصية SpriteFrames ضمن النافذة الفرعية Animation في حاوية "الفاحص" ثم انقر على مربع النص empty واختر "جديدة SpriteFrame". انقر مجددًا لفتح لوحة "إطارات-اﻷرسومة".

03 spriteframes panel

ستجد إلى اليمين قائمة بالرسومات، انقر على الافتراضية وسمها "walk"، ثم انقر على أيقونة إضافة إطار في الزاوية العليا اليمينية وأضف إطارًا آخر سمِّه "up". ابحث بعد ذلك عن الصور المناسبة في المجلد "art" في نظام الملفات وانقل الصور playerGrey_walk[1/2] إلى اﻹطار "walk" بالسحب واﻹفلات، أو بفتح الصورة من خلال أيقونة المجلد وكرر العملية بنقل الصورتين playerGrey_up[1/2] إلى اﻹطار "up".

04 spriteframes panel2

إن أبعاد الصور أكبر من أبعاد نافذة اللعبة، ولا بد من تصغير هذه الصور بالنقر على العقدة AnimatedSprite2D ومن ثم ضبط الخاصية Scale على القيمة Scale. ستجد هذه الخاصية في حاوية الفاحص تحت العنوان Node2D والقائمة "Transform تحويل":

05 player scale

أضف أخيرًا عقدة من النوع CollisionShape2D لتكون ابنًا للعقدة Player، وتحدد هذه العقدة "صندوق التصادم" المحيط باللاعب أو حدود منطقة التصادم المحيطة به. وتلائمنا في هذا الصدد كائن من النوع CapsuleShape2D، لهذا انقر في "الفاحص" على المربع إلى جوار العنوان واختر "جديدة CapsuleShape2D". استخدم بعد ذلك مقبضي التحكم بالأبعاد (النقطتين الحمراوين) في نافذة المشهد لتغطية الأرسومة بالغلاف:

06 player coll shape

عندما تنتهي من ذلك سيكون شكل مشهد اللاعب Player كالتالي:

07 player scene nodes

تأكد من حفظ المشهد مجددًا بعد هذه التغييرات. سنضيف تاليًا سكربت إلى عقدة اللاعب لتحريكه ثم نُعدّ آلية لترصد التصادم لنعرف إذا ما اصطدم اللاعب بشيء ما.

كتابة الشيفرة اللازمة لتحريك اللاعب

سنعمل في هذا القسم على كتابة شيفرة لتحريك اللاعب، وإعداده ليترصد التصدامات، لهذا، علينا إضافة بعض الخواص الوظيفية التي لا تقدمها العقد المتوفرة عن طريق إضافة سكربت أو كود برمجي إلى العقدة. انقر على العقدة Player ثم انقر على "إلحاق نص برمجي":

08 edit script

لا داعي لتغيير أي شيئ في نافذة إلحاق نص برمجي، اترك كل شيء كما هو وانقر على زر "أنشئ".

ملاحظة: إن كنت تريد إنشاء سكربت #C، اختر هذه اللغة من القائمة المنسدلة قبل النقر على "أنشئ".

09 attach node window

extends Area2D

@export var speed = 400 # How fast the player will move (pixels/sec).
var screen_size # Size of the game window.

تُسمح لنا التعليمة export قبل المتغير speed بضبط قيمته في نافذة الفاحص. ولهذا اﻷمر فائدته إن أردت تعديل قيمة المتغير بالطريقة نفسها التي تعدّل فيها خاصيات أي عقدة موجودة أصلًا في جودو. انقر اﻵن على العقدة Player وسترى الخاصية موجودة ضمن قسم "متغيرات السكربت" في حاوية الفاحص (تحت نفس الاسم الذي يحمله ملف السكربت). وتذكر أن تغيير القيمة في هذا المكان سيلغي القيمة التي يحملها المتغير في السكربت.

10 export variable

اقتباس

تحذير

إن كنت تستخدم #C، لا بد من إعادة بناء ملفات التجميع assemblies المشروع كلما أردت عرض المتغيرات أو اﻹشارات المصدّرة إلى المحرر من الشيفرة. يمكنك بناء المشروع يدويًا بالنقر على الزر "بناء Build" أعلى يمين المحرر. كما تستطيع بدء عملية البناء من لوحة MSBuild بالنقر على كلمة MSBuild في أسفل نافذة المحرر لعرض اللوحة ثم النقر على الزر "Build".

يتضمن السكربت player.gd تلقائيًا الدالتين ()ready_ و ()process_. فإن لم تختر القالب الافتراضي للسكربت أنشئ هاتين الدالتين. وتُستدعى الدالة ()ready_ عندما تدخل عقدة شجرة المشاهد وهو وقت مناسب لمعرفة أبعاد نافذة اللعبة

func _ready():
    screen_size = get_viewport_rect().size

بإمكاننا اﻵن استخدام الدالة ()process_ لتحديد ما يفعله اللاعب، وتُستدعى هذه الدالة من أجل كل إطار ونستخدمها لتحديث العناصر في لعبتنا والتي نتوقع أن تتغير أحيانًا. فمن أجل لاعبنا لا بد من:

  • التحقق من وجود دخل.
  • تحريكه في الاتجاه المطلوب.
  • تشغيل الرسوم المتحركة المناسبة.

كما ذكرنا علينا بداية التحقق من الدخل، أي هل يضغط اللاعب على زر معين مثلًا؟ ففي لعبتنا هناك عناصر إدخال لأربعة اتجاهات علينا أن نتحقق منها. عُرّفت إجراءات الدخل في إعدادات المشروع تحت عنوان "خريطة الإدخال". وفيها نستطيع تعريف أحداث مخصصة وتعيين أزرار مختلفة، وأحداث تتعلق بالفأرة وغيرها من المدخلات.

انقر على المشروع، ثم إعدادات المشروع لتفتح نافذة اﻹعدادات، ثم انقر على النافذة الفرعية "خريطة الإدخال" في الأعلى. اكتب بعد ذلك "move_right" (تحرك يمينًا) في الشريط العلوي وانقر الزر "أضف" ﻹضافة الإجراء move_right.

11 move right action

علينا اﻵن أن نربط اﻹجراء بزر معين، لهذا انقر على أيقونة "+" إلى اليسار كي نفتح نافذة "تهيئة الحدث event configuration".

12 adding action

كل ما عليك الآن هو النقر على الزر الذي تريد ربطه بالحدث سواء زر لوحة مفاتيح أو زر الفأرة. انقر الآن مفتاح السهم اليميني على لوحة المفاتيح وسيظهر الخيار تلقائيًا في مربع "يتم رصد المدخلات.." انقر بعد ذلك على "حسنًا" لتعيين المفتاح.

13 assign action key

كرر نفس الخطوات لربط الحركات الثلاث الباقية كالتالي:

  1. اربط move_left بالسهم اليساري.
  2. اربط move_up بالسهم للأعلى.
  3. اربط move_down بالسهم للأسفل.

يجب أن تظهر خارطة المدخلات كالتالي:

14 input mapping complete

انقر اﻵن على "إغلاق" ﻹغلاق إعدادات المشروع.

ملاحظة: ربطنا مفتاح واحد بكل إجراء دخل، لكنك تستطيع أن تربط أكثر من مفتاح أو زر عصا تحكم أو زر فأرة بإجراء الدخل نفسه.

تستطيع أن تقرر إذا ما ضُغط زر باستخدام العبارة ()Input.is_action_pressed التي تعيد القيمة true إذا ضُغط الزر وfalse إن لم يُضغط.

func _process(delta):
    var velocity = Vector2.ZERO # The player's movement vector.
    if Input.is_action_pressed("move_right"):
        velocity.x += 1
    if Input.is_action_pressed("move_left"):
        velocity.x -= 1
    if Input.is_action_pressed("move_down"):
        velocity.y += 1
    if Input.is_action_pressed("move_up"):
        velocity.y -= 1

    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite2D.play()
    else:
        $AnimatedSprite2D.stop()

بدأنا بضبط قيمة velocity على (0, 0) فلن يتحرك اللاعب افتراضيًا، وبعد ذلك تحققنا من كل المدخلات وأضفنا إلى المتغير velocity أو طرحنا منه للحصول على الاتجاه. فلو ضغطنا على السهم اليميني واليساري في نفس الوقت ستكون نتيجة المتجه velocity هي (1, 1)، وفي هذه الحالة نكون قد أضفنا حركة أفقية وعمودية في نفس الوقت، وسيتحرك اللاعب بشكل أسرع بالاتجاه القطري موازنة بالحالة التي يتحرك فيها أفقيًا فقط. لكن بإمكاننا منع حدوث هذا اﻷمر بتسوية قيمة السرعة بأن نضبط قيمتها على 1 ثم نضربه بالقيمة المطلوبة ولن تكون السرعة في الاتجاه القطري عندها أكبر.

اقتباس

تلميح

إن لم تستخدم المتجهات سابقًا أو تحتاج إلى تذكرها يمكنك الاطلاع على المقال Vector math.

كما علينا أن تحقق فيما لو تحرّك اللاعب كي نستدعي الدالتين ()play و ()stop في AnimatedSprite2D

اقتباس

تلميح

$ هو اختصار للدالة ()get_node. فالكتابة ()AnimatedSprite2D.play$ تماثل العبارة البرمجية ()get_node("AnimatedSprite2D").play

يُعيد $ عقدة معينة إن كانت موجودة في نفس المسار النسبي ويعيد null إن لم تكن موجودة في هذا المسار. وطالما أن AnimatedSprite2D هي عقدة ابن للعقدة الحالية، بإمكاننا استخدام AnimatedSprite2D$.

وطالما حددنا الآن اتجاه الحركة، بإمكاننا تحديث موقع اللاعب. كما نستطيع باستخدام الدالة ()clamp منع اللاعب من مغادرة الشاشة وتقييده ضمن مجال محدد. أضف الشيفرة التالية إلى أسفل الدالة ()process_ (انتبه إلى أن الشيفرة غير منزاحة تحت else?

position += velocity * delta
position = position.clamp(Vector2.ZERO, screen_size)
اقتباس

تلميح

يشير المعامل delta إلى طول اﻹطار (الوقت الذي يحتاجه اﻹطار حتى يكتمل). ويضمن استخدام هذه القيمة اتساق الحركة حتى لو تغيّر معدل اﻹطارات.

انقر على الزر "شغل المشهد الحالي" (F6 أو Cmd+R في ماك أو إس) وتأكد من قدرتك على تحريك اللاعب ضمن المشهد في جميع الاتجاهات.

اقتباس

تحذير:

إن ظهر خطأ في نافذة "منقح اﻷخطاء" ينص على التالي: "Attempt to call function 'play' in base 'null instance' on a null instance"، فمن المحتمل أن يعني هذا الخطأ أن اسم العقدة مكتوب بطريقة خاطئة، فأسماء العقد حساسة لحالة اﻷحرف ولا بد أن يُطابق NodeName$ الاسم الذي تراه في شجرة المشاهد.

اختيار الرسوم المتحركة

بإمكاننا تحريك اللاعب الآن، لكننا نحتاج إلى تغيير الرسم المتحرك الذي يمثّل الكائن وفقًا لاتجاهه. ليدنا الرسم "تحرّك" والذي يعرض اللاعب وهو يتحرك يمينًا، ولا بد من قلبه أفقيًا حتى يعبّر عن التحرك نحو اليسار باستخدام الخاصية flip_h. وكذلك لدينا الرسم "up" الذي يجب أن يُعكس عموديًا لتمثيل الحركة نحو اﻷسفل باستخدام الخاصية flip_v. لهذا عليك اضافة الشيفرة التالية إلى أسفل الدالة ()process_:

if velocity.x != 0:
    $AnimatedSprite2D.animation = "walk"
    $AnimatedSprite2D.flip_v = false
    # اطلع على املاحظة التالية بخصوص اﻹسناد المنطقي
    $AnimatedSprite2D.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite2D.animation = "up"
    $AnimatedSprite2D.flip_v = velocity.y > 0

ملاحظة: يُعد استخدام طريقة اﻹسناد المنطقي في هذه الشيفرة اختصارًا شائعًا. فما نفعله هو اختبار موازنة (منطقي) وإسناد قيمة منطقية، لهذا يمكننا تنفيذ اﻷمرين معًا. وما يفعله هذا الاختصار مطابق لعمل الشيفرة التالية:

if velocity.x < 0:
    $AnimatedSprite2D.flip_h = true
else:
    $AnimatedSprite2D.flip_h = false

شغّل المشهد وتأكد من تغيّر الرسم مع تغير اتجاه الحركة.

اقتباس

تلميح

من اﻷخطاء الشائعة هنا كتابة أسماء الرسومات بشكل خاطئ. ولا بد أن يكون اسم الرسم المتحرك في لوحة "إطارات الرسومات المتحركة" مطابقًا لاسمه في الشيفرة. فإن اسميت الرسم "Walk" مثلًا فلا بد أن يبدأ بحرف كبير أيضًا في الشيفرة.

عندما تتأكد أن كل شيء يعمل كما يجب، أضف السطر التالي إلى الدالة ()ready_ كي يختفي اللاعب في بداية اللعبة.

hide()

إعداد التصادمات

نريد من اللاعب Player أن يعرف متى يستطدم بالعدو، لكننا لم نصنع اﻷعداء بعد! لا بأس بذلك لأننا سنستخدم حاليًا إشارات جودو لننجز اﻷمر. أضف اﻷسطر التالية إلى أعلى السكربت. فإن كنت تستخدم GDScript، أضفها بعد العبارة extends Area2D، وإن كنت تستخدم لغة #C ضعها بعد العبارة
public partial class Player: Area2D.

signal hit

تُعرّف التعليمة السابقة إشارة خاصة باسم "hit" يبثها اللاعب (يرسلها) عندما يتصادم مع عدو. وسنستخدم الكائن Area2D لالتقاط هذه اﻹشارة. اختر العقدة Player وانقر على النافذة الفرعية "عقدة" ضمن لوحة "الفاحص" كي تعرض قائمة اﻹشارات التي يمكن للاعب بثها:

15 hit signal

لاحظ وجود إشارتنا المخصصة "hit" أيضًا ضمن تلك القائمة. وطالما أن العدو سيكون عقدة من النوع RigidBody2D، سنحتاج إلى الإشارة body_entered(body: Node2D). أوجد تلك اﻹشارة في القائمة ثم انقر عليها بالزر اليميني واختر "يتصل" لتظهر نافذة "قم بوصل اﻹشارة إلى دالة". لا حاجة لتغيير أي شيء، بل انقر فقط على "وصل" وسيوّلد جودو تلقائيًا الدالة المناسبة في الشيفرة:

16 body entered code

لاحظ اﻷيقونة الخضراء إلى يسار الشيفرة المخصصة للإشارة وتدل على أن إشارة متصلة مع هذه الدالة. أضف اﻵن الشيفرة التالية إلى الدالة:

func _on_body_entered(body):
    hide() # يختفي اللاعب بعد أن يصطدم.
    hit.emit()
    # Must be deferred as we can't change physics properties on a physics callback.
    $CollisionShape2D.set_deferred("disabled", true)

في كل مرة يصدم بها العدو اللاعب ستُرسل اﻹشارة، ولا بد من تعطيل التصادم الخاص باللاعب كي لا نفعّل اﻹشارة hit أكثر من مرة.

ملاحظة: قد ينتج عن تعطيل غلاف التصادم الخاص بالمنطقة خطأ إن حدث اﻷمرأثناء معالجة المحرّك للتصادمات. لهذا استخدم الدالة ()set_deferred ﻹخبار المحرّك ألا يعطّل غلاف التصادم حتى يرى أن اﻷمر آمن.

أضف أخيرًا دالة نستدعيها ﻹعادة ضبط اللاعب عندما تبدأ لعبة جديدة

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false

إنشاء شخصية العدو

حان الوقت اﻵن ﻹنشاء اﻷعداء الذي يجب على اللاعب تفاديهم. ولن يكون سلوكهم معقدًا جدًا بل سيتحركون عشوائيًا على أطراف الشاشة، يأخذون اتجاهًا عشوائيًا ويتحركون وفق خط مستقيم. نبدأ عملنا بإنشاء مشهد باسم Mob يشكل الأساس الذي نشتق منه أي عدد نحتاجه من هذه الكائنات في لعبتنا.

إعداد العقدة

انقر على مشهد>مشهد جديد ثم أضف العقد التالية وفق الترتيب المبين:

  • RigidBody2D:

  • AnimatedSprite2D

  • CollisonShape2D

  • VisibleOnScreenNotifier2D

ولا تنسَ ضبط العقدة اﻷم كي لا يمكن اختيار اﻷبناء كما فعلنا سابقًا عند بناء شخصية اللاعب. اختر بعد ذلك العقدة Mob ثم اضبط قيمة الخاصية Gravity Scale على 0، وذلك في قسم RigidBody2D ضمن الفاحص. يمنع هذا اﻷمر الأعداء من السقوط للأسفل.

افتح المجموعة "Collision" الموجودة في اللوحة "CollisionObject2D" تحت "RigidBody2D" ضمن الفاحص. الغ بعد ذلك تفعيل الخيار 1 ضمن الخاصية Mask بالنقر عليه كي لا تتصادم اﻷعداء فيما بينها.

17 mobs prevent collisions

اضبط العقدة كما فعلنا في مشهد اللاعب، وهنا نستخدم ثلاث رسومات هي fly و swim و walk، وهنالك صورتان لكل مشهد في المجلد "art". تُضبط الخاصية Animation Speed (سرعة التحريك) لكل رسم متحرك على حدى، لهذا اضبط كلًا منها على 3:

18 setting up enemy animation

بإمكانك اﻵن النقر على الزر "تشغيل الرسم المتحرك" إلى يسار "سرعة التحريك" لعرض الرسوم المتحركة.

سنختار إحدى هذه الرسوم عشوائيًا حتى يكون للأعداء أشكال مختلفة، وكما هو الحال مع رسومات اللاعب لا بد من تصغير هذه الرسومات، وذلك بضبط الخاصية Scale على (0.75, 0.75) (راجع فقرة إنشاء اللاعب لتتذكر كيفية العمل).

علينا اﻵن أن نضيف غلاف CapsuleShape2D من أجل التصادمات كما فعلنا مع اللاعب. ولكي يتماشى الغلاف مع الرسم المتحرك لا بد من تدويره بضبط الخاصية Rotation Degrees على 90 (تحت لوحة "Node2D" والقائمة "Transform" ضمن الفاحص).

كتابة شيفرة تحريك العدو

أضف سكربت إلى العقدة Mob كما فعلنا سابقًا:

extends RigidBody2D

نشغّل باستخدام الدالة ()ready_ الرسومات ونختار عشوائيًا أحد اﻷنواع الثلاث لهذه الرسوميات كالتالي:

func _ready():
    var mob_types = $AnimatedSprite2D.sprite_frames.get_animation_names()
    $AnimatedSprite2D.play(mob_types[randi() % mob_types.size()])

ما تفعله هذه الشيفرة هو الحصول على أسماء الرسومات من الخاصية frames للعقدة AnimatedSprite2D، وستكون النتيجة مصفوفة تضم اﻷنواع الثلاث: ["walk", "swim", "fly"]. ثم نختار عشوائيًا رقمًا بين 0 و 2 لاختيار أحد اﻹطارات الثلاث من المصفوفة السابقة (يبدأ العدد في المصفوفات من 0) بتطبيق التعليمة randi() % n، والتي تختار عددًا صحيحًا عشوائيًا بين 0 و n-1.

وأخيرًا نحتاج إلى شيفرة كي يحذف العدو نفسه عندما يغادر شاشة اللعبة. ولتنفيذ ذلك صل الإشارة ()Screen_exited العائدة للعقدة ()VisibleOnScreenNotifier إلى العقدة Mob (راجع فقرة وصل إشارة اللاعب التي نفّذناها سابقًا) ثم أضف الأمر ()queue_free إلى الدالة التي تظهر في السكربت كالتالي:

func _on_visible_on_screen_notifier_2d_screen_exited():
    queue_free()

وهكذا سيكتمل مشهد العدو.

الخلاصة

بهذا نكون قد وصلنا لنهاية مقالنا الحالي الذي أنشأنها فيه مشهدين من مشاهد اللعبة ثنائية الأبعاد في محرك جودو، تابع معنا المقال التالي الذي سنقوم فيه بضم المشهدين معًا والسماح للأعداء بالتكاثر على الشاشة والحركة لتحويل المشاهد إلى لعبة تعمل كما خططنا لها.

ترجمة -وبتصرف- للمقالات: Creating the Player scene و Coding the player و Creating the enemy

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...