Pourquoi les systèmes de réputation transforment le jeu de rôle
La plupart des serveurs FiveM traitent la progression des personnages comme binaire : soit tu as un travail, soit tu n'en avez pas. Il n’y a aucun sentiment de croissance, de maîtrise ou de statut mérité. Un système de réputation et de compétences change fondamentalement cela en offrant aux joueurs une progression mesurable qui récompense le temps investi et les activités réalisées. Lorsqu'un mécanicien répare son centième véhicule et débloque un réglage avancé du moteur, cet exploit semble mérité. Lorsqu'un criminel bâtit suffisamment de réputation dans la rue pour accéder à un équipement de braquage de haut niveau, il ressent un sentiment tangible de progression qui fait avancer son gameplay. Dans ce guide, nous allons construire un cadre complet de réputation et de compétences à partir de zéro, couvrant les calculs d'XP, les arbres de compétences, les niveaux de réputation, les capacités à débloquer et un système de prestige qui maintient l'engagement des joueurs de fin de partie.
Schéma de base de données pour les compétences des joueurs
La base de tout système de progression est un schéma de base de données bien conçu. Tu as besoin de tableaux qui suivent l'XP de compétence individuelle, les scores de réputation par faction ou activité et les capacités débloquées. Le schéma doit être suffisamment normalisé pour être interrogeable, mais suffisamment dénormalisé pour éviter des jointures coûteuses à chaque contrôle de compétence. Nous utilisons un seul player_skills table avec une clé composite d'identification de citoyen et de nom de compétence, plus une clé distincte player_reputation table pour la position des factions. Cette séparation permet de distinguer les compétences (capacités personnelles) de la réputation (comment les PNJ et les factions tu perçoivent), ce qui permet des interactions de jeu plus nuancées.
-- SQL schema
CREATE TABLE IF NOT EXISTS player_skills (
citizenid VARCHAR(50) NOT NULL,
skill_name VARCHAR(50) NOT NULL,
xp INT DEFAULT 0,
level INT DEFAULT 1,
prestige INT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (citizenid, skill_name)
);
CREATE TABLE IF NOT EXISTS player_reputation (
citizenid VARCHAR(50) NOT NULL,
faction VARCHAR(50) NOT NULL,
reputation INT DEFAULT 0,
tier VARCHAR(20) DEFAULT 'neutral',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (citizenid, faction)
);
Calcul XP et courbes de nivellement
Une courbe XP plate où chaque niveau nécessite la même quantité d'expérience ne semble pas gratifiante, car les premiers niveaux passent trop vite tandis que les niveaux ultérieurs semblent identiques. La meilleure approche utilise une courbe polynomiale où chaque niveau nécessite progressivement plus d'XP, mais l'augmentation n'est pas si forte que le niveau maximum devienne inaccessible. Une formule courante est requiredXP = baseXP * (level ^ exponent) où une base de 100 et un exposant de 1,5 créent une courbe lisse. Le niveau 1 nécessite 100 XP, le niveau 10 nécessite environ 3 162 XP et le niveau 50 nécessite environ 35 355 XP. Cela permet de maintenir une progression rapide tout en donnant l'impression que les niveaux supérieurs sont de véritables réalisations qui nécessitent un jeu dédié pour être atteints.
-- shared/skills_config.lua
SkillsConfig = {}
SkillsConfig.BaseXP = 100
SkillsConfig.Exponent = 1.5
SkillsConfig.MaxLevel = 50
function SkillsConfig.GetRequiredXP(level)
return math.floor(SkillsConfig.BaseXP * (level ^ SkillsConfig.Exponent))
end
function SkillsConfig.GetTotalXPForLevel(targetLevel)
local total = 0
for i = 1, targetLevel - 1 do
total = total + SkillsConfig.GetRequiredXP(i)
end
return total
end
-- Skill definitions with XP sources
SkillsConfig.Skills = {
driving = {
label = 'Driving',
icon = 'fa-car',
xpSources = {
{ action = 'distance_driven', xpPer = 1, unit = 'per 500m' },
{ action = 'race_won', xpPer = 150, unit = 'per win' },
{ action = 'no_crash_streak', xpPer = 50, unit = 'per 5min' },
}
},
shooting = {
label = 'Marksmanship',
icon = 'fa-crosshairs',
xpSources = {
{ action = 'headshot', xpPer = 25, unit = 'per kill' },
{ action = 'bodyshot', xpPer = 10, unit = 'per kill' },
{ action = 'range_practice', xpPer = 5, unit = 'per target' },
}
},
mechanic = {
label = 'Mechanic',
icon = 'fa-wrench',
xpSources = {
{ action = 'vehicle_repaired', xpPer = 30, unit = 'per repair' },
{ action = 'engine_swap', xpPer = 100, unit = 'per swap' },
{ action = 'custom_tune', xpPer = 75, unit = 'per tune' },
}
},
cooking = {
label = 'Cooking',
icon = 'fa-utensils',
xpSources = {
{ action = 'meal_cooked', xpPer = 20, unit = 'per meal' },
{ action = 'recipe_discovered', xpPer = 50, unit = 'per recipe' },
}
},
stamina = {
label = 'Stamina',
icon = 'fa-running',
xpSources = {
{ action = 'distance_sprinted', xpPer = 1, unit = 'per 200m' },
{ action = 'swimming', xpPer = 2, unit = 'per 100m' },
}
},
}
Gestion XP côté serveur
Toutes les modifications XP doivent être effectuées côté serveur pour empêcher les exploits. Le serveur valide chaque demande d'octroi XP, vérifie la limitation du débit pour éviter le spam, applique tous les multiplicateurs actifs et conserve le résultat dans la base de données. Une décision de conception critique est de savoir s'il faut économiser sur chaque modification XP ou script par lots. Économiser sur chaque petit gain d'XP martèle la base de données, en particulier pour les compétences passives comme la distance de conduite. La solution est un cache en mémoire qui est vidé périodiquement dans la base de données, combiné à des sauvegardes immédiates pour les événements importants tels que les montées de niveau. Cela tu donne une précision en temps réel pour le joueur tout en gardant la charge de la base de données gérable.
-- server/skills_manager.lua
local playerCache = {}
local SAVE_INTERVAL = 60000 -- flush to DB every 60 seconds
-- Load player skills from DB on join
AddEventHandler('playerJoining', function()
local src = source
local citizenid = GetCitizenId(src)
if not citizenid then return end
local skills = MySQL.query.await('SELECT * FROM player_skills WHERE citizenid = ?', {citizenid})
playerCache[citizenid] = {}
for _, row in ipairs(skills or {}) do
playerCache[citizenid][row.skill_name] = {
xp = row.xp,
level = row.level,
prestige = row.prestige,
dirty = false
}
end
end)
function AddSkillXP(citizenid, skillName, amount)
if not SkillsConfig.Skills[skillName] then return end
if not playerCache[citizenid] then return end
local skill = playerCache[citizenid][skillName]
if not skill then
skill = { xp = 0, level = 1, prestige = 0, dirty = false }
playerCache[citizenid][skillName] = skill
end
-- Apply prestige multiplier (each prestige = +5% XP)
local multiplier = 1.0 + (skill.prestige * 0.05)
local finalXP = math.floor(amount * multiplier)
skill.xp = skill.xp + finalXP
skill.dirty = true
-- Check for level up
local required = SkillsConfig.GetRequiredXP(skill.level)
while skill.xp >= required and skill.level < SkillsConfig.MaxLevel do
skill.xp = skill.xp - required
skill.level = skill.level + 1
required = SkillsConfig.GetRequiredXP(skill.level)
-- Notify player of level up
local src = GetPlayerByCitizenId(citizenid)
if src then
TriggerClientEvent('skills:levelUp', src, skillName, skill.level)
end
-- Immediate save on level up
SaveSkill(citizenid, skillName, skill)
end
return skill
end
exports('AddSkillXP', AddSkillXP)
Arbres de compétences et capacités déblocables
Les arbres de compétences ajoutent une couche stratégique en plus du nivellement brut. Au lieu que tous les joueurs de niveau 20 en mécanique soient identiques, les arbres de compétences leur permettent de se spécialiser. Un mécanicien peut investir des points dans les performances du moteur, ouvrant ainsi la possibilité d'ajouter des kits turbo, tandis qu'un autre se spécialise dans la carrosserie, ayant accès à des peintures et des livrées exclusives. Chaque niveau accorde un point de compétence qui peut être dépensé dans un arbre ramifié. La structure arborescente utilise une simple relation parent-enfant dans laquelle le déverrouillage d'un nœud enfant nécessite à la fois le déverrouillage du parent et un niveau de compétence minimum. Cela empêche les joueurs de se précipiter vers les capacités les plus puissantes sans acquérir les compétences fondamentales.
-- shared/skill_trees.lua
SkillTrees = {
mechanic = {
{ id = 'basic_repair', label = 'Basic Repair', reqLevel = 1, parent = nil,
effect = { type = 'speed', value = 1.0 }, desc = 'Standard repair speed' },
{ id = 'fast_repair', label = 'Quick Hands', reqLevel = 5, parent = 'basic_repair',
effect = { type = 'speed', value = 1.3 }, desc = '30% faster repairs' },
{ id = 'engine_diag', label = 'Engine Diagnostics', reqLevel = 10, parent = 'basic_repair',
effect = { type = 'unlock', value = 'engine_scan' }, desc = 'Scan engine health remotely' },
{ id = 'turbo_kit', label = 'Turbo Installation', reqLevel = 20, parent = 'engine_diag',
effect = { type = 'unlock', value = 'install_turbo' }, desc = 'Install turbo kits on vehicles' },
{ id = 'master_tune', label = 'Master Tuner', reqLevel = 35, parent = 'turbo_kit',
effect = { type = 'unlock', value = 'advanced_tune' }, desc = 'Access to advanced ECU tuning' },
{ id = 'body_expert', label = 'Body Expert', reqLevel = 10, parent = 'basic_repair',
effect = { type = 'unlock', value = 'custom_paint' }, desc = 'Unlock exclusive paint options' },
{ id = 'livery_master', label = 'Livery Master', reqLevel = 25, parent = 'body_expert',
effect = { type = 'unlock', value = 'custom_livery' }, desc = 'Create and apply custom liveries' },
},
}
-- server: unlock a skill tree node
function UnlockNode(citizenid, skillName, nodeId)
local tree = SkillTrees[skillName]
if not tree then return false, 'No tree for skill' end
local node = nil
for _, n in ipairs(tree) do
if n.id == nodeId then node = n break end
end
if not node then return false, 'Node not found' end
local skill = GetPlayerSkill(citizenid, skillName)
if not skill or skill.level < node.reqLevel then
return false, 'Level too low'
end
if node.parent then
local parentUnlocked = IsNodeUnlocked(citizenid, skillName, node.parent)
if not parentUnlocked then return false, 'Parent node locked' end
end
MySQL.insert('INSERT INTO player_skill_nodes (citizenid, skill_name, node_id) VALUES (?, ?, ?)',
{citizenid, skillName, nodeId})
return true
end
Niveaux de réputation et statut de faction
La réputation suit la façon dont les organisations et les factions perçoivent le joueur, indépendamment des compétences brutes. Un joueur peut être un conducteur expert mais avoir une mauvaise réputation auprès de la faction policière en raison de ses activités criminelles. La réputation est organisée en niveaux allant d'hostile à neutre en passant par vénéré, chaque niveau débloquant différentes options de dialogue, accès aux missions et prix des fournisseurs. Gagner de la réputation auprès d'une faction peut la réduire auprès des factions opposées, créant ainsi des compromis significatifs. Par exemple, effectuer des livraisons de drogue augmente la réputation du cartel mais diminue la réputation de la police. Ce système encourage les joueurs à faire des choix qui définissent leur personnage plutôt que de maximiser chaque faction simultanément.
-- shared/reputation_config.lua
ReputationConfig = {
tiers = {
{ name = 'hostile', minRep = -1000, color = '#ef4444' },
{ name = 'unfriendly', minRep = -500, color = '#f97316' },
{ name = 'neutral', minRep = 0, color = '#94a3b8' },
{ name = 'friendly', minRep = 500, color = '#22c55e' },
{ name = 'honored', minRep = 1500, color = '#3b82f6' },
{ name = 'revered', minRep = 3000, color = '#a855f7' },
},
factions = {
police = { label = 'LSPD', opposing = {'cartel', 'gang_ballas'} },
ems = { label = 'EMS', opposing = {} },
cartel = { label = 'Madrazo Cartel', opposing = {'police'} },
mechanic = { label = 'LS Customs', opposing = {} },
gang_ballas = { label = 'Ballas', opposing = {'police', 'gang_families'} },
gang_families = { label = 'Families', opposing = {'gang_ballas'} },
},
opposingPenalty = 0.5, -- lose 50% of gained rep from opposing factions
}
-- server: modify reputation with faction cascading
function ModifyReputation(citizenid, faction, amount)
local config = ReputationConfig.factions[faction]
if not config then return end
-- Apply to primary faction
AdjustRep(citizenid, faction, amount)
-- Penalize opposing factions
if amount > 0 then
for _, opposing in ipairs(config.opposing) do
local penalty = math.floor(amount * ReputationConfig.opposingPenalty)
AdjustRep(citizenid, opposing, -penalty)
end
end
end
Le système Prestige
Une fois qu’un joueur atteint le niveau maximum dans une compétence, il a besoin d’une raison pour continuer à s’engager dans cette activité. Le système de prestige permet aux joueurs de réinitialiser une compétence au niveau 1 en échange de bonus permanents : un badge de prestige cosmétique affiché à côté de leur nom, un bonus d'XP de 5 % par niveau de prestige sur cette compétence et l'accès à des éléments à débloquer exclusifs au prestige comme des modifications de véhicule uniques ou des recettes d'artisanat rares. Le compteur de prestige est illimité, mais chaque prestige successif prend plus de temps car le boost d'XP rend les niveaux précédents triviaux tandis que la courbe rattrape toujours son retard aux niveaux supérieurs. Affichez le nombre de prestige sous forme de chiffres romains ou d'étoiles à côté du nom de la compétence dans l'interface utilisateur pour donner un indicateur visuel clair du dévouement d'un joueur.
-- server: prestige a maxed skill
function PrestigeSkill(citizenid, skillName)
local skill = playerCache[citizenid] and playerCache[citizenid][skillName]
if not skill then return false, 'Skill not found' end
if skill.level < SkillsConfig.MaxLevel then return false, 'Not max level' end
-- Reset level and XP, increment prestige
skill.level = 1
skill.xp = 0
skill.prestige = skill.prestige + 1
skill.dirty = true
-- Save immediately
SaveSkill(citizenid, skillName, skill)
-- Grant prestige reward
local src = GetPlayerByCitizenId(citizenid)
if src then
TriggerClientEvent('skills:prestige', src, skillName, skill.prestige)
-- Unlock prestige-specific items
if skill.prestige == 1 then
exports.ox_inventory:AddItem(src, 'prestige_badge_'..skillName, 1)
elseif skill.prestige == 5 then
exports.ox_inventory:AddItem(src, 'gold_tool_'..skillName, 1)
end
end
return true, skill.prestige
end
RegisterNetEvent('skills:requestPrestige', function(skillName)
local src = source
local citizenid = GetCitizenId(src)
local success, result = PrestigeSkill(citizenid, skillName)
if success then
lib.notify(src, { title = 'Prestige!', description = ('Prestige %d achieved for %s'):format(result, skillName), type = 'success' })
else
lib.notify(src, { title = 'Cannot Prestige', description = result, type = 'error' })
end
end)
Interface utilisateur côté client et suivi XP passif
Le client gère le suivi XP passif pour des activités telles que la distance parcourue et le sprint, en envoyant des mises à jour périodiques au serveur. Une erreur courante consiste à envoyer un événement à chaque mètre parcouru, ce qui inonde le réseau. Au lieu de cela, accumulez la distance dans une variable locale et envoyez une mise à jour par lots toutes les 30 secondes. Pour l'interface utilisateur, créez un panneau de compétences accessible via une commande ou un raccourci clavier qui affiche toutes les compétences avec leur niveau actuel, la barre de progression XP, le nombre de prestige et l'arbre de compétences. Utilisez NUI avec un framework moderne comme React ou Vue pour la visualisation de l'arborescence, affichant les nœuds verrouillés sous forme de nœuds grisés et déverrouillés avec un effet lumineux. La barre de progression devrait s'animer en douceur lorsque de l'XP est gagnée, donnant un retour visuel satisfaisant qui renforce la boucle de progression et maintient les joueurs motivés pour continuer à développer leur personnage.

