الشبكة العربية لمطوري الألعاب

موهوب  عبدالله الشمّري مشاركة 1

السلام عليكم ..

سبق أن طرحت هذا الموضوع في الفريق العربي .. ولكن يبدو أن مناقشة الموضوع بشكل عام .. غير مناسب .. وليس من السهولة تطبيقه ..

لذلك السؤال سيكون حول تطبيق فكرة ال       serializationفي برمجة الألعاب  ..

لو فرضنا أننا طورنا محرك صغير جدا .. وأردنا أن يتم دعم ال serialization  فيه .. بمعنى .. أن نعمل كذا :


 Sprite *S  = new Sprite();

 ObjectOutputStream::writeObject( S) ; 


كيف نطبق أو نحاكي هذا المفهوم .. في السي بلس ..

الطريقة التي كنت أنوي تطبيقها هي بعمل كلاس أب اسمه Object .. يحمل ثلاث دوال مهمة تساعد على تطبيق هذا المبدأ :



class Object { 
 virtual void storeInFile(const Stream& str); 
virtual  void loadFromFile(const Stream& str);
 virtual String toString();
 virtual void writeToXML(const XMLStream& str);
}


وكل كلاس يقوم بعمل imp للدوال السابقة .. وبالتالي نكتب محتويات الكلاس الى ملف .. بأكثر من طريقة  .

السؤال .. هل توجد طريقة أحسن .. من هذه .. وهل هذه تنفع أصلا !!

شكرا لكم مقدما☺ ..

--
طالب - تخصص نظم معلومات .
--

خبير مدير وسام البهنسي مشاركة 2

يوجد طريقتان أساسيتان تستخدمان في حل موضوع الـ serialization في الحالة العامة، وهناك طريقة خاصة جداً تعمل فقط على الأنظمة المحدودة.
 
الطريقة الأولى هي التي تفضلت أخي الشمري بطرحها. وهي طريقة نظيفة وفعالة وقابلة للتمديد بسهولة نسبياً. إلا أن هناك بعض التفاصيل التي أود أن ألفت انتباهك إليها كي تستطيع تطبيقها بنجاح.
 
المشكلة الكبرى في عملية التحميل هي معرفة نوع الـ class الذي يجب إنشاؤه باستخدام تعليمة new. فمثلاً، فلنفرض أننا اتبعنا بنية Object التي اقترحتها أنت:


class Object
{
public:
  virtual ~Object() {} // لا تنسى هذا السطر، فهو ضروري جداً 
  virtual void storeInFile(Stream& strm) = 0
  {
    strm << PosX;
    strm << PosY;
  }

  virtual void loadFromFile(Stream& strm) = 0
  {
    strm >> PosX;
    strm >> PosY;
  }
 
protected:
  float PosX,PosY;
};
 
 
class ColoredMesh : public Object
{
public:
  virtual ~ColoredMesh() {}
  virtual void storeInFile(Stream& strm)
  {
    Object::storeInFile(strm);
    strm << ColorR;
    strm << ColorG;
    strm << ColorB;

  }

  virtual void loadFromFile(Stream& strm)
  {
    Object::loadFromFile(strm);

    strm >> ColorR;
    strm >> ColorG;
    strm >> ColorB;

  }
 
protected:
  float ColorR;
  float ColorG;
  float ColorB;
};
 
 
class MonochromeMesh : public Object
{
public:
  virtual ~MonochromeMesh() {}
  virtual void storeInFile(Stream& strm)
  {
    Object::storeInFile(strm);
    strm << ColorIntensity;
  }

  virtual void loadFromFile(Stream& strm)
  {
    Object::loadFromFile(strm);

    strm >> ColorIntensity;

  }
 
protected:
  float ColorIntensity;
};
 
 
void main(void)
{
  FileStream strm("C:\\GameFile.bin");
 
  // الآن نريد تحميل الملف، لكننا لا نعلم ما هو نوع الكلاس المطلوب إنشاؤه
  // أي السطرين التاليين هو الذي يجب تنفيذه؟
  //Object *pObject = new ColoredMesh();
  //Object *pObject = new MonochromeMesh();

  pObject->loadFromFile(strm);
}
 
 
 
