Lua client et réseau
Le Lua tourne à deux endroits : sur le serveur (les règles) et chez chaque joueur (l'affichage). Cette page explique le côté client, le dessin du HUD, et comment les deux côtés communiquent.
Serveur et client
| Serveur | Client | |
|---|---|---|
| Où | Chez l'hôte de la partie | Chez chaque joueur (l'hôte aussi) |
| Rôle | Règles, santé, props, équipes, scores | HUD, messages à l'écran |
| Fichiers d'un gamemode | shared.lua, init.lua, server/, entities/ | shared.lua, cl_init.lua, client/, entities/, effects/ (effets Lua) |
| Fichiers d'un addon | lua/autorun/, lua/autorun/server/, lua/entities/, lua/*.lua | lua/autorun/, lua/autorun/client/, lua/entities/, lua/effects/ |
| Test | if SERVER then … end | if CLIENT then … end |
Les fichiers partagés tournent des deux côtés : c'est là qu'on déclare les équipes et les entités. Les scripts client sont envoyés par le serveur aux joueurs qui rejoignent, ils n'ont donc rien à installer. Ils tournent dans le même bac à sable que les scripts serveur (pas d'accès au système ; seulement un dossier de données propre, voir Données et réglages).
Les autres fichiers d'un addon (dans un sous-dossier de lua/) ne sont lancés que par include ; pour qu'un client puisse les inclure, le serveur doit les marquer avec AddCSLuaFile.
Pour des fenêtres, des boutons et des listes, ou pour réagir aux touches, voir Menus, clavier et souris.
Les fonctions marquées serveur dans l'API n'existent pas côté client, et inversement (par exemple ply:SetHealth est serveur, LocalPlayer() est client).
Dessiner le HUD
GM:HUDPaint() (ou hook.Add("HUDPaint", …)) est appelé à chaque image ; on y dessine avec draw.* et surface.*. Les coordonnées sont en pixels, (0, 0) en haut à gauche, jusqu'à ScrW(), ScrH().
function GM:HUDPaint()
local ply = LocalPlayer()
draw.RoundedBox(8, ScrW() - 220, ScrH() - 90, 200, 60, Color(0, 0, 0, 160))
draw.SimpleText("Points : " .. ply:Frags(), "Big", ScrW() - 120, ScrH() - 60, Color(255, 220, 80), TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER)
surface.SetDrawColor(Color(255, 255, 255, 120))
surface.DrawOutlinedRect(20, 20, 160, 12, 2)
surface.DrawRect(22, 22, 156 * ply:Health() / 100, 8)
end
| Fonction | Rôle |
|---|---|
draw.SimpleText(texte, police, x, y, couleur, alignX, alignY) | Texte avec contour. Polices : "Default" (18), "Small" (14), "Bold" (18), "Big" (32), "Title" (44) ou une taille en nombre. Alignements : TEXT_ALIGN_LEFT, CENTER, RIGHT (horizontal), TEXT_ALIGN_TOP, CENTER, BOTTOM (vertical). |
draw.RoundedBox(rayon, x, y, l, h, couleur) | Rectangle à coins arrondis. |
draw.GetFontHeight(police) | Hauteur d'une ligne. |
surface.SetDrawColor(couleur) | Couleur des fonctions surface.* suivantes (ou r, g, b, a). |
surface.DrawRect, DrawOutlinedRect, DrawLine, DrawCircle | Rectangle, contour, ligne, disque. |
surface.CreateFont, surface.DrawText, Material, surface.DrawTexturedRect… | Polices personnelles et images : voir Polices et Images. |
Pour placer un texte au-dessus d'un joueur (vec:ToScreen()), dessiner des panneaux dans le monde (cam.Start3D2D), entourer des objets (halo.Add), dessiner faisceaux et sprites, créer des particules et des effets Lua (EFFECT), allumer des lumières dynamiques ou teinter l'écran, voir Rendu 3D et effets.
Cacher des parties du HUD
function GM:HUDShouldDraw(name)
if name == "Crosshair" then return false end
return true
end
Noms : "Health" (santé et armure en bas à gauche), "Weapon" (en bas à droite : nom de l'arme, munitions du chargeur et de réserve avec une barre par balle, rechargement ; rien pour une arme sans munitions comme le physgun, le toolgun ou le pied-de-biche), "Damage" (bords rouges quand on est blessé ou que la santé est basse, et arc rouge autour du viseur vers celui qui a tiré), "WeaponSelection" (sélecteur d'armes en haut, ouvert à la molette ou par les touches d'arme), "Voice" (chat vocal : les autres joueurs qui parlent en haut à gauche, ton micro ouvert en bas à droite), "Crosshair" (viseur), "PropOwner" (à droite du viseur : refus de la protection des props).
Noms (suite) : "Scoreboard" (tableau des scores du jeu, voir plus bas). SWEP.DrawCrosshair = false cache aussi le viseur.
Messages
chat.AddText(…): une ligne dans le chat de ce joueur seulement, en couleurs (voir Chat en couleurs).notification.AddLegacy(texte, type, durée): une notification en haut à droite, pendantduréesecondes (3 par défaut, de 0,5 à 60).type(NOTIFY_GENERIC… dans GMod) est ignoré ; ces constantes ne sont pas définies, passe0.notification.AddProgress(id, texte, fraction): une notification qui reste jusqu'ànotification.Kill(id). RappelerAddProgressavec le mêmeidchange son texte au lieu d'en ajouter une ;fraction(0 à 1, facultative) ajoute un pourcentage (« Chargement 40 % »).notification.Kill(id): fait disparaître la notificationid.
-- Une notification de progression pendant 5 secondes.
local debut = CurTime()
timer.Create("telechargement", 0.25, 20, function()
local f = math.min((CurTime() - debut) / 5, 1)
notification.AddProgress("dl", "Téléchargement des données", f)
if f >= 1 then
notification.Kill("dl")
notification.AddLegacy("Données prêtes", 0, 4)
end
end)
Tailles relatives à l'écran
ScreenScale(n) renvoie n × ScrW() / 640 et ScreenScaleH(n) renvoie n × ScrH() / 480, comme dans GMod : pratique pour garder les proportions d'un HUD écrit pour une résolution de 640 × 480 (draw.RoundedBox(4, ScreenScale(10), ScreenScaleH(10), ScreenScale(100), ScreenScaleH(20), …)).
Ouvrir un lien : gui.OpenURL
gui.OpenURL(url) propose d'ouvrir une adresse http:// ou https:// dans le navigateur du joueur : une fenêtre « Ouvrir un lien » montre l'adresse, et rien ne s'ouvre si le joueur choisit Annuler. Les autres adresses sont refusées (erreur). Les liens des panneaux DHTML passent aussi par là.
Chat en couleurs
chat.AddText(…) accepte autant d'arguments qu'on veut : une Color change la couleur de la suite, un joueur affiche son nom (couleur de son équipe, sinon sa couleur), le reste est affiché comme texte (nil est ignoré). 64 morceaux et environ 1000 caractères au plus. La couleur de départ est le bleu clair des messages système.
Le hook client OnPlayerChat(ply, texte, équipe, mort) est appelé pour chaque ligne de chat d'un joueur (après PlayerSay sur le serveur) ; renvoyer true cache la ligne par défaut. ply peut être nil (joueur parti), équipe vaut toujours false, mort indique que le joueur est mort.
-- cl_init.lua : les morts parlent en gris, préfixe d'équipe en couleur.
hook.Add("OnPlayerChat", "chat_couleurs", function(ply, texte, equipe, mort)
if not IsValid(ply) then return end
if mort then
chat.AddText(Color(150, 150, 150), "*MORT* ", ply, Color(180, 180, 180), " : " .. texte)
else
chat.AddText(team.GetColor(ply:Team()), "[" .. team.GetName(ply:Team()) .. "] ", ply, Color(255, 255, 255), " : " .. texte)
end
return true
end)
-- Annonce locale.
chat.AddText(Color(255, 80, 80), "Attention ! ", Color(255, 255, 255), "La manche commence dans ", Color(255, 220, 0), 10, Color(255, 255, 255), " secondes.")
Tableau des scores
Le gamemode peut remplacer le tableau des scores du jeu. Hooks client : ScoreboardShow() quand la touche est enfoncée (renvoyer true : celui du jeu ne s'ouvre pas, et la souris reste capturée) et ScoreboardHide() quand elle est relâchée (toujours appelé). HUDShouldDraw("Scoreboard") qui renvoie false cache le tableau du jeu sans en afficher d'autre.
-- cl_init.lua : tableau des scores du gamemode.
local montrer = false
function GM:ScoreboardShow()
montrer = true
return true
end
function GM:ScoreboardHide()
montrer = false
end
hook.Add("HUDPaint", "mon_tableau", function()
if not montrer then return end
local joueurs = player.GetAll()
local w, h = 420, 60 + #joueurs * 28
local x, y = ScrW() / 2 - w / 2, ScrH() / 2 - h / 2
draw.RoundedBox(8, x, y, w, h, Color(0, 0, 0, 200))
draw.SimpleText("Mon mode — " .. #joueurs .. " joueurs", "Bold", x + w / 2, y + 20, Color(255, 220, 0), 1, 1)
for i, ply in ipairs(joueurs) do
local ligne = y + 40 + (i - 1) * 28
draw.SimpleText(ply:Nick(), "Default", x + 20, ligne, team.GetColor(ply:Team()))
draw.SimpleText(ply:Frags() .. " / " .. ply:Deaths(), "Default", x + w - 20, ligne, Color(255, 255, 255), 2)
end
end)
Fil des éliminations
Le jeu affiche en haut à droite un fil des morts, comme GMod : tueur [icône] victime, les noms dans la couleur de leur équipe, 6 lignes au plus, chacune 6 secondes. L'icône désigne l'inflictor : la classe de l'arme que le tueur avait en main ("pistol", une classe SWEP…), sinon la cause de la mort : "fall", "explosion", "burn", "crush", "drown", "suicide", "generic". Un PNJ tueur apparaît sous son nom. Le message du chat (PlayerDeathNotice) reste à part.
Icônes : killicon
| Fonction | Rôle |
|---|---|
killicon.Add(classe, matériau, couleur) | Icône image (chemin comme pour Material), 32 px de haut, teintée par couleur (blanc par défaut) |
killicon.AddFont(classe, police, symbole, couleur) | Icône texte : symbole écrit avec la police (orange par défaut). killicon.AddTexCode fait la même chose |
killicon.AddAlias(classe, autre) | classe utilise l'icône de autre |
killicon.Exists(classe), killicon.Get(classe) | Icône déclarée ? Sa table (material ou font / symbol, color) |
killicon.GetSize(classe) | Deux valeurs : largeur, hauteur en pixels |
killicon.Draw(x, y, classe, alpha) | Dessine l'icône centrée sur x, le haut à y (dans HUDPaint). Classe sans icône : son nom entre crochets |
Les causes du jeu ont une icône texte par défaut ([chute], [explosion], [feu], [écrasé], [noyé], [suicide], [mort]) ; un killicon.Add ou AddFont sur le même nom la remplace.
Remplacer le fil : AddDeathNotice et DrawDeathNotice
- Hook client
AddDeathNotice(tueur, équipeTueur, inflictor, victime, équipeVictime), à chaque mort d'un joueur. Les noms sont du texte (tueurvaut""s'il n'y en a pas, son équipe-1). Si un hook renvoie une valeur, ou si le gamemode définitGM:AddDeathNotice, la ligne n'est pas ajoutée au fil du jeu : à toi de l'afficher. - Hook client
DrawDeathNotice(x, y), à chaque image où le fil du jeu a des lignes, avec sa position en fractions de l'écran (le coin haut droit du fil). Renvoyer une valeur cache le fil du jeu pour cette image.
-- cl_init.lua : icônes des armes du gamemode, et le fil en bas à gauche.
killicon.Add("laser_pistol", "mon_mode/icones/laser.png", Color(255, 80, 80))
killicon.AddFont("pied_de_biche", "Bold", "[PdB]", Color(255, 200, 80))
killicon.AddAlias("laser_rifle", "laser_pistol")
local morts = {}
hook.Add("AddDeathNotice", "mon_fil", function(tueur, equipeTueur, arme, victime, equipeVictime)
table.insert(morts, 1, { tueur = tueur, arme = arme, victime = victime, fin = CurTime() + 8 })
return true -- pas de ligne dans le fil du jeu
end)
hook.Add("HUDPaint", "mon_fil", function()
for i = #morts, 1, -1 do
if morts[i].fin < CurTime() then table.remove(morts, i) end
end
for i, m in ipairs(morts) do
local y = ScrH() - 200 - i * 34
draw.SimpleText(m.tueur ~= "" and m.tueur or "?", "Default", 120, y + 10, Color(255, 255, 255), TEXT_ALIGN_RIGHT)
killicon.Draw(160, y, m.arme, 255)
draw.SimpleText(m.victime, "Default", 200, y + 10, Color(255, 120, 120))
end
end)
Commandes client
concommand.Add marche aussi côté client : la commande tourne sur l'ordinateur du joueur, sans passer par le serveur (ouvrir un menu, changer un réglage d'affichage). Le joueur la tape dans le chat comme une commande serveur : /nom arguments. Le jeu essaie d'abord les commandes client ; si aucune ne porte ce nom, la ligne part au serveur.
concommand.Add(nom, fonction, autoComplete, aide, flags) : fonction(ply, args, texte, nom) (ply = LocalPlayer(), args = les mots, texte = les arguments en une ligne). Une fonction écrite à la manière de GMod, function(ply, cmd, args, texte) (2e paramètre nommé cmd, command…), reçoit cet ordre-là. flags est ignoré. Aussi concommand.Remove(nom), concommand.Run(ply, nom, args, texte) et concommand.GetTable() (deux tables : commandes, fonctions d'autocomplétion). Détails des deux côtés : API Lua.
Touche Tab dans le chat, sur une ligne qui commence par / : complète le nom d'une commande client (plusieurs possibilités : elles sont listées dans le chat). Après le nom et une espace, Tab appelle autoComplete(nom, texteArguments), qui renvoie une liste de lignes complètes ("nom argument", 64 au plus) ; s'il n'y en a qu'une, elle remplace la ligne.
-- cl_init.lua : /theme sombre|clair|neon, avec complétion par Tab.
local themes = { "sombre", "clair", "neon" }
concommand.Add("theme", function(ply, args)
local choix = args[1]
if not table.HasValue(themes, choix) then
chat.AddText(Color(255, 80, 80), "Thèmes : " .. table.concat(themes, ", "))
return
end
THEME_HUD = choix -- lu par le HUDPaint du gamemode
chat.AddText(Color(120, 255, 120), "Thème : " .. choix)
end, function(cmd, texte)
local debut = string.Trim(texte):lower()
local lignes = {}
for _, t in ipairs(themes) do
if t:sub(1, #debut) == debut then lignes[#lignes + 1] = cmd .. " " .. t end
end
return lignes
end, "Change le thème du HUD")
Caméra et actions
Remplacer la caméra : CalcView
Le hook client CalcView(ply, origine, angles, fov, znear, zfar) est appelé à chaque image avec la caméra prévue. Renvoie une table { origin = Vector, angles = Angle, fov = nombre, drawviewer = booléen } (champs facultatifs) pour la remplacer ; ne rien renvoyer garde la caméra normale. drawviewer = true affiche son propre corps.
-- Vue de dessus façon jeu d'arcade.
hook.Add("CalcView", "vue_dessus", function(ply, pos, ang, fov)
return { origin = ply:GetPos() + Vector(0, 15, 0), angles = Angle(-89, 0, 0), fov = 70, drawviewer = true }
end)
Côté serveur, ply:SetEyeAngles(ang) tourne la vue d'un joueur et ply:SetThirdPerson(bool) force la 3e personne (voir Objets).
Bloquer une action : PlayerBindPress
Le hook client PlayerBindPress(ply, action, appuyé) est appelé quand le joueur appuie sur une action du jeu ("jump", "attack", "use", "spawn_menu", "flashlight", "scoreboard"…). Renvoie true pour la bloquer. Liste complète : PlayerBindPress.
-- Pas de saut pendant la préparation.
hook.Add("PlayerBindPress", "pas_de_saut", function(ply, action, appuye)
if action == "jump" and GetGlobalBool("preparation") then return true end
end)
Pour réagir aux actions sans les bloquer, des deux côtés : hooks KeyPress / KeyRelease et ply:KeyDown(IN_…).
Changer le déplacement : SetupMove
Chaque joueur calcule son propre déplacement : les hooks StartCommand, CreateMove, SetupMove, Move et FinishMove tournent donc côté client, pour LocalPlayer(), à chaque image physique (seulement s'ils sont utilisés). Ils reçoivent un CUserCmd et un CMoveData pour changer la vitesse, la position, le saut ou la vue. Mets-les dans un fichier partagé : quand le serveur a lui aussi un de ces hooks, son anti-triche ne prend plus un jetpack ou un grappin pour de la triche (il vérifie encore les murs). Exemple de jetpack : Déplacement du joueur.
Dans ces hooks, cmd:GetMouseX() et cmd:GetMouseY() donnent le mouvement de la souris (en pixels) depuis la commande précédente, quand la souris tourne la caméra (0 côté serveur, ou quand la souris n'est pas capturée par la vue, menu ouvert par exemple) ; cmd:SetMouseX(n) / SetMouseY(n) changent la valeur lue ensuite dans cette commande (sans effet sur la caméra).
Précharger les modèles
La première fois qu'un modèle 3D est affiché (icône du menu de spawn, apparition), le jeu lit son fichier et l'envoie à la carte graphique : avec un modèle très détaillé, le jeu fige un instant. Un addon peut charger tous les modèles en avance, derrière un écran de chargement, comme les addons « precache » de GMod. Ces fonctions n'existent que côté client.
| Fonction | Rôle |
|---|---|
util.GetModelList() | Tous les modèles de la partie : props et véhicules du jeu, puis ceux des addons activés (Workshop compris). Chaque entrée : id, name, category, type ("prop", "vehicle" ou "weapon" pour les armes d'addon), addon (titre de l'addon, "PhysiCore" pour le jeu), builtin, precached. |
util.PrecacheModel(id, fonction) | Charge le modèle en arrière-plan (lecture du fichier dans un autre fil, sans figer le jeu), puis génère son icône du menu de spawn. Appelle ensuite fonction(ok, id). Les demandes passent une par une, dans l'ordre. Renvoie false si l'id est inconnu ici. |
util.IsModelPrecached(id) | true si le modèle est déjà préchargé. Il le reste jusqu'à ce que les addons changent (activation, téléchargement Workshop). |
Le hook client ClientReady est le bon moment pour commencer : la map, les addons du serveur et les scripts sont chargés. Exemple minimal (un vrai écran de chargement est dans l'addon « Préchargement des modèles ») :
hook.Add("ClientReady", "monaddon_preload", function()
local liste = util.GetModelList()
local fait = 0
for _, modele in ipairs(liste) do
util.PrecacheModel(modele.id, function(ok)
fait = fait + 1
if fait == #liste then
notification.AddLegacy("Modèles chargés")
end
end)
end
end)
Pour un écran plein écran qui bloque le joueur pendant le chargement : un DPanel de la taille de l'écran avec MakePopup() et une fonction Paint (voir Menus, clavier et souris).
Variables réseau
Le moyen le plus simple de partager un état : le serveur écrit, tout le monde lit. Les valeurs sont envoyées aux joueurs (y compris ceux qui arrivent plus tard).
| Serveur (écriture) | Partout (lecture) |
|---|---|
SetGlobalInt(nom, v), SetGlobalFloat, SetGlobalBool, SetGlobalString, SetGlobalVector | GetGlobalInt(nom, défaut)… |
ent:SetNWInt(nom, v)… sur un joueur ou une entité | ent:GetNWInt(nom, défaut)… |
-- serveur
ply:SetNWString("role", "chasseur")
-- client
draw.SimpleText("Rôle : " .. LocalPlayer():GetNWString("role", "aucun"), "Default", 20, 80, Color(255, 255, 255))
Les équipes, points et morts des joueurs sont des variables réseau (ply:Team(), ply:Frags(), ply:Deaths()), tout comme les scores d'équipe.
CurTime() côté client suit l'horloge du serveur : un SetGlobalFloat("fin", CurTime() + 60) du serveur donne un chrono juste chez tous les joueurs.Messages net
Pour un événement ponctuel (une annonce, une action du joueur), on envoie un message : on le commence avec net.Start, on y écrit des valeurs, on l'envoie ; l'autre côté le reçoit avec net.Receive et lit les valeurs dans le même ordre.
-- serveur (init.lua)
hook.Add("PlayerDeath", "annonce_mort", function(ply, cause)
net.Start("mort")
net.WriteEntity(ply)
net.WriteString(cause)
net.Broadcast()
end)
-- client (cl_init.lua)
net.Receive("mort", function(len)
local ply = net.ReadEntity()
local cause = net.ReadString()
if IsValid(ply) then
notification.AddLegacy(ply:Name() .. " " .. cause)
end
end)
| Fonction | Côté |
|---|---|
net.Start(nom) | partagé |
net.WriteString, WriteInt, WriteFloat, WriteBool, WriteVector, WriteColor, WriteEntity | partagé |
net.ReadString()… (mêmes types) | partagé |
net.WriteTable(t) / net.ReadTable() | partagé : une table entière (voir ci-dessous) |
net.Receive(nom, function(taille, ply) … end) | partagé (ply = l'expéditeur, côté serveur) |
net.Send(ply ou table de joueurs), net.Broadcast() | serveur |
net.SendToServer() | client |
net.WriteTable(t) envoie une table avec ses sous-tables (16 niveaux au plus) : clés et valeurs nombres, textes, booléens, Vector, Color et entités (les fonctions et autres valeurs sont laissées de côté). Elle voyage en morceaux de texte de 4000 caractères, 250 au plus (environ 1 Mo) ; chaque morceau compte dans la limite de valeurs du message. net.ReadTable() la relit (une table vide si elle est invalide), sans jamais exécuter de code.