Entités scriptées

Des objets du monde qui ont leur propre comportement en Lua : un bouton, une pièce à ramasser, une zone de capture, un distributeur… Comme les SENT de Garry's Mod.

Déclarer une classe

Une entité se définit dans un fichier entities/<classe>.lua d'un gamemode (ou lua/entities/<classe>.lua d'un addon). Le nom du fichier est le nom de la classe. Pendant le chargement du fichier, la table ENT représente la classe :

-- entities/distributeur.lua
ENT.PrintName = "Distributeur"
ENT.Model = "builtin:crate_metal"
ENT.Physics = "static"          -- apparaît figé
ENT.PhysgunDisabled = true      -- le physgun ne peut pas l'attraper

if SERVER then
	function ENT:Initialize()
		self:SetNWInt("stock", 5)
	end

	function ENT:Use(ply)
		local stock = self:GetNWInt("stock", 0)
		if stock <= 0 then
			ply:ChatPrint("Distributeur vide.")
			return
		end
		self:SetNWInt("stock", stock - 1)
		ents.SpawnProp("builtin:ball", self:GetPos() + Vector(0, 1, 0))
	end
end

Ces fichiers sont partagés : ils sont chargés côté serveur et côté client. Mets les fonctions serveur dans if SERVER then … end.

Champs

