PNJ

Des personnages non joueurs qui se déplacent seuls sur la carte, voient et entendent, se battent, fuient ou se mettent à couvert. Ils ont le même mannequin que les joueurs. Le serveur calcule leur intelligence et leurs déplacements, les joueurs reçoivent leur position et leurs animations. Pour écrire toi-même leur intelligence en Lua, voir NextBot.

Dans le jeu

Les PNJ sont dans l'onglet PNJ du menu de spawn : un clic en fait apparaître un là où tu vises, tourné vers toi. Z (annuler) le retire, comme un prop, et l'outil Suppression du toolgun aussi. Le gamemode décide qui peut en faire apparaître (PlayerSpawnNPC : Sandbox autorise, Base refuse) et le serveur limite leur nombre (npc_limit, 50 par défaut, voir Serveur).

ClassePNJFactionComportement
citizenCitoyencitizenSe promène ; fuit les ennemis, les tirs proches et ceux qui le blessent.
hostile_crowbarEnnemi (pied-de-biche)hostileRepère les joueurs, les gardes et les citoyens, court vers eux et frappe.
hostile_pistolEnnemi (pistolet)hostileTire à distance, se met à couvert quand il a peu de vie.
hostile_rifleEnnemi (fusil d'assaut)hostileTire par rafales, de plus loin.
ally_pistolGarde (pistolet)allySuit le joueur qui l'a fait apparaître et attaque les ennemis.
ally_shotgunGarde (fusil à pompe)allyPareil, au fusil à pompe, de près.

Avec Sandbox, le physgun attrape les PNJ comme les props : le PNJ reste debout, suit le faisceau, tourne avec toi et retombe quand tu le lâches (clic gauche relâché ou clic droit ; il ne se fige pas). En multijoueur, la protection des props décide qui peut attraper, toolgun (remover) et blesser les PNJ d'un autre joueur : par défaut son propriétaire et ses amis (les PNJ créés par les scripts suivent la règle « sans propriétaire »). Un gamemode peut aussi refuser avec le hook PhysgunPickup.

Toutes les armes du jeu, les armes personnalisées, les armes Lua et les explosions blessent les PNJ, avec la tête ×2 (sauf ceux d'un autre joueur que la protection des props refuse). Le réglage pvp ne les concerne pas. Tuer un PNJ donne un point (Frags) au joueur. Un PNJ mort tombe en ragdoll puis disparaît après 20 secondes.

Ce qu'ils savent faire

Le navmesh

Le navmesh est la carte des endroits où un PNJ peut marcher. Une map .pcmap peut l'avoir calculé dans l'éditeur (onglet Monde → Calculer le navmesh) : il est alors chargé instantanément. Sinon, il se calcule tout seul au chargement de la carte (intégrée ou .pcmap), en arrière-plan, par tuiles de 64 m, en commençant par celles qui entourent les joueurs et les points d'apparition (les PNJ proches sont prêts plus vite) : les PNJ marchent tout droit en attendant. En Lua, navmesh.IsLoaded() dit quand toute la carte est calculée. Le terrain des maps compte comme sol. Il tient compte du décor fixe, des props figés (recalculé autour d'eux deux secondes après qu'ils apparaissent, bougent ou disparaissent) et des zones interdites aux PNJ.

Dans l'éditeur de map : onglet Monde → Voir le navmesh affiche en vert le navmesh calculé, et l'entité Zone interdite aux PNJ (boîte invisible en jeu) retire une zone du navmesh. Un PNJ marche sur des pentes jusqu'à 46°, monte des marches de 45 cm et passe sous 1,8 m.

Une map .pcmap peut désactiver son navmesh (onglet Monde → Navmesh des PNJ) : rien n'est calculé et les PNJ restent sur place ; ils tournent, visent, tirent et frappent ce qui passe à portée.

Lua

La bibliothèque npc existe dans les deux réalités. Créer et modifier un PNJ, c'est sur le serveur ; les lire marche partout.

FonctionRéalitéRôle
npc.Register(classe, réglages)partagéAjoute une classe de PNJ (aussi dans l'onglet PNJ). À appeler dans un fichier partagé de l'addon.
npc.GetList()partagéLes classes : {Class, Name, Category, Faction, Weapon}.
npc.Create(classe, pos, yaw)serveurFait apparaître un PNJ (sans propriétaire ni hook PlayerSpawnNPC). Renvoie le PNJ, ou nil.
npc.GetAll()partagéTous les PNJ.
npc.FindInSphere(pos, rayon)partagéLes PNJ à moins de rayon mètres.

Réglages d'une classe

Tous facultatifs. Les armes sont celles du pack : "crowbar", "pistol", "shotgun", "rifle" ou "" (aucune).

CléPar défautRôle
name, category"PNJ", "PNJ"Nom dans le menu et dans les messages de mort (« abattu par … »), rubrique de l'onglet PNJ. Rien n'est affiché au-dessus de la tête d'un PNJ.
faction"citizen""citizen", "hostile", "ally" ou une faction à toi (neutre envers tout le monde, à régler avec AddRelationship).
health, weapon, color, scale100, "", gris, 1Vie, arme, couleur du t-shirt, taille (0,3 à 3).
walk_speed, run_speed1,6, 4,6Vitesses en m/s.
accuracy, damage, reaction0,6, 0,5, 0,45Précision (0 à 1), multiplicateur des dégâts de l'arme, délai de réaction (s).
sight, fov, hearing40, 120, 1Distance de vue (m), champ de vision (°), multiplicateur de l'ouïe.
cover_health0,35Part de vie sous laquelle il se met à couvert (0 = jamais).
model""Playermodel porté à la place du mannequin : "addon:<clé>/playermodels/<fichier>.json". Dans le menu de spawn, la liste Apparence de l'onglet PNJ le choisit pour chaque PNJ qu'on fait apparaître.
skin, bodygroups0, {}Skin du playermodel (0 = d'origine) et sa tenue : l'option de chaque bodygroup, à partir de 0, par exemple {1, 0, 2}.

Apparence en multijoueur

Le serveur décide de l'apparence d'un PNJ et tous les joueurs voient la même : nom, couleur, taille, arme, playermodel, skin et tenue. Un joueur qui rejoint la partie après l'apparition du PNJ le voit tel qu'il est à ce moment-là, changements compris. Un joueur qui n'a pas le playermodel le télécharge depuis le Workshop s'il y est publié ; en attendant, il voit le mannequin. Une classe déclarée seulement côté serveur (npc.Register dans un fichier serveur) s'affiche aussi correctement chez les joueurs, mais n'apparaît pas dans leur onglet PNJ : déclare-la dans un fichier partagé.

Méthodes d'un PNJ

MéthodeRéalitéRôle
IsValid(), IsNPC(), IsPlayer()partagéToujours là ? Vrai / faux.
GetClass(), GetName(), EntIndex()partagéClasse, nom affiché, identifiant.
GetPos(), EyePos(), GetAngles(), GetForward(), GetVelocity()partagéPosition, yeux, orientation, vitesse.
Health(), GetMaxHealth(), Alive()partagéVie.
GetFaction(), GetWeapon(), GetColor(), GetOwner()partagéFaction, arme, couleur, joueur qui l'a fait apparaître (ou nil).
GetPlayerModel(), GetPlayerSkin(), GetBodygroup(groupe)partagéPlayermodel porté ("" = mannequin), son skin, l'option choisie d'un bodygroup (à partir de 0).
GetState()serveur"idle", "wander", "investigate", "combat", "cover", "flee", "follow", "move" ou "guard".
GetEnemy(), Disposition(ent), Visible(ent)serveurEnnemi actuel ; "hate", "fear", "like" ou "neutral" envers un joueur ou un PNJ ; le voit-il ?
SetPos(v), SetAngles(a), SetName(s), SetColor(c)serveurPlace, tourne, renomme, recolore.
SetPlayerModel(id, skin), SetBodygroup(groupe, option)serveurFait porter un playermodel ou un prop (nil ou "" : le mannequin) avec un skin, dans sa tenue par défaut ; change un bodygroup du playermodel porté. Tout le monde voit le changement.
SetHealth(n), SetMaxHealth(n), TakeDamage(n, attaquant), Kill(), Remove()serveurVie, dégâts, mort, suppression.
SetFaction(f), AddRelationship(cible, disposition)serveurChange de camp ; règle son attitude envers un joueur, un PNJ ou une faction ("player" pour tous les joueurs).
SetEnemy(ent)serveurL'attaque tout de suite (nil : oublie son ennemi).
MoveTo(pos), StopMoving()serveurVa à un point en courant (hook NPCReachedGoal à l'arrivée) ; annule.
Follow(ent), SetSchedule(nom)serveurSuit un joueur ou un PNJ (nil : arrête) ; ce qu'il fait au calme : "" (automatique), "idle", "wander", "guard" (reste sur place) ou "follow".
Give(arme), StripWeapon()serveurChange ou retire son arme.
SetAccuracy(n), SetDamageScale(n), SetSpeed(marche, course)serveurPrécision, dégâts, vitesses de ce PNJ.

Hooks

HookArgumentsRetour
PlayerSpawnNPCply, classetrue pour autoriser l'onglet PNJ
PlayerSpawnedNPCply, pnj—
NPCSpawnedpnj— (PNJ sans joueur : npc.Create)
NPCTakeDamagepnj, dégâts, attaquant, typefalse annule, un nombre remplace les dégâts
OnNPCKilledpnj, attaquant, cause—
NPCReachedGoalpnj, pos—
NPCRemovedpnj—

Quand un PNJ blesse un joueur, PlayerShouldTakeDamage et PlayerHurt sont appelés avec un attaquant nil ; la cause du message est « a été tué par nom du PNJ ».

-- lua/autorun/zombies.lua (partagé) : une classe de PNJ.
npc.Register("zombie", {
	name = "Zombie", category = "Monstres", faction = "hostile",
	health = 60, weapon = "crowbar", color = Color(90, 140, 70),
	walk_speed = 1.0, run_speed = 3.2, damage = 0.8, sight = 25, cover_health = 0,
})

if SERVER then
	-- Une vague de zombies toutes les 30 secondes autour du point "spawn_zombies".
	timer.Create("vagues", 30, 0, function()
		local centre = map.GetPoint("spawn_zombies")
		if not centre then return end
		for i = 1, 4 do
			local z = npc.Create("zombie", centre + Vector(math.random(-6, 6), 0.2, math.random(-6, 6)))
			if IsValid(z) then z:SetSchedule("wander") end
		end
	end)

	-- Les gardes du menu de spawn en uniforme : un playermodel de l'addon, skin 1, deuxième option du premier bodygroup.
	hook.Add("PlayerSpawnedNPC", "uniforme_gardes", function(ply, pnj)
		if pnj:GetClass() == "ally_pistol" then
			pnj:SetPlayerModel("addon:mon_addon/playermodels/garde.json", 1)
			pnj:SetBodygroup(0, 1)
		end
	end)

	-- Points pour chaque zombie abattu.
	hook.Add("OnNPCKilled", "points_zombies", function(pnj, attaquant)
		if pnj:GetClass() == "zombie" and IsValid(attaquant) and attaquant:IsPlayer() then
			attaquant:ChatPrint("Zombie abattu !")
		end
	end)
end

NextBot : une IA en Lua

Un NextBot est un PNJ dont l'intelligence est le code de sa classe, comme les NextBots de Garry's Mod. Il a le corps des PNJ (mannequin ou playermodel, collisions, dégâts, ragdoll, réseau), mais le jeu ne lui donne aucun comportement : c'est ton code qui le fait marcher, viser, attendre…

Déclarer et créer

C'est une entité scriptée (entities/<classe>.lua d'un gamemode ou lua/entities/<classe>.lua d'un addon) avec ENT.Base = "base_nextbot" (ou ENT.Type = "nextbot"). ENT.Model peut être un playermodel ("addon:<clé>/playermodels/<fichier>.json") ; sinon c'est le mannequin.

Côté serveur, ents.Create(classe) crée le corps (immobile, en attente) ; règle SetPos, SetAngles et éventuellement SetModel(playermodel), puis Spawn() appelle ENT:Initialize() et démarre ENT:RunBehaviour(). Il a toutes les méthodes d'un PNJ (GetPos, SetHealth, TakeDamage, Remove…), plus :

RunBehaviour, une coroutine

ENT:RunBehaviour() tourne dans une coroutine : écris-la comme une boucle sans fin. coroutine.yield() rend la main au jeu jusqu'à l'image physique suivante ; coroutine.wait(s) attend s secondes. Une erreur dans la coroutine est affichée avec sa pile d'appels et arrête le comportement. À chaque image physique du serveur, le jeu appelle aussi ENT:BehaveUpdate(intervalle) (qui fait avancer la coroutine : ne la remplace que si tu sais ce que tu fais), ENT:BodyUpdate() et ENT:Think() (pas de NextThink pour un NextBot : elle est appelée à chaque image).

MéthodeRôle
self:MoveToPos(pos, options) → texteCalcule un chemin sur le navmesh et le suit jusqu'au bout, en rendant la main à chaque image (à appeler dans RunBehaviour). Renvoie "ok" (arrivé), "failed" (pas de chemin : point hors du navmesh), "stuck" (bloqué : self:HandleStuck() est appelée, par défaut loco:ClearStuck()) ou "timeout". Options (facultatives) : tolerance (distance d'arrivée, 1 m par défaut), maxage (abandon après tant de secondes), repath (recalcule le chemin toutes les tant de secondes), draw (true : dessine le chemin), lookahead (gardé pour les scripts).
self:GetRangeTo(pos ou ent), self:GetRangeSquaredTo(…)Distance (m) jusqu'à un point, un prop ou un PNJ, et son carré. Pour un joueur, passe sa position : self:GetRangeTo(ply:GetPos()).
self:PlaySequenceAndWait(clip, vitesse)Joue un clip d'animation du modèle (mannequin ou playermodel) vu par tous, attend sa durée (divisée par vitesse, 1 par défaut), puis rend les animations de marche. Un clip inconnu ne joue rien et n'attend pas. Dans RunBehaviour seulement.
self:SetSequence(clip) / self:ResetSequence(clip)Joue un clip sans attendre, jusqu'au prochain changement ; "" rend les animations de marche.
self:StartActivity(act) / self:GetActivity()Gardent une valeur pour les scripts (pas d'animation associée).
self:BecomeRagdoll(dmg)Le corps tombe en ragdoll ; il disparaît après 20 secondes et n'est plus listé par npc.GetAll().

Évènements

Fonction de la classe (serveur)Quand
ENT:OnInjured(dmg)Il perd de la vie (après le hook NPCTakeDamage). dmg : GetDamage(), GetAttacker(), GetDamageType() (DMG_BULLET, DMG_CLUB pour le corps à corps, DMG_BLAST, DMG_FALL)…
ENT:OnKilled(dmg)Sa vie tombe à 0. Par défaut : hook OnNPCKilled(bot, attaquant, "") puis self:BecomeRagdoll(dmg). Si tu la remplaces, appelle toi-même BecomeRagdoll ou Remove, sinon il reste debout à 0.
ENT:OnStuck() / ENT:OnUnStuck()Il essaie d'avancer sans y arriver depuis 1 seconde / il bouge de nouveau.
ENT:OnLandOnGround() / ENT:OnLeaveGround()Il touche le sol / le quitte (saut, chute).
ENT:OnContact(ent)Il heurte un prop, un joueur ou un PNJ (au plus toutes les 0,2 s pour chacun).

Se déplacer : self.loco

self.loco (serveur) commande le corps. Unités : mètres, m/s, degrés.

MéthodeRôle
loco:Approach(pos, poids)Avance vers pos. L'ordre dure 0,15 s : appelle-la à chaque image (poids est ignoré).
loco:FaceTowards(pos)Se tourne vers pos (à chaque image aussi). Sans cela, il regarde où il va.
loco:SetDesiredSpeed(v) / GetDesiredSpeed()Vitesse de marche, 0 à 60 m/s (3 par défaut).
loco:SetAcceleration(a) / GetAcceleration()Accélération au sol (0,1 à 500 m/s², 18 par défaut). Elle sert aussi à freiner.
loco:SetDeceleration(a) / GetDeceleration()Gardée pour les scripts (le freinage suit SetAcceleration).
loco:Jump()Saute, s'il est au sol et en train d'avancer (pendant un Approach).
loco:JumpAcrossGap(pos)Bondit vers pos (au sol ; 8 m/s à l'horizontale au plus).
loco:SetJumpHeight(m) / GetJumpHeight()Hauteur du saut (0 à 20 m ; environ 1,2 m sans réglage).
loco:SetStepHeight(m) / GetStepHeight()Hauteur de marche franchie sans sauter (0 à 2 m, 0,5 par défaut ; multipliée par la taille du PNJ).
loco:SetMaxYawRate(°/s) / GetMaxYawRate()Vitesse de rotation maximale (1 à 3600). Sans réglage, il tourne en douceur, sans limite fixe (GetMaxYawRate renvoie alors 250).
loco:SetDeathDropHeight(m) / GetDeathDropHeight()Gardée pour les scripts (2 par défaut) : le jeu ne s'en sert pas.
loco:IsStuck(), loco:ClearStuck()Bloqué ? Oublie qu'il l'est.
loco:IsOnGround(), loco:IsClimbingOrJumping(), loco:IsAttemptingToMove()Au sol, en l'air, un Approach est en cours.
loco:GetVelocity() / SetVelocity(v)Vitesse (m/s, 100 au plus).
loco:GetGroundMotionVector()Direction de son déplacement au sol (vecteur unitaire).
loco:IsAreaTraversable(aire)Toujours true.

Chemins : Path

Path("Follow") crée un chemin à suivre soi-même (c'est ce que fait MoveToPos). Côté serveur.

MéthodeRôle
path:Compute(bot, pos) → booléenCalcule le chemin sur le navmesh (512 points au plus). false si le départ ou l'arrivée est hors du navmesh.
path:Update(bot)À chaque image : passe au point suivant quand le bot l'a atteint et le fait avancer vers lui (FaceTowards + Approach). Le chemin devient invalide à l'arrivée.
path:Chase(bot, ent)Comme Update, en recalculant le chemin quand il a plus de 0,5 s ou que la cible a bougé de plus de 1 m.
path:IsValid(), path:Invalidate()Reste-t-il du chemin ? L'abandonne.
path:GetAge(), path:ResetAge()Secondes depuis le dernier calcul ; remet à 0.
path:GetStart(), path:GetEnd(), path:GetLength()Premier et dernier point (Vector), longueur (m).
path:GetCurrentGoal()Le point visé : {pos, type, distanceFromStart} (type et distanceFromStart valent 0), ou nil.
path:GetAllSegments(), path:FirstSegment(), path:LastSegment(), path:GetCursorPosition()Les points ({pos, type}), le premier, le dernier, le numéro du point visé.
path:SetGoalTolerance(m) / GetGoalTolerance()Distance à l'horizontale à laquelle un point compte comme atteint (0,4 m au moins).
path:SetMinLookAheadDistance(m) / GetMinLookAheadDistance()Gardée pour les scripts.
path:Draw()Dessine le chemin avec debugoverlay (0,1 s).

La bibliothèque navmesh lit le navmesh de la carte. Le navmesh de PhysiCore est fait de polygones, pas des carrés de Source : une « aire » est ici un point du navmesh, avec les méthodes de Garry's Mod qui ont un sens pour un point.

FonctionRenvoie
navmesh.IsLoaded()true quand le navmesh de toute la carte est prêt (il se calcule en arrière-plan après le chargement ; false si la map l'a désactivé).
navmesh.GetNearestNavArea(pos, _, distMax)L'aire au point du navmesh le plus proche (nil au-delà de distMax mètres, si donné).
navmesh.GetNavArea(pos, dessous)L'aire sous pos : le point le plus proche, s'il est entre 0,5 m au-dessus et dessous mètres (2 par défaut) en dessous ; sinon nil.
navmesh.Find(pos, rayon, descente, montée)Jusqu'à 16 aires autour de pos (rayon 200 m au plus), à moins de max(descente, montée) mètres de hauteur (2 par défaut).
navmesh.GetAllNavAreas(), navmesh.GetNavAreaCount()Les aires à moins de 50 m des joueurs (pas toute la carte), leur nombre.
Méthode d'une aireRenvoie
GetCenter(), GetZ(), GetID()Le point, sa hauteur (Y), un identifiant tiré de sa position.
GetRandomPoint()Un point du navmesh au hasard, à moins de 2 m.
ClosestPointOnArea(pos)Le point du navmesh le plus proche de pos.
Contains(pos)pos est à moins de 1,5 m à l'horizontale et 2 m en hauteur.
GetAdjacentAreas(), IsConnected(aire)Les aires à moins de 4 m ; l'autre aire est-elle à moins de 6 m ?
GetSizeX(), GetSizeY()Toujours 3.
IsValid(), IsUnderwater(), HasAttributes()true, false, false.
-- entities/garde_lua.lua : patrouille autour de son point d'apparition, poursuit les joueurs proches.
ENT.Base = "base_nextbot"
ENT.PrintName = "Garde (Lua)"

if SERVER then
	function ENT:Initialize()
		self:SetMaxHealth(80)
		self:SetHealth(80)
		self.origine = self:GetPos()
		self.loco:SetDesiredSpeed(2)
		self.loco:SetMaxYawRate(180)
	end

	function ENT:CibleProche()
		for _, ply in ipairs(player.GetAll()) do
			if ply:Alive() and self:GetRangeTo(ply:GetPos()) < 15 then return ply end
		end
	end

	function ENT:RunBehaviour()
		while true do
			local cible = self:CibleProche()
			if cible then
				-- Poursuite : 5 m/s, chemin recalculé quand la cible bouge.
				self.loco:SetDesiredSpeed(5)
				local chemin = Path("Follow")
				chemin:SetGoalTolerance(1.5)
				while IsValid(cible) and cible:Alive() and self:GetRangeTo(cible:GetPos()) < 20 do
					chemin:Chase(self, cible)
					coroutine.yield()
				end
				self.loco:SetDesiredSpeed(2)
			else
				-- Patrouille : un point au hasard à moins de 10 m de l'origine.
				local aire = navmesh.GetNearestNavArea(self.origine + Vector(math.random(-10, 10), 0, math.random(-10, 10)))
				if aire then self:MoveToPos(aire:GetCenter(), { tolerance = 1, maxage = 10 }) end
				coroutine.wait(2)
			end
		end
	end

	function ENT:OnInjured(dmg)
		local attaquant = dmg:GetAttacker()
		if IsValid(attaquant) and attaquant:IsPlayer() then attaquant:ChatPrint("Le garde t'a repéré !") end
	end
end

-- Ailleurs, côté serveur, quand le navmesh est prêt :
-- if navmesh.IsLoaded() then
-- 	local bot = ents.Create("garde_lua")
-- 	if IsValid(bot) then bot:SetPos(Vector(0, 0.2, 5)) bot:Spawn() end
-- end

Limites