WebSockets والتطبيقات الفورية

مفاهيم الألعاب في الوقت الفعلي

20 دقيقة الدرس 29 من 35

مفاهيم الألعاب في الوقت الفعلي

تتطلب الألعاب متعددة اللاعبين في الوقت الفعلي تقنيات شبكات متخصصة لتوفير تجربة لعب سلسة ومستجيبة على الرغم من زمن انتقال الشبكة. في هذا الدرس، سنستكشف المفاهيم الأساسية لبناء الألعاب متعددة اللاعبين.

بنية حلقة اللعبة

حلقة اللعبة هي جوهر أي لعبة في الوقت الفعلي، وتنسق التحديثات والعرض ومزامنة الشبكة.

// حلقة اللعبة من جانب العميل class GameClient { constructor() { this.lastUpdateTime = Date.now(); this.targetFPS = 60; this.frameTime = 1000 / this.targetFPS; this.isRunning = false; this.gameState = {}; } start() { this.isRunning = true; this.lastUpdateTime = Date.now(); this.loop(); } loop() { if (!this.isRunning) return; const now = Date.now(); const deltaTime = now - this.lastUpdateTime; // خطوة زمنية ثابتة للفيزياء if (deltaTime >= this.frameTime) { this.update(deltaTime / 1000); // تحويل إلى ثوانٍ this.render(); this.lastUpdateTime = now - (deltaTime % this.frameTime); } requestAnimationFrame(() => this.loop()); } update(deltaTime) { // تحديث منطق اللعبة this.updatePlayerInput(deltaTime); this.updatePhysics(deltaTime); this.interpolateEntities(deltaTime); this.sendInputToServer(); } render() { // عرض حالة اللعبة الحالية this.clearCanvas(); this.renderEntities(); this.renderUI(); } stop() { this.isRunning = false; } } // حلقة اللعبة من جانب الخادم class GameServer { constructor() { this.tickRate = 30; // تحديثات الخادم في الثانية this.tickInterval = 1000 / this.tickRate; this.lastTick = Date.now(); this.gameState = { players: new Map(), projectiles: [], timestamp: Date.now() }; } start() { setInterval(() => this.tick(), this.tickInterval); } tick() { const now = Date.now(); const deltaTime = (now - this.lastTick) / 1000; // معالجة مدخلات اللاعب this.processInputs(); // تحديث حالة اللعبة this.updatePhysics(deltaTime); this.detectCollisions(); this.updateGameLogic(deltaTime); // بث الحالة لجميع العملاء this.broadcastState(); this.lastTick = now; } processInputs() { for (const [playerId, player] of this.gameState.players) { while (player.inputQueue.length > 0) { const input = player.inputQueue.shift(); this.applyInput(player, input); } } } applyInput(player, input) { // التحقق من صحة وتطبيق مدخل اللاعب if (input.action === 'move') { player.velocity.x = input.direction.x * player.speed; player.velocity.y = input.direction.y * player.speed; player.lastProcessedInput = input.sequenceNumber; } } broadcastState() { const state = { timestamp: Date.now(), players: Array.from(this.gameState.players.entries()).map( ([id, player]) => ({ id, position: player.position, rotation: player.rotation, lastProcessedInput: player.lastProcessedInput }) ) }; io.emit('game:state', state); } }

التنبؤ من جانب العميل

يسمح التنبؤ من جانب العميل للاعبين برؤية ردود فعل فورية لإجراءاتهم، مما يقلل من الكمون المدرك.