ChampRôle
ENT.ModelModèle (identifiant de prop, par exemple "builtin:crate" ou un modèle de pack). Par défaut, une petite caisse.
ENT.Physics"static" : l'entité apparaît figée. Sinon elle est physique.
ENT.PhysgunDisabledtrue : le physgun ne l'attrape pas (géré par GM:PhysgunPickup du gamemode Base).
ENT.AlwaysActivetrue : une entité posée dans la map ne s'endort jamais, même sans joueur autour (voir Proximité réseau).
ENT.PrintName, autres champsLibres : tu peux ranger tes propres réglages dans la classe.
ENT.Type"anim" (par défaut) : un prop normal. "point" : entité logique, invisible, sans collision et figée (minuterie, gestionnaire de manche…). "brush" : zone invisible, traversable et figée qui reçoit StartTouch / Touch / EndTouch (voir Zones de contact). "filter" se comporte comme "point". "nextbot" : un NextBot.
ENT.BaseClasse dont celle-ci hérite (ses fonctions et champs). "base_nextbot" fait de la classe un NextBot (PNJ dont l'IA est écrite en Lua).
ENT.Spawnable, ENT.Categorytrue : dans l'onglet Entités du menu de spawn, rangée dans Category (« Autres » par défaut) sous son PrintName.
ENT.AdminOnlytrue : seuls les admins la font apparaître depuis le menu de spawn ou la recollent depuis une dupe.
ENT.DisableDuplicatortrue : laissée de côté quand on colle une dupe (sauf classe enregistrée avec duplicator.RegisterEntityClass).

Fonctions appelées par le jeu (serveur)

FonctionQuand
ENT:Initialize()Juste après ent:Spawn().
ENT:Use(ply)Un joueur appuie sur E en la visant (à moins de 4 m).
ENT:StartTouch(ent)Un joueur, un prop (débris compris) ou un PNJ entre dans sa zone de contact (voir Zones de contact).
ENT:Touch(ent)À chaque image physique, pour chaque objet encore dans la zone.
ENT:EndTouch(ent)L'objet sort de la zone.
ENT:Think()Des deux côtés, à chaque image physique (60 fois par seconde) : garde-la légère. self:NextThink(CurTime() + s) saute les appels suivants jusqu'à ce moment (chaque côté a le sien). Côté client, seulement si la classe définit Think. Une entité de la map figée loin de tous les joueurs ne pense plus (voir Proximité réseau).
ENT:OnRemove()Juste avant sa suppression.
ENT:PhysicsCollide(data)Elle heurte un prop ou un joueur (au plus toutes les 0,05 s). Voir Réagir aux chocs.
ENT:OnTakeDamage(dmginfo)Elle reçoit des dégâts : balle, coup, projectile ou explosion d'une arme, util.BlastDamage, ou ent:TakeDamage(montant, attaquant, inflicteur). Appelée après le hook EntityTakeDamage, sauf si un hook a renvoyé true. dmginfo a les méthodes de celui de ScalePlayerDamage : GetDamage(), GetAttacker(), GetInflictor() (l'id de l'arme, texte), GetDamageType(), IsDamageType(DMG_…), IsBulletDamage(), IsExplosionDamage()… (DMG_BULLET pour les balles, DMG_CLUB pour les armes de corps à corps, DMG_BLAST pour les explosions, DMG_GENERIC pour ent:TakeDamage). Le jeu ne retire pas de points de vie tout seul : à toi de les compter avec SetHealth / Health (exemple ci-dessous).
ENT:KeyValue(clé, valeur)Pour chaque propriété réglée dans l'éditeur de map, avant Initialize, puis à chaque ent:SetKeyValue.
ENT:SpawnFunction(ply, tr, classe)Le joueur la fait apparaître depuis l'onglet Entités : crée l'entité où il vise (tr : trace du regard) et la renvoie. Sans cette fonction, le jeu la pose lui-même. self est la table de la classe.
ENT:PreEntityCopy()Avant que duplicator.Copy la lise.
ENT:SetupDataTables()Pour chaque entité, des deux côtés : déclarer ses NetworkVar (voir plus bas).
ENT:UpdateTransmitState()Après Initialize : TRANSMIT_NEVER cache l'entité aux joueurs (voir plus bas).
ENT:PostEntityPaste(ply, ent, créées)Elle vient d'être recréée par le collage d'une dupe (créées : toutes les entités de la dupe).

Dans ces fonctions, self est l'entité : elle a toutes les méthodes des entités (self:GetPos(), self:Remove(), self:SetNWInt()…) et tu peux y stocker tes propres champs (self.compteur = 0).

-- entities/caisse_fragile.lua : cassée après 50 points de dégâts.
ENT.Model = "builtin:crate"

if SERVER then
	function ENT:Initialize()
		self:SetMaxHealth(50)
		self:SetHealth(50)
	end

	function ENT:OnTakeDamage(dmg)
		self:SetHealth(self:Health() - dmg:GetDamage())
		if self:Health() <= 0 then
			local attaquant = dmg:GetAttacker()
			if IsValid(attaquant) and attaquant:IsPlayer() then
				attaquant:ChatPrint("Caisse détruite (" .. tostring(dmg:GetInflictor()) .. ")")
			end
			self:Remove()
		end
	end
end

-- ailleurs, côté serveur : 20 points de dégâts sans arme
-- caisse:TakeDamage(20, ply, "piege")

Santé

Chaque entité (prop, entité scriptée, véhicule) a une santé enregistrée et envoyée à tous les joueurs : ent:Health() et ent:GetMaxHealth() des deux côtés (0 par défaut), ent:SetHealth(n) et ent:SetMaxHealth(n) côté serveur (nombres entiers). C'est une simple valeur : le jeu ne la baisse pas quand l'entité prend des dégâts et ne la supprime pas à 0. C'est ENT:OnTakeDamage (ou le hook EntityTakeDamage) qui décide. Règle-la dans ENT:Initialize : avant Spawn, elle n'est pas gardée.

Zones de contact et déclencheurs

Côté serveur, chaque entité scriptée dont la classe définit StartTouch, Touch ou EndTouch (ou qui est un déclencheur, SetTrigger) a une zone de contact : par défaut la boîte de son modèle agrandie de 10 cm. Les joueurs, props (débris compris) et PNJ qui y entrent lancent ENT:StartTouch(ent), puis ENT:Touch(ent) à chaque image physique tant qu'ils y sont, et ENT:EndTouch(ent) quand ils en sortent. Le décor de la map ne compte pas.

MéthodeCôtéRôle
ent:SetCollisionBounds(mins, maxs)serveurRemplace la zone par une boîte de coins mins et maxs, dans le repère de l'entité (mètres, ±500 au plus). Changer la zone lance EndTouch pour ce qui était dedans ; les objets encore là rentrent aussitôt.
ent:GetCollisionBounds()partagéDeux Vector : les bornes réglées, sinon la boîte du modèle.
ent:SetTrigger(bool) / ent:GetTrigger()serveur / partagéMarque l'entité comme déclencheur (c'est le cas des entités ENT.Type = "brush"). Les entités scriptées ont une zone de contact dans tous les cas : pour une zone que l'on traverse, rends-la aussi non solide (SetSolid(SOLID_NONE), voir Forme et solidité).
-- entities/zone_soin.lua : une zone invisible de 4 × 3 × 4 m qui soigne.
ENT.Type = "brush"

if SERVER then
	function ENT:Initialize()
		self:SetCollisionBounds(Vector(-2, 0, -2), Vector(2, 3, 2))
	end

	function ENT:StartTouch(ent)
		if ent:IsPlayer() then ent:ChatPrint("Zone de soin") end
	end

	function ENT:Touch(ent)
		if ent:IsPlayer() and ent:Health() < 100 then ent:SetHealth(ent:Health() + 1) end
	end

	function ENT:Think()
		self:NextThink(CurTime() + 1) -- inutile ici d'y passer 60 fois par seconde
	end
