العودة إلى المدونة

كيفية بناء Game Inventory System آمن مع Database Integration للعناصر التي يتم فحصها

نُشر في 25 يونيو 2026
كيفية بناء Game Inventory System آمن مع Database Integration للعناصر التي يتم فحصها

باختصار

يشرح هذا المقال كيفية بناء نظام مخزون ألعاب (game inventory system) آمن عبر ربط تفاعلات اللاعبين بآليات التحقق من جهة الـ server وقواعد البيانات الدائمة. كما يقدم المقال تطبيقاً عملياً باستخدام Unreal Engine C++ ويوضح الفروق بين استخدام قواعد البيانات العلاقاتية ومخازن المستندات (NoSQL) لحفظ بيانات المخزون. وأخيراً، يستعرض المقال أفضل الممارسات الأمنية مثل استخدام التوكنات المؤقتة والتحقق من المسافة لمنع استغلال الثغرات وتكرار العناصر.

سيستغل اللاعبون أي ثغرة لتكرار العناصر النادرة (duplicate rare items)، وتعد الـ client-authoritative state transitions هدفهم المفضل. في ألعاب الرعب (horror games)، وألعاب المغامرة، وألعاب الـ RPG، تعد عملية فحص عنصر ما—مثل التقاط مفتاح، وتدويره ثلاثي الأبعاد (3D) للعثور على دليل، ثم إضافته إلى الـ inventory الخاص باللاعب—ميكانيكية كلاسيكية. إذا كان الـ game client يحدد مباشرة متى يتم إضافة عنصر إلى الـ inventory دون التحقق من جهة الـ server، فإن أداة packet injection بسيطة مثل Cheat Engine أو Fiddler يمكنها خداع الـ client لإرسال إشارات "إضافة عنصر" (add item) لعناصر لم يرها اللاعب من الأساس. لمنع ذلك، يجب على المطورين تطبيق Game Inventory System Database Integration قوية تربط تفاعلات الـ client-side بـ server-authoritative logic وحفظ سحابي آمن (secure cloud persistence).

دورة حياة فحص العناصر وإضافتها إلى الـ Inventory

لبناء عملية inventory sync آمنة، يجب أولاً تفكيك دورة حياة فحص العناصر. ينسق هذا التسلسل بين الـ actors في العالم الفيزيائي، وحالات الفحص المحلية على الـ client-side، ومكونات الـ inventory التي تعتمد على الـ server-authoritative، وتخزين قاعدة البيانات.

  1. Interaction Detection: يقترب اللاعب من actor فيزيائي (AInspectableActor) في عالم اللعبة. يقوم line trace أو collision volume بتمييز الكائن كعنصر تفاعلي.
  2. Inspection Mode Transition: يضغط اللاعب على مفتاح التفاعل. يدخل الـ client في حالة فحص محلية (localized inspection state)، مما يؤدي إلى قفل حركة الشخصية، وتدوير الكائن في حاوية إحداثيات مخصصة بمساحة الشاشة (screen-space coordinates container)، وعرض واجهة وصف ثنائية الأبعاد (2D description overlay).
  3. Verification Stage: ينقر اللاعب على "Take". وبدلاً من قيام الـ client بإضافة العنصر إلى الـ inventory مباشرة، فإنه يرسل طلب interaction token إلى الـ server.
  4. Server Validation: يتحقق الـ server من أن اللاعب يقع ضمن نصف قطر التفاعل الفيزيائي (~250 Unreal units) من الـ actor وأن الـ actor نشط.
  5. Database Integration: يقوم الـ server بإضافة العنصر إلى مصفوفة الـ inventory، ويكتب التحديث في قاعدة البيانات الدائمة (persistent database)، ويرسل حدث تدمير (destruction event) للـ actor في عالم اللعبة.

إذا كنت تطور لعبة Multiplayer، فإن إدارة هذه الحالة على الـ server أمر بالغ الأهمية. تواجه العديد من الفرق كوابيس الـ multiplayer inventory مع مالكي الـ actor component المتبدلين في Unreal Engine عندما لا يقومون بإعداد الـ replication والـ network authority بشكل صحيح على مكونات الـ inventory المخصصة.

كتابة منطق الفحص باستخدام Unreal Engine C++