لهذا السبب لا يمكننا اعتماد الهيكلية التي طرحتها أنت حرفياً، وإنما نحن بحاجة إلى تعديل جوهري. فكود التحميل هو المسؤول عن معرفة نوع الجسم الذي يجب تحميله وليس المستخدم...
 
ما البديل؟
 
في الحالة المثلى، نود أن يكون الكود بهذه البساطة:
 

FileStream strm("C:\\GameFile.bin"); // فتح ملف اللعبة
Object *pObject = Object::Load(strm); // تحميل الجسم من الملف
 
C3DMesh *pMesh = dynamic_cast(pObject);
if (pMesh)
{
    pGame->AddMesh(pMesh);
}
else
{
   // It is not a mesh, it is something else!
   cout << pObject->GetType() << endl;
}
 
لاحظ أن الإجراء Load يتم نداؤه على أنه static. وهو يتكفل بإنشاء الجسم الصحيح داخلياً. لذلك المستخدم النهائي ليس مضطراً لمعرفة الجسم الذي تم تحميله مسبقاً، وليس بحاجة لاستخدام تعليمة new بنفسه. الكود التالي يقدم الهيكلية الصحيحة لنفس البنية السابقة:
 


// قائمة بجميع أنواع الأجسام التي ندعمها
enum ObjectType
{
  Type_MeshColored,
  Type_MeshMonochrome,
};
 
// الكلاس الأساسي لكل الأجسام في اللعبة
class Object
{
public:
  Object(ObjectType type) : MyType(type)
  {
  }
 
  virtual ~Object() {}

 
  static void Save(Stream& strm,Object *pObject)
  {
    strm << (int)pObject->MyType; // حفظ نوع الجسم
    strm << pObject->PosX;
    strm << pObject->PosY;
    pObject->storeInFile(strm); // حفظ المعلومات الإضافية الخاصة بالجسم الفعلي


  }
 
  static Object* Load(Stream& strm);
 
protected:
  virtual void storeInFile(Stream& strm) = 0;
  virtual void loadFromFile(Stream& strm) = 0;
  float PosX,PosY;
 
private:
  ObjectType MyType;
};
 
 
// جسم ملون
class ColoredMesh : public Object
{
public:
  ColoredMesh() : Object(Type_MeshColored)
  {
  }

  virtual ~ColoredMesh() {}

protected:
  virtual void storeInFile(Stream& strm)
  {
    strm << ColorR;
    strm << ColorG;
    strm << ColorB;

  }

  virtual void loadFromFile(Stream& strm)
  {
    strm >> ColorR;

    strm >> ColorG;
    strm >> ColorB;

  }
 
protected:
  float ColorR;
  float ColorG;
  float ColorB;
};
 
 
// جسم رمادي

class MonochromeMesh : public Object
{
public:
  MonochromeMesh() : Object(Type_MeshMonochrome)
  {
  }

  virtual ~MonochromeMesh() {}



  virtual void storeInFile(Stream& strm)
  {
    strm << ColorIntensity;
  }

  virtual void loadFromFile(Stream& strm)
  {
    strm >> ColorIntensity;
  }
 
protected:
  float ColorIntensity;
};
 
// كود التحميل المركزي في المحرك

Object* Object::Load(Stream& strm)
{
  int ObjectType;
  strm >> ObjectType;  // حمل نوع الجسم المحفوظ في الملف
 
  // الآن ننشئ الجسم الصحيح بناءً على النوع المحفوظ في الملف
  Object *pNewObject = NULL;
  switch (ObjectType)
  {
    case Type_MeshColored:
      pNewObject = new ColoredMesh();
      break;
 
    case Type_MeshMonochrome:
      pNewObject = new MonochromeMesh();
      break;
  }
 
  // تحميل الخصائص المشتركة
  strm >> pNewObject->PosX;
  strm >> pNewObject->PosY;
 
  pNewObject->loadFromFile(strm); // تحميل المعلومات الخاصة الإضافية
 
  return pNewObject; // جاهز للاستخدام المباشر
}
 
 
void main(void)
{
  FileStream strm("C:\\GameFile.bin"); // فتح ملف اللعبة
  Object *pObject = Object::Load(strm); // تحميل الجسم من الملف
  // افعل أي شيء بالجسم الذي تم تحميله
 
  Object::Save(strm,pObject); // حفظ الجسم في الملف
 
  delete pObject; // لا ننسى تحرير الجسم ;)
}
 
 
 
 
هذه الطريقة مشهورة بطريقة المصنع (class factory) . وذلك لأنك تعتمد على إجراء وحيد يعمل كمصنع لجميع الأجسام في اللعبة (Object::Load).
لاحظ مدى بساطة التحميل بالنسبة للمستخدم النهائي، ولاحظ مدى بساطة إنشاء كلاسات جديدة وجعلها تدعم الحفظ والتحميل دون أخطاء.
 