end

Les collisions elles-mêmes (forme, solidité, frottement, rebond, collisions au cas par cas avec ShouldCollide) sont dans Monde et physique.

Créer une entité

-- serveur
local ent = ents.Create("distributeur")
ent:SetPos(Vector(0, 0.5, 5))
ent:SetAngles(Vector(0, 90, 0))
ent:Spawn()

Entre ents.Create et Spawn, on peut régler SetPos, SetAngles, SetColor et SetModel. Une entité scriptée est un prop réseau comme les autres : elle apparaît chez tous les joueurs, et ents.FindByClass("distributeur") la retrouve.

Elle a toutes les méthodes de physique des entités (vitesse, forces, groupe de collision, SetParent…) : voir Monde et physique.

Le hook OnEntityCreated(ent) est appelé des deux côtés pour chaque prop, véhicule ou entité scriptée qui apparaît, à l'image suivante (l'entité est alors prête : modèle, classe, position). Une entité scriptée y est déjà passée par ENT:Initialize.

Ragdolls

Côté serveur, ents.Create("prop_ragdoll") prépare un corps qui tombe en ragdoll dès son apparition. Avant Spawn, on règle SetModel (un playermodel "addon:<clé>/playermodels/<fichier>.json", sinon le mannequin), SetPos et SetAngles (seul le yaw compte). Après Spawn, il a les méthodes des PNJ (GetPos, IsValid, Remove…) et GetClass() renvoie "prop_ragdoll".

-- serveur : un corps tombe devant le joueur.
local corps = ents.Create("prop_ragdoll")
corps:SetPos(ply:GetPos() + ply:GetForward() * 3 + Vector(0, 1, 0))
corps:Spawn()
timer.Simple(30, function() if IsValid(corps) then corps:Remove() end end)

Pour faire tomber un joueur sans le tuer : ply:CreateRagdoll().

Menu de spawn et dupes

Avec ENT.Spawnable = true, l'entité apparaît dans l'onglet Entités du menu de spawn ; le clic passe par le hook PlayerSpawnSENT, puis ENT:SpawnFunction (ou la pose par défaut, sur la surface visée, tournée vers le joueur). Elle appartient au joueur et s'annule avec Annuler.

Dans une dupe, une entité scriptée est recréée en Lua : par la fonction de duplicator.RegisterEntityClass si sa classe en a une (ses champs nommés passent dans la dupe), sinon par une duplication générique (modèle, position, angles, couleur), sauf avec ENT.DisableDuplicator. Tout est expliqué, avec un exemple complet, dans Intégration au bac à sable.

Dessin, réseau et modèles client

Variables réseau typées : SetupDataTables et NetworkVar

Si la classe a une fonction ENT:SetupDataTables(), elle est appelée pour chaque entité de la classe, des deux côtés. Dedans, self:NetworkVar(type, numéro, nom) (ou self:NetworkVar(type, nom)) crée self:Get<nom>() et self:Set<nom>(valeur). La valeur est rangée dans la variable réseau "DT_<nom>" : changée par le serveur, elle est envoyée à tous les joueurs. Types : "String", "Bool", "Float", "Double", "Int", "UInt", "Vector", "Angle", "Entity". Le numéro est ignoré (gardé pour les scripts de GMod).

self:NetworkVarNotify(nom, fonction) appelle fonction(ent, nom, ancienne, nouvelle) à chaque Set<nom>, juste avant le changement, dans le realm qui l'appelle (pas quand la valeur arrive par le réseau). Côté client, Set<nom> ne change rien : il lance seulement ces fonctions.

-- lua/entities/compteur.lua
ENT.PrintName = "Compteur"
ENT.Spawnable = true

function ENT:SetupDataTables()
	self:NetworkVar("Int", 0, "Clics")
	self:NetworkVar("Entity", 1, "Dernier")
	if SERVER then
		self:NetworkVarNotify("Clics", function(ent, nom, avant, apres)
			if apres >= 10 then PrintMessage("Le compteur a atteint 10 !") end
		end)
	end
end

if SERVER then
	function ENT:Use(ply)
		self:SetClics(self:GetClics() + 1)
		self:SetDernier(ply)
	end
end

Dessiner soi-même : ENT:Draw

