Sistema de reputación para FiveM: estadísticas, habilidades y progresión
Añade reputación y progresión de habilidades a FiveM. Consulta fórmulas, desbloqueos, barras de interfaz y scripts para motivar a los jugadores.
Agency Scripts
Fundador y desarrollador principal en Agency Scripts
Por qué los sistemas de reputación transforman el roleplay
La mayoría de los servidores FiveM tratan la progresión de los personajes como algo binario: tienes un trabajo o no lo tienes. No hay una sensación de crecimiento, dominio o estatus ganado. Un sistema de reputación y habilidades cambia esto radicalmente al ofrecer a los jugadores una progresión cuantificable que recompensa el tiempo invertido y las actividades completadas. Cuando un mecánico repara su vehículo número cien y desbloquea la puesta a punto avanzada del motor, ese logro se siente merecido. Cuando un criminal acumula suficiente reputación callejera para acceder a equipo de atraco de alto nivel, siente de forma tangible una progresión que impulsa su juego. En esta guía, crearemos desde cero un sistema completo de reputación y habilidades que abarcará los cálculos de XP, los árboles de habilidades, los niveles de reputación, las habilidades desbloqueables y un sistema de prestigio que mantenga interesados a los jugadores veteranos.
Esquema de base de datos para las habilidades de los jugadores
La base de cualquier sistema de progresión es un esquema de base de datos bien diseñado. Necesitas tablas para registrar la XP de cada habilidad, las puntuaciones de reputación por facción o actividad y las habilidades desbloqueadas. El esquema debe estar suficientemente normalizado para permitir consultas, pero también lo bastante desnormalizado para evitar uniones costosas en cada comprobación de habilidad. Usamos una sola player_skills tabla con una clave compuesta de ID de citizen y nombre de habilidad, además de una player_reputation tabla independiente para la reputación con cada facción. Esta separación mantiene las habilidades (la capacidad personal) diferenciadas de la reputación (cómo te perciben los NPC y las facciones), lo que permite interacciones de juego más variadas.
-- 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)
);
Cálculo de XP y curvas de nivel
Una curva de XP plana, en la que cada nivel requiere la misma cantidad de experiencia, resulta poco gratificante porque los primeros niveles se superan demasiado rápido, mientras que los posteriores se sienten iguales. El mejor enfoque utiliza una curva polinómica en la que cada nivel requiere progresivamente más XP, pero el aumento no es tan pronunciado como para que alcanzar el nivel máximo sea imposible. Una fórmula habitual es requiredXP = baseXP * (level ^ exponent) donde una base de 100 y un exponente de 1.5 crean una curva gradual. El nivel 1 requiere 100 XP, el nivel 10 requiere unos 3,162 XP y el nivel 50 requiere aproximadamente 35,355 XP. Así, la progresión inicial es ágil, mientras que los niveles más altos se sienten como logros reales que requieren dedicación para alcanzarlos.
-- 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' },
}
},
}
Gestión de XP en el servidor
Todas las modificaciones de XP deben realizarse en el servidor para evitar exploits. El servidor valida cada solicitud de otorgamiento de XP, comprueba que se apliquen límites de frecuencia para evitar el spam, aplica los multiplicadores activos y guarda el resultado en la base de datos. Una decisión de diseño fundamental es si guardar cada cambio de XP o agrupar las escrituras. Guardar cada pequeña ganancia de XP sobrecarga la base de datos, especialmente con habilidades pasivas como la distancia recorrida al conducir. La solución es una caché en memoria que se vuelca periódicamente a la base de datos, junto con guardados inmediatos para eventos importantes, como subir de nivel. Esto te da precisión en tiempo real para el jugador y mantiene la carga de la base de datos bajo control.
-- 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)
Árboles de habilidades y habilidades desbloqueables
Los árboles de habilidades añaden una capa estratégica a la progresión de niveles. En lugar de que todos los jugadores con nivel 20 en mecánica sean idénticos, los árboles de habilidades les permiten especializarse. Un mecánico podría invertir puntos en el rendimiento del motor y desbloquear la capacidad de instalar kits de turbo, mientras que otro se especializa en carrocería y obtiene acceso a pinturas y diseños exclusivos. Cada nivel otorga un punto de habilidad que se puede gastar en un árbol ramificado. La estructura del árbol utiliza una relación sencilla entre nodos padre e hijo: para desbloquear un nodo hijo, el nodo padre debe estar desbloqueado y hay que tener un nivel mínimo de habilidad. Esto evita que los jugadores se apresuren a conseguir las habilidades más poderosas sin desarrollar antes una competencia básica.
-- 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
Niveles de reputación y prestigio ante las facciones
La reputación refleja cómo perciben al jugador las organizaciones y facciones, y es independiente de su habilidad. Un jugador puede ser un conductor experto, pero tener una reputación pésima con la facción policial por sus actividades criminales. La reputación se organiza en niveles que van de hostil a neutral y hasta venerado, y cada nivel desbloquea distintas opciones de diálogo, acceso a misiones y precios de vendedores. Ganar reputación con una facción puede reducirla con facciones rivales, lo que genera decisiones con consecuencias. Por ejemplo, completar entregas de drogas aumenta la reputación con el cartel, pero reduce la reputación con la policía. Este sistema anima a los jugadores a tomar decisiones que definan a sus personajes, en lugar de alcanzar la reputación máxima con todas las facciones a la vez.
-- 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
El sistema de prestigio
Cuando un jugador alcanza el nivel máximo de una habilidad, necesita una razón para seguir participando en esa actividad. El sistema de prestigio se la da, ya que permite restablecer una habilidad al nivel 1 a cambio de bonificaciones permanentes: una insignia cosmética de prestigio junto al nombre, un aumento del 5% de XP por cada nivel de prestigio en esa habilidad y acceso a desbloqueos exclusivos de prestigio, como modificaciones de vehículos únicas o recetas de fabricación raras. El contador de prestigio no tiene límite, pero cada prestigio sucesivo tarda más porque el aumento de XP hace que los primeros niveles sean triviales, mientras que la curva vuelve a ser exigente en los niveles altos. Muestra el contador de prestigio con números romanos o estrellas junto al nombre de la habilidad en la UI para indicar claramente la dedicación del jugador.
-- 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)
UI del cliente y seguimiento de XP pasivo
El cliente realiza el seguimiento pasivo de XP para actividades como conducir cierta distancia y correr, y envía actualizaciones periódicas al servidor. Un error habitual es enviar un evento por cada metro recorrido, lo que satura la red. En su lugar, acumula la distancia en una variable local y envía una actualización por lotes cada 30 segundos. Para la UI, crea un panel de habilidades accesible mediante un comando o una tecla asignada, que muestre todas las habilidades con su nivel actual, la barra de progreso de XP, el contador de prestigio y el árbol de habilidades. Usa NUI con un framework moderno como React o Vue para visualizar el árbol: muestra los nodos bloqueados en gris y los desbloqueados con un efecto de brillo. La barra de progreso debería animarse suavemente cuando se gana XP, ofreciendo una respuesta visual satisfactoria que refuerce el ciclo de progresión y mantenga a los jugadores motivados para seguir desarrollando a sus personajes.