إغلاق علامة تبويب في المتصفح لا ينبغي أن يمحو أربع ساعات من التقدم. يبدو هذا بديهيًا، ومع ذلك، تتعامل الكثير من ألعاب المتصفح مع التخزين المحلي localStorage كأمر ثانوي. يحقق اللاعب نتيجة عالية، ويعدل إعداداته، ثم يعود غدًا ليجد كل شيء قد اختفى. والأسوأ من ذلك، هو عودتهم بعد تحديث (patch) ليجدوا أن اللعبة تظهر خطأً لأن ملف الحفظ على أجهزتهم لم يعد يتوافق مع الكود الذي أرسلته للتو. بناء لعبة تصويب من نوع survivor في Phaser 4 يعني التعامل مع موجات مستمرة من الأعداء، لكن التهديد الحقيقي طويل الأمد هو تحديثاتك المستقبلية.
يبني معظم المطورين نظام الحفظ الأول الخاص بهم عن طريق أخذ كائن (object)، وتمريره عبر JSON.stringify ، ثم وضعه في localStorage. عند التحميل، يقومون بتحليله (parse) وإعادته إلى اللعبة بشكل خام. هذا يعمل في اليوم الأول، لكنه يتعطل بمجرد إضافة إعداد جديد، أو علامة فتح (unlock flag) جديدة، أو طبقة ثالثة من التكوين المتداخل (nested configuration). إذا كان لدى لاعب عائد ملف حفظ قديم يفتقر إلى خاصية vignette ، وكان الكود الجديد الخاص بك يتوقع وجودها، فستحصل على undefined بدلاً من القيمة المنطقية (boolean) التي كنت تتوقعها. اضرب ذلك في عشرات الميزات الجديدة، وستواجه كابوسًا في تصحيح الأخطاء (debugging) يضرب أكثر لاعبيك ولاءً أولاً.
ابدأ بعقد (Contract)، وليس بكائن خام
قبل أن تلمس localStorage ، حدد مخطط حفظ افتراضي (default save schema) في قاعدة الكود الخاصة بك. فكر فيه كعقد يجب على كل ملف حفظ الالتزام به، سواء تم إنشاؤه قبل خمس دقائق أو قبل خمسة أشهر. نقطة البداية الواضحة قد تبدو هكذا:
const defaultSave = {
highScore: 0,
settings: {
screenShake: true,
vignette: true
}
};
هذا الكائن موجود في الكود المصدري الخاص بك. عندما تبدأ اللعبة، يكون هذا الهيكل متاحًا لك دائمًا. إنه يمنحك خط أساس (baseline). كما أنه يجبرك على التفكير في الهيكل قبل تسلسل أي شيء (serialize). إذا تخطيت هذه الخطوة وقمت ببساطة بتخزين أي كائن حالة (state object) تراه مناسبًا في ذلك الوقت، فسينتهي بك الأمر بمفاتيح غير متسقة، وحقول مفقودة، وفشل صامت عندما تخرج ملفات الحفظ القديمة عن المزامنة مع توقعاتك.
التحميل الدفاعي باستخدام Try/Catch
التخزين المحلي ليس قاعدة بيانات. إنه مجرد خزانة نصوص (string closet) في المتصفح، ويمكن أن ينتهي الأمر بأي شيء هناك. قد يكون المستخدم قد عدل قيمة يدويًا، أو انقطعت عملية كتابة غير مكتملة، أو قامت إضافة في المتصفح بإلقاء بيانات غير صالحة في المفتاح الذي حددته. عندما تستخرج ذلك النص وتعطيه لـ JSON.parse ، فإن حرفًا واحدًا تالفًا سيؤدي إلى استثناء (exception) فادح. في لعبة Phaser، يمكن لهذا الخطأ غير المعالج أن يجمد تسلسل التشغيل أو يعيد اللاعب إلى شاشة فارغة.
قم دائمًا بتغليف منطق القراءة والتحليل في كتلة try/catch. في حالة الفشل، ارجع إلى المخطط الافتراضي الخاص بك. الهدف بسيط: إذا كان ملف الحفظ غير قابل للقراءة، فتعامل مع اللاعب كمستخدم جديد بدلاً من تعطل الجلسة بأكملها. هذه العادة الواحدة تميز المشاريع الهواة عن الإصدارات الجاهزة للإنتاج (production-grade builds). لا تكلف شيئًا تقريبًا لتنفيذها، وهي تحميك من تقارير الأخطاء الغامضة التي يستحيل إعادة إنتاجها.
دمج البيانات القديمة مع الافتراضيات
التحليل الناجح لا يعني أنك في أمان. لا تستبدل كائنك الافتراضي بالكامل بالنتيجة التي تم تحليلها. قد لا يحتوي ملف الحفظ القديم هذا على أحدث إعداداتك. قد يخزن screenShake ولكن ليس vignette. إذا افترض منطق اللعبة وجود vignette لأنه تم إصداره مع التحديث الأخير، فستعود مجددًا لمطاردة أخطاء undefined.
بدلاً من ذلك، ادمج البيانات المحملة مع افتراضياتك. استخدم Object.assign لوضع القيم المحفوظة فوق المخطط الأساسي. تقوم الافتراضيات بملء كل فجوة مفقودة تلقائيًا. الخصائص الجديدة التي أضفتها في الإصدار الثاني تحصل على قيمها الأولية من الكائن الافتراضي. أما الخصائص الموجودة التي غيرها اللاعب بالفعل، فيتم استبدالها بتفضيلاته المخزنة. الجميع رابح. يحتفظ اللاعب العائد بنتيجته العالية، وتكتسب اللعبة إمكانية الوصول إلى المفتاح الجديد الذي أضفته بالأمس دون أن تتعطل.
ضع في اعتبارك أن Object.assign يقوم بعملية دمج سطحية (shallow merge). إذا أصبح كائن الإعدادات الخاص بك متداخلًا بعمق بمرور الوقت، فقد تحتاج إلى التعامل مع تلك الكائنات الداخلية بمزيد من العناية. ومع ذلك، يظل المبدأ قائمًا: يجب أن تزين بيانات اللاعب افتراضياتك، لا أن تستبدلها تمامًا.
أضف إصدارًا لمفاتيحك
المتصفحات لا تحذف مدخلات التخزين المحلي القديمة تلقائيًا. إذا قمت بتغيير هيكل بياناتك بشكل جذري، فأنت بحاجة إلى طريقة نظيفة للتخلي عن التنسيق القديم. قم بتسمية مفتاح التخزين الخاص بك بلاحقة إصدار. bitSurvivorsSave_v1 هو اسم صريح. فهو يخبرك بالضبط أي مخطط كتب ذلك الملف. لاحقًا، عندما تقوم بإعادة هيكلة التقدم أو إضافة نظام حقيبة (inventory) كامل، انتقل إلى bitSurvivorsSave_v2.
هذا يمنحك فائدتين عمليتين. أولاً، لن تقوم أبداً بتحليل بيانات (blob) من الإصدار الأول (v1) باستخدام منطق الإصدار الثاني (v2) عن طريق الخطأ. ثانياً، يمكنك كتابة كود ترحيل (migration code) إذا أردت. عند بدء التشغيل، تحقق من وجود v1. إذا كان موجوداً ولم يكن v2 موجوداً، قم بترحيل البيانات القديمة إلى الهيكل الجديد، واكتبها في المفتاح (key) الجديد، ثم تابع العمل. إذا كنت لا تريد الترحيل، فعلى الأقل سيظل المفتاح القديم موجوداً في التخزين دون ضرر بينما يتجاهله الكود الجديد الخاص بك. في كلتا الحالتين، تمنع عملية الإصدار (versioning) حدوث تلف صامت للبيانات.
اجعل عملية الحفظ غير مرئية
يجب أن يكون استمرار البيانات (Persistence) كالتنفس؛ لا ينبغي للاعب أن يضطر للتفكير فيه أبداً. لا تضف زر "تطبيق" (Apply) في قائمة الإعدادات. أزرار التطبيق تسبب عوائق وتدرب المستخدمين على القلق بشأن ما إذا كانت خياراتهم قد حُفظت بالفعل. كما أنها تفتح الباب لفقدان البيانات عندما يقوم اللاعب بتغيير ثلاثة خيارات، ثم ينسى الضغط على "تطبيق"، ويغلق التبويب.
احفظ البيانات في اللحظة التي يحدث فيها التفاعل. عندما ينقر اللاعب على مربع اختيار لتعطيل اهتزاز الشاشة، استدعِ دالة الكتابة (write function) فوراً. وعندما تنتهي الجولة ويتم حساب النتيجة النهائية، اكتب أعلى نتيجة جديدة قبل أن ينتهي تحريك شاشة "نهاية اللعبة" (game over). الحفظ القائم على الأحداث (Event-driven saving) يجعل بنيتك البرمجية قابلة للتنبؤ لأن عملية الحفظ تكون دائماً ملاصقة للإجراء الذي غيّر البيانات. لن تضطر أبداً للبحث عن دالة تجميع مركزية (central batching function) أو القلق بشأن الحالة القديمة (stale state).
هذا النهج يبسط أيضاً نموذجك الذهني. فأنت تعرف بالضبط أين يحدث حفظ البيانات: في دالة الاستدعاء (callback) التي تتعامل مع التبديل، وفي الدالة التي تتعامل مع الموت. لا توجد عمليات كتابة غامضة مبعثرة عبر الكود المصدري.
ابنِ زر إعادة ضبط لنفسك
ستتسبب في تلف ملفات الحفظ الخاصة بك أثناء التطوير. ستكتب بيانات خاطئة، وتختبر حالات حافة (edge cases)، وستحتاج إلى العودة إلى حالة نظيفة بسرعة. ابنِ زر إعادة ضبط ضمن قائمة تصحيح الأخطاء (debug menu) أو عبر مزيج مفاتيح مخفي. اجعل زر إعادة الضبط هذا يقوم بشيئين بهذا الترتيب المحدد: إعادة ضبط الحالة في الذاكرة (in-memory state) إلى المخطط الافتراضي (default schema)، ثم استدعِ فوراً نفس دالة الحفظ التي تكتب في التخزين المحلي (local storage).
إذا قمت بمسح المتغير المحلي فقط وتجاهلت خطوة الكتابة، فلن تكون قد أنجزت شيئاً. فبمجرد تحديث الصفحة التالي، سيقوم المتصفح بسحب البيانات القديمة وإحيائها من جديد. إعادة الضبط التي تنسى حفظ البيانات هي نوع من الأخطاء التي تضيع عليك وقتاً طويلاً. أتقن هذا التسلسل مرة واحدة، وسيبقى حلقة الاختبار الخاصة بك سريعة لبقية المشروع.
الخلاصة الحقيقية
الحفظ ليس ميزة تضيفها في النهاية، بل هو بنية تحتية تحدد ما إذا كانت لعبتك تبدو متينة وتحترم وقت اللاعب. تعتمد ألعاب التصويب للبقاء (survivor shooter) في Phaser 4 حياتها أو موتها على تكرار الجولات. إذا كان تبويب المتصفح بمثابة سلاح موجه نحو تقدم اللاعب، فسيتوقفون في النهاية عن العودة. اكتب مخططاً (schema)، ودافع ضد البيانات السيئة، وادمج البيانات بدلاً من استبدالها، واستخدم الإصدارات لمفاتيحك، واحفظ البيانات عند كل حدث ذي معنى. ستشكر نفسك المستقبلية، وكل لاعب يعود بعد تحديثك القادم، على ذلك.
