Intégration au bac à sable

Ce qui fait qu'un addon se comporte comme un outil du jeu : ses créations s'annulent avec la touche Annuler, se nettoient depuis le menu de spawn, passent dans les dupes, apparaissent dans le menu de spawn et le menu contextuel, et peuvent changer le déplacement du joueur. Les noms sont ceux de Garry's Mod : undo, cleanup, duplicator, list, properties, spawnmenu, DForm, CMoveData et CUserCmd.

BibliothèqueCôtéRôle
undoserveurAjouter une entrée à la pile d'annulation du joueur
cleanuppartagé (Add et CC_* : serveur)Catégories de l'onglet Nettoyage du menu de spawn
duplicatorserveurEntités scriptées et données dans les dupes, copier / coller par code
list, table.CopypartagéListes nommées partagées entre addons ("SpawnableEntities"…)
propertiespartagéActions du menu contextuel sur un objet
spawnmenu, DFormclientOnglets du menu de spawn et panneaux d'options d'outils
CMoveData, CUserCmd, ply:SetMoveTypeclient (hooks) / serveur (SetMoveType)Modifier le déplacement du joueur : jetpack, double saut, vol…
driveserveur (vue : client)Piloter une entité avec ses touches

unpack existe aussi (le même que table.unpack), pour les scripts écrits pour Garry's Mod.

Annulation : undo

Côté serveur. Une entrée se construit entre undo.Create et undo.Finish ; elle va en haut de la pile d'annulation du joueur, avec les props et dupes qu'il fait apparaître lui-même. La touche Annuler (Z par défaut) enlève la dernière entrée : ses entités sont supprimées et ses fonctions appelées. Une entrée dont les entités ont déjà disparu et qui n'a pas de fonction est sautée (on passe à la précédente).

FonctionRôle
undo.Create(nom)Commence une entrée ("Undo" si pas de nom).
undo.AddEntity(ent)Entité supprimée par l'annulation (prop, entité scriptée, véhicule, PNJ).
undo.AddFunction(fonction, ...)Appelée à l'annulation avec fonction(entrée, ...) (les arguments donnés ici).
undo.SetPlayer(ply)Le joueur dont la pile reçoit l'entrée. Obligatoire : sans joueur valide, Finish ne fait rien.
undo.SetCustomUndoText(texte)Message affiché à l'annulation : « Annulé : texte » (sinon « Annulé : nom »).
undo.Finish(nom)Termine l'entrée et la pousse sur la pile (nom facultatif remplace celui de Create).
undo.GetTable(ply) → tableEntrées Lua encore annulables (de ce joueur, ou de tous sans argument), la plus ancienne en premier. Chaque entrée : Name, Entities, Functions, Owner, CustomUndoText, Id.

Le hook serveur CanUndo(ply, entrée) est appelé avant chaque annulation, y compris pour les entrées du jeu (un prop, une dupe, un PNJ, une contrainte : Name vaut alors "Prop", "Dupe", "NPC" ou "Constraint", et Entities donne les entités). Renvoyer false garde l'entrée : le joueur lit « Impossible d'annuler : nom. »

-- lua/autorun/caisses.lua (partagé) : /caisse fait apparaître une caisse annulable et nettoyable.
cleanup.Register("caisses")  -- des deux côtés : le client en a besoin pour l'onglet Nettoyage

if SERVER then
	local function poser_caisse(ply)
		local tr = ply:GetEyeTrace()
		if not tr.Hit or tr.HitPos:Distance(ply:GetPos()) > 20 then return end
		local caisse = ents.SpawnProp("builtin:crate", tr.HitPos + Vector(0, 0.5, 0))
		if not caisse then return end

		undo.Create("Caisse")
		undo.AddEntity(caisse)
		undo.AddFunction(function(entree, joueur)
			if IsValid(joueur) then joueur:ChatPrint("Ta caisse a été retirée.") end
		end, ply)
		undo.SetPlayer(ply)
		undo.Finish()

		cleanup.Add(ply, "caisses", caisse)
	end

	concommand.Add("caisse", poser_caisse)
	-- Le bouton de l'onglet « Caisses » (voir Menu de spawn) envoie ce message.
	net.Receive("caisses_poser", function(taille, ply) poser_caisse(ply) end)

	-- Pas d'annulation pendant une manche.
	hook.Add("CanUndo", "caisses_manche", function(ply, entree)
		if GetGlobalBool("manche_en_cours") then return false end
	end)
