Données et réglages

Garder des informations d'une partie à l'autre (scores, argent, niveaux) avec file et le JSON, et donner à l'hôte des réglages qu'il peut changer sans toucher au code (durée de manche, limite de props…) avec les ConVars.

Sauvegarder des fichiers : file

Chaque gamemode et chaque addon a son propre dossier de données. Un script ne voit que celui de son gamemode ou de son addon : il ne peut ni lire les données des autres, ni sortir de son dossier.

CôtéDossier
Serveur, partie hébergée depuis le jeulua_data/<dossier>/ dans les données du jeu (%APPDATA%\Godot\app_userdata\PhysiCore\)
Serveur dédiélua_data/<dossier>/ à côté de server.cfg
Client (chaque joueur)lua_data_client/<dossier>/ dans les données du jeu

<dossier> vaut gamemode_<nom> pour un gamemode (ex. gamemode_sandbox) et l'identifiant de l'addon pour un addon : local_<nom du dossier> pour un addon local, ws_<numéro> pour un addon du Workshop (qui garde donc ses données après une mise à jour).

FonctionRenvoie
file.Write(nom, texte)true si écrit (remplace le fichier)
file.Append(nom, texte)Ajoute à la fin
file.Read(nom)Le texte, ou nil si le fichier n'existe pas
file.Exists(nom), file.IsDir(nom)Existe ? Est-ce un dossier ?
file.Delete(nom)Supprime un fichier (ou un dossier vide)
file.CreateDir(nom)Crée un sous-dossier
file.Find(motif)Deux tables, comme GMod : les fichiers puis les dossiers dont le nom correspond, ex. local fichiers, dossiers = file.Find("joueurs/*")
file.Open(nom, mode)Un objet File pour lire ou écrire par morceaux (texte ou binaire), ou nil
file.AsyncRead(nom, chemin, rappel)Lit le fichier et appelle rappel(nom, chemin, statut, données) à l'image suivante (voir plus bas)
file.Size(nom), file.Time(nom)Taille en octets (-1 si absent), date de modification (secondes Unix)

Règles : noms en minuscules (a-z, chiffres, _, -, .), au plus 4 sous-dossiers (joueurs/76561198000000000.json), extensions .txt, .json, .dat ou .csv pour écrire. Limites : 1 Mio par fichier, 16 Mio et 512 fichiers par dossier. Un nom refusé ou une limite dépassée renvoie false et écrit l'erreur dans la console.

Serveur ou client ?Ce qui compte (argent, niveaux) se sauvegarde côté serveur : les fichiers du client sont sur l'ordinateur du joueur, qui peut les modifier. Côté client, garde seulement des préférences d'affichage.

Tables en JSON : util