لتطبيق هذا التدفق، سنقوم بإنشاء ثلاثة مكونات: واجهة (IInspectableInterface)، وactor قابل للفحص (AInspectableActor)، ومكون inventory مكرر للاعب (UInventoryComponent) يدعم الـ replication.

إليك ملف الرأس (header file) للواجهة والذي يوضح كيفية استقبال الـ actors لأوامر الفحص:

// InspectableInterface.h
#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "InspectableInterface.generated.h"

UINTERFACE(MinimalAPI)
class UInspectableInterface : public UInterface
{
	GENERATED_BODY()
};

class HORIZON_GAME_API IInspectableInterface
{
	GENERATED_BODY()

public:
	virtual void OnInspectStarted(APlayerController* InspectingPlayer) = 0;
	virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) = 0;
};

بعد ذلك، لنقم بتطبيق فئة AInspectableActor. تتعامل هذه الفئة مع الكائن الفيزيائي في العالم، وتقوم بتخزين معرفه الفريد (unique identifier)، والحد الأقصى لنطاق التفاعل، وحالته.

// InspectableActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InspectableInterface.h"
#include "InspectableActor.generated.h"

UCLASS()
class HORIZON_GAME_API AInspectableActor : public AActor, public IInspectableInterface
{
	GENERATED_BODY()

public:
	AInspectableActor();

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
	FName ItemID;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
	FString ItemDisplayName;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
	float MaxInteractionDistance;

	virtual void OnInspectStarted(APlayerController* InspectingPlayer) override;
	virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) override;

protected:
	virtual void BeginPlay() override;

private:
	bool bIsBeingInspected;
	TWeakObjectPtr<APlayerController> CurrentInspectingPlayer;
};

إليك ملف التطبيق (implementation file) للـ inspectable actor:

// InspectableActor.cpp
#include "InspectableActor.h"
#include "GameFramework/PlayerController.h"

AInspectableActor::AInspectableActor()
{
	PrimaryActorTick.bCanEverTick = false;
	bIsBeingInspected = false;
	MaxInteractionDistance = 250.0f;
	ItemID = "item_default";
	ItemDisplayName = "Generic Item";
}

void AInspectableActor::BeginPlay()
{
	Super::BeginPlay();
}

void AInspectableActor::OnInspectStarted(APlayerController* InspectingPlayer)
{
	if (!InspectingPlayer || bIsBeingInspected) return;

	APawn* PlayerPawn = InspectingPlayer->GetPawn();
	if (!PlayerPawn) return;

	float Distance = FVector::Dist(PlayerPawn->GetActorLocation(), GetActorLocation());
	if (Distance > MaxInteractionDistance)
	{
		UE_LOG(LogTemp, Warning, TEXT("Player too far to inspect %s"), *GetName());
		return;
	}

	bIsBeingInspected = true;
	CurrentInspectingPlayer = InspectingPlayer;
}

void AInspectableActor::OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake)
{
	if (InspectingPlayer != CurrentInspectingPlayer.Get()) return;

	if (bWantsToTake && HasAuthority())
	{
		UActorComponent* InvComp = InspectingPlayer->GetComponentByClass(UInventoryComponent::StaticClass());
		if (InvComp)
		{
			UInventoryComponent* Inventory = Cast<UInventoryComponent>(InvComp);
			if (Inventory)
			{
				Inventory->Server_TryAddInspectedItem(this);
			}
		}
	}
	
	bIsBeingInspected = false;
	CurrentInspectingPlayer.Reset();
}

الآن، لنقم بإنشاء الـ UInventoryComponent الذي يدير قائمة الـ inventory الخاصة باللاعب ويقوم بعمل replication لهذه البيانات عبر الشبكة إلى اللاعبين على جهة الـ client.

// InventoryComponent.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InventoryComponent.generated.h"

class AInspectableActor;

USTRUCT(BlueprintType)
struct FInventoryItem
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	FName ItemID;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	int32 Quantity;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	FString InspectedTimestamp;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class HORIZON_GAME_API UInventoryComponent : public UActorComponent
{
	GENERATED_BODY()

public:
	UInventoryComponent();

	UFUNCTION(Server, Reliable, WithValidation)
	void Server_TryAddInspectedItem(AInspectableActor* TargetActor);

	bool AddItemToLocalState(FName InItemID, int32 Quantity);
	void SaveInventoryToDatabase();

protected:
	UPROPERTY(Replicated, BlueprintReadOnly, Category = "Inventory")
	TArray<FInventoryItem> Items;

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

وهذا هو ملف التطبيق حيث نعرف منطق الـ RPC replication والتحقق من الكتابة (write validation):

// InventoryComponent.cpp
#include "InventoryComponent.cpp"
#include "InspectableActor.h"
#include "Net/UnrealNetwork.h"

UInventoryComponent::UInventoryComponent()
{
	SetIsReplicatedByDefault(true);
}

void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME(UInventoryComponent, Items);
}