Côté client, si la classe a ENT:Draw() ou ENT:DrawTranslucent(), elles sont appelées à chaque image pour chaque entité de la classe, et le modèle n'est affiché que si l'une d'elles appelle self:DrawModel() pendant cette image. Sans ces fonctions, le modèle est affiché normalement. Pour écrire au-dessus d'une entité, utilise plutôt cam.Start3D2D dans le hook PostDrawOpaqueRenderables (il ne marche pas dans ENT:Draw).

-- Partagé : le compteur n'est visible que de près (15 m), et clignote après 10 clics.
if CLIENT then
	function ENT:Draw()
		if LocalPlayer():GetPos():Distance(self:GetPos()) > 15 then return end
		if self:GetClics() >= 10 and math.floor(CurTime() * 4) % 2 == 0 then return end
		self:DrawModel()
	end
end

Entités du serveur seulement : UpdateTransmitState

Côté serveur, juste après ENT:Initialize, le jeu appelle ENT:UpdateTransmitState() si la classe l'a. Si elle renvoie TRANSMIT_NEVER, l'entité est invisible et sans collision chez les joueurs connectés (l'hôte d'une partie hébergée, qui est le serveur, la voit toujours) : pratique pour une entité logique (minuterie, zone, gestionnaire de manche). TRANSMIT_ALWAYS et TRANSMIT_PVS laissent l'entité normale.

function ENT:UpdateTransmitState() return TRANSMIT_NEVER end

Modèles client : ents.CreateClientProp

Côté client, ents.CreateClientProp(modèle) crée un modèle que seul ce joueur voit, sans physique (512 au plus). Il apparaît après :Spawn(). Méthodes : SetModel / GetModel, SetPos / GetPos, SetAngles / GetAngles, SetColor / GetColor, SetNoDraw / GetNoDraw, SetModelScale / GetModelScale (0,01 à 50), SetParent(ent) (suit un prop, un PNJ ou un joueur en gardant l'écart actuel : place-le d'abord, après Spawn), DrawModel, Remove, IsValid, GetClass ("class C_PhysPropClientside"). ClientsideModel existe toujours, avec ses propres méthodes.

Supprimer sans erreur

SafeRemoveEntity(ent) supprime l'entité si elle existe encore (rien sinon) ; SafeRemoveEntityDelayed(ent, délai) fait de même après délai secondes.

Os, points d'attache, animations, sous-matériaux, mode de rendu, feu et désintégration : voir Os, animations et effets.

Placer une entité dans une map

Dans l'éditeur de map (onglet Entités), le bouton Entité scriptée et la liste des classes posent une entité qui sera créée au lancement de la map, avec un nom et des propriétés clé = valeur. La classe les reçoit dans ENT:KeyValue avant ENT:Initialize :

if SERVER then
	function ENT:KeyValue(key, value)
		if key == "stock" then self.stock = tonumber(value) or 0 end
	end
end

En Lua : ent:GetName(), ent:GetKeyValue(clé), ent:GetKeyValues(), ent:SetKeyValue(clé, valeur) (serveur) et ents.FindByName(nom). Voir Points, zones et entités de map.

Proximité réseau

Une map peut contenir des milliers d'entités (toutes les portes d'une ville, par exemple). Pour qu'elles ne coûtent rien quand personne n'est là, les props et entités de la map figés ne marchent qu'à proximité des joueurs, quatre fois par seconde :

OùQuandEffet
ServeurAucun joueur vivant à moins de 60 m (réveil quand un joueur revient à moins de 60 m, sommeil au-delà de 70 m)En sommeil : plus de calcul physique, plus de zone de contact, plus de ENT:Think. Une position ou un angle changés par Lua pendant ce temps sont quand même envoyés aux joueurs.
Client (chaque joueur)À plus de 150 m de la caméra, si l'objet mesure moins de 8 mCaché, plus suivi, plus de ENT:Think client. Réaffiché en dessous de 140 m.

Ne sont jamais concernés : les props et entités créés en jeu (par les joueurs ou par Lua avec ents.Create), les props non figés, attachés (SetParent) et les véhicules. Une animation lancée par Think (une porte qui s'ouvre) reprend où elle en est au réveil : basée sur CurTime(), elle se termine aussitôt. Une classe qui doit continuer à travailler sans personne autour (minuterie, générateur) déclare ENT.AlwaysActive = true.

ents.FindByClass utilise un index par classe (refait seulement quand une entité apparaît, disparaît ou change de classe) : l'appeler à chaque image pour quelques classes reste léger même avec des milliers d'entités. ents.GetAll() parcourt toujours tout.