التوثيق
🌐 اللغة:
HTML ثابت محلي @p2play-js/p2p-game السمة
⚠️ تحذير: تم ترجمة هذا التوثيق تلقائياً من الإنجليزية. قد توجد أخطاء. النسخة الإنجليزية الأصلية

مقدمة

@p2play-js/p2p-game هي مكتبة TypeScript معيارية لبناء ألعاب متعددة اللاعبين P2P (WebRTC) قائمة على المتصفح. توفر مزامنة الحالة (كاملة/دلتا)، استراتيجيات الاتساق (الطابع الزمني/السلطوي)، محول إشارة WebSocket الحد الأدنى، مساعدي الحركة، انتخاب/ترحيل المضيف، وطبقة تراكب ping.

بداية سريعة

npm install @p2play-js/p2p-game
import { P2PGameLibrary, WebSocketSignaling } from "@p2play-js/p2p-game";

const signaling = new WebSocketSignaling("اللاعب-أ", "الغرفة-42", "wss://مثالك-ws");
const multiP2PGame = new P2PGameLibrary({
  signaling,
  maxPlayers: 4,
  syncStrategy: "delta",
  conflictResolution: "timestamp",
});

await multiP2PGame.start();

multiP2PGame.on("playerMove", (id, pos) => {/* العرض */});

العروض التوضيحية

البنية المعمارية

  • تستخدم المكتبة خادم إشارة WebSocket لإدارة الغرف، والحفاظ على قائمة المعرفات، وتوجيه رسائل SDP/ICE إلى نظراء محددين.
  • يشكل النظراء شبكة كاملة: لكل زوج من النظراء، من يكون playerId الخاص به أصغر ترتيبًا أبجديًا ينشئ عرض WebRTC. هذا يمنع تضارب العروض.
  • بعد إنشاء قنوات البيانات، تتدفق رسائل اللعب نظيرًا إلى نظير؛ ولم يعد خادم الإشارة يمرر حركة تطبيقية.
  • اختيار المضيف حتمي: يصبح أصغر playerId هو المضيف. وعند مغادرة المضيف، يُنتخب التالي ويُرسل لقطة كاملة جديدة.
ما هي الإشارة؟
لا يمكن للمتصفحات فتح اتصالات WebRTC دون تبادل بيانات وصفية أولاً (عروض/إجابات SDP ومرشحي ICE) عبر قناة خارجية. يقوم خادم الإشارة فقط بهذا التبادل ويحافظ على قائمة الغرفة؛ ولا يمرر اللعب بعد فتح قنوات البيانات.

تسلسل الإشارة

sequenceDiagram participant A as العميل أ participant S as خادم الإشارة WS participant B as العميل ب A->>S: register {roomId, from, announce} B->>S: register {roomId, from, announce} S-->>A: sys: roster [A,B] S-->>B: sys: roster [A,B] note over A,B: أصغر playerId يبدأ العرض A->>S: kind: desc (offer), to: B S->>B: kind: desc (offer) from A B->>S: kind: desc (answer), to: A S->>A: kind: desc (answer) from B A->>S: kind: ice, to: B B->>S: kind: ice, to: A note over A,B: تُفتح قناة البيانات → يصبح اللعب P2P

طوبولوجيا شبكة كاملة

graph LR A[لاعب أ] --- B[لاعب ب] A --- C[لاعب ج] B --- C classDef host fill:#2b79c2,stroke:#2a3150,color:#fff; class A host;

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

  • لقطات كاملة: الانضمامات/الترحيل، إعادة مزامنة تصحيحية.
  • تحديثات دلتا: تغييرات مسار مستهدفة (نهج هجين عمليًا).
كامل مقابل دلتا
اللقطات الكاملة قوية وبسيطة لكنها ثقيلة؛ أما الدلتا فمضغوطة وفعالة لكنها تحتاج مخطط حالة مستقر. عمليًا، استخدم الدلتا معظم الوقت واللقطة الكاملة عند انضمام نظراء أو بعد ترحيل المضيف.

الاتساق

  • الطابع الزمني (افتراضي): آخر‑كاتب‑يفوز (LWW) حسب تسلسل كل مرسل.
  • سلطوي: قبول الأفعال فقط من السلطة (المضيف أو معرف ثابت).