// تنفيذ التنبؤ من جانب العميل class PredictivePlayer { constructor(socket) { this.socket = socket; this.position = { x: 0, y: 0 }; this.velocity = { x: 0, y: 0 }; this.speed = 200; // تتبع الإدخال this.inputSequenceNumber = 0; this.pendingInputs = []; this.lastProcessedInput = 0; this.setupNetworkHandlers(); } setupNetworkHandlers() { // تلقي الحالة الرسمية من الخادم this.socket.on('game:state', (state) => { this.reconcile(state); }); } handleInput(input) { // تعيين رقم تسلسلي input.sequenceNumber = ++this.inputSequenceNumber; input.timestamp = Date.now(); // التخزين للمصالحة this.pendingInputs.push(input); // تطبيق الإدخال فورًا (التنبؤ) this.applyInput(input); // الإرسال إلى الخادم this.socket.emit('game:input', input); } applyInput(input) { if (input.action === 'move') { const deltaTime = 1 / 60; // افترض 60 FPS this.velocity.x = input.direction.x * this.speed; this.velocity.y = input.direction.y * this.speed; this.position.x += this.velocity.x * deltaTime; this.position.y += this.velocity.y * deltaTime; } } reconcile(serverState) { // ابحث عن لاعبنا في حالة الخادم const serverPlayer = serverState.players.find( p => p.id === this.socket.id ); if (!serverPlayer) return; // تحديث آخر إدخال معالج this.lastProcessedInput = serverPlayer.lastProcessedInput; // تعيين الموضع إلى موضع الخادم الرسمي this.position = { ...serverPlayer.position }; // إزالة المدخلات المعالجة this.pendingInputs = this.pendingInputs.filter( input => input.sequenceNumber > this.lastProcessedInput ); // إعادة تطبيق المدخلات المعلقة for (const input of this.pendingInputs) { this.applyInput(input); } } update(deltaTime) { // تحديث الموضع بناءً على السرعة this.position.x += this.velocity.x * deltaTime; this.position.y += this.velocity.y * deltaTime; } } // الاستخدام const player = new PredictivePlayer(socket); // معالجة إدخال لوحة المفاتيح document.addEventListener('keydown', (e) => { const input = { action: 'move', direction: getDirectionFromKey(e.key), timestamp: Date.now() }; player.handleInput(input); });
فوائد التنبؤ: يلغي التنبؤ من جانب العميل تأخير الإدخال، مما يجعل الألعاب تبدو مستجيبة حتى مع زمن انتقال 100-150 مللي ثانية. يرى اللاعبون أفعالهم على الفور بينما يتحقق الخادم منها.

مصالحة الخادم

تصحح مصالحة الخادم تنبؤات العميل عندما تختلف عن حالة الخادم الرسمية.

// مصالحة متقدمة مع التنعيم class ReconciledEntity { constructor() { this.position = { x: 0, y: 0 }; this.serverPosition = { x: 0, y: 0 }; this.renderPosition = { x: 0, y: 0 }; this.reconciliationThreshold = 10; // بكسل this.smoothingFactor = 0.2; } reconcileWithServer(serverPosition) { const distance = Math.hypot( serverPosition.x - this.position.x, serverPosition.y - this.position.y ); if (distance > this.reconciliationThreshold) { // عدم تطابق كبير - انتقل إلى موضع الخادم console.warn(`المصالحة: المسافة=${distance.toFixed(2)}بكسل`); // تصحيح سلس على عدة إطارات this.serverPosition = { ...serverPosition }; } } update(deltaTime) { // الاستيفاء السلس نحو موضع الخادم if (this.serverPosition) { this.position.x += (this.serverPosition.x - this.position.x) * this.smoothingFactor; this.position.y += (this.serverPosition.y - this.position.y) * this.smoothingFactor; // مسح موضع الخادم عندما يكون قريبًا بما فيه الكفاية const distance = Math.hypot( this.serverPosition.x - this.position.x, this.serverPosition.y - this.position.y ); if (distance < 1) { this.serverPosition = null; } } } getRenderPosition() { return this.position; } }

تعويض التأخير

تضمن تقنيات تعويض التأخير لعبًا عادلاً من خلال حساب زمن انتقال الشبكة في الكشف عن الإصابات والتفاعلات.

