هر آموزش توسعه بازی به یک شکل شروع میشود: مستطیلهای رنگی که روی یک بوم خاکستری حرکت میکنند. این برای یادگیری نحو (syntax) خوب است، اما هیچ چیز درباره اینکه یک موتور بازی واقعاً چگونه نفس میکشد به شما نمیآموزد. من میخواستم از دنیای مستطیلها خارج شوم. میخواستم چیزی بسازم که حس یک بازی واقعی را داشته باشد—یک بازی dungeon crawler از بالا به پایین (top-down) با حرکت، مبارزه، یک HUD و صدا.
من خودم را متعهد به Phaser v4 کردم و یک قانون سخت برای خودم گذاشتم: صفر دارایی (asset) خارجی. بدون فایلهای تصویری، بدون کلیپهای صوتی و بدون ابزارهای ساخت (build tools) مانند Webpack یا Vite. کل پروژه باید درون یک فایل HTML واحد که با JavaScript ساده نوشته شده بود، قرار میگرفت. این محدودیت صرفاً برای مینیمالیسم نبود؛ بلکه هدف آن از بین بردن هرگونه بهانه و هر «جعبه سیاه» بود. وقتی نمیتوانید یک بسته sprite را برای پوشاندن شکافهای دانشی خود دانلود کنید، مجبور میشوید یاد بگیرید که موتور بازی چگونه بافتها (textures)، انیمیشنها، صدا و وضعیت (state) را در لایههای زیرین مدیریت میکند.
ترسیم جهان از طریق کد
در یک پروژه معمولی Phaser، شما تابع this.load.image() را داخل یک تابع preload فراخوانی میکنید و موتور را به سمت یک فایل PNG هدایت میکنید. بدون آن گزینه، باید به شیء Graphics روی بیاورید. آن را نمونهسازی (instantiate) میکنید، اشکال اولیه را میکشید—مستطیلهایی برای کاشیهای کف، خطوط ضخیمتر برای دیوارها، و شاید یک دایره برای نشانگر بازیکن—و سپس generateTexture را فراخوانی میکنید. این متد، بافر گرافیکی را ثبت کرده و آن را با یک کلید انتخابی شما، در مدیریت بافت (texture manager) Phaser ثبت میکند.
از آن لحظه به بعد، موتور با آن bitmap تولید شده دقیقاً مانند یک فایل تصویری بارگذاری شده رفتار میکند. میتوانید آن را به tilemapها اختصاص دهید، آن را به spriteها برش دهید یا رنگآمیزی (tint) کنید. برای این بازی dungeon crawler، این به معنای آن بود که میتوانستم یک شبکه کف را به صورت رویهای (procedurally) تولید کنم، قطعات دیوار را جایگذاری کنم و بدون خروج از ویرایشگر کد، پالت رنگی را تغییر دهم. درس عملی این است که یک texture صرفاً تکهای از دادههای bitmap است که در حافظه قرار دارد. برای Phaser فرقی نمیکند که این بافت از طریق یک درخواست HTTP رسیده باشد یا از طریق یک فراخوانی دستی Graphics.
این رویکرد همچنین باعث میشود درباره ترتیب ترسیم (draw order) و دستهبندی (batching) آگاهانه فکر کنید. وقتی هر دیوار و کاشی کف از همان خانواده بافتهای تولید شده میآیند، شروع به توجه به نحوه گروهبندی فراخوانیهای رندر توسط Phaser میکنید. شما تفاوت بین یک لایه tilemap ایستا و یک spritemap از اشیاء مجزا را متوجه میشوید، زیرا خودتان به صورت دستی تصمیم میگیرید که کدام یک شایسته وجود داشتن به عنوان یک نمونه texture است.
انیمیشن بدون Spritesheets
مربعهای ایستا خیلی زود خستهکننده میشوند، اما سیستم انیمیشن Phaser انتظار یک spritesheet را دارد—معمولاً یک فایل PNG واحد که فریمها در یک شبکه چیده شدهاند. من آن نوار را با استفاده از یک عنصر HTML canvas خارج از صفحه (offscreen) بازسازی کردم. برای هر فریم از یک انیمیشن، بوم را پاک کردم و یک ژست جدید کشیدم: یک چرخش ساده شمشیر، یک چرخش گامبهگام پیادهروی، یا تکانهای آرام یک دشمن در حالت انتظار. وقتی نوار کامل شد، آن را به عنوان یک spritesheet در Phaser ثبت کردم و عرض و ارتفاع فریمها را تعریف کردم تا موتور بداند یک ژست کجا تمام میشود و ژست بعدی از کجا شروع میشود.
انجام دستی این کار دقیقاً نشان میدهد که یک انیمیشن در لایههای زیرین چیست: مجموعهای از مرزهای فریم روی یک texture مشترک. کامپوننت انیمیشن Phaser یک فریم شروع، یک فریم پایان و یک نرخ فریم (frame rate) میخواهد. سپس در هر تیک از ساعت بازی، یک اشارهگر را از میان آن برشهای مستطیلی عبور میدهد. شما دیگر به spritesheetها به چشم داراییهای جادویی ساخته شده توسط هنرمندان نگاه نمیکنید و آنها را به عنوان ریاضیات مختصات میبینید. این دیدگاه زمانی که در حال برش هنر واقعی هستید یا در حال عیبیابی دلیل پخش نامرتب فریمهای یک انیمیشن هستید، بسیار ارزشمند است.
صدا از هیچ
فایلهای صوتی ممنوع بودند، بنابراین مستقیماً از Web Audio API استفاده کردم. چند خط کد JavaScript میتواند یک oscillator node ایجاد کند، آن را روی یک موج مربعی یا سینوسی تنظیم کند، از یک gain node عبور دهد و یک انفجار کوتاه صوتی را زمانبندی کند. من کمککنندههای (helpers) کوچکی برای رویدادهای رایج نوشتم: یک بوق با فرکانس پایین برای صدای قدمها، یک صدای جیرجیر صعودی برای برداشتن غنیمت (loot)، و یک صدای موج مربعی خشن برای دریافت آسیب.
این صداهای سنتز شده (synthesized) به صورت طراحیشده جایگزینهای موقت (placeholders) هستند، اما بلافاصله بازخورد مکانیکی به بازی میدهند. میتوانید قبل از اینکه وقت خود را صرف ضبط یا تهیه صدای واقعی کنید، بفهمید که آیا زمانبندی درست است یا خیر. پاداش واقعی در معماری نهفته است. از آنجایی که شما محرک صدا را در یک تابع ساده بستهبندی کردهاید، جایگزین کردن بوق سنتز شده با یک sound buffer بارگذاری شده در آینده، تنها با تغییر یک خط انجام میشود. بقیه بازی—رویداد برخورد، چشمک زدن رابط کاربری، افزایش امتیاز—دستنخورده باقی میماند. شما ابتدا حس بازی را نمونهسازی (prototype) میکنید و سپس کیفیت (fidelity) آن را ارتقا میدهید.
مدیریت چندین جهان
یک بازی واقعی به بیش از یک صفحه نیاز دارد، بنابراین پروژه را به صحنههای مجزای Phaser تقسیم کردم: Menu، Game، UI و Pause. صحنه UI به صورت موازی با صحنه Game اجرا میشود و همزمان راهاندازی میشود تا نوار سلامت (health bar) و شمارنده امتیاز در محیط اختصاصی خودشان باشند، در حالی که کاوش در سیاهچالها در لایهای زیرین در جریان است. آنها صرفاً از طریق رویدادها (events) با هم ارتباط برقرار میکنند. وقتی بازیکن آسیب میبیند، صحنه Game یک تغییر صادر میکند. صحنه UI آن را گوش میدهد و اشیاء متنی خود را بهروز میکند. صحنه Game، صحنه UI را وارد (import) نمیکند، متدهای آن را فراخوانی نمیکند و حتی بررسی نمیکند که آیا وجود دارد یا خیر. صرفاً دادهها را به فضای خالی میفرستد. این جداسازی (decoupling) به این معناست که میتوانید HUD را برای تست جدا کنید یا آن را کاملاً جایگزین کنید، بدون اینکه به حلقه اصلی بازی (core game loop) دست بزنید.
برای توقف (pause)، از یک صحنه Pause انباشته (stacked) استفاده کردم که روی صحنه Game قرار میگیرد. نکته حیاتی این است که فراخوانی scene.pause() روی صحنه Game، در واقع دنیای فیزیک را منجمد کرده و تایمرها را متوقف میکند. صحنه Game بهروزرسانی را متوقف میکند، اما صحنه Pause برای رندر کردن یک منو و انتظار برای سیگنال ادامه (unpause) فعال باقی میماند. اگر تا به حال فقط با استفاده از یک پرچم بولی (boolean flag) در یک حلقه بهروزرسانی بزرگ، وضعیت توقف را مدیریت کردهاید، این تجربه مانند کشف یک کلید برق است. موتور به جای اینکه شما را مجبور کند کدتان را با محافظهای if (isPaused) return پر کنید، یک چرخه حیات (lifecycle) واقعی برای توقف در اختیار شما قرار میدهد.
درسهای سخت از باگهای واقعی
کار کردن در این سطح نزدیک به سختافزار (metal)، دو عادت را آشکار کرد که باید تغییر میدادم.
اول، سعی کردم از متدی در Phaser v4 استفاده کنم که عمومی به نظر میرسید اما بخشی از API مستند شده نبود. این متد بین نسخهها تغییر کرد و ساخت (build) من را خراب کرد. من کد را بازنویسی (refactor) کردم تا از getChildren() استفاده کنم که یک متد عمومی، پایدار و مستند شده است، و آن ناپایداری از بین رفت. درس ساده است: اگر متدی در مستندات رسمی نیست، بازی خود را بر پایه آن نبازید. APIهای داخلی به دلیلی داخلی هستند. به بخشهای عمومی (public surface area) پایبند باشید تا پروژهتان در برابر بهروزرسانیهای موتور دوام بیاورد.
دوم، یاد گرفتم که هرگز برای همه چیز به رویدادها اعتماد نکنم. فراخوانیهای رویداد (Event callbacks) برای تعاملات کماهمیت مانند برداشتن یک سکه یا باز کردن یک صندوقچه به خوبی کار میکنند. اما برای انتقالهای وضعیت حیاتی (critical state transitions) — بهویژه Game Over — یک بررسی اضافی در حلقه اصلی بهروزرسانی اضافه کردم. اگر یک شنونده (listener) حذف شود، یک صحنه در یک میکروثانیه نامناسب متوقف شود، یا یک شرایط رقابتی (race condition) بین ارسال و مدیریت رخ دهد، رویدادها ممکن است به اشتباه عمل کنند. با بررسی مستقیم سلامت بازیکن در حلقه بهروزرسانی و اجبار به وضعیت پایان بازی (game-over) در صورت رسیدن آن به صفر، اطمینان حاصل کردم که اگر رویدادی اجرا نشد، بازی هرگز در یک وضعیت بلاتکلیف (limbo) گیر نکند. رویدادها همچنان اثرات ثانویه — لرزش صفحه، صداها، ارسال امتیاز — را مدیریت میکنند، اما منطق اصلی (authoritative logic) در جایی قرار دارد که ساعت بازی قرار دارد.
چرا باید این کار را امتحان کنید
اگر در حال یادگیری توسعه بازی هستید، این محدودیت دقیق را برای پروژه بعدی خود اعمال کنید: بدون داراییهای (assets) خارجی، فقط یک فایل HTML. به نظر محدودکننده میرسد، اما هر بهانهای را از بین میبرد. نیازی به پیکربندی یک باندلر (bundler)، کلنجار رفتن با خطاهای CORS در فایلهای صوتی محلی، یا گذراندن یک بعدازظهر برای جمعآوری بستههای دارایی رایگان ندارید. شما کد مینویسید، مرورگر را رفرش میکنید و نتایج را میبینید.
مهمتر از آن، خواهید فهمید که چرا موتور اینگونه رفتار میکند. خواهید دانست که چگونه یک بافت (texture) وارد GPU میشود، چون generateTexture را فراخوانی کردهاید. خواهید دانست که فریمهای انیمیشن چگونه ایندکس میشوند، چون مرزها را دستی ثبت کردهاید. خواهید دانست که صدا چگونه به بلندگوها میرسد، چون نوسانساز (oscillator) را سیمکشی کردهاید. این دانش مستقیماً به پروژههای بزرگتری که از داراییهای خارجی استفاده میکنند منتقل میشود، زیرا مکانیسمهای زیربنایی هرگز تغییر نمیکنند — موتور