الاتساق في الألعاب
في وضع الطابع الزمني تُقبل أحدث عملية لكل مرسل وفق قاعدة آخر‑كاتب‑يفوز (LWW): تُتجاهل أي رسالة يَقل فيها seq عن آخر قيمة شوهدت لذلك المرسل. في الوضع السلطوي يطبق نظير واحد (غالبًا مضيف موثوق) كل الأفعال لمنع التعارض والغش؛ ويرسل بقية النظراء نيات ويقبلون التصحيحات.

الحركة

تهدف هذه المكتبة إلى حركة سلسة لكن متوقعة في ظل اضطراب الشبكة. تجمع بين الاستيفاء (سلاسة بين عينات معروفة) و الاستقراء المحدود (نوافذ توقع قصيرة) لإخفاء التحديثات المتأخرة دون الابتعاد كثيرًا عن الحقيقة.

الاستيفاء

عند استلام موضع بعيد جديد، لا ننتقل إليه فورًا. بدلًا من ذلك، في كل إطار نتحرك بجزء من المسافة المتبقية. يتحكم عامل smoothing في هذا الجزء (0..1). القيم الأكبر تقلل التأخر البصري لكنها قد تبدو "عائمة".

// شبه‑كود
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec يأخذ بالحسبان حد الاستقراء (انظر أدناه)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// محور Z اختياري إذا توفر
ضبط قيمة smoothing
ابدأ بحوالي 0.2–0.3. إن تأخرت الحركة خلف المدخلات فزدها؛ وإن تذبذبت أو تجاوزت فانقصها.

الاستقراء (بحد)

إذا لم يصل تحديث جديد في هذا الإطار، نستخدم مؤقتًا آخر سرعة معروفة للإسقاط للأمام. لمنع الانجراف نحد نافذة الإسقاط عبر extrapolationMs (مثلًا 120–140 مللي ثانية). بعد تجاوز الميزانية نتوقف عن الإسقاط وننتظر التحديث السلطوي التالي.

لماذا التحديد؟
الاستقراء لفترة طويلة يولد أخطاء واضحة (عبور الجدران، انتقال لحظي عند التصحيح). حد قصير يخفي الاضطراب المؤقت ويبقي العرض قريبًا من الحقيقة.

ثنائي الأبعاد مقابل ثلاثي الأبعاد

المواضع والسرعات ثنائية البعد افتراضيًا؛ أضف z لثلاثي أبعاد بسيط. إذا عرّفت worldBounds.depth فسيُقيد محور Z أيضًا.

حدود العالم مقابل عالم مفتوح

باستخدام worldBounds نقيّد المواضع إلى [0..width] و[0..height] (وZ إلى [0..depth] إن توفر). لبيئات العالم المفتوح، عيّن ignoreWorldBounds: true لتعطيل كل القيود (تبقى التصادمات لاعب‑مقابل‑لاعب فقط).

التصادمات (دوائر/كرات)

تُعالج التصادمات كفصل متماثل بين دوائر (ثنائي الأبعاد) أو كرات (ثلاثي الأبعاد) ذات نصف قطر متساوٍ. عند تداخل لاعبين، نحسب المتجه الموحّد بينهما وندفع الاثنين للابتعاد بنصف مسافة التداخل. هذا بسيط ومستقر للألعاب غير الرسمية.

// لاعبان A,B بنصف قطر r
const dx = B.x - A.x, dy = B.y - A.y, dz = (B.z||0) - (A.z||0);
const dist = Math.max(1e-6, Math.hypot(dx, dy, dz));
const overlap = Math.max(0, 2*r - dist) / 2;
const nx = dx / dist, ny = dy / dist, nz = dz / dist;
A.x -= nx * overlap; A.y -= ny * overlap; A.z = (A.z||0) - nz * overlap;
B.x += nx * overlap; B.y += ny * overlap; B.z = (B.z||0) + nz * overlap;
            
ملاحظة التعقيد
الخوارزمية البسيطة تتحقق من كل الأزواج (O(n²)). هذا مناسب للغرف الصغيرة. للمشاهد المزدحمة يمكن إضافة تقطيع مكاني (شبكات/أشجار رباعية) على مستوى التطبيق.

التدفق: خطوة الحركة

flowchart TD A[آخر حالة معروفة] --> B{تحديث شبكة جديد؟} B -- نعم --> C[إعادة تعيين الطوابع الزمنية؛ تطبيق الاستيفاء] B -- لا --> D{ميزانية الاستقراء المتبقية؟} D -- نعم --> E[إسقاط بآخر سرعة * smoothing] D -- لا --> F[تثبيت الموضع] C --> G{حدود العالم؟} E --> G G -- تقييد --> H[تطبيق القيود] G -- عالم مفتوح --> I[تجاوز القيود] H --> J[حل التصادمات] I --> J J --> K[عرض]

