الرسوم والحركة في C#
في نهاية هذا الدرس ستتمكن من رسم اشكال ونصوص وصور على Windows Form باستخدام GDI+ واضافة الحركة اليها عبر timer وعناصر متحركة مستقلة ومدخلات لوحة المفاتيح ونمط خطوط يتحكم فيه المستخدم.
الاهداف
في نهاية هذا الدرس ستتمكن من:
- الحصول على كائن
Graphicsمن الـPaintEventArgsالخاص بحدث Paint، او من method الـCreateGraphicsالخاصة بالـ control، او منGraphics.FromImageالخاصة بـBitmap، وشرح متى تستخدم كل واحدة. - استخدام نظام الاحداثيات في Windows Forms (نقطة الاصل واتجاهات المحاور والـ pixels) لتحديد مكان رسم الشكل.
- استخدام
Penلرسم حدود الشكل وSolidBrushلملء داخله. - رسم خطوط ومستطيلات وقطع ناقصة واقواس ومضلعات ونصوص باستخدام methods الرسم في class الـ
Graphics. - شرح لماذا المحتوى المرسوم بـ
CreateGraphicsيختفي بينما المحتوى المرسوم داخل Paint يبقى، واستخدامInvalidateوRefreshللتحكم في اعادة الرسم. - ضبط اعدادات الـ
Timercontrol (الـIntervalوالـEnabled) وكتابة handler لحدثTickيحدث الموقع ويعيد رسم الـ form. - اكتشاف وصول عنصر متحرك الى حافة الـ form وعكس اتجاهه ليرتد للداخل.
- تحريك عدة عناصر مستقلة، لكل منها موقعه وسرعته الخاصة، من
Timerواحد مشترك. - تحريك صورة بـ
DrawImageباستخدام نفس منطق الارتداد المطبق على الاشكال العادية، وتشغيل رسوم متحركة frame-by-frame تتحكم في سرعتها بـTrackBar. - تحريك control نفسه (الـ
LeftوالـTopالخاصين بـLabel) مباشرة من handler حدثTickالخاص بـTimer، كبديل لرسم شكل بـGraphics، وشرح اي الحالتين تناسب كل طريقة. - قراءة ضغطات مفاتيح الاسهم عبر حدث
KeyDownالخاص بالـ form واستخدامها لاختيار اتجاه العنصر المتحرك بدلا من ارتداده تلقائيا. - حساب نقطة نهاية خط من زاوية وطول باستخدام
Math.CosوMath.Sin، ورسم نمط متعدد الخطوط بـGraphics.DrawLinesومصفوفة من قيمPoint.
المتطلبات السابقة
- الدرس السابق "C# Windows Forms Introduction, Part 2" غطى انشاء مشروع وتسمية control قبل النقر المزدوج عليه وانشاء event handler عبر النقر المزدوج او تبويب Events والاطلاع على كود الـ designer المولد. راجعه اولا ان لم تكن مرتاحا مع اي من ذلك.
- Visual Studio مع الـ workload المسمى ".NET desktop development" على Windows.
- اساسيات C#: الـ classes والـ fields والـ constructors وحلقات
for/foreachوالـ arrays وList<T>والـif/elseوالـ casting واستخدامusingللتخلص من كائن.
نظرة عامة
هذا الدرس مكون من ثلاثة اجزاء. الجزء الاول (المفاهيم من 1 الى 5) هو برمجة الرسوم بـ GDI+: الحصول على سطح رسم ورسم اشكال ونصوص عليه والتحكم في اعادة رسمه. الجزء الثاني (المفهومان 6 و7) هو الحركة: الـ Timer يغير موقعا بشكل متكرر ويعيد رسم الـ form، مما يحول صورة ثابتة الى شيء يتحرك. الجزء الثالث (المفاهيم من 8 الى 10) يغطي ثلاث تقنيات لا تتبع نمط Paint واعادة الرسم في الجزء الثاني: تحريك control عبر خصائصه بدلا من رسمه، واختيار اتجاه بلوحة المفاتيح بدلا من الارتداد التلقائي، ورسم نمط خطوط من ارقام يدخلها المستخدم. المهام تبني على الجزء الاول والثاني، اما الجزء الثالث فهو خلفية يمكنك تطبيقها كتوسعات اختيارية لنفس المهام.
المفهوم 1: كائن الـ Graphics والـ GDI+
التعريف. الـ GDI+ هو نظام الرسم خلف كل نافذة على سطح مكتب Windows. في .NET يظهر من خلال class الـ Graphics في الـ namespace المسمى System.Drawing: كل method رسم (DrawLine وDrawString وغيرها من methods رسم الاشكال في المفهوم 4) تنتمي لكائن Graphics وليس للـ form نفسه.
الغرض. قبل رسم اي شيء على form، تحتاج كائن Graphics يمثل سطح ذلك الـ form.
كيف يعمل. هناك ثلاث طرق للحصول عليه، واول اثنتين ترسمان على سطح الـ form الظاهر على الشاشة. داخل حدث Paint الخاص بالـ form (الذي ينشا بالنقر المزدوج على Paint في تبويب Events في نافذة Properties)، الـ handler وهو private void Form1_Paint(object sender, PaintEventArgs e) يستقبل PaintEventArgs e التي تحتوي property اسمها Graphics تمثل السطح لتلك اعادة الرسم: Graphics g = e.Graphics;. اي شيء يرسم هنا يعاد رسمه في كل حدث Paint، ولهذا المحتوى الدائم ينتمي هنا. خارج Paint، اي method CreateGraphics() خاصة باي control تعيد كائن Graphics لسطحه الحالي على الشاشة: Graphics g = this.CreateGraphics();. هذا يرسم مباشرة على الـ pixels الظاهرة الان ولا يعاد تشغيله في اعادة الرسم التالية (المفهوم 5 يشرح ماذا يعني ذلك عمليا). الطريقة الثالثة لا تمس سطح الـ form اطلاقا: Graphics.FromImage(bitmap) تعيد كائن Graphics تكتب methods الرسم الخاصة به في buffer الـ pixels الخاص بتلك الـ Bitmap في الذاكرة، سواء ظهر الـ form ام لا. هذه هي الطريقة التي تبنى بها صورة بالكود بدلا من تحميلها من ملف (المفهوم 7 والمهمة 5 يستخدمانها لرسم sprite مرة واحدة في الـ constructor قبل ظهور الـ form).
flowchart TD
A["Form shown, resized, uncovered,<br/>or Invalidate() called"] --> B["Paint event fires<br/>with a PaintEventArgs e"]
B --> C["g = e.Graphics<br/>(redrawn every time Paint fires)"]
D["Code outside Paint,<br/>e.g. a Button Click"] --> E["g = this.CreateGraphics()<br/>(drawn once, forgotten next repaint)"]
H["A Bitmap object,<br/>e.g. in the constructor"] --> I["g = Graphics.FromImage(bitmap)<br/>(drawn once, into the bitmap itself)"]
C --> F[Pen, Brush, DrawXxx methods]
E --> F
I --> J["Bitmap now holds the drawing;<br/>draw it later with DrawImage"]كائن الـ Graphics المحصول عليه من CreateGraphics() او من Graphics.FromImage() يجب تحريره بـ g.Dispose(); (او block من using) عند الانتهاء منه. اما كائن الـ Graphics المحصول عليه من e.Graphics فلا يجب ذلك، لان حدث Paint يمتلكه ويتخلص منه بنفسه. هذا يتبع نفس معرفة using-for-disposing المذكورة في المتطلبات السابقة، مطبقة هنا لان CreateGraphics() وGraphics.FromImage() كل منهما يعطي مورد GDI+ لا يسترده اي شيء اخر في الـ framework تلقائيا.
المفهوم 2: نظام احداثيات Windows Forms
التعريف. كل method رسم تاخذ احداثيات بالـ pixels على مستوى نقطة اصله (0, 0) في الزاوية العلوية اليسرى لسطح الرسم. محور x ينمو لليمين ومحور y ينمو للاسفل، وهو عكس ما تراه في حصة الهندسة.
كيف يعمل. نقطة عند x = 100 وy = 50 تقع على بعد 100 pixel من الحافة اليسرى و50 pixel من الحافة العلوية. DrawLine(pen, x1, y1, x2, y2) تصل النقطة (x1, y1) بالنقطة (x2, y2)، والارقام الاربعة هي زوجان من الاحداثيات وليست اربعة قياسات منفصلة.
مثال. g.DrawLine(Pens.Black, 20, 20, 150, 90); تبدا على بعد 20 pixel من اليسار والاعلى وتنتهي على بعد 150 pixel من اليسار و90 من الاعلى. لان 90 اكبر من 20، نقطة النهاية اخفض على الشاشة، رغم ان الخط سيبدو كانه يتجه "للاعلى" تحت محور y في حصة الهندسة.
المفهوم 3: الـ Pen والـ Brush
التعريف. الـ Pen يرسم حدود الشكل والـ SolidBrush يملا داخله بلون. الحدود مهمة الـ Pen والداخل مهمة الـ Brush، وmethods الرسم مسماة وفقا لذلك: methods الـ DrawXxx تاخذ Pen وmethods الـ FillXxx تاخذ Brush.
كيف يعمل. new Pen(Color.Black) ينشئ pen اسود بعرض pixel واحد، والـ argument الثاني يحدد عرضه: new Pen(Color.Black, 3). new SolidBrush(Color.Red) ينشئ brush احمر. Color يوفر الوانا مسماة (Color.Black) وColor.FromArgb(red, green, blue) للون مخصص من ثلاثة ارقام من 0 الى 255. هذا الـ overload ذو الثلاثة arguments ينتج دائما لونا معتما بالكامل، بينما overload اخر بـ اربعة arguments وهو Color.FromArgb(alpha, red, green, blue) يسمح ايضا بتحديد قناة الـ alpha (الشفافية) مباشرة. للـ pen او brush لمرة واحدة بلون مسمى، الـ classes الساكنة Pens وBrushes تعطي كائنا جاهزا لكل لون، مثل Pens.Black او Brushes.Red، فلا تحتاج لانشاء واحد بنفسك عندما لا تحتاج لاختيار اللون اثناء التشغيل. باقي هذا الدرس ينشئ Pen او SolidBrush صراحة دائما، لان ذلك هو الحال الشائع عندما يختار اللون اثناء التشغيل بدلا من تثبيته في الكود.
مثال.
Pen outlinePen = new Pen(Color.Black, 2);
SolidBrush fillBrush = new SolidBrush(Color.LightBlue);هذان السطران تعريفا fields، يكتبان فوق الـ constructor وليس داخل method، كل منهما بـ inline initializer يعمل مرة واحدة عند انشاء كائن الـ form. كلا الكائنين يخزنان كـ fields بدلا من اعادة بنائهما داخل Paint، لاعادة استخدام نفس الـ Pen والـ Brush في كل اعادة رسم بدلا من اعادة انشائهما عشرات المرات في الثانية عند تشغيل الحركة. لهذا الـ Pen او الـ Brush الذي يتخلص منه عادة بـ Dispose() (او block من using) عند الانتهاء منه، حيث ان كلا منهما يغلف مورد GDI غير مدار، يبقى حيا بدلا من ذلك طوال عمر الـ form نفسه: لا يخرج ابدا من النطاق، فلا يوجد شيء للتخلص منه حتى يغلق الـ form. الـ brush المبني من بيانات لا توجد الا بعد وجود كائن ما، مثل اللون العشوائي الخاص بكرة في المفهوم 7، لا يمكن ان يكون field طويل العمر بنفس الطريقة لان كل كرة تحتاج واحدا جديدا. ذلك الـ brush ينشا في المكان الذي يستخدم فيه ويوضع في block من using، فيتخلص منه فورا بعد ملء ذلك الشكل الواحد. هذه نفس فكرة using-for-disposing من المفهوم 1، مطبقة على كائنات الـ Pen والـ Brush تحديدا.
المفهوم 4: رسم الاشكال والنصوص
التعريف. Graphics يوفر عائلة methods لكل شكل: DrawLine وDrawRectangle/FillRectangle وDrawEllipse/FillEllipse وDrawArc وDrawPolygon/FillPolygon وDrawString للنصوص.
كيف يعمل. DrawRectangle(pen, x, y, width, height) ترسم مستطيلا زاويته العلوية اليسرى عند (x, y). DrawEllipse/FillEllipse(pen or brush, x, y, width, height) لا تاخذ مركزا ونصف قطر، بل تاخذ مستطيلا محيطا وترسم القطع الناقص المحصور فيه، فـ width وheight متساويان ينتجان دائرة. DrawArc(pen, x, y, width, height, startAngle, sweepAngle) ترسم شريحة من نفس القطع الناقص المحصور: الـ startAngle تقاس بالدرجات من موضع الساعة 3 على القطع الناقص، والـ sweepAngle الموجب يمسح باتجاه عقارب الساعة من هناك، فـ startAngle بقيمة 0 وsweepAngle بقيمة 180 ترسم النصف السفلي. الـ sweepAngle السالب يمسح عكس عقارب الساعة. كتمرين، تحقق بنفسك ان startAngle بقيمة 0 مع sweepAngle بقيمة -90 ترسم نفس ربع القطع الناقص الذي ترسمه startAngle بقيمة -90 مع sweepAngle بقيمة 90، لكن بالاتجاه المعاكس حوله. DrawPolygon/FillPolygon(pen or brush, points) تصل مصفوفة من قيم Point في شكل مغلق، مضيفة قطعة مستقيمة اخيرة من اخر نقطة الى الاولى، وهو ما لا ترسمه اي من methods الرسم الاخرى. Point يجمع قيمة x وy في قيمة واحدة، نفس الرقمين اللذين ياخذهما DrawLine كـ arguments منفصلة. DrawString(text, font, brush, x, y) ترسم text عند (x, y) بالـ Font والـ Brush المعطيين. new Font("Arial", 16) تنشئ Font: الـ argument الاول اسم عائلة الخط كـ string، والثاني الحجم بالنقاط.
مثال.
g.DrawLine(outlinePen, 20, 20, 150, 90);
g.DrawRectangle(outlinePen, 20, 120, 120, 80);
g.FillEllipse(fillBrush, 160, 120, 100, 80);
g.DrawEllipse(outlinePen, 160, 120, 100, 80);
g.DrawArc(outlinePen, 280, 120, 80, 80, 0, 180);
Point[] triangle = { new Point(400, 200), new Point(440, 120), new Point(480, 200) };
g.DrawPolygon(outlinePen, triangle);
g.DrawString("Lab 4", new Font("Arial", 16), fillBrush, 20, 220);بالترتيب: خط مائل من (20, 20) الى (150, 90)، ثم مستطيل 120 في 80 عند (20, 120)، ثم شكل بيضاوي ملون بالازرق الفاتح ثم محدد بالاسود في مربع 100 في 80 عند (160, 120)، ثم النصف السفلي من الدائرة المحصورة في مربع 80 في 80 عند (280, 120)، ثم مثلث يمر بالنقاط (400, 200) و(440, 120) و(480, 200) يغلق تلقائيا عائدا لنقطته الاولى، واخيرا النص "Lab 4" مرسوما بنفس الـ fillBrush المستخدم للشكل البيضاوي بدلا من انشاء brush جديد، يبدا عند (20, 220) اسفل صف الاشكال فوقه.
المفهوم 5: اعادة رسم الـ Form: الـ Invalidate والـ Refresh ولماذا يختفي رسم CreateGraphics
التعريف. Invalidate() تعلم الـ control بانه يحتاج اعادة رسم، فيطلق Paint في المرة التالية التي يكون فيها التطبيق متاحا لمعالجته. Refresh() تفعل نفس الشيء لكنها تفرض اعادة الرسم فورا.
الغرض. نظام التشغيل يستدعي Paint فقط عندما يتطلب شيء ذلك (النافذة تكشف، تغير حجمها، تعرض لاول مرة) او عندما يطلب الكود واحدا. الحركة هي كود يطلب اعادة رسم بعد اخرى، في كل مرة مع شيء تحرك قليلا.
كيف يعمل. المحتوى المرسوم عبر e.Graphics داخل Paint امن لانه يعاد رسمه في كل مرة يطلق فيها Paint. المحتوى المرسوم عبر CreateGraphics() يكتب فقط على صورة الشاشة الحالية: في المرة التالية التي يبطل فيها اي شيء تلك الصورة، سواء بتغيير الحجم او التصغير والاستعادة او التغطية بنافذة اخرى ثم الكشف او بـ Invalidate()/Refresh() صريح، يعيد Windows الرسم بطلق Paint، ولا يبقى الا ما رسمه Paint نفسه. لهذا تحريك عنصر يعني تحديث متغير الموقع واستدعاء Invalidate() وليس ابدا رسم الموقع الجديد بـ CreateGraphics() فوق القديم، لان الموقع القديم سيبقى تحته.
المفهوم 6: الـ Timer Control وتحريك عنصر واحد
التعريف. الـ Timer control يطلق حدث Tick بشكل متكرر، مرة كل Interval millisecond، طالما property الـ Enabled قيمتها true. لا يظهر على الـ form بل يجلس في الشريط اسفل الـ designer.
كيف يعمل. حدد الـ Interval بالـ milliseconds (30 تعطي تقريبا 33 تحديثا في الثانية) وانقر نقرا مزدوجا على الـ Timer لانشاء handler الـ Tick. داخله: اضف السرعة الى الموقع، وتحقق ان كان الموقع الجديد قد تجاوز حافة الـ form، وان كان كذلك اعكس اشارة تلك السرعة ليعود العنصر بالاتجاه الاخر، وهو ارتداد حقيقي وليس التفافا. مساحة الرسم المتاحة هي ClientSize.Width في ClientSize.Height وليس Width/Height: الـ Width والـ Height الخاصان بالـ form (او الـ Size المجمع) يقيسان النافذة كلها بما فيها شريط العنوان والحدود، بينما ClientSize يقيس فقط المنطقة الداخلية القابلة للرسم داخل تلك الحدود. شكل متحرك بحجم معروف يبقى داخل الـ form بالضبط طالما موقعه زائد حجمه يبقى بين 0 وذلك الـ client size. ارتداده عن Width/Height بدلا من ذلك سيسمح له بالانزلاق قليلا تحت شريط العنوان او بعد الحافة المرئية، لان تلك الـ pixels الاضافية ليست جزءا من المنطقة الداخلية القابلة للرسم. تقنية اكتشاف الحافة والعكس هذه، وclass الـ Ball المبني حولها في المفهوم 7، توسعان نفس فكرة الموقع زائد Invalidate() باضافة فحص الحدود الذي يتيحه نظام الاحداثيات في المفهوم 2.
flowchart LR
A["Timer ticks every Interval ms"] --> B["Tick handler runs"]
B --> C["Add SpeedX / SpeedY to X / Y"]
C --> D{"Past an edge<br/>of ClientSize?"}
D -->|Yes| E["Reverse that speed's sign"]
D -->|No| F["Keep the same speed"]
E --> G["Invalidate()"]
F --> G
G --> H["Paint redraws the object<br/>at its new position"]
H --> Aمثال. لشكل واحد بقياس 30 في 30 بـ fields هي x وy وspeedX وspeedY:
private void animationTimer_Tick(object sender, EventArgs e)
{
x += speedX;
y += speedY;
if (x < 0 || x + 30 > this.ClientSize.Width)
{
speedX = -speedX;
}
if (y < 0 || y + 30 > this.ClientSize.Height)
{
speedY = -speedY;
}
this.Invalidate();
}اول سطرين يحركان العنصر بسرعته الحالية في كل tick. الـ if الاول يتحقق ان كانت الحافة اليسرى للعنصر تجاوزت الحد الايسر، او حافته اليمنى (الموقع زائد العرض) تجاوزت الحد الايمن. عكس speedX عندها يرسل العنصر بالاتجاه الاخر. الـ if الثاني يفعل نفس الشيء للحافتين العلوية والسفلية بشكل مستقل. Invalidate() تجدول اعادة الرسم التي تعرض الموقع الجديد (المفهوم 5). ضبط property الـ DoubleBuffered الخاصة بالـ form على true، عادة في الـ constructor، يرسم كل frame على buffer خارج الشاشة اولا ثم ينسخ النتيجة كاملة الى الشاشة دفعة واحدة، مما يزيل الوميض الذي تظهره الحركة المعتمدة على Timer بدونه.
المفهوم 7: عناصر متعددة وTimer مشترك واحد وتحريك الصور
التعريف. Timer واحد يمكنه تشغيل اي عدد من العناصر المتحركة المستقلة، طالما كل عنصر يحتفظ بموقعه وسرعته الخاصين بدلا من مشاركة مجموعة واحدة من المتغيرات. تحريك صورة يعني اما تحريك صورة واحدة بـ DrawImage باستخدام نفس منطق الارتداد المطبق على الاشكال، او التنقل بين سلسلة قصيرة من الصور لتبدو الصورة نفسها كانها تتحرك، مثل sprite يمشي.
كيف يعمل. الشرط الوحيد الحقيقي هو ان يحتفظ كل عنصر بموقعه وسرعته الخاصين، نفس المبدا وراء ارتداد عنصرين من نوع label بشكل مستقل، بـ fields خاصة بهما، في المفهوم 8. طريقة مناسبة لتحقيق ذلك لعدد غير معروف ومتزايد من العناصر هي collection، مثلا List<Ball> من class صغير بـ fields هي X وY وSpeedX وSpeedY وColor، ينمو اثناء التشغيل كلما انشئت عناصر جديدة. هذا يتبع مبدا الـ fields الخاصة بكل عنصر، مطبقا على عدد غير معروف من العناصر بدلا من اثنين او ثلاثة ثابتين:
private class Ball
{
public int X;
public int Y;
public int SpeedX;
public int SpeedY;
public Color Color;
}الـ Ball معرف كـ private لان لا شيء خارج Form1 يحتاجه ابدا. كل field هو field عادي وpublic بدلا من property، لان الكود الوحيد الذي يقراها او يكتبها هو كود Form1 نفسه. public هنا تعني فقط "يمكن الوصول اليه من باقي كود Form1" وليس من خارج الـ class. X وY يحتويان موقع الكرة الحالي، وSpeedX وSpeedY مقدار تغير ذلك الموقع في كل tick، وColor اللون الذي اعطي لها عند انشائها. handler الـ Tick واحد يمر على القائمة بـ foreach ويطبق منطق المفهوم 6 على fields كل عنصر. اعطاء عنصرين سرعات بداية مختلفة يجعلهما يتباعدان فورا ولا يتزامنان ابدا، لان لا شيء في الحلقة يربط fields عنصر بعنصر اخر. كائن Random وهو new Random() يعطي رقما عشوائيا زائفا مختلفا في كل مرة تستدعى احدى methods الخاصة به. rng.Next(min, max) تعيد عددا صحيحا من min حتى max ولكن لا تشمل max، فـ rng.Next(0, 5) يمكن ان تعيد 0 او 1 او 2 او 3 او 4 ولكن ابدا 5. استدعاؤها مرة لكل field عند انشاء Ball جديد هو ما يعطي كل واحد موقع ظهور ووتيرة خاصين به.
Graphics.DrawImage(image, x, y) ترسم Bitmap عند (x, y) باخذ حجم الصورة نفسه، فـ fields الحركة والارتداد في المفهوم 6 تنطبق على صورة تماما كما على شكل. فقط الاستدعاء النهائي يتغير، من FillEllipse الى DrawImage.
يمكن بناء bitmap الـ sprite بالكود بدلا من تحميله من ملف، باستخدام Graphics.FromImage (المفهوم 1). تحميل صورة من ملف بدلا من ذلك، بـ new Bitmap("file-name")، يعمل بنفس الطريقة تماما لكل ما يلي. بناء الـ bitmap بالكود بدلا من ذلك، بـ Graphics.FromImage، يعني امكانية تجربة نفس منطق الـ sprite animation بدون الحاجة لملف صورة خاص بك.
spriteImage = new Bitmap(32, 32);
using (Graphics gi = Graphics.FromImage(spriteImage))
{
gi.Clear(Color.Transparent);
gi.FillEllipse(Brushes.Orange, 0, 0, 32, 32);
gi.DrawEllipse(Pens.Black, 0, 0, 31, 31);
}new Bitmap(32, 32) تخصص صورة بقياس 32 في 32 pixel، فارغة وغير محددة حتى يرسم شيء فيها. Graphics.FromImage(spriteImage) تعيد كائن Graphics تكتب methods الرسم الخاصة به في pixels تلك الـ bitmap بدلا من الـ form، موضوعا في block من using ليتخلص منه فور رسم الدائرة لانه مطلوب مرة واحدة فقط. gi.Clear(Color.Transparent) تملا الـ bitmap كلها بلون شفاف تماما اولا، لتبقى الزوايا المربعة حول الدائرة شفافة بدلا من ان تظهر كمربع صلب عند رسم الـ sprite على الـ form لاحقا. الاستدعاءان بعد ذلك هما نفس زوج FillEllipse/DrawEllipse من المفهوم 4، لكن موجهان لـ gi بدلا من g الخاص بالـ form. لان هذا يعمل في الـ constructor قبل ظهور الـ form، يكون لدى الـ sprite دائرته جاهزة من اول مرة يرسمه فيها Form1_Paint.
استيراد صورة الى مشروع كـ resource يجعل Visual Studio يولد class اسمه Properties.Resources يحتوي مدخلة واحدة لكل ملف مستورد مسماة باسم ذلك الملف. Properties.Resources.ResourceManager.GetObject(name) تبحث عن مدخلة في ذلك الـ class المولد باسم الـ resource اثناء التشغيل. لـ sprite يتحرك في مكانه، احتفظ بـ List<string> frames من اسماء الـ resources وعدد صحيح currentFrame يبدا من 0. في كل Tick، قدم الـ index ولفه بعامل الـ modulo: currentFrame = (currentFrame + 1) % frames.Count;، ابحث عن bitmap ذلك الـ frame بـ Properties.Resources.ResourceManager.GetObject(frames[currentFrame]) (هذا يعيد object عاديا لان الـ method لا تعرف مسبقا نوع الـ resource المطلوب، لذا النتيجة تحول الى (Bitmap))، ثم عينها لـ property الـ Image الخاصة بـ PictureBox. الـ TrackBar هو control شريط تمرير يسحبه المستخدم بين Minimum وMaximum، موقعه الحالي ظاهر عبر property الـ Value. حدث Scroll الخاص به يمكنه اعادة تعيين Interval الخاص بالـ Timer لقيمة Value الخاصة بالشريط اثناء التشغيل، فـ interval اصغر (ticks اكثر في الثانية) يشغل الـ frames اسرع. عندما لا تتوفر مجموعة صور frames، نفس الفكرة تعمل بـ bitmaps مبنية بالطريقة التي بني بها spriteImage اعلاه بدلا من المستوردة: ابن List<Bitmap> frames مرة واحدة في الـ constructor بتكرار نمط new Bitmap(...) زائد Graphics.FromImage(...) في حلقة، مع رسم شيء مختلف قليلا في كل bitmap (دائرة بنصف قطر يكبر مثلا). ثم في كل Tick، قدم currentFrame بنفس الطريقة وعين frames[currentFrame] لـ property الـ Image الخاصة بالـ PictureBox مباشرة، بدون بحث عن resource او تحويل نوع، لان القائمة تحتوي بالفعل كائنات Bitmap حقيقية.
flowchart TD
T["One animationTimer_Tick"] --> B1["Ball 1: X, Y, SpeedX, SpeedY"]
T --> B2["Ball 2: X, Y, SpeedX, SpeedY"]
T --> S["Sprite bitmap: X, Y, SpeedX, SpeedY"]
B1 --> P["Form1_Paint draws every<br/>object at its own position"]
B2 --> P
S --> Pالمفهوم 8: بديل للـ Graphics: تحريك موقع الـ Control نفسه
التعريف. كل شيء في الجزء الاول والثاني يحرك برسم شكل او bitmap بـ Graphics داخل Form1_Paint. الـ control الموجود فعلا على الـ form، مثل Label، لا يحتاج ان يرسم اصلا ليتحرك: تغيير property الـ Left والـ Top الخاصتين به يعيد موضعته فورا، بدون Paint override ولا كائن Graphics ولا استدعاء Invalidate() في اي مكان.
الغرض. بعض الاشياء التي تحركها هي اشكال او صور رسمتها بنفسك، حيث Graphics وPaint هما الخيار الوحيد. اشياء اخرى هي بالفعل controls موجودة على الـ form، مثل Label يعرض نصا. تحريك واحد منها مباشرة عبر properties الموقع الخاصة به ابسط من محاولة رسم نسخة شبيهة بـ Graphics.
كيف يعمل. كل Label متحرك يحتفظ بزوج من fields السرعة خاصين به، مثلا L1x/L1y لـ label1 وزوج منفصل L2x/L2y لـ label2، نفس مبدا الـ fields الخاصة بكل عنصر كـ class الـ Ball في المفهوم 7. handler الـ Tick المشترك لـ Timer واحد يضيف سرعة كل label الى Left وTop الخاصين به، ثم يرتده بنفس طريقة المفهوم 6 لارتداد الشكل المرسوم، باستثناء ان الاختبار الان يقرا Width وHeight الخاصين بالـ control بدلا من حجم ثابت. الالية نفسها ليست اكثر من منطق ارتداد المفهوم 6 مطبقا على properties الـ Left/Top الخاصة بالـ control بدلا من fields الشكل المرسوم.
flowchart LR
A["Timer ticks every Interval ms"] --> B["Tick handler runs"]
B --> C["Add L1x / L1y to<br/>label1.Left / label1.Top"]
C --> D{"Past an edge of<br/>ClientSize.Width/Height?<br/>(test uses label1.Width/Height)"}
D -->|Yes| E["Reverse L1x or L1y"]
D -->|No| F["Keep the same speed"]
E --> G["label1 is already redrawn:<br/>no Invalidate(), no Paint"]
F --> G
G --> H["Same four steps repeat<br/>for label2 with L2x/L2y"]
H --> Aمثال.
private void timer1_Tick(object sender, EventArgs e)
{
// label1's own movement
label1.Left += L1x;
label1.Top += L1y;
if (label1.Left + label1.Width > ClientSize.Width || label1.Left < 0)
{
L1x = -L1x;
}
if (label1.Top + label1.Height > ClientSize.Height || label1.Top < 0)
{
L1y = -L1y;
}
// label2's own movement, with its own speed fields
label2.Left += L2x;
label2.Top += L2y;
if (label2.Left + label2.Width > ClientSize.Width || label2.Left < 0)
{
L2x = -L2x;
}
if (label2.Top + label2.Height > ClientSize.Height || label2.Top < 0)
{
L2y = -L2y;
}
}label1.Left += L1x; وlabel1.Top += L1y; تحركان الـ label نفسه بتغيير properties الموقع التي يوفرها Windows Forms لكل control، نفس نمط += المطبق على x += speedX في field الشكل المرسوم في المفهوم 6. الـ if blocks الاثنان هما نفس اختبار الحافة في المفهوم 6، مع قراءة label1.Width/Height بدلا من 30 الثابت، وعكس field السرعة الخاص بذلك الـ label عندما يكون صحيحا. النصف الثاني من الـ handler يكرر نفس الاسطر الاربعة بالضبط لـ label2 باستخدام fields الـ L2x/L2y الخاصة به لكي لا يؤثر اي label على سرعة الاخر ابدا. لا شيء هنا يستدعي Invalidate() او يمس Form1_Paint، لان الـ control يعيد رسم نفسه كلما تغيرت احدى properties الخاصة به. تلك صفة في الـ control وليس في رسم GDI+.
المفهوم 9: الحركة بلوحة المفاتيح
التعريف. بدلا من الارتداد عن الحواف تلقائيا، يمكن للمستخدم اختيار اتجاه العنصر المتحرك بالضغط على مفاتيح الاسهم. العنصر يستمر بالتحرك بنفس الاتجاه، tick بعد tick، حتى يضغط مفتاح سهم مختلف.
الغرض. الارتداد حركة تلقائية شبيهة بالفيزياء. لعبة يوجه فيها اللاعب شيئا تحتاج ان يكون الاتجاه نفسه مدخلا وليس رد فعل محسوبا على حافة.
كيف يعمل. enum يعرف نوعا بمجموعة ثابتة ومسماة من القيم الممكنة، تقارن بـ == كاي قيمة اخرى. enum Position { Left, Right, Up, Down } يسمي الاتجاهات الاربعة، وfield مثل Position objectPosition = Position.Right; يحمل الاتجاه الحالي. حدث KeyDown الخاص بالـ form (الـ handler الخاص به ياخذ KeyEventArgs e) يقارن e.KeyCode مع Keys.Left وKeys.Right وKeys.Up وKeys.Down، ويعيد تعيين objectPosition ليطابق اي سهم ضغط. لكي يستقبل الـ form حدث KeyDown اصلا بينما توجد controls اخرى عليه، يجب ضبط property الـ KeyPreview الخاصة بالـ form على true، لتصل ضغطات المفاتيح للـ form قبل اي control يملك الـ focus حاليا. هذا سلوك Windows Forms قياسي، وبدونه لن يرى الـ form الذي يحتوي controls اخرى مثل Button حدث KeyDown ابدا بمجرد ان يحصل احد تلك الـ controls على الـ focus. handler الـ Tick لم يعد يضيف سرعة ثابتة الى كل من x وy في كل مرة. بدلا من ذلك يتحقق من objectPosition ويغير x فقط (لـ Left/Right) او y فقط (لـ Up/Down)، بخطوة ثابتة في الاتجاه المطابق، ثم يستدعي Invalidate() كالمعتاد.
flowchart LR
K["KeyDown fires<br/>(needs KeyPreview = true)"] --> KC{"Which arrow key?"}
KC -->|Left| PL["objectPosition = Left"]
KC -->|Right| PR["objectPosition = Right"]
KC -->|Up| PU["objectPosition = Up"]
KC -->|Down| PD["objectPosition = Down"]
T["Timer ticks every Interval ms"] --> R{"Read objectPosition"}
R -->|Left or Right| MX["Change x by a fixed step"]
R -->|Up or Down| MY["Change y by a fixed step"]
MX --> I["Invalidate()"]
MY --> Iمثال.
private enum Position { Left, Right, Up, Down }
private Position objectPosition = Position.Right;
private int x = 0;
private int y = 0;
private void Form1_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Left) objectPosition = Position.Left;
else if (e.KeyCode == Keys.Right) objectPosition = Position.Right;
else if (e.KeyCode == Keys.Up) objectPosition = Position.Up;
else if (e.KeyCode == Keys.Down) objectPosition = Position.Down;
}
private void animationTimer_Tick(object sender, EventArgs e)
{
if (objectPosition == Position.Left) x -= 5;
else if (objectPosition == Position.Right) x += 5;
else if (objectPosition == Position.Up) y -= 5;
else if (objectPosition == Position.Down) y += 5;
this.Invalidate();
}objectPosition تبدا بـ Position.Right، فيتحرك العنصر لليمين حتى اول ضغطة مفتاح. Form1_KeyDown تعمل مرة واحدة لكل ضغطة مفتاح وتغير فقط اي فرع ياخذه handler الـ Tick في المرة التالية، ولا تحرك اي شيء بنفسها. animationTimer_Tick تعمل وفق جدول الـ Timer الخاص بها بغض النظر عما اذا ضغط مفتاح للتو، والـ if/else if ذات الاربعة فروع تغير واحدة بالضبط من x او y في كل tick بـ 5 pixels في الاتجاه الذي تسميه objectPosition حاليا، ثم Invalidate() تجدول اعادة الرسم كما في المفهوم 5.
المفهوم 10: انماط الخطوط المحكومة بالمستخدم بـ DrawLines والزوايا
التعريف. Graphics.DrawLines(pen, points) ترسم سلسلة متصلة من القطع المستقيمة عبر مصفوفة من قيم Point في استدعاء واحد، قطعة واحدة بين كل زوج نقاط متتاليتين، على عكس DrawLine التي لا ترسم ابدا الا قطعة واحدة بين احداثيتين. Point هو structure صغير يحتوي X وY، نفس الرقمين اللذين ياخذهما DrawLine كـ arguments منفصلة، مجمعين في قيمة واحدة.
الغرض. قراءة بضعة ارقام من المستخدم (عدد الخطوط وطولها وبزاوية كم) ورسم خط اخر في كل مرة، كل خط ملتف بتلك الزاوية عن الاخير، يبني نمطا يتحكم فيه المستخدم بدلا من صورة ثابتة في الكود.
كيف يعمل. Panel مثبت ليملا جزءا من الـ form (Dock = DockStyle.Fill، يضبط في نافذة Properties) يعطي سطح رسم عاديا منفصلا عن باقي controls الـ form. panel.CreateGraphics() تحصل على Graphics له بنفس طريقة this.CreateGraphics() للـ form (المفهوم 1)، وpanel.Refresh() تفرض اعادة رسمه قبل اضافة الخط التالي. نقطة نهاية الخط تحسب من نقطة بداية وطول وزاوية بالدرجات: بما ان Math.Cos وMath.Sin تتوقعان الـ radians، تحول الزاوية اولا: angle * Math.PI / 180، ثم endX = startX + (int)(Math.Cos(radians) * length) وendY = startY + (int)(Math.Sin(radians) * length). رسم numberOfLines خط يعني التكرار بهذا العدد، مع الرسم من نقطة النهاية السابقة الى الجديدة المحسوبة، ثم اعتبار تلك نقطة بداية الخط التالي، فكل خط يكمل من حيث انتهى الاخير. اضافة نفس الزاوية في كل مرة تلف الشكل كله قليلا مع كل خط، وهذا ما ينتج نمطا بدلا من شعاع مستقيم. int.Parse(textBox.Text) تحول ما كتبه المستخدم في TextBox الى العدد الصحيح الذي يحتاجه باقي الحساب. اعطاء الـ pen لونا جديدا بـ Color.FromArgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255)) قبل كل خط، من Random (المفهوم 7)، يجعل كل خط في النمط بلون مختلف.
flowchart TD
S["Start of one loop iteration<br/>(startX, startY, currentAngle, length)"] --> R["radians = currentAngle * Math.PI / 180"]
R --> E["endX = startX + cos(radians) * length<br/>endY = startY + sin(radians) * length"]
E --> D["DrawLines from (startX, startY)<br/>to (endX, endY)"]
D --> U["startX = endX, startY = endY<br/>currentAngle += angle, length += increment"]
U --> N{"More lines left?"}
N -->|Yes| S
N -->|No| Done["Loop ends"]مثال.
private void goButton_Click(object sender, EventArgs e)
{
int numberOfLines = int.Parse(numberOfLinesBox.Text);
int angle = int.Parse(angleBox.Text);
int length = int.Parse(lengthBox.Text);
int increment = int.Parse(incrementBox.Text);
int startX = canvas.Width / 2;
int startY = canvas.Height / 2;
int currentAngle = 0;
Random rnd = new Random();
Pen pen = new Pen(Color.Black, 1);
Graphics g = canvas.CreateGraphics();
canvas.Refresh();
for (int i = 0; i < numberOfLines; i++)
{
pen.Color = Color.FromArgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));
double radians = currentAngle * Math.PI / 180;
int endX = startX + (int)(Math.Cos(radians) * length);
int endY = startY + (int)(Math.Sin(radians) * length);
Point[] points = { new Point(startX, startY), new Point(endX, endY) };
g.DrawLines(pen, points);
startX = endX;
startY = endY;
currentAngle += angle;
length += increment;
}
}الكتلة الاولى تقرا صناديق النص الاربعة وتحول كلا منها من نص الى int بـ int.Parse. startX/startY تضبطان على منتصف الـ canvas panel وهما canvas.Width / 2 وcanvas.Height / 2. currentAngle تبدا من 0 وتلتف بمقدار angle درجة بعد كل خط، فالـ angle هو في الحقيقة مقدار الالتفاف وليس اتجاها ثابتا واحدا. canvas.Refresh() تمسح ما كان يعرضه الـ panel قبل هذه الضغطة. حلقة الـ for تعمل مرة لكل خط: تختار لون pen عشوائيا جديدا، وتحول currentAngle الجاري الى radians وتحسب نقطة النهاية على بعد ذلك العدد من الـ pixels من نقطة البداية الحالية في ذلك الاتجاه، وترسم خطا من نقطتين بـ DrawLines من البداية الى نقطة النهاية تلك، ثم تنقل startX/startY الى نقطة النهاية المرسومة للتو، وتلف currentAngle بمقدار angle درجة اخرى، وتزيد length بمقدار increment. عند ضبط angle على 90 وlength ينمو بمقدار 1 في كل مرة، يلتف الاتجاه ربع دائرة بعد كل خط بينما يطول الخط قليلا، وهذا ما يرسم لولبا شبه مربع بدلا من شعاع مستقيم واحد.
مهام المعمل
المهام من 1 الى 5 تبني مشروعا واحدا اسمه GraphicsAnimationLab، تضيف الى نفس Form1 خطوة بخطوة. القائمة الكاملة للبرنامج في نهاية هذه الصفحة تحتوي الكود النهائي للمهام الخمس، كل كتلة موسومة بالمهمة التي تضيفها. قارن فقط الكتل حتى مهمتك الحالية. المهمة 6 هي مشروع ثان منفصل اسمه FrameAnimation. لا يحتاج ملفات صور خاصة بك لان المفهوم 7 يوضح كيف تبني frames الخاصة به اثناء التشغيل، لكنه يخبرك ايضا اين تضع صورا حقيقية ان كانت لديك مجموعة.
المهمة 1: رسم صورة ثابتة بـ Graphics في حدث Paint
- انشئ مشروع Windows Forms App (.NET) اسمه
GraphicsAnimationLab. المتوقع: مشروع فارغ يفتح يعرضForm1في الـ designer. - حدد
Form1، افتح تبويب Events في نافذة Properties، وانقر نقرا مزدوجا علىPaint. المتوقع:Form1_Paint(object sender, PaintEventArgs e)فارغة تنشا. - اضف fields الـ
outlinePenوالـfillBrushمن المفهوم 3 فوق الـ constructor. المتوقع: المشروع يبنى. - داخل
Form1_Paint، احصل علىGraphics g = e.Graphics;واضف استدعاءات الرسم السبعة من مثال المفهوم 4. المتوقع: المشروع يبنى بدون اخطاء. - شغل المشروع. المتوقع: خط مائل قرب الاعلى، ومستطيل وشكل بيضاوي ازرق فاتح بحدود سوداء تحته، ونصف دائرة ومثلث على يمينهما، والنص "Lab 4" في الاسفل.
- اسحب زاوية الـ form لتغيير حجمه اثناء التشغيل. المتوقع: الصورة لا تزال موجودة، لان Paint يعيد رسمها في كل مرة.
المهمة 2: الرسم مباشرة بـ CreateGraphics وفرض اعادة الرسم
- اضف
Button(Name:drawNowButton،Text:Draw Now)، بعيدا عن اشكال المهمة 1، وانقر نقرا مزدوجا عليه. المتوقع:drawNowButton_Click(object sender, EventArgs e)فارغة تنشا. - داخل الـ handler المولد، استخدم
this.CreateGraphics()للحصول علىGraphics، ثم ارسم مستطيلا احمر بـPenوDrawRectangle(المفهومان 3 و4)، كما في method الـdrawNowButton_Clickفي القائمة الكاملة للبرنامج، ثم استدعg.Dispose();. المتوقع: المشروع يبنى. - شغل وانقر Draw Now. المتوقع: مستطيل احمر يظهر فورا.
- اثناء التشغيل، غير حجم النافذة، او غطها بنافذة اخرى ثم ارجعها.
المتوقع: اشكال المهمة 1 لا تزال مرئية، لكن المستطيل الاحمر اختفى، لانه رسم بـ
CreateGraphics()خارجForm1_Paint، فلا شيء يعيد رسمه في حدث Paint التالي. - تمرين اختياري: انشئ مشروعا ثانيا منفصلا بـ
Panelمثبت، واربعة مدخلاتTextBox(عدد الخطوط والزاوية والطول والزيادة) وButton، وابن نمط الخطوط المحكوم بالمستخدم من المفهوم 10. المتوقع: النقر على الزر يرسم العدد المطلوب من قطع الخطوط المتصلة الملونة عشوائيا على الـ panel، تلتف بالزاوية المدخلة وتنمو بالزيادة المدخلة في كل مرة.
المهمة 3: تحريك شكل واحد بـ Timer وارتداده عن الحواف
- اضف
Timer(Name:animationTimer،Interval:30) وانقر نقرا مزدوجا عليه لانشاءanimationTimer_Tick. المتوقع:animationTimer_Tick(object sender, EventArgs e)فارغة تنشا. - اضف
Button(Name:addBallButton،Text:Add Ball) وانقر نقرا مزدوجا عليه. المتوقع:addBallButton_Click(object sender, EventArgs e)فارغة تنشا. - فوق الـ constructor، اضف class الـ
Ball(المفهوم 7) وfields الـballs/rngمن القائمة الكاملة للبرنامج. المتوقع: المشروع يبنى (System.Collections.Genericيجب ان يكون في قائمةusingليتم compile الـList<Ball>). - في الـ constructor، بعد
InitializeComponent();، اضبطthis.Width = 700; this.Height = 500; this.DoubleBuffered = true;. - اكمل
addBallButton_Click: انشئBallبموقع عشوائي يبقي الدائرة كاملة بقياس 30 في 30 على الشاشة (rng.Next(0, ClientSize.Width - 30)لـX، ونفس النمط معHeightلـY) وسرعة عشوائية من 2 حتى 6 غير شاملة في كل محور (rng.Next(2, 6)لـSpeedXوSpeedY)، ولون عشوائيColor.FromArgb(rng.Next(256), rng.Next(256), rng.Next(256))، اضفه لـballs، واضبطanimationTimer.Enabled = true;. المتوقع: المشروع يبنى. - اضف حلقة
foreachعلىballsفي نهايةForm1_Paintتملا قطعا ناقصا بقياس 30 في 30 لكل كرة بلونها: لكلBall b، ضعnew SolidBrush(b.Color)في block منusing(المفهوم 3) واستدعFillEllipseبه، لان لون كل كرة لا يعرف الا بعد وجودها ولا يمكن ان يكون field معاد الاستخدام مثلoutlinePenوfillBrush. المتوقع: المشروع يبنى. - اكمل
animationTimer_Tickبمنطق الحركة والارتداد من المفهوم 6، معدلا للمرور علىballsبـforeach. المتوقع: المشروع يبنى بدون اخطاء. - شغل وانقر Add Ball مرة واحدة. المتوقع: دائرة ملونة تظهر في موقع عشوائي وتبدا بالتحرك. عند وصولها لاي حافة من الـ form، ترتد للداخل بدلا من ان تخرج او تتوقف.
- تمرين اختياري: اعط الـ form handler لـ
KeyDownوfield منenum Positionكما في المفهوم 9، واضبطthis.KeyPreview = true;، واستخدم مفاتيح الاسهم لتوجيه اخر كرة اضيفت بدلا من ارتدادها تلقائيا فقط: داخل حلقةforeachفيanimationTimer_Tick، قارن متغير الحلقة باخر كرة بالمرجعif (b == balls[balls.Count - 1])، وطبق حركة المفهوم 9 القائمة على الاتجاه فقط داخل ذلك الفرع، مع ابقاء فحص الارتداد التلقائي في فرعelseلكلBallاخر فيballs. المتوقع: ضغط مفتاح سهم يغير اتجاه حركة تلك الكرة فورا، وتستمر بالتحرك بذلك الاتجاه حتى يضغط مفتاح سهم مختلف.
المهمة 4: تحريك عدة كرات مستقلة من Timer مشترك واحد
- بينما المهمة 3 لا تزال تعمل، انقر Add Ball خمس او ست مرات اخرى، في اوقات مختلفة.
المتوقع: كل نقرة تضيف دائرة اخرى بموقع وسرعة عشوائيين جديدين. كل دائرة تستمر بالتحرك والارتداد في مسارها الخاص، لان كلا منها يحتفظ بـ
XوYوSpeedXوSpeedYالخاصة بها. - راقب تصادم دائرتين.
المتوقع: تمران عبر بعضهما، لان
animationTimer_Tickيفحص كل كرة فقط مقابل حواف الـ form ولا يفحصها ابدا مقابل كرة اخرى. هذا متوقع لان لا فحص كهذا كتب. - اوقف المشروع، غير
IntervalالـanimationTimerمن30الى100في الـ designer، وشغل مرة اخرى. المتوقع: كل كرة تتحرك ابطا بشكل ملحوظ، لان interval اكبر يعني تحديثات اقل في الثانية. اعده الى30بعد ذلك. - تمرين اختياري: اضف عنصرين من نوع
Labelcontrol الى الـ form، وفي handlerTickمنفصل او كتلة اضافية فيanimationTimer_Tick، ارتد كلا منهما بتغييرLeft/Topالخاصين به مباشرة، مع زوج fields سرعة خاصين به، كما في المفهوم 8، بدلا من رسم اي شيء بـGraphics. المتوقع: كلا الـ labels يتحركان ويرتدان مستقلين عن بعضهما وعن الكرات، بدون اي كودForm1_Paintمضاف لهما اطلاقا.
المهمة 5: تحريك صورة بـ DrawImage
- فوق الـ constructor، اضف هذه الـ fields من القائمة الكاملة للبرنامج:
private Bitmap spriteImage; private int spriteX = 50, spriteY = 50, spriteSpeedX = 4, spriteSpeedY = 3;. الـspriteImageيبنى في الـ constructor في الخطوة التالية. الـ fields الـintالاربعة تعطي الـ sprite موقعه الابتدائي (50, 50) وسرعته الخاصة، منفصلة عن اي كرة. المتوقع: المشروع يبنى. - في الـ constructor، بعد الخطوة 4 من المهمة 3، ابن bitmap دائريا صغيرا اثناء التشغيل بـ
Graphics.FromImage(spriteImage)، متبعا مثال بناء الـ bitmap في المفهوم 7 (الظاهر ايضا في القائمة الكاملة للبرنامج)، لكي لا يحتاج المشروع ملف صورة مستوردا. المتوقع: المشروع يبنى.Graphics.FromImageترسم في الـ bitmap نفسه وليس على الـ form، وهكذا يحصل على دائرته قبل ظهور الـ form. - اضف
g.DrawImage(spriteImage, spriteX, spriteY);في نهايةForm1_Paint. المتوقع: المشروع يبنى. - في
animationTimer_Tick، بعد حلقة الـforeach، اضف منطق حركة وارتداد الـ sprite الخاص، مفحوصا مقابلspriteImage.Width/Heightبنفس طريقة الكرة. المتوقع: المشروع يبنى بدون اخطاء. - شغل بدون النقر على Add Ball.
المتوقع: لا شيء يتحرك بعد، لان
animationTimerيفعل فقط داخلaddBallButton_Click. الـ sprite يجلس ساكنا عند (50, 50). - انقر Add Ball مرة واحدة.
المتوقع: الكرة والـ sprite يتحركان ويرتدان في نفس الوقت، بسرعتيهما المستقلتين، مشغلين بالـ
animationTimerالواحد. - اختياري: استبدل الـ bitmap المولد بصورة حقيقية. اضف ملف صورة موجودا للمشروع، اضبط property الـ Copy to Output Directory على Copy if newer، واستبدل اسطر بناء الـ bitmap الثلاثة بـ
spriteImage = new Bitmap("your-file-name.png");. المتوقع: الصورة المستوردة تتحرك وترتد تماما كما فعلت الدائرة المولدة، لان لاDrawImageولا منطق الارتداد يعتمدان على كيفية انشاء الـ bitmap.
المهمة 6: رسوم متحركة Frame-by-Frame بـ Timer وTrackBar
انشئ مشروعا ثانيا منفصلا اسمه FrameAnimation لهذه المهمة.
-
اضف
Timer(Name:animationTimer،Interval:50،Enabled: مفعل)، وPictureBox(Name:frameImage،SizeMode:StretchImage)، وTrackBar(Name:speedBar،Minimum:50،Maximum:100).SizeMode: StretchImageيمدد اي صورة تعين لـframeImage.Imageلتملا حدود الـPictureBoxبدلا من قصها، فكل frame يملا نفس المربع حتى لو لم ترسم الـ frames كلها بنفس ذلك القياس بالضبط. المتوقع: المشروع يبنى مع وضعanimationTimerوframeImageوspeedBarعلى الـ form. -
ابن سلسلة الـ frames فوق الـ constructor. ان كانت لديك مجموعة صور frames خاصة بك (sprite sheet مقسمة الى ملفات منفصلة)، استوردها كـ project resources: انقر بالزر الايمن على المشروع، Add، Existing Item، حددها معا، واكمل مع نقطة الصور المستوردة في الخطوة 3. والا فلا حاجة لملفات صور: عرف field من
List<Bitmap> frames = new List<Bitmap>();، وفي الـ constructor بعدInitializeComponent();، ابن 8 bitmaps في حلقة، واحدا لكل frame، بنفس طريقة بناءspriteImageفي المفهوم 7:new Bitmap(64, 64)زائد block منusing (Graphics.FromImage(...))يرسم دائرة تنمو بمقدار 4 pixels من نصف القطر في كل frame مع بقائها في منتصف الـ bitmap بقياس 64 في 64.FillEllipseلا تزال تاخذ مستطيلا محيطا وليس مركزا ونصف قطر (المفهوم 4)، لذا نصف القطر المختار يجب تحويله اولا: لـ index الـ frame وهوiمن 0 الى 7،int radius = 4 * (i + 1);تعطي انصاف اقطار 4 و8 و12 وهكذا حتى 32، والمستطيل المحيط الذي يبقي دائرة بنصف القطر ذلك في منتصف الـ bitmap بقياس 64 في 64 هوint x = 32 - radius; int y = 32 - radius; int size = radius * 2;، ويمرر كـFillEllipse(Brushes.Orange, x, y, size, size). اضف كل bitmap منتهي الىframesقبل الانتقال للتكرار التالي، ثم اكمل مع نقطة الـ bitmaps المولدة في الخطوة 3. -
اكمل تعريف قائمة الـ frames، حسب اي نصف من الخطوة 2 اتبعت:
- الصور المستوردة: عرف
List<string> framesباسماء الـ resources بدون الامتداد وint currentFrame = 0;، كما في مثال المفهوم 7. - الـ Bitmaps المولدة: بعد الحلقة من الخطوة 2، اضبط
frameImage.Image = frames[0];ليعرض المربع اول frame قبل اول tick للـ timer، وعرفint currentFrame = 0;.
المتوقع في كلتا الحالتين: المشروع يبنى.
- الصور المستوردة: عرف
-
انقر نقرا مزدوجا على
animationTimerواكتب handler الـTick. السطر الاول واحد في كلا الفرعين، يقدم الـ index ويلفه بعامل الـ modulo:currentFrame = (currentFrame + 1) % frames.Count;(المفهوم 7). ثم:- الصور المستوردة: احضر الـ bitmap بـ
Properties.Resources.ResourceManager.GetObject(frames[currentFrame])، حولها الىBitmap، وعينها لـframeImage.Image. - الـ Bitmaps المولدة: عين
frames[currentFrame]لـframeImage.Imageمباشرة، بدون بحث او تحويل نوع، لانframesتحتوي بالفعل كائناتBitmapحقيقية.
المتوقع: المشروع يبنى.
- الصور المستوردة: احضر الـ bitmap بـ
-
شغل المشروع. المتوقع: مربع الصورة يتنقل بين الـ frames مرة كل 50 millisecond، معطيا مظهر حركة مستمرة (دائرة نابضة للـ frames المولدة، او sprite خاص بك للمستوردة).
-
انقر نقرا مزدوجا على
speedBarوفي handler الـScrollالخاص به، اضبطanimationTimer.Interval = speedBar.Value;. المتوقع: المشروع يبنى. -
شغل واسحب شريط التمرير نحو 50 ثم نحو 100. المتوقع: الحركة تسرع بشكل ملحوظ قرب 50 (interval اقصر يعني ticks اكثر في الثانية) وتبطئ قرب 100، وتتغير مباشرة اثناء سحب الشريط.
ملخص
- كائن
Graphicsهو سطح رسم GDI+، يحصل عليه بثلاث طرق:e.Graphicsداخل Paint يعاد رسمه في كل اعادة رسم.this.CreateGraphics()يرسم مرة واحدة مباشرة على صورة الشاشة الحالية ولا يحفظ.Graphics.FromImage(bitmap)يرسم في pixels الـBitmapنفسه بدلا من الـ form، وهكذا تبنى صورة بالكود قبل ظهور الـ form. - نظام الاحداثيات يبدا من (0, 0) في الزاوية العلوية اليسرى، x ينمو لليمين وy ينمو للاسفل، بالـ pixels.
- الـ
Penيرسم الحدود والـSolidBrushيملا الداخل وPens/Brushesيعطيان كائنات جاهزة للالوان المسماة وDrawStringيرسم نصا بـFont(اسم العائلة ثم الحجم بالنقاط). القطع الناقص والقوس يعرفان بمستطيل محيط وليس مركزا ونصف قطر. القوس يضيف زاوية بداية (من موضع الساعة 3) وزاوية مسح، موجبة لاتجاه عقارب الساعة وسالبة لعكسه.DrawPolygon/FillPolygonتصل مصفوفة من قيمPointفي شكل مغلق مضيفة قطعة اخيرة عائدة للنقطة الاولى. الـ pens والـ brushes تتخلص منها عادة عند الانتهاء، والطويلة العمر تبقى كـ field وتعاد استخدامها، والقصيرة العمر المبنية من بيانات لا تعرف الا عند الاستخدام (لون الكرة العشوائي) تبنى هناك وتوضع في block منusing. Invalidate()تجدول اعادة رسم للفرصة التالية وRefresh()تفرضها فورا. اي شيء يجب ان يبقى بعد تغيير الحجم او الـ animation frame التالي يجب ان يرسم داخل Paint وليس ابدا عبرCreateGraphics().- الـ
TimerيطلقTickكلIntervalmillisecond طالماEnabledقيمتهاtrue. اضافة سرعة الى موقع وعكس اشارة تلك السرعة عندما يتجاوز الموقع زائد حجم العنصر حافة منClientSizeواستدعاءInvalidate()ينتج عنصرا واحدا مرتدا.DoubleBuffered = trueيزيل الوميض الناتج. - نفس handler الـ
Tickيشغل اي عدد من العناصر المستقلة، بشرط ان يحتفظ كل منها بموقعه وسرعته الخاصين، مثلا في class صغير مخزن فيList<T>ويحدث بـforeach. method الـ.Next(min, max)الخاصة بكائنRandomتعيد عددا صحيحا منminحتىmaxغير شامل، وهذا ما يعطي كل عنصر جديد نقطة ظهور وسرعة خاصتين به. DrawImage(image, x, y)تحرك صورة تماما مثلماDrawRectangleتحرك شكلا، فنفس منطق الارتداد ينطبق بدون تغيير. الـ sprite المتحرك في مكانه يتنقل بين قائمة frames بـcurrentFrame = (currentFrame + 1) % frames.Count، اما اسماء resources تحضر بـProperties.Resources.ResourceManager.GetObjectوتحول الىBitmap، او bitmaps مبنية اثناء التشغيل تستخدم مباشرة. حدثScrollالخاص بـTrackBarيمكنه تغييرIntervalالخاص بالـTimerاثناء التشغيل لتغيير السرعة.- الـ control الموجود فعلا على الـ form مثل
Labelيمكن تحريكه بدونGraphicsاطلاقا بتغييرLeft/Topالخاصين به مباشرة من handler الـTick. handler الـKeyDownوenumللاتجاهات يسمحان للمستخدم بتوجيه اتجاه عنصر بدلا من ارتداده تلقائيا. وGraphics.DrawLinesمع مصفوفة من قيمPointمعMath.Cos/Math.Sinعلى زاوية محولة الى radians يحول بضعة ارقام يكتبها المستخدم الى نمط خطوط.
القائمة الكاملة للبرنامج
هذا هو Form1.cs النهائي لمشروع GraphicsAnimationLab المستخدم في المهام من 1 الى 5. كل كتلة موسومة بالمهمة التي تضيفها، فقارن فقط الكتل حتى المهمة التي تعمل عليها حاليا. هذا الملف يحتوي النصف القابل للتحرير من partial class الـ Form1. النصف المولد (InitializeComponent والـ fields التي ينشئها لـ drawNowButton وaddBallButton وanimationTimer) ينتج تلقائيا بمجرد اضافة تلك الـ controls الثلاثة في الـ designer كما تصف المهام بالضبط، وربط احداث Paint وClick وTick الخاصة بها بالنقر المزدوج عليها هناك.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
namespace GraphicsAnimationLab
{
public partial class Form1 : Form
{
// Task 1: a Pen draws outlines, a SolidBrush fills interiors.
private Pen outlinePen = new Pen(Color.Black, 2);
private SolidBrush fillBrush = new SolidBrush(Color.LightBlue);
// Task 3: one small class per moving ball, so every ball keeps its
// own position and its own speed instead of sharing one set of
// variables.
private class Ball
{
public int X;
public int Y;
public int SpeedX;
public int SpeedY;
public Color Color;
}
// Task 3 and Task 4: every ball currently on the form, all moved by
// the one shared animationTimer.
private List<Ball> balls = new List<Ball>();
private Random rng = new Random();
// Task 5: a small bitmap, animated with the same bounce logic as a ball.
private Bitmap spriteImage;
private int spriteX = 50;
private int spriteY = 50;
private int spriteSpeedX = 4;
private int spriteSpeedY = 3;
public Form1()
{
InitializeComponent();
this.Width = 700;
this.Height = 500;
this.DoubleBuffered = true; // Task 3: removes the flicker of a Timer-driven animation.
// Task 5: build a small circular sprite at run time, so the
// project does not depend on an imported image file.
spriteImage = new Bitmap(32, 32);
using (Graphics gi = Graphics.FromImage(spriteImage))
{
gi.Clear(Color.Transparent);
gi.FillEllipse(Brushes.Orange, 0, 0, 32, 32);
gi.DrawEllipse(Pens.Black, 0, 0, 31, 31);
}
}
// Task 1 (later tasks add more drawing to this same handler):
// everything drawn here is redrawn on every repaint, so it survives
// a resize, a minimize, or another window passing over the form.
private void Form1_Paint(object sender, PaintEventArgs e)
{
Graphics g = e.Graphics;
// Task 1: the static picture.
g.DrawLine(outlinePen, 20, 20, 150, 90);
g.DrawRectangle(outlinePen, 20, 120, 120, 80);
g.FillEllipse(fillBrush, 160, 120, 100, 80);
g.DrawEllipse(outlinePen, 160, 120, 100, 80);
g.DrawArc(outlinePen, 280, 120, 80, 80, 0, 180);
Point[] triangle = { new Point(400, 200), new Point(440, 120), new Point(480, 200) };
g.DrawPolygon(outlinePen, triangle);
g.DrawString("Lab 4", new Font("Arial", 16), fillBrush, 20, 220);
// Task 3 and Task 4: every ball currently in the list.
foreach (Ball b in balls)
{
using (SolidBrush ballBrush = new SolidBrush(b.Color))
{
g.FillEllipse(ballBrush, b.X, b.Y, 30, 30);
}
}
// Task 5: the sprite, drawn with DrawImage like any other picture.
g.DrawImage(spriteImage, spriteX, spriteY);
}
// Task 2: drawn directly on the form's current surface, outside
// Paint. Resize the window or cover it with another window afterward
// and this rectangle is gone, because Form1_Paint never drew it.
private void drawNowButton_Click(object sender, EventArgs e)
{
Graphics g = this.CreateGraphics();
using (Pen p = new Pen(Color.Red, 3))
{
g.DrawRectangle(p, 450, 20, 100, 60);
}
g.Dispose();
}
// Task 3 (one click) and Task 4 (several clicks): a new, independent
// ball every time the button is pressed.
private void addBallButton_Click(object sender, EventArgs e)
{
Ball b = new Ball();
b.X = rng.Next(0, this.ClientSize.Width - 30);
b.Y = rng.Next(0, this.ClientSize.Height - 30);
b.SpeedX = rng.Next(2, 6);
b.SpeedY = rng.Next(2, 6);
b.Color = Color.FromArgb(rng.Next(256), rng.Next(256), rng.Next(256));
balls.Add(b);
animationTimer.Enabled = true;
}
// Task 3 and Task 4: one shared Timer moves every ball and the
// sprite, bouncing each one independently off the edges of the form.
private void animationTimer_Tick(object sender, EventArgs e)
{
foreach (Ball b in balls)
{
b.X += b.SpeedX;
b.Y += b.SpeedY;
if (b.X < 0 || b.X + 30 > this.ClientSize.Width)
{
b.SpeedX = -b.SpeedX;
}
if (b.Y < 0 || b.Y + 30 > this.ClientSize.Height)
{
b.SpeedY = -b.SpeedY;
}
}
// Task 5: the sprite bounces the same way, on its own speed.
spriteX += spriteSpeedX;
spriteY += spriteSpeedY;
if (spriteX < 0 || spriteX + spriteImage.Width > this.ClientSize.Width)
{
spriteSpeedX = -spriteSpeedX;
}
if (spriteY < 0 || spriteY + spriteImage.Height > this.ClientSize.Height)
{
spriteSpeedY = -spriteSpeedY;
}
this.Invalidate();
}
}
}