الكود أعلاه يمثل بالفعل وحدة حفظ وتحميل كاملة تستطيع إدراجها مباشرة في محركك والبناء عليها وتطويرها.
 
أما موضوع نوعية الملف الناتج (ثنائي أو XML أو نصي ... الخ) فيتم تحديده بنوع الـ Stream الذي يمرر لإجراءات الحفظ. فلو مررت XmlStream فسيتم حفظ/تحميل ملف XML، ولو مررت BinaryStream فسيتم حفظ/تحميل ملف ثنائي وهكذا.
 
هذه الطريقة منتشرة بالفعل وتستخدم في الكثير من الألعاب والبرامج، ولقد كان الإصدار الثالث من محرك DSK يعتمد هذا النظام لحفظ جميع الموارد المدعومة (إكساءات، مجسمات، مظللات، حركات ...الخ). وقد ورثت لعبة قريش نفس هذا النظام أثناء حفظ المراحل من برنامج تصميم المراحل (الإدريسي).
 
سأكمل الموضوع بالحديث عن الطريقتين الأخريين للـ serialization في مشاركة لاحقة ضمن هذا الموضوع إن شاء الله. ولو كان لديك أسئلة عما تحدثنا عنه حتى الآن فلا تتردد بطرحها ومناقشة الأفكار...

وسام البهنسي
مبرمج في إنفيديا وإنفريمز

موهوب  عبدالله الشمّري مشاركة 3

جزاك الله خير ,, الشرح واضح ..
لايوجد أسئلة .. باستثناء نقطة بسيطة ..
المستخدم للمحرك .. لن يستطيع أن يدعم كائناته التي صنعها بنفسه وورثت من Object ..
بمعنى, بعد توزيع المحرك( مغلق المصدر)  .. لن أستطيع التعديل على الدالة Object::load .. حتى تدعم الكلاسات الجديدة التي أنشأها المستخدم ..
عموما أكيد لهذه المشكلة حل .. لم أفكر بصراحة .. لذلك تستطيع أن تتجاهل هذه النقطة .. اذا كنت ترى أن حلها سهل☺ ..
 
أتطلع لمعرفة باقي الطرق ..

--
طالب - تخصص نظم معلومات .
--

خبير مدير وسام البهنسي مشاركة 4

وفي 29 يونيو 2008 10:00 ص، أعرب الشمري عن رأيه بالموقف كالآتي:

المستخدم للمحرك .. لن يستطيع أن يدعم كائناته التي صنعها بنفسه وورثت من Object ..
بمعنى, بعد توزيع المحرك( مغلق المصدر)  .. لن أستطيع التعديل على الدالة Object::load .. حتى تدعم الكلاسات الجديدة التي أنشأها المستخدم ..
عموما أكيد لهذه المشكلة حل .. لم أفكر بصراحة .. لذلك تستطيع أن تتجاهل هذه النقطة .. اذا كنت ترى أن حلها سهل☺ ..

كلامك صحيح 100%. في هذه الحالة يوجد حل لطيف أيضاً. الفكرة أن يقوم المستخدم "بتسجيل" الكلاسات التي يريد من نظام الحفظ أن يدعمها. عملية التسجيل هي عبارة عن إضافة مؤشر إلى إجراء (function pointer) إلى قائمة يحتفظ بها المحرك ويستخدمها عند إنشاء الكلاسات الجديدة أثناء التحميل.
 
مثلاً:
 

typedef Object* (*ObjectFactory)(int classType); // تعريف نوع (مؤشر إلى إجراء) ـ
 
static Object* EngineFactory(int classType)
{
  switch (classType)
  {
    case Type_Mesh: return new Mesh();
    case Type_Light: return new Light();
    case Type_Texture: return new Texture();

  }
  return NULL; // Unknown type
}
 