حل التصادم (دائرة/كرة)

flowchart TD A(البدء) --> B(حساب المسافة بين A وB) B --> C{هل المسافة أقل من ضعفي نصف القطر؟} C -- لا --> Z(لا تداخل) C -- نعم --> D(حساب متجه موحّد من A إلى B) D --> E(حساب التداخل = ضعفي نصف القطر ناقص المسافة) E --> F(تحريك A بمتجه موحّد سالب × التداخل ÷ 2) E --> G(تحريك B بمتجه موحّد موجب × التداخل ÷ 2) F --> H(تم) G --> H
الاستيفاء مقابل الاستقراء
يُنعّم الاستيفاء بين المواضع المعروفة؛ ويتوقع الاستقراء حركة قصيرة الأمد باستخدام السرعة عندما تتأخر التحديثات. أبقِ نوافذ الاستقراء قصيرة (extrapolationMs) لتفادي الخطأ المرئي.

تفاصيل الشبكة

  • استراتيجيات الضغط العكسي: دمج/إسقاطات للقنوات المشبعة.
  • السعة: فرض maxPlayers + حدث maxCapacityReached.
  • STUN/TURN: وفر TURN للشبكات الصارمة؛ واستخدم WSS للإشارة.
حول NAT وTURN
الكثير من الشبكات المؤسسية/الفندقية تحظر الاتصال المباشر. يُعيد خادم TURN تمرير الحركة ليتمكن النظراء من الاتصال، مقابل زمن وصول أعلى ونقل خادمي إضافي. وفر بيانات اعتماد TURN في الإنتاج للموثوقية.

الضغط العكسي

يحمي الضغط العكسي قناة البيانات من الحمل الزائد. عندما ينمو مخزن الإرسال الداخلي للقناة (المعروض كـ RTCDataChannel.bufferedAmount) فوق عتبة، يمكنك إيقاف الإرسال مؤقتًا، أو إسقاط الرسائل منخفضة القيمة، أو دمج عدة تحديثات في أحدثها.

كيف يعمل
كل send() يزيد bufferedAmount حتى يُفرّغ المتصفح البيانات عبر الشبكة. إذا واصلت الإرسال أسرع مما تتحمله الشبكة فسيتفاقم التأخير وتتقطع التطبيقات. تخفف الاستراتيجيات أدناه ذلك.

الاستراتيجيات

  • off: بلا حماية. للاستخدام مع رسائل صغيرة ونادرة فقط.
  • drop-moves: عند تجاوز العتبة، تجاهل رسائل move الجديدة (المدخلات مؤقتة؛ الإسقاط مقبول غالبًا).
  • coalesce-moves: احتفظ بآخر رسالة move لكل نظير في الصف، واستبدل الأقدم.