end
Props créés par LuaUn prop de ents.SpawnProp ou ents.Create appartient au monde : il ne compte pas dans la limite du joueur. undo et cleanup.Add le rattachent quand même au joueur pour l'annulation et le nettoyage.

Nettoyage : cleanup

L'onglet Nettoyage du menu de spawn a un bouton par catégorie (« Tout » en premier), et une deuxième rangée « Tous les joueurs (admin) » pour les admins. Les catégories intégrées :

CatégorieBoutonCe qui est supprimé (ce que le joueur possède)
""ToutTous ses props, entités, véhicules, PNJ et contraintes, et tout ce qui est rangé avec cleanup.Add
"props"PropsLes props ordinaires (pas les entités scriptées ni les véhicules)
"vehicles"VéhiculesLes véhicules
"npcs"PNJLes PNJ
"sents"EntitésLes entités scriptées (hors véhicules)
"constraints"ContraintesSes contraintes (soudures, cordes…)

Chaque catégorie supprime aussi les entités rangées sous ce nom avec cleanup.Add. Le joueur lit « Nettoyé (Props) : 3 éléments. » Le hook serveur CanCleanup(ply, catégorie) peut refuser (false : « Nettoyage refusé par le mode de jeu. »). Nettoyer les objets de tous les joueurs demande la permission cleanup.

FonctionCôtéRôle
cleanup.Register(catégorie)partagéAjoute une catégorie (un bouton portant son nom). À appeler des deux côtés (fichier partagé) : le bouton vient de la liste du client.
cleanup.GetList() → tablepartagéLes catégories ajoutées par Register (pas les intégrées).
cleanup.Add(ply, catégorie, ent)serveurRange une entité (prop, entité scriptée, PNJ) dans une catégorie du joueur.
cleanup.ReplaceEntity(ancienne, nouvelle)serveurUne entité en remplace une autre dans les catégories.
cleanup.CC_Cleanup(ply, catégorie)serveurNettoie comme le bouton (aussi la forme GMod CC_Cleanup(ply, commande, { "props" })). Sans catégorie : tout.
cleanup.CC_AdminCleanup(ply, catégorie)serveurLa même chose pour les objets de tous les joueurs ; ply doit avoir la permission cleanup.

Dupes : duplicator

Côté serveur. Les dupes gardent les props, leurs contraintes et leurs gadgets. Avec duplicator, elles gardent aussi les entités scriptées et des données posées par les addons.

Entités scriptées dans les dupes

Quand une dupe contient une entité scriptée, le serveur la recrée en Lua :

Les dupes du toolgun sont sauvegardées par le jeu du joueur, qui ne voit que les données envoyées par le réseau. Le serveur copie donc toutes les 0,5 s les champs nommés dans RegisterEntityClass (booléens, nombres, textes, vecteurs, couleurs) dans les données de l'entité ("dupe_fields") : une dupe faite par un joueur les garde.

Modificateurs d'entité

duplicator.StoreEntityModifier(ent, id, données) attache une table à n'importe quel prop (8 Ko au plus par entité, tous modificateurs compris) ; elle voyage avec la dupe (champ "lua_mods" des données du prop). Au collage, la fonction enregistrée avec duplicator.RegisterEntityModifier(id, fonction) est appelée avec fonction(ply, ent, données) : c'est elle qui réapplique l'effet (une donnée sans fonction enregistrée ne fait rien). duplicator.ClearEntityModifier(ent, id) l'enlève.

Hooks et fonctions de l'entité

AppelQuand
hook CanDuplicate(ply, liste)Avant un collage (toolgun, onglet Dupes, duplicator.Paste). liste : { { Class, Model }, … }. false refuse (« Dupe refusée par le mode de jeu. »).
hook PreDuplicate(ply, liste)Juste après, si le collage est accepté.
ENT:PostEntityPaste(ply, ent, créées)Après le collage, sur chaque entité scriptée collée ; créées : toutes les entités de la dupe.
hook PostDuplicate(ply, créées)Après le collage et les modificateurs.
ENT:PreEntityCopy()Avant que duplicator.Copy lise l'entité.

ply vaut nil pour un collage sans joueur (duplicator.Paste(nil, …)).

Copier et coller par code

