ساخت یک حلقه بازی حرفه ای در TypeScript: از پیاده سازی پایه تا پیشرفته

الف حلقه بازی ضربان قلب هر موتور بازی است که چرخه پیوسته پردازش ورودی، بهروزرسانی وضعیت بازی و رندر فریمها را تنظیم میکند. این مکانیسم اساسی است که تعیین میکند بازی شما چگونه اجرا میشود، به ورودی بازیکن پاسخ میدهد و گیمپلی روان را حفظ میکند.
در این مقاله، پیچیدگیهای پیادهسازی حلقههای بازی در TypeScript را با تمرکز بر الگوهای پیشرفته مانند به روز رسانی های گام زمانی ثابت ، زمان بندی فریم کارآمد ، و بهینه سازی عملکرد مهم که به شما در ساختن کمک می کند بازی های درجه حرفه ای.
مفاهیم حلقه اصلی بازی
یک حلقه بازی معمولی شامل سه مرحله اولیه است که بارها و بارها تکرار می شود:
- ورودی فرآیند
- وضعیت بازی را به روز کنید
- حالت فعلی بازی را رندر کنید سپس تکرار کنید.
بیایید به آنچه در هر مرحله اتفاق می افتد شیرجه بزنیم:
ورودی فرآیند : پردازش ورودی شامل بررسی وضعیت فعلی موارد زیر است:
- کلیدهای صفحه کلید (فشرده / رها شده)
- موقعیت و دکمه های ماوس
- ورودی های گیم پد
- رویدادها را لمس کنید
- هر دستگاه ورودی دیگر
فیزیک بازی را به روز کنید : مرحله به روز رسانی فیزیک:
- موقعیت های شی را بر اساس سرعت به روز می کند
- نیرو یا شتاب را اعمال می کند
- بررسی برای برخورد
- فعل و انفعالات فیزیک را حل می کند
- وضعیت بازی را بر اساس ورودی به روز می کند
رندر قاب : مرحله رندر:
- فریم قبلی را پاک می کند
- دنیای بازی را ترسیم می کند
- اشیاء بازی را رندر می کند
- جلوه های بصری را اعمال می کند
- نمایشگر را به روز می کند
رویکرد ساده لوحانه: در حالی که حلقه
بیایید با ساده ترین پیاده سازی ممکن شروع کنیم – یک حلقه while:
function naiveGameLoop() {
while (true) {
processInput();
updateGamePhysics();
render();
}
}
این رویکرد چندین مشکل اساسی دارد:
- موضوع اصلی مسدود می شود و باعث مسدود شدن مرورگر می شود : از آنجایی که موتور جاوا اسکریپت تک رشته ای است، حلقه while به نخ اصلی اجازه انجام کارهای دیگر را نمی دهد.
- بدون زمان بندی ثابت بین به روز رسانی : حلقه با حداکثر سرعت ممکن بدون هیچ کنترلی بر سرعت بازی اجرا می شود.
- دستگاه های مختلف با سرعت های مختلف کار خواهند کرد : دستگاههای مختلف ممکن است با سرعتهای متفاوتی کار کنند که منجر به زمانبندی متناقض بین بهروزرسانیها میشود.
رویکرد بازگشتی
تلاش دیگر ممکن است استفاده از بازگشت برای حلقه بازی باشد:
function recursiveGameLoop() {
processInput();
updateGamePhysics();
render();
// Call itself for the next frame
recursiveGameLoop();
}
این رویکرد دارای مشکلات مشابه با رویکرد حلقه while است، با مسائل اضافی زیر:
-
هر تماس بازگشتی یک فریم جدید به پشته تماس اضافه می کند : هر تماس بازگشتی یک فریم جدید به پشته تماس اضافه می کند که می تواند پس از چند فریم منجر به سرریز پشته شود.
-
بازی بعد از چند ثانیه با خطای زیر از کار می افتد: “بیشتر از حداکثر اندازه پشته تماس”
درخواست AnimationFrame را وارد کنید
نه حلقه while و نه رویکرد بازگشتی کنترلی را که ما برای یک حلقه بازی مناسب نیاز داریم را فراهم نمی کنند. ما به راهی نیاز داریم که:
- زمان بندی فریم را دقیقاً کنترل کنید
- از مشکلات سرریز پشته خودداری کنید
- مرورگر را پاسخگو نگه دارید
آیا راهی داخلی برای ایجاد یک حلقه بازگشتی بهینه شده در مرورگر وجود دارد؟
بله وجود دارد و به آن می گویند requestAnimationFrame
. این API مرورگر به طور خاص برای انیمیشن های روان و حلقه های بازی طراحی شده است.
function betterGameLoop() {
function loop() {
processInput();
updateGamePhysics();
render();
// Browser handles timing and throttling
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
با استفاده از requestAnimationFrame
چندین مزیت فوری به ما می دهد:
- مرورگر زمان انیمیشن های ما را بهینه می کند
- حلقه به طور خودکار هنگام تعویض برگه ها متوقف می شود
- دیگر خبری از سرریز پشته یا انجماد مرورگر نیست
- انیمیشن های روان تر با همگام سازی با نرخ تازه سازی صفحه نمایش
بهینه سازی عملکرد
در حالی که requestAnimationFrame
مشکلات اولیه ما را حل می کند، این پیاده سازی هنوز مسائل مهمی دارد:
- بدون کنترل بر روی سرعت بازی در دستگاه های مختلف
- محاسبات فیزیک به نرخ فریم گره خورده است
- مراحل زمانی متناقض بین بهروزرسانیها
- هیچ راهی برای رسیدگی به افت عملکرد به زیبایی وجود ندارد
برای رسیدگی به این چالشها، یک حلقه بازی قوی با بهروزرسانیهای مرحله زمانی ثابت پیادهسازی میکنیم. بیایید تکه تکه اجرا را بشکنیم.
پیاده سازی کلاس GameLoop
ما GameLoop
کلاس از الگوی Singleton استفاده می کند و یک حلقه بازی با زمان ثابت را پیاده سازی می کند. این بهروزرسانیهای ثابت فیزیک را در دستگاههای مختلف تضمین میکند و در عین حال رندر صاف را حفظ میکند. بیایید هر جزء را بررسی کنیم:
ویژگی های اصلی و مدیریت دولتی
در قلب حلقه بازی ما، باید حالت های مختلف مربوط به زمان بندی را ردیابی کنیم:
export class GameLoop {
private lastRequestId?: number;
private isRunning: boolean = false;
public lastTimestamp: number = 0;
private deltaTime: number = 0;
private accumulator: number = 0;
public gameStartTime: number = 0;
}
هر متغیر هدف خاصی را دنبال می کند:
-
lastRequestId
: شناسه قاب انیمیشن را برای پاکسازی هنگام توقف حلقه ذخیره می کند -
isRunning
: حالت حلقه بازی (در حال اجرا/توقف) را کنترل می کند -
lastTimestamp
: زمان فریم قبلی را برای محاسبات دلتا ثبت می کند -
deltaTime
: زمان سپری شده از آخرین فریم، برای به روز رسانی های مبتنی بر زمان استفاده می شود -
accumulator
: زمان باقیمانده را برای بهروزرسانیهای مرحله زمانی ثابت ردیابی میکند -
gameStartTime
: زمان شروع بازی را برای ردیابی کل زمان ضبط می کند
این متغیرها با هم، زمان بندی روان بازی و کنترل حلقه بازی مناسب را تضمین می کنند.
کنترل نرخ فریم
برای اطمینان از عملکرد ثابت در دستگاههای مختلف، ما مرزهای FPS را پیادهسازی میکنیم:
// FPS settings
private readonly DEFAULT_FPS = 60;
private readonly MIN_FPS = 20;
private readonly MAX_FPS = 144;
private targetFps: number = this.DEFAULT_FPS;
این تنظیمات تعادلی بین گیمپلی روان و محدودیتهای عملکرد ایجاد میکنند، به طوری که 60 FPS نقطه شیرین اکثر بازیها است.
پیاده سازی الگوی Singleton
private static instance: GameLoop;
private constructor() {}
public static getInstance(): GameLoop {
if (!this.instance) {
this.instance = new GameLoop();
}
return this.instance;
}
برای اطمینان از اینکه تنها یک نمونه از حلقه بازی وجود دارد، از الگوی Singleton استفاده می کنیم.
این getInstance
متد تضمین میکند که تنها یک نمونه از حلقه بازی وجود دارد و آن را به یک تکتون تبدیل میکند.
سازنده برای جلوگیری از نمونه سازی مستقیم و اطمینان از وجود تنها یک نمونه خصوصی ساخته شده است.
سیستم مدیریت زمان
سیستم زمان بندی محاسبات مهمی را برای زمان بندی فریم و مدت بازی ارائه می دهد:
public get time(): number {
return this.lastTimestamp - this.gameStartTime;
}
private get targetFrameTime(): number {
return 1000 / this.targetFps;
}
private get maxDeltaTime(): number {
return 1000 / this.MIN_FPS;
}
بیایید ببینیم هر گیرنده چه می کند:
-
time
: کل زمان سپری شده از شروع بازی را در میلی ثانیه برمی گرداند. -
targetFrameTime
: زمان ایده آل را برای هر فریم محاسبه می کند (مثلاً 16.67 میلی ثانیه برای 60 فریم در ثانیه). -
maxDeltaTime
: حداکثر زمان دلتای مجاز در هر فریم (50 میلیثانیه در 20 فریم در ثانیه).
محاسبه زمان فریم
private calculateDeltaTime(timestamp: number): number {
const deltaTime = timestamp - this.lastTimestamp;
this.lastTimestamp = timestamp;
return Math.min(deltaTime, this.maxDeltaTime);
}
زمان دلتا نشان دهنده مدت زمان رندر فریم قبلی است. این امر به دو دلیل حیاتی است:
- در یک دستگاه سریع که با سرعت 120 فریم در ثانیه کار می کند، هر فریم حدود 8 میلی ثانیه طول می کشد
- در دستگاهی کندتر که با سرعت 30 فریم در ثانیه کار می کند، هر فریم حدود 33 میلی ثانیه طول می کشد
بدون زمان دلتا ، اشیاء بازی حرکت می کنند 4 برابر سریعتر در دستگاه 120 FPS! با ضرب حرکت در زمان دلتا، سرعت ثابت را در همه دستگاهها تضمین میکنیم.
با این حال، ما باید زمان دلتا را برای رسیدگی به موارد شدید محدود کنیم ( مارپیچ مرگ ). این سناریو را در نظر بگیرید:
- بازیکن شخصیت خود را با سرعت 100 پیکسل در ثانیه به جلو می برد
- پخش کننده به مدت 5 ثانیه به برگه مرورگر دیگری سوئیچ می کند
- هنگامی که آنها به برگه بازی بازگشتند:
- زمان دلتای بدون پوشش: 5000 میلیثانیه × 100 پیکسل در ثانیه = کاراکتر 500 پیکسل به جلو را از راه دور منتقل میکند!
- زمان محدود شده دلتا (50 میلی ثانیه): حداکثر حرکت 5 پیکسل در هر فریم است که از تله پورت جلوگیری می کند.
به همین دلیل است که استفاده می کنیم Math.min(deltaTime, this.maxDeltaTime)
– گیم پلی روان را حتی پس از وقفه تضمین می کند.
چرا «مارپیچ مرگ»؟ تصور کنید یک بازی شروع به تاخیر می کند. بدون درپوش، تأخیر باعث میشود که اجسام بیش از حد دور شوند، که باعث تاخیر بیشتر میشود، که باعث میشود حتی بیشتر حرکت کنند… مانند مارپیچی که بزرگتر میشود تا زمانی که بازی خراب شود. به همین دلیل است که آن را سرپوش می گذاریم!
سیستم به روز رسانی گام زمانی ثابت
private updateGameLogic(update: (dt: number) => void) {
this.accumulator += this.deltaTime;
if (this.accumulator > this.maxDeltaTime) {
this.accumulator = this.maxDeltaTime;
}
const NumberOfUpdates = Math.floor(this.accumulator / this.targetFrameTime);
for (let i = 0; i < NumberOfUpdates; i++) {
update(this.targetFrameTime / 1000);
this.accumulator -= this.targetFrameTime;
}
}
این روش از الگوی انباشت کننده برای مدیریت زمان استفاده می کند. در اینجا نحوه کار آن آمده است:
-
ابتدا قاب ها را اضافه می کنیم
deltaTime
به ماaccumulator
: -
برای جلوگیری از مارپیچ مرگ، روی انباشته را درپوش می گذاریم:
-
ما محاسبه می کنیم که چه تعداد به روز رسانی باید انجام دهیم:
-
در نهایت بهروزرسانیها را در مراحل ثابت انجام میدهیم:
انباشته تضمین می کند که ما هرگز زمان را از دست نمی دهیم: باقیمانده به فریم بعدی منتقل می شود و زمان بندی عالی را حفظ می کند.
پیاده سازی حلقه اصلی
public start(update: (dt: number) => void, render: () => void) {
this.isRunning = true;
this.lastTimestamp = performance.now();
this.gameStartTime = this.lastTimestamp;
const loop = (timestamp: number) => {
if (!this.isRunning) return;
this.lastRequestId = requestAnimationFrame(loop);
this.deltaTime = this.calculateDeltaTime(timestamp);
this.updateGameLogic(update);
render();
};
requestAnimationFrame(loop);
}
این start
متد دو فراخوان می گیرد: update
برای منطق و فیزیک بازی و render
برای کشیدن بازی تماس پاسخ به روز رسانی زمان دلتا را در ثانیه دریافت می کند، در حالی که رندر پس از هر به روز رسانی فراخوانی می شود.
ابتدا سیستم زمان بندی خود را راه اندازی می کنیم:
this.isRunning = true;
this.lastTimestamp = performance.now();
this.gameStartTime = this.lastTimestamp;
سپس حلقه اصلی بازی خود را تعریف می کنیم. ابتدا بررسی می کند که آیا بازی هنوز در حال اجرا است یا خیر و بلافاصله فریم بعدی را برنامه ریزی می کند:
const loop = (timestamp: number) => {
if (!this.isRunning) return;
this.lastRequestId = requestAnimationFrame(loop);
در مرحله بعد، با استفاده از سیستم زمانی دلتای خود، مدت زمانی را که از آخرین فریم گذشته است محاسبه می کنیم:
this.deltaTime = this.calculateDeltaTime(timestamp);
در نهایت، منطق بازی را در یک گام ثابت به روز می کنیم و فریم را رندر می کنیم:
this.updateGameLogic(update); // Fixed timestep physics
render(); // Smooth visual updates
با درخواست زودهنگام فریم انیمیشن بعدی، به مرورگر زمان بیشتری برای آماده سازی می دهیم و عملکرد را بهبود می بخشیم.
رابط کنترل حلقه
public stop() {
if (!this.lastRequestId) return;
this.isRunning = false;
cancelAnimationFrame(this.lastRequestId);
this.lastRequestId = undefined;
this.gameStartTime = 0;
}
setTargetFPS(fps: number) {
this.targetFps = Math.min(Math.max(fps, this.MIN_FPS), this.MAX_FPS);
}
این stop()
متد با خیال راحت حلقه بازی را خاموش می کند:
- بررسی می کند که آیا حلقه واقعاً در حال اجرا است
- فریم انیمیشن بعدی را لغو می کند
- متغیرهای زمان بازی را بازنشانی می کند
این setTargetFPS()
روش کنترل سرعت بازی:
- FPS را بین 20 (MIN_FPS) تا 144 (MAX_FPS) میبندد
- مثال:
setTargetFPS(30)
برای دستگاه های کندتر - مثال:
setTargetFPS(144)
برای نمایشگرهای سطح بالا
قرار دادن آن همه با هم
ما GameLoop
class یک موتور بازی قوی را از طریق چندین مؤلفه کلیدی ایجاد می کند:
-
مدیریت زمان :
-
به روز رسانی های فیزیک ثابت :
-
رندر در مقابل به روز رسانی تماس ها :
نتیجه یک حلقه بازی است که زمان بندی عالی را حفظ می کند و در عین حال با قابلیت های هر دستگاهی سازگار است.
نتیجه گیری
درک حلقههای بازی برای ساخت بازیهای عملکردی که تجربیات ثابتی را در دستگاههای مختلف ارائه میدهند، بسیار مهم است. ما همه چیز را از پیاده سازی های اولیه گرفته تا مفاهیم پیشرفته مانند به روز رسانی های گام ثابت و مدیریت زمان دلتا پوشش داده ایم. این الگوها به شما کمک می کند تا بازی های روان و حرفه ای را در TypeScript ایجاد کنید.
من این مفاهیم را در حین ساختن یک کلون Flappy Bird طی یک جلسه برنامه نویسی زنده یاد گرفتم و اجرا کردم. اگر میخواهید این مفاهیم را در عمل ببینید و در مورد توسعه بازی بیشتر بدانید، میتوانید پیادهسازی را در اینجا مشاهده کنید:
پخش زنده نشان می دهد که چگونه می توان این مفاهیم حلقه بازی را در یک پروژه واقعی به کار برد و درک کاربردهای عملی آنها را آسان تر می کند.