const multiP2PGame = new P2PGameLibrary({
  signaling,
  backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
  
العتبات الموصى بها
ابدأ بـ 256–512 كيلوبايت. إذا وصلت العتبة بشكل متكرر، خفّض تكرار الرسائل أو حجم الحمولة (مثلًا استخدم دلتا، binary‑min، كمِّن المتجهات).

الأحداث وواجهة البرمجة (مختارات)

  • on('playerMove')، on('inventoryUpdate')، on('objectTransfer')
  • on('stateSync')، on('stateDelta')، on('hostChange')، on('ping')
  • broadcastMove()، updateInventory()، transferItem()
  • broadcastPayload()، sendPayload()
  • setStateAndBroadcast()، announcePresence()، getHostId()

مرجع الأنواع

GameLibOptions

type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta";
type ConflictResolution = "timestamp" | "authoritative";

interface BackpressureOptions {
  strategy?: "off" | "drop-moves" | "coalesce-moves";
  thresholdBytes?: number; // ~256KB الافتراضي
}

interface DebugOptions {
  enabled?: boolean;
  onSend?: (info: {
    type: "broadcast" | "send";
    to: string | "all";
    payloadBytes: number;
    delivered: number;
    queued: number;
    serialization: SerializationStrategy;
    timestamp: number;
  }) => void;
}

interface MovementOptions {
  maxSpeed?: number;
  smoothing?: number; // 0..1
  extrapolationMs?: number;
  worldBounds?: { width: number; height: number; depth?: number };
  ignoreWorldBounds?: boolean;
  playerRadius?: number;
}

interface GameLibOptions {
  maxPlayers?: number;
  syncStrategy?: SyncStrategy;
  conflictResolution?: ConflictResolution;
  authoritativeClientId?: string;
  serialization?: SerializationStrategy;
  iceServers?: RTCIceServer[];
  cleanupOnPeerLeave?: boolean;
  debug?: DebugOptions;
  backpressure?: BackpressureOptions;
  pingOverlay?: { enabled?: boolean; position?: "top-left"|"top-right"|"bottom-left"|"bottom-right"; canvas?: HTMLCanvasElement | null };
  movement?: MovementOptions;
}

الأحداث

type EventMap = {
  playerMove: (playerId: string, position: { x:number; y:number; z?:number }) => void;
  inventoryUpdate: (playerId: string, items: Array<{ id:string; type:string; quantity:number }>) => void;
  objectTransfer: (fromId: string, toId: string, item: { id:string; type:string; quantity:number }) => void;
  stateSync: (state: GlobalGameState) => void;
  stateDelta: (delta: StateDelta) => void;
  peerJoin: (playerId: string) => void;
  peerLeave: (playerId: string) => void;
  hostChange: (hostId: string) => void;
  ping: (playerId: string, ms: number) => void;
  sharedPayload: (from: string, payload: unknown, channel?: string) => void;
  maxCapacityReached: (maxPlayers: number) => void;
};

interface GlobalGameState {
  players: Record<string, { id:string; position:{x:number;y:number;z?:number}; velocity?:{x:number;y:number;z?:number} }>;
  inventories: Record<string, Array<{ id:string; type:string; quantity:number }>>;
  objects: Record<string, { id:string; kind:string; data:Record<string,unknown> }>;
  tick: number;
}

interface StateDelta { tick:number; changes: Array<{ path:string; value:unknown }> }

قواعد مسارات الدلتا

  • المسارات هي مفاتيح كائنات مفصولة بنقطة (لا دعم لفهارس المصفوفات).
  • اجعل البنى سطحية ومفهرسة لتحديثات موجهة (مثل objects.chest.42) وتجنب المصفوفات العميقة.
// جيد: خريطة كائنات
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// غير مدعوم: مسار فهرس مصفوفة مثل 'objects[3]' أو 'players.list.0'

P2PGameLibrary

الباني

new P2PGameLibrary(options: GameLibOptions & { signaling: WebSocketSignaling | SignalingAdapter })

دورة الحياة

await start(): Promise<void>
on<N extends keyof EventMap>(name: N, handler: EventMap[N]): () => void
getState(): GlobalGameState
getHostId(): string | undefined
setPingOverlayEnabled(enabled: boolean): void
tick(now?: number): void // تطبيق الاستيفاء/التصادمات مرة واحدة

أدوات الحالة

setStateAndBroadcast(selfId: string, changes: Array<{ path:string; value:unknown }>): string[]
broadcastFullState(selfId: string): void
broadcastDelta(selfId: string, paths: string[]): void

واجهات اللعب

announcePresence(selfId: string, position = { x:0, y:0 }): void
broadcastMove(selfId: string, position: {x:number;y:number;z?:number}, velocity?: {x:number;y:number;z?:number}): void
updateInventory(selfId: string, items: Array<{ id:string; type:string; quantity:number }>): void
transferItem(selfId: string, to: string, item: { id:string; type:string; quantity:number }): void

واجهات الحمولة

broadcastPayload(selfId: string, payload: unknown, channel?: string): void
sendPayload(selfId: string, to: string, payload: unknown, channel?: string): void

الرسائل (النقل)

// اتحاد NetMessage (مختار)
type NetMessage =
  | { t:"move"; from:string; ts:number; seq?:number; position:{x:number;y:number;z?:number}; velocity?:{x:number;y:number;z?:number} }
  | { t:"inventory"; from:string; ts:number; seq?:number; items:Array<{id:string;type:string;quantity:number}> }
  | { t:"transfer"; from:string; ts:number; seq?:number; to:string; item:{id:string;type:string;quantity:number} }
  | { t:"state_full"; from:string; ts:number; seq?:number; state: GlobalGameState }
  | { t:"state_delta"; from:string; ts:number; seq?:number; delta: StateDelta }
  | { t:"payload"; from:string; ts:number; seq?:number; payload: unknown; channel?: string };

// التسلسل
// الاستراتيجية: "json" (سلاسل نصية) أو "binary-min" (ArrayBuffer UTF-8 JSON)

محول الإشارة

تجريد تستخدمه المكتبة لتبادل SDP/ICE عبر أي خلفية (WebSocket، REST، ...).

interface SignalingAdapter {
  localId: string;
  roomId?: string;
  register(): Promise<void>; // الانضمام واستلام القائمة
  announce(desc: RTCSessionDescriptionInit, to?: string): Promise<void>;
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void): void;
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void): void;
  onRoster(cb: (roster: string[]) => void): void;
  sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void>;
}

