بستن یک تب مرورگر نباید چهار ساعت پیشرفت را از بین ببرد. این موضوع بدیهی به نظر میرسد، اما بسیاری از بازیهای مرورگر با localStorage مثل یک موضوع فرعی برخورد میکنند. بازیکن یک امتیاز بالا کسب میکند، تنظیمات خود را تغییر میدهد، فردا برمیگردد و میبیند همه چیز از بین رفته است. بدتر از آن، بعد از یک آپدیت (patch) برمیگردند و بازی خطا میدهد، چون فایل ذخیره شده در دستگاه آنها دیگر با کدی که شما تازه منتشر کردهاید مطابقت ندارد. ساخت یک بازی شوتر سبک survivor در Phaser 4 به معنای مقابله با موجهای مداوم دشمنان است، اما تهدید واقعی و طولانیمدت، آپدیتهای آینده خودتان است.
بیشتر توسعهدهندگان اولین سیستم ذخیرهسازی خود را با گرفتن یک شیء (object)، اجرای آن از طریق JSON.stringify و ریختن آن در localStorage میسازند. هنگام بارگذاری، آن را parse کرده و به صورت خام به بازی برمیگردانند. این روش در روز اول کار میکند، اما به محض اینکه یک تنظیم جدید، یک پرچم (flag) بازگشایی جدید یا لایه سومی از پیکربندی تو در تو اضافه کنید، از کار میافتد. اگر بازیکن بازگشتهای یک فایل ذخیره قدیمی داشته باشد که فاقد ویژگی vignette است و کد جدید شما انتظار وجود آن را داشته باشد، در جایی که انتظار یک مقدار boolean داشتید، با undefined مواجه میشوید. این موضوع را در دهها ویژگی جدید ضرب کنید و با یک کابوس عیبیابی (debugging) روبرو خواهید شد که اولین ضربه را به وفادارترین بازیکنان شما میزند.
با یک قرارداد شروع کنید، نه یک شیء خام
قبل از اینکه حتی به localStorage دست بزنید، یک schema پیشفرض برای ذخیرهسازی در کد خود تعریف کنید. آن را به عنوان قراردادی در نظر بگیرید که هر فایل ذخیرهای باید به آن پایبند باشد، چه پنج دقیقه پیش ساخته شده باشد و چه پنج ماه پیش. یک نقطه شروع شفاف میتواند به این شکل باشد:
const defaultSave = {
highScore: 0,
settings: {
screenShake: true,
vignette: true
}
};
این شیء در کد منبع شما قرار دارد. وقتی بازی اجرا میشود، همیشه این ساختار را در دسترس دارید. این کار به شما یک خط پایه (baseline) میدهد. همچنین شما را مجبور میکند قبل از سریالسازی (serialize) هر چیزی، به ساختار فکر کنید. اگر از این مرحله بگذرید و صرفاً هر شیء وضعیتی (state object) را که در آن لحظه راحتتر است ذخیره کنید، در نهایت با کلیدهای ناسازگار، فیلدهای مفقود شده و شکستهای بیصدا مواجه میشوید، زمانی که ذخیرههای قدیمی با انتظارات شما همگام نیستند.
بارگذاری تدافعی با استفاده از Try/Catch
localStorage یک پایگاه داده نیست؛ بلکه کمدی از رشتهها (strings) در مرورگر است و هر چیزی میتواند در آن قرار بگیرد. ممکن است کاربر یک مقدار را به صورت دستی ویرایش کرده باشد، یا یک عملیات نوشتن نیمهتمام قطع شده باشد، یا یک افزونه مرورگر زبالهای را در کلیدی که شما اختصاص دادهاید ریخته باشد. وقتی آن رشته را بیرون میکشید و به JSON.parse میدهید، یک کاراکتر خراب شده میتواند یک exception سخت ایجاد کند. در یک بازی Phaser، این خطای مدیریتنشده میتواند توالی بوت (boot sequence) شما را متوقف کند یا بازیکن را به یک صفحه خالی برگرداند.
همیشه منطق خواندن و parse کردن خود را در یک بلوک try/catch قرار دهید. در صورت بروز خطا، به schema پیشفرض خود بازگردید (fall back). هدف ساده است: اگر فایل ذخیره غیرقابل خواندن است، با بازیکن مثل یک کاربر جدید برخورد کنید تا کل جلسه (session) کرش نکند. این یک عادت، پروژههای تفریحی را از نسخههای آماده تولید (production-grade) متمایز میکند. پیادهسازی آن تقریباً هیچ هزینهای ندارد و شما را از گزارشهای باگ مرموز که بازسازی آنها غیرممکن است، نجات میدهد.
ادغام دادههای قدیمی با مقادیر پیشفرض
یک parse موفق به این معنا نیست که شما در امان هستید. هرگز شیء پیشفرض خود را به طور کامل با نتیجه parse شده جایگزین نکنید. آن فایل ذخیره قدیمی ممکن است شامل جدیدترین تنظیمات شما نباشد. ممکن است screenShake را ذخیره کرده باشد اما vignette را نه. اگر منطق بازی شما فرض کند که vignette وجود دارد (چون با آخرین آپدیت ارائه شده است)، دوباره به دنبال خطاهای undefined خواهید گشت.
در عوض، دادههای بارگذاری شده را با مقادیر پیشفرض خود ادغام کنید. از Object.assign استفاده کنید تا مقادیر ذخیره شده را روی schema پایه قرار دهید. مقادیر پیشفرض به طور خودکار هر شکاف موجود را پر میکنند. ویژگیهای جدیدی که در نسخه دو اضافه کردهاید، مقادیر اولیه خود را از شیء پیشفرض میگیرند. ویژگیهای موجود که بازیکن واقعاً تغییر داده است، با ترجیحات ذخیره شده جایگزین میشوند. همه برنده میشوند. بازیکن بازگشته امتیاز بالای خود را حفظ میکند و بازی بدون از کار افتادن، به سوئیچ جدیدی که دیروز اضافه کردهاید دسترسی پیدا میکند.
به خاطر داشته باشید که Object.assign یک ادغام سطحی (shallow merge) انجام میدهد. اگر شیء تنظیمات شما با گذشت زمان به صورت عمیق (deeply nested) درآید، ممکن است نیاز باشد با دقت بیشتری با آن اشیاء داخلی برخورد کنید. با این حال، اصل موضوع پابرجا است: دادههای بازیکن باید به مقادیر پیشفرض شما اضافه شوند، نه اینکه مستقیماً جایگزین آنها شوند.
نسخهبندی کلیدهای خود
مرورگرها ورودیهای قدیمی localStorage را به طور خودکار حذف نمیکنند. اگر ساختار داده خود را به طور اساسی تغییر دادید، به روشی تمیز برای رها کردن فرمت قدیمی نیاز دارید. نام کلید ذخیرهسازی خود را با یک پسوند نسخه مشخص کنید. bitSurvivorsSave_v1 صریح است. این به شما میگوید دقیقاً کدام schema آن فایل را نوشته است. بعداً، وقتی سیستم پیشرفت را بازنگری کردید یا یک سیستم موجودی (inventory) کامل اضافه کردید، به bitSurvivorsSave_v2 بروید.
این کار دو مزیت کاربردی به شما میدهد. اول اینکه، هرگز به اشتباه یک دیتای v1 را با منطق v2 تجزیه (parse) نمیکنید. دوم اینکه، اگر بخواهید میتوانید کد مهاجرت (migration) بنویسید. هنگام اجرا، وجود v1 را بررسی کنید. اگر v1 وجود داشت و v2 نبود، دادههای قدیمی را به ساختار جدید منتقل کنید، آنها را در کلید جدید بنویسید و ادامه دهید. اگر نمیخواهید مهاجرت انجام دهید، حداقل کلید قدیمی بدون هیچ آسیبی در حافظه باقی میماند در حالی که کد جدید شما آن را نادیده میگیرد. در هر دو صورت، نسخهبندی (versioning) از خرابی بیخبر دادهها جلوگیری میکند.
ذخیرهسازی را نامرئی کنید
ماندگاری دادهها (Persistence) باید مثل نفس کشیدن باشد. بازیکن هرگز نباید به آن فکر کند. یک دکمه Apply در منوی تنظیمات خود اضافه نکنید. دکمههای Apply باعث ایجاد اصطکاک میشوند و کاربر را عادت میدهند که نگران باشد آیا انتخابهایش واقعاً ثبت شدهاند یا خیر. همچنین وقتی بازیکنی سه گزینه را تغییر میدهد، دکمه Apply را نمیزند و تب را میبندد، این دکمهها باعث از دست رفتن دادهها میشوند.
در همان لحظهای که تعامل رخ میدهد، ذخیره کنید. وقتی بازیکن برای غیرفعال کردن لرزش صفحه (screen shake) یک چکباکس را علامت میزند، بلافاصله تابع نوشتن (write function) خود را فراخوانی کنید. وقتی مرحله تمام شد و امتیاز نهایی محاسبه شد، قبل از اینکه انیمیشن صفحه "Game Over" تمام شود، امتیاز بالا (high score) جدید را بنویسید. ذخیرهسازی رویداد-محور (Event-driven) معماری شما را قابل پیشبینی نگه میدارد، زیرا ذخیرهسازی همیشه دقیقاً در کنار همان عملیاتی قرار دارد که داده را تغییر داده است. شما هرگز مجبور نخواهید بود به دنبال یک تابع دستهای (batching function) مرکزی بگردید یا نگران وضعیتهای قدیمی (stale state) باشید.
این رویکرد همچنین مدل ذهنی شما را سادهتر میکند. شما دقیقاً میدانید ماندگاری دادهها کجا اتفاق میافتد: در کالبکی (callback) که تغییر وضعیت (toggle) را مدیریت میکند و در تابعی که مرگ را مدیریت میکند. هیچ نوشتن مرموزی در جایجای کد پراکنده نیست.
یک دکمه ریست برای خودتان بسازید
شما در طول توسعه، فایلهای ذخیره خود را خراب خواهید کرد. دادههای اشتباه خواهید نوشت، موارد خاص (edge cases) را تست خواهید کرد و نیاز خواهید داشت که سریعاً به یک حالت پاک (clean state) بازگردید. یک دکمه ریست در یک منوی دیباگ یا یک ترکیب کلید مخفی بسازید. کاری کنید که آن دکمه ریست دقیقاً به همین ترتیب دو کار انجام دهد: وضعیت در حافظه (in-memory state) خود را به طرحواره (schema) پیشفرض بازگرداند، و سپس بلافاصله همان تابع ذخیرهسازی را که در local storage مینویسد، فراخوانی کند.
اگر فقط متغیر محلی را پاک کنید و مرحله نوشتن را نادیده بگیرید، هیچ کاری انجام ندادهاید. رفرش کردن بعدی صفحه، دادههای قدیمی را از مرورگر بیرون میکشد و دوباره احیا میکند. ریستی که فراموش میکند دادهها را ذخیره کند، از آن دسته باگهایی است که یک بعدازظهر را هدر میدهد. این توالی را یک بار درست پیاده کنید تا حلقه تست شما در بقیه پروژه سریع باقی بماند.
نکته اصلی
ذخیرهسازی ویژگیای نیست که در انتها به بازی بچسبانید. ذخیرهسازی زیرساختی است که تعیین میکند آیا بازی شما پایدار به نظر میرسد و به زمان بازیکن احترام میگذارد یا خیر. یک بازی Phaser 4 در سبک survivor shooter، با تکرار مراحل زنده میماند یا میمیرد. اگر تب مرورگر مانند یک اسلحه پر باشد که به سمت پیشرفت بازیکن نشانه رفته است، آنها در نهایت دیگر باز نخواهند گشت. یک schema بنویسید، در برابر دادههای بد دفاع کنید، به جای جایگزینی، دادهها را ادغام (merge) کنید، کلیدهای خود را نسخهبندی کنید و در هر رویداد معنادار ذخیره کنید. خودِ آیندهتان و هر بازیکنی که پس از آپدیت بعدی شما بازمیگردد، از شما سپاسگزار خواهند بود.