static vector g_Factories; // مصفوفة بكل المصانع المدعومة
 
// الإجراء الرسمي لإنشاء الكلاسات في البرنامج بشكل عام
Object* CreateObject(int classType)
{
  // حلقة تدور على كل المصانع المسجلة لتجد المصنع المناسب
  for (unsigned int i=0;i<vector.size();i++)>
  {
    Object *pNewObject = g_Factories[i](classType); // نداء المصنع
    if (pNewObject != NULL)
      return pNewObject; // تم إنشاء الكلاس بنجاح!
  }
 
  // لو وصلنا إلى هنا فهذا يعني أننا نواجه نوع كلاس غريب غير مدعوم من أي مصنع مسجل
  return NULL;
}
 
// الإجراء الرسمي لتسجيل المصانع في المحرك
void RegisterFactory(ObjectFactory factoryFunction)
{
  g_Factories.add(factoryFunction);
}
 
// هذا الإجراء تخيلي، هدفه إيضاح كيفية تسجيل مصنع جديد
void EngineInit()
{
  // إضافة المصنع الأساسي للمحرك والذي يدعم جميع كلاسات المحرك الأصلية
  RegisterFactory(EngineFactory);
}
 
ضمن هذا النظام، كل ما عليك فعله هو كتابة إجراء يقوم بعمل switch على نوع الكلاس المطلوب، فإن كان ضمن القائمة التي تدعمها فعليك إنشاؤه ، وإلا فعليك إرجاع القيمة NULL والتي تعني أنك لا تدعم الكلاس المطلوب.
المحرك يملك قائمة بكل هذه الإجراءات، وسيدور عليها واحداً واحداً إلى أن ينجح أحد المصانع بإنشاء الكلاس.
لذلك فإن المستخدم يستطيع الآن جعل المحرك يدعم تحميل الكلاسات الجديدة التي لم يكن المحرك يدعمها أساساً.
 
مثلاً:



enum GameTypes
{
  Type_Car = 1000, // كن حذراً مع هذه الأرقام
  Type_Train,
  Type_Ship,

};
 
class Car : public Object
{
  ...
};
 
class Train : public Object
{
  ...
};
 
class Ship : public Object
{
  ...
};
 
 
Object* GameVehiclesFactory(int classType)
{
  switch (classType)
  {
    case Type_Car: return new Car();
    case Type_Train: return new Train();
    case Type_Ship: return new Ship();
  }
  return NULL;
}
 
 
void GameInit(void)
{
  EngineInit(); // تشغيل المحرك
  RegisterFactory(GameVehiclesFactory); // تسجيل مصنع المركبات في اللعبة
  ...
}
 
 
النقطة الوحيدة التي يجب أن تأخذ الحذر منها هي القيم العددية الموافقة لأنواع الكلاسات المسجلة. فلو أنك أعطيت كلاسات المحرك القيم 1، 2، 3، ... الخ، فإن بقية الكلاسات يجب أن تبتعد عن هذه القيم. عادة يكفي التنويه للمستخدم أن المجال من 1 إلى 1000 مثلاً محجوز لكلاسات المحرك، بينما بقية الأرقام مفتوحة.
 
 


في 29 يونيو 2008 10:00 ص، قال الشمري بهدوء وتؤدة:

( لدي سؤال خارج الموضوع بعد اذنك .. لو أمكنك الاجابة عليه بعد الانتهاء من موضوعنا الرئيسي.
أكثر من مرة تكلمت عن لعبة قريش .. هل أنت أحد مطوريها ...  )
 

نعم، والحمد لله. كان لي الشرف بالعمل مع فريق من المبرمجين الأذكياء لبناء اللعبة من الصفر. كان دوري يتمركز حول برمجة الأنظمة والرسوميات. بينما الأخ جواد أبو زخم فقد أخذ على عاتقه برمجة الذكاء الصناعي بأكمله للعبة (لك أن تتخيل هول الموقف بالنسبة للعبة استراتيجية)، ولا ننسى طبعاً عبد اللطيف حاجي علي والذي قام ببرمجة الصوت، واجهات الاستخدام، الـ scripting، دعم اللغات، وحدات الإدخال وأمور أخرى كثيرة. 
 