WebSocket الإشارة

تطبيق مرجعي مستخدم في الأمثلة؛ البروتوكول: { sys:'roster', roster:string[] }؛ رسائل موجهة عبر to.

new WebSocketSignaling(localId: string, roomId: string, serverUrl: string)
await signaling.register();
// ...

أشكال الرسائل

// عميل ← خادم (تسجيل)
{ roomId: string, from: string, announce: true, kind: 'register' }

// خادم ← عملاء (بث قائمة)
{ sys: 'roster', roomId: string, roster: string[] }

// عميل ← خادم (SDP/ICE، موجه أو بث ضمن الغرفة)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

PeerManager (داخلي)

  • يحافظ على RTCPeerConnection واحد وRTCDataChannel واحد لكل نظير، مع ربط ردود النداء اللازمة.
  • لنظيرين، يبدأ صاحب playerId الأصغر بإنشاء العرض؛ ويردّ الآخر. يمنع ذلك العروض المتزامنة.
  • يصدر أحداث peerJoin، peerLeave، hostChange وping؛ ويمرر رسائل الشبكة المفككة كـ netMessage.
  • الضغط العكسي: يدعم off/drop-moves/coalesce-moves.
  • السعة: يفرض maxPlayers ويصدر maxCapacityReached.

EventBus (داخلي)

class EventBus {
  on<N extends keyof EventMap>(name: N, fn: EventMap[N]): () => void
  off<N extends keyof EventMap>(name: N, fn: EventMap[N]): void
  emit<N extends keyof EventMap>(name: N, ...args: Parameters<EventMap[N]>): void
}

عادةً ما تشترك عبر P2PGameLibrary.on() الذي يفوض إلى الحافلة الداخلية.

PingOverlay

تظهر طبقة صغيرة فوق الصفحة لتتبع RTT لكل نظير. تحتفظ بمخطط قصير (~60 عينة) وتفيد بالتطوير.

الخيارات

{
  enabled?: boolean; // افتراضيًا false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // افتراضيًا 'top-right'
  canvas?: HTMLCanvasElement | null; // وفر canvas أو دعه يُنشأ تلقائيًا
}

التسلسل

  • الاستراتيجيات: json أو binary-min.
  • الاستراتيجيات غير المعروفة تُطلق خطأ.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

function createSerializer(strategy: 'json'|'binary-min' = 'json'): Serializer

أمثلة

مضيف سلطوي يطبق نيات

const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on("sharedPayload", (from, payload, channel) => {
  if (!isHost()) return;
  if (channel === "move-intent" && typeof payload === "object") {
    const p = payload as { pos:{x:number;y:number}; vel?:{x:number;y:number} };
    multiP2PGame.broadcastMove(multiP2PGame.getHostId()!, p.pos, p.vel);
  }
});

مرجع الأحداث

playerMove

game.on('playerMove', (playerId, position) => {
  drawAvatar(playerId, position);
});

ملاحظات الإنتاج

  • تجهيز TURN وWSS للإشارة الآمنة.
  • فكّر في وضع سلطوي لمضيف موثوق للإنصاف.
  • راقب RTCDataChannel.bufferedAmount واضبط الضغط العكسي.

استكشاف الأخطاء

فشل إنشاء اتصال WebRTC

  • محتوى مختلط: استخدم HTTPS/WSS.
  • غياب TURN: الشبكات الصارمة تحتاج TURN.
  • CORS/جدار ناري: تحقق من الأصول والمنافذ.

سير عمل اللعبة

أنماط من الطرف إلى الطرف لربط الشبكة والاتساق والحالة لأنواع ألعاب مختلفة.

مسرد المصطلحات

  • SDP: بروتوكول وصف الجلسة.
  • ICE: إنشاء اتصال تفاعلي.
  • STUN: خادم يساعد العميل على معرفة عنوانه العام.
  • TURN: خادم ترحيل عند تعذر P2P المباشر.
  • DataChannel: قناة بيانات ثنائية الاتجاه في WebRTC.
  • LWW: آخر‑كاتب‑يفوز.