FonctionRôle
duplicator.Copy(ent) → tableL'entité et tout ce qui lui est relié par des contraintes : { Entities = { [n] = { Class, Model, Pos, Angle, Frozen, Color, EntityMods, champs… } }, Constraints = { { Type, Ent1, Ent2, LPos1, LPos2, Axis, Params } }, Mins, Maxs }. Ent1 / Ent2 sont des clés de Entities (Ent2 = 0 : le monde). Positions dans le monde.
duplicator.Paste(ply, entités, contraintes) → créées, nombreRecrée ces tables (même forme). Renvoie les entités créées (mêmes clés) et le nombre de contraintes créées. Avec un joueur, elles lui appartiennent et forment une entrée d'annulation. 150 entités au plus, à moins de 150 m de leur centre (n'importe où sur la map).
duplicator.CreateEntityFromTable(ply, données) → EntityRecrée une seule entrée de Entities.
duplicator.FindEntityClass(classe) → table ou nil{ Func, Args } d'une classe enregistrée.
-- Serveur : /cloner recopie ce que le joueur vise (et ce qui y est soudé) 3 m plus haut.
concommand.Add("cloner", function(ply)
	local ent = ply:GetEyeTrace().Entity
	if not IsValid(ent) or ent:IsPlayer() then return end
	local copie = duplicator.Copy(ent)
	for _, entree in pairs(copie.Entities) do
		entree.Pos = entree.Pos + Vector(0, 3, 0)
	end
	local creees, contraintes = duplicator.Paste(ply, copie.Entities, copie.Constraints)
	ply:ChatPrint(table.Count(creees) .. " entités, " .. contraintes .. " contraintes.")
end)

Entités dans le menu de spawn

Une entité scriptée avec ENT.Spawnable = true (ou ajoutée à la liste "SpawnableEntities") apparaît dans l'onglet Entités du menu de spawn, rangée par ENT.Category (« Autres » par défaut) sous son ENT.PrintName. Avec ENT.AdminOnly = true, le bouton porte « (admin) » et ne marche que pour les admins.