(تعديل: قرأت للتو موضوعك الجديد عن تطوير لعبة قريش، وسأعطيك تفاصيل أكثر هناك إن شاء الله)

 
لقد وعدتك بالحديث عن الحلين الآخرين لمشكلة الحفظ والتحميل. لذلك فلنتابع:
 
الحل الثاني هو ذلك المستخدم في NET. ، ويعتمد على ما يدعى بالـ Reflection. بالعربي، يعني أن اللغة تستطيع أن تعطيك وصفاً كاملاً لأي كلاس. مثلاً، يمكنك أن تمسك أي كلاس وتسأله: أعطني قائمة بكل المتغيرات التي تحملها وهل هي public أم protected ..الخ، أو أعطني قائمة بجميع الإجراءات وهل هي override أم abstract...الخ.
 
انظر المثال الآتي بلغة #C:



object someObject = new Bitmap("C:\\Me.bmp");
Type objType = someObject.GetType();
 
foreach (PropertyInfo property in objType.GetProperties())
{
  Console.WriteLine(property.Name);
}
 
النظام قادر على إعطاءك جميع المعلومات عن كل الكائنات البرمجية في اللغة، مما يعني أنه يمكنك أن تقوم بحفظ الكلاسات مهما كانت وحتى دون الرجوع للمستخدم على الإطلاق. وفي الحقيقة فإن NET. تعتمد كثيراً على هذه الميزة في حفظ واسترجاع المعلومات مثلاً عند إرسالها ببروتوكول SOAP عبر الإنترنت.
 
الخبر السيء هو أن ++C لا تدعم مثل هذا النظام بنفسها، وإنما فقط نسخة محدودة جداً منه (تدعى الـ run-time type information) والتي لم تصمم بهدف تقديم reflection كامل عن كائنات اللغة. لذلك فعليك أن تبني هذا النظام بنفسك، وبصراحة فبناؤه صعب جداً وينضوي على الكثير من الحيل التي تخلق مشاكل ومحدوديات لا يستسيغها الكثيرون. محرك Unreal يستخدم هذا النظام. شخصياً أنا أنصح بالابتعاد عن هذه الطريقة في ++C، لكني أيضاً أرشحها بشدة في NET. لأنها تعمل هناك بشكل سلس وطبيعي.
 
 
الطريقة الثالثة والتي كما ذكرت سابقاً متبعة في الأجهزة المحدودة بالدرجة الأولى، هي النسخة الكربونية من ذاكرة الجهاز.
مثلاً افترض أنك تتعامل مع جهاز ذو ذاكرة 32 ميجا فقط، عندها تستطيع حفظ كامل المعلومات إلى ملف كما هي، ومن ثم إعادتها إلى الذاكرة ككتلة واحدة والتعامل معها مباشرة بعد الانتهاء من التحميل. فقط...  وبهذه البساطة..
 
بقدر ما تظهر هذه الطريقة أنها متحررة، إلا أنها في الحقيقة لا تصلح إلا في حالات محدودة جداً عندما تكون كامل الذاكرة تحت سيطرتك، وأنت تعرف ما هي المعلومات المحفوظة في كل قسم منها. لأنك عندما تقوم بتحميل كلاس يقوم بعمل override لبعض الإجراءات من كلاس أب مثلاً، فيجب عليك استرجاع الـ vtable الصحيح، وهذا يعني أن يتواجد الجدول في نفس المكان في الذاكرة دائماً وأبداً.
 
في الحقيقة تستطيع أيضاً استخدام هذه الطريقة في ويندوز على البي سي، لكن هناك تفاصيل كثيرة يجب أن تتأكد منها كي تنجح.
 
 
إن التحدي الأصعب في الـ serialization هو حفظ المؤشرات... لكن هذه قصة أخرى (كما يقول رفعت إسماعيل☺
 
أرجو أن تساعدك هذه المعلومات في عملك...

وسام البهنسي
مبرمج في إنفيديا وإنفريمز

موهوب  عبدالله الشمّري مشاركة 5

بارك الله فيك أخي ... معلومات قيمة لن أجدها حتى في الكتب التخصصة , شكرا لك ..

--
طالب - تخصص نظم معلومات .
--