Pourquoi l'architecture de l'inventaire est importante
Le système d'inventaire est l'épine dorsale de tout serveur de jeu de rôle FiveM. Chaque interaction du joueur avec des objets, depuis la prise d'une arme jusqu'à la remise d'une clé à quelqu'un, se déroule dans l'inventaire. Un inventaire mal conçu entraîne des exploits de duplication d'objets, des problèmes de désynchronisation et des joueurs frustrés qui perdent leur équipement. Les systèmes d'inventaire les plus robustes suivent un modèle faisant autorité sur le serveur dans lequel le client affiche uniquement ce que le serveur lui dit, sans jamais faire confiance au client pour signaler son propre état. Cette décision architecturale élimine à elle seule la majorité des exploits de duplication qui affectent les serveurs dotés d'inventaires approuvés par les clients. Lors de la planification de ton inventaire, pensez d'abord au flux de données : le serveur possède la vérité, le client la restitue et chaque mutation passe par un événement de serveur validé.
Systèmes basés sur le poids et les emplacements
Le choix entre des systèmes d'inventaire basés sur le poids et sur les emplacements façonne fondamentalement l'expérience du joueur. Dans un système basé sur des emplacements, chaque emplacement contient un type d'élément avec une taille de pile maximale, et le nombre total d'emplacements définit la capacité de charge. Dans un système basé sur le poids, chaque objet a une valeur de poids et le joueur a un poids maximum à transporter. De nombreux frameworks modernes combinent les deux approches, en utilisant des emplacements pour l'organisation mais en limitant la capacité totale en fonction du poids. Voici un exemple de définition d'élément combinant poids et emplacement :
-- Shared item definitions (items.lua)
QBCore.Shared.Items = {
['water_bottle'] = {
name = 'water_bottle',
label = 'Water Bottle',
weight = 500, -- grams
type = 'item',
image = 'water_bottle.png',
unique = false,
useable = true,
shouldClose = true,
description = 'A refreshing bottle of water',
stackSize = 10, -- max per slot
},
['lockpick'] = {
name = 'lockpick',
label = 'Lockpick',
weight = 200,
type = 'item',
image = 'lockpick.png',
unique = false,
useable = true,
shouldClose = true,
description = 'Used to pick locks',
stackSize = 5,
},
}
Le weight Le champ est stocké en grammes pour plus de précision, et le stackSize contrôle combien de cet élément tiennent dans un seul emplacement. Lorsqu'un joueur tente de ramasser un objet, vérifiez à la fois qu'un emplacement est disponible et que le poids total ne dépassera pas le maximum. Cette double validation empêche les joueurs de transporter des quantités irréalistes d'objets lourds même s'ils disposent d'emplacements vides.
Validation côté serveur et anti-exploit
Chaque action d'inventaire doit être validée sur le serveur avant de prendre effet. Lorsqu'un joueur fait glisser un élément de l'emplacement 3 vers l'emplacement 7, le client envoie une demande de déplacement et le serveur vérifie que l'emplacement source contient réellement cet élément, que l'emplacement de destination peut l'accepter et que les quantités sont cohérentes. Ne laissez jamais le client spécifier le nombre d’éléments ou créer des éléments à partir de rien. Voici un gestionnaire de déplacement sécurisé côté serveur :
RegisterNetEvent('inventory:server:moveItem', function(fromSlot, toSlot, fromAmount)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
local fromItem = Player.PlayerData.items[fromSlot]
if not fromItem then
-- Source slot is empty, possible exploit attempt
DropPlayer(src, 'Invalid inventory operation')
return
end
if fromAmount > fromItem.amount or fromAmount < 1 then
DropPlayer(src, 'Invalid inventory amount')
return
end
local toItem = Player.PlayerData.items[toSlot]
if toItem and toItem.name == fromItem.name and not fromItem.unique then
-- Stack items together
local maxStack = QBCore.Shared.Items[fromItem.name].stackSize or 50
local canStack = maxStack - toItem.amount
local moveAmount = math.min(fromAmount, canStack)
if moveAmount > 0 then
toItem.amount = toItem.amount + moveAmount
fromItem.amount = fromItem.amount - moveAmount
if fromItem.amount <= 0 then
Player.PlayerData.items[fromSlot] = nil
end
end
else
-- Swap items between slots
Player.PlayerData.items[toSlot] = fromItem
Player.PlayerData.items[fromSlot] = toItem
end
Player.Functions.SetPlayerData('items', Player.PlayerData.items)
end)
Remarquez le DropPlayer nécessite des opérations manifestement impossibles. La journalisation de ces événements dans une table d'audit distincte tu aide à identifier les tentatives et les modèles d'exploitation. Envisagez également de mettre en œuvre une limitation du débit sur les événements d'inventaire, car les acteurs légitimes effectuent rarement plus de quelques opérations d'inventaire par seconde, tandis que les exploits automatisés envoient souvent des centaines de requêtes rapidement.
Métadonnées des éléments et éléments uniques
Les métadonnées transforment des éléments simples en objets riches et uniques. Une arme peut porter son numéro de série, sa durabilité et les modifications qui y sont attachées. Un téléphone peut stocker son numéro attribué et la référence de sa liste de contacts. Les produits alimentaires peuvent avoir un horodatage d’expiration. Les métadonnées sont stockées sous forme de table Lua sérialisée en JSON dans la base de données, attachée à chaque instance d'élément. La principale distinction réside entre les éléments empilables, qui partagent les mêmes métadonnées, et les éléments uniques, qui possèdent chacun leur propre table de métadonnées. Voici comment créer une arme avec des métadonnées complètes :
-- Creating a weapon with metadata
function CreateWeaponItem(src, weaponName, serial)
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return false end
local metadata = {
serial = serial or GenerateSerial(),
durability = 100.0,
ammo = 0,
attachments = {},
registered = false,
registeredTo = nil,
quality = math.random(85, 100),
created = os.time(),
}
return Player.Functions.AddItem(weaponName, 1, nil, metadata)
end
function GenerateSerial()
local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
local serial = ''
for i = 1, 10 do
local idx = math.random(1, #chars)
serial = serial .. chars:sub(idx, idx)
end
return serial
end
Lors de l'affichage d'articles avec des métadonnées dans le NUI, transmettez les métadonnées avec les informations sur l'article afin que l'interface utilisateur puisse afficher des détails tels que des barres de durabilité, des numéros de série et des évaluations de qualité. Cela donne aux joueurs une connexion plus profonde avec leurs objets et prend en charge des scénarios de jeu de rôle avancés tels que des systèmes d'enregistrement d'armes ou des enquêtes médico-légales retraçant un numéro de série jusqu'à son propriétaire.
Performances du NUI par glisser-déposer
L'interface utilisateur de l'inventaire est l'un des éléments NUI les plus sensibles aux performances, car les joueurs interagissent constamment avec lui. Évitez de restituer l'intégralité de la grille d'inventaire à chaque mise à jour. Utilisez plutôt une approche DOM virtuel ou des mises à jour d’éléments ciblées qui modifient uniquement les emplacements modifiés. Lorsqu'un joueur fait glisser un élément, gérez le déplacement entièrement dans JavaScript à l'aide d'événements de souris plutôt que d'envoyer des mises à jour de position au client Lua pendant le déplacement. Envoyez uniquement le résultat final de la suppression sous la forme d'un seul rappel NUI. Voici un modèle de gestionnaire de glissement performant :
// Inventory NUI - performant drag and drop
let draggedItem = null;
let dragElement = null;
document.addEventListener('mousedown', (e) => {
const slot = e.target.closest('.inv-slot[data-has-item="true"]');
if (!slot) return;
draggedItem = {
slot: parseInt(slot.dataset.slot),
item: JSON.parse(slot.dataset.itemInfo),
};
dragElement = slot.cloneNode(true);
dragElement.classList.add('dragging-ghost');
dragElement.style.position = 'fixed';
dragElement.style.pointerEvents = 'none';
dragElement.style.zIndex = '9999';
document.body.appendChild(dragElement);
moveDragElement(e.clientX, e.clientY);
});
document.addEventListener('mousemove', (e) => {
if (!dragElement) return;
moveDragElement(e.clientX, e.clientY);
});
document.addEventListener('mouseup', (e) => {
if (!draggedItem) return;
const targetSlot = e.target.closest('.inv-slot');
if (targetSlot) {
const toSlot = parseInt(targetSlot.dataset.slot);
// Send only the final result to Lua
fetch(`https://${GetParentResourceName()}/moveItem`, {
method: 'POST',
body: JSON.stringify({
fromSlot: draggedItem.slot,
toSlot: toSlot,
amount: draggedItem.item.amount,
}),
});
}
if (dragElement) dragElement.remove();
draggedItem = null;
dragElement = null;
});
Pour le rendu visuel, utilisez CSS Grid pour la disposition des emplacements et évitez les animations CSS lourdes sur les objets de l'inventaire car les joueurs peuvent avoir des dizaines d'emplacements visibles simultanément. Les sprites d'image pour les icônes d'éléments se chargent plus rapidement que les fichiers d'image individuels et réduisent les requêtes HTTP lors de la première ouverture du NUI.
Systèmes de cachettes et de conteneurs
Au-delà de l’inventaire personnel, les joueurs doivent accéder à des stockages externes tels que des coffres de véhicules, des réserves de maison et un stockage d’organisation partagé. Chaque type de conteneur doit avoir ses propres limites de capacité et règles de contrôle d'accès. Les coffres des véhicules utilisent la plaque d'immatriculation du véhicule comme identifiant unique, les caches de la maison utilisent les identifiants de propriété et les caches de travail utilisent le nom du travail associé à une vérification de note. Stockez les inventaires des conteneurs dans une table de base de données dédiée, distincte des inventaires des joueurs pour que les requêtes restent efficaces :
CREATE TABLE IF NOT EXISTS stash_items (
id INT AUTO_INCREMENT PRIMARY KEY,
stash_id VARCHAR(100) NOT NULL,
slot INT NOT NULL,
item_name VARCHAR(50) NOT NULL,
amount INT DEFAULT 1,
metadata LONGTEXT DEFAULT '{}',
UNIQUE KEY unique_stash_slot (stash_id, slot),
INDEX idx_stash_id (stash_id)
);
-- Example stash_id values:
-- 'trunk_ABC123' (vehicle trunk by plate)
-- 'house_42' (house stash by property id)
-- 'police_evidence_1' (job stash with identifier)
Lorsqu'un joueur ouvre un conteneur, chargez son contenu depuis la base de données et verrouillez-le pour empêcher l'accès simultané de plusieurs joueurs. Utilisez une table de verrouillage côté serveur qui suit les identifiants de cache actuellement ouverts et par qui. Relâchez le verrou lorsque le joueur ferme le conteneur ou se déconnecte. Cela évite l'exploit de duplication classique où deux joueurs ouvrent le même coffre et retirent tous deux les mêmes objets.
Synchronisation et persistance des stocks
La synchronisation des données d'inventaire entre la mémoire du serveur, la base de données et l'affichage du client nécessite une coordination minutieuse. Enregistrez périodiquement l'inventaire des joueurs dans la base de données, et non à chaque changement d'élément, afin de réduire la charge d'script de la base de données. Un intervalle de sauvegarde de 30 à 60 secondes fonctionne bien pour la plupart des serveurs. De plus, enregistrez toujours la déconnexion du lecteur et l'arrêt du serveur en utilisant le playerDropped événement et un gestionnaire d'arrêt. Implémentez un système d'indicateurs sales qui n'écrit dans la base de données que lorsque l'inventaire a réellement changé depuis la dernière sauvegarde :
local inventoryDirty = {}
-- Mark inventory as needing save
function MarkDirty(citizenid)
inventoryDirty[citizenid] = true
end
-- Periodic save loop
CreateThread(function()
while true do
Wait(30000) -- 30 seconds
for citizenid, dirty in pairs(inventoryDirty) do
if dirty then
local Player = QBCore.Functions.GetPlayerByCitizenId(citizenid)
if Player then
SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
end
inventoryDirty[citizenid] = nil
end
end
end
end)
AddEventHandler('playerDropped', function()
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if Player then
local citizenid = Player.PlayerData.citizenid
if inventoryDirty[citizenid] then
SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
inventoryDirty[citizenid] = nil
end
end
end)
Pour le côté client, les mises à jour NUI sont effectuées par lots afin que plusieurs modifications rapides de l'inventaire, telles que la réception de plusieurs objets issus d'une opération de fabrication, entraînent une seule actualisation de l'interface utilisateur plutôt qu'une par article. Cela élimine l'effet de scintillement que les joueurs voient lorsque les objets apparaissent un par un dans leur grille d'inventaire.
Surveillance et optimisation des performances
Surveillez les performances de ton système d'inventaire en suivant les indicateurs clés : temps de sauvegarde moyen de la base de données, taux de traitement des événements et fréquence de rendu NUI. Utilisez le profileur FiveM pour identifier les goulots d'étranglement dans tes rappels d'inventaire. Les pièges de performances courants incluent l'itération sur tous les inventaires des joueurs à chaque image pour des fonctionnalités basées sur la proximité telles que la collecte d'objets au sol, la sérialisation inutile d'objets de métadonnées volumineux et le fait de laisser les images NUI actives lorsque l'inventaire est fermé. Pour les objets au sol, utilisez un système de grille spatiale qui vérifie uniquement les éléments dans la cellule du joueur plutôt que d'analyser chaque élément déposé sur le serveur. Mettez en cache les définitions d'éléments dans une table de recherche indexée par nom d'élément afin que la récupération des propriétés de l'élément soit une recherche de hachage O(1) plutôt qu'une analyse de tableau O(n). Enfin, envisagez de mettre en œuvre la pagination de l'inventaire pour les conteneurs de très grande capacité, en chargeant uniquement les emplacements visibles et en récupérant des lignes supplémentaires au fur et à mesure que le lecteur fait défiler, ce qui permet de conserver à la fois les requêtes de base de données et le rendu NUI léger, même pour les entrepôts comportant des centaines d'emplacements.