Un clic la fait apparaître côté serveur là où le joueur vise (120 m au plus) : le hook PlayerSpawnSENT(ply, classe) peut refuser (false), puis ENT:SpawnFunction(ply, tr, classe) la crée si la classe en a une (elle reçoit la trace du regard et renvoie l'entité) ; sinon le jeu la pose sur la surface visée, tournée vers le joueur, et appelle Spawn et Activate. L'entité appartient au joueur, s'annule avec Annuler, puis PlayerSpawnedSENT(ply, ent) est appelé.

Exemple : un coffre duplicable

-- lua/entities/coffre.lua (partagé)
ENT.PrintName = "Coffre à pièces"
ENT.Category = "Mon addon"
ENT.Spawnable = true
ENT.Model = "builtin:crate"

if SERVER then
	function ENT:Initialize()
		self.Pieces = self.Pieces or 10
	end

	function ENT:Use(ply)
		if self.Pieces <= 0 then return end
		self.Pieces = self.Pieces - 1
		ply:ChatPrint("Une pièce ! Il en reste " .. self.Pieces .. ".")
	end

	-- Posé 0,5 m au-dessus de la surface visée.
	function ENT:SpawnFunction(ply, tr, classe)
		local ent = ents.Create(classe)
		ent:SetPos(tr.HitPos + tr.HitNormal * 0.5)
		ent:Spawn()
		return ent
	end

	-- Le nombre de pièces passe dans les dupes (copié dans les données de l'entité toutes les 0,5 s).
	duplicator.RegisterEntityClass("coffre", function(ply, Pos, Ang, Pieces)
		local ent = ents.Create("coffre")
		ent:SetPos(Pos)
		ent:SetAngles(Ang)
		ent.Pieces = Pieces
		ent:Spawn()
		return ent
	end, "Pos", "Ang", "Pieces")

	function ENT:PostEntityPaste(ply, ent, creees)
		if IsValid(ply) then ply:ChatPrint("Coffre collé avec " .. tostring(self.Pieces) .. " pièces.") end
	end

	-- Une étiquette sur n'importe quel prop, gardée par les dupes.
	duplicator.RegisterEntityModifier("monaddon_etiquette", function(ply, ent, donnees)
		ent:SetNWString("etiquette", tostring(donnees.texte or ""))
	end)

	concommand.Add("etiquette", function(ply, args, texte)
		local ent = ply:GetEyeTrace().Entity
		if not IsValid(ent) or ent:IsPlayer() then return end
		ent:SetNWString("etiquette", texte)
		duplicator.StoreEntityModifier(ent, "monaddon_etiquette", { texte = texte })
	end)
end

Listes : list

Des tables nommées que les addons remplissent et lisent, des deux côtés (chaque côté a les siennes : remplis-les dans un fichier partagé).

FonctionRôle
list.Set(nom, clé, valeur)Range une valeur sous une clé.
list.Add(nom, valeur) → indexAjoute à la suite.
list.Get(nom) → tableUne copie de la liste (la modifier ne change pas la liste).
list.GetForEdit(nom) → tableLa liste elle-même (créée vide au besoin).
list.HasEntry(nom, clé), list.Contains(nom, valeur) → booléenLa clé existe ; la valeur est dans la liste.
list.GetTable() → tableLes noms des listes existantes.
table.Copy(t) → tableCopie profonde d'une table (16 niveaux au plus).

Le jeu lit la liste "SpawnableEntities" : list.Set("SpawnableEntities", classe, { PrintName = "…", ClassName = classe, Category = "…", AdminOnly = false }) ajoute une entité scriptée existante à l'onglet Entités sans toucher à son fichier.

Le joueur maintient la touche Menu contextuel (N par défaut, réglable dans Paramètres → Touches ; le C de Garry's Mod) : la souris est libre, l'objet sous le curseur s'éclaire, et un clic sur lui ouvre la liste de ses actions. Relâcher la touche (ou Échap) ferme le menu. Le gamemode Sandbox fournit "freeze" (Figer / libérer) et "remove" (Supprimer) ; les addons en ajoutent avec properties.Add.

properties.Add(nom, table) se fait dans un fichier partagé : le client construit le menu, le serveur reçoit l'action.

ChampRôle
MenuLabelTexte dans le menu (sinon le nom).
OrderRang dans le menu, du plus petit au plus grand (0 par défaut ; à égalité, par nom).
PrependSpacertrue : un séparateur avant cette action.
MenuIconAccepté pour la compatibilité (pas encore affiché).
Filter(self, ent, ply) → booléenL'action est-elle proposée pour cet objet ? Par défaut properties.CanBeTargeted(ent, ply).
Action(self, ent, tr)Client : l'action est choisie. tr : la trace sous le curseur (HitPos, HitNormal, Entity…).
Receive(self, taille, ply)Serveur : reçoit le message envoyé par self:MsgStart() … self:MsgEnd() dans Action (entre les deux, les net.Write… habituels).

properties.CanBeTargeted(ent, ply) est vrai pour une entité valide qui n'est pas un joueur et que ply peut toucher au toolgun selon la protection des props. properties.List contient les propriétés par nom. Côté client, properties.GetHovered() donne l'entité sous la souris (ou sous le viseur quand la souris tient la caméra). Le hook CanProperty(ply, nom, ent) est demandé par les propriétés de Sandbox (des deux côtés) : false les cache et les refuse. Les hooks client ContextMenuOpen (false empêche l'ouverture), OnContextMenuOpen et OnContextMenuClose suivent le menu.

-- lua/autorun/peinture.lua (partagé)
properties.Add("peindre_rouge", {
	MenuLabel = "Peindre en rouge",
	Order = 500,
	Filter = function(self, ent, ply)
		return properties.CanBeTargeted(ent, ply) and hook.Run("CanProperty", ply, self.InternalName, ent) ~= false
	end,
	Action = function(self, ent, tr)
		self:MsgStart()
		net.WriteEntity(ent)
		self:MsgEnd()
	end,
	Receive = function(self, taille, ply)
		local ent = net.ReadEntity()
		-- Toujours revérifier côté serveur : le message vient d'un joueur.
		if self:Filter(ent, ply) then ent:SetColor(Color(255, 0, 0)) end
	end,
})

Côté client. Le menu de spawn montre, dans l'ordre : les onglets du jeu et des addons de props, Entités (s'il y a des entités Spawnable), les onglets créés en Lua, puis Nettoyage, Dupes et Protection.

Onglets de création

spawnmenu.AddCreationTab(nom, fonction, icône, ordre, infobulle) ajoute un onglet titré nom. fonction() doit renvoyer un panneau vgui : il est créé une seule fois, à la première ouverture du menu, puis gardé (avec son état) tant que les scripts tournent. Les onglets sont triés par ordre (1000 par défaut) puis par nom ; infobulle s'affiche au survol de l'onglet ; icône est gardée pour la compatibilité. spawnmenu.GetCreationTabs() donne les onglets ajoutés.

-- lua/autorun/client/onglet_caisses.lua : un onglet avec un bouton (reçu par le serveur dans l'exemple undo).
spawnmenu.AddCreationTab("Caisses", function()
	local page = vgui.Create("DPanel")
	page:DockPadding(10, 10, 10, 10)
	local titre = vgui.Create("DLabel", page)
	titre:SetText("Caisses rapides")
	titre:Dock(TOP)
	local bouton = vgui.Create("DButton", page)
	bouton:SetText("Poser une caisse")
	bouton:SetTall(36)
	bouton:Dock(TOP)
	bouton.DoClick = function()
		net.Start("caisses_poser")
		net.SendToServer()
	end
	return page
end, nil, 50, "Des caisses annulables")

Options d'outils

Les onglets, catégories et options d'outils créés en Lua s'affichent sous les outils du jeu, dans la liste des outils du menu de spawn : le nom de l'onglet en titre, puis ses catégories et leurs options. Choisir une option affiche son panneau à côté : CPanelFunction(panneau) reçoit un DForm neuf à chaque fois, puis la commande de l'option est lancée.

FonctionRôle
spawnmenu.AddToolTab(nom, titre, icône) → ongletCrée (ou retrouve) un onglet d'outils.
spawnmenu.AddToolCategory(onglet, id, titre)Ajoute une catégorie dans un onglet (créé au besoin).
spawnmenu.AddToolMenuOption(onglet, catégorie, id, titre, commande, config, fonction, options)Ajoute une option. fonction(panneau) remplit son panneau. commande : "gmod_tool <outil>" prend le toolgun sur cet outil ; "nom valeur" change la ConVar nom si elle existe (sinon rien) ; "" : rien.
spawnmenu.GetToolMenu(onglet), spawnmenu.GetTools()Les catégories d'un onglet ; tous les onglets.

Les hooks client AddToolMenuTabs, AddToolMenuCategories et PopulateToolMenu sont appelés, dans cet ordre, une fois par lancement des scripts, quand le menu en a besoin pour la première fois : ajoute-les au chargement du fichier.

-- lua/autorun/client/jetpack_menu.lua : réglages du jetpack (exemple suivant) dans la liste des outils.
hook.Add("PopulateToolMenu", "jetpack_menu", function()
	spawnmenu.AddToolMenuOption("Options", "Jetpack", "jetpack_reglages", "Réglages du jetpack", "", "", function(panneau)
		panneau:Help("Maintiens Courir et Saut pour t'envoler.")
		panneau:CheckBox("Activer le jetpack", "jetpack_actif")
		panneau:NumSlider("Poussée (m/s²)", "jetpack_poussee", 5, 40, 0)
	end)
end)

Déplacement du joueur

Chaque joueur calcule son propre déplacement (le serveur le vérifie). Les hooks de déplacement tournent donc côté client, pour le joueur local, à chaque image physique (60 fois par seconde), dans cet ordre :

  1. StartCommand(ply, cmd) puis CreateMove(cmd) : les commandes du tick (CUserCmd).
  2. SetupMove(ply, mv, cmd) puis Move(ply, mv), avant que le jeu déplace le joueur (CMoveData). Si Move renvoie true, le jeu ne déplace pas le joueur : la vitesse et la position de mv deviennent son état (sans collisions : c'est au hook de les gérer).
  3. Le jeu déplace le joueur, puis FinishMove(ply, mv) avec sa nouvelle vitesse et position (modifiables).

Rien n'est appelé (aucun coût) tant qu'aucun hook ni méthode du gamemode (GM:SetupMove…) n'utilise ces événements. Ils ne sont pas appelés quand le joueur est mort, bloqué (ply:Freeze) ou en MOVETYPE_NONE.

Unités du jeu : mètres, m/s, degrés. Les boutons sont les bits IN_*. Ce qui agit sur le jeu : le bit IN_JUMP (l'ajouter au sol fait sauter, l'enlever annule le saut), les vitesses avant / côté (bornées à la vitesse max), la vitesse, la position, la vitesse max (pour ce tick) et les angles de vue de cmd. Les autres bits, la vitesse verticale et les angles de mv se lisent mais ne changent rien. Une vitesse vers le haut décolle le joueur du sol.

Anti-tricheLe serveur vérifie la vitesse, le vol et les murs de chaque joueur. Quand le serveur a aussi un hook de déplacement (mets-le dans un fichier partagé, comme ci-dessous), il cesse de contrôler la vitesse et le vol (un jetpack serait pris pour de la triche), mais vérifie toujours les murs.
-- lua/autorun/jetpack.lua (partagé : le hook côté serveur coupe les contrôles de vitesse et de vol)
if CLIENT then
	CreateClientConVar("jetpack_actif", 1, true, false, "Jetpack (Courir + Saut)", 0, 1)
	CreateClientConVar("jetpack_poussee", 20, true, false, "Poussée du jetpack", 5, 40)
end

-- Appelé seulement chez le client, pour son propre joueur.
hook.Add("SetupMove", "jetpack", function(ply, mv, cmd)
	if not GetConVar("jetpack_actif"):GetBool() then return end
	if not (mv:KeyDown(IN_SPEED) and mv:KeyDown(IN_JUMP)) then return end
	local v = mv:GetVelocity()
	local poussee = GetConVar("jetpack_poussee"):GetFloat()
	mv:SetVelocity(Vector(v.x, math.min(v.y + poussee / 60, 8), v.z))
end)

Piloter une entité : drive

Côté serveur, drive.PlayerStartDriving(ply, ent, mode) fait piloter une entité au joueur : il reste figé sur place, ses touches et sa vue déplacent l'entité (figée elle aussi, donc sans physique), et sa caméra passe derrière elle en 3e personne. La touche Utiliser (E) ou drive.PlayerStopDriving(ply) arrête. Des deux côtés, ply:IsDrivingEntity(), ply:GetDrivingEntity(), ply:GetDrivingMode() et drive.GetMethod(ply) (l'objet du mode en cours : self.Player, self.Entity).

Modes fournis : "drive_noclip" (l'entité vole où le joueur regarde, 6 m/s, 20 m/s avec Courir ; Saut monte, Accroupi descend) et "drive_base" (ne bouge pas tout seul). drive.Register(nom, table, base) en ajoute un ; ses fonctions sont appelées à chaque image physique côté serveur : Init(), StartMove(mv, cmd), Move(mv), FinishMove(mv) (celle de drive_base place l'entité à mv:GetOrigin()), Stop(), et côté client CalcView(view) (modifie view.origin, view.angles). mv et cmd sont les mêmes objets que pour le déplacement du joueur, avec la position et la vitesse de l'entité.

-- Partagé : une action « Piloter » dans le menu contextuel.
properties.Add("piloter", {
	MenuLabel = "Piloter",
	Order = 600,
	Action = function(self, ent)
		self:MsgStart()
		net.WriteEntity(ent)
		self:MsgEnd()
	end,
	Receive = function(self, taille, ply)
		local ent = net.ReadEntity()
		if self:Filter(ent, ply) then drive.PlayerStartDriving(ply, ent, "drive_noclip") end
	end,
})

Type de déplacement

Côté serveur, ply:SetMoveType(MOVETYPE_…) choisit comment un joueur bouge ; ply:GetMoveType() le lit (des deux côtés). Tant qu'un type est imposé, la touche noclip est ignorée.

ConstanteEffet
MOVETYPE_WALKNormal (par défaut) : rend la main au jeu et à la touche noclip.
MOVETYPE_NONENe bouge plus (il peut regarder autour).
MOVETYPE_FLYVole vers où il regarde (Saut monte, Accroupi descend) en butant sur les murs.
MOVETYPE_NOCLIP, MOVETYPE_OBSERVERNoclip : vole à travers les murs.

Les autres constantes de Garry's Mod (MOVETYPE_ISOMETRIC, STEP, FLYGRAVITY, VPHYSICS, PUSH, LADDER, CUSTOM) existent et se lisent avec GetMoveType, mais bougent comme WALK. Sans type imposé, GetMoveType renvoie MOVETYPE_NOCLIP en noclip, sinon MOVETYPE_WALK.

-- Serveur : /voler fait voler un admin (contre les murs), /voler encore le fait marcher.
concommand.Add("voler", function(ply)
	if not ply:IsAdmin() then return end
	if ply:GetMoveType() == MOVETYPE_FLY then
		ply:SetMoveType(MOVETYPE_WALK)
	else
		ply:SetMoveType(MOVETYPE_FLY)
	end
end)