// تعويض التأخير من جانب الخادم للإطلاق class LagCompensation { constructor() { this.historyDuration = 1000; // ثانية واحدة من التاريخ this.playerHistory = new Map(); // playerId -> تاريخ الموضع } recordPlayerPosition(playerId, position, timestamp) { if (!this.playerHistory.has(playerId)) { this.playerHistory.set(playerId, []); } const history = this.playerHistory.get(playerId); history.push({ position, timestamp }); // احتفظ بالتاريخ الحديث فقط const cutoff = timestamp - this.historyDuration; const validHistory = history.filter(h => h.timestamp > cutoff); this.playerHistory.set(playerId, validHistory); } getHistoricalPosition(playerId, timestamp) { const history = this.playerHistory.get(playerId); if (!history || history.length === 0) return null; // البحث عن الموضع في الطابع الزمني المطلوب for (let i = 0; i < history.length - 1; i++) { const current = history[i]; const next = history[i + 1]; if (timestamp >= current.timestamp && timestamp <= next.timestamp) { // الاستيفاء بين المواضع const t = (timestamp - current.timestamp) / (next.timestamp - current.timestamp); return { x: current.position.x + (next.position.x - current.position.x) * t, y: current.position.y + (next.position.y - current.position.y) * t }; } } return history[history.length - 1].position; } handleShot(shooterId, targetId, clientTimestamp, shotPosition) { const shooterLatency = this.getPlayerLatency(shooterId); // إرجاع الوقت إلى عندما تم إطلاق الطلقة على شاشة الرامي const historicalTimestamp = Date.now() - shooterLatency; // الحصول على موضع الهدف في ذلك الوقت التاريخي const targetPosition = this.getHistoricalPosition( targetId, historicalTimestamp ); if (!targetPosition) return false; // التحقق مما إذا أصابت الطلقة الهدف في الموضع التاريخي const distance = Math.hypot( shotPosition.x - targetPosition.x, shotPosition.y - targetPosition.y ); const hitRadius = 20; // نصف قطر الاصطدام return distance <= hitRadius; } getPlayerLatency(playerId) { // تتبع متوسط الكمون لكل لاعب const player = this.getPlayer(playerId); return player ? player.averageLatency : 100; } } // الاستخدام على الخادم const lagComp = new LagCompensation(); io.on('connection', (socket) => { socket.on('game:shoot', (data) => { const hit = lagComp.handleShot( socket.id, data.targetId, data.clientTimestamp, data.position ); if (hit) { io.emit('game:hit', { shooterId: socket.id, targetId: data.targetId }); } }); });
مقايضات تعويض التأخير: بينما يحسن تعويض التأخير العدالة للاعبين ذوي الكمون العالي، يمكن أن يتسبب في مواقف "إطلاق خلف الجدران". وازن بين التعويض وتدابير مكافحة الغش وحدود الكمون المعقولة.

الاستيفاء

يسهل استيفاء الكيان الحركة بين تحديثات الخادم، مما ينشئ رسومًا متحركة سلسة على الرغم من معدلات التحديث المنخفضة.

// استيفاء الكيان للحركة السلسة class InterpolatedEntity { constructor() { this.positionBuffer = []; this.renderDelay = 100; // تأخير العرض بمقدار 100 مللي ثانية this.position = { x: 0, y: 0 }; } addServerUpdate(position, timestamp) { this.positionBuffer.push({ position, timestamp }); // احتفظ بحجم المخزن معقولاً if (this.positionBuffer.length > 60) { this.positionBuffer.shift(); } } update() { const now = Date.now(); const renderTime = now - this.renderDelay; // ابحث عن موضعين للاستيفاء بينهما let previous = null; let next = null; for (let i = 0; i < this.positionBuffer.length - 1; i++) { if (this.positionBuffer[i].timestamp <= renderTime && this.positionBuffer[i + 1].timestamp >= renderTime) { previous = this.positionBuffer[i]; next = this.positionBuffer[i + 1]; break; } } if (!previous || !next) { // استخدم أحدث موضع إذا لم يكن الاستيفاء ممكنًا if (this.positionBuffer.length > 0) { const latest = this.positionBuffer[this.positionBuffer.length - 1]; this.position = { ...latest.position }; } return; } // الاستيفاء بين المواضع const totalTime = next.timestamp - previous.timestamp; const elapsedTime = renderTime - previous.timestamp; const t = totalTime > 0 ? elapsedTime / totalTime : 0; this.position.x = previous.position.x + (next.position.x - previous.position.x) * t; this.position.y = previous.position.y + (next.position.y - previous.position.y) * t; // تنظيف المواضع القديمة this.positionBuffer = this.positionBuffer.filter( p => p.timestamp > renderTime - 1000 ); } getRenderPosition() { return this.position; } } // الاستخدام const entities = new Map(); socket.on('game:state', (state) => { state.players.forEach(playerData => { if (!entities.has(playerData.id)) { entities.set(playerData.id, new InterpolatedEntity()); } const entity = entities.get(playerData.id); entity.addServerUpdate(playerData.position, state.timestamp); }); }); // في حلقة اللعبة function render() { entities.forEach(entity => { entity.update(); const pos = entity.getRenderPosition(); drawPlayer(pos.x, pos.y); }); }