util.TableToJSON(table, joli) transforme une table en texte JSON (joli = true pour l'indenter), util.JSONToTable(texte) fait l'inverse (nil si le texte est invalide). Les Vector deviennent "[x y z]" et les Color "{r g b a}", et redeviennent des vecteurs et des couleurs à la lecture. Les nombres entiers restent entiers (3, pas 3.0). Les fonctions sont ignorées.

-- Serveur : argent des joueurs, gardé entre les parties.
local function chemin(ply)
	return "argent/" .. ply:SteamID() .. ".json"
end

hook.Add("PlayerInitialSpawn", "argent_charger", function(ply)
	local data = util.JSONToTable(file.Read(chemin(ply)) or "") or {}
	ply:SetNWInt("argent", data.argent or 100)
end)

local function sauver(ply)
	file.Write(chemin(ply), util.TableToJSON({ argent = ply:GetNWInt("argent") }))
end

hook.Add("PlayerDisconnected", "argent_sauver", sauver)
timer.Create("argent_sauvegarde", 60, 0, function()
	for _, ply in ipairs(player.GetAll()) do sauver(ply) end
end)

En solo et sans Steam, ply:SteamID() vaut "0" : tous les joueurs hors ligne partagent alors le même fichier.

Fichiers ouverts : file.Open

file.Open(nom, mode) ouvre un fichier du dossier de données et renvoie un objet File (ou nil si le nom est refusé, si le fichier à lire n'existe pas ou si une limite est atteinte). Le troisième argument ("DATA"…) est accepté et ignoré, comme pour les autres fonctions file.* : c'est toujours ton dossier.

ModeEffet
"r", "rb"Lecture.
"w", "wb"Écriture : crée ou vide le fichier.
"a", "ab"Ajout à la fin du fichier existant.
MéthodeDescription
f:Read(n)n octets lus comme du texte UTF-8 (sans n : tout le reste) ; nil à la fin.
f:ReadLine()La ligne suivante, "\n" compris ; nil à la fin.
f:ReadByte(), f:ReadBool()Un octet (0 à 255), un booléen (1 octet).
f:ReadShort(), f:ReadUShort()Entier 16 bits signé, non signé.
f:ReadLong(), f:ReadULong()Entier 32 bits signé, non signé.
f:ReadUInt64()Entier 64 bits, renvoyé en texte (un SteamID64 tient dedans).
f:ReadFloat(), f:ReadDouble()Nombre à virgule 32 / 64 bits.
f:Write(texte)Écrit le texte (UTF-8).
f:WriteByte, WriteBool, WriteShort, WriteUShort, WriteLong, WriteULong, WriteUInt64(texte), WriteFloat, WriteDoubleÉcrivent les mêmes types que les lectures.
f:Seek(pos), f:Skip(n), f:Tell()Aller à l'octet pos, avancer de n octets, position actuelle.
f:Size(), f:EndOfFile()Taille en octets, fin du fichier atteinte ?
f:Flush(), f:Close()Écrire sur le disque, fermer.

Les nombres sont écrits en petit-boutiste (little endian). Mêmes règles et limites que file.Write (noms, extensions .txt / .json / .dat / .csv pour écrire, 1 Mio par fichier, 16 Mio et 512 fichiers par dossier). 32 fichiers ouverts au plus : pense à f:Close() ; les fichiers encore ouverts sont fermés quand le Lua s'arrête.

-- Serveur : positions enregistrées en binaire.
local f = file.Open("positions.dat", "wb")
if f then
	f:WriteUShort(#player.GetAll())
	for _, ply in ipairs(player.GetAll()) do
		local pos = ply:GetPos()
		f:WriteFloat(pos.x) f:WriteFloat(pos.y) f:WriteFloat(pos.z)
	end
	f:Close()
end

local f = file.Open("positions.dat", "rb")
if f then
	for i = 1, f:ReadUShort() do
		print(i, Vector(f:ReadFloat(), f:ReadFloat(), f:ReadFloat()))
	end
	f:Close()
end

file.AsyncRead(nom, chemin, rappel) lit le fichier tout de suite et appelle rappel(nom, chemin, statut, données) à l'image suivante : statut vaut FSASYNC_OK (0) et données le texte, ou FSASYNC_ERR_FILEOPEN (-1) et nil si le fichier n'existe pas. La fonction renvoie aussi ce statut.

file.AsyncRead("config.json", "DATA", function(nom, chemin, statut, donnees)
	if statut ~= FSASYNC_OK then return end
	PrintTable(util.JSONToTable(donnees) or {})
end)

Base de données : sql

Une base SQLite, comme la bibliothèque sql de GMod. Le serveur utilise sv.db (dans les données du jeu pour une partie hébergée, à côté de server.cfg pour un serveur dédié) ; chaque joueur a sa propre cl.db côté client. Tous les gamemodes et addons partagent la même base : préfixe le nom de tes tables (monaddon_scores).

FonctionRenvoie
sql.Query(requête)Les lignes ({ { colonne = "valeur", … }, … }), nil s'il n'y en a aucune, false en cas d'erreur
sql.QueryRow(requête, n)La ligne n (1 par défaut), nil ou false
sql.QueryValue(requête)La première valeur de la première ligne
sql.SQLStr(texte, sansGuillemets)Le texte échappé entre apostrophes ('l''eau'), à utiliser pour chaque valeur venant d'un joueur
sql.TableExists(nom), sql.IndexExists(nom)La table, l'index existe-t-il ?
sql.Begin(), sql.Commit()Regroupe beaucoup d'écritures en une transaction (bien plus rapide)
sql.LastError()Message de la dernière erreur, ou nil
sql.IsAvailable()L'extension SQLite est-elle chargée ?

Comme dans GMod, toutes les valeurs reviennent en texte (tonumber pour les nombres) et NULL devient le texte "NULL". Pour la sécurité, ATTACH, DETACH, VACUUM INTO et load_extension sont refusés (ils pourraient lire ou écrire d'autres fichiers) ; une requête fait 1 Mo au plus. SQLite vient de l'extension addons/godot-sqlite (licence MIT), fournie pour Windows et Linux : ailleurs, sql.IsAvailable() vaut false.

-- Serveur : meilleurs scores.
if not sql.TableExists("monaddon_scores") then
	sql.Query("CREATE TABLE monaddon_scores ( steamid TEXT PRIMARY KEY, nom TEXT, points INTEGER )")
end

local function enregistrer(ply, points)
	sql.Query("REPLACE INTO monaddon_scores VALUES ( " .. sql.SQLStr(ply:SteamID()) .. ", "
		.. sql.SQLStr(ply:Nick()) .. ", " .. math.floor(points) .. " )")
end

local function top10()
	local lignes = sql.Query("SELECT nom, points FROM monaddon_scores ORDER BY points DESC LIMIT 10")
	if lignes == false then print("Erreur SQL :", sql.LastError()) return {} end
	return lignes or {}
end

Données par joueur : PData

ply:SetPData(clé, valeur), ply:GetPData(clé, défaut) et ply:RemovePData(clé) gardent une valeur (texte) par joueur dans la table playerpdata de la base (créée au besoin), d'une partie à l'autre. Le joueur est reconnu par son SteamID ; sans Steam (SteamID 0), par "nom:<pseudo>". Les mêmes avec un identifiant au choix : util.SetPData(id, clé, valeur), util.GetPData(id, clé, défaut), util.RemovePData(id, clé). Côté client, c'est la base cl.db du joueur.

-- Serveur : nombre de connexions de chaque joueur.
hook.Add("PlayerInitialSpawn", "compte_visites", function(ply)
	local visites = tonumber(ply:GetPData("visites", 0)) + 1
	ply:SetPData("visites", visites)
	ply:ChatPrint("Bienvenue pour la " .. visites .. "e fois !")
end)

Requêtes web : http

Désactivées par défaut. L'hôte les autorise avec la ConVar sv_allow_http 1 (allow_http dans la section [security] de server.cfg), et peut les limiter à des domaines avec sv_http_whitelist ("api.exemple.fr,exemple.org" : ces domaines et leurs sous-domaines ; vide = tous). Le réglage du serveur vaut aussi pour le Lua client des joueurs. Les adresses du réseau local (localhost, 127.x, 10.x, 192.168.x, 172.16-31.x…) sont toujours refusées. Limites : 8 requêtes en même temps, 4 Mo par réponse, 30 s.

FonctionRôle
http.Fetch(url, succès, échec, entêtes)GET. succès(corps, taille, entêtes, code), échec(raison).
http.Post(url, champs, succès, échec, entêtes)POST d'un formulaire (champs : table texte → texte).
HTTP({ url, method, parameters, headers, body, type, success, failed })Requête complète (GET, POST, PUT, DELETE, PATCH, HEAD) ; success(code, corps, entêtes). Renvoie false si elle est refusée tout de suite (et appelle failed).

Les réponses sont du texte (UTF-8). Une requête refusée (HTTP désactivé, domaine non autorisé, trop de requêtes) appelle la fonction d'échec avec la raison.

-- Serveur : envoyer un message sur un webhook quand un joueur arrive.
hook.Add("PlayerInitialSpawn", "webhook", function(ply)
	HTTP({
		url = "https://exemple.fr/webhook",
		method = "POST",
		type = "application/json",
		body = util.TableToJSON({ content = ply:Nick() .. " a rejoint le serveur" }),
		failed = function(raison) print("Webhook :", raison) end,
	})
end)

Pour jouer un son téléchargé : sound.PlayURL.

Réglages du serveur : ConVars

Une ConVar est une valeur nommée que l'hôte et les admins peuvent changer pendant la partie. Le gamemode la crée et la lit ; la valeur est envoyée à tous les joueurs.

-- shared.lua (ou un fichier serveur) d'un gamemode
local duree = CreateConVar("cache_duree_manche", 180, FCVAR_ARCHIVE + FCVAR_NOTIFY,
	"Durée d'une manche (secondes)", 30, 900)

-- Serveur : au début de la manche
timer.Create("manche", duree:GetInt(), 1, finir_manche)

CreateConVar(nom, défaut, drapeaux, aide, min, max)

Crée (ou retrouve) une ConVar et renvoie son objet. Le nom est en minuscules (a-z, chiffres, _, .), 64 caractères au plus. Avec min et max, la valeur est un nombre gardé entre les deux ; min = 0 et max = 1 en font une case à cocher dans le panneau des réglages. Côté client, CreateConVar renvoie le même objet en lecture seule (avec défaut tant que la valeur du serveur n'est pas arrivée).

DrapeauEffet
FCVAR_ARCHIVELa valeur est sauvegardée (convars.cfg) et reprise aux parties suivantes
FCVAR_NOTIFYAnnonce les changements dans le chat (« X a réglé … sur … »)
FCVAR_REPLICATEDAccepté pour Garry's Mod : toutes les ConVars du serveur sont déjà envoyées aux joueurs
FCVAR_PROTECTEDValeur cachée (pas envoyée aux joueurs, masquée dans les listes) : mots de passe, clés
FCVAR_NONEAucun (0)

Combine-les avec +. Les ConVars d'un gamemode sont oubliées quand une autre partie commence (les valeurs archivées restent dans convars.cfg) ; un rechargement des scripts les garde.

L'objet ConVar

MéthodeRenvoie
cvar:GetInt(), GetFloat(), GetBool(), GetString()La valeur actuelle
cvar:GetDefault(), GetName(), GetHelpText(), GetMin(), GetMax()Infos
cvar:SetInt(n), SetFloat, SetBool, SetString, Revert()Change la valeur (serveur ; côté client, seulement une ConVar client)

Aussi : GetConVar(nom) (nil si inconnue), GetConVarNumber(nom), GetConVarString(nom), et RunConsoleCommand(nom, valeur) qui change une ConVar (côté serveur, une commande concommand sinon).

Réagir à un changement

cvars.AddChangeCallback(nom, function(nom, ancienne, nouvelle) … end, identifiant) est appelée des deux côtés quand la valeur change (valeurs en texte). cvars.RemoveChangeCallback(nom, identifiant) la retire.

if SERVER then
	cvars.AddChangeCallback("cache_duree_manche", function(nom, ancienne, nouvelle)
		PrintMessage("Les prochaines manches dureront " .. nouvelle .. " secondes.")
	end, "annonce")
end

Changer une ConVar en jeu

ConVars intégrées

Elles reflètent les réglages de server.cfg : les changer modifie et enregistre aussi le réglage.

ConVarRéglage
sbox_maxpropsProps max par joueur (0 à 2000)
sbox_noclipNoclip autorisé, si le gamemode l'autorise (0 ou 1, au prochain respawn)
sbox_playershurtplayersDégâts entre joueurs, lu par les gamemodes (0 ou 1)
sv_props_per_secondProps max par seconde et par joueur (1 à 100)
sv_rp_movementDéplacements réalistes pour le roleplay (0 ou 1) : inertie, pas de bunny hop, ralenti à l'atterrissage, plus lent en montée, à reculer et de côté
sv_player_pushLes joueurs poussent les props et se bousculent en marchant (0 ou 1)
sv_anticheatAnti-triche des déplacements (0 ou 1, 1 par défaut) : vitesse, vol, noclip interdit, murs traversés. Voir Sécurité
sv_force_fogBrouillard imposé (0 ou 1) : l'option Brouillard des joueurs est ignorée, tout le monde voit le brouillard de la carte (et celui des scripts)
sv_nametag_distanceDistance (m) jusqu'à laquelle on voit le pseudo au-dessus d'un joueur (10 par défaut, 0 = jamais)
sv_allow_httpRequêtes web du Lua autorisées (0 ou 1, 0 par défaut). Voir Requêtes web
sv_http_whitelistDomaines web autorisés, séparés par des virgules (vide = tous)
sv_allowcsluaTout joueur peut lancer du Lua sur son propre jeu avec /lua_run_cl (0 ou 1, 0 par défaut)

ConVars client

CreateClientConVar(nom, défaut, sauver, _, aide, min, max) (client) crée une préférence propre au joueur, gardée sur son ordinateur si sauver vaut true (par défaut) dans lua_client_convars.cfg. Change-la avec cvar:SetBool(…) ou RunConsoleCommand(nom, valeur). Par défaut, le serveur ne la voit pas.

-- cl_init.lua : une option « afficher le chrono » cochable dans un menu.
local chrono = CreateClientConVar("cache_afficher_chrono", 1, true, false, "Afficher le chrono")

local case = vgui.Create("DCheckBoxLabel", fenetre)
case:SetText("Afficher le chrono")
case:SetChecked(chrono:GetBool())
case.OnChange = function(self, coche) chrono:SetBool(coche) end

Préférences envoyées au serveur (userinfo)

Avec true en 4e argument, la ConVar client est « userinfo » : sa valeur est envoyée au serveur à la création et à chaque changement (64 par joueur, 256 caractères). Le serveur la lit avec ply:GetInfo(nom) (texte, "" si absente) ou ply:GetInfoNum(nom, défaut). Côté client, ces deux méthodes lisent sa propre ConVar client.

-- cl_init.lua
CreateClientConVar("monmode_arme_favorite", "pistol", true, true, "Arme donnée à l'apparition")

-- init.lua
function GM:PlayerLoadout(ply)
	local arme = ply:GetInfo("monmode_arme_favorite")
	if arme == "pistol" or arme == "shotgun" then ply:Give(arme) else ply:Give("crowbar") end
end
Valeur choisie par le joueurUn joueur peut mettre n'importe quoi dans une ConVar client : vérifie toujours la valeur côté serveur, comme ci-dessus.