System reputacji w FiveM: statystyki, umiejętności i progresja
Dodaj reputację i progresję umiejętności do FiveM. Formuły statystyk, odblokowania, paski UI i najlepsze skrypty reputacji, aby utrzymać graczy przy grindzie na dłużej.
Agency Scripts
Założyciel i główny deweloper w Agency Scripts
Dlaczego systemy reputacji odmieniają Roleplay
Większość serwerów FiveM traktuje progresję postaci binarnie: albo masz pracę, albo nie. Brakuje w tym poczucia rozwoju, mistrzostwa czy wypracowanego statusu. System reputacji i umiejętności zmienia to diametralnie, dając graczom mierzalną progresję, która nagradza poświęcony czas i ukończone aktywności. Kiedy mechanik naprawia swój setny pojazd i odblokowuje zaawansowany tuning silnika, to osiągnięcie daje poczucie zasłużonego sukcesu. Kiedy przestępca buduje wystarczającą reputację na ulicy, by zyskać dostęp do zaawansowanego sprzętu do napadów, pojawia się namacalne poczucie progresji napędzające rozgrywkę. W tym poradniku zbudujemy od podstaw kompletny framework reputacji i umiejętności, obejmujący obliczenia XP, drzewka umiejętności, poziomy reputacji, umiejętności do odblokowania oraz system prestiżu, który angażuje graczy na etapie endgame.
Schemat bazy danych dla umiejętności graczy
Podstawą każdego systemu progresji jest dobrze zaprojektowany schemat bazy danych. Potrzebujesz tabel śledzących punkty XP poszczególnych umiejętności, punkty reputacji dla danej frakcji lub aktywności oraz odblokowane zdolności. Schemat powinien być na tyle znormalizowany, aby można było wykonywać zapytania, ale jednocześnie na tyle zdenormalizowany, aby uniknąć kosztownych złączeń przy każdym sprawdzaniu umiejętności. Używamy pojedynczej player_skills tabela ze złożonym kluczem składającym się z citizen ID i nazwy umiejętności, a także oddzielna player_reputation tabela dla reputacji we frakcjach. Ten podział oddziela umiejętności (zdolności osobiste) od reputacji (sposobu, w jaki postrzegają Cię NPC i frakcje), co pozwala na bardziej zróżnicowane interakcje w rozgrywce.
-- 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)
);
Obliczanie XP i krzywe poziomów
Płaska krzywa XP, w której każdy poziom wymaga tyle samo doświadczenia, jest mało satysfakcjonująca, ponieważ początkowe poziomy mija się zbyt szybko, a późniejsze wydają się identyczne. Najlepsze podejście wykorzystuje krzywą wielomianową, w której każdy poziom wymaga stopniowo więcej punktów XP, ale wzrost nie jest na tyle stromy, by maksymalny poziom stał się nieosiągalny. Powszechną formułą jest requiredXP = baseXP * (level ^ exponent) gdzie wartość bazowa 100 i wykładnik 1.5 tworzą łagodną krzywą. Poziom 1 wymaga 100 XP, poziom 10 wymaga około 3,162 XP, a poziom 50 w przybliżeniu 35,355 XP. Zapewnia to dynamiczny początkowy postęp, sprawiając jednocześnie, że wyższe poziomy są odczuwalne jako prawdziwe osiągnięcia, których zdobycie wymaga zaangażowania w grę.
-- 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' },
}
},
}
Zarządzanie XP po stronie serwera
Wszystkie modyfikacje XP muszą odbywać się po stronie serwera, aby zapobiec exploitom. Serwer weryfikuje każde żądanie przyznania XP, sprawdza rate limiting, aby zapobiec spamowi, stosuje aktywne mnożniki i zapisuje wynik w bazie danych. Kluczową decyzją projektową jest to, czy zapisywać przy każdej zmianie XP, czy grupować operacje zapisu. Zapisywanie przy każdym drobnym przyroście XP mocno obciąża bazę danych, szczególnie w przypadku umiejętności pasywnych, takich jak przejechany dystans. Rozwiązaniem jest pamięć podręczna w RAM, która okresowo zrzuca dane do bazy danych, połączona z natychmiastowym zapisem przy istotnych zdarzeniach, takich jak awans na wyższy poziom. Zapewnia to graczowi dokładność w czasie rzeczywistym przy jednoczesnym zachowaniu optymalnego obciążenia bazy danych.
-- 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)
Drzewka umiejętności i odblokowywane zdolności
Drzewka umiejętności dodają element strategii do zwykłego awansowania na kolejne poziomy. Zamiast sytuacji, w której każdy gracz na 20. poziomie mechanika jest identyczny, drzewka umiejętności pozwalają im się specjalizować. Jeden mechanik może zainwestować punkty w osiągi silnika, odblokowując możliwość montażu turbosprężarek, podczas gdy inny wyspecjalizuje się w blacharstwie, zyskując dostęp do ekskluzywnych lakierów i oklein. Każdy poziom daje jeden punkt umiejętności do wydania w rozgałęzionym drzewku. Struktura drzewka opiera się na prostej relacji nadrzędny-podrzędny, w której odblokowanie węzła podrzędnego wymaga zarówno odblokowania węzła nadrzędnego, jak i minimalnego poziomu umiejętności. Zapobiega to szybkiemu odblokowywaniu najpotężniejszych zdolności bez wcześniejszego zdobycia podstawowych kompetencji.
-- 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
Poziomy reputacji i relacje z frakcjami
Reputacja określa, jak organizacje i frakcje postrzegają gracza, niezależnie od samych umiejętności. Gracz może być wybitnym kierowcą, ale mieć fatalne relacje z policją z powodu działalności przestępczej. Reputacja jest podzielona na poziomy: od wrogiego przez neutralny aż po szanowany, a każdy z nich odblokowuje inne opcje dialogowe, dostęp do misji oraz ceny u handlarzy. Zyskiwanie reputacji u jednej frakcji może obniżać ją u frakcji przeciwnej, tworząc istotne kompromisy. Na przykład dostarczanie narkotyków zwiększa reputację w kartelu, ale obniża reputację w policji. System ten zachęca graczy do podejmowania wyborów definiujących ich postać, zamiast maksymalizowania każdej frakcji jednocześnie.
-- 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
System prestiżu
Gdy gracz osiągnie maksymalny poziom danej umiejętności, potrzebuje powodu, by dalej się w nią angażować. Zapewnia to system prestiżu, umożliwiając zresetowanie umiejętności z powrotem do poziomu 1 w zamian za stałe bonusy: kosmetyczną odznakę prestiżu wyświetlaną obok nicku, 5% premii do XP na poziom prestiżu w danej umiejętności oraz dostęp do unikalnych nagród prestiżu, takich jak rzadkie modyfikacje pojazdów czy unikalne receptury craftingu. Licznik prestiżu jest nieograniczony, ale każdy kolejny poziom zajmuje więcej czasu, ponieważ premia do XP sprawia, że wczesne poziomy są banalne, podczas gdy krzywa trudności dogania gracza na wyższych poziomach. Wyświetlaj liczbę prestiży jako cyfry rzymskie lub gwiazdki obok nazwy umiejętności w UI, aby dać czytelny wizualny wskaźnik zaangażowania gracza.
-- 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 po stronie klienta i pasywne śledzenie XP
Klient obsługuje pasywne śledzenie XP za czynności takie jak przebyty dystans autem czy sprint, wysyłając cykliczne aktualizacje na serwer. Częstym błędem jest wysyłanie zdarzenia po każdym przebytym metrze, co zalewa sieć pakietami. Zamiast tego kumuluj dystans w zmiennej lokalnej i wysyłaj zbiorczą aktualizację co 30 sekund. W przypadku UI zbuduj panel umiejętności dostępny za pomocą komendy lub keybinda, który pokazuje wszystkie skille z ich aktualnym poziomem, paskiem postępu XP, licznikiem prestiżu oraz drzewkiem umiejętności. Użyj NUI z nowoczesnym frameworkiem, takim jak React lub Vue, do wizualizacji drzewka, pokazując zablokowane węzły jako wyszarzone, a odblokowane z efektem poświaty. Pasek postępu powinien płynnie animować się po zdobyciu XP, dając satysfakcjonujący feedback wizualny, który wzmacnia pętlę progresji i motywuje graczy do dalszego rozwijania postaci.