مزامنة الحالة

تقلل مزامنة الحالة الفعالة من النطاق الترددي مع إبقاء جميع العملاء محدثين.

// ضغط دلتا لتحديثات الحالة class StateSync { constructor() { this.lastSentState = new Map(); } createDeltaUpdate(fullState, clientId) { const lastState = this.lastSentState.get(clientId); if (!lastState) { // التحديث الأول - إرسال الحالة الكاملة this.lastSentState.set(clientId, this.cloneState(fullState)); return { type: 'full', data: fullState }; } // حساب الدلتا const delta = { type: 'delta', added: [], updated: [], removed: [] }; // البحث عن الكيانات المضافة والمحدثة fullState.entities.forEach((entity, id) => { const lastEntity = lastState.entities.get(id); if (!lastEntity) { delta.added.push({ id, ...entity }); } else if (this.hasChanged(entity, lastEntity)) { delta.updated.push({ id, ...this.getDifference(entity, lastEntity) }); } }); // البحث عن الكيانات المحذوفة lastState.entities.forEach((entity, id) => { if (!fullState.entities.has(id)) { delta.removed.push(id); } }); // تحديث آخر حالة مرسلة this.lastSentState.set(clientId, this.cloneState(fullState)); return delta; } hasChanged(entity1, entity2) { return entity1.position.x !== entity2.position.x || entity1.position.y !== entity2.position.y || entity1.rotation !== entity2.rotation || entity1.health !== entity2.health; } getDifference(entity, lastEntity) { const diff = {}; if (entity.position.x !== lastEntity.position.x || entity.position.y !== lastEntity.position.y) { diff.position = entity.position; } if (entity.rotation !== lastEntity.rotation) { diff.rotation = entity.rotation; } if (entity.health !== lastEntity.health) { diff.health = entity.health; } return diff; } cloneState(state) { return { entities: new Map(state.entities), timestamp: state.timestamp }; } } // استخدام الخادم const stateSync = new StateSync(); function broadcastState() { const fullState = getCurrentGameState(); io.sockets.sockets.forEach((socket) => { const update = stateSync.createDeltaUpdate(fullState, socket.id); // تحديثات دلتا عادةً أصغر بنسبة 80-95٪ من الحالة الكاملة socket.emit('game:state', update); }); }
أفضل ممارسات الشبكات:
  • معدل التحديث: 20-30 هرتز لمعظم الألعاب، 60+ هرتز لألعاب FPS التنافسية
  • استخدم النقل غير الموثوق (مثل UDP) لتحديثات الموضع المتكررة
  • استخدم النقل الموثوق للأحداث الحرجة (الضرر، التسجيل)
  • نفذ التنبؤ من جانب العميل للاعب المحلي فقط
  • استيفاء اللاعبين الآخرين 100-200 مللي ثانية في الماضي
  • استخدم ضغط دلتا لتقليل النطاق الترددي
تمرين تطبيقي:
  1. نفذ نظام تنبؤ كامل من جانب العميل مع مصالحة الخادم للعبة حركة بسيطة من أعلى إلى أسفل
  2. أنشئ نظام استيفاء يعرض اللاعبين الآخرين بسلاسة بين تحديثات الخادم
  3. ابنِ نظام تعويض التأخير للعبة إطلاق النار يعيد الوقت إلى الوراء للكشف عن الإصابات
  4. نفذ ضغط دلتا لتقليل النطاق الترددي لتحديث الحالة بنسبة 80٪
  5. أنشئ عرض كمون يظهر ping وفقدان الحزم لتصحيح الأخطاء