bool UInventoryComponent::Server_TryAddInspectedItem_Validate(AInspectableActor* TargetActor)
{
	if (!TargetActor) return false;

	APawn* OwnerPawn = Cast<APawn>(GetOwner());
	if (!OwnerPawn) return false;

	// Server-side distance check
	float Distance = FVector::Dist(OwnerPawn->GetActorLocation(), TargetActor->GetActorLocation());
	return Distance <= TargetActor->MaxInteractionDistance;
}

void UInventoryComponent::Server_TryAddInspectedItem_Implementation(AInspectableActor* TargetActor)
{
	if (!TargetActor) return;

	AddItemToLocalState(TargetActor->ItemID, 1);
	SaveInventoryToDatabase();

	TargetActor->Destroy();
}

bool UInventoryComponent::AddItemToLocalState(FName InItemID, int32 Quantity)
{
	for (FInventoryItem& Item : Items)
	{
		if (Item.ItemID == InItemID)
		{
			Item.Quantity += Quantity;
			return true;
		}
	}

	FInventoryItem NewItem;
	NewItem.ItemID = InItemID;
	NewItem.Quantity = Quantity;
	NewItem.InspectedTimestamp = FDateTime::UtcNow().ToString();
	Items.Add(NewItem);
	return true;
}

void UInventoryComponent::SaveInventoryToDatabase()
{
	// Database integration trigger goes here
}

تصميم مخطط قاعدة بيانات الـ Inventory

بمجرد أن يتحقق الـ server من أن اللاعب قد فحص العنصر بالفعل، يجب حفظ هذا التغيير في الحالة. الاختيار بين قاعدة بيانات علاقاتية (SQL) أو مخزن مستندات (NoSQL) يغير كيفية هيكلة الـ database schemas الخاصة بك.

معيار التقييم علاقاتي (PostgreSQL) مخزن مستندات (NoSQL / MongoDB)
هيكل البيانات جداول طبيعية (Normalized tables)، مفاتيح خارجية صارمة (strict foreign keys) مستندات ومصفوفات مفتاح-قيمة متداخلة (Nested key-value documents & arrays)
سلامة المعاملات توافق ACID كامل وتلقائي (Full ACID-compliance out of the box) عمليات ذرية (Atomic operations) مقتصرة على مستندات فردية
تعقيد الاستعلام مرتفع (يتطلب استعلامات SQL JOIN للبحث عن العناصر) منخفض (بحث مباشر في ملف اللاعب الشخصي - player profile)
القابلية للتوسع رأسي (يتطلب sharding يدوي للتوسع) أفقي (قدرات sharding مدمجة)

المخطط العلاقاتي (PostgreSQL Schema)

في قاعدة البيانات العلاقاتية، ستحتاج إلى فصل اللاعبين، والبيانات التعريفية العامة للعنصر (global item metadata)، وقوائم الـ inventory النشطة لتجنب تكرار البيانات (data redundancy). يتطلب هذا ثلاثة جداول:

CREATE TABLE players (
    player_id VARCHAR(64) PRIMARY KEY,
    username VARCHAR(100) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE game_items (
    item_id VARCHAR(64) PRIMARY KEY,
    display_name VARCHAR(100) NOT NULL,
    item_type VARCHAR(50) DEFAULT 'QuestItem'
);

CREATE TABLE inventory_items (
    id SERIAL PRIMARY KEY,
    player_id VARCHAR(64) REFERENCES players(player_id) ON DELETE CASCADE,
    item_id VARCHAR(64) REFERENCES game_items(item_id),
    quantity INTEGER CHECK (quantity > 0),
    inspected_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(player_id, item_id)
);

مخطط المستندات (NoSQL Schema)

في قاعدة بيانات المستندات، تقوم بتخزين الـ inventory الخاص باللاعب كمصفوفة متداخلة (nested array) داخل مستند اللاعب الأساسي. يتيح ذلك للـ backend الخاص بك استرداد حالة اللاعب بالكامل في استعلام واحد:

{
  "_id": "usr_90a82b3d11ef",
  "username": "SurvivorX",
  "inventory": [
    {
      "item_id": "key_rusted_01",
      "quantity": 1,
      "inspected_timestamp": "2026-06-25T00:02:20Z"
    },
    {
      "item_id": "document_diary_03",
      "quantity": 1,
      "inspected_timestamp": "2026-06-25T00:05:12Z"
    }
  ],
  "last_updated": "2026-06-25T00:05:12Z"
}

معضلة المزامنة: الحفظ من جهة الـ Client-Side مقابل الـ Server-Authoritative

تغير مزامنة هذه الحالة طريقة تجربة اللاعبين للعبة ومدى قوتها ضد محاولات الغش. إذا كان كود الـ client-side يحفظ مباشرة في قاعدة البيانات، فيمكن للاعب تعديل عناوين الذاكرة لكتابة قيم عشوائية في قاعدة البيانات.

لمنع ذلك، يجب عليك تطبيق عمليات تحقق server-authoritative:

  1. Interaction Tokens: عندما يبدأ اللاعب في فحص عنصر ما، يقوم الـ server بتوليد interaction token مؤقت (صالح لمدة 60 ثانية) ويسجله على نسخة الـ world actor النشطة.
  2. Double-Spend Protection: عندما يلتقط اللاعب العنصر، يستهلك الـ server التوكن. إذا تم استقبال حزمة التقاط مكررة (duplicate pickup packet)، يرفضها الـ server.
  3. Synchronous Replication: تأكد من دفع تغييرات الـ inventory إلى شاشات الـ clients فوراً.

علاوة على ذلك، لدفع التحديثات المرئية إلى واجهات الـ inventory عبر الـ game clients المتصلين في الوقت الفعلي (real-time)، فإن الاعتماد على آليات polling ثقيلة سيخنق معدل تحديث الـ server (server's tick rate). بدلاً من ذلك، يجب عليك التخلي عن HTTP polling وتطبيق اتصال Websockets مخصص لمزامنة الـ inventory في الوقت الفعلي.

تطبيق كود التحقق في الـ Backend

إذا اخترت كتابة الـ server المخصص الخاص بك، فستحتاج إلى API endpoint يتعامل مع تحديثات قاعدة البيانات بأمان. أدناه مقتطف كود لـ Backend مبني على Node.js Express باستخدام PostgreSQL. يتعامل هذا السكربت مع الـ connection pooling، وعزل المعاملات (transaction isolation)، واستهلاك التوكن لكتابة عمليات التقاط العناصر:

const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());

const dbPool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

app.post('/api/v1/inventory/add', async (req, res) => {
  const { playerId, itemId, interactionToken } = req.body;
  
  if (!playerId || !itemId || !interactionToken) {
    return res.status(400).json({ error: 'Missing required parameters' });
  }
  
  try {
    // 1. Verify interaction token exists and is active
    const tokenResult = await dbPool.query(
      'SELECT status FROM interactions WHERE token = $1 AND player_id = $2',
      [interactionToken, playerId]
    );
    
    if (tokenResult.rows.length === 0 || tokenResult.rows[0].status !== 'active') {
      return res.status(400).json({ error: 'Invalid or expired interaction token' });
    }

    // 2. Begin transaction
    await dbPool.query('BEGIN');
    
    // Consume the token to prevent double-spending
    await dbPool.query(
      "UPDATE interactions SET status = 'consumed' WHERE token = $1",
      [interactionToken]
    );

    // Insert or increment inventory record
    await dbPool.query(
      `INSERT INTO inventory_items (player_id, item_id, quantity, inspected_timestamp) 
       VALUES ($1, $2, 1, NOW()) 
       ON CONFLICT (player_id, item_id) 
       DO UPDATE SET quantity = inventory_items.quantity + 1`,
      [playerId, itemId]
    );

    await dbPool.query('COMMIT');
    return res.status(200).json({ success: true });
  } catch (error) {
    await dbPool.query('ROLLBACK');
    console.error('Database transaction error:', error);
    return res.status(500).json({ error: 'Internal Server Error' });
  }
});

إن بناء هذه البنية التحتية بنفسك يأتي مع تكاليف تطوير عالية (high developer overhead). يجب عليك إعداد الـ load balancers، وتوفير مجموعات قواعد البيانات (database clusters)، وكتابة مدراء إعادة محاولة مخصصين على جهة الـ client-side، وبناء بروتوكولات مصادقة آمنة (secure authentication protocols). هذا يتطلب بسهولة من 4 إلى 6 أسابيع من عمل البنية التحتية قبل أن تكتب سطراً واحداً من كود الـ game loop.

تبسيط حفظ الحالة باستخدام horizOn

بدلاً من كتابة express middleware مخصصة، وإدارة PostgreSQL connection pools، ومحاربة انقطاعات الشبكة، يمكنك نقل هذا التعقيد إلى horizOn. مع horizOn، ستحصل على حل قاعدة بيانات مدار بالكامل (fully managed database) مصمم خصيصاً لتطوير الألعاب.

باستخدام ميزات قاعدة البيانات السحابية (cloud database)، لن تحتاج إلى كتابة أي سكربتات Backend لمعالجة التحقق من الحالة. يمكنك إطلاق عمليات كتابة مباشرة لقاعدة البيانات الدائمة من نسخة Unreal Engine الخاصة بك والمستندة إلى الـ server-authoritative باستخدام مكتبة الـ client:

void UInventoryComponent::SaveInventoryToCloud(FName InItemID, int32 InQuantity)
{
	TSharedPtr<FJsonObject> RequestData = MakeShareable(new FJsonObject());
	RequestData->SetStringField("player_id", PlayerID);
	RequestData->SetStringField("item_id", InItemID.ToString());
	RequestData->SetNumberField("quantity", InQuantity);

	// Single call to [horizOn](https://horizon.pm)'s secure database client
	FHorizonClient::Get()->Database("inventories")
		->Upsert(RequestData)
		->OnSuccess(this, &UInventoryComponent::OnSaveSuccess)
		->OnFailure(this, &UInventoryComponent::OnSaveFailure)
		->Execute();
}

يقوم هذا بتحديث قاعدة البيانات بشكل معاملاتي (transactionally)، ويضمن عدم قدرة اللاعبين على تزييف السجلات (spoof the records) على الـ clients الخاصة بهم، ويقوم تلقائياً بتخزين التحديثات مؤقتاً محلياً (caches updates locally) إذا كان اللاعب يعاني من انقطاع مؤقت في الإنترنت.

أفضل الممارسات لأنظمة الـ Persistent Game Inventories

لبناء نظام مخزون (inventory system) عالي الأداء، قم بتطبيق أفضل الممارسات التالية:

  1. Enforce Distance-Based Validation on the Server: تحقق من مسافة FVector::Dist بين الـ pawn والـ inspectable actor قبل إضافة العناصر. إذا كانت المسافة مستحيلة فيزيائياً، قم بتسجيلها كنشاط مريب من جهة الـ client.
  2. Utilize Idempotent Transaction Tokens: قم بتوليد UUIDs فريدة للمعاملات (transaction UUIDs) على الـ server عند بدء الفحص. هذا يمنع أخطاء الزيادة المزدوجة (double-increment bugs) عند إعادة محاولة إرسال حزم الشبكة بسبب ارتفاع زمن الاستجابة (latency spikes).
  3. Use Optimistic Local Updates with Server Correction: اجعل واجهة المستخدم (UI) تبدو سريعة الاستجابة من خلال تحديث شاشة الـ player inventory فوراً على جهة الـ client-side، ولكن احتفظ بها في حالة "انتظار التأكيد" (Pending Confirmation) حتى يرجع الـ server تأكيداً بنجاح عملية الكتابة.
  4. Log State Anomalies: راقب عدد المرات التي يحاول فيها الـ clients التقاط عناصر غير معلمة كعناصر تم فحصها (inspected) في جلستهم النشطة، حيث يعد هذا مؤشراً رئيسياً على استخدام أدوات الـ cheat injection.

هل أنت مستعد لتوسيع نطاق الـ multiplayer backend الخاص بك؟ جرب horizOn مجاناً أو تحقق من وثائق المطورين (developer docs) الخاصة بنا للبدء اليوم.


المصدر: How do you put an item into the inventory after